mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-15 05:42:34 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad67c7030c | ||
|
|
2250148e3a |
@@ -40,7 +40,6 @@ This is the section I read most carefully. I need to see that *you* tested this,
|
||||
|
||||
### Checklist
|
||||
|
||||
- [ ] Plugin changes follow the [SDK import boundaries](../docs/plugins.md#sdk-import-boundaries) (if applicable)
|
||||
- [ ] One focused change
|
||||
- [ ] `npm run typecheck` passes
|
||||
- [ ] `npm run lint` passes
|
||||
|
||||
@@ -50,26 +50,11 @@ app:
|
||||
- "packages/expo-two-way-audio/**"
|
||||
|
||||
sdk:
|
||||
- "packages/plugin/**"
|
||||
- "plugin-examples/**"
|
||||
- "public-docs/plugins/v0.8/**"
|
||||
- "packages/client/**"
|
||||
- "packages/highlight/**"
|
||||
- "packages/protocol/**"
|
||||
|
||||
browser:
|
||||
- "packages/server/src/server/agent/provider-snapshot-manager.ts"
|
||||
- "packages/server/src/server/session/provider/provider-catalog-session.ts"
|
||||
- "packages/client/src/compat/normalize-provider-models.ts"
|
||||
- "packages/protocol/src/client-capabilities.ts"
|
||||
- "packages/server/src/server/agent/provider-registry.ts"
|
||||
- "packages/server/src/server/agent/agent-sdk-types.ts"
|
||||
- "packages/server/src/server/agent/providers/codex-app-server-agent.ts"
|
||||
- "packages/server/src/server/agent/providers/claude/agent.ts"
|
||||
- "packages/server/src/server/agent/plugin-provider.ts"
|
||||
- "packages/server/src/server/plugins/{index,plugin-process,plugin-process-protocol,runtime}.ts"
|
||||
- "packages/server/src/executable-resolution/**"
|
||||
- "packages/plugin/src/server/provider.ts"
|
||||
- "packages/app/src/!(desktop)/**"
|
||||
- "packages/app/e2e/browser/**"
|
||||
- "packages/app/e2e/support/**"
|
||||
|
||||
@@ -59,6 +59,8 @@ jobs:
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.format != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -78,6 +80,8 @@ jobs:
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -103,6 +107,8 @@ jobs:
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -131,6 +137,8 @@ jobs:
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' || needs.changes.outputs.hub != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
steps: &server_test_steps
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -172,6 +180,8 @@ jobs:
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' || needs.changes.outputs.hub != 'false') }}
|
||||
runs-on: windows-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
steps: *server_test_steps
|
||||
|
||||
desktop-tests-ubuntu:
|
||||
@@ -258,6 +268,8 @@ jobs:
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.app != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -284,6 +296,8 @@ jobs:
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -298,15 +312,6 @@ jobs:
|
||||
- name: Build client dependencies
|
||||
run: npm run build:client
|
||||
|
||||
- name: Build plugin SDK
|
||||
run: npm run build:plugin
|
||||
|
||||
- name: Run plugin SDK tests
|
||||
run: npm run test --workspace=@getpaseo/plugin
|
||||
|
||||
- name: Typecheck plugin examples
|
||||
run: npm run typecheck --workspace=@getpaseo/plugin
|
||||
|
||||
- name: Run protocol tests
|
||||
run: npm run test --workspace=@getpaseo/protocol
|
||||
|
||||
@@ -325,6 +330,7 @@ jobs:
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
PLAYWRIGHT_SHARD: "1/4"
|
||||
PLAYWRIGHT_ARTIFACT: "1"
|
||||
steps: &playwright_test_steps
|
||||
@@ -372,6 +378,7 @@ jobs:
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
PLAYWRIGHT_SHARD: "2/4"
|
||||
PLAYWRIGHT_ARTIFACT: "2"
|
||||
steps: *playwright_test_steps
|
||||
@@ -382,6 +389,7 @@ jobs:
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
PLAYWRIGHT_SHARD: "3/4"
|
||||
PLAYWRIGHT_ARTIFACT: "3"
|
||||
steps: *playwright_test_steps
|
||||
@@ -392,6 +400,7 @@ jobs:
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
PLAYWRIGHT_SHARD: "4/4"
|
||||
PLAYWRIGHT_ARTIFACT: "4"
|
||||
steps: *playwright_test_steps
|
||||
@@ -401,6 +410,8 @@ jobs:
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.relay != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -424,6 +435,7 @@ jobs:
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.cli != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0"
|
||||
PASEO_DICTATION_ENABLED: "0"
|
||||
PASEO_VOICE_MODE_ENABLED: "0"
|
||||
@@ -455,6 +467,7 @@ jobs:
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.cli != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0"
|
||||
PASEO_DICTATION_ENABLED: "0"
|
||||
PASEO_VOICE_MODE_ENABLED: "0"
|
||||
@@ -468,6 +481,7 @@ jobs:
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.cli != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0"
|
||||
PASEO_DICTATION_ENABLED: "0"
|
||||
PASEO_VOICE_MODE_ENABLED: "0"
|
||||
|
||||
@@ -547,10 +547,28 @@ jobs:
|
||||
fi
|
||||
timestamp="$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")"
|
||||
node ../scripts/stamp-rollout.mjs --release-date "$timestamp" --rollout-hours "$ROLLOUT_HOURS" "${files[@]}"
|
||||
node ../scripts/validate-desktop-manifests.mjs \
|
||||
--release-date "$timestamp" \
|
||||
--rollout-hours "$ROLLOUT_HOURS" \
|
||||
"${files[@]}"
|
||||
# shellcheck disable=SC2016
|
||||
ROLLOUT_HOURS_EXPECTED="$ROLLOUT_HOURS" RELEASE_DATE_EXPECTED="$timestamp" node -e '
|
||||
const yaml = require("js-yaml");
|
||||
const fs = require("fs");
|
||||
const expectedHours = Number(process.env.ROLLOUT_HOURS_EXPECTED);
|
||||
const expectedDate = process.env.RELEASE_DATE_EXPECTED;
|
||||
if (!Number.isFinite(expectedHours) || expectedHours < 0) {
|
||||
throw new Error(`expected non-negative rolloutHours, got ${process.env.ROLLOUT_HOURS_EXPECTED}`);
|
||||
}
|
||||
for (const f of process.argv.slice(1)) {
|
||||
const m = yaml.load(fs.readFileSync(f, "utf8")) ?? {};
|
||||
if (m.rolloutHours !== expectedHours) {
|
||||
throw new Error(`${f}: rolloutHours=${m.rolloutHours}, expected ${expectedHours}`);
|
||||
}
|
||||
if (m.releaseDate !== expectedDate) {
|
||||
throw new Error(`${f}: releaseDate=${m.releaseDate}, expected ${expectedDate}`);
|
||||
}
|
||||
if (typeof m.version !== "string" || m.version.length === 0) {
|
||||
throw new Error(`${f}: missing or invalid version`);
|
||||
}
|
||||
}
|
||||
' "${files[@]}"
|
||||
gh release upload "$release_lookup" "${files[@]}" --clobber --repo "${{ github.repository }}"
|
||||
|
||||
failed_builds=()
|
||||
|
||||
@@ -1,92 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 0.8.0-beta.1 - 2026-09-08
|
||||
|
||||
This release considerably extends what plugins can do: complete providers, custom components in the chat, slash commands, settings screens, and agent lifecycle hooks.
|
||||
|
||||
The plugin API has a breaking change. Plugins now use separate client and server entries and runtime-owned SDK entry points, so plugins written for 0.7 must follow the [migration guide](https://paseo.sh/docs/plugins/v0.8/migration) before they load on 0.8.
|
||||
|
||||
It also fixes regressions across desktop updates, mobile, Import session, and agent lifecycle.
|
||||
|
||||
### Plugins
|
||||
|
||||
- Added provider contributions: a plugin registers a complete provider, directly or through the bundled ACP shim, with its own icon, session settings, permissions, and timeline rendering ([#4314](https://github.com/getpaseo/paseo/pull/4314))
|
||||
- Added live timeline items: a plugin renders custom components in the chat by transforming timeline items at render time ([e34aea2](https://github.com/getpaseo/paseo/commit/e34aea206e6ec7ac2d5fa9766db28fb221ae990c))
|
||||
- Added durable timeline rows: a daemon handler appends its own persisted rows to an agent's timeline ([e34aea2](https://github.com/getpaseo/paseo/commit/e34aea206e6ec7ac2d5fa9766db28fb221ae990c))
|
||||
- Added slash commands: a plugin registers commands in the composer that run on the client without a provider turn ([e34aea2](https://github.com/getpaseo/paseo/commit/e34aea206e6ec7ac2d5fa9766db28fb221ae990c))
|
||||
- Added settings screens: a plugin contributes screens under Settings → Plugins from shared settings components, with host-scoped persistence, validation, and Command Center navigation ([#4357](https://github.com/getpaseo/paseo/pull/4357))
|
||||
- Added lifecycle hooks: server callbacks observe agent creation, turns, permissions, and archive events, and can answer permissions or send follow-ups ([#4435](https://github.com/getpaseo/paseo/pull/4435))
|
||||
- Added agent creation transforms: before hooks rewrite a new agent's provider, model, launch environment, MCP servers, and workspace isolation ([#4435](https://github.com/getpaseo/paseo/pull/4435))
|
||||
- Added modal bodies: a plugin owns the whole sheet body with custom padding, ScrollView or FlatList scrolling, horizontal tabs, keyboard-aware inputs, and a clipboard copy action ([#4392](https://github.com/getpaseo/paseo/pull/4392), [#4431](https://github.com/getpaseo/paseo/pull/4431))
|
||||
- Added terminals to the SDK: create, list, write, send keys, capture, and kill terminals by workspace ID ([#4358](https://github.com/getpaseo/paseo/pull/4358))
|
||||
- Added `paseo.projects.subscribe()` for live project updates ([#3983](https://github.com/getpaseo/paseo/pull/3983) by [@omercnet](https://github.com/omercnet))
|
||||
- Added `agent.respondToPermission()` to answer pending permissions from a plugin ([#3985](https://github.com/getpaseo/paseo/pull/3985) by [@omercnet](https://github.com/omercnet))
|
||||
- Added version requirements: `requirements.paseo` in `paseo-plugin.json` declares the supported Paseo versions, incompatible installs, updates, and reloads are rejected before any code runs, and prerelease Paseo versions satisfy any range their stable core satisfies ([#4430](https://github.com/getpaseo/paseo/pull/4430), [#4452](https://github.com/getpaseo/paseo/pull/4452))
|
||||
- Changed plugins to separate `index.client.ts` and `index.server.ts` entries with `client/`, `server/`, and `shared/` directories; plugins written for 0.7 must follow the [migration guide](https://paseo.sh/docs/plugins/v0.8/migration) ([#4206](https://github.com/getpaseo/paseo/pull/4206))
|
||||
- Changed the plugin SDK into runtime-owned entry points: `@getpaseo/plugin` for shared code, `@getpaseo/plugin/client` for the app, and `@getpaseo/plugin/server` for the daemon, with crossed imports rejected at compile time ([#4347](https://github.com/getpaseo/paseo/pull/4347) by [@liujin0506](https://github.com/liujin0506), [@panrafal](https://github.com/panrafal))
|
||||
- Changed legacy plugin load failures to say the plugin was built for an older Paseo and link to the migration guide ([#4265](https://github.com/getpaseo/paseo/pull/4265))
|
||||
- Changed `paseo plugin ls` to report runtime state, source, installed commit, and load errors without contacting a remote ([#4265](https://github.com/getpaseo/paseo/pull/4265))
|
||||
- Fixed workspace plugin panels missing from the Explorer tab-rail menu ([#4446](https://github.com/getpaseo/paseo/pull/4446))
|
||||
- Added versioned plugin documentation for v0.7 and v0.8 with a migration guide ([#4245](https://github.com/getpaseo/paseo/pull/4245), [#4452](https://github.com/getpaseo/paseo/pull/4452))
|
||||
|
||||
### Added
|
||||
|
||||
- Added Import session to the sidebar footer, the History and sidebar empty states, and the Command Center ([#4216](https://github.com/getpaseo/paseo/pull/4216))
|
||||
- Added search, Load more, and a This workspace / Show all scope to the Import session sheet ([#4216](https://github.com/getpaseo/paseo/pull/4216))
|
||||
- Added customizable top-level sidebar items: reorder and hide New workspace, History, Search, Schedules, and plugin items from Appearance settings ([#4203](https://github.com/getpaseo/paseo/pull/4203))
|
||||
- Added provider notifications from Pi extensions and OpenCode as timeline items with info, warning, and error levels ([#3411](https://github.com/getpaseo/paseo/pull/3411) by [@trim21](https://github.com/trim21))
|
||||
- Added a fullscreen viewer for Mermaid diagrams on web and desktop ([#4107](https://github.com/getpaseo/paseo/pull/4107) by [@gengjiawen](https://github.com/gengjiawen))
|
||||
- Added Copy, Copy line, and Select all to the web diff context menu, with Cmd/Ctrl+A and Escape ([#4229](https://github.com/getpaseo/paseo/pull/4229))
|
||||
- Added Gajae Code to the ACP provider catalog ([#3471](https://github.com/getpaseo/paseo/pull/3471) by [@Yeachan-Heo](https://github.com/Yeachan-Heo))
|
||||
- Added per-provider Paseo tool policy in daemon config to disable all or selected Paseo tools for a provider ([#4277](https://github.com/getpaseo/paseo/pull/4277) by [@mcowger](https://github.com/mcowger))
|
||||
- Added explicit setup approval for fork pull request workspaces: setup, automatic terminals, and scripts wait for Run setup or `paseo workspace setup <id>` ([#4215](https://github.com/getpaseo/paseo/pull/4215))
|
||||
- Added Hub follow-ups to existing agents, archived workspace recovery, and provider catalog discovery through the daemon API ([#4354](https://github.com/getpaseo/paseo/pull/4354), [#4242](https://github.com/getpaseo/paseo/pull/4242))
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed the desktop app to require macOS 13 or newer; macOS 12 stops receiving desktop updates ([#4322](https://github.com/getpaseo/paseo/pull/4322))
|
||||
- Changed `--host` to a global CLI option: `paseo --host <target> <command>` ([#4238](https://github.com/getpaseo/paseo/pull/4238))
|
||||
- Changed the sidebar footer Home button to Import session; the Home route and tile remain ([#4216](https://github.com/getpaseo/paseo/pull/4216))
|
||||
|
||||
### Improved
|
||||
|
||||
- Reconnected every host immediately on app foreground instead of waiting for reconnect backoff ([#4160](https://github.com/getpaseo/paseo/pull/4160))
|
||||
- Smoothed mobile keyboard transitions by moving the timeline and composer together instead of relaying out every frame ([#4275](https://github.com/getpaseo/paseo/pull/4275))
|
||||
- Reloaded only the providers whose configuration changed instead of restarting every provider and resetting every catalog ([#4332](https://github.com/getpaseo/paseo/pull/4332))
|
||||
- Shortened agent tab tooltips to one title line with the agent ID and last activity ([#4214](https://github.com/getpaseo/paseo/pull/4214))
|
||||
- Added macOS updater and ShipIt state to App Diagnostics ([#4322](https://github.com/getpaseo/paseo/pull/4322))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the mobile terminal keyboard closing and reopening when using toolbar keys or Enter ([#4469](https://github.com/getpaseo/paseo/pull/4469))
|
||||
- Fixed macOS desktop updates closing the app without installing the new version ([#4322](https://github.com/getpaseo/paseo/pull/4322))
|
||||
- Fixed the desktop updater reporting up to date while a new release had no updater manifests yet ([#4201](https://github.com/getpaseo/paseo/pull/4201))
|
||||
- Fixed `paseo restart` failing from the desktop-installed CLI on Linux `.deb`, RPM, and tarball installs ([#4207](https://github.com/getpaseo/paseo/pull/4207))
|
||||
- Fixed the packaged macOS Dock icon rendering oversized ([#4389](https://github.com/getpaseo/paseo/pull/4389))
|
||||
- Fixed cached conversations and the workspace list not painting when live updates arrived during cache reads ([#4436](https://github.com/getpaseo/paseo/pull/4436))
|
||||
- Fixed queued messages not sending after a turn completed while the app was in the background ([#4160](https://github.com/getpaseo/paseo/pull/4160))
|
||||
- Fixed the New workspace form losing the selected model after creating a workspace ([#4401](https://github.com/getpaseo/paseo/pull/4401))
|
||||
- Fixed a crash with `Select a model` when the selected provider disappeared during workspace creation ([#4332](https://github.com/getpaseo/paseo/pull/4332))
|
||||
- Fixed Codex model rows staying on Loading forever in the New workspace model picker ([#4283](https://github.com/getpaseo/paseo/pull/4283) by [@colonelpanic8](https://github.com/colonelpanic8))
|
||||
- Fixed reloading a Codex agent failing with `already has an active writer` ([#4353](https://github.com/getpaseo/paseo/pull/4353))
|
||||
- Fixed Pi agents stuck running after an extension-triggered turn ([#3849](https://github.com/getpaseo/paseo/pull/3849) by [@mjakl](https://github.com/mjakl))
|
||||
- Fixed nested provider subagents shown as direct children of the root agent and their notifications appearing in the root timeline ([#4321](https://github.com/getpaseo/paseo/pull/4321))
|
||||
- Fixed repeated provider tool-call IDs across autonomous turns producing duplicate timeline rows ([140b0bb](https://github.com/getpaseo/paseo/commit/140b0bb716205cf0e00c0ff5b6b3c20af6c79413))
|
||||
- Fixed the Import session spinner never stopping when one provider hung; the provider now shows an inline error with Retry ([#4216](https://github.com/getpaseo/paseo/pull/4216))
|
||||
- Fixed Import session ignoring Claude Code `/rename` titles ([#4216](https://github.com/getpaseo/paseo/pull/4216))
|
||||
- Fixed importing a session into a directory with an active workspace creating a duplicate workspace ([#4216](https://github.com/getpaseo/paseo/pull/4216))
|
||||
- Fixed empty ACP sessions appearing in Import session ([#4335](https://github.com/getpaseo/paseo/pull/4335))
|
||||
- Fixed diff file headers lagging behind the document while scrolling on web and desktop ([#4240](https://github.com/getpaseo/paseo/pull/4240))
|
||||
- Fixed the Changes tree and its working diff going out of sync when switching between Committed and Uncommitted ([#4199](https://github.com/getpaseo/paseo/pull/4199))
|
||||
- Fixed Mermaid diagrams shrinking on every streaming re-render and the inline diagram box collapsing ([#4107](https://github.com/getpaseo/paseo/pull/4107) by [@gengjiawen](https://github.com/gengjiawen))
|
||||
- Fixed Mermaid source corrupted on reload when cached history overlapped the live message ([#4210](https://github.com/getpaseo/paseo/pull/4210))
|
||||
- Fixed Settings navigation retaining closed views in memory on desktop ([#4228](https://github.com/getpaseo/paseo/pull/4228))
|
||||
- Fixed the file watcher missing nested directory changes on Windows ([#4354](https://github.com/getpaseo/paseo/pull/4354), [#4358](https://github.com/getpaseo/paseo/pull/4358))
|
||||
- Fixed daemon Git commands honoring a repository's `core.fsmonitor`, which let a checkout run its own command ([#4208](https://github.com/getpaseo/paseo/pull/4208))
|
||||
- Fixed terminal activity reporting for OpenCode 2 sessions started from a terminal ([#4300](https://github.com/getpaseo/paseo/pull/4300) by [@mr-karan](https://github.com/mr-karan))
|
||||
- Changed the plugin SDK's React peer range to `~19.1.0` so CLI installs resolve a patched 19.1.x instead of the version dependency scanners flag ([#4100](https://github.com/getpaseo/paseo/pull/4100) by [@liujin0506](https://github.com/liujin0506))
|
||||
- Fixed the paseo.sh homepage offering the Mac download to every visitor before hydration and desktop builds to phones ([#4222](https://github.com/getpaseo/paseo/pull/4222))
|
||||
|
||||
## 0.7.2 - 2026-09-02
|
||||
|
||||
### Added
|
||||
|
||||
@@ -108,8 +108,6 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir
|
||||
|
||||
## Critical rules
|
||||
|
||||
- **Before changing the plugin SDK, compiler, host module maps, scaffold, or examples, read [SDK import boundaries](docs/plugins.md#sdk-import-boundaries).** Classify the export by runtime first and preserve the enforced boundaries.
|
||||
|
||||
- **NEVER restart the main Paseo daemon on port 6767 without permission** — it manages all running agents. If you're an agent, restarting it kills your own process.
|
||||
- **NEVER assume a timeout means the service needs restarting** — timeouts can be transient.
|
||||
- **NEVER add auth checks to tests** — agent providers handle their own auth.
|
||||
|
||||
+97
-61
@@ -1,104 +1,140 @@
|
||||
# Contributing to Paseo
|
||||
|
||||
This guide is here to save us both time and help you find a useful way to contribute.
|
||||
|
||||
Paseo grows through bug reports, testing, workflow discussions, plugins, documentation, and people helping each other. The sections below explain where each contribution belongs and what to expect.
|
||||
Thank you for taking the time to contribute to Paseo.
|
||||
|
||||
## Philosophy
|
||||
|
||||
**Paseo has a lean, opinionated core built to be extended.**
|
||||
Paseo is an opinionated product, built on freedom and flexibility: any agent provider, any device, running on your own machine. It is meant to be composable, so you can build the workflow you want. Read more about the product vision [here](docs/product.md).
|
||||
|
||||
The goal is a low floor and a high ceiling: a polished default experience that is easy to understand, with the flexibility to choose your providers, run on your own infrastructure, and build your own workflows. Paseo should remain self-hosted and respectful of your privacy and control.
|
||||
Given Paseo's scope, contributing to it takes a lot of context that is very hard to transfer. That's why product, design, architecture, and workflow decisions are currently all made by the maintainer.
|
||||
|
||||
The core continues to evolve through improvements to the shared experience. Specialized workflows and integrations belong in the extension ecosystem. Product, design, architecture, and workflow decisions remain with the maintainer.
|
||||
I pick what to build based on whether it fits the product, how many workflows it improves, whether it keeps things composable, whether we can hold the quality bar, and whether I want to build it.
|
||||
|
||||
Read the [product philosophy](docs/product.md) for the reasoning behind these choices and how Paseo develops.
|
||||
## Report bugs in GitHub issues or Discord
|
||||
|
||||
## Build a plugin
|
||||
Open an [issue](https://github.com/getpaseo/paseo/issues) or post in [Discord](https://discord.gg/jz8T2uahpH).
|
||||
|
||||
Most specialized workflows and integrations are better served by plugins.
|
||||
> [!IMPORTANT]
|
||||
> Feature requests opened as issues will get closed automatically
|
||||
|
||||
Plugins let you build what you need, share it, and maintain it independently. Start with the [plugin documentation](https://paseo.sh/docs/plugins). For applications and integrations built around Paseo, see the [SDK documentation](https://paseo.sh/docs/sdk).
|
||||
If you used an agent to investigate, paste the raw evidence and repro steps it collected, not its diagnosis.
|
||||
|
||||
If an extension point is missing, describe the workflow in [Discussions](https://github.com/getpaseo/paseo/discussions). A reusable capability that enables several plugins may be a better addition than implementing one particular workflow in core.
|
||||
You may submit a PR to fix a bug, please read the PR guidelines below.
|
||||
|
||||
## Report bugs
|
||||
## Product discussions go in GitHub Discussions or Discord
|
||||
|
||||
Open a [GitHub issue](https://github.com/getpaseo/paseo/issues) and fill out the bug report template.
|
||||
|
||||
Include:
|
||||
|
||||
- What you did, what you expected, and what happened.
|
||||
- Reproduction steps, Paseo version, and platform.
|
||||
- Relevant logs, screenshots, or a recording.
|
||||
|
||||
If an agent investigated, include the evidence and reproduction steps it collected. Its explanation of the cause still needs verification.
|
||||
|
||||
Focused fixes for reproducible bugs are welcome. If you submit one, follow the pull request and QA guidance below.
|
||||
|
||||
## Discuss workflows before proposing features
|
||||
|
||||
Product discussions, including feature requests, belong in [GitHub Discussions](https://github.com/getpaseo/paseo/discussions). Feature requests opened as issues will be closed.
|
||||
|
||||
Explain:
|
||||
There's no feature request backlog. Open a product discussion in [Discussions](https://github.com/getpaseo/paseo/discussions) or `#product` in [Discord](https://discord.gg/jz8T2uahpH), and frame it as a workflow:
|
||||
|
||||
- What are you trying to do?
|
||||
- How do you do it today?
|
||||
- Where does Paseo get in the way?
|
||||
- What would a better workflow look like?
|
||||
- What would the flow look like if it worked for you?
|
||||
|
||||
This helps other people describe their needs and gives me useful context when deciding what to build.
|
||||
"Can you build X" is not useful because it doesn't tell me about your problem.
|
||||
|
||||
There is no feature request backlog or commitment to implement a discussion. I may read and learn from it without replying.
|
||||
A discussion also lets other people add their own version of the same problem, and that's what I read when I decide what to build.
|
||||
|
||||
## If you still want to submit a pull request
|
||||
Due to the volume and my limited bandwidth, I may not participate in all the discussions, if something gains interest it will make it's way to me.
|
||||
|
||||
After considering plugins and discussing the workflow, you may still want to propose a change to core. Focused fixes for reproducible bugs are also welcome.
|
||||
## Pull requests
|
||||
|
||||
**Pull requests are closed by default.** I may reopen the ones I want to take forward. Submit one only if you are comfortable with it being closed without a detailed review or explanation.
|
||||
Anyone can open a pull request, but there are no guarantees of it getting merged, only submit one if you're okay with it being closed.
|
||||
|
||||
Paseo receives more pull requests than I can responsibly review. Reviewing every submission and explaining every decision would consume the time available for developing the product.
|
||||
Open it as a draft if the work isn't ready for review, if you want to run the checks against it, or if you want feedback on the direction before you finish it. Mark it ready when you want it looked at.
|
||||
|
||||
I can prompt an agent to write code just as you can. The bottlenecks are choosing what to build, shaping it, verifying its behavior, and taking responsibility for its maintenance. A finished implementation still leaves that work to do.
|
||||
Here is the criteria I use to decide:
|
||||
|
||||
If you choose to submit a PR, these are the basics to cover.
|
||||
✅ Likely to be accepted
|
||||
|
||||
### What I look for in a core feature
|
||||
- Fits the product vision
|
||||
- One focused change
|
||||
- Links the bug it fixes, or the discussion behind it if there is one
|
||||
- Addresses bot reviews
|
||||
- Explains the problem you're solving
|
||||
- QA evidence
|
||||
- Automated tests
|
||||
- Screenshots or video for UI changes, on every affected platform
|
||||
- Says which platforms you tested and which you didn't
|
||||
- Maintainer edits enabled
|
||||
|
||||
- **Demonstrated demand.** Link a discussion with concrete examples of other users needing the workflow. A feature for a small, specialized use case belongs in a plugin.
|
||||
- **Value across workflows.** The feature should improve how existing capabilities work together and benefit a broad set of users. A reusable improvement is more valuable than an isolated control for one task.
|
||||
- **A coherent design across platforms and providers.** Shared capabilities should work across platforms and providers wherever applicable. Do not reshape a shared abstraction around one provider while leaving the others unsupported or inconsistent.
|
||||
- **A finished experience.** Follow the [design guidelines](docs/design.md). The feature must look and feel right in Paseo, including loading states, layout stability, and interaction performance. PRs with janky or rushed interfaces will be closed. Design judgment remains with the maintainer.
|
||||
⛔️ Will be rejected
|
||||
|
||||
### Keep the scope small
|
||||
- Bundles unrelated changes
|
||||
- Fails checks
|
||||
- Ignores bot reviews
|
||||
- Takes a feature or the design in a direction I don't want
|
||||
- No QA evidence
|
||||
- No tests
|
||||
- Clearly fully AI-generated PR
|
||||
|
||||
Submit one focused change. PRs adding more than roughly **3,000 lines of production code** are unlikely to be accepted. Prefer the smallest complete improvement that can be tested and understood on its own.
|
||||
### What to expect
|
||||
|
||||
### QA evidence
|
||||
- PRs that were explicitly approved in a discussion are preferred.
|
||||
- Unsolicited PR can be closed without a detailed review.
|
||||
- Your PR can be narrowed, refactored, or redesigned.
|
||||
- Your PR might be accepted but not merged immediately.
|
||||
- You will be attributed for your work in the changelog, even if I redesign your PR
|
||||
|
||||
Run the changed application or service and test the affected workflow yourself. Include detailed QA evidence so I can see what you exercised and what happened. PRs without this verification and evidence will be closed, whether they fix a bug or add a feature.
|
||||
## QA evidence
|
||||
|
||||
Explain the problem and link the relevant bug report or discussion. Include:
|
||||
QA is the main bottleneck of Paseo's product development, and it's not just whether the feature works, it's also whether it meets the quality bar.
|
||||
|
||||
- Automated tests that exercise the changed behavior. A bug fix needs a regression test that fails on the broken version.
|
||||
- The commands you ran and their output.
|
||||
- A recording for interactive UI changes, or before-and-after screenshots for static changes.
|
||||
- The platforms you tested and any affected platforms you could not test.
|
||||
Pull requests without evidence will be closed.
|
||||
|
||||
The [QA guide](docs/qa.md) explains the expected evidence and available tooling.
|
||||
What's accepted:
|
||||
|
||||
Using an agent is welcome. Sending it to implement a change and submitting its output without trying the result yourself does not meet this bar.
|
||||
- The shell commands you ran, pasted with their output
|
||||
- The tests you added and their results
|
||||
- Before and after screenshots
|
||||
- A video of the whole interaction
|
||||
- Logs, requests, responses
|
||||
|
||||
**Satisfying all of the above does not mean your pull request will be merged.**
|
||||
Bugs and features both need automated tests, and the tests have to exercise the real thing. UI changes need a video, or a screenshot if it's static.
|
||||
|
||||
### What happens to your contribution
|
||||
The [QA guide](docs/qa.md) covers what to check for each of these and the tooling to do it.
|
||||
|
||||
If I take a contribution forward, I may narrow it, reshape it, or use it as a reference for my own implementation. **You will be credited for your contribution, including when I reimplement it.**
|
||||
## I want to become a maintainer
|
||||
|
||||
A closed PR does not necessarily mean the underlying problem was dismissed. It means I am not taking that submission forward. Detailed reviews, individual explanations, and follow-up discussions cannot be provided at this volume.
|
||||
There's no formal process to become a maintainer, just consistently get involved with the project by answering questions, testing, reproducing bugs, discussing workflows with the community and offer to implement features when we reach an agreement.
|
||||
|
||||
## Help the community
|
||||
## FAQ
|
||||
|
||||
Testing betas, reproducing bugs, improving documentation, sharing plugins, and answering questions all help Paseo develop.
|
||||
### I'm blocked, where do I go?
|
||||
|
||||
There is no formal process for becoming a maintainer. Consistent involvement and good judgment build the shared context needed to take on more responsibility.
|
||||
Join [Discord](https://discord.gg/jz8T2uahpH), ask your questions there.
|
||||
|
||||
### Do you prefer Github or Discord?
|
||||
|
||||
I am more active on Discord for general questions.
|
||||
|
||||
I prefer Github for bugs because the template helps collect information more efficiently.
|
||||
|
||||
### Why was my feature request closed?
|
||||
|
||||
Issues are only for bugs. Open it as a product discussion instead, framed as a workflow.
|
||||
|
||||
### Why didn't my PR get a detailed review?
|
||||
|
||||
Writing a review is very time consuming and Paseo receives a lot of PRs, I cannot give detailed explanations at volume.
|
||||
|
||||
### How long until someone looks at my pull request?
|
||||
|
||||
It depends on what else is going on. QA takes longer than reading the diff. You are welcome to join Discord to ask.
|
||||
|
||||
### Why do you want tests and a video for a small change?
|
||||
|
||||
Because it's faster and safer than me checking out and running every PR myself to test it manually.
|
||||
|
||||
QA evidence help me take your PR seriously for a deeper review and maintainer QA.
|
||||
|
||||
### I only have one platform, can I still contribute?
|
||||
|
||||
Yes. Test what you have, and say what you didn't test.
|
||||
|
||||
### Can I use an agent to write my contribution?
|
||||
|
||||
Yes, as long as you understand what you're submitting and you tested it yourself.
|
||||
|
||||
### Is there a roadmap I can read?
|
||||
|
||||
[docs/product.md](docs/product.md) covers the direction. There's no public list of what I'm building next.
|
||||
|
||||
@@ -47,15 +47,6 @@ Run agents in parallel on your own machines. Ship from your phone or your desk.
|
||||
- **Cross-device:** iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal.
|
||||
- **Privacy-first:** Paseo doesn't have any telemetry, tracking, or forced log-ins.
|
||||
|
||||
## Plugins
|
||||
|
||||
Add themes, workspace panels, commands, settings screens, and coding-agent providers with trusted
|
||||
TypeScript plugins. Install from a local directory or Git repository with `paseo plugin add <source>`.
|
||||
|
||||
See the [plugin docs](https://paseo.sh/docs/plugins) for your Paseo version, or start with the
|
||||
[0.8 beta quickstart](https://paseo.sh/docs/plugins/v0.8). Plugins run with access to your daemon
|
||||
machine and inside connected clients; install only code you trust.
|
||||
|
||||
## Getting Started
|
||||
|
||||
Paseo runs a local server called the daemon that manages your coding agents. Clients like the desktop app, mobile app, web app, and CLI connect to it.
|
||||
|
||||
+3
-12
@@ -21,10 +21,6 @@ the agent runs through `ensureAgentLoaded()`, which resumes the durable provider
|
||||
same Paseo agent ID. Provider history is not appended again when the canonical timeline is already
|
||||
primed.
|
||||
|
||||
Reload releases the old runtime before resuming its durable session: an idle provider process can
|
||||
still own an exclusive writer. A close failure retains that runtime for cleanup and blocks the
|
||||
replacement. Once closure succeeds, a failed resume leaves the durable agent closed and retryable.
|
||||
|
||||
Idle agents remain resident indefinitely. Runtime closure happens only through an explicit lifecycle
|
||||
action such as archive, replacement, reload, workspace teardown, or daemon shutdown.
|
||||
|
||||
@@ -148,10 +144,6 @@ Workspace status is an aggregate activity signal computed **per `workspaceId`**.
|
||||
|
||||
Running provider-native subagents contribute `running` to the workspace owned by their parent agent. Their completed, failed, and canceled states stay in the parent's subagents track.
|
||||
|
||||
A finished workspace can be marked unread after it has been reviewed. The daemon restores
|
||||
`finished` attention on its newest eligible workspace-root agent without sending a new completion
|
||||
notification. Opening the workspace clears that attention through the normal focus flow.
|
||||
|
||||
## The subagents track
|
||||
|
||||
The track is a pill at the foot of an agent's pane (`packages/app/src/subagents/track.tsx`): a count you can read at a glance, and a panel behind it — a popover on wide screens, a sheet on compact ones — holding the rows. It floats over the transcript rather than sitting in a band above the composer, so the timeline scrolls underneath it; `packages/app/src/panels/agent-tracks.tsx` owns that placement, and the pill frame is shared with the task list in `packages/app/src/composer/tracks.tsx`.
|
||||
@@ -164,9 +156,9 @@ The rows combine two kinds of children:
|
||||
parentAgentId === thisAgent.id AND !archivedAt
|
||||
```
|
||||
|
||||
- **Provider subagents** are child executions owned by Claude, Codex, or OpenCode. They are not inserted into `AgentManager` as managed agents. Providers emit a separate descriptor and timeline stream through `agent.provider_subagents.*`; the client keeps that state outside the normal agent store and merges only the presentation rows into the track. A descriptor's optional `parentSubagentId` identifies its direct provider-subagent parent; an absent value identifies a direct child of the managed agent.
|
||||
- **Provider subagents** are child executions owned by Claude, Codex, or OpenCode. They are not inserted into `AgentManager` as managed agents. Providers emit a separate descriptor and timeline stream through `agent.provider_subagents.*`; the client keeps that state outside the normal agent store and merges only the presentation rows into the track.
|
||||
|
||||
Clicking either kind opens a workspace tab. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical.
|
||||
Clicking either kind opens a workspace tab. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical.
|
||||
|
||||
Provider timelines use the same structural timeline item format but deliberately have a separate lifecycle and transport. A provider thread/session identifier is not a Paseo agent identifier, and closing its tab is always layout-only.
|
||||
|
||||
@@ -182,8 +174,7 @@ Claude Code announces subagent lifecycle on the SDK stream (`task_started` / `ta
|
||||
- **A resumed task can be announced again with a new `tool_use_id`.** The first Task tool id remains the canonical descriptor and later ids are routing aliases for the same session-scoped task. The resumed prompt is added to that child timeline.
|
||||
- **Effort is only reachable through hooks.** It appears nowhere on the message stream at any depth, and the level Paseo requests is not necessarily the level that runs — a model that does not support it is silently downgraded. A hook firing inside a subagent reports the active post-downgrade level next to its `agent_id`, which is the same id `task_started` calls `task_id`.
|
||||
- **Backgrounded subagents emit no frames carrying `parent_tool_use_id` at all.** Everything keyed off that field sees nothing for one; they are visible only because the task protocol announces them.
|
||||
- **Nested ownership comes from the launching sidechain, not `spawn_depth` alone.** A sidechain's Agent or Bash tool call records the direct owner of that `tool_use_id`; the following `task_started` inherits it. This routes a grandchild descriptor and child-owned background notifications without relying on labels or flattening them into the managed parent.
|
||||
- **On replay, `<session>/subagents/` holds every descendant beside the root.** Resolve the tree one proven generation at a time: the root transcript admits direct children, then each admitted sidechain transcript admits its children by `toolUseId`. `spawnDepth` orders candidates but does not establish ownership. Unresolved sidecars remain excluded as ambient or unrelated work.
|
||||
- **On replay, `<session>/subagents/` holds every descendant, not just this session's children.** `agent-<id>.meta.json` carries `spawnDepth`: `1` is a direct child, `2+` was spawned by another subagent and its `toolUseId` names a Task call made inside its parent's session, which nothing in this transcript can resolve. Replaying those adds rows the live stream never showed, each with no Task card and no recoverable outcome, so they render as running forever. One recorded session showed 10 subagents live and would have replayed 22.
|
||||
- **Replay `totalTokens` is a context-size reading, not cumulative spend.** Claude Code finalizes a subagent by summing the _last_ assistant message's usage block and shipping that as `usage.total_tokens`. Summing per-entry usage instead multiplies the cached prefix by the turn count and reports a number several times larger than the live path.
|
||||
|
||||
Archived Paseo subagents disappear from the track, by design. To remove one from the track without closing its tab, use the **archive button** on the row — it opens a confirm dialog and archives the subagent on confirm. Provider-owned rows have no individual Paseo lifecycle controls.
|
||||
|
||||
@@ -94,10 +94,9 @@ facade. App and CLI may import the low-level driver from
|
||||
`@getpaseo/client/internal/daemon-client` during migration, while new SDK-shaped
|
||||
code imports from `@getpaseo/client`.
|
||||
|
||||
`PaseoApi` is the capability-only boundary over workspaces, agents, terminals, providers, and config.
|
||||
`PaseoApi` is the capability-only boundary over workspaces, agents, providers, and config.
|
||||
`PaseoClient` adds connection lifecycle. App plugin surfaces borrow an API over their selected
|
||||
host's client; plugin subprocesses use the same facade over a host-owned IPC transport. Protocol capability ownership and subscription lifetimes follow
|
||||
[the client contract](protocol-compatibility.md#client-capability-ownership).
|
||||
host's client; plugin subprocesses use the same facade over a host-owned IPC transport.
|
||||
|
||||
### `packages/app` — Mobile + web client (Expo)
|
||||
|
||||
|
||||
+2
-41
@@ -275,45 +275,6 @@ rather than storing something it cannot describe. That is why the client gates t
|
||||
UI on `server_info.features.agentProfiles` instead of letting a save appear to succeed against an
|
||||
older daemon.
|
||||
|
||||
### Agent provider Paseo tools
|
||||
|
||||
`agents.providers` is keyed by the exact provider ID used to launch the agent. The built-in IDs are
|
||||
`claude`, `codex`, `copilot`, `opencode`, `pi`, and `omp`. Custom provider IDs are their literal
|
||||
configuration keys, such as `my-claude` or `zai`, not the provider named by `extends`.
|
||||
|
||||
Each entry may include a Paseo-tool policy:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"my-claude": {
|
||||
"extends": "claude",
|
||||
"label": "My Claude",
|
||||
"paseoTools": {
|
||||
"enabled": true,
|
||||
"disabledTools": ["browser_evaluate"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Absent `paseoTools`, or absent fields within it, means Paseo tools are enabled and all tools are
|
||||
allowed. `enabled: false` disables the provider's Paseo catalog; `disabledTools` lists exact tool
|
||||
IDs to omit. The policy covers the core and browser catalog, not the voice-only `speak` tool.
|
||||
Browser tools also require `daemon.browserTools.enabled` and a connected browser host.
|
||||
This policy controls the catalog presented to an agent. It is not an authorization boundary for
|
||||
agents that can access the host through a shell.
|
||||
|
||||
`daemon.mcp.injectIntoAgents` is the global override. When it is `false`, no provider receives
|
||||
Paseo tools; otherwise the provider policy applies. Provider and global policy are resolved when a
|
||||
session is created, resumed, imported, or reloaded, so configuration changes affect the next
|
||||
session rather than an already-running one.
|
||||
|
||||
`agents.metadataGeneration.providers` controls the preferred structured-generation fallback order for daemon-side metadata tasks such as commit messages, PR text, branch names, and generated agent titles. Entries are tried first in the configured order, then Paseo falls through to dynamically discovered defaults and finally the current selection when available.
|
||||
|
||||
### Git process limits
|
||||
|
||||
Git process limits are global to one daemon. The start-rate limit defaults to `64` processes per
|
||||
@@ -574,8 +535,8 @@ Right-sidebar client state splits on whether it is determined by the directory o
|
||||
|
||||
The durable client replica uses IndexedDB on browser/Electron and expo-sqlite on native. Rows use the
|
||||
compound key `(serverId, kind, id)`; kinds are `agent`, `workspace`, `project`, `timeline`, and
|
||||
`checkpoint`. Directory entities have individual rows, timelines use the agent id, and the checkpoint
|
||||
uses the singleton id.
|
||||
`checkpoint`. Directory entities have individual rows. Timeline and checkpoint use the singleton id
|
||||
and have at most one row per host.
|
||||
|
||||
The store is a typed persistence boundary. It returns values to directory and timeline owners and
|
||||
accepts their explicit commits; it never reads or writes UI state. Reads are scoped to the requested
|
||||
|
||||
+1
-1
@@ -120,7 +120,7 @@ Workspace and chat surfaces use the full width — these are working surfaces, n
|
||||
|
||||
Sections sit apart. `<SettingsSection>` owns its own bottom margin; the next thing is wrapped in another `<SettingsSection>`. The agent-list `sectionHeading` carries the same `marginTop`/`marginBottom` rhythm (`packages/app/src/components/agent-list.tsx:511-517`). Adding `marginBottom` to a section is wrong.
|
||||
|
||||
A section or group explains itself through the `info` prop on `<SettingsSection>` or `<SettingsGroup>` — an info icon beside the header that opens a tooltip (`packages/app/src/components/settings/headings/settings-info-tip.tsx`). A muted paragraph between the header and the card is wrong: it sits in the section's own gap, so it reads as a second heading rather than as prose belonging to the header. Explanatory copy that describes one row belongs to that row, as `settingsStyles.rowHint` inside the card.
|
||||
A section or group explains itself through the `info` prop on `<SettingsSection>` or `<SettingsGroup>` — an info icon beside the header that opens a tooltip (`packages/app/src/screens/settings/settings-info-tip.tsx`). A muted paragraph between the header and the card is wrong: it sits in the section's own gap, so it reads as a second heading rather than as prose belonging to the header. Explanatory copy that describes one row belongs to that row, as `settingsStyles.rowHint` inside the card.
|
||||
|
||||
Cards inside a section sit closer than sections. Rows inside a card touch — only the divider separates them. The rhythm is page → spacious; section → spacious; card → tight.
|
||||
|
||||
|
||||
+3
-6
@@ -556,16 +556,13 @@ npm run cli -- daemon status # Check daemon status
|
||||
npm run cli -- clone owner/repo --dir ~/workspace # Clone GitHub repo and register project
|
||||
```
|
||||
|
||||
Use the global `--host` option to point the CLI at a different daemon:
|
||||
Use `--host` to point the CLI at a different daemon:
|
||||
|
||||
```bash
|
||||
npm run cli -- --host localhost:7777 ls -a
|
||||
npm run cli -- --host ssh://user@host ls -a
|
||||
npm run cli -- ls -a --host localhost:7777
|
||||
npm run cli -- ls -a --host ssh://user@host
|
||||
```
|
||||
|
||||
Set `PASEO_HOST` to use the same target across invocations. An explicit
|
||||
`--host` overrides the environment variable.
|
||||
|
||||
In an SSH URI, the URL port is the SSH server port. The remote daemon defaults to `127.0.0.1:6767`; use `?daemonPort=7777` to override it. The transport runs non-interactively through the local OpenSSH client and never installs, starts, or configures the remote daemon. User-facing setup and troubleshooting live in [public-docs/connectivity.md](../public-docs/connectivity.md#ssh).
|
||||
|
||||
Desktop integrations can focus an existing agent without creating one or
|
||||
|
||||
@@ -39,10 +39,9 @@ that pane from the workspace split tree and docks it separately. Persisted ident
|
||||
literal `"explorer"` pane id and `explorerPaneIdByWorkspace` key for compatibility.
|
||||
|
||||
The tab rail has no inline add or close controls. Its context menu opens a New Tab launcher and
|
||||
toggles Files, Changes, and Explorer-compatible workspace-scoped plugin panels from the shared
|
||||
launch catalog. Individual tab menus close instances or move compatible tabs to main. Explorer tabs
|
||||
can be reordered, but the dock cannot be split. Selecting an Explorer tab does not change workspace
|
||||
focus.
|
||||
toggles the singleton Files and Changes views. Individual tab menus close instances or move
|
||||
compatible tabs to main. Explorer tabs can be reordered, but the dock cannot be split. Selecting
|
||||
an Explorer tab does not change workspace focus.
|
||||
|
||||
Cmd+E shows or hides Explorer without changing its selected view. Compact layouts use the combined
|
||||
full-screen Explorer overlay for Changes, Files, and pull requests, and close it after a file opens.
|
||||
|
||||
+1
-25
@@ -64,10 +64,7 @@ root host route and pass the nested workspace screen when a host route is
|
||||
already mounted, or Expo Router can append extra hidden workspace deck entries.
|
||||
The workspace navigation helper inspects the mounted navigation state to make
|
||||
that decision; if no host route is mounted yet, it falls back to ordinary route
|
||||
navigation. Both paths wait for the root navigation container to be ready. The
|
||||
workspace navigation owner keeps the latest pending intent across ref
|
||||
re-registration and applies it on the container's `ready` event; routes must not
|
||||
add their own readiness retries.
|
||||
navigation.
|
||||
|
||||
Those hidden entries are not harmless: composer floating panels can measure
|
||||
against the wrong deck and disappear offscreen. Follow the
|
||||
@@ -80,19 +77,6 @@ only use local param fallback during cold mount (`/` or empty pathname), or a
|
||||
hidden workspace can overwrite the remembered workspace before Settings or
|
||||
History returns.
|
||||
|
||||
Plugin settings use the distinct `settings/hosts/[serverId]/plugins/[pluginId]/[screenId]` leaf;
|
||||
Back returns to that host's Plugins page.
|
||||
|
||||
Settings detail routes are separate siblings on purpose. Keep
|
||||
`settings/[section]`, the host routes, the projects index, and project detail as
|
||||
distinct route names. `router.dismissTo()` ultimately matches stack entries by
|
||||
route name. A single catch-all Settings route would make project detail and the
|
||||
projects index the same route; Back would update params in place and leave a
|
||||
phantom detail entry underneath. The host routes also stay outside the
|
||||
store-ready protected group so a cold host deep link can survive daemon startup.
|
||||
Do not collapse this topology with a catch-all, `getId`, or
|
||||
`dangerouslySingular` workaround.
|
||||
|
||||
## Agent Targets
|
||||
|
||||
Notifications and agent URLs enter the router with different authoritative
|
||||
@@ -147,14 +131,6 @@ Do not read the active theme with `useUnistyles()` in a layout to build
|
||||
a small `withUnistyles` boundary without subscribing the route tree itself to
|
||||
every Unistyles runtime update.
|
||||
|
||||
Navigators keep their identity across appearance changes. `ThemedStack` remounts
|
||||
each screen's content below the native stack when appearance tokens change; a
|
||||
keyed wrapper above a stack remounts the stack itself, and Android crashes when
|
||||
that happens while a FragmentManager transaction is running, which settings
|
||||
hydration at startup makes likely. A screen that mounts a nested navigator (the
|
||||
root stack's `h/[serverId]`) is passed in `nestedNavigatorScreens` so the nested
|
||||
stack owns its own screens. See [unistyles.md](unistyles.md).
|
||||
|
||||
## Regression Shape
|
||||
|
||||
Pure helper tests are useful but not enough. The failure mode here is native
|
||||
|
||||
+15
-31
@@ -134,39 +134,19 @@ The fix for transforms is Gotcha 3. The fix for context is Gotcha 7.
|
||||
|
||||
## Gotcha 3 — Keyboard layout and portal anchors
|
||||
|
||||
`KeyboardTranslateView` owns visual keyboard motion. Android uses the
|
||||
controller's Reanimated signal. iOS uses its native-driver `Animated.event`
|
||||
signal because the stock iOS Reanimated value changes at move start, while
|
||||
writing a replacement Reanimated value every frame interrupts UIKit's hide
|
||||
animation. Keep this platform choice inside `KeyboardTranslateView`.
|
||||
Move the chat surface with worklet-driven bottom padding from
|
||||
`KeyboardShiftProvider`. Padding keeps the stream, composer, visual position,
|
||||
and native hit testing in the same layout. Do not translate the stream and
|
||||
composer independently.
|
||||
|
||||
`KeyboardDock` always keeps the chat surface at full height and wraps it in
|
||||
`KeyboardTranslateView`. The panel root clips it at the header edge, so rows
|
||||
slide under the header on open and out from under it on close. Do not swap the
|
||||
dock to keyboard padding at rest. Changing its height creates either a blank
|
||||
band or a paused transition when the inverted list updates its viewport.
|
||||
Do not use the controller's raw keyboard progress for the padding. It can retain
|
||||
a nonzero value after the keyboard closes. The shared provider normalizes that
|
||||
state and reconciles native animation-end events.
|
||||
|
||||
Do not animate a layout prop through the keyboard transition. On Android
|
||||
Fabric, each animated layout update clones the shadow tree and runs Yoga over
|
||||
the dock subtree. This measured about 10 ms per frame on the emulator and the
|
||||
mounted layouts arrived in bursts every 2–4 frames. A transform does not dirty
|
||||
layout. Preserve the translated dock's history scroll range with a far-end
|
||||
content inset on the inverted stream list. Update that inset only when keyboard
|
||||
motion settles; never drive it per frame.
|
||||
|
||||
Move the stream and composer together through `KeyboardDock`. Do not translate
|
||||
them independently.
|
||||
|
||||
`KeyboardShiftProvider` owns the normalized shift used for settled insets and
|
||||
portal geometry, plus the `isMoving` worklet value. It reconciles native
|
||||
animation-end events because the controller can retain a nonzero value after
|
||||
the keyboard closes.
|
||||
|
||||
Measure portal anchors while the dock is at rest. If a popover opens during
|
||||
motion, re-measure when `isMoving` settles. `measureInWindow` includes the
|
||||
dock's current layout and transform. Snapshot the shift at measurement time and
|
||||
apply only the subsequent shift delta to the animated `bottom`. Adding the full
|
||||
shift moves the portal twice and can place it over the composer controls.
|
||||
`measureInWindow` already includes the dock's current padding layout. When a
|
||||
portal opens, snapshot the current shift and apply only the subsequent shift
|
||||
delta to its animated `bottom`. Adding the full shift moves the portal twice and
|
||||
can place it over the composer controls.
|
||||
|
||||
The provider also reconciles iOS from the controller's native `onEnd` event.
|
||||
The controller's stock iOS shared values update at move start and during an
|
||||
@@ -175,6 +155,10 @@ leave the last height/progress pair stuck in either the open or closed state.
|
||||
Keep that terminal reconciliation on the UI thread; a later focus or blur must
|
||||
not be required to repair the offset.
|
||||
|
||||
Re-measure on `Keyboard.addListener('keyboardDidShow'|'keyboardDidHide')` only
|
||||
to refresh the snapshot if the keyboard was mid-transition when the popover
|
||||
opened.
|
||||
|
||||
## Gotcha 4 — Host-relative positioning before platform offsets
|
||||
|
||||
The generic anchored-overlay rule is:
|
||||
|
||||
@@ -78,14 +78,6 @@ complete`), keyed off the opened snapshot's serverId. Waiting for data is a
|
||||
timezone) once — never in a preview line AND a helper line.
|
||||
- `useUnistyles` is banned (see docs/unistyles.md); lint enforces.
|
||||
|
||||
## Settings rows
|
||||
|
||||
Use the named components from `@/components/settings` for settings cards and controls.
|
||||
`SettingsCard` owns row separators; `SettingsRow` accepts custom content. Heading-only screens
|
||||
can import `SettingsGroup` and `SettingsSection` directly from their modules in
|
||||
`@/components/settings/headings/`.
|
||||
The plugin UI entry exposes the same components; keep them independent of storage and routing.
|
||||
|
||||
## Data gating
|
||||
|
||||
Aggregate hooks return a discriminated load state:
|
||||
|
||||
+57
-36
@@ -4,7 +4,7 @@ Paseo Hub is an explicit opt-in connection from one Paseo daemon to one Hub. Run
|
||||
not register it with a Hub. The relationship begins only when a user runs
|
||||
`paseo hub connect [url]` from the daemon machine with an explicit API key or matching stored CLI login.
|
||||
|
||||
The human CLI login and daemon relationship are separate identities. `paseo hub login [url]` stores a durable organization-scoped CLI credential keyed by normalized Hub origin under `PASEO_HOME`. Interactive login optionally connects the local daemon, then points to the Hub UI for trigger configuration; it does not scaffold or deploy configuration. `paseo hub init` remains the explicit triggers-as-code scaffold: it writes one self-contained organization trigger under `.paseo/triggers/`, validates it through the trigger API, and optionally installs it. `paseo hub deploy` validates and installs every trigger in that directory; passing `--project` keeps deploying the legacy project bundle instead. `paseo hub export [directory]` writes the active organization's current triggers in the same layout, using the active login unless another Hub or API key is selected. Origin resolution uses explicit command input, `PASEO_HUB_URL`, active login, then `https://hub.paseo.sh`. Connect uses exact-origin authority to request a one-time enrollment token, then passes only that token to the daemon. The daemon generates and persists its own relationship credential.
|
||||
The human CLI login and daemon relationship are separate identities. `paseo hub login [url]` stores a durable organization-scoped CLI credential keyed by normalized Hub origin under `PASEO_HOME`. Interactive login optionally connects the local daemon, then points to the Hub UI for trigger configuration; it does not scaffold or deploy configuration. `paseo hub init` remains the explicit triggers-as-code scaffold. `paseo hub export [directory]` writes the active organization's current triggers as one self-contained YAML file per trigger, using the active login unless another Hub or API key is selected. Origin resolution uses explicit command input, `PASEO_HUB_URL`, active login, then `https://hub.paseo.sh`. Connect uses exact-origin authority to request a one-time enrollment token, then passes only that token to the daemon. The daemon generates and persists its own relationship credential.
|
||||
|
||||
## Connection and authority
|
||||
|
||||
@@ -25,48 +25,69 @@ during interactive login or later with `paseo hub permissions grant hub.execute`
|
||||
created before this split migrate their legacy execution scope to `hub.execute`. Hub sessions cannot
|
||||
manage their own relationship or permissions.
|
||||
|
||||
## Session grants and agent operations
|
||||
## Session grants and execution ownership
|
||||
|
||||
Hub uses the same authenticated, resumable Session protocol as other clients. Its persisted
|
||||
`hub.execute` permission authorizes ordinary agent creation, messaging, cancellation, archival,
|
||||
agent/workspace observation, timeline subscriptions, and workspace recovery. This authority is
|
||||
daemon-wide; it is not limited to agents created by that Hub. Daemon configuration, terminals,
|
||||
browser control, and permission management still require their own permissions. See
|
||||
[permissions.md](permissions.md).
|
||||
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
|
||||
connection receives its persisted permissions. Legacy relationship files carrying
|
||||
`hub.execution.*` migrate once to `hub.execute` at the persistence boundary. A denied request returns
|
||||
the ordinary `rpc_error` shape. See [permissions.md](permissions.md).
|
||||
|
||||
Clients using this contract check `server_info.features.hubAgentRpc` and
|
||||
`server_info.features.agentRequestReceipts` once. An older host must be upgraded; do not silently
|
||||
fall back to creating a fresh agent when continuation was requested.
|
||||
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
|
||||
agents owned by that daemon identity, so unrelated local agents remain outside the Hub surface.
|
||||
|
||||
Hub owns conversation keys, trigger policy, execution records, and the mapping to workspace/agent
|
||||
IDs. None of these routing concepts are part of the generic daemon RPCs. Creation accepts ordinary
|
||||
provider configuration, exact MCP tool policy, environment, and workspace/worktree selection.
|
||||
Private session configuration is persisted for recovery and is not exposed in agent snapshots.
|
||||
Provider controls remain provider-native; see [providers.md](providers.md).
|
||||
Each Hub create carries an execution ID. The daemon stores that ID with the agent's relationship
|
||||
owner before acknowledging creation. Duplicate or replayed creates for the same daemon and
|
||||
execution resolve to the same durable agent. After a lost response, reconnect, or daemon restart,
|
||||
the Hub retries `hub.execution.agent.create.request` with the same execution ID. The idempotent
|
||||
response returns the existing agent and its current state; there is no separate reconciliation RPC.
|
||||
Transient stream frames are not durably replayed.
|
||||
|
||||
`create_agent_request.idempotencyKey` identifies one creation operation. With a key, omit
|
||||
`initialPrompt`, persist the returned agent/workspace identity, then deliver the prompt using
|
||||
`send_agent_message_request` with a stable `messageId`. Request IDs correlate individual attempts;
|
||||
creation keys and message IDs identify the operation across attempts. A creation key is daemon-wide;
|
||||
a message ID is scoped to its agent. Reusing either with different arguments is a conflict.
|
||||
Daemon restart preserves the Hub relationship and owned execution identity, but interrupts any
|
||||
active turn. The daemon persists that agent as `closed`; an idempotent create retry returns the same
|
||||
daemon, execution, and agent identity with that terminal state. Paseo never stores or automatically
|
||||
replays the original prompt. A duplicate create returns the existing agent without starting another
|
||||
turn.
|
||||
|
||||
The daemon journals the assigned agent ID before creation. Concurrent retries share one operation,
|
||||
and a retry after a lost acknowledgement or restart returns the durable agent without creating a
|
||||
workspace or starting a turn. Deleting that agent does not make its old creation key reusable.
|
||||
Confirmed message delivery is also journaled, so retrying its message ID does not submit it again.
|
||||
An unfinished delivery after restart reports `agent_request_outcome_unknown`: a provider can have
|
||||
accepted a prompt before the daemon recorded success, so automatic resubmission could duplicate
|
||||
work. Inspect the agent before choosing a new message ID. Receipts contain identity and request
|
||||
hashes, never prompts, environment values, or credentials.
|
||||
Every Hub execution creates a fresh Paseo workspace. The workspace owns the execution's agents and
|
||||
terminals. Local checkout and worktree targets select only the workspace backing and isolation; the
|
||||
Hub cannot select or reuse an existing workspace. Hub creates use the same agent creation path as
|
||||
trusted clients. They may select any worktree target shape and carry optional MCP server configuration and provider-native
|
||||
`providerOptions` for the agent session. The daemon keeps that configuration in its private agent
|
||||
record so provider sessions can recover after a restart; neither ordinary client snapshots and
|
||||
updates nor Hub projections expose session configuration. See [providers.md](providers.md) for the
|
||||
supported provider keys.
|
||||
|
||||
Before messaging an archived workspace, call `workspace.recovery.inspect.request`, then
|
||||
`workspace.recovery.restore.request` and await success. The native message handler unarchives the
|
||||
agent and loads its persisted provider session. `activeTurnBehavior: "steer"` uses the provider's
|
||||
native steering behavior, including Paseo's existing behavior when that provider cannot steer.
|
||||
Execution completion and arrival-specific output authority remain Hub responsibilities.
|
||||
Hub tool preapproval is a private, structured list of `{ kind: "mcp", server, tool }` references.
|
||||
Every reference must name an MCP server injected by the same create request. The daemon translates
|
||||
only those identities into the selected provider's native approval configuration. The protocol
|
||||
cannot name or preapprove native tools such as Bash, Edit, or Write. Explicit local or managed ask
|
||||
and deny policy takes precedence. Providers without exact MCP preapproval support reject unattended
|
||||
Hub creation instead of broadening access or waiting for an invisible prompt.
|
||||
When a create request includes a tool policy, a successful response includes
|
||||
`toolPolicyApplied: true`; absence of that acknowledgement is not success for unattended execution.
|
||||
|
||||
The older `hub.execution.*` RPCs remain accepted for existing clients. Their execution ownership
|
||||
and create deduplication behavior are unchanged; new continuation work uses the ordinary RPCs.
|
||||
A Hub workflow may use read-only provider settings for classifier steps. This is defense in depth,
|
||||
not a security boundary: classifier labels and prompt intent do not authorize tools. Exact MCP grants
|
||||
and the provider's local or managed policy remain the authorization controls.
|
||||
|
||||
Execution completion policy remains outside the daemon: a completed agent turn does not imply that
|
||||
the Hub execution is terminal.
|
||||
|
||||
The Hub ends an execution by sending `hub.execution.control.request` with the durable execution ID
|
||||
and either `interrupt` or `archive`. The daemon resolves the agent from the authenticated daemon
|
||||
relationship plus that execution ID; callers cannot supply an agent ID or workspace path. Both
|
||||
actions are idempotent and continue to resolve from stored ownership after daemon restart.
|
||||
If no execution exists for that authenticated daemon and execution ID, interrupt and archive return
|
||||
success because the requested stopped or archived state already holds. An execution owned by another
|
||||
daemon is indistinguishable from a missing execution and is never exposed or affected.
|
||||
|
||||
Interrupt uses the ordinary agent cancellation lifecycle. Archive resolves the execution agent's
|
||||
required workspaceId and sends it through the shared workspace archive service. The service archives
|
||||
that workspace's agents and terminals, then removes Paseo-owned backing directories only after their
|
||||
final active workspace reference disappears. Local checkouts remain on disk; sibling workspaces
|
||||
sharing a backing directory remain active.
|
||||
|
||||
## Disconnect and revocation
|
||||
|
||||
|
||||
@@ -33,9 +33,7 @@ The UI worklet owns transient motion:
|
||||
React publishes the active panel only when the canonical target and the UI-thread position agree at
|
||||
the final anchor. Retained content 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. Native panel hosts and their dependent draggable lists
|
||||
also retain identity across appearance hydration and settings changes. Do not wrap those hosts in
|
||||
appearance keys; see [Unistyles appearance boundaries](unistyles.md#runtime-theme-patching-for-user-preferences).
|
||||
React owns pointer events and accessibility.
|
||||
|
||||
## Why one position
|
||||
|
||||
|
||||
@@ -35,18 +35,6 @@ PASEO_MOBILE_E2E_METRO_PORT=62093 npm run test:e2e:mobile
|
||||
|
||||
[native-terminal-basic.ios.ad](../packages/app/e2e/mobile/agent-device/native-terminal-basic.ios.ad) and [native-terminal-basic.android.ad](../packages/app/e2e/mobile/agent-device/native-terminal-basic.android.ad) are the smallest examples. Each opens a fresh terminal, types a command at zero delay, submits it, and asserts its distinct output. The app must be connected to a daemon with an active workspace.
|
||||
|
||||
For Android keyboard continuity, run the current checkout in the app, open an idle terminal
|
||||
with an empty prompt, hide its keyboard, and run:
|
||||
|
||||
```bash
|
||||
ANDROID_SERIAL=emulator-5554 node packages/app/e2e/mobile/terminal-keyboard/android.mjs
|
||||
```
|
||||
|
||||
Use a real docked software keyboard. The harness taps Ctrl, Esc, and Enter and checks Android's
|
||||
focused input identity and IME hide/show events. It saves screenshots and logs under
|
||||
`.dev/agent-device-artifacts/terminal-keyboard-android`. Set `PASEO_TERMINAL_KEYBOARD_APP_ID=sh.paseo`
|
||||
to test an installed production build. It never submits a chat message.
|
||||
|
||||
When replay diverges, read its ranked selector suggestions. Edit the script deliberately and rerun it from the beginning. `--update` is retained for compatibility but no longer rewrites scripts.
|
||||
|
||||
## Maestro compatibility
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ A pairing invitation is neither. It is an expiring, single-use exchange that cre
|
||||
| `workspace.write` | Prompts, agent control, files, terminals, git operations, and scripts |
|
||||
| `workspace.manage` | Create, rename, archive, and remove projects and workspaces |
|
||||
| `automation.manage` | Schedules, heartbeats, and loops |
|
||||
| `hub.execute` | Agent lifecycle, agent/workspace observation, and workspace recovery |
|
||||
| `hub.execute` | The Hub-owned execution lifecycle |
|
||||
|
||||
Agents and terminals use workspace authority. Both can execute code and mutate the workspace, so separate write permissions would claim an isolation boundary the daemon cannot enforce.
|
||||
|
||||
|
||||
+60
-131
@@ -1,7 +1,7 @@
|
||||
# Plugins
|
||||
|
||||
Local plugins contribute daemon RPCs, native app surfaces, workspace panels, Command Center items,
|
||||
client slash commands, timeline items, header buttons, composer pills, app themes, composer attachment sources, and settings screens.
|
||||
client slash commands, timeline items, composer pills, app themes, and composer attachment sources.
|
||||
Paseo executes `index.server.ts` in a subprocess and `index.client.tsx` in every connected app.
|
||||
|
||||
> **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.
|
||||
@@ -41,7 +41,7 @@ runtime-safe: run `paseo reload` after editing `config.json`. Enabling starts ev
|
||||
enabled plugin; disabling tears them all down without restarting the daemon. Plugin source entries
|
||||
remain lifecycle-owned and do not reload from manual config edits.
|
||||
|
||||
The directory contains a manifest declaring identity and Paseo requirements, one optional entry per runtime, runtime-owned
|
||||
The directory contains an identity-only manifest, one optional entry per runtime, runtime-owned
|
||||
directories, and local typechecking support. At least one entry is required.
|
||||
|
||||
```text
|
||||
@@ -62,15 +62,10 @@ runtime modules, so consumers do not install these packages when adding the plug
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my-plugin",
|
||||
"requirements": { "paseo": ">=0.8.0" }
|
||||
"id": "my-plugin"
|
||||
}
|
||||
```
|
||||
|
||||
Declare the supported Paseo range and keep it current when adopting newer APIs. See the
|
||||
[requirements contract](../public-docs/plugins/v0.8/reference.md#requirements), including legacy
|
||||
manifests and prerelease matching.
|
||||
|
||||
The config key is the runtime plugin ID. The manifest ID is the default selected during install;
|
||||
`--id` overrides it. Existing configuration is not renamed when the manifest changes, and the
|
||||
runtime does not compare the two IDs. The same directory can be installed under several config
|
||||
@@ -96,7 +91,7 @@ paseo plugin add https://gitlab.com/group/repository.git
|
||||
paseo plugin add https://git.example.com/owner/repository.git
|
||||
paseo plugin add owner/monorepo:plugins/review
|
||||
paseo plugin add owner/repository --ref main
|
||||
paseo plugin ls
|
||||
paseo plugin status
|
||||
paseo plugin update review
|
||||
paseo plugin update --all
|
||||
```
|
||||
@@ -104,8 +99,8 @@ 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;
|
||||
tags and commits stay pinned. `ls` reports the installed commit without contacting the remote.
|
||||
Removing a Git source deletes Paseo's managed checkout.
|
||||
tags and commits stay pinned. `status` fetches tracked refs and reports the installed and available
|
||||
commits. Removing a Git source deletes Paseo's managed checkout.
|
||||
|
||||
### Declare Git preparation
|
||||
|
||||
@@ -116,7 +111,6 @@ step:
|
||||
```json
|
||||
{
|
||||
"id": "review",
|
||||
"requirements": { "paseo": ">=0.8.0" },
|
||||
"build": [
|
||||
["npm", "ci"],
|
||||
["npm", "run", "build"]
|
||||
@@ -158,46 +152,22 @@ Do not put any other code modules in the plugin root.
|
||||
|
||||
Shared files import contract helpers and types from `@getpaseo/plugin`. Server handler files import
|
||||
`PluginHandlerContext` from `@getpaseo/plugin/server`. Client files import Paseo UI from
|
||||
`@getpaseo/plugin/client/react-native`. Its `Icon` resolves a Lucide name using the client's installed icon
|
||||
`@getpaseo/plugin/react-native`. Its `Icon` resolves a Lucide name using the client's installed icon
|
||||
set; an unknown name renders nothing so it cannot break the plugin surface.
|
||||
Its controlled modal keeps presentation metadata on `<Modal title="…" icon={…}>` and body UI in
|
||||
`<Modal.Content>`. Body layout, sheet-aware scrolling, and clipboard actions follow the
|
||||
[host UI contract](../public-docs/plugins/v0.8/reference.md#host-ui).
|
||||
`<Modal.Content>`.
|
||||
Plugin UI runs on desktop and mobile across multiple themes: color every `Text` from
|
||||
`theme.colors.foreground` or `theme.colors.foregroundMuted`, and size layout from `layout.compact`.
|
||||
See `public-docs/plugins/v0.8/reference.md`.
|
||||
See `public-docs/plugins/reference.md`.
|
||||
|
||||
### SDK import boundaries
|
||||
| Module | Use it for |
|
||||
| ------------------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| `@getpaseo/plugin` | contribution contracts, shared definitions, RPC input/output types, and client data hooks |
|
||||
| `@getpaseo/plugin/react-native` | Paseo React Native components and UI hooks |
|
||||
| `@getpaseo/plugin/server` | handler-only types such as `PluginHandlerContext` |
|
||||
|
||||
Classify every SDK export before adding it. All client entry points and implementations live under
|
||||
`client/`; all server entry points and implementations live under `server/`. The package root is shared code: plain data types,
|
||||
Zod schemas, and functions that run in both runtimes. A type-only import is still an architectural
|
||||
dependency; shared types must not refer to React components, hooks, Node APIs, or server contexts.
|
||||
|
||||
| Entry | Owns | May depend on |
|
||||
| ---------------------------------------------------- | -------------------------------------------------------------------------- | ---------------------- |
|
||||
| `@getpaseo/plugin` | Shared data, schemas, RPC/settings definitions, runtime-neutral helpers | Shared code only |
|
||||
| `@getpaseo/plugin/server` | Server contribution/handler contexts and lifecycle contracts | Shared and server code |
|
||||
| `@getpaseo/plugin/server/provider`, `/server/acp` | Server provider contracts and adapters | Shared and server code |
|
||||
| `@getpaseo/plugin/client` | Client contribution contexts, hooks, navigation, and UI contribution types | Shared and client code |
|
||||
| `@getpaseo/plugin/client/react-native`, `/client/ui` | Host-provided UI components | Shared and client code |
|
||||
| `@getpaseo/plugin/client/host` | App-owned rendering integration; not a plugin-author entry | Shared and client code |
|
||||
|
||||
Server code imports shared helpers from the root and server capabilities from `/server`. Client
|
||||
code imports shared helpers from the root and client capabilities from `/client`. Neither runtime
|
||||
imports the other. Re-exports follow the same rule; tree shaking does not establish a boundary.
|
||||
React, React Native, JSX runtimes, and client hooks must never be reachable from the root or any
|
||||
server entry. Node and platform-specific code must never be reachable from the shared root.
|
||||
|
||||
The SDK boundary checks and real plugin-subprocess tests enforce these rules. Every SDK change
|
||||
must preserve them and update the public reference, migration guide, scaffold, and examples when
|
||||
an author-facing import changes. The plugin compiler enforces the same runtime entry rules for
|
||||
plugin-authored code. Keep the package export map and host-provided module maps consistent.
|
||||
|
||||
The compiler rejects imports across runtime directories or SDK entries, React dependencies in
|
||||
server code, and Node imports in client code (including bare names such as `fs`). Shared modules
|
||||
cannot import runtime-owned modules. Forbidden imports fail compilation; never replace them with
|
||||
empty module stubs. A relative
|
||||
The compiler rejects a client import of `server/`, a server import of `client/`, and every `node:`
|
||||
import reachable from client code. Shared modules cannot import runtime-owned modules. A relative
|
||||
import to any other code file in the plugin root is also rejected; move it into `client/`, `server/`,
|
||||
or `shared/`. These are compile errors naming the importing file and boundary rule. Top-level React
|
||||
Native calls such as `StyleSheet.create` belong in `client/`.
|
||||
@@ -206,12 +176,12 @@ The scaffold omits `"DOM"` from `tsconfig.json` and does not use `/// <reference
|
||||
browser globals are not available across the plugin. Put sanctioned web-only APIs in
|
||||
`client/web.ts`, declare only the globals that module uses, gate each export with
|
||||
`Platform.OS === "web"`, and provide a native implementation or no-op. See the
|
||||
[public plugin reference](../public-docs/plugins/v0.8/reference.md#works-on-mobile) for the complete
|
||||
[public plugin reference](../public-docs/plugins/reference.md#works-on-mobile) for the complete
|
||||
pattern.
|
||||
|
||||
```ts
|
||||
// index.server.ts
|
||||
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
||||
import type { PluginServerContext } from "@getpaseo/plugin";
|
||||
import { createGreeting } from "./server/greeting";
|
||||
import { greetRpc } from "./shared/greeting";
|
||||
|
||||
@@ -223,7 +193,7 @@ export default function contribute(server: PluginServerContext) {
|
||||
|
||||
```tsx
|
||||
// index.client.tsx
|
||||
import type { PluginClientContext } from "@getpaseo/plugin/client";
|
||||
import type { PluginClientContext } from "@getpaseo/plugin";
|
||||
import { Greeting } from "./client/greeting";
|
||||
|
||||
export default function contribute(client: PluginClientContext) {
|
||||
@@ -247,7 +217,7 @@ typed async function. Use the host-provided `@tanstack/react-query` for request
|
||||
Paseo gives each plugin installation its own query client.
|
||||
|
||||
`usePaseo()` and the handler's `{ paseo }` context expose the same `PaseoApi`: projects,
|
||||
workspaces, agents, terminals, providers, and daemon config. They do not expose connection lifecycle. A surface borrows the
|
||||
workspaces, agents, providers, and daemon config. They do not expose connection lifecycle. A surface borrows the
|
||||
selected host's existing connection; switching the screen's host changes both `usePaseo()` and
|
||||
`useRpc()` to that host. An offline selected host fails there and never falls through to another
|
||||
installation. A server handler owns an IPC-backed daemon session for the life of its subprocess.
|
||||
@@ -283,77 +253,48 @@ optional client-owned agent and workspace navigation; its absence is the compati
|
||||
older clients. Other navigation remains limited to registered global surfaces and workspace panels.
|
||||
Plugins do not receive Expo Router or workspace-layout store access.
|
||||
|
||||
## Lifecycle hooks
|
||||
## Contribute composer pills
|
||||
|
||||
Server entries register lifecycle observers with `server.on()` and request transforms with
|
||||
`server.before()`. The [public reference](../public-docs/plugins/v0.8/reference.md#lifecycle-hooks)
|
||||
owns callback shapes, ordering, and failure behavior. `plugin-examples/lifecycle-logger` registers all
|
||||
eleven hooks; `plugin-examples/lifecycle-actions` demonstrates common automation callbacks.
|
||||
Add and remove targeted pills from the client entry lifecycle. `index.client.tsx` runs once per
|
||||
plugin installation in each connected app and never runs in the daemon subprocess. It can subscribe
|
||||
to the client API, call plugin RPCs, and own arbitrary client state without mounting a panel or
|
||||
surface.
|
||||
|
||||
Emit from the operation owner, not a client subscription. Provider history replay must not trigger
|
||||
live hooks. Observers must not be awaited inside agent mutations: a callback can send a prompt or
|
||||
answer a permission through its own daemon session. Awaiting it there deadlocks that command.
|
||||
|
||||
## Contribute a provider
|
||||
|
||||
Register a provider from `index.server.ts`. The provider connection is callback-based and owns all
|
||||
of its sessions; plugin RPC is not part of the provider data path.
|
||||
|
||||
```ts
|
||||
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
||||
import type { ProviderRegistration } from "@getpaseo/plugin/server/provider";
|
||||
import { createProvider } from "./server/provider";
|
||||
|
||||
export default function contribute(server: PluginServerContext) {
|
||||
server.registerProvider(createProvider() satisfies ProviderRegistration);
|
||||
return () => {};
|
||||
```tsx
|
||||
export function contributeClient(client: PluginClientContext) {
|
||||
const pills = new Map<string, () => void>();
|
||||
const unsubscribe = client.paseo.agents.subscribe((update) => {
|
||||
if (update.kind !== "upsert" || !update.agent.workspaceId) return;
|
||||
const { id: agentId, workspaceId } = update.agent;
|
||||
pills.get(agentId)?.();
|
||||
pills.set(
|
||||
agentId,
|
||||
client.addComposerPill({
|
||||
id: "review",
|
||||
title: "Open review",
|
||||
workspaceId,
|
||||
agentId,
|
||||
Component: ReviewPill,
|
||||
async onPress() {
|
||||
await client.rpc(refreshReview, { agentId });
|
||||
client.openPanel("review", { workspaceId, agentId });
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
for (const remove of pills.values()) remove();
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Implement optional `ProviderRegistration.getCatalogCacheKey(options)` to share equivalent catalogue
|
||||
probes. The callback runs in the plugin process before discovery and receives the actual global or
|
||||
workspace target. Return a key covering effective configuration and execution environment, or
|
||||
`undefined` for target-specific caching. Ignore `force` when choosing identity. Existing providers
|
||||
need no change. See [catalogue ownership](providers.md#provider-snapshot-refresh-contract).
|
||||
|
||||
`send()` resolves after acceptance. Publish operation completion, prompt disposition, turn state,
|
||||
configuration, permissions, persistence, and complete timeline snapshots through `onEvent()`.
|
||||
Route messages, structured commands, steering, and command side effects through `session.prompt`.
|
||||
Provider settings are toggle/select data that Paseo renders in the composer. Keep private options in
|
||||
the opaque `providerOptions` config object.
|
||||
|
||||
Agent refresh closes the current provider session and opens it again with current configuration and
|
||||
persistence. Providers re-read credentials, environment, global configuration, and MCP servers on
|
||||
`session.open`; there is no provider reload input.
|
||||
|
||||
For an ACP command, register `runAcpProvider({ id, label, command })` from
|
||||
`@getpaseo/plugin/server/acp`. Its transformer hooks cover narrow vendor differences; do not translate the
|
||||
whole provider event stream. The direct and ACP examples live in `plugin-examples/provider-direct`
|
||||
and `plugin-examples/provider-acp-transformer`.
|
||||
|
||||
Provider-emitted plugin timeline items use the same renderer registration as transformed and
|
||||
daemon-appended plugin items. The direct example includes both sides. The renderer-only
|
||||
`plugin-examples/inline-thinking` example shows that timeline presentation remains independent of a
|
||||
provider implementation. The public [provider plugin guide](../public-docs/plugins/v0.8/providers.md)
|
||||
owns author workflow, lifecycle, testing, and distribution guidance.
|
||||
|
||||
`ProviderRegistration.icon` is a file path relative to the plugin directory, such as `icon.svg`.
|
||||
It must resolve inside that directory to a regular SVG file no larger than 64 KiB. The SVG must be
|
||||
self-contained: scripts, styles, `foreignObject`, event-handler attributes, JavaScript URLs, and
|
||||
external `href` or `xlink:href` references are rejected. Fragment references such as `#mark` are
|
||||
allowed. Paseo reads and sanitizes the file when the plugin starts; the string is never an inline
|
||||
SVG or URL.
|
||||
|
||||
## Contribute buttons
|
||||
|
||||
Header buttons and composer pills share the client-only descriptor and registration lifecycle in
|
||||
`packages/app/src/plugins/buttons/`. The [public button reference](../public-docs/plugins/v0.8/reference.md#header-buttons)
|
||||
owns the author API and placement rules. Keep presentation policy in this module so another
|
||||
placement can reuse behavior without copying registration or action state.
|
||||
|
||||
Native sheets teleport their children. Button surfaces rebuild the installation's SDK, state,
|
||||
query, and toast providers inside the surface content, including overflow pages from different
|
||||
plugins. Providers only around the trigger do not reach those bodies.
|
||||
Call `contributeClient(client)` from `index.client.tsx`, or move its body into that entry.
|
||||
`addComposerPill` returns an idempotent removal function. A pill appears only in the
|
||||
matching workspace and agent track bar alongside Tasks and Subagents. Paseo owns the pressable,
|
||||
shared chrome, pending state, error reporting, and placement. The component owns its icon and text;
|
||||
the callback is client code by construction. Removing the pill, reloading the plugin, disconnecting
|
||||
the host, or unloading the app tears down the contribution.
|
||||
|
||||
## Contribute timeline items
|
||||
|
||||
@@ -427,7 +368,7 @@ credentials and vendor API calls stay in the daemon handler.
|
||||
|
||||
```ts
|
||||
// index.server.ts
|
||||
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
||||
import type { PluginServerContext } from "@getpaseo/plugin";
|
||||
import { search } from "./server/issues";
|
||||
import { searchIssues } from "./shared/issues";
|
||||
|
||||
@@ -439,7 +380,7 @@ export default function contribute(server: PluginServerContext) {
|
||||
|
||||
```tsx
|
||||
// index.client.tsx
|
||||
import type { PluginClientContext } from "@getpaseo/plugin/client";
|
||||
import type { PluginClientContext } from "@getpaseo/plugin";
|
||||
import { issues } from "./shared/issues";
|
||||
|
||||
export default function contribute(client: PluginClientContext) {
|
||||
@@ -453,18 +394,6 @@ on several hosts are not coalesced. The selected snapshot submits as a text atta
|
||||
external-resource presentation, so it remains readable if the plugin is removed or an older peer
|
||||
drops the optional presentation fields.
|
||||
|
||||
## Contribute settings
|
||||
|
||||
Register ordinary components with `client.addSettingsScreen` and open them with `openSettings`.
|
||||
The host settings shell owns navigation and layout; plugin content must not add another page
|
||||
scroll view or header. See the [author contract](../public-docs/plugins/v0.8/reference.md#settings-screens)
|
||||
and `plugin-examples/settings` for the named UI components and persistence API.
|
||||
|
||||
Settings storage is scoped to the runtime installation ID, never the source path or manifest ID.
|
||||
Its writer lives with the plugin subprocess, while its directory lives outside managed sources,
|
||||
so updates and reloads retain values. Settings-change notifications must not enter the catalog
|
||||
reload path: that path disposes the plugin and would destroy open drafts after every save.
|
||||
|
||||
## Contribute a theme
|
||||
|
||||
`addTheme` takes a small light or dark palette and a display name. Paseo expands it through the
|
||||
@@ -488,7 +417,7 @@ stable rather than arrival-ordered. The app resolves that id
|
||||
against the installed catalog on every change; an id nothing contributes falls back to the default
|
||||
preference instead of painting the reserved slot's placeholder colors.
|
||||
|
||||
Existing plugin authors should follow the standalone [v0.8 runtime-entry migration guide](../public-docs/plugins/v0.8/migration.md).
|
||||
Existing plugin authors should follow the standalone [runtime-entry migration guide](../public-docs/plugins/migration.md).
|
||||
|
||||
See `plugin-examples/local-plugin` for a native surface, `plugin-examples/linear` for a complete
|
||||
attachment-source example, `plugin-examples/timeline-items` for timeline projection, and
|
||||
|
||||
+53
-74
@@ -1,102 +1,81 @@
|
||||
# Product
|
||||
|
||||
Paseo is an environment for running, monitoring, and interacting with coding agents across desktop, mobile, web, and the command line.
|
||||
What Paseo is, who it's for, and where it's going.
|
||||
|
||||
**Paseo has a lean, opinionated core built to be extended.** It should be easy to start using and leave room for people to build far beyond the default experience.
|
||||
## What is Paseo
|
||||
|
||||
## Agents are the focus
|
||||
Paseo is a next-generation development environment built around agents. One interface to run, monitor, and interact with coding agents across desktop, mobile, terminal, and web.
|
||||
|
||||
The central workflow is giving an agent a task, understanding what it is doing, providing direction, and reviewing the result. Files, terminals, diffs, and other supporting tools help you do that.
|
||||
The development workflow is shifting from manually editing files to orchestrating agents that do the editing. Paseo is built for that workflow.
|
||||
|
||||
Paseo should make this workflow coherent across providers and devices. You should be able to use the agent that fits a task and keep working from another device without rebuilding your environment around it.
|
||||
## Core philosophy
|
||||
|
||||
This is the basis for product decisions. A familiar feature from another development tool earns its place by improving that experience. Familiarity alone does not make it a requirement for Paseo.
|
||||
Freedom and flexibility. Every design decision follows from this:
|
||||
|
||||
## A low floor and a high ceiling
|
||||
- **Multi-provider** — Use any coding agent harness. Pick the right model for each job, switch freely as the landscape shifts. No vendor lock-in.
|
||||
- **Cross-device** — Desktop, mobile, web, CLI. Start work at your desk, check progress from your phone, script from the terminal.
|
||||
- **Self-hosted** — The daemon runs on your machine. Your code, your keys, your environment. No inference markup, no cloud dependency.
|
||||
- **Respectful** - No telemetry, no forced cloud, no forced accounts
|
||||
- **Open source** — Apache-2.0. Users can inspect, fork, and contribute.
|
||||
- **BYOK** — Bring your own keys. Use your subsidized plans and first-party provider pricing. Paseo adds zero cost on top.
|
||||
|
||||
### Easy to start
|
||||
## How it works
|
||||
|
||||
The default experience should be lean, polished, and understandable to someone who has never run a server or configured a VPN.
|
||||
### Projects and workspaces
|
||||
|
||||
With a supported coding agent installed and authenticated, you can open the desktop app and start working. The app manages its local daemon. To connect your phone through the optional Paseo relay, enable pairing and scan a QR code. You do not need to configure a VPN or understand the networking behind it.
|
||||
Projects are grouped in the sidebar, detected automatically from your filesystem and tagged by git remote when available.
|
||||
|
||||
You should not need to know that plugins exist to get a useful, complete experience. Extra capability should become discoverable when it helps with something you want to do.
|
||||
Each project opens as a workspace. For git projects, the default workspace is the main checkout. Users can create additional workspaces, which are isolated copies (git worktrees) where agents work without affecting main.
|
||||
|
||||
Keeping this starting point approachable is an ongoing responsibility. A technically correct feature can still make the product harder to understand through extra choices, controls, or setup.
|
||||
### Inside a workspace
|
||||
|
||||
### Room to build your own setup
|
||||
A workspace is a flexible canvas:
|
||||
|
||||
The same product should also work for someone running agents on a home server, using their own network, or building an automated workflow.
|
||||
- Launch multiple agents side by side in split panes
|
||||
- Open terminals alongside agents
|
||||
- Mix and match providers within the same workspace
|
||||
|
||||
The daemon can run independently of the desktop app. Clients connect to it locally or remotely, and multiple clients can use the same daemon. This separation lets you choose where the work runs and how you interact with it.
|
||||
### The daemon
|
||||
|
||||
For example, someone can use the relay to connect their phone with minimal setup. Someone else can run a daemon on their own infrastructure and connect through SSH or a VPN. Both use the same underlying system.
|
||||
Paseo is a client-server system. The daemon (Node.js) runs on your machine, manages agent processes, and streams output in real time over WebSocket. Clients connect to the daemon — locally or remotely.
|
||||
|
||||
Paseo should provide useful defaults while keeping these choices available. You should not have to adopt every part of the system to benefit from the parts you want.
|
||||
This architecture means:
|
||||
|
||||
See the [connectivity guide](https://paseo.sh/docs/connectivity) for supported connection methods and [architecture](architecture.md) for the system design.
|
||||
- The daemon can run on any machine: laptop, VM, remote server
|
||||
- Multiple clients can connect simultaneously
|
||||
- Agents keep running when a client disconnects — the daemon owns them, not the client
|
||||
- Quitting the desktop app stops the daemon it started, so "restart the app" is a real fix; a daemon you run yourself is unaffected
|
||||
|
||||
## Freedom, ownership, and privacy
|
||||
## Target user
|
||||
|
||||
Paseo's flexibility depends on keeping control with the user:
|
||||
Anyone who builds software:
|
||||
|
||||
- **Self-hosted:** Run agents on your own machines, with your own environment, files, and credentials.
|
||||
- **Provider choice:** Use supported agent harnesses and extend provider support through plugins. Your workflow should be able to evolve as your choice of provider changes.
|
||||
- **Cross-device:** Use desktop, mobile, web, and CLI clients according to the situation.
|
||||
- **Privacy:** No Paseo telemetry, tracking, or forced account. The relay is optional and end-to-end encrypted.
|
||||
- **Open source:** Paseo is Apache-2.0 licensed. You can inspect it, modify it, and build on it.
|
||||
- Care about owning their tools and their data
|
||||
- Use multiple AI providers and want to switch freely
|
||||
- Run agents on real tasks across real projects
|
||||
- Want to work from multiple devices
|
||||
|
||||
Convenience and ownership should work together. Offering a straightforward way to connect should preserve the option to operate your own setup.
|
||||
## What compounds over time
|
||||
|
||||
## A lean core that keeps improving
|
||||
- **Trust** — Showing up daily, shipping in public, being open source. Earned slowly, lost quickly.
|
||||
- **Community contributions** — Code, packaging, skills, agent configs. Contributors become advocates.
|
||||
- **Ecosystem** — Skills, integrations, shared configs. Community-built content that makes the platform more valuable.
|
||||
|
||||
The core provides the shared experience: starting work, interacting with agents, understanding their state, and moving between workspaces and devices.
|
||||
## Strategic bets
|
||||
|
||||
Core development continues through improvements to reliability, performance, interaction design, and workflows with broad value. A lean core takes ongoing work.
|
||||
1. **Models commoditize.** Value moves to the orchestration layer. The best model changes monthly — the workflow layer stays.
|
||||
2. **Multi-provider wins.** No single provider stays on top. Developers want the best model for each task.
|
||||
3. **The daemon as infrastructure.** Server/client architecture enables deployment anywhere.
|
||||
4. **Open source outlasts funding.** Open source communities are resilient. Contributors become advocates.
|
||||
|
||||
Every addition also has a lasting cost. It needs a place in the interface, an understandable relationship with existing behavior, testing across affected platforms, and maintenance as the rest of the product changes. A feature can work exactly as intended and still be the wrong addition.
|
||||
## Current state (May 2026)
|
||||
|
||||
The preference is for a small number of well-shaped capabilities that work together. Before adding a specialized workflow, consider whether an existing capability can serve it or whether a reusable extension point would let people build it themselves.
|
||||
|
||||
The boundary can evolve. Some built-in capabilities may eventually make more sense as plugins. Any such move needs to preserve a coherent default experience and account for people who already depend on it.
|
||||
|
||||
## Extensibility and composition
|
||||
|
||||
Plugins are a central part of how Paseo gains capability.
|
||||
|
||||
Someone who needs a different agent provider, a specialized workspace panel, or a custom command should be able to build and share it independently. They can make the choices that fit their users and maintain the integration on their own schedule.
|
||||
|
||||
Paseo's role is to provide interfaces that make those contributions possible across the daemon and connected clients. The [plugin documentation](https://paseo.sh/docs/plugins) describes what each released API supports.
|
||||
|
||||
The [SDK](https://paseo.sh/docs/sdk) lets applications and services use the daemon directly. A separate dashboard, automation service, or client can build on the same agent infrastructure. Those projects do not all need to become features of the main app.
|
||||
|
||||
Composition means useful pieces can be combined in ways the maintainer did not have to predict. Keeping the daemon independent, offering multiple connection methods, and exposing agent operations through APIs gives people room to choose those combinations.
|
||||
|
||||
The extension interfaces need the same care as the core experience. They should be understandable, reusable, and maintainable. New extension points should follow concrete workflows that need them.
|
||||
|
||||
## How Paseo develops
|
||||
|
||||
Product, design, architecture, and workflow decisions remain with the maintainer.
|
||||
|
||||
Those decisions draw on the history of the product: why a workflow has its current shape, what previous approaches taught us, how it interacts with the rest of the system, and where it is intended to go. Documentation shares that context, but familiarity also develops through sustained use and maintenance.
|
||||
|
||||
Development starts with the smallest useful change that can make a substantial difference. After shipping it, leave room to learn:
|
||||
|
||||
- Can people discover and understand it?
|
||||
- Does it work well in their actual workflows?
|
||||
- What friction or missing capability appears with repeated use?
|
||||
- Does the next addition improve the shared experience or belong in a plugin?
|
||||
|
||||
That feedback guides the next step. Expanding a workflow too far before people use it creates commitments that are difficult to reverse. Users build habits and integrations around what ships, so removing features or changing interfaces has a real cost.
|
||||
|
||||
Writing an implementation is one part of this process. Product judgment, design, QA, review, and learning from use determine how quickly a change can responsibly become part of Paseo.
|
||||
|
||||
## An ecosystem people can build on
|
||||
|
||||
People contribute through reproducible bug reports, testing, workflow discussions, documentation, plugins, integrations, and helping each other.
|
||||
|
||||
A plugin can solve a problem for its users without adding that workflow to everyone's default experience. An alternative client can explore a different interface while using the same daemon. These projects let people take ownership of the experience they want.
|
||||
|
||||
The aim is an ecosystem that can develop independently of the maintainer's ability to review core changes. Its value comes from useful things people build, maintain, and share.
|
||||
|
||||
Paseo is an independent project. Sustainable development, a coherent product, and the freedom to keep building are priorities. See [Contributing](../CONTRIBUTING.md) for how to participate.
|
||||
- Desktop (Electron), mobile (iOS/Android), web, CLI
|
||||
- Built-in providers: Claude Code (Agent SDK), Codex (app-server), GitHub Copilot (ACP), OpenCode, Pi, OMP
|
||||
- One-click ACP provider catalog: CodeWhale, Cursor, Hermes, Qwen Coder, Kimi Code, and others — plus custom ACP providers
|
||||
- Voice mode: dictate prompts or talk through problems hands-free
|
||||
- MCP server exposes the daemon to other agents (workspaces, create/detach agent, schedules, heartbeats, terminals, workspace renaming)
|
||||
- Scheduled agents (cron-style triggers) via app, CLI, and MCP
|
||||
- Frequent releases (multiple per week)
|
||||
- Community contributions across packaging, providers, and bug fixes
|
||||
- Key UX: split panes, keybinding customization, workspace model, in-app browser
|
||||
|
||||
@@ -38,18 +38,6 @@ The app checks for the capability and either runs the feature or tells the user
|
||||
|
||||
Existing functionality keeps working across versions because of the protocol contract. Gating a new feature never substitutes for that.
|
||||
|
||||
## Client capability ownership
|
||||
|
||||
The client package advertises the protocol behavior it implements. Add each new capability to
|
||||
its exhaustive defaults and implement the associated subscription or decoding behavior there.
|
||||
The app, CLI, and plugins inherit those defaults; they supply only host resources such as browser
|
||||
automation, or explicit overrides. A schema accepting a message does not establish support for
|
||||
its delivery semantics.
|
||||
|
||||
Connecting creates no timeline or event demand. Client subscriptions own their network membership,
|
||||
release it on unsubscribe, and restore it after reconnect. Raw message observers inspect traffic
|
||||
without requesting streams. Application caches and which agents are visible remain caller-owned.
|
||||
|
||||
## Every shim is tagged and dated
|
||||
|
||||
A shim that exists for old-app or old-daemon support carries a comment naming it, the version it arrived in, and when it can go:
|
||||
|
||||
+7
-46
@@ -57,9 +57,7 @@ Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`
|
||||
|
||||
Claude first-party model metadata lives in `packages/server/src/server/agent/providers/claude/model-manifest.ts`. When adding or updating a Claude model, update that manifest only; the model picker thinking options and Claude-specific feature gates are derived from the manifest. Do not add model-specific Claude capability lists in feature code.
|
||||
|
||||
Paseo tools are not implemented as MCP tools internally. They live in a shared tool catalog under `packages/server/src/server/agent/tools/`; MCP is only the fallback adapter. The daemon resolves `agents.providers.<provider>.paseoTools` by the exact provider ID. The catalog policy belongs to the caller: it filters the tools exposed to the current agent. When that agent calls `create_agent`, the child receives the policy for the child provider ID; the caller's policy is not inherited.
|
||||
|
||||
A provider that can register runtime tools directly should set `supportsNativePaseoTools: true` and consume the already-filtered `launchContext.paseoTools` in `createSession`/`resumeSession`. When native tools are present, `AgentManager` strips the internal Paseo MCP server from the provider launch config so the provider does not receive the same tools twice. Providers that only know MCP should keep `supportsMcpServers: true` and let the daemon inject `/mcp/agents`; the MCP server builds the same policy-filtered catalog for that caller. Filtering is enforced at catalog registration in both paths. Browser tools remain subject to the daemon browser-tools setting and browser-host availability.
|
||||
Paseo tools are not implemented as MCP tools internally. They live in a shared tool catalog under `packages/server/src/server/agent/tools/`; MCP is only the fallback adapter. A provider that can register runtime tools directly should set `supportsNativePaseoTools: true` and consume `launchContext.paseoTools` in `createSession`/`resumeSession`. When native tools are present, `AgentManager` strips the internal Paseo MCP server from the provider launch config so the provider does not receive the same tools twice. Providers that only know MCP should keep `supportsMcpServers: true` and let the daemon inject `/mcp/agents`.
|
||||
|
||||
Pi is a process-backed provider. Paseo requires the user to have the `pi` binary installed and talks to it through `pi --mode rpc`; the server package does not embed Pi's SDK/runtime packages.
|
||||
|
||||
@@ -115,18 +113,7 @@ Daemon bootstrap reconciles that ledger in the background, without blocking star
|
||||
|
||||
## Provider Snapshot Refresh Contract
|
||||
|
||||
Provider snapshots are views of the catalogues needed for a target. Before looking up cached
|
||||
results or discovery in flight, the manager calls optional `AgentClient.getCatalogCacheKey(options)`.
|
||||
The provider owns equivalence: equal keys must mean the same availability, models and modes,
|
||||
including the effective configuration and execution environment. `force` does not change identity.
|
||||
Omitting the method or returning `undefined` keeps target-specific caching. Codex and Claude's
|
||||
current host clients share across directories; configured provider identities remain isolated.
|
||||
|
||||
The key chooses storage, never execution. Availability and catalogue discovery receive the actual
|
||||
`{ scope: "global", force }` or `{ scope: "workspace", cwd, force }` request, including when another
|
||||
target can share its result. Runtime-aware adapters must use that target for both probing and key
|
||||
resolution. An explicit home-directory workspace is distinct from a semantic global request.
|
||||
Plugin callbacks follow the same contract; see [plugin providers](plugins.md#contribute-a-provider).
|
||||
The daemon keeps provider snapshots per resolved working directory, with a separate semantic global scope for settings/provider management and requests that do not carry a cwd. Provider catalog probes receive a discriminated `FetchCatalogOptions`: `{ scope: "global", force }` for global catalog refreshes, or `{ scope: "workspace", cwd, force }` for project-scoped refreshes. Providers decide what global means for their runtime; do not infer global by comparing a cwd to the user's home directory.
|
||||
|
||||
`ProviderSnapshotManager` owns one refresh deadline per provider. The deadline starts before the
|
||||
availability check and covers that check plus the complete catalog probe. Providers that make
|
||||
@@ -135,41 +122,15 @@ aborts the shared refresh signal at the deadline. Providers name active catalog
|
||||
finish subprocess, server, or session cleanup before rejecting. Timeout errors list the operations
|
||||
that were still active when the deadline expired.
|
||||
|
||||
Catalogue results stay cached by identity until explicit refresh or a change to that provider's configuration.
|
||||
Keys are resolved on each read so project configuration can select a different cached catalogue.
|
||||
Selector opening may read a loading or stale query, but does not force provider probing.
|
||||
Snapshot reads may probe providers only while the requested cwd scope is cold. Once an entry is warm, its `ready`, `error`, or `unavailable` state stays cached until an explicit refresh. Do not add TTL revalidation, focus-triggered refreshes, selector-open refreshes, or config-reload refreshes. Selector-open refetches may read an already-loading or stale React Query, but they must not force provider probing on their own.
|
||||
|
||||
Saved provider/model choices are user intent. Catalogue failure must not erase them or substitute
|
||||
another model. Creation reads the caller's host and directory directly; an earlier global snapshot
|
||||
must not settle a project form's initial selection.
|
||||
Capable clients receive a compact, content-addressed snapshot. Model rows derive their provider from the containing entry and reference snapshot-level thinking sets. The app persists that compact shape per server and cwd, then sends its hash on the next pull; an unchanged response carries no catalog body. Keep the legacy encoding for clients without the capability. The hash covers the complete client-visible compact snapshot, including status and `fetchedAt`, so explicit refreshes invalidate it even when the discovered catalog is otherwise equal.
|
||||
|
||||
The server's provider keys never cross the wire. Clients advertising `provider_snapshot_references`
|
||||
receive directory-to-hash announcements; an unknown hash uses the existing snapshot request.
|
||||
The manager detaches each fresh result once and publishes its content identity with the entry;
|
||||
readers share these values read-only. Each target keeps its published snapshot until membership,
|
||||
content or discovery freshness changes. All affected targets commit before subscribers run;
|
||||
subscriber failures are logged without changing discovery or configuration outcomes.
|
||||
The session compares both sides of a committed transition under the client's current visibility
|
||||
policy and sends only visible changes. It combines result identities with the client's encoding
|
||||
and icon policy, without traversing models. Reference hashes exclude freshness;
|
||||
legacy embedded hashes include it. Only responses that send a body compact the catalogue.
|
||||
Per-provider `fetchedAt` travels separately for reference clients and still means when discovery succeeded. `generatedAt`
|
||||
remains the time the response or announcement was generated. Refresh keeps settled entries visible
|
||||
until the next result, so unchanged discovery sends freshness without another model body.
|
||||
Settings refresh is the user-facing "forget stale provider knowledge everywhere" action. A settings refresh clears provider snapshot caches and in-flight loads across all cwd scopes, then immediately refreshes only the global snapshot with `force: true`. Workspace snapshots are re-probed lazily on the next scoped read; do not fan out a settings refresh across every known workspace.
|
||||
|
||||
The app's snapshot cache owns compact expansion and stores one body per server/hash, with separate
|
||||
directory/hash/freshness records under the same byte budget. Missing bodies share a React Query
|
||||
request; directory queries cancel superseded pulls before accepting pushes. Default SDK clients
|
||||
keep expanded entries and full updates. Only callers that own materialization opt into wire snapshots.
|
||||
Keep the full encoding for older clients; compact-snapshot support alone does not imply reference support.
|
||||
Registry/config replacement may update visible metadata such as label, description, default mode, enabled state, and provider membership, but it must not spawn provider processes. If a provider needs to be re-probed after a config change, route that through the explicit settings refresh path.
|
||||
|
||||
Settings refresh invalidates requested providers across known targets and refreshes them in their
|
||||
actual execution context, so every connected client receives the refreshed view. Discovery shares
|
||||
work by provider key and admits at most four concurrent catalogue probes per configured provider, so a stalled provider does not block discovery for others. Configuration replacement invalidates
|
||||
only changed providers; unchanged entries, clients, and discovery in flight survive. Preparation
|
||||
leaves installed reads untouched until commit. Plugin replacement uses registration runtime
|
||||
identity, so unchanged registrations and builtins keep their results. Await the refresh or warmup
|
||||
promise for completion: equal results, including equal discovery timestamps, emit no transition.
|
||||
Boundary tests should assert observable behavior: cold reads may call provider availability/model/mode discovery for that scope; warm reads and registry replacement must not; explicit workspace refreshes affect only one cwd; settings refresh wipes all scopes but immediately refreshes only global.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -11,9 +11,6 @@ The bar is four questions:
|
||||
|
||||
Every pull request needs evidence for the questions its change touches. Pull requests without evidence get closed.
|
||||
|
||||
For plugin changes, verify the [SDK import boundaries](plugins.md#sdk-import-boundaries) before
|
||||
exercising contributions. Include the boundary tests and both runtime loaders in the evidence.
|
||||
|
||||
## Evidence
|
||||
|
||||
Evidence is something someone else can look at:
|
||||
|
||||
@@ -253,15 +253,6 @@ If you ship N+1 while N is still ramping, N+1 starts a fresh rollout from its ow
|
||||
|
||||
If N+1 is a hotfix for a bug in N, dispatch `desktop-rollout.yml -f tag=v0.1.<N+1> -f rollout_hours=0` after N+1 publishes so the users who already got N reach the fix fast.
|
||||
|
||||
### macOS system floor
|
||||
|
||||
The desktop app requires macOS 13 or newer. Keep both release guards when the floor changes:
|
||||
|
||||
- `packages/desktop/electron-builder.yml` writes the macOS version to `LSMinimumSystemVersion` for new installs.
|
||||
- `scripts/merge-mac-manifest.mjs` writes the matching Darwin kernel version to `minimumSystemVersion` in the update manifest. Existing clients check this before downloading an update.
|
||||
|
||||
macOS 13 maps to Darwin 22. The two values use different version domains; do not copy the macOS version into the update manifest.
|
||||
|
||||
### Limitations
|
||||
|
||||
- **No pause / kill switch.** To stop new admissions, ship a superseding release. Clients revalidate on quit and will not install the superseded download, but a client that already completed installation cannot be recalled; ship a hotfix `+1` patch.
|
||||
@@ -486,8 +477,6 @@ Release notes depend on the changelog heading format. The heading **must** be st
|
||||
|
||||
No prefix (`v`), no extra text. `Release Notes Sync` matches the `## X.Y.Z` (or `## X.Y.Z-beta.N`) line for the pushed tag to extract the version. A malformed heading breaks the release-notes sync for that tag.
|
||||
|
||||
`CHANGELOG.md` on `main` is also what the app's **What's new** sheet fetches and renders, so the file is a shipped product surface, not just a release input. `##` starts a release and `###` starts a section; the app reads section titles from the document, so renaming or adding one needs no app change. Everything under a section is rendered as Markdown: prose, lists, links, inline code, fenced code, block quotes, tables, and images. Raw HTML does not render — the shared Markdown parser runs with `html: false`, so a `<video>`, `<iframe>` or `<embed>` tag reaches the reader as visible markup. Keep media out of the changelog, or link to it. A GitHub callout renders as a block quote with its `[!NOTE]` marker still in the text. A release entry is what a user reads on a phone the moment they are offered the update — write it for them.
|
||||
|
||||
## Changelog policy
|
||||
|
||||
- `CHANGELOG.md` includes stable releases and every entry in the current beta series.
|
||||
@@ -645,7 +634,6 @@ Each beta entry records what its testers receive. Promotion produces the single
|
||||
- [ ] The GitHub Release was published only after the three stable manifests were uploaded, and it has the changelog body and every expected macOS, Linux, Windows, and Android APK asset
|
||||
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
|
||||
- [ ] The GitHub Release contains `latest-mac.yml`, `latest-linux.yml`, and `latest.yml`
|
||||
- [ ] `latest-mac.yml` contains the current `minimumSystemVersion` guard
|
||||
- [ ] GitHub `Android APK Release` workflow for the same tag is green
|
||||
- [ ] GitHub `Docker` workflow is green and both the versioned and `latest` images are published
|
||||
- [ ] GitHub `Release Notes Sync` is green and the release body matches the stable changelog entry
|
||||
|
||||
@@ -65,18 +65,12 @@ Codex hook mapping:
|
||||
- `PermissionRequest` → `needs-input`
|
||||
- `Stop` → `idle`
|
||||
|
||||
OpenCode uses a server plugin instead of command hooks. Both generations discover the same global plugin file. Their loaders select separate entrypoints: OpenCode 1 calls `server()` with `{ type, properties }` bus events; OpenCode 2 calls `setup()` and subscribes to decoded `{ type, data }` events. Do not share their status mapping: V1 publishes `session.status` snapshots, while V2 publishes `session.execution.*` transitions.
|
||||
OpenCode uses a server plugin instead of command hooks. The plugin listens to OpenCode bus events and emits these Paseo hook events:
|
||||
|
||||
| OpenCode event | Generation | Activity |
|
||||
| ----------------------------------------------------------- | ---------- | ----------- |
|
||||
| `session.status` with `busy` or `retry` | 1 | running |
|
||||
| `session.status` with `idle` | 1 | idle |
|
||||
| `session.execution.started` | 2 | running |
|
||||
| `session.execution.succeeded`, `.failed`, or `.interrupted` | 2 | idle |
|
||||
| `permission.asked` | Both | needs-input |
|
||||
| `permission.replied` | Both | running |
|
||||
|
||||
The plugin translates both event contracts into the existing Paseo hook events. OpenCode 2 disposes its event subscription when the plugin unloads.
|
||||
- `session.status` with `busy` or `retry` → `running`
|
||||
- `session.status` with `idle` → `idle`
|
||||
- `permission.asked` → `needs-input`
|
||||
- `permission.replied` → `running`
|
||||
|
||||
The daemon maps hook states onto terminal activity like an agent lifecycle plus unread attention: `running` → `state: working`, `idle` → `state: idle`, and `needs-input` → `state: idle` with `attentionReason: needs_input`. A `working` → `idle` transition records `state: idle` with `attentionReason: finished` until the user focuses that terminal; plain idle terminals still contribute no workspace status.
|
||||
|
||||
|
||||
@@ -152,7 +152,7 @@ remaining page through the existing stream reducer. It must not append full proj
|
||||
live prefix.
|
||||
|
||||
Every path that sends a message to an agent — composer send, dictation accept-and-send, queued
|
||||
send-now, and the host runtime's automatic queue drain — goes through
|
||||
send-now, and the automatic queue drain in `HostRuntime` — goes through
|
||||
`dispatchComposerAgentMessage` with a submission writer. There is no second transport for the same
|
||||
product action: calling `client.sendAgentMessage` directly skips the submitted row and the pending
|
||||
footer, and permanently drops attachments because the daemon does not echo them back.
|
||||
@@ -196,9 +196,9 @@ fork positions retain the canonical `turnId` boundaries.
|
||||
The compatibility boundary for older daemons is snapshot normalization: running/idle status becomes an
|
||||
anonymous active turn or idle state once, and downstream code consumes the same activity shape. The app
|
||||
does not combine anonymous lifecycle events, timestamps, timeline rows, and resume coverage to infer a
|
||||
second running state. Disconnect preserves the last replicated turn until cache or network hydration
|
||||
advances it; replica removal remains the destructive close boundary. Elapsed time comes only from turn
|
||||
liveness, never from submission records or whichever timeline rows happen to be mounted.
|
||||
second running state. Disconnect and replica removal remain destructive close boundaries. Elapsed time
|
||||
comes only from turn liveness, never from submission records or whichever timeline rows happen to be
|
||||
mounted.
|
||||
|
||||
The daemon records one canonical submitted user row at acceptance. Its wire `messageId` is the
|
||||
submission's `clientMessageId`, so the row is born with its final identity and remains immutable on
|
||||
|
||||
+1
-14
@@ -346,22 +346,9 @@ Gotchas:
|
||||
- **Narrow the discriminated union before spreading.** `updateTheme`'s updater returns the theme union; spreading the union widens `colorScheme` to `"light" | "dark"`, which is assignable to neither concrete member. Branch on `t.colorScheme` so each branch spreads a single narrowed theme type (no `as`).
|
||||
- **`lineHeight.diff` is the code/diff line-height axis** — it is coupled to the code-font-size control (≈ `codeFontSize * 1.5`). Do NOT use it for prose. Markdown body line-height scales with content size (`Math.round(theme.fontSize.content * 1.4)`); routing prose through `lineHeight.diff` clips text at small code sizes.
|
||||
- **High-churn draft values** (live-while-typing in the appearance preview) bypass the theme: apply them as inline styles marked with `inlineUnistylesStyle` so per-keystroke values don't grow the `#unistyles-web` CSS registry.
|
||||
- **Web numeric appearance tokens need a render; native tracked styles update in place.** On web, string tokens such as colors become CSS variables, but numeric tokens (font sizes, line heights) are baked into generated classes. `AppearanceStyleBoundary` remounts web shell chrome and explorer content to refresh those classes. Native sidebars and explorer hosts stay outside appearance keys: their gesture refs outlive detached dependents until Gesture Handler's passive cleanup, so remounting a related gesture can query an unmounted view. Their tracked native styles and existing themed leaf props update without replacing the gesture hosts or draggable lists. Parsed PR markdown uses its own `withUnistyles` style mapping.
|
||||
- **Appearance keys belong below native lifetime owners.** `ThemedStack` wraps screen content through `screenLayout`, preserving navigator identity; nested navigators are listed in `nestedNavigatorScreens` and own their own screen boundaries. The remaining shell surfaces retain their appearance refresh separately from native panel hosts. A key above a native stack can detach its screen container inside a FragmentManager transaction during settings hydration. Keep both navigators and retained panel gesture hosts outside those keys. `applyAppearance` patches the active theme before inactive registry entries so subscribers receive the committed values in the same update. Do not add local appearance keys or a boundary above a navigator.
|
||||
- **The app shell uses one `AppearanceStyleBoundary`.** Runtime-patched numeric theme values are baked into Unistyles web classes rather than CSS variables, while parsed/memoized content also does not naturally re-run when appearance tokens change. The boundary sits below stable runtime providers in `app/_layout.tsx` and remounts the visual shell once. `applyAppearance` patches the active theme before inactive registry entries so its subscribers receive the committed values in the same update. Do not add local appearance keys or nested boundaries.
|
||||
- **Dynamic font tokens stay widened.** `fontFamily`, `fontSize`, and `lineHeight` on `commonTheme` are annotated `string`/`number` (not narrowed by `as const`) so the updater's return assigns; the platform default stacks live in `DEFAULT_UI_FONT_STACK` / `DEFAULT_MONO_FONT_STACK`.
|
||||
|
||||
## Patching The Web Runtime
|
||||
|
||||
When backporting a Unistyles web fix, patch the TypeScript source and both
|
||||
shipped JavaScript builds. Native Metro resolves the package's `react-native`
|
||||
export to `src`, but browser and Electron Metro resolve its `browser` export to
|
||||
`lib/module`; CommonJS consumers use `lib/commonjs`. A source-only patch leaves
|
||||
Electron running the old code.
|
||||
|
||||
Register every dependency patch in `scripts/postinstall-patches.mjs`. A file in
|
||||
`patches/` is inert unless that script knows which installed package activates
|
||||
it.
|
||||
|
||||
## Debugging
|
||||
|
||||
To inspect what the Babel plugin sees, temporarily enable [`debug: true`](https://www.unistyl.es/v3/other/babel-plugin#debug) in `packages/app/babel.config.js`:
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-8P7kk9B2A81e13IIyyfo8fIoNGlrutfV/WGKAFHscUc=
|
||||
sha256-R5NuJTwMOYPo0i60qhF0lZU+OMHwjnIKf9gI1fCJ9/M=
|
||||
|
||||
Generated
+182
-111
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.8.0-beta.1",
|
||||
"version": "0.7.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.8.0-beta.1",
|
||||
"version": "0.7.2",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
@@ -28,7 +28,6 @@
|
||||
"@vercel/nft": "^1.5.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"electron": "44.2.0",
|
||||
"get-port-cli": "^3.0.0",
|
||||
"js-yaml": "^4.1.1",
|
||||
"knip": "^5.82.1",
|
||||
@@ -2884,16 +2883,6 @@
|
||||
"node": ">=0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron-internal/extract-zip": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.5.tgz",
|
||||
"integrity": "sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/asar": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz",
|
||||
@@ -3000,37 +2989,70 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/get": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@electron/get/-/get-5.1.0.tgz",
|
||||
"integrity": "sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA==",
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz",
|
||||
"integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.1.1",
|
||||
"env-paths": "^3.0.0",
|
||||
"graceful-fs": "^4.2.11",
|
||||
"env-paths": "^2.2.0",
|
||||
"fs-extra": "^8.1.0",
|
||||
"got": "^11.8.5",
|
||||
"progress": "^2.0.3",
|
||||
"semver": "^7.6.3",
|
||||
"semver": "^6.2.0",
|
||||
"sumchecker": "^3.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
"node": ">=12"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"undici": "^7.24.4"
|
||||
"global-agent": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/get/node_modules/env-paths": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz",
|
||||
"integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==",
|
||||
"node_modules/@electron/get/node_modules/fs-extra": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
|
||||
"integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^4.0.0",
|
||||
"universalify": "^0.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6 <7 || >=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/get/node_modules/jsonfile": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
|
||||
"integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optionalDependencies": {
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/get/node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/get/node_modules/universalify": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
|
||||
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
"node": ">= 4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/notarize": {
|
||||
@@ -13059,13 +13081,6 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/semver": {
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.8.0.tgz",
|
||||
"integrity": "sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/send": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
|
||||
@@ -13172,6 +13187,17 @@
|
||||
"integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/yauzl": {
|
||||
"version": "2.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
|
||||
"integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.56.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz",
|
||||
@@ -15787,6 +15813,16 @@
|
||||
"ieee754": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-crc32": {
|
||||
"version": "0.2.13",
|
||||
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
|
||||
"integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
@@ -18639,22 +18675,22 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron": {
|
||||
"version": "44.2.0",
|
||||
"resolved": "https://registry.npmjs.org/electron/-/electron-44.2.0.tgz",
|
||||
"integrity": "sha512-oK1icjhapp3xsUZycO9WZaLmd3hJnA7ieobC4mpdtO1pEN+PPGWpA3m1Mgzzuc1wzSD2Wai4zcH4yfpGJqxFMA==",
|
||||
"version": "41.2.0",
|
||||
"resolved": "https://registry.npmjs.org/electron/-/electron-41.2.0.tgz",
|
||||
"integrity": "sha512-0OKLiymqfV0WK68RBXqAm3Myad2TpI5wwxLCBEUcH5Nugo3YfSk7p1Js/AL9266qTz5xZioUnxt9hG8FFwax0g==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@electron-internal/extract-zip": "^1.0.1",
|
||||
"@electron/get": "^5.0.0",
|
||||
"@types/node": "^24.9.0"
|
||||
"@electron/get": "^2.0.0",
|
||||
"@types/node": "^24.9.0",
|
||||
"extract-zip": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"electron": "cli.js",
|
||||
"install-electron": "install.js"
|
||||
"electron": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 22.12.0"
|
||||
"node": ">= 12.20.55"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-builder": {
|
||||
@@ -18761,19 +18797,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron/node_modules/@types/node": {
|
||||
"version": "24.13.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
|
||||
"integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
|
||||
"version": "24.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz",
|
||||
"integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/electron/node_modules/undici-types": {
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -21845,6 +21881,43 @@
|
||||
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/extract-zip": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
|
||||
"integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"debug": "^4.1.1",
|
||||
"get-stream": "^5.1.0",
|
||||
"yauzl": "^2.10.0"
|
||||
},
|
||||
"bin": {
|
||||
"extract-zip": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.17.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@types/yauzl": "^2.9.1"
|
||||
}
|
||||
},
|
||||
"node_modules/extract-zip/node_modules/get-stream": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
|
||||
"integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pump": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/extsprintf": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz",
|
||||
@@ -22009,6 +22082,16 @@
|
||||
"walk-up-path": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fd-slicer": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
|
||||
"integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pend": "~1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
@@ -29553,6 +29636,13 @@
|
||||
"url": "https://github.com/sponsors/jet2jet"
|
||||
}
|
||||
},
|
||||
"node_modules/pend": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
|
||||
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -34474,6 +34564,7 @@
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
@@ -36046,6 +36137,17 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/yauzl": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
|
||||
"integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-crc32": "~0.2.3",
|
||||
"fd-slicer": "~1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/yn": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
|
||||
@@ -36196,7 +36298,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.8.0-beta.1",
|
||||
"version": "0.7.2",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -37261,12 +37363,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.8.0-beta.1",
|
||||
"version": "0.7.2",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.8.0-beta.1",
|
||||
"@getpaseo/protocol": "0.8.0-beta.1",
|
||||
"@getpaseo/server": "0.8.0-beta.1",
|
||||
"@getpaseo/client": "0.7.2",
|
||||
"@getpaseo/protocol": "0.7.2",
|
||||
"@getpaseo/server": "0.7.2",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -37513,10 +37615,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.8.0-beta.1",
|
||||
"version": "0.7.2",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.8.0-beta.1",
|
||||
"@getpaseo/relay": "0.8.0-beta.1",
|
||||
"@getpaseo/protocol": "0.7.2",
|
||||
"@getpaseo/relay": "0.7.2",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37527,7 +37629,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.8.0-beta.1",
|
||||
"version": "0.7.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -37540,7 +37642,7 @@
|
||||
"devDependencies": {
|
||||
"@types/node": "24.6.0",
|
||||
"@types/ws": "^8.5.14",
|
||||
"electron": "44.2.0",
|
||||
"electron": "41.2.0",
|
||||
"electron-builder": "26.8.1",
|
||||
"typescript": "5.9.3",
|
||||
"unzip-crx-3": "^0.2.0",
|
||||
@@ -37770,7 +37872,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.8.0-beta.1",
|
||||
"version": "0.7.2",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -38666,7 +38768,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.8.0-beta.1",
|
||||
"version": "0.7.2",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -38695,74 +38797,43 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.8.0-beta.1",
|
||||
"version": "0.7.2",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^1.4.0",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.8.0-beta.1",
|
||||
"@getpaseo/protocol": "0.8.0-beta.1",
|
||||
"@getpaseo/client": "0.7.2",
|
||||
"@getpaseo/protocol": "0.7.2",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.6",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.8.0-beta.1",
|
||||
"@getpaseo/protocol": "0.8.0-beta.1",
|
||||
"react": "~19.1.0",
|
||||
"react-native": ">=0.81.5",
|
||||
"@getpaseo/client": "0.7.2",
|
||||
"@getpaseo/protocol": "0.7.2",
|
||||
"react": "19.1.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"packages/plugin/node_modules/@agentclientprotocol/sdk": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-1.4.0.tgz",
|
||||
"integrity": "sha512-/eufudw+aFY1LKLolT6yFE6UMmYRl7fMJ/DEONSIyR6wI3slHWITBsANRGqXEY8FRzqUxwh7QEaGiZHcJPVThg==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
}
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.8.0-beta.1",
|
||||
"version": "0.7.2",
|
||||
"dependencies": {
|
||||
"semver": "^7.8.5",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/semver": "^7.8.0",
|
||||
"typescript": "^5.2.2",
|
||||
"vitest": "^4.1.6",
|
||||
"zod-aot": "0.20.4"
|
||||
}
|
||||
},
|
||||
"packages/protocol/node_modules/semver": {
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.8.0-beta.1",
|
||||
"version": "0.7.2",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38980,16 +39051,16 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.8.0-beta.1",
|
||||
"version": "0.7.2",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.8.0-beta.1",
|
||||
"@getpaseo/highlight": "0.8.0-beta.1",
|
||||
"@getpaseo/plugin": "0.8.0-beta.1",
|
||||
"@getpaseo/protocol": "0.8.0-beta.1",
|
||||
"@getpaseo/relay": "0.8.0-beta.1",
|
||||
"@getpaseo/client": "0.7.2",
|
||||
"@getpaseo/highlight": "0.7.2",
|
||||
"@getpaseo/plugin": "0.7.2",
|
||||
"@getpaseo/protocol": "0.7.2",
|
||||
"@getpaseo/relay": "0.7.2",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -39015,7 +39086,6 @@
|
||||
"sherpa-onnx-node": "1.12.28",
|
||||
"strip-ansi": "^7.1.2",
|
||||
"tree-kill": "^1.2.2",
|
||||
"typescript": "^5.9.3",
|
||||
"uuid": "^9.0.1",
|
||||
"which": "^5.0.0",
|
||||
"ws": "^8.14.2",
|
||||
@@ -39031,6 +39101,7 @@
|
||||
"@vitest/ui": "^4.1.6",
|
||||
"cross-env": "^10.1.0",
|
||||
"tsx": "^4.6.0",
|
||||
"typescript": "^5.2.2",
|
||||
"vitest": "^4.1.6"
|
||||
}
|
||||
},
|
||||
@@ -39867,7 +39938,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.8.0-beta.1",
|
||||
"version": "0.7.2",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
+1
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.8.0-beta.1",
|
||||
"version": "0.7.2",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
@@ -126,7 +126,6 @@
|
||||
"@vercel/nft": "^1.5.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"electron": "44.2.0",
|
||||
"get-port-cli": "^3.0.0",
|
||||
"js-yaml": "^4.1.1",
|
||||
"knip": "^5.82.1",
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
expectAgentIdle,
|
||||
expectAgentReadyToInterrupt,
|
||||
expectAgentSurfacesIdle,
|
||||
expectInlineWorkingIndicator,
|
||||
expectRunningAgentChrome,
|
||||
expectVisibleAgentSurfacesIdle,
|
||||
} from "../support/helpers/agent-stream";
|
||||
@@ -550,18 +549,9 @@ async function expectProviderAcknowledgementBeforeRpcAcceptanceSettlesSubmission
|
||||
const userMessage = await submitMessageWithImage(page, prompt);
|
||||
await gate.waitForHeldServerMessage();
|
||||
await gate.waitForAgentStreamItem("user_message");
|
||||
await gate.waitForAgentStreamEvent("turn_started");
|
||||
gate.releaseHeldServerMessage();
|
||||
await expect(userMessage).toHaveAttribute("aria-busy", "false");
|
||||
await gate.drop();
|
||||
await expectInlineWorkingIndicator(page);
|
||||
await expect(userMessage).toHaveAttribute("aria-busy", "false");
|
||||
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
gate.setServerMessageSuppressed("agent_status", false);
|
||||
gate.setServerMessageSuppressed("agent_update", false);
|
||||
gate.restoreFresh();
|
||||
await expectVisibleAgentSurfacesIdle(page);
|
||||
await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0);
|
||||
await expect(userMessage).toHaveAttribute("aria-busy", "false");
|
||||
} finally {
|
||||
gate.restore();
|
||||
@@ -1001,7 +991,7 @@ test.describe("Agent message submission", () => {
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
await submitMessage(page, "Keep running until the queued turn is ready.");
|
||||
await expectRunningAgentChrome(page, title);
|
||||
await expectAgentReadyToInterrupt(page);
|
||||
await queueMessage(page, secondPrompt);
|
||||
await expect(page.getByRole("button", { name: "Send queued message now" })).toBeVisible();
|
||||
|
||||
|
||||
@@ -216,9 +216,7 @@ test.describe("Agent stream UI", () => {
|
||||
await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
// Reasoning rows collapse when the mock starts its next assistant response. Wait past
|
||||
// that transition so their temporary height cannot satisfy the scroll-away setup.
|
||||
await awaitAssistantMessage(page, "Now I have a clearer picture.");
|
||||
await awaitAssistantMessage(page);
|
||||
await waitForScrollableChat(page, {
|
||||
minScrollableDistance: SCROLL_AWAY_MIN_SCROLLABLE_DISTANCE,
|
||||
timeout: 45_000,
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import { expect, test as base } from "../support/fixtures";
|
||||
import type { Page, TestInfo } from "@playwright/test";
|
||||
import {
|
||||
startIsolatedHostDaemon,
|
||||
type IsolatedHostDaemon,
|
||||
} from "../support/helpers/isolated-host-daemon";
|
||||
import { seedSavedSettingsHosts } from "../support/helpers/settings";
|
||||
|
||||
const test = base.extend<{ reconnectHost: IsolatedHostDaemon }>({
|
||||
reconnectHost: async ({ e2eWorker }, provide) => {
|
||||
void e2eWorker;
|
||||
const daemon = await startIsolatedHostDaemon("background-reconnect");
|
||||
try {
|
||||
await provide(daemon);
|
||||
} finally {
|
||||
await daemon.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
async function captureHost({
|
||||
page,
|
||||
testInfo,
|
||||
name,
|
||||
}: {
|
||||
page: Page;
|
||||
testInfo: TestInfo;
|
||||
name: string;
|
||||
}): Promise<void> {
|
||||
await page.screenshot({ path: testInfo.outputPath(`${name}.png`) });
|
||||
}
|
||||
|
||||
async function refocusConnectedTab(page: Page): Promise<void> {
|
||||
await setTabVisibility(page, "visible");
|
||||
await expect(page.getByText("Online", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function setTabVisibility(page: Page, visibility: DocumentVisibilityState): Promise<void> {
|
||||
await page.evaluate((state) => {
|
||||
Object.defineProperty(document, "visibilityState", { configurable: true, get: () => state });
|
||||
Object.defineProperty(document, "hidden", {
|
||||
configurable: true,
|
||||
get: () => state === "hidden",
|
||||
});
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
}, visibility);
|
||||
}
|
||||
|
||||
async function openHostOverview(page: Page, daemon: IsolatedHostDaemon): Promise<void> {
|
||||
await page.goto("/");
|
||||
await seedSavedSettingsHosts(page, [
|
||||
{
|
||||
serverId: daemon.serverId,
|
||||
label: "Reconnect QA",
|
||||
endpoint: `127.0.0.1:${daemon.port}`,
|
||||
},
|
||||
]);
|
||||
await page.goto(`/settings/hosts/${daemon.serverId}/host`);
|
||||
await expect(page.getByText("Online", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function restartHostWhileHidden(page: Page, daemon: IsolatedHostDaemon): Promise<void> {
|
||||
const reconnectedSocket = page.waitForEvent("websocket", {
|
||||
timeout: 30_000,
|
||||
predicate: (socket) => new URL(socket.url()).port === String(daemon.port),
|
||||
});
|
||||
await daemon.restart();
|
||||
await reconnectedSocket;
|
||||
await expect(page.getByText("Online", { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
expect(await page.evaluate(() => document.visibilityState)).toBe("hidden");
|
||||
}
|
||||
|
||||
test("a hidden tab reconnects after daemon restart and stays connected on refocus", async ({
|
||||
page,
|
||||
reconnectHost,
|
||||
}, testInfo) => {
|
||||
await openHostOverview(page, reconnectHost);
|
||||
await captureHost({ page, testInfo, name: "before-hide" });
|
||||
await setTabVisibility(page, "hidden");
|
||||
await restartHostWhileHidden(page, reconnectHost);
|
||||
await captureHost({ page, testInfo, name: "reconnected-while-hidden" });
|
||||
await refocusConnectedTab(page);
|
||||
await captureHost({ page, testInfo, name: "reconnected-after-refocus" });
|
||||
});
|
||||
@@ -311,8 +311,8 @@ test("an empty Changes comparison links to the populated comparison", async ({ p
|
||||
await mode.click();
|
||||
await page.getByTestId("changes-diff-mode-uncommitted").click();
|
||||
|
||||
await expect(tree.getByText("No changes to display", { exact: true })).toBeVisible();
|
||||
await expect(panel.getByText("No changes to display", { exact: true })).toBeVisible();
|
||||
await expect(tree.getByText("No uncommitted changes", { exact: true })).toBeVisible();
|
||||
await expect(panel.getByText("No uncommitted changes", { exact: true })).toBeVisible();
|
||||
const seeCommitted = tree.getByRole("button", { name: "See committed changes" });
|
||||
await expect(seeCommitted).toBeVisible();
|
||||
await seeCommitted.click();
|
||||
@@ -397,24 +397,23 @@ test("every interactive file header has the same hover feedback", async ({ page
|
||||
|
||||
const first = page.getByTestId("diff-file-0-toggle");
|
||||
const second = page.getByTestId("diff-file-1-toggle");
|
||||
const firstCanvas = page.getByTestId("git-diff-sticky-header-0");
|
||||
const secondCanvas = page.getByTestId("git-diff-sticky-header-1");
|
||||
const normalBackground = await headerCanvasPixel(firstCanvas, first, 10);
|
||||
const canvas = page.getByTestId("git-diff-header-canvas");
|
||||
const normalBackground = await headerCanvasPixel(canvas, first, 10);
|
||||
|
||||
await first.hover();
|
||||
await expect.poll(() => headerCanvasPixel(firstCanvas, first, 10)).not.toBe(normalBackground);
|
||||
const hoverBackground = await headerCanvasPixel(firstCanvas, first, 10);
|
||||
await expect.poll(() => headerCanvasPixel(canvas, first, 10)).not.toBe(normalBackground);
|
||||
const hoverBackground = await headerCanvasPixel(canvas, first, 10);
|
||||
|
||||
await first.click();
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(first).toHaveAttribute("aria-expanded", "false");
|
||||
await expect.poll(() => headerCanvasPixel(firstCanvas, first, 10)).toBe(normalBackground);
|
||||
await expect.poll(() => headerCanvasPixel(canvas, first, 10)).toBe(normalBackground);
|
||||
|
||||
await first.hover();
|
||||
await expect.poll(() => headerCanvasPixel(firstCanvas, first, 10)).toBe(hoverBackground);
|
||||
await expect.poll(() => headerCanvasPixel(canvas, first, 10)).toBe(hoverBackground);
|
||||
await second.hover();
|
||||
await expect.poll(() => headerCanvasPixel(firstCanvas, first, 10)).toBe(normalBackground);
|
||||
await expect.poll(() => headerCanvasPixel(secondCanvas, second, 10)).toBe(hoverBackground);
|
||||
await expect.poll(() => headerCanvasPixel(canvas, first, 10)).toBe(normalBackground);
|
||||
await expect.poll(() => headerCanvasPixel(canvas, second, 10)).toBe(hoverBackground);
|
||||
});
|
||||
|
||||
test("horizontal body scrolling never moves or repaints the canvas header", async ({ page }) => {
|
||||
@@ -423,28 +422,22 @@ test("horizontal body scrolling never moves or repaints the canvas header", asyn
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openSelectionWorkspaceChanges(page, workspace);
|
||||
|
||||
const headerCanvas = page.getByTestId("git-diff-canvas");
|
||||
const headerCanvas = page.getByTestId("git-diff-header-canvas");
|
||||
const header = page.getByTestId("diff-file-0-toggle");
|
||||
const beforePixel = await headerCanvasPixel(headerCanvas, header, 10);
|
||||
const beforeImage = await headerCanvas.evaluate((element) =>
|
||||
(element as HTMLCanvasElement).toDataURL(),
|
||||
);
|
||||
const beforeBounds = await header.boundingBox();
|
||||
|
||||
await horizontallyScrollFirstFile(page, 320);
|
||||
await page.waitForTimeout(50);
|
||||
|
||||
expect(await headerCanvasPixel(headerCanvas, header, 10)).toBe(beforePixel);
|
||||
expect(await headerCanvas.evaluate((element) => (element as HTMLCanvasElement).toDataURL())).toBe(
|
||||
beforeImage,
|
||||
);
|
||||
expect(await header.boundingBox()).toEqual(beforeBounds);
|
||||
});
|
||||
|
||||
test("in-flow file headers move with the diff document", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithStickyTransitionDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChangesSurface(page, workspace);
|
||||
|
||||
const motion = await measureInFlowHeaderMotion(page);
|
||||
|
||||
expect(motion).toEqual({ headerSurface: -120, shell: -120 });
|
||||
});
|
||||
|
||||
test("the outgoing sticky header hands off without a gap or overlap", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
@@ -1103,7 +1096,7 @@ test("canvas headers keep a many-file diff bounded while scrolling end to end",
|
||||
|
||||
const root = page.getByTestId("git-diff-canvas-root");
|
||||
const scroller = page.getByTestId("git-diff-scroll");
|
||||
await expect(root.locator('[data-testid^="git-diff-sticky-header-"]')).toHaveCount(2);
|
||||
await expect(page.getByTestId("git-diff-header-canvas")).toBeVisible({ timeout: 30_000 });
|
||||
await expect.poll(() => root.locator('[data-diff-header="true"]').count()).toBeLessThan(120);
|
||||
|
||||
await scroller.evaluate((element) => {
|
||||
@@ -1113,7 +1106,7 @@ test("canvas headers keep a many-file diff bounded while scrolling end to end",
|
||||
|
||||
await expect(page.getByTestId("diff-file-1999")).toBeVisible();
|
||||
await expect.poll(() => root.locator('[data-diff-header="true"]').count()).toBeLessThan(120);
|
||||
await expect(root.locator('[data-testid^="git-diff-sticky-header-"]')).toHaveCount(2);
|
||||
await expect(page.getByTestId("git-diff-header-canvas")).toBeVisible();
|
||||
});
|
||||
|
||||
test("the whole reviewable row reveals the gutter affordance and uses a text cursor", async ({
|
||||
@@ -2037,39 +2030,6 @@ async function horizontallyScrollFirstFile(page: Page, requestedOffset: number):
|
||||
return retainedOffset;
|
||||
}
|
||||
|
||||
async function measureInFlowHeaderMotion(
|
||||
page: Page,
|
||||
): Promise<{ headerSurface: number; shell: number }> {
|
||||
return page.getByTestId("git-diff-scroll").evaluate(async (element) => {
|
||||
const scroll = element as HTMLElement;
|
||||
const headerSurface = document.querySelector<HTMLElement>('[data-testid="git-diff-canvas"]');
|
||||
const shell = document.querySelector<HTMLElement>('[data-diff-header-path="src/second.ts"]');
|
||||
if (!headerSurface || !shell) throw new Error("Diff header motion surfaces are unavailable");
|
||||
|
||||
const shellDocumentTop =
|
||||
scroll.scrollTop + shell.getBoundingClientRect().top - scroll.getBoundingClientRect().top;
|
||||
scroll.scrollTop = Math.max(0, shellDocumentTop - scroll.clientHeight + 80);
|
||||
scroll.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||
await new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
||||
);
|
||||
|
||||
const before = {
|
||||
headerSurface: headerSurface.getBoundingClientRect().top,
|
||||
shell: shell.getBoundingClientRect().top,
|
||||
};
|
||||
scroll.scrollTop += 120;
|
||||
const after = {
|
||||
headerSurface: headerSurface.getBoundingClientRect().top,
|
||||
shell: shell.getBoundingClientRect().top,
|
||||
};
|
||||
return {
|
||||
headerSurface: after.headerSurface - before.headerSurface,
|
||||
shell: after.shell - before.shell,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function longPressFileHeader(page: Page, header: Locator): Promise<void> {
|
||||
const bounds = await header.boundingBox();
|
||||
if (!bounds) throw new Error("File header has no bounds");
|
||||
|
||||
@@ -105,7 +105,7 @@ async function createFeatureCommit(repoPath: string): Promise<void> {
|
||||
async function expectCommitDiffHeaderGeometry(panel: Locator): Promise<void> {
|
||||
const [header, canvas] = await Promise.all([
|
||||
panel.getByTestId("diff-file-0").boundingBox(),
|
||||
panel.getByTestId("git-diff-sticky-header-0").boundingBox(),
|
||||
panel.getByTestId("git-diff-header-canvas").boundingBox(),
|
||||
]);
|
||||
expect(header).not.toBeNull();
|
||||
expect(canvas).not.toBeNull();
|
||||
|
||||
@@ -93,10 +93,7 @@ diffPerfDescribe("Diff canvas performance", () => {
|
||||
[
|
||||
root.querySelectorAll('[data-diff-header="true"]').length,
|
||||
root.querySelectorAll("*").length,
|
||||
root.querySelector('[data-testid="git-diff-canvas"]') &&
|
||||
root.querySelectorAll('[data-testid^="git-diff-sticky-header-"]').length === 2
|
||||
? "canvas-pool"
|
||||
: "dom",
|
||||
root.querySelector('[data-testid="git-diff-header-canvas"]') ? "canvas" : "dom",
|
||||
] as const,
|
||||
);
|
||||
const headerDomMetrics = await page.getByTestId("diff-file-0-toggle").evaluate((header) => {
|
||||
@@ -128,7 +125,7 @@ diffPerfDescribe("Diff canvas performance", () => {
|
||||
};
|
||||
});
|
||||
const headerCanvasMetrics = await page
|
||||
.getByTestId("git-diff-sticky-header-0")
|
||||
.getByTestId("git-diff-header-canvas")
|
||||
.evaluate((canvas: HTMLCanvasElement) => {
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) throw new Error("Header canvas has no 2D context");
|
||||
@@ -171,7 +168,6 @@ diffPerfDescribe("Diff canvas performance", () => {
|
||||
);
|
||||
if (!RUN_HEADER_COMPARISON) {
|
||||
expect(benchmark.maximumShells).toBeLessThan(120);
|
||||
expect(benchmark.maximumStickyCanvases).toBe(2);
|
||||
expect(benchmark.blankHeaderFrames).toBe(0);
|
||||
expect(benchmark.medianFrameMs).toBeLessThanOrEqual(MANY_FILE_MAX_MEDIAN_FRAME_MS);
|
||||
expect(benchmark.p95FrameMs).toBeLessThanOrEqual(MANY_FILE_MAX_P95_FRAME_MS);
|
||||
@@ -439,8 +435,7 @@ async function openFirstReviewEditor(page: Page): Promise<number> {
|
||||
}
|
||||
|
||||
async function expectBoundedCanvasSurfaces(page: Page): Promise<void> {
|
||||
await expect(page.locator("canvas")).toHaveCount(3);
|
||||
await expect(page.locator('[data-testid^="git-diff-sticky-header-"]')).toHaveCount(2);
|
||||
await expect(page.locator("canvas")).toHaveCount(2);
|
||||
await expect(page.locator('[data-testid^="diff-code-row-"]')).toHaveCount(0);
|
||||
const elementCount = await page
|
||||
.getByTestId("git-diff-canvas-root")
|
||||
@@ -532,57 +527,40 @@ async function benchmarkManyFileHeaderScrolling(page: Page): Promise<{
|
||||
p95FrameMs: number;
|
||||
maximumFrameMs: number;
|
||||
maximumShells: number;
|
||||
maximumStickyCanvases: number;
|
||||
blankHeaderFrames: number;
|
||||
endScrollTop: number;
|
||||
maximumScrollTop: number;
|
||||
}> {
|
||||
return page.getByTestId("git-diff-scroll").evaluate(async (element) => {
|
||||
const scroll = element as HTMLElement;
|
||||
const canvas = document.querySelector<HTMLCanvasElement>(
|
||||
'[data-testid="git-diff-header-canvas"]',
|
||||
);
|
||||
const maximumScrollTop = scroll.scrollHeight - scroll.clientHeight;
|
||||
const frameDurations: number[] = [];
|
||||
let maximumShells = 0;
|
||||
let maximumStickyCanvases = 0;
|
||||
let blankHeaderFrames = 0;
|
||||
const sampleHeader = () => {
|
||||
maximumShells = Math.max(
|
||||
maximumShells,
|
||||
document.querySelectorAll('[data-diff-header="true"]').length,
|
||||
);
|
||||
const canvases = Array.from(
|
||||
document.querySelectorAll<HTMLCanvasElement>('[data-testid^="git-diff-sticky-header-"]'),
|
||||
);
|
||||
maximumStickyCanvases = Math.max(maximumStickyCanvases, canvases.length);
|
||||
const scrollBounds = scroll.getBoundingClientRect();
|
||||
const sampleX = scrollBounds.left + 4;
|
||||
const sampleY = scrollBounds.top + 10;
|
||||
const canvas = canvases.find((candidate) => {
|
||||
const bounds = candidate.getBoundingClientRect();
|
||||
return (
|
||||
bounds.width > 0 &&
|
||||
sampleX >= bounds.left &&
|
||||
sampleX < bounds.right &&
|
||||
sampleY >= bounds.top &&
|
||||
sampleY < bounds.bottom
|
||||
);
|
||||
});
|
||||
if (canvas) {
|
||||
const context = canvas.getContext("2d")!;
|
||||
const bounds = canvas.getBoundingClientRect();
|
||||
const ratio = canvas.width / bounds.width;
|
||||
const ratio = canvas.width / canvas.getBoundingClientRect().width;
|
||||
if (
|
||||
context.getImageData(
|
||||
Math.round((sampleX - bounds.left) * ratio),
|
||||
Math.round((sampleY - bounds.top) * ratio),
|
||||
1,
|
||||
1,
|
||||
).data[3] === 0
|
||||
context.getImageData(Math.round(4 * ratio), Math.round(10 * ratio), 1, 1).data[3] === 0
|
||||
) {
|
||||
blankHeaderFrames += 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
blankHeaderFrames += 1;
|
||||
const bounds = scroll.getBoundingClientRect();
|
||||
if (
|
||||
!document.elementFromPoint(bounds.left + 4, bounds.top + 10)?.closest("[data-diff-header]")
|
||||
) {
|
||||
blankHeaderFrames += 1;
|
||||
}
|
||||
};
|
||||
|
||||
for (const progress of [0, 0.25, 0.5, 0.75, 1]) {
|
||||
@@ -623,7 +601,6 @@ async function benchmarkManyFileHeaderScrolling(page: Page): Promise<{
|
||||
p95FrameMs: percentile(0.95),
|
||||
maximumFrameMs: Math.max(...frameDurations),
|
||||
maximumShells,
|
||||
maximumStickyCanvases,
|
||||
blankHeaderFrames,
|
||||
endScrollTop: scroll.scrollTop,
|
||||
maximumScrollTop,
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
withExplorerPanelPlugins,
|
||||
expectWorkspacePanelsInExplorerLauncher,
|
||||
openWorkspacePanelFromExplorerMenu,
|
||||
expectIndependentExplorerPanelToggles,
|
||||
expectExplorerPanelWithFocusedAgent,
|
||||
} from "../support/helpers/explorer-plugin-menu";
|
||||
|
||||
test("Explorer toggles workspace panels independently without inheriting agent context", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await withExplorerPanelPlugins(page, async (workspace) => {
|
||||
await test.step("the New Tab launcher offers compatible workspace panels", () =>
|
||||
expectWorkspacePanelsInExplorerLauncher(page));
|
||||
await test.step("the rail menu offers the same workspace panels", () =>
|
||||
openWorkspacePanelFromExplorerMenu(page, workspace, testInfo));
|
||||
await test.step("different panels and plugins keep independent selection and closing", () =>
|
||||
expectIndependentExplorerPanelToggles(page, workspace));
|
||||
await test.step("focusing an agent does not add agent panels to Explorer", () =>
|
||||
expectExplorerPanelWithFocusedAgent(page, workspace, testInfo));
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,4 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
|
||||
import type { FormPreferences } from "@/create-agent-preferences/preferences";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
@@ -51,52 +49,30 @@ function getSessionMessage(message: WebSocketMessage): Record<string, unknown> |
|
||||
return maybeEnvelope.message as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function seedCodexDefaultPermissionPreferences(page: Page, cwd: string): Promise<string> {
|
||||
const client = await connectDaemonClient<DaemonClient>({
|
||||
clientIdPrefix: "codex-mode-preferences",
|
||||
});
|
||||
try {
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await client.getProvidersSnapshot({ cwd })).entries.find(
|
||||
(entry) => entry.provider === "codex",
|
||||
)?.status,
|
||||
)
|
||||
.toBe("ready");
|
||||
const snapshot = await client.getProvidersSnapshot({ cwd });
|
||||
const model = snapshot.entries
|
||||
.find((entry) => entry.provider === "codex")
|
||||
?.models?.find((candidate) => candidate.thinkingOptions?.length);
|
||||
if (!model?.thinkingOptions?.[0])
|
||||
throw new Error("Codex catalogue must expose a model with thinking options");
|
||||
await page.addInitScript(
|
||||
({ preferencesKey, modelId, thinkingOptionId }) => {
|
||||
localStorage.setItem(
|
||||
preferencesKey,
|
||||
JSON.stringify({
|
||||
provider: "codex",
|
||||
providerPreferences: {
|
||||
codex: {
|
||||
model: modelId,
|
||||
mode: "auto",
|
||||
thinkingByModel: { [modelId]: thinkingOptionId },
|
||||
async function seedCodexDefaultPermissionPreferences(page: Page): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ preferencesKey }) => {
|
||||
localStorage.setItem(
|
||||
preferencesKey,
|
||||
JSON.stringify({
|
||||
provider: "codex",
|
||||
providerPreferences: {
|
||||
codex: {
|
||||
model: "gpt-5.4-mini",
|
||||
mode: "auto",
|
||||
thinkingByModel: {
|
||||
"gpt-5.4-mini": "low",
|
||||
},
|
||||
mock: { model: "ten-second-stream" },
|
||||
},
|
||||
} satisfies FormPreferences),
|
||||
);
|
||||
},
|
||||
{
|
||||
preferencesKey: CREATE_AGENT_PREFERENCES_KEY,
|
||||
modelId: model.id,
|
||||
thinkingOptionId: model.thinkingOptions[0].id,
|
||||
},
|
||||
);
|
||||
return model.id;
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
mock: {
|
||||
model: "ten-second-stream",
|
||||
},
|
||||
},
|
||||
} satisfies FormPreferences),
|
||||
);
|
||||
},
|
||||
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY },
|
||||
);
|
||||
}
|
||||
|
||||
async function readCodexModePreference(page: Page): Promise<unknown> {
|
||||
@@ -180,8 +156,9 @@ test.describe("New workspace Codex mode preferences", () => {
|
||||
}) => {
|
||||
const seeded = await seedWorkspace({ repoPrefix: "codex-mode-preferences-" });
|
||||
const createAgentRecorder = await recordAndBlockCreateAgentRequests(page);
|
||||
await seedCodexDefaultPermissionPreferences(page);
|
||||
|
||||
try {
|
||||
await seedCodexDefaultPermissionPreferences(page, seeded.repoPath);
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
@@ -216,15 +193,16 @@ test.describe("New workspace Codex mode preferences", () => {
|
||||
|
||||
test("uses the live Codex agent mode as the next New Workspace default", async ({ page }) => {
|
||||
const seeded = await seedWorkspace({ repoPrefix: "codex-live-mode-preferences-" });
|
||||
await seedCodexDefaultPermissionPreferences(page);
|
||||
|
||||
try {
|
||||
const model = await seedCodexDefaultPermissionPreferences(page, seeded.repoPath);
|
||||
const agent = await seeded.client.createAgent({
|
||||
provider: "codex",
|
||||
cwd: seeded.repoPath,
|
||||
workspaceId: seeded.workspaceId,
|
||||
title: "Codex live mode preference e2e",
|
||||
modeId: "auto",
|
||||
model,
|
||||
model: "gpt-5.4-mini",
|
||||
});
|
||||
|
||||
await openAgentRoute(page, {
|
||||
|
||||
@@ -21,13 +21,13 @@ import {
|
||||
type TerminalProfileSeed,
|
||||
} from "../support/helpers/new-workspace-launch";
|
||||
|
||||
// Wait for input until project cleanup kills the terminal. A fixed lifetime
|
||||
// can expire before a busy browser attaches or finishes revisiting the composer.
|
||||
// `sleep` keeps the terminal alive long enough for the UI to attach and
|
||||
// render output before the process exits — see new-workspace-launch-terminal.spec.ts.
|
||||
const PROMPT_PROFILE: TerminalProfile = {
|
||||
id: "e2e-memory-profile",
|
||||
name: "Memory Profile",
|
||||
command: "/bin/sh",
|
||||
args: ["-c", 'echo remembered: "$0"; exec cat', "{{{prompt}}}"],
|
||||
args: ["-c", 'echo remembered: "$0"; sleep 10', "{{{prompt}}}"],
|
||||
};
|
||||
|
||||
function hasLeftNewWorkspaceRoute(url: URL): boolean {
|
||||
|
||||
@@ -25,13 +25,14 @@ import {
|
||||
//
|
||||
// The sentinel becomes `$0` (a positional shell parameter, not string
|
||||
// interpolation into the script source), so the prompt reaches the process
|
||||
// exactly as typed with no injection risk. `cat` waits for input until project
|
||||
// cleanup kills the terminal, so attachment does not race a fixed process lifetime.
|
||||
// exactly as typed with no injection risk. `sleep` keeps the terminal alive
|
||||
// long enough for the UI to attach and render the output before it exits —
|
||||
// a bare `echo` exits before the frontend subscribes to its output.
|
||||
const PROMPT_PROFILE: TerminalProfile = {
|
||||
id: "e2e-echo-prompt",
|
||||
name: "Echo Prompt",
|
||||
command: "/bin/sh",
|
||||
args: ["-c", 'echo captured: "$0"; exec cat', "{{{prompt}}}"],
|
||||
args: ["-c", 'echo captured: "$0"; sleep 10', "{{{prompt}}}"],
|
||||
};
|
||||
|
||||
// No sentinel anywhere: takes no prompt, composer goes read-only.
|
||||
@@ -39,7 +40,7 @@ const BARE_PROFILE: TerminalProfile = {
|
||||
id: "e2e-echo-bare",
|
||||
name: "Bare Echo",
|
||||
command: "/bin/sh",
|
||||
args: ["-c", "echo bare-launch-static-line; exec cat"],
|
||||
args: ["-c", "echo bare-launch-static-line; sleep 10"],
|
||||
};
|
||||
|
||||
test.describe("New workspace: launching a terminal", () => {
|
||||
@@ -87,7 +88,7 @@ test.describe("New workspace: launching a terminal", () => {
|
||||
});
|
||||
await selectLaunchOption(page, BARE_PROFILE.id);
|
||||
await expect(terminalPromptInput(page)).toHaveCount(0);
|
||||
await expectTerminalPreviewCommand(page, "/bin/sh -c echo bare-launch-static-line; exec cat");
|
||||
await expectTerminalPreviewCommand(page, "/bin/sh -c echo bare-launch-static-line; sleep 10");
|
||||
await expect(terminalLaunchSubmit(page)).toHaveText("Launch");
|
||||
});
|
||||
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import {
|
||||
chooseModel,
|
||||
reselectModel,
|
||||
startWithoutRememberedModel,
|
||||
expectCreatedModelAgents,
|
||||
expectRememberedModel,
|
||||
expectSavedSelection,
|
||||
} from "../support/helpers/model-memory";
|
||||
import {
|
||||
openGlobalNewWorkspaceComposer,
|
||||
submitNewWorkspacePrompt,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
|
||||
test("remembers real Codex GPT-6 Astra after repeated successful workspace creation", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "codex-astra-memory-" });
|
||||
const client = await connectDaemonClient<DaemonClient>({ clientIdPrefix: "codex-astra-memory" });
|
||||
try {
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await client.getProvidersSnapshot({ cwd: workspace.repoPath })).entries.find(
|
||||
(entry) => entry.provider === "codex",
|
||||
)?.status,
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toBe("ready");
|
||||
const snapshot = await client.getProvidersSnapshot({ cwd: workspace.repoPath });
|
||||
const codex = snapshot.entries.find((entry) => entry.provider === "codex");
|
||||
expect(codex?.status).toBe("ready");
|
||||
const astra = codex?.models?.find((model) => model.id === "gpt-6-astra");
|
||||
expect(astra?.id).toBe("gpt-6-astra");
|
||||
const label = astra!.label;
|
||||
await startWithoutRememberedModel(page);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForSidebarHydration(page);
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
for (const [index, select] of [chooseModel, reselectModel].entries()) {
|
||||
const count = index + 1;
|
||||
await test.step(`create workspace ${count} and return`, async () => {
|
||||
await select(page, "codex", label);
|
||||
await expectSavedSelection(page, "codex", "gpt-6-astra");
|
||||
await submitNewWorkspacePrompt(page, "Reply with OK. Do not use tools or change files.");
|
||||
await expect(page).toHaveURL(/\/workspace\//);
|
||||
await expectCreatedModelAgents(page, client, "codex", "gpt-6-astra", count);
|
||||
await expect(
|
||||
page.getByTestId("assistant-message").filter({ hasText: "OK", visible: true }),
|
||||
).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await expectSavedSelection(page, "codex", "gpt-6-astra");
|
||||
await expectRememberedModel(page, label);
|
||||
});
|
||||
}
|
||||
await page.screenshot({ path: test.info().outputPath("astra-remembered.png") });
|
||||
} finally {
|
||||
await client.close();
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,109 +0,0 @@
|
||||
import { mkdtemp, rename, rm, symlink } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { seedModelProvider } from "../support/helpers/agent-profiles";
|
||||
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import {
|
||||
openGlobalNewWorkspaceComposer,
|
||||
submitNewWorkspaceEmpty,
|
||||
submitNewWorkspacePrompt,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
|
||||
import {
|
||||
chooseModel,
|
||||
reselectModel,
|
||||
startWithoutRememberedModel,
|
||||
expectSavedSelection,
|
||||
expectCreatedModelAgents,
|
||||
expectRememberedModel,
|
||||
} from "../support/helpers/model-memory";
|
||||
|
||||
const PROVIDER = "model-memory-diagnostic";
|
||||
const MODEL = "pi-profile-model";
|
||||
const LABEL = "Pi profile model";
|
||||
|
||||
async function expectProviderStatus(client: DaemonClient, cwd: string | undefined, status: string) {
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await client.getProvidersSnapshot(cwd ? { cwd } : undefined)).entries.find(
|
||||
(entry) => entry.provider === PROVIDER,
|
||||
)?.status,
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe(status);
|
||||
}
|
||||
|
||||
async function prepareCatalogState(
|
||||
client: DaemonClient,
|
||||
executable: string,
|
||||
cwd: string,
|
||||
hostStatus: "ready" | "unavailable",
|
||||
) {
|
||||
await expectProviderStatus(client, undefined, "ready");
|
||||
await expectProviderStatus(client, cwd, "ready");
|
||||
await rename(executable, `${executable}.parked`);
|
||||
await client.refreshProvidersSnapshot({ providers: [PROVIDER] });
|
||||
await expectProviderStatus(client, undefined, "unavailable");
|
||||
await rename(`${executable}.parked`, executable);
|
||||
if (hostStatus === "ready") await client.refreshProvidersSnapshot({ providers: [PROVIDER] });
|
||||
await client.refreshProvidersSnapshot({ cwd, providers: [PROVIDER] });
|
||||
await expectProviderStatus(client, cwd, "ready");
|
||||
await expectProviderStatus(client, undefined, hostStatus);
|
||||
}
|
||||
|
||||
for (const hostStatus of ["ready", "unavailable"] as const) {
|
||||
test(`remembers the manually selected model after two creations with a ${hostStatus} host catalog and ready project catalog`, async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(150_000);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "model-memory-scope-" });
|
||||
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-model-memory-bin-"));
|
||||
const executable = path.join(binDir, "provider-node");
|
||||
await symlink(process.execPath, executable);
|
||||
const provider = await seedModelProvider({
|
||||
id: PROVIDER,
|
||||
label: "Model memory diagnostic",
|
||||
extends: "pi",
|
||||
command: [executable, path.resolve("e2e/fixtures/fake-pi-rpc.mjs")],
|
||||
models: [{ id: MODEL, label: LABEL, description: "Remembered selection diagnostic" }],
|
||||
});
|
||||
const client = await connectDaemonClient<DaemonClient>({ clientIdPrefix: "model-memory" });
|
||||
try {
|
||||
await prepareCatalogState(client, executable, workspace.repoPath, hostStatus);
|
||||
|
||||
await startWithoutRememberedModel(page);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForSidebarHydration(page);
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
for (const [index, select] of [chooseModel, reselectModel].entries()) {
|
||||
const count = index + 1;
|
||||
await test.step(`create workspace ${count} and return`, async () => {
|
||||
await select(page, PROVIDER, LABEL);
|
||||
await expectSavedSelection(page, PROVIDER, MODEL);
|
||||
await submitNewWorkspacePrompt(page, "Remember this model on the next workspace");
|
||||
await expect(page).toHaveURL(/\/workspace\//);
|
||||
await expectCreatedModelAgents(page, client, PROVIDER, MODEL, count);
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await expectSavedSelection(page, PROVIDER, MODEL);
|
||||
await expectRememberedModel(page, LABEL);
|
||||
});
|
||||
}
|
||||
await submitNewWorkspaceEmpty(page);
|
||||
await expect(page).toHaveURL(/\/workspace\//);
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await expectSavedSelection(page, PROVIDER, MODEL);
|
||||
await expectRememberedModel(page, LABEL);
|
||||
} finally {
|
||||
await client.close();
|
||||
await provider.restore();
|
||||
await workspace.cleanup();
|
||||
await rm(binDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { test, expect, type Page } from "../support/fixtures";
|
||||
import {
|
||||
closeModelPicker,
|
||||
drillIntoProvider,
|
||||
openModelPicker,
|
||||
seedModelProvider,
|
||||
} from "../support/helpers/agent-profiles";
|
||||
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { openGlobalNewWorkspaceComposer } from "../support/helpers/new-workspace";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
import { expectRememberedModel, expectSavedSelection } from "../support/helpers/model-memory";
|
||||
|
||||
const PROVIDER = "remembered-model-recovery";
|
||||
const MODEL = "remembered-model";
|
||||
const LABEL = "Remembered model";
|
||||
|
||||
async function rememberModel(page: Page) {
|
||||
await page.addInitScript(
|
||||
({ provider, model }) => {
|
||||
localStorage.setItem(
|
||||
"@paseo:create-agent-preferences",
|
||||
JSON.stringify({
|
||||
provider,
|
||||
providerPreferences: { [provider]: { model } },
|
||||
}),
|
||||
);
|
||||
},
|
||||
{ provider: PROVIDER, model: MODEL },
|
||||
);
|
||||
}
|
||||
|
||||
async function expectRecoveredModelInPicker(page: Page) {
|
||||
await openModelPicker(page);
|
||||
await page.getByRole("dialog").getByRole("button", { name: "Back", exact: true }).click();
|
||||
await drillIntoProvider(page, PROVIDER);
|
||||
await expect(
|
||||
page.getByTestId("combobox-desktop-container").getByText(LABEL, { exact: true }),
|
||||
).toBeVisible();
|
||||
await closeModelPicker(page);
|
||||
}
|
||||
|
||||
async function setProviderAvailability(client: DaemonClient, cwd: string, available: boolean) {
|
||||
await client.patchDaemonConfig({
|
||||
providers: {
|
||||
[PROVIDER]: {
|
||||
command: [available ? process.execPath : "/missing-paseo-diagnostic-provider"],
|
||||
},
|
||||
},
|
||||
});
|
||||
for (const scope of [undefined, cwd]) {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const snapshot = await client.getProvidersSnapshot(scope ? { cwd: scope } : undefined);
|
||||
return snapshot.entries.find((entry) => entry.provider === PROVIDER)?.status;
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe(available ? "ready" : "unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
test("restores the remembered model when a provider recovers after New workspace opens", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "model-recovery-" });
|
||||
const provider = await seedModelProvider({
|
||||
id: PROVIDER,
|
||||
label: "Remembered provider",
|
||||
models: [{ id: MODEL, label: LABEL, description: "Model restoration diagnostic" }],
|
||||
command: [process.execPath],
|
||||
});
|
||||
const client = await connectDaemonClient<DaemonClient>({ clientIdPrefix: "model-recovery" });
|
||||
try {
|
||||
await rememberModel(page);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForSidebarHydration(page);
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await expectRememberedModel(page, LABEL);
|
||||
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForSidebarHydration(page);
|
||||
await setProviderAvailability(client, workspace.repoPath, false);
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await expect(
|
||||
page.getByTestId("combined-model-selector").filter({ visible: true }),
|
||||
).not.toContainText("Loading...");
|
||||
|
||||
await setProviderAvailability(client, workspace.repoPath, true);
|
||||
await expectSavedSelection(page, PROVIDER, MODEL);
|
||||
await expectRecoveredModelInPicker(page);
|
||||
await expectRememberedModel(page, LABEL);
|
||||
} finally {
|
||||
await client.close();
|
||||
await provider.restore();
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,22 +0,0 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import { verifyDelayedWorkspaceCreation } from "../support/helpers/new-workspace-navigation";
|
||||
|
||||
test.describe("Delayed workspace creation", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
for (const launch of ["chat", "terminal", "empty"] as const) {
|
||||
test(`${launch}: keeps the workspace chosen during creation`, async ({ page }) => {
|
||||
await verifyDelayedWorkspaceCreation(page, launch, "leave");
|
||||
});
|
||||
test(`worktree ${launch}: keeps the workspace chosen during creation`, async ({ page }) => {
|
||||
await verifyDelayedWorkspaceCreation(page, launch, "leave", "worktree");
|
||||
});
|
||||
test(`${launch}: opens the created workspace when the user stays`, async ({ page }) => {
|
||||
await verifyDelayedWorkspaceCreation(page, launch, "stay");
|
||||
});
|
||||
}
|
||||
|
||||
test("preserves a newer draft opened while creation is pending", async ({ page }) => {
|
||||
await verifyDelayedWorkspaceCreation(page, "chat", "new-draft");
|
||||
});
|
||||
});
|
||||
@@ -1,13 +0,0 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import { withButtonAuthorExample } from "../support/helpers/plugin-button-example";
|
||||
|
||||
test("the button author example shows each capability without a competing overflow button", async ({
|
||||
page,
|
||||
}, info) => {
|
||||
test.setTimeout(120_000);
|
||||
await withButtonAuthorExample(page, info, async (buttons) => {
|
||||
await buttons.refreshFromHeaderAndComposer();
|
||||
await buttons.useMenusAndToggleButtons();
|
||||
await buttons.inspectLiveWorkspaceDetails();
|
||||
});
|
||||
});
|
||||
@@ -1,14 +0,0 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import { withButtonShowcase } from "../support/helpers/plugin-buttons";
|
||||
|
||||
test("plugin header and composer buttons share actions, menus, content, and updates", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(180_000);
|
||||
await withButtonShowcase(page, testInfo, async (buttons) => {
|
||||
await buttons.openWideMenusAndPopovers();
|
||||
await buttons.runAndUpdateActions();
|
||||
await buttons.openCompactSheets();
|
||||
await buttons.hideAndUnloadOpenButtons();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,3 @@
|
||||
import { pluginRequirements } from "../support/helpers/plugin-fixture";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -12,8 +11,8 @@ import {
|
||||
|
||||
const PLUGIN_ID = "plugin-host-ui-e2e";
|
||||
|
||||
const PLUGIN_SOURCE = `import { usePaseo } from "@getpaseo/plugin/client";
|
||||
import { Icon, Modal, useToast } from "@getpaseo/plugin/client/react-native";
|
||||
const PLUGIN_SOURCE = `import { usePaseo } from "@getpaseo/plugin";
|
||||
import { Icon, Modal, useToast } from "@getpaseo/plugin/react-native";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import React, { useState } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
@@ -119,10 +118,7 @@ test("plugin modal adapts its presentation and preserves host contexts", async (
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-host-ui-e2e-"));
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previousConfig = await client.getDaemonConfig();
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: PLUGIN_ID }));
|
||||
await writeFile(path.join(directory, "index.client.tsx"), PLUGIN_SOURCE);
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { pluginRequirements } from "../support/helpers/plugin-fixture";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -121,10 +120,7 @@ test("installs, reloads, recovers, disables, and removes a trusted local plugin"
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-e2e-"));
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previous = await client.getDaemonConfig();
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: "e2e-plugin", requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: "e2e-plugin" }));
|
||||
await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Plugin v1"));
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
import { copyPluginExample } from "../support/helpers/plugin-fixture";
|
||||
import { expect, test as base, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||
import {
|
||||
openMobileAgentSidebar,
|
||||
expectMobileAgentSidebarVisible,
|
||||
} from "../support/helpers/sidebar";
|
||||
|
||||
async function openExample(page: Page, example: string) {
|
||||
await page.getByRole("button", { name: `Open ${example}`, exact: true }).click();
|
||||
await expect(page.getByText(`Modal example: ${example}`, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function closeExample(page: Page) {
|
||||
await page.getByRole("button", { name: "Close", exact: true }).last().click();
|
||||
await expect(page.getByRole("button", { name: "Open Form", exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function copyAndPaste(page: Page) {
|
||||
await page.getByRole("button", { name: "Copy text", exact: true }).click();
|
||||
await expect(page.getByText("Text copied", { exact: true })).toBeVisible();
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("Copied from Paseo");
|
||||
await page.getByRole("textbox", { name: "Paste here" }).focus();
|
||||
await page.keyboard.press("ControlOrMeta+A");
|
||||
await page.keyboard.press("ControlOrMeta+V");
|
||||
await expect(page.getByRole("textbox", { name: "Paste here" })).toHaveValue("Copied from Paseo");
|
||||
}
|
||||
|
||||
async function scrollToLastRow(page: Page) {
|
||||
await page.getByRole("button", { name: "Row 1", exact: true }).hover();
|
||||
await page.mouse.wheel(0, 7000);
|
||||
await expect(page.getByRole("button", { name: "Row 100", exact: true })).toBeInViewport();
|
||||
await page.getByRole("button", { name: "Row 100", exact: true }).click();
|
||||
}
|
||||
|
||||
async function expectSingleInset(page: Page, left = "24px", bottom = "24px") {
|
||||
const form = await page.getByTestId("modal-example-form").boundingBox();
|
||||
const input = await page.getByRole("textbox", { name: "Paste here" }).boundingBox();
|
||||
expect(form).not.toBeNull();
|
||||
expect(input?.x).toBe(form?.x);
|
||||
// The real content View owns the inset, not a third-party contentContainerStyle.
|
||||
const padding = await page.getByTestId("modal-example-form").evaluate((node) => {
|
||||
const content = node.parentElement!;
|
||||
return {
|
||||
left: getComputedStyle(content).paddingLeft,
|
||||
bottom: getComputedStyle(content).paddingBottom,
|
||||
};
|
||||
});
|
||||
expect(padding).toEqual({ left, bottom });
|
||||
}
|
||||
|
||||
async function expectFullBleed(page: Page) {
|
||||
const padding = await page
|
||||
.getByRole("button", { name: "Row 1", exact: true })
|
||||
.evaluate((node) => {
|
||||
const content = node.parentElement!;
|
||||
return {
|
||||
left: getComputedStyle(content).paddingLeft,
|
||||
bottom: getComputedStyle(content).paddingBottom,
|
||||
};
|
||||
});
|
||||
expect(padding).toEqual({ left: "0px", bottom: "0px" });
|
||||
}
|
||||
|
||||
async function exerciseBodies(page: Page) {
|
||||
await openExample(page, "Form");
|
||||
await expectSingleInset(page);
|
||||
await copyAndPaste(page);
|
||||
await closeExample(page);
|
||||
await openExample(page, "Custom inset");
|
||||
await expectSingleInset(page, "8px", "40px");
|
||||
await closeExample(page);
|
||||
await openExample(page, "Full bleed");
|
||||
await expectFullBleed(page);
|
||||
await scrollToLastRow(page);
|
||||
await closeExample(page);
|
||||
await openExample(page, "ScrollView");
|
||||
await scrollToLastRow(page);
|
||||
await closeExample(page);
|
||||
await openExample(page, "FlatList");
|
||||
await page.getByRole("button", { name: "Jump to last row", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "Row 100", exact: true })).toBeInViewport();
|
||||
await closeExample(page);
|
||||
await openExample(page, "Horizontal");
|
||||
await page.getByRole("button", { name: "Overview", exact: true }).hover();
|
||||
await page.mouse.wheel(900, 0);
|
||||
await expect(page.getByRole("button", { name: "System logs", exact: true })).toBeInViewport();
|
||||
await page.getByRole("button", { name: "System logs", exact: true }).click();
|
||||
await closeExample(page);
|
||||
await expect(page.getByText("Selected: System logs", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
const test = base.extend<{ modalExample: Page }>({
|
||||
modalExample: async ({ page, context }, provide) => {
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previous = await client.getDaemonConfig();
|
||||
const example = await copyPluginExample("modal-ui");
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(example.directory);
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await provide(page);
|
||||
} finally {
|
||||
await client.removePlugin("modal-ui-example").catch(() => undefined);
|
||||
await client
|
||||
.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false })
|
||||
.catch(() => undefined);
|
||||
await client.close().catch(() => undefined);
|
||||
await example.cleanup();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
async function openWideExamples(page: Page) {
|
||||
await page.setViewportSize({ width: 1100, height: 800 });
|
||||
await gotoAppShell(page);
|
||||
await page.getByRole("button", { name: "Modal examples", exact: true }).click();
|
||||
}
|
||||
|
||||
async function openCompactExamples(page: Page) {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openMobileAgentSidebar(page);
|
||||
await expectMobileAgentSidebarVisible(page);
|
||||
await page.getByRole("button", { name: "Modal examples", exact: true }).click();
|
||||
}
|
||||
|
||||
async function expectDeniedCopyFeedback(page: Page) {
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
try {
|
||||
const { targetInfo } = await cdp.send("Target.getTargetInfo");
|
||||
await cdp.send("Browser.setPermission", {
|
||||
browserContextId: targetInfo.browserContextId,
|
||||
permission: { name: "clipboard-write" },
|
||||
setting: "denied",
|
||||
origin: new URL(page.url()).origin,
|
||||
});
|
||||
expect(
|
||||
await page.evaluate(
|
||||
async () =>
|
||||
(await navigator.permissions.query({ name: "clipboard-write" as PermissionName })).state,
|
||||
),
|
||||
).toBe("denied");
|
||||
await openExample(page, "Form");
|
||||
await page.getByRole("button", { name: "Copy text", exact: true }).click();
|
||||
await expect(
|
||||
page.getByText("Could not copy text. Select the text and use Copy.", { exact: true }),
|
||||
).toBeVisible();
|
||||
await closeExample(page);
|
||||
} finally {
|
||||
await cdp.detach();
|
||||
}
|
||||
}
|
||||
|
||||
test("plugin bodies support padding, scroll ownership, horizontal tabs and clipboard on wide and compact layouts", async ({
|
||||
modalExample: page,
|
||||
}) => {
|
||||
await openWideExamples(page);
|
||||
await test.step("wide dialogs", () => exerciseBodies(page));
|
||||
await openCompactExamples(page);
|
||||
await test.step("compact sheets", () => exerciseBodies(page));
|
||||
await test.step("denied clipboard writes report failure", () => expectDeniedCopyFeedback(page));
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
import {
|
||||
test,
|
||||
verifyProviderSettings,
|
||||
verifyNewWorkspaceModelIcon,
|
||||
verifyCompactModelIcon,
|
||||
verifyExistingAgentModelIcon,
|
||||
} from "../support/helpers/plugin-provider-icons";
|
||||
|
||||
test("plugin provider icons follow the model through settings and wide and compact composers", async ({
|
||||
providerIcons,
|
||||
}) => {
|
||||
await verifyProviderSettings(providerIcons);
|
||||
await verifyNewWorkspaceModelIcon(providerIcons);
|
||||
await verifyCompactModelIcon(providerIcons);
|
||||
await verifyExistingAgentModelIcon(providerIcons);
|
||||
});
|
||||
@@ -1,18 +0,0 @@
|
||||
import {
|
||||
test,
|
||||
openRequirementHost,
|
||||
expectAppMismatch,
|
||||
correctRequirementAndReload,
|
||||
rejectDaemonMismatch,
|
||||
} from "../support/helpers/plugin-requirements";
|
||||
|
||||
test("shows which runtime is incompatible and recovers after the requirement is corrected", async ({
|
||||
page,
|
||||
requirementHost,
|
||||
}) => {
|
||||
await openRequirementHost(page, requirementHost);
|
||||
await expectAppMismatch(page);
|
||||
await correctRequirementAndReload(page, requirementHost.directory);
|
||||
await rejectDaemonMismatch(page, requirementHost.directory);
|
||||
await correctRequirementAndReload(page, requirementHost.directory);
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
import {
|
||||
test,
|
||||
openDisplaySettings,
|
||||
openCompactDisplaySettings,
|
||||
groupByWorkspace,
|
||||
hideMetadata,
|
||||
reloadSavedDisplaySettings,
|
||||
editTitle,
|
||||
replaceTitle,
|
||||
saveTitle,
|
||||
expectTitleRequired,
|
||||
expectSavedTitle,
|
||||
changeMetadataFromAnotherClient,
|
||||
expectConflictPreservesDraft,
|
||||
discardTitle,
|
||||
returnAndReopenSettings,
|
||||
expectSettingsUnavailable,
|
||||
} from "../support/helpers/plugin-settings";
|
||||
|
||||
test("display preferences survive a reload", async ({ page }) => {
|
||||
await openDisplaySettings(page);
|
||||
await groupByWorkspace(page);
|
||||
await hideMetadata(page);
|
||||
await reloadSavedDisplaySettings(page);
|
||||
});
|
||||
|
||||
test("invalid titles stay editable until a valid title is saved", async ({ page }) => {
|
||||
await openDisplaySettings(page);
|
||||
await editTitle(page, "");
|
||||
await saveTitle(page);
|
||||
await expectTitleRequired(page);
|
||||
await replaceTitle(page, "My monitor");
|
||||
await saveTitle(page);
|
||||
await expectSavedTitle(page, "My monitor");
|
||||
});
|
||||
|
||||
test("another client's save preserves a conflicting draft", async ({ page }) => {
|
||||
await openDisplaySettings(page);
|
||||
await editTitle(page, "Unsaved title");
|
||||
await changeMetadataFromAnotherClient(page);
|
||||
await saveTitle(page);
|
||||
await expectConflictPreservesDraft(page, "Unsaved title");
|
||||
await discardTitle(page);
|
||||
});
|
||||
|
||||
test.describe("compact plugin settings", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
|
||||
test("Back and plugin re-enable restore the settings screen", async ({
|
||||
page,
|
||||
settingsPlugin,
|
||||
}) => {
|
||||
await openCompactDisplaySettings(page);
|
||||
await groupByWorkspace(page);
|
||||
await hideMetadata(page);
|
||||
await returnAndReopenSettings(page);
|
||||
await settingsPlugin.disablePlugin("settings-example");
|
||||
await expectSettingsUnavailable(page);
|
||||
await settingsPlugin.enablePlugin("settings-example");
|
||||
await reloadSavedDisplaySettings(page);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,3 @@
|
||||
import { pluginRequirements } from "../support/helpers/plugin-fixture";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -54,10 +53,7 @@ test("applies a contributed theme and falls back when its plugin is gone", async
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-theme-e2e-"));
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previousConfig = await client.getDaemonConfig();
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: PLUGIN_ID }));
|
||||
await writeFile(path.join(directory, "index.client.ts"), PLUGIN_SOURCE);
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { pluginRequirements } from "../support/helpers/plugin-fixture";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -30,8 +29,7 @@ function isSettledWorkspaceUrl(url: URL): boolean {
|
||||
function pluginClientSource(input: { workspaceId: string; agentId: string }): string {
|
||||
return `import React, { useRef } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { Icon } from "@getpaseo/plugin/client/react-native";
|
||||
import { useAgent, useWorkspace } from "@getpaseo/plugin/client";
|
||||
import { Icon, useAgent, useWorkspace } from "@getpaseo/plugin";
|
||||
import { recordComposerOpen } from "./shared/rpc";
|
||||
|
||||
function WorkspacePanel({ workspaceId, host, layout }) {
|
||||
@@ -61,6 +59,12 @@ function SidebarCollisionSurface() {
|
||||
return <View><Text>Sidebar collision surface</Text></View>;
|
||||
}
|
||||
|
||||
function ComposerPill({ theme, workspaceId, agentId }) {
|
||||
const workspace = useWorkspace(workspaceId, (value) => ({ title: value.title }));
|
||||
const agent = useAgent(agentId, (value) => ({ title: value.title }));
|
||||
return <><Icon name="Scan" size={14} color={theme.colors.foregroundMuted} /><Text numberOfLines={1} style={{ color: theme.colors.foregroundMuted, flexShrink: 1 }}>Review {workspace?.title}:{agent?.title}</Text></>;
|
||||
}
|
||||
|
||||
function contributeClient(client) {
|
||||
const pills = new Map();
|
||||
const remove = (agentId) => {
|
||||
@@ -75,25 +79,23 @@ function contributeClient(client) {
|
||||
const agent = update.agent;
|
||||
if (agent.title !== "Plugin panel context agent" || !agent.workspaceId) return;
|
||||
remove(agent.id);
|
||||
const pill = client.addComposerPill({
|
||||
let removePill = () => {};
|
||||
removePill = client.addComposerPill({
|
||||
id: "review",
|
||||
title: "Open composer review",
|
||||
workspaceId: agent.workspaceId,
|
||||
agentId: agent.id,
|
||||
button: {
|
||||
title: "Open composer review",
|
||||
icon: "Scan",
|
||||
label: "Review",
|
||||
behavior: { kind: "action", async onPress() {
|
||||
Component: ComposerPill,
|
||||
async onPress() {
|
||||
await client.rpc(recordComposerOpen, { workspaceId: agent.workspaceId });
|
||||
pill.remove();
|
||||
removePill();
|
||||
client.openPanel("agent", {
|
||||
workspaceId: agent.workspaceId,
|
||||
agentId: agent.id,
|
||||
});
|
||||
} },
|
||||
},
|
||||
});
|
||||
pills.set(agent.id, () => pill.remove());
|
||||
pills.set(agent.id, removePill);
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
@@ -197,10 +199,7 @@ test.describe("plugin workspace panels and Command Center", () => {
|
||||
repoPrefix: "plugin-panel-secondary-",
|
||||
port: secondaryDaemon.port,
|
||||
});
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: PLUGIN_ID }));
|
||||
await writePluginSources(directory, {
|
||||
workspaceId: primary.workspaceId,
|
||||
agentId: "missing-agent",
|
||||
@@ -329,7 +328,9 @@ test.describe("plugin workspace panels and Command Center", () => {
|
||||
timeout: 30_000,
|
||||
});
|
||||
const composerPill = page.getByRole("button", { name: "Open composer review" });
|
||||
await expect(composerPill).toContainText("Review");
|
||||
await expect(composerPill).toContainText(
|
||||
"Review Unrelated title update:Plugin panel context agent",
|
||||
);
|
||||
await capture(page, testInfo, "plugin-composer-pill-wide");
|
||||
await page.setViewportSize(COMPACT_VIEWPORT);
|
||||
await expect(page.getByRole("button", { name: "Open composer review" })).toBeVisible();
|
||||
|
||||
@@ -9,11 +9,6 @@ import {
|
||||
type AgentHandle,
|
||||
type RewindFlowProvider,
|
||||
} from "../support/helpers/rewind-flow";
|
||||
import {
|
||||
expectNestedProviderSubagentOwnership,
|
||||
launchNestedProviderSubagentOwnershipScenario,
|
||||
reopenNestedProviderSession,
|
||||
} from "../support/helpers/provider-subagents";
|
||||
import { openSubagentsTrack } from "../support/helpers/subagents";
|
||||
|
||||
interface ProviderSubagentCase {
|
||||
@@ -31,7 +26,7 @@ const cases: ProviderSubagentCase[] = [
|
||||
provider: "claude",
|
||||
sentinel: "CLAUDE_CHILD_SENTINEL",
|
||||
expectedName: "sentinel_child",
|
||||
providerConfig: { model: "claude-sonnet-5" },
|
||||
providerConfig: { model: "opus" },
|
||||
prompt:
|
||||
'Use Claude Code\'s native Task tool exactly once. Set its subagent_type input to "Explore" and its name input to "sentinel_child". Ask it to reply with exactly CLAUDE_CHILD_SENTINEL and do nothing else. Wait for it, then reply ROOT_DONE. Do not use Paseo tools.',
|
||||
},
|
||||
@@ -152,23 +147,3 @@ test.describe("real provider subagent timelines", () => {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test.describe("real Claude nested subagent ownership", () => {
|
||||
test.setTimeout(600_000);
|
||||
|
||||
test("keeps a grandchild and its background notification with their direct owners", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-claude-nested-ui-")));
|
||||
let handle: AgentHandle | undefined;
|
||||
|
||||
try {
|
||||
handle = await launchNestedProviderSubagentOwnershipScenario(page, cwd);
|
||||
await expectNestedProviderSubagentOwnership(page, testInfo, "live");
|
||||
await reopenNestedProviderSession(handle);
|
||||
await expectNestedProviderSubagentOwnership(page, testInfo, "reopened");
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { openSettingsSection } from "../support/helpers/settings";
|
||||
import { openWhatsNew, release, serveChangelog } from "../support/helpers/changelog";
|
||||
|
||||
const DISCORD_DESTINATION =
|
||||
/^https:\/\/(?:discord\.gg\/jz8T2uahpH|discord\.com\/invite\/jz8T2uahpH)(?:[/?#]|$)/;
|
||||
@@ -42,7 +41,7 @@ async function expectExternalPage(
|
||||
await popup.close();
|
||||
}
|
||||
|
||||
test("opens troubleshooting and support destinations", async ({ page }) => {
|
||||
test("opens troubleshooting, support, and release destinations", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await expect(page.getByTestId("sidebar-help")).toBeVisible();
|
||||
|
||||
@@ -63,60 +62,18 @@ test("opens troubleshooting and support destinations", async ({ page }) => {
|
||||
await closeSheet(page, "keyboard-shortcuts-dialog");
|
||||
});
|
||||
|
||||
await test.step("opens support pages", async () => {
|
||||
await test.step("opens support and release pages", async () => {
|
||||
await openHelpMenu(page);
|
||||
await expectExternalPage(page, "sidebar-help-discord", DISCORD_DESTINATION);
|
||||
|
||||
await openHelpMenu(page);
|
||||
await expectExternalPage(page, "sidebar-help-github", GITHUB_ISSUE_DESTINATION);
|
||||
|
||||
await openHelpMenu(page);
|
||||
await expectExternalPage(page, "sidebar-help-changelog", CHANGELOG_DESTINATION);
|
||||
});
|
||||
});
|
||||
|
||||
test("renders the changelog in the app and links the website", async ({ page }) => {
|
||||
// A callout, a section name the app has never seen, and a fenced sample whose
|
||||
// contents look like a release heading.
|
||||
await serveChangelog(page, [
|
||||
"# Changelog",
|
||||
"",
|
||||
"## 9.1.0 - 2026-03-04",
|
||||
"",
|
||||
"Headline release note.",
|
||||
"",
|
||||
"> [!WARNING]",
|
||||
"> Read this before upgrading.",
|
||||
"",
|
||||
"### Sparkles",
|
||||
"",
|
||||
"- Added a brand new thing",
|
||||
"",
|
||||
"```md",
|
||||
"## 0.0.0 - 1999-01-01",
|
||||
"```",
|
||||
"",
|
||||
"## 9.0.0 - 2026-02-01",
|
||||
"",
|
||||
"### Fixed",
|
||||
"",
|
||||
"- Fixed an older thing",
|
||||
"",
|
||||
]);
|
||||
await gotoAppShell(page);
|
||||
|
||||
const sheet = await openWhatsNew(page);
|
||||
const latest = release(sheet, "9.1.0");
|
||||
|
||||
await expect(latest.getByText("March 4, 2026", { exact: true })).toBeVisible();
|
||||
await expect(latest.getByText("Headline release note.")).toBeVisible();
|
||||
await expect(latest.getByText("Read this before upgrading.")).toBeVisible();
|
||||
await expect(latest.getByText("Sparkles", { exact: true })).toBeVisible();
|
||||
await expect(latest.getByText("Added a brand new thing")).toBeVisible();
|
||||
await expect(release(sheet, "9.0.0")).toBeVisible();
|
||||
await expect(release(sheet, "0.0.0")).toHaveCount(0);
|
||||
|
||||
await expectExternalPage(page, "changelog-open-website", CHANGELOG_DESTINATION);
|
||||
await closeSheet(page, "changelog-sheet");
|
||||
});
|
||||
|
||||
test("searches keyboard shortcuts from the sidebar help menu", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(navigator, "platform", { get: () => "MacIntel" });
|
||||
|
||||
@@ -1,229 +0,0 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test as base, expect } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
openAgentRoute,
|
||||
seedMockAgentWorkspace,
|
||||
type MockAgentWorkspace,
|
||||
} from "../support/helpers/mock-agent";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { closeMobileAgentSidebar, openMobileAgentSidebar } from "../support/helpers/sidebar";
|
||||
|
||||
interface FinishedWorkspaces {
|
||||
subject: MockAgentWorkspace;
|
||||
other: MockAgentWorkspace;
|
||||
}
|
||||
|
||||
const test = base.extend<{ workspaces: FinishedWorkspaces }>({
|
||||
workspaces: async ({ browserName: _browserName }, provide) => {
|
||||
const seeded: MockAgentWorkspace[] = [];
|
||||
async function finishedWorkspace(title: string) {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "workspace-mark-unread-",
|
||||
title,
|
||||
initialPrompt: "Finish this turn.",
|
||||
});
|
||||
seeded.push(workspace);
|
||||
await workspace.client.waitForFinish(workspace.agentId, 20_000);
|
||||
await workspace.client.clearWorkspaceAttention(workspace.workspaceId);
|
||||
return workspace;
|
||||
}
|
||||
try {
|
||||
await provide({
|
||||
subject: await finishedWorkspace("Unread subject"),
|
||||
other: await finishedWorkspace("Other workspace"),
|
||||
});
|
||||
} finally {
|
||||
for (const workspace of seeded) await workspace.cleanup();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function workspaceRow(page: Page, workspaceId: string) {
|
||||
return page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
|
||||
}
|
||||
|
||||
async function openWorkspace(page: Page, workspaceId: string) {
|
||||
await workspaceRow(page, workspaceId).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/workspace/${workspaceId}`));
|
||||
}
|
||||
|
||||
async function chooseReadAction(page: Page, workspaceId: string, action: "read" | "unread") {
|
||||
await workspaceRow(page, workspaceId).hover();
|
||||
await page.getByTestId(`sidebar-workspace-kebab-${getServerId()}:${workspaceId}`).click();
|
||||
const item = page.getByRole("menuitem", { name: `Mark as ${action}`, exact: true });
|
||||
await expect(item).toBeVisible();
|
||||
await item.click();
|
||||
}
|
||||
|
||||
async function markAsUnread(page: Page, workspaceId: string) {
|
||||
await chooseReadAction(page, workspaceId, "unread");
|
||||
await expectStatus(page, workspaceId, "attention");
|
||||
}
|
||||
|
||||
async function markAsRead(page: Page, workspaceId: string) {
|
||||
await chooseReadAction(page, workspaceId, "read");
|
||||
await expectStatus(page, workspaceId, "done");
|
||||
}
|
||||
|
||||
async function expectStatus(page: Page, workspaceId: string, status: "done" | "attention") {
|
||||
await expect(
|
||||
workspaceRow(page, workspaceId).getByTestId(`workspace-status-indicator-${status}`),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
async function markBackgroundWorkspaceAndReopen(page: Page, workspaceId: string) {
|
||||
await test.step("background workspace gains green dot and clears when clicked", async () => {
|
||||
await markAsUnread(page, workspaceId);
|
||||
await openWorkspace(page, workspaceId);
|
||||
await expectStatus(page, workspaceId, "done");
|
||||
});
|
||||
}
|
||||
|
||||
async function leaveMarkedWorkspaceAndRead(page: Page, { subject, other }: FinishedWorkspaces) {
|
||||
await test.step("leaving preserves manual unread until Mark as read", async () => {
|
||||
await markAsUnread(page, subject.workspaceId);
|
||||
await openWorkspace(page, other.workspaceId);
|
||||
await markAsRead(page, subject.workspaceId);
|
||||
await openWorkspace(page, subject.workspaceId);
|
||||
});
|
||||
}
|
||||
|
||||
async function leaveMarkedWorkspaceAndReopen(page: Page, { subject, other }: FinishedWorkspaces) {
|
||||
await test.step("leaving preserves manual unread until reopening", async () => {
|
||||
await markAsUnread(page, subject.workspaceId);
|
||||
await openWorkspace(page, other.workspaceId);
|
||||
await expectStatus(page, subject.workspaceId, "attention");
|
||||
await openWorkspace(page, subject.workspaceId);
|
||||
await expectStatus(page, subject.workspaceId, "done");
|
||||
});
|
||||
}
|
||||
|
||||
async function completeTurnAndLeave(page: Page, { subject, other }: FinishedWorkspaces) {
|
||||
await test.step("ordinary completion still clears on departure", async () => {
|
||||
await subject.client.sendAgentMessage(subject.agentId, "Finish another turn.");
|
||||
await subject.client.waitForFinish(subject.agentId, 20_000);
|
||||
await expectStatus(page, subject.workspaceId, "attention");
|
||||
await openWorkspace(page, other.workspaceId);
|
||||
await expectStatus(page, subject.workspaceId, "done");
|
||||
});
|
||||
}
|
||||
|
||||
async function openWorkspaceOnCompact(page: Page, workspaceId: string) {
|
||||
await openWorkspace(page, workspaceId);
|
||||
await openMobileAgentSidebar(page);
|
||||
}
|
||||
|
||||
async function leaveMarkedWorkspaceAndReadOnCompact(
|
||||
page: Page,
|
||||
{ subject, other }: FinishedWorkspaces,
|
||||
) {
|
||||
await test.step("leaving preserves manual unread until Mark as read", async () => {
|
||||
await markAsUnread(page, subject.workspaceId);
|
||||
await openWorkspaceOnCompact(page, other.workspaceId);
|
||||
await markAsRead(page, subject.workspaceId);
|
||||
await openWorkspaceOnCompact(page, subject.workspaceId);
|
||||
});
|
||||
}
|
||||
|
||||
async function leaveMarkedWorkspaceAndReopenOnCompact(
|
||||
page: Page,
|
||||
{ subject, other }: FinishedWorkspaces,
|
||||
) {
|
||||
await test.step("leaving preserves manual unread until reopening", async () => {
|
||||
await markAsUnread(page, subject.workspaceId);
|
||||
await openWorkspaceOnCompact(page, other.workspaceId);
|
||||
await expectStatus(page, subject.workspaceId, "attention");
|
||||
await openWorkspaceOnCompact(page, subject.workspaceId);
|
||||
await expectStatus(page, subject.workspaceId, "done");
|
||||
});
|
||||
}
|
||||
|
||||
async function completeTurnAndLeaveOnCompact(page: Page, { subject, other }: FinishedWorkspaces) {
|
||||
await test.step("ordinary completion still clears on departure", async () => {
|
||||
await closeMobileAgentSidebar(page);
|
||||
await subject.client.sendAgentMessage(subject.agentId, "Finish another turn.");
|
||||
await subject.client.waitForFinish(subject.agentId, 20_000);
|
||||
await openMobileAgentSidebar(page);
|
||||
await expectStatus(page, subject.workspaceId, "attention");
|
||||
await openWorkspaceOnCompact(page, other.workspaceId);
|
||||
await expectStatus(page, subject.workspaceId, "done");
|
||||
});
|
||||
}
|
||||
|
||||
async function markBackgroundWorkspaceAndRead(page: Page, workspaceId: string) {
|
||||
await test.step("explicit Mark as read clears a background workspace", async () => {
|
||||
await markAsUnread(page, workspaceId);
|
||||
await markAsRead(page, workspaceId);
|
||||
});
|
||||
}
|
||||
|
||||
async function expectSelectedAgent(page: Page, agentId: string) {
|
||||
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`).first()).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
}
|
||||
|
||||
async function addFinishedAgent(workspace: MockAgentWorkspace) {
|
||||
return workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.cwd,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Newest agent",
|
||||
modeId: "load-test",
|
||||
model: "e2e-fast-stream",
|
||||
});
|
||||
}
|
||||
|
||||
async function openCompactWorkspace(page: Page, workspace: MockAgentWorkspace) {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openAgentRoute(page, workspace);
|
||||
await openMobileAgentSidebar(page);
|
||||
}
|
||||
|
||||
async function markUnreadThenResumeChat(page: Page, workspaceId: string) {
|
||||
await test.step("interacting with the visible chat clears manual unread", async () => {
|
||||
await markAsUnread(page, workspaceId);
|
||||
await closeMobileAgentSidebar(page);
|
||||
await page.getByRole("textbox", { name: "Message agent..." }).click();
|
||||
await openMobileAgentSidebar(page);
|
||||
await expectStatus(page, workspaceId, "done");
|
||||
});
|
||||
}
|
||||
|
||||
test("manual unread survives departure and clears on reopening without changing normal completions", async ({
|
||||
page,
|
||||
workspaces,
|
||||
}) => {
|
||||
await gotoAppShell(page);
|
||||
await openWorkspace(page, workspaces.other.workspaceId);
|
||||
await markBackgroundWorkspaceAndReopen(page, workspaces.subject.workspaceId);
|
||||
await leaveMarkedWorkspaceAndRead(page, workspaces);
|
||||
await leaveMarkedWorkspaceAndReopen(page, workspaces);
|
||||
await completeTurnAndLeave(page, workspaces);
|
||||
await markBackgroundWorkspaceAndRead(page, workspaces.subject.workspaceId);
|
||||
});
|
||||
|
||||
test("clicking a multi-agent workspace reveals and clears its marked agent", async ({
|
||||
page,
|
||||
workspaces,
|
||||
}) => {
|
||||
await openAgentRoute(page, workspaces.subject);
|
||||
await expectSelectedAgent(page, workspaces.subject.agentId);
|
||||
await openWorkspace(page, workspaces.other.workspaceId);
|
||||
const newest = await addFinishedAgent(workspaces.subject);
|
||||
await markBackgroundWorkspaceAndReopen(page, workspaces.subject.workspaceId);
|
||||
await expectSelectedAgent(page, newest.id);
|
||||
});
|
||||
|
||||
test("manual unread survives leaving the current workspace on compact layout", async ({
|
||||
page,
|
||||
workspaces,
|
||||
}) => {
|
||||
await openCompactWorkspace(page, workspaces.subject);
|
||||
await leaveMarkedWorkspaceAndReadOnCompact(page, workspaces);
|
||||
await leaveMarkedWorkspaceAndReopenOnCompact(page, workspaces);
|
||||
await markUnreadThenResumeChat(page, workspaces.subject.workspaceId);
|
||||
await completeTurnAndLeaveOnCompact(page, workspaces);
|
||||
});
|
||||
@@ -14,7 +14,6 @@ import { getServerId } from "../support/helpers/server-id";
|
||||
import { projectEquivalenceViewKey } from "../support/helpers/project-view-key";
|
||||
import { escapeRegex } from "../support/helpers/regex";
|
||||
import { openFilesPanel } from "../support/helpers/workspace-tabs";
|
||||
import { seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
const GITHUB_REMOTE_URL = "https://github.com/test-owner/test-repo.git";
|
||||
|
||||
@@ -49,20 +48,6 @@ async function waitForSidebarWorkspace(page: import("@playwright/test").Page, wo
|
||||
return row;
|
||||
}
|
||||
|
||||
async function openWorkspaceReadAction(
|
||||
page: import("@playwright/test").Page,
|
||||
workspaceId: string,
|
||||
action: "read" | "unread",
|
||||
) {
|
||||
const workspaceKey = `${getServerId()}:${workspaceId}`;
|
||||
const row = await waitForSidebarWorkspace(page, workspaceId);
|
||||
await row.hover();
|
||||
await page.getByTestId(`sidebar-workspace-kebab-${workspaceKey}`).click();
|
||||
const item = page.getByTestId(`sidebar-workspace-menu-mark-as-${action}-${workspaceKey}`);
|
||||
await expect(item).toBeVisible({ timeout: 30_000 });
|
||||
return item;
|
||||
}
|
||||
|
||||
async function openWorkspaceHoverCard(page: import("@playwright/test").Page, workspaceId: string) {
|
||||
const row = await waitForSidebarWorkspace(page, workspaceId);
|
||||
await row.hover();
|
||||
@@ -202,32 +187,6 @@ test.describe("Sidebar workspace list", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("marks a finished workspace unread until it is opened again", async ({ page }) => {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "sidebar-mark-unread-",
|
||||
title: "Mark unread",
|
||||
initialPrompt: "Finish this test turn.",
|
||||
});
|
||||
|
||||
try {
|
||||
await workspace.client.waitForFinish(workspace.agentId, 20_000);
|
||||
await workspace.client.clearWorkspaceAttention(workspace.workspaceId);
|
||||
expect(workspace.client.getLastServerInfoMessage()?.features?.workspaceMarkUnread).toBe(true);
|
||||
await gotoAppShell(page);
|
||||
|
||||
const row = await waitForSidebarWorkspace(page, workspace.workspaceId);
|
||||
await expect(row.getByTestId("workspace-status-indicator-done")).toBeVisible();
|
||||
await (await openWorkspaceReadAction(page, workspace.workspaceId, "unread")).click();
|
||||
await openWorkspaceReadAction(page, workspace.workspaceId, "read");
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await openWorkspaceFromSidebar(page, workspace.workspaceId);
|
||||
await openWorkspaceReadAction(page, workspace.workspaceId, "unread");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("Paseo-owned worktree hover card shows the worktree directory name", async ({ page }) => {
|
||||
await withPaseoOwnedWorktree(async ({ projectName, workspaceId, worktreeSlug }) => {
|
||||
await gotoAppShell(page);
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import { test, expect, type Page } from "../support/fixtures";
|
||||
import { clickNewTerminal, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { renameModalInput, renameModalSubmit } from "../support/helpers/rename";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import { selectWorkspaceInSidebar } from "../support/helpers/sidebar";
|
||||
|
||||
function terminalTab(page: Page, terminalId: string) {
|
||||
// Terminal tab chips have no tab role; their persistent identity survives title loading.
|
||||
return page.getByTestId(`workspace-tab-terminal_${terminalId}`).filter({ visible: true }).first();
|
||||
}
|
||||
|
||||
async function createNamedTerminal(page: Page, workspace: SeededWorkspace, title: string) {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await clickNewTerminal(page);
|
||||
const list = () =>
|
||||
workspace.client.listTerminals(workspace.repoPath, undefined, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
});
|
||||
await expect.poll(async () => (await list()).terminals.length).toBe(1);
|
||||
const terminalId = (await list()).terminals[0]!.id;
|
||||
await terminalTab(page, terminalId).click({ button: "right" });
|
||||
await page.getByRole("menuitem", { name: "Rename", exact: true }).click();
|
||||
const modal = `workspace-tab-rename-modal-terminal-${terminalId}`;
|
||||
await renameModalInput(page, modal).fill(title);
|
||||
await renameModalSubmit(page, modal).click();
|
||||
await expect(renameModalInput(page, modal)).toHaveCount(0);
|
||||
await expect(terminalTab(page, terminalId)).toHaveText(title);
|
||||
return terminalId;
|
||||
}
|
||||
|
||||
async function recordRestoredTabText(page: Page, terminalId: string) {
|
||||
// Install before the next document loads: a final-state assertion would miss the flash.
|
||||
await page.addInitScript((id) => {
|
||||
const samples: string[] = [];
|
||||
Object.assign(window, { terminalTitleSamples: samples });
|
||||
new MutationObserver(() => {
|
||||
const tab = document.querySelector(`[data-testid="workspace-tab-terminal_${id}"]`);
|
||||
if (!tab) return;
|
||||
const text = (tab.textContent ?? "").trim();
|
||||
if (samples.at(-1) !== text) samples.push(text);
|
||||
}).observe(document, { subtree: true, childList: true, characterData: true });
|
||||
}, terminalId);
|
||||
}
|
||||
|
||||
async function expectRestoredTitle(page: Page, terminalId: string, title: string, stage: string) {
|
||||
await expect(terminalTab(page, terminalId)).toHaveText(title, { timeout: 30_000 });
|
||||
const samples = await page.evaluate(
|
||||
() => (window as unknown as { terminalTitleSamples: string[] }).terminalTitleSamples,
|
||||
);
|
||||
await test.info().attach(`${stage}-tab-text`, {
|
||||
body: JSON.stringify(samples),
|
||||
contentType: "application/json",
|
||||
});
|
||||
expect(samples).toContain(title);
|
||||
expect(samples).not.toContain("Terminal");
|
||||
}
|
||||
|
||||
test("restored terminal titles do not flash a default name while loading", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "terminal-title-loading-", git: false });
|
||||
const other = await seedWorkspace({ repoPrefix: "terminal-title-other-", git: false });
|
||||
try {
|
||||
const title = "My named terminal";
|
||||
const terminalId = await createNamedTerminal(page, workspace, title);
|
||||
await recordRestoredTabText(page, terminalId);
|
||||
|
||||
await test.step("reload with a terminal tab restored", async () => {
|
||||
await page.reload();
|
||||
await expectRestoredTitle(page, terminalId, title, "reload");
|
||||
});
|
||||
|
||||
await test.step("first workspace visit in a fresh page session", async () => {
|
||||
await gotoWorkspace(page, other.workspaceId);
|
||||
await selectWorkspaceInSidebar(page, workspace.workspaceId);
|
||||
await expectRestoredTitle(page, terminalId, title, "first-visit");
|
||||
});
|
||||
|
||||
await test.step("return to the workspace with its titles already loaded", async () => {
|
||||
await selectWorkspaceInSidebar(page, other.workspaceId);
|
||||
await selectWorkspaceInSidebar(page, workspace.workspaceId);
|
||||
await expectRestoredTitle(page, terminalId, title, "return-visit");
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
await other.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { expect, type Page } from "@playwright/test";
|
||||
import { metroTest as test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
createMockIdleAgent,
|
||||
createIdleAgent,
|
||||
expectSessionRowArchived,
|
||||
openSessions,
|
||||
} from "../support/helpers/archive-tab";
|
||||
@@ -97,7 +97,7 @@ test.describe("Worktree restore after daemon restart", () => {
|
||||
createdProjectIds.add(worktree.projectKey);
|
||||
createdWorktreeDirectories.add(worktree.workspaceDirectory);
|
||||
|
||||
const agent = await createMockIdleAgent(client, {
|
||||
const agent = await createIdleAgent(client, {
|
||||
cwd: worktree.workspaceDirectory,
|
||||
workspaceId: worktree.workspaceId,
|
||||
title: `restart-restore-${randomUUID().slice(0, 8)}`,
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
# Native sheet gestures
|
||||
|
||||
Use an Android emulator at 1080×1920, density 420, with the checkout's native app bundle loaded.
|
||||
Connect the app to an isolated daemon, enable plugins, and install `plugin-examples/modal-ui` there.
|
||||
Set `SHEET_QA_SERVER_ID` to that host's server ID, then run from the repository root:
|
||||
|
||||
```sh
|
||||
agent-device test packages/app/e2e/mobile/modal-sheet/gestures.android.ad \
|
||||
--env EXAMPLES_URL="paseo://h/${SHEET_QA_SERVER_ID}/plugin/modal-ui-example/surface/main" \
|
||||
--record-video --artifacts-dir .dev/sheet-qa/plugin
|
||||
|
||||
agent-device test packages/app/e2e/mobile/modal-sheet/model.android.ad \
|
||||
--env FORM_URL="paseo://new?serverId=${SHEET_QA_SERVER_ID}" \
|
||||
--record-video --artifacts-dir .dev/sheet-qa/model
|
||||
```
|
||||
|
||||
The plugin journey checks body dismissal, reopening, expansion and last-row reachability with SDK
|
||||
ScrollView and FlatList, programmatic scrolling after expansion, downward list scrolling, and
|
||||
horizontal tab selection. The model journey checks body dismissal and the nested sheet's return to
|
||||
its parent without selecting a model or submitting a prompt. It does not assert catalog contents.
|
||||
|
||||
These scripts live outside the default mobile suite because they require the installed example and
|
||||
an explicit connected host. See [mobile testing](../../../../../docs/mobile-testing.md) for device setup.
|
||||
|
||||
## Recorded verification
|
||||
|
||||
Android API 35, 1080×1920 at density 420, development binary 0.7.2 with this checkout's JavaScript.
|
||||
Plugin tests used an isolated daemon. Recordings show the same body drag
|
||||
[before](evidence/android-before.mp4) and [after](evidence/android-after.mp4) the fix.
|
||||
The [browser recording](evidence/browser.webm) covers the existing plugin modal journey at wide and
|
||||
compact sizes.
|
||||
|
||||
```text
|
||||
Baseline dismissal regression:
|
||||
failed at step 6: wait timed out for selector: label="Open ScrollView"
|
||||
Current surface: Bottom sheet handle, Bottom Sheet, Close, Row 1.
|
||||
|
||||
Fixed dismissal regression: 1 passed (6.47s)
|
||||
Full plugin gesture journey: 1 passed (44.3s)
|
||||
Model sheet dismissal/reopen journey: 1 passed (13.7s)
|
||||
Browser plugin-modal-body.spec.ts: 1 passed (33.4s)
|
||||
Root typecheck, lint and format: passed
|
||||
```
|
||||
|
||||
The model form's catalog stayed on “Loading…” in the debug app even though the isolated daemon's
|
||||
provider API returned models. Its sheet dismissal/reopen was exercised; populated model selection
|
||||
was not. iOS and Electron were not exercised.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,52 +0,0 @@
|
||||
# Install plugin-examples/modal-ui on the connected host and set EXAMPLES_URL to
|
||||
# paseo://h/<serverId>/plugin/modal-ui-example/surface/main.
|
||||
# Gesture coordinates target a 1080x1920 Android emulator at density 420.
|
||||
context platform=android timeout=120000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}" "${EXAMPLES_URL}"
|
||||
wait "label=\"Open ScrollView\"" 20000
|
||||
press "label=\"Open ScrollView\""
|
||||
wait "label=\"Row 1\"" 10000
|
||||
gesture pan 540 980 0 820 500
|
||||
wait "label=\"Open ScrollView\"" 3000
|
||||
|
||||
# The caller-owned ScrollView expands with a body drag and reaches its final row.
|
||||
press "label=\"Open ScrollView\""
|
||||
wait "label=\"Row 1\"" 10000
|
||||
gesture pan 540 980 0 -600 500
|
||||
wait "label=\"Row 10\"" 3000
|
||||
scroll bottom
|
||||
wait "label=\"Row 100\"" 3000
|
||||
press "role=\"button\" label=\"Row 100\""
|
||||
press "label=\"Close\""
|
||||
wait "text=\"Selected: Row 100\"" 3000
|
||||
|
||||
# FlatList has the same body dismissal, then supports refs after expansion.
|
||||
press "label=\"Open FlatList\""
|
||||
wait "label=\"Row 1\"" 10000
|
||||
gesture pan 540 1100 0 700 500
|
||||
wait "label=\"Open FlatList\"" 3000
|
||||
press "label=\"Open FlatList\""
|
||||
wait "label=\"Row 1\"" 10000
|
||||
gesture pan 540 1140 0 -750 500
|
||||
wait "label=\"Row 9\"" 3000
|
||||
press "label=\"Jump to last row\""
|
||||
wait "label=\"Row 100\"" 3000
|
||||
# Dragging down while scrolled moves the list, leaving the sheet open.
|
||||
gesture pan 540 650 0 900 500
|
||||
wait "label=\"Row 88\"" 3000
|
||||
is visible "label=\"Close\""
|
||||
press "label=\"Close\""
|
||||
wait "label=\"Open Horizontal\"" 3000
|
||||
|
||||
# Horizontal tabs still receive gestures inside the host-owned vertical body.
|
||||
press "label=\"Open Horizontal\""
|
||||
wait "label=\"Overview\"" 10000
|
||||
gesture pan 960 980 -880 0 500
|
||||
wait stable 300 5000
|
||||
press "role=\"button\" label=\"System logs\""
|
||||
wait stable 300 5000
|
||||
press "label=\"Close\""
|
||||
wait "text=\"Selected: System logs\"" 3000
|
||||
close
|
||||
@@ -1,22 +0,0 @@
|
||||
# FORM_URL is paseo://new?serverId=<connected-serverId>.
|
||||
# Gesture coordinates target a 1080x1920 Android emulator at density 420.
|
||||
context platform=android timeout=60000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}" "${FORM_URL}"
|
||||
wait "id=\"combined-model-selector\"" 20000
|
||||
press "id=\"combined-model-selector\""
|
||||
wait "id=\"agent-controls-model\"" 10000
|
||||
gesture pan 540 790 0 960 500
|
||||
wait "id=\"combined-model-selector\"" 3000
|
||||
|
||||
# Reopen, dismiss the nested model browser, then dismiss its restored parent.
|
||||
press "id=\"combined-model-selector\""
|
||||
wait "id=\"agent-controls-model\"" 10000
|
||||
press "id=\"agent-controls-model\""
|
||||
wait stable 300 5000
|
||||
gesture pan 540 790 0 960 500
|
||||
wait "id=\"agent-controls-model\"" 3000
|
||||
gesture pan 540 790 0 960 500
|
||||
wait "id=\"combined-model-selector\"" 3000
|
||||
close
|
||||
@@ -1,117 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { setTimeout } from "node:timers/promises";
|
||||
|
||||
// Open an idle terminal with an empty prompt and hide its keyboard before running.
|
||||
// The app must already run the checkout/build being verified against a real daemon.
|
||||
const appId = process.env.PASEO_TERMINAL_KEYBOARD_APP_ID ?? "sh.paseo.debug";
|
||||
const serial = process.env.ANDROID_SERIAL ?? "emulator-5554";
|
||||
const artifacts = resolve(".dev/agent-device-artifacts/terminal-keyboard-android");
|
||||
const session = "terminal-keyboard-android";
|
||||
const env = {
|
||||
...process.env,
|
||||
AGENT_DEVICE_STATE_DIR: resolve(".dev/agent-device-terminal-keyboard"),
|
||||
};
|
||||
mkdirSync(artifacts, { recursive: true });
|
||||
|
||||
function run(binary, args) {
|
||||
return execFileSync(binary, args, { env, encoding: "utf8", timeout: 30_000 });
|
||||
}
|
||||
function ad(...args) {
|
||||
return run("agent-device", [...args, "--session", session]);
|
||||
}
|
||||
function adb(...args) {
|
||||
return run("adb", ["-s", serial, ...args]);
|
||||
}
|
||||
function keyboardState() {
|
||||
const state = adb("shell", "dumpsys", "input_method");
|
||||
const view = state.match(/^ mServedView=(.+)$/m)?.[1] ?? "";
|
||||
return {
|
||||
shown: /^ mInputShown=true$/m.test(state),
|
||||
// The native object identity survives layout/flag changes but not a remount.
|
||||
input: view.match(/ReactEditText\{(\w+)/)?.[1] ?? null,
|
||||
};
|
||||
}
|
||||
async function waitForKeyboard(shown) {
|
||||
let stable = 0;
|
||||
const deadline = Date.now() + 10_000;
|
||||
while (Date.now() < deadline) {
|
||||
const state = keyboardState();
|
||||
stable = state.shown === shown ? stable + 1 : 0;
|
||||
if (stable === 3) return state;
|
||||
await setTimeout(100);
|
||||
}
|
||||
throw new Error(`Keyboard did not settle at shown=${shown}`);
|
||||
}
|
||||
function keyboardShift() {
|
||||
const state = adb("shell", "dumpsys", "window");
|
||||
const ime = state.match(/type=ime frame=\[0,(\d+)\].*visible=true/);
|
||||
const navigation = state.match(/type=navigationBars frame=\[0,(\d+)\]/);
|
||||
assert.ok(ime && navigation, "Expected a docked keyboard and navigation bar");
|
||||
return Number(navigation[1]) - Number(ime[1]);
|
||||
}
|
||||
function imeEvents() {
|
||||
return adb("logcat", "-d", "-v", "threadtime", "ImeTracker:I", "*:S")
|
||||
.split("\n")
|
||||
.filter((line) => line.includes(`${appId}:`));
|
||||
}
|
||||
function tap(rect, shift) {
|
||||
ad(
|
||||
"press",
|
||||
String(Math.round(rect.x + rect.width / 2)),
|
||||
String(Math.round(rect.y + rect.height / 2 - shift)),
|
||||
);
|
||||
}
|
||||
|
||||
let toggle;
|
||||
try {
|
||||
ad("open", appId, "--platform", "android", "--serial", serial, "--no-test-ime");
|
||||
assert.equal(keyboardState().shown, false, "Start with the terminal keyboard hidden");
|
||||
// Accessibility snapshots can hang with a real IME open. Measure the controls
|
||||
// while hidden, then project them by Android's actual docked-keyboard inset.
|
||||
const snapshot = JSON.parse(ad("snapshot", "-i", "--json"));
|
||||
const rectFor = (id) => {
|
||||
const node = snapshot.data.nodes.find((candidate) => candidate.identifier === id);
|
||||
assert.ok(node?.rect, `Open a terminal first: missing ${id}`);
|
||||
return node.rect;
|
||||
};
|
||||
toggle = rectFor("terminal-keyboard-toggle");
|
||||
const controls = ["ctrl", "ctrl", "esc", "enter"].map((key) => ({
|
||||
key,
|
||||
rect: rectFor(`terminal-key-${key}`),
|
||||
}));
|
||||
tap(toggle, 0);
|
||||
const baseline = await waitForKeyboard(true);
|
||||
assert.notEqual(baseline.input, null, "Expected the native terminal input to own the IME");
|
||||
const shift = keyboardShift();
|
||||
const before = new Set(imeEvents());
|
||||
ad("screenshot", resolve(artifacts, "before.png"));
|
||||
|
||||
for (const { key, rect } of controls) {
|
||||
tap(rect, shift);
|
||||
const after = await waitForKeyboard(true);
|
||||
const events = imeEvents().filter((line) => !before.has(line));
|
||||
writeFileSync(resolve(artifacts, "ime-events.log"), events.join("\n"));
|
||||
writeFileSync(
|
||||
resolve(artifacts, "focus.json"),
|
||||
JSON.stringify({ key, baseline, after }, null, 2),
|
||||
);
|
||||
assert.equal(after.input, baseline.input, `${key} replaced the focused native input`);
|
||||
assert.deepEqual(
|
||||
events.filter((line) => /onRequestHide|onRequestShow/.test(line)),
|
||||
[],
|
||||
`${key} cycled the software keyboard`,
|
||||
);
|
||||
}
|
||||
ad("screenshot", resolve(artifacts, "after.png"));
|
||||
console.log("PASS: Ctrl, Esc and Enter preserve the native input without IME hide/show requests");
|
||||
} finally {
|
||||
if (toggle && keyboardState().shown) {
|
||||
tap(toggle, keyboardShift());
|
||||
await waitForKeyboard(false);
|
||||
}
|
||||
ad("close");
|
||||
run("agent-device", ["daemon", "stop", "--clean"]);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
const CHANGELOG_SOURCE_URL = "https://raw.githubusercontent.com/getpaseo/paseo/main/CHANGELOG.md";
|
||||
|
||||
/**
|
||||
* Serves a changelog to the app instead of the repository's.
|
||||
*
|
||||
* The sheet renders whatever the document contains, so a fixture is how a test
|
||||
* states which authoring shapes it cares about.
|
||||
*/
|
||||
export async function serveChangelog(page: Page, lines: string[]): Promise<void> {
|
||||
await page.route(CHANGELOG_SOURCE_URL, (route) =>
|
||||
route.fulfill({ status: 200, contentType: "text/plain", body: lines.join("\n") }),
|
||||
);
|
||||
}
|
||||
|
||||
export async function openWhatsNew(page: Page): Promise<Locator> {
|
||||
await page.getByTestId("sidebar-help").click();
|
||||
await expect(page.getByTestId("sidebar-help-menu")).toBeVisible();
|
||||
await page.getByTestId("sidebar-help-changelog").click();
|
||||
|
||||
const sheet = page.getByTestId("changelog-sheet");
|
||||
await expect(sheet).toBeVisible();
|
||||
return sheet;
|
||||
}
|
||||
|
||||
export function release(sheet: Locator, version: string): Locator {
|
||||
return sheet.getByTestId(`changelog-release-${version}`);
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, type Page, type TestInfo } from "@playwright/test";
|
||||
import { gotoWorkspace } from "./launcher";
|
||||
import { buildAgentRoute } from "./mock-agent";
|
||||
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
import { pluginRequirements } from "./plugin-fixture";
|
||||
import { seedWorkspace } from "./seed-client";
|
||||
import { ensureExplorerSidebar } from "./workspace-tabs";
|
||||
|
||||
type ExplorerPanelWorkspace = Awaited<ReturnType<typeof seedWorkspace>>;
|
||||
|
||||
async function writePanelPlugin(id: string, title: string) {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-explorer-menu-"));
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id, requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(directory, "index.client.tsx"),
|
||||
`
|
||||
import React from "react";
|
||||
import { Text } from "react-native";
|
||||
export default function contribute(client) {
|
||||
client.addWorkspacePanel({ id: "review", title: ${JSON.stringify(title)}, icon: "Blocks", context: "workspace", locations: ["workspace", "explorer"], Component: ({ workspaceId }) => <Text>{${JSON.stringify(title)} + " workspace " + workspaceId}</Text> });
|
||||
client.addWorkspacePanel({ id: "summary", title: ${JSON.stringify(`${title} summary`)}, icon: "Blocks", context: "workspace", locations: ["explorer"], Component: () => <Text>Summary content</Text> });
|
||||
client.addWorkspacePanel({ id: "main", title: ${JSON.stringify(`${title} main only`)}, icon: "Blocks", context: "workspace", Component: () => null });
|
||||
client.addWorkspacePanel({ id: "agent", title: ${JSON.stringify(`${title} agent`)}, icon: "Blocks", context: "agent", locations: ["explorer"], Component: () => null });
|
||||
return () => {};
|
||||
}`,
|
||||
);
|
||||
return directory;
|
||||
}
|
||||
|
||||
async function openExplorerMenu(page: Page) {
|
||||
const explorer = await ensureExplorerSidebar(page);
|
||||
// The rail's empty background has no accessible role; tab menus are separate.
|
||||
await explorer.getByTestId("explorer-sidebar-tab-rail").click({
|
||||
button: "right",
|
||||
position: { x: 20, y: 2 },
|
||||
});
|
||||
const menu = page.getByTestId("explorer-sidebar-tab-configuration");
|
||||
await expect(menu).toBeVisible();
|
||||
await expect(menu).toHaveCSS("opacity", "1");
|
||||
return menu;
|
||||
}
|
||||
|
||||
async function toggleExplorerView(page: Page, name: string) {
|
||||
const menu = await openExplorerMenu(page);
|
||||
await menu.getByRole("menuitem", { name, exact: true }).click();
|
||||
await expect(menu).not.toBeVisible();
|
||||
}
|
||||
|
||||
export async function withExplorerPanelPlugins(
|
||||
page: Page,
|
||||
run: (workspace: ExplorerPanelWorkspace) => Promise<void>,
|
||||
) {
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previousConfig = await client.getDaemonConfig();
|
||||
const workspace = await seedWorkspace({ repoPrefix: "explorer-plugin-menu-" });
|
||||
const first = await writePanelPlugin("explorer-review", "Review");
|
||||
const second = await writePanelPlugin("explorer-other", "Other review");
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(first);
|
||||
await client.installDirectoryPlugin(second);
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
|
||||
await run(workspace);
|
||||
} finally {
|
||||
await client.removePlugin("explorer-review");
|
||||
await client.removePlugin("explorer-other");
|
||||
await client.patchDaemonConfig({
|
||||
pluginsEnabled: previousConfig.config.pluginsEnabled ?? false,
|
||||
});
|
||||
await client.close();
|
||||
await workspace.cleanup();
|
||||
await rm(first, { recursive: true, force: true });
|
||||
await rm(second, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectWorkspacePanelsInExplorerLauncher(page: Page) {
|
||||
const menu = await openExplorerMenu(page);
|
||||
await menu.getByRole("menuitem", { name: "New tab", exact: true }).click();
|
||||
const launcher = page.getByTestId("workspace-explorer-sidebar");
|
||||
await expect(launcher.getByRole("button", { name: "Review", exact: true })).toBeVisible();
|
||||
await expect(launcher.getByRole("button", { name: "Review agent", exact: true })).toHaveCount(0);
|
||||
await expect(launcher.getByRole("button", { name: "Review main only", exact: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
export async function openWorkspacePanelFromExplorerMenu(
|
||||
page: Page,
|
||||
workspace: ExplorerPanelWorkspace,
|
||||
testInfo: TestInfo,
|
||||
) {
|
||||
const menu = await openExplorerMenu(page);
|
||||
await testInfo.attach("explorer-panel-menu", {
|
||||
body: await page.screenshot({ path: testInfo.outputPath("explorer-panel-menu.png") }),
|
||||
contentType: "image/png",
|
||||
});
|
||||
await expect(menu.getByRole("menuitem")).toHaveText([
|
||||
"New tab",
|
||||
"Changes",
|
||||
"Files",
|
||||
"Other review",
|
||||
"Other review summary",
|
||||
"Review",
|
||||
"Review summary",
|
||||
]);
|
||||
await expect(menu.getByRole("menuitem", { name: "Review", exact: true })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"false",
|
||||
);
|
||||
await menu.getByRole("menuitem", { name: "Review", exact: true }).click();
|
||||
await expect(
|
||||
page.getByText(`Review workspace ${workspace.workspaceId}`, { exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectIndependentExplorerPanelToggles(
|
||||
page: Page,
|
||||
workspace: ExplorerPanelWorkspace,
|
||||
) {
|
||||
await toggleExplorerView(page, "Other review");
|
||||
await expect(
|
||||
page.getByText(`Other review workspace ${workspace.workspaceId}`, { exact: true }),
|
||||
).toBeVisible();
|
||||
await toggleExplorerView(page, "Review summary");
|
||||
const menu = await openExplorerMenu(page);
|
||||
await expect(menu.getByRole("menuitem", { name: "Review", exact: true })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true",
|
||||
);
|
||||
await expect(menu.getByRole("menuitem", { name: "Other review", exact: true })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true",
|
||||
);
|
||||
await expect(menu.getByRole("menuitem", { name: "Review summary", exact: true })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true",
|
||||
);
|
||||
await menu.getByRole("menuitem", { name: "Review", exact: true }).click();
|
||||
const updated = await openExplorerMenu(page);
|
||||
await expect(updated.getByRole("menuitem", { name: "Review", exact: true })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"false",
|
||||
);
|
||||
await expect(
|
||||
updated.getByRole("menuitem", { name: "Other review", exact: true }),
|
||||
).toHaveAttribute("aria-checked", "true");
|
||||
await expect(
|
||||
updated.getByRole("menuitem", { name: "Review summary", exact: true }),
|
||||
).toHaveAttribute("aria-checked", "true");
|
||||
await page.keyboard.press("Escape");
|
||||
await toggleExplorerView(page, "Files");
|
||||
await toggleExplorerView(page, "Files");
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("explorer-sidebar-tab-rail")
|
||||
.getByRole("button", { name: "Browse workspace files", exact: true }),
|
||||
).toHaveCount(1);
|
||||
}
|
||||
|
||||
export async function expectExplorerPanelWithFocusedAgent(
|
||||
page: Page,
|
||||
workspace: ExplorerPanelWorkspace,
|
||||
testInfo: TestInfo,
|
||||
) {
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Focused agent",
|
||||
model: "ten-second-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
await page.goto(buildAgentRoute(workspace.workspaceId, agent.id));
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${agent.id}`).filter({ visible: true }),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
const menu = await openExplorerMenu(page);
|
||||
await expect(menu.getByRole("menuitem", { name: "Review agent", exact: true })).toHaveCount(0);
|
||||
await expect(menu.getByRole("menuitem", { name: "Other review agent", exact: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await menu.getByRole("menuitem", { name: "Review", exact: true }).click();
|
||||
await expect(
|
||||
page.getByText(`Review workspace ${workspace.workspaceId}`, { exact: true }),
|
||||
).toBeVisible();
|
||||
await testInfo.attach("workspace-panel-with-focused-agent", {
|
||||
body: await page.screenshot({
|
||||
path: testInfo.outputPath("workspace-panel-with-focused-agent.png"),
|
||||
}),
|
||||
contentType: "image/png",
|
||||
});
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { expect, type Page } from "../fixtures";
|
||||
import { drillIntoProvider, openModelPicker } from "./agent-profiles";
|
||||
|
||||
export async function startWithoutRememberedModel(page: Page) {
|
||||
await page.addInitScript(() => localStorage.removeItem("@paseo:create-agent-preferences"));
|
||||
}
|
||||
|
||||
export async function chooseModel(page: Page, provider: string, label: string) {
|
||||
await openModelPicker(page);
|
||||
await expect(
|
||||
page.getByRole("dialog").getByRole("button", { name: "Back", exact: true }),
|
||||
).toHaveCount(0);
|
||||
await selectProviderModel(page, provider, label);
|
||||
}
|
||||
|
||||
export async function reselectModel(page: Page, provider: string, label: string) {
|
||||
await expectRememberedModel(page, label);
|
||||
await openModelPicker(page);
|
||||
await page.getByRole("dialog").getByRole("button", { name: "Back", exact: true }).click();
|
||||
await selectProviderModel(page, provider, label);
|
||||
}
|
||||
|
||||
async function selectProviderModel(page: Page, provider: string, label: string) {
|
||||
await drillIntoProvider(page, provider);
|
||||
await page.getByTestId("combobox-desktop-container").getByText(label, { exact: true }).click();
|
||||
await expect(
|
||||
page
|
||||
.getByRole("button", { name: `Select model (${label})`, exact: true })
|
||||
.filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectSavedSelection(page: Page, provider: string, model: string) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() =>
|
||||
JSON.parse(localStorage.getItem("@paseo:create-agent-preferences") ?? "null"),
|
||||
),
|
||||
)
|
||||
.toMatchObject({ provider, providerPreferences: { [provider]: { model } } });
|
||||
}
|
||||
|
||||
export async function expectCreatedModelAgents(
|
||||
page: Page,
|
||||
client: DaemonClient,
|
||||
provider: string,
|
||||
model: string,
|
||||
count: number,
|
||||
) {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const result = await client.fetchAgents({ scope: "active" });
|
||||
return result.entries
|
||||
.filter(({ agent }) => agent.provider === provider)
|
||||
.map(({ agent }) => agent.model);
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toEqual(Array(count).fill(model));
|
||||
await expect(page.getByTestId(/^workspace-tab-agent_/).filter({ visible: true })).toHaveCount(1);
|
||||
}
|
||||
|
||||
export async function expectRememberedModel(page: Page, label: string) {
|
||||
await expect(
|
||||
page
|
||||
.getByRole("button", { name: `Select model (${label})`, exact: true })
|
||||
.filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
import { expect, test, type Page } from "../fixtures";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { gotoAppShell } from "./app";
|
||||
import { scrollTimelineToOldestLoadedEdge } from "./timeline-pagination";
|
||||
import {
|
||||
archiveLocalWorkspaceFromDaemon,
|
||||
archiveWorkspaceFromDaemon,
|
||||
connectNewWorkspaceDaemonClient,
|
||||
countWorkspaceAgents,
|
||||
countWorkspaceTerminals,
|
||||
delayBrowserWorkspaceCreatedResponse,
|
||||
expectNewWorkspaceDraft,
|
||||
fillNewWorkspaceDraft,
|
||||
openNewWorkspaceComposer,
|
||||
openProjectViaDaemon,
|
||||
selectWorkspaceIsolation,
|
||||
submitNewWorkspaceEmpty,
|
||||
submitNewWorkspacePrompt,
|
||||
waitForCreatedWorkspace,
|
||||
} from "./new-workspace";
|
||||
import { createTempGitRepo } from "./workspace";
|
||||
import { expectAppRoute } from "./route-assertions";
|
||||
import { getServerId } from "./server-id";
|
||||
import {
|
||||
expectTerminalOutputContains,
|
||||
expectWorkspaceOpensWithTerminalTab,
|
||||
fillTerminalPrompt,
|
||||
seedTerminalProfiles,
|
||||
selectLaunchOption,
|
||||
submitTerminalLaunch,
|
||||
type TerminalProfile,
|
||||
} from "./new-workspace-launch";
|
||||
import { switchWorkspaceViaSidebar, waitForSidebarHydration } from "./workspace-ui";
|
||||
import { dropFileOnComposer, expectAttachmentPill } from "./composer";
|
||||
|
||||
const PROMPT = "Hello from the navigation guard";
|
||||
const NEXT_PROMPT = "Keep this newer draft";
|
||||
const CONTEXT = {
|
||||
name: "context.json",
|
||||
mimeType: "application/json",
|
||||
buffer: Buffer.from('{"preserve":"attachment"}'),
|
||||
};
|
||||
const PROFILE: TerminalProfile = {
|
||||
id: "e2e-nav-guard-echo",
|
||||
name: "Nav Guard Echo",
|
||||
command: "/bin/sh",
|
||||
args: ["-c", 'echo captured: "$0"; sleep 120', "{{{prompt}}}"],
|
||||
};
|
||||
|
||||
export async function verifyDelayedWorkspaceCreation(
|
||||
page: Page,
|
||||
launch: "chat" | "terminal" | "empty",
|
||||
destination: "leave" | "stay" | "new-draft",
|
||||
isolation: "local" | "worktree" = "local",
|
||||
): Promise<void> {
|
||||
const client = await connectNewWorkspaceDaemonClient();
|
||||
const repo = await createTempGitRepo("workspace-focus-");
|
||||
const profileSeed = await seedTerminalProfiles([PROFILE]);
|
||||
const delay = await delayBrowserWorkspaceCreatedResponse(page);
|
||||
const serverId = getServerId();
|
||||
let localWorkspaceId: string | undefined;
|
||||
let createdDirectory: string | undefined;
|
||||
let newDraftRoute = "";
|
||||
|
||||
try {
|
||||
const project = await openProjectViaDaemon(client, repo.path);
|
||||
localWorkspaceId = project.workspaceId;
|
||||
const knownIds = new Set((await client.fetchWorkspaces()).entries.map((entry) => entry.id));
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: project.workspaceId });
|
||||
|
||||
await test.step("Submit workspace creation with the requested content", async () => {
|
||||
await openNewWorkspaceComposer(page, project);
|
||||
await selectWorkspaceIsolation(page, isolation);
|
||||
if (launch === "chat") {
|
||||
await dropFileOnComposer(page, CONTEXT);
|
||||
await expectAttachmentPill(page, "composer-file-attachment-pill");
|
||||
await submitNewWorkspacePrompt(page, PROMPT);
|
||||
} else if (launch === "terminal") {
|
||||
await selectLaunchOption(page, PROFILE.id);
|
||||
await fillTerminalPrompt(page, PROMPT);
|
||||
await submitTerminalLaunch(page);
|
||||
} else {
|
||||
await submitNewWorkspaceEmpty(page);
|
||||
}
|
||||
await delay.waitForCreateRequest();
|
||||
});
|
||||
|
||||
await test.step("Choose where to work while the creation response is held", async () => {
|
||||
if (destination !== "stay") {
|
||||
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: project.workspaceId });
|
||||
}
|
||||
if (destination === "new-draft") {
|
||||
await openNewWorkspaceComposer(page, project);
|
||||
await fillNewWorkspaceDraft(page, NEXT_PROMPT);
|
||||
const url = new URL(page.url());
|
||||
newDraftRoute = url.pathname + url.search;
|
||||
}
|
||||
// Recording pacing only; the response is held deterministically above.
|
||||
if (process.env.E2E_RECORD_VIDEO === "1") {
|
||||
await page.mouse.move(1100, 80);
|
||||
await page.waitForTimeout(1500);
|
||||
}
|
||||
delay.release();
|
||||
});
|
||||
|
||||
const created = await waitForCreatedWorkspace(client, knownIds);
|
||||
createdDirectory = created.workspaceDirectory;
|
||||
expect(created.workspaceKind).toBe(isolation === "worktree" ? "worktree" : "local_checkout");
|
||||
await test.step("Completion creates exactly the requested resource and preserves focus", async () => {
|
||||
await expect
|
||||
.poll(() => countWorkspaceAgents(client, created.id))
|
||||
.toBe(launch === "chat" ? 1 : 0);
|
||||
await expect
|
||||
.poll(() => countWorkspaceTerminals(client, created.id))
|
||||
.toBe(launch === "terminal" ? 1 : 0);
|
||||
// A negative navigation assertion needs an observation window after completion.
|
||||
await page.waitForTimeout(5000);
|
||||
const expectedRoute =
|
||||
destination === "new-draft"
|
||||
? newDraftRoute
|
||||
: buildHostWorkspaceRoute(
|
||||
serverId,
|
||||
destination === "stay" ? created.id : project.workspaceId,
|
||||
);
|
||||
await expectAppRoute(page, expectedRoute);
|
||||
if (destination === "new-draft") await expectNewWorkspaceDraft(page, NEXT_PROMPT);
|
||||
if (launch === "chat") {
|
||||
expect(delay.agentRequests).toHaveLength(1);
|
||||
const upload = delay.agentRequests[0]?.attachments?.find(
|
||||
(item) => item.type === "uploaded_file",
|
||||
);
|
||||
if (!upload) throw new Error("The create request did not contain the submitted file");
|
||||
expect(await readFile(upload.path)).toEqual(CONTEXT.buffer);
|
||||
expect(delay.agentRequests[0]).toMatchObject({
|
||||
workspaceId: created.id,
|
||||
initialPrompt: PROMPT,
|
||||
config: {
|
||||
provider: "mock",
|
||||
model: "ten-second-stream",
|
||||
modeId: "load-test",
|
||||
thinkingOptionId: "low",
|
||||
cwd: created.workspaceDirectory,
|
||||
},
|
||||
attachments: [
|
||||
expect.objectContaining({
|
||||
fileName: CONTEXT.name,
|
||||
mimeType: CONTEXT.mimeType,
|
||||
size: CONTEXT.buffer.length,
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await test.step("Visit the new workspace and return without submitting a second draft", async () => {
|
||||
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: created.id });
|
||||
if (launch === "chat") {
|
||||
await scrollTimelineToOldestLoadedEdge(page);
|
||||
await expect(page.getByText(PROMPT, { exact: true }).first()).toBeVisible();
|
||||
} else if (launch === "terminal") {
|
||||
await expectWorkspaceOpensWithTerminalTab(page);
|
||||
await expectTerminalOutputContains(page, `captured: ${PROMPT}`);
|
||||
}
|
||||
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: project.workspaceId });
|
||||
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: created.id });
|
||||
await expect
|
||||
.poll(() => countWorkspaceAgents(client, created.id))
|
||||
.toBe(launch === "chat" ? 1 : 0);
|
||||
await expect
|
||||
.poll(() => countWorkspaceTerminals(client, created.id))
|
||||
.toBe(launch === "terminal" ? 1 : 0);
|
||||
if (launch === "chat") {
|
||||
expect(delay.agentRequests).toHaveLength(1);
|
||||
await openNewWorkspaceComposer(page, project);
|
||||
await expectNewWorkspaceDraft(page, destination === "new-draft" ? NEXT_PROMPT : "");
|
||||
}
|
||||
if (process.env.E2E_RECORD_VIDEO === "1") {
|
||||
await page.mouse.move(1100, 80);
|
||||
await page.waitForTimeout(1500);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
await page.screenshot({ path: test.info().outputPath("before-cleanup.png") });
|
||||
console.error(error);
|
||||
throw error;
|
||||
} finally {
|
||||
delay.release();
|
||||
if (createdDirectory) await archiveWorkspaceFromDaemon(client, createdDirectory);
|
||||
if (localWorkspaceId) await archiveLocalWorkspaceFromDaemon(client, localWorkspaceId);
|
||||
await profileSeed.restore();
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { expect, type BrowserContext, type Page } from "@playwright/test";
|
||||
import type { CreateAgentRequestMessage } from "@getpaseo/protocol/messages";
|
||||
import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
|
||||
import { connectDaemonClient } from "./daemon-client-loader";
|
||||
@@ -17,16 +16,12 @@ type NewWorkspaceDaemonClient = Pick<
|
||||
| "connect"
|
||||
| "createPaseoWorktree"
|
||||
| "createWorkspace"
|
||||
| "fetchAgents"
|
||||
| "fetchWorkspaces"
|
||||
| "getPaseoWorktreeList"
|
||||
| "getDaemonConfig"
|
||||
| "installDirectoryPlugin"
|
||||
| "disablePlugin"
|
||||
| "enablePlugin"
|
||||
| "inspectWorkspaceRecovery"
|
||||
| "listProjects"
|
||||
| "listTerminals"
|
||||
| "on"
|
||||
| "patchDaemonConfig"
|
||||
| "removeProject"
|
||||
@@ -625,120 +620,3 @@ export async function delayBrowserAgentCreatedStatus(
|
||||
waitForDelayedCreatedStatus: () => delayedCreatedStatusSeen,
|
||||
};
|
||||
}
|
||||
|
||||
export interface WorkspaceCreatedDelayControl {
|
||||
agentRequests: readonly CreateAgentRequestMessage[];
|
||||
release(): void;
|
||||
waitForCreateRequest(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds `workspace.create.response` so a test can act as the user does while the daemon is still
|
||||
* running `git worktree add` — most importantly, navigate somewhere else.
|
||||
*/
|
||||
export async function delayBrowserWorkspaceCreatedResponse(
|
||||
page: Page,
|
||||
): Promise<WorkspaceCreatedDelayControl> {
|
||||
const daemonPortPattern = daemonWsRoutePattern();
|
||||
const agentRequests: CreateAgentRequestMessage[] = [];
|
||||
const createRequestIds = new Set<string>();
|
||||
const delayedForwards: Array<() => void> = [];
|
||||
let releaseRequested = false;
|
||||
let resolveCreateRequest: (() => void) | null = null;
|
||||
const createRequestSeen = new Promise<void>((resolve) => {
|
||||
resolveCreateRequest = resolve;
|
||||
});
|
||||
|
||||
await page.routeWebSocket(daemonPortPattern, (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
|
||||
ws.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
if (sessionMessage?.type === "create_agent_request")
|
||||
agentRequests.push(sessionMessage as CreateAgentRequestMessage);
|
||||
if (sessionMessage?.type === "workspace.create.request") {
|
||||
const requestId = getStringField(sessionMessage, "requestId");
|
||||
if (requestId) {
|
||||
createRequestIds.add(requestId);
|
||||
resolveCreateRequest?.();
|
||||
}
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
|
||||
server.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
const payload =
|
||||
sessionMessage?.type === "workspace.create.response" &&
|
||||
typeof sessionMessage.payload === "object"
|
||||
? (sessionMessage.payload as Record<string, unknown>)
|
||||
: null;
|
||||
const requestId = payload ? getStringField(payload, "requestId") : null;
|
||||
|
||||
if (requestId && createRequestIds.has(requestId) && !releaseRequested) {
|
||||
delayedForwards.push(() => ws.send(message));
|
||||
return;
|
||||
}
|
||||
|
||||
ws.send(message);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
agentRequests,
|
||||
release() {
|
||||
releaseRequested = true;
|
||||
for (const forward of delayedForwards.splice(0)) {
|
||||
forward();
|
||||
}
|
||||
},
|
||||
waitForCreateRequest: () => createRequestSeen,
|
||||
};
|
||||
}
|
||||
|
||||
type WorkspaceEntry = Awaited<
|
||||
ReturnType<NewWorkspaceDaemonClient["fetchWorkspaces"]>
|
||||
>["entries"][number];
|
||||
|
||||
/**
|
||||
* Waits for a workspace the caller did not already know about, and returns it. Tests that create a
|
||||
* workspace without navigating to it have no route to assert on, so the daemon's own list is the
|
||||
* signal that creation finished.
|
||||
*/
|
||||
export async function waitForCreatedWorkspace(
|
||||
client: NewWorkspaceDaemonClient,
|
||||
knownWorkspaceIds: ReadonlySet<string>,
|
||||
options?: { timeout?: number },
|
||||
): Promise<WorkspaceEntry> {
|
||||
let created: WorkspaceEntry | undefined;
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const payload = await client.fetchWorkspaces();
|
||||
created = payload.entries.find((entry) => !knownWorkspaceIds.has(entry.id));
|
||||
return created !== undefined;
|
||||
},
|
||||
{ timeout: options?.timeout ?? 60_000 },
|
||||
)
|
||||
.toBe(true);
|
||||
if (!created) {
|
||||
throw new Error("Workspace list reported a new workspace but it could not be read back");
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
export async function countWorkspaceAgents(
|
||||
client: NewWorkspaceDaemonClient,
|
||||
workspaceId: string,
|
||||
): Promise<number> {
|
||||
const payload = await client.fetchAgents({ filter: { includeArchived: true } });
|
||||
return payload.entries.filter((entry) => entry.agent.workspaceId === workspaceId).length;
|
||||
}
|
||||
|
||||
export async function countWorkspaceTerminals(
|
||||
client: NewWorkspaceDaemonClient,
|
||||
workspaceId: string,
|
||||
): Promise<number> {
|
||||
const payload = await client.listTerminals(undefined, undefined, { workspaceId });
|
||||
return payload.terminals.length;
|
||||
}
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
import type { Locator, TestInfo } from "@playwright/test";
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
import { copyPluginExample } from "./plugin-fixture";
|
||||
import { gotoAppShell } from "./app";
|
||||
import { buildAgentRoute } from "./mock-agent";
|
||||
import { openCommandCenter } from "./command-center";
|
||||
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
import { seedWorkspace } from "./seed-client";
|
||||
import { waitForSettledPosition } from "./sheet-layout";
|
||||
|
||||
const WIDE = { width: 1440, height: 900 };
|
||||
const COMPACT = { width: 390, height: 844 };
|
||||
|
||||
function headerButtons(page: Page) {
|
||||
return page.getByRole("toolbar", { name: "Workspace actions", exact: true }).getByRole("button");
|
||||
}
|
||||
|
||||
async function exampleCommand(page: Page, name: string) {
|
||||
await page.setViewportSize(WIDE);
|
||||
const panel = await openCommandCenter(page);
|
||||
const title = `Button examples: ${name}`;
|
||||
await panel.getByTestId("command-center-input").fill(title);
|
||||
await panel.getByRole("button", { name: title, exact: true }).click();
|
||||
await expect(panel).not.toBeVisible();
|
||||
}
|
||||
|
||||
async function capture(
|
||||
page: Page,
|
||||
info: TestInfo,
|
||||
name: string,
|
||||
subject: Locator = headerButtons(page),
|
||||
) {
|
||||
await expect(headerButtons(page)).toHaveCount(1);
|
||||
await expect(subject).toBeInViewport();
|
||||
await waitForSettledPosition(subject);
|
||||
const file = info.outputPath(`${name}.png`);
|
||||
await page.screenshot({ path: file, animations: "disabled" });
|
||||
await info.attach(name, { path: file, contentType: "image/png" });
|
||||
}
|
||||
|
||||
async function closeDetails(page: Page) {
|
||||
await page.getByRole("button", { name: "Close workspace details", exact: true }).click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Close workspace details", exact: true }),
|
||||
).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function withButtonAuthorExample(
|
||||
page: Page,
|
||||
info: TestInfo,
|
||||
run: (buttons: {
|
||||
refreshFromHeaderAndComposer(): Promise<void>;
|
||||
useMenusAndToggleButtons(): Promise<void>;
|
||||
inspectLiveWorkspaceDetails(): Promise<void>;
|
||||
}) => Promise<void>,
|
||||
) {
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "button-example-",
|
||||
title: "Button examples",
|
||||
});
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Example agent",
|
||||
model: "ten-second-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const config = await client.getDaemonConfig();
|
||||
const example = await copyPluginExample("buttons");
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(example.directory);
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoAppShell(page);
|
||||
await page.goto(buildAgentRoute(workspace.workspaceId, agent.id));
|
||||
|
||||
await run({
|
||||
refreshFromHeaderAndComposer: () =>
|
||||
test.step("icon-only header action and labeled composer action update in place", async () => {
|
||||
await exampleCommand(page, "action");
|
||||
await headerButtons(page).click();
|
||||
await expect(headerButtons(page)).toHaveAccessibleName("Refresh workspace (1)");
|
||||
await page.getByRole("button", { name: "Refresh context", exact: true }).click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Refresh context", exact: true }),
|
||||
).toContainText("Refreshed · 2");
|
||||
await capture(page, info, "01-actions");
|
||||
await page.setViewportSize(COMPACT);
|
||||
await capture(page, info, "02-compact-actions");
|
||||
}),
|
||||
useMenusAndToggleButtons: () =>
|
||||
test.step("named menus include separators, nested actions, disabled items, and custom content", async () => {
|
||||
await exampleCommand(page, "menu");
|
||||
await headerButtons(page).click();
|
||||
await expect(
|
||||
page.getByRole("menuitem", { name: "Publish (unavailable in example)", exact: true }),
|
||||
).toBeDisabled();
|
||||
await capture(
|
||||
page,
|
||||
info,
|
||||
"03-header-menu",
|
||||
page.getByRole("menuitem", { name: "Refresh workspace", exact: true }),
|
||||
);
|
||||
await page.getByRole("menuitem", { name: "Workspace details", exact: true }).click();
|
||||
await expect(page.getByText("Button examples", { exact: true }).last()).toBeVisible();
|
||||
await closeDetails(page);
|
||||
await page.getByRole("button", { name: "Composer tools", exact: true }).click();
|
||||
await capture(
|
||||
page,
|
||||
info,
|
||||
"04-composer-menu",
|
||||
page.getByRole("menuitem", { name: "Refresh workspace", exact: true }),
|
||||
);
|
||||
await page.getByRole("menuitem", { name: "Display", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "Hide example buttons", exact: true }).click();
|
||||
await expect(headerButtons(page)).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Composer tools", exact: true }),
|
||||
).toHaveCount(0);
|
||||
await exampleCommand(page, "show");
|
||||
await exampleCommand(page, "disable");
|
||||
await expect(headerButtons(page)).toBeDisabled();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Composer tools", exact: true }),
|
||||
).toBeDisabled();
|
||||
await capture(page, info, "05-disabled");
|
||||
await exampleCommand(page, "enable");
|
||||
await page.setViewportSize(COMPACT);
|
||||
await headerButtons(page).click();
|
||||
await expect(
|
||||
page.getByRole("menuitem", { name: "Refresh workspace", exact: true }),
|
||||
).toBeInViewport();
|
||||
await capture(
|
||||
page,
|
||||
info,
|
||||
"06-compact-menu",
|
||||
page.getByRole("menuitem", { name: "Refresh workspace", exact: true }),
|
||||
);
|
||||
await page.getByRole("menuitem", { name: "Refresh workspace", exact: true }).click();
|
||||
}),
|
||||
inspectLiveWorkspaceDetails: () =>
|
||||
test.step("custom icons and content react to workspace changes and adapt to sheets", async () => {
|
||||
await exampleCommand(page, "popover");
|
||||
await headerButtons(page).click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Close workspace details", exact: true }),
|
||||
).toBeVisible();
|
||||
await capture(
|
||||
page,
|
||||
info,
|
||||
"07-header-popover",
|
||||
page.getByRole("button", { name: "Close workspace details", exact: true }),
|
||||
);
|
||||
await client.setWorkspaceTitle(workspace.workspaceId, "Renamed from the daemon");
|
||||
await expect(
|
||||
page.getByText("Renamed from the daemon", { exact: true }).last(),
|
||||
).toBeVisible();
|
||||
await closeDetails(page);
|
||||
await page.getByRole("button", { name: "Context details", exact: true }).click();
|
||||
await capture(
|
||||
page,
|
||||
info,
|
||||
"08-composer-popover",
|
||||
page.getByRole("button", { name: "Close workspace details", exact: true }),
|
||||
);
|
||||
await closeDetails(page);
|
||||
await page.setViewportSize(COMPACT);
|
||||
await page.getByRole("button", { name: "Context details", exact: true }).click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Close workspace details", exact: true }),
|
||||
).toBeInViewport();
|
||||
await capture(
|
||||
page,
|
||||
info,
|
||||
"09-compact-popover",
|
||||
page.getByRole("button", { name: "Close workspace details", exact: true }),
|
||||
);
|
||||
await closeDetails(page);
|
||||
await client.disablePlugin("button-examples");
|
||||
await expect(headerButtons(page)).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Context details", exact: true }),
|
||||
).toHaveCount(0);
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
await client.removePlugin("button-examples");
|
||||
await client.patchDaemonConfig({ pluginsEnabled: config.config.pluginsEnabled });
|
||||
await client.close();
|
||||
await example.cleanup();
|
||||
await workspace.cleanup();
|
||||
}
|
||||
}
|
||||
@@ -1,486 +0,0 @@
|
||||
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { Locator, TestInfo } from "@playwright/test";
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
import { gotoAppShell } from "./app";
|
||||
import { buildAgentRoute } from "./mock-agent";
|
||||
import { openCommandCenter } from "./command-center";
|
||||
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
import { seedWorkspace } from "./seed-client";
|
||||
import { pluginRequirements } from "./plugin-fixture";
|
||||
import { waitForSettledPosition } from "./sheet-layout";
|
||||
|
||||
const PLUGIN_ID = "button-showcase";
|
||||
const WIDE = { width: 1440, height: 900 };
|
||||
const COMPACT = { width: 390, height: 844 };
|
||||
|
||||
function clientSource(workspaceId: string, agentId: string): string {
|
||||
return `import React from "react";
|
||||
import { Text, View, Pressable } from "react-native";
|
||||
import { useWorkspace, useRpc } from "@getpaseo/plugin/client";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { summary } from "./shared/rpc";
|
||||
|
||||
function StatusIcon({ size, theme }) {
|
||||
return <View style={{ width: size, height: size, borderRadius: size / 2, backgroundColor: theme.colors.statusSuccess }} />;
|
||||
}
|
||||
|
||||
function Details({ workspaceId, theme, layout, close }) {
|
||||
const workspace = useWorkspace(workspaceId, (workspace) => workspace.name);
|
||||
const rpc = useRpc(summary);
|
||||
const query = useQuery({ queryKey: ["summary"], queryFn: () => rpc({}) });
|
||||
return <View style={{ gap: 12 }}>
|
||||
<Text style={{ color: theme.colors.foreground, fontSize: 18 }}>Deployment status</Text>
|
||||
<Text style={{ color: theme.colors.foregroundMuted }}>{workspace}</Text>
|
||||
<Text style={{ color: theme.colors.statusSuccess }}>{query.data?.message ?? "Loading deployment..."}</Text>
|
||||
<Text style={{ color: theme.colors.foregroundMuted }}>3 checks passed · Production</Text>
|
||||
<Text style={{ color: theme.colors.foregroundMuted }}>Presentation: {layout.compact ? "compact" : "wide"}</Text>
|
||||
<Pressable accessibilityRole="button" accessibilityLabel="Close deployment details" onPress={close} style={{ padding: 8, borderRadius: 6, backgroundColor: theme.colors.surface2 }}><Text style={{ color: theme.colors.foreground }}>Done</Text></Pressable>
|
||||
</View>;
|
||||
}
|
||||
|
||||
export default function contribute(client) {
|
||||
const workspaceId = ${JSON.stringify(workspaceId)};
|
||||
const agentId = ${JSON.stringify(agentId)};
|
||||
const registrations = [];
|
||||
let finish = () => {};
|
||||
let finishReview = () => {};
|
||||
let attempts = 0;
|
||||
const action = { kind: "action", async onPress() {
|
||||
if (++attempts === 1) throw new Error("Deployment failed. Try again.");
|
||||
await new Promise((resolve) => { finish = resolve; });
|
||||
deploy.update({ label: "Deployed" });
|
||||
} };
|
||||
const header = (id, button) => { const registration = client.addHeaderButton({ id, workspaceId, button }); registrations.push(registration); return registration; };
|
||||
const pill = (id, button) => { const registration = client.addComposerPill({ id, workspaceId, agentId, button }); registrations.push(registration); return registration; };
|
||||
const menu = { kind: "menu", items: [
|
||||
{ kind: "item", id: "run", title: "Run checks", icon: "Play", behavior: { kind: "action", onPress() { checks.update({ label: "Checks passed" }); } } },
|
||||
{ kind: "separator", id: "divider" },
|
||||
{ kind: "item", id: "environment", title: "Environment", behavior: { kind: "menu", items: [
|
||||
{ kind: "item", id: "staging", title: "Staging", icon: "FlaskConical", behavior: { kind: "action", onPress() {} } },
|
||||
{ kind: "item", id: "production", title: "Production", icon: "Globe", disabled: true, behavior: { kind: "action", onPress() {} } },
|
||||
] } },
|
||||
{ kind: "item", id: "details", title: "Deployment details", icon: "Activity", behavior: { kind: "popover", Content: Details } },
|
||||
] };
|
||||
const deploy = header("deploy", { title: "Deploy application", icon: "Rocket", label: "Deploy", behavior: action });
|
||||
const checks = header("checks", { title: "Header checks", icon: "ListChecks", label: "Checks", behavior: menu });
|
||||
header("status", { title: "Header status", icon: StatusIcon, label: "Healthy", behavior: { kind: "popover", Content: Details } });
|
||||
header("logs", { title: "Open deployment logs", icon: "ScrollText", behavior: { kind: "popover", Content: Details } });
|
||||
header("locked", { title: "Production locked", icon: "Lock", disabled: true, behavior: { kind: "action", onPress() {} } });
|
||||
const review = pill("review", { title: "Run review", icon: "Scan", label: "Review", behavior: { kind: "action", async onPress() { await new Promise((resolve) => { finishReview = resolve; }); review.update({ label: "Reviewed" }); } } });
|
||||
const pillMenu = pill("checks", { title: "Composer checks", icon: "ListChecks", label: "Checks", behavior: menu });
|
||||
pill("usage", { title: "Composer status", icon: StatusIcon, label: "Status", behavior: { kind: "popover", Content: Details } });
|
||||
const command = (id, title, onSelect) => client.addCommandCenterItem({ id, title, icon: "Settings", context: "workspace", onSelect });
|
||||
command("finish", "Finish deployment", () => finish());
|
||||
command("finish-review", "Finish review", () => finishReview());
|
||||
command("hide-deploy", "Hide deployment button", () => deploy.update({ visible: false }));
|
||||
command("show-deploy", "Show deployment button", () => deploy.update({ visible: true }));
|
||||
command("hide", "Hide review button", () => review.update({ visible: false }));
|
||||
command("show", "Show review button", () => review.update({ visible: true, label: "Review ready" }));
|
||||
command("disable", "Disable example buttons", () => { deploy.update({ disabled: true }); review.update({ disabled: true }); });
|
||||
command("enable", "Enable example buttons", () => { deploy.update({ disabled: false }); review.update({ disabled: false }); });
|
||||
command("icon-only", "Use icon-only header", () => deploy.update({ label: undefined }));
|
||||
command("hide-menu", "Hide composer menu", () => pillMenu.update({ visible: false }));
|
||||
command("show-menu", "Show composer menu", () => pillMenu.update({ visible: true }));
|
||||
const unsubscribe = client.paseo.workspaces.ref(workspaceId).subscribe((update) => {
|
||||
if (update.kind !== "upsert") return;
|
||||
deploy.update({ visible: update.workspace.name !== "Compact checks" });
|
||||
pillMenu.update({ visible: update.workspace.name !== "Hide composer menu" });
|
||||
});
|
||||
return () => { unsubscribe(); finish(); finishReview(); for (const registration of registrations) registration.remove(); };
|
||||
}`;
|
||||
}
|
||||
|
||||
async function installShowcase(workspaceId: string, agentId: string) {
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const config = await client.getDaemonConfig();
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-buttons-"));
|
||||
await mkdir(path.join(directory, "shared"));
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(path.join(directory, "index.client.tsx"), clientSource(workspaceId, agentId));
|
||||
await writeFile(
|
||||
path.join(directory, "shared/rpc.ts"),
|
||||
`import { defineRpc } from "@getpaseo/plugin"; import { z } from "zod"; export const summary = defineRpc({ name: "summary", input: z.object({}), output: z.object({ message: z.string() }) });`,
|
||||
);
|
||||
await writeFile(
|
||||
path.join(directory, "index.server.ts"),
|
||||
`import { summary } from "./shared/rpc"; export default function contribute(server) { server.handle(summary, async () => ({ message: "All systems operational" })); return () => {}; }`,
|
||||
);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(directory);
|
||||
return {
|
||||
disable: () => client.disablePlugin(PLUGIN_ID),
|
||||
setTitle: (title: string) => client.setWorkspaceTitle(workspaceId, title),
|
||||
async cleanup() {
|
||||
await client.removePlugin(PLUGIN_ID);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: config.config.pluginsEnabled });
|
||||
await client.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function press(page: Page, title: string) {
|
||||
await page.getByRole("button", { name: title, exact: true }).click();
|
||||
}
|
||||
|
||||
async function openHeaderOverflow(page: Page) {
|
||||
await page
|
||||
.getByRole("toolbar", { name: "Workspace actions", exact: true })
|
||||
.getByRole("button", { name: "More actions", exact: true })
|
||||
.click();
|
||||
}
|
||||
|
||||
async function choose(page: Page, title: string) {
|
||||
await page.getByRole("menuitem", { name: title, exact: true }).click();
|
||||
}
|
||||
|
||||
async function command(page: Page, title: string) {
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill(title);
|
||||
await panel.getByRole("button", { name: title, exact: true }).click();
|
||||
await expect(panel).not.toBeVisible();
|
||||
}
|
||||
|
||||
async function capture(
|
||||
page: Page,
|
||||
testInfo: TestInfo,
|
||||
name: string,
|
||||
subject: Locator = page.getByRole("toolbar", { name: "Workspace actions", exact: true }),
|
||||
) {
|
||||
await expect(subject).toBeInViewport();
|
||||
await waitForSettledPosition(subject);
|
||||
const file = testInfo.outputPath(`${name}.png`);
|
||||
await page.screenshot({ path: file, animations: "disabled" });
|
||||
await testInfo.attach(name, { path: file, contentType: "image/png" });
|
||||
}
|
||||
|
||||
async function expectDetails(page: Page) {
|
||||
await expect(page.getByText("All systems operational", { exact: true })).toBeInViewport();
|
||||
}
|
||||
|
||||
async function expectHeaderTextTruncated(page: Page) {
|
||||
const title = page.getByTestId("workspace-header-title");
|
||||
const subtitle = page.getByTestId("workspace-header-subtitle");
|
||||
await expect(title).toHaveCSS("text-overflow", "ellipsis");
|
||||
await expect(subtitle).toHaveCSS("text-overflow", "ellipsis");
|
||||
// The text itself must overflow its box, rather than being clipped by an ancestor.
|
||||
await expect
|
||||
.poll(() => title.evaluate((element) => element.scrollWidth > element.clientWidth))
|
||||
.toBe(true);
|
||||
await expect
|
||||
.poll(() => subtitle.evaluate((element) => element.scrollWidth > element.clientWidth))
|
||||
.toBe(true);
|
||||
const titleBox = await title.boundingBox();
|
||||
const subtitleBox = await subtitle.boundingBox();
|
||||
const actionsBox = await page
|
||||
.getByRole("button", { name: "Workspace actions", exact: true })
|
||||
.boundingBox();
|
||||
expect(titleBox).not.toBeNull();
|
||||
expect(subtitleBox).not.toBeNull();
|
||||
expect(actionsBox).not.toBeNull();
|
||||
expect(titleBox!.x + titleBox!.width).toBeLessThanOrEqual(actionsBox!.x);
|
||||
expect(subtitleBox!.x + subtitleBox!.width).toBeLessThanOrEqual(actionsBox!.x);
|
||||
const hostIcon = page.getByLabel("localhost", { exact: true }).locator("svg");
|
||||
await expect.poll(async () => (await hostIcon.boundingBox())?.width).toBe(12);
|
||||
}
|
||||
|
||||
async function holdButton(page: Page, button: Locator) {
|
||||
await button.hover();
|
||||
await page.mouse.down();
|
||||
}
|
||||
|
||||
async function openCompactOverflowWithHeaderChrome(page: Page, testInfo: TestInfo) {
|
||||
const hamburger = page.getByRole("button", { name: "Open menu", exact: true });
|
||||
const action = page.getByRole("button", { name: "Deploy application", exact: true });
|
||||
const overflow = page
|
||||
.getByRole("toolbar", { name: "Workspace actions", exact: true })
|
||||
.getByRole("button", { name: "More actions", exact: true });
|
||||
await expect(action).toHaveCSS("border-top-width", "0px");
|
||||
await expect(overflow).toHaveCSS("border-top-width", "0px");
|
||||
await hamburger.hover();
|
||||
const highlight = await hamburger.evaluate(
|
||||
(element) => getComputedStyle(element).backgroundColor,
|
||||
);
|
||||
await action.hover();
|
||||
await expect(action).toHaveCSS("background-color", highlight);
|
||||
await holdButton(page, overflow);
|
||||
await expect(overflow).toHaveCSS("background-color", highlight);
|
||||
await capture(page, testInfo, "12c-compact-overflow-pressed");
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
export async function withButtonShowcase(
|
||||
page: Page,
|
||||
testInfo: TestInfo,
|
||||
run: (buttons: {
|
||||
openWideMenusAndPopovers(): Promise<void>;
|
||||
runAndUpdateActions(): Promise<void>;
|
||||
openCompactSheets(): Promise<void>;
|
||||
hideAndUnloadOpenButtons(): Promise<void>;
|
||||
}) => Promise<void>,
|
||||
) {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "plugin-buttons-" });
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Button showcase",
|
||||
model: "ten-second-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
const showcase = await installShowcase(workspace.workspaceId, agent.id);
|
||||
try {
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoAppShell(page);
|
||||
await page.goto(buildAgentRoute(workspace.workspaceId, agent.id));
|
||||
await expect(page.getByRole("button", { name: "Run review", exact: true })).toBeVisible();
|
||||
|
||||
await run({
|
||||
openWideMenusAndPopovers: () =>
|
||||
test.step("wide buttons have placement-owned labels and chevrons", async () => {
|
||||
await expect(
|
||||
page
|
||||
.getByRole("button", { name: "Header checks", exact: true })
|
||||
.getByTestId("plugin-button-chevron"),
|
||||
).toHaveCount(1);
|
||||
await expect(
|
||||
page
|
||||
.getByRole("button", { name: "Composer checks", exact: true })
|
||||
.getByTestId("plugin-button-chevron"),
|
||||
).toHaveCount(0);
|
||||
await capture(page, testInfo, "01-wide-buttons");
|
||||
await press(page, "Header checks");
|
||||
await expect(
|
||||
page.getByRole("menuitem", { name: "Run checks", exact: true }),
|
||||
).toBeVisible();
|
||||
await capture(
|
||||
page,
|
||||
testInfo,
|
||||
"02-header-menu",
|
||||
page.getByRole("menuitem", { name: "Run checks", exact: true }),
|
||||
);
|
||||
await choose(page, "Environment");
|
||||
await expect(page.getByRole("menuitem", { name: "Staging", exact: true })).toBeVisible();
|
||||
await capture(
|
||||
page,
|
||||
testInfo,
|
||||
"03-header-submenu",
|
||||
page.getByRole("menuitem", { name: "Staging", exact: true }),
|
||||
);
|
||||
await choose(page, "Staging");
|
||||
await openHeaderOverflow(page);
|
||||
await expect(
|
||||
page.getByRole("menuitem", { name: "Production locked", exact: true }),
|
||||
).toBeDisabled();
|
||||
await capture(
|
||||
page,
|
||||
testInfo,
|
||||
"03b-wide-overflow",
|
||||
page.getByRole("menuitem", { name: "Production locked", exact: true }),
|
||||
);
|
||||
await choose(page, "Open deployment logs");
|
||||
await expectDetails(page);
|
||||
await press(page, "Close deployment details");
|
||||
await press(page, "Header status");
|
||||
await expectDetails(page);
|
||||
await capture(
|
||||
page,
|
||||
testInfo,
|
||||
"04-header-popover",
|
||||
page.getByRole("button", { name: "Close deployment details", exact: true }),
|
||||
);
|
||||
await press(page, "Close deployment details");
|
||||
await press(page, "Composer checks");
|
||||
await capture(
|
||||
page,
|
||||
testInfo,
|
||||
"05-composer-menu",
|
||||
page.getByRole("menuitem", { name: "Run checks", exact: true }),
|
||||
);
|
||||
await choose(page, "Deployment details");
|
||||
await expectDetails(page);
|
||||
await capture(
|
||||
page,
|
||||
testInfo,
|
||||
"06-composer-menu-content",
|
||||
page.getByRole("button", { name: "Close deployment details", exact: true }),
|
||||
);
|
||||
await press(page, "Close deployment details");
|
||||
await press(page, "Composer status");
|
||||
await expectDetails(page);
|
||||
await capture(
|
||||
page,
|
||||
testInfo,
|
||||
"07-composer-popover",
|
||||
page.getByRole("button", { name: "Close deployment details", exact: true }),
|
||||
);
|
||||
await press(page, "Close deployment details");
|
||||
}),
|
||||
runAndUpdateActions: () =>
|
||||
test.step("actions fail visibly, retry, and remain busy until completion", async () => {
|
||||
await press(page, "Deploy application");
|
||||
await expect(
|
||||
page.getByText("Deployment failed. Try again.", { exact: true }),
|
||||
).toBeVisible();
|
||||
await capture(
|
||||
page,
|
||||
testInfo,
|
||||
"08-action-error",
|
||||
page.getByText("Deployment failed. Try again.", { exact: true }),
|
||||
);
|
||||
await press(page, "Deploy application");
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Deploy application", exact: true }),
|
||||
).toBeDisabled();
|
||||
await capture(page, testInfo, "09-action-pending");
|
||||
await command(page, "Finish deployment");
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Deploy application", exact: true }),
|
||||
).toContainText("Deployed");
|
||||
await press(page, "Run review");
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Run review", exact: true }),
|
||||
).toBeDisabled();
|
||||
await capture(page, testInfo, "09b-composer-pending");
|
||||
await command(page, "Finish review");
|
||||
await expect(page.getByRole("button", { name: "Run review", exact: true })).toContainText(
|
||||
"Reviewed",
|
||||
);
|
||||
await command(page, "Disable example buttons");
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Run review", exact: true }),
|
||||
).toBeDisabled();
|
||||
await capture(page, testInfo, "10-disabled-buttons");
|
||||
await command(page, "Enable example buttons");
|
||||
await command(page, "Use icon-only header");
|
||||
await capture(page, testInfo, "11-icon-only-header");
|
||||
await command(page, "Hide review button");
|
||||
await expect(page.getByRole("button", { name: "Run review", exact: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await command(page, "Show review button");
|
||||
await expect(page.getByRole("button", { name: "Run review", exact: true })).toContainText(
|
||||
"Review ready",
|
||||
);
|
||||
}),
|
||||
openCompactSheets: () =>
|
||||
test.step("compact triggers and overflow open sheets with working plugin context", async () => {
|
||||
await page.setViewportSize(COMPACT);
|
||||
await expect(page.getByTestId("plugin-button-chevron")).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Composer checks", exact: true }),
|
||||
).toContainText("Checks");
|
||||
await workspace.client.renameProject(
|
||||
workspace.projectId,
|
||||
"Plugin buttons with a long project name",
|
||||
);
|
||||
await expect(page.getByTestId("workspace-header-subtitle")).toHaveText(
|
||||
"Plugin buttons with a long project name",
|
||||
);
|
||||
await showcase.setTitle("Button showcase with a long workspace title");
|
||||
await expect(page.getByTestId("workspace-header-title")).toHaveText(
|
||||
"Button showcase with a long workspace title",
|
||||
);
|
||||
await expectHeaderTextTruncated(page);
|
||||
await capture(page, testInfo, "12-compact-buttons");
|
||||
await openCompactOverflowWithHeaderChrome(page, testInfo);
|
||||
await expect(
|
||||
page.getByRole("menuitem", { name: "Production locked", exact: true }),
|
||||
).toBeDisabled();
|
||||
await capture(
|
||||
page,
|
||||
testInfo,
|
||||
"13-compact-header-overflow",
|
||||
page.getByRole("menuitem", { name: "Production locked", exact: true }),
|
||||
);
|
||||
await choose(page, "Header checks");
|
||||
await capture(
|
||||
page,
|
||||
testInfo,
|
||||
"14-compact-header-menu",
|
||||
page.getByRole("menuitem", { name: "Run checks", exact: true }),
|
||||
);
|
||||
await choose(page, "Deployment details");
|
||||
await expectDetails(page);
|
||||
await capture(
|
||||
page,
|
||||
testInfo,
|
||||
"15-compact-header-content",
|
||||
page.getByRole("button", { name: "Close deployment details", exact: true }),
|
||||
);
|
||||
await press(page, "Close deployment details");
|
||||
await showcase.setTitle("Compact checks");
|
||||
await press(page, "Header checks");
|
||||
await expect(
|
||||
page
|
||||
.getByRole("button", { name: "Header checks", exact: true })
|
||||
.getByTestId("plugin-button-chevron"),
|
||||
).toHaveCount(0);
|
||||
await capture(
|
||||
page,
|
||||
testInfo,
|
||||
"15b-compact-header-direct-menu",
|
||||
page.getByRole("menuitem", { name: "Run checks", exact: true }),
|
||||
);
|
||||
await choose(page, "Run checks");
|
||||
await showcase.setTitle("Button showcase");
|
||||
await press(page, "Composer status");
|
||||
await expectDetails(page);
|
||||
await capture(
|
||||
page,
|
||||
testInfo,
|
||||
"16-compact-composer-popover",
|
||||
page.getByRole("button", { name: "Close deployment details", exact: true }),
|
||||
);
|
||||
await press(page, "Close deployment details");
|
||||
await press(page, "Composer checks");
|
||||
await capture(
|
||||
page,
|
||||
testInfo,
|
||||
"17-compact-composer-menu",
|
||||
page.getByRole("menuitem", { name: "Run checks", exact: true }),
|
||||
);
|
||||
await choose(page, "Run checks");
|
||||
}),
|
||||
hideAndUnloadOpenButtons: () =>
|
||||
test.step("hiding an open button and unloading a plugin remove its surfaces", async () => {
|
||||
await page.setViewportSize(WIDE);
|
||||
await press(page, "Composer checks");
|
||||
await showcase.setTitle("Hide composer menu");
|
||||
await expect(page.getByRole("menuitem", { name: "Run checks", exact: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Composer checks", exact: true }),
|
||||
).toHaveCount(0);
|
||||
await showcase.setTitle("Button showcase");
|
||||
await page.emulateMedia({ colorScheme: "dark" });
|
||||
await capture(page, testInfo, "18-dark-buttons");
|
||||
await press(page, "Header status");
|
||||
await expectDetails(page);
|
||||
await capture(
|
||||
page,
|
||||
testInfo,
|
||||
"19-dark-popover",
|
||||
page.getByRole("button", { name: "Close deployment details", exact: true }),
|
||||
);
|
||||
await showcase.disable();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Header status", exact: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Run review", exact: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expect(page.getByText("All systems operational", { exact: true })).toHaveCount(0);
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
await showcase.cleanup();
|
||||
await workspace.cleanup();
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { cp, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { z } from "zod";
|
||||
import appPackage from "../../../package.json";
|
||||
|
||||
export const pluginRequirements = { paseo: `>=${appPackage.version}` };
|
||||
|
||||
export async function copyPluginExample(name: string) {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-example-"));
|
||||
try {
|
||||
await cp(path.resolve(__dirname, "../../../../../plugin-examples", name), directory, {
|
||||
recursive: true,
|
||||
});
|
||||
const manifestPath = path.join(directory, "paseo-plugin.json");
|
||||
const manifest = z
|
||||
.record(z.string(), z.unknown())
|
||||
.parse(JSON.parse(await readFile(manifestPath, "utf8")));
|
||||
// Exercise example UI against the checkout runtime before its release version is bumped.
|
||||
await writeFile(
|
||||
manifestPath,
|
||||
JSON.stringify({ ...manifest, requirements: pluginRequirements }),
|
||||
);
|
||||
return { directory, cleanup: () => rm(directory, { recursive: true, force: true }) };
|
||||
} catch (error) {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { Locator, TestInfo } from "@playwright/test";
|
||||
import { expect, test as base, type Page } from "../fixtures";
|
||||
import { gotoAppShell, openSettings } from "./app";
|
||||
import { openModelPicker } from "./agent-profiles";
|
||||
import { openAgentRoute } from "./mock-agent";
|
||||
import { connectNewWorkspaceDaemonClient, openGlobalNewWorkspaceComposer } from "./new-workspace";
|
||||
import { copyPluginExample } from "./plugin-fixture";
|
||||
import { seedWorkspace, type SeededWorkspace } from "./seed-client";
|
||||
import { getServerId } from "./server-id";
|
||||
import { openSettingsHostSection } from "./settings";
|
||||
|
||||
const MODEL_LABEL = "Select model (Example 1)";
|
||||
const WIDE = { width: 1400, height: 950 };
|
||||
const COMPACT = { width: 390, height: 844 };
|
||||
|
||||
async function readIconPaths(page: Page, pluginDirectory: string): Promise<string[]> {
|
||||
const svg = await readFile(path.join(pluginDirectory, "icon.svg"), "utf8");
|
||||
return page.evaluate(
|
||||
(source) =>
|
||||
Array.from(
|
||||
new DOMParser().parseFromString(source, "image/svg+xml").querySelectorAll("path"),
|
||||
(element) => element.getAttribute("d") ?? "",
|
||||
),
|
||||
svg,
|
||||
);
|
||||
}
|
||||
|
||||
function readIconDrawings(icons: SVGElement[]): string[][] {
|
||||
return icons.map((icon) =>
|
||||
Array.from(icon.querySelectorAll("path"), (element) => element.getAttribute("d") ?? ""),
|
||||
);
|
||||
}
|
||||
|
||||
async function expectProviderIcon(surface: Locator, paths: string[]): Promise<void> {
|
||||
await expect(surface).toBeVisible();
|
||||
// Provider icons are decorative SVGs without accessible names. Compare the
|
||||
// plugin asset's drawing, allowing surface-specific size and theme colours.
|
||||
await expect
|
||||
.poll(() => surface.locator("svg").evaluateAll(readIconDrawings))
|
||||
.toContainEqual(paths);
|
||||
}
|
||||
|
||||
async function selectPluginModel(page: Page): Promise<void> {
|
||||
await openModelPicker(page);
|
||||
await page.getByRole("button", { name: "Back", exact: true }).click();
|
||||
await page.getByText("Direct provider example", { exact: true }).click();
|
||||
await page.getByText("Example 1", { exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: MODEL_LABEL, exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
|
||||
const screenshot = testInfo.outputPath(`${name}.png`);
|
||||
await page.screenshot({ path: screenshot });
|
||||
await testInfo.attach(name, { path: screenshot, contentType: "image/png" });
|
||||
}
|
||||
|
||||
interface ProviderIconJourney {
|
||||
page: Page;
|
||||
testInfo: TestInfo;
|
||||
iconPaths: string[];
|
||||
workspace: SeededWorkspace;
|
||||
}
|
||||
|
||||
export const test = base.extend<{ providerIcons: ProviderIconJourney }>({
|
||||
providerIcons: async ({ page }, provide, testInfo) => {
|
||||
const client = await connectNewWorkspaceDaemonClient();
|
||||
try {
|
||||
const previous = await client.getDaemonConfig();
|
||||
const plugin = await copyPluginExample("provider-direct");
|
||||
try {
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(plugin.directory);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "plugin-provider-icons-" });
|
||||
try {
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoAppShell(page);
|
||||
const iconPaths = await readIconPaths(page, plugin.directory);
|
||||
await provide({ page, testInfo, iconPaths, workspace });
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await client.removePlugin("provider-direct-example");
|
||||
} finally {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await plugin.cleanup();
|
||||
}
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export async function verifyProviderSettings({
|
||||
page,
|
||||
iconPaths,
|
||||
testInfo,
|
||||
}: ProviderIconJourney): Promise<void> {
|
||||
await test.step("provider settings render the registered icon", async () => {
|
||||
await openSettings(page);
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
await expectProviderIcon(
|
||||
page.getByRole("button", { name: "Direct provider example provider details", exact: true }),
|
||||
iconPaths,
|
||||
);
|
||||
await capture(page, testInfo, "provider-settings");
|
||||
await page.getByTestId("settings-back-to-workspace").click();
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyNewWorkspaceModelIcon({
|
||||
page,
|
||||
iconPaths,
|
||||
testInfo,
|
||||
}: ProviderIconJourney): Promise<void> {
|
||||
await test.step("new workspace keeps the picker icon on its selected model button", async () => {
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await selectPluginModel(page);
|
||||
await openModelPicker(page);
|
||||
await expectProviderIcon(page.getByTestId("model-row-direct-example-example-1"), iconPaths);
|
||||
await capture(page, testInfo, "new-workspace-picker");
|
||||
await page.keyboard.press("Escape");
|
||||
await expectProviderIcon(
|
||||
page.getByRole("button", { name: MODEL_LABEL, exact: true }),
|
||||
iconPaths,
|
||||
);
|
||||
await capture(page, testInfo, "new-workspace-selected-model");
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyCompactModelIcon({
|
||||
page,
|
||||
iconPaths,
|
||||
testInfo,
|
||||
}: ProviderIconJourney): Promise<void> {
|
||||
await test.step("compact composer preserves the same provider icon", async () => {
|
||||
await page.setViewportSize(COMPACT);
|
||||
await expectProviderIcon(
|
||||
page.getByRole("button", { name: MODEL_LABEL, exact: true }),
|
||||
iconPaths,
|
||||
);
|
||||
await capture(page, testInfo, "compact-selected-model");
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyExistingAgentModelIcon({
|
||||
page,
|
||||
iconPaths,
|
||||
testInfo,
|
||||
workspace,
|
||||
}: ProviderIconJourney): Promise<void> {
|
||||
await test.step("existing agent composer uses the same icon", async () => {
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "direct-example",
|
||||
model: "example-1",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Plugin icon agent",
|
||||
});
|
||||
await page.setViewportSize(WIDE);
|
||||
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: agent.id });
|
||||
await expectProviderIcon(
|
||||
page.getByRole("button", { name: MODEL_LABEL, exact: true }),
|
||||
iconPaths,
|
||||
);
|
||||
await capture(page, testInfo, "agent-selected-model");
|
||||
});
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { spawnTsx, killProcessTree } from "./spawn-node";
|
||||
import { expect, test as base, type Page } from "../fixtures";
|
||||
import { connectDaemonClient } from "./daemon-client-loader";
|
||||
import { addConnectedHostAndReload } from "./hosts";
|
||||
import { gotoAppShell, openSettings } from "./app";
|
||||
import { openHostSection, selectSettingsHost } from "./settings";
|
||||
import { pluginRequirements } from "./plugin-fixture";
|
||||
|
||||
export const test = base.extend<{
|
||||
requirementHost: { serverId: string; port: number; directory: string; client: DaemonClient };
|
||||
}>({
|
||||
requirementHost: async ({ e2eWorker }, provide) => {
|
||||
void e2eWorker;
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-requirement-ui-"));
|
||||
await writeRequirements(directory, "^99.0.0");
|
||||
await writeFile(
|
||||
path.join(directory, "index.client.ts"),
|
||||
`export default function(client) {
|
||||
return () => {};
|
||||
}`,
|
||||
);
|
||||
const daemon = spawnTsx(
|
||||
path.resolve(__dirname, "../../../../server/src/server/test-utils/versioned-daemon.ts"),
|
||||
["99.0.0"],
|
||||
{ stdio: ["ignore", "pipe", "pipe", "ipc"] },
|
||||
);
|
||||
const ready = Promise.withResolvers<{ port: number; serverId: string }>();
|
||||
const timeout = setTimeout(
|
||||
() => ready.reject(new Error("Versioned daemon startup timed out")),
|
||||
30_000,
|
||||
);
|
||||
let output = "";
|
||||
daemon.stdout?.on("data", (data) => {
|
||||
output += data;
|
||||
});
|
||||
daemon.stderr?.on("data", (data) => {
|
||||
output += data;
|
||||
});
|
||||
daemon.once("error", ready.reject);
|
||||
daemon.once("exit", () => ready.reject(new Error(`Versioned daemon exited: ${output}`)));
|
||||
daemon.once("message", (message) => {
|
||||
if (
|
||||
typeof message !== "object" ||
|
||||
message === null ||
|
||||
!("port" in message) ||
|
||||
typeof message.port !== "number" ||
|
||||
!("serverId" in message) ||
|
||||
typeof message.serverId !== "string"
|
||||
) {
|
||||
ready.reject(new Error("Invalid versioned daemon startup message"));
|
||||
return;
|
||||
}
|
||||
ready.resolve({ port: message.port, serverId: message.serverId });
|
||||
});
|
||||
try {
|
||||
const host = await ready.promise;
|
||||
clearTimeout(timeout);
|
||||
const client = await connectDaemonClient<DaemonClient>({
|
||||
port: host.port,
|
||||
clientIdPrefix: "requirements",
|
||||
});
|
||||
try {
|
||||
await client.installDirectoryPlugin(directory);
|
||||
await provide({ ...host, directory, client });
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
await killProcessTree(daemon);
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
async function writeRequirements(directory: string, paseo: string) {
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: "requirements-example", requirements: { paseo } }),
|
||||
);
|
||||
}
|
||||
|
||||
export async function openRequirementHost(page: Page, host: { serverId: string; port: number }) {
|
||||
await gotoAppShell(page);
|
||||
await addConnectedHostAndReload(page, { ...host, label: "Plugin requirements" });
|
||||
await openSettings(page);
|
||||
await selectSettingsHost(page, host.serverId);
|
||||
await openHostSection(page, host.serverId, "plugins");
|
||||
}
|
||||
|
||||
export async function expectAppMismatch(page: Page) {
|
||||
await expect(page.getByLabel("requirements-example failed", { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(/Plugin "requirements-example" requires Paseo \^99.0.0. Your app is/),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
export async function correctRequirementAndReload(page: Page, directory: string) {
|
||||
await writeRequirements(directory, pluginRequirements.paseo);
|
||||
await page.getByRole("button", { name: "Reload", exact: true }).click();
|
||||
await expect(page.getByLabel("requirements-example running", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(/Your app is/)).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function rejectDaemonMismatch(page: Page, directory: string) {
|
||||
await writeRequirements(directory, ">=100.0.0");
|
||||
await page.getByRole("button", { name: "Reload", exact: true }).click();
|
||||
await expect(page.getByLabel("requirements-example failed", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(/Your daemon is 99.0.0/).first()).toBeVisible();
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import { copyPluginExample } from "./plugin-fixture";
|
||||
import { expect, test as base, type Page } from "../fixtures";
|
||||
import { gotoAppShell, openSettings } from "./app";
|
||||
import { goBackInSettings, openCompactSettings, openHostSection } from "./settings";
|
||||
import { getServerId } from "./server-id";
|
||||
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
|
||||
type SettingsClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
|
||||
export const test = base.extend<{ settingsPlugin: SettingsClient }>({
|
||||
settingsPlugin: [
|
||||
async ({ e2eWorker, page }, provide, testInfo) => {
|
||||
void e2eWorker;
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previous = await client.getDaemonConfig();
|
||||
const example = await copyPluginExample("settings");
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(example.directory);
|
||||
await provide(client);
|
||||
await page.screenshot({ path: testInfo.outputPath("settings.png") });
|
||||
} finally {
|
||||
await client.removePlugin("settings-example");
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
|
||||
await client.close();
|
||||
await example.cleanup();
|
||||
}
|
||||
},
|
||||
{ auto: true },
|
||||
],
|
||||
});
|
||||
|
||||
export async function openDisplaySettings(page: Page) {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openPluginScreen(page);
|
||||
}
|
||||
export async function openCompactDisplaySettings(page: Page) {
|
||||
await gotoAppShell(page);
|
||||
await openCompactSettings(page, new URL(page.url()).pathname);
|
||||
await openPluginScreen(page);
|
||||
}
|
||||
async function openPluginScreen(page: Page) {
|
||||
await openHostSection(page, getServerId(), "plugins");
|
||||
await page.getByRole("button", { name: "Open", exact: true }).click();
|
||||
await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible();
|
||||
}
|
||||
export async function groupByWorkspace(page: Page) {
|
||||
await page.getByRole("button", { name: "Group agents by", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "Workspace", exact: true }).click();
|
||||
await expect(page.getByText("Grouped by workspace", { exact: true })).toBeVisible();
|
||||
}
|
||||
export async function hideMetadata(page: Page) {
|
||||
await page.getByRole("switch", { name: "Show metadata" }).click();
|
||||
await expect(page.getByText("Metadata hidden", { exact: true })).toBeVisible();
|
||||
}
|
||||
export async function reloadSavedDisplaySettings(page: Page) {
|
||||
await page.reload();
|
||||
await expect(page.getByText("Grouped by workspace", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("switch", { name: "Show metadata" })).not.toBeChecked();
|
||||
}
|
||||
export async function editTitle(page: Page, title: string) {
|
||||
await page.getByRole("button", { name: "Edit title", exact: true }).click();
|
||||
await replaceTitle(page, title);
|
||||
}
|
||||
export async function replaceTitle(page: Page, title: string) {
|
||||
await page.getByRole("textbox", { name: "Monitor title", exact: true }).fill(title);
|
||||
}
|
||||
export async function saveTitle(page: Page) {
|
||||
await page.getByRole("button", { name: "Save title", exact: true }).click();
|
||||
}
|
||||
export async function expectTitleRequired(page: Page) {
|
||||
await expect(page.getByText(/Enter a title/).first()).toBeVisible();
|
||||
}
|
||||
export async function expectSavedTitle(page: Page, title: string) {
|
||||
await expect(page.getByText(title, { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("textbox", { name: "Monitor title", exact: true })).toHaveCount(0);
|
||||
}
|
||||
export async function changeMetadataFromAnotherClient(page: Page) {
|
||||
const peer = await page.context().newPage();
|
||||
try {
|
||||
await peer.goto(page.url());
|
||||
await hideMetadata(peer);
|
||||
await expect(page.getByText("Metadata hidden", { exact: true })).toBeVisible();
|
||||
} finally {
|
||||
await peer.close();
|
||||
}
|
||||
}
|
||||
export async function expectConflictPreservesDraft(page: Page, title: string) {
|
||||
await expect(page.getByText(/Settings changed on another client/)).toBeVisible();
|
||||
await expect(page.getByRole("textbox", { name: "Monitor title", exact: true })).toHaveValue(
|
||||
title,
|
||||
);
|
||||
}
|
||||
export async function discardTitle(page: Page) {
|
||||
await page.getByRole("button", { name: "Discard", exact: true }).click();
|
||||
await expectSavedTitle(page, "Agent monitor");
|
||||
}
|
||||
export async function returnAndReopenSettings(page: Page) {
|
||||
await goBackInSettings(page);
|
||||
await expect(page.getByRole("button", { name: "Open", exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Open", exact: true }).click();
|
||||
await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible();
|
||||
}
|
||||
export async function expectSettingsUnavailable(page: Page) {
|
||||
await expect(
|
||||
page.getByText("This plugin settings screen is unavailable.", { exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import { expect, type Locator, type Page, type TestInfo } from "@playwright/test";
|
||||
|
||||
import { launchAgent, sendMessage, type AgentHandle } from "./rewind-flow";
|
||||
import { openSubagentsTrack } from "./subagents";
|
||||
|
||||
const NESTED_OWNERSHIP_PROMPT =
|
||||
"You are ROOT_OWNER. Use Claude Code's native Agent tool exactly once, never Paseo tools. " +
|
||||
"Name the agent direct_owner and give it this complete task: You are DIRECT_OWNER. Use " +
|
||||
"Claude Code's native Agent tool exactly once. Name that agent nested_owner and give it " +
|
||||
"this complete task: You are NESTED_OWNER. Use Bash exactly once to run `sleep 2; printf " +
|
||||
"'NESTED_BACKGROUND_SENTINEL\\n'` with run_in_background true. Wait for the background " +
|
||||
"command's completion notification. Use TaskOutput with block true to wait for the Bash task and verify exit code 0 and the sentinel output before replying exactly NESTED_DONE. Do not finish while it is running. Wait for nested_owner " +
|
||||
"to finish, then reply exactly DIRECT_DONE. Wait for direct_owner to finish, then reply " +
|
||||
"exactly ROOT_DONE.";
|
||||
|
||||
export async function launchNestedProviderSubagentOwnershipScenario(
|
||||
page: Page,
|
||||
cwd: string,
|
||||
): Promise<AgentHandle> {
|
||||
const handle = await launchAgent({
|
||||
page,
|
||||
provider: "claude",
|
||||
cwd,
|
||||
mode: "full-access",
|
||||
providerConfig: { model: "claude-sonnet-5" },
|
||||
});
|
||||
await sendMessage(handle, NESTED_OWNERSHIP_PROMPT);
|
||||
await expect(page.getByText("ROOT_DONE", { exact: true }).last()).toBeVisible({
|
||||
timeout: 120_000,
|
||||
});
|
||||
return handle;
|
||||
}
|
||||
|
||||
async function attachScreenshot(page: Page, testInfo: TestInfo, name: string): Promise<void> {
|
||||
const path = testInfo.outputPath(`${name}.png`);
|
||||
await page.screenshot({ path });
|
||||
await testInfo.attach(name, { path, contentType: "image/png" });
|
||||
}
|
||||
|
||||
async function expectOnlySubagent(page: Page, label: RegExp): Promise<Locator> {
|
||||
const rows = page.locator('[data-testid^="subagents-track-row-"]');
|
||||
await expect(rows).toHaveCount(1);
|
||||
const row = page.getByTestId("subagents-track-header-panel").getByRole("button", { name: label });
|
||||
await expect(row).toBeVisible();
|
||||
return row;
|
||||
}
|
||||
|
||||
async function selectOnlyRootSubagent(page: Page, label: RegExp): Promise<void> {
|
||||
await openSubagentsTrack(page);
|
||||
const row = await expectOnlySubagent(page, label);
|
||||
await row.click();
|
||||
}
|
||||
|
||||
async function openOwnedSubagents(page: Page, ownerPanel: Locator): Promise<void> {
|
||||
await ownerPanel.getByTestId("subagents-track-header").click();
|
||||
await expect(page.getByTestId("subagents-track-header-panel")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectNestedProviderSubagentOwnership(
|
||||
page: Page,
|
||||
testInfo: TestInfo,
|
||||
phase: string,
|
||||
): Promise<void> {
|
||||
await expect(
|
||||
page.getByText("Task notification", { exact: true }).filter({ visible: true }),
|
||||
).toHaveCount(0);
|
||||
await selectOnlyRootSubagent(page, /^direct_owner\b/);
|
||||
const directPanel = page.getByTestId("provider-subagent-panel").filter({ visible: true });
|
||||
await expect(directPanel).toBeVisible();
|
||||
|
||||
await openOwnedSubagents(page, directPanel);
|
||||
const nestedRow = await expectOnlySubagent(page, /^nested_owner\b/);
|
||||
await attachScreenshot(page, testInfo, `nested-ownership-${phase}-tree`);
|
||||
await nestedRow.click();
|
||||
|
||||
const nestedPanel = page.locator('[data-testid="provider-subagent-panel"]:visible');
|
||||
await expect(
|
||||
nestedPanel
|
||||
.getByRole("button", {
|
||||
name: /^Task notification Background command .*completed \(exit code 0\)/,
|
||||
})
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
await expect(nestedPanel).toContainText("NESTED_BACKGROUND_SENTINEL");
|
||||
await expect(nestedPanel).toContainText("NESTED_DONE");
|
||||
await attachScreenshot(page, testInfo, `nested-ownership-${phase}-timeline`);
|
||||
}
|
||||
|
||||
export async function reopenNestedProviderSession(handle: AgentHandle): Promise<void> {
|
||||
await handle.client.refreshAgent(handle.agentId);
|
||||
await handle.page.reload();
|
||||
await handle.page.getByTestId(`workspace-tab-agent_${handle.agentId}`).first().click();
|
||||
await expect(handle.page.getByText("ROOT_DONE", { exact: true }).last()).toBeVisible();
|
||||
}
|
||||
@@ -318,9 +318,9 @@ export async function assertFileContains(filePath: string, text: string): Promis
|
||||
|
||||
export async function assertComposerIdle(handle: Pick<AgentHandle, "page">): Promise<void> {
|
||||
await expectComposerEditable(handle.page);
|
||||
await expect(
|
||||
handle.page.getByRole("button", { name: /^(?:Stop agent|Canceling agent)$/ }),
|
||||
).toHaveCount(0, { timeout: 30_000 });
|
||||
await expect(handle.page.getByRole("button", { name: /stop|cancel/i })).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(handle.page.getByTestId("turn-working-indicator")).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
@@ -45,7 +45,6 @@ export interface SeedDaemonClient {
|
||||
entries: SeedWorkspaceDescriptor[];
|
||||
}>;
|
||||
setWorkspacePinned(workspaceId: string, pinned: boolean): Promise<{ pinnedAt: string | null }>;
|
||||
clearWorkspaceAttention(workspaceId: string): Promise<void>;
|
||||
setWorkspaceLabel(input: {
|
||||
workspaceId: string;
|
||||
label: { name: string; color: "red" };
|
||||
@@ -160,7 +159,6 @@ export interface SeedDaemonClient {
|
||||
features?: {
|
||||
projectAdd?: boolean;
|
||||
workspaceRecovery?: boolean;
|
||||
workspaceMarkUnread?: boolean;
|
||||
} | null;
|
||||
} | null;
|
||||
fetchAgentHistory(options?: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.8.0-beta.1",
|
||||
"version": "0.7.2",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { AgentProfile } from "@getpaseo/protocol/messages";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { SettingsSection } from "@/components/settings/headings/settings-section";
|
||||
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
|
||||
@@ -7,7 +7,7 @@ import { ArrowUpRight, Blocks, Check, Settings2 } from "lucide-react-native";
|
||||
import type { AgentSkillOperation, AgentSkillsStatus } from "@getpaseo/protocol/messages";
|
||||
import type { TFunction } from "i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsSection } from "@/components/settings/headings/settings-section";
|
||||
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Check } from "lucide-react-native";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { SettingsSection } from "@/components/settings/headings/settings-section";
|
||||
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "react";
|
||||
import {
|
||||
FlatList,
|
||||
Keyboard,
|
||||
Platform,
|
||||
View,
|
||||
type LayoutChangeEvent,
|
||||
@@ -22,8 +23,6 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { useSettledKeyboardShift } from "@/hooks/keyboard-shift-context";
|
||||
import { resolveStreamKeyboardInset } from "@/hooks/keyboard-shift-policy";
|
||||
import { useRevisedHistoryRows } from "./history-row-revision";
|
||||
import { useBottomAnchorController } from "./bottom-anchor-controller";
|
||||
import { useScrollKeyboardDismiss } from "./scroll-keyboard-dismiss/use-scroll-keyboard-dismiss";
|
||||
@@ -105,7 +104,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
const scrollOffsetYRef = useRef(0);
|
||||
const isUserScrollActiveRef = useRef(false);
|
||||
const scrollKeyboardDismiss = useScrollKeyboardDismiss();
|
||||
const settledKeyboardShift = useSettledKeyboardShift();
|
||||
const userScrollEndFrameIdRef = useRef<number | null>(null);
|
||||
const programmaticScrollEventBudgetRef = useRef(0);
|
||||
const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false);
|
||||
@@ -275,34 +273,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
Platform.OS === "android" && bottomAnchorController.mode === "sticky-bottom"
|
||||
? undefined
|
||||
: DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION;
|
||||
const streamKeyboardInset = useMemo(
|
||||
() =>
|
||||
resolveStreamKeyboardInset({
|
||||
platform: Platform.OS === "ios" ? "ios" : "android",
|
||||
settledShift: settledKeyboardShift,
|
||||
}),
|
||||
[settledKeyboardShift],
|
||||
);
|
||||
const listContentContainerStyle = useMemo(
|
||||
() => [
|
||||
baseListContentContainerStyle,
|
||||
{ paddingBottom: streamKeyboardInset.contentContainerPaddingBottom },
|
||||
],
|
||||
[baseListContentContainerStyle, streamKeyboardInset.contentContainerPaddingBottom],
|
||||
);
|
||||
const listInsetProps = useMemo(
|
||||
() =>
|
||||
streamKeyboardInset.contentInset
|
||||
? {
|
||||
automaticallyAdjustContentInsets: false,
|
||||
automaticallyAdjustsScrollIndicatorInsets: false,
|
||||
contentInsetAdjustmentBehavior: "never" as const,
|
||||
contentInset: streamKeyboardInset.contentInset,
|
||||
scrollIndicatorInsets: streamKeyboardInset.contentInset,
|
||||
}
|
||||
: {},
|
||||
[streamKeyboardInset.contentInset],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
streamViewportMetricsRef.current = {
|
||||
@@ -335,7 +305,27 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
};
|
||||
}, [agentId, clearNativeViewportSettling, clearPendingUserScrollEnd, evaluateHistoryStart]);
|
||||
|
||||
useEffect(() => () => clearNativeViewportSettling(), [clearNativeViewportSettling]);
|
||||
useEffect(() => {
|
||||
const keyboardEvents = [
|
||||
"keyboardWillShow",
|
||||
"keyboardWillHide",
|
||||
"keyboardDidShow",
|
||||
"keyboardDidHide",
|
||||
"keyboardWillChangeFrame",
|
||||
"keyboardDidChangeFrame",
|
||||
] as const;
|
||||
const subscriptions = keyboardEvents.map((eventName) =>
|
||||
Keyboard.addListener(eventName, () => {
|
||||
markNativeViewportSettling();
|
||||
}),
|
||||
);
|
||||
return () => {
|
||||
for (const subscription of subscriptions) {
|
||||
subscription.remove();
|
||||
}
|
||||
clearNativeViewportSettling();
|
||||
};
|
||||
}, [clearNativeViewportSettling, markNativeViewportSettling]);
|
||||
|
||||
useEffect(() => {
|
||||
bottomAnchorController.prepareForStickyContentChange();
|
||||
@@ -570,7 +560,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
// data or the live header changes, preserving the row identities above.
|
||||
return (
|
||||
<FlatList
|
||||
{...listInsetProps}
|
||||
ref={flatListRef}
|
||||
data={historyRows}
|
||||
renderItem={renderItem}
|
||||
@@ -580,7 +569,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
nativeID="agent-chat-scroll-native-virtualized"
|
||||
ListHeaderComponent={liveHeaderContent ?? undefined}
|
||||
ListFooterComponent={historyFooterContent ?? undefined}
|
||||
contentContainerStyle={listContentContainerStyle}
|
||||
contentContainerStyle={baseListContentContainerStyle}
|
||||
style={listStyle}
|
||||
onLayout={handleListLayout}
|
||||
onScroll={handleScroll}
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
AssistantMessage,
|
||||
SpeakMessage,
|
||||
UserMessage,
|
||||
Notification,
|
||||
ActivityLog,
|
||||
ToolCall,
|
||||
TodoListCard,
|
||||
CompactionMarker,
|
||||
@@ -877,8 +877,15 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
case "tool_call":
|
||||
return renderToolCallItem(layoutItem, item);
|
||||
|
||||
case "notification":
|
||||
return <Notification level={item.level} message={item.message} />;
|
||||
case "activity_log":
|
||||
return (
|
||||
<ActivityLog
|
||||
type={item.activityType}
|
||||
message={item.message}
|
||||
timestamp={item.timestamp.getTime()}
|
||||
metadata={item.metadata}
|
||||
/>
|
||||
);
|
||||
|
||||
case "todo_list":
|
||||
return <TodoListCard items={item.items} activity={item.activity} />;
|
||||
|
||||
@@ -70,7 +70,7 @@ export function estimateStreamItemHeight(item: StreamItem): number {
|
||||
return COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE;
|
||||
case "todo_list":
|
||||
return 144;
|
||||
case "notification":
|
||||
case "activity_log":
|
||||
return 88;
|
||||
case "compaction":
|
||||
return 72;
|
||||
|
||||
@@ -28,13 +28,12 @@ import { CommandCenterProvider } from "@/command-center/provider";
|
||||
import { CommandCenterWorkspaceActions } from "@/command-center/workspace-registration";
|
||||
import { PluginCommandCenterActions } from "@/plugins/command-center/registration";
|
||||
import { AddProjectFlowHost } from "@/components/add-project-flow-host";
|
||||
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
|
||||
import { WorktreeSetupCalloutSource } from "@/components/worktree-setup-callout-source";
|
||||
import { DownloadToast } from "@/components/download-toast";
|
||||
import { QuittingOverlay } from "@/components/quitting-overlay";
|
||||
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
||||
import { ChangelogHost } from "@/changelog";
|
||||
import { AppDiagnosticHost } from "@/components/app-diagnostic-host";
|
||||
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
|
||||
import { LeftSidebar } from "@/components/left-sidebar";
|
||||
import { WindowSidebarMenuToggle } from "@/components/headers/menu-header";
|
||||
import { DesktopWindowControls } from "@/components/desktop/window-controls";
|
||||
@@ -70,7 +69,6 @@ import {
|
||||
resolveStartupNavigationReady,
|
||||
shouldRunStartupGiveUpTimer,
|
||||
startHostRuntimeBootstrap,
|
||||
bindHostRuntimeAppState,
|
||||
type StartupBlocker,
|
||||
} from "@/navigation/host-runtime-bootstrap";
|
||||
import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation";
|
||||
@@ -374,11 +372,6 @@ async function shouldStartBuiltInDaemon(): Promise<boolean> {
|
||||
}
|
||||
|
||||
function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
useEffect(() => {
|
||||
const store = getHostRuntimeStore();
|
||||
return bindHostRuntimeAppState(store, AppState);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const store = getHostRuntimeStore();
|
||||
const daemonStartService = getDaemonStartService({ store });
|
||||
@@ -552,15 +545,11 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
keyboardShortcutsEnabled={keyboardShortcutsEnabled}
|
||||
/>
|
||||
);
|
||||
let themedSidebarChrome = sidebarChrome;
|
||||
if (isWeb) {
|
||||
themedSidebarChrome = <AppearanceStyleBoundary>{sidebarChrome}</AppearanceStyleBoundary>;
|
||||
}
|
||||
const workspaceChrome = (
|
||||
<View style={rowStyle}>
|
||||
{!isCompactLayout ? (
|
||||
<WindowChromeRegion corners={appChromeLayout.sidebarCorners}>
|
||||
{themedSidebarChrome}
|
||||
{sidebarChrome}
|
||||
</WindowChromeRegion>
|
||||
) : null}
|
||||
{usesCompactExplorerHost ? (
|
||||
@@ -580,49 +569,42 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
</View>
|
||||
);
|
||||
|
||||
// Native panel gesture hosts outlive appearance keys, like native navigators.
|
||||
// Their tracked styles update in place; web numeric styles still need remounting.
|
||||
const surface = (
|
||||
<View style={layoutStyles.surfaceFill}>
|
||||
{workspaceChrome}
|
||||
<AppearanceStyleBoundary>
|
||||
{!isCompactLayout && appChromeLayout.sidebarToggleOwner === "window" ? (
|
||||
<WindowChromeRegion corners="top-left">
|
||||
<WindowChromeSafeArea
|
||||
placement="inline"
|
||||
horizontalPadding={WINDOW_SIDEBAR_TOGGLE_HORIZONTAL_PADDING}
|
||||
pointerEvents="box-none"
|
||||
style={layoutStyles.windowSidebarToggle}
|
||||
>
|
||||
<WindowSidebarMenuToggle />
|
||||
</WindowChromeSafeArea>
|
||||
</WindowChromeRegion>
|
||||
) : null}
|
||||
<DesktopWindowControls />
|
||||
<FloatingPanelPortalHost />
|
||||
</AppearanceStyleBoundary>
|
||||
{isCompactLayout ? themedSidebarChrome : null}
|
||||
<AppearanceStyleBoundary>
|
||||
<DownloadToast />
|
||||
<RosettaCalloutSource />
|
||||
<UpdateCalloutSource />
|
||||
<LegacyAgentSkillsMigration />
|
||||
<WorktreeSetupCalloutSource />
|
||||
<CommandCenterRootActions />
|
||||
<CommandCenterWorkspaceActions />
|
||||
<PluginCommandCenterActions />
|
||||
<WorkspacePinShortcutHandler />
|
||||
<WorkspaceRenameHost />
|
||||
<CommandCenter />
|
||||
<AddProjectFlowHost />
|
||||
<HostChooserModal />
|
||||
<ProviderSettingsHost />
|
||||
<WorkspaceSetupDialog />
|
||||
<KeyboardShortcutsDialog />
|
||||
<AppDiagnosticHost />
|
||||
<ChangelogHost />
|
||||
<QuittingOverlay />
|
||||
</AppearanceStyleBoundary>
|
||||
{!isCompactLayout && appChromeLayout.sidebarToggleOwner === "window" ? (
|
||||
<WindowChromeRegion corners="top-left">
|
||||
<WindowChromeSafeArea
|
||||
placement="inline"
|
||||
horizontalPadding={WINDOW_SIDEBAR_TOGGLE_HORIZONTAL_PADDING}
|
||||
pointerEvents="box-none"
|
||||
style={layoutStyles.windowSidebarToggle}
|
||||
>
|
||||
<WindowSidebarMenuToggle />
|
||||
</WindowChromeSafeArea>
|
||||
</WindowChromeRegion>
|
||||
) : null}
|
||||
<DesktopWindowControls />
|
||||
<FloatingPanelPortalHost />
|
||||
{isCompactLayout ? sidebarChrome : null}
|
||||
<DownloadToast />
|
||||
<RosettaCalloutSource />
|
||||
<UpdateCalloutSource />
|
||||
<LegacyAgentSkillsMigration />
|
||||
<WorktreeSetupCalloutSource />
|
||||
<CommandCenterRootActions />
|
||||
<CommandCenterWorkspaceActions />
|
||||
<PluginCommandCenterActions />
|
||||
<WorkspacePinShortcutHandler />
|
||||
<WorkspaceRenameHost />
|
||||
<CommandCenter />
|
||||
<AddProjectFlowHost />
|
||||
<HostChooserModal />
|
||||
<ProviderSettingsHost />
|
||||
<WorkspaceSetupDialog />
|
||||
<KeyboardShortcutsDialog />
|
||||
<AppDiagnosticHost />
|
||||
<QuittingOverlay />
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -684,7 +666,7 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
|
||||
<OfferLinkListener upsertDaemonFromOfferUrl={upsertConnectionFromOfferUrl} />
|
||||
<HostSessionManager />
|
||||
<FaviconStatusSync />
|
||||
{children}
|
||||
<AppearanceStyleBoundary>{children}</AppearanceStyleBoundary>
|
||||
</VoiceProvider>
|
||||
</AppearanceProvider>
|
||||
);
|
||||
@@ -895,15 +877,11 @@ const ROOT_STACK_SCREEN_OPTIONS = {
|
||||
headerShown: false,
|
||||
animation: "none" as const,
|
||||
};
|
||||
const ROOT_STACK_NESTED_NAVIGATOR_SCREENS = ["h/[serverId]"] as const;
|
||||
|
||||
function RootStack() {
|
||||
const storeReady = useStoreReady();
|
||||
return (
|
||||
<ThemedStack
|
||||
screenOptions={ROOT_STACK_SCREEN_OPTIONS}
|
||||
nestedNavigatorScreens={ROOT_STACK_NESTED_NAVIGATOR_SCREENS}
|
||||
>
|
||||
<ThemedStack screenOptions={ROOT_STACK_SCREEN_OPTIONS}>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Protected guard={storeReady}>
|
||||
<Stack.Screen name="welcome" />
|
||||
@@ -918,7 +896,6 @@ function RootStack() {
|
||||
<Stack.Screen name="h/[serverId]" />
|
||||
<Stack.Screen name="settings/hosts/[serverId]/index" />
|
||||
<Stack.Screen name="settings/hosts/[serverId]/[hostSection]" />
|
||||
<Stack.Screen name="settings/hosts/[serverId]/plugins/[pluginId]/[screenId]" />
|
||||
<Stack.Screen name="settings/hosts/[serverId]/projects/index" />
|
||||
<Stack.Screen name="settings/hosts/[serverId]/projects/[projectId]" />
|
||||
</ThemedStack>
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import SettingsScreen from "@/screens/settings-screen";
|
||||
|
||||
export default function PluginSettingsRoute() {
|
||||
const { serverId, pluginId, screenId } = useLocalSearchParams<{
|
||||
serverId: string;
|
||||
pluginId: string;
|
||||
screenId: string;
|
||||
}>();
|
||||
const view = useMemo(
|
||||
() => ({ kind: "plugin" as const, serverId, pluginId, screenId }),
|
||||
[serverId, pluginId, screenId],
|
||||
);
|
||||
return (
|
||||
<HostRouteBootstrapBoundary>
|
||||
<SettingsScreen view={view} />
|
||||
</HostRouteBootstrapBoundary>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { useChangelogStore } from "./internal/changelog-store";
|
||||
import { ChangelogSheet } from "./internal/changelog-sheet";
|
||||
|
||||
/** Mounted once by the app shell so any surface can call `openChangelog()`. */
|
||||
export function ChangelogHost() {
|
||||
const visible = useChangelogStore((state) => state.visible);
|
||||
const close = useChangelogStore((state) => state.close);
|
||||
|
||||
return <ChangelogSheet visible={visible} onClose={close} />;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
/**
|
||||
* What's new: the release notes, read from the repository's CHANGELOG.md and
|
||||
* rendered in the app instead of sending the reader to a browser.
|
||||
*
|
||||
* Two things leave this module — the host the app shell mounts, and the command
|
||||
* every entry point calls. Where the document comes from, how it is parsed, and
|
||||
* how much of it renders at once stay inside; import from `@/changelog`, never
|
||||
* a path within it.
|
||||
*/
|
||||
export { ChangelogHost } from "./changelog-host";
|
||||
export { openChangelog } from "./open-changelog";
|
||||
@@ -1,241 +0,0 @@
|
||||
import { memo, useCallback, useMemo } from "react";
|
||||
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { ExternalLink, Gift } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { MarkdownRenderer } from "@/components/markdown/renderer";
|
||||
import { Alert } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import {
|
||||
iconButtonChromeGlyphSize,
|
||||
iconButtonChromeStyle,
|
||||
} from "@/components/ui/icon-button-chrome";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import { resolveAppVersion } from "@/utils/app-version";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { useChangelog, type ChangelogState } from "./changelog-source";
|
||||
import { useRevealedReleases } from "./use-revealed-releases";
|
||||
import {
|
||||
formatChangelogDate,
|
||||
type ChangelogRelease,
|
||||
type ChangelogSection,
|
||||
} from "./parse-changelog";
|
||||
|
||||
const WEBSITE_CHANGELOG_URL = "https://paseo.sh/changelog";
|
||||
|
||||
const ThemedGift = withUnistyles(Gift);
|
||||
const ThemedExternalLink = withUnistyles(ExternalLink);
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
const sheetLeadingIcon = <ThemedGift size={ICON_SIZE.md} uniProps={mutedColorMapping} />;
|
||||
const websiteButtonStyle = (state: PressableStateCallbackType & { hovered?: boolean }) =>
|
||||
iconButtonChromeStyle({ size: "large", state });
|
||||
|
||||
interface ChangelogSheetProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ChangelogSheet({ visible, onClose }: ChangelogSheetProps) {
|
||||
const { t } = useTranslation();
|
||||
const { state, reload } = useChangelog(visible);
|
||||
const { count, showMore } = useRevealedReleases(visible && state.status === "ready");
|
||||
|
||||
const handleOpenWebsite = useCallback(() => {
|
||||
void openExternalUrl(WEBSITE_CHANGELOG_URL);
|
||||
}, []);
|
||||
|
||||
const header: SheetHeader = useMemo(
|
||||
() => ({
|
||||
title: t("changelog.title"),
|
||||
leading: sheetLeadingIcon,
|
||||
actions: (
|
||||
<Pressable
|
||||
onPress={handleOpenWebsite}
|
||||
hitSlop={8}
|
||||
style={websiteButtonStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("changelog.openWebsite")}
|
||||
testID="changelog-open-website"
|
||||
>
|
||||
<ThemedExternalLink
|
||||
size={iconButtonChromeGlyphSize("large")}
|
||||
uniProps={mutedColorMapping}
|
||||
/>
|
||||
</Pressable>
|
||||
),
|
||||
}),
|
||||
[handleOpenWebsite, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
header={header}
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
snapPoints={SNAP_POINTS}
|
||||
desktopMaxWidth={620}
|
||||
desktopHeight="85%"
|
||||
testID="changelog-sheet"
|
||||
>
|
||||
<ChangelogBody state={state} shownReleases={count} onShowMore={showMore} onRetry={reload} />
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
|
||||
const SNAP_POINTS = ["85%", "95%"];
|
||||
|
||||
interface ChangelogBodyProps {
|
||||
state: ChangelogState;
|
||||
shownReleases: number;
|
||||
onShowMore: () => void;
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
function ChangelogBody({ state, shownReleases, onShowMore, onRetry }: ChangelogBodyProps) {
|
||||
const { t } = useTranslation();
|
||||
const appVersion = useMemo(() => resolveAppVersion()?.replace(/^v/i, "") ?? null, []);
|
||||
|
||||
if (state.status === "loading") {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<ThemedLoadingSpinner size="large" uniProps={mutedColorMapping} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.status === "error") {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<Alert
|
||||
variant="error"
|
||||
title={t("changelog.error.title")}
|
||||
description={t("changelog.error.description")}
|
||||
testID="changelog-error"
|
||||
>
|
||||
<Button variant="outline" size="sm" onPress={onRetry} testID="changelog-retry">
|
||||
{t("common.actions.retry")}
|
||||
</Button>
|
||||
</Alert>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const visibleReleases = state.releases.slice(0, shownReleases);
|
||||
|
||||
return (
|
||||
<View style={styles.releaseList}>
|
||||
{visibleReleases.map((release) => (
|
||||
<ReleaseView
|
||||
key={`${release.version}:${release.date}`}
|
||||
release={release}
|
||||
isCurrent={release.version === appVersion}
|
||||
/>
|
||||
))}
|
||||
{state.releases.length > visibleReleases.length ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onPress={onShowMore}
|
||||
style={styles.showMore}
|
||||
testID="changelog-show-more"
|
||||
>
|
||||
{t("changelog.showMore")}
|
||||
</Button>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface ReleaseViewProps {
|
||||
release: ChangelogRelease;
|
||||
isCurrent: boolean;
|
||||
}
|
||||
|
||||
const ReleaseView = memo(function ReleaseView({ release, isCurrent }: ReleaseViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const date = formatChangelogDate(release.date);
|
||||
|
||||
return (
|
||||
<View style={styles.release} testID={`changelog-release-${release.version}`}>
|
||||
<View style={styles.releaseHeading}>
|
||||
<Text style={styles.version}>{release.version}</Text>
|
||||
{isCurrent ? <StatusBadge label={t("changelog.installed")} /> : null}
|
||||
<View style={styles.headingSpacer} />
|
||||
{date ? <Text style={styles.date}>{date}</Text> : null}
|
||||
</View>
|
||||
{keyChangelogSections(release.sections).map(({ key, section }) => (
|
||||
<View key={key} style={styles.section}>
|
||||
{section.title ? <Text style={styles.sectionTitle}>{section.title}</Text> : null}
|
||||
{section.body ? <MarkdownRenderer text={section.body} compact /> : null}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
/** A release can repeat a section title; the occurrence keeps the key stable. */
|
||||
function keyChangelogSections(
|
||||
sections: readonly ChangelogSection[],
|
||||
): { key: string; section: ChangelogSection }[] {
|
||||
const seen = new Map<string, number>();
|
||||
return sections.map((section) => {
|
||||
const title = section.title ?? "";
|
||||
const occurrence = seen.get(title) ?? 0;
|
||||
seen.set(title, occurrence + 1);
|
||||
return { key: `${title}:${occurrence}`, section };
|
||||
});
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
centered: {
|
||||
flex: 1,
|
||||
minHeight: 160,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
releaseList: {
|
||||
gap: theme.spacing[8],
|
||||
paddingBottom: theme.spacing[4],
|
||||
},
|
||||
release: {
|
||||
gap: theme.spacing[4],
|
||||
},
|
||||
releaseHeading: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingBottom: theme.spacing[2],
|
||||
borderBottomWidth: theme.borderWidth[1],
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
headingSpacer: {
|
||||
flex: 1,
|
||||
},
|
||||
version: {
|
||||
fontSize: theme.fontSize.lg,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
date: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
section: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foregroundMuted,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
showMore: {
|
||||
alignSelf: "center",
|
||||
},
|
||||
}));
|
||||
@@ -1,72 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { parseChangelog, type ChangelogRelease } from "./parse-changelog";
|
||||
|
||||
const CHANGELOG_URL = "https://raw.githubusercontent.com/getpaseo/paseo/main/CHANGELOG.md";
|
||||
|
||||
export type ChangelogState =
|
||||
| { status: "loading" }
|
||||
| { status: "ready"; releases: ChangelogRelease[] }
|
||||
| { status: "error" };
|
||||
|
||||
// Survives close/reopen so the second look paints without a spinner. The raw
|
||||
// text is kept alongside the releases so an unchanged revalidation can be
|
||||
// dropped: handing back an equal-but-new array would re-render every release.
|
||||
let cached: { markdown: string; releases: ChangelogRelease[] } | null = null;
|
||||
|
||||
export interface Changelog {
|
||||
state: ChangelogState;
|
||||
reload: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the changelog from the repository the app was built from.
|
||||
*
|
||||
* The daemon is not involved: the changelog describes the app, a phone reaching
|
||||
* a relay already has internet, and going through a host would make the notes
|
||||
* depend on which host happens to be connected.
|
||||
*
|
||||
* Every open refetches, because the whole point of opening it is a release that
|
||||
* shipped after this app started. A previous result stays on screen while that
|
||||
* happens, and survives a failed revalidation.
|
||||
*/
|
||||
export function useChangelog(enabled: boolean): Changelog {
|
||||
const [state, setState] = useState<ChangelogState>(readCache);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
setState(readCache());
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch(CHANGELOG_URL, { signal: controller.signal });
|
||||
if (!response.ok) throw new Error(`Changelog request failed: ${response.status}`);
|
||||
const markdown = await response.text();
|
||||
if (cached?.markdown === markdown) return;
|
||||
const releases = parseChangelog(markdown);
|
||||
if (releases.length === 0) throw new Error("Changelog has no releases");
|
||||
cached = { markdown, releases };
|
||||
setState({ status: "ready", releases });
|
||||
} catch {
|
||||
if (controller.signal.aborted) return;
|
||||
if (cached) return;
|
||||
setState({ status: "error" });
|
||||
}
|
||||
})();
|
||||
|
||||
return () => controller.abort();
|
||||
}, [enabled, attempt]);
|
||||
|
||||
const reload = useCallback(() => {
|
||||
cached = null;
|
||||
setAttempt((value) => value + 1);
|
||||
}, []);
|
||||
|
||||
return { state, reload };
|
||||
}
|
||||
|
||||
function readCache(): ChangelogState {
|
||||
return cached ? { status: "ready", releases: cached.releases } : { status: "loading" };
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
interface ChangelogVisibility {
|
||||
visible: boolean;
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export const useChangelogStore = create<ChangelogVisibility>((set) => ({
|
||||
visible: false,
|
||||
open: () => set({ visible: true }),
|
||||
close: () => set({ visible: false }),
|
||||
}));
|
||||
@@ -1,301 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatChangelogDate,
|
||||
parseChangelog,
|
||||
type ChangelogRelease,
|
||||
type ChangelogSection,
|
||||
} from "./parse-changelog";
|
||||
|
||||
function bodies(markdown: string, releaseIndex = 0): string[] {
|
||||
return parseChangelog(markdown)[releaseIndex].sections.map(sectionBody);
|
||||
}
|
||||
|
||||
function sectionBody(section: ChangelogSection): string {
|
||||
return section.body;
|
||||
}
|
||||
|
||||
function sectionTitle(section: ChangelogSection): string | null {
|
||||
return section.title;
|
||||
}
|
||||
|
||||
function releaseDate(release: ChangelogRelease): string {
|
||||
return release.date;
|
||||
}
|
||||
|
||||
function authoredLines(release: ChangelogRelease): string[] {
|
||||
return release.sections.flatMap(sectionLines);
|
||||
}
|
||||
|
||||
function sectionLines(section: ChangelogSection): string[] {
|
||||
return [...(section.title ? [`### ${section.title}`] : []), ...section.body.split("\n")];
|
||||
}
|
||||
|
||||
function isNotBlank(line: string): boolean {
|
||||
return line.trim().length > 0;
|
||||
}
|
||||
|
||||
describe("parseChangelog", () => {
|
||||
it("reads the shape the changelog is authored in today", () => {
|
||||
const releases = parseChangelog(
|
||||
[
|
||||
"# Changelog",
|
||||
"",
|
||||
"## 0.8.0-beta.1 - 2026-09-08",
|
||||
"",
|
||||
"This release extends what plugins can do.",
|
||||
"",
|
||||
"### Plugins",
|
||||
"",
|
||||
"- Added provider contributions ([#4314](https://example.com/4314))",
|
||||
"",
|
||||
"### Fixed",
|
||||
"",
|
||||
"- Fixed the mobile terminal keyboard closing",
|
||||
"",
|
||||
"## 0.7.2 - 2026-09-02",
|
||||
"",
|
||||
"### Fixed",
|
||||
"",
|
||||
"- Fixed a thing",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
expect(releases).toEqual([
|
||||
{
|
||||
version: "0.8.0-beta.1",
|
||||
date: "2026-09-08",
|
||||
sections: [
|
||||
{ title: null, body: "This release extends what plugins can do." },
|
||||
{
|
||||
title: "Plugins",
|
||||
body: "- Added provider contributions ([#4314](https://example.com/4314))",
|
||||
},
|
||||
{ title: "Fixed", body: "- Fixed the mobile terminal keyboard closing" },
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.7.2",
|
||||
date: "2026-09-02",
|
||||
sections: [{ title: "Fixed", body: "- Fixed a thing" }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops everything above the first release heading", () => {
|
||||
const releases = parseChangelog(
|
||||
"# Changelog\n\nAll notable changes.\n\n## 1.0.0 - 2026-01-01\n\n- One\n",
|
||||
);
|
||||
expect(releases).toHaveLength(1);
|
||||
expect(releases[0].sections).toEqual([{ title: null, body: "- One" }]);
|
||||
});
|
||||
|
||||
it("returns nothing for a document with no releases", () => {
|
||||
expect(parseChangelog("")).toEqual([]);
|
||||
expect(parseChangelog("# Changelog\n\nNothing yet.\n")).toEqual([]);
|
||||
});
|
||||
|
||||
describe("release headings", () => {
|
||||
const headings: [string, { version: string; date: string }][] = [
|
||||
["## 1.2.3 - 2026-01-01", { version: "1.2.3", date: "2026-01-01" }],
|
||||
["## 1.2.3", { version: "1.2.3", date: "" }],
|
||||
["## [1.2.3] - 2026-01-01", { version: "1.2.3", date: "2026-01-01" }],
|
||||
["## v1.2.3 - 2026-01-01", { version: "1.2.3", date: "2026-01-01" }],
|
||||
["## 1.2.3 — 2026-01-01", { version: "1.2.3", date: "2026-01-01" }],
|
||||
["## 1.2.3 – 2026-01-01", { version: "1.2.3", date: "2026-01-01" }],
|
||||
["## 1.2.3 (2026-01-01)", { version: "1.2.3", date: "2026-01-01" }],
|
||||
["## 1.2.3 - January 1, 2026", { version: "1.2.3", date: "January 1, 2026" }],
|
||||
[
|
||||
"## [1.2.3](https://example.com/v1.2.3) - 2026-01-01",
|
||||
{ version: "1.2.3", date: "2026-01-01" },
|
||||
],
|
||||
["## Unreleased", { version: "Unreleased", date: "" }],
|
||||
["## [Unreleased]", { version: "Unreleased", date: "" }],
|
||||
["## 1.2.3 - 2026-01-01 ", { version: "1.2.3", date: "2026-01-01" }],
|
||||
["## 1.2.3 - 2026-01-01 ##", { version: "1.2.3", date: "2026-01-01" }],
|
||||
[" ## 1.2.3 - 2026-01-01", { version: "1.2.3", date: "2026-01-01" }],
|
||||
["## 0.8.0-beta.1 - 2026-09-08", { version: "0.8.0-beta.1", date: "2026-09-08" }],
|
||||
];
|
||||
|
||||
for (const [heading, expected] of headings) {
|
||||
it(`reads ${JSON.stringify(heading)}`, () => {
|
||||
const [release] = parseChangelog(`${heading}\n\n- Entry\n`);
|
||||
expect({ version: release.version, date: release.date }).toEqual(expected);
|
||||
});
|
||||
}
|
||||
|
||||
it("keeps both entries when a version appears twice", () => {
|
||||
const releases = parseChangelog(
|
||||
"## 1.0.0 - 2026-01-02\n\n- Two\n\n## 1.0.0 - 2026-01-01\n\n- One\n",
|
||||
);
|
||||
expect(releases.map(releaseDate)).toEqual(["2026-01-02", "2026-01-01"]);
|
||||
});
|
||||
|
||||
it("does not treat a deeper heading as a release", () => {
|
||||
const releases = parseChangelog("## 1.0.0 - 2026-01-01\n\n#### Deep\n\n- One\n");
|
||||
expect(releases).toHaveLength(1);
|
||||
expect(releases[0].sections).toEqual([{ title: null, body: "#### Deep\n\n- One" }]);
|
||||
});
|
||||
|
||||
it("does not treat an unspaced hash run as a heading", () => {
|
||||
const releases = parseChangelog("## 1.0.0 - 2026-01-01\n\n##notaheading\n");
|
||||
expect(releases).toHaveLength(1);
|
||||
expect(releases[0].sections[0].body).toBe("##notaheading");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sections", () => {
|
||||
it("names sections from the document instead of a fixed list", () => {
|
||||
const releases = parseChangelog(
|
||||
"## 1.0.0 - 2026-01-01\n\n### Security\n\n- One\n\n### Deprecated\n\n- Two\n",
|
||||
);
|
||||
expect(releases[0].sections.map(sectionTitle)).toEqual(["Security", "Deprecated"]);
|
||||
});
|
||||
|
||||
it("keeps a section that has a heading but no body", () => {
|
||||
const releases = parseChangelog("## 1.0.0 - 2026-01-01\n\n### Fixed\n\n### Added\n\n- One\n");
|
||||
expect(releases[0].sections).toEqual([
|
||||
{ title: "Fixed", body: "" },
|
||||
{ title: "Added", body: "- One" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops an empty lead section", () => {
|
||||
const releases = parseChangelog("## 1.0.0 - 2026-01-01\n\n### Fixed\n\n- One\n");
|
||||
expect(releases[0].sections).toEqual([{ title: "Fixed", body: "- One" }]);
|
||||
});
|
||||
|
||||
it("keeps a release that is only prose", () => {
|
||||
const releases = parseChangelog("## 1.0.0 - 2026-01-01\n\nJust a note.\n");
|
||||
expect(releases[0].sections).toEqual([{ title: null, body: "Just a note." }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("block kinds it does not interpret", () => {
|
||||
it("passes a block quote, callout, table and embed through untouched", () => {
|
||||
const source = [
|
||||
"## 1.0.0 - 2026-01-01",
|
||||
"",
|
||||
"### Notes",
|
||||
"",
|
||||
"> [!WARNING]",
|
||||
"> This release drops macOS 12.",
|
||||
"",
|
||||
"| Platform | Status |",
|
||||
"| --- | --- |",
|
||||
"| macOS | Shipped |",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
'<video src="https://example.com/demo.mp4" controls></video>',
|
||||
"",
|
||||
'<iframe src="https://example.com/embed"></iframe>',
|
||||
"",
|
||||
"<details><summary>More</summary>Hidden</details>",
|
||||
].join("\n");
|
||||
|
||||
expect(bodies(source)).toEqual([source.split("\n").slice(4).join("\n")]);
|
||||
});
|
||||
|
||||
it("does not split on a heading inside a fenced code block", () => {
|
||||
const source = [
|
||||
"## 1.0.0 - 2026-01-01",
|
||||
"",
|
||||
"### Added",
|
||||
"",
|
||||
"```md",
|
||||
"## 9.9.9 - 2099-01-01",
|
||||
"### Fake section",
|
||||
"```",
|
||||
"",
|
||||
"- Real entry",
|
||||
].join("\n");
|
||||
|
||||
const releases = parseChangelog(source);
|
||||
expect(releases).toHaveLength(1);
|
||||
expect(releases[0].sections).toEqual([
|
||||
{
|
||||
title: "Added",
|
||||
body: "```md\n## 9.9.9 - 2099-01-01\n### Fake section\n```\n\n- Real entry",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("tracks tilde fences and longer fence runs", () => {
|
||||
const source = [
|
||||
"## 1.0.0 - 2026-01-01",
|
||||
"",
|
||||
"~~~~",
|
||||
"## 9.9.9",
|
||||
"```",
|
||||
"~~~~",
|
||||
"",
|
||||
"- Real entry",
|
||||
].join("\n");
|
||||
|
||||
expect(parseChangelog(source)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not split on a heading inside a fence nested in a list item", () => {
|
||||
const source = [
|
||||
"## 1.0.0 - 2026-01-01",
|
||||
"",
|
||||
"- Entry with a sample",
|
||||
"",
|
||||
" ```",
|
||||
" ## 9.9.9",
|
||||
" ```",
|
||||
].join("\n");
|
||||
|
||||
expect(parseChangelog(source)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("reads CRLF documents", () => {
|
||||
const releases = parseChangelog("## 1.0.0 - 2026-01-01\r\n\r\n### Fixed\r\n\r\n- One\r\n");
|
||||
expect(releases).toEqual([
|
||||
{ version: "1.0.0", date: "2026-01-01", sections: [{ title: "Fixed", body: "- One" }] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatChangelogDate", () => {
|
||||
it("localizes an ISO date without shifting it across a timezone", () => {
|
||||
expect(formatChangelogDate("2026-09-08")).toBe("September 8, 2026");
|
||||
});
|
||||
|
||||
it("shows anything else as authored", () => {
|
||||
expect(formatChangelogDate("September 8, 2026")).toBe("September 8, 2026");
|
||||
expect(formatChangelogDate("")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the repository's own CHANGELOG.md", () => {
|
||||
const markdown = readFileSync(
|
||||
fileURLToPath(new URL("../../../../../CHANGELOG.md", import.meta.url)),
|
||||
"utf8",
|
||||
);
|
||||
const releases = parseChangelog(markdown);
|
||||
|
||||
it("parses every release heading", () => {
|
||||
expect(releases.length).toBeGreaterThan(50);
|
||||
for (const release of releases) {
|
||||
expect(release.version).toMatch(/^\d+\.\d+\.\d+/);
|
||||
expect(release.date).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
expect(release.sections.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps every authored line inside a release", () => {
|
||||
const documentLines = markdown.split("\n");
|
||||
const fromFirstRelease = documentLines.slice(
|
||||
documentLines.findIndex((line) => line.startsWith("## ")),
|
||||
);
|
||||
|
||||
expect(releases.flatMap(authoredLines).filter(isNotBlank)).toEqual(
|
||||
fromFirstRelease.filter(isNotBlank).filter((line) => !line.startsWith("## ")),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,151 +0,0 @@
|
||||
/**
|
||||
* Structural parser for a CHANGELOG.md document.
|
||||
*
|
||||
* The document is authored in the repository, fetched at runtime, and rewritten
|
||||
* on every release, so this parser commits to exactly two facts: a `##` heading
|
||||
* starts a release and a `###` heading starts a section inside it. Everything
|
||||
* else — prose, callouts, block quotes, fenced code, images, video and embed
|
||||
* HTML, tables, nested lists — is carried through verbatim and handed to the
|
||||
* Markdown renderer. A new block kind in the changelog therefore needs no
|
||||
* change here.
|
||||
*
|
||||
* Section titles are read, never matched: renaming "Improved" or adding a
|
||||
* section is a content change, not a code change.
|
||||
*
|
||||
* The one way a line scan can be fooled is a fenced block whose contents look
|
||||
* like a heading, so fences are tracked.
|
||||
*/
|
||||
|
||||
export interface ChangelogSection {
|
||||
/** The `###` heading text, or null for content that precedes the first one. */
|
||||
title: string | null;
|
||||
/** Verbatim markdown under the heading. */
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface ChangelogRelease {
|
||||
/** Version as authored, stripped of link, bracket and `v` decoration. */
|
||||
version: string;
|
||||
/** The heading tail after the version. Empty when the heading carried none. */
|
||||
date: string;
|
||||
/** Sections in document order; the untitled lead section comes first. */
|
||||
sections: ChangelogSection[];
|
||||
}
|
||||
|
||||
// `##` / `###` must be followed by whitespace, which is what keeps each pattern
|
||||
// from matching one level deeper.
|
||||
const RELEASE_HEADING = /^ {0,3}##(?:[ \t]+(.*?))?[ \t]*$/;
|
||||
const SECTION_HEADING = /^ {0,3}###(?:[ \t]+(.*?))?[ \t]*$/;
|
||||
const FENCE_OPEN = /^\s*(`{3,}|~{3,})/;
|
||||
const ATX_CLOSING = /[ \t]+#+[ \t]*$/;
|
||||
const MARKDOWN_LINK = /\[([^\]]*)\]\([^)]*\)/g;
|
||||
const DASH_SEPARATED_DATE = /^(.*?)\s+[-–—]\s+(.+)$/;
|
||||
const PARENTHESIZED_DATE = /^(.*?)\s*\((.+)\)$/;
|
||||
const BRACKETED_VERSION = /^\[(.*)\]$/;
|
||||
const LEADING_V = /^v(?=\d)/i;
|
||||
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
interface SectionDraft {
|
||||
title: string | null;
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
interface ReleaseDraft {
|
||||
version: string;
|
||||
date: string;
|
||||
sections: SectionDraft[];
|
||||
}
|
||||
|
||||
export function parseChangelog(markdown: string): ChangelogRelease[] {
|
||||
const releases: ReleaseDraft[] = [];
|
||||
let fence: string | null = null;
|
||||
|
||||
for (const line of markdown.split(/\r?\n/)) {
|
||||
const fenceEdge = line.match(FENCE_OPEN)?.[1];
|
||||
if (fenceEdge) {
|
||||
if (!fence) {
|
||||
fence = fenceEdge;
|
||||
} else if (fenceEdge[0] === fence[0] && fenceEdge.length >= fence.length) {
|
||||
fence = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!fence) {
|
||||
const releaseHeading = line.match(RELEASE_HEADING);
|
||||
if (releaseHeading) {
|
||||
releases.push({
|
||||
...splitReleaseHeading(releaseHeading[1] ?? ""),
|
||||
sections: [{ title: null, lines: [] }],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const sectionHeading = line.match(SECTION_HEADING);
|
||||
if (sectionHeading && releases.length > 0) {
|
||||
releases[releases.length - 1].sections.push({
|
||||
title: (sectionHeading[1] ?? "").replace(ATX_CLOSING, "").trim() || null,
|
||||
lines: [],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const release = releases.at(-1);
|
||||
if (release) release.sections[release.sections.length - 1].lines.push(line);
|
||||
}
|
||||
|
||||
return releases.map(materializeRelease);
|
||||
}
|
||||
|
||||
function materializeRelease(draft: ReleaseDraft): ChangelogRelease {
|
||||
const sections = draft.sections
|
||||
.map((section) => ({ title: section.title, body: trimBlankLines(section.lines) }))
|
||||
// A heading with an empty body still names something; an empty lead section
|
||||
// is just the gap between the release heading and the first section.
|
||||
.filter((section) => section.title !== null || section.body.length > 0);
|
||||
return { version: draft.version, date: draft.date, sections };
|
||||
}
|
||||
|
||||
function trimBlankLines(lines: string[]): string {
|
||||
let start = 0;
|
||||
let end = lines.length;
|
||||
while (start < end && lines[start].trim().length === 0) start += 1;
|
||||
while (end > start && lines[end - 1].trim().length === 0) end -= 1;
|
||||
return lines.slice(start, end).join("\n");
|
||||
}
|
||||
|
||||
function splitReleaseHeading(heading: string): { version: string; date: string } {
|
||||
const plain = heading.replace(ATX_CLOSING, "").replace(MARKDOWN_LINK, "$1").trim();
|
||||
|
||||
const dashed = plain.match(DASH_SEPARATED_DATE);
|
||||
if (dashed) return { version: normalizeVersion(dashed[1]), date: dashed[2].trim() };
|
||||
|
||||
const parenthesized = plain.match(PARENTHESIZED_DATE);
|
||||
if (parenthesized) {
|
||||
return { version: normalizeVersion(parenthesized[1]), date: parenthesized[2].trim() };
|
||||
}
|
||||
|
||||
return { version: normalizeVersion(plain), date: "" };
|
||||
}
|
||||
|
||||
function normalizeVersion(raw: string): string {
|
||||
const unbracketed = raw.trim().replace(BRACKETED_VERSION, "$1").trim();
|
||||
return unbracketed.replace(LEADING_V, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* ISO dates become the reader's locale; anything else the author wrote is shown
|
||||
* as authored rather than guessed at.
|
||||
*/
|
||||
export function formatChangelogDate(date: string): string {
|
||||
if (!ISO_DATE.test(date)) return date;
|
||||
const [year, month, day] = date.split("-").map(Number);
|
||||
const value = new Date(Date.UTC(year, month - 1, day));
|
||||
if (Number.isNaN(value.getTime())) return date;
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
}).format(value);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user