mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-31 13:42:50 +00:00
Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c54be00e99 | ||
|
|
59661f2bb8 | ||
|
|
06fae60809 | ||
|
|
95575ad05a | ||
|
|
7ea9286aa4 | ||
|
|
07125d2c02 | ||
|
|
0c38749c38 | ||
|
|
ecec33265e | ||
|
|
22463bb7e5 | ||
|
|
c56638ea8c | ||
|
|
185ab3f300 | ||
|
|
1694828275 | ||
|
|
74a377ff6a | ||
|
|
63923fdd07 | ||
|
|
11e76f6655 | ||
|
|
4a3e6dea63 | ||
|
|
f35d860725 | ||
|
|
95f4eb7577 | ||
|
|
fcd81f591e | ||
|
|
b0fe4efc8c | ||
|
|
f2f93087ed | ||
|
|
73a5e6c63e | ||
|
|
fa53ba8f93 | ||
|
|
375e9b3e53 | ||
|
|
adf80a9fe0 |
+35
-1
@@ -1,30 +1,64 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## 0.7.0-beta.1 - 2026-08-27
|
## 0.7.0 - 2026-08-31
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Changed the project license to Apache-2.0 ([#3944](https://github.com/getpaseo/paseo/pull/3944))
|
- Changed the project license to Apache-2.0 ([#3944](https://github.com/getpaseo/paseo/pull/3944))
|
||||||
- Changed Cmd/Ctrl+E to toggle Explorer visibility without selecting a view ([#3896](https://github.com/getpaseo/paseo/pull/3896))
|
- Changed Cmd/Ctrl+E to toggle Explorer visibility without selecting a view ([#3896](https://github.com/getpaseo/paseo/pull/3896))
|
||||||
|
- Changed the default mobile content text size from 15px to 16px ([#4111](https://github.com/getpaseo/paseo/pull/4111))
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Added plugin installation and updates directly from Git repositories ([#3920](https://github.com/getpaseo/paseo/pull/3920))
|
- Added plugin installation and updates directly from Git repositories ([#3920](https://github.com/getpaseo/paseo/pull/3920))
|
||||||
- Added plugin-defined timeline transformations and rendering ([#3940](https://github.com/getpaseo/paseo/pull/3940))
|
- Added plugin-defined timeline transformations and rendering ([#3940](https://github.com/getpaseo/paseo/pull/3940))
|
||||||
|
- Added SSH connections to existing remote daemons from Desktop and CLI ([#3989](https://github.com/getpaseo/paseo/pull/3989) by [@reidlevesque](https://github.com/reidlevesque))
|
||||||
|
- Added workspace management and layout actions to the Command Center ([#3013](https://github.com/getpaseo/paseo/pull/3013) by [@cleiter](https://github.com/cleiter))
|
||||||
|
- Added contextual composer-pill contributions for plugins ([#3956](https://github.com/getpaseo/paseo/pull/3956))
|
||||||
|
- Added host UI primitives for client plugins ([#3967](https://github.com/getpaseo/paseo/pull/3967))
|
||||||
|
- Added readable prompts, details, and results for Paseo tool calls ([#4066](https://github.com/getpaseo/paseo/pull/4066))
|
||||||
|
- Added zoom and pan to workspace images and assistant timeline previews ([#4032](https://github.com/getpaseo/paseo/pull/4032), [#4049](https://github.com/getpaseo/paseo/pull/4049), [#4064](https://github.com/getpaseo/paseo/pull/4064))
|
||||||
- Added opening child folders directly in the preferred desktop editor ([#3615](https://github.com/getpaseo/paseo/pull/3615) by [@caikovsky](https://github.com/caikovsky))
|
- Added opening child folders directly in the preferred desktop editor ([#3615](https://github.com/getpaseo/paseo/pull/3615) by [@caikovsky](https://github.com/caikovsky))
|
||||||
|
- Added host navigation from plugin surfaces to agents and workspaces ([#3901](https://github.com/getpaseo/paseo/pull/3901) by [@omercnet](https://github.com/omercnet))
|
||||||
- Added host-rendered icons to plugin client surfaces ([#3903](https://github.com/getpaseo/paseo/pull/3903) by [@omercnet](https://github.com/omercnet))
|
- Added host-rendered icons to plugin client surfaces ([#3903](https://github.com/getpaseo/paseo/pull/3903) by [@omercnet](https://github.com/omercnet))
|
||||||
- Added semantic surface, border, success, and warning colors to plugin themes ([#3898](https://github.com/getpaseo/paseo/pull/3898) by [@omercnet](https://github.com/omercnet))
|
- Added semantic surface, border, success, and warning colors to plugin themes ([#3898](https://github.com/getpaseo/paseo/pull/3898) by [@omercnet](https://github.com/omercnet))
|
||||||
- Added registered project listing to the public plugin SDK ([#3899](https://github.com/getpaseo/paseo/pull/3899) by [@omercnet](https://github.com/omercnet))
|
- Added registered project listing to the public plugin SDK ([#3899](https://github.com/getpaseo/paseo/pull/3899) by [@omercnet](https://github.com/omercnet))
|
||||||
|
- Added PR and MR number search to the Command Center ([#3008](https://github.com/getpaseo/paseo/pull/3008) by [@cleiter](https://github.com/cleiter))
|
||||||
|
- Added session commands to agent SDK handles ([#3719](https://github.com/getpaseo/paseo/pull/3719) by [@gpambrozio](https://github.com/gpambrozio), [@marvin-ambrozio](https://github.com/marvin-ambrozio))
|
||||||
- Added F-Droid release metadata and stable changelog generation ([#2501](https://github.com/getpaseo/paseo/pull/2501) by [@antonok-edm](https://github.com/antonok-edm))
|
- Added F-Droid release metadata and stable changelog generation ([#2501](https://github.com/getpaseo/paseo/pull/2501) by [@antonok-edm](https://github.com/antonok-edm))
|
||||||
|
- Added Astro syntax highlighting to files, the editor, and diffs ([#3997](https://github.com/getpaseo/paseo/pull/3997))
|
||||||
|
|
||||||
### Improved
|
### Improved
|
||||||
|
|
||||||
- Streamed assistant text at a steady visual rate instead of in arrival lumps ([#3612](https://github.com/getpaseo/paseo/pull/3612) by [@Tommypop2](https://github.com/Tommypop2))
|
- Streamed assistant text at a steady visual rate instead of in arrival lumps ([#3612](https://github.com/getpaseo/paseo/pull/3612) by [@Tommypop2](https://github.com/Tommypop2))
|
||||||
- Painted cached conversations before unrelated cache restoration and network loading ([#3907](https://github.com/getpaseo/paseo/pull/3907))
|
- Painted cached conversations before unrelated cache restoration and network loading ([#3907](https://github.com/getpaseo/paseo/pull/3907))
|
||||||
|
- Reused one OpenCode helper across ordinary agent sessions instead of spawning one per agent ([#4009](https://github.com/getpaseo/paseo/pull/4009))
|
||||||
|
- Batched GitHub pull request polling within the reserved API budget ([#3825](https://github.com/getpaseo/paseo/pull/3825) by [@dezchai](https://github.com/dezchai))
|
||||||
- Added manual, action-required, and warning states to GitLab and Gitea checks ([#2337](https://github.com/getpaseo/paseo/pull/2337) by [@nllptrx](https://github.com/nllptrx))
|
- Added manual, action-required, and warning states to GitLab and Gitea checks ([#2337](https://github.com/getpaseo/paseo/pull/2337) by [@nllptrx](https://github.com/nllptrx))
|
||||||
|
- Kept cold-start pull request status checks on the shared GitHub batch path ([#4025](https://github.com/getpaseo/paseo/pull/4025))
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the daemon crashing when a Claude or OMP JSONL process closes during a write ([#4048](https://github.com/getpaseo/paseo/pull/4048))
|
||||||
|
- Fixed Android first-message submissions crashing when the keyboard restarted during composer teardown ([#4044](https://github.com/getpaseo/paseo/pull/4044))
|
||||||
|
- Fixed long mobile drafts hiding composer controls behind the keyboard ([#4051](https://github.com/getpaseo/paseo/pull/4051))
|
||||||
|
- Fixed Android timelines shifting when tapping selectable text ([#4090](https://github.com/getpaseo/paseo/pull/4090))
|
||||||
|
- Fixed web and Electron composers losing focus after submission ([#4067](https://github.com/getpaseo/paseo/pull/4067))
|
||||||
|
- Fixed Android dictation leaving Bluetooth audio in call-quality routing after capture stopped ([#4069](https://github.com/getpaseo/paseo/pull/4069))
|
||||||
|
- Fixed dictation retries timing out after an abandoned partial transcript ([#4065](https://github.com/getpaseo/paseo/pull/4065))
|
||||||
|
- Fixed immediate dictation submissions omitting the newest speech segment ([#3968](https://github.com/getpaseo/paseo/pull/3968))
|
||||||
|
- Fixed the model selector crashing in iPad desktop layouts ([#3992](https://github.com/getpaseo/paseo/pull/3992) by [@yzim](https://github.com/yzim))
|
||||||
|
- Fixed OpenCode child-session prompts missing from provider subagent timelines ([#4055](https://github.com/getpaseo/paseo/pull/4055) by [@mcowger](https://github.com/mcowger))
|
||||||
|
- Fixed archived agents disappearing when opened from History ([#4033](https://github.com/getpaseo/paseo/pull/4033))
|
||||||
|
- Fixed restored workspace tabs entering reconciliation loops ([#3987](https://github.com/getpaseo/paseo/pull/3987))
|
||||||
|
- Fixed archived workspaces returning from durable cache ([#3975](https://github.com/getpaseo/paseo/pull/3975))
|
||||||
|
- Fixed Grok unified-billing accounts showing zero credits instead of weekly usage ([#4029](https://github.com/getpaseo/paseo/pull/4029) by [@Lite-G](https://github.com/Lite-G), [@claude](https://github.com/claude))
|
||||||
|
- Fixed daemon reconnects stalling while Git processes were queued ([#3945](https://github.com/getpaseo/paseo/pull/3945))
|
||||||
|
- Fixed slow Pi and OMP startup requests timing out after 30 seconds ([#4008](https://github.com/getpaseo/paseo/pull/4008))
|
||||||
|
- Fixed commits and squash merges bypassing configured Git signing ([#3976](https://github.com/getpaseo/paseo/pull/3976))
|
||||||
|
- Fixed Android system navigation covering the Commits controls ([#4005](https://github.com/getpaseo/paseo/pull/4005))
|
||||||
|
- Fixed new agent work appearing as reopened tasks ([#4068](https://github.com/getpaseo/paseo/pull/4068))
|
||||||
|
- Fixed Markdown literal characters being replaced by typographic symbols ([#3253](https://github.com/getpaseo/paseo/pull/3253) by [@cleiter](https://github.com/cleiter))
|
||||||
- Fixed rewinding long conversations replaying the complete rebuilt timeline ([#3642](https://github.com/getpaseo/paseo/pull/3642))
|
- Fixed rewinding long conversations replaying the complete rebuilt timeline ([#3642](https://github.com/getpaseo/paseo/pull/3642))
|
||||||
- Fixed rewound prompts remaining blank in the mounted iOS composer ([#3946](https://github.com/getpaseo/paseo/pull/3946))
|
- Fixed rewound prompts remaining blank in the mounted iOS composer ([#3946](https://github.com/getpaseo/paseo/pull/3946))
|
||||||
- Fixed native plugin setup and async client callbacks failing on iOS ([#3942](https://github.com/getpaseo/paseo/pull/3942))
|
- Fixed native plugin setup and async client callbacks failing on iOS ([#3942](https://github.com/getpaseo/paseo/pull/3942))
|
||||||
|
|||||||
@@ -404,7 +404,7 @@ Custom OMP profiles should extend `omp`. They inherit the OMP adapter's `rpc-ui`
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`params.sessionDir` is used only for importing sessions that were started outside Paseo. If `command` or XDG env vars move OMP's state directory, set `params.sessionDir` to the resulting OMP JSONL session directory; launching and resuming still go through the configured command. `params.rpcTimeoutMs` overrides the 60-second OMP control-plane RPC deadline.
|
`params.sessionDir` is used only for importing sessions that were started outside Paseo. If `command` or XDG env vars move OMP's state directory, set `params.sessionDir` to the resulting OMP JSONL session directory; launching and resuming still go through the configured command. OMP waits 20 seconds for its initial `ready` frame and 60 seconds for later control-plane RPCs by default. `params.rpcTimeoutMs` overrides both deadlines.
|
||||||
|
|
||||||
For other providers that keep Pi's `--mode rpc` API but write sessions somewhere else, extend `pi`, replace the command, and provide the JSONL session directory:
|
For other providers that keep Pi's `--mode rpc` API but write sessions somewhere else, extend `pi`, replace the command, and provide the JSONL session directory:
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -30,7 +30,7 @@ Before adding a new component, read `components/ui/`. The primitive usually exis
|
|||||||
|
|
||||||
Hierarchy is conveyed through weight and color, not size. Most interface text is `fontSize.base`; compact metadata and hints use `fontSize.sm`. The distinction between a row's primary line and its secondary line is `foreground` versus `foregroundMuted`.
|
Hierarchy is conveyed through weight and color, not size. Most interface text is `fontSize.base`; compact metadata and hints use `fontSize.sm`. The distinction between a row's primary line and its secondary line is `foreground` versus `foregroundMuted`.
|
||||||
|
|
||||||
The authored interface ramp uses a 14px base. New native installs default to 15px; web and desktop default to 14px. The Appearance **Interface size** setting is the rendered `fontSize.base` value and scales the rest of the UI ramp proportionally. Primary readable content has its own `fontSize.content`, which defaults to 15px on every platform. It owns message bodies, composer input, Markdown, and PR prose. Controls, navigation, metadata, tool chrome, code, diffs, editors, and terminals stay on their interface or code tokens. **Code size** remains independent.
|
The authored interface ramp uses a 14px base. New native installs default to 15px; web and desktop default to 14px. The Appearance **Interface size** setting is the rendered `fontSize.base` value and scales the rest of the UI ramp proportionally. Primary readable content has its own `fontSize.content`, which defaults to 16px on native and 15px on web and desktop. It owns message bodies, composer input, Markdown, and PR prose. Controls, navigation, metadata, tool chrome, code, diffs, editors, and terminals stay on their interface or code tokens. **Code size** remains independent.
|
||||||
|
|
||||||
Weight has three tiers, applied by role:
|
Weight has three tiers, applied by role:
|
||||||
|
|
||||||
|
|||||||
+10
-6
@@ -17,17 +17,21 @@ The daemon persists a relationship ID and private connection credential before e
|
|||||||
relationship is independent of its current transport, so a future transport can replace the direct
|
relationship is independent of its current transport, so a future transport can replace the direct
|
||||||
WebSocket without pairing again. The current foundation supports one Hub relationship per daemon.
|
WebSocket without pairing again. The current foundation supports one Hub relationship per daemon.
|
||||||
|
|
||||||
Normal authenticated daemon sessions may run the `hub.management.daemon.connect`,
|
Normal authenticated daemon sessions may manage the daemon's Hub relationship and permissions.
|
||||||
`hub.management.daemon.get_status`, and `hub.management.daemon.disconnect` RPCs. Hub connections
|
Hub connections have no daemon permissions by default. Connecting gives Hub machine identity and
|
||||||
receive only `hub.execution.*` authority, so execution credentials cannot manage the relationship.
|
presence but no execution authority. The `hub.execute` permission lets workflows triggered from
|
||||||
|
GitHub, Slack, Discord, Linear, and other integrations create workspaces and run agents. Grant it
|
||||||
|
during interactive login or later with `paseo hub permissions grant hub.execute`. Relationships
|
||||||
|
created before this split migrate their legacy execution scope to `hub.execute`. Hub sessions cannot
|
||||||
|
manage their own relationship or permissions.
|
||||||
|
|
||||||
## Session grants and execution ownership
|
## Session grants and execution ownership
|
||||||
|
|
||||||
Trusted clients and the Hub use the same `Session` implementation. The connection boundary supplies
|
Trusted clients and the Hub use the same `Session` implementation. The connection boundary supplies
|
||||||
semantic permissions: trusted clients receive the owner permission set, while an enrolled Hub
|
semantic permissions: trusted clients receive the owner permission set, while an enrolled Hub
|
||||||
connection receives `hub.execute`. The current enrollment and persistence format still carries
|
connection receives its persisted permissions. Legacy relationship files carrying
|
||||||
`hub.execution.*`; one compatibility boundary translates it before the session is created. A denied
|
`hub.execution.*` migrate once to `hub.execute` at the persistence boundary. A denied request returns
|
||||||
request returns the ordinary `rpc_error` shape. See [permissions.md](permissions.md).
|
the ordinary `rpc_error` shape. See [permissions.md](permissions.md).
|
||||||
|
|
||||||
The Hub connection still has a narrow lifecycle boundary: it has no trusted-client hello/resume,
|
The Hub connection still has a narrow lifecycle boundary: it has no trusted-client hello/resume,
|
||||||
browser, binary, retained-session, or broadcast state. Its outbound execution events include only
|
browser, binary, retained-session, or broadcast state. Its outbound execution events include only
|
||||||
|
|||||||
@@ -30,8 +30,10 @@ The UI worklet owns transient motion:
|
|||||||
- the active gesture's starting revision
|
- the active gesture's starting revision
|
||||||
- the last settled target
|
- the last settled target
|
||||||
|
|
||||||
React also owns presentation lifecycle: whether an overlay is mounted/displayed and whether it may
|
React publishes the active panel only after its motion reaches the final anchor. Retained content
|
||||||
receive pointer events. Worklets never own `display` or `pointerEvents`.
|
never observes gesture previews, progress, or an unsettled target. The gesture hosts stay mounted;
|
||||||
|
worklets reveal their retained overlays through native styles while React owns pointer events and
|
||||||
|
accessibility.
|
||||||
|
|
||||||
## Why one position
|
## Why one position
|
||||||
|
|
||||||
@@ -85,8 +87,8 @@ definition, no longer eligible to begin.
|
|||||||
- Animated panel nodes use React Native static styles plus inline theme values. Do not attach
|
- Animated panel nodes use React Native static styles plus inline theme values. Do not attach
|
||||||
Unistyles-generated styles to those nodes; Unistyles and Reanimated patching the same Fabric node
|
Unistyles-generated styles to those nodes; Unistyles and Reanimated patching the same Fabric node
|
||||||
has caused native crashes.
|
has caused native crashes.
|
||||||
- The plain React wrapper owns `display: none` after settlement. This prevents a stale Fabric animated
|
- Gesture start and progress must not update React state. The retained overlay is already mounted and
|
||||||
prop commit from resurrecting a closed overlay.
|
offscreen; only the shared position and its derived native styles move during a drag.
|
||||||
- Hidden tabs and workspaces use `RetainedPanel`. It owns a non-collapsible native root, visibility,
|
- Hidden tabs and workspaces use `RetainedPanel`. It owns a non-collapsible native root, visibility,
|
||||||
pointer events, and the active signal consumed by `useRetainedPanelActive`.
|
pointer events, and the active signal consumed by `useRetainedPanelActive`.
|
||||||
- Panels whose gesture wrapper already owns visibility use `RetainedPanelActivity` to provide the
|
- Panels whose gesture wrapper already owns visibility use `RetainedPanelActivity` to provide the
|
||||||
@@ -98,8 +100,8 @@ definition, no longer eligible to begin.
|
|||||||
- Retention order and render order are separate concerns. LRU metadata may change on every switch;
|
- Retention order and render order are separate concerns. LRU metadata may change on every switch;
|
||||||
keyed retained roots must keep a stable sibling order. Moving large retained roots triggered Fabric
|
keyed retained roots must keep a stable sibling order. Moving large retained roots triggered Fabric
|
||||||
Differ failures (`addViewAt` / `removeViewAt` view reuse) on Android.
|
Differ failures (`addViewAt` / `removeViewAt` view reuse) on Android.
|
||||||
- The newly active panel must be included in the same render that changes selection. Adding it from an
|
- `useIsMobilePanelActive` follows the settled target, not the requested target. Activation work may
|
||||||
effect creates a committed frame where every retained panel is hidden, which is a real blank screen.
|
begin only after the finishing animation completes; cancellation publishes nothing.
|
||||||
- Do not suspend retained native subtrees with `Suspense`/`react-freeze`. Suspension changes native
|
- Do not suspend retained native subtrees with `Suspense`/`react-freeze`. Suspension changes native
|
||||||
ownership and can detach descendants. Keep the tree mounted, stabilize its subscriptions/selectors,
|
ownership and can detach descendants. Keep the tree mounted, stabilize its subscriptions/selectors,
|
||||||
and use the retained-panel active signal to stop timers, polling, and other genuine background work.
|
and use the retained-panel active signal to stop timers, polling, and other genuine background work.
|
||||||
|
|||||||
+1
-1
@@ -56,4 +56,4 @@ The Hub authenticates as a service principal. Its locally selected grants decide
|
|||||||
|
|
||||||
Hub user and role identifiers remain opaque external subjects. The Hub may create and revoke linked daemon principals when granted `access.manage`; the daemon does not interpret accounts, organizations, or roles.
|
Hub user and role identifiers remain opaque external subjects. The Hub may create and revoke linked daemon principals when granted `access.manage`; the daemon does not interpret accounts, organizations, or roles.
|
||||||
|
|
||||||
The current Hub enrollment protocol still exchanges `hub.execution.*`. The daemon translates that legacy value to `hub.execute` once at the connection boundary. Remove the translation when Hub enrollment exchanges semantic permissions.
|
Hub enrollment and permission updates exchange these semantic permissions directly. Legacy persisted Hub relationships that contain `hub.execution.*` migrate once to `hub.execute` when the daemon loads them; new relationships never persist or emit transport scopes as authority.
|
||||||
|
|||||||
+38
-15
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
Local plugins contribute daemon RPCs, native app surfaces, workspace panels, Command Center items,
|
Local plugins contribute daemon RPCs, native app surfaces, workspace panels, Command Center items,
|
||||||
composer pills, app themes, and composer attachment sources from one `index.ts`. Paseo executes the server contribution in a
|
composer pills, app themes, and composer attachment sources from one `index.ts`. Paseo executes the server contribution in a
|
||||||
subprocess and evaluates the client contribution in the app runtime. Plugin code is trusted code;
|
subprocess and evaluates the client contribution in the app runtime.
|
||||||
Paseo does not sandbox it.
|
|
||||||
|
> **Trust every plugin you add.** `paseo plugin add` and `paseo plugin install` mean “I trust this codebase.” Plugins are unsandboxed: server code and Git preparation commands run with the daemon user's access on the daemon host, and client contributions run inside Paseo. The repository's dependencies and future updates are part of that trust decision. With `--host`, preparation runs on that remote daemon host.
|
||||||
|
|
||||||
## Install a directory source
|
## Install a directory source
|
||||||
|
|
||||||
@@ -47,15 +48,13 @@ my-plugin/
|
|||||||
paseo-plugin.json
|
paseo-plugin.json
|
||||||
index.ts
|
index.ts
|
||||||
main.client.tsx
|
main.client.tsx
|
||||||
paseo-plugin.d.ts
|
|
||||||
package.json
|
package.json
|
||||||
tsconfig.json
|
tsconfig.json
|
||||||
```
|
```
|
||||||
|
|
||||||
Paseo compiles TypeScript and TSX when loading the plugin, so these packages are development dependencies only.
|
The generated `package.json` installs `@getpaseo/plugin` and the other host modules as development
|
||||||
The generated declaration file supplies `@getpaseo/plugin` and `@getpaseo/plugin/server` types until the
|
dependencies for local typechecking and tests. Paseo compiles TypeScript and TSX and supplies the
|
||||||
SDK is distributed as a public package. Regenerate new plugins with the matching Paseo CLI when the
|
runtime modules, so consumers do not install these packages when adding the plugin.
|
||||||
SDK contract changes.
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -84,28 +83,52 @@ directory always wins over shorthand resolution.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
paseo plugin add owner/repository
|
paseo plugin add owner/repository
|
||||||
|
paseo plugin add https://gitlab.com/group/repository.git
|
||||||
paseo plugin add https://git.example.com/owner/repository.git
|
paseo plugin add https://git.example.com/owner/repository.git
|
||||||
paseo plugin add owner/monorepo --path plugins/review
|
paseo plugin add owner/monorepo:plugins/review
|
||||||
paseo plugin add owner/repository --ref main
|
paseo plugin add owner/repository --ref main
|
||||||
paseo plugin status
|
paseo plugin status
|
||||||
paseo plugin update review
|
paseo plugin update review
|
||||||
paseo plugin update --all
|
paseo plugin update --all
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Append `:relative/path` to the source when the plugin lives below the repository root.
|
||||||
|
|
||||||
Omitting `--ref` tracks the remote's default branch. A branch passed with `--ref` also tracks;
|
Omitting `--ref` tracks the remote's default branch. A branch passed with `--ref` also tracks;
|
||||||
tags and commits stay pinned. `status` fetches tracked refs and reports the installed and available
|
tags and commits stay pinned. `status` fetches tracked refs and reports the installed and available
|
||||||
commits. `update` checks out a new version, validates its manifest, compiles both bundles, and starts
|
commits. Removing a Git source deletes Paseo's managed checkout.
|
||||||
it before changing the configured path. A compilation or startup failure restores the running
|
|
||||||
version. Removing a Git source deletes Paseo's managed checkout.
|
|
||||||
|
|
||||||
Git installation does not run a package manager or install script. A distributable source plugin
|
### Declare Git preparation
|
||||||
uses Paseo's host-provided modules or commits any other bundled source it needs. Native React Native
|
|
||||||
dependencies work only when the Paseo app already ships the native module.
|
Most plugins should omit `build`. Use it only when the staged checkout must install a dependency
|
||||||
|
that Paseo does not provide, generate source or assets, or perform another required preparation
|
||||||
|
step:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "review",
|
||||||
|
"build": [
|
||||||
|
["npm", "ci"],
|
||||||
|
["npm", "run", "build"]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`build` is an optional list of argv arrays. Each array must contain at least one non-empty string;
|
||||||
|
shell command strings are rejected. Paseo starts the executable directly, without a shell, from the
|
||||||
|
plugin directory in the staged checkout. It never detects lockfiles or chooses a package manager.
|
||||||
|
|
||||||
|
On install and every update, Paseo resolves the exact Git revision and manifest, runs the declared
|
||||||
|
commands, then validates, compiles, and activates the candidate. It logs each argv command and its
|
||||||
|
output in the daemon log. If a command fails, the error includes its output, Paseo discards the
|
||||||
|
candidate, and the existing installed and running version stays untouched. On a remote daemon, all
|
||||||
|
of this happens on the remote daemon host.
|
||||||
|
|
||||||
Server contributions can write to stdout and stderr with normal Node logging. Paseo adds `[paseo]`
|
Server contributions can write to stdout and stderr with normal Node logging. Paseo adds `[paseo]`
|
||||||
entries for loading, ready, stopping, and stopped transitions. Compilation and load failures are
|
entries for loading, ready, stopping, and stopped transitions. Compilation and load failures are
|
||||||
recorded as stderr entries before a subprocess exists. Inspect the recent in-memory
|
recorded as stderr entries before a subprocess exists. Inspect the recent in-memory
|
||||||
tail from the host plugin settings or with `paseo plugin logs <id>`. Reload, disable, and process
|
tail from the host plugin settings or with `paseo plugin logs <id>`. Git preparation commands are
|
||||||
|
recorded in `$PASEO_HOME/daemon.log` before a plugin exists, rather than the plugin log tail. Reload, disable, and process
|
||||||
failure retain the tail; removing the plugin clears it. Daemon restarts do not retain the tail, but
|
failure retain the tail; removing the plugin clears it. Daemon restarts do not retain the tail, but
|
||||||
structured copies remain in `$PASEO_HOME/daemon.log`. Plugin output can contain secrets, so do not
|
structured copies remain in `$PASEO_HOME/daemon.log`. Plugin output can contain secrets, so do not
|
||||||
log credentials or tokens.
|
log credentials or tokens.
|
||||||
|
|||||||
+3
-3
@@ -67,7 +67,7 @@ Pi model records expose input capabilities through `model.input`. Only send raw
|
|||||||
|
|
||||||
Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loaded for the agent cwd. Probe with Pi RPC `get_commands`; the adapter registers an extension command named `mcp` (often with `sourceInfo.source` containing `pi-mcp-adapter`). When Paseo injects MCP servers into Pi, write a per-agent MCP config and pass it with `--mcp-config` instead of modifying user or project MCP files. Because that flag replaces the Pi global config layer, preserve the existing `<Pi agent dir>/mcp.json` in the generated file before overlaying injected servers. For local HTTP servers such as Paseo's own `/mcp/agents` endpoint, explicitly disable adapter OAuth (`auth: false`, `oauth: false`) in the generated config.
|
Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loaded for the agent cwd. Probe with Pi RPC `get_commands`; the adapter registers an extension command named `mcp` (often with `sourceInfo.source` containing `pi-mcp-adapter`). When Paseo injects MCP servers into Pi, write a per-agent MCP config and pass it with `--mcp-config` instead of modifying user or project MCP files. Because that flag replaces the Pi global config layer, preserve the existing `<Pi agent dir>/mcp.json` in the generated file before overlaying injected servers. For local HTTP servers such as Paseo's own `/mcp/agents` endpoint, explicitly disable adapter OAuth (`auth: false`, `oauth: false`) in the generated config.
|
||||||
|
|
||||||
Pi and OMP control-plane RPCs wait 60 seconds by default. Override the provider's `params.rpcTimeoutMs` when extension or MCP startup on a slow host needs a different deadline. Timeout errors name the pending RPC phase and report both elapsed time and the configured deadline. This setting does not govern long-running Pi compaction or Pi extension UI results.
|
Pi control-plane RPCs wait 60 seconds by default. Override `params.rpcTimeoutMs` when extension or MCP startup on a slow host needs more time. Timeout errors name the pending RPC phase and report both elapsed time and the configured deadline. This setting does not govern long-running Pi compaction or Pi extension UI results. See [OMP profiles and Pi-compatible forks](custom-providers.md#omp-profiles-and-pi-compatible-forks) for OMP startup and RPC deadlines.
|
||||||
|
|
||||||
Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does not expose a recent-session listing command. Resume and full history hydration still go through `pi --mode rpc` using the session file as `nativeHandle`.
|
Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does not expose a recent-session listing command. Resume and full history hydration still go through `pi --mode rpc` using the session file as `nativeHandle`.
|
||||||
|
|
||||||
@@ -85,9 +85,9 @@ OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prom
|
|||||||
|
|
||||||
`AgentManager` owns the one canonical timeline row for a foreground prompt carrying a Paseo `clientMessageId`. It records that row when `startTurn` accepts, with the wire `messageId` set to the same value. Provider adapters still emit their native user-message echo with the same `clientMessageId` when available; the manager records its provider identity on the internal row without changing or redispatching the wire item. If an adapter emits the echo before `startTurn` resolves, the manager records the provider identity with the row at acceptance. Provider adapters continue to own externally initiated user rows that have no Paseo client identity. Do not perform global transcript text dedupe.
|
`AgentManager` owns the one canonical timeline row for a foreground prompt carrying a Paseo `clientMessageId`. It records that row when `startTurn` accepts, with the wire `messageId` set to the same value. Provider adapters still emit their native user-message echo with the same `clientMessageId` when available; the manager records its provider identity on the internal row without changing or redispatching the wire item. If an adapter emits the echo before `startTurn` resolves, the manager records the provider identity with the row at acceptance. Provider adapters continue to own externally initiated user rows that have no Paseo client identity. Do not perform global transcript text dedupe.
|
||||||
|
|
||||||
Active-turn steering is an optional `AgentSession.steerActiveTurn` operation. The manager owns admission against its exact foreground turn, canonical user-message creation, echo reconciliation, and falls back to the normal interrupt-and-replace path only when the adapter reports `unavailable`. An adapter error leaves the steer's fate ambiguous and must surface without an interrupt or retry. Codex calls `turn/steer` with the native expected turn and Paseo client user-message ID. Claude pushes an admitted steer into the exact active SDK query input; isolated control commands remain unavailable. OpenCode calls `session/prompt_async` with an OpenCode-generated message ID; the server queues the prompt while busy and the next LLM call in the same Paseo turn includes it. A missing session reports `unavailable` and uses the normal interrupt fallback.
|
Active-turn steering is an optional `AgentSession.steerActiveTurn` operation. The manager owns admission against its exact foreground turn, canonical user-message creation, echo reconciliation, and falls back to the normal interrupt-and-replace path only when the adapter reports `unavailable`. An adapter error leaves the steer's fate ambiguous and must surface without an interrupt or retry. Codex calls `turn/steer` with the native expected turn and Paseo client user-message ID. Claude pushes an admitted steer into the exact active SDK query input; isolated control commands remain unavailable. OpenCode calls `session/prompt_async` with an OpenCode-generated message ID; the server queues the prompt while busy and the next LLM call in the same Paseo turn includes it. Pi sends its native `steer` RPC, which queues the message for delivery after the in-flight assistant turn's tool calls. Slash-command inputs report `unavailable` because pi rejects extension commands on the steer path, and echo identity is correlated by message text because pi's steer RPC takes no message ID. A missing session reports `unavailable` and uses the normal interrupt fallback.
|
||||||
|
|
||||||
A steering adapter also owes its interrupt: stopping a turn must discard the steers the provider has not read yet, or one of them resumes the turn the user just stopped. Codex clears pending input when it aborts a turn; Claude does not, so its adapter cancels the SDK messages it queued before calling `query.interrupt()`.
|
A steering adapter also owes its interrupt: stopping a turn must discard the steers the provider has not read yet, or one of them resumes the turn the user just stopped. Codex clears pending input when it aborts a turn; Claude does not, so its adapter cancels the SDK messages it queued before calling `query.interrupt()`. Pi requires `clear_queue` before `abort`; older binaries without that RPC retain their native queue behavior until the pi compatibility floor reaches 0.84.4.
|
||||||
|
|
||||||
`SteerActiveTurnOptions.clearPendingPermissions` makes permission release part of the provider contract. A provider that accepts such a steer queues it first, denies permissions blocking its delivery, and stops once the steer is read. Steers without the flag leave permissions open. A denied plan remains in the timeline because the pending card was the only other copy of its text.
|
`SteerActiveTurnOptions.clearPendingPermissions` makes permission release part of the provider contract. A provider that accepts such a steer queues it first, denies permissions blocking its delivery, and stops once the steer is read. Steers without the flag leave permissions open. A denied plan remains in the timeline because the pending card was the only other copy of its text.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
Changed
|
||||||
|
- Changed the project license to Apache-2.0
|
||||||
|
- Changed Cmd/Ctrl+E to toggle Explorer visibility without selecting a view
|
||||||
|
- Changed the default mobile content text size from 15px to 16px
|
||||||
|
|
||||||
|
Added
|
||||||
|
- Added plugin installation and updates directly from Git repositories
|
||||||
|
- Added plugin-defined timeline transformations and rendering
|
||||||
|
- Added SSH connections to existing remote daemons from Desktop and CLI
|
||||||
|
|
||||||
|
Full notes: https://paseo.sh/changelog
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
Changed
|
||||||
|
- Changed the project license to Apache-2.0
|
||||||
|
- Changed Cmd/Ctrl+E to toggle Explorer visibility without selecting a view
|
||||||
|
- Changed the default mobile content text size from 15px to 16px
|
||||||
|
|
||||||
|
Added
|
||||||
|
- Added plugin installation and updates directly from Git repositories
|
||||||
|
- Added plugin-defined timeline transformations and rendering
|
||||||
|
- Added SSH connections to existing remote daemons from Desktop and CLI
|
||||||
|
|
||||||
|
Full notes: https://paseo.sh/changelog
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
Changed
|
||||||
|
- Changed the project license to Apache-2.0
|
||||||
|
- Changed Cmd/Ctrl+E to toggle Explorer visibility without selecting a view
|
||||||
|
- Changed the default mobile content text size from 15px to 16px
|
||||||
|
|
||||||
|
Added
|
||||||
|
- Added plugin installation and updates directly from Git repositories
|
||||||
|
- Added plugin-defined timeline transformations and rendering
|
||||||
|
- Added SSH connections to existing remote daemons from Desktop and CLI
|
||||||
|
|
||||||
|
Full notes: https://paseo.sh/changelog
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
Changed
|
||||||
|
- Changed the project license to Apache-2.0
|
||||||
|
- Changed Cmd/Ctrl+E to toggle Explorer visibility without selecting a view
|
||||||
|
- Changed the default mobile content text size from 15px to 16px
|
||||||
|
|
||||||
|
Added
|
||||||
|
- Added plugin installation and updates directly from Git repositories
|
||||||
|
- Added plugin-defined timeline transformations and rendering
|
||||||
|
- Added SSH connections to existing remote daemons from Desktop and CLI
|
||||||
|
|
||||||
|
Full notes: https://paseo.sh/changelog
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
sha256-zn0UJm7RPR5mlCvKTddcq0bIugrp6VcRBPdwJX8kJIg=
|
sha256-HFYBrCP62r6ZofazBIGSOnMhk8IIEmv+TFJMpqTIJQ8=
|
||||||
|
|||||||
Generated
+27
-27
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.7.0-beta.1",
|
"version": "0.7.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.7.0-beta.1",
|
"version": "0.7.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
@@ -36298,7 +36298,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.7.0-beta.1",
|
"version": "0.7.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/commands": "6.10.4",
|
"@codemirror/commands": "6.10.4",
|
||||||
"@codemirror/language": "6.12.4",
|
"@codemirror/language": "6.12.4",
|
||||||
@@ -37363,12 +37363,12 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@getpaseo/cli",
|
"name": "@getpaseo/cli",
|
||||||
"version": "0.7.0-beta.1",
|
"version": "0.7.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/client": "0.7.0-beta.1",
|
"@getpaseo/client": "0.7.0",
|
||||||
"@getpaseo/protocol": "0.7.0-beta.1",
|
"@getpaseo/protocol": "0.7.0",
|
||||||
"@getpaseo/server": "0.7.0-beta.1",
|
"@getpaseo/server": "0.7.0",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
@@ -37615,10 +37615,10 @@
|
|||||||
},
|
},
|
||||||
"packages/client": {
|
"packages/client": {
|
||||||
"name": "@getpaseo/client",
|
"name": "@getpaseo/client",
|
||||||
"version": "0.7.0-beta.1",
|
"version": "0.7.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/protocol": "0.7.0-beta.1",
|
"@getpaseo/protocol": "0.7.0",
|
||||||
"@getpaseo/relay": "0.7.0-beta.1",
|
"@getpaseo/relay": "0.7.0",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -37629,7 +37629,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@getpaseo/desktop",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.7.0-beta.1",
|
"version": "0.7.0",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/cli": "*",
|
"@getpaseo/cli": "*",
|
||||||
@@ -37872,7 +37872,7 @@
|
|||||||
},
|
},
|
||||||
"packages/expo-two-way-audio": {
|
"packages/expo-two-way-audio": {
|
||||||
"name": "@getpaseo/expo-two-way-audio",
|
"name": "@getpaseo/expo-two-way-audio",
|
||||||
"version": "0.7.0-beta.1",
|
"version": "0.7.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
@@ -38768,7 +38768,7 @@
|
|||||||
},
|
},
|
||||||
"packages/highlight": {
|
"packages/highlight": {
|
||||||
"name": "@getpaseo/highlight",
|
"name": "@getpaseo/highlight",
|
||||||
"version": "0.7.0-beta.1",
|
"version": "0.7.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/language": "6.12.4",
|
"@codemirror/language": "6.12.4",
|
||||||
"@codemirror/legacy-modes": "^6.5.3",
|
"@codemirror/legacy-modes": "^6.5.3",
|
||||||
@@ -38797,13 +38797,13 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin": {
|
"packages/plugin": {
|
||||||
"name": "@getpaseo/plugin",
|
"name": "@getpaseo/plugin",
|
||||||
"version": "0.7.0-beta.1",
|
"version": "0.7.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"use-sync-external-store": "^1.6.0"
|
"use-sync-external-store": "^1.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@getpaseo/client": "0.7.0-beta.1",
|
"@getpaseo/client": "0.7.0",
|
||||||
"@getpaseo/protocol": "0.7.0-beta.1",
|
"@getpaseo/protocol": "0.7.0",
|
||||||
"@types/node": "^20.9.0",
|
"@types/node": "^20.9.0",
|
||||||
"@types/react": "~19.2.0",
|
"@types/react": "~19.2.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
@@ -38812,15 +38812,15 @@
|
|||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@getpaseo/client": "0.7.0-beta.1",
|
"@getpaseo/client": "0.7.0",
|
||||||
"@getpaseo/protocol": "0.7.0-beta.1",
|
"@getpaseo/protocol": "0.7.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/protocol": {
|
"packages/protocol": {
|
||||||
"name": "@getpaseo/protocol",
|
"name": "@getpaseo/protocol",
|
||||||
"version": "0.7.0-beta.1",
|
"version": "0.7.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
@@ -38833,7 +38833,7 @@
|
|||||||
},
|
},
|
||||||
"packages/relay": {
|
"packages/relay": {
|
||||||
"name": "@getpaseo/relay",
|
"name": "@getpaseo/relay",
|
||||||
"version": "0.7.0-beta.1",
|
"version": "0.7.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"base64-js": "^1.5.1",
|
"base64-js": "^1.5.1",
|
||||||
"tweetnacl": "^1.0.3",
|
"tweetnacl": "^1.0.3",
|
||||||
@@ -39051,17 +39051,17 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@getpaseo/server",
|
"name": "@getpaseo/server",
|
||||||
"version": "0.7.0-beta.1",
|
"version": "0.7.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.17.1",
|
"@agentclientprotocol/sdk": "^0.17.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||||
"@anthropic-ai/sdk": "^0.104.2",
|
"@anthropic-ai/sdk": "^0.104.2",
|
||||||
"@babel/parser": "^7.29.8",
|
"@babel/parser": "^7.29.8",
|
||||||
"@getpaseo/client": "0.7.0-beta.1",
|
"@getpaseo/client": "0.7.0",
|
||||||
"@getpaseo/highlight": "0.7.0-beta.1",
|
"@getpaseo/highlight": "0.7.0",
|
||||||
"@getpaseo/plugin": "0.7.0-beta.1",
|
"@getpaseo/plugin": "0.7.0",
|
||||||
"@getpaseo/protocol": "0.7.0-beta.1",
|
"@getpaseo/protocol": "0.7.0",
|
||||||
"@getpaseo/relay": "0.7.0-beta.1",
|
"@getpaseo/relay": "0.7.0",
|
||||||
"@isaacs/ttlcache": "^2.1.4",
|
"@isaacs/ttlcache": "^2.1.4",
|
||||||
"@modelcontextprotocol/sdk": "1.29.0",
|
"@modelcontextprotocol/sdk": "1.29.0",
|
||||||
"@opencode-ai/sdk": "1.14.46",
|
"@opencode-ai/sdk": "1.14.46",
|
||||||
@@ -39985,7 +39985,7 @@
|
|||||||
},
|
},
|
||||||
"packages/website": {
|
"packages/website": {
|
||||||
"name": "@getpaseo/website",
|
"name": "@getpaseo/website",
|
||||||
"version": "0.7.0-beta.1",
|
"version": "0.7.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "^1.29.1",
|
"@cloudflare/vite-plugin": "^1.29.1",
|
||||||
"@cloudflare/workers-types": "^4.20260317.1",
|
"@cloudflare/workers-types": "^4.20260317.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.7.0-beta.1",
|
"version": "0.7.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|||||||
@@ -39,7 +39,10 @@ import {
|
|||||||
expectResumeOverflowFallsBackToOneTail,
|
expectResumeOverflowFallsBackToOneTail,
|
||||||
rememberTimelineRequestCounts,
|
rememberTimelineRequestCounts,
|
||||||
} from "../support/helpers/timeline-resume";
|
} from "../support/helpers/timeline-resume";
|
||||||
import { workspaceDeckEntryLocator } from "../support/helpers/workspace-ui";
|
import {
|
||||||
|
waitForWorkspaceInSidebar,
|
||||||
|
workspaceDeckEntryLocator,
|
||||||
|
} from "../support/helpers/workspace-ui";
|
||||||
import { expectInFlightForkAvailable } from "../support/helpers/assistant-fork";
|
import { expectInFlightForkAvailable } from "../support/helpers/assistant-fork";
|
||||||
import {
|
import {
|
||||||
scrollTimelineToNewestLoadedEdge,
|
scrollTimelineToNewestLoadedEdge,
|
||||||
@@ -468,6 +471,10 @@ async function expectHiddenStreamingSubmissionOrderAfterWorkspaceEviction(
|
|||||||
|
|
||||||
await target.client.waitForFinish(target.agentId, 30_000);
|
await target.client.waitForFinish(target.agentId, 30_000);
|
||||||
const requestsBeforeReturn = rememberTimelineRequestCounts(gate);
|
const requestsBeforeReturn = rememberTimelineRequestCounts(gate);
|
||||||
|
await waitForWorkspaceInSidebar(page, {
|
||||||
|
serverId: getServerId(),
|
||||||
|
workspaceId: target.workspaceId,
|
||||||
|
});
|
||||||
await openAgentRoute(page, target);
|
await openAgentRoute(page, target);
|
||||||
await expectComposerVisible(page);
|
await expectComposerVisible(page);
|
||||||
await subscriptions.waitForSubscribedAgents([target.agentId]);
|
await subscriptions.waitForSubscribedAgents([target.agentId]);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
switchAwayAndBackWithoutImageInstability,
|
switchAwayAndBackWithoutImageInstability,
|
||||||
userPagesUntilAssistantImageRenders,
|
userPagesUntilAssistantImageRenders,
|
||||||
} from "../support/helpers/assistant-images";
|
} from "../support/helpers/assistant-images";
|
||||||
|
import { expectAgentReadyToInterrupt } from "../support/helpers/agent-stream";
|
||||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||||
|
|
||||||
const test = base.extend<{ imageWorkspace: SeededWorkspace }>({
|
const test = base.extend<{ imageWorkspace: SeededWorkspace }>({
|
||||||
@@ -84,8 +85,15 @@ test("opens a timeline image in a zoomable lightbox", async ({
|
|||||||
.poll(async () => Math.round((await transformedContent.boundingBox())?.width ?? 0))
|
.poll(async () => Math.round((await transformedContent.boundingBox())?.width ?? 0))
|
||||||
.toBe(Math.round(initialBox!.width));
|
.toBe(Math.round(initialBox!.width));
|
||||||
|
|
||||||
await canvas.click({ position: { x: 4, y: 4 } });
|
await workspace.client.sendAgentMessage(
|
||||||
|
imageAgent.id,
|
||||||
|
"Stay running while the assistant image lightbox is closed.",
|
||||||
|
);
|
||||||
|
await expectAgentReadyToInterrupt(page);
|
||||||
|
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
await expect(lightboxImage).toHaveCount(0);
|
await expect(lightboxImage).toHaveCount(0);
|
||||||
|
await expectAgentReadyToInterrupt(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("reloading a timeline anchors near-tail assistant image growth", async ({
|
test("reloading a timeline anchors near-tail assistant image growth", async ({
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { expect, test } from "../support/fixtures";
|
||||||
|
import { getUtf8ByteLength } from "../../src/components/assistant-message-render-limit";
|
||||||
|
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||||
|
|
||||||
|
test("caps a newline-heavy assistant message before rendering markdown", async ({ page }) => {
|
||||||
|
const hiddenTail = "HIDDEN_OVERSIZED_MESSAGE_TAIL";
|
||||||
|
const response = `${"x\n".repeat(16_000)}${hiddenTail}`;
|
||||||
|
const agent = await seedMockAgentWorkspace({
|
||||||
|
repoPrefix: "assistant-message-render-limit-",
|
||||||
|
title: "Assistant message render limit",
|
||||||
|
initialPrompt: "Render the configured oversized response.",
|
||||||
|
featureValues: { mockAssistantResponse: response },
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||||
|
await openAgentRoute(page, agent);
|
||||||
|
|
||||||
|
const assistantMessage = page.getByTestId("assistant-message").last();
|
||||||
|
await expect(assistantMessage).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(assistantMessage).not.toContainText(hiddenTail);
|
||||||
|
|
||||||
|
const notice = assistantMessage.getByTestId("assistant-message-capped-notice");
|
||||||
|
await expect(notice).toBeVisible();
|
||||||
|
await expect(notice).toHaveText(
|
||||||
|
`This message was capped (${getUtf8ByteLength(response)} bytes).`,
|
||||||
|
);
|
||||||
|
await expect(notice).toHaveCSS("font-style", "italic");
|
||||||
|
} finally {
|
||||||
|
await agent.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -3,8 +3,8 @@ import {
|
|||||||
openPrPane,
|
openPrPane,
|
||||||
expectPrPaneTitle,
|
expectPrPaneTitle,
|
||||||
expectPrPaneState,
|
expectPrPaneState,
|
||||||
expectPrPaneCheckSummary,
|
expectPrPaneChecks,
|
||||||
expectPrPaneActivityCount,
|
expectPrPaneActivity,
|
||||||
} from "../support/helpers/pr-pane";
|
} from "../support/helpers/pr-pane";
|
||||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||||
import {
|
import {
|
||||||
@@ -50,7 +50,6 @@ test.describe("PR pane", () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ title: "PR with reviews", state: "open", commentCount: 3 },
|
{ title: "PR with reviews", state: "open", commentCount: 3 },
|
||||||
{ title: "PR with no checks", state: "open" },
|
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -102,25 +101,21 @@ test.describe("PR pane", () => {
|
|||||||
await expectPrPaneTitle(page, "Work in progress");
|
await expectPrPaneTitle(page, "Work in progress");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders check pills with correct success/failure/pending counts", async ({ page }) => {
|
test("renders seeded checks in their success, failure, and pending groups", async ({ page }) => {
|
||||||
await gotoWorkspace(page, workspaceByTitle.get("PR with mixed checks")!);
|
await gotoWorkspace(page, workspaceByTitle.get("PR with mixed checks")!);
|
||||||
await openPrPane(page);
|
await openPrPane(page);
|
||||||
|
|
||||||
await expectPrPaneCheckSummary(page, { success: 2, failure: 1, pending: 1 });
|
await expectPrPaneChecks(page, {
|
||||||
|
success: ["build-1", "build-2"],
|
||||||
|
failure: ["deploy"],
|
||||||
|
pending: ["security"],
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders activity rows with correct count", async ({ page }) => {
|
test("renders every seeded activity comment", async ({ page }) => {
|
||||||
await gotoWorkspace(page, workspaceByTitle.get("PR with reviews")!);
|
await gotoWorkspace(page, workspaceByTitle.get("PR with reviews")!);
|
||||||
await openPrPane(page);
|
await openPrPane(page);
|
||||||
|
|
||||||
await expectPrPaneActivityCount(page, 3);
|
await expectPrPaneActivity(page, ["Test comment 1", "Test comment 2", "Test comment 3"]);
|
||||||
});
|
|
||||||
|
|
||||||
test("renders gracefully with zero checks", async ({ page }) => {
|
|
||||||
await gotoWorkspace(page, workspaceByTitle.get("PR with no checks")!);
|
|
||||||
await openPrPane(page);
|
|
||||||
|
|
||||||
await expectPrPaneCheckSummary(page, { success: 0, failure: 0, pending: 0 });
|
|
||||||
await expectPrPaneTitle(page, "PR with no checks");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ const cases: ProviderSubagentCase[] = [
|
|||||||
sentinel: "OPENCODE_CHILD_SENTINEL",
|
sentinel: "OPENCODE_CHILD_SENTINEL",
|
||||||
expectedName: "Verify OpenCode descriptor",
|
expectedName: "Verify OpenCode descriptor",
|
||||||
expectedSubtitle: /explore · gpt-5\.4(?: · [^\n·]+)? · \d+(?:\.\d+)?k? tokens/i,
|
expectedSubtitle: /explore · gpt-5\.4(?: · [^\n·]+)? · \d+(?:\.\d+)?k? tokens/i,
|
||||||
expectsUserMessage: false,
|
|
||||||
providerConfig: { model: "openai/gpt-5.4" },
|
providerConfig: { model: "openai/gpt-5.4" },
|
||||||
prompt:
|
prompt:
|
||||||
'Use the task tool exactly once with description "Verify OpenCode descriptor" and the explore subagent. Ask it to reply with exactly OPENCODE_CHILD_SENTINEL and do nothing else. Wait for it, then reply ROOT_DONE.',
|
'Use the task tool exactly once with description "Verify OpenCode descriptor" and the explore subagent. Ask it to reply with exactly OPENCODE_CHILD_SENTINEL and do nothing else. Wait for it, then reply ROOT_DONE.',
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
expectTurnCopyButton,
|
expectTurnCopyButton,
|
||||||
} from "../support/helpers/agent-stream";
|
} from "../support/helpers/agent-stream";
|
||||||
import {
|
import {
|
||||||
expectReconnectingToastDebounced,
|
|
||||||
expectReconnectingToastGone,
|
expectReconnectingToastGone,
|
||||||
expectReconnectingToastVisible,
|
expectReconnectingToastVisible,
|
||||||
} from "../support/helpers/workspace-ui";
|
} from "../support/helpers/workspace-ui";
|
||||||
@@ -181,13 +180,17 @@ test.describe("Viewed agent timelines", () => {
|
|||||||
"true",
|
"true",
|
||||||
);
|
);
|
||||||
await gate.drop();
|
await gate.drop();
|
||||||
await expectReconnectingToastDebounced(page);
|
await gate.waitForBlockedConnection();
|
||||||
await expectReconnectingToastVisible(page);
|
|
||||||
await commitMessage(scenario, scenario.firstAgentId, "Committed while the chat reconnects.");
|
await commitMessage(scenario, scenario.firstAgentId, "Committed while the chat reconnects.");
|
||||||
await expect(
|
await expect(
|
||||||
page.getByText("Committed while the chat reconnects.", { exact: true }),
|
page.getByText("Committed while the chat reconnects.", { exact: true }),
|
||||||
).toHaveCount(0);
|
).toHaveCount(0);
|
||||||
|
// Hold the first authoritative catch-up response so the assertion observes
|
||||||
|
// the reconnect boundary instead of racing a socket that has not reopened yet.
|
||||||
|
gate.holdNextServerMessage("fetch_agent_timeline_response");
|
||||||
gate.restore();
|
gate.restore();
|
||||||
|
await gate.waitForHeldServerMessage("fetch_agent_timeline_response");
|
||||||
|
gate.releaseHeldServerMessage("fetch_agent_timeline_response");
|
||||||
await expectReconnectingToastGone(page);
|
await expectReconnectingToastGone(page);
|
||||||
const recoveredMessage = page.getByText("Committed while the chat reconnects.", {
|
const recoveredMessage = page.getByText("Committed while the chat reconnects.", {
|
||||||
exact: true,
|
exact: true,
|
||||||
@@ -209,6 +212,7 @@ test.describe("Viewed agent timelines", () => {
|
|||||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
|
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
|
||||||
await selectAgent(page, "First viewed chat");
|
await selectAgent(page, "First viewed chat");
|
||||||
await gate.drop();
|
await gate.drop();
|
||||||
|
await gate.waitForBlockedConnection();
|
||||||
await expectReconnectingToastVisible(page);
|
await expectReconnectingToastVisible(page);
|
||||||
|
|
||||||
await selectAgent(page, "Second viewed chat");
|
await selectAgent(page, "Second viewed chat");
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
import { expect, test, type Page } from "../support/fixtures";
|
import { expect, test, type Page } from "../support/fixtures";
|
||||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||||
import {
|
import {
|
||||||
createIdleAgent,
|
createMockIdleAgent,
|
||||||
expectWorkspaceTabHidden,
|
expectWorkspaceTabHidden,
|
||||||
expectWorkspaceTabVisible,
|
expectWorkspaceTabVisible,
|
||||||
openWorkspaceWithAgents,
|
openWorkspaceWithAgents,
|
||||||
@@ -159,7 +159,7 @@ test.describe("Workspace navigation regression", () => {
|
|||||||
const workspace = await seedWorkspace({ repoPrefix: "workspace-reconnect-" });
|
const workspace = await seedWorkspace({ repoPrefix: "workspace-reconnect-" });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const agent = await createIdleAgent(workspace.client, {
|
const agent = await createMockIdleAgent(workspace.client, {
|
||||||
cwd: workspace.repoPath,
|
cwd: workspace.repoPath,
|
||||||
workspaceId: workspace.workspaceId,
|
workspaceId: workspace.workspaceId,
|
||||||
title: `workspace-reconnect-${Date.now()}`,
|
title: `workspace-reconnect-${Date.now()}`,
|
||||||
@@ -180,6 +180,7 @@ test.describe("Workspace navigation regression", () => {
|
|||||||
await expectWorkspaceTabVisible(page, agent.id);
|
await expectWorkspaceTabVisible(page, agent.id);
|
||||||
|
|
||||||
await daemonGate.drop();
|
await daemonGate.drop();
|
||||||
|
await daemonGate.waitForBlockedConnection();
|
||||||
await expectReconnectingToastVisible(page);
|
await expectReconnectingToastVisible(page);
|
||||||
await expectWorkspaceHeader(page, {
|
await expectWorkspaceHeader(page, {
|
||||||
title: workspace.workspaceName,
|
title: workspace.workspaceName,
|
||||||
@@ -217,12 +218,12 @@ test.describe("Workspace navigation regression", () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
createIdleAgent(primaryWorkspace.client, {
|
createMockIdleAgent(primaryWorkspace.client, {
|
||||||
cwd: primaryWorkspace.repoPath,
|
cwd: primaryWorkspace.repoPath,
|
||||||
workspaceId: primaryWorkspace.workspaceId,
|
workspaceId: primaryWorkspace.workspaceId,
|
||||||
title: "Active host agent",
|
title: "Active host agent",
|
||||||
}),
|
}),
|
||||||
createIdleAgent(secondaryWorkspace.client, {
|
createMockIdleAgent(secondaryWorkspace.client, {
|
||||||
cwd: secondaryWorkspace.repoPath,
|
cwd: secondaryWorkspace.repoPath,
|
||||||
workspaceId: secondaryWorkspace.workspaceId,
|
workspaceId: secondaryWorkspace.workspaceId,
|
||||||
title: "Inactive host agent",
|
title: "Inactive host agent",
|
||||||
@@ -319,12 +320,12 @@ test.describe("Workspace navigation regression", () => {
|
|||||||
const secondWorkspace = await seedWorkspace({ repoPrefix: "workspace-nav-reg-b-" });
|
const secondWorkspace = await seedWorkspace({ repoPrefix: "workspace-nav-reg-b-" });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const firstAgent = await createIdleAgent(firstWorkspace.client, {
|
const firstAgent = await createMockIdleAgent(firstWorkspace.client, {
|
||||||
cwd: firstWorkspace.repoPath,
|
cwd: firstWorkspace.repoPath,
|
||||||
workspaceId: firstWorkspace.workspaceId,
|
workspaceId: firstWorkspace.workspaceId,
|
||||||
title: `workspace-nav-a-${Date.now()}`,
|
title: `workspace-nav-a-${Date.now()}`,
|
||||||
});
|
});
|
||||||
const secondAgent = await createIdleAgent(secondWorkspace.client, {
|
const secondAgent = await createMockIdleAgent(secondWorkspace.client, {
|
||||||
cwd: secondWorkspace.repoPath,
|
cwd: secondWorkspace.repoPath,
|
||||||
workspaceId: secondWorkspace.workspaceId,
|
workspaceId: secondWorkspace.workspaceId,
|
||||||
title: `workspace-nav-b-${Date.now()}`,
|
title: `workspace-nav-b-${Date.now()}`,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { expect, type Page } from "@playwright/test";
|
import { expect, type Locator, type Page } from "@playwright/test";
|
||||||
|
|
||||||
const NEAR_BOTTOM_THRESHOLD_PX = 72;
|
const NEAR_BOTTOM_THRESHOLD_PX = 72;
|
||||||
const DEFAULT_SCROLL_TOLERANCE_PX = 24;
|
const DEFAULT_SCROLL_TOLERANCE_PX = 24;
|
||||||
@@ -14,6 +14,31 @@ function getVisibleChatScroll(page: Page) {
|
|||||||
return page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
return page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PositionedToolCall {
|
||||||
|
locator: Locator;
|
||||||
|
bounds: { x: number; y: number; width: number; height: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readRenderedToolCalls(toolCalls: Locator): Promise<PositionedToolCall[]> {
|
||||||
|
const entries = await Promise.all(
|
||||||
|
Array.from({ length: await toolCalls.count() }, async (_, index) => ({
|
||||||
|
locator: toolCalls.nth(index),
|
||||||
|
bounds: await toolCalls.nth(index).boundingBox(),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
return entries.filter(
|
||||||
|
(entry): entry is PositionedToolCall => entry.bounds !== null && entry.bounds.width > 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closestToolCallToY(toolCalls: PositionedToolCall[], targetY: number) {
|
||||||
|
return toolCalls.toSorted(
|
||||||
|
(left, right) =>
|
||||||
|
Math.abs(left.bounds.y + left.bounds.height / 2 - targetY) -
|
||||||
|
Math.abs(right.bounds.y + right.bounds.height / 2 - targetY),
|
||||||
|
)[0];
|
||||||
|
}
|
||||||
|
|
||||||
export async function readScrollMetrics(page: Page): Promise<ScrollMetrics> {
|
export async function readScrollMetrics(page: Page): Promise<ScrollMetrics> {
|
||||||
return getVisibleChatScroll(page).evaluate((root: Element) => {
|
return getVisibleChatScroll(page).evaluate((root: Element) => {
|
||||||
const candidates = [root, ...Array.from(root.querySelectorAll("*"))]
|
const candidates = [root, ...Array.from(root.querySelectorAll("*"))]
|
||||||
@@ -180,58 +205,53 @@ export async function clickToolCallBesideScrollToBottomButton(page: Page): Promi
|
|||||||
const visibleButtonBounds = buttonBounds!;
|
const visibleButtonBounds = buttonBounds!;
|
||||||
|
|
||||||
const toolCalls = page.locator('[data-testid="tool-call-badge"] [role="button"]');
|
const toolCalls = page.locator('[data-testid="tool-call-badge"] [role="button"]');
|
||||||
const toolCallBounds = await Promise.all(
|
|
||||||
Array.from({ length: await toolCalls.count() }, async (_, index) => ({
|
|
||||||
index,
|
|
||||||
bounds: await toolCalls.nth(index).boundingBox(),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
const buttonCenterY = visibleButtonBounds.y + visibleButtonBounds.height / 2;
|
const buttonCenterY = visibleButtonBounds.y + visibleButtonBounds.height / 2;
|
||||||
const candidate = toolCallBounds
|
const initialToolCalls = await readRenderedToolCalls(toolCalls);
|
||||||
.filter(
|
const initialCandidate = closestToolCallToY(initialToolCalls, buttonCenterY);
|
||||||
(entry): entry is { index: number; bounds: NonNullable<typeof entry.bounds> } =>
|
|
||||||
entry.bounds !== null && entry.bounds.width > 0,
|
|
||||||
)
|
|
||||||
.sort(
|
|
||||||
(left, right) =>
|
|
||||||
Math.abs(left.bounds.y + left.bounds.height / 2 - buttonCenterY) -
|
|
||||||
Math.abs(right.bounds.y + right.bounds.height / 2 - buttonCenterY),
|
|
||||||
)[0];
|
|
||||||
expect(
|
expect(
|
||||||
candidate,
|
initialCandidate,
|
||||||
`Expected at least one rendered tool-call badge: ${JSON.stringify({
|
`Expected at least one rendered tool-call badge: ${JSON.stringify({
|
||||||
buttonBounds,
|
buttonBounds,
|
||||||
scrollMetrics: await readScrollMetrics(page),
|
scrollMetrics: await readScrollMetrics(page),
|
||||||
toolCallBounds,
|
toolCallBounds: initialToolCalls.map(({ bounds }) => bounds),
|
||||||
})}`,
|
})}`,
|
||||||
).toBeDefined();
|
).toBeDefined();
|
||||||
const visibleToolCall = candidate!;
|
|
||||||
const initialToolCallCenterY = visibleToolCall.bounds.y + visibleToolCall.bounds.height / 2;
|
|
||||||
await getVisibleChatScroll(page).evaluate((scroll, deltaY) => {
|
|
||||||
(scroll as HTMLElement).scrollTop += deltaY;
|
|
||||||
}, initialToolCallCenterY - buttonCenterY);
|
|
||||||
|
|
||||||
const alignedToolCall = toolCalls.nth(visibleToolCall.index);
|
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
const [currentButtonBounds, currentToolCallBounds] = await Promise.all([
|
const currentButtonBounds = await scrollToBottomButton.boundingBox();
|
||||||
scrollToBottomButton.boundingBox(),
|
if (!currentButtonBounds) return false;
|
||||||
alignedToolCall.boundingBox(),
|
const currentButtonCenterY = currentButtonBounds.y + currentButtonBounds.height / 2;
|
||||||
]);
|
const closest = closestToolCallToY(
|
||||||
if (!currentButtonBounds || !currentToolCallBounds) {
|
await readRenderedToolCalls(toolCalls),
|
||||||
return false;
|
currentButtonCenterY,
|
||||||
}
|
);
|
||||||
const toolCallCenterY = currentToolCallBounds.y + currentToolCallBounds.height / 2;
|
if (!closest) return false;
|
||||||
return (
|
const toolCallCenterY = closest.bounds.y + closest.bounds.height / 2;
|
||||||
|
if (
|
||||||
toolCallCenterY >= currentButtonBounds.y &&
|
toolCallCenterY >= currentButtonBounds.y &&
|
||||||
toolCallCenterY <= currentButtonBounds.y + currentButtonBounds.height
|
toolCallCenterY <= currentButtonBounds.y + currentButtonBounds.height
|
||||||
);
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const deltaY = Math.max(-200, Math.min(200, toolCallCenterY - currentButtonCenterY));
|
||||||
|
await getVisibleChatScroll(page).evaluate((scroll, delta) => {
|
||||||
|
(scroll as HTMLElement).scrollTop += delta;
|
||||||
|
}, deltaY);
|
||||||
|
return false;
|
||||||
})
|
})
|
||||||
.toBe(true);
|
.toBe(true);
|
||||||
|
|
||||||
|
const currentButtonBounds = await scrollToBottomButton.boundingBox();
|
||||||
|
expect(currentButtonBounds, "Expected scroll-to-bottom button to remain visible").not.toBeNull();
|
||||||
|
const currentButtonCenterY = currentButtonBounds!.y + currentButtonBounds!.height / 2;
|
||||||
|
const alignedToolCall = closestToolCallToY(
|
||||||
|
await readRenderedToolCalls(toolCalls),
|
||||||
|
currentButtonCenterY,
|
||||||
|
)?.locator;
|
||||||
|
expect(alignedToolCall, "Expected an aligned tool-call badge").toBeDefined();
|
||||||
const [alignedButtonBounds, visibleToolCallBounds] = await Promise.all([
|
const [alignedButtonBounds, visibleToolCallBounds] = await Promise.all([
|
||||||
scrollToBottomButton.boundingBox(),
|
scrollToBottomButton.boundingBox(),
|
||||||
alignedToolCall.boundingBox(),
|
alignedToolCall!.boundingBox(),
|
||||||
]);
|
]);
|
||||||
expect(alignedButtonBounds, "Expected scroll-to-bottom button to remain visible").not.toBeNull();
|
expect(alignedButtonBounds, "Expected scroll-to-bottom button to remain visible").not.toBeNull();
|
||||||
expect(
|
expect(
|
||||||
@@ -245,7 +265,7 @@ export async function clickToolCallBesideScrollToBottomButton(page: Page): Promi
|
|||||||
x: finalToolCallBounds.x + 24,
|
x: finalToolCallBounds.x + 24,
|
||||||
y: finalToolCallBounds.y + finalToolCallBounds.height / 2,
|
y: finalToolCallBounds.y + finalToolCallBounds.height / 2,
|
||||||
};
|
};
|
||||||
const toolCallReceivesPointer = await alignedToolCall.evaluate((toolCall, point) => {
|
const toolCallReceivesPointer = await alignedToolCall!.evaluate((toolCall, point) => {
|
||||||
const hit = document.elementFromPoint(point.x, point.y);
|
const hit = document.elementFromPoint(point.x, point.y);
|
||||||
return hit !== null && toolCall.contains(hit);
|
return hit !== null && toolCall.contains(hit);
|
||||||
}, clickPoint);
|
}, clickPoint);
|
||||||
|
|||||||
@@ -52,18 +52,18 @@ export interface IdleAgentSeedClient {
|
|||||||
): Promise<{ status: string }>;
|
): Promise<{ status: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createIdleAgent(
|
async function seedIdleAgent(
|
||||||
client: IdleAgentSeedClient,
|
client: IdleAgentSeedClient,
|
||||||
input: { cwd: string; workspaceId: string; title: string },
|
input: { cwd: string; workspaceId: string; title: string },
|
||||||
|
provider: {
|
||||||
|
provider: string;
|
||||||
|
model: string;
|
||||||
|
modeId: string;
|
||||||
|
featureValues?: Record<string, unknown>;
|
||||||
|
},
|
||||||
): Promise<ArchiveTabAgent> {
|
): Promise<ArchiveTabAgent> {
|
||||||
const created = await client.createAgent({
|
const created = await client.createAgent({
|
||||||
provider: "opencode",
|
...provider,
|
||||||
model: "opencode/gpt-5-nano",
|
|
||||||
// OpenCode has no "bypassPermissions" mode (that's Claude's). Use build with
|
|
||||||
// auto_accept for unattended full access — mode validation now rejects modes
|
|
||||||
// the provider doesn't define.
|
|
||||||
modeId: "build",
|
|
||||||
featureValues: { auto_accept: true },
|
|
||||||
cwd: input.cwd,
|
cwd: input.cwd,
|
||||||
workspaceId: input.workspaceId,
|
workspaceId: input.workspaceId,
|
||||||
title: input.title,
|
title: input.title,
|
||||||
@@ -84,6 +84,32 @@ export async function createIdleAgent(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createIdleAgent(
|
||||||
|
client: IdleAgentSeedClient,
|
||||||
|
input: { cwd: string; workspaceId: string; title: string },
|
||||||
|
): Promise<ArchiveTabAgent> {
|
||||||
|
return seedIdleAgent(client, input, {
|
||||||
|
provider: "opencode",
|
||||||
|
model: "opencode/gpt-5-nano",
|
||||||
|
// OpenCode has no "bypassPermissions" mode (that's Claude's). Use build with
|
||||||
|
// auto_accept for unattended full access — mode validation now rejects modes
|
||||||
|
// the provider doesn't define.
|
||||||
|
modeId: "build",
|
||||||
|
featureValues: { auto_accept: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createMockIdleAgent(
|
||||||
|
client: IdleAgentSeedClient,
|
||||||
|
input: { cwd: string; workspaceId: string; title: string },
|
||||||
|
): Promise<ArchiveTabAgent> {
|
||||||
|
return seedIdleAgent(client, input, {
|
||||||
|
provider: "mock",
|
||||||
|
model: "e2e-fast-stream",
|
||||||
|
modeId: "load-test",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function archiveAgentFromDaemon(
|
export async function archiveAgentFromDaemon(
|
||||||
client: { archiveAgent(agentId: string): Promise<{ archivedAt: string }> },
|
client: { archiveAgent(agentId: string): Promise<{ archivedAt: string }> },
|
||||||
agentId: string,
|
agentId: string,
|
||||||
|
|||||||
@@ -315,6 +315,9 @@ export async function installDaemonWebSocketGate(page: Page) {
|
|||||||
const suppressedAgentStreamEventTypes = new Set<string>();
|
const suppressedAgentStreamEventTypes = new Set<string>();
|
||||||
const suppressedAgentStreamItemTypes = new Set<string>();
|
const suppressedAgentStreamItemTypes = new Set<string>();
|
||||||
const activeSockets = new Set<WebSocketRoute>();
|
const activeSockets = new Set<WebSocketRoute>();
|
||||||
|
let blockedConnectionCount = 0;
|
||||||
|
let blockedConnectionCountAtDrop = 0;
|
||||||
|
const blockedConnectionWaiters = new Set<() => void>();
|
||||||
let latestServer: WebSocketRoute | null = null;
|
let latestServer: WebSocketRoute | null = null;
|
||||||
const directoryStarts: DirectoryRequestStartCounts = {
|
const directoryStarts: DirectoryRequestStartCounts = {
|
||||||
subscribed: { agents: 0, workspaces: 0 },
|
subscribed: { agents: 0, workspaces: 0 },
|
||||||
@@ -443,6 +446,9 @@ export async function installDaemonWebSocketGate(page: Page) {
|
|||||||
|
|
||||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||||
if (!acceptingConnections) {
|
if (!acceptingConnections) {
|
||||||
|
blockedConnectionCount += 1;
|
||||||
|
for (const resolve of blockedConnectionWaiters) resolve();
|
||||||
|
blockedConnectionWaiters.clear();
|
||||||
void ws.close({ code: 1008, reason: "Blocked by reconnect test." });
|
void ws.close({ code: 1008, reason: "Blocked by reconnect test." });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -583,6 +589,7 @@ export async function installDaemonWebSocketGate(page: Page) {
|
|||||||
forward?.();
|
forward?.();
|
||||||
},
|
},
|
||||||
async drop(): Promise<void> {
|
async drop(): Promise<void> {
|
||||||
|
blockedConnectionCountAtDrop = blockedConnectionCount;
|
||||||
acceptingConnections = false;
|
acceptingConnections = false;
|
||||||
const sockets = Array.from(activeSockets);
|
const sockets = Array.from(activeSockets);
|
||||||
activeSockets.clear();
|
activeSockets.clear();
|
||||||
@@ -592,6 +599,10 @@ export async function installDaemonWebSocketGate(page: Page) {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
async waitForBlockedConnection(): Promise<void> {
|
||||||
|
if (blockedConnectionCount > blockedConnectionCountAtDrop) return;
|
||||||
|
await new Promise<void>((resolve) => blockedConnectionWaiters.add(resolve));
|
||||||
|
},
|
||||||
restore(): void {
|
restore(): void {
|
||||||
acceptingConnections = true;
|
acceptingConnections = true;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { expect, type Page } from "@playwright/test";
|
import { expect, type Page } from "@playwright/test";
|
||||||
import type { CheckPresentationCounts } from "@/git/check-presentation";
|
|
||||||
import { getStateLabel } from "@/git/pull-request-panel/data";
|
import { getStateLabel } from "@/git/pull-request-panel/data";
|
||||||
import { openPullRequestPanel } from "./workspace-tabs";
|
import { openPullRequestPanel } from "./workspace-tabs";
|
||||||
|
|
||||||
@@ -20,23 +19,31 @@ export async function expectPrPaneState(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function assertCheckPill(page: Page, testId: string, count: number): Promise<void> {
|
export async function expectPrPaneChecks(
|
||||||
const locator = page.getByTestId(testId);
|
page: Page,
|
||||||
await expect(locator).toHaveCount(count > 0 ? 1 : 0, { timeout: 15_000 });
|
checks: { success: string[]; failure: string[]; pending: string[] },
|
||||||
if (count > 0) {
|
): Promise<void> {
|
||||||
await expect(locator).toContainText(String(count));
|
const section = page.getByTestId("pr-pane-checks");
|
||||||
|
const groups = [
|
||||||
|
{ status: "failure", label: /failing checks?$/, names: checks.failure },
|
||||||
|
{ status: "pending", label: /in progress checks?$/, names: checks.pending },
|
||||||
|
{ status: "success", label: /successful checks?$/, names: checks.success },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const group of groups) {
|
||||||
|
const groupSection = section.getByTestId(`pr-pane-check-group-${group.status}`);
|
||||||
|
await expect(groupSection.getByRole("button", { name: group.label })).toBeVisible({
|
||||||
|
timeout: 15_000,
|
||||||
|
});
|
||||||
|
for (const name of group.names) {
|
||||||
|
await expect(groupSection.getByText(name, { exact: true })).toHaveCount(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function expectPrPaneCheckSummary(
|
export async function expectPrPaneActivity(page: Page, bodies: string[]): Promise<void> {
|
||||||
page: Page,
|
const activity = page.getByTestId("pr-pane-activity-row");
|
||||||
counts: Pick<CheckPresentationCounts, "success" | "failure" | "pending">,
|
for (const body of bodies) {
|
||||||
): Promise<void> {
|
await expect(activity.getByText(body, { exact: true })).toBeVisible({ timeout: 15_000 });
|
||||||
await assertCheckPill(page, "pr-pane-check-success", counts.success);
|
}
|
||||||
await assertCheckPill(page, "pr-pane-check-failure", counts.failure);
|
|
||||||
await assertCheckPill(page, "pr-pane-check-pending", counts.pending);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function expectPrPaneActivityCount(page: Page, count: number): Promise<void> {
|
|
||||||
await expect(page.getByTestId("pr-pane-activity-row")).toHaveCount(count, { timeout: 15_000 });
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,14 +112,6 @@ export async function expectReconnectingToastGone(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function expectReconnectingToastDebounced(
|
|
||||||
page: Page,
|
|
||||||
options?: { durationMs?: number },
|
|
||||||
): Promise<void> {
|
|
||||||
await page.waitForTimeout(options?.durationMs ?? 750);
|
|
||||||
await expect(page.getByTestId("agent-reconnecting-toast")).toHaveCount(0, { timeout: 100 });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function expectHostConnectingOrOffline(
|
export async function expectHostConnectingOrOffline(
|
||||||
page: Page,
|
page: Page,
|
||||||
options?: { timeout?: number },
|
options?: { timeout?: number },
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.7.0-beta.1",
|
"version": "0.7.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ import { useOpenProject } from "@/hooks/use-open-project";
|
|||||||
import { useAppSettings } from "@/hooks/use-settings";
|
import { useAppSettings } from "@/hooks/use-settings";
|
||||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||||
import { useOpenAgentListGesture } from "@/mobile-panels/gestures";
|
import { useOpenAgentListGesture } from "@/mobile-panels/gestures";
|
||||||
import { MobilePanelsProvider } from "@/mobile-panels/provider";
|
import { MobilePanelsProvider, useIsMobilePanelActive } from "@/mobile-panels/provider";
|
||||||
import { I18nProvider } from "@/i18n/provider";
|
import { I18nProvider } from "@/i18n/provider";
|
||||||
import {
|
import {
|
||||||
KeyboardActionDispatcherProvider,
|
KeyboardActionDispatcherProvider,
|
||||||
@@ -114,7 +114,7 @@ import {
|
|||||||
useHosts,
|
useHosts,
|
||||||
} from "@/runtime/host-runtime";
|
} from "@/runtime/host-runtime";
|
||||||
import { getDaemonStartService } from "@/runtime/daemon-start-service";
|
import { getDaemonStartService } from "@/runtime/daemon-start-service";
|
||||||
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import { flushDraftPersistStorage } from "@/stores/draft-store";
|
import { flushDraftPersistStorage } from "@/stores/draft-store";
|
||||||
import { getNextThemePreference } from "@/styles/theme";
|
import { getNextThemePreference } from "@/styles/theme";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
@@ -627,10 +627,9 @@ function SidebarChrome({
|
|||||||
keyboardShortcutsEnabled: boolean;
|
keyboardShortcutsEnabled: boolean;
|
||||||
}) {
|
}) {
|
||||||
const isCompactLayout = useIsCompactFormFactor();
|
const isCompactLayout = useIsCompactFormFactor();
|
||||||
const isOpen = usePanelStore((state) =>
|
const isMobileActive = useIsMobilePanelActive("agent-list");
|
||||||
selectIsAgentListOpen(state, { isCompact: isCompactLayout }),
|
const isDesktopOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||||
);
|
const active = visible && (isCompactLayout ? isMobileActive : isDesktopOpen);
|
||||||
const active = visible && isOpen;
|
|
||||||
return (
|
return (
|
||||||
<SidebarModelProvider active={active}>
|
<SidebarModelProvider active={active}>
|
||||||
{mounted ? <LeftSidebar active={active} /> : null}
|
{mounted ? <LeftSidebar active={active} /> : null}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT,
|
||||||
|
capAssistantMessageForRender,
|
||||||
|
getUtf8ByteLength,
|
||||||
|
} from "./assistant-message-render-limit";
|
||||||
|
|
||||||
|
describe("assistant message render limit", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves messages at the limit unchanged", () => {
|
||||||
|
const message = "a".repeat(ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT);
|
||||||
|
|
||||||
|
expect(capAssistantMessageForRender(message)).toEqual({ text: message, capped: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps oversized messages before markdown rendering", () => {
|
||||||
|
const message = "a".repeat(ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT + 1);
|
||||||
|
|
||||||
|
expect(capAssistantMessageForRender(message)).toEqual({
|
||||||
|
text: "a".repeat(ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT),
|
||||||
|
capped: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not split a grapheme cluster at the boundary", () => {
|
||||||
|
const prefix = "a".repeat(ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT - 1);
|
||||||
|
|
||||||
|
expect(capAssistantMessageForRender(`${prefix}étail`)).toEqual({
|
||||||
|
text: prefix,
|
||||||
|
capped: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts the complete message size in UTF-8 bytes", () => {
|
||||||
|
expect(getUtf8ByteLength("aé😀")).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a bounded prefix when grapheme segmentation is unavailable", async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
vi.stubGlobal("Intl", { ...Intl, Segmenter: undefined });
|
||||||
|
const { capAssistantMessageForRender: capWithoutSegmenter } =
|
||||||
|
await import("./assistant-message-render-limit");
|
||||||
|
const message = "a".repeat(ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT + 1);
|
||||||
|
|
||||||
|
expect(capWithoutSegmenter(message)).toEqual({
|
||||||
|
text: "a".repeat(ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT),
|
||||||
|
capped: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { clampToSafeRevealBoundary, isTextRevealPacingSupported } from "@/agent-stream/text-reveal";
|
||||||
|
|
||||||
|
export const ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT = 32_000;
|
||||||
|
|
||||||
|
export interface CappedAssistantMessage {
|
||||||
|
text: string;
|
||||||
|
capped: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUtf8ByteLength(message: string): number {
|
||||||
|
let bytes = 0;
|
||||||
|
|
||||||
|
for (let index = 0; index < message.length; index += 1) {
|
||||||
|
const codeUnit = message.charCodeAt(index);
|
||||||
|
if (codeUnit < 0x80) {
|
||||||
|
bytes += 1;
|
||||||
|
} else if (codeUnit < 0x800) {
|
||||||
|
bytes += 2;
|
||||||
|
} else if (
|
||||||
|
codeUnit >= 0xd800 &&
|
||||||
|
codeUnit <= 0xdbff &&
|
||||||
|
index + 1 < message.length &&
|
||||||
|
message.charCodeAt(index + 1) >= 0xdc00 &&
|
||||||
|
message.charCodeAt(index + 1) <= 0xdfff
|
||||||
|
) {
|
||||||
|
bytes += 4;
|
||||||
|
index += 1;
|
||||||
|
} else {
|
||||||
|
bytes += 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function capAssistantMessageForRender(message: string): CappedAssistantMessage {
|
||||||
|
if (message.length <= ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT) {
|
||||||
|
return { text: message, capped: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
let end = ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT;
|
||||||
|
if (isTextRevealPacingSupported()) {
|
||||||
|
end = clampToSafeRevealBoundary(message, end);
|
||||||
|
} else {
|
||||||
|
const finalCodeUnit = message.charCodeAt(end - 1);
|
||||||
|
if (finalCodeUnit >= 0xd800 && finalCodeUnit <= 0xdbff) {
|
||||||
|
end -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { text: message.slice(0, end), capped: true };
|
||||||
|
}
|
||||||
@@ -136,6 +136,8 @@ let container: HTMLElement | null = null;
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const dom = new JSDOM("<!doctype html><html><body></body></html>");
|
const dom = new JSDOM("<!doctype html><html><body></body></html>");
|
||||||
|
dom.window.requestAnimationFrame = vi.fn(() => 1);
|
||||||
|
dom.window.cancelAnimationFrame = vi.fn();
|
||||||
vi.stubGlobal("React", React);
|
vi.stubGlobal("React", React);
|
||||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||||
vi.stubGlobal("window", dom.window);
|
vi.stubGlobal("window", dom.window);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { SPACING } from "@/styles/theme";
|
|||||||
import { WindowChromeRootRegion } from "@/utils/desktop-window";
|
import { WindowChromeRootRegion } from "@/utils/desktop-window";
|
||||||
import { ZoomableImage } from "@/components/zoomable-viewport/image";
|
import { ZoomableImage } from "@/components/zoomable-viewport/image";
|
||||||
import type { ViewportSize } from "@/components/zoomable-viewport/geometry";
|
import type { ViewportSize } from "@/components/zoomable-viewport/geometry";
|
||||||
|
import { useGlobalWebOverlayLayer, useWebOverlayRegistration } from "@/lib/overlay-root";
|
||||||
|
|
||||||
export type ImageLightboxSource =
|
export type ImageLightboxSource =
|
||||||
| { type: "attachment"; metadata: AttachmentMetadata }
|
| { type: "attachment"; metadata: AttachmentMetadata }
|
||||||
@@ -33,23 +34,27 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
|
|||||||
const url = source?.type === "uri" ? source.uri : attachmentUrl;
|
const url = source?.type === "uri" ? source.uri : attachmentUrl;
|
||||||
const contentSize = source?.type === "uri" ? source.contentSize : undefined;
|
const contentSize = source?.type === "uri" ? source.contentSize : undefined;
|
||||||
const [errored, setErrored] = useState(false);
|
const [errored, setErrored] = useState(false);
|
||||||
|
const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && source !== null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setErrored(false);
|
setErrored(false);
|
||||||
}, [metadata?.id, url]);
|
}, [metadata?.id, url]);
|
||||||
|
|
||||||
useEffect(() => {
|
const handleWebOverlayKeyDown = useCallback(
|
||||||
if (!isWeb || !source) return;
|
(event: KeyboardEvent) => {
|
||||||
function handleKeydown(event: KeyboardEvent) {
|
if (event.key !== "Escape") return false;
|
||||||
if (event.key === "Escape") {
|
event.preventDefault();
|
||||||
onClose();
|
event.stopPropagation();
|
||||||
}
|
onClose();
|
||||||
}
|
return true;
|
||||||
window.addEventListener("keydown", handleKeydown);
|
},
|
||||||
return () => {
|
[onClose],
|
||||||
window.removeEventListener("keydown", handleKeydown);
|
);
|
||||||
};
|
const setWebOverlayScope = useWebOverlayRegistration({
|
||||||
}, [onClose, source]);
|
active: isWeb && source !== null,
|
||||||
|
layer: modalLayer,
|
||||||
|
onKeyDown: handleWebOverlayKeyDown,
|
||||||
|
});
|
||||||
|
|
||||||
const contentLayerStyle = useMemo(
|
const contentLayerStyle = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -87,7 +92,7 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
|
|||||||
<Modal transparent animationType="fade" statusBarTranslucent visible onRequestClose={onClose}>
|
<Modal transparent animationType="fade" statusBarTranslucent visible onRequestClose={onClose}>
|
||||||
<ModalRoot style={styles.root}>
|
<ModalRoot style={styles.root}>
|
||||||
<WindowChromeRootRegion corners="both">
|
<WindowChromeRootRegion corners="both">
|
||||||
<View style={styles.root}>
|
<View ref={setWebOverlayScope} style={styles.root}>
|
||||||
<Pressable
|
<Pressable
|
||||||
testID="attachment-lightbox-backdrop"
|
testID="attachment-lightbox-backdrop"
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ import {
|
|||||||
NativeExplorerSidebarDock,
|
NativeExplorerSidebarDock,
|
||||||
} from "@/components/compact-explorer-sidebar";
|
} from "@/components/compact-explorer-sidebar";
|
||||||
import { useOpenFileExplorerGesture } from "@/mobile-panels/gestures";
|
import { useOpenFileExplorerGesture } from "@/mobile-panels/gestures";
|
||||||
|
import { useIsMobilePanelActive } from "@/mobile-panels/provider";
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import { selectIsCompactFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||||
import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status";
|
import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status";
|
||||||
@@ -51,7 +52,7 @@ function useActiveCompactExplorerSidebarModel(
|
|||||||
): CompactExplorerSidebarHostModel | null {
|
): CompactExplorerSidebarHostModel | null {
|
||||||
const selection = useActiveWorkspaceSelection();
|
const selection = useActiveWorkspaceSelection();
|
||||||
const workspace = useWorkspace(selection?.serverId ?? null, selection?.workspaceId ?? null);
|
const workspace = useWorkspace(selection?.serverId ?? null, selection?.workspaceId ?? null);
|
||||||
const isExplorerOpen = usePanelStore(selectIsCompactFileExplorerOpen);
|
const isExplorerActive = useIsMobilePanelActive("file-explorer");
|
||||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||||
const client = useHostRuntimeClient(selection?.serverId ?? "");
|
const client = useHostRuntimeClient(selection?.serverId ?? "");
|
||||||
const isConnected = useHostRuntimeIsConnected(selection?.serverId ?? "");
|
const isConnected = useHostRuntimeIsConnected(selection?.serverId ?? "");
|
||||||
@@ -67,32 +68,32 @@ function useActiveCompactExplorerSidebarModel(
|
|||||||
const resolvedModel = useMemo(
|
const resolvedModel = useMemo(
|
||||||
() =>
|
() =>
|
||||||
resolveCompactExplorerSidebarHostModel({
|
resolveCompactExplorerSidebarHostModel({
|
||||||
previous: isExplorerOpen ? retainedModelRef.current : null,
|
previous: isExplorerActive ? retainedModelRef.current : null,
|
||||||
selection,
|
selection,
|
||||||
workspace,
|
workspace,
|
||||||
isGit: checkoutQuery.data?.isGit ?? false,
|
isGit: checkoutQuery.data?.isGit ?? false,
|
||||||
}),
|
}),
|
||||||
[checkoutQuery.data?.isGit, isExplorerOpen, selection, workspace],
|
[checkoutQuery.data?.isGit, isExplorerActive, selection, workspace],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selection) {
|
if (!selection) {
|
||||||
retainedModelRef.current = null;
|
retainedModelRef.current = null;
|
||||||
if (enabled && isExplorerOpen) {
|
if (enabled && isExplorerActive) {
|
||||||
showMobileAgent();
|
showMobileAgent();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isExplorerOpen) {
|
if (!isExplorerActive) {
|
||||||
retainedModelRef.current = null;
|
retainedModelRef.current = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (resolvedModel) {
|
if (resolvedModel) {
|
||||||
retainedModelRef.current = resolvedModel;
|
retainedModelRef.current = resolvedModel;
|
||||||
}
|
}
|
||||||
}, [enabled, isExplorerOpen, resolvedModel, selection, showMobileAgent]);
|
}, [enabled, isExplorerActive, resolvedModel, selection, showMobileAgent]);
|
||||||
|
|
||||||
return selection ? (resolvedModel ?? (isExplorerOpen ? retainedModelRef.current : null)) : null;
|
return selection ? (resolvedModel ?? (isExplorerActive ? retainedModelRef.current : null)) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CompactExplorerSidebarHostProps {
|
interface CompactExplorerSidebarHostProps {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
type ExplorerTab,
|
type ExplorerTab,
|
||||||
} from "@/stores/panel-store";
|
} from "@/stores/panel-store";
|
||||||
import { useCloseFileExplorerGesture } from "@/mobile-panels/gestures";
|
import { useCloseFileExplorerGesture } from "@/mobile-panels/gestures";
|
||||||
|
import { useIsMobilePanelActive } from "@/mobile-panels/provider";
|
||||||
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
||||||
import {
|
import {
|
||||||
HEADER_INNER_HEIGHT,
|
HEADER_INNER_HEIGHT,
|
||||||
@@ -81,7 +82,7 @@ export function CompactExplorerSidebar({
|
|||||||
}: ExplorerSidebarProps) {
|
}: ExplorerSidebarProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const isOpen = usePanelStore(selectIsCompactFileExplorerOpen);
|
const isActive = useIsMobilePanelActive("file-explorer");
|
||||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||||
const { explorerTab, handleTabPress } = useExplorerSidebarSharedState({
|
const { explorerTab, handleTabPress } = useExplorerSidebarSharedState({
|
||||||
serverId,
|
serverId,
|
||||||
@@ -99,11 +100,11 @@ export function CompactExplorerSidebar({
|
|||||||
(reason: string) => {
|
(reason: string) => {
|
||||||
logExplorerSidebar("handleClose", {
|
logExplorerSidebar("handleClose", {
|
||||||
reason,
|
reason,
|
||||||
isOpen,
|
isOpen: isActive,
|
||||||
});
|
});
|
||||||
showMobileAgent();
|
showMobileAgent();
|
||||||
},
|
},
|
||||||
[isOpen, showMobileAgent],
|
[isActive, showMobileAgent],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleHeaderClose = useCallback(() => handleClose("header-close-button"), [handleClose]);
|
const handleHeaderClose = useCallback(() => handleClose("header-close-button"), [handleClose]);
|
||||||
@@ -127,7 +128,7 @@ export function CompactExplorerSidebar({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RetainedPanelActivity active={isOpen}>
|
<RetainedPanelActivity active={isActive}>
|
||||||
<MobilePanelOverlay
|
<MobilePanelOverlay
|
||||||
panel="file-explorer"
|
panel="file-explorer"
|
||||||
closeGesture={closeGesture}
|
closeGesture={closeGesture}
|
||||||
@@ -141,7 +142,7 @@ export function CompactExplorerSidebar({
|
|||||||
workspaceId={workspaceId}
|
workspaceId={workspaceId}
|
||||||
workspaceRoot={workspaceRoot}
|
workspaceRoot={workspaceRoot}
|
||||||
isGit={isGit}
|
isGit={isGit}
|
||||||
isOpen={isOpen}
|
isOpen={isActive}
|
||||||
onOpenFile={onOpenFile}
|
onOpenFile={onOpenFile}
|
||||||
/>
|
/>
|
||||||
</MobilePanelOverlay>
|
</MobilePanelOverlay>
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ import { usePanelStore } from "@/stores/panel-store";
|
|||||||
import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window";
|
import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window";
|
||||||
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
|
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
|
||||||
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
||||||
import { useIsMobilePanelPresented } from "@/mobile-panels/provider";
|
|
||||||
import {
|
import {
|
||||||
buildOpenProjectRoute,
|
buildOpenProjectRoute,
|
||||||
buildNewWorkspaceRoute,
|
buildNewWorkspaceRoute,
|
||||||
@@ -121,6 +120,7 @@ interface SidebarLabels {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface MobileSidebarProps extends SidebarSharedProps {
|
interface MobileSidebarProps extends SidebarSharedProps {
|
||||||
|
active: boolean;
|
||||||
insetsTop: number;
|
insetsTop: number;
|
||||||
insetsBottom: number;
|
insetsBottom: number;
|
||||||
closeSidebar: () => void;
|
closeSidebar: () => void;
|
||||||
@@ -273,6 +273,7 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
|
|||||||
<RetainedPanelActivity active={active}>
|
<RetainedPanelActivity active={active}>
|
||||||
<MobileSidebar
|
<MobileSidebar
|
||||||
{...sharedProps}
|
{...sharedProps}
|
||||||
|
active={active}
|
||||||
insetsTop={insets.top}
|
insetsTop={insets.top}
|
||||||
insetsBottom={insets.bottom}
|
insetsBottom={insets.bottom}
|
||||||
closeSidebar={showMobileAgent}
|
closeSidebar={showMobileAgent}
|
||||||
@@ -604,6 +605,7 @@ function SidebarFooter({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function MobileSidebar({
|
function MobileSidebar({
|
||||||
|
active,
|
||||||
theme,
|
theme,
|
||||||
workspaceGroups,
|
workspaceGroups,
|
||||||
projectIconTargets,
|
projectIconTargets,
|
||||||
@@ -638,7 +640,6 @@ function MobileSidebar({
|
|||||||
const isSessionsActive = pathname.includes("/sessions");
|
const isSessionsActive = pathname.includes("/sessions");
|
||||||
const isSchedulesActive = pathname.includes("/schedules");
|
const isSchedulesActive = pathname.includes("/schedules");
|
||||||
const { gesture: closeGesture, gestureRef: closeGestureRef } = useCloseAgentListGesture();
|
const { gesture: closeGesture, gestureRef: closeGestureRef } = useCloseAgentListGesture();
|
||||||
const dragGestureHostPresented = useIsMobilePanelPresented("agent-list");
|
|
||||||
|
|
||||||
const handleViewMore = useCallback(() => {
|
const handleViewMore = useCallback(() => {
|
||||||
closeSidebar();
|
closeSidebar();
|
||||||
@@ -737,7 +738,7 @@ function MobileSidebar({
|
|||||||
onWorkspacePress={handleWorkspacePress}
|
onWorkspacePress={handleWorkspacePress}
|
||||||
onAddProject={handleOpenProject}
|
onAddProject={handleOpenProject}
|
||||||
parentGestureRef={closeGestureRef}
|
parentGestureRef={closeGestureRef}
|
||||||
dragGestureHostPresented={dragGestureHostPresented}
|
dragGestureHostActive={active}
|
||||||
listHeaderComponent={workspacesSectionHeaderElement}
|
listHeaderComponent={workspacesSectionHeaderElement}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ import {
|
|||||||
markdownCopyTableCellDataSet,
|
markdownCopyTableCellDataSet,
|
||||||
type MarkdownCopyInlineTag,
|
type MarkdownCopyInlineTag,
|
||||||
} from "@/assistant-selection-copy/markup";
|
} from "@/assistant-selection-copy/markup";
|
||||||
|
import { capAssistantMessageForRender, getUtf8ByteLength } from "./assistant-message-render-limit";
|
||||||
export type { InlinePathTarget } from "@/assistant-file-links";
|
export type { InlinePathTarget } from "@/assistant-file-links";
|
||||||
export type { AssistantForkTarget };
|
export type { AssistantForkTarget };
|
||||||
|
|
||||||
@@ -763,6 +764,13 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
|
|||||||
containerCompactBottom: {
|
containerCompactBottom: {
|
||||||
paddingBottom: 0,
|
paddingBottom: 0,
|
||||||
},
|
},
|
||||||
|
cappedNotice: {
|
||||||
|
marginTop: theme.spacing[3],
|
||||||
|
fontFamily: theme.fontFamily.ui,
|
||||||
|
fontSize: theme.fontSize.base,
|
||||||
|
fontStyle: "italic",
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
},
|
||||||
imageFrame: {
|
imageFrame: {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
minHeight: 160,
|
minHeight: 160,
|
||||||
@@ -1488,10 +1496,16 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
spacing = "default",
|
spacing = "default",
|
||||||
phase,
|
phase,
|
||||||
}: AssistantMessageProps) {
|
}: AssistantMessageProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const markdownParser = useMemo(createAssistantMarkdownParser, []);
|
const markdownParser = useMemo(createAssistantMarkdownParser, []);
|
||||||
|
const renderedMessage = useMemo(() => capAssistantMessageForRender(message), [message]);
|
||||||
// Paint a paced prefix while the turn is streaming so text arrives at a steady
|
// Paint a paced prefix while the turn is streaming so text arrives at a steady
|
||||||
// rate instead of in whatever lumps the daemon's coalescing window produced.
|
// rate instead of in whatever lumps the daemon's coalescing window produced.
|
||||||
const revealedMessage = useRevealedText(message, phase);
|
const revealedMessage = useRevealedText(renderedMessage.text, phase);
|
||||||
|
const fullMessageByteLength = useMemo(
|
||||||
|
() => (renderedMessage.capped && phase === "complete" ? getUtf8ByteLength(message) : null),
|
||||||
|
[message, phase, renderedMessage.capped],
|
||||||
|
);
|
||||||
|
|
||||||
const fileLinkActions = useAssistantFileLinkActions();
|
const fileLinkActions = useAssistantFileLinkActions();
|
||||||
const handleMarkdownLinkPress = useStableEvent((url: string) => {
|
const handleMarkdownLinkPress = useStableEvent((url: string) => {
|
||||||
@@ -1970,6 +1984,14 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
/>
|
/>
|
||||||
</AssistantMessageBlockContainer>
|
</AssistantMessageBlockContainer>
|
||||||
))}
|
))}
|
||||||
|
{fullMessageByteLength !== null ? (
|
||||||
|
<Text
|
||||||
|
testID="assistant-message-capped-notice"
|
||||||
|
style={assistantMessageStylesheet.cappedNotice}
|
||||||
|
>
|
||||||
|
{t("agentStream.messageCapped", { bytes: fullMessageByteLength })}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -234,7 +234,7 @@ interface SidebarWorkspaceListProps {
|
|||||||
listHeaderComponent?: ReactElement | null;
|
listHeaderComponent?: ReactElement | null;
|
||||||
/** Gesture ref for coordinating with parent gestures (e.g., sidebar close) */
|
/** Gesture ref for coordinating with parent gestures (e.g., sidebar close) */
|
||||||
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
||||||
dragGestureHostPresented?: boolean;
|
dragGestureHostActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProjectHeaderRowProps {
|
interface ProjectHeaderRowProps {
|
||||||
@@ -1540,7 +1540,7 @@ function ProjectBlock({
|
|||||||
isDragging,
|
isDragging,
|
||||||
dragHandleProps,
|
dragHandleProps,
|
||||||
useNestable,
|
useNestable,
|
||||||
dragGestureHostPresented,
|
dragGestureHostActive,
|
||||||
creatingWorkspaceIds,
|
creatingWorkspaceIds,
|
||||||
activeWorkspaceSelection,
|
activeWorkspaceSelection,
|
||||||
hostBadgeByServerId,
|
hostBadgeByServerId,
|
||||||
@@ -1565,7 +1565,7 @@ function ProjectBlock({
|
|||||||
isDragging: boolean;
|
isDragging: boolean;
|
||||||
dragHandleProps?: DraggableListDragHandleProps;
|
dragHandleProps?: DraggableListDragHandleProps;
|
||||||
useNestable: boolean;
|
useNestable: boolean;
|
||||||
dragGestureHostPresented?: boolean;
|
dragGestureHostActive?: boolean;
|
||||||
creatingWorkspaceIds: ReadonlySet<string>;
|
creatingWorkspaceIds: ReadonlySet<string>;
|
||||||
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
|
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
|
||||||
hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>;
|
hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>;
|
||||||
@@ -1745,7 +1745,7 @@ function ProjectBlock({
|
|||||||
useDragHandle
|
useDragHandle
|
||||||
nestable={useNestable}
|
nestable={useNestable}
|
||||||
simultaneousGestureRef={parentGestureRef}
|
simultaneousGestureRef={parentGestureRef}
|
||||||
gestureHostPresented={dragGestureHostPresented}
|
gestureHostPresented={dragGestureHostActive}
|
||||||
containerStyle={styles.workspaceListContainer}
|
containerStyle={styles.workspaceListContainer}
|
||||||
/>
|
/>
|
||||||
{canToggleWorkspaces ? (
|
{canToggleWorkspaces ? (
|
||||||
@@ -1829,7 +1829,7 @@ function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlo
|
|||||||
previous.isDragging === next.isDragging &&
|
previous.isDragging === next.isDragging &&
|
||||||
previous.dragHandleProps === next.dragHandleProps &&
|
previous.dragHandleProps === next.dragHandleProps &&
|
||||||
previous.useNestable === next.useNestable &&
|
previous.useNestable === next.useNestable &&
|
||||||
previous.dragGestureHostPresented === next.dragGestureHostPresented &&
|
previous.dragGestureHostActive === next.dragGestureHostActive &&
|
||||||
previous.creatingWorkspaceIds === next.creatingWorkspaceIds &&
|
previous.creatingWorkspaceIds === next.creatingWorkspaceIds &&
|
||||||
areProjectBlockSelectionsEqual(previous, next)
|
areProjectBlockSelectionsEqual(previous, next)
|
||||||
);
|
);
|
||||||
@@ -1882,7 +1882,7 @@ export function SidebarWorkspaceList({
|
|||||||
listFooterComponent,
|
listFooterComponent,
|
||||||
listHeaderComponent,
|
listHeaderComponent,
|
||||||
parentGestureRef,
|
parentGestureRef,
|
||||||
dragGestureHostPresented,
|
dragGestureHostActive,
|
||||||
}: SidebarWorkspaceListProps) {
|
}: SidebarWorkspaceListProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const hosts = useHosts();
|
const hosts = useHosts();
|
||||||
@@ -1959,7 +1959,7 @@ export function SidebarWorkspaceList({
|
|||||||
listHeaderComponent={listHeaderComponent}
|
listHeaderComponent={listHeaderComponent}
|
||||||
sidebarFilterEmpty={sidebarFilterEmpty}
|
sidebarFilterEmpty={sidebarFilterEmpty}
|
||||||
parentGestureRef={parentGestureRef}
|
parentGestureRef={parentGestureRef}
|
||||||
dragGestureHostPresented={dragGestureHostPresented}
|
dragGestureHostActive={dragGestureHostActive}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ProjectModeList
|
<ProjectModeList
|
||||||
@@ -1977,7 +1977,7 @@ export function SidebarWorkspaceList({
|
|||||||
sidebarFilterEmpty={sidebarFilterEmpty}
|
sidebarFilterEmpty={sidebarFilterEmpty}
|
||||||
hasActiveProjectFilter={hasActiveProjectFilter}
|
hasActiveProjectFilter={hasActiveProjectFilter}
|
||||||
parentGestureRef={parentGestureRef}
|
parentGestureRef={parentGestureRef}
|
||||||
dragGestureHostPresented={dragGestureHostPresented}
|
dragGestureHostActive={dragGestureHostActive}
|
||||||
pathname={pathname}
|
pathname={pathname}
|
||||||
hostBadgeByServerId={hostBadgeByServerId}
|
hostBadgeByServerId={hostBadgeByServerId}
|
||||||
supportsMultiplicityByServerId={supportsMultiplicityByServerId}
|
supportsMultiplicityByServerId={supportsMultiplicityByServerId}
|
||||||
@@ -2010,7 +2010,7 @@ function SidebarGroupedModeList({
|
|||||||
listHeaderComponent,
|
listHeaderComponent,
|
||||||
sidebarFilterEmpty,
|
sidebarFilterEmpty,
|
||||||
parentGestureRef,
|
parentGestureRef,
|
||||||
dragGestureHostPresented,
|
dragGestureHostActive,
|
||||||
}: {
|
}: {
|
||||||
workspaceGroups: SidebarWorkspaceGroup[];
|
workspaceGroups: SidebarWorkspaceGroup[];
|
||||||
pinnedGroups: PinnedSidebarGroups;
|
pinnedGroups: PinnedSidebarGroups;
|
||||||
@@ -2025,7 +2025,7 @@ function SidebarGroupedModeList({
|
|||||||
listHeaderComponent?: ReactElement | null;
|
listHeaderComponent?: ReactElement | null;
|
||||||
sidebarFilterEmpty: boolean;
|
sidebarFilterEmpty: boolean;
|
||||||
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
||||||
dragGestureHostPresented?: boolean;
|
dragGestureHostActive?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const showShortcutBadges = useShowShortcutBadges();
|
const showShortcutBadges = useShowShortcutBadges();
|
||||||
const pinnedWorkspaces = useMemo(
|
const pinnedWorkspaces = useMemo(
|
||||||
@@ -2052,7 +2052,7 @@ function SidebarGroupedModeList({
|
|||||||
listHeaderComponent={listHeaderComponent}
|
listHeaderComponent={listHeaderComponent}
|
||||||
sidebarFilterEmpty={sidebarFilterEmpty}
|
sidebarFilterEmpty={sidebarFilterEmpty}
|
||||||
parentGestureRef={parentGestureRef}
|
parentGestureRef={parentGestureRef}
|
||||||
dragGestureHostPresented={dragGestureHostPresented}
|
dragGestureHostActive={dragGestureHostActive}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2072,7 +2072,7 @@ function ProjectModeList({
|
|||||||
sidebarFilterEmpty,
|
sidebarFilterEmpty,
|
||||||
hasActiveProjectFilter,
|
hasActiveProjectFilter,
|
||||||
parentGestureRef,
|
parentGestureRef,
|
||||||
dragGestureHostPresented,
|
dragGestureHostActive,
|
||||||
pathname,
|
pathname,
|
||||||
hostBadgeByServerId,
|
hostBadgeByServerId,
|
||||||
supportsMultiplicityByServerId,
|
supportsMultiplicityByServerId,
|
||||||
@@ -2289,7 +2289,7 @@ function ProjectModeList({
|
|||||||
isDragging={dragState.isDragging}
|
isDragging={dragState.isDragging}
|
||||||
dragHandleProps={dragState.dragHandleProps}
|
dragHandleProps={dragState.dragHandleProps}
|
||||||
useNestable={platformIsNative}
|
useNestable={platformIsNative}
|
||||||
dragGestureHostPresented={dragGestureHostPresented}
|
dragGestureHostActive={dragGestureHostActive}
|
||||||
creatingWorkspaceIds={creatingWorkspaceIds}
|
creatingWorkspaceIds={creatingWorkspaceIds}
|
||||||
activeWorkspaceSelection={activeWorkspaceSelection}
|
activeWorkspaceSelection={activeWorkspaceSelection}
|
||||||
hostBadgeByServerId={hostBadgeByServerId}
|
hostBadgeByServerId={hostBadgeByServerId}
|
||||||
@@ -2311,7 +2311,7 @@ function ProjectModeList({
|
|||||||
onWorkspacePress,
|
onWorkspacePress,
|
||||||
onToggleProjectCollapsed,
|
onToggleProjectCollapsed,
|
||||||
parentGestureRef,
|
parentGestureRef,
|
||||||
dragGestureHostPresented,
|
dragGestureHostActive,
|
||||||
projectIconByProjectViewKey,
|
projectIconByProjectViewKey,
|
||||||
selectionEnabled,
|
selectionEnabled,
|
||||||
shortcutIndexByWorkspaceKey,
|
shortcutIndexByWorkspaceKey,
|
||||||
@@ -2388,7 +2388,7 @@ function ProjectModeList({
|
|||||||
useDragHandle
|
useDragHandle
|
||||||
nestable={platformIsNative}
|
nestable={platformIsNative}
|
||||||
simultaneousGestureRef={parentGestureRef}
|
simultaneousGestureRef={parentGestureRef}
|
||||||
gestureHostPresented={dragGestureHostPresented}
|
gestureHostPresented={dragGestureHostActive}
|
||||||
containerStyle={styles.projectListContainer}
|
containerStyle={styles.projectListContainer}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -2411,7 +2411,7 @@ function ProjectModeList({
|
|||||||
useDragHandle
|
useDragHandle
|
||||||
nestable={platformIsNative}
|
nestable={platformIsNative}
|
||||||
simultaneousGestureRef={parentGestureRef}
|
simultaneousGestureRef={parentGestureRef}
|
||||||
gestureHostPresented={dragGestureHostPresented}
|
gestureHostPresented={dragGestureHostActive}
|
||||||
containerStyle={styles.workspaceListContainer}
|
containerStyle={styles.workspaceListContainer}
|
||||||
/>
|
/>
|
||||||
{canTogglePinnedChats ? (
|
{canTogglePinnedChats ? (
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ interface StatusWorkspaceListProps {
|
|||||||
/** Swaps the group list for the label filter's empty state. Never the header above it. */
|
/** Swaps the group list for the label filter's empty state. Never the header above it. */
|
||||||
sidebarFilterEmpty?: boolean;
|
sidebarFilterEmpty?: boolean;
|
||||||
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
||||||
dragGestureHostPresented?: boolean;
|
dragGestureHostActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SidebarStatusWorkspaceList({
|
export function SidebarStatusWorkspaceList({
|
||||||
@@ -143,7 +143,7 @@ export function SidebarStatusWorkspaceList({
|
|||||||
listHeaderComponent,
|
listHeaderComponent,
|
||||||
sidebarFilterEmpty = false,
|
sidebarFilterEmpty = false,
|
||||||
parentGestureRef,
|
parentGestureRef,
|
||||||
dragGestureHostPresented,
|
dragGestureHostActive,
|
||||||
}: StatusWorkspaceListProps) {
|
}: StatusWorkspaceListProps) {
|
||||||
const collapsedWorkspaceGroupKeys = useSidebarCollapsedSectionsStore(
|
const collapsedWorkspaceGroupKeys = useSidebarCollapsedSectionsStore(
|
||||||
(state) => state.collapsedWorkspaceGroupKeys,
|
(state) => state.collapsedWorkspaceGroupKeys,
|
||||||
@@ -214,7 +214,7 @@ export function SidebarStatusWorkspaceList({
|
|||||||
useDragHandle
|
useDragHandle
|
||||||
nestable={platformIsNative}
|
nestable={platformIsNative}
|
||||||
simultaneousGestureRef={parentGestureRef}
|
simultaneousGestureRef={parentGestureRef}
|
||||||
gestureHostPresented={dragGestureHostPresented}
|
gestureHostPresented={dragGestureHostActive}
|
||||||
/>
|
/>
|
||||||
{canTogglePinnedWorkspaces ? (
|
{canTogglePinnedWorkspaces ? (
|
||||||
<SidebarGroupToggleRow
|
<SidebarGroupToggleRow
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
const resized = canvas.width !== pixelWidth || canvas.height !== pixelHeight;
|
const resized = canvas.width !== pixelWidth || canvas.height !== pixelHeight;
|
||||||
if (canvas.width !== pixelWidth) canvas.width = pixelWidth;
|
if (canvas.width !== pixelWidth) canvas.width = pixelWidth;
|
||||||
if (canvas.height !== pixelHeight) canvas.height = pixelHeight;
|
if (canvas.height !== pixelHeight) canvas.height = pixelHeight;
|
||||||
|
canvas.style.width = `${currentModel.viewportWidth}px`;
|
||||||
canvas.style.top = `${canvasTop}px`;
|
canvas.style.top = `${canvasTop}px`;
|
||||||
canvas.style.height = `${canvasHeight}px`;
|
canvas.style.height = `${canvasHeight}px`;
|
||||||
const context = canvas.getContext("2d");
|
const context = canvas.getContext("2d");
|
||||||
@@ -535,11 +536,10 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
const canvasStyle = useMemo<React.CSSProperties>(
|
const canvasStyle = useMemo<React.CSSProperties>(
|
||||||
() => ({
|
() => ({
|
||||||
...CANVAS_STYLE,
|
...CANVAS_STYLE,
|
||||||
width: model.viewportWidth,
|
|
||||||
fontFamily: (loadedTypography ?? desiredTypography).family,
|
fontFamily: (loadedTypography ?? desiredTypography).family,
|
||||||
fontSize: (loadedTypography ?? desiredTypography).size,
|
fontSize: (loadedTypography ?? desiredTypography).size,
|
||||||
}),
|
}),
|
||||||
[desiredTypography, loadedTypography, model.viewportWidth],
|
[desiredTypography, loadedTypography],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ function CheckGroup({
|
|||||||
}) {
|
}) {
|
||||||
const handlePress = useCallback(() => onToggle(group.status), [group.status, onToggle]);
|
const handlePress = useCallback(() => onToggle(group.status), [group.status, onToggle]);
|
||||||
return (
|
return (
|
||||||
<View>
|
<View testID={`pr-pane-check-group-${group.status}`}>
|
||||||
<Pressable onPress={handlePress} style={styles.groupHeader} accessibilityRole="button">
|
<Pressable onPress={handlePress} style={styles.groupHeader} accessibilityRole="button">
|
||||||
<Text style={styles.groupLabel}>{group.label}</Text>
|
<Text style={styles.groupLabel}>{group.label}</Text>
|
||||||
{collapsed ? (
|
{collapsed ? (
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ function storedSendBehavior(storage: Storage): SendBehavior | undefined {
|
|||||||
return raw === undefined ? undefined : JSON.parse(raw).sendBehavior;
|
return raw === undefined ? undefined : JSON.parse(raw).sendBehavior;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function storedContentFontSize(storage: Storage): number | undefined {
|
||||||
|
const raw = storage.entries.get(APP_SETTINGS_KEY);
|
||||||
|
return raw === undefined ? undefined : JSON.parse(raw).contentFontSize;
|
||||||
|
}
|
||||||
|
|
||||||
/** An in-memory storage whose write to `failingKey` always throws, as a full disk would. */
|
/** An in-memory storage whose write to `failingKey` always throws, as a full disk would. */
|
||||||
function createFailingWriteStorage(failingKey: string): Storage {
|
function createFailingWriteStorage(failingKey: string): Storage {
|
||||||
const storage = createInMemoryKeyValueStorage();
|
const storage = createInMemoryKeyValueStorage();
|
||||||
@@ -81,6 +86,47 @@ describe("migrateAppSettings", () => {
|
|||||||
expect(appliedIds(storage)).toEqual(["some-later-migration", "steer-default"]);
|
expect(appliedIds(storage)).toEqual(["some-later-migration", "steer-default"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("migrates every mobile 15px content preference to 16px", async () => {
|
||||||
|
const storage = createInMemoryKeyValueStorage();
|
||||||
|
const settings = { ...settingsWith("steer"), contentFontSize: 15 };
|
||||||
|
|
||||||
|
const result = await migrateAppSettings(settings, storage, undefined, { native: true });
|
||||||
|
|
||||||
|
expect(result.contentFontSize).toBe(16);
|
||||||
|
expect(storedContentFontSize(storage)).toBe(16);
|
||||||
|
expect(appliedIds(storage)).toEqual(["steer-default", "mobile-content-16"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a 15px web content preference unchanged", async () => {
|
||||||
|
const storage = createInMemoryKeyValueStorage();
|
||||||
|
const settings = { ...settingsWith("steer"), contentFontSize: 15 };
|
||||||
|
|
||||||
|
const result = await migrateAppSettings(settings, storage, undefined, { native: false });
|
||||||
|
|
||||||
|
expect(result.contentFontSize).toBe(15);
|
||||||
|
expect(storedContentFontSize(storage)).toBeUndefined();
|
||||||
|
expect(appliedIds(storage)).toEqual(["steer-default"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets a mobile user choose 15px after the default migration ran", async () => {
|
||||||
|
const storage = createInMemoryKeyValueStorage();
|
||||||
|
await migrateAppSettings(
|
||||||
|
{ ...settingsWith("steer"), contentFontSize: 15 },
|
||||||
|
storage,
|
||||||
|
undefined,
|
||||||
|
{ native: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await migrateAppSettings(
|
||||||
|
{ ...settingsWith("steer"), contentFontSize: 15 },
|
||||||
|
storage,
|
||||||
|
undefined,
|
||||||
|
{ native: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.contentFontSize).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
it("stays unmarked when the settings write fails, so a later launch retries", async () => {
|
it("stays unmarked when the settings write fails, so a later launch retries", async () => {
|
||||||
const storage = createFailingWriteStorage(APP_SETTINGS_KEY);
|
const storage = createFailingWriteStorage(APP_SETTINGS_KEY);
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ const AppliedMigrationsSchema = z.strictObject({ applied: z.array(z.string()) })
|
|||||||
*/
|
*/
|
||||||
const STEER_DEFAULT_MIGRATION = "steer-default";
|
const STEER_DEFAULT_MIGRATION = "steer-default";
|
||||||
|
|
||||||
|
/** Existing mobile installs materialized the old 15px content default in storage. */
|
||||||
|
const MOBILE_CONTENT_16_MIGRATION = "mobile-content-16";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Brings stored settings up to date, returning what the caller should use. Owns both writes so
|
* Brings stored settings up to date, returning what the caller should use. Owns both writes so
|
||||||
* the marker can only ever be written after the settings it describes: a failed marker write
|
* the marker can only ever be written after the settings it describes: a failed marker write
|
||||||
@@ -23,6 +26,7 @@ export async function migrateAppSettings(
|
|||||||
settings: AppSettings,
|
settings: AppSettings,
|
||||||
storage: KeyValueStorage,
|
storage: KeyValueStorage,
|
||||||
stored?: PersistedAppSettings,
|
stored?: PersistedAppSettings,
|
||||||
|
options: { native?: boolean } = {},
|
||||||
): Promise<AppSettings> {
|
): Promise<AppSettings> {
|
||||||
const migrationMarker = await readValidatedJson(
|
const migrationMarker = await readValidatedJson(
|
||||||
storage,
|
storage,
|
||||||
@@ -30,12 +34,26 @@ export async function migrateAppSettings(
|
|||||||
AppliedMigrationsSchema,
|
AppliedMigrationsSchema,
|
||||||
);
|
);
|
||||||
const applied = new Set(migrationMarker?.applied ?? []);
|
const applied = new Set(migrationMarker?.applied ?? []);
|
||||||
if (applied.has(STEER_DEFAULT_MIGRATION)) {
|
let addedMigration = false;
|
||||||
|
|
||||||
|
let migrated = settings;
|
||||||
|
if (!applied.has(STEER_DEFAULT_MIGRATION)) {
|
||||||
|
migrated =
|
||||||
|
migrated.sendBehavior === "interrupt" ? { ...migrated, sendBehavior: "steer" } : migrated;
|
||||||
|
applied.add(STEER_DEFAULT_MIGRATION);
|
||||||
|
addedMigration = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.native && !applied.has(MOBILE_CONTENT_16_MIGRATION)) {
|
||||||
|
migrated = migrated.contentFontSize === 15 ? { ...migrated, contentFontSize: 16 } : migrated;
|
||||||
|
applied.add(MOBILE_CONTENT_16_MIGRATION);
|
||||||
|
addedMigration = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!addedMigration) {
|
||||||
return settings;
|
return settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
const migrated: AppSettings =
|
|
||||||
settings.sendBehavior === "interrupt" ? { ...settings, sendBehavior: "steer" } : settings;
|
|
||||||
if (migrated !== settings) {
|
if (migrated !== settings) {
|
||||||
const storedSidebarRowItems = stored?.sidebarRowItems ?? {};
|
const storedSidebarRowItems = stored?.sidebarRowItems ?? {};
|
||||||
await storage.setItem(
|
await storage.setItem(
|
||||||
@@ -48,7 +66,6 @@ export async function migrateAppSettings(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
applied.add(STEER_DEFAULT_MIGRATION);
|
|
||||||
await storage.setItem(SETTINGS_MIGRATIONS_KEY, JSON.stringify({ applied: [...applied] }));
|
await storage.setItem(SETTINGS_MIGRATIONS_KEY, JSON.stringify({ applied: [...applied] }));
|
||||||
return migrated;
|
return migrated;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -659,8 +659,8 @@ describe("appearance settings", () => {
|
|||||||
expect(defaultUiBaseFontSize(false)).toBe(14);
|
expect(defaultUiBaseFontSize(false)).toBe(14);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses a 15px content default on mobile and web", () => {
|
it("uses a 16px content default on mobile and a 15px default on web", () => {
|
||||||
expect(defaultContentFontSize(true)).toBe(15);
|
expect(defaultContentFontSize(true)).toBe(16);
|
||||||
expect(defaultContentFontSize(false)).toBe(15);
|
expect(defaultContentFontSize(false)).toBe(15);
|
||||||
expect(DEFAULT_CONTENT_FONT_SIZE).toBe(defaultContentFontSize(false));
|
expect(DEFAULT_CONTENT_FONT_SIZE).toBe(defaultContentFontSize(false));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export const DEFAULT_UI_BASE_FONT_SIZE = defaultUiBaseFontSize(isNative);
|
|||||||
export const MIN_UI_BASE_FONT_SIZE = 10;
|
export const MIN_UI_BASE_FONT_SIZE = 10;
|
||||||
export const MAX_UI_BASE_FONT_SIZE = 21;
|
export const MAX_UI_BASE_FONT_SIZE = 21;
|
||||||
export function defaultContentFontSize(native: boolean): number {
|
export function defaultContentFontSize(native: boolean): number {
|
||||||
return native ? 15 : FONT_SIZE.content;
|
return native ? 16 : FONT_SIZE.content;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_CONTENT_FONT_SIZE = defaultContentFontSize(isNative);
|
export const DEFAULT_CONTENT_FONT_SIZE = defaultContentFontSize(isNative);
|
||||||
@@ -75,7 +75,7 @@ export interface AppSettings {
|
|||||||
uiFontFamily: string; // "" = platform default UI stack
|
uiFontFamily: string; // "" = platform default UI stack
|
||||||
monoFontFamily: string; // "" = platform default mono stack
|
monoFontFamily: string; // "" = platform default mono stack
|
||||||
uiBaseFontSize: number; // clamped px, platform default 14 or 15
|
uiBaseFontSize: number; // clamped px, platform default 14 or 15
|
||||||
contentFontSize: number; // clamped px, default 15
|
contentFontSize: number; // clamped px, platform default 15 or 16
|
||||||
codeFontSize: number; // clamped px, default 12
|
codeFontSize: number; // clamped px, default 12
|
||||||
syntaxTheme: SyntaxThemeId; // default "one"
|
syntaxTheme: SyntaxThemeId; // default "one"
|
||||||
workspaceTitleSource: WorkspaceTitleSource;
|
workspaceTitleSource: WorkspaceTitleSource;
|
||||||
@@ -343,7 +343,7 @@ export async function loadAppSettingsFromStorage(deps: SettingsDeps): Promise<Ap
|
|||||||
await writeAppSettings(deps.storage, read.stored, read.settings);
|
await writeAppSettings(deps.storage, read.stored, read.settings);
|
||||||
}
|
}
|
||||||
const { needsWrite: _needsWrite, ...stored } = read.stored;
|
const { needsWrite: _needsWrite, ...stored } = read.stored;
|
||||||
return await migrateAppSettings(read.settings, deps.storage, stored);
|
return await migrateAppSettings(read.settings, deps.storage, stored, { native: isNative });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[AppSettings] Failed to load settings:", error);
|
console.error("[AppSettings] Failed to load settings:", error);
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -203,6 +203,7 @@ export const ar: TranslationResources = {
|
|||||||
empty: "ابدأ الدردشة مع هذا الوكيل...",
|
empty: "ابدأ الدردشة مع هذا الوكيل...",
|
||||||
scrollToBottom: "قم بالتمرير إلى الأسفل",
|
scrollToBottom: "قم بالتمرير إلى الأسفل",
|
||||||
historyLoadFailed: "تعذر تحميل سجل الوكيل",
|
historyLoadFailed: "تعذر تحميل سجل الوكيل",
|
||||||
|
messageCapped: "تم اقتطاع هذه الرسالة ({{bytes}} بايت).",
|
||||||
permission: {
|
permission: {
|
||||||
plan: "يخطط",
|
plan: "يخطط",
|
||||||
required: "الإذن مطلوب",
|
required: "الإذن مطلوب",
|
||||||
|
|||||||
@@ -200,6 +200,7 @@ export const en = {
|
|||||||
empty: "Start chatting with this agent...",
|
empty: "Start chatting with this agent...",
|
||||||
scrollToBottom: "Scroll to bottom",
|
scrollToBottom: "Scroll to bottom",
|
||||||
historyLoadFailed: "Couldn't load agent history",
|
historyLoadFailed: "Couldn't load agent history",
|
||||||
|
messageCapped: "This message was capped ({{bytes}} bytes).",
|
||||||
permission: {
|
permission: {
|
||||||
plan: "Plan",
|
plan: "Plan",
|
||||||
required: "Permission Required",
|
required: "Permission Required",
|
||||||
|
|||||||
@@ -203,6 +203,7 @@ export const es: TranslationResources = {
|
|||||||
empty: "Comience a chatear con este agente...",
|
empty: "Comience a chatear con este agente...",
|
||||||
scrollToBottom: "Desplazarse hacia abajo",
|
scrollToBottom: "Desplazarse hacia abajo",
|
||||||
historyLoadFailed: "No se pudo cargar el historial del agente",
|
historyLoadFailed: "No se pudo cargar el historial del agente",
|
||||||
|
messageCapped: "Este mensaje fue truncado ({{bytes}} bytes).",
|
||||||
permission: {
|
permission: {
|
||||||
plan: "Plan",
|
plan: "Plan",
|
||||||
required: "Permiso requerido",
|
required: "Permiso requerido",
|
||||||
|
|||||||
@@ -205,6 +205,7 @@ export const fr: TranslationResources = {
|
|||||||
empty: "Commencez à discuter avec cet agent...",
|
empty: "Commencez à discuter avec cet agent...",
|
||||||
scrollToBottom: "Faire défiler vers le bas",
|
scrollToBottom: "Faire défiler vers le bas",
|
||||||
historyLoadFailed: "Impossible de charger l’historique de l’agent",
|
historyLoadFailed: "Impossible de charger l’historique de l’agent",
|
||||||
|
messageCapped: "Ce message a été tronqué ({{bytes}} octets).",
|
||||||
permission: {
|
permission: {
|
||||||
plan: "Plan",
|
plan: "Plan",
|
||||||
required: "Autorisation requise",
|
required: "Autorisation requise",
|
||||||
|
|||||||
@@ -203,6 +203,7 @@ export const ja: TranslationResources = {
|
|||||||
empty: "このエージェントとチャットを始めましょう...",
|
empty: "このエージェントとチャットを始めましょう...",
|
||||||
scrollToBottom: "下にスクロール",
|
scrollToBottom: "下にスクロール",
|
||||||
historyLoadFailed: "エージェントの履歴を読み込めませんでした",
|
historyLoadFailed: "エージェントの履歴を読み込めませんでした",
|
||||||
|
messageCapped: "このメッセージは上限で切り詰められました({{bytes}}バイト)。",
|
||||||
permission: {
|
permission: {
|
||||||
plan: "プラン",
|
plan: "プラン",
|
||||||
required: "権限が必要です",
|
required: "権限が必要です",
|
||||||
|
|||||||
@@ -203,6 +203,7 @@ export const ko: TranslationResources = {
|
|||||||
empty: "이 에이전트와 대화를 시작하세요...",
|
empty: "이 에이전트와 대화를 시작하세요...",
|
||||||
scrollToBottom: "맨 아래로 스크롤",
|
scrollToBottom: "맨 아래로 스크롤",
|
||||||
historyLoadFailed: "에이전트 기록을 로드할 수 없습니다.",
|
historyLoadFailed: "에이전트 기록을 로드할 수 없습니다.",
|
||||||
|
messageCapped: "이 메시지는 길이 제한으로 잘렸습니다({{bytes}}바이트).",
|
||||||
permission: {
|
permission: {
|
||||||
plan: "계획",
|
plan: "계획",
|
||||||
required: "권한 필요",
|
required: "권한 필요",
|
||||||
|
|||||||
@@ -203,6 +203,7 @@ export const ptBR: TranslationResources = {
|
|||||||
empty: "Comece a conversar com este agente...",
|
empty: "Comece a conversar com este agente...",
|
||||||
scrollToBottom: "Rolar para o fim",
|
scrollToBottom: "Rolar para o fim",
|
||||||
historyLoadFailed: "Não foi possível carregar o histórico do agente",
|
historyLoadFailed: "Não foi possível carregar o histórico do agente",
|
||||||
|
messageCapped: "Esta mensagem foi truncada ({{bytes}} bytes).",
|
||||||
permission: {
|
permission: {
|
||||||
plan: "Plano",
|
plan: "Plano",
|
||||||
required: "Permissão necessária",
|
required: "Permissão necessária",
|
||||||
|
|||||||
@@ -203,6 +203,7 @@ export const ru: TranslationResources = {
|
|||||||
empty: "Начните общаться с этим агентом...",
|
empty: "Начните общаться с этим агентом...",
|
||||||
scrollToBottom: "Прокрутить вниз",
|
scrollToBottom: "Прокрутить вниз",
|
||||||
historyLoadFailed: "Не удалось загрузить историю агента",
|
historyLoadFailed: "Не удалось загрузить историю агента",
|
||||||
|
messageCapped: "Это сообщение было обрезано ({{bytes}} байт).",
|
||||||
permission: {
|
permission: {
|
||||||
plan: "План",
|
plan: "План",
|
||||||
required: "Требуется разрешение",
|
required: "Требуется разрешение",
|
||||||
|
|||||||
@@ -203,6 +203,7 @@ export const zhCN: TranslationResources = {
|
|||||||
empty: "开始和这个 Agent 对话...",
|
empty: "开始和这个 Agent 对话...",
|
||||||
scrollToBottom: "滚动到底部",
|
scrollToBottom: "滚动到底部",
|
||||||
historyLoadFailed: "无法加载智能体历史记录",
|
historyLoadFailed: "无法加载智能体历史记录",
|
||||||
|
messageCapped: "此消息已被截断({{bytes}} 字节)。",
|
||||||
permission: {
|
permission: {
|
||||||
plan: "Plan",
|
plan: "Plan",
|
||||||
required: "需要权限",
|
required: "需要权限",
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export function useOpenAgentListGesture(enabled: boolean) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.onStart(() => {
|
.onStart(() => {
|
||||||
startedRevision.value = beginGesture({ origin: "agent", preview: "agent-list" });
|
startedRevision.value = beginGesture({ origin: "agent" });
|
||||||
})
|
})
|
||||||
.onUpdate((event) => {
|
.onUpdate((event) => {
|
||||||
updateGesture(startedRevision.value, -event.translationX / windowWidth);
|
updateGesture(startedRevision.value, -event.translationX / windowWidth);
|
||||||
@@ -188,7 +188,6 @@ export function useCloseAgentListGesture() {
|
|||||||
.onStart(() => {
|
.onStart(() => {
|
||||||
startedRevision.value = beginGesture({
|
startedRevision.value = beginGesture({
|
||||||
origin: "agent-list",
|
origin: "agent-list",
|
||||||
preview: "agent-list",
|
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.onUpdate((event) => {
|
.onUpdate((event) => {
|
||||||
@@ -290,7 +289,6 @@ export function useOpenFileExplorerGesture({ enabled, onOpen }: OpenFileExplorer
|
|||||||
.onStart(() => {
|
.onStart(() => {
|
||||||
startedRevision.value = beginGesture({
|
startedRevision.value = beginGesture({
|
||||||
origin: "agent",
|
origin: "agent",
|
||||||
preview: "file-explorer",
|
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.onUpdate((event) => {
|
.onUpdate((event) => {
|
||||||
@@ -386,7 +384,6 @@ export function useCloseFileExplorerGesture() {
|
|||||||
.onStart(() => {
|
.onStart(() => {
|
||||||
startedRevision.value = beginGesture({
|
startedRevision.value = beginGesture({
|
||||||
origin: "file-explorer",
|
origin: "file-explorer",
|
||||||
preview: "file-explorer",
|
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.onUpdate((event) => {
|
.onUpdate((event) => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
canBeginMobilePanelGesture,
|
canBeginMobilePanelGesture,
|
||||||
createMobilePanelMotionState,
|
createMobilePanelMotionState,
|
||||||
getMobilePanelFrame,
|
getMobilePanelFrame,
|
||||||
|
isMobilePanelActive,
|
||||||
isMobilePanelGestureCurrent,
|
isMobilePanelGestureCurrent,
|
||||||
transitionMobilePanel,
|
transitionMobilePanel,
|
||||||
type MobilePanelCommit,
|
type MobilePanelCommit,
|
||||||
@@ -77,6 +78,71 @@ class MobilePanelsScenario {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("mobile panel ownership", () => {
|
describe("mobile panel ownership", () => {
|
||||||
|
it("publishes activity only after motion settles", () => {
|
||||||
|
const initial = createMobilePanelMotionState({ target: "agent", revision: 0 });
|
||||||
|
const dragging = transitionMobilePanel(initial, {
|
||||||
|
type: "gesture.begin",
|
||||||
|
origin: "agent",
|
||||||
|
}).state;
|
||||||
|
const released = transitionMobilePanel(dragging, {
|
||||||
|
type: "gesture.finish",
|
||||||
|
startedRevision: 0,
|
||||||
|
success: true,
|
||||||
|
target: "agent-list",
|
||||||
|
}).state;
|
||||||
|
const commanded = transitionMobilePanel(released, {
|
||||||
|
type: "command",
|
||||||
|
selection: { target: "agent-list", revision: 1 },
|
||||||
|
}).state;
|
||||||
|
|
||||||
|
expect(isMobilePanelActive(dragging, "agent-list")).toBe(false);
|
||||||
|
expect(isMobilePanelActive(released, "agent-list")).toBe(false);
|
||||||
|
expect(isMobilePanelActive(commanded, "agent-list")).toBe(false);
|
||||||
|
|
||||||
|
const settled = transitionMobilePanel(commanded, {
|
||||||
|
type: "animation.finished",
|
||||||
|
revision: 1,
|
||||||
|
target: "agent-list",
|
||||||
|
}).state;
|
||||||
|
expect(isMobilePanelActive(settled, "agent-list")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not change activity for a cancelled preview", () => {
|
||||||
|
const initial = createMobilePanelMotionState({ target: "agent", revision: 0 });
|
||||||
|
const dragging = transitionMobilePanel(initial, {
|
||||||
|
type: "gesture.begin",
|
||||||
|
origin: "agent",
|
||||||
|
}).state;
|
||||||
|
const cancelled = transitionMobilePanel(dragging, {
|
||||||
|
type: "gesture.finish",
|
||||||
|
startedRevision: 0,
|
||||||
|
success: false,
|
||||||
|
target: "agent-list",
|
||||||
|
}).state;
|
||||||
|
|
||||||
|
expect(isMobilePanelActive(dragging, "agent")).toBe(true);
|
||||||
|
expect(isMobilePanelActive(cancelled, "agent")).toBe(true);
|
||||||
|
expect(isMobilePanelActive(cancelled, "agent-list")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the visible panel active until its closing motion settles", () => {
|
||||||
|
const initial = createMobilePanelMotionState({ target: "agent-list", revision: 0 });
|
||||||
|
const closing = transitionMobilePanel(initial, {
|
||||||
|
type: "command",
|
||||||
|
selection: { target: "agent", revision: 1 },
|
||||||
|
}).state;
|
||||||
|
|
||||||
|
expect(isMobilePanelActive(closing, "agent-list")).toBe(true);
|
||||||
|
|
||||||
|
const settled = transitionMobilePanel(closing, {
|
||||||
|
type: "animation.finished",
|
||||||
|
revision: 1,
|
||||||
|
target: "agent",
|
||||||
|
}).state;
|
||||||
|
expect(isMobilePanelActive(settled, "agent-list")).toBe(false);
|
||||||
|
expect(isMobilePanelActive(settled, "agent")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("follows programmatic commands through left, center, and right", () => {
|
it("follows programmatic commands through left, center, and right", () => {
|
||||||
const panels = new MobilePanelsScenario();
|
const panels = new MobilePanelsScenario();
|
||||||
|
|
||||||
|
|||||||
@@ -143,6 +143,13 @@ export function isMobilePanelGestureCurrent(
|
|||||||
return state.revision === startedRevision && state.gesture?.startedRevision === startedRevision;
|
return state.revision === startedRevision && state.gesture?.startedRevision === startedRevision;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isMobilePanelActive(
|
||||||
|
state: MobilePanelMotionState,
|
||||||
|
panel: MobilePanelView,
|
||||||
|
): boolean {
|
||||||
|
return state.settledTarget === panel;
|
||||||
|
}
|
||||||
|
|
||||||
export function getMobilePanelFrame(position: number, width: number) {
|
export function getMobilePanelFrame(position: number, width: number) {
|
||||||
"worklet";
|
"worklet";
|
||||||
const clampedPosition = Math.max(-1, Math.min(1, position));
|
const clampedPosition = Math.max(-1, Math.min(1, position));
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { isWeb } from "@/constants/platform";
|
|||||||
import { WindowChromeRootRegion } from "@/utils/desktop-window";
|
import { WindowChromeRootRegion } from "@/utils/desktop-window";
|
||||||
import { usePanelStore, type MobilePanelView } from "@/stores/panel-store";
|
import { usePanelStore, type MobilePanelView } from "@/stores/panel-store";
|
||||||
import { getMobilePanelFrame } from "./model";
|
import { getMobilePanelFrame } from "./model";
|
||||||
import { useIsMobilePanelPresented, useMobilePanelsRuntime } from "./provider";
|
import { useIsMobilePanelActive, useMobilePanelsRuntime } from "./provider";
|
||||||
|
|
||||||
type OverlayPanel = Exclude<MobilePanelView, "agent">;
|
type OverlayPanel = Exclude<MobilePanelView, "agent">;
|
||||||
|
|
||||||
@@ -24,10 +24,8 @@ export function MobilePanelOverlay({
|
|||||||
panelStyle,
|
panelStyle,
|
||||||
}: MobilePanelOverlayProps) {
|
}: MobilePanelOverlayProps) {
|
||||||
const { position, windowWidth } = useMobilePanelsRuntime();
|
const { position, windowWidth } = useMobilePanelsRuntime();
|
||||||
const target = usePanelStore((state) => state.mobilePanel.target);
|
|
||||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||||
const isOpen = target === panel;
|
const isOpen = useIsMobilePanelActive(panel);
|
||||||
const isPresented = useIsMobilePanelPresented(panel);
|
|
||||||
const isLeft = panel === "agent-list";
|
const isLeft = panel === "agent-list";
|
||||||
|
|
||||||
const sidebarAnimatedStyle = useAnimatedStyle(() => {
|
const sidebarAnimatedStyle = useAnimatedStyle(() => {
|
||||||
@@ -42,10 +40,11 @@ export function MobilePanelOverlay({
|
|||||||
return { opacity: isLeft ? frame.leftBackdropOpacity : frame.rightBackdropOpacity };
|
return { opacity: isLeft ? frame.leftBackdropOpacity : frame.rightBackdropOpacity };
|
||||||
}, [isLeft, windowWidth]);
|
}, [isLeft, windowWidth]);
|
||||||
|
|
||||||
const overlayStyle = useMemo(
|
const overlayAnimatedStyle = useAnimatedStyle(() => {
|
||||||
() => [styles.overlay, { display: isPresented ? ("flex" as const) : ("none" as const) }],
|
const isVisible = isLeft ? position.value < 0 : position.value > 0;
|
||||||
[isPresented],
|
return { display: isVisible ? ("flex" as const) : ("none" as const) };
|
||||||
);
|
}, [isLeft]);
|
||||||
|
|
||||||
const positionedPanelStyle = isLeft ? styles.leftPanel : styles.rightPanel;
|
const positionedPanelStyle = isLeft ? styles.leftPanel : styles.rightPanel;
|
||||||
const backdropStyle = useMemo(
|
const backdropStyle = useMemo(
|
||||||
() => [styles.backdrop, backdropAnimatedStyle],
|
() => [styles.backdrop, backdropAnimatedStyle],
|
||||||
@@ -73,19 +72,25 @@ export function MobilePanelOverlay({
|
|||||||
{/* Fabric needs an always-mounted native host to attach the close handler while the
|
{/* Fabric needs an always-mounted native host to attach the close handler while the
|
||||||
retained panel content is hidden. nativeID keeps that host registered. */}
|
retained panel content is hidden. nativeID keeps that host registered. */}
|
||||||
<View
|
<View
|
||||||
|
accessibilityElementsHidden={!isOpen}
|
||||||
collapsable={false}
|
collapsable={false}
|
||||||
|
importantForAccessibility={isOpen ? "auto" : "no-hide-descendants"}
|
||||||
nativeID={`${panel}-gesture-host`}
|
nativeID={`${panel}-gesture-host`}
|
||||||
pointerEvents={overlayPointerEvents}
|
pointerEvents={overlayPointerEvents}
|
||||||
style={styles.overlay}
|
style={styles.overlay}
|
||||||
>
|
>
|
||||||
<View style={overlayStyle} pointerEvents={overlayPointerEvents}>
|
<Animated.View
|
||||||
|
pointerEvents={overlayPointerEvents}
|
||||||
|
style={[styles.overlay, overlayAnimatedStyle]}
|
||||||
|
>
|
||||||
<Pressable
|
<Pressable
|
||||||
|
accessible={false}
|
||||||
accessibilityElementsHidden
|
accessibilityElementsHidden
|
||||||
importantForAccessibility="no-hide-descendants"
|
importantForAccessibility="no-hide-descendants"
|
||||||
onPress={showMobileAgent}
|
onPress={showMobileAgent}
|
||||||
pointerEvents={isOpen ? "auto" : "none"}
|
pointerEvents={isOpen ? "auto" : "none"}
|
||||||
style={StyleSheet.absoluteFillObject}
|
style={StyleSheet.absoluteFillObject}
|
||||||
testID={`${panel}-backdrop`}
|
testID={isOpen ? `${panel}-backdrop` : undefined}
|
||||||
>
|
>
|
||||||
<Animated.View pointerEvents="none" style={backdropStyle} />
|
<Animated.View pointerEvents="none" style={backdropStyle} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -93,7 +98,7 @@ export function MobilePanelOverlay({
|
|||||||
<Animated.View pointerEvents={isOpen ? "auto" : "none"} style={combinedPanelStyle}>
|
<Animated.View pointerEvents={isOpen ? "auto" : "none"} style={combinedPanelStyle}>
|
||||||
<WindowChromeRootRegion corners="both">{children}</WindowChromeRootRegion>
|
<WindowChromeRootRegion corners="both">{children}</WindowChromeRootRegion>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</View>
|
</Animated.View>
|
||||||
</View>
|
</View>
|
||||||
</GestureDetector>
|
</GestureDetector>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -39,18 +39,6 @@ import {
|
|||||||
|
|
||||||
const ANIMATION_DURATION = 220;
|
const ANIMATION_DURATION = 220;
|
||||||
const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1);
|
const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1);
|
||||||
const LEFT_PANEL_MASK = 1;
|
|
||||||
const RIGHT_PANEL_MASK = 2;
|
|
||||||
|
|
||||||
function getPanelMask(panel: MobilePanelView): number {
|
|
||||||
if (panel === "agent-list") {
|
|
||||||
return LEFT_PANEL_MASK;
|
|
||||||
}
|
|
||||||
if (panel === "file-explorer") {
|
|
||||||
return RIGHT_PANEL_MASK;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MobilePanelsRuntime {
|
interface MobilePanelsRuntime {
|
||||||
beginGesture: (input: BeginGestureInput) => number;
|
beginGesture: (input: BeginGestureInput) => number;
|
||||||
@@ -69,7 +57,6 @@ interface MobilePanelsRuntime {
|
|||||||
|
|
||||||
interface BeginGestureInput {
|
interface BeginGestureInput {
|
||||||
origin: MobilePanelView;
|
origin: MobilePanelView;
|
||||||
preview: MobilePanelView;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FinishGestureInput {
|
interface FinishGestureInput {
|
||||||
@@ -79,7 +66,7 @@ interface FinishGestureInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MobilePanelsContext = createContext<MobilePanelsRuntime | null>(null);
|
const MobilePanelsContext = createContext<MobilePanelsRuntime | null>(null);
|
||||||
const MobilePanelPresentationContext = createContext(0);
|
const MobilePanelActiveContext = createContext<MobilePanelView>("agent");
|
||||||
|
|
||||||
export function MobilePanelsProvider({ children }: { children: ReactNode }) {
|
export function MobilePanelsProvider({ children }: { children: ReactNode }) {
|
||||||
const { width: windowWidth } = useWindowDimensions();
|
const { width: windowWidth } = useWindowDimensions();
|
||||||
@@ -92,7 +79,7 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) {
|
|||||||
const leftCloseGestureRef = useRef<GestureType | undefined>(undefined);
|
const leftCloseGestureRef = useRef<GestureType | undefined>(undefined);
|
||||||
const rightOpenGestureRef = useRef<GestureType | undefined>(undefined);
|
const rightOpenGestureRef = useRef<GestureType | undefined>(undefined);
|
||||||
const rightCloseGestureRef = useRef<GestureType | undefined>(undefined);
|
const rightCloseGestureRef = useRef<GestureType | undefined>(undefined);
|
||||||
const [presentedPanels, setPresentedPanels] = useState(getPanelMask(initialSelection.target));
|
const [activePanel, setActivePanel] = useState(initialSelection.target);
|
||||||
|
|
||||||
const setOpenGestureBlocked = useCallback(
|
const setOpenGestureBlocked = useCallback(
|
||||||
(owner: symbol, blocked: boolean) => {
|
(owner: symbol, blocked: boolean) => {
|
||||||
@@ -106,19 +93,15 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) {
|
|||||||
[openGesturesBlocked],
|
[openGesturesBlocked],
|
||||||
);
|
);
|
||||||
|
|
||||||
const presentPanel = useCallback((panel: MobilePanelView) => {
|
const publishActivePanel = useCallback((panel: MobilePanelView, revision: number) => {
|
||||||
const mask = getPanelMask(panel);
|
|
||||||
if (mask) {
|
|
||||||
setPresentedPanels((current) => current | mask);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const settlePresentation = useCallback((panel: MobilePanelView, revision: number) => {
|
|
||||||
const selection = usePanelStore.getState().mobilePanel;
|
const selection = usePanelStore.getState().mobilePanel;
|
||||||
if (selection.revision !== revision || selection.target !== panel) {
|
if (selection.revision !== revision || selection.target !== panel) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setPresentedPanels(getPanelMask(panel));
|
if (isNative && panel !== "agent") {
|
||||||
|
Keyboard.dismiss();
|
||||||
|
}
|
||||||
|
setActivePanel(panel);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const animateTransition = useCallback(
|
const animateTransition = useCallback(
|
||||||
@@ -146,11 +129,11 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
motionState.value = settled.state;
|
motionState.value = settled.state;
|
||||||
scheduleOnRN(settlePresentation, target, revision);
|
scheduleOnRN(publishActivePanel, target, revision);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[motionState, position, settlePresentation],
|
[motionState, position, publishActivePanel],
|
||||||
);
|
);
|
||||||
|
|
||||||
const applySelection = useCallback(
|
const applySelection = useCallback(
|
||||||
@@ -176,18 +159,12 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) {
|
|||||||
if (selection === previousState.mobilePanel) {
|
if (selection === previousState.mobilePanel) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (selection.target !== "agent") {
|
|
||||||
presentPanel(selection.target);
|
|
||||||
if (isNative) {
|
|
||||||
Keyboard.dismiss();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
scheduleOnUI(applySelection, selection);
|
scheduleOnUI(applySelection, selection);
|
||||||
});
|
});
|
||||||
}, [applySelection, presentPanel]);
|
}, [applySelection]);
|
||||||
|
|
||||||
const beginGesture = useCallback(
|
const beginGesture = useCallback(
|
||||||
({ origin, preview }: BeginGestureInput): number => {
|
({ origin }: BeginGestureInput): number => {
|
||||||
"worklet";
|
"worklet";
|
||||||
const currentState = motionState.value;
|
const currentState = motionState.value;
|
||||||
if (!canBeginMobilePanelGesture(currentState, origin, position.value)) {
|
if (!canBeginMobilePanelGesture(currentState, origin, position.value)) {
|
||||||
@@ -199,10 +176,9 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) {
|
|||||||
});
|
});
|
||||||
motionState.value = transition.state;
|
motionState.value = transition.state;
|
||||||
cancelAnimation(position);
|
cancelAnimation(position);
|
||||||
scheduleOnRN(presentPanel, preview);
|
|
||||||
return transition.state.gesture?.startedRevision ?? -1;
|
return transition.state.gesture?.startedRevision ?? -1;
|
||||||
},
|
},
|
||||||
[motionState, position, presentPanel],
|
[motionState, position],
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateGesture = useCallback(
|
const updateGesture = useCallback(
|
||||||
@@ -266,9 +242,9 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<MobilePanelsContext.Provider value={value}>
|
<MobilePanelsContext.Provider value={value}>
|
||||||
<MobilePanelPresentationContext.Provider value={presentedPanels}>
|
<MobilePanelActiveContext.Provider value={activePanel}>
|
||||||
{children}
|
{children}
|
||||||
</MobilePanelPresentationContext.Provider>
|
</MobilePanelActiveContext.Provider>
|
||||||
</MobilePanelsContext.Provider>
|
</MobilePanelsContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -282,9 +258,8 @@ export function useMobilePanelsRuntime(): MobilePanelsRuntime {
|
|||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useIsMobilePanelPresented(panel: MobilePanelView): boolean {
|
export function useIsMobilePanelActive(panel: MobilePanelView): boolean {
|
||||||
const presentedPanels = useContext(MobilePanelPresentationContext);
|
return useContext(MobilePanelActiveContext) === panel;
|
||||||
return (presentedPanels & getPanelMask(panel)) !== 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useBlockMobilePanelOpenGestures(blocked: boolean): void {
|
export function useBlockMobilePanelOpenGestures(blocked: boolean): void {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/cli",
|
"name": "@getpaseo/cli",
|
||||||
"version": "0.7.0-beta.1",
|
"version": "0.7.0",
|
||||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||||
"bin": {
|
"bin": {
|
||||||
"paseo": "bin/paseo"
|
"paseo": "bin/paseo"
|
||||||
@@ -28,9 +28,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/client": "0.7.0-beta.1",
|
"@getpaseo/client": "0.7.0",
|
||||||
"@getpaseo/protocol": "0.7.0-beta.1",
|
"@getpaseo/protocol": "0.7.0",
|
||||||
"@getpaseo/server": "0.7.0-beta.1",
|
"@getpaseo/server": "0.7.0",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { runHubLogin } from "./login.js";
|
|||||||
import { runHubLogout } from "./logout.js";
|
import { runHubLogout } from "./logout.js";
|
||||||
import { runHubProjects } from "./projects.js";
|
import { runHubProjects } from "./projects.js";
|
||||||
import { createHubCommand } from "./index.js";
|
import { createHubCommand } from "./index.js";
|
||||||
|
import { runHubPermissionChange, runHubPermissionsList } from "./permissions.js";
|
||||||
import type { HubReporter } from "./reporter.js";
|
import type { HubReporter } from "./reporter.js";
|
||||||
|
|
||||||
const quietReporter: HubReporter = { progress() {} };
|
const quietReporter: HubReporter = { progress() {} };
|
||||||
@@ -25,6 +26,7 @@ describe("Hub commands", () => {
|
|||||||
"connect",
|
"connect",
|
||||||
"status",
|
"status",
|
||||||
"disconnect",
|
"disconnect",
|
||||||
|
"permissions",
|
||||||
"projects",
|
"projects",
|
||||||
"export",
|
"export",
|
||||||
"deploy",
|
"deploy",
|
||||||
@@ -174,12 +176,87 @@ describe("Hub commands", () => {
|
|||||||
|
|
||||||
assert.deepEqual(observed, [{ origin: "https://hub.test", credential: "stored-human-secret" }]);
|
assert.deepEqual(observed, [{ origin: "https://hub.test", credential: "stored-human-secret" }]);
|
||||||
assert.deepEqual(daemon.connections, [
|
assert.deepEqual(daemon.connections, [
|
||||||
{ origin: "https://hub.test", token: "one-time-enrollment-token-with-enough-length" },
|
{
|
||||||
|
origin: "https://hub.test",
|
||||||
|
token: "one-time-enrollment-token-with-enough-length",
|
||||||
|
permissions: [],
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
assert.equal(daemon.connections[0]?.token.includes("stored-human-secret"), false);
|
assert.equal(daemon.connections[0]?.token.includes("stored-human-secret"), false);
|
||||||
assert.deepEqual(progress, ["Connecting this daemon to https://hub.test"]);
|
assert.deepEqual(progress, ["Connecting this daemon to https://hub.test"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("grants workflow execution only when explicitly requested", async () => {
|
||||||
|
const credentials = new MemoryCredentials();
|
||||||
|
credentials.save({ origin: "https://hub.test", credential: "secret" });
|
||||||
|
const daemon = new FakeDaemon("https://hub.test");
|
||||||
|
|
||||||
|
await runHubConnect(
|
||||||
|
"https://hub.test",
|
||||||
|
{ permissions: ["hub.execute"] },
|
||||||
|
{
|
||||||
|
env: {},
|
||||||
|
credentials,
|
||||||
|
hub: { issueEnrollmentToken: async () => "one-time-token" },
|
||||||
|
daemon: new FakeDaemonConnection(daemon),
|
||||||
|
reporter: quietReporter,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(daemon.connections[0]?.permissions, ["hub.execute"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("revokes a relationship when an older daemon widens connected-only access", async () => {
|
||||||
|
const credentials = new MemoryCredentials();
|
||||||
|
credentials.save({ origin: "https://hub.test", credential: "secret" });
|
||||||
|
const daemon = new FakeDaemon("https://hub.test");
|
||||||
|
daemon.returnedPermissions = ["hub.execute"];
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
runHubConnect(
|
||||||
|
"https://hub.test",
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
env: {},
|
||||||
|
credentials,
|
||||||
|
hub: { issueEnrollmentToken: async () => "one-time-token" },
|
||||||
|
daemon: new FakeDaemonConnection(daemon),
|
||||||
|
reporter: quietReporter,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
/did not honor the requested Hub access/u,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(daemon.disconnects, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists, grants, and revokes semantic Hub permissions without a wizard", async () => {
|
||||||
|
const daemon = new FakeDaemon("https://hub.test");
|
||||||
|
daemon.returnedPermissions = [];
|
||||||
|
const connection = new FakeDaemonConnection(daemon);
|
||||||
|
const progress: string[] = [];
|
||||||
|
const dependencies = {
|
||||||
|
daemon: connection,
|
||||||
|
reporter: { progress: (message: string) => progress.push(message) },
|
||||||
|
};
|
||||||
|
|
||||||
|
const empty = await runHubPermissionsList({}, dependencies);
|
||||||
|
assert.deepEqual(empty.data, []);
|
||||||
|
|
||||||
|
await runHubPermissionChange("grant", "hub.execute", {}, dependencies);
|
||||||
|
const granted = await runHubPermissionsList({}, dependencies);
|
||||||
|
assert.deepEqual(granted.data, [
|
||||||
|
{ permission: "hub.execute", description: "Run agents for Hub automations" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await runHubPermissionChange("revoke", "hub.execute", {}, dependencies);
|
||||||
|
assert.deepEqual((await runHubPermissionsList({}, dependencies)).data, []);
|
||||||
|
assert.deepEqual(progress, [
|
||||||
|
"Granted hub.execute to https://hub.test",
|
||||||
|
"Revoked hub.execute from https://hub.test",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("projects reports its normalized destination before listing", async () => {
|
it("projects reports its normalized destination before listing", async () => {
|
||||||
const credentials = new MemoryCredentials();
|
const credentials = new MemoryCredentials();
|
||||||
const events: string[] = [];
|
const events: string[] = [];
|
||||||
@@ -523,22 +600,39 @@ class FakeDaemonConnection implements HubDaemonConnection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class FakeDaemon implements HubDaemonClient {
|
class FakeDaemon implements HubDaemonClient {
|
||||||
readonly connections: Array<{ origin: string; token: string }> = [];
|
readonly connections: Array<{
|
||||||
|
origin: string;
|
||||||
|
token: string;
|
||||||
|
permissions: readonly string[];
|
||||||
|
}> = [];
|
||||||
disconnects = 0;
|
disconnects = 0;
|
||||||
readonly disconnectForces: boolean[] = [];
|
readonly disconnectForces: boolean[] = [];
|
||||||
disconnectError: Error | null = null;
|
disconnectError: Error | null = null;
|
||||||
disconnectWarning: string | null = null;
|
disconnectWarning: string | null = null;
|
||||||
beforeDisconnect: (() => void) | null = null;
|
beforeDisconnect: (() => void) | null = null;
|
||||||
|
returnedPermissions: readonly string[] | null = null;
|
||||||
|
|
||||||
constructor(private readonly origin: string) {}
|
constructor(private readonly origin: string) {}
|
||||||
|
|
||||||
async connectHub(origin: string, token: string) {
|
async connectHub(origin: string, token: string, permissions: readonly string[] = []) {
|
||||||
this.connections.push({ origin, token });
|
this.connections.push({ origin, token, permissions });
|
||||||
return { status: hubStatus("connected", origin) };
|
return {
|
||||||
|
status: hubStatus("connected", origin, this.returnedPermissions ?? permissions),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateHubPermissions(input: { grant?: readonly string[]; revoke?: readonly string[] }) {
|
||||||
|
const current = new Set(this.returnedPermissions ?? []);
|
||||||
|
for (const permission of input.revoke ?? []) current.delete(permission);
|
||||||
|
for (const permission of input.grant ?? []) current.add(permission);
|
||||||
|
this.returnedPermissions = [...current];
|
||||||
|
return { status: hubStatus("connected", this.origin, this.returnedPermissions) };
|
||||||
}
|
}
|
||||||
|
|
||||||
async getHubStatus() {
|
async getHubStatus() {
|
||||||
return { status: hubStatus("connected", this.origin) };
|
return {
|
||||||
|
status: hubStatus("connected", this.origin, this.returnedPermissions ?? ["hub.execute"]),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getProvidersSnapshot() {
|
async getProvidersSnapshot() {
|
||||||
@@ -559,12 +653,16 @@ class FakeDaemon implements HubDaemonClient {
|
|||||||
async close() {}
|
async close() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function hubStatus(state: string, origin: string | null): HubStatus {
|
function hubStatus(
|
||||||
|
state: string,
|
||||||
|
origin: string | null,
|
||||||
|
permissions: readonly string[] = ["hub.execute"],
|
||||||
|
): HubStatus {
|
||||||
return {
|
return {
|
||||||
state,
|
state,
|
||||||
daemonId: state === "connected" ? "daemon-1" : null,
|
daemonId: state === "connected" ? "daemon-1" : null,
|
||||||
hubOrigin: origin,
|
hubOrigin: origin,
|
||||||
scopes: state === "connected" ? ["hub.execution.*"] : [],
|
permissions: state === "connected" ? [...permissions] : [],
|
||||||
connectedAt: null,
|
connectedAt: null,
|
||||||
lastError: null,
|
lastError: null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ interface HubConnectOptions {
|
|||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
host?: string;
|
host?: string;
|
||||||
json?: boolean;
|
json?: boolean;
|
||||||
|
permission?: readonly string[];
|
||||||
|
permissions?: readonly string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HubConnectDependencies {
|
interface HubConnectDependencies {
|
||||||
@@ -38,8 +40,18 @@ export async function runHubConnect(
|
|||||||
reportHubProgress(dependencies.reporter, options, `Connecting this daemon to ${origin}`);
|
reportHubProgress(dependencies.reporter, options, `Connecting this daemon to ${origin}`);
|
||||||
const credential = resolveHubCredential({ ...resolution, origin });
|
const credential = resolveHubCredential({ ...resolution, origin });
|
||||||
const token = await dependencies.hub.issueEnrollmentToken(origin, credential);
|
const token = await dependencies.hub.issueEnrollmentToken(origin, credential);
|
||||||
|
const permissions = options.permissions ?? options.permission ?? [];
|
||||||
return withHubDaemon(dependencies.daemon, options.host, async (daemon) => {
|
return withHubDaemon(dependencies.daemon, options.host, async (daemon) => {
|
||||||
const response = await daemon.connectHub(origin, token);
|
const response = await daemon.connectHub(origin, token, permissions);
|
||||||
|
if (
|
||||||
|
response.status.hubOrigin !== null &&
|
||||||
|
!samePermissions(response.status.permissions, permissions)
|
||||||
|
) {
|
||||||
|
await daemon.disconnectHub(false).catch(() => undefined);
|
||||||
|
throw new Error(
|
||||||
|
"The daemon did not honor the requested Hub access. Update Paseo before connecting it.",
|
||||||
|
);
|
||||||
|
}
|
||||||
return hubStatusResult(response.status);
|
return hubStatusResult(response.status);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -51,7 +63,8 @@ export function addHubConnectCommand(parent: Command, dependencies: HubConnectDe
|
|||||||
.command("connect")
|
.command("connect")
|
||||||
.description("Enroll this daemon with a Paseo Hub")
|
.description("Enroll this daemon with a Paseo Hub")
|
||||||
.argument("[origin]", "Paseo Hub origin")
|
.argument("[origin]", "Paseo Hub origin")
|
||||||
.option("--api-key <secret>", "Organization API key"),
|
.option("--api-key <secret>", "Organization API key")
|
||||||
|
.option("--permission <permission...>", "Grant daemon permission during connection"),
|
||||||
),
|
),
|
||||||
).action(
|
).action(
|
||||||
withOutput(async (...args) => {
|
withOutput(async (...args) => {
|
||||||
@@ -61,3 +74,7 @@ export function addHubConnectCommand(parent: Command, dependencies: HubConnectDe
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function samePermissions(actual: readonly string[], expected: readonly string[]): boolean {
|
||||||
|
return actual.length === expected.length && expected.every((scope) => actual.includes(scope));
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export interface HubStatus {
|
|||||||
state: string;
|
state: string;
|
||||||
daemonId: string | null;
|
daemonId: string | null;
|
||||||
hubOrigin: string | null;
|
hubOrigin: string | null;
|
||||||
scopes: string[];
|
permissions: string[];
|
||||||
connectedAt: string | null;
|
connectedAt: string | null;
|
||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
}
|
}
|
||||||
@@ -15,7 +15,15 @@ export interface HubProvidersSnapshotOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface HubDaemonClient {
|
export interface HubDaemonClient {
|
||||||
connectHub(url: string, token: string): Promise<{ status: HubStatus }>;
|
connectHub(
|
||||||
|
url: string,
|
||||||
|
token: string,
|
||||||
|
permissions?: readonly string[],
|
||||||
|
): Promise<{ status: HubStatus }>;
|
||||||
|
updateHubPermissions(input: {
|
||||||
|
grant?: readonly string[];
|
||||||
|
revoke?: readonly string[];
|
||||||
|
}): Promise<{ status: HubStatus }>;
|
||||||
getHubStatus(): Promise<{ status: HubStatus }>;
|
getHubStatus(): Promise<{ status: HubStatus }>;
|
||||||
disconnectHub(force: boolean): Promise<{ status: HubStatus; warning?: string }>;
|
disconnectHub(force: boolean): Promise<{ status: HubStatus; warning?: string }>;
|
||||||
getProvidersSnapshot(
|
getProvidersSnapshot(
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { processHubReporter, type HubReporter } from "./reporter.js";
|
|||||||
import { hubStatusResult } from "./status-output.js";
|
import { hubStatusResult } from "./status-output.js";
|
||||||
import { addHubResolutionHelp } from "./help.js";
|
import { addHubResolutionHelp } from "./help.js";
|
||||||
import { addHubInitCommand, continueHubGuidedSetup } from "./init.js";
|
import { addHubInitCommand, continueHubGuidedSetup } from "./init.js";
|
||||||
|
import { addHubPermissionsCommand } from "./permissions.js";
|
||||||
|
|
||||||
interface HubCommandEnvironment {
|
interface HubCommandEnvironment {
|
||||||
env: Readonly<Record<string, string | undefined>>;
|
env: Readonly<Record<string, string | undefined>>;
|
||||||
@@ -80,6 +81,10 @@ export function createHubCommand(overrides: Partial<HubCommandEnvironment> = {})
|
|||||||
daemon: environment.daemon,
|
daemon: environment.daemon,
|
||||||
reporter: environment.reporter,
|
reporter: environment.reporter,
|
||||||
});
|
});
|
||||||
|
addHubPermissionsCommand(hub, {
|
||||||
|
daemon: environment.daemon,
|
||||||
|
reporter: environment.reporter,
|
||||||
|
});
|
||||||
addHubProjectsCommand(hub, {
|
addHubProjectsCommand(hub, {
|
||||||
env: environment.env,
|
env: environment.env,
|
||||||
credentials: environment.credentials,
|
credentials: environment.credentials,
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ describe("Hub guided setup continuation", () => {
|
|||||||
const cwd = await temporaryDirectory();
|
const cwd = await temporaryDirectory();
|
||||||
const credentials = new MemoryCredentials();
|
const credentials = new MemoryCredentials();
|
||||||
const daemon = new SetupDaemon();
|
const daemon = new SetupDaemon();
|
||||||
const prompts = new PromptAnswers([true], [], []);
|
const prompts = new PromptAnswers([true, false], [], []);
|
||||||
const calls: Array<{ operation: string; origin: string; files?: readonly string[] }> = [];
|
const calls: Array<{ operation: string; origin: string; files?: readonly string[] }> = [];
|
||||||
const environment = setupEnvironment(cwd, credentials, daemon, prompts, calls);
|
const environment = setupEnvironment(cwd, credentials, daemon, prompts, calls);
|
||||||
|
|
||||||
@@ -42,9 +42,13 @@ describe("Hub guided setup continuation", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.deepEqual(prompts.confirmations, ["Connect this daemon to this Hub?"]);
|
assert.deepEqual(prompts.confirmations, [
|
||||||
|
"Connect this daemon to Paseo Hub?\n\nConnecting lets Hub identify this daemon and show whether it is online.\nIt does not allow Hub to create workspaces or run agents.",
|
||||||
|
"Allow Hub automations to run agents on this daemon?\n\nThis lets workflows triggered from GitHub, Slack, Discord, Linear, and other integrations create workspaces and run agents here.\n\nAgents can access files and run commands allowed by their workspace runtime.",
|
||||||
|
]);
|
||||||
assert.deepEqual(prompts.selections, []);
|
assert.deepEqual(prompts.selections, []);
|
||||||
assert.deepEqual(prompts.messages, [
|
assert.deepEqual(prompts.messages, [
|
||||||
|
"Daemon connected with no permissions.\n\nEnable Hub automations later:\n paseo hub permissions grant hub.execute",
|
||||||
"Configure triggers in Hub: https://hub.test/triggers\nOr scaffold triggers as code: paseo hub init",
|
"Configure triggers in Hub: https://hub.test/triggers\nOr scaffold triggers as code: paseo hub init",
|
||||||
]);
|
]);
|
||||||
assert.deepEqual(calls, [{ operation: "token", origin: "https://hub.test" }]);
|
assert.deepEqual(calls, [{ operation: "token", origin: "https://hub.test" }]);
|
||||||
@@ -70,6 +74,26 @@ describe("Hub guided setup continuation", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not reopen onboarding or widen permissions for an existing connection", async () => {
|
||||||
|
const cwd = await temporaryDirectory();
|
||||||
|
const credentials = new MemoryCredentials();
|
||||||
|
credentials.save({ origin: "https://hub.test", credential: "secret" });
|
||||||
|
const daemon = new SetupDaemon();
|
||||||
|
await daemon.connectHub("https://hub.test", "existing-token", []);
|
||||||
|
const prompts = new PromptAnswers([], [], []);
|
||||||
|
|
||||||
|
await continueHubGuidedSetup(
|
||||||
|
"https://hub.test",
|
||||||
|
setupEnvironment(cwd, credentials, daemon, prompts, []),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(prompts.confirmations, []);
|
||||||
|
assert.deepEqual(prompts.messages, [
|
||||||
|
"This daemon is already connected to https://hub.test. Permissions: None.",
|
||||||
|
"Configure triggers in Hub: https://hub.test/triggers\nOr scaffold triggers as code: paseo hub init",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("does not require a Hub app connection during login", async () => {
|
it("does not require a Hub app connection during login", async () => {
|
||||||
const cwd = await temporaryDirectory();
|
const cwd = await temporaryDirectory();
|
||||||
const credentials = new MemoryCredentials();
|
const credentials = new MemoryCredentials();
|
||||||
@@ -333,6 +357,7 @@ class SetupDaemon implements HubDaemonClient {
|
|||||||
connections = 0;
|
connections = 0;
|
||||||
readonly snapshotCwds: Array<string | undefined> = [];
|
readonly snapshotCwds: Array<string | undefined> = [];
|
||||||
private origin: string | null = null;
|
private origin: string | null = null;
|
||||||
|
private currentPermissions: string[] = [];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
readonly providerEntries: readonly ProviderSnapshotEntry[] = [
|
readonly providerEntries: readonly ProviderSnapshotEntry[] = [
|
||||||
@@ -355,14 +380,26 @@ class SetupDaemon implements HubDaemonClient {
|
|||||||
private readonly providerEntrySequence?: readonly (readonly ProviderSnapshotEntry[])[],
|
private readonly providerEntrySequence?: readonly (readonly ProviderSnapshotEntry[])[],
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async connectHub(origin: string) {
|
async connectHub(origin: string, _token: string, permissions: readonly string[] = []) {
|
||||||
this.connections += 1;
|
this.connections += 1;
|
||||||
this.origin = origin;
|
this.origin = origin;
|
||||||
return { status: status(origin) };
|
this.currentPermissions = [...permissions];
|
||||||
|
return { status: status(origin, permissions) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateHubPermissions(input: { grant?: readonly string[]; revoke?: readonly string[] }) {
|
||||||
|
const permissions = new Set(this.origin === null ? [] : this.currentPermissions);
|
||||||
|
for (const permission of input.revoke ?? []) permissions.delete(permission);
|
||||||
|
for (const permission of input.grant ?? []) permissions.add(permission);
|
||||||
|
this.currentPermissions = [...permissions];
|
||||||
|
return { status: status(this.origin ?? "https://hub.test", this.currentPermissions) };
|
||||||
}
|
}
|
||||||
|
|
||||||
async getHubStatus() {
|
async getHubStatus() {
|
||||||
return { status: this.origin === null ? disconnectedStatus() : status(this.origin) };
|
return {
|
||||||
|
status:
|
||||||
|
this.origin === null ? disconnectedStatus() : status(this.origin, this.currentPermissions),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getProvidersSnapshot(options?: { cwd?: string }) {
|
async getProvidersSnapshot(options?: { cwd?: string }) {
|
||||||
@@ -381,12 +418,12 @@ class SetupDaemon implements HubDaemonClient {
|
|||||||
async close() {}
|
async close() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function status(origin: string): HubStatus {
|
function status(origin: string, permissions: readonly string[] = []): HubStatus {
|
||||||
return {
|
return {
|
||||||
state: "connected",
|
state: "connected",
|
||||||
daemonId: "daemon-1",
|
daemonId: "daemon-1",
|
||||||
hubOrigin: origin,
|
hubOrigin: origin,
|
||||||
scopes: [],
|
permissions: [...permissions],
|
||||||
connectedAt: null,
|
connectedAt: null,
|
||||||
lastError: null,
|
lastError: null,
|
||||||
};
|
};
|
||||||
@@ -397,7 +434,7 @@ function disconnectedStatus(): HubStatus {
|
|||||||
state: "not_connected",
|
state: "not_connected",
|
||||||
daemonId: null,
|
daemonId: null,
|
||||||
hubOrigin: null,
|
hubOrigin: null,
|
||||||
scopes: [],
|
permissions: [],
|
||||||
connectedAt: null,
|
connectedAt: null,
|
||||||
lastError: null,
|
lastError: null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ describe("Hub init planning", () => {
|
|||||||
state: "connected",
|
state: "connected",
|
||||||
daemonId: "daemon-1",
|
daemonId: "daemon-1",
|
||||||
hubOrigin: "https://hub.test",
|
hubOrigin: "https://hub.test",
|
||||||
scopes: ["hub.execution.*"],
|
permissions: ["hub.execute"],
|
||||||
connectedAt: null,
|
connectedAt: null,
|
||||||
lastError: null,
|
lastError: null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -191,8 +191,43 @@ export async function continueHubGuidedSetup(
|
|||||||
origin: string,
|
origin: string,
|
||||||
environment: HubGuidedSetupEnvironment,
|
environment: HubGuidedSetupEnvironment,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (await requiredConfirm(environment, "Connect this daemon to this Hub?", true)) {
|
const currentStatus = await withHubDaemon(environment.daemon, undefined, async (daemon) =>
|
||||||
await ensureDaemonConnection(origin, environment, true);
|
daemon.getHubStatus().then((response) => response.status),
|
||||||
|
);
|
||||||
|
const current = resolveHubInitConnection(currentStatus, origin);
|
||||||
|
if (current.kind === "connected") {
|
||||||
|
reportMessage(
|
||||||
|
environment,
|
||||||
|
`This daemon is already connected to ${origin}. Permissions: ${
|
||||||
|
currentStatus.permissions.join(", ") || "None"
|
||||||
|
}.`,
|
||||||
|
);
|
||||||
|
} else if (current.kind === "pending") {
|
||||||
|
await waitForDaemonReady(origin, environment.daemon);
|
||||||
|
} else if (current.kind === "conflict") {
|
||||||
|
reportMessage(
|
||||||
|
environment,
|
||||||
|
`This daemon is connected to ${current.origin}. Disconnect it before connecting to ${origin}.`,
|
||||||
|
);
|
||||||
|
} else if (
|
||||||
|
await requiredConfirm(
|
||||||
|
environment,
|
||||||
|
"Connect this daemon to Paseo Hub?\n\nConnecting lets Hub identify this daemon and show whether it is online.\nIt does not allow Hub to create workspaces or run agents.",
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
const grantExecution = await requiredConfirm(
|
||||||
|
environment,
|
||||||
|
"Allow Hub automations to run agents on this daemon?\n\nThis lets workflows triggered from GitHub, Slack, Discord, Linear, and other integrations create workspaces and run agents here.\n\nAgents can access files and run commands allowed by their workspace runtime.",
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
await ensureDaemonConnection(origin, environment, true, grantExecution ? ["hub.execute"] : []);
|
||||||
|
if (!grantExecution) {
|
||||||
|
reportMessage(
|
||||||
|
environment,
|
||||||
|
"Daemon connected with no permissions.\n\nEnable Hub automations later:\n paseo hub permissions grant hub.execute",
|
||||||
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
reportMessage(
|
reportMessage(
|
||||||
environment,
|
environment,
|
||||||
@@ -252,12 +287,19 @@ async function ensureDaemonConnection(
|
|||||||
origin: string,
|
origin: string,
|
||||||
environment: HubGuidedSetupEnvironment,
|
environment: HubGuidedSetupEnvironment,
|
||||||
confirmed = false,
|
confirmed = false,
|
||||||
|
permissions: readonly string[] = ["hub.execute"],
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const status = await withHubDaemon(environment.daemon, undefined, async (daemon) =>
|
const status = await withHubDaemon(environment.daemon, undefined, async (daemon) =>
|
||||||
daemon.getHubStatus().then((response) => response.status),
|
daemon.getHubStatus().then((response) => response.status),
|
||||||
);
|
);
|
||||||
const connection = resolveHubInitConnection(status, origin);
|
const connection = resolveHubInitConnection(status, origin);
|
||||||
if (connection.kind === "connected") {
|
if (connection.kind === "connected") {
|
||||||
|
if (permissions.includes("hub.execute") && !status.permissions.includes("hub.execute")) {
|
||||||
|
throw new HubCommandError(
|
||||||
|
"HUB_DAEMON_EXECUTION_NOT_ALLOWED",
|
||||||
|
"This daemon is connected to Hub but cannot run Hub automations. Run `paseo hub permissions grant hub.execute`, then run Hub init again.",
|
||||||
|
);
|
||||||
|
}
|
||||||
return connection.daemonId;
|
return connection.daemonId;
|
||||||
}
|
}
|
||||||
if (connection.kind === "pending") {
|
if (connection.kind === "pending") {
|
||||||
@@ -271,20 +313,25 @@ async function ensureDaemonConnection(
|
|||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
!confirmed &&
|
!confirmed &&
|
||||||
!(await requiredConfirm(environment, `Connect this daemon to ${origin}?`, true))
|
!(await requiredConfirm(
|
||||||
|
environment,
|
||||||
|
`Connect this daemon to ${origin} and allow Hub workflows to create and control workspaces and agents?`,
|
||||||
|
true,
|
||||||
|
))
|
||||||
) {
|
) {
|
||||||
throw new HubInitCancelledError("A connected daemon is required to create the bundle.");
|
throw new HubInitCancelledError("A connected daemon is required to create the bundle.");
|
||||||
}
|
}
|
||||||
return connectDaemon(origin, environment);
|
return connectDaemon(origin, environment, permissions);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function connectDaemon(
|
async function connectDaemon(
|
||||||
origin: string,
|
origin: string,
|
||||||
environment: HubGuidedSetupEnvironment,
|
environment: HubGuidedSetupEnvironment,
|
||||||
|
permissions: readonly string[] = ["hub.execute"],
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
await runHubConnect(
|
await runHubConnect(
|
||||||
origin,
|
origin,
|
||||||
{},
|
{ permissions },
|
||||||
{
|
{
|
||||||
env: environment.env,
|
env: environment.env,
|
||||||
credentials: environment.credentials,
|
credentials: environment.credentials,
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import type { Command } from "commander";
|
||||||
|
import { withOutput, type ListResult, type OutputSchema } from "../../output/index.js";
|
||||||
|
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
|
||||||
|
import type { HubDaemonConnection, HubStatus } from "./daemon-client.js";
|
||||||
|
import { withHubDaemon } from "./daemon-client.js";
|
||||||
|
import { reportHubProgress, type HubReporter } from "./reporter.js";
|
||||||
|
import { hubStatusResult } from "./status-output.js";
|
||||||
|
|
||||||
|
interface HubPermissionsOptions {
|
||||||
|
host?: string;
|
||||||
|
json?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HubPermissionsDependencies {
|
||||||
|
daemon: HubDaemonConnection;
|
||||||
|
reporter: HubReporter;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PermissionRow {
|
||||||
|
permission: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const schema: OutputSchema<PermissionRow> = {
|
||||||
|
idField: "permission",
|
||||||
|
columns: [
|
||||||
|
{ header: "PERMISSION", field: "permission" },
|
||||||
|
{ header: "DESCRIPTION", field: "description" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function runHubPermissionsList(
|
||||||
|
options: HubPermissionsOptions,
|
||||||
|
dependencies: HubPermissionsDependencies,
|
||||||
|
): Promise<ListResult<PermissionRow>> {
|
||||||
|
return withHubDaemon(dependencies.daemon, options.host, async (client) => {
|
||||||
|
const status = (await client.getHubStatus()).status;
|
||||||
|
requireConnectedHub(status);
|
||||||
|
return {
|
||||||
|
type: "list",
|
||||||
|
data: status.permissions.map((permission) => ({
|
||||||
|
permission,
|
||||||
|
description: describePermission(permission),
|
||||||
|
})),
|
||||||
|
schema,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runHubPermissionChange(
|
||||||
|
operation: "grant" | "revoke",
|
||||||
|
permission: string,
|
||||||
|
options: HubPermissionsOptions,
|
||||||
|
dependencies: HubPermissionsDependencies,
|
||||||
|
) {
|
||||||
|
return withHubDaemon(dependencies.daemon, options.host, async (client) => {
|
||||||
|
const current = (await client.getHubStatus()).status;
|
||||||
|
requireConnectedHub(current);
|
||||||
|
const response = await client.updateHubPermissions(
|
||||||
|
operation === "grant" ? { grant: [permission] } : { revoke: [permission] },
|
||||||
|
);
|
||||||
|
reportHubProgress(
|
||||||
|
dependencies.reporter,
|
||||||
|
options,
|
||||||
|
`${operation === "grant" ? "Granted" : "Revoked"} ${permission} ${
|
||||||
|
operation === "grant" ? "to" : "from"
|
||||||
|
} ${response.status.hubOrigin}`,
|
||||||
|
);
|
||||||
|
return hubStatusResult(response.status);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addHubPermissionsCommand(
|
||||||
|
parent: Command,
|
||||||
|
dependencies: HubPermissionsDependencies,
|
||||||
|
): void {
|
||||||
|
const permissions = parent
|
||||||
|
.command("permissions")
|
||||||
|
.description("Manage this Hub's daemon permissions");
|
||||||
|
|
||||||
|
addJsonAndDaemonHostOptions(permissions.command("list")).action(
|
||||||
|
withOutput(async (...args) => {
|
||||||
|
const options = args.at(-2) as HubPermissionsOptions;
|
||||||
|
return runHubPermissionsList(options, dependencies);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const operation of ["grant", "revoke"] as const) {
|
||||||
|
addJsonAndDaemonHostOptions(
|
||||||
|
permissions.command(operation).argument("<permission>", "Daemon permission"),
|
||||||
|
).action(
|
||||||
|
withOutput(async (...args) => {
|
||||||
|
const permission = args[0] as string;
|
||||||
|
const options = args.at(-2) as HubPermissionsOptions;
|
||||||
|
return runHubPermissionChange(operation, permission, options, dependencies);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireConnectedHub(status: HubStatus): void {
|
||||||
|
if (
|
||||||
|
status.hubOrigin === null ||
|
||||||
|
(status.state !== "connected" && status.state !== "reconnecting")
|
||||||
|
) {
|
||||||
|
throw new Error("This daemon is not connected to a Hub");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function describePermission(permission: string): string {
|
||||||
|
return permission === "hub.execute" ? "Run agents for Hub automations" : permission;
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ export interface HubRow {
|
|||||||
state: string;
|
state: string;
|
||||||
daemonId: string | null;
|
daemonId: string | null;
|
||||||
hub: string | null;
|
hub: string | null;
|
||||||
scopes: string;
|
permissions: string;
|
||||||
connectedAt: string | null;
|
connectedAt: string | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
warning?: string;
|
warning?: string;
|
||||||
@@ -17,7 +17,7 @@ const schema: OutputSchema<HubRow> = {
|
|||||||
{ header: "STATE", field: "state" },
|
{ header: "STATE", field: "state" },
|
||||||
{ header: "HUB", field: "hub" },
|
{ header: "HUB", field: "hub" },
|
||||||
{ header: "DAEMON", field: "daemonId" },
|
{ header: "DAEMON", field: "daemonId" },
|
||||||
{ header: "SCOPES", field: "scopes" },
|
{ header: "PERMISSIONS", field: "permissions" },
|
||||||
{ header: "CONNECTED", field: "connectedAt" },
|
{ header: "CONNECTED", field: "connectedAt" },
|
||||||
{ header: "ERROR", field: "error" },
|
{ header: "ERROR", field: "error" },
|
||||||
{ header: "WARNING", field: "warning" },
|
{ header: "WARNING", field: "warning" },
|
||||||
@@ -36,7 +36,7 @@ export function hubStatusResult(
|
|||||||
state: status.state,
|
state: status.state,
|
||||||
daemonId: status.daemonId,
|
daemonId: status.daemonId,
|
||||||
hub: reportedHubOrigin,
|
hub: reportedHubOrigin,
|
||||||
scopes: status.scopes.join(", "),
|
permissions: status.permissions.join(", "),
|
||||||
connectedAt: status.connectedAt,
|
connectedAt: status.connectedAt,
|
||||||
error: status.lastError,
|
error: status.lastError,
|
||||||
warning,
|
warning,
|
||||||
|
|||||||
@@ -9,25 +9,44 @@ const getPluginLogs = vi.fn(async () => [
|
|||||||
message: "ready",
|
message: "ready",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
const installDirectoryPlugin = vi.fn(async () => ({
|
||||||
|
id: "trusted-plugin",
|
||||||
|
path: "/plugins/trusted-plugin",
|
||||||
|
enabled: true,
|
||||||
|
status: "running" as const,
|
||||||
|
}));
|
||||||
|
const installPluginSource = vi.fn(async () => ({
|
||||||
|
id: "trusted-plugin",
|
||||||
|
path: "/plugins/trusted-plugin",
|
||||||
|
enabled: true,
|
||||||
|
status: "running" as const,
|
||||||
|
}));
|
||||||
const close = vi.fn(async () => undefined);
|
const close = vi.fn(async () => undefined);
|
||||||
const features: { pluginManagement?: boolean; pluginLogs?: boolean } = {};
|
const features: {
|
||||||
|
pluginManagement?: boolean;
|
||||||
|
pluginLogs?: boolean;
|
||||||
|
pluginGitManagement?: boolean;
|
||||||
|
} = {};
|
||||||
|
|
||||||
vi.mock("../../utils/client.js", () => ({
|
vi.mock("../../utils/client.js", () => ({
|
||||||
connectToDaemon: vi.fn(async () => ({
|
connectToDaemon: vi.fn(async () => ({
|
||||||
getLastServerInfoMessage: () => ({ features }),
|
getLastServerInfoMessage: () => ({ features }),
|
||||||
listPlugins,
|
listPlugins,
|
||||||
getPluginLogs,
|
getPluginLogs,
|
||||||
|
installDirectoryPlugin,
|
||||||
|
installPluginSource,
|
||||||
close,
|
close,
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { render } from "../../output/index.js";
|
import { render } from "../../output/index.js";
|
||||||
import { runPluginListCommand, runPluginLogsCommand } from "./index.js";
|
import { createPluginCommand, runPluginListCommand, runPluginLogsCommand } from "./index.js";
|
||||||
|
|
||||||
describe("plugin management commands", () => {
|
describe("plugin management commands", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
features.pluginManagement = false;
|
features.pluginManagement = false;
|
||||||
features.pluginLogs = false;
|
features.pluginLogs = false;
|
||||||
|
features.pluginGitManagement = false;
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -63,4 +82,55 @@ describe("plugin management commands", () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("makes trust explicit at the plugin add entry point", () => {
|
||||||
|
const command = createPluginCommand();
|
||||||
|
expect(
|
||||||
|
command.commands.find((subcommand) => subcommand.name() === "install")?.description(),
|
||||||
|
).toContain("Trust and install");
|
||||||
|
expect(command.helpInformation()).toContain("trusted, unsandboxed plugins");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prints the trust acknowledgement before installing", async () => {
|
||||||
|
features.pluginManagement = true;
|
||||||
|
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
||||||
|
const command = createPluginCommand();
|
||||||
|
|
||||||
|
await command.parseAsync(["install", "/plugins/trusted-plugin"], { from: "user" });
|
||||||
|
|
||||||
|
expect(stderr).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("Git build commands run unsandboxed on the daemon host"),
|
||||||
|
);
|
||||||
|
expect(installDirectoryPlugin).toHaveBeenCalledWith("/plugins/trusted-plugin", undefined);
|
||||||
|
stderr.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("folds the legacy --path option into the plugin source reference", async () => {
|
||||||
|
features.pluginGitManagement = true;
|
||||||
|
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
||||||
|
const command = createPluginCommand();
|
||||||
|
|
||||||
|
await command.parseAsync(["install", "owner/monorepo", "--path", "plugins/review"], {
|
||||||
|
from: "user",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(installPluginSource).toHaveBeenCalledWith({
|
||||||
|
source: "owner/monorepo:plugins/review",
|
||||||
|
});
|
||||||
|
stderr.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps an absolute monorepo path as one plugin source reference", async () => {
|
||||||
|
features.pluginGitManagement = true;
|
||||||
|
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
||||||
|
const command = createPluginCommand();
|
||||||
|
|
||||||
|
await command.parseAsync(["install", "/plugins/monorepo:plugins/review"], { from: "user" });
|
||||||
|
|
||||||
|
expect(installPluginSource).toHaveBeenCalledWith({
|
||||||
|
source: "/plugins/monorepo:plugins/review",
|
||||||
|
});
|
||||||
|
expect(installDirectoryPlugin).not.toHaveBeenCalled();
|
||||||
|
stderr.mockRestore();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import type {
|
|||||||
PluginSourceStatusItem,
|
PluginSourceStatusItem,
|
||||||
PluginSourceUpdateItem,
|
PluginSourceUpdateItem,
|
||||||
} from "@getpaseo/protocol/messages";
|
} from "@getpaseo/protocol/messages";
|
||||||
|
import {
|
||||||
|
formatPluginSourceReference,
|
||||||
|
parsePluginSourceReference,
|
||||||
|
} from "@getpaseo/protocol/plugin-source-reference";
|
||||||
import type { CommandOptions, ListResult, OutputSchema, SingleResult } from "../../output/index.js";
|
import type { CommandOptions, ListResult, OutputSchema, SingleResult } from "../../output/index.js";
|
||||||
import { withOutput } from "../../output/index.js";
|
import { withOutput } from "../../output/index.js";
|
||||||
import { addJsonAndDaemonHostOptions, addJsonOption } from "../../utils/command-options.js";
|
import { addJsonAndDaemonHostOptions, addJsonOption } from "../../utils/command-options.js";
|
||||||
@@ -113,6 +117,9 @@ async function install(
|
|||||||
options: PluginOptions,
|
options: PluginOptions,
|
||||||
_command: Command,
|
_command: Command,
|
||||||
): Promise<SingleResult<PluginListItem>> {
|
): Promise<SingleResult<PluginListItem>> {
|
||||||
|
process.stderr.write(
|
||||||
|
"Trusting plugin code: server code and Git build commands run unsandboxed on the daemon host; client code runs inside Paseo. Dependencies and future updates are part of the codebase you trust.\n",
|
||||||
|
);
|
||||||
const isExplicitPath =
|
const isExplicitPath =
|
||||||
path.isAbsolute(source) ||
|
path.isAbsolute(source) ||
|
||||||
source === "." ||
|
source === "." ||
|
||||||
@@ -121,17 +128,19 @@ async function install(
|
|||||||
source.startsWith("../") ||
|
source.startsWith("../") ||
|
||||||
source.startsWith(".\\") ||
|
source.startsWith(".\\") ||
|
||||||
source.startsWith("..\\");
|
source.startsWith("..\\");
|
||||||
const canUseLegacyDirectoryInstall = isExplicitPath && !options.ref && !options.path;
|
const hasPluginPathSuffix = parsePluginSourceReference(source).pluginPath !== undefined;
|
||||||
|
const canUseLegacyDirectoryInstall =
|
||||||
|
isExplicitPath && !hasPluginPathSuffix && !options.ref && !options.path;
|
||||||
|
const sourceReference = formatPluginSourceReference(source, options.path);
|
||||||
const data = canUseLegacyDirectoryInstall
|
const data = canUseLegacyDirectoryInstall
|
||||||
? await withPluginManagementClient(options.host, (client) =>
|
? await withPluginManagementClient(options.host, (client) =>
|
||||||
client.installDirectoryPlugin(source, options.id),
|
client.installDirectoryPlugin(source, options.id),
|
||||||
)
|
)
|
||||||
: await withPluginSourceClient(options.host, (client) =>
|
: await withPluginSourceClient(options.host, (client) =>
|
||||||
client.installPluginSource({
|
client.installPluginSource({
|
||||||
source,
|
source: sourceReference,
|
||||||
...(options.id ? { id: options.id } : {}),
|
...(options.id ? { id: options.id } : {}),
|
||||||
...(options.ref ? { ref: options.ref } : {}),
|
...(options.ref ? { ref: options.ref } : {}),
|
||||||
...(options.path ? { pluginPath: options.path } : {}),
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
return { type: "single", data, schema: pluginSchema };
|
return { type: "single", data, schema: pluginSchema };
|
||||||
@@ -188,7 +197,7 @@ async function remove(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createPluginCommand(): Command {
|
export function createPluginCommand(): Command {
|
||||||
const plugin = new Command("plugin").description("Manage trusted plugins");
|
const plugin = new Command("plugin").description("Manage trusted, unsandboxed plugins");
|
||||||
addJsonOption(
|
addJsonOption(
|
||||||
plugin
|
plugin
|
||||||
.command("init")
|
.command("init")
|
||||||
@@ -206,11 +215,11 @@ export function createPluginCommand(): Command {
|
|||||||
plugin
|
plugin
|
||||||
.command("install")
|
.command("install")
|
||||||
.alias("add")
|
.alias("add")
|
||||||
.description("Install a plugin from a directory or Git repository")
|
.description("Trust and install a plugin from a directory or Git repository")
|
||||||
.argument("<source>", "Host directory, owner/repo shorthand, or Git URL")
|
.argument("<source>", "Host directory, Git source, or Git source:plugin/path")
|
||||||
.option("--id <id>", "Runtime plugin ID (defaults to paseo-plugin.json id)")
|
.option("--id <id>", "Runtime plugin ID (defaults to paseo-plugin.json id)")
|
||||||
.option("--ref <ref>", "Git branch, tag, or commit")
|
.option("--ref <ref>", "Git branch, tag, or commit")
|
||||||
.option("--path <path>", "Plugin directory within the repository"),
|
.option("--path <path>", "Legacy form of the :plugin/path source suffix"),
|
||||||
).action(withOutput(install));
|
).action(withOutput(install));
|
||||||
addJsonAndDaemonHostOptions(
|
addJsonAndDaemonHostOptions(
|
||||||
plugin.command("status").description("Check plugin source updates").argument("[id]"),
|
plugin.command("status").description("Check plugin source updates").argument("[id]"),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import ts from "typescript";
|
import ts from "typescript";
|
||||||
@@ -34,6 +34,22 @@ describe("plugin scaffold", () => {
|
|||||||
expect(JSON.parse(await readFile(path.join(directory, "paseo-plugin.json"), "utf8"))).toEqual({
|
expect(JSON.parse(await readFile(path.join(directory, "paseo-plugin.json"), "utf8"))).toEqual({
|
||||||
id: "hello-plugin",
|
id: "hello-plugin",
|
||||||
});
|
});
|
||||||
|
expect(JSON.parse(await readFile(path.join(directory, "package.json"), "utf8"))).toEqual({
|
||||||
|
name: "hello-plugin",
|
||||||
|
private: true,
|
||||||
|
version: "0.0.0",
|
||||||
|
scripts: { typecheck: "tsc --noEmit" },
|
||||||
|
devDependencies: {
|
||||||
|
"@getpaseo/plugin": "0.7.0",
|
||||||
|
"@tanstack/react-query": "^5.90.11",
|
||||||
|
"@types/react": "~19.2.0",
|
||||||
|
react: "19.1.0",
|
||||||
|
"react-native": "0.81.5",
|
||||||
|
typescript: "^5.9.3",
|
||||||
|
zod: "^4.4.3",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(await readdir(directory)).not.toContain("paseo-plugin.d.ts");
|
||||||
await expect(readFile(path.join(directory, "index.ts"), "utf8")).resolves.toContain(
|
await expect(readFile(path.join(directory, "index.ts"), "utf8")).resolves.toContain(
|
||||||
'from "./main.client"',
|
'from "./main.client"',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,364 +1,7 @@
|
|||||||
import { mkdir, readdir, writeFile } from "node:fs/promises";
|
import { mkdir, readdir, writeFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { PluginIdSchema } from "@getpaseo/protocol/messages";
|
import { PluginIdSchema } from "@getpaseo/protocol/messages";
|
||||||
|
import { resolveCliVersion } from "../../version.js";
|
||||||
const SDK_DECLARATIONS = `declare module "@getpaseo/plugin/server" {
|
|
||||||
import type { PaseoApi } from "@getpaseo/client";
|
|
||||||
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
|
|
||||||
|
|
||||||
export interface PluginRpcContract<
|
|
||||||
InputSchema extends ZodType = ZodType,
|
|
||||||
OutputSchema extends ZodType = ZodType,
|
|
||||||
> {
|
|
||||||
name: string;
|
|
||||||
input: InputSchema;
|
|
||||||
output: OutputSchema;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PluginAttachmentItem {
|
|
||||||
id: string;
|
|
||||||
identifier: string;
|
|
||||||
title: string;
|
|
||||||
subtitle?: string;
|
|
||||||
url: string;
|
|
||||||
text: string;
|
|
||||||
resourceType: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PluginAttachmentSearchPayload {
|
|
||||||
items: PluginAttachmentItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PluginAttachmentSourceContribution {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
icon: string;
|
|
||||||
pickerTitle: string;
|
|
||||||
searchPlaceholder: string;
|
|
||||||
search: PluginRpcContract;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PluginHandlerContext {
|
|
||||||
paseo: PaseoApi;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function defineRpc<InputSchema extends ZodType, OutputSchema extends ZodType>(definition: {
|
|
||||||
name: string;
|
|
||||||
input: InputSchema;
|
|
||||||
output: OutputSchema;
|
|
||||||
}): PluginRpcContract<InputSchema, OutputSchema>;
|
|
||||||
|
|
||||||
export function defineAttachmentSource<Definition extends PluginAttachmentSourceContribution>(
|
|
||||||
definition: Definition,
|
|
||||||
): Definition;
|
|
||||||
|
|
||||||
export const PluginAttachmentItemSchema: import("zod").ZodType<PluginAttachmentItem>;
|
|
||||||
export const PluginAttachmentSearchPayloadSchema: import("zod").ZodType<PluginAttachmentSearchPayload>;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module "@getpaseo/plugin/react-native" {
|
|
||||||
import type { ComponentType, FunctionComponent, ReactNode } from "react";
|
|
||||||
|
|
||||||
export interface PluginIconProps {
|
|
||||||
name: string;
|
|
||||||
size?: number;
|
|
||||||
color?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ModalProps {
|
|
||||||
title: string;
|
|
||||||
icon?: ReactNode;
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange(open: boolean): void;
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ModalContentProps {
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ModalComponent extends FunctionComponent<ModalProps> {
|
|
||||||
Content: ComponentType<ModalContentProps>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ToastVariant = "default" | "info" | "success" | "warning" | "error";
|
|
||||||
export interface ToastOptions {
|
|
||||||
variant?: ToastVariant;
|
|
||||||
durationMs?: number;
|
|
||||||
}
|
|
||||||
export interface ToastApi {
|
|
||||||
show(message: string, options?: ToastOptions): void;
|
|
||||||
error(message: string): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Icon: ComponentType<PluginIconProps>;
|
|
||||||
export const Modal: ModalComponent;
|
|
||||||
export function useToast(): ToastApi;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module "@getpaseo/plugin" {
|
|
||||||
import type { ComponentType } from "react";
|
|
||||||
import type { PaseoApi } from "@getpaseo/client";
|
|
||||||
import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";
|
|
||||||
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
|
|
||||||
import type {
|
|
||||||
PluginAttachmentSourceContribution,
|
|
||||||
PluginHandlerContext,
|
|
||||||
PluginRpcContract,
|
|
||||||
} from "@getpaseo/plugin/server";
|
|
||||||
|
|
||||||
export {
|
|
||||||
PluginAttachmentItemSchema,
|
|
||||||
PluginAttachmentSearchPayloadSchema,
|
|
||||||
defineAttachmentSource,
|
|
||||||
defineRpc,
|
|
||||||
type PluginAttachmentItem,
|
|
||||||
type PluginAttachmentSearchPayload,
|
|
||||||
type PluginAttachmentSourceContribution,
|
|
||||||
type PluginHandlerContext,
|
|
||||||
type PluginRpcContract,
|
|
||||||
} from "@getpaseo/plugin/server";
|
|
||||||
|
|
||||||
export interface PluginTheme {
|
|
||||||
readonly colors: {
|
|
||||||
readonly surface0: string;
|
|
||||||
readonly surface1: string;
|
|
||||||
readonly surface2: string;
|
|
||||||
readonly border: string;
|
|
||||||
readonly foreground: string;
|
|
||||||
readonly foregroundMuted: string;
|
|
||||||
readonly accent: string;
|
|
||||||
readonly accentForeground: string;
|
|
||||||
readonly statusSuccess: string;
|
|
||||||
readonly statusWarning: string;
|
|
||||||
readonly statusDanger: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PluginHostProps {
|
|
||||||
theme: PluginTheme;
|
|
||||||
host: { id: string; label: string };
|
|
||||||
layout: { compact: boolean; platform: "ios" | "android" | "web" };
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PluginNavigableHostProps extends PluginHostProps {
|
|
||||||
/** Client-owned navigation. Undefined on older hosts; hide dependent affordances when absent. */
|
|
||||||
readonly navigation?: {
|
|
||||||
readonly openAgent: (input: { readonly agentId: string }) => void;
|
|
||||||
readonly openWorkspace: (input: { readonly workspaceId: string }) => void;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PluginSurfaceProps extends PluginNavigableHostProps {}
|
|
||||||
|
|
||||||
export interface PluginIconProps {
|
|
||||||
name: string;
|
|
||||||
size?: number;
|
|
||||||
color?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PluginWorkspaceSnapshot {
|
|
||||||
readonly id: string;
|
|
||||||
readonly projectId: string;
|
|
||||||
readonly projectDisplayName: string;
|
|
||||||
readonly projectRootPath: string;
|
|
||||||
readonly directory: string;
|
|
||||||
readonly projectKind: "git" | "non_git" | "directory";
|
|
||||||
readonly kind: "directory" | "local_checkout" | "checkout" | "worktree";
|
|
||||||
readonly name: string;
|
|
||||||
readonly title: string | null;
|
|
||||||
readonly status: "needs_input" | "failed" | "running" | "attention" | "done";
|
|
||||||
readonly statusEnteredAt: string | null;
|
|
||||||
readonly archivingAt: string | null;
|
|
||||||
readonly diffStat: { readonly additions: number; readonly deletions: number } | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PluginAgentSnapshot {
|
|
||||||
readonly id: string;
|
|
||||||
readonly workspaceId: string;
|
|
||||||
readonly provider: string;
|
|
||||||
readonly status: "initializing" | "idle" | "running" | "error" | "closed";
|
|
||||||
readonly createdAt: string;
|
|
||||||
readonly updatedAt: string;
|
|
||||||
readonly lastActivityAt: string;
|
|
||||||
readonly title: string | null;
|
|
||||||
readonly cwd: string;
|
|
||||||
readonly model: string | null;
|
|
||||||
readonly currentModeId: string | null;
|
|
||||||
readonly thinkingOptionId: string | null;
|
|
||||||
readonly requiresAttention: boolean;
|
|
||||||
readonly attentionReason: "finished" | "error" | "permission" | null;
|
|
||||||
readonly parentAgentId: string | null;
|
|
||||||
readonly labels: Readonly<Record<string, string>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PluginWorkspacePanelProps extends PluginNavigableHostProps {
|
|
||||||
context: "workspace";
|
|
||||||
workspaceId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PluginAgentPanelProps extends PluginNavigableHostProps {
|
|
||||||
context: "agent";
|
|
||||||
workspaceId: string;
|
|
||||||
agentId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PluginComposerPillProps extends PluginHostProps {
|
|
||||||
workspaceId: string;
|
|
||||||
agentId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PluginComposerPillContribution {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
workspaceId: string;
|
|
||||||
agentId: string;
|
|
||||||
Component: ComponentType<PluginComposerPillProps>;
|
|
||||||
onPress(): void | Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PluginPanelLocation = "workspace" | "explorer";
|
|
||||||
export interface PluginOpenPanelOptions { location?: PluginPanelLocation; }
|
|
||||||
export interface PluginClientOpenPanelOptions extends PluginOpenPanelOptions {
|
|
||||||
workspaceId: string;
|
|
||||||
agentId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PluginWorkspacePanelContribution =
|
|
||||||
| { id: string; title: string; icon: string; locations?: readonly PluginPanelLocation[]; context: "workspace"; Component: ComponentType<PluginWorkspacePanelProps> }
|
|
||||||
| { id: string; title: string; icon: string; locations?: readonly PluginPanelLocation[]; context: "agent"; Component: ComponentType<PluginAgentPanelProps> };
|
|
||||||
|
|
||||||
export interface PluginSidebarContribution {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
icon: string;
|
|
||||||
surface: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PluginThemeColors {
|
|
||||||
background: string;
|
|
||||||
foreground: string;
|
|
||||||
raised: string;
|
|
||||||
control: string;
|
|
||||||
border: string;
|
|
||||||
accent?: string;
|
|
||||||
mutedForeground: string;
|
|
||||||
ring: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PluginThemeContribution {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
appearance: "light" | "dark";
|
|
||||||
colors: PluginThemeColors;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PluginSurfaceContribution {
|
|
||||||
id: string;
|
|
||||||
Component: ComponentType<PluginSurfaceProps>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PluginTimelineData = null | boolean | number | string | PluginTimelineData[] | { [key: string]: PluginTimelineData };
|
|
||||||
export interface PluginTimelineItem { type: "plugin"; kind: string; version: number; data: PluginTimelineData; }
|
|
||||||
export interface PluginTimelineTransformResult { items: PluginTimelineItem[]; }
|
|
||||||
export type PluginTimelineTransformerContribution<ItemType extends AgentTimelineItem["type"] = AgentTimelineItem["type"]> =
|
|
||||||
ItemType extends AgentTimelineItem["type"]
|
|
||||||
? {
|
|
||||||
id: string;
|
|
||||||
query: { itemType: ItemType };
|
|
||||||
transform(input: { item: Extract<AgentTimelineItem, { type: ItemType }> }): PluginTimelineTransformResult | undefined;
|
|
||||||
}
|
|
||||||
: never;
|
|
||||||
export interface PluginTimelineItemProps<Data = unknown> extends PluginHostProps {
|
|
||||||
agentId: string;
|
|
||||||
item: { type: "plugin"; kind: string; version: number; data: Data };
|
|
||||||
timestamp: Date;
|
|
||||||
}
|
|
||||||
export interface PluginTimelineRendererContribution<Schema extends ZodType = ZodType> {
|
|
||||||
kind: string;
|
|
||||||
version: number;
|
|
||||||
schema: Schema;
|
|
||||||
Component: ComponentType<PluginTimelineItemProps<ZodOutput<Schema>>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PluginCommandCapabilities {
|
|
||||||
paseo: PaseoApi;
|
|
||||||
rpc<InputSchema extends ZodType, OutputSchema extends ZodType>(
|
|
||||||
contract: PluginRpcContract<InputSchema, OutputSchema>,
|
|
||||||
input: ZodInput<InputSchema>,
|
|
||||||
): Promise<ZodOutput<OutputSchema>>;
|
|
||||||
openSurface(id: string): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PluginGlobalCommandContext extends PluginCommandCapabilities {
|
|
||||||
context: "global";
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PluginWorkspaceCommandContext extends PluginCommandCapabilities {
|
|
||||||
context: "workspace";
|
|
||||||
workspace: PluginWorkspaceSnapshot;
|
|
||||||
openPanel(id: string, options?: PluginOpenPanelOptions): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PluginAgentCommandContext extends PluginCommandCapabilities {
|
|
||||||
context: "agent";
|
|
||||||
workspace: PluginWorkspaceSnapshot;
|
|
||||||
agent: PluginAgentSnapshot;
|
|
||||||
openPanel(id: string, options?: PluginOpenPanelOptions): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PluginClientContext extends PluginCommandCapabilities {
|
|
||||||
addComposerPill(contribution: PluginComposerPillContribution): PluginCleanup;
|
|
||||||
openPanel(id: string, options: PluginClientOpenPanelOptions): void;
|
|
||||||
}
|
|
||||||
export type PluginClientContribution = (client: PluginClientContext) => PluginCleanup;
|
|
||||||
|
|
||||||
export type PluginCommandCenterItemContribution =
|
|
||||||
| { id: string; title: string; icon: string; keywords?: readonly string[]; context: "global"; onSelect(context: PluginGlobalCommandContext): void | Promise<void> }
|
|
||||||
| { id: string; title: string; icon: string; keywords?: readonly string[]; context: "workspace"; onSelect(context: PluginWorkspaceCommandContext): void | Promise<void> }
|
|
||||||
| { id: string; title: string; icon: string; keywords?: readonly string[]; context: "agent"; onSelect(context: PluginAgentCommandContext): void | Promise<void> };
|
|
||||||
|
|
||||||
export interface PluginContext {
|
|
||||||
handle<InputSchema extends ZodType, OutputSchema extends ZodType>(
|
|
||||||
contract: PluginRpcContract<InputSchema, OutputSchema>,
|
|
||||||
handler: (
|
|
||||||
input: ZodOutput<InputSchema>,
|
|
||||||
context: PluginHandlerContext,
|
|
||||||
) => ZodInput<OutputSchema> | Promise<ZodInput<OutputSchema>>,
|
|
||||||
): void;
|
|
||||||
addSurface(id: string, Component: ComponentType<PluginSurfaceProps>): void;
|
|
||||||
addSidebarItem(contribution: PluginSidebarContribution): void;
|
|
||||||
addWorkspacePanel(contribution: PluginWorkspacePanelContribution): void;
|
|
||||||
addCommandCenterItem(contribution: PluginCommandCenterItemContribution): void;
|
|
||||||
addClientSide(contribution: PluginClientContribution): void;
|
|
||||||
addAttachmentSource(contribution: PluginAttachmentSourceContribution): void;
|
|
||||||
addTheme(contribution: PluginThemeContribution): void;
|
|
||||||
addTimelineTransformer<ItemType extends AgentTimelineItem["type"]>(contribution: PluginTimelineTransformerContribution<ItemType>): void;
|
|
||||||
addTimelineRenderer<Schema extends ZodType>(contribution: PluginTimelineRendererContribution<Schema>): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PluginCleanup = () => void | Promise<void>;
|
|
||||||
export type PluginContribution = (plugin: PluginContext) => PluginCleanup;
|
|
||||||
|
|
||||||
export const Icon: ComponentType<PluginIconProps>;
|
|
||||||
|
|
||||||
export function useRpc<InputSchema extends ZodType, OutputSchema extends ZodType>(
|
|
||||||
contract: PluginRpcContract<InputSchema, OutputSchema>,
|
|
||||||
): (input: ZodInput<InputSchema>) => Promise<ZodOutput<OutputSchema>>;
|
|
||||||
|
|
||||||
export function usePaseo(): PaseoApi;
|
|
||||||
|
|
||||||
export function useWorkspace<Selection>(
|
|
||||||
workspaceId: string,
|
|
||||||
selector: (workspace: PluginWorkspaceSnapshot) => Selection,
|
|
||||||
): Selection | null;
|
|
||||||
|
|
||||||
export function useAgent<Selection>(
|
|
||||||
agentId: string,
|
|
||||||
selector: (agent: PluginAgentSnapshot) => Selection,
|
|
||||||
): Selection | null;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const TSCONFIG = {
|
const TSCONFIG = {
|
||||||
compilerOptions: {
|
compilerOptions: {
|
||||||
@@ -432,8 +75,7 @@ export async function scaffoldPluginDirectory(
|
|||||||
version: "0.0.0",
|
version: "0.0.0",
|
||||||
scripts: { typecheck: "tsc --noEmit" },
|
scripts: { typecheck: "tsc --noEmit" },
|
||||||
devDependencies: {
|
devDependencies: {
|
||||||
"@getpaseo/client": "^0.4.0",
|
"@getpaseo/plugin": resolveCliVersion(),
|
||||||
"@getpaseo/protocol": "^0.6.1",
|
|
||||||
"@tanstack/react-query": "^5.90.11",
|
"@tanstack/react-query": "^5.90.11",
|
||||||
"@types/react": "~19.2.0",
|
"@types/react": "~19.2.0",
|
||||||
react: "19.1.0",
|
react: "19.1.0",
|
||||||
@@ -446,7 +88,6 @@ export async function scaffoldPluginDirectory(
|
|||||||
["paseo-plugin.json", `${JSON.stringify({ id }, null, 2)}\n`],
|
["paseo-plugin.json", `${JSON.stringify({ id }, null, 2)}\n`],
|
||||||
["package.json", `${JSON.stringify(packageJson, null, 2)}\n`],
|
["package.json", `${JSON.stringify(packageJson, null, 2)}\n`],
|
||||||
["tsconfig.json", `${JSON.stringify(TSCONFIG, null, 2)}\n`],
|
["tsconfig.json", `${JSON.stringify(TSCONFIG, null, 2)}\n`],
|
||||||
["paseo-plugin.d.ts", SDK_DECLARATIONS],
|
|
||||||
["index.ts", ENTRY],
|
["index.ts", ENTRY],
|
||||||
["main.client.tsx", CLIENT_SURFACE],
|
["main.client.tsx", CLIENT_SURFACE],
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/client",
|
"name": "@getpaseo/client",
|
||||||
"version": "0.7.0-beta.1",
|
"version": "0.7.0",
|
||||||
"description": "Paseo client SDK package",
|
"description": "Paseo client SDK package",
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
@@ -39,8 +39,8 @@
|
|||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/protocol": "0.7.0-beta.1",
|
"@getpaseo/protocol": "0.7.0",
|
||||||
"@getpaseo/relay": "0.7.0-beta.1",
|
"@getpaseo/relay": "0.7.0",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1988,6 +1988,49 @@ test("file context action RPCs correlate success and error responses", async ()
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("serializes plugin source suffixes through the legacy path field", async () => {
|
||||||
|
const mock = createMockTransport();
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: "ws://test",
|
||||||
|
clientId: "clsk_plugin_source",
|
||||||
|
logger: createMockLogger(),
|
||||||
|
reconnect: { enabled: false },
|
||||||
|
transportFactory: () => mock.transport,
|
||||||
|
});
|
||||||
|
clients.push(client);
|
||||||
|
|
||||||
|
const connectPromise = client.connect();
|
||||||
|
mock.triggerOpen();
|
||||||
|
await connectPromise;
|
||||||
|
|
||||||
|
const installPromise = client.installPluginSource({
|
||||||
|
source: "owner/repository:plugins/review",
|
||||||
|
});
|
||||||
|
const request = parseSentFrame(mock.sent.at(-1));
|
||||||
|
expect(request).toEqual({
|
||||||
|
type: "plugin.source.install.request",
|
||||||
|
requestId: expect.any(String),
|
||||||
|
source: "owner/repository",
|
||||||
|
pluginPath: "plugins/review",
|
||||||
|
});
|
||||||
|
mock.triggerMessage(
|
||||||
|
wrapSessionMessage({
|
||||||
|
type: "plugin.source.install.response",
|
||||||
|
payload: {
|
||||||
|
requestId: request.requestId,
|
||||||
|
plugin: {
|
||||||
|
id: "review",
|
||||||
|
path: "/plugins/review",
|
||||||
|
enabled: true,
|
||||||
|
status: "running",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(installPromise).resolves.toMatchObject({ id: "review", status: "running" });
|
||||||
|
});
|
||||||
|
|
||||||
test("a connection loss rejects an in-flight file context action", async () => {
|
test("a connection loss rejects an in-flight file context action", async () => {
|
||||||
const mock = createMockTransport();
|
const mock = createMockTransport();
|
||||||
const client = new DaemonClient({
|
const client = new DaemonClient({
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities";
|
import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities";
|
||||||
import type { AgentAttentionNotificationPayload } from "@getpaseo/protocol/agent-attention-notification";
|
import type { AgentAttentionNotificationPayload } from "@getpaseo/protocol/agent-attention-notification";
|
||||||
|
import { parsePluginSourceReference } from "@getpaseo/protocol/plugin-source-reference";
|
||||||
import {
|
import {
|
||||||
AgentCreateFailedStatusPayloadSchema,
|
AgentCreateFailedStatusPayloadSchema,
|
||||||
AgentCreatedStatusPayloadSchema,
|
AgentCreatedStatusPayloadSchema,
|
||||||
@@ -309,7 +310,7 @@ export type BrowserAutomationExecuteResponseMessage = BrowserAutomationExecuteRe
|
|||||||
export interface DaemonClientConfig {
|
export interface DaemonClientConfig {
|
||||||
url: string;
|
url: string;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
clientType?: "mobile" | "browser" | "cli" | "mcp";
|
clientType?: "mobile" | "browser" | "cli" | "mcp" | "hub";
|
||||||
appVersion?: string;
|
appVersion?: string;
|
||||||
runtimeGeneration?: number | null;
|
runtimeGeneration?: number | null;
|
||||||
password?: string;
|
password?: string;
|
||||||
@@ -4680,15 +4681,36 @@ export class DaemonClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async connectHub(hubUrl: string, token: string, requestId?: string) {
|
async connectHub(
|
||||||
|
hubUrl: string,
|
||||||
|
token: string,
|
||||||
|
permissions: readonly string[] = [],
|
||||||
|
requestId?: string,
|
||||||
|
) {
|
||||||
this.requireHubRelationshipSupport();
|
this.requireHubRelationshipSupport();
|
||||||
return this.sendCorrelatedSessionRequest({
|
return this.sendCorrelatedSessionRequest({
|
||||||
requestId,
|
requestId,
|
||||||
message: { type: "hub.management.daemon.connect.request", hubUrl, token },
|
message: { type: "hub.management.daemon.connect.request", hubUrl, token, permissions },
|
||||||
responseType: "hub.management.daemon.connect.response",
|
responseType: "hub.management.daemon.connect.response",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateHubPermissions(
|
||||||
|
input: { grant?: readonly string[]; revoke?: readonly string[] },
|
||||||
|
requestId?: string,
|
||||||
|
) {
|
||||||
|
this.requireHubRelationshipSupport();
|
||||||
|
return this.sendCorrelatedSessionRequest({
|
||||||
|
requestId,
|
||||||
|
message: {
|
||||||
|
type: "hub.management.daemon.permissions.update.request",
|
||||||
|
grant: input.grant ?? [],
|
||||||
|
revoke: input.revoke ?? [],
|
||||||
|
},
|
||||||
|
responseType: "hub.management.daemon.permissions.update.response",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async getHubStatus(requestId?: string) {
|
async getHubStatus(requestId?: string) {
|
||||||
this.requireHubRelationshipSupport();
|
this.requireHubRelationshipSupport();
|
||||||
return this.sendCorrelatedSessionRequest({
|
return this.sendCorrelatedSessionRequest({
|
||||||
@@ -4950,12 +4972,19 @@ export class DaemonClient {
|
|||||||
source: string;
|
source: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
ref?: string;
|
ref?: string;
|
||||||
pluginPath?: string;
|
|
||||||
}): Promise<PluginListItem> {
|
}): Promise<PluginListItem> {
|
||||||
const requestId = this.createRequestId();
|
const requestId = this.createRequestId();
|
||||||
|
const reference = parsePluginSourceReference(input.source);
|
||||||
const payload = await this.sendCorrelatedSessionRequest({
|
const payload = await this.sendCorrelatedSessionRequest({
|
||||||
requestId,
|
requestId,
|
||||||
message: { type: "plugin.source.install.request", requestId, ...input },
|
message: {
|
||||||
|
type: "plugin.source.install.request",
|
||||||
|
requestId,
|
||||||
|
source: reference.source,
|
||||||
|
...(reference.pluginPath ? { pluginPath: reference.pluginPath } : {}),
|
||||||
|
...(input.id ? { id: input.id } : {}),
|
||||||
|
...(input.ref ? { ref: input.ref } : {}),
|
||||||
|
},
|
||||||
responseType: "plugin.source.install.response",
|
responseType: "plugin.source.install.response",
|
||||||
});
|
});
|
||||||
return payload.plugin;
|
return payload.plugin;
|
||||||
|
|||||||
@@ -232,6 +232,20 @@ async function callBrowserTool(client, name, args = {}) {
|
|||||||
return mcpPayload(await client.callTool({ name, args }), name);
|
return mcpPayload(await client.callTool({ name, args }), name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function callBrowserToolUntilReady(client, name, args = {}) {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const result = await client.callTool({ name, args });
|
||||||
|
const payload = result.structuredContent;
|
||||||
|
if (payload?.ok === true) return payload.result;
|
||||||
|
if (payload?.ok !== false || payload.error?.retryable !== true) {
|
||||||
|
return mcpPayload(result, name);
|
||||||
|
}
|
||||||
|
await delay(100);
|
||||||
|
}
|
||||||
|
throw new Error(`${name} remained unavailable for ${timeoutMs}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
async function waitForGuestSelector(client, browserId) {
|
async function waitForGuestSelector(client, browserId) {
|
||||||
const deadline = Date.now() + 5_000;
|
const deadline = Date.now() + 5_000;
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
@@ -568,7 +582,7 @@ async function runRegression({ page, client, serverId, targetUrl, callerAgentId,
|
|||||||
await originalDeck.getByTestId(`workspace-tab-agent_${callerAgentId}`).click();
|
await originalDeck.getByTestId(`workspace-tab-agent_${callerAgentId}`).click();
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
try {
|
try {
|
||||||
await callBrowserTool(client, "browser_screenshot", { browserId });
|
await callBrowserToolUntilReady(client, "browser_screenshot", { browserId });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
failures.push(`inactive browser remains captureable: ${String(error)}`);
|
failures.push(`inactive browser remains captureable: ${String(error)}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/desktop",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.7.0-beta.1",
|
"version": "0.7.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Paseo desktop app (Electron wrapper)",
|
"description": "Paseo desktop app (Electron wrapper)",
|
||||||
"homepage": "https://paseo.sh",
|
"homepage": "https://paseo.sh",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/expo-two-way-audio",
|
"name": "@getpaseo/expo-two-way-audio",
|
||||||
"version": "0.7.0-beta.1",
|
"version": "0.7.0",
|
||||||
"description": "Native module for two way audio streaming",
|
"description": "Native module for two way audio streaming",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"ExpoTwoWayAudio",
|
"ExpoTwoWayAudio",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/highlight",
|
"name": "@getpaseo/highlight",
|
||||||
"version": "0.7.0-beta.1",
|
"version": "0.7.0",
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
"!dist/**/*.map",
|
"!dist/**/*.map",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/plugin",
|
"name": "@getpaseo/plugin",
|
||||||
"version": "0.7.0-beta.1",
|
"version": "0.7.0",
|
||||||
"description": "Paseo plugin SDK package",
|
"description": "Paseo plugin SDK package",
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
@@ -44,8 +44,8 @@
|
|||||||
"use-sync-external-store": "^1.6.0"
|
"use-sync-external-store": "^1.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@getpaseo/client": "0.7.0-beta.1",
|
"@getpaseo/client": "0.7.0",
|
||||||
"@getpaseo/protocol": "0.7.0-beta.1",
|
"@getpaseo/protocol": "0.7.0",
|
||||||
"@types/node": "^20.9.0",
|
"@types/node": "^20.9.0",
|
||||||
"@types/react": "~19.2.0",
|
"@types/react": "~19.2.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
@@ -54,8 +54,8 @@
|
|||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@getpaseo/client": "0.7.0-beta.1",
|
"@getpaseo/client": "0.7.0",
|
||||||
"@getpaseo/protocol": "0.7.0-beta.1",
|
"@getpaseo/protocol": "0.7.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/protocol",
|
"name": "@getpaseo/protocol",
|
||||||
"version": "0.7.0-beta.1",
|
"version": "0.7.0",
|
||||||
"description": "Paseo shared protocol schemas and wire types",
|
"description": "Paseo shared protocol schemas and wire types",
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
|
|||||||
@@ -335,9 +335,16 @@ describe("Hub session protocol", () => {
|
|||||||
requestId: "r1",
|
requestId: "r1",
|
||||||
hubUrl: "https://hub.example",
|
hubUrl: "https://hub.example",
|
||||||
token: "token",
|
token: "token",
|
||||||
|
permissions: [],
|
||||||
},
|
},
|
||||||
{ type: "hub.management.daemon.get_status.request", requestId: "r2" },
|
{ type: "hub.management.daemon.get_status.request", requestId: "r2" },
|
||||||
{ type: "hub.management.daemon.disconnect.request", requestId: "r3", force: true },
|
{ type: "hub.management.daemon.disconnect.request", requestId: "r3", force: true },
|
||||||
|
{
|
||||||
|
type: "hub.management.daemon.permissions.update.request",
|
||||||
|
requestId: "r4",
|
||||||
|
grant: ["hub.execute"],
|
||||||
|
revoke: [],
|
||||||
|
},
|
||||||
])("accepts trusted management request $type", (message) => {
|
])("accepts trusted management request $type", (message) => {
|
||||||
expect(SessionInboundMessageSchema.parse(message)).toEqual(message);
|
expect(SessionInboundMessageSchema.parse(message)).toEqual(message);
|
||||||
});
|
});
|
||||||
@@ -351,7 +358,7 @@ describe("Hub session protocol", () => {
|
|||||||
state: "connected",
|
state: "connected",
|
||||||
daemonId: "daemon-1",
|
daemonId: "daemon-1",
|
||||||
hubOrigin: "https://hub.example",
|
hubOrigin: "https://hub.example",
|
||||||
scopes: ["hub.execution.*"],
|
permissions: ["hub.execute"],
|
||||||
connectedAt: "2026-07-13T00:00:00.000Z",
|
connectedAt: "2026-07-13T00:00:00.000Z",
|
||||||
lastError: null,
|
lastError: null,
|
||||||
},
|
},
|
||||||
@@ -365,7 +372,7 @@ describe("Hub session protocol", () => {
|
|||||||
state: "not_connected",
|
state: "not_connected",
|
||||||
daemonId: null,
|
daemonId: null,
|
||||||
hubOrigin: null,
|
hubOrigin: null,
|
||||||
scopes: [],
|
permissions: [],
|
||||||
connectedAt: null,
|
connectedAt: null,
|
||||||
lastError: null,
|
lastError: null,
|
||||||
},
|
},
|
||||||
@@ -379,13 +386,27 @@ describe("Hub session protocol", () => {
|
|||||||
state: "disconnecting",
|
state: "disconnecting",
|
||||||
daemonId: "daemon-1",
|
daemonId: "daemon-1",
|
||||||
hubOrigin: "https://hub.example",
|
hubOrigin: "https://hub.example",
|
||||||
scopes: ["hub.execution.*"],
|
permissions: ["hub.execute"],
|
||||||
connectedAt: null,
|
connectedAt: null,
|
||||||
lastError: "offline",
|
lastError: "offline",
|
||||||
},
|
},
|
||||||
warning: "pending",
|
warning: "pending",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: "hub.management.daemon.permissions.update.response",
|
||||||
|
payload: {
|
||||||
|
requestId: "r4",
|
||||||
|
status: {
|
||||||
|
state: "connected",
|
||||||
|
daemonId: "daemon-1",
|
||||||
|
hubOrigin: "https://hub.example",
|
||||||
|
permissions: ["hub.execute"],
|
||||||
|
connectedAt: "2026-07-13T00:00:00.000Z",
|
||||||
|
lastError: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
])("accepts trusted management response $type", (message) => {
|
])("accepts trusted management response $type", (message) => {
|
||||||
expect(SessionOutboundMessageSchema.parse(message)).toEqual(message);
|
expect(SessionOutboundMessageSchema.parse(message)).toEqual(message);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -53,8 +53,15 @@ describe("plugin protocol compatibility", () => {
|
|||||||
SessionInboundMessageSchema.parse({
|
SessionInboundMessageSchema.parse({
|
||||||
type: "plugin.source.install.request",
|
type: "plugin.source.install.request",
|
||||||
requestId: "request-install",
|
requestId: "request-install",
|
||||||
source: "owner/repository",
|
source: "owner/repository:plugins/review",
|
||||||
ref: "main",
|
ref: "main",
|
||||||
|
}).type,
|
||||||
|
).toBe("plugin.source.install.request");
|
||||||
|
expect(
|
||||||
|
SessionInboundMessageSchema.parse({
|
||||||
|
type: "plugin.source.install.request",
|
||||||
|
requestId: "request-install-old-client",
|
||||||
|
source: "owner/repository",
|
||||||
pluginPath: "plugins/review",
|
pluginPath: "plugins/review",
|
||||||
}).type,
|
}).type,
|
||||||
).toBe("plugin.source.install.request");
|
).toBe("plugin.source.install.request");
|
||||||
|
|||||||
@@ -93,6 +93,20 @@ export {
|
|||||||
// Mutable daemon config schemas (shared between server store and client)
|
// Mutable daemon config schemas (shared between server store and client)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const DAEMON_PERMISSIONS = [
|
||||||
|
"daemon.read",
|
||||||
|
"daemon.manage",
|
||||||
|
"tunnel.manage",
|
||||||
|
"access.manage",
|
||||||
|
"workspace.read",
|
||||||
|
"workspace.write",
|
||||||
|
"workspace.manage",
|
||||||
|
"automation.manage",
|
||||||
|
"hub.execute",
|
||||||
|
] as const;
|
||||||
|
export const DaemonPermissionSchema = z.enum(DAEMON_PERMISSIONS);
|
||||||
|
export type DaemonPermission = z.infer<typeof DaemonPermissionSchema>;
|
||||||
|
|
||||||
const MutableDaemonProviderModelSchema = z
|
const MutableDaemonProviderModelSchema = z
|
||||||
.object({
|
.object({
|
||||||
id: z.string().min(1),
|
id: z.string().min(1),
|
||||||
@@ -1393,6 +1407,7 @@ export const HubManagementDaemonConnectRequestSchema = z.object({
|
|||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
hubUrl: z.string(),
|
hubUrl: z.string(),
|
||||||
token: z.string(),
|
token: z.string(),
|
||||||
|
permissions: z.array(DaemonPermissionSchema).default([]),
|
||||||
});
|
});
|
||||||
export const HubManagementDaemonGetStatusRequestSchema = z.object({
|
export const HubManagementDaemonGetStatusRequestSchema = z.object({
|
||||||
type: z.literal("hub.management.daemon.get_status.request"),
|
type: z.literal("hub.management.daemon.get_status.request"),
|
||||||
@@ -1403,6 +1418,12 @@ export const HubManagementDaemonDisconnectRequestSchema = z.object({
|
|||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
force: z.boolean().optional(),
|
force: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
export const HubManagementDaemonPermissionsUpdateRequestSchema = z.object({
|
||||||
|
type: z.literal("hub.management.daemon.permissions.update.request"),
|
||||||
|
requestId: z.string(),
|
||||||
|
grant: z.array(DaemonPermissionSchema).default([]),
|
||||||
|
revoke: z.array(DaemonPermissionSchema).default([]),
|
||||||
|
});
|
||||||
|
|
||||||
export const DiagnosticsRequestSchema = z.object({
|
export const DiagnosticsRequestSchema = z.object({
|
||||||
type: z.literal("diagnostics.request"),
|
type: z.literal("diagnostics.request"),
|
||||||
@@ -1444,6 +1465,7 @@ export const PluginSourceInstallRequestSchema = z.object({
|
|||||||
source: z.string().min(1),
|
source: z.string().min(1),
|
||||||
id: PluginIdSchema.optional(),
|
id: PluginIdSchema.optional(),
|
||||||
ref: z.string().min(1).optional(),
|
ref: z.string().min(1).optional(),
|
||||||
|
// COMPAT(plugin-source-path): accepted for v0.7 clients; remove after 2027-09-01.
|
||||||
pluginPath: z.string().min(1).optional(),
|
pluginPath: z.string().min(1).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3011,6 +3033,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
HubManagementDaemonConnectRequestSchema,
|
HubManagementDaemonConnectRequestSchema,
|
||||||
HubManagementDaemonGetStatusRequestSchema,
|
HubManagementDaemonGetStatusRequestSchema,
|
||||||
HubManagementDaemonDisconnectRequestSchema,
|
HubManagementDaemonDisconnectRequestSchema,
|
||||||
|
HubManagementDaemonPermissionsUpdateRequestSchema,
|
||||||
DiagnosticsRequestSchema,
|
DiagnosticsRequestSchema,
|
||||||
PluginCatalogGetRequestSchema,
|
PluginCatalogGetRequestSchema,
|
||||||
PluginListRequestSchema,
|
PluginListRequestSchema,
|
||||||
@@ -3325,6 +3348,8 @@ export const ServerInfoStatusPayloadSchema = z
|
|||||||
serverId: z.string().trim().min(1),
|
serverId: z.string().trim().min(1),
|
||||||
hostname: ServerInfoHostnameSchema.optional(),
|
hostname: ServerInfoHostnameSchema.optional(),
|
||||||
version: ServerInfoVersionSchema.optional(),
|
version: ServerInfoVersionSchema.optional(),
|
||||||
|
// COMPAT(sessionPermissions): optional while clients support older daemons.
|
||||||
|
permissions: z.array(DaemonPermissionSchema).optional(),
|
||||||
// COMPAT(desktopManaged): added in v0.1.X, remove optional parsing after 2027-01-16.
|
// COMPAT(desktopManaged): added in v0.1.X, remove optional parsing after 2027-01-16.
|
||||||
desktopManaged: z.boolean().optional(),
|
desktopManaged: z.boolean().optional(),
|
||||||
capabilities: ServerCapabilitiesFromUnknownSchema.optional(),
|
capabilities: ServerCapabilitiesFromUnknownSchema.optional(),
|
||||||
@@ -4599,7 +4624,7 @@ export const HubRelationshipStatusSchema = z.object({
|
|||||||
]),
|
]),
|
||||||
daemonId: z.string().nullable(),
|
daemonId: z.string().nullable(),
|
||||||
hubOrigin: z.string().nullable(),
|
hubOrigin: z.string().nullable(),
|
||||||
scopes: z.array(z.string()),
|
permissions: z.array(DaemonPermissionSchema),
|
||||||
connectedAt: z.string().nullable(),
|
connectedAt: z.string().nullable(),
|
||||||
lastError: z.string().nullable(),
|
lastError: z.string().nullable(),
|
||||||
});
|
});
|
||||||
@@ -4619,6 +4644,10 @@ export const HubManagementDaemonDisconnectResponseSchema = z.object({
|
|||||||
warning: z.string().optional(),
|
warning: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
export const HubManagementDaemonPermissionsUpdateResponseSchema = z.object({
|
||||||
|
type: z.literal("hub.management.daemon.permissions.update.response"),
|
||||||
|
payload: z.object({ requestId: z.string(), status: HubRelationshipStatusSchema }),
|
||||||
|
});
|
||||||
|
|
||||||
export const DaemonGetPairingOfferResponseSchema = z.object({
|
export const DaemonGetPairingOfferResponseSchema = z.object({
|
||||||
type: z.literal("daemon.get_pairing_offer.response"),
|
type: z.literal("daemon.get_pairing_offer.response"),
|
||||||
@@ -6362,6 +6391,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
HubManagementDaemonConnectResponseSchema,
|
HubManagementDaemonConnectResponseSchema,
|
||||||
HubManagementDaemonGetStatusResponseSchema,
|
HubManagementDaemonGetStatusResponseSchema,
|
||||||
HubManagementDaemonDisconnectResponseSchema,
|
HubManagementDaemonDisconnectResponseSchema,
|
||||||
|
HubManagementDaemonPermissionsUpdateResponseSchema,
|
||||||
DiagnosticsResponseSchema,
|
DiagnosticsResponseSchema,
|
||||||
GetDaemonConfigResponseMessageSchema,
|
GetDaemonConfigResponseMessageSchema,
|
||||||
SetDaemonConfigResponseMessageSchema,
|
SetDaemonConfigResponseMessageSchema,
|
||||||
@@ -6936,7 +6966,7 @@ export const WSPongMessageSchema = z.object({
|
|||||||
export const WSHelloMessageSchema = z.object({
|
export const WSHelloMessageSchema = z.object({
|
||||||
type: z.literal("hello"),
|
type: z.literal("hello"),
|
||||||
clientId: z.string().min(1),
|
clientId: z.string().min(1),
|
||||||
clientType: z.enum(["mobile", "browser", "cli", "mcp"]),
|
clientType: z.enum(["mobile", "browser", "cli", "mcp", "hub"]),
|
||||||
protocolVersion: z.number().int(),
|
protocolVersion: z.number().int(),
|
||||||
appVersion: z.string().optional(),
|
appVersion: z.string().optional(),
|
||||||
capabilities: z
|
capabilities: z
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { parsePluginSourceReference } from "./plugin-source-reference.js";
|
||||||
|
|
||||||
|
describe("plugin source references", () => {
|
||||||
|
it.each([
|
||||||
|
["owner/repository", "owner/repository", undefined],
|
||||||
|
["owner/repository:plugins/review", "owner/repository", "plugins/review"],
|
||||||
|
[
|
||||||
|
"https://example.test:8443/owner/repository.git",
|
||||||
|
"https://example.test:8443/owner/repository.git",
|
||||||
|
undefined,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"https://example.test:8443/owner/repository.git:plugins/review",
|
||||||
|
"https://example.test:8443/owner/repository.git",
|
||||||
|
"plugins/review",
|
||||||
|
],
|
||||||
|
["git@example.test:owner/repository.git", "git@example.test:owner/repository.git", undefined],
|
||||||
|
[
|
||||||
|
"git@example.test:owner/repository.git:plugins/review",
|
||||||
|
"git@example.test:owner/repository.git",
|
||||||
|
"plugins/review",
|
||||||
|
],
|
||||||
|
["file:///D:/plugins/repository", "file:///D:/plugins/repository", undefined],
|
||||||
|
[
|
||||||
|
"file:///D:/plugins/repository:plugins/review",
|
||||||
|
"file:///D:/plugins/repository",
|
||||||
|
"plugins/review",
|
||||||
|
],
|
||||||
|
["D:\\plugins\\repository", "D:\\plugins\\repository", undefined],
|
||||||
|
["D:\\plugins\\repository:plugins/review", "D:\\plugins\\repository", "plugins/review"],
|
||||||
|
])("parses %s", (reference, source, pluginPath) => {
|
||||||
|
expect(parsePluginSourceReference(reference)).toEqual({ source, pluginPath });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
export interface PluginSourceReference {
|
||||||
|
source: string;
|
||||||
|
pluginPath: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePluginSourceReference(reference: string): PluginSourceReference {
|
||||||
|
const separator = reference.lastIndexOf(":");
|
||||||
|
if (separator === -1) return { source: reference, pluginPath: undefined };
|
||||||
|
|
||||||
|
const pluginPath = reference.slice(separator + 1);
|
||||||
|
if (!isPortableRelativePluginPath(pluginPath)) {
|
||||||
|
return { source: reference, pluginPath: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheme = reference.indexOf("://");
|
||||||
|
if (scheme !== -1) {
|
||||||
|
const pathStart = reference.indexOf("/", scheme + 3);
|
||||||
|
if (pathStart === -1 || separator < pathStart) {
|
||||||
|
return { source: reference, pluginPath: undefined };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const scpSeparator = reference.match(/^[^/@\s]+@[^:\s]+:/)?.[0].length;
|
||||||
|
if (scpSeparator !== undefined && separator === scpSeparator - 1) {
|
||||||
|
return { source: reference, pluginPath: undefined };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { source: reference.slice(0, separator), pluginPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPluginSourceReference(
|
||||||
|
source: string,
|
||||||
|
pluginPath: string | undefined,
|
||||||
|
): string {
|
||||||
|
if (pluginPath === undefined) return source;
|
||||||
|
if (!isPortableRelativePluginPath(pluginPath)) {
|
||||||
|
throw new Error("Plugin path must be a portable path relative to the repository");
|
||||||
|
}
|
||||||
|
return `${source}:${pluginPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPortableRelativePluginPath(pluginPath: string): boolean {
|
||||||
|
if (pluginPath === ".") return true;
|
||||||
|
if (!pluginPath || pluginPath.startsWith("/") || pluginPath.startsWith("\\")) return false;
|
||||||
|
if (/^[A-Za-z]:/.test(pluginPath) || pluginPath.includes(":")) return false;
|
||||||
|
return pluginPath.split(/[\\/]/).every((part) => part !== "" && part !== "." && part !== "..");
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/relay",
|
"name": "@getpaseo/relay",
|
||||||
"version": "0.7.0-beta.1",
|
"version": "0.7.0",
|
||||||
"description": "Paseo relay for bridging daemon and client connections",
|
"description": "Paseo relay for bridging daemon and client connections",
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/server",
|
"name": "@getpaseo/server",
|
||||||
"version": "0.7.0-beta.1",
|
"version": "0.7.0",
|
||||||
"description": "Paseo backend server",
|
"description": "Paseo backend server",
|
||||||
"files": [
|
"files": [
|
||||||
"dist/server",
|
"dist/server",
|
||||||
@@ -72,11 +72,11 @@
|
|||||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||||
"@anthropic-ai/sdk": "^0.104.2",
|
"@anthropic-ai/sdk": "^0.104.2",
|
||||||
"@babel/parser": "^7.29.8",
|
"@babel/parser": "^7.29.8",
|
||||||
"@getpaseo/client": "0.7.0-beta.1",
|
"@getpaseo/client": "0.7.0",
|
||||||
"@getpaseo/highlight": "0.7.0-beta.1",
|
"@getpaseo/highlight": "0.7.0",
|
||||||
"@getpaseo/plugin": "0.7.0-beta.1",
|
"@getpaseo/plugin": "0.7.0",
|
||||||
"@getpaseo/protocol": "0.7.0-beta.1",
|
"@getpaseo/protocol": "0.7.0",
|
||||||
"@getpaseo/relay": "0.7.0-beta.1",
|
"@getpaseo/relay": "0.7.0",
|
||||||
"@isaacs/ttlcache": "^2.1.4",
|
"@isaacs/ttlcache": "^2.1.4",
|
||||||
"@modelcontextprotocol/sdk": "1.29.0",
|
"@modelcontextprotocol/sdk": "1.29.0",
|
||||||
"@opencode-ai/sdk": "1.14.46",
|
"@opencode-ai/sdk": "1.14.46",
|
||||||
|
|||||||
@@ -129,9 +129,9 @@ second line'`,
|
|||||||
|
|
||||||
const result = curateAgentActivity(timeline);
|
const result = curateAgentActivity(timeline);
|
||||||
|
|
||||||
expect(result).toContain("[Exec Command]");
|
expect(result).toContain("[Exec command]");
|
||||||
expect(result).toContain("[Read File]");
|
expect(result).toContain("[Read file]");
|
||||||
expect(result).toContain("[Web Search]");
|
expect(result).toContain("[Web search]");
|
||||||
expect(result).not.toContain("npm run lint");
|
expect(result).not.toContain("npm run lint");
|
||||||
expect(result).not.toContain("src/index.ts");
|
expect(result).not.toContain("src/index.ts");
|
||||||
expect(result).not.toContain("zod union");
|
expect(result).not.toContain("zod union");
|
||||||
|
|||||||
@@ -5543,6 +5543,88 @@ test("waitForAgentRunStart resolves while a foreground run is still only pending
|
|||||||
expect(manager.getAgent(snapshot.id)?.lifecycle).toBe("idle");
|
expect(manager.getAgent(snapshot.id)?.lifecycle).toBe("idle");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("waitForAgentRunStart ignores a prior turn error while the next run starts", async () => {
|
||||||
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-error-resume-"));
|
||||||
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||||
|
const retryStartEntered = deferred<void>();
|
||||||
|
const releaseRetryStart = deferred<void>();
|
||||||
|
|
||||||
|
class ErrorThenResumeSession extends TestAgentSession {
|
||||||
|
override async startTurn(): Promise<{ turnId: string }> {
|
||||||
|
const turnId = `turn-${++this.turnIdCounter}`;
|
||||||
|
if (this.turnIdCounter === 1) {
|
||||||
|
void (async () => {
|
||||||
|
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
|
||||||
|
this.pushEvent({
|
||||||
|
type: "turn_failed",
|
||||||
|
provider: this.provider,
|
||||||
|
turnId,
|
||||||
|
error: "model at capacity",
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
return { turnId };
|
||||||
|
}
|
||||||
|
|
||||||
|
retryStartEntered.resolve();
|
||||||
|
await releaseRetryStart.promise;
|
||||||
|
void (async () => {
|
||||||
|
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
|
||||||
|
this.pushEvent({ type: "turn_completed", provider: this.provider, turnId });
|
||||||
|
})();
|
||||||
|
return { turnId };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ErrorThenResumeClient extends TestAgentClient {
|
||||||
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||||
|
return new ErrorThenResumeSession(config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const manager = new AgentManager({
|
||||||
|
clients: { codex: new ErrorThenResumeClient() },
|
||||||
|
registry: storage,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
let agentId: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||||
|
workspaceId: undefined,
|
||||||
|
});
|
||||||
|
agentId = snapshot.id;
|
||||||
|
|
||||||
|
await manager.runAgent(agentId, "fail").catch(() => undefined);
|
||||||
|
expect(manager.getAgent(agentId)?.lifecycle).toBe("error");
|
||||||
|
|
||||||
|
const dispatch = await startAgentRun(manager, agentId, "resume", logger);
|
||||||
|
expect(dispatch.disposition).toBe("turn_started");
|
||||||
|
const wait = manager.waitForAgentRunStart(agentId);
|
||||||
|
let earlyResult: "pending" | "resolved" | "rejected" = "pending";
|
||||||
|
void wait.then(
|
||||||
|
() => {
|
||||||
|
earlyResult = "resolved";
|
||||||
|
return earlyResult;
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
earlyResult = "rejected";
|
||||||
|
return earlyResult;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await retryStartEntered.promise;
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(earlyResult).toBe("pending");
|
||||||
|
releaseRetryStart.resolve();
|
||||||
|
await expect(wait).resolves.toBeUndefined();
|
||||||
|
await manager.waitForAgentEvent(agentId);
|
||||||
|
} finally {
|
||||||
|
releaseRetryStart.resolve();
|
||||||
|
if (agentId) await manager.closeAgent(agentId).catch(() => undefined);
|
||||||
|
rmSync(workdir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("replaceAgentRun does not emit idle or resolve waiters between interrupted and replacement runs", async () => {
|
test("replaceAgentRun does not emit idle or resolve waiters between interrupted and replacement runs", async () => {
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-replace-run-"));
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-replace-run-"));
|
||||||
const storagePath = join(workdir, "agents");
|
const storagePath = join(workdir, "agents");
|
||||||
|
|||||||
@@ -2165,6 +2165,7 @@ export class AgentManager {
|
|||||||
}
|
}
|
||||||
agent.pendingReplacement = false;
|
agent.pendingReplacement = false;
|
||||||
const errorMsg = error instanceof Error ? error.message : "Failed to start turn";
|
const errorMsg = error instanceof Error ? error.message : "Failed to start turn";
|
||||||
|
pendingRun.start = { status: "failed", error: errorMsg };
|
||||||
await this.handleStreamEvent(agent, {
|
await this.handleStreamEvent(agent, {
|
||||||
type: "turn_failed",
|
type: "turn_failed",
|
||||||
provider: agent.provider,
|
provider: agent.provider,
|
||||||
@@ -2232,8 +2233,7 @@ export class AgentManager {
|
|||||||
agent.pendingReplacement = false;
|
agent.pendingReplacement = false;
|
||||||
}
|
}
|
||||||
const turnStartedAt = new Date();
|
const turnStartedAt = new Date();
|
||||||
pendingRun.started = true;
|
pendingRun.start = { status: "started", turnId };
|
||||||
pendingRun.turnId = turnId;
|
|
||||||
agent.activeForegroundTurnId = turnId;
|
agent.activeForegroundTurnId = turnId;
|
||||||
this.openActiveTurn(agent, turnId, turnStartedAt);
|
this.openActiveTurn(agent, turnId, turnStartedAt);
|
||||||
agent.lifecycle = "running";
|
agent.lifecycle = "running";
|
||||||
@@ -2575,7 +2575,10 @@ export class AgentManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pendingRun = this.runs.getPendingRun(agentId);
|
const pendingRun = this.runs.getPendingRun(agentId);
|
||||||
if ((snapshot.lifecycle === "running" || pendingRun?.started) && !snapshot.pendingReplacement) {
|
if (
|
||||||
|
(snapshot.lifecycle === "running" || pendingRun?.start.status === "started") &&
|
||||||
|
!snapshot.pendingReplacement
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2640,14 +2643,19 @@ export class AgentManager {
|
|||||||
|
|
||||||
const currentPendingRun = this.runs.getPendingRun(agentId);
|
const currentPendingRun = this.runs.getPendingRun(agentId);
|
||||||
if (
|
if (
|
||||||
(current.lifecycle === "running" || currentPendingRun?.started) &&
|
(current.lifecycle === "running" || currentPendingRun?.start.status === "started") &&
|
||||||
!current.pendingReplacement
|
!current.pendingReplacement
|
||||||
) {
|
) {
|
||||||
finishOk();
|
finishOk();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (current.lifecycle === "error" && !currentPendingRun?.started) {
|
if (currentPendingRun?.start.status === "failed") {
|
||||||
|
finishErr(new Error(currentPendingRun.start.error));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.lifecycle === "error" && !currentPendingRun) {
|
||||||
finishErr(new Error(current.lastError ?? `Agent ${agentId} failed to start`));
|
finishErr(new Error(current.lastError ?? `Agent ${agentId} failed to start`));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -2734,16 +2742,17 @@ export class AgentManager {
|
|||||||
return { status: settlement === "completed" ? "settled" : "refused" };
|
return { status: settlement === "completed" ? "settled" : "refused" };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (settlement === "timed_out" && run.turnId) {
|
const runTurnId = this.runs.getTurnId(agentId);
|
||||||
|
if (settlement === "timed_out" && runTurnId) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
{ agentId, turnId: run.turnId, kind: run.kind },
|
{ agentId, turnId: runTurnId, kind: run.kind },
|
||||||
"cancelAgentRun: acknowledged turn still active after timeout, force-canceling",
|
"cancelAgentRun: acknowledged turn still active after timeout, force-canceling",
|
||||||
);
|
);
|
||||||
await this.dispatchSessionEvent(agent, {
|
await this.dispatchSessionEvent(agent, {
|
||||||
type: "turn_canceled",
|
type: "turn_canceled",
|
||||||
provider: agent.provider,
|
provider: agent.provider,
|
||||||
reason: "interrupted",
|
reason: "interrupted",
|
||||||
turnId: run.turnId,
|
turnId: runTurnId,
|
||||||
});
|
});
|
||||||
await run.settledPromise;
|
await run.settledPromise;
|
||||||
} else if (settlement === "timed_out" && run.kind === "foreground") {
|
} else if (settlement === "timed_out" && run.kind === "foreground") {
|
||||||
@@ -3023,7 +3032,7 @@ export class AgentManager {
|
|||||||
let hasStarted =
|
let hasStarted =
|
||||||
isAgentBusy(initialStatus) ||
|
isAgentBusy(initialStatus) ||
|
||||||
Boolean(snapshot.activeForegroundTurnId) ||
|
Boolean(snapshot.activeForegroundTurnId) ||
|
||||||
Boolean(pendingForegroundRun?.started);
|
pendingForegroundRun?.start.status === "started";
|
||||||
let terminalStatusOverride: AgentLifecycleStatus | null = null;
|
let terminalStatusOverride: AgentLifecycleStatus | null = null;
|
||||||
let finished = false;
|
let finished = false;
|
||||||
|
|
||||||
@@ -3429,7 +3438,7 @@ export class AgentManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const pendingRun = this.runs.getPendingRun(agentId);
|
const pendingRun = this.runs.getPendingRun(agentId);
|
||||||
if (pendingRun && !pendingRun.started) {
|
if (pendingRun?.start.status === "pending") {
|
||||||
pendingRun.stagedEvents.push(event);
|
pendingRun.stagedEvents.push(event);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ export interface PendingForegroundRun {
|
|||||||
token: string;
|
token: string;
|
||||||
kind: "foreground";
|
kind: "foreground";
|
||||||
stagedEvents: AgentStreamEvent[];
|
stagedEvents: AgentStreamEvent[];
|
||||||
turnId: string | null;
|
start:
|
||||||
started: boolean;
|
| { status: "pending" }
|
||||||
|
| { status: "started"; turnId: string }
|
||||||
|
| { status: "failed"; error: string };
|
||||||
settled: boolean;
|
settled: boolean;
|
||||||
settledPromise: Promise<void>;
|
settledPromise: Promise<void>;
|
||||||
resolveSettled: () => void;
|
resolveSettled: () => void;
|
||||||
@@ -64,6 +66,13 @@ export class AgentRunState {
|
|||||||
return this.runs.has(agentId);
|
return this.runs.has(agentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getTurnId(agentId: string): string | null {
|
||||||
|
const run = this.runs.get(agentId);
|
||||||
|
if (!run) return null;
|
||||||
|
if (run.kind === "autonomous") return run.turnId;
|
||||||
|
return run.start.status === "started" ? run.start.turnId : null;
|
||||||
|
}
|
||||||
|
|
||||||
trackAutonomousRun(agentId: string, turnId: string | null): TrackedAgentRun {
|
trackAutonomousRun(agentId: string, turnId: string | null): TrackedAgentRun {
|
||||||
const current = this.runs.get(agentId);
|
const current = this.runs.get(agentId);
|
||||||
if (current) {
|
if (current) {
|
||||||
@@ -85,7 +94,10 @@ export class AgentRunState {
|
|||||||
if (!run) {
|
if (!run) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (run.kind === "foreground" && (run.turnId === null || run.turnId !== turnId)) {
|
if (
|
||||||
|
run.kind === "foreground" &&
|
||||||
|
(run.start.status !== "started" || run.start.turnId !== turnId)
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@@ -268,8 +280,7 @@ function createPendingForegroundRun(): PendingForegroundRun {
|
|||||||
return {
|
return {
|
||||||
...createTrackedRunState(),
|
...createTrackedRunState(),
|
||||||
kind: "foreground",
|
kind: "foreground",
|
||||||
turnId: null,
|
start: { status: "pending" },
|
||||||
started: false,
|
|
||||||
stagedEvents: [],
|
stagedEvents: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,6 +104,10 @@ interface CollaborationModeRecord {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface CodexSessionTestAccess {
|
interface CodexSessionTestAccess {
|
||||||
|
codexUserMessageTurns(): {
|
||||||
|
resolve(messageId: string): { index: number; turnId: string | null } | null;
|
||||||
|
count(): number;
|
||||||
|
};
|
||||||
ensureThreadLoaded(): Promise<void>;
|
ensureThreadLoaded(): Promise<void>;
|
||||||
handleToolApprovalRequest(params: unknown): Promise<unknown>;
|
handleToolApprovalRequest(params: unknown): Promise<unknown>;
|
||||||
handleNotification(method: string, params: unknown): void;
|
handleNotification(method: string, params: unknown): void;
|
||||||
@@ -499,13 +503,14 @@ function markdownImageSource(markdown: string): string {
|
|||||||
|
|
||||||
function emitCodexUserMessage(
|
function emitCodexUserMessage(
|
||||||
appServer: FakeCodexAppServer,
|
appServer: FakeCodexAppServer,
|
||||||
input: { id: string; text: string; threadId?: string },
|
input: { id: string; text: string; threadId?: string; turnId?: string },
|
||||||
): void {
|
): void {
|
||||||
appServer.child.stdout.write(
|
appServer.child.stdout.write(
|
||||||
`${JSON.stringify({
|
`${JSON.stringify({
|
||||||
method: "item/started",
|
method: "item/started",
|
||||||
params: {
|
params: {
|
||||||
threadId: input.threadId ?? "thread-1",
|
threadId: input.threadId ?? "thread-1",
|
||||||
|
...(input.turnId ? { turnId: input.turnId } : {}),
|
||||||
item: {
|
item: {
|
||||||
type: "userMessage",
|
type: "userMessage",
|
||||||
id: input.id,
|
id: input.id,
|
||||||
@@ -1669,6 +1674,62 @@ describe("Codex app-server provider", () => {
|
|||||||
await session.close();
|
await session.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("rewinds a paginated conversation through the public session capability", async () => {
|
||||||
|
const appServer = createFakeCodexAppServer({
|
||||||
|
"thread/read": () => ({
|
||||||
|
thread: { id: "thread-1", historyMode: "paginated", turns: [] },
|
||||||
|
}),
|
||||||
|
"thread/rollback": () => {
|
||||||
|
throw new Error("paginated threads do not support thread/rollback");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const session = new CodexAppServerAgentSession(
|
||||||
|
createConfig({ cwd: "/workspace/project" }),
|
||||||
|
null,
|
||||||
|
createTestLogger(),
|
||||||
|
async () => appServer.child,
|
||||||
|
);
|
||||||
|
|
||||||
|
await session.startTurn("remember first");
|
||||||
|
emitCodexUserMessage(appServer, {
|
||||||
|
id: "codex-first",
|
||||||
|
text: "remember first",
|
||||||
|
turnId: "turn-first",
|
||||||
|
});
|
||||||
|
appServer.completeTurn();
|
||||||
|
await session.startTurn("remember second");
|
||||||
|
emitCodexUserMessage(appServer, {
|
||||||
|
id: "codex-second",
|
||||||
|
text: "remember second",
|
||||||
|
turnId: "turn-second",
|
||||||
|
});
|
||||||
|
appServer.completeTurn();
|
||||||
|
|
||||||
|
await session.revertConversation({ messageId: "codex-first" });
|
||||||
|
|
||||||
|
const forkRequests = appServer
|
||||||
|
.requests()
|
||||||
|
.filter((request) => request.method === "thread/fork")
|
||||||
|
.map((request) => request.params);
|
||||||
|
expect(forkRequests).toEqual([
|
||||||
|
{
|
||||||
|
threadId: "thread-1",
|
||||||
|
beforeTurnId: "turn-first",
|
||||||
|
cwd: "/workspace/project",
|
||||||
|
model: "gpt-5.4",
|
||||||
|
serviceTier: null,
|
||||||
|
excludeTurns: false,
|
||||||
|
persistExtendedHistory: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(appServer.recordedRollbacks).toEqual([]);
|
||||||
|
await expect(session.getRuntimeInfo()).resolves.toMatchObject({
|
||||||
|
sessionId: "forked-thread",
|
||||||
|
});
|
||||||
|
appServer.assertNoErrors();
|
||||||
|
await session.close();
|
||||||
|
});
|
||||||
|
|
||||||
test("correlates a Codex user message with the submitting client message", async () => {
|
test("correlates a Codex user message with the submitting client message", async () => {
|
||||||
const appServer = createFakeCodexAppServer();
|
const appServer = createFakeCodexAppServer();
|
||||||
const session = new CodexAppServerAgentSession(
|
const session = new CodexAppServerAgentSession(
|
||||||
@@ -3817,6 +3878,35 @@ describe("Codex app-server provider", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("retains native turn ids from persisted user messages", async () => {
|
||||||
|
const session = createSession();
|
||||||
|
session.client = {
|
||||||
|
request: vi.fn(async () => ({
|
||||||
|
thread: {
|
||||||
|
turns: [
|
||||||
|
{
|
||||||
|
id: "native-turn-1",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
type: "userMessage",
|
||||||
|
id: "message-history",
|
||||||
|
content: [{ type: "text", text: "History prompt" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
await asInternals(session).loadPersistedHistory();
|
||||||
|
|
||||||
|
expect(asInternals(session).codexUserMessageTurns().resolve("message-history")).toEqual({
|
||||||
|
index: 0,
|
||||||
|
turnId: "native-turn-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("loads mixed legacy and MultiAgentV2 sub-agent history", async () => {
|
test("loads mixed legacy and MultiAgentV2 sub-agent history", async () => {
|
||||||
const session = createSession();
|
const session = createSession();
|
||||||
session.client = {
|
session.client = {
|
||||||
|
|||||||
@@ -429,6 +429,7 @@ interface CodexConfiguredDefaults {
|
|||||||
interface PersistedTimelineEntry {
|
interface PersistedTimelineEntry {
|
||||||
item: AgentTimelineItem;
|
item: AgentTimelineItem;
|
||||||
timestamp?: string;
|
timestamp?: string;
|
||||||
|
providerTurnId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PersistedSubAgentRoute {
|
interface PersistedSubAgentRoute {
|
||||||
@@ -1990,6 +1991,9 @@ async function loadCodexThreadHistoryTimeline(params: {
|
|||||||
timeline.push({
|
timeline.push({
|
||||||
item: settledTimelineItem,
|
item: settledTimelineItem,
|
||||||
timestamp: timestamp ?? undefined,
|
timestamp: timestamp ?? undefined,
|
||||||
|
...(timelineItem.type === "user_message" && typeof turn.id === "string"
|
||||||
|
? { providerTurnId: turn.id }
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
for (const childThreadId of readCodexHistoricalSubAgentThreadIds(item)) {
|
for (const childThreadId of readCodexHistoricalSubAgentThreadIds(item)) {
|
||||||
subAgentTimelineIndexByThreadId.set(childThreadId, timeline.length - 1);
|
subAgentTimelineIndexByThreadId.set(childThreadId, timeline.length - 1);
|
||||||
@@ -2162,6 +2166,7 @@ const ItemTextDeltaNotificationSchema = z
|
|||||||
const ItemLifecycleNotificationSchema = z
|
const ItemLifecycleNotificationSchema = z
|
||||||
.object({
|
.object({
|
||||||
threadId: z.string().optional(),
|
threadId: z.string().optional(),
|
||||||
|
turnId: z.string().optional(),
|
||||||
item: z
|
item: z
|
||||||
.object({
|
.object({
|
||||||
id: z.string().optional(),
|
id: z.string().optional(),
|
||||||
@@ -2183,6 +2188,11 @@ const CodexEventThreadIdFields = {
|
|||||||
thread_id: z.string().optional(),
|
thread_id: z.string().optional(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const CodexEventTurnIdFields = {
|
||||||
|
turnId: z.string().optional(),
|
||||||
|
turn_id: z.string().optional(),
|
||||||
|
};
|
||||||
|
|
||||||
function getCodexEventThreadId(params: {
|
function getCodexEventThreadId(params: {
|
||||||
threadId?: string;
|
threadId?: string;
|
||||||
thread_id?: string;
|
thread_id?: string;
|
||||||
@@ -2191,6 +2201,14 @@ function getCodexEventThreadId(params: {
|
|||||||
return params.threadId ?? params.thread_id ?? params.msg.threadId ?? params.msg.thread_id ?? null;
|
return params.threadId ?? params.thread_id ?? params.msg.threadId ?? params.msg.thread_id ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getCodexEventTurnId(params: {
|
||||||
|
turnId?: string;
|
||||||
|
turn_id?: string;
|
||||||
|
msg: { turnId?: string; turn_id?: string };
|
||||||
|
}): string | null {
|
||||||
|
return params.turnId ?? params.turn_id ?? params.msg.turnId ?? params.msg.turn_id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
const CodexEventTurnAbortedNotificationSchema = z
|
const CodexEventTurnAbortedNotificationSchema = z
|
||||||
.object({
|
.object({
|
||||||
...CodexEventThreadIdFields,
|
...CodexEventThreadIdFields,
|
||||||
@@ -2219,9 +2237,11 @@ const CodexEventTaskCompleteNotificationSchema = z
|
|||||||
const CodexEventItemLifecycleNotificationSchema = z
|
const CodexEventItemLifecycleNotificationSchema = z
|
||||||
.object({
|
.object({
|
||||||
...CodexEventThreadIdFields,
|
...CodexEventThreadIdFields,
|
||||||
|
...CodexEventTurnIdFields,
|
||||||
msg: z
|
msg: z
|
||||||
.object({
|
.object({
|
||||||
...CodexEventThreadIdFields,
|
...CodexEventThreadIdFields,
|
||||||
|
...CodexEventTurnIdFields,
|
||||||
type: z.enum(["item_started", "item_completed"]),
|
type: z.enum(["item_started", "item_completed"]),
|
||||||
item: z
|
item: z
|
||||||
.object({
|
.object({
|
||||||
@@ -2402,12 +2422,14 @@ type ParsedCodexNotification =
|
|||||||
kind: "item_completed";
|
kind: "item_completed";
|
||||||
source: "item" | "codex_event";
|
source: "item" | "codex_event";
|
||||||
threadId: string | null;
|
threadId: string | null;
|
||||||
|
turnId: string | null;
|
||||||
item: { id?: string; type?: string; [key: string]: unknown };
|
item: { id?: string; type?: string; [key: string]: unknown };
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
kind: "item_started";
|
kind: "item_started";
|
||||||
source: "item" | "codex_event";
|
source: "item" | "codex_event";
|
||||||
threadId: string | null;
|
threadId: string | null;
|
||||||
|
turnId: string | null;
|
||||||
item: { id?: string; type?: string; [key: string]: unknown };
|
item: { id?: string; type?: string; [key: string]: unknown };
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
@@ -2664,6 +2686,7 @@ const CodexNotificationSchema = z.union([
|
|||||||
kind: "item_completed",
|
kind: "item_completed",
|
||||||
source: "item",
|
source: "item",
|
||||||
threadId: params.threadId ?? null,
|
threadId: params.threadId ?? null,
|
||||||
|
turnId: params.turnId ?? null,
|
||||||
item: params.item,
|
item: params.item,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -2681,6 +2704,7 @@ const CodexNotificationSchema = z.union([
|
|||||||
kind: "item_started",
|
kind: "item_started",
|
||||||
source: "item",
|
source: "item",
|
||||||
threadId: params.threadId ?? null,
|
threadId: params.threadId ?? null,
|
||||||
|
turnId: params.turnId ?? null,
|
||||||
item: params.item,
|
item: params.item,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -2701,6 +2725,7 @@ const CodexNotificationSchema = z.union([
|
|||||||
kind: "item_started",
|
kind: "item_started",
|
||||||
source: "codex_event",
|
source: "codex_event",
|
||||||
threadId: getCodexEventThreadId(params),
|
threadId: getCodexEventThreadId(params),
|
||||||
|
turnId: getCodexEventTurnId(params),
|
||||||
item: params.msg.item,
|
item: params.msg.item,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -2721,6 +2746,7 @@ const CodexNotificationSchema = z.union([
|
|||||||
kind: "item_completed",
|
kind: "item_completed",
|
||||||
source: "codex_event",
|
source: "codex_event",
|
||||||
threadId: getCodexEventThreadId(params),
|
threadId: getCodexEventThreadId(params),
|
||||||
|
turnId: getCodexEventTurnId(params),
|
||||||
item: params.msg.item,
|
item: params.msg.item,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -3317,6 +3343,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
private latestPlanResult: { callId: string; text: string; turnId: string | null } | null = null;
|
private latestPlanResult: { callId: string; text: string; turnId: string | null } | null = null;
|
||||||
private readonly userMessageTurnIndexes = new Map<string, number>();
|
private readonly userMessageTurnIndexes = new Map<string, number>();
|
||||||
private readonly userMessageTurnIds: string[] = [];
|
private readonly userMessageTurnIds: string[] = [];
|
||||||
|
private readonly userMessageProviderTurnIds = new Map<string, string>();
|
||||||
private pendingManualCompactionStarts = 0;
|
private pendingManualCompactionStarts = 0;
|
||||||
private compactionTriggerByItemId = new Map<string, "auto" | "manual">();
|
private compactionTriggerByItemId = new Map<string, "auto" | "manual">();
|
||||||
private pendingRootCompactionItemIds = new Set<string>();
|
private pendingRootCompactionItemIds = new Set<string>();
|
||||||
@@ -3761,7 +3788,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
this.resetCodexUserMessageTurns();
|
this.resetCodexUserMessageTurns();
|
||||||
for (const entry of timeline) {
|
for (const entry of timeline) {
|
||||||
if (entry.item.type === "user_message") {
|
if (entry.item.type === "user_message") {
|
||||||
this.rememberCodexUserMessageTurn(entry.item.messageId);
|
this.rememberCodexUserMessageTurn(entry.item.messageId, entry.providerTurnId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.persistedHistory = timeline;
|
this.persistedHistory = timeline;
|
||||||
@@ -4250,28 +4277,42 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private rememberCodexUserMessageTurn(messageId: string | null | undefined): boolean {
|
private rememberCodexUserMessageTurn(
|
||||||
|
messageId: string | null | undefined,
|
||||||
|
providerTurnId?: string | null,
|
||||||
|
): boolean {
|
||||||
if (typeof messageId !== "string" || messageId.length === 0) {
|
if (typeof messageId !== "string" || messageId.length === 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (this.userMessageTurnIndexes.has(messageId)) {
|
if (this.userMessageTurnIndexes.has(messageId)) {
|
||||||
|
if (providerTurnId) {
|
||||||
|
this.userMessageProviderTurnIds.set(messageId, providerTurnId);
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
this.userMessageTurnIndexes.set(messageId, this.userMessageTurnIds.length);
|
this.userMessageTurnIndexes.set(messageId, this.userMessageTurnIds.length);
|
||||||
this.userMessageTurnIds.push(messageId);
|
this.userMessageTurnIds.push(messageId);
|
||||||
|
if (providerTurnId) {
|
||||||
|
this.userMessageProviderTurnIds.set(messageId, providerTurnId);
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private resetCodexUserMessageTurns(): void {
|
private resetCodexUserMessageTurns(): void {
|
||||||
this.userMessageTurnIndexes.clear();
|
this.userMessageTurnIndexes.clear();
|
||||||
this.userMessageTurnIds.length = 0;
|
this.userMessageTurnIds.length = 0;
|
||||||
|
this.userMessageProviderTurnIds.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
private truncateCodexUserMessageTurns(numTurns: number): void {
|
private truncateCodexUserMessageTurns(numTurns: number): void {
|
||||||
if (numTurns <= 0) {
|
if (numTurns <= 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.userMessageTurnIds.length = Math.max(0, this.userMessageTurnIds.length - numTurns);
|
const retainedCount = Math.max(0, this.userMessageTurnIds.length - numTurns);
|
||||||
|
const removedMessageIds = this.userMessageTurnIds.splice(retainedCount);
|
||||||
|
for (const messageId of removedMessageIds) {
|
||||||
|
this.userMessageProviderTurnIds.delete(messageId);
|
||||||
|
}
|
||||||
this.userMessageTurnIndexes.clear();
|
this.userMessageTurnIndexes.clear();
|
||||||
this.userMessageTurnIds.forEach((messageId, index) => {
|
this.userMessageTurnIds.forEach((messageId, index) => {
|
||||||
this.userMessageTurnIndexes.set(messageId, index);
|
this.userMessageTurnIndexes.set(messageId, index);
|
||||||
@@ -4280,7 +4321,12 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
|
|
||||||
private codexUserMessageTurns(): CodexUserMessageTurnIndex {
|
private codexUserMessageTurns(): CodexUserMessageTurnIndex {
|
||||||
return {
|
return {
|
||||||
resolve: (messageId) => this.userMessageTurnIndexes.get(messageId) ?? null,
|
resolve: (messageId) => {
|
||||||
|
const index = this.userMessageTurnIndexes.get(messageId);
|
||||||
|
return index === undefined
|
||||||
|
? null
|
||||||
|
: { index, turnId: this.userMessageProviderTurnIds.get(messageId) ?? null };
|
||||||
|
},
|
||||||
count: () => this.userMessageTurnIds.length,
|
count: () => this.userMessageTurnIds.length,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -6442,7 +6488,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
this.emitSubAgentActivityUpdate(childSubAgentCallId, "running");
|
this.emitSubAgentActivityUpdate(childSubAgentCallId, "running");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!this.rememberCodexUserMessageTurn(timelineItem.messageId)) {
|
if (!this.rememberCodexUserMessageTurn(timelineItem.messageId, parsed.turnId)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const clientMessageId = timelineItem.clientMessageId ?? this.activeClientMessageId;
|
const clientMessageId = timelineItem.clientMessageId ?? this.activeClientMessageId;
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ type UnexpectedTerminationHandler = (error: Error) => void;
|
|||||||
|
|
||||||
export interface CodexThreadForkParams {
|
export interface CodexThreadForkParams {
|
||||||
threadId: string;
|
threadId: string;
|
||||||
|
beforeTurnId?: string | null;
|
||||||
path?: string | null;
|
path?: string | null;
|
||||||
model?: string | null;
|
model?: string | null;
|
||||||
modelProvider?: string | null;
|
modelProvider?: string | null;
|
||||||
|
|||||||
@@ -51,16 +51,25 @@ class FakeCodex implements CodexRewindClient {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
request(): Promise<unknown> {
|
request(method: string): Promise<unknown> {
|
||||||
throw new Error("FakeCodex uses typed thread methods");
|
if (method === "thread/read") {
|
||||||
|
return Promise.resolve({ thread: { id: "source-thread", historyMode: "legacy" } });
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected request: ${method}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class CodexMessageTurns implements CodexUserMessageTurnIndex {
|
class CodexMessageTurns implements CodexUserMessageTurnIndex {
|
||||||
constructor(private readonly indexesByMessageId: Map<string, number>) {}
|
constructor(
|
||||||
|
private readonly indexesByMessageId: Map<string, number>,
|
||||||
|
private readonly turnIdsByMessageId: Map<string, string> = new Map(),
|
||||||
|
) {}
|
||||||
|
|
||||||
resolve(messageId: string): number | null {
|
resolve(messageId: string): { index: number; turnId: string | null } | null {
|
||||||
return this.indexesByMessageId.get(messageId) ?? null;
|
const index = this.indexesByMessageId.get(messageId);
|
||||||
|
return index === undefined
|
||||||
|
? null
|
||||||
|
: { index, turnId: this.turnIdsByMessageId.get(messageId) ?? null };
|
||||||
}
|
}
|
||||||
|
|
||||||
count(): number {
|
count(): number {
|
||||||
@@ -131,6 +140,95 @@ describe("Codex Rewind", () => {
|
|||||||
expect(reboundThreadId).toBe("forked-thread");
|
expect(reboundThreadId).toBe("forked-thread");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("rewinds a paginated conversation with one bounded fork and no rollback", async () => {
|
||||||
|
class PaginatedCodex extends FakeCodex {
|
||||||
|
override request(method: string): Promise<unknown> {
|
||||||
|
if (method === "thread/read") {
|
||||||
|
return Promise.resolve({ thread: { id: "source-thread", historyMode: "paginated" } });
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected request: ${method}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
override rollbackThread(): Promise<CodexThreadRollbackResponse> {
|
||||||
|
throw new Error("paginated threads do not support thread/rollback");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const codex = new PaginatedCodex();
|
||||||
|
const userMessageTurns = new CodexMessageTurns(
|
||||||
|
new Map([
|
||||||
|
["codex-first", 0],
|
||||||
|
["codex-second", 1],
|
||||||
|
]),
|
||||||
|
new Map([
|
||||||
|
["codex-first", "turn-first"],
|
||||||
|
["codex-second", "turn-second"],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
let reboundThreadId: string | null = null;
|
||||||
|
|
||||||
|
await revertCodexConversation({
|
||||||
|
client: codex,
|
||||||
|
threadId: "source-thread",
|
||||||
|
messageId: "codex-first",
|
||||||
|
cwd: "/workspace/project",
|
||||||
|
model: "gpt-5.4-mini",
|
||||||
|
serviceTier: null,
|
||||||
|
userMessageTurns,
|
||||||
|
setThreadId: (threadId) => {
|
||||||
|
reboundThreadId = threadId;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(codex.recordedForks).toEqual([
|
||||||
|
{
|
||||||
|
threadId: "source-thread",
|
||||||
|
beforeTurnId: "turn-first",
|
||||||
|
cwd: "/workspace/project",
|
||||||
|
model: "gpt-5.4-mini",
|
||||||
|
serviceTier: null,
|
||||||
|
excludeTurns: false,
|
||||||
|
persistExtendedHistory: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(codex.recordedRollbacks).toEqual([]);
|
||||||
|
expect(reboundThreadId).toBe("forked-thread");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not fork a paginated thread when the target turn id is unavailable", async () => {
|
||||||
|
class PaginatedCodex extends FakeCodex {
|
||||||
|
override request(method: string): Promise<unknown> {
|
||||||
|
if (method === "thread/read") {
|
||||||
|
return Promise.resolve({ thread: { id: "source-thread", historyMode: "paginated" } });
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected request: ${method}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const codex = new PaginatedCodex();
|
||||||
|
const userMessageTurns = new CodexMessageTurns(new Map([["codex-first", 0]]));
|
||||||
|
let reboundThreadId: string | null = null;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
revertCodexConversation({
|
||||||
|
client: codex,
|
||||||
|
threadId: "source-thread",
|
||||||
|
messageId: "codex-first",
|
||||||
|
cwd: "/workspace/project",
|
||||||
|
model: "gpt-5.4-mini",
|
||||||
|
serviceTier: null,
|
||||||
|
userMessageTurns,
|
||||||
|
setThreadId: (threadId) => {
|
||||||
|
reboundThreadId = threadId;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("Codex could not find the turn containing user message codex-first");
|
||||||
|
|
||||||
|
expect(codex.recordedForks).toEqual([]);
|
||||||
|
expect(codex.recordedRollbacks).toEqual([]);
|
||||||
|
expect(reboundThreadId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
test("declines to rewind when the user message is not in the Codex thread", async () => {
|
test("declines to rewind when the user message is not in the Codex thread", async () => {
|
||||||
const codex = new FakeCodex();
|
const codex = new FakeCodex();
|
||||||
const userMessageTurns = new CodexMessageTurns(new Map([["codex-first", 0]]));
|
const userMessageTurns = new CodexMessageTurns(new Map([["codex-first", 0]]));
|
||||||
|
|||||||
@@ -16,10 +16,30 @@ export interface CodexRewindClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CodexUserMessageTurnIndex {
|
export interface CodexUserMessageTurnIndex {
|
||||||
resolve(messageId: string): number | null;
|
resolve(messageId: string): { index: number; turnId: string | null } | null;
|
||||||
count(): number;
|
count(): number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CodexThreadHistoryMode = "legacy" | "paginated";
|
||||||
|
|
||||||
|
async function readCodexThreadHistoryMode(
|
||||||
|
client: CodexRewindClient,
|
||||||
|
threadId: string,
|
||||||
|
): Promise<CodexThreadHistoryMode> {
|
||||||
|
const response = await client.request("thread/read", { threadId, includeTurns: false });
|
||||||
|
if (typeof response !== "object" || response === null || !("thread" in response)) {
|
||||||
|
throw new Error("Codex thread/read did not return thread metadata");
|
||||||
|
}
|
||||||
|
const thread = response.thread;
|
||||||
|
if (typeof thread !== "object" || thread === null || !("historyMode" in thread)) {
|
||||||
|
return "legacy";
|
||||||
|
}
|
||||||
|
if (thread.historyMode === "legacy" || thread.historyMode === "paginated") {
|
||||||
|
return thread.historyMode;
|
||||||
|
}
|
||||||
|
throw new Error(`Codex thread/read returned unknown history mode ${String(thread.historyMode)}`);
|
||||||
|
}
|
||||||
|
|
||||||
async function forkCodexThread(
|
async function forkCodexThread(
|
||||||
client: CodexRewindClient,
|
client: CodexRewindClient,
|
||||||
params: CodexThreadForkParams,
|
params: CodexThreadForkParams,
|
||||||
@@ -54,17 +74,35 @@ export async function revertCodexConversation(input: {
|
|||||||
throw new Error("Codex thread is not ready for rewind");
|
throw new Error("Codex thread is not ready for rewind");
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetTurnIndex = input.userMessageTurns.resolve(input.messageId);
|
const targetTurn = input.userMessageTurns.resolve(input.messageId);
|
||||||
if (targetTurnIndex === null) {
|
if (targetTurn === null) {
|
||||||
throw new Error(`Codex could not find user message ${input.messageId} in the current thread`);
|
throw new Error(`Codex could not find user message ${input.messageId} in the current thread`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentUserTurnCount = input.userMessageTurns.count();
|
const currentUserTurnCount = input.userMessageTurns.count();
|
||||||
const numTurns = currentUserTurnCount - targetTurnIndex;
|
const numTurns = currentUserTurnCount - targetTurn.index;
|
||||||
if (numTurns < 0) {
|
if (numTurns < 0) {
|
||||||
throw new Error(`Codex user message ${input.messageId} is outside the current thread`);
|
throw new Error(`Codex user message ${input.messageId} is outside the current thread`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const historyMode = await readCodexThreadHistoryMode(input.client, input.threadId);
|
||||||
|
if (historyMode === "paginated") {
|
||||||
|
if (!targetTurn.turnId) {
|
||||||
|
throw new Error(`Codex could not find the turn containing user message ${input.messageId}`);
|
||||||
|
}
|
||||||
|
const forked = await forkCodexThread(input.client, {
|
||||||
|
threadId: input.threadId,
|
||||||
|
beforeTurnId: targetTurn.turnId,
|
||||||
|
cwd: input.cwd ?? null,
|
||||||
|
model: input.model ?? null,
|
||||||
|
serviceTier: input.serviceTier ?? null,
|
||||||
|
excludeTurns: false,
|
||||||
|
persistExtendedHistory: true,
|
||||||
|
});
|
||||||
|
await input.setThreadId(forked.thread.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Fork is non-destructive: the old thread file stays on disk and remains
|
// Fork is non-destructive: the old thread file stays on disk and remains
|
||||||
// recoverable with `codex resume <old-uuid>` if the rewind target was wrong.
|
// recoverable with `codex resume <old-uuid>` if the rewind target was wrong.
|
||||||
const forked = await forkCodexThread(input.client, {
|
const forked = await forkCodexThread(input.client, {
|
||||||
|
|||||||
@@ -54,6 +54,14 @@ readline.createInterface({ input: process.stdin, crlfDelay: Infinity }).on("line
|
|||||||
});
|
});
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const CLOSED_STDIN_CHILD_SOURCE = String.raw`
|
||||||
|
const fs = require("node:fs");
|
||||||
|
|
||||||
|
fs.closeSync(0);
|
||||||
|
process.stdout.write(JSON.stringify({ type: "stdin_closed" }) + "\n");
|
||||||
|
setInterval(() => {}, 1_000);
|
||||||
|
`;
|
||||||
|
|
||||||
interface InMemoryChildProcess extends ChildProcessWithoutNullStreams {
|
interface InMemoryChildProcess extends ChildProcessWithoutNullStreams {
|
||||||
stdin: PassThrough;
|
stdin: PassThrough;
|
||||||
stdout: PassThrough;
|
stdout: PassThrough;
|
||||||
@@ -63,6 +71,7 @@ interface InMemoryChildProcess extends ChildProcessWithoutNullStreams {
|
|||||||
interface StartProcessOptions {
|
interface StartProcessOptions {
|
||||||
child?: ChildProcessWithoutNullStreams;
|
child?: ChildProcessWithoutNullStreams;
|
||||||
defaultRequestTimeoutMs?: number;
|
defaultRequestTimeoutMs?: number;
|
||||||
|
source?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createInMemoryChildProcess(): InMemoryChildProcess {
|
function createInMemoryChildProcess(): InMemoryChildProcess {
|
||||||
@@ -85,7 +94,7 @@ function startProcess(options: StartProcessOptions = {}): JsonlRpcProcess {
|
|||||||
return new JsonlRpcProcess({
|
return new JsonlRpcProcess({
|
||||||
launch: {
|
launch: {
|
||||||
command: process.execPath,
|
command: process.execPath,
|
||||||
args: ["-e", CHILD_SOURCE, "--", "resolved-arg"],
|
args: ["-e", options.source ?? CHILD_SOURCE, "--", "resolved-arg"],
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
env: { JSONL_RPC_TEST_VALUE: "resolved-env" },
|
env: { JSONL_RPC_TEST_VALUE: "resolved-env" },
|
||||||
},
|
},
|
||||||
@@ -222,6 +231,70 @@ describe("JsonlRpcProcess", () => {
|
|||||||
await rejection;
|
await rejection;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("closes the transport when a direct send synchronously throws EPIPE", async () => {
|
||||||
|
const child = createInMemoryChildProcess();
|
||||||
|
const transport = startProcess({ child });
|
||||||
|
|
||||||
|
child.stdin.write = () => {
|
||||||
|
throw Object.assign(new Error("write EPIPE"), { code: "EPIPE" });
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(() => transport.send({ type: "notice" })).not.toThrow();
|
||||||
|
await expect(transport.request({ type: "echo", value: "after" })).rejects.toThrow(
|
||||||
|
"JSONL RPC process is closed",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Closing fd 0 does not sever the inherited pipe on Windows, so this fixture cannot produce EPIPE.
|
||||||
|
test.skipIf(process.platform === "win32")(
|
||||||
|
"keeps the parent alive when a real child closes its stdin pipe",
|
||||||
|
async () => {
|
||||||
|
const transport = startProcess({ source: CLOSED_STDIN_CHILD_SOURCE });
|
||||||
|
const stdinClosed = new Promise<void>((resolve) => {
|
||||||
|
const unsubscribe = transport.onMessage((message) => {
|
||||||
|
if (message.type !== "stdin_closed") return;
|
||||||
|
unsubscribe();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await stdinClosed;
|
||||||
|
await expect(transport.request({ type: "echo", value: "after" })).rejects.toThrow(
|
||||||
|
/EPIPE|stdin is not writable/,
|
||||||
|
);
|
||||||
|
await expect(transport.request({ type: "echo", value: "later" })).rejects.toThrow(
|
||||||
|
"JSONL RPC process is closed",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test("stdin error events close the transport instead of becoming uncaught exceptions", async () => {
|
||||||
|
const child = createInMemoryChildProcess();
|
||||||
|
const transport = startProcess({ child });
|
||||||
|
const request = transport.request({ type: "hang" });
|
||||||
|
const err = Object.assign(new Error("write EPIPE"), { code: "EPIPE" });
|
||||||
|
|
||||||
|
child.stdin.emit("error", err);
|
||||||
|
|
||||||
|
await expect(request).rejects.toThrow("write EPIPE");
|
||||||
|
await expect(transport.request({ type: "echo", value: "after" })).rejects.toThrow(
|
||||||
|
"JSONL RPC process is closed",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a non-writable stdin closes the transport instead of hanging the request", async () => {
|
||||||
|
const child = createInMemoryChildProcess();
|
||||||
|
const transport = startProcess({ child });
|
||||||
|
child.stdin.end();
|
||||||
|
|
||||||
|
await expect(transport.request({ type: "hang" })).rejects.toThrow(
|
||||||
|
"JSONL RPC stdin is not writable",
|
||||||
|
);
|
||||||
|
await expect(transport.request({ type: "echo", value: "after" })).rejects.toThrow(
|
||||||
|
"JSONL RPC process is closed",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("reassembles protocol v2 chunked responses into the logical frame", async () => {
|
test("reassembles protocol v2 chunked responses into the logical frame", async () => {
|
||||||
const child = createInMemoryChildProcess();
|
const child = createInMemoryChildProcess();
|
||||||
const transport = startProcess({ child });
|
const transport = startProcess({ child });
|
||||||
|
|||||||
@@ -104,6 +104,9 @@ export class JsonlRpcProcess {
|
|||||||
this.stderrBuffer = this.stderrBuffer.slice(-STDERR_BUFFER_LIMIT);
|
this.stderrBuffer = this.stderrBuffer.slice(-STDERR_BUFFER_LIMIT);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
this.child.stdin.on("error", (error) => {
|
||||||
|
this.handleStdinError(error);
|
||||||
|
});
|
||||||
this.child.on("error", (error) => {
|
this.child.on("error", (error) => {
|
||||||
this.failAll(error instanceof Error ? error : new Error(String(error)));
|
this.failAll(error instanceof Error ? error : new Error(String(error)));
|
||||||
});
|
});
|
||||||
@@ -173,10 +176,18 @@ export class JsonlRpcProcess {
|
|||||||
}
|
}
|
||||||
|
|
||||||
send(message: Record<string, unknown>): void {
|
send(message: Record<string, unknown>): void {
|
||||||
if (this.disposed || this.child.stdin.destroyed || !this.child.stdin.writable) {
|
if (this.disposed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.child.stdin.write(`${JSON.stringify(message)}\n`);
|
if (this.child.stdin.destroyed || !this.child.stdin.writable) {
|
||||||
|
this.handleStdinError(new Error(`${this.diagnosticName} stdin is not writable`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this.child.stdin.write(`${JSON.stringify(message)}\n`);
|
||||||
|
} catch (error) {
|
||||||
|
this.handleStdinError(error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async close(error = new Error(`${this.diagnosticName} process is closed`)): Promise<void> {
|
async close(error = new Error(`${this.diagnosticName} process is closed`)): Promise<void> {
|
||||||
@@ -243,6 +254,15 @@ export class JsonlRpcProcess {
|
|||||||
pending.resolve(response.data);
|
pending.resolve(response.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private handleStdinError(error: unknown): void {
|
||||||
|
if (this.disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const err = error instanceof Error ? error : new Error(String(error));
|
||||||
|
this.options.logger.warn({ err }, `${this.diagnosticName} stdin write failed`);
|
||||||
|
void this.close(err);
|
||||||
|
}
|
||||||
|
|
||||||
private failAll(error: Error): void {
|
private failAll(error: Error): void {
|
||||||
if (this.disposed) {
|
if (this.disposed) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user