mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-01 14:12:51 +00:00
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:
@@ -21,6 +21,7 @@ on:
|
||||
- "packages/workspace-runtime-contract/**"
|
||||
- "packages/expo-two-way-audio/**"
|
||||
- "packages/highlight/**"
|
||||
- "packages/plugin/**"
|
||||
- "packages/protocol/**"
|
||||
- "packages/relay/**"
|
||||
- "packages/server/**"
|
||||
|
||||
@@ -10,6 +10,7 @@ on:
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "packages/highlight/**"
|
||||
- "packages/plugin/**"
|
||||
- "packages/server/**"
|
||||
- "packages/relay/**"
|
||||
- "packages/cli/**"
|
||||
|
||||
@@ -12,6 +12,7 @@ on:
|
||||
- "packages/app/**"
|
||||
- "packages/expo-two-way-audio/**"
|
||||
- "packages/highlight/**"
|
||||
- "packages/plugin/**"
|
||||
- "packages/protocol/**"
|
||||
- "packages/client/**"
|
||||
- "packages/server/**"
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -1 +1 @@
|
||||
sha256-QFP2lkZBO/MOqgSyO2NmWVHEn6jsiwo7VKX42iKzh70=
|
||||
sha256-MgMvQC+EGJ8gGosqel8oC88pMzf2Mtetd2D8Yg5VFAM=
|
||||
|
||||
Generated
+528
@@ -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
@@ -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> {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 用于工作区标题、分支名称、提交消息和拉取请求草稿的模型",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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],
|
||||
},
|
||||
}));
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
@@ -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: [],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user