Merge remote-tracking branch 'origin/main' into workspace-runtime-poc

# Conflicts:
#	nix/npm-deps.hash
#	package.json
#	packages/client/src/daemon-client.ts
#	packages/server/src/server/bootstrap.ts
#	packages/server/src/server/websocket-server.ts
This commit is contained in:
Mohamed Boudra
2026-08-14 18:43:58 +02:00
150 changed files with 9206 additions and 134 deletions
+1
View File
@@ -21,6 +21,7 @@ on:
- "packages/workspace-runtime-contract/**"
- "packages/expo-two-way-audio/**"
- "packages/highlight/**"
- "packages/plugin/**"
- "packages/protocol/**"
- "packages/relay/**"
- "packages/server/**"
+1
View File
@@ -10,6 +10,7 @@ on:
- "package.json"
- "package-lock.json"
- "packages/highlight/**"
- "packages/plugin/**"
- "packages/server/**"
- "packages/relay/**"
- "packages/cli/**"
+1
View File
@@ -12,6 +12,7 @@ on:
- "packages/app/**"
- "packages/expo-two-way-audio/**"
- "packages/highlight/**"
- "packages/plugin/**"
- "packages/protocol/**"
- "packages/client/**"
- "packages/server/**"
+1
View File
@@ -40,6 +40,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
| [docs/forge-providers.md](docs/forge-providers.md) | Adding a git forge: registry/manifest, drop-in checklist, self-host/GHES, the two facts tiers |
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
| [docs/plugins.md](docs/plugins.md) | Local plugin manifest, directory source config, RPCs, native surfaces, and attachment sources |
| [docs/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 |
+1
View File
@@ -25,6 +25,7 @@ RUN set -eux; \
npm pack --workspace=@getpaseo/workspace-runtime-contract --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/workspace-helper --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/client --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@paseo/plugin --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/server --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/cli --pack-destination /tmp/paseo-packs
+2 -2
View File
@@ -165,7 +165,7 @@ Claude Code announces subagent lifecycle on the SDK stream (`task_started` / `ta
Archived Paseo subagents disappear from the track, by design. To remove one from the track without closing its tab, use the **archive button** on the row — it opens a confirm dialog and archives the subagent on confirm. Provider-owned rows have no individual Paseo lifecycle controls.
The track header's **Archive finished** action hides finished provider-owned rows in the current app session. Their native sessions and timelines are untouched, and managed Paseo subagents are not archived by this bulk action. If a hidden provider child starts running again, the app brings it back to the track.
The track header's **Archive finished** action covers every finished row. It archives idle or errored managed Paseo subagents one at a time, and hides completed, failed, or canceled provider-owned rows in the current app session. Native sessions and timelines are untouched. Running and initializing children remain in the track. If a hidden provider child starts running again, the app brings it back to the track.
To keep the agent alive but remove it from the parent's track, use **detach**. The daemon clears the relationship lifecycle labels, emits the normal agent update, and every client reclassifies the agent from subagent to root/sibling from that updated snapshot.
@@ -185,7 +185,7 @@ We considered universal decoupling (no tab close ever archives, archive is alway
### Subagent accumulation under long-lived parents
A parent that spawns many subagents will see the track grow. Managed Paseo subagents can be archived individually. Finished provider-owned rows can be hidden together with **Archive finished**; this is app-local presentation state and resets when the app restarts.
A parent that spawns many subagents will see the track grow. Managed Paseo subagents can be archived individually or with **Archive finished**. That action hides finished provider-owned rows locally; this presentation state resets when the app restarts.
### Cross-client tab dismissal
+173
View File
@@ -0,0 +1,173 @@
# Local plugins
Local plugins contribute daemon RPCs, native app surfaces, and composer attachment sources from one
`index.tsx`. Paseo executes the server contribution in a subprocess and evaluates the client
contribution in the app runtime. Plugin code is trusted code; this first slice does not sandbox it.
## Install a directory source
Create a typecheckable plugin project, install its development dependencies, then install it into
the daemon. `init` only writes the project files; it does not run the package manager.
```bash
paseo plugin init /absolute/path/to/my-plugin
cd /absolute/path/to/my-plugin
npm install
npm run typecheck
paseo plugin install /absolute/path/to/my-plugin
paseo plugin install /absolute/path/to/my-plugin --id another-runtime-id
paseo plugin ls
```
The daemon stores directory sources under the root `plugins` object:
```json
{
"pluginsEnabled": true,
"plugins": {
"my-plugin": {
"source": "directory",
"path": "/absolute/path/to/my-plugin",
"enabled": true
}
}
}
```
The plugin system is disabled unless `pluginsEnabled` is `true`.
The directory contains an identity-only manifest, one entry point, and local typechecking support:
```text
my-plugin/
paseo-plugin.json
index.tsx
paseo-plugin.d.ts
package.json
tsconfig.json
```
Paseo compiles TSX when loading the plugin, so these packages are development dependencies only.
The generated declaration file supplies `@paseo/plugin` types until the SDK is distributed as a
public package. Regenerate new plugins with the matching Paseo CLI when the SDK contract changes.
```json
{
"id": "my-plugin"
}
```
The config key is the runtime plugin ID. The manifest ID is the default selected during install;
`--id` overrides it. Existing configuration is not renamed when the manifest changes, and the
runtime does not compare the two IDs. The same directory can be installed under several config
keys.
Source changes are explicit. Run `paseo plugin reload <id>` to stop and fully tear down the old
plugin before compiling and starting from disk. A failed reload stays failed; Paseo does not restore
the old code. Use `enable`, `disable`, and `remove` to manage one plugin. Remove deletes only its
configuration, never its source directory. The global `pluginsEnabled` switch remains available.
## Contribute behavior and UI
Default export one contribution function. Paseo calls it with a plugin-scoped context. The compiler
removes UI registrations from the server bundle and RPC registrations from the client bundle before
resolving dependencies.
```tsx
import { Text } from "react-native";
import { useQuery } from "@tanstack/react-query";
import { z } from "zod";
import { defineRpc, type PluginContext, useRpc } from "@paseo/plugin";
const greetRpc = defineRpc({
name: "greet",
input: z.object({ name: z.string() }),
output: z.object({ message: z.string() }),
});
function Greeting() {
const greet = useRpc(greetRpc);
const query = useQuery({
queryKey: ["greeting", "Paseo"],
queryFn: () => greet({ name: "Paseo" }),
});
return <Text>{query.data?.message}</Text>;
}
export default function contribute(plugin: PluginContext) {
plugin.handle(greetRpc, async ({ name }) => ({ message: `Hello, ${name}` }));
plugin.addSurface("main", Greeting);
plugin.addSidebarItem({
id: "main",
title: "Greeting",
icon: "MessageCircle",
surface: "main",
});
return () => undefined;
}
```
The contribution function must return cleanup. Paseo invokes it once when the plugin is reloaded,
disabled, removed, disconnected, or shut down. Cleanup is for resources created by plugin code.
Paseo removes registered contributions, unmounts surfaces, clears query state, rejects pending RPCs,
and stops the subprocess. Cleanup errors are logged and do not interrupt host teardown.
Paseo owns the route, screen header, Lucide icon validation, close action, theme DTO, layout facts,
and render error boundary. The contributed component owns the complete body below the header.
RPC contracts validate inputs and outputs in both the app and plugin subprocess. `useRpc` returns a
typed async function. Use the host-provided `@tanstack/react-query` for request state and caching;
Paseo gives each plugin installation its own query client.
When the same plugin contribution exists on multiple hosts, Paseo shows it once in the sidebar and
adds a host picker to the screen header. The selected host supplies the bundle, RPC transport, and
query cache. Plugin code cannot address another host.
## Contribute composer attachments
Register a declarative attachment source backed by a plugin RPC. Paseo owns the attachment menu,
search picker, drafts, selected pill, and submission. The plugin returns complete text snapshots;
credentials and vendor API calls stay in the daemon handler.
```tsx
const searchIssues = defineRpc({
name: "issues.search",
input: z.object({ query: z.string() }),
output: z.object({
items: z.array(
z.object({
id: z.string(),
identifier: z.string(),
title: z.string(),
subtitle: z.string().optional(),
url: z.string().url(),
text: z.string(),
resourceType: z.string(),
}),
),
}),
});
const issues = defineAttachmentSource({
id: "issues",
title: "Acme issue",
icon: "CircleDot",
pickerTitle: "Attach Acme issue",
searchPlaceholder: "Search by identifier or title",
search: searchIssues,
});
export default function contribute(plugin: PluginContext) {
plugin.handle(searchIssues, ({ query }) => searchAcmeIssues(query));
plugin.addAttachmentSource(issues);
return () => undefined;
}
```
Attachment sources stay scoped to the composer's host. Unlike sidebar contributions, equal sources
on several hosts are not coalesced. The selected snapshot submits as a text attachment with neutral
external-resource presentation, so it remains readable if the plugin is removed or an older peer
drops the optional presentation fields.
See `plugin-examples/local-plugin` for a native surface and `plugin-examples/linear` for a complete
attachment-source example.
+15 -9
View File
@@ -85,16 +85,22 @@ recomposition while the runtime still owns the same directory snapshot and timel
Removing the host from the registry is the destructive boundary: it stops the runtime and clears the
session and host-scoped setup state together.
The durable replica cache is a display cache, not a synchronization checkpoint. Its timeline record
contains only the focused `agentId` and a truncated item tail. It never persists a cursor, epoch,
older-history availability, authority status, or sync generation because those facts would describe
the complete source dataset rather than the truncated display dataset.
The durable replica cache persists synchronization authority only when it can store the complete
current canonical window losslessly. The stored range describes those exact items: `startSeq` drives
older pagination and `endSeq` drives forward catch-up. Restore paints the items immediately, requests
`after endSeq`, and requests `before startSeq` when the user loads older history.
Restoring that cache produces a painted timeline: the items may render immediately, but the first
daemon timeline request is still `tail`. A successful tail response atomically establishes canonical
items, range, and older-history availability. Live rows received between cache paint and that tail
response stay in the separate live head, do not advance a cursor or trigger gap recovery, and are
reconciled with the authoritative tail and subsequent catch-up.
The first resume request is bounded. If it reports more newer history, fetch one latest bounded tail
instead of replaying every missed page. Live gap recovery still pages forward until current.
If the canonical window exceeds the cache item limit, contains a discontiguous retained range, has a
live head, or includes presentation data the cache cannot encode losslessly, persistence drops the
range and keeps a display-only tail. Restore then uses the ordinary bounded `tail` bootstrap. Never
slice items while retaining the pre-slice range; that falsely certifies discarded source rows.
Live rows received between cache paint and catch-up stay in the separate live head and reconcile with
the authoritative range through the existing forward-page path. The cache does not persist sync
generation or unreconciled local submissions.
Every daemon-derived live item carries its timeline epoch and sequence position. Bootstrap
replacement keeps only positioned rows newer than the page it installs, while unresolved local
+1 -1
View File
@@ -1 +1 @@
sha256-QFP2lkZBO/MOqgSyO2NmWVHEn6jsiwo7VKX42iKzh70=
sha256-MgMvQC+EGJ8gGosqel8oC88pMzf2Mtetd2D8Yg5VFAM=
+528
View File
@@ -12,6 +12,7 @@
"workspaces": [
"packages/expo-two-way-audio",
"packages/highlight",
"packages/plugin",
"packages/protocol",
"packages/client",
"packages/server",
@@ -9583,6 +9584,10 @@
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@paseo/plugin": {
"resolved": "packages/plugin",
"link": true
},
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
@@ -36144,6 +36149,7 @@
"@gorhom/bottom-sheet": "^5.2.14",
"@gorhom/portal": "^1.0.14",
"@mattermost/react-native-paste-input": "2.0.1",
"@paseo/plugin": "*",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-masked-view/masked-view": "^0.3.2",
"@react-native/normalize-colors": "^0.81.5",
@@ -38607,6 +38613,22 @@
"vitest": "^4.1.6"
}
},
"packages/plugin": {
"name": "@paseo/plugin",
"version": "0.3.0",
"devDependencies": {
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
"typescript": "^5.9.3",
"vitest": "^4.1.6",
"zod": "^4.4.3"
},
"peerDependencies": {
"react": "19.1.0",
"zod": "^4.4.3"
}
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.4.0",
@@ -38845,6 +38867,7 @@
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
"@anthropic-ai/sdk": "^0.104.2",
"@babel/parser": "^7.29.8",
"@getpaseo/client": "0.4.0",
"@getpaseo/highlight": "0.4.0",
"@getpaseo/protocol": "0.4.0",
@@ -38854,11 +38877,13 @@
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
"@paseo/plugin": "*",
"@xterm/headless": "^6.0.0",
"ai": "5.0.78",
"ajv": "^8.20.0",
"bcryptjs": "^3.0.3",
"dotenv": "^17.2.3",
"esbuild": "^0.25.12",
"express": "^4.18.2",
"fast-deep-equal": "^3.1.3",
"lru-cache": "^11.5.1",
@@ -39022,6 +39047,468 @@
"win32"
]
},
"packages/server/node_modules/@babel/helper-string-parser": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"packages/server/node_modules/@babel/helper-validator-identifier": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"packages/server/node_modules/@babel/parser": {
"version": "7.29.8",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
"integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.8"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"packages/server/node_modules/@babel/types": {
"version": "7.29.8",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
"integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"packages/server/node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/android-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/android-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/android-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/darwin-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/darwin-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/freebsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/linux-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/linux-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/linux-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/linux-loong64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
"cpu": [
"loong64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/linux-mips64el": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
"cpu": [
"mips64el"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/linux-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/linux-riscv64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/linux-s390x": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
"cpu": [
"s390x"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/linux-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/netbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/openbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/openharmony-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/sunos-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/win32-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/win32-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/win32-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@isaacs/ttlcache": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-2.1.4.tgz",
@@ -39176,6 +39663,47 @@
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"packages/server/node_modules/esbuild": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.25.12",
"@esbuild/android-arm": "0.25.12",
"@esbuild/android-arm64": "0.25.12",
"@esbuild/android-x64": "0.25.12",
"@esbuild/darwin-arm64": "0.25.12",
"@esbuild/darwin-x64": "0.25.12",
"@esbuild/freebsd-arm64": "0.25.12",
"@esbuild/freebsd-x64": "0.25.12",
"@esbuild/linux-arm": "0.25.12",
"@esbuild/linux-arm64": "0.25.12",
"@esbuild/linux-ia32": "0.25.12",
"@esbuild/linux-loong64": "0.25.12",
"@esbuild/linux-mips64el": "0.25.12",
"@esbuild/linux-ppc64": "0.25.12",
"@esbuild/linux-riscv64": "0.25.12",
"@esbuild/linux-s390x": "0.25.12",
"@esbuild/linux-x64": "0.25.12",
"@esbuild/netbsd-arm64": "0.25.12",
"@esbuild/netbsd-x64": "0.25.12",
"@esbuild/openbsd-arm64": "0.25.12",
"@esbuild/openbsd-x64": "0.25.12",
"@esbuild/openharmony-arm64": "0.25.12",
"@esbuild/sunos-x64": "0.25.12",
"@esbuild/win32-arm64": "0.25.12",
"@esbuild/win32-ia32": "0.25.12",
"@esbuild/win32-x64": "0.25.12"
}
},
"packages/server/node_modules/isexe": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz",
+7 -4
View File
@@ -23,6 +23,7 @@
"workspaces": [
"packages/expo-two-way-audio",
"packages/highlight",
"packages/plugin",
"packages/protocol",
"packages/client",
"packages/server",
@@ -49,6 +50,8 @@
"build": "npm run build --workspaces --if-present",
"build:highlight": "npm run build --workspace=@getpaseo/highlight",
"build:highlight:clean": "npm run build:clean --workspace=@getpaseo/highlight",
"build:plugin": "npm run build --workspace=@paseo/plugin",
"build:plugin:clean": "npm run build:clean --workspace=@paseo/plugin",
"build:relay": "npm run build --workspace=@getpaseo/relay",
"build:relay:clean": "npm run build:clean --workspace=@getpaseo/relay",
"build:protocol": "npm run build --workspace=@getpaseo/protocol",
@@ -58,13 +61,13 @@
"build:workspace-runtime-contract": "npm run build --workspace=@getpaseo/workspace-runtime-contract",
"build:workspace-helper": "npm run build --workspace=@getpaseo/workspace-helper",
"build:workspace-runtime-fixture": "npm run build --workspace=@getpaseo/fixture-workspace-runtime",
"build:server-deps": "npm run build:workspace-runtime-contract && npm run build:workspace-helper && concurrently --kill-others-on-fail --names highlight,relay,client --prefix-colors yellow,blue,cyan \"npm run build:highlight\" \"npm run build:relay\" \"npm run build:client\"",
"build:server-deps:clean": "npm run build:clean --workspace=@getpaseo/workspace-runtime-contract && npm run build:clean --workspace=@getpaseo/workspace-helper && npm run build:highlight:clean && npm run build:relay:clean && npm run build:client:clean",
"build:server-deps": "npm run build:workspace-runtime-contract && npm run build:workspace-helper && concurrently --kill-others-on-fail --names highlight,plugin,relay,client --prefix-colors yellow,green,blue,cyan \"npm run build:highlight\" \"npm run build:plugin\" \"npm run build:relay\" \"npm run build:client\"",
"build:server-deps:clean": "npm run build:clean --workspace=@getpaseo/workspace-runtime-contract && npm run build:clean --workspace=@getpaseo/workspace-helper && npm run build:highlight:clean && npm run build:plugin:clean && npm run build:relay:clean && npm run build:client:clean",
"build:server": "npm run build:server-deps && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli",
"build:server:clean": "npm run build:server-deps:clean && npm run build:clean --workspace=@getpaseo/server && npm run build:clean --workspace=@getpaseo/cli",
"build:daemon-web-ui": "node scripts/build-daemon-web-ui.mjs",
"build:app-deps": "npm run build:highlight && npm run build:client && npm run build --workspace=@getpaseo/expo-two-way-audio",
"build:app-deps:clean": "npm run build:highlight:clean && npm run build:client:clean && npm run build --workspace=@getpaseo/expo-two-way-audio",
"build:app-deps": "npm run build:highlight && npm run build:plugin && npm run build:client && npm run build --workspace=@getpaseo/expo-two-way-audio",
"build:app-deps:clean": "npm run build:highlight:clean && npm run build:plugin:clean && npm run build:client:clean && npm run build --workspace=@getpaseo/expo-two-way-audio",
"watch:protocol": "npm run watch --workspace=@getpaseo/protocol",
"watch:client": "tsc -p packages/client/tsconfig.json --watch --preserveWatchOutput",
"typecheck": "npm run typecheck --workspaces --if-present",
@@ -30,6 +30,12 @@ import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { delayBrowserAgentCreatedStatus } from "../support/helpers/new-workspace";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import { selectModel } from "../support/helpers/app";
import { observeTimelineSubscriptions } from "../support/helpers/timeline-delivery";
import {
expectResumeOverflowFallsBackToOneTail,
rememberTimelineRequestCounts,
} from "../support/helpers/timeline-resume";
import { workspaceDeckEntryLocator } from "../support/helpers/workspace-ui";
import {
scrollTimelineToNewestLoadedEdge,
scrollTimelineUntilOlderHistoryIsReachable,
@@ -336,6 +342,64 @@ async function expectInterruptedTurnOrderAfterReconnect(
}
}
async function expectHiddenStreamingSubmissionOrderAfterWorkspaceEviction(
page: Page,
testInfo: { workerIndex: number },
): Promise<void> {
const subscriptions = observeTimelineSubscriptions(page);
const gate = await installDaemonWebSocketGate(page);
const target = await seedMockAgentWorkspace({
repoPrefix: `submission-hidden-stream-${testInfo.workerIndex}-`,
title: "Hidden streaming submission",
model: "ten-second-stream",
});
const evictionAgents = await Promise.all(
Array.from({ length: 3 }, (_unused, index) =>
seedMockAgentWorkspace({
repoPrefix: `submission-workspace-eviction-${testInfo.workerIndex}-${index}-`,
title: `Workspace eviction ${index + 1}`,
}),
),
);
const prompt = "Keep this hidden image prompt before its streaming output.";
const targetDeckEntry = workspaceDeckEntryLocator(page, getServerId(), target.workspaceId);
try {
await openAgentRoute(page, target);
await expectComposerVisible(page);
await subscriptions.waitForSubscribedAgents([target.agentId]);
const userMessageCount = gate.getAgentStreamItemCount("user_message");
gate.setAgentStreamSuppressed(true);
const promptRow = await submitMessageWithImage(page, prompt);
await gate.waitForAgentStreamItem("user_message", userMessageCount + 1);
for (const evictionAgent of evictionAgents) {
await openAgentRoute(page, evictionAgent);
await expectComposerVisible(page);
}
await expect(targetDeckEntry).toHaveCount(0);
await subscriptions.waitForSubscribedAgents([evictionAgents[2]!.agentId]);
gate.setAgentStreamSuppressed(false);
await target.client.waitForFinish(target.agentId, 30_000);
const requestsBeforeReturn = rememberTimelineRequestCounts(gate);
await openAgentRoute(page, target);
await expectComposerVisible(page);
await subscriptions.waitForSubscribedAgents([target.agentId]);
const response = page.getByText("(end of synthetic stream)", { exact: true }).last();
await expect(promptRow).toBeVisible();
await expect(response).toBeVisible();
await expectRenderedBefore(promptRow, response);
expectResumeOverflowFallsBackToOneTail(gate, requestsBeforeReturn);
} finally {
gate.setAgentStreamSuppressed(false);
gate.restore();
await Promise.all([...evictionAgents.map((agent) => agent.cleanup()), target.cleanup()]);
}
}
async function expectCompletedSubmissionClearsAfterMissedRunningTransition(
page: Page,
testInfo: { workerIndex: number },
@@ -1013,6 +1077,13 @@ test.describe("Agent message submission", () => {
await expectInterruptedTurnOrderAfterReconnect(page, testInfo);
});
test("keeps a streaming hidden submission before its output after workspace eviction", async ({
page,
}, testInfo) => {
test.setTimeout(120_000);
await expectHiddenStreamingSubmissionOrderAfterWorkspaceEviction(page, testInfo);
});
test("clears an attachment-only submission when canonical history arrives after a missed running transition", async ({
page,
}, testInfo) => {
@@ -26,9 +26,41 @@ import {
expectTimelineViewportAnchoredAfterPrepend,
userNavigatesTimelineToHistoryStartWithKeyboard,
userScrollsTimelineToHistoryStart,
waitForPersistedCanonicalTimelineRange,
} from "../support/helpers/timeline-pagination";
import { trackAgentTimelineRequests } from "../support/helpers/agent-timeline-gate";
import { seedMockAgentWorkspace } from "../support/helpers/mock-agent";
test.describe("Agent timeline pagination", () => {
test("resumes a persisted canonical timeline after its exact end", async ({ page }) => {
const prompt = "timeline persisted range resumes without tail replay";
const agent = await seedMockAgentWorkspace({
repoPrefix: "timeline-persisted-range-",
title: "Persisted timeline range",
initialPrompt: prompt,
});
try {
await agent.client.waitForFinish(agent.agentId, 15_000);
await openAgentTimeline(page, agent);
await expectTimelinePromptVisible(page, prompt);
const range = await waitForPersistedCanonicalTimelineRange(page, agent.agentId);
const tracker = await trackAgentTimelineRequests(page, agent.agentId);
await page.reload();
await expectTimelinePromptVisible(page, prompt);
const request = await tracker.nextRequest();
expect(request).toEqual({
direction: "after",
cursor: { epoch: range.epoch, seq: range.endSeq },
});
await tracker.waitForResponse();
expect(tracker.requests().some((entry) => entry.direction === "tail")).toBe(false);
} finally {
await agent.cleanup();
}
});
test("keeps a fixed history-start gutter before, during, and after pagination", async ({
page,
}) => {
@@ -13,9 +13,10 @@ import {
import {
commitTimelineTurnsWhileDisconnected,
disconnectViewedTimeline,
expectOneTailCheckWithoutCatchUpPages,
expectOneResumeCheckWithoutTail,
expectResumeOverflowFallsBackToOneTail,
rememberTimelineRequestCounts,
restoreViewedTimelineWithHeldTail,
restoreViewedTimelineWithHeldResponse,
} from "../support/helpers/timeline-resume";
test.describe("Agent timeline resume", () => {
@@ -29,11 +30,11 @@ test.describe("Agent timeline resume", () => {
const requests = rememberTimelineRequestCounts(gate);
await disconnectViewedTimeline(page, gate);
const background = await commitTimelineTurnsWhileDisconnected(agent, 24);
await restoreViewedTimelineWithHeldTail(page, gate);
await restoreViewedTimelineWithHeldResponse(page, gate);
await expectTimelinePromptVisible(page, background.newestPrompt);
await expectTimelinePromptNotMounted(page, background.firstPrompt);
expectOneTailCheckWithoutCatchUpPages(gate, requests);
expectResumeOverflowFallsBackToOneTail(gate, requests);
await scrollTimelineUntilOlderHistoryIsReachable(page, background.firstPrompt);
} finally {
gate.restore();
@@ -53,10 +54,10 @@ test.describe("Agent timeline resume", () => {
const requests = rememberTimelineRequestCounts(gate);
await disconnectViewedTimeline(page, gate);
await restoreViewedTimelineWithHeldTail(page, gate);
await restoreViewedTimelineWithHeldResponse(page, gate);
await expectTimelinePresentationUnchanged(page, presentation);
expectOneTailCheckWithoutCatchUpPages(gate, requests);
expectOneResumeCheckWithoutTail(gate, requests);
} finally {
gate.restore();
await agent.cleanup();
@@ -0,0 +1,79 @@
import { test } from "../support/fixtures";
import { openAgentRoute } from "../support/helpers/mock-agent";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import {
archiveFinishedSubagents,
expectArchiveFinishedInProgress,
expectArchiveFinishedRetry,
expectManagedSubagentArchived,
expectManagedSubagentUnarchived,
expectSubagentRowGone,
expectSubagentRowVisible,
holdManagedSubagentArchiveRequest,
openSubagentsTrack,
rejectNextManagedSubagentArchiveRequest,
seedParentWithSubagent,
} from "../support/helpers/subagents";
test.describe("Archive finished subagents", () => {
let workspace: SeededWorkspace;
test.beforeAll(async () => {
workspace = await seedWorkspace({ repoPrefix: "archive-finished-subagents-" });
});
test.afterAll(async () => {
await workspace?.cleanup();
});
test("archives a finished managed child from the track", async ({ page }) => {
const agents = await seedParentWithSubagent(workspace, {
parentTitle: "Archive parent",
childTitle: "Finished child",
});
await workspace.client.waitForAgentUpsert(
agents.child.id,
(snapshot) => snapshot.status === "idle",
);
const archiveGate = await holdManagedSubagentArchiveRequest(page, agents.child.id);
await openAgentRoute(page, { workspaceId: agents.workspaceId, agentId: agents.parent.id });
await openSubagentsTrack(page);
await expectSubagentRowVisible(page, agents.child.id);
await archiveFinishedSubagents(page);
await archiveGate.waitForRequest();
await expectSubagentRowGone(page, agents.child.id);
await expectArchiveFinishedInProgress(page, 0, 1);
await expectManagedSubagentUnarchived(workspace, agents.child.id);
archiveGate.release();
await expectManagedSubagentArchived(workspace, agents.child.id);
});
test("restores a failed child and retries the archive", async ({ page }) => {
const agents = await seedParentWithSubagent(workspace, {
parentTitle: "Retry parent",
childTitle: "Retry child",
});
await workspace.client.waitForAgentUpsert(
agents.child.id,
(snapshot) => snapshot.status === "idle",
);
const rejection = await rejectNextManagedSubagentArchiveRequest(page, agents.child.id);
await openAgentRoute(page, { workspaceId: agents.workspaceId, agentId: agents.parent.id });
await openSubagentsTrack(page);
await expectSubagentRowVisible(page, agents.child.id);
await archiveFinishedSubagents(page);
await rejection.waitForRejection();
await expectSubagentRowVisible(page, agents.child.id);
await expectArchiveFinishedRetry(page, 1, 1);
await archiveFinishedSubagents(page);
await expectSubagentRowGone(page, agents.child.id);
await expectManagedSubagentArchived(workspace, agents.child.id);
});
});
@@ -0,0 +1,209 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { gotoAppShell, openSettings } from "../support/helpers/app";
import { getServerId } from "../support/helpers/server-id";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import {
expectSettingsHeader,
openHostSection,
openSettingsHost,
} from "../support/helpers/settings";
function observePluginCatalog(page: Page) {
let responses = 0;
page.on("websocket", (socket) => {
socket.on("framereceived", ({ payload }) => {
if (typeof payload !== "string") return;
try {
const envelope = JSON.parse(payload) as {
type?: unknown;
message?: { type?: unknown };
};
const message = envelope.type === "session" ? envelope.message : envelope;
if (message?.type === "plugin.catalog.get.response") responses += 1;
} catch {
return;
}
});
});
return {
waitForInitialFetch: async () => {
await expect.poll(() => responses).toBeGreaterThan(0);
},
};
}
function pluginSource(title: string, renderError?: string): string {
return `import React from "react";
import { Text } from "react-native";
export default function contribute(plugin) {
function Surface() {
${renderError ? `throw new Error(${JSON.stringify(renderError)});` : ""}
const cleanups = Number(globalThis.sessionStorage?.getItem("paseo-plugin-cleanups") || "0");
return <Text>${title} cleanup {cleanups}</Text>;
}
plugin.addSurface("main", Surface);
plugin.addSidebarItem({ id: "main", title: ${JSON.stringify(title)}, icon: "Blocks", surface: "main" });
${
renderError
? `function HealthySurface() { return <Text>Healthy contribution</Text>; }
plugin.addSurface("healthy", HealthySurface);
plugin.addSidebarItem({ id: "healthy", title: "Healthy surface", icon: "Blocks", surface: "healthy" });`
: ""
}
return () => {
const storage = globalThis.sessionStorage;
if (storage) {
const cleanups = Number(storage.getItem("paseo-plugin-cleanups") || "0");
storage.setItem("paseo-plugin-cleanups", String(cleanups + 1));
}
};
}`;
}
async function openPluginSettings(page: Page): Promise<void> {
const serverId = getServerId();
await openSettings(page);
await openSettingsHost(page, serverId);
await openHostSection(page, serverId, "plugins");
await expectSettingsHeader(page, "Plugins");
}
async function reloadPlugin(page: Page): Promise<void> {
await page.getByRole("button", { name: "Reload", exact: true }).click();
await expect(page.getByText("Reloaded e2e-plugin", { exact: true })).toBeVisible();
}
async function expectContributionBody(page: Page, expectedBody: string): Promise<void> {
const body = page.getByText(expectedBody, { exact: true }).filter({ visible: true });
await expect(body).toHaveCount(1);
await expect(body).toBeVisible();
}
async function openContribution(page: Page, title: string, expectedBody: string): Promise<void> {
await page.getByRole("button", { name: title, exact: true }).click();
await expectContributionBody(page, expectedBody);
}
async function leavePluginSettings(page: Page): Promise<void> {
await page.getByRole("button", { name: "Back", exact: true }).click();
await expect(page).not.toHaveURL(/\/settings\//);
}
async function openContributionFromSettings(
page: Page,
title: string,
expectedBody: string,
): Promise<void> {
await leavePluginSettings(page);
await openContribution(page, title, expectedBody);
}
async function reloadActiveContribution(
page: Page,
client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>,
expectedBody: string,
): Promise<void> {
await client.reloadPlugin("e2e-plugin");
await expectContributionBody(page, expectedBody);
}
async function expectContributionRemoved(page: Page, title: string): Promise<void> {
await expect(page.getByRole("button", { name: title, exact: true })).not.toBeVisible();
}
test("installs, reloads, recovers, disables, and removes a trusted local plugin", async ({
page,
}) => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-e2e-"));
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: "e2e-plugin" }));
await writeFile(path.join(directory, "index.tsx"), pluginSource("Plugin v1"));
try {
const catalog = observePluginCatalog(page);
await gotoAppShell(page);
await openPluginSettings(page);
await catalog.waitForInitialFetch();
await client.patchDaemonConfig({ pluginsEnabled: false });
await page.getByRole("switch", { name: "Enable plugins" }).click();
await expect(page.getByText("Plugins enabled", { exact: true })).toBeVisible();
await page.getByLabel("Plugin directory").fill(directory);
await page.getByLabel("Plugin installation ID").focus();
await expect(page.getByLabel("Plugin installation ID")).toHaveValue("e2e-plugin");
await page.getByRole("button", { name: "Install directory" }).click();
await expect(page.getByText("Installed e2e-plugin", { exact: true })).toBeVisible();
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
await openContributionFromSettings(page, "Plugin v1", "Plugin v1 cleanup 0");
await openPluginSettings(page);
await reloadPlugin(page);
await openContributionFromSettings(page, "Plugin v1", "Plugin v1 cleanup 1");
await writeFile(path.join(directory, "index.tsx"), pluginSource("Plugin v2"));
await openPluginSettings(page);
await reloadPlugin(page);
await openContributionFromSettings(page, "Plugin v2", "Plugin v2 cleanup 2");
await expect(page.getByRole("button", { name: "Plugin v1", exact: true })).not.toBeVisible();
await writeFile(
path.join(directory, "index.tsx"),
pluginSource("Broken surface", "render exploded"),
);
await openPluginSettings(page);
await reloadPlugin(page);
await openContributionFromSettings(page, "Broken surface", "Plugin failed: render exploded");
await openContribution(page, "Healthy surface", "Healthy contribution");
await openContribution(page, "Broken surface", "Plugin failed: render exploded");
await writeFile(path.join(directory, "index.tsx"), pluginSource("Recovered surface"));
await reloadActiveContribution(page, client, "Recovered surface cleanup 4");
await expect(
page.getByRole("button", { name: "Broken surface", exact: true }),
).not.toBeVisible();
await writeFile(path.join(directory, "index.tsx"), "export default broken syntax !!!");
await openPluginSettings(page);
await page.getByRole("button", { name: "Reload", exact: true }).click();
await expect(page.getByTestId("plugin-management-feedback")).toContainText("Request failed");
await expect(page.getByLabel("e2e-plugin failed")).toBeVisible();
await leavePluginSettings(page);
await expectContributionRemoved(page, "Recovered surface");
await writeFile(path.join(directory, "index.tsx"), pluginSource("Plugin v3"));
await openPluginSettings(page);
await page.getByRole("button", { name: "Reload", exact: true }).click();
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
await openContributionFromSettings(page, "Plugin v3", "Plugin v3 cleanup 5");
await openPluginSettings(page);
await page.getByRole("button", { name: "Disable", exact: true }).click();
await expect(page.getByLabel("e2e-plugin disabled")).toBeVisible();
await leavePluginSettings(page);
await expectContributionRemoved(page, "Plugin v3");
await openPluginSettings(page);
await page.getByRole("button", { name: "Enable", exact: true }).click();
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
await openContributionFromSettings(page, "Plugin v3", "Plugin v3 cleanup 6");
await openPluginSettings(page);
page.once("dialog", (dialog) => dialog.accept());
await page.getByRole("button", { name: "Remove", exact: true }).click();
await expect(page.getByText("No plugins configured", { exact: true })).toBeVisible();
await page.getByLabel("Plugin directory").fill(directory);
await page.getByRole("button", { name: "Install directory" }).click();
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
await gotoAppShell(page);
await openContribution(page, "Plugin v3", "Plugin v3 cleanup 7");
} finally {
await client.removePlugin("e2e-plugin").catch(() => undefined);
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false });
await client.close().catch(() => undefined);
await rm(directory, { recursive: true, force: true });
}
});
@@ -31,6 +31,82 @@ export interface PromptJumpRequestTracker {
requests(): Array<{ cursorSeq: number | null; limit: number | null; mergeWindow: boolean }>;
}
export interface TimelineRequestTracker {
nextRequest(): Promise<{
direction: string | null;
cursor: { epoch: string; seq: number } | null;
}>;
requests(): Array<{
direction: string | null;
cursor: { epoch: string; seq: number } | null;
}>;
waitForResponse(): Promise<void>;
}
export async function trackAgentTimelineRequests(
page: Page,
agentId: string,
): Promise<TimelineRequestTracker> {
type Request = ReturnType<TimelineRequestTracker["requests"]>[number];
const seen: Request[] = [];
const waiters: Array<(request: Request) => void> = [];
let responseSeen = false;
let resolveResponse: (() => void) | null = null;
const response = new Promise<void>((resolve) => {
resolveResponse = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
if (
sessionMessage?.type === "fetch_agent_timeline_request" &&
sessionMessage.agentId === agentId
) {
const rawCursor = sessionMessage.cursor;
const cursor =
rawCursor &&
typeof rawCursor === "object" &&
typeof (rawCursor as { epoch?: unknown }).epoch === "string" &&
typeof (rawCursor as { seq?: unknown }).seq === "number"
? {
epoch: (rawCursor as { epoch: string }).epoch,
seq: (rawCursor as { seq: number }).seq,
}
: null;
const request = {
direction: typeof sessionMessage.direction === "string" ? sessionMessage.direction : null,
cursor,
};
seen.push(request);
waiters.shift()?.(request);
}
server.send(message);
});
server.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
const payload = sessionMessage ? getPayload(sessionMessage) : null;
if (
sessionMessage?.type === "fetch_agent_timeline_response" &&
payload?.agentId === agentId
) {
responseSeen = true;
resolveResponse?.();
}
ws.send(message);
});
});
return {
nextRequest() {
const request = seen[0];
if (request) return Promise.resolve(request);
return new Promise((resolve) => waiters.push(resolve));
},
requests: () => [...seen],
waitForResponse: () => (responseSeen ? Promise.resolve() : response),
};
}
export async function trackPromptJumpRequests(
page: Page,
agentId: string,
@@ -24,6 +24,8 @@ type NewWorkspaceDaemonClient = Pick<
| "on"
| "patchDaemonConfig"
| "removeProject"
| "removePlugin"
| "reloadPlugin"
>;
type CreateWorkspacePayload = Awaited<ReturnType<NewWorkspaceDaemonClient["createWorkspace"]>>;
@@ -5,10 +5,16 @@ const STORE_NAME = "key-value";
const STORAGE_KEY = "@paseo:replica-cache";
export interface ReplicaCacheRecord {
version?: number;
hosts?: Array<{
timeline?: {
agentId?: string;
items?: Array<Record<string, unknown>>;
range?: {
endSeq?: number;
epoch?: string;
startSeq?: number;
} | null;
} | null;
}>;
}
@@ -41,6 +41,7 @@ type HostSection =
| "providers"
| "usage"
| "terminals"
| "plugins"
| "host";
export async function openSettingsSection(page: Page, section: SettingsSection): Promise<void> {
@@ -1,7 +1,19 @@
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { expect, type Page } from "@playwright/test";
import { daemonWsRoutePattern } from "./daemon-port";
import type { SeededWorkspace } from "./seed-client";
type WebSocketMessage = string | Buffer;
export interface ManagedSubagentArchiveGate {
release(): void;
waitForRequest(): Promise<void>;
}
export interface ManagedSubagentArchiveRejection {
waitForRejection(): Promise<void>;
}
export interface SeededSubagentPair {
parent: {
id: string;
@@ -132,6 +144,147 @@ export async function expectSubagentRowGone(page: Page, childId: string): Promis
});
}
export async function expectManagedSubagentUnarchived(
workspace: Pick<SeededWorkspace, "client">,
childId: string,
): Promise<void> {
await expect(workspace.client.fetchAgent({ agentId: childId })).resolves.toMatchObject({
agent: { id: childId, archivedAt: null },
});
}
export async function expectManagedSubagentArchived(
workspace: Pick<SeededWorkspace, "client">,
childId: string,
): Promise<void> {
await expect
.poll(() => workspace.client.fetchAgent({ agentId: childId }), { timeout: 30_000 })
.toMatchObject({ agent: { id: childId, archivedAt: expect.any(String) } });
}
export async function archiveFinishedSubagents(page: Page): Promise<void> {
await page.getByRole("button", { name: "Archive finished subagents" }).click();
}
export async function expectArchiveFinishedInProgress(
page: Page,
completed: number,
total: number,
): Promise<void> {
await expect(page.getByRole("button", { name: "Archive finished subagents" })).toBeDisabled();
await expect(page.getByText(`${completed}/${total}`, { exact: true })).toBeVisible();
}
export async function expectArchiveFinishedRetry(
page: Page,
failed: number,
total: number,
): Promise<void> {
await expect(page.getByText(`Retry (${failed}/${total})`, { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Archive finished subagents" })).toBeEnabled();
}
export async function holdManagedSubagentArchiveRequest(
page: Page,
subagentId: string,
): Promise<ManagedSubagentArchiveGate> {
let released = false;
let resolveRequest: (() => void) | undefined;
const delayedForwards: Array<() => void> = [];
const request = new Promise<void>((resolve) => {
resolveRequest = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
if (isArchiveRequestForSubagent(message, subagentId) && !released) {
delayedForwards.push(() => server.send(message));
resolveRequest?.();
return;
}
server.send(message);
});
server.onMessage((message) => ws.send(message));
});
return {
release() {
released = true;
for (const forward of delayedForwards.splice(0)) forward();
},
waitForRequest: () => request,
};
}
export async function rejectNextManagedSubagentArchiveRequest(
page: Page,
subagentId: string,
): Promise<ManagedSubagentArchiveRejection> {
let resolveRejection: (() => void) | undefined;
const rejection = new Promise<void>((resolve) => {
resolveRejection = resolve;
});
let rejected = false;
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
const requestId = archiveRequestId(message, subagentId);
if (requestId && !rejected) {
rejected = true;
// A daemon-rejected archive RPC: the request never reaches the daemon, and its normal
// correlated error travels back through the real browser client and mutation rollback.
ws.send(
JSON.stringify({
type: "session",
message: {
type: "rpc_error",
payload: {
requestId,
requestType: "archive_agent_request",
error: "Archive rejected for test",
code: "archive_rejected",
},
},
}),
);
resolveRejection?.();
return;
}
server.send(message);
});
server.onMessage((message) => ws.send(message));
});
return { waitForRejection: () => rejection };
}
function isArchiveRequestForSubagent(message: WebSocketMessage, subagentId: string): boolean {
return archiveRequestId(message, subagentId) !== null;
}
function archiveRequestId(message: WebSocketMessage, subagentId: string): string | null {
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
try {
const envelope = JSON.parse(rawMessage) as {
type?: unknown;
message?: { type?: unknown; agentId?: unknown; requestId?: unknown };
};
if (
envelope.type === "session" &&
envelope.message?.type === "archive_agent_request" &&
envelope.message.agentId === subagentId &&
typeof envelope.message.requestId === "string"
) {
return envelope.message.requestId;
}
return null;
} catch {
return null;
}
}
export async function detachSubagentFromTrack(page: Page, childId: string): Promise<void> {
const row = page.getByTestId(`subagents-track-row-${childId}`);
await expect(row).toBeVisible({ timeout: 30_000 });
@@ -40,6 +40,12 @@ interface TimelineViewportSnapshot {
scrollTop: number;
}
interface PersistedCanonicalTimelineRange {
epoch: string;
startSeq: number;
endSeq: number;
}
export interface TimelinePresentationSnapshot {
marker: string;
position: TimelinePromptPositionSnapshot;
@@ -298,6 +304,32 @@ export async function reloadAgentTimelineFromPersistedReplica(
await expectTimelinePromptVisible(page, agent.newestPrompt);
}
export async function waitForPersistedCanonicalTimelineRange(
page: Page,
agentId: string,
): Promise<PersistedCanonicalTimelineRange> {
const readRange = async () => {
const cache = await readReplicaCache(page);
if (cache?.version !== 6) return null;
const range = cache.hosts?.find((host) => host.timeline?.agentId === agentId)?.timeline?.range;
if (
typeof range?.epoch !== "string" ||
typeof range.startSeq !== "number" ||
typeof range.endSeq !== "number"
) {
return null;
}
return { epoch: range.epoch, startSeq: range.startSeq, endSeq: range.endSeq };
};
await expect.poll(readRange).not.toBeNull();
const range = await readRange();
if (!range) {
throw new Error(`Persisted canonical timeline range is missing for ${agentId}`);
}
return range;
}
export async function holdNextOlderTimelinePage(
page: Page,
agent: LongTimelineAgent,
@@ -40,12 +40,20 @@ export function rememberTimelineRequestCounts(gate: DaemonWebSocketGate): Timeli
};
}
export function expectOneTailCheckWithoutCatchUpPages(
export function expectOneResumeCheckWithoutTail(
gate: DaemonWebSocketGate,
before: TimelineRequestCounts,
): void {
expect(gate.getTimelineRequestCount("after") - before.after).toBe(1);
expect(gate.getTimelineRequestCount("tail") - before.tail).toBe(0);
}
export function expectResumeOverflowFallsBackToOneTail(
gate: DaemonWebSocketGate,
before: TimelineRequestCounts,
): void {
expect(gate.getTimelineRequestCount("after") - before.after).toBe(1);
expect(gate.getTimelineRequestCount("tail") - before.tail).toBe(1);
expect(gate.getTimelineRequestCount("after") - before.after).toBe(0);
}
export async function disconnectViewedTimeline(
@@ -56,7 +64,7 @@ export async function disconnectViewedTimeline(
await expectReconnectingToastVisible(page);
}
export async function restoreViewedTimelineWithHeldTail(
export async function restoreViewedTimelineWithHeldResponse(
page: Page,
gate: DaemonWebSocketGate,
): Promise<void> {
+1
View File
@@ -49,6 +49,7 @@
"@gorhom/bottom-sheet": "^5.2.14",
"@gorhom/portal": "^1.0.14",
"@mattermost/react-native-paste-input": "2.0.1",
"@paseo/plugin": "*",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-masked-view/masked-view": "^0.3.2",
"@react-native/normalize-colors": "^0.81.5",
+2
View File
@@ -127,6 +127,7 @@ import {
} from "@/utils/host-routes";
import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { PluginCatalogSync } from "@/plugins";
import {
ensureOsNotificationPermission,
WEB_NOTIFICATION_CLICK_EVENT,
@@ -270,6 +271,7 @@ function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
return (
<SessionProvider key={daemon.serverId} serverId={daemon.serverId} client={client}>
<LegacyFavoriteProfileMigrationBootstrap serverId={daemon.serverId} client={client} />
<PluginCatalogSync serverId={daemon.serverId} client={client} />
</SessionProvider>
);
}
@@ -41,6 +41,7 @@ function KnownHostRoute() {
<Stack.Screen name="sessions" />
<Stack.Screen name="open-project" />
<Stack.Screen name="settings" />
<Stack.Screen name="plugin/[pluginId]/[surfaceId]" />
</ThemedStack>
);
@@ -0,0 +1,5 @@
import { PluginSurfaceScreen } from "@/plugins";
export default function PluginSurfaceRoute() {
return <PluginSurfaceScreen />;
}
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { i18n } from "@/i18n/i18next";
import { getAgentAttachmentPillContent } from "./attachment-pill-content";
describe("agent attachment pill content", () => {
it("presents external resources with their provider identity", () => {
const content = getAgentAttachmentPillContent(
{
type: "text",
mimeType: "text/plain",
title: "ENG-123 Plugin attachments",
text: "Linear issue ENG-123: Plugin attachments",
externalResource: {
provider: "linear",
providerLabel: "Linear issue",
resourceType: "issue",
id: "issue-uuid",
identifier: "ENG-123",
title: "Plugin attachments",
url: "https://linear.app/acme/issue/ENG-123/plugin-attachments",
},
},
i18n.t,
);
expect(content.title).toBe("Plugin attachments");
expect(content.subtitle).toBe("Linear issue ENG-123");
});
});
@@ -1,5 +1,6 @@
import type { ReactNode } from "react";
import type { TFunction } from "i18next";
import React from "react";
import {
CircleDot,
FileText,
@@ -86,6 +87,13 @@ export function getAgentAttachmentPillContent(
subtitle: `Issue #${attachment.number}`,
};
case "text":
if (attachment.externalResource) {
return {
icon: attachmentGithubIssueIcon,
title: attachment.externalResource.title,
subtitle: `${attachment.externalResource.providerLabel} ${attachment.externalResource.identifier}`,
};
}
return {
icon: attachmentFileIcon,
title: attachment.title ?? t("message.attachments.textAttachment"),
+2
View File
@@ -3,6 +3,7 @@ import type {
ForgeSearchItem,
UploadedFileAttachment,
} from "@getpaseo/protocol/messages";
import type { PluginResourceComposerAttachment } from "@/plugins/attachments";
export type AttachmentStorageType = "web-indexeddb" | "desktop-file" | "native-file";
@@ -106,6 +107,7 @@ export type UserComposerAttachment =
| { kind: "image"; metadata: AttachmentMetadata }
| { kind: "file"; attachment: UploadedFileAttachment }
| WorkspaceFileComposerAttachment
| PluginResourceComposerAttachment
| { kind: "forge_issue"; item: ForgeSearchItem }
| { kind: "forge_change_request"; item: ForgeSearchItem }
// COMPAT(githubAttachmentKinds): added in v0.1.106, remove after 2026-12-28 once daemon floor >= v0.1.106
@@ -75,6 +75,7 @@ import type { ShortcutKey } from "@/utils/format-shortcut";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
import { PluginSidebarItems } from "@/plugins";
type SidebarTheme = ReturnType<typeof useUnistyles>["theme"];
@@ -681,6 +682,7 @@ function MobileSidebar({
testID="sidebar-schedules"
variant="compact"
/>
<PluginSidebarItems onBeforeNavigate={closeSidebar} />
</View>
<WindowChromeSafeArea placement="inline" style={styles.mobileCloseButtonRow}>
<Pressable
@@ -899,6 +901,7 @@ function DesktopSidebar({
testID="sidebar-schedules"
variant="compact"
/>
<PluginSidebarItems />
</View>
</View>
+29
View File
@@ -863,6 +863,35 @@ describe("openComposerAttachment", () => {
});
expect(externalUrlCalls).toEqual([issueItem.url]);
});
it("opens plugin resource URLs through the external url opener", () => {
const externalUrlCalls: string[] = [];
openComposerAttachment({
attachment: {
kind: "plugin_resource",
pluginId: "linear",
sourceId: "issues",
sourceTitle: "Linear issue",
sourceIcon: "CircleDot",
item: {
id: "issue-uuid",
identifier: "ENG-123",
title: "Plugin attachments",
url: "https://linear.app/acme/issue/ENG-123/plugin-attachments",
text: "Linear issue ENG-123: Plugin attachments",
resourceType: "issue",
},
},
setLightboxMetadata: () => {
throw new Error("unexpected lightbox call");
},
openWorkspaceAttachment: () => false,
openExternalUrl: (url) => {
externalUrlCalls.push(url);
},
});
expect(externalUrlCalls).toEqual(["https://linear.app/acme/issue/ENG-123/plugin-attachments"]);
});
});
describe("toggleGithubAttachment", () => {
@@ -10,6 +10,7 @@ import {
buildLegacyGitHubAttachmentFromSearchItem,
} from "@/utils/review-attachments";
import { workspaceFileAttachmentToAgentAttachment } from "@/attachments/workspace-file";
import { pluginResourceAttachmentToAgentAttachment } from "@/plugins/attachments";
export type ComposerAttachmentSubmitFormat = "forge" | "legacy-github";
@@ -55,6 +56,11 @@ export function splitComposerAttachmentsForSubmit(
continue;
}
if (attachment.kind === "plugin_resource") {
agentAttachments.push(pluginResourceAttachmentToAgentAttachment(attachment));
continue;
}
if (isWorkspaceAttachment(attachment)) {
if (attachment.kind === "browser_element" && attachment.attachment.screenshot) {
images.push(attachment.attachment.screenshot);
+33 -1
View File
@@ -127,6 +127,7 @@ import { ForgeBrandIcon } from "@/git/forge-icon";
import { useComposerGithubAutoAttach } from "./github/auto-attach";
import { readClipboardImage } from "./clipboard-image";
import { normalizeNativePastedImages, type NativePastedFile } from "./native-pasted-image";
import { PluginResourceAttachmentPill, usePluginAttachmentPicker } from "@/plugins";
import { resolveClientSlashCommand, type ClientSlashCommand } from "@/client-slash-commands";
import {
appendWorkspaceFileAttachment,
@@ -442,6 +443,20 @@ function renderComposerAttachmentPill(args: RenderComposerAttachmentPillArgs): R
onRemove,
});
}
if (attachment.kind === "plugin_resource") {
return (
<PluginResourceAttachmentPill
key={`${attachment.pluginId}:${attachment.sourceId}:${attachment.item.id}`}
attachment={attachment}
index={index}
disabled={disabled}
onOpen={onOpen}
onRemove={onRemove}
openLabel={labels.openGithub}
removeLabel={labels.removeGithub}
/>
);
}
return (
<GithubAttachmentPill
key={`${attachment.item.kind}:${attachment.item.number}`}
@@ -1163,6 +1178,14 @@ export function Composer({
const [lightboxMetadata, setLightboxMetadata] = useState<AttachmentMetadata | null>(null);
const attachButtonRef = useRef<View | null>(null);
const messageInputRef = useRef<MessageInputRef>(null);
const pluginAttachments = usePluginAttachmentPicker({
serverId,
client,
connected: isConnected,
attachments,
onChangeAttachments: setSelectedAttachments,
anchorRef: attachButtonRef,
});
const isComposerLocked = resolveIsComposerLocked(submitBehavior, isSubmitLoading);
const keyboardHandlerIdRef = useRef(
`message-input:${serverId}:${agentId}:${Math.random().toString(36).slice(2)}`,
@@ -1960,6 +1983,7 @@ export function Composer({
setIsGithubPickerOpen(true);
},
},
...pluginAttachments.menuItems,
{
id: "file",
label: t("composer.attachments.addFile"),
@@ -1970,7 +1994,14 @@ export function Composer({
},
);
return items;
}, [forgePresentation, handlePasteImage, handlePickFile, handlePickImage, t]);
}, [
forgePresentation,
handlePasteImage,
handlePickFile,
handlePickImage,
pluginAttachments.menuItems,
t,
]);
const handleToggleGithubItem = useCallback(
(item: ForgeSearchItem) => {
@@ -2230,6 +2261,7 @@ export function Composer({
emptyText={githubEmptyText}
renderOption={renderGithubPickerOption}
/>
{pluginAttachments.picker}
</View>
</View>
</View>
+46 -7
View File
@@ -19,7 +19,10 @@ import {
type TimelineReducerSideEffect,
} from "@/timeline/session-stream-reducers";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { isTimelineResumeSnapshotAuthoritative } from "@/timeline/timeline-sync-plan";
import {
isTimelineResumeSnapshotAuthoritative,
planTimelineTailFetch,
} from "@/timeline/timeline-sync-plan";
import {
createViewedTimelineSync,
type TimelineDeliveryMode,
@@ -33,6 +36,7 @@ import {
type AgentAttentionNotificationPayload,
type NotificationPermissionRequest,
} from "@getpaseo/protocol/agent-attention-notification";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { AgentSessionConfig } from "@getpaseo/protocol/agent-types";
import type { GitSetupOptions } from "@getpaseo/protocol/messages";
@@ -67,6 +71,20 @@ import { applyCheckoutStatusUpdateFromEvent } from "@/git/checkout-status-cache"
import { useProviderSubagentStore } from "@/subagents/provider-store";
import { revalidateSessionAfterResume } from "@/contexts/session-resume-revalidation";
type TimelineResponsePayload = Extract<
SessionOutboundMessage,
{ type: "fetch_agent_timeline_response" }
>["payload"];
function consumeForcedTimelineTailReplacement(
payload: TimelineResponsePayload,
replacements: Set<string>,
): TimelineResponsePayload {
if (payload.direction !== "tail") return payload;
if (!replacements.delete(payload.agentId)) return payload;
return { ...payload, reset: true };
}
// Re-export types from session-store and draft-store for backward compatibility
export type { DraftInput } from "@/stores/draft-store";
export type {
@@ -350,6 +368,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const _sessionStateTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const attentionNotifiedRef = useRef<Map<string, number>>(new Map());
const appStateRef = useRef(AppState.currentState);
const forcedTimelineTailReplacements = useRef(new Set<string>());
const viewedTimelineSyncRef = useRef<ViewedTimelineSync | null>(null);
const audioOutputBuffersRef = useRef<Map<string, BufferedAudioChunk[]>>(new Map());
const activeAudioGroupsRef = useRef<Set<string>>(new Set());
@@ -528,12 +547,11 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
);
const applyTimelineResponse = useCallback(
(
payload: Extract<
SessionOutboundMessage,
{ type: "fetch_agent_timeline_response" }
>["payload"],
) => {
(receivedPayload: TimelineResponsePayload) => {
const payload = consumeForcedTimelineTailReplacement(
receivedPayload,
forcedTimelineTailReplacements.current,
);
const agentId = payload.agentId;
const initKey = getInitKey(serverId, agentId);
const shouldMarkAuthoritativeHistoryApplied = isTimelineResumeSnapshotAuthoritative({
@@ -642,6 +660,15 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const sync = createViewedTimelineSync({
initialDeliveryMode,
setSubscription: (agentIds) => client.setAgentTimelineSubscription(agentIds),
readCursor: (agentId) => {
const timeline = selectAgentTimelineState(
useSessionStore.getState().sessions[serverId],
agentId,
);
return timeline.status === "synced" && timeline.range
? { epoch: timeline.range.epoch, endSeq: timeline.range.endSeq }
: undefined;
},
fetchPage: async (agentId, request) => {
const session = useSessionStore.getState().sessions[serverId];
const initKey = getInitKey(serverId, agentId);
@@ -672,6 +699,18 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
throw error;
}
},
fetchLatestTail: async (agentId) => {
forcedTimelineTailReplacements.current.add(agentId);
try {
return await getHostRuntimeStore().fetchAgentTimeline(
serverId,
agentId,
planTimelineTailFetch(),
);
} finally {
forcedTimelineTailReplacements.current.delete(agentId);
}
},
reportError: (error) => {
console.warn("[Session] viewed timeline synchronization failed", { serverId, error });
},
+6 -2
View File
@@ -4,6 +4,7 @@ import {
useQueries,
useQuery,
type QueryKey,
type QueryClient,
type UseQueryOptions,
type UseQueryResult,
} from "@tanstack/react-query";
@@ -47,8 +48,11 @@ export function useFetchQuery<
TError = Error,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
>(input: FetchQueryInput<TQueryFnData, TError, TData, TQueryKey>): UseQueryResult<TData, TError> {
return useQuery(fetchQueryOptions(input));
>(
input: FetchQueryInput<TQueryFnData, TError, TData, TQueryKey>,
queryClient?: QueryClient,
): UseQueryResult<TData, TError> {
return useQuery(fetchQueryOptions(input), queryClient);
}
export function useFetchQueries<TData>(
@@ -49,7 +49,7 @@ afterEach(() => {
});
describe("ensureAgentIsInitialized", () => {
it("requests a bounded projected tail when authoritative history is loaded", () => {
it("catches up after loaded authoritative history", () => {
const client = new FakeDaemonClient();
const runtime = new FakeTimelineRuntime();
useSessionStore.getState().initializeSession(serverId, client as never);
@@ -74,13 +74,14 @@ describe("ensureAgentIsInitialized", () => {
serverId,
agentId,
request: {
direction: "tail",
direction: "after",
cursor: { epoch: "epoch-1", seq: 42 },
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "projected",
},
},
]);
expect(getInitDeferred(getInitKey(serverId, agentId))?.requestDirection).toBe("tail");
expect(getInitDeferred(getInitKey(serverId, agentId))?.requestDirection).toBe("after");
});
it("requests a bounded projected tail when no authoritative cursor is available", () => {
@@ -127,6 +128,8 @@ describe("ensureAgentIsInitialized", () => {
timestamp: new Date("2026-07-27T10:00:00.000Z"),
},
],
range: null,
hasOlder: false,
},
});
@@ -151,6 +154,52 @@ describe("ensureAgentIsInitialized", () => {
]);
});
it("catches up after the restored canonical replica range", () => {
const client = new FakeDaemonClient();
const runtime = new FakeTimelineRuntime();
useSessionStore.getState().restoreSessionReplica(serverId, {
agents: new Map(),
workspaces: new Map(),
projects: new Map(),
timeline: {
agentId,
items: [
{
kind: "assistant_message",
id: "canonical-item",
text: "Canonical before restart",
timelineCursor: { epoch: "epoch-1", seq: 12 },
timestamp: new Date("2026-07-27T10:00:00.000Z"),
},
],
range: { epoch: "epoch-1", startSeq: 10, endSeq: 12 },
hasOlder: true,
},
});
void ensureAgentIsInitialized({
serverId,
agentId,
client: client as never,
runtime,
setAgentInitializing: bindSetAgentInitializing(),
});
expect(runtime.requests).toEqual([
{
serverId,
agentId,
request: {
direction: "after",
cursor: { epoch: "epoch-1", seq: 12 },
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "projected",
},
},
]);
expect(getInitDeferred(getInitKey(serverId, agentId))?.requestDirection).toBe("after");
});
it("times out initialization after 65 seconds", async () => {
vi.useFakeTimers();
const client = new FakeDaemonClient();
@@ -1,7 +1,7 @@
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { useSessionStore } from "@/stores/session-store";
import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store";
import {
createInitDeferred,
getInitDeferred,
@@ -11,7 +11,7 @@ import {
refreshInitTimeout,
} from "@/utils/agent-initialization";
import { getHostRuntimeStore, type HostRuntimeStore } from "@/runtime/host-runtime";
import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
import { planTimelineResumeFetch, planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
import { i18n } from "@/i18n/i18next";
export type SetAgentInitializing = (agentId: string, initializing: boolean) => void;
@@ -51,7 +51,10 @@ export function ensureAgentIsInitialized(input: EnsureAgentIsInitializedInput):
return existing.promise;
}
const timelineRequest = planTimelineTailFetch();
const timeline = selectAgentTimelineState(useSessionStore.getState().sessions[serverId], agentId);
const timelineRequest = planTimelineResumeFetch(
timeline.status === "synced" ? timeline.range : null,
);
const deferred = createInitDeferred(key, timelineRequest.direction);
refreshAgentInitializationTimeout({ key, agentId, setAgentInitializing });
+4
View File
@@ -1,4 +1,5 @@
import type { TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const ar: TranslationResources = {
common: {
@@ -1620,6 +1621,7 @@ export const ar: TranslationResources = {
archiveTooltip: "أرشفة الوكيل الفرعي",
archiveFinishedAction: "أرشفة الوكلاء الفرعيين المكتملين",
archiveFinishedTooltip: "أرشفة المكتملين",
archiveFinishedRetry: "إعادة المحاولة ({{failed}}/{{total}})",
},
panels: {
draft: {
@@ -1788,8 +1790,10 @@ export const ar: TranslationResources = {
providers: "مقدمي الخدمات",
usage: "الاستخدام",
terminals: "Terminals",
plugins: "Plugins",
host: "نظرة عامة",
},
plugins: pluginSettings.ar,
metadataGeneration: {
title: "إنشاء البيانات الوصفية",
description:
+49
View File
@@ -1631,6 +1631,7 @@ export const en = {
archiveTooltip: "Archive subagent",
archiveFinishedAction: "Archive finished subagents",
archiveFinishedTooltip: "Archive finished",
archiveFinishedRetry: "Retry ({{failed}}/{{total}})",
},
panels: {
draft: {
@@ -1799,8 +1800,56 @@ export const en = {
providers: "Providers",
usage: "Usage",
terminals: "Terminals",
plugins: "Plugins",
host: "Overview",
},
plugins: {
title: "Plugins",
trustedTitle: "Plugins are trusted code",
trustedDescription:
"They run on this host and inside the app without sandboxing. Install only code you trust.",
globalTitle: "Enable plugins",
globalHint: "Global switch for every configured plugin",
directoryLabel: "Plugin directory",
directoryPlaceholder: "/absolute/path/on/host",
idLabel: "Plugin installation ID",
idHint: "Leave blank to use paseo-plugin.json",
idPlaceholder: "Manifest default",
install: "Install directory",
installing: "Installing…",
status: { running: "running", disabled: "disabled", failed: "failed" },
actions: {
reload: "Reload",
reloading: "Reloading…",
enable: "Enable",
enabling: "Enabling…",
disable: "Disable",
disabling: "Disabling…",
remove: "Remove",
removing: "Removing…",
},
feedback: {
installed: "Installed {{id}}",
reload: "Reloaded {{id}}",
enable: "Enabled {{id}}",
disable: "Disabled {{id}}",
remove: "Removed {{id}}",
kept: "Kept {{id}}",
globalEnabled: "Plugins enabled",
globalDisabled: "Plugins disabled",
},
removeConfirmTitle: "Remove {{id}}?",
removeConfirmMessage: "This removes the configuration. The source directory is not deleted.",
states: {
offlineTitle: "Plugin host is offline",
offlineDescription: "Reconnect to this host to manage its plugins.",
updateTitle: "Update this host to manage plugins",
loading: "Loading plugins…",
errorTitle: "Unable to load plugins",
retry: "Retry",
empty: "No plugins configured",
},
},
metadataGeneration: {
title: "Metadata generation",
description:
+4
View File
@@ -1,4 +1,5 @@
import type { TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const es: TranslationResources = {
common: {
@@ -1663,6 +1664,7 @@ export const es: TranslationResources = {
archiveTooltip: "Subagente de archivo",
archiveFinishedAction: "Archivar subagentes finalizados",
archiveFinishedTooltip: "Archivar finalizados",
archiveFinishedRetry: "Reintentar ({{failed}}/{{total}})",
},
panels: {
draft: {
@@ -1831,8 +1833,10 @@ export const es: TranslationResources = {
providers: "Proveedores",
usage: "Uso",
terminals: "Terminals",
plugins: "Plugins",
host: "Resumen",
},
plugins: pluginSettings.es,
metadataGeneration: {
title: "Generación de metadatos",
description:
+4
View File
@@ -1,4 +1,5 @@
import type { TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const fr: TranslationResources = {
common: {
@@ -1667,6 +1668,7 @@ export const fr: TranslationResources = {
archiveTooltip: "Sous-agent d'archivage",
archiveFinishedAction: "Archiver les sous-agents terminés",
archiveFinishedTooltip: "Archiver les terminés",
archiveFinishedRetry: "Réessayer ({{failed}}/{{total}})",
},
panels: {
draft: {
@@ -1835,8 +1837,10 @@ export const fr: TranslationResources = {
providers: "Fournisseurs",
usage: "Utilisation",
terminals: "Terminals",
plugins: "Plugins",
host: "Aperçu",
},
plugins: pluginSettings.fr,
metadataGeneration: {
title: "Génération de métadonnées",
description:
+4
View File
@@ -1,4 +1,5 @@
import type { TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const ja: TranslationResources = {
common: {
@@ -1636,6 +1637,7 @@ export const ja: TranslationResources = {
archiveTooltip: "サブエージェントをアーカイブ",
archiveFinishedAction: "完了したサブエージェントをアーカイブ",
archiveFinishedTooltip: "完了した項目をアーカイブ",
archiveFinishedRetry: "再試行 ({{failed}}/{{total}})",
},
panels: {
draft: {
@@ -1804,8 +1806,10 @@ export const ja: TranslationResources = {
providers: "プロバイダー",
usage: "使用状況",
terminals: "ターミナル",
plugins: "プラグイン",
host: "概要",
},
plugins: pluginSettings.ja,
metadataGeneration: {
title: "メタデータ生成",
description:
+4
View File
@@ -1,4 +1,5 @@
import type { TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const ko: TranslationResources = {
common: {
@@ -1630,6 +1631,7 @@ export const ko: TranslationResources = {
archiveTooltip: "서브에이전트 보관",
archiveFinishedAction: "완료된 하위 에이전트 보관",
archiveFinishedTooltip: "아카이브 완료",
archiveFinishedRetry: "다시 시도 ({{failed}}/{{total}})",
},
panels: {
draft: {
@@ -1798,8 +1800,10 @@ export const ko: TranslationResources = {
providers: "프로바이더",
usage: "사용량",
terminals: "터미널",
plugins: "플러그인",
host: "개요",
},
plugins: pluginSettings.ko,
metadataGeneration: {
title: "메타데이터 생성",
description:
@@ -0,0 +1,380 @@
const shared = {
directoryPlaceholder: "/absolute/path/on/host",
} as const;
export const pluginSettings = {
ar: {
...shared,
title: "الإضافات",
trustedTitle: "الإضافات تعليمات برمجية موثوقة",
trustedDescription:
"تعمل على هذا المضيف وداخل التطبيق دون عزل. ثبّت فقط التعليمات البرمجية التي تثق بها.",
globalTitle: "تفعيل الإضافات",
globalHint: "مفتاح عام لكل الإضافات المضبوطة",
directoryLabel: "مجلد الإضافة",
idLabel: "معرّف تثبيت الإضافة",
idHint: "اتركه فارغًا لاستخدام paseo-plugin.json",
idPlaceholder: "القيمة الافتراضية للبيان",
install: "تثبيت المجلد",
installing: "جارٍ التثبيت…",
status: { running: "قيد التشغيل", disabled: "معطّلة", failed: "فشلت" },
actions: {
reload: "إعادة تحميل",
reloading: "جارٍ إعادة التحميل…",
enable: "تفعيل",
enabling: "جارٍ التفعيل…",
disable: "تعطيل",
disabling: "جارٍ التعطيل…",
remove: "إزالة",
removing: "جارٍ الإزالة…",
},
feedback: {
installed: "تم تثبيت {{id}}",
reload: "تمت إعادة تحميل {{id}}",
enable: "تم تفعيل {{id}}",
disable: "تم تعطيل {{id}}",
remove: "تمت إزالة {{id}}",
kept: "تم الإبقاء على {{id}}",
globalEnabled: "تم تفعيل الإضافات",
globalDisabled: "تم تعطيل الإضافات",
},
removeConfirmTitle: "إزالة {{id}}؟",
removeConfirmMessage: "يؤدي هذا إلى إزالة الإعداد فقط. لن يُحذف مجلد المصدر.",
states: {
offlineTitle: "مضيف الإضافات غير متصل",
offlineDescription: "أعد الاتصال بهذا المضيف لإدارة إضافاته.",
updateTitle: "حدّث هذا المضيف لإدارة الإضافات",
loading: "جارٍ تحميل الإضافات…",
errorTitle: "تعذّر تحميل الإضافات",
retry: "إعادة المحاولة",
empty: "لا توجد إضافات مضبوطة",
},
},
es: {
...shared,
title: "Plugins",
trustedTitle: "Los plugins son código de confianza",
trustedDescription:
"Se ejecutan en este host y dentro de la app sin aislamiento. Instala solo código de confianza.",
globalTitle: "Activar plugins",
globalHint: "Interruptor global para todos los plugins configurados",
directoryLabel: "Directorio del plugin",
idLabel: "ID de instalación del plugin",
idHint: "Déjalo vacío para usar paseo-plugin.json",
idPlaceholder: "Valor del manifiesto",
install: "Instalar directorio",
installing: "Instalando…",
status: { running: "en ejecución", disabled: "desactivado", failed: "fallido" },
actions: {
reload: "Recargar",
reloading: "Recargando…",
enable: "Activar",
enabling: "Activando…",
disable: "Desactivar",
disabling: "Desactivando…",
remove: "Eliminar",
removing: "Eliminando…",
},
feedback: {
installed: "{{id}} instalado",
reload: "{{id}} recargado",
enable: "{{id}} activado",
disable: "{{id}} desactivado",
remove: "{{id}} eliminado",
kept: "Se conservó {{id}}",
globalEnabled: "Plugins activados",
globalDisabled: "Plugins desactivados",
},
removeConfirmTitle: "¿Eliminar {{id}}?",
removeConfirmMessage: "Esto elimina la configuración. El directorio de origen no se borra.",
states: {
offlineTitle: "El host de plugins está sin conexión",
offlineDescription: "Vuelve a conectar este host para gestionar sus plugins.",
updateTitle: "Actualiza este host para gestionar plugins",
loading: "Cargando plugins…",
errorTitle: "No se pudieron cargar los plugins",
retry: "Reintentar",
empty: "No hay plugins configurados",
},
},
fr: {
...shared,
title: "Plugins",
trustedTitle: "Les plugins sont du code de confiance",
trustedDescription:
"Ils s’exécutent sur cet hôte et dans l’app sans isolation. N’installez que du code fiable.",
globalTitle: "Activer les plugins",
globalHint: "Interrupteur global pour tous les plugins configurés",
directoryLabel: "Dossier du plugin",
idLabel: "ID d’installation du plugin",
idHint: "Laissez vide pour utiliser paseo-plugin.json",
idPlaceholder: "Valeur du manifeste",
install: "Installer le dossier",
installing: "Installation…",
status: { running: "actif", disabled: "désactivé", failed: "échec" },
actions: {
reload: "Recharger",
reloading: "Rechargement…",
enable: "Activer",
enabling: "Activation…",
disable: "Désactiver",
disabling: "Désactivation…",
remove: "Supprimer",
removing: "Suppression…",
},
feedback: {
installed: "{{id}} installé",
reload: "{{id}} rechargé",
enable: "{{id}} activé",
disable: "{{id}} désactivé",
remove: "{{id}} supprimé",
kept: "{{id}} conservé",
globalEnabled: "Plugins activés",
globalDisabled: "Plugins désactivés",
},
removeConfirmTitle: "Supprimer {{id}} ?",
removeConfirmMessage: "La configuration sera supprimée. Le dossier source ne sera pas effacé.",
states: {
offlineTitle: "L’hôte des plugins est hors ligne",
offlineDescription: "Reconnectez cet hôte pour gérer ses plugins.",
updateTitle: "Mettez cet hôte à jour pour gérer les plugins",
loading: "Chargement des plugins…",
errorTitle: "Impossible de charger les plugins",
retry: "Réessayer",
empty: "Aucun plugin configuré",
},
},
ja: {
...shared,
title: "プラグイン",
trustedTitle: "プラグインは信頼済みコードです",
trustedDescription:
"ホストとアプリ内で分離されずに実行されます。信頼できるコードのみインストールしてください。",
globalTitle: "プラグインを有効化",
globalHint: "設定済みプラグインすべての共通スイッチ",
directoryLabel: "プラグインディレクトリ",
idLabel: "プラグインのインストール ID",
idHint: "paseo-plugin.json を使う場合は空欄にします",
idPlaceholder: "マニフェストの既定値",
install: "ディレクトリをインストール",
installing: "インストール中…",
status: { running: "実行中", disabled: "無効", failed: "失敗" },
actions: {
reload: "再読み込み",
reloading: "再読み込み中…",
enable: "有効化",
enabling: "有効化中…",
disable: "無効化",
disabling: "無効化中…",
remove: "削除",
removing: "削除中…",
},
feedback: {
installed: "{{id}} をインストールしました",
reload: "{{id}} を再読み込みしました",
enable: "{{id}} を有効にしました",
disable: "{{id}} を無効にしました",
remove: "{{id}} を削除しました",
kept: "{{id}} を保持しました",
globalEnabled: "プラグインを有効にしました",
globalDisabled: "プラグインを無効にしました",
},
removeConfirmTitle: "{{id}} を削除しますか?",
removeConfirmMessage: "設定のみを削除します。ソースディレクトリは削除されません。",
states: {
offlineTitle: "プラグインホストはオフラインです",
offlineDescription: "プラグインを管理するにはホストに再接続してください。",
updateTitle: "プラグインを管理するにはホストを更新してください",
loading: "プラグインを読み込み中…",
errorTitle: "プラグインを読み込めません",
retry: "再試行",
empty: "設定済みプラグインはありません",
},
},
ko: {
...shared,
title: "플러그인",
trustedTitle: "플러그인은 신뢰하는 코드입니다",
trustedDescription: "호스트와 앱 안에서 격리 없이 실행됩니다. 신뢰하는 코드만 설치하세요.",
globalTitle: "플러그인 활성화",
globalHint: "설정된 모든 플러그인의 전역 스위치",
directoryLabel: "플러그인 디렉터리",
idLabel: "플러그인 설치 ID",
idHint: "paseo-plugin.json을 사용하려면 비워 두세요",
idPlaceholder: "매니페스트 기본값",
install: "디렉터리 설치",
installing: "설치 중…",
status: { running: "실행 중", disabled: "비활성화", failed: "실패" },
actions: {
reload: "다시 로드",
reloading: "다시 로드 중…",
enable: "활성화",
enabling: "활성화 중…",
disable: "비활성화",
disabling: "비활성화 중…",
remove: "제거",
removing: "제거 중…",
},
feedback: {
installed: "{{id}} 설치됨",
reload: "{{id}} 다시 로드됨",
enable: "{{id}} 활성화됨",
disable: "{{id}} 비활성화됨",
remove: "{{id}} 제거됨",
kept: "{{id}} 유지됨",
globalEnabled: "플러그인 활성화됨",
globalDisabled: "플러그인 비활성화됨",
},
removeConfirmTitle: "{{id}}을(를) 제거할까요?",
removeConfirmMessage: "설정만 제거합니다. 소스 디렉터리는 삭제되지 않습니다.",
states: {
offlineTitle: "플러그인 호스트가 오프라인입니다",
offlineDescription: "플러그인을 관리하려면 호스트에 다시 연결하세요.",
updateTitle: "플러그인을 관리하려면 호스트를 업데이트하세요",
loading: "플러그인 로드 중…",
errorTitle: "플러그인을 로드할 수 없습니다",
retry: "다시 시도",
empty: "설정된 플러그인이 없습니다",
},
},
"pt-BR": {
...shared,
title: "Plugins",
trustedTitle: "Plugins são código confiável",
trustedDescription:
"Eles rodam neste host e dentro do app sem isolamento. Instale apenas código confiável.",
globalTitle: "Ativar plugins",
globalHint: "Controle global para todos os plugins configurados",
directoryLabel: "Diretório do plugin",
idLabel: "ID de instalação do plugin",
idHint: "Deixe vazio para usar paseo-plugin.json",
idPlaceholder: "Padrão do manifesto",
install: "Instalar diretório",
installing: "Instalando…",
status: { running: "em execução", disabled: "desativado", failed: "falhou" },
actions: {
reload: "Recarregar",
reloading: "Recarregando…",
enable: "Ativar",
enabling: "Ativando…",
disable: "Desativar",
disabling: "Desativando…",
remove: "Remover",
removing: "Removendo…",
},
feedback: {
installed: "{{id}} instalado",
reload: "{{id}} recarregado",
enable: "{{id}} ativado",
disable: "{{id}} desativado",
remove: "{{id}} removido",
kept: "{{id}} mantido",
globalEnabled: "Plugins ativados",
globalDisabled: "Plugins desativados",
},
removeConfirmTitle: "Remover {{id}}?",
removeConfirmMessage: "Isso remove a configuração. O diretório de origem não será excluído.",
states: {
offlineTitle: "O host de plugins está offline",
offlineDescription: "Reconecte este host para gerenciar seus plugins.",
updateTitle: "Atualize este host para gerenciar plugins",
loading: "Carregando plugins…",
errorTitle: "Não foi possível carregar os plugins",
retry: "Tentar novamente",
empty: "Nenhum plugin configurado",
},
},
ru: {
...shared,
title: "Плагины",
trustedTitle: "Плагины — доверенный код",
trustedDescription:
"Они выполняются на хосте и в приложении без изоляции. Устанавливайте только доверенный код.",
globalTitle: "Включить плагины",
globalHint: "Общий переключатель для всех настроенных плагинов",
directoryLabel: "Каталог плагина",
idLabel: "ID установки плагина",
idHint: "Оставьте пустым, чтобы использовать paseo-plugin.json",
idPlaceholder: "Значение из манифеста",
install: "Установить каталог",
installing: "Установка…",
status: { running: "работает", disabled: "отключён", failed: "ошибка" },
actions: {
reload: "Перезагрузить",
reloading: "Перезагрузка…",
enable: "Включить",
enabling: "Включение…",
disable: "Отключить",
disabling: "Отключение…",
remove: "Удалить",
removing: "Удаление…",
},
feedback: {
installed: "{{id}} установлен",
reload: "{{id}} перезагружен",
enable: "{{id}} включён",
disable: "{{id}} отключён",
remove: "{{id}} удалён",
kept: "{{id}} сохранён",
globalEnabled: "Плагины включены",
globalDisabled: "Плагины отключены",
},
removeConfirmTitle: "Удалить {{id}}?",
removeConfirmMessage: "Конфигурация будет удалена. Исходный каталог останется на месте.",
states: {
offlineTitle: "Хост плагинов не в сети",
offlineDescription: "Подключите хост снова, чтобы управлять плагинами.",
updateTitle: "Обновите хост для управления плагинами",
loading: "Загрузка плагинов…",
errorTitle: "Не удалось загрузить плагины",
retry: "Повторить",
empty: "Нет настроенных плагинов",
},
},
"zh-CN": {
...shared,
title: "插件",
trustedTitle: "插件是受信任的代码",
trustedDescription: "它们会在此主机和应用内运行,且没有隔离。请仅安装你信任的代码。",
globalTitle: "启用插件",
globalHint: "所有已配置插件的全局开关",
directoryLabel: "插件目录",
idLabel: "插件安装 ID",
idHint: "留空以使用 paseo-plugin.json",
idPlaceholder: "清单默认值",
install: "安装目录",
installing: "正在安装…",
status: { running: "运行中", disabled: "已禁用", failed: "失败" },
actions: {
reload: "重新加载",
reloading: "正在重新加载…",
enable: "启用",
enabling: "正在启用…",
disable: "禁用",
disabling: "正在禁用…",
remove: "移除",
removing: "正在移除…",
},
feedback: {
installed: "已安装 {{id}}",
reload: "已重新加载 {{id}}",
enable: "已启用 {{id}}",
disable: "已禁用 {{id}}",
remove: "已移除 {{id}}",
kept: "已保留 {{id}}",
globalEnabled: "插件已启用",
globalDisabled: "插件已禁用",
},
removeConfirmTitle: "移除 {{id}}?",
removeConfirmMessage: "这会移除配置,但不会删除源目录。",
states: {
offlineTitle: "插件主机离线",
offlineDescription: "重新连接此主机以管理其插件。",
updateTitle: "更新此主机以管理插件",
loading: "正在加载插件…",
errorTitle: "无法加载插件",
retry: "重试",
empty: "没有已配置的插件",
},
},
} as const;
+4
View File
@@ -1,4 +1,5 @@
import type { TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const ptBR: TranslationResources = {
common: {
@@ -1649,6 +1650,7 @@ export const ptBR: TranslationResources = {
archiveTooltip: "Arquivar subagente",
archiveFinishedAction: "Arquivar subagentes concluídos",
archiveFinishedTooltip: "Arquivar concluídos",
archiveFinishedRetry: "Tentar novamente ({{failed}}/{{total}})",
},
panels: {
draft: {
@@ -1817,8 +1819,10 @@ export const ptBR: TranslationResources = {
providers: "Provedores",
usage: "Uso",
terminals: "Terminais",
plugins: "Plugins",
host: "Visão geral",
},
plugins: pluginSettings["pt-BR"],
metadataGeneration: {
title: "Geração de metadados",
description:
+4
View File
@@ -1,4 +1,5 @@
import type { TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const ru: TranslationResources = {
common: {
@@ -1654,6 +1655,7 @@ export const ru: TranslationResources = {
archiveTooltip: "Архивный субагент",
archiveFinishedAction: "Архивировать завершенные субагенты",
archiveFinishedTooltip: "Архивировать завершенные",
archiveFinishedRetry: "Повторить ({{failed}}/{{total}})",
},
panels: {
draft: {
@@ -1822,8 +1824,10 @@ export const ru: TranslationResources = {
providers: "Провайдеры",
usage: "Использование",
terminals: "Terminals",
plugins: "Plugins",
host: "Обзор",
},
plugins: pluginSettings.ru,
metadataGeneration: {
title: "Создание метаданных",
description:
+4
View File
@@ -1,4 +1,5 @@
import type { TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const zhCN: TranslationResources = {
common: {
@@ -1600,6 +1601,7 @@ export const zhCN: TranslationResources = {
archiveTooltip: "归档 subagent",
archiveFinishedAction: "归档已完成的 subagent",
archiveFinishedTooltip: "归档已完成项",
archiveFinishedRetry: "重试 ({{failed}}/{{total}})",
},
panels: {
draft: {
@@ -1768,8 +1770,10 @@ export const zhCN: TranslationResources = {
providers: "Providers",
usage: "使用情况",
terminals: "Terminals",
plugins: "插件",
host: "概览",
},
plugins: pluginSettings["zh-CN"],
metadataGeneration: {
title: "元数据生成",
description: "选择 Paseo 用于工作区标题、分支名称、提交消息和拉取请求草稿的模型",
+5 -3
View File
@@ -88,7 +88,7 @@ import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import type { Theme } from "@/styles/theme";
import {
useHideFinishedProviderSubagents,
useArchiveFinishedSubagents,
useArchiveSubagent,
useDetachSubagent,
useSubagentsForParent,
@@ -1532,9 +1532,10 @@ function ActiveAgentComposer({
);
const handleArchiveSubagent = useArchiveSubagent({ serverId });
const handleDetachSubagent = useDetachSubagent({ serverId });
const handleHideFinishedProviderSubagents = useHideFinishedProviderSubagents({
const archiveFinishedSubagents = useArchiveFinishedSubagents({
serverId,
parentAgentId: agentId,
rows: subagentRows,
});
const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({
serverId,
@@ -1628,7 +1629,8 @@ function ActiveAgentComposer({
onOpenSubagent={handleOpenSubagent}
onOpenProviderSubagent={handleOpenProviderSubagent}
onArchiveSubagent={handleArchiveSubagent}
onArchiveFinished={handleHideFinishedProviderSubagents}
onArchiveFinished={archiveFinishedSubagents.archiveFinished}
archiveFinishedStatus={archiveFinishedSubagents.status}
onDetachSubagent={canDetachSubagents ? handleDetachSubagent : undefined}
/>
<Composer
@@ -0,0 +1,5 @@
export {
PluginResourceComposerAttachmentSchema,
pluginResourceAttachmentToAgentAttachment,
type PluginResourceComposerAttachment,
} from "./model";
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import {
createPluginResourceAttachment,
PluginResourceComposerAttachmentSchema,
pluginResourceAttachmentToAgentAttachment,
togglePluginResourceAttachment,
} from "./model";
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
const source = {
pluginId: "linear",
sourceId: "issues",
sourceTitle: "Linear issue",
sourceIcon: "CircleDot",
};
const item = {
id: "issue-uuid",
identifier: "ENG-123",
title: "Plugin attachments",
subtitle: "In progress · Mohamed",
url: "https://linear.app/acme/issue/ENG-123/plugin-attachments",
text: "Linear issue ENG-123: Plugin attachments\nStatus: In progress",
resourceType: "issue",
};
describe("plugin resource attachments", () => {
it("creates a draft-safe composer attachment", () => {
const attachment = createPluginResourceAttachment(source, item);
expect(PluginResourceComposerAttachmentSchema.parse(attachment)).toEqual({
kind: "plugin_resource",
...source,
item,
});
});
it("toggles one resource by plugin, source, and stable item id", () => {
const attachment = createPluginResourceAttachment(source, item);
expect(togglePluginResourceAttachment([], attachment)).toEqual([attachment]);
expect(togglePluginResourceAttachment([attachment], attachment)).toEqual([]);
});
it("submits through the backward-compatible text attachment", () => {
const attachment = createPluginResourceAttachment(source, item);
const agentAttachment = pluginResourceAttachmentToAgentAttachment(attachment);
expect(agentAttachment).toEqual({
type: "text",
mimeType: "text/plain",
title: "ENG-123 Plugin attachments",
text: item.text,
externalResource: {
provider: "linear",
providerLabel: "Linear issue",
resourceType: "issue",
id: "issue-uuid",
identifier: "ENG-123",
title: "Plugin attachments",
url: item.url,
},
});
expect(splitComposerAttachmentsForSubmit([attachment])).toEqual({
images: [],
attachments: [agentAttachment],
});
});
});
@@ -0,0 +1,70 @@
import { z } from "zod";
import type { AgentAttachment } from "@getpaseo/protocol/messages";
import { PluginAttachmentItemSchema, type PluginAttachmentItem } from "@paseo/plugin";
import type { UserComposerAttachment } from "@/attachments/types";
export const PluginResourceComposerAttachmentSchema = z.object({
kind: z.literal("plugin_resource"),
pluginId: z.string().min(1),
sourceId: z.string().min(1),
sourceTitle: z.string().min(1),
sourceIcon: z.string().min(1),
item: PluginAttachmentItemSchema,
});
export type PluginResourceComposerAttachment = z.infer<
typeof PluginResourceComposerAttachmentSchema
>;
export interface PluginResourceSourceIdentity {
pluginId: string;
sourceId: string;
sourceTitle: string;
sourceIcon: string;
}
export function createPluginResourceAttachment(
source: PluginResourceSourceIdentity,
item: PluginAttachmentItem,
): PluginResourceComposerAttachment {
return {
kind: "plugin_resource",
...source,
item,
};
}
export function togglePluginResourceAttachment(
current: UserComposerAttachment[],
attachment: PluginResourceComposerAttachment,
): UserComposerAttachment[] {
const matches = (candidate: UserComposerAttachment) =>
candidate.kind === "plugin_resource" &&
candidate.pluginId === attachment.pluginId &&
candidate.sourceId === attachment.sourceId &&
candidate.item.id === attachment.item.id;
if (current.some(matches)) {
return current.filter((candidate) => !matches(candidate));
}
return [...current, attachment];
}
export function pluginResourceAttachmentToAgentAttachment(
attachment: PluginResourceComposerAttachment,
): AgentAttachment {
return {
type: "text",
mimeType: "text/plain",
title: `${attachment.item.identifier} ${attachment.item.title}`,
text: attachment.item.text,
externalResource: {
provider: attachment.pluginId,
providerLabel: attachment.sourceTitle,
resourceType: attachment.item.resourceType,
id: attachment.item.id,
identifier: attachment.item.identifier,
title: attachment.item.title,
url: attachment.item.url,
},
};
}
@@ -0,0 +1,179 @@
import { useCallback, useMemo, useState, type ReactElement, type RefObject } from "react";
import { View } from "react-native";
import { withUnistyles } from "react-native-unistyles";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { PluginAttachmentItem, PluginAttachmentSourceContribution } from "@paseo/plugin";
import { searchPluginAttachments } from "@paseo/plugin/host";
import type { LucideIcon } from "lucide-react-native";
import type { UserComposerAttachment } from "@/attachments/types";
import type { AttachmentMenuItem } from "@/composer/input/input";
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
import { useFetchQuery } from "@/data/query";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { resolvePluginIcon } from "../icons";
import { useInstalledPlugins } from "../registry";
import type { InstalledPlugin } from "../types";
import { createPluginResourceAttachment, togglePluginResourceAttachment } from "./model";
const SEARCH_STALE_TIME_MS = 30_000;
const EMPTY_ATTACHMENT_ITEMS: PluginAttachmentItem[] = [];
const iconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
function SourceIcon({ Icon, color = "" }: { Icon: LucideIcon; color?: string }) {
return <Icon size={ICON_SIZE.md} color={color} />;
}
const ThemedSourceIcon = withUnistyles(SourceIcon);
interface InstalledAttachmentSource {
plugin: InstalledPlugin;
source: PluginAttachmentSourceContribution;
key: string;
}
interface PluginAttachmentPickerInput {
serverId: string;
client: DaemonClient | null;
connected: boolean;
attachments: UserComposerAttachment[];
onChangeAttachments: (attachments: UserComposerAttachment[]) => void;
anchorRef: RefObject<View | null>;
}
interface PluginAttachmentPickerBinding {
menuItems: AttachmentMenuItem[];
picker: ReactElement | null;
}
function searchEmptyText(error: unknown, isFetching: boolean): string {
if (error instanceof Error) return error.message;
if (error) return String(error);
return isFetching ? "Searching..." : "No results";
}
function installedAttachmentSources(
plugins: InstalledPlugin[],
serverId: string,
): InstalledAttachmentSource[] {
return plugins
.filter((plugin) => plugin.serverId === serverId)
.flatMap((plugin) =>
plugin.attachmentSources.map((source) => ({
plugin,
source,
key: `${plugin.id}/${source.id}`,
})),
);
}
function attachmentOptions(items: PluginAttachmentItem[]): ComboboxOption[] {
return items.map((item) => ({
id: item.id,
label: `${item.identifier} ${item.title}`,
description: item.subtitle,
}));
}
export function usePluginAttachmentPicker(
input: PluginAttachmentPickerInput,
): PluginAttachmentPickerBinding {
const plugins = useInstalledPlugins();
const sources = useMemo(
() => installedAttachmentSources(plugins, input.serverId),
[input.serverId, plugins],
);
const [activeKey, setActiveKey] = useState<string | null>(null);
const [query, setQuery] = useState("");
const active = sources.find((candidate) => candidate.key === activeKey) ?? null;
const trimmedQuery = query.trim();
const search = useFetchQuery(
{
queryKey: [
"plugin-attachment-search",
input.serverId,
active?.plugin.id ?? "",
active?.source.id ?? "",
trimmedQuery,
],
queryFn: async () => {
if (!input.client || !active) throw new Error("Plugin host is offline");
const client = input.client;
return searchPluginAttachments(
active.source,
(method, rpcInput) => client.invokePluginRpc(active.plugin.id, method, rpcInput),
trimmedQuery,
);
},
enabled: input.connected && active !== null,
dataShape: "list",
staleTimeMs: SEARCH_STALE_TIME_MS,
},
active?.plugin.queryClient,
);
const items = search.error
? EMPTY_ATTACHMENT_ITEMS
: (search.data?.items ?? EMPTY_ATTACHMENT_ITEMS);
const options = useMemo(() => attachmentOptions(items), [items]);
const close = useCallback(() => {
setActiveKey(null);
setQuery("");
}, []);
const handleOpenChange = useCallback(
(open: boolean) => {
if (!open) close();
},
[close],
);
const handleSelect = useCallback(
(itemId: string) => {
if (!active) return;
const item = items.find((candidate) => candidate.id === itemId);
if (!item) return;
const attachment = createPluginResourceAttachment(
{
pluginId: active.plugin.id,
sourceId: active.source.id,
sourceTitle: active.source.title,
sourceIcon: active.source.icon,
},
item,
);
input.onChangeAttachments(togglePluginResourceAttachment(input.attachments, attachment));
close();
},
[active, close, input, items],
);
const menuItems = useMemo(
() =>
sources.map(({ key, source }) => {
const Icon = resolvePluginIcon(source.icon);
return {
id: `plugin:${key}`,
label: `Attach ${source.title}`,
icon: <ThemedSourceIcon Icon={Icon} uniProps={iconColorMapping} />,
onSelect: () => setActiveKey(key),
};
}),
[sources],
);
if (!active) return { menuItems, picker: null };
return {
menuItems,
picker: (
<Combobox
options={options}
value=""
onSelect={handleSelect}
searchable
searchPlaceholder={active.source.searchPlaceholder}
title={active.source.pickerTitle}
open
onOpenChange={handleOpenChange}
onSearchQueryChange={setQuery}
desktopPlacement="top-start"
anchorRef={input.anchorRef}
emptyText={searchEmptyText(search.error, search.isFetching)}
/>
),
};
}
@@ -0,0 +1,65 @@
import { useCallback, useMemo } from "react";
import { withUnistyles } from "react-native-unistyles";
import { AttachmentLabel, AttachmentPill } from "@/components/attachment-pill";
import type { ComposerAttachment } from "@/attachments/types";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { resolvePluginIcon } from "../icons";
import type { PluginResourceComposerAttachment } from "./model";
const iconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
function ResourceIcon({
Icon,
color = "",
}: {
Icon: ReturnType<typeof resolvePluginIcon>;
color?: string;
}) {
return <Icon size={ICON_SIZE.sm} color={color} />;
}
const ThemedResourceIcon = withUnistyles(ResourceIcon);
interface PluginResourceAttachmentPillProps {
attachment: PluginResourceComposerAttachment;
index: number;
disabled: boolean;
onOpen: (attachment: ComposerAttachment) => void;
onRemove: (index: number) => void;
openLabel: (kind: string, identifier: string) => string;
removeLabel: (kind: string, identifier: string) => string;
}
export function PluginResourceAttachmentPill({
attachment,
index,
disabled,
onOpen,
onRemove,
openLabel,
removeLabel,
}: PluginResourceAttachmentPillProps) {
const Icon = resolvePluginIcon(attachment.sourceIcon);
const handleOpen = useCallback(() => onOpen(attachment), [attachment, onOpen]);
const handleRemove = useCallback(() => onRemove(index), [index, onRemove]);
const icon = useMemo(
() => <ThemedResourceIcon Icon={Icon} uniProps={iconColorMapping} />,
[Icon],
);
return (
<AttachmentPill
testID="composer-plugin-resource-attachment-pill"
onOpen={handleOpen}
onRemove={handleRemove}
openAccessibilityLabel={openLabel(attachment.sourceTitle, attachment.item.identifier)}
removeAccessibilityLabel={removeLabel(attachment.sourceTitle, attachment.item.identifier)}
disabled={disabled}
>
<AttachmentLabel
icon={icon}
title={attachment.item.title}
subtitle={`${attachment.sourceTitle} ${attachment.item.identifier}`}
/>
</AttachmentPill>
);
}
@@ -0,0 +1,91 @@
/**
* @vitest-environment jsdom
*/
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
const connection = vi.hoisted(() => ({ connected: true, supported: true }));
vi.mock("@/runtime/host-runtime", () => ({
useHostRuntimeIsConnected: () => connection.connected,
}));
vi.mock("@/runtime/host-features", () => ({
useHostFeature: () => connection.supported,
}));
import { PluginCatalogSync } from "./catalog-sync";
import { pluginRegistry } from "./registry";
function bundle(): string {
return `(function() { return { default: function(plugin) {
plugin.addSurface("main", function() { return null; });
return function() { globalThis.__catalogSyncCleanups = (globalThis.__catalogSyncCleanups || 0) + 1; };
} }; })`;
}
const unsubscribe = vi.fn();
const catalogClient = {
getPluginCatalog: vi.fn(async () => [{ id: "example", clientBundle: bundle() }]),
on: vi.fn(() => unsubscribe),
} as unknown as DaemonClient;
describe("PluginCatalogSync", () => {
let container: HTMLElement;
let root: Root;
beforeEach(() => {
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
connection.connected = true;
connection.supported = true;
unsubscribe.mockReset();
vi.mocked(catalogClient.getPluginCatalog).mockClear();
vi.mocked(catalogClient.on).mockClear();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
pluginRegistry.removeHost("host-a");
container.remove();
Reflect.deleteProperty(globalThis, "__catalogSyncCleanups");
vi.unstubAllGlobals();
});
async function renderSync(): Promise<void> {
await act(async () => {
root.render(<PluginCatalogSync serverId="host-a" client={catalogClient} />);
await Promise.resolve();
});
}
it("tears down through disconnect and unmount boundaries exactly once each", async () => {
await renderSync();
const first = pluginRegistry.getSnapshot()[0];
expect(first?.id).toBe("example");
first?.queryClient.setQueryData(["owned"], "state");
connection.connected = false;
await renderSync();
expect(Reflect.get(globalThis, "__catalogSyncCleanups")).toBe(1);
expect(first?.queryClient.getQueryCache().getAll()).toEqual([]);
expect(pluginRegistry.getSnapshot()).toEqual([]);
connection.connected = true;
await renderSync();
expect(pluginRegistry.getSnapshot()).toHaveLength(1);
act(() => root.unmount());
root = createRoot(container);
expect(Reflect.get(globalThis, "__catalogSyncCleanups")).toBe(2);
expect(pluginRegistry.getSnapshot()).toEqual([]);
expect(unsubscribe).toHaveBeenCalledTimes(2);
});
});
+62
View File
@@ -0,0 +1,62 @@
import { useEffect } from "react";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { useHostFeature } from "@/runtime/host-features";
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { pluginRegistry } from "./registry";
export function PluginCatalogSync({
serverId,
client,
}: {
serverId: string;
client: DaemonClient;
}) {
const connected = useHostRuntimeIsConnected(serverId);
const supported = useHostFeature(serverId, "plugins");
useEffect(() => {
let cancelled = false;
let refreshQueue = Promise.resolve();
if (!supported) {
pluginRegistry.removeHost(serverId);
return;
}
if (!connected) {
pluginRegistry.removeHost(serverId);
return;
}
const refresh = (replacePluginId?: string) => {
refreshQueue = refreshQueue.then(() =>
client
.getPluginCatalog()
.then((catalog) => {
if (!cancelled) {
pluginRegistry.installCatalog(serverId, catalog, { replacePluginId });
}
return undefined;
})
.catch((error) => {
if (!cancelled) {
console.warn(`[Plugins] Failed to load catalog for ${serverId}`, error);
}
return undefined;
}),
);
return refreshQueue;
};
void refresh();
const unsubscribe = client.on("status", (message) => {
if (message.payload.status === "plugin_catalog_changed") {
const pluginId = message.payload.pluginId;
if (typeof pluginId === "string") void refresh(pluginId);
}
});
return () => {
cancelled = true;
unsubscribe();
};
}, [client, connected, serverId, supported]);
useEffect(() => () => pluginRegistry.removeHost(serverId), [serverId]);
return null;
}
+111
View File
@@ -0,0 +1,111 @@
import { describe, expect, it } from "vitest";
import { evaluatePluginClientBundle } from "./evaluate";
function bundle(body: string): string {
return `(function() {
const module = { exports: {} };
module.exports.default = function(plugin) { ${body}; return function() {}; };
return module.exports;
})`;
}
describe("evaluatePluginClientBundle", () => {
it("collects a surface and its sidebar placement", () => {
const plugin = evaluatePluginClientBundle(
"example",
bundle(`
function Surface() { return null; }
plugin.addSurface("main", Surface);
plugin.addSidebarItem({ id: "main", title: "Example", icon: "Blocks", surface: "main" });
`),
);
expect(plugin.id).toBe("example");
expect(plugin.surfaces.map((surface) => surface.id)).toEqual(["main"]);
expect(plugin.sidebarItems).toEqual([
{ id: "main", title: "Example", icon: "Blocks", surface: "main" },
]);
});
it("collects a declarative attachment source", () => {
const plugin = evaluatePluginClientBundle(
"linear",
bundle(`
plugin.addAttachmentSource({
id: "issues",
title: "Linear issue",
icon: "CircleDot",
pickerTitle: "Attach Linear issue",
searchPlaceholder: "Search by identifier or title",
search: { name: "issues.search", input: {}, output: {} },
});
`),
);
expect(plugin.attachmentSources).toEqual([
{
id: "issues",
title: "Linear issue",
icon: "CircleDot",
pickerTitle: "Attach Linear issue",
searchPlaceholder: "Search by identifier or title",
search: { name: "issues.search", input: {}, output: {} },
},
]);
});
it("rejects duplicate attachment source ids", () => {
expect(() =>
evaluatePluginClientBundle(
"linear",
bundle(`
const source = {
id: "issues",
title: "Linear issue",
icon: "CircleDot",
pickerTitle: "Attach Linear issue",
searchPlaceholder: "Search",
search: { name: "issues.search", input: {}, output: {} },
};
plugin.addAttachmentSource(source);
plugin.addAttachmentSource(source);
`),
),
).toThrow("Duplicate attachment source: issues");
});
it("rejects a sidebar placement whose surface does not exist", () => {
expect(() =>
evaluatePluginClientBundle(
"example",
bundle(`
plugin.addSidebarItem({ id: "main", title: "Example", icon: "Blocks", surface: "missing" });
`),
),
).toThrow("references missing surface missing");
});
it("rejects a bundle without a default contribution function", () => {
expect(() => evaluatePluginClientBundle("example", `(function() { return {}; })`)).toThrow(
"must default export a function",
);
});
it("requires a cleanup function", () => {
expect(() =>
evaluatePluginClientBundle("example", `(function() { return { default: function() {} }; })`),
).toThrow("must return a cleanup function");
});
it("does not publish partial contributions when setup fails", () => {
expect(() =>
evaluatePluginClientBundle(
"example",
`(function() { return { default: function(plugin) {
plugin.addSurface("main", function() { return null; });
throw new Error("setup exploded");
} }; })`,
),
).toThrow("setup exploded");
});
});
+135
View File
@@ -0,0 +1,135 @@
import * as React from "react";
import * as ReactJsxRuntime from "react/jsx-runtime";
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 {
defineAttachmentSource,
defineRpc,
type PluginAttachmentSourceContribution,
type PluginSidebarContribution,
type PluginSurfaceProps,
useRpc,
} from "@paseo/plugin";
import { createPluginContext, type PluginRegistrationCollector } from "@paseo/plugin/host";
import type { EvaluatedPlugin } from "./types";
import type { ComponentType } from "react";
import { resolvePluginIcon } from "./icons";
const CONTRIBUTION_ID = /^[a-z][a-z0-9-]*$/;
function requireId(value: string, label: string): string {
const id = value.trim();
if (!CONTRIBUTION_ID.test(id)) throw new Error(`Invalid ${label}: ${value}`);
return id;
}
export function evaluatePluginClientBundle(id: string, bundle: string): EvaluatedPlugin {
const collector: PluginRegistrationCollector = {
surfaces: [],
sidebarItems: [],
attachmentSources: [],
};
const surfaceIds = new Set<string>();
const sidebarItemIds = new Set<string>();
const attachmentSourceIds = new Set<string>();
const pluginContext = createPluginContext({
addSurface(surfaceId: string, Component: ComponentType<PluginSurfaceProps>) {
const normalizedId = requireId(surfaceId, "surface id");
if (surfaceIds.has(normalizedId)) throw new Error(`Duplicate surface: ${normalizedId}`);
if (typeof Component !== "function")
throw new Error(`Surface ${normalizedId} is not a component`);
surfaceIds.add(normalizedId);
collector.surfaces.push({ id: normalizedId, Component });
},
addSidebarItem(contribution: PluginSidebarContribution) {
const normalizedId = requireId(contribution.id, "sidebar item id");
if (sidebarItemIds.has(normalizedId))
throw new Error(`Duplicate sidebar item: ${normalizedId}`);
if (!contribution.title.trim()) throw new Error(`Sidebar item ${normalizedId} has no title`);
if (!contribution.icon.trim()) throw new Error(`Sidebar item ${normalizedId} has no icon`);
resolvePluginIcon(contribution.icon.trim());
sidebarItemIds.add(normalizedId);
collector.sidebarItems.push({
id: normalizedId,
title: contribution.title.trim(),
icon: contribution.icon.trim(),
surface: requireId(contribution.surface, "sidebar surface id"),
});
},
addAttachmentSource(contribution: PluginAttachmentSourceContribution) {
const normalizedId = requireId(contribution.id, "attachment source id");
if (attachmentSourceIds.has(normalizedId)) {
throw new Error(`Duplicate attachment source: ${normalizedId}`);
}
const title = contribution.title.trim();
const icon = contribution.icon.trim();
const pickerTitle = contribution.pickerTitle.trim();
const searchPlaceholder = contribution.searchPlaceholder.trim();
const method = contribution.search.name.trim();
if (!title) throw new Error(`Attachment source ${normalizedId} has no title`);
if (!icon) throw new Error(`Attachment source ${normalizedId} has no icon`);
if (!pickerTitle) throw new Error(`Attachment source ${normalizedId} has no picker title`);
if (!searchPlaceholder) {
throw new Error(`Attachment source ${normalizedId} has no search placeholder`);
}
if (!method) throw new Error(`Attachment source ${normalizedId} has no search RPC`);
resolvePluginIcon(icon);
attachmentSourceIds.add(normalizedId);
collector.attachmentSources.push({
id: normalizedId,
title,
icon,
pickerTitle,
searchPlaceholder,
search: { ...contribution.search, name: method },
});
},
});
const runtimeRequire = (name: string): unknown => {
if (name === "react") return React;
if (name === "react/jsx-runtime") return ReactJsxRuntime;
if (name === "react-native") return ReactNative;
if (name === "@paseo/plugin") return { defineAttachmentSource, defineRpc, useRpc };
if (name === "@tanstack/react-query") return ReactQuery;
if (name === "zod") return Zod;
throw new Error(`Module "${name}" is not available in plugin client code`);
};
const evaluate: (source: string) => unknown = globalThis.eval;
const factory = evaluate(bundle);
if (typeof factory !== "function")
throw new Error(`Plugin ${id} client bundle is not executable`);
const exports = factory(runtimeRequire);
const setup =
exports !== null && typeof exports === "object" ? Reflect.get(exports, "default") : undefined;
if (typeof setup !== "function") {
throw new Error(`Plugin ${id} must default export a function`);
}
const cleanup = setup(pluginContext);
if (typeof cleanup !== "function") {
throw new Error(`Plugin ${id} contribution must return a cleanup function`);
}
try {
for (const item of collector.sidebarItems) {
if (!surfaceIds.has(item.surface)) {
throw new Error(`Sidebar item ${item.id} references missing surface ${item.surface}`);
}
}
} catch (error) {
try {
cleanup();
} catch (cleanupError) {
console.warn(`[Plugins] Cleanup failed after setup error for ${id}`, cleanupError);
}
throw error;
}
return {
id,
cleanup,
surfaces: collector.surfaces,
sidebarItems: collector.sidebarItems,
attachmentSources: collector.attachmentSources,
};
}
+11
View File
@@ -0,0 +1,11 @@
import * as LucideIcons from "lucide-react-native";
import type { LucideIcon } from "lucide-react-native";
export function resolvePluginIcon(name: string): LucideIcon {
const candidate = Reflect.get(LucideIcons, name);
const isComponent =
typeof candidate === "function" ||
(typeof candidate === "object" && candidate !== null && "$$typeof" in candidate);
if (!isComponent) throw new Error(`Unknown Lucide icon: ${name}`);
return candidate as LucideIcon;
}
+5
View File
@@ -0,0 +1,5 @@
export { PluginCatalogSync } from "./catalog-sync";
export { PluginSidebarItems } from "./sidebar-items";
export { PluginSurfaceScreen } from "./surface-screen";
export { usePluginAttachmentPicker } from "./attachments/picker";
export { PluginResourceAttachmentPill } from "./attachments/pill";
+140
View File
@@ -0,0 +1,140 @@
import { afterEach, describe, expect, it } from "vitest";
import { pluginRegistry } from "./registry";
function bundle(marker: string): string {
return `(function() {
const module = { exports: {} };
module.exports.default = function(plugin) {
function Surface() { return ${JSON.stringify(marker)}; }
plugin.addSurface("main", Surface);
plugin.addSidebarItem({ id: "main", title: "Example", icon: "Blocks", surface: "main" });
return function() { globalThis.__pluginCleanups = (globalThis.__pluginCleanups || 0) + 1; };
};
return module.exports;
})`;
}
afterEach(() => {
pluginRegistry.removeHost("host-a");
pluginRegistry.removeHost("host-b");
Reflect.deleteProperty(globalThis, "__pluginCleanups");
});
describe("PluginRegistry", () => {
it("preserves a plugin query cache when the same bundle reconnects", () => {
const clientBundle = bundle("one");
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle }]);
const first = pluginRegistry.getSnapshot()[0];
first?.queryClient.setQueryData(["counter"], 3);
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle }]);
const second = pluginRegistry.getSnapshot()[0];
expect(second).toBe(first);
expect(second?.queryClient).toBe(first?.queryClient);
expect(second?.queryClient.getQueryData(["counter"])).toBe(3);
});
it("replaces the query cache when the plugin bundle changes", () => {
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle: bundle("one") }]);
const first = pluginRegistry.getSnapshot()[0];
first?.queryClient.setQueryData(["counter"], 3);
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle: bundle("two") }]);
const second = pluginRegistry.getSnapshot()[0];
expect(second?.queryClient).not.toBe(first?.queryClient);
expect(second?.queryClient.getQueryData(["counter"])).toBeUndefined();
expect(first?.queryClient.getQueryData(["counter"])).toBeUndefined();
expect(Reflect.get(globalThis, "__pluginCleanups")).toBe(1);
});
it("isolates query caches between hosts", () => {
const clientBundle = bundle("same");
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle }]);
pluginRegistry.installCatalog("host-b", [{ id: "example", clientBundle }]);
const [hostA, hostB] = pluginRegistry.getSnapshot();
hostA?.queryClient.setQueryData(["counter"], 3);
expect(hostB?.queryClient.getQueryData(["counter"])).toBeUndefined();
});
it("cleans up the old installation before evaluating its replacement", () => {
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle: bundle("one") }]);
const replacement = `(function() { return { default: function(plugin) {
globalThis.__cleanupSeenByReplacement = globalThis.__pluginCleanups || 0;
plugin.addSurface("main", function() { return null; });
return function() {};
} }; })`;
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle: replacement }]);
expect(Reflect.get(globalThis, "__cleanupSeenByReplacement")).toBe(1);
Reflect.deleteProperty(globalThis, "__cleanupSeenByReplacement");
});
it("replaces an unchanged bundle when explicit reload names the plugin", () => {
const clientBundle = bundle("same");
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle }]);
const first = pluginRegistry.getSnapshot()[0];
first?.queryClient.setQueryData(["attachment-search"], "cached");
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle }], {
replacePluginId: "example",
});
const second = pluginRegistry.getSnapshot()[0];
expect(second).not.toBe(first);
expect(first?.queryClient.getQueryData(["attachment-search"])).toBeUndefined();
expect(Reflect.get(globalThis, "__pluginCleanups")).toBe(1);
});
it.each(["disable", "remove"])("cleans a catalog entry exactly once on %s", () => {
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle: bundle("one") }]);
pluginRegistry.installCatalog("host-a", []);
pluginRegistry.installCatalog("host-a", []);
expect(Reflect.get(globalThis, "__pluginCleanups")).toBe(1);
expect(pluginRegistry.getSnapshot()).toEqual([]);
});
it("cancels an in-flight attachment query during teardown", async () => {
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle: bundle("one") }]);
const plugin = pluginRegistry.getSnapshot()[0];
if (!plugin) throw new Error("Expected installed plugin");
let observeAbort: () => void = () => undefined;
const aborted = new Promise<void>((resolve) => {
observeAbort = resolve;
});
void plugin.queryClient
.fetchQuery({
queryKey: ["plugin-attachment-search", "example"],
queryFn: ({ signal }) =>
new Promise<never>((_resolve, reject) => {
signal.addEventListener("abort", () => {
observeAbort();
reject(new Error("aborted"));
});
}),
})
.catch(() => undefined);
pluginRegistry.installCatalog("host-a", []);
await aborted;
expect(plugin.queryClient.getQueryCache().getAll()).toEqual([]);
});
it("continues host teardown when cleanup throws", () => {
const throwing = `(function() { return { default: function(plugin) {
plugin.addSurface("main", function() { return null; });
return function() { throw new Error("cleanup exploded"); };
} }; })`;
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle: throwing }]);
pluginRegistry.getSnapshot()[0]?.queryClient.setQueryData(["counter"], 3);
expect(() => pluginRegistry.removeHost("host-a")).not.toThrow();
expect(pluginRegistry.getSnapshot()).toEqual([]);
});
});
+139
View File
@@ -0,0 +1,139 @@
import { useSyncExternalStore } from "react";
import { QueryClient } from "@tanstack/react-query";
import { evaluatePluginClientBundle } from "./evaluate";
import type { InstalledPlugin } from "./types";
interface CatalogPlugin {
id: string;
clientBundle: string;
}
class PluginRegistry {
private readonly byHost = new Map<string, InstalledPlugin[]>();
private readonly listeners = new Set<() => void>();
private snapshot: InstalledPlugin[] = [];
private readonly disposed = new WeakSet<InstalledPlugin>();
private readonly evaluationErrors = new Map<string, string>();
subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
getSnapshot = (): InstalledPlugin[] => this.snapshot;
getEvaluationError(serverId: string, pluginId: string): string | undefined {
return this.evaluationErrors.get(`${serverId}/${pluginId}`);
}
installCatalog(
serverId: string,
catalog: CatalogPlugin[],
options: { replacePluginId?: string } = {},
): void {
const previous = this.byHost.get(serverId) ?? [];
const preserved = catalog.flatMap((entry) => {
const existing = previous.find(
(plugin) =>
plugin.id !== options.replacePluginId &&
plugin.id === entry.id &&
plugin.clientBundle === entry.clientBundle,
);
return existing ? [existing] : [];
});
const removed = previous.filter((plugin) => !preserved.includes(plugin));
if (removed.length > 0) {
this.byHost.set(serverId, preserved);
this.publish();
for (const plugin of removed) this.dispose(plugin);
}
const installed = catalog.flatMap((entry) => {
const key = `${serverId}/${entry.id}`;
try {
const existing = preserved.find(
(plugin) => plugin.id === entry.id && plugin.clientBundle === entry.clientBundle,
);
if (existing) {
this.evaluationErrors.delete(key);
return [existing];
}
const queryClient = new QueryClient();
const evaluated = [
{
...evaluatePluginClientBundle(entry.id, entry.clientBundle),
serverId,
clientBundle: entry.clientBundle,
queryClient,
},
];
this.evaluationErrors.delete(key);
return evaluated;
} catch (error) {
this.evaluationErrors.set(key, error instanceof Error ? error.message : String(error));
console.warn(`[Plugins] Failed to evaluate ${serverId}/${entry.id}`, error);
return [];
}
});
const configuredIds = new Set(catalog.map((entry) => entry.id));
for (const key of this.evaluationErrors.keys()) {
if (key.startsWith(`${serverId}/`) && !configuredIds.has(key.slice(serverId.length + 1))) {
this.evaluationErrors.delete(key);
}
}
this.byHost.set(serverId, installed);
this.publish();
}
removeHost(serverId: string): void {
const installed = this.byHost.get(serverId);
if (!installed) return;
for (const plugin of installed) this.dispose(plugin);
for (const key of this.evaluationErrors.keys()) {
if (key.startsWith(`${serverId}/`)) this.evaluationErrors.delete(key);
}
this.byHost.delete(serverId);
this.publish();
}
private dispose(plugin: InstalledPlugin): void {
if (this.disposed.has(plugin)) return;
this.disposed.add(plugin);
plugin.queryClient.clear();
try {
plugin.cleanup();
} catch (error) {
console.warn(`[Plugins] Cleanup failed for ${plugin.serverId}/${plugin.id}`, error);
}
}
private publish(): void {
this.snapshot = [...this.byHost.values()]
.flat()
.sort((left, right) =>
`${left.serverId}/${left.id}`.localeCompare(`${right.serverId}/${right.id}`),
);
for (const listener of this.listeners) listener();
}
}
export const pluginRegistry = new PluginRegistry();
export function useInstalledPlugins(): InstalledPlugin[] {
return useSyncExternalStore(
pluginRegistry.subscribe,
pluginRegistry.getSnapshot,
pluginRegistry.getSnapshot,
);
}
export function useInstalledPlugin(serverId: string, pluginId: string): InstalledPlugin | null {
return (
useInstalledPlugins().find(
(plugin) => plugin.serverId === serverId && plugin.id === pluginId,
) ?? null
);
}
export function usePluginInstallations(pluginId: string): InstalledPlugin[] {
return useInstalledPlugins().filter((plugin) => plugin.id === pluginId);
}
+17
View File
@@ -0,0 +1,17 @@
export function buildPluginSurfaceRoute(
serverId: string,
pluginId: string,
contributionId: string,
): `/h/${string}/plugin/${string}/${string}` {
return `/h/${encodeURIComponent(serverId)}/plugin/${encodeURIComponent(pluginId)}/${encodeURIComponent(contributionId)}`;
}
export function hostIdFromPathname(pathname: string): string | null {
const encoded = /^\/h\/([^/]+)/.exec(pathname)?.[1];
if (!encoded) return null;
try {
return decodeURIComponent(encoded);
} catch {
return null;
}
}
@@ -0,0 +1,45 @@
import { QueryClient } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
import type { InstalledPlugin } from "./types";
import { groupPluginSidebarContributions } from "./sidebar-groups";
function installed(serverId: string, contributionId = "main"): InstalledPlugin {
return {
id: "example",
cleanup: () => undefined,
serverId,
clientBundle: serverId,
queryClient: new QueryClient(),
surfaces: [{ id: "surface", Component: () => null }],
sidebarItems: [
{
id: contributionId,
title: "Example",
icon: "Blocks",
surface: "surface",
},
],
attachmentSources: [],
};
}
describe("groupPluginSidebarContributions", () => {
it("coalesces the same plugin contribution across hosts", () => {
const groups = groupPluginSidebarContributions([installed("host-a"), installed("host-b")]);
expect(groups).toHaveLength(1);
expect(groups[0]?.targets.map((target) => target.plugin.serverId)).toEqual([
"host-a",
"host-b",
]);
});
it("keeps different contribution ids separate", () => {
const groups = groupPluginSidebarContributions([
installed("host-a", "main"),
installed("host-b", "settings"),
]);
expect(groups.map((group) => group.key)).toEqual(["example/main", "example/settings"]);
});
});
@@ -0,0 +1,48 @@
import type { InstalledPlugin, PluginSidebarContribution } from "./types";
const preferredHosts = new Map<string, string>();
export interface PluginSidebarTarget {
plugin: InstalledPlugin;
item: PluginSidebarContribution;
}
export interface PluginSidebarGroup {
key: string;
pluginId: string;
contributionId: string;
title: string;
icon: string;
targets: PluginSidebarTarget[];
}
export function groupPluginSidebarContributions(plugins: InstalledPlugin[]): PluginSidebarGroup[] {
const groups = new Map<string, PluginSidebarGroup>();
for (const plugin of plugins) {
for (const item of plugin.sidebarItems) {
const key = `${plugin.id}/${item.id}`;
const existing = groups.get(key);
if (existing) {
existing.targets.push({ plugin, item });
} else {
groups.set(key, {
key,
pluginId: plugin.id,
contributionId: item.id,
title: item.title,
icon: item.icon,
targets: [{ plugin, item }],
});
}
}
}
return [...groups.values()];
}
export function getPreferredPluginContributionHost(groupKey: string): string | null {
return preferredHosts.get(groupKey) ?? null;
}
export function rememberPluginContributionHost(groupKey: string, serverId: string): void {
preferredHosts.set(groupKey, serverId);
}
@@ -0,0 +1,80 @@
import { router, usePathname } from "expo-router";
import { useCallback, useMemo } from "react";
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
import { resolvePluginIcon } from "./icons";
import { useInstalledPlugins } from "./registry";
import { buildPluginSurfaceRoute, hostIdFromPathname } from "./routes";
import {
groupPluginSidebarContributions,
getPreferredPluginContributionHost,
rememberPluginContributionHost,
type PluginSidebarGroup,
type PluginSidebarTarget,
} from "./sidebar-groups";
function selectTarget(
group: PluginSidebarGroup,
currentHostId: string | null,
): PluginSidebarTarget {
const current = group.targets.find((target) => target.plugin.serverId === currentHostId);
if (current) return current;
const rememberedHostId = getPreferredPluginContributionHost(group.key);
const remembered = group.targets.find((target) => target.plugin.serverId === rememberedHostId);
return remembered ?? group.targets[0];
}
export function PluginSidebarItems({ onBeforeNavigate }: { onBeforeNavigate?: () => void }) {
const plugins = useInstalledPlugins();
const pathname = usePathname();
const groups = useMemo(() => groupPluginSidebarContributions(plugins), [plugins]);
const currentHostId = hostIdFromPathname(pathname);
return groups.map((group) => (
<PluginSidebarItemRow
key={group.key}
group={group}
currentHostId={currentHostId}
pathname={pathname}
onBeforeNavigate={onBeforeNavigate}
/>
));
}
function PluginSidebarItemRow({
group,
currentHostId,
pathname,
onBeforeNavigate,
}: {
group: PluginSidebarGroup;
currentHostId: string | null;
pathname: string;
onBeforeNavigate?: () => void;
}) {
const target = selectTarget(group, currentHostId);
const route = buildPluginSurfaceRoute(
target.plugin.serverId,
group.pluginId,
group.contributionId,
);
const isActive = group.targets.some(
(candidate) =>
pathname ===
buildPluginSurfaceRoute(candidate.plugin.serverId, group.pluginId, group.contributionId),
);
const navigate = useCallback(() => {
rememberPluginContributionHost(group.key, target.plugin.serverId);
onBeforeNavigate?.();
router.push(route);
}, [group.key, onBeforeNavigate, route, target.plugin.serverId]);
return (
<SidebarHeaderRow
icon={resolvePluginIcon(group.icon)}
label={group.title}
onPress={navigate}
isActive={isActive}
testID={`plugin-sidebar-${group.pluginId}-${group.contributionId}`}
variant="compact"
/>
);
}
@@ -0,0 +1,79 @@
/**
* @vitest-environment jsdom
*/
import React, { act, useEffect, useState } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { Text } from "react-native";
import { SurfaceErrorBoundary } from "./surface-error-boundary";
let oldSurfaceCleanups = 0;
function FailingSurface() {
const [failed, setFailed] = useState(false);
useEffect(() => {
setFailed(true);
return () => {
oldSurfaceCleanups += 1;
};
}, []);
if (failed) throw new Error("old render exploded");
return <Text>Old surface mounted</Text>;
}
function CorrectedSurface() {
return <Text>Corrected surface</Text>;
}
const oldInstallation = {};
const correctedInstallation = {};
describe("SurfaceErrorBoundary", () => {
const roots: Array<ReturnType<typeof createRoot>> = [];
const containers: HTMLElement[] = [];
afterEach(() => {
for (const root of roots.splice(0)) act(() => root.unmount());
for (const container of containers.splice(0)) container.remove();
vi.restoreAllMocks();
vi.unstubAllGlobals();
oldSurfaceCleanups = 0;
});
it("recovers a mounted failed route when its installation and surface are replaced", async () => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.spyOn(console, "warn").mockImplementation(() => undefined);
vi.spyOn(console, "error").mockImplementation(() => undefined);
const container = document.createElement("div");
containers.push(container);
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
await act(async () => {
root.render(
<SurfaceErrorBoundary
key="host/plugin/surface"
installation={oldInstallation}
Surface={FailingSurface}
>
<FailingSurface />
</SurfaceErrorBoundary>,
);
});
expect(container.textContent).toBe("Plugin failed: old render exploded");
expect(oldSurfaceCleanups).toBe(1);
await act(async () => {
root.render(
<SurfaceErrorBoundary
key="host/plugin/surface"
installation={correctedInstallation}
Surface={CorrectedSurface}
>
<CorrectedSurface />
</SurfaceErrorBoundary>,
);
});
expect(container.textContent).toBe("Corrected surface");
expect(oldSurfaceCleanups).toBe(1);
});
});
@@ -0,0 +1,51 @@
import React, { Component, type ErrorInfo, type ReactNode } from "react";
import { Text } from "react-native";
import { StyleSheet } from "react-native-unistyles";
interface SurfaceErrorBoundaryProps {
children: ReactNode;
installation: object;
Surface: unknown;
}
interface SurfaceErrorBoundaryState {
error: string | null;
}
export class SurfaceErrorBoundary extends Component<
SurfaceErrorBoundaryProps,
SurfaceErrorBoundaryState
> {
state: SurfaceErrorBoundaryState = { error: null };
static getDerivedStateFromError(error: unknown): SurfaceErrorBoundaryState {
return { error: error instanceof Error ? error.message : String(error) };
}
componentDidCatch(error: Error, info: ErrorInfo): void {
console.warn("[Plugins] Surface render failed", error, info.componentStack);
}
componentDidUpdate(previous: SurfaceErrorBoundaryProps): void {
if (
this.state.error &&
(previous.installation !== this.props.installation || previous.Surface !== this.props.Surface)
) {
this.setState({ error: null });
}
}
render() {
if (this.state.error) {
return <Text style={styles.errorText}>Plugin failed: {this.state.error}</Text>;
}
return this.props.children;
}
}
const styles = StyleSheet.create((theme) => ({
errorText: {
color: theme.colors.statusDanger,
padding: theme.spacing[4],
},
}));
+270
View File
@@ -0,0 +1,270 @@
import { router, useLocalSearchParams } from "expo-router";
import { QueryClientProvider } from "@tanstack/react-query";
import type { PluginSurfaceProps } from "@paseo/plugin";
import { PluginRpcProvider } from "@paseo/plugin/host";
import { ChevronDown, X } from "lucide-react-native";
import { useCallback, useMemo, useRef, useState, type ComponentType } from "react";
import { Platform, Pressable, Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { HeaderIconBadge } from "@/components/headers/header-icon-badge";
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
import { ScreenHeader } from "@/components/headers/screen-header";
import { ScreenTitle } from "@/components/headers/screen-title";
import { HostPicker } from "@/components/hosts/host-picker";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime";
import type { Theme } from "@/styles/theme";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { resolvePluginIcon } from "./icons";
import { useInstalledPlugin, usePluginInstallations } from "./registry";
import { buildPluginSurfaceRoute } from "./routes";
import { rememberPluginContributionHost } from "./sidebar-groups";
import { SurfaceErrorBoundary } from "./surface-error-boundary";
const EMPTY_SHORTCUT_KEYS: ShortcutKey[] = [];
const EMPTY_THEME_DTO: Record<string, unknown> = {};
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const pluginThemeMapping = (theme: Theme) => ({
themeDto: JSON.parse(JSON.stringify(theme)) as Record<string, unknown>,
});
const ThemedX = withUnistyles(X);
const ThemedChevronDown = withUnistyles(ChevronDown);
function routeParam(value: string | string[] | undefined): string {
return typeof value === "string" ? value : "";
}
function PluginHeaderIcon({
Icon,
color = "",
}: {
Icon: ReturnType<typeof resolvePluginIcon>;
color?: string;
}) {
return <Icon size={16} color={color} />;
}
const ThemedPluginHeaderIcon = withUnistyles(PluginHeaderIcon);
function SurfaceRenderer({
Surface,
invoke,
plugin,
layout,
host,
themeDto = EMPTY_THEME_DTO,
}: {
Surface: ComponentType<PluginSurfaceProps>;
invoke(method: string, input: unknown): Promise<unknown>;
plugin: NonNullable<ReturnType<typeof useInstalledPlugin>>;
layout: PluginSurfaceProps["layout"];
host: PluginSurfaceProps["host"];
themeDto?: Record<string, unknown>;
}) {
return (
<QueryClientProvider client={plugin.queryClient}>
<PluginRpcProvider invoke={invoke}>
<Surface theme={themeDto} host={host} layout={layout} />
</PluginRpcProvider>
</QueryClientProvider>
);
}
const ThemedSurfaceRenderer = withUnistyles(SurfaceRenderer);
function resolvePlatform(): PluginSurfaceProps["layout"]["platform"] {
if (Platform.OS === "ios") return "ios";
if (Platform.OS === "android") return "android";
return "web";
}
function PluginHostSwitcher({
serverId,
pluginId,
contributionId,
serverIds,
}: {
serverId: string;
pluginId: string;
contributionId: string;
serverIds: string[];
}) {
const allHosts = useHosts();
const hosts = useMemo(
() => allHosts.filter((host) => serverIds.includes(host.serverId)),
[allHosts, serverIds],
);
const [open, setOpen] = useState(false);
const anchorRef = useRef<View | null>(null);
const selectedLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId;
const selectHost = useCallback(
(nextServerId: string) => {
rememberPluginContributionHost(`${pluginId}/${contributionId}`, nextServerId);
router.replace(buildPluginSurfaceRoute(nextServerId, pluginId, contributionId));
},
[contributionId, pluginId],
);
const openPicker = useCallback(() => setOpen(true), []);
const show = serverIds.length > 1 && hosts.length > 1;
if (!show) return null;
return (
<HostPicker
hosts={hosts}
value={serverId}
onSelect={selectHost}
open={open}
onOpenChange={setOpen}
anchorRef={anchorRef}
title="Choose plugin host"
desktopPlacement="bottom-start"
>
<View ref={anchorRef} collapsable={false}>
<Pressable
accessibilityRole="button"
accessibilityLabel={`Plugin host: ${selectedLabel}`}
testID="plugin-host-switcher"
onPress={openPicker}
style={styles.hostSwitcher}
>
<Text numberOfLines={1} style={styles.hostSwitcherText}>
{selectedLabel}
</Text>
<ThemedChevronDown size={14} uniProps={mutedColorMapping} />
</Pressable>
</View>
</HostPicker>
);
}
export function PluginSurfaceScreen() {
const params = useLocalSearchParams<{
serverId?: string | string[];
pluginId?: string | string[];
surfaceId?: string | string[];
}>();
const serverId = routeParam(params.serverId);
const pluginId = routeParam(params.pluginId);
const contributionId = routeParam(params.surfaceId);
const plugin = useInstalledPlugin(serverId, pluginId);
const installations = usePluginInstallations(pluginId);
const hosts = useHosts();
const client = useHostRuntimeClient(serverId);
const compact = useIsCompactFormFactor();
const sidebarItem = plugin?.sidebarItems.find((entry) => entry.id === contributionId) ?? null;
const surface = plugin?.surfaces.find((entry) => entry.id === sidebarItem?.surface) ?? null;
const hostLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId;
const contributionServerIds = useMemo(
() =>
installations
.filter((entry) => entry.sidebarItems.some((item) => item.id === contributionId))
.map((entry) => entry.serverId),
[contributionId, installations],
);
const title = sidebarItem?.title ?? (pluginId || "Plugin");
const Icon = sidebarItem ? resolvePluginIcon(sidebarItem.icon) : null;
const invoke = useCallback(
async (method: string, input: unknown) => {
if (!client) throw new Error("Plugin host is offline");
return client.invokePluginRpc(pluginId, method, input);
},
[client, pluginId],
);
const close = useCallback(() => {
if (router.canGoBack()) router.back();
else router.replace(`/h/${encodeURIComponent(serverId)}`);
}, [serverId]);
const layout = useMemo(() => ({ compact, platform: resolvePlatform() }), [compact]);
const host = useMemo(() => ({ id: serverId, label: hostLabel }), [hostLabel, serverId]);
const headerLeft = useMemo(
() => (
<>
{Icon ? (
<HeaderIconBadge>
<ThemedPluginHeaderIcon Icon={Icon} uniProps={mutedColorMapping} />
</HeaderIconBadge>
) : null}
<ScreenTitle>{title}</ScreenTitle>
</>
),
[Icon, title],
);
const headerRight = useMemo(
() => (
<>
<PluginHostSwitcher
serverId={serverId}
pluginId={pluginId}
contributionId={contributionId}
serverIds={contributionServerIds}
/>
<HeaderToggleButton
accessibilityLabel="Close plugin"
onPress={close}
testID="plugin-surface-close"
tooltipKeys={EMPTY_SHORTCUT_KEYS}
tooltipLabel="Close"
tooltipSide="bottom"
>
<ThemedX size={18} uniProps={mutedColorMapping} />
</HeaderToggleButton>
</>
),
[close, contributionId, contributionServerIds, pluginId, serverId],
);
return (
<View style={styles.screen}>
<ScreenHeader left={headerLeft} right={headerRight} />
<View style={styles.body}>
{plugin && surface ? (
<SurfaceErrorBoundary
key={`${serverId}/${pluginId}/${contributionId}`}
installation={plugin}
Surface={surface.Component}
>
<ThemedSurfaceRenderer
Surface={surface.Component}
invoke={invoke}
plugin={plugin}
host={host}
layout={layout}
uniProps={pluginThemeMapping}
/>
</SurfaceErrorBoundary>
) : (
<Text style={styles.errorText}>This plugin surface is unavailable.</Text>
)}
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
screen: {
flex: 1,
backgroundColor: theme.colors.surface0,
},
body: {
flex: 1,
},
errorText: {
color: theme.colors.statusDanger,
padding: theme.spacing[4],
},
hostSwitcher: {
maxWidth: 180,
minHeight: 32,
paddingHorizontal: theme.spacing[2],
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface1,
},
hostSwitcherText: {
flexShrink: 1,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
}));
+26
View File
@@ -0,0 +1,26 @@
import type { QueryClient } from "@tanstack/react-query";
import type {
PluginAttachmentSourceContribution,
PluginSidebarContribution,
PluginSurfaceContribution,
} from "@paseo/plugin";
export interface EvaluatedPlugin {
id: string;
cleanup: () => void;
surfaces: PluginSurfaceContribution[];
sidebarItems: PluginSidebarContribution[];
attachmentSources: PluginAttachmentSourceContribution[];
}
export interface InstalledPlugin extends EvaluatedPlugin {
serverId: string;
clientBundle: string;
queryClient: QueryClient;
}
export type {
PluginAttachmentSourceContribution,
PluginSidebarContribution,
PluginSurfaceContribution,
};
@@ -111,6 +111,31 @@ function message(id: string, text: string): StreamItem {
};
}
function toolCall(): StreamItem {
return {
kind: "tool_call",
id: "tool-1",
timelineCursor: { epoch: "epoch-1", seq: 12 },
timestamp: new Date("2026-07-18T08:02:00.000Z"),
payload: {
source: "agent",
data: {
provider: "codex",
callId: "call-1",
name: "shell",
status: "completed",
error: null,
detail: {
type: "shell",
command: "npm test",
output: "passed",
exitCode: 0,
},
},
},
};
}
function seedSession(): void {
const store = useSessionStore.getState();
store.initializeSession(SERVER_ID, null);
@@ -209,7 +234,7 @@ describe("ReplicaCache", () => {
cache.setHosts([]);
});
it("restores a displayable stale replica without claiming remote hydration", async () => {
it("restores the exact persisted canonical timeline window", async () => {
const storage = new MemoryStorage();
const writer = new ReplicaCache(storage);
writer.setHosts([SERVER_ID]);
@@ -237,11 +262,65 @@ describe("ReplicaCache", () => {
expect(session.workspaces.get("workspace-1")?.statusEnteredAt).toBeInstanceOf(Date);
expect(session.workspaces.get("workspace-1")?.worktreeSlug).toBe("owned-worktree");
expect(session.agentStreamTail.get("agent-1")).toEqual([message("message-1", "Cached")]);
expect(session.agentAuthoritativeHistoryApplied).toEqual(new Map());
expect(session.agentTimelineCursor).toEqual(new Map());
expect(session.agentTimelineHasOlder).toEqual(new Map());
expect(session.agentTimelineHasNewer).toEqual(new Map());
expect(session.agentAuthoritativeHistoryApplied).toEqual(new Map([["agent-1", true]]));
expect(session.agentTimelineCursor).toEqual(
new Map([["agent-1", { epoch: "epoch-1", startSeq: 1, endSeq: 12 }]]),
);
expect(session.agentTimelineHasOlder).toEqual(new Map([["agent-1", true]]));
expect(session.agentTimelineHasNewer).toEqual(new Map([["agent-1", false]]));
expect(session.agentHistorySyncGeneration).toEqual(new Map());
expect(selectAgentTimelineState(session, "agent-1")).toEqual({
status: "synced",
items: [message("message-1", "Cached")],
range: { epoch: "epoch-1", startSeq: 1, endSeq: 12 },
older: "available",
newer: "none",
});
});
it("restores tool calls inside an authoritative cached window", async () => {
const storage = new MemoryStorage();
const writer = new ReplicaCache(storage);
writer.setHosts([SERVER_ID]);
seedSession();
useSessionStore.getState().setAgentStreamTail(SERVER_ID, new Map([["agent-1", [toolCall()]]]));
await writer.flush();
useSessionStore.getState().clearSession(SERVER_ID);
const reader = new ReplicaCache(storage);
reader.setHosts([SERVER_ID]);
await reader.restore();
const session = useSessionStore.getState().sessions[SERVER_ID];
expect(session?.agentStreamTail.get("agent-1")).toEqual([toolCall()]);
expect(session?.agentTimelineCursor.get("agent-1")).toEqual({
epoch: "epoch-1",
startSeq: 1,
endSeq: 12,
});
});
it("restores display-only when retained items do not reach the stored range end", async () => {
const storage = new MemoryStorage();
const writer = new ReplicaCache(storage);
writer.setHosts([SERVER_ID]);
seedSession();
useSessionStore
.getState()
.setAgentTimelineCursor(
SERVER_ID,
new Map([["agent-1", { epoch: "epoch-1", startSeq: 1, endSeq: 13 }]]),
);
await writer.flush();
useSessionStore.getState().clearSession(SERVER_ID);
const reader = new ReplicaCache(storage);
reader.setHosts([SERVER_ID]);
await reader.restore();
const session = useSessionStore.getState().sessions[SERVER_ID];
expect(session?.agentStreamTail.get("agent-1")).toEqual([message("message-1", "Cached")]);
expect(session?.agentTimelineCursor).toEqual(new Map());
expect(selectAgentTimelineState(session, "agent-1")).toEqual({
status: "painted",
items: [message("message-1", "Cached")],
@@ -264,9 +343,10 @@ describe("ReplicaCache", () => {
normalizeWorkspaceDescriptor(workspace("workspace-2", "project-2", "/repo/other")),
),
);
const secondTimeline = Array.from({ length: 60 }, (_, index) =>
message(`message-${index}`, `Second ${index}`),
);
const secondTimeline = Array.from({ length: 60 }, (_, index) => ({
...message(`message-${index}`, `Second ${index}`),
timelineCursor: { epoch: "epoch-2", seq: index + 1 },
}));
store.setAgentStreamTail(
SERVER_ID,
new Map([
@@ -274,6 +354,12 @@ describe("ReplicaCache", () => {
["agent-2", secondTimeline],
]),
);
store.setAgentTimelineCursor(
SERVER_ID,
new Map([["agent-2", { epoch: "epoch-2", startSeq: 1, endSeq: 60 }]]),
);
store.setAgentTimelineHasOlder(SERVER_ID, new Map([["agent-2", true]]));
store.setAgentAuthoritativeHistoryApplied(SERVER_ID, "agent-2", true);
store.setFocusedAgentId(SERVER_ID, "agent-2");
await cache.flush();
@@ -293,13 +379,23 @@ describe("ReplicaCache", () => {
]);
expect(Array.from(timelines?.keys() ?? [])).toEqual(["agent-2"]);
expect(timelines?.get("agent-2")).toEqual(secondTimeline.slice(-50));
expect(session?.agentTimelineCursor.has("agent-2")).toBe(false);
expect(selectAgentTimelineState(session, "agent-2")).toEqual({
status: "painted",
items: secondTimeline.slice(-50),
});
const persisted = JSON.parse(storage.values.get("@paseo:replica-cache") ?? "null") as {
version: number;
hosts: Array<{ timeline: Record<string, unknown> | null }>;
};
expect(persisted.version).toBe(5);
expect(Object.keys(persisted.hosts[0]?.timeline ?? {}).sort()).toEqual(["agentId", "items"]);
expect(persisted.version).toBe(6);
expect(Object.keys(persisted.hosts[0]?.timeline ?? {}).sort()).toEqual([
"agentId",
"hasOlder",
"items",
"range",
]);
});
it("persists reconciled rows without caching unreconciled local presentations", async () => {
@@ -404,12 +500,12 @@ describe("ReplicaCache", () => {
expect(Object.keys(useSessionStore.getState().sessions).sort()).toEqual(["host-a", "host-c"]);
});
it("rejects and clears version 4 cache data before overwriting it on flush", async () => {
it("rejects and clears version 5 cache data before overwriting it on flush", async () => {
const storage = new MemoryStorage();
storage.values.set(
"@paseo:replica-cache",
JSON.stringify({
version: 4,
version: 5,
hosts: [
{
serverId: SERVER_ID,
@@ -435,7 +531,7 @@ describe("ReplicaCache", () => {
expect(useSessionStore.getState().sessions[SERVER_ID]).toBeUndefined();
expect(JSON.parse(storage.values.get("@paseo:replica-cache") ?? "null")).toEqual({
version: 5,
version: 6,
hosts: [],
});
});
+117 -9
View File
@@ -1,6 +1,6 @@
import { Buffer } from "buffer";
import { z } from "zod";
import { AgentStatusSchema } from "@getpaseo/protocol/messages";
import { AgentStatusSchema, AgentTimelineItemPayloadSchema } from "@getpaseo/protocol/messages";
import { AgentProviderSchema } from "@getpaseo/protocol/provider-manifest";
import {
normalizeProjectDescriptor,
@@ -9,6 +9,7 @@ import {
useSessionStore,
type Agent,
type SessionReplica,
type SessionReplicaTimeline,
type SessionState,
type ProjectDescriptor,
type WorkspaceDescriptor,
@@ -17,7 +18,7 @@ import { isUnreconciledLocalUserMessage, type StreamItem } from "@/types/stream"
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
const STORAGE_KEY = "@paseo:replica-cache";
const CACHE_VERSION = 5;
const CACHE_VERSION = 6;
const PERSIST_DELAY_MS = 750;
const MAX_TIMELINE_ITEMS = 50;
const MAX_CACHE_BYTES = 32 * 1024 * 1024;
@@ -91,6 +92,12 @@ const StoredTimelineItemSchema = z.discriminatedUnion("kind", [
trigger: z.enum(["auto", "manual"]).optional(),
preTokens: z.number().nonnegative().optional(),
}),
z.strictObject({
...TimelineItemBaseShape,
kind: z.literal("tool_call"),
provider: AgentProviderSchema,
item: AgentTimelineItemPayloadSchema.refine((item) => item.type === "tool_call"),
}),
]);
const AgentCapabilitiesSchema = z.strictObject({
@@ -246,6 +253,14 @@ const StoredProjectSchema = z.strictObject({
const StoredTimelineSchema = z.strictObject({
agentId: z.string(),
items: z.array(StoredTimelineItemSchema),
range: z
.strictObject({
epoch: z.string(),
startSeq: z.number().int().nonnegative(),
endSeq: z.number().int().nonnegative(),
})
.nullable(),
hasOlder: z.boolean(),
});
const StoredHostSchema = z.strictObject({
@@ -275,6 +290,8 @@ interface ReplicaInput {
projects: ReadonlyMap<string, ProjectDescriptor>;
focusedAgent: Agent | undefined;
timelineItems: StreamItem[] | undefined;
timelineRange: SessionReplicaTimeline["range"];
timelineHasOlder: boolean;
}
export interface ReplicaCacheStorage {
@@ -294,6 +311,8 @@ function deserializeTimeline(stored: StoredHost["timeline"]): SessionReplica["ti
return {
agentId: stored.agentId,
items: stored.items.map(deserializeTimelineItem),
range: stored.range,
hasOlder: stored.hasOlder,
};
}
@@ -351,7 +370,23 @@ function serializeTimelineItem(item: StreamItem): StoredTimelineItem | null {
...(item.preTokens !== undefined ? { preTokens: item.preTokens } : {}),
};
case "tool_call":
return null;
if (item.payload.source !== "agent") return null;
const storedTool = AgentTimelineItemPayloadSchema.safeParse({
type: "tool_call",
callId: item.payload.data.callId,
name: item.payload.data.name,
status: item.payload.data.status,
error: item.payload.data.error,
detail: item.payload.data.detail,
...(item.payload.data.metadata ? { metadata: item.payload.data.metadata } : {}),
});
if (!storedTool.success || storedTool.data.type !== "tool_call") return null;
return {
...base,
kind: item.kind,
provider: item.payload.data.provider,
item: storedTool.data,
};
}
}
@@ -404,6 +439,28 @@ function deserializeTimelineItem(item: StoredTimelineItem): StreamItem {
...(item.trigger ? { trigger: item.trigger } : {}),
...(item.preTokens !== undefined ? { preTokens: item.preTokens } : {}),
};
case "tool_call": {
const tool = item.item;
if (tool.type !== "tool_call") {
throw new Error("Stored tool call contains a non-tool timeline item");
}
return {
...base,
kind: item.kind,
payload: {
source: "agent",
data: {
provider: item.provider,
callId: tool.callId,
name: tool.name,
status: tool.status,
error: tool.error,
detail: tool.detail,
...(tool.metadata ? { metadata: tool.metadata } : {}),
},
},
};
}
}
}
@@ -529,13 +586,28 @@ function serializeProject(project: ProjectDescriptor): StoredProject {
};
}
function isTimelineItemStoredLosslessly(item: StreamItem): boolean {
switch (item.kind) {
case "user_message":
return (item.images?.length ?? 0) === 0 && (item.attachments?.length ?? 0) === 0;
case "activity_log":
return item.metadata === undefined;
case "tool_call":
return item.payload.source === "agent";
default:
return true;
}
}
function replicaInputsEqual(left: ReplicaInput, right: ReplicaInput): boolean {
return (
left.agents === right.agents &&
left.workspaces === right.workspaces &&
left.projects === right.projects &&
left.focusedAgent === right.focusedAgent &&
left.timelineItems === right.timelineItems
left.timelineItems === right.timelineItems &&
left.timelineRange === right.timelineRange &&
left.timelineHasOlder === right.timelineHasOlder
);
}
@@ -550,14 +622,33 @@ function selectReplicaInput(session: SessionState, agentId: string | null): Repl
projects: session.projects,
focusedAgent: agent,
timelineItems: timeline.status === "cold" ? undefined : timeline.items,
timelineRange: timeline.status === "synced" ? timeline.range : null,
timelineHasOlder: timeline.status === "synced" && timeline.older === "available",
};
}
function serializeHost(serverId: string, input: ReplicaInput, directorySync?: unknown): StoredHost {
const items = input.timelineItems
?.filter((item) => item.kind !== "user_message" || !isUnreconciledLocalUserMessage(item))
.map(serializeTimelineItem)
.filter((item) => item !== null);
const canonicalItems = input.timelineItems?.filter(
(item) => item.kind !== "user_message" || !isUnreconciledLocalUserMessage(item),
);
const items = canonicalItems
? canonicalItems.map(serializeTimelineItem).filter((item) => item !== null)
: undefined;
const range = input.timelineRange;
const canPersistCoverage =
range !== null &&
range.retainedRanges === undefined &&
canonicalItems !== undefined &&
canonicalItems.length <= MAX_TIMELINE_ITEMS &&
items?.length === canonicalItems.length &&
canonicalItems.every(
(item) =>
isTimelineItemStoredLosslessly(item) &&
item.timelineCursor?.epoch === range.epoch &&
item.timelineCursor.seq >= range.startSeq &&
item.timelineCursor.seq <= range.endSeq,
) &&
canonicalItems.some((item) => item.timelineCursor?.seq === range.endSeq);
return {
serverId,
agents: Array.from(input.agents.values(), serializeAgent),
@@ -569,6 +660,10 @@ function serializeHost(serverId: string, input: ReplicaInput, directorySync?: un
? {
agentId: input.focusedAgent.id,
items: items.slice(-MAX_TIMELINE_ITEMS),
range: canPersistCoverage
? { epoch: range.epoch, startSeq: range.startSeq, endSeq: range.endSeq }
: null,
hasOlder: canPersistCoverage ? input.timelineHasOlder : false,
}
: null,
...(directorySync ? { directorySync } : {}),
@@ -783,8 +878,21 @@ export class ReplicaCache {
if (session.focusedAgentId) {
this.lastFocusedAgentIds.set(serverId, session.focusedAgentId);
}
const input = selectReplicaInput(session, this.lastFocusedAgentIds.get(serverId) ?? null);
const focusedAgentId = this.lastFocusedAgentIds.get(serverId) ?? null;
const previous = this.capturedInputs.get(serverId);
const selected = selectReplicaInput(session, focusedAgentId);
const hasTimelineHead =
focusedAgentId !== null && (session.agentStreamHead.get(focusedAgentId)?.length ?? 0) > 0;
let input = selected;
if (hasTimelineHead && previous && selected.timelineItems === previous.timelineItems) {
input = {
...selected,
timelineRange: previous.timelineRange,
timelineHasOlder: previous.timelineHasOlder,
};
} else if (hasTimelineHead) {
input = { ...selected, timelineRange: null, timelineHasOlder: false };
}
if (previous && replicaInputsEqual(previous, input)) return false;
this.capturedInputs.set(serverId, input);
@@ -37,6 +37,7 @@ import {
Code2,
Smartphone,
Sparkles,
Blocks,
} from "lucide-react-native";
import { DropdownTrigger } from "@/components/ui/dropdown-trigger";
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
@@ -108,6 +109,7 @@ import {
HostWorkspacesPage,
HostTerminalsPage,
} from "@/screens/settings/host-page";
import { HostPluginsPage } from "@/screens/settings/plugins-page";
import { MetadataGenerationPage } from "@/screens/settings/metadata-generation-page";
import ProjectsScreen from "@/screens/projects-screen";
import ProjectSettingsScreen from "@/screens/project-settings-screen";
@@ -191,6 +193,7 @@ const HOST_SECTION_ITEMS: HostSectionItem[] = [
{ id: "providers", labelKey: "settings.hostSections.providers", icon: Boxes },
{ id: "usage", labelKey: "settings.hostSections.usage", icon: Gauge },
{ id: "terminals", labelKey: "settings.hostSections.terminals", icon: SquareTerminal },
{ id: "plugins", labelKey: "settings.hostSections.plugins", icon: Blocks },
];
function renderHostSettingsContent(
@@ -216,6 +219,8 @@ function renderHostSettingsContent(
return <HostUsagePage serverId={view.serverId} />;
case "terminals":
return <HostTerminalsPage serverId={view.serverId} />;
case "plugins":
return <HostPluginsPage serverId={view.serverId} />;
case "host":
return <HostSettingsPage serverId={view.serverId} onHostRemoved={onHostRemoved} />;
}
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { resolvePluginPageState } from "./plugins-page-state";
describe("resolvePluginPageState", () => {
it.each([
["offline", false, false, false, false, 0],
["unsupported", true, false, false, false, 0],
["loading", true, true, true, false, 0],
["error", true, true, false, true, 0],
["empty", true, true, false, false, 0],
["ready", true, true, false, false, 1],
] as const)("resolves %s", (expected, connected, supported, loading, error, pluginCount) => {
expect(resolvePluginPageState({ connected, supported, loading, error, pluginCount })).toBe(
expected,
);
});
});
@@ -0,0 +1,15 @@
export type PluginPageState = "offline" | "unsupported" | "loading" | "error" | "empty" | "ready";
export function resolvePluginPageState(input: {
connected: boolean;
supported: boolean;
loading: boolean;
error: boolean;
pluginCount: number;
}): PluginPageState {
if (!input.connected) return "offline";
if (!input.supported) return "unsupported";
if (input.loading) return "loading";
if (input.error) return "error";
return input.pluginCount === 0 ? "empty" : "ready";
}
@@ -0,0 +1,173 @@
/**
* @vitest-environment jsdom
*/
import { i18n as testI18n } from "@/i18n/i18next";
import React, { type ReactElement } from "react";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { PluginListItem } from "@getpaseo/protocol/messages";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { HostPluginsPage } from "./plugins-page";
void testI18n;
const runtime = vi.hoisted(() => ({
connected: true,
supported: true,
client: null as DaemonClient | null,
}));
vi.mock("@/runtime/host-runtime", () => ({
useHostRuntimeClient: () => runtime.client,
useHostRuntimeIsConnected: () => runtime.connected,
}));
vi.mock("@/runtime/host-features", () => ({
useHostFeature: () => runtime.supported,
}));
vi.mock("react-native-reanimated", () => ({
default: { View: "div" },
Easing: { ease: "ease", inOut: (value: unknown) => value },
interpolateColor: (value: number, _input: number[], output: string[]) =>
value >= 1 ? output[1] : output[0],
useAnimatedStyle: (factory: () => unknown) => factory(),
useDerivedValue: (factory: () => unknown) => ({ value: factory() }),
withTiming: (value: unknown) => value,
}));
vi.mock("@gorhom/bottom-sheet", async () => {
const ReactModule = await vi.importActual<typeof import("react")>("react");
const { ScrollView, TextInput } =
await vi.importActual<typeof import("react-native")>("react-native");
return {
BottomSheetBackdrop: () => null,
BottomSheetModal: ReactModule.forwardRef(() => null),
BottomSheetScrollView: ScrollView,
BottomSheetTextInput: TextInput,
useBottomSheetInternal: () => null,
};
});
vi.mock("react-native-safe-area-context", () => ({
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
}));
function never<T>(): Promise<T> {
return new Promise<T>(() => undefined);
}
function plugin(enabled = true): PluginListItem {
return {
id: "example",
path: "/plugins/example",
enabled,
status: enabled ? "running" : "disabled",
};
}
function createClient() {
return {
on: vi.fn(() => () => undefined),
getDaemonConfig: vi.fn(async () => ({ config: { pluginsEnabled: true } })),
patchDaemonConfig: vi.fn(async () => ({ config: { pluginsEnabled: true } })),
listPlugins: vi.fn(async (): Promise<PluginListItem[]> => []),
inspectDirectoryPlugin: vi.fn(async () => ({ id: "example" })),
installDirectoryPlugin: vi.fn(async () => plugin()),
reloadPlugin: vi.fn(async () => plugin()),
enablePlugin: vi.fn(async () => plugin()),
disablePlugin: vi.fn(async () => plugin(false)),
removePlugin: vi.fn(async () => undefined),
};
}
type PluginClient = ReturnType<typeof createClient>;
function renderPage(client: PluginClient | null): void {
runtime.client = client as unknown as DaemonClient | null;
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const element: ReactElement = (
<QueryClientProvider client={queryClient}>
<HostPluginsPage serverId="host-a" />
</QueryClientProvider>
);
render(element);
}
describe("HostPluginsPage", () => {
beforeEach(() => {
vi.stubGlobal("React", React);
runtime.connected = true;
runtime.supported = true;
runtime.client = null;
vi.stubGlobal(
"confirm",
vi.fn(() => true),
);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
it("renders the offline state", () => {
runtime.connected = false;
renderPage(null);
expect(screen.getByRole("alert").textContent).toContain("Plugin host is offline");
});
it("renders a catalog error and retries through the real query", async () => {
const client = createClient();
client.listPlugins.mockRejectedValueOnce(new Error("catalog exploded"));
renderPage(client);
expect(await screen.findByText("Unable to load plugins")).toBeDefined();
expect(screen.getByText("catalog exploded")).toBeDefined();
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
await waitFor(() => expect(client.listPlugins).toHaveBeenCalledTimes(2));
expect(await screen.findByText("No plugins configured")).toBeDefined();
});
it.each([
["reloadPlugin", "Reload", "Reloading…", true],
["disablePlugin", "Disable", "Disabling…", true],
["enablePlugin", "Enable", "Enabling…", false],
["removePlugin", "Remove", "Removing…", true],
] as const)("renders %s pending on its plugin row", async (method, trigger, pending, enabled) => {
const client = createClient();
client.listPlugins.mockResolvedValue([plugin(enabled)]);
client[method].mockImplementation(() => never<never>());
renderPage(client);
fireEvent.click(await screen.findByRole("button", { name: trigger }));
const pendingControl = await screen.findByRole("button", { name: pending });
expect(pendingControl.getAttribute("aria-disabled")).toBe("true");
expect(client[method]).toHaveBeenCalledTimes(1);
});
it("renders install pending through the real form and mutation", async () => {
const client = createClient();
client.installDirectoryPlugin.mockImplementation(() => never<never>());
renderPage(client);
fireEvent.change(screen.getByLabelText("Plugin directory"), {
target: { value: "/plugins/example" },
});
fireEvent.click(screen.getByRole("button", { name: "Install directory" }));
const pendingControl = await screen.findByRole("button", { name: "Installing…" });
expect(pendingControl.getAttribute("aria-disabled")).toBe("true");
expect(client.installDirectoryPlugin).toHaveBeenCalledWith("/plugins/example", undefined);
});
});
@@ -0,0 +1,359 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useMutation } from "@tanstack/react-query";
import type { PluginListItem } from "@getpaseo/protocol/messages";
import { Alert } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Field, FormTextInput } from "@/components/ui/form-field";
import { StatusBadge } from "@/components/ui/status-badge";
import { Switch } from "@/components/ui/switch";
import { useFetchQuery } from "@/data/query";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { useHostFeature } from "@/runtime/host-features";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { SettingsSection } from "@/screens/settings/settings-section";
import { resolvePluginPageState } from "@/screens/settings/plugins-page-state";
import { pluginRegistry, useInstalledPlugins } from "@/plugins/registry";
import { settingsStyles } from "@/styles/settings";
import { confirmDialog } from "@/utils/confirm-dialog";
const pluginQueryKey = (serverId: string) => ["plugins", serverId] as const;
type PluginRowAction = "reload" | "enable" | "disable" | "remove";
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function pluginRowAction(action: string | undefined): PluginRowAction | undefined {
if (action === "reload" || action === "enable" || action === "disable" || action === "remove") {
return action;
}
return undefined;
}
function PluginRow({
plugin,
clientError,
pending,
pendingAction,
onAction,
}: {
plugin: PluginListItem;
clientError?: string;
pending: boolean;
pendingAction?: PluginRowAction;
onAction(action: PluginRowAction, plugin: PluginListItem): void;
}) {
const { t } = useTranslation();
const reload = useCallback(() => onAction("reload", plugin), [onAction, plugin]);
const toggle = useCallback(
() => onAction(plugin.enabled ? "disable" : "enable", plugin),
[onAction, plugin],
);
const remove = useCallback(() => onAction("remove", plugin), [onAction, plugin]);
const status = clientError ? "failed" : plugin.status;
let badgeVariant: "success" | "error" | "muted" = "muted";
if (status === "running") badgeVariant = "success";
else if (status === "failed") badgeVariant = "error";
const statusLabel = t(`settings.plugins.status.${status}`);
let toggleLabel = t(
plugin.enabled ? "settings.plugins.actions.disable" : "settings.plugins.actions.enable",
);
if (pendingAction === "enable") toggleLabel = t("settings.plugins.actions.enabling");
else if (pendingAction === "disable") toggleLabel = t("settings.plugins.actions.disabling");
return (
<View style={styles.pluginRow} accessibilityLabel={`${plugin.id} ${statusLabel}`}>
<View style={settingsStyles.rowContent}>
<View style={styles.pluginTitle}>
<Text style={settingsStyles.rowTitle}>{plugin.id}</Text>
<StatusBadge label={statusLabel} variant={badgeVariant} />
</View>
<Text style={settingsStyles.rowHint}>{plugin.path}</Text>
{clientError || plugin.error ? (
<Text style={styles.error}>{clientError ?? plugin.error}</Text>
) : null}
</View>
<View style={styles.actions}>
<Button variant="outline" size="sm" onPress={reload} disabled={pending || !plugin.enabled}>
{pendingAction === "reload"
? t("settings.plugins.actions.reloading")
: t("settings.plugins.actions.reload")}
</Button>
<Button variant="outline" size="sm" onPress={toggle} disabled={pending}>
{toggleLabel}
</Button>
<Button variant="outline" size="sm" onPress={remove} disabled={pending}>
{pendingAction === "remove"
? t("settings.plugins.actions.removing")
: t("settings.plugins.actions.remove")}
</Button>
</View>
</View>
);
}
export function HostPluginsPage({ serverId }: { serverId: string }) {
const { t } = useTranslation();
const client = useHostRuntimeClient(serverId);
const connected = useHostRuntimeIsConnected(serverId);
const supported = useHostFeature(serverId, "pluginManagement");
const { config, patchConfig } = useDaemonConfig(serverId);
const refreshQueue = useRef(Promise.resolve());
useInstalledPlugins();
const queryKey = useMemo(() => pluginQueryKey(serverId), [serverId]);
const [directory, setDirectory] = useState("");
const [pluginId, setPluginId] = useState("");
const [directoryResetKey, setDirectoryResetKey] = useState(0);
const [pluginIdResetKey, setPluginIdResetKey] = useState(0);
const [feedback, setFeedback] = useState<{ kind: "success" | "error"; message: string } | null>(
null,
);
const plugins = useFetchQuery({
queryKey,
queryFn: async () => {
if (!client) throw new Error("Plugin host is offline");
return client.listPlugins();
},
enabled: Boolean(client && connected && supported),
dataShape: "list",
staleTimeMs: 1_000,
});
const refetchPlugins = plugins.refetch;
const refresh = useCallback(async () => {
if (!client) return;
const refetch = async () => {
await refetchPlugins();
};
refreshQueue.current = refreshQueue.current.then(refetch, refetch);
await refreshQueue.current;
}, [client, refetchPlugins]);
useEffect(() => {
if (!client) return;
return client.on("status", (message) => {
if (message.payload.status === "plugin_catalog_changed") void refresh();
});
}, [client, refresh]);
const mutation = useMutation({
mutationFn: async (operation: {
action: string;
pluginId?: string;
run: () => Promise<string>;
}) => operation.run(),
onSuccess: async (message) => {
setFeedback({ kind: "success", message });
await refresh();
},
onError: (error) => setFeedback({ kind: "error", message: errorMessage(error) }),
});
const install = useCallback(() => {
const path = directory.trim();
if (!client || !path) return;
setFeedback(null);
mutation.mutate({
action: "install",
run: async () => {
const installed = await client.installDirectoryPlugin(path, pluginId.trim() || undefined);
setDirectory("");
setPluginId("");
setDirectoryResetKey((key) => key + 1);
setPluginIdResetKey((key) => key + 1);
return t("settings.plugins.feedback.installed", { id: installed.id });
},
});
}, [client, directory, mutation, pluginId, t]);
const prefillManifestId = useCallback(() => {
const path = directory.trim();
if (!client || !path || pluginId.trim()) return;
void client
.inspectDirectoryPlugin(path)
.then(({ id }) => {
setPluginId(id);
setPluginIdResetKey((key) => key + 1);
return undefined;
})
.catch(() => undefined);
}, [client, directory, pluginId]);
const action = useCallback(
(name: PluginRowAction, plugin: PluginListItem) => {
if (!client) return;
const run = async () => {
if (name === "remove") {
const confirmed = await confirmDialog({
title: t("settings.plugins.removeConfirmTitle", { id: plugin.id }),
message: t("settings.plugins.removeConfirmMessage"),
confirmLabel: t("settings.plugins.actions.remove"),
destructive: true,
});
if (!confirmed) return t("settings.plugins.feedback.kept", { id: plugin.id });
await client.removePlugin(plugin.id);
} else {
await client[`${name}Plugin`](plugin.id);
}
return t(`settings.plugins.feedback.${name}`, { id: plugin.id });
};
setFeedback(null);
mutation.mutate({ action: name, pluginId: plugin.id, run });
},
[client, mutation, t],
);
const toggleGlobal = useCallback(
(enabled: boolean) => {
setFeedback(null);
mutation.mutate({
action: enabled ? "global-enable" : "global-disable",
run: async () => {
await patchConfig({ pluginsEnabled: enabled });
return enabled
? t("settings.plugins.feedback.globalEnabled")
: t("settings.plugins.feedback.globalDisabled");
},
});
},
[mutation, patchConfig, t],
);
const pageState = resolvePluginPageState({
connected,
supported,
loading: plugins.isLoading,
error: plugins.isError,
pluginCount: plugins.data?.length ?? 0,
});
const retry = useCallback(() => {
void refetchPlugins();
}, [refetchPlugins]);
if (pageState === "offline") {
return (
<Alert
variant="warning"
title={t("settings.plugins.states.offlineTitle")}
description={t("settings.plugins.states.offlineDescription")}
/>
);
}
if (pageState === "unsupported") {
return <Alert variant="warning" title={t("settings.plugins.states.updateTitle")} />;
}
let catalogContent = <Alert title={t("settings.plugins.states.loading")} />;
if (pageState === "error") {
catalogContent = (
<Alert
variant="error"
title={t("settings.plugins.states.errorTitle")}
description={errorMessage(plugins.error)}
>
<Button variant="outline" size="sm" onPress={retry}>
{t("settings.plugins.states.retry")}
</Button>
</Alert>
);
} else if (pageState !== "loading") {
catalogContent = (
<View style={settingsStyles.card}>
{plugins.data?.length ? (
plugins.data.map((plugin) => {
const clientError = pluginRegistry.getEvaluationError(serverId, plugin.id);
const pending = mutation.isPending && mutation.variables?.pluginId === plugin.id;
return (
<PluginRow
key={plugin.id}
plugin={plugin}
clientError={clientError}
pending={mutation.isPending}
pendingAction={pending ? pluginRowAction(mutation.variables?.action) : undefined}
onAction={action}
/>
);
})
) : (
<View style={styles.empty}>
<Text style={settingsStyles.rowHint}>{t("settings.plugins.states.empty")}</Text>
</View>
)}
</View>
);
}
return (
<View>
<SettingsSection title={t("settings.plugins.title")}>
<Alert
variant="warning"
title={t("settings.plugins.trustedTitle")}
description={t("settings.plugins.trustedDescription")}
/>
<View style={settingsStyles.card}>
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{t("settings.plugins.globalTitle")}</Text>
<Text style={settingsStyles.rowHint}>{t("settings.plugins.globalHint")}</Text>
</View>
<Switch
value={config?.pluginsEnabled === true}
onValueChange={toggleGlobal}
disabled={mutation.isPending}
accessibilityLabel={t("settings.plugins.globalTitle")}
/>
</View>
</View>
<View style={[settingsStyles.card, styles.install]}>
<Field label={t("settings.plugins.directoryLabel")}>
<FormTextInput
initialValue=""
resetKey={directoryResetKey}
onChangeText={setDirectory}
onBlur={prefillManifestId}
placeholder={t("settings.plugins.directoryPlaceholder")}
autoCapitalize="none"
autoCorrect={false}
editable={!mutation.isPending}
accessibilityLabel={t("settings.plugins.directoryLabel")}
/>
</Field>
<Field label={t("settings.plugins.idLabel")} hint={t("settings.plugins.idHint")}>
<FormTextInput
initialValue={pluginId}
resetKey={pluginIdResetKey}
onChangeText={setPluginId}
placeholder={t("settings.plugins.idPlaceholder")}
autoCapitalize="none"
autoCorrect={false}
editable={!mutation.isPending}
accessibilityLabel={t("settings.plugins.idLabel")}
/>
</Field>
<Button onPress={install} disabled={!directory.trim() || mutation.isPending}>
{mutation.isPending && mutation.variables?.action === "install"
? t("settings.plugins.installing")
: t("settings.plugins.install")}
</Button>
</View>
{feedback ? (
<Alert
variant={feedback.kind}
title={feedback.message}
testID="plugin-management-feedback"
/>
) : null}
{catalogContent}
</SettingsSection>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
install: { padding: theme.spacing[4], gap: theme.spacing[3] },
pluginRow: {
padding: theme.spacing[4],
gap: theme.spacing[3],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
pluginTitle: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] },
actions: { flexDirection: "row", flexWrap: "wrap", gap: theme.spacing[2] },
error: { color: theme.colors.statusDanger, fontSize: theme.fontSize.xs },
empty: { padding: theme.spacing[4], alignItems: "center" },
}));
@@ -71,6 +71,34 @@ describe("draft-store lifecycle", () => {
});
describe("draft-store normalization", () => {
it("preserves plugin resources when hydrating a draft", () => {
const attachment = {
kind: "plugin_resource" as const,
pluginId: "linear",
sourceId: "issues",
sourceTitle: "Linear issue",
sourceIcon: "CircleDot",
item: {
id: "issue-uuid",
identifier: "ENG-123",
title: "Plugin attachments",
subtitle: "In progress",
url: "https://linear.app/acme/issue/ENG-123/plugin-attachments",
text: "Linear issue ENG-123: Plugin attachments",
resourceType: "issue",
},
};
expect(
toDraftInputIfReady({
input: { text: "Implement this", attachments: [attachment] },
lifecycle: "active",
updatedAt: 1,
version: 1,
}),
).toEqual({ text: "Implement this", attachments: [attachment] });
});
it("preserves uploaded file attachments when hydrating a draft", () => {
const attachment = {
kind: "file" as const,
@@ -3,6 +3,7 @@ import {
type AttachmentMetadata,
type UserComposerAttachment,
} from "@/attachments/types";
import { PluginResourceComposerAttachmentSchema } from "@/plugins/attachments";
import { z } from "zod";
export const DRAFT_STORE_VERSION = 5;
@@ -95,6 +96,7 @@ export const UserComposerAttachmentSchema: z.ZodType<UserComposerAttachment> = z
z.strictObject({ kind: z.literal("forge_issue"), item: IssueItemSchema }),
z.strictObject({ kind: z.literal("forge_change_request"), item: ChangeRequestItemSchema }),
z.strictObject({ kind: z.literal("github_issue"), item: IssueItemSchema }),
PluginResourceComposerAttachmentSchema,
z.strictObject({
kind: z.literal("github_pr"),
item: ChangeRequestItemSchema,
+16
View File
@@ -310,6 +310,8 @@ export interface AgentTimelineCursorState {
export interface SessionReplicaTimeline {
agentId: string;
items: StreamItem[];
range: AgentTimelineCursorState | null;
hasOlder: boolean;
}
export interface SessionReplica {
@@ -791,6 +793,16 @@ export const useSessionStore = create<SessionStore>()(
const tasks = latestTasksFromStream(timeline.items);
if (tasks.length > 0) agentTasks.set(timeline.agentId, tasks);
}
const agentTimelineCursor = new Map<string, AgentTimelineCursorState>();
const agentTimelineHasOlder = new Map<string, boolean>();
const agentTimelineHasNewer = new Map<string, boolean>();
const agentAuthoritativeHistoryApplied = new Map<string, boolean>();
if (timeline?.range) {
agentTimelineCursor.set(timeline.agentId, timeline.range);
agentTimelineHasOlder.set(timeline.agentId, timeline.hasOlder);
agentTimelineHasNewer.set(timeline.agentId, false);
agentAuthoritativeHistoryApplied.set(timeline.agentId, true);
}
const agentLastActivity = new Map(prev.agentLastActivity);
for (const agent of replica.agents.values()) {
agentLastActivity.set(agent.id, agent.lastActivityAt);
@@ -808,6 +820,10 @@ export const useSessionStore = create<SessionStore>()(
hasWorkspaceDirectorySnapshot: true,
agentStreamTail,
agentTasks,
agentTimelineCursor,
agentTimelineHasOlder,
agentTimelineHasNewer,
agentAuthoritativeHistoryApplied,
},
},
agentLastActivity,
@@ -0,0 +1,426 @@
import { describe, expect, it } from "vitest";
import { createArchiveFinishedSubagents, type ManagedSubagentSnapshot } from "./archive-finished";
import type { PaseoSubagentRow, ProviderSubagentRow } from "./select";
function paseo(id: string, status: PaseoSubagentRow["status"] = "idle"): PaseoSubagentRow {
return {
kind: "paseo",
id,
provider: "codex",
title: id,
description: null,
subtitle: null,
status,
requiresAttention: false,
createdAt: new Date(),
};
}
function provider(
id: string,
status: ProviderSubagentRow["status"] = "completed",
): ProviderSubagentRow {
return {
kind: "provider",
id,
parentAgentId: "parent",
provider: "codex",
title: id,
description: null,
subtitle: null,
status,
requiresAttention: false,
createdAt: new Date(),
};
}
function managed(
id: string,
status: ManagedSubagentSnapshot["status"] = "idle",
overrides: Partial<ManagedSubagentSnapshot> = {},
): ManagedSubagentSnapshot {
return {
id,
status,
parentAgentId: "parent",
archivedAt: null,
...overrides,
};
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
describe("createArchiveFinishedSubagents", () => {
it("dismisses provider rows before sequential managed archives and reports combined progress", async () => {
const first = deferred<void>();
const second = deferred<void>();
const current = new Map([
["first", managed("first")],
["second", managed("second")],
["resumed", managed("resumed", "running")],
]);
const events: string[] = [];
const archive = createArchiveFinishedSubagents(
[provider("native"), paseo("first"), paseo("second", "error"), paseo("resumed")],
{
parentAgentId: "parent",
getManagedSubagent: (id) => current.get(id),
archiveManagedSubagent: (id) => {
events.push(`archive:${id}`);
return id === "first" ? first.promise : second.promise;
},
dismissProviderSubagents: (ids) => events.push(`dismiss:${ids.join(",")}`),
},
);
const progress: string[] = [];
archive.subscribe(() => {
const status = archive.getState().status;
if (status.kind === "archiving") {
progress.push(`${status.completedCount}/${status.totalCount}`);
}
});
const operation = archive.archiveFinished();
expect(archive.getState()).toEqual({
eligibleCount: 4,
status: { kind: "archiving", completedCount: 1, totalCount: 4 },
});
expect(events).toEqual(["dismiss:native", "archive:first"]);
first.resolve();
await Promise.resolve();
expect(archive.getState()).toEqual({
eligibleCount: 4,
status: { kind: "archiving", completedCount: 2, totalCount: 4 },
});
expect(events).toEqual(["dismiss:native", "archive:first", "archive:second"]);
second.resolve();
await expect(operation).resolves.toEqual({
archivedPaseoIds: ["first", "second"],
dismissedProviderIds: ["native"],
skippedPaseoIds: ["resumed"],
failures: [],
});
expect(progress).toEqual(["0/4", "1/4", "2/4", "3/4", "4/4"]);
expect(archive.getState()).toEqual({ eligibleCount: 4, status: { kind: "idle" } });
});
it("retains failure through optimistic rollback, retries current rows, and resets for a later eligible-set change", async () => {
const first = deferred<void>();
const second = deferred<void>();
const retry = deferred<void>();
const current = new Map([
["first", managed("first")],
["second", managed("second")],
]);
const responses = [first.promise, second.promise, retry.promise];
const calls: string[] = [];
const archive = createArchiveFinishedSubagents([paseo("first"), paseo("second")], {
parentAgentId: "parent",
getManagedSubagent: (id) => current.get(id),
archiveManagedSubagent: (id) => {
calls.push(id);
const response = responses.shift();
if (!response) throw new Error("Unexpected archive");
return response;
},
dismissProviderSubagents: () => undefined,
});
const firstAttempt = archive.archiveFinished();
archive.setRows([paseo("second")]);
const firstError = new Error("first failed");
first.reject(firstError);
await Promise.resolve();
second.resolve();
archive.setRows([]);
await expect(firstAttempt).resolves.toEqual({
archivedPaseoIds: ["second"],
dismissedProviderIds: [],
skippedPaseoIds: [],
failures: [{ id: "first", error: firstError }],
});
expect(archive.getState()).toEqual({
eligibleCount: 0,
status: { kind: "failed", failedCount: 1, totalCount: 1 },
});
archive.setRows([paseo("first")]);
expect(archive.getState()).toEqual({
eligibleCount: 1,
status: { kind: "failed", failedCount: 1, totalCount: 1 },
});
expect(calls).toEqual(["first", "second"]);
archive.setRows([paseo("first", "error")]);
expect(archive.getState()).toEqual({
eligibleCount: 1,
status: { kind: "failed", failedCount: 1, totalCount: 1 },
});
const retryAttempt = archive.archiveFinished();
expect(archive.getState()).toEqual({
eligibleCount: 1,
status: { kind: "archiving", completedCount: 0, totalCount: 1 },
});
expect(calls).toEqual(["first", "second", "first"]);
const retryError = new Error("retry failed");
retry.reject(retryError);
await expect(retryAttempt).resolves.toEqual({
archivedPaseoIds: [],
dismissedProviderIds: [],
skippedPaseoIds: [],
failures: [{ id: "first", error: retryError }],
});
expect(archive.getState()).toEqual({
eligibleCount: 1,
status: { kind: "failed", failedCount: 1, totalCount: 1 },
});
archive.setRows([paseo("first"), paseo("later")]);
expect(archive.getState()).toEqual({ eligibleCount: 2, status: { kind: "idle" } });
});
it("keeps retained failure for eligible status changes and row reorder, then resets on membership change", async () => {
const firstError = new Error("first failed");
const secondError = new Error("second failed");
const current = new Map([
["first", managed("first")],
["second", managed("second")],
]);
const archive = createArchiveFinishedSubagents([paseo("first"), paseo("second")], {
parentAgentId: "parent",
getManagedSubagent: (id) => current.get(id),
archiveManagedSubagent: async (id) => {
throw id === "first" ? firstError : secondError;
},
dismissProviderSubagents: () => undefined,
});
const operation = archive.archiveFinished();
archive.setRows([]);
await operation;
archive.setRows([paseo("first"), paseo("second")]);
archive.setRows([paseo("second", "error"), paseo("first", "error")]);
expect(archive.getState()).toEqual({
eligibleCount: 2,
status: { kind: "failed", failedCount: 2, totalCount: 2 },
});
archive.setRows([paseo("second", "error")]);
expect(archive.getState()).toEqual({ eligibleCount: 1, status: { kind: "idle" } });
});
it("clears retry state when the authoritative failed child is running", async () => {
const current = new Map([["failed", managed("failed")]]);
const archive = createArchiveFinishedSubagents([paseo("failed")], {
parentAgentId: "parent",
getManagedSubagent: (id) => current.get(id),
archiveManagedSubagent: async () => {
current.set("failed", managed("failed", "running"));
throw new Error("archive failed");
},
dismissProviderSubagents: () => undefined,
});
const operation = archive.archiveFinished();
archive.setRows([]);
await operation;
archive.setRows([paseo("failed", "running")]);
expect(archive.getState()).toEqual({ eligibleCount: 0, status: { kind: "idle" } });
await expect(archive.archiveFinished()).resolves.toEqual({
archivedPaseoIds: [],
dismissedProviderIds: [],
skippedPaseoIds: [],
failures: [],
});
});
it("clears retry state when the authoritative failed child is initializing", async () => {
const current = new Map([["failed", managed("failed")]]);
const archive = createArchiveFinishedSubagents([paseo("failed")], {
parentAgentId: "parent",
getManagedSubagent: (id) => current.get(id),
archiveManagedSubagent: async () => {
current.set("failed", managed("failed", "initializing"));
throw new Error("archive failed");
},
dismissProviderSubagents: () => undefined,
});
const operation = archive.archiveFinished();
archive.setRows([]);
await operation;
archive.setRows([paseo("failed", "initializing")]);
expect(archive.getState()).toEqual({ eligibleCount: 0, status: { kind: "idle" } });
await expect(archive.archiveFinished()).resolves.toEqual({
archivedPaseoIds: [],
dismissedProviderIds: [],
skippedPaseoIds: [],
failures: [],
});
});
it.each([
["is missing", (current: Map<string, ManagedSubagentSnapshot>) => current.delete("failed")],
[
"is archived",
(current: Map<string, ManagedSubagentSnapshot>) =>
current.set("failed", managed("failed", "idle", { archivedAt: new Date() })),
],
[
"belongs to a different parent",
(current: Map<string, ManagedSubagentSnapshot>) =>
current.set("failed", managed("failed", "idle", { parentAgentId: "other-parent" })),
],
])(
"clears retry state when the authoritative failed child %s",
async (_label, updateSnapshot) => {
const error = new Error("archive failed");
const current = new Map([["failed", managed("failed")]]);
const archive = createArchiveFinishedSubagents([paseo("failed")], {
parentAgentId: "parent",
getManagedSubagent: (id) => current.get(id),
archiveManagedSubagent: async () => {
updateSnapshot(current);
throw error;
},
dismissProviderSubagents: () => undefined,
});
await expect(archive.archiveFinished()).resolves.toEqual({
archivedPaseoIds: [],
dismissedProviderIds: [],
skippedPaseoIds: [],
failures: [{ id: "failed", error }],
});
expect(archive.getState()).toEqual({ eligibleCount: 1, status: { kind: "idle" } });
},
);
it("retains only eligible rolled-back failures and retries only those children", async () => {
const firstError = new Error("first failed");
const secondError = new Error("second failed");
const current = new Map([
["first", managed("first")],
["second", managed("second")],
]);
const calls: string[] = [];
const archive = createArchiveFinishedSubagents([paseo("first"), paseo("second")], {
parentAgentId: "parent",
getManagedSubagent: (id) => current.get(id),
archiveManagedSubagent: async (id) => {
calls.push(id);
if (calls.length === 1) throw firstError;
if (calls.length === 2) {
current.set("second", managed("second", "running"));
throw secondError;
}
},
dismissProviderSubagents: () => undefined,
});
const operation = archive.archiveFinished();
archive.setRows([]);
await expect(operation).resolves.toEqual({
archivedPaseoIds: [],
dismissedProviderIds: [],
skippedPaseoIds: [],
failures: [
{ id: "first", error: firstError },
{ id: "second", error: secondError },
],
});
archive.setRows([paseo("first"), paseo("second", "running")]);
expect(archive.getState()).toEqual({
eligibleCount: 1,
status: { kind: "failed", failedCount: 1, totalCount: 1 },
});
await expect(archive.archiveFinished()).resolves.toEqual({
archivedPaseoIds: ["first"],
dismissedProviderIds: [],
skippedPaseoIds: [],
failures: [],
});
expect(calls).toEqual(["first", "second", "first"]);
});
it("rechecks each managed child and skips resumed, missing, archived, reparented, running, and initializing rows", async () => {
const rows = [
paseo("first"),
paseo("resumed"),
paseo("missing"),
paseo("archived"),
paseo("reparented"),
paseo("running"),
paseo("initializing"),
];
const current = new Map<string, ManagedSubagentSnapshot>([
["first", managed("first")],
["resumed", managed("resumed")],
["archived", managed("archived", "idle", { archivedAt: new Date() })],
["reparented", managed("reparented", "idle", { parentAgentId: "other-parent" })],
["running", managed("running", "running")],
["initializing", managed("initializing", "initializing")],
]);
const archived: string[] = [];
const archive = createArchiveFinishedSubagents(rows, {
parentAgentId: "parent",
getManagedSubagent: (id) => current.get(id),
archiveManagedSubagent: async (id) => {
archived.push(id);
current.set("resumed", managed("resumed", "running"));
},
dismissProviderSubagents: () => undefined,
});
await expect(archive.archiveFinished()).resolves.toEqual({
archivedPaseoIds: ["first"],
dismissedProviderIds: [],
skippedPaseoIds: ["resumed", "missing", "archived", "reparented", "running", "initializing"],
failures: [],
});
expect(archived).toEqual(["first"]);
expect(archive.getState()).toEqual({ eligibleCount: 7, status: { kind: "idle" } });
});
it("dismisses finished provider rows locally without removing their descriptors", async () => {
const descriptors = new Map([
["finished", provider("finished")],
["running", provider("running", "running")],
]);
const dismissed: string[][] = [];
const archive = createArchiveFinishedSubagents([...descriptors.values()], {
parentAgentId: "parent",
getManagedSubagent: () => undefined,
archiveManagedSubagent: async () => undefined,
dismissProviderSubagents: (ids) => dismissed.push(ids),
});
await expect(archive.archiveFinished()).resolves.toEqual({
archivedPaseoIds: [],
dismissedProviderIds: ["finished"],
skippedPaseoIds: [],
failures: [],
});
expect(dismissed).toEqual([["finished"]]);
expect(descriptors.get("finished")).toEqual(provider("finished"));
expect(descriptors.get("running")).toEqual(provider("running", "running"));
});
});
@@ -0,0 +1,203 @@
import type { Agent } from "@/stores/session-store";
import type { SubagentRow } from "./select";
export type ArchiveFinishedStatus =
| { kind: "idle" }
| { kind: "archiving"; completedCount: number; totalCount: number }
| { kind: "failed"; failedCount: number; totalCount: number };
export interface ArchiveFinishedState {
eligibleCount: number;
status: ArchiveFinishedStatus;
}
export interface ArchiveFinishedOutcome {
archivedPaseoIds: string[];
dismissedProviderIds: string[];
skippedPaseoIds: string[];
failures: Array<{ id: string; error: unknown }>;
}
export type ManagedSubagentSnapshot = Pick<Agent, "id" | "status" | "parentAgentId" | "archivedAt">;
export interface ArchiveFinishedSubagentsDeps {
parentAgentId: string;
getManagedSubagent: (id: string) => ManagedSubagentSnapshot | undefined;
archiveManagedSubagent: (id: string) => Promise<void>;
dismissProviderSubagents: (ids: string[]) => void;
}
export interface ArchiveFinishedSubagents {
getState(): ArchiveFinishedState;
setRows(rows: readonly SubagentRow[]): void;
subscribe(listener: () => void): () => void;
archiveFinished(): Promise<ArchiveFinishedOutcome>;
}
export function isFinishedSubagent(row: SubagentRow): boolean {
if (row.kind === "paseo") return row.status === "idle" || row.status === "error";
return row.status === "completed" || row.status === "failed" || row.status === "canceled";
}
function canArchiveManagedSubagent(
agent: ManagedSubagentSnapshot | undefined,
parentAgentId: string,
): boolean {
return Boolean(
agent &&
!agent.archivedAt &&
agent.parentAgentId === parentAgentId &&
(agent.status === "idle" || agent.status === "error"),
);
}
function eligibleSignature(rows: readonly SubagentRow[]): string {
return identitySignature(rowIdentities(rows));
}
function managedRowIdentity(id: string): string {
return `paseo:${id}`;
}
function rowIdentity(row: SubagentRow): string {
return row.kind === "paseo" ? managedRowIdentity(row.id) : `provider:${row.id}`;
}
function rowIdentities(rows: readonly SubagentRow[]): Set<string> {
return new Set(rows.map(rowIdentity));
}
function identitySignature(ids: ReadonlySet<string>): string {
return [...ids].sort().join("\0");
}
function emptyOutcome(): ArchiveFinishedOutcome {
return {
archivedPaseoIds: [],
dismissedProviderIds: [],
skippedPaseoIds: [],
failures: [],
};
}
export function createArchiveFinishedSubagents(
initialRows: readonly SubagentRow[],
deps: ArchiveFinishedSubagentsDeps,
): ArchiveFinishedSubagents {
let eligibleRows = initialRows.filter(isFinishedSubagent);
let signature = eligibleSignature(eligibleRows);
let failedSignature: string | null = null;
let state: ArchiveFinishedState = {
eligibleCount: eligibleRows.length,
status: { kind: "idle" },
};
const listeners = new Set<() => void>();
const publish = () => {
for (const listener of listeners) listener();
};
const setState = (next: ArchiveFinishedState) => {
state = next;
publish();
};
const setRows = (rows: readonly SubagentRow[]) => {
const nextEligibleRows = rows.filter(isFinishedSubagent);
const nextSignature = eligibleSignature(nextEligibleRows);
const eligibilityChanged = signature !== nextSignature;
eligibleRows = nextEligibleRows;
signature = nextSignature;
if (state.status.kind === "failed" && failedSignature !== signature) {
failedSignature = null;
setState({ eligibleCount: eligibleRows.length, status: { kind: "idle" } });
return;
}
if (state.eligibleCount !== eligibleRows.length || eligibilityChanged) {
setState({ ...state, eligibleCount: eligibleRows.length });
}
};
const archiveFinished = () => {
if (state.status.kind === "archiving" || eligibleRows.length === 0) {
return Promise.resolve(emptyOutcome());
}
const snapshot = eligibleRows;
const totalCount = snapshot.length;
setState({
eligibleCount: eligibleRows.length,
status: { kind: "archiving", completedCount: 0, totalCount },
});
return runArchiveFinished(snapshot, deps, (completedCount) => {
setState({
eligibleCount: eligibleRows.length,
status: { kind: "archiving", completedCount, totalCount },
});
}).then(({ outcome, retryableFailureIds }) => {
failedSignature =
retryableFailureIds.size > 0 ? identitySignature(retryableFailureIds) : null;
setState({
eligibleCount: eligibleRows.length,
status:
retryableFailureIds.size > 0
? {
kind: "failed",
failedCount: retryableFailureIds.size,
totalCount: retryableFailureIds.size,
}
: { kind: "idle" },
});
return outcome;
});
};
return {
getState: () => state,
setRows,
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
archiveFinished,
};
}
async function runArchiveFinished(
rows: readonly SubagentRow[],
deps: ArchiveFinishedSubagentsDeps,
reportProgress: (completedCount: number) => void,
): Promise<{ outcome: ArchiveFinishedOutcome; retryableFailureIds: Set<string> }> {
const providerIds = rows.filter((row) => row.kind === "provider").map((row) => row.id);
const paseoIds = rows.filter((row) => row.kind === "paseo").map((row) => row.id);
let completedCount = 0;
const outcome = emptyOutcome();
const retryableFailureIds = new Set<string>();
if (providerIds.length > 0) {
deps.dismissProviderSubagents(providerIds);
outcome.dismissedProviderIds.push(...providerIds);
completedCount += providerIds.length;
reportProgress(completedCount);
}
for (const id of paseoIds) {
if (canArchiveManagedSubagent(deps.getManagedSubagent(id), deps.parentAgentId)) {
try {
await deps.archiveManagedSubagent(id);
outcome.archivedPaseoIds.push(id);
} catch (error) {
outcome.failures.push({ id, error });
if (canArchiveManagedSubagent(deps.getManagedSubagent(id), deps.parentAgentId)) {
retryableFailureIds.add(managedRowIdentity(id));
}
}
} else {
outcome.skippedPaseoIds.push(id);
}
completedCount += 1;
reportProgress(completedCount);
}
return { outcome, retryableFailureIds };
}
+5 -4
View File
@@ -2,9 +2,10 @@ export type { SubagentRow } from "./select";
export { selectSubagentsForParent, useSubagentsForParent } from "./select";
export { useArchiveSubagent, type UseArchiveSubagentInput } from "./use-archive-subagent";
export { useDetachSubagent, type UseDetachSubagentInput } from "./use-detach-subagent";
export {
useHideFinishedProviderSubagents,
type UseHideFinishedProviderSubagentsInput,
} from "./use-hide-finished-provider-subagents";
export { resolveCloseAgentTabPolicy, type CloseAgentTabPolicy } from "./close-tab-policy";
export { isWorkspaceRootAgent } from "./workspace-root-policy";
export {
useArchiveFinishedSubagents,
type ArchiveFinishedStatus,
type UseArchiveFinishedSubagentsInput,
} from "./use-archive-finished";
@@ -160,7 +160,7 @@ describe("provider subagent client store", () => {
item: { type: "assistant_message", text: "Finished output." },
});
store.hideFinishedForParent(SERVER_ID, PARENT_ID);
store.hideFromTrack(SERVER_ID, PARENT_ID, [SUBAGENT_ID]);
const state = useProviderSubagentStore.getState();
const key = providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID);
@@ -185,7 +185,7 @@ describe("provider subagent client store", () => {
toolCallId: "call-1",
};
store.applyUpdate(SERVER_ID, { kind: "upsert", subagent: completed });
store.hideFinishedForParent(SERVER_ID, PARENT_ID);
store.hideFromTrack(SERVER_ID, PARENT_ID, [SUBAGENT_ID]);
store.replaceList(SERVER_ID, PARENT_ID, [completed]);
const key = providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID);
@@ -215,7 +215,7 @@ describe("provider subagent client store", () => {
toolCallId: "call-1",
},
});
store.hideFinishedForParent(SERVER_ID, PARENT_ID);
store.hideFromTrack(SERVER_ID, PARENT_ID, [SUBAGENT_ID]);
store.replaceList(SERVER_ID, PARENT_ID, []);
@@ -239,7 +239,7 @@ describe("provider subagent client store", () => {
toolCallId: "call-1",
};
store.applyUpdate(SERVER_ID, { kind: "upsert", subagent: completed });
store.hideFinishedForParent(SERVER_ID, PARENT_ID);
store.hideFromTrack(SERVER_ID, PARENT_ID, [SUBAGENT_ID]);
store.applyUpdate(SERVER_ID, {
kind: "remove",
parentAgentId: PARENT_ID,
+5 -7
View File
@@ -33,7 +33,7 @@ interface ProviderSubagentState {
descriptors: Map<string, ProviderSubagentDescriptorPayload>;
timelines: Map<string, ProviderSubagentTimelineState>;
hiddenFromTrack: Set<string>;
hideFinishedForParent(serverId: string, parentAgentId: string): void;
hideFromTrack(serverId: string, parentAgentId: string, subagentIds: readonly string[]): void;
replaceList(
serverId: string,
parentAgentId: string,
@@ -196,14 +196,12 @@ export const useProviderSubagentStore = create<ProviderSubagentState>((set) => (
descriptors: new Map(),
timelines: new Map(),
hiddenFromTrack: new Set(),
hideFinishedForParent(serverId, parentAgentId) {
hideFromTrack(serverId, parentAgentId, subagentIds) {
set((state) => {
const prefix = parentPrefix(serverId, parentAgentId);
const hiddenFromTrack = new Set(state.hiddenFromTrack);
for (const [key, subagent] of state.descriptors) {
if (key.startsWith(prefix) && subagent.status !== "running") {
hiddenFromTrack.add(key);
}
for (const subagentId of subagentIds) {
const key = providerSubagentKey(serverId, parentAgentId, subagentId);
if (state.descriptors.get(key)?.status !== "running") hiddenFromTrack.add(key);
}
return { hiddenFromTrack };
});
+1 -1
View File
@@ -116,7 +116,7 @@ describe("selectSubagentsForParent", () => {
toolCallId: "call-1",
},
});
store.hideFinishedForParent(SERVER_ID, "parent-a");
store.hideFromTrack(SERVER_ID, "parent-a", ["provider-child"]);
expect(
selectProviderSubagentsForParent(
@@ -76,7 +76,7 @@ describe("formatHeaderLabel", () => {
});
describe("countFinishedSubagents", () => {
it("counts only terminal provider-owned children", () => {
it("counts eligible managed and terminal provider-owned children", () => {
const providerRows: SubagentRow[] = [
{
kind: "provider",
@@ -110,6 +110,16 @@ describe("countFinishedSubagents", () => {
row({ id: "managed-idle", status: "idle" }),
...providerRows,
]),
).toBe(2);
});
it("excludes running and initializing managed children", () => {
expect(
countFinishedSubagents([
row({ id: "running", status: "running" }),
row({ id: "initializing", status: "initializing" }),
row({ id: "finished", status: "idle" }),
]),
).toBe(1);
});
});
@@ -1,6 +1,7 @@
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
import type { SubagentRow } from "./select";
import { isFinishedSubagent } from "./archive-finished";
import { providerSubagentLifecycleStatus } from "./provider-store";
function presentationStatus(row: SubagentRow) {
@@ -55,7 +56,7 @@ export function formatHeaderLabel(rows: readonly SubagentRow[]): string {
}
export function countFinishedSubagents(rows: readonly SubagentRow[]): number {
return rows.filter((row) => row.kind === "provider" && row.status !== "running").length;
return rows.filter(isFinishedSubagent).length;
}
export function resolveRowLabel(title: string | null | undefined): string | null {
+39 -3
View File
@@ -14,6 +14,7 @@ import {
} from "@/screens/workspace/workspace-tab-presentation";
import type { Theme } from "@/styles/theme";
import type { SubagentRow } from "./select";
import type { ArchiveFinishedStatus } from "./use-archive-finished";
import {
buildSubagentRowPresentationData,
countFinishedSubagents,
@@ -34,6 +35,7 @@ export interface SubagentsTrackProps {
onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void;
onArchiveSubagent: (id: string) => void;
onArchiveFinished?: () => void;
archiveFinishedStatus?: ArchiveFinishedStatus;
onDetachSubagent?: (id: string) => void;
}
@@ -55,6 +57,7 @@ export function SubagentsTrack({
onOpenProviderSubagent,
onArchiveSubagent,
onArchiveFinished,
archiveFinishedStatus = { kind: "idle" },
onDetachSubagent,
}: SubagentsTrackProps): ReactElement | null {
const { t } = useTranslation();
@@ -75,12 +78,15 @@ export function SubagentsTrack({
);
const headerAccessibilityState = useMemo(() => ({ expanded }), [expanded]);
if (rows.length === 0) {
const isArchivingFinished = archiveFinishedStatus.kind === "archiving";
const isArchiveFinishedFailed = archiveFinishedStatus.kind === "failed";
if (rows.length === 0 && !isArchivingFinished && !isArchiveFinishedFailed) {
return null;
}
const headerLabel = formatHeaderLabel(rows);
const finishedCount = countFinishedSubagents(rows);
const showArchiveFinished = finishedCount > 0 || isArchivingFinished || isArchiveFinishedFailed;
return (
<View style={styles.outer} testID="subagents-track">
@@ -100,7 +106,7 @@ export function SubagentsTrack({
>
{headerLabel}
</Button>
{finishedCount > 0 && onArchiveFinished ? (
{showArchiveFinished && onArchiveFinished ? (
<View style={styles.headerAction}>
<SubagentActionButton
accessibilityLabel={t("subagents.archiveFinishedAction")}
@@ -108,8 +114,28 @@ export function SubagentsTrack({
tooltipLabel={t("subagents.archiveFinishedTooltip")}
icon="archive"
visible
disabled={isArchivingFinished}
onPress={onArchiveFinished}
/>
{archiveFinishedStatus.kind === "archiving" ? (
<Text
style={styles.archiveFinishedStatus}
testID="subagents-track-archive-progress"
>
{archiveFinishedStatus.completedCount}/{archiveFinishedStatus.totalCount}
</Text>
) : null}
{archiveFinishedStatus.kind === "failed" ? (
<Text
style={styles.archiveFinishedStatus}
testID="subagents-track-archive-failed"
>
{t("subagents.archiveFinishedRetry", {
failed: archiveFinishedStatus.failedCount,
total: archiveFinishedStatus.totalCount,
})}
</Text>
) : null}
</View>
) : null}
</View>
@@ -275,6 +301,7 @@ function SubagentActionButton({
tooltipLabel,
icon,
visible,
disabled = false,
onPress,
}: {
accessibilityLabel: string;
@@ -282,15 +309,17 @@ function SubagentActionButton({
tooltipLabel: string;
icon: SubagentActionIcon;
visible: boolean;
disabled?: boolean;
onPress: () => void;
}): ReactElement {
return (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild disabled={!visible}>
<TooltipTrigger asChild disabled={!visible || disabled}>
<Pressable
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
testID={testID}
disabled={disabled}
onPress={onPress}
style={styles.actionButton}
hitSlop={8}
@@ -344,6 +373,13 @@ const styles = StyleSheet.create((theme) => ({
},
headerAction: {
paddingRight: theme.spacing[2],
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
archiveFinishedStatus: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
headerCollapsed: {
paddingBottom: theme.spacing[4],
@@ -0,0 +1,54 @@
import { useEffect, useMemo, useSyncExternalStore } from "react";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { useSessionStore } from "@/stores/session-store";
import {
createArchiveFinishedSubagents,
type ArchiveFinishedOutcome,
type ArchiveFinishedState,
type ArchiveFinishedSubagents,
} from "./archive-finished";
import { useProviderSubagentStore } from "./provider-store";
import type { SubagentRow } from "./select";
export type { ArchiveFinishedStatus } from "./archive-finished";
export interface UseArchiveFinishedSubagentsInput {
serverId: string;
parentAgentId: string;
rows: readonly SubagentRow[];
}
export interface ArchiveFinishedSubagentsCapability extends ArchiveFinishedState {
archiveFinished: () => Promise<ArchiveFinishedOutcome>;
}
export function useArchiveFinishedSubagents({
serverId,
parentAgentId,
rows,
}: UseArchiveFinishedSubagentsInput): ArchiveFinishedSubagentsCapability {
const { archiveAgent } = useArchiveAgent();
const archiveFinished = useMemo<ArchiveFinishedSubagents>(
() =>
createArchiveFinishedSubagents([], {
parentAgentId,
getManagedSubagent: (id) => useSessionStore.getState().sessions[serverId]?.agents.get(id),
archiveManagedSubagent: (id) => archiveAgent({ serverId, agentId: id }),
dismissProviderSubagents: (ids) => {
useProviderSubagentStore.getState().hideFromTrack(serverId, parentAgentId, ids);
},
}),
[archiveAgent, parentAgentId, serverId],
);
const state = useSyncExternalStore(
archiveFinished.subscribe,
archiveFinished.getState,
archiveFinished.getState,
);
useEffect(() => {
archiveFinished.setRows(rows);
}, [archiveFinished, rows]);
return { ...state, archiveFinished: archiveFinished.archiveFinished };
}
@@ -1,16 +0,0 @@
import { useCallback } from "react";
import { useProviderSubagentStore } from "./provider-store";
export interface UseHideFinishedProviderSubagentsInput {
serverId: string;
parentAgentId: string;
}
export function useHideFinishedProviderSubagents({
serverId,
parentAgentId,
}: UseHideFinishedProviderSubagentsInput): () => void {
return useCallback(() => {
useProviderSubagentStore.getState().hideFinishedForParent(serverId, parentAgentId);
}, [parentAgentId, serverId]);
}
@@ -5,6 +5,7 @@ import {
isTimelineResumeSnapshotAuthoritative,
planTimelineOlderFetch,
planTimelinePromptJump,
planTimelineResumeFetch,
planTimelineTailFetch,
} from "./timeline-sync-plan";
@@ -19,6 +20,15 @@ describe("timeline sync planning", () => {
});
});
test("a restored canonical range catches up after its exact end", () => {
expect(planTimelineResumeFetch({ epoch: "epoch-1", endSeq: 49 })).toEqual({
direction: "after",
cursor: { epoch: "epoch-1", seq: 49 },
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "projected",
});
});
test("older history loads one bounded page before the start cursor", () => {
const plan = planTimelineOlderFetch({ epoch: "epoch-1", seq: 25 });
@@ -51,6 +51,14 @@ export function planTimelineTailFetch() {
} as const;
}
export function planTimelineResumeFetch(
range: { epoch: string; endSeq: number } | null | undefined,
): ProjectedTimelineForwardFetchPlan {
return range
? planTimelineCatchUpAfter({ epoch: range.epoch, seq: range.endSeq })
: planTimelineTailFetch();
}
export function planTimelineOlderFetch(cursor: TimelineSyncCursor) {
return {
direction: "before",
@@ -33,6 +33,7 @@ interface TimelineFetch {
class TimelineWorld {
readonly errors: string[] = [];
readonly cursors = new Map<string, { epoch: string; endSeq: number }>();
readonly sync = createViewedTimelineSync({
initialDeliveryMode: "selective",
setSubscription: async (agentIds) => {
@@ -45,6 +46,7 @@ class TimelineWorld {
this.releaseMembershipWaiter();
return result.promise;
},
readCursor: (agentId) => this.cursors.get(agentId),
fetchPage: async (agentId, request) => {
const result = deferred<{
hasNewer: boolean;
@@ -63,6 +65,12 @@ class TimelineWorld {
this.releaseFetchWaiters();
return result.promise;
},
fetchLatestTail: (agentId) =>
this.fetchTimeline(agentId, {
direction: "tail",
limit: 40,
projection: "projected",
}),
reportError: (error) => {
this.errors.push(error instanceof Error ? error.message : String(error));
const waiter = this.errorWaiters.shift();
@@ -94,6 +102,25 @@ class TimelineWorld {
private readonly scheduled: Array<{ task: () => void; delayMs: number }> = [];
private readonly retryWaiters: Array<(retry: () => void) => void> = [];
private fetchTimeline(
agentId: string,
request: ProjectedTimelineForwardFetchPlan,
): Promise<{ hasNewer: boolean; endCursor: { epoch: string; seq: number } | null }> {
const result = deferred<{
hasNewer: boolean;
endCursor: { epoch: string; seq: number } | null;
}>();
this.fetches.push({
agentId,
request,
respond: ({ hasNewer, seq = 1 }) =>
result.resolve({ hasNewer, endCursor: { epoch: `epoch-${agentId}`, seq } }),
fail: (message) => result.reject(new Error(message)),
});
this.releaseFetchWaiters();
return result.promise;
}
nextMembership(): Promise<MembershipRequest> {
const request = this.memberships.shift();
if (request) return Promise.resolve(request);
@@ -166,6 +193,41 @@ test("uses a tail fetch when an agent becomes visible", async () => {
fetch.respond({ hasNewer: false });
});
test("catches up after the restored cursor when an agent becomes visible", async () => {
const world = new TimelineWorld();
world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 42 });
world.sync.setConnected(true);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const membership = await world.nextMembership();
membership.succeed();
const fetch = await world.nextFetch("agent-a");
expect(fetch.request).toEqual({
direction: "after",
cursor: { epoch: "epoch-agent-a", seq: 42 },
limit: 40,
projection: "projected",
});
fetch.respond({ hasNewer: false });
});
test("falls back to the latest tail when a restored cursor has more than one catch-up page", async () => {
const world = new TimelineWorld();
world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 42 });
world.sync.setConnected(true);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const membership = await world.nextMembership();
membership.succeed();
const probe = await world.nextFetch("agent-a");
expect(probe.request.direction).toBe("after");
probe.respond({ hasNewer: true, seq: 82 });
const fallback = await world.nextFetch("agent-a");
expect(fallback.request).toEqual({ direction: "tail", limit: 40, projection: "projected" });
fallback.respond({ hasNewer: false });
});
test("a gap absorbed by a running tail is recovered after the tail completes", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
@@ -1,6 +1,6 @@
import {
planTimelineCatchUpAfter,
planTimelineTailFetch,
planTimelineResumeFetch,
type ProjectedTimelineForwardFetchPlan,
} from "./timeline-sync-plan";
@@ -12,10 +12,12 @@ interface TimelinePageResult {
interface ViewedTimelineSyncPorts {
initialDeliveryMode: TimelineDeliveryMode;
setSubscription(agentIds: string[]): Promise<void>;
readCursor(agentId: string): { epoch: string; endSeq: number } | undefined;
fetchPage(
agentId: string,
request: ProjectedTimelineForwardFetchPlan,
): Promise<TimelinePageResult>;
fetchLatestTail(agentId: string): Promise<TimelinePageResult>;
reportError(error: unknown): void;
schedule(task: () => void, delayMs: number): () => void;
}
@@ -136,6 +138,12 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
const isAcknowledged = (agentId: string) => acknowledged.includes(agentId);
const isDesired = (agentId: string) => desired.includes(agentId);
const ownsCatchUp = (agentId: string, generation: number) =>
!disposed &&
connected &&
isDesired(agentId) &&
isAcknowledged(agentId) &&
catchUps.get(agentId)?.generation === generation;
const notifyListeners = () => {
for (const listener of listeners) listener();
@@ -168,30 +176,26 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
agentId: string,
generation: number,
request: ProjectedTimelineForwardFetchPlan,
fallbackToLatestTailOnOverflow: boolean,
): Promise<void> => {
if (
disposed ||
!connected ||
!isDesired(agentId) ||
!isAcknowledged(agentId) ||
catchUps.get(agentId)?.generation !== generation
) {
return;
}
if (!ownsCatchUp(agentId, generation)) return;
try {
const page = await ports.fetchPage(agentId, request);
if (
disposed ||
!connected ||
!isDesired(agentId) ||
!isAcknowledged(agentId) ||
catchUps.get(agentId)?.generation !== generation
) {
return;
}
if (!ownsCatchUp(agentId, generation)) return;
if (page.hasNewer && page.endCursor) {
await fetchUntilCurrent(agentId, generation, planTimelineCatchUpAfter(page.endCursor));
if (fallbackToLatestTailOnOverflow) {
await ports.fetchLatestTail(agentId);
catchUps.set(agentId, { generation, status: "complete" });
setVisibilityCatchUpReady(agentId);
return;
}
await fetchUntilCurrent(
agentId,
generation,
planTimelineCatchUpAfter(page.endCursor),
false,
);
return;
}
if (page.hasNewer) {
@@ -230,7 +234,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
if (request) pendingCatchUps.set(agentId, request);
return;
}
const nextRequest = request ?? planTimelineTailFetch();
const nextRequest = request ?? planTimelineResumeFetch(ports.readCursor(agentId));
const current = catchUps.get(agentId);
const decision = decideCatchUp({ current, request: nextRequest, supersede });
if (decision === "keep-and-park") {
@@ -245,7 +249,12 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
catchUpGenerations.set(agentId, generation);
catchUps.set(agentId, { generation, status: "running", request: nextRequest });
pendingCatchUps.delete(agentId);
void fetchUntilCurrent(agentId, generation, nextRequest);
void fetchUntilCurrent(
agentId,
generation,
nextRequest,
request === undefined && nextRequest.direction === "after",
);
};
const startAcknowledgedCatchUps = () => {
+1
View File
@@ -517,6 +517,7 @@ export const HOST_SECTION_SLUGS = [
"providers",
"usage",
"terminals",
"plugins",
"host",
] as const;
@@ -7,14 +7,28 @@ const testTheme = {
statusDanger: "#b91c1c",
statusWarning: "#d97706",
statusMerged: "#7c3aed",
accent: "#2563eb",
accentForeground: "#ffffff",
destructive: "#dc2626",
destructiveForeground: "#ffffff",
surface1: "#fafafa",
surface2: "#f4f4f5",
surface3: "#e4e4e7",
border: "#e4e4e7",
borderAccent: "#a1a1aa",
palette: {
amber: { 500: "#f59e0b" },
blue: { 300: "#93c5fd" },
red: { 300: "#fca5a5" },
white: "#ffffff",
},
},
borderWidth: { 1: 1 },
spacing: [0, 4, 8, 12, 16, 20, 24, 28, 32],
fontSize: {
xs: 12,
sm: 14,
base: 16,
},
fontWeight: {
normal: "400",
@@ -23,7 +37,12 @@ const testTheme = {
borderRadius: {
base: 4,
md: 6,
lg: 8,
xl: 12,
full: 9999,
},
iconSize: { sm: 16 },
opacity: { 50: 0.5 },
};
type StyleFactory<T> = (theme: typeof testTheme) => T;
+20
View File
@@ -55,4 +55,24 @@ describe("canonical CLI surface", () => {
expect(open?.helpInformation()).toContain("<agent-id>");
expect(open?.helpInformation()).toContain("--server <server-id>");
});
it("offers the complete local plugin lifecycle", () => {
const plugin = createCli().commands.find((command) => command.name() === "plugin");
expect(plugin?.commands.map((command) => command.name())).toEqual([
"init",
"ls",
"install",
"reload",
"enable",
"disable",
"remove",
]);
expect(
plugin?.commands.find((command) => command.name() === "init")?.helpInformation(),
).toContain("--id <id>");
expect(
plugin?.commands.find((command) => command.name() === "install")?.helpInformation(),
).toContain("--id <id>");
});
});
+2
View File
@@ -3,6 +3,7 @@ import { createAgentCommand } from "./commands/agent/index.js";
import { createDaemonCommand } from "./commands/daemon/index.js";
import { createPermitCommand } from "./commands/permit/index.js";
import { createProviderCommand } from "./commands/provider/index.js";
import { createPluginCommand } from "./commands/plugin/index.js";
import { createScheduleCommand } from "./commands/schedule/index.js";
import { createSpeechCommand } from "./commands/speech/index.js";
import { createScriptCommand } from "./commands/script/index.js";
@@ -182,6 +183,7 @@ export function createCli(): Command {
// Provider commands
program.addCommand(createProviderCommand());
program.addCommand(createPluginCommand());
// Speech model commands
program.addCommand(createSpeechCommand());
@@ -0,0 +1,25 @@
import { describe, expect, it, vi } from "vitest";
const listPlugins = vi.fn(async () => []);
const close = vi.fn(async () => undefined);
vi.mock("../../utils/client.js", () => ({
connectToDaemon: vi.fn(async () => ({
getLastServerInfoMessage: () => ({ features: { pluginManagement: false } }),
listPlugins,
close,
})),
}));
import { runPluginListCommand } from "./index.js";
describe("plugin management commands", () => {
it("requires host support before attempting a management RPC", async () => {
await expect(runPluginListCommand({}, {} as never)).rejects.toMatchObject({
code: "DAEMON_UPDATE_REQUIRED",
message: "Update the host to use plugin management.",
});
expect(listPlugins).not.toHaveBeenCalled();
expect(close).toHaveBeenCalledTimes(1);
});
});

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