mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 21:32:40 +00:00
Compare commits
99
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdce65dfd5 | ||
|
|
7f8a485a77 | ||
|
|
4748aad103 | ||
|
|
75bbca4a1b | ||
|
|
23853a1515 | ||
|
|
f0bd2c8483 | ||
|
|
30195d3bd1 | ||
|
|
770b87a15c | ||
|
|
b03dc804eb | ||
|
|
373a98c64d | ||
|
|
599846650c | ||
|
|
b5610928a5 | ||
|
|
b44bb63cf4 | ||
|
|
4950d4c2ea | ||
|
|
4149eeb407 | ||
|
|
a732cc5756 | ||
|
|
6a1cc98fdf | ||
|
|
9c91dbbf42 | ||
|
|
2b9501a810 | ||
|
|
52e4060d76 | ||
|
|
a4b92768d9 | ||
|
|
0f27230746 | ||
|
|
9c195feb9b | ||
|
|
90f3d95415 | ||
|
|
1a6054138d | ||
|
|
20bccd31a3 | ||
|
|
d0d60a4052 | ||
|
|
9ca6f24315 | ||
|
|
36312352db | ||
|
|
c190fed27c | ||
|
|
9a7301d9a0 | ||
|
|
781e07f4ea | ||
|
|
44b9980a70 | ||
|
|
ed0d4e6c5b | ||
|
|
1b887e534b | ||
|
|
4426a6b11f | ||
|
|
fc1cb82e02 | ||
|
|
0c6dbefa40 | ||
|
|
94106a30ab | ||
|
|
635a8be437 | ||
|
|
8e916fd2dc | ||
|
|
474b4e70b4 | ||
|
|
124e512b58 | ||
|
|
0ba7251280 | ||
|
|
b58ccf9269 | ||
|
|
2f6b585a3a | ||
|
|
2c5c629db8 | ||
|
|
6ed364ffe2 | ||
|
|
74f2158c45 | ||
|
|
d012b6f071 | ||
|
|
5f5c585e68 | ||
|
|
23d7a8eaf9 | ||
|
|
7b167784d7 | ||
|
|
40bdb9a043 | ||
|
|
2f54c0e309 | ||
|
|
b9d7d4b18e | ||
|
|
1c905a114e | ||
|
|
163e7d1cc4 | ||
|
|
cdb1163145 | ||
|
|
f24664b882 | ||
|
|
a39959219f | ||
|
|
db6520bf36 | ||
|
|
b2ff359b60 | ||
|
|
308e9af845 | ||
|
|
07044136c8 | ||
|
|
b7ca1f7502 | ||
|
|
917e39495b | ||
|
|
8c003351d0 | ||
|
|
36a13601dd | ||
|
|
b599d38a77 | ||
|
|
7253228dc5 | ||
|
|
334bf62370 | ||
|
|
6aa9f92156 | ||
|
|
29ae25d7aa | ||
|
|
df9e8e8f06 | ||
|
|
94bda1f925 | ||
|
|
c5d5441c9c | ||
|
|
ca2b633286 | ||
|
|
a98d154b6b | ||
|
|
42f1d0b289 | ||
|
|
48e65b407f | ||
|
|
7954babca6 | ||
|
|
05a0cabc07 | ||
|
|
b7f137c2a0 | ||
|
|
02c4bc76a6 | ||
|
|
ccb4e444ed | ||
|
|
b0a7d493aa | ||
|
|
bfec7ac3ad | ||
|
|
5269c7b592 | ||
|
|
05dc2022c8 | ||
|
|
c0daf8e066 | ||
|
|
6ae6231d1f | ||
|
|
c77a6761af | ||
|
|
fd9cc5d693 | ||
|
|
7761d122d8 | ||
|
|
01a1d3b2b6 | ||
|
|
3bca9b7a92 | ||
|
|
8ffe7c75d5 | ||
|
|
74cf1931b1 |
@@ -18,7 +18,7 @@ Closes #
|
||||
|
||||
### Reasoning
|
||||
|
||||
<!-- A short description of the reasoning of this change in your own words. What was wrong, and how do you hope this PR fixes it. If you can't explain this briefly, the PR is probably too big. This description must be grounded in real user flows and framed as value provider to Paseo users. -->
|
||||
<!-- A short description of the reasoning of this change in your own words. What was wrong, and how do you hope this PR fixes it. If you can't explain this briefly, the PR is probably too big. This description must be grounded in real user flows and framed as value provided to Paseo users. -->
|
||||
|
||||
### Goals
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ on:
|
||||
- "packages/client/**"
|
||||
- "packages/expo-two-way-audio/**"
|
||||
- "packages/highlight/**"
|
||||
- "packages/plugin/**"
|
||||
- "packages/protocol/**"
|
||||
- "packages/relay/**"
|
||||
- "packages/server/**"
|
||||
|
||||
@@ -10,6 +10,7 @@ on:
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "packages/highlight/**"
|
||||
- "packages/plugin/**"
|
||||
- "packages/server/**"
|
||||
- "packages/relay/**"
|
||||
- "packages/cli/**"
|
||||
|
||||
@@ -12,6 +12,7 @@ on:
|
||||
- "packages/app/**"
|
||||
- "packages/expo-two-way-audio/**"
|
||||
- "packages/highlight/**"
|
||||
- "packages/plugin/**"
|
||||
- "packages/protocol/**"
|
||||
- "packages/client/**"
|
||||
- "packages/server/**"
|
||||
|
||||
@@ -1,5 +1,83 @@
|
||||
# Changelog
|
||||
|
||||
## 0.4.0 - 2026-08-13
|
||||
|
||||
### Breaking
|
||||
|
||||
- Removed `paseo chat` and `paseo loop` ([#3053](https://github.com/getpaseo/paseo/pull/3053))
|
||||
|
||||
### Added
|
||||
|
||||
- Added host-wide agent profiles for reusable provider, model, mode, thinking, and feature settings ([#3208](https://github.com/getpaseo/paseo/pull/3208))
|
||||
- Added workspace file search to Cmd/Ctrl+P ([#3059](https://github.com/getpaseo/paseo/pull/3059))
|
||||
- Added file and folder actions to Files and Changes ([#3027](https://github.com/getpaseo/paseo/pull/3027) by [@nikuscs](https://github.com/nikuscs))
|
||||
- Added interactive Mermaid diagrams to chats and Markdown previews ([#2306](https://github.com/getpaseo/paseo/pull/2306) by [@dmeledon](https://github.com/dmeledon))
|
||||
- Added live task progress above the composer and in the timeline ([#3227](https://github.com/getpaseo/paseo/pull/3227))
|
||||
- Added a model setting for generated workspace titles, branch names, commits, and pull request drafts ([#3215](https://github.com/getpaseo/paseo/pull/3215))
|
||||
- Added the supported `@getpaseo/client` TypeScript SDK ([#3141](https://github.com/getpaseo/paseo/pull/3141))
|
||||
- Added provider installation diagnostics to the CLI ([#3243](https://github.com/getpaseo/paseo/pull/3243))
|
||||
- Added workspace rename to the CLI ([#3209](https://github.com/getpaseo/paseo/pull/3209) by [@martinhanzik](https://github.com/martinhanzik))
|
||||
|
||||
### Improved
|
||||
|
||||
- Added a centered reading layout and formatted YAML front matter to Markdown previews ([#3240](https://github.com/getpaseo/paseo/pull/3240))
|
||||
- Added sidebar grouping to the Command Center ([#3063](https://github.com/getpaseo/paseo/pull/3063) by [@cleiter](https://github.com/cleiter))
|
||||
- Added every built-in theme to theme shortcuts ([#3214](https://github.com/getpaseo/paseo/pull/3214))
|
||||
- Removed periodic Git polling for idle workspaces ([#3323](https://github.com/getpaseo/paseo/pull/3323))
|
||||
- Set provider catalog refreshes to a configurable two-minute deadline ([#3322](https://github.com/getpaseo/paseo/pull/3322))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed terminal sessions being lost after host sleep or daemon worker stalls ([#3235](https://github.com/getpaseo/paseo/pull/3235), [#3263](https://github.com/getpaseo/paseo/pull/3263))
|
||||
- Fixed daemon hang when archiving a workspace ([#3107](https://github.com/getpaseo/paseo/pull/3107))
|
||||
- Fixed New Workspace crash on Android when projects span multiple hosts ([#3241](https://github.com/getpaseo/paseo/pull/3241))
|
||||
- Fixed provider cache exhausting Android local storage ([#3234](https://github.com/getpaseo/paseo/pull/3234))
|
||||
- Fixed dictation recordings being lost during connection interruptions ([#3159](https://github.com/getpaseo/paseo/pull/3159))
|
||||
- Fixed new workspaces reusing an existing worktree ([#3224](https://github.com/getpaseo/paseo/pull/3224))
|
||||
- Fixed new worktrees ignoring the setup command saved in Project Settings ([#3233](https://github.com/getpaseo/paseo/pull/3233))
|
||||
- Fixed new worktrees becoming dirty when the selected base contained a different `paseo.json` ([#3311](https://github.com/getpaseo/paseo/pull/3311))
|
||||
- Fixed removed hosts continuing to receive push notifications ([#3176](https://github.com/getpaseo/paseo/pull/3176))
|
||||
- Fixed delegated-agent notifications stopping after permission prompts or workspace closure ([#3177](https://github.com/getpaseo/paseo/pull/3177), [#3192](https://github.com/getpaseo/paseo/pull/3192) by [@thomasvan](https://github.com/thomasvan), [@wilgon456](https://github.com/wilgon456))
|
||||
- Fixed OMP agent startup when model catalogs exceed the protocol-v1 frame limit ([#3184](https://github.com/getpaseo/paseo/pull/3184) by [@pi3123](https://github.com/pi3123))
|
||||
- Fixed OpenCode models being unable to return to their default variant ([#3281](https://github.com/getpaseo/paseo/pull/3281))
|
||||
- Fixed completed Codex subagents remaining marked active ([#3188](https://github.com/getpaseo/paseo/pull/3188) by [@Strainy](https://github.com/Strainy))
|
||||
- Fixed Codex compaction leaving turns marked as working ([#3211](https://github.com/getpaseo/paseo/pull/3211) by [@edihasaj](https://github.com/edihasaj))
|
||||
- Fixed Cursor usage with current Cursor logins ([#2704](https://github.com/getpaseo/paseo/pull/2704) by [@QuteSaltyFish](https://github.com/QuteSaltyFish))
|
||||
- Fixed SVG project icons rendering as blank on mobile ([#2579](https://github.com/getpaseo/paseo/pull/2579) by [@colonelpanic8](https://github.com/colonelpanic8))
|
||||
- Fixed crash when persisted cache was incompatible ([#3289](https://github.com/getpaseo/paseo/pull/3289))
|
||||
- Fixed open delegated agents being archived with their parent ([#3279](https://github.com/getpaseo/paseo/pull/3279))
|
||||
- Fixed agent creation when profiles carried modes unsupported by the selected provider ([#3331](https://github.com/getpaseo/paseo/pull/3331))
|
||||
- Fixed Claude agents waiting on Paseo's ten-minute MCP timeout ([#3315](https://github.com/getpaseo/paseo/pull/3315))
|
||||
- Fixed Copy resume command for Hermes agents ([#3300](https://github.com/getpaseo/paseo/pull/3300) by [@desmond-rai](https://github.com/desmond-rai))
|
||||
|
||||
## 0.3.1 - 2026-08-09
|
||||
|
||||
### Added
|
||||
|
||||
- Unassign keyboard shortcuts and see the effective bindings in shortcut help ([#2510](https://github.com/getpaseo/paseo/pull/2510) by [@cleiter](https://github.com/cleiter), [#2985](https://github.com/getpaseo/paseo/pull/2985) by [@cleiter](https://github.com/cleiter))
|
||||
- Choose a Pure black theme ([#3012](https://github.com/getpaseo/paseo/pull/3012) by [@shrimpwtf](https://github.com/shrimpwtf))
|
||||
|
||||
### Improved
|
||||
|
||||
- Running agents use a clearer animated status ring ([c0daf8e](https://github.com/getpaseo/paseo/commit/c0daf8e066e0d7379723a2a6ddea20afc71ead20) by [@cleiter](https://github.com/cleiter))
|
||||
- Workspace Git updates stay responsive under file-watcher failures, heavy Git activity, and large shared-worktree repositories ([#3056](https://github.com/getpaseo/paseo/pull/3056), [#3033](https://github.com/getpaseo/paseo/pull/3033) by [@dwyanewang](https://github.com/dwyanewang), [#3078](https://github.com/getpaseo/paseo/pull/3078))
|
||||
- Kimi usage refreshes correctly and shows five-hour limits and reasoning levels ([#2743](https://github.com/getpaseo/paseo/pull/2743) by [@UnbrokenHunter](https://github.com/UnbrokenHunter))
|
||||
- Resize the desktop sidebar with touch ([#2953](https://github.com/getpaseo/paseo/pull/2953) by [@colonelpanic8](https://github.com/colonelpanic8))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Direct LAN connections work again on macOS ([#3071](https://github.com/getpaseo/paseo/pull/3071))
|
||||
- Desktop update checks no longer report an error while platform downloads are still publishing ([#3076](https://github.com/getpaseo/paseo/pull/3076))
|
||||
- Copied lists keep their markers across selection boundaries ([#3055](https://github.com/getpaseo/paseo/pull/3055))
|
||||
- Switching hosts keeps the active project selected ([#3051](https://github.com/getpaseo/paseo/pull/3051))
|
||||
- New Workspace keeps project and isolation controls inside the composer ([#3050](https://github.com/getpaseo/paseo/pull/3050))
|
||||
- Session rename is reachable from the mobile sessions menu ([#2924](https://github.com/getpaseo/paseo/pull/2924) by [@kaspesi](https://github.com/kaspesi))
|
||||
- Desktop notification sounds play again ([#2582](https://github.com/getpaseo/paseo/pull/2582) by [@gstamp](https://github.com/gstamp))
|
||||
- Codex slash commands omit disabled skills ([#2759](https://github.com/getpaseo/paseo/pull/2759) by [@jasonhnd](https://github.com/jasonhnd))
|
||||
- Exact ignored file paths resolve correctly ([#3042](https://github.com/getpaseo/paseo/pull/3042) by [@cleiter](https://github.com/cleiter))
|
||||
- Claude question notifications summarize the requested input ([#2925](https://github.com/getpaseo/paseo/pull/2925) by [@SihyeonJeon](https://github.com/SihyeonJeon))
|
||||
- The crash screen stays readable and keeps its retry action reachable ([#3043](https://github.com/getpaseo/paseo/pull/3043))
|
||||
|
||||
## 0.3.0 - 2026-08-08
|
||||
|
||||
### Added
|
||||
|
||||
@@ -40,12 +40,14 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
|
||||
| [docs/forge-providers.md](docs/forge-providers.md) | Adding a git forge: registry/manifest, drop-in checklist, self-host/GHES, the two facts tiers |
|
||||
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
|
||||
| [docs/plugins.md](docs/plugins.md) | Local plugin manifest, directory source config, RPCs, native surfaces, and attachment sources |
|
||||
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
|
||||
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
|
||||
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
|
||||
| [docs/protocol-compatibility.md](docs/protocol-compatibility.md) | Why app/daemon versions drift, protocol vs feature contract, capability gating, COMPAT tagging |
|
||||
| [docs/protocol-validation.md](docs/protocol-validation.md) | zod-aot generated inbound WebSocket validation, patched compiler regressions, schema-purity rules |
|
||||
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
|
||||
| [docs/file-observation.md](docs/file-observation.md) | Recursive watcher ownership, Linux constraints, teardown invariants, and Parcel comparison |
|
||||
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
|
||||
| [docs/qa.md](docs/qa.md) | QA evidence bar for pull requests — platform matrix, version drift, performance, UI proof |
|
||||
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
|
||||
|
||||
+3
-3
@@ -6,7 +6,7 @@ Thank you for taking the time to contribute to Paseo.
|
||||
|
||||
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).
|
||||
|
||||
Given Paseo's scope, contributing to 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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -72,8 +72,8 @@ Here is the criteria I use to decide:
|
||||
- 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 accpeted but not merged immediately.
|
||||
- You wil be attributed for your work in the changelog, even if I redesign your PR
|
||||
- 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
|
||||
|
||||
## QA evidence
|
||||
|
||||
|
||||
@@ -118,7 +118,6 @@ npx skills add getpaseo/paseo
|
||||
どのエージェントとの会話でも使用できます。
|
||||
|
||||
- `/paseo-handoff` — エージェント間で作業を引き継ぎます。私はこれを使って Claude で計画し、Codex に実装を引き継いでいます。
|
||||
- `/paseo-loop` — 明確な受け入れ基準に沿ってエージェントをループさせます(Ralph loops とも呼ばれます)。検証役を追加することもできます。
|
||||
- `/paseo-advisor` — 単一のエージェントをアドバイザーとして起動し、作業を委任せずにセカンドオピニオンを得ます。
|
||||
- `/paseo-committee` — 対照的な2つのエージェントで委員会を構成し、一歩引いた視点で根本原因を分析して計画を作成します。
|
||||
|
||||
|
||||
@@ -128,7 +128,6 @@ npx skills add getpaseo/paseo
|
||||
그런 다음 어떤 에이전트 대화에서든 아래 명령을 사용할 수 있습니다.
|
||||
|
||||
- `/paseo-handoff` — 에이전트 간에 작업을 넘깁니다. Claude로 계획을 세운 뒤 Codex에 구현을 넘길 때 이 기능을 씁니다.
|
||||
- `/paseo-loop` — 명확한 완료 조건을 기준으로 에이전트를 반복 실행합니다(Ralph loop). 검증 에이전트를 추가할 수도 있습니다.
|
||||
- `/paseo-advisor` — 작업 자체를 넘기지 않고, 에이전트 하나를 조언자로 띄워 두 번째 의견을 받습니다.
|
||||
- `/paseo-committee` — 서로 다른 관점의 에이전트 두 개로 위원회를 구성해, 한 발 물러나 근본 원인을 분석하고 계획을 세웁니다.
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ Everything you can do in the app, you can do from the terminal.
|
||||
|
||||
```bash
|
||||
paseo run --provider claude/opus-4.6 "implement user authentication"
|
||||
paseo run --provider codex/gpt-5.4 --worktree feature-x "implement feature X"
|
||||
paseo run --provider codex/gpt-5.5 --worktree feature-x "implement feature X"
|
||||
|
||||
paseo ls # list running agents
|
||||
paseo attach abc123 # stream live output
|
||||
@@ -117,6 +117,30 @@ paseo --host workstation.local:6767 run "run the full test suite"
|
||||
|
||||
See the [full CLI reference](https://paseo.sh/docs/cli) for more.
|
||||
|
||||
## TypeScript SDK
|
||||
|
||||
Build issue integrations, dashboards, and orchestration services with `@getpaseo/client`:
|
||||
|
||||
```ts
|
||||
import { createPaseoClient } from "@getpaseo/client";
|
||||
|
||||
const client = createPaseoClient({ url: "ws://127.0.0.1:6767/ws" });
|
||||
await client.connect();
|
||||
|
||||
const agent = await client.agents.create({
|
||||
config: { provider: "codex/gpt-5.5" },
|
||||
cwd: "/Users/me/dev/storefront",
|
||||
prompt: "Review the current diff and name the riskiest change.",
|
||||
});
|
||||
|
||||
const result = await agent.waitForFinish();
|
||||
console.log(result.lastMessage);
|
||||
|
||||
await client.close();
|
||||
```
|
||||
|
||||
See the [SDK quickstart](https://paseo.sh/docs/sdk/quickstart), [recipes](https://paseo.sh/docs/sdk/recipes), and [API reference](https://paseo.sh/docs/sdk/reference).
|
||||
|
||||
## Skills
|
||||
|
||||
Skills teach your agent to use Paseo to orchestrate other agents.
|
||||
@@ -128,7 +152,6 @@ npx skills add getpaseo/paseo
|
||||
Then use them in any agent conversation:
|
||||
|
||||
- `/paseo-handoff` — hand off work between agents. I use this to plan with Claude and then handoff to Codex to implement.
|
||||
- `/paseo-loop` — loop an agent against clear acceptance criteria (aka Ralph loops), optionally with a verifier.
|
||||
- `/paseo-advisor` — spin up a single agent as an advisor for a second opinion, without delegating the work itself.
|
||||
- `/paseo-committee` — form a committee of two contrasting agents to step back, do root cause analysis, and produce a plan.
|
||||
|
||||
|
||||
@@ -118,7 +118,6 @@ npx skills add getpaseo/paseo
|
||||
然后在任意 agent 对话中使用:
|
||||
|
||||
- `/paseo-handoff` — 在 agents 之间交接工作。我会用它先和 Claude 规划,再交给 Codex 实现。
|
||||
- `/paseo-loop` — 让 agent 按明确验收标准循环工作(也叫 Ralph loops),也可以加 verifier。
|
||||
- `/paseo-advisor` — 启动单个 agent 作为 advisor,提供第二意见,但不把工作委托出去。
|
||||
- `/paseo-committee` — 组建两个风格互补的 agents,让它们后退一步做根因分析并产出计划。
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ RUN set -eux; \
|
||||
npm pack --workspace=@getpaseo/relay --pack-destination /tmp/paseo-packs; \
|
||||
npm pack --workspace=@getpaseo/protocol --pack-destination /tmp/paseo-packs; \
|
||||
npm pack --workspace=@getpaseo/client --pack-destination /tmp/paseo-packs; \
|
||||
npm pack --workspace=@paseo/plugin --pack-destination /tmp/paseo-packs; \
|
||||
npm pack --workspace=@getpaseo/server --pack-destination /tmp/paseo-packs; \
|
||||
npm pack --workspace=@getpaseo/cli --pack-destination /tmp/paseo-packs
|
||||
|
||||
|
||||
+28
-12
@@ -44,11 +44,26 @@ Agents can launch other agents via the agent-scoped `create_agent` MCP tool. Age
|
||||
- **Subagents** — exist as part of the creating agent's work, appear in that agent's subagent track, and are archived with it.
|
||||
- **Detached agents** — stand on their own after an explicit detach transition, do not appear in the former parent's subagent track, and are not archived with it.
|
||||
|
||||
Parent archive detaches a subagent instead of archiving it when either condition holds:
|
||||
|
||||
- The child belongs to another workspace.
|
||||
- The child is currently open in an agent tab.
|
||||
|
||||
All other children archive with the parent. After the workspace layout hydrates, the client marks
|
||||
every managed subagent present in its tabs with `paseo.open-agent-tab.<client-id>=true` through the
|
||||
generic agent metadata update. This includes background and restored tabs; navigation does not own
|
||||
the marker. Closing a tab sets that client's label to `false`. Any `true` client label keeps the child
|
||||
open. Detach clears the parent and every open-tab label. The surviving child therefore becomes a
|
||||
normal root agent immediately, and closing its still-open tab archives it.
|
||||
|
||||
Runtime ownership is resolved from explicit workspace ID and caller context, never from `cwd`. Workspace creation is a separate operation with `local | worktree` isolation; agent creation only selects an existing workspace.
|
||||
|
||||
Users can also detach an existing subagent from the subagents track. Detach is deliberately a manual lifecycle gesture, not an agent-facing MCP tool. It removes the `paseo.parent-agent-id` label only: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive.
|
||||
Users can also detach an existing subagent from the subagents track. Detach is deliberately a manual lifecycle gesture, not an agent-facing MCP tool. It removes the parent and open-tab lifecycle labels: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive.
|
||||
|
||||
`notifyOnFinish` defaults to `true` for agent-scoped creation and background prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts.
|
||||
Permission requests are notification checkpoints, not the end of that subscription. The caller is notified again after a permission response when the child finishes, errors, or requests another permission.
|
||||
The permission notification includes the normalized request plus the child and request IDs, so the caller can inspect it and respond without fetching agent status.
|
||||
A watched child that closes before its finish event also notifies the caller so delegated work cannot disappear silently during archive or workspace teardown.
|
||||
|
||||
## Provider-managed child agents
|
||||
|
||||
@@ -71,7 +86,7 @@ Archiving runs through `AgentManager.archiveAgent` (`packages/server/src/server/
|
||||
2. Set `archivedAt` and normalize `lastStatus` away from `running`/`initializing`
|
||||
3. Notify subscribers
|
||||
4. Close the runtime (kills the process if still running)
|
||||
5. **Cascade-archive children** — any agent whose `paseo.parent-agent-id` label matches the archived agent gets archived too, recursively
|
||||
5. **Resolve children** — detach cross-workspace and open-tab children; cascade-archive the rest recursively
|
||||
|
||||
Cascade is what keeps subagent fleets from outliving their orchestrator.
|
||||
|
||||
@@ -106,7 +121,7 @@ These are two distinct concepts that used to be conflated:
|
||||
|
||||
Closing a tab on a **root agent** still archives — the tab is the agent's home, so closing it means "I'm done with this agent." A confirm dialog protects against archiving a running agent by accident.
|
||||
|
||||
Closing a tab on a **subagent** (any agent with `parentAgentId`) is **layout-only**. The agent stays unarchived and stays in its parent's track. The user can re-open the tab from the track at any time. This is implemented in `handleCloseAgentTab` (`packages/app/src/screens/workspace/workspace-screen.tsx`).
|
||||
Closing a tab on a **subagent** (any agent with `parentAgentId`) is **layout-only**. The app clears the current client's open-tab label before removing the tab. Another client's open tab remains protected. The agent stays unarchived and stays in its parent's track, so a later parent archive cascades to it when no client still has it open. The user can re-open the tab from the track at any time. Single and bulk tab close apply the same policy.
|
||||
|
||||
The asymmetry is intentional: a subagent's persistent relationship lives in the parent's track. Same-workspace subagents are not auto-opened as tabs; the user opens one from that track when needed. A cross-workspace subagent is also auto-opened as a tab in its own workspace so opening that workspace does not appear empty. It remains in the parent's track until it is actually detached.
|
||||
|
||||
@@ -150,9 +165,9 @@ Claude Code announces subagent lifecycle on the SDK stream (`task_started` / `ta
|
||||
|
||||
Archived Paseo subagents disappear from the track, by design. To remove one from the track without closing its tab, use the **archive button** on the row — it opens a confirm dialog and archives the subagent on confirm. Provider-owned rows have no individual Paseo lifecycle controls.
|
||||
|
||||
The track header's **Archive finished** action hides finished provider-owned rows in the current app session. Their native sessions and timelines are untouched, and managed Paseo subagents are not archived by this bulk action. If a hidden provider child starts running again, the app brings it back to the track.
|
||||
The track header's **Archive finished** action covers every finished row. It archives idle or errored managed Paseo subagents one at a time, and hides completed, failed, or canceled provider-owned rows in the current app session. Native sessions and timelines are untouched. Running and initializing children remain in the track. If a hidden provider child starts running again, the app brings it back to the track.
|
||||
|
||||
To keep the agent alive but remove it from the parent's track, use **detach**. The daemon clears the parent label, emits the normal agent update, and every client reclassifies the agent from subagent to root/sibling from that updated snapshot.
|
||||
To keep the agent alive but remove it from the parent's track, use **detach**. The daemon clears the relationship lifecycle labels, emits the normal agent update, and every client reclassifies the agent from subagent to root/sibling from that updated snapshot.
|
||||
|
||||
## Why this shape
|
||||
|
||||
@@ -170,7 +185,7 @@ We considered universal decoupling (no tab close ever archives, archive is alway
|
||||
|
||||
### Subagent accumulation under long-lived parents
|
||||
|
||||
A parent that spawns many subagents will see the track grow. Managed Paseo subagents can be archived individually. Finished provider-owned rows can be hidden together with **Archive finished**; this is app-local presentation state and resets when the app restarts.
|
||||
A parent that spawns many subagents will see the track grow. Managed Paseo subagents can be archived individually or with **Archive finished**. That action hides finished provider-owned rows locally; this presentation state resets when the app restarts.
|
||||
|
||||
### Cross-client tab dismissal
|
||||
|
||||
@@ -186,11 +201,12 @@ $PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json
|
||||
|
||||
Each agent is a single JSON file. Fields relevant to this doc:
|
||||
|
||||
| Field | Type | Meaning |
|
||||
| --------------------------------- | ------------- | ---------------------------------------------------------------------------------- |
|
||||
| `id` | `string` | Stable identifier |
|
||||
| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) |
|
||||
| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically for agent-scoped creation and removed by detach |
|
||||
| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` |
|
||||
| Field | Type | Meaning |
|
||||
| -------------------------------------------- | ------------- | ---------------------------------------------------------------------------------- |
|
||||
| `id` | `string` | Stable identifier |
|
||||
| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) |
|
||||
| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically for agent-scoped creation and removed by detach |
|
||||
| `labels["paseo.open-agent-tab.<client-id>"]` | `string?` | `"true"` protects an open tab on that client; detach clears every matching label |
|
||||
| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` |
|
||||
|
||||
See [`docs/data-model.md`](./data-model.md) for the full agent record.
|
||||
|
||||
+17
-14
@@ -69,6 +69,7 @@ not retain non-Git directories.
|
||||
| `server/bootstrap.ts` | Daemon initialization: HTTP server, WS server, agent manager, storage, relay |
|
||||
| `server/websocket-server.ts` | WebSocket connection management, hello handshake, binary frame routing |
|
||||
| `server/session.ts` | Per-client session state, timeline subscriptions, terminal operations |
|
||||
| `server/directory-sync/` | Daemon-global latest-state sequences for projects, workspaces, and agents |
|
||||
| `server/agent/agent-manager.ts` | Agent lifecycle state machine, timeline tracking, subscriber management |
|
||||
| `server/agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` |
|
||||
| `server/agent/tools/` | Transport-neutral catalog for workspaces, agents, permissions, and automation |
|
||||
@@ -76,8 +77,6 @@ not retain non-Git directories.
|
||||
| `server/agent/providers/` | Provider adapters (see "Agent providers" below) |
|
||||
| `server/relay-transport.ts` | Outbound relay connection with E2E encryption |
|
||||
| `server/schedule/` | Cron-based scheduled agents |
|
||||
| `server/loop-service.ts` | Looping agent runs that retry until an exit condition |
|
||||
| `server/chat/` | Chat rooms for agent-to-agent and human-to-agent messaging |
|
||||
|
||||
### `packages/protocol` — Wire schemas and shared protocol types
|
||||
|
||||
@@ -99,19 +98,27 @@ Cross-platform React Native app that connects to one or more daemons.
|
||||
|
||||
- Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.). The `workspaceId` URL segment is an opaque workspace id, not a directly meaningful filesystem path.
|
||||
- `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state
|
||||
- `runtime/replica-cache` keeps a non-authoritative per-host display replica in AsyncStorage: only the last focused agent, its workspace, and a short timeline tail. It restores before navigation becomes ready, leaves remote hydration flags false, and is atomically replaced by the normal snapshot-plus-delta synchronization path.
|
||||
- `runtime/replica-cache` keeps the complete project, workspace, and active-agent directory plus one short focused timeline tail in AsyncStorage. It restores before navigation becomes ready and leaves remote hydration flags false.
|
||||
- `runtime/directory-sync` owns directory reconciliation. On reconnect it passes the persisted per-entity cursor through `project.list`, `fetch_workspaces`, and `fetch_agents`; the daemon returns each entity's latest projection when its sequence is newer, plus tombstones.
|
||||
- `SessionContext` wraps the daemon client for the active session
|
||||
- Composer UI and submit/draft behavior live in `packages/app/src/composer/`; screens and panels should integrate it from there instead of dropping composer internals into `components/`, `hooks/`, or `screens/workspace/`
|
||||
- Timeline reducers in `timeline/session-stream-reducers.ts` handle compaction, gap detection, sequence-based deduplication
|
||||
- Timeline sync correctness is documented in [docs/timeline-sync.md](timeline-sync.md): live streams are for immediacy, `fetch_agent_timeline_request` is authoritative, and catch-up is paged but complete.
|
||||
- Voice features: dictation (STT) and voice agent (realtime)
|
||||
|
||||
The replica cache exists only to paint stale data immediately while the host connects. It does not
|
||||
own mutations, infer deletions, or replace daemon reconciliation. Pending permission requests are
|
||||
not restored from it. AsyncStorage is not encrypted, so the cached timeline tail may contain source
|
||||
code, prompts, and tool output; encrypted-at-rest storage is a separate product/security decision.
|
||||
Its serialized payload has a 1 MiB byte budget and evicts whole host snapshots in least-recently-
|
||||
written order; a single oversized host is omitted rather than partially restored.
|
||||
The replica cache paints stale data immediately while the host connects. Directory cursors are
|
||||
reconciliation checkpoints; cached entities remain non-authoritative until the daemon answers.
|
||||
Pending permission requests are not restored from it. AsyncStorage is not encrypted, so the cached
|
||||
timeline tail may contain source code, prompts, and tool output; encrypted-at-rest storage is a
|
||||
separate product/security decision. Its serialized payload has a 32 MiB byte budget and evicts whole
|
||||
host snapshots in least-recently-written order; a single oversized host is omitted rather than
|
||||
partially restored. Browser and Electron builds store it in IndexedDB. Native builds use
|
||||
AsyncStorage, and Android reserves 64 MiB for that database.
|
||||
|
||||
The three directory entity types have independent monotonic sequences and share one daemon
|
||||
generation. The daemon retains only the latest projection per entity and bounded tombstones, not an
|
||||
event log. A missing, expired, or previous-generation cursor receives a full snapshot. Projects are
|
||||
independent records; a project with no workspaces does not need a workspace placeholder.
|
||||
|
||||
### `packages/cli` — Command-line client
|
||||
|
||||
@@ -119,13 +126,11 @@ Commander.js CLI with Docker-style commands. Common agent operations are also ex
|
||||
|
||||
- `paseo agent ls/run/import/attach/logs/stop/delete/send/inspect/wait/archive/reload/update/mode`
|
||||
- `paseo daemon start/stop/restart/status/pair/set-password`
|
||||
- `paseo chat ls/create/inspect/post/read/wait/delete`
|
||||
- `paseo terminal ls/create/capture/send-keys/kill`
|
||||
- `paseo script ls/start/stop`
|
||||
- `paseo loop run/ls/inspect/logs/stop`
|
||||
- `paseo schedule create/ls/inspect/update/pause/resume/run-once/logs/delete`
|
||||
- `paseo heartbeat create/update/delete`
|
||||
- `paseo workspace create/ls/archive`
|
||||
- `paseo workspace create/ls/rename/archive`
|
||||
- `paseo permit allow/deny/ls`
|
||||
- `paseo provider ls/models`
|
||||
- hidden legacy `paseo worktree create/ls/archive` compatibility alias
|
||||
@@ -384,9 +389,7 @@ $PASEO_HOME/
|
||||
├── projects/projects.json # Project registry
|
||||
├── projects/workspaces.json # Workspace registry
|
||||
├── projects/icons/ # Custom project icon images
|
||||
├── chat/ # Chat rooms
|
||||
├── schedules/ # Scheduled-agent definitions and runs
|
||||
├── loops/ # Loop runs and logs
|
||||
├── config.json # Daemon config (mutable)
|
||||
├── daemon-keypair.json # Daemon identity for relay/E2EE
|
||||
├── push-tokens.json # Mobile push tokens
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Paseo supports configuring custom agent providers through `config.json` (located at `$PASEO_HOME/config.json`, typically `~/.paseo/config.json`). You can extend built-in providers with different API backends, add ACP-compatible agents, set custom binaries, disable providers, and create multiple profiles for the same underlying provider.
|
||||
|
||||
All provider configuration lives under `agents.providers` in config.json:
|
||||
Provider definitions live under `agents.providers` in config.json:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -17,6 +17,20 @@ All provider configuration lives under `agents.providers` in config.json:
|
||||
|
||||
Provider IDs must be lowercase alphanumeric with hyphens (`/^[a-z][a-z0-9-]*$/`).
|
||||
|
||||
Each provider catalog refresh waits up to 2 minutes. If a provider loads many plugins or a large
|
||||
agent catalog during startup, raise the limit in milliseconds:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"catalogRefreshTimeoutMs": 180000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The limit applies independently to every provider refresh and covers availability plus the entire
|
||||
catalog probe. `PASEO_PROVIDER_REFRESH_TIMEOUT_MS` sets it when the config field is absent.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
@@ -245,6 +259,8 @@ requires_openai_auth = false
|
||||
|
||||
You can create multiple entries that extend the same built-in provider. Each gets its own entry in the provider list with independent credentials, models, and environment.
|
||||
|
||||
"Profile" here means a provider alias, and it is not an **Agent profile** — that is a named bundle of provider, model, mode, thinking option and features, stored under `daemon.agentProfiles`. See [glossary.md](glossary.md) for all four senses of the word.
|
||||
|
||||
Example: two different Anthropic accounts as separate profiles:
|
||||
|
||||
```json
|
||||
|
||||
+33
-139
@@ -54,10 +54,6 @@ $PASEO_HOME/
|
||||
│ └── {agentId}.json # One file per agent
|
||||
├── schedules/
|
||||
│ └── {scheduleId}.json # One file per schedule
|
||||
├── chat/
|
||||
│ └── rooms.json # All rooms + messages
|
||||
├── loops/
|
||||
│ └── loops.json # All loop records
|
||||
├── projects/
|
||||
│ ├── projects.json # Project registry
|
||||
│ ├── workspaces.json # Workspace registry
|
||||
@@ -89,7 +85,7 @@ Each agent is stored as a separate JSON file, grouped by project directory.
|
||||
| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp |
|
||||
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
|
||||
| `title` | `string?` | User-visible title |
|
||||
| `labels` | `Record<string, string>` | Key-value labels (default `{}`). `paseo.parent-agent-id` is set automatically for agent-scoped creation and removed by detach — see [agent-lifecycle.md](./agent-lifecycle.md) |
|
||||
| `labels` | `Record<string, string>` | Key-value labels (default `{}`). Paseo uses `paseo.parent-agent-id` for parentage and client-scoped `paseo.open-agent-tab.*` labels while managed subagent tabs are open — see [agent-lifecycle.md](./agent-lifecycle.md) |
|
||||
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"`. `closed` means the record is resumable but has no live provider runtime; archive remains represented separately by `archivedAt`. |
|
||||
| `lastModeId` | `string?` | Last active mode ID |
|
||||
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
|
||||
@@ -100,7 +96,7 @@ Each agent is stored as a separate JSON file, grouped by project directory.
|
||||
| `requiresAttention` | `boolean?` | Whether the agent needs user attention |
|
||||
| `attentionReason` | `"finished" \| "error" \| "permission"?` | Why attention is needed |
|
||||
| `attentionTimestamp` | `string?` (ISO 8601) | When attention was flagged |
|
||||
| `internal` | `boolean?` | Whether this is a system-internal agent (loop workers, etc.) |
|
||||
| `internal` | `boolean?` | Whether this is a system-internal agent |
|
||||
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
|
||||
|
||||
### Nested: SerializableConfig
|
||||
@@ -179,6 +175,13 @@ Terminal activity contributes to the workspace status bucket **per `workspaceId`
|
||||
|
||||
Single file, validated with `PersistedConfigSchema`.
|
||||
|
||||
`paseo reload` reads and validates this file once inside the daemon. That snapshot drives resolution,
|
||||
classification, application, and reload bookkeeping. `DaemonConfigStore` owns applying runtime-safe
|
||||
fields and their removal/default semantics; session handlers and the CLI only relay the structured
|
||||
result. Normal config patches persist only the requested fields, so launch overrides and resolved
|
||||
defaults never leak into the file. Startup-only fields remain compared with the daemon's launch
|
||||
snapshot so a mixed edit can apply its live subset and still name the paths that require restart.
|
||||
|
||||
```
|
||||
{
|
||||
version: 1,
|
||||
@@ -189,6 +192,8 @@ Single file, validated with `PersistedConfigSchema`.
|
||||
mcp: { enabled: boolean, injectIntoAgents: boolean },
|
||||
git: { maxProcessesPerSecond: number, maxProcessConcurrency: number },
|
||||
appendSystemPrompt: string, // appended to supported provider system/developer prompts
|
||||
terminalProfiles: TerminalProfile[], // named shell commands; omitted means DEFAULT_TERMINAL_PROFILES
|
||||
agentProfiles: AgentProfile[], // named agent launch bundles; omitted means none
|
||||
cors: { allowedOrigins: string[] },
|
||||
relay: { enabled: boolean, endpoint: string, publicEndpoint: string, useTls: boolean, publicUseTls: boolean }, // new homes materialize enabled: false
|
||||
auth: { password: string } // bcrypt hash, optional
|
||||
@@ -235,6 +240,21 @@ Single file, validated with `PersistedConfigSchema`.
|
||||
|
||||
All fields are optional with sensible defaults.
|
||||
|
||||
### Profile lists
|
||||
|
||||
`terminalProfiles` and `agentProfiles` are both whole-list fields: a config patch replaces the
|
||||
array, never merges entries, so a client sends the complete next list on every add, edit, reorder
|
||||
and remove. List order is the display order.
|
||||
|
||||
Absent and empty mean different things for terminal profiles — omitting the key falls back to
|
||||
`DEFAULT_TERMINAL_PROFILES`, while `[]` means the user removed them all. Agent profiles have no
|
||||
defaults, so both mean none.
|
||||
|
||||
`PersistedConfigSchema` parses strictly, so a daemon that predates a field drops it on write
|
||||
rather than storing something it cannot describe. That is why the client gates the agent profiles
|
||||
UI on `server_info.features.agentProfiles` instead of letting a save appear to succeed against an
|
||||
older daemon.
|
||||
|
||||
### Git process limits
|
||||
|
||||
Git process limits are global to one daemon. The start-rate limit defaults to `64` processes per
|
||||
@@ -265,9 +285,9 @@ Environment variables override `config.json`:
|
||||
| `PASEO_GIT_MAX_PROCESS_CONCURRENCY` | `maxProcessConcurrency` |
|
||||
| `PASEO_GIT_CONCURRENCY` | Legacy concurrency alias |
|
||||
|
||||
`PASEO_GIT_MAX_PROCESS_CONCURRENCY` wins when it and the legacy alias are both set. Restart the
|
||||
daemon after changing the file or environment. Run `paseo daemon restart` for a standalone daemon.
|
||||
For a desktop-managed daemon, fully quit and reopen Paseo Desktop.
|
||||
`PASEO_GIT_MAX_PROCESS_CONCURRENCY` wins when it and the legacy alias are both set. Run `paseo reload`
|
||||
after changing `config.json`. Environment changes require a daemon restart; the launch environment
|
||||
remains authoritative during reload.
|
||||
|
||||
`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.
|
||||
|
||||
@@ -358,133 +378,7 @@ One file per schedule. ID is 8 hex characters.
|
||||
|
||||
---
|
||||
|
||||
## 4. Chat
|
||||
|
||||
**Path:** `$PASEO_HOME/chat/rooms.json`
|
||||
|
||||
Single file containing all rooms and messages.
|
||||
|
||||
```json
|
||||
{
|
||||
"rooms": [ ... ],
|
||||
"messages": [ ... ]
|
||||
}
|
||||
```
|
||||
|
||||
### ChatRoom
|
||||
|
||||
| Field | Type | Description |
|
||||
| ----------- | ------------------- | ----------------------------------- |
|
||||
| `id` | `string` (UUID) | |
|
||||
| `name` | `string` | Unique room name (case-insensitive) |
|
||||
| `purpose` | `string?` | Room description |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
| `updatedAt` | `string` (ISO 8601) | Updated on each new message |
|
||||
|
||||
### ChatMessage
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------------ | ------------------- | ----------------------------------- |
|
||||
| `id` | `string` (UUID) | |
|
||||
| `roomId` | `string` | FK to ChatRoom.id |
|
||||
| `authorAgentId` | `string` | Agent ID of the author |
|
||||
| `body` | `string` | Message text (supports `@mentions`) |
|
||||
| `replyToMessageId` | `string?` | FK to another ChatMessage.id |
|
||||
| `mentionAgentIds` | `string[]` | Extracted `@mention` agent IDs |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
|
||||
---
|
||||
|
||||
## 5. Loop
|
||||
|
||||
**Path:** `$PASEO_HOME/loops/loops.json`
|
||||
|
||||
Single file containing an array of all loop records. Writes are direct (not atomic) and serialized through an in-memory queue. On daemon startup any record with `status: "running"` is recovered as `"stopped"` with an interruption log entry.
|
||||
|
||||
| Field | Type | Description |
|
||||
| ----------------------- | --------------------------------------------------- | ------------------------------------------ |
|
||||
| `id` | `string` | 8-char UUID prefix |
|
||||
| `name` | `string?` | Human-readable name |
|
||||
| `prompt` | `string` | Worker prompt |
|
||||
| `cwd` | `string` | Working directory |
|
||||
| `provider` | `string` | Default provider |
|
||||
| `model` | `string?` | Default model |
|
||||
| `modeId` | `string?` | Default mode ID |
|
||||
| `workerProvider` | `string?` | Override provider for workers |
|
||||
| `workerModel` | `string?` | Override model for workers |
|
||||
| `verifierProvider` | `string?` | Override provider for verifiers |
|
||||
| `verifierModel` | `string?` | Override model for verifiers |
|
||||
| `verifierModeId` | `string?` | Override mode ID for verifiers |
|
||||
| `verifyPrompt` | `string?` | LLM verification prompt |
|
||||
| `verifyChecks` | `string[]` | Shell commands to run as checks |
|
||||
| `archive` | `boolean` | Whether to archive worker agents after use |
|
||||
| `sleepMs` | `number` | Delay between iterations (ms) |
|
||||
| `maxIterations` | `number?` | Cap on iterations |
|
||||
| `maxTimeMs` | `number?` | Total time budget (ms) |
|
||||
| `status` | `"running" \| "succeeded" \| "failed" \| "stopped"` | |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
| `updatedAt` | `string` (ISO 8601) | |
|
||||
| `startedAt` | `string` (ISO 8601) | |
|
||||
| `completedAt` | `string?` (ISO 8601) | |
|
||||
| `stopRequestedAt` | `string?` (ISO 8601) | |
|
||||
| `iterations` | `LoopIteration[]` | |
|
||||
| `logs` | `LoopLogEntry[]` | |
|
||||
| `nextLogSeq` | `number` | Monotonic log sequence counter |
|
||||
| `activeIteration` | `number?` | Currently executing iteration index |
|
||||
| `activeWorkerAgentId` | `string?` | Currently running worker agent |
|
||||
| `activeVerifierAgentId` | `string?` | Currently running verifier agent |
|
||||
|
||||
### Nested: LoopIteration
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------------- | --------------------------------------------------- | ------------------------ |
|
||||
| `index` | `number` | 1-based iteration index |
|
||||
| `workerAgentId` | `string?` | Agent ID of the worker |
|
||||
| `workerStartedAt` | `string` (ISO 8601) | |
|
||||
| `workerCompletedAt` | `string?` (ISO 8601) | |
|
||||
| `verifierAgentId` | `string?` | Agent ID of the verifier |
|
||||
| `status` | `"running" \| "succeeded" \| "failed" \| "stopped"` | |
|
||||
| `workerOutcome` | `"completed" \| "failed" \| "canceled"?` | |
|
||||
| `failureReason` | `string?` | |
|
||||
| `verifyChecks` | `LoopVerifyCheckResult[]` | Shell check results |
|
||||
| `verifyPrompt` | `LoopVerifyPromptResult?` | LLM verification result |
|
||||
|
||||
### Nested: LoopLogEntry
|
||||
|
||||
| Field | Type |
|
||||
| ----------- | ---------------------------------------------------- |
|
||||
| `seq` | `number` (monotonic) |
|
||||
| `timestamp` | `string` (ISO 8601) |
|
||||
| `iteration` | `number?` |
|
||||
| `source` | `"loop" \| "worker" \| "verifier" \| "verify-check"` |
|
||||
| `level` | `"info" \| "error"` |
|
||||
| `text` | `string` |
|
||||
|
||||
### Nested: LoopVerifyCheckResult
|
||||
|
||||
| Field | Type |
|
||||
| ------------- | ------------------- |
|
||||
| `command` | `string` |
|
||||
| `exitCode` | `number` |
|
||||
| `passed` | `boolean` |
|
||||
| `stdout` | `string` |
|
||||
| `stderr` | `string` |
|
||||
| `startedAt` | `string` (ISO 8601) |
|
||||
| `completedAt` | `string` (ISO 8601) |
|
||||
|
||||
### Nested: LoopVerifyPromptResult
|
||||
|
||||
| Field | Type |
|
||||
| ----------------- | ------------------- |
|
||||
| `passed` | `boolean` |
|
||||
| `reason` | `string` |
|
||||
| `verifierAgentId` | `string?` |
|
||||
| `startedAt` | `string` (ISO 8601) |
|
||||
| `completedAt` | `string` (ISO 8601) |
|
||||
|
||||
---
|
||||
|
||||
## 6. Project Registry
|
||||
## 4. Project Registry
|
||||
|
||||
**Path:** `$PASEO_HOME/projects/projects.json`
|
||||
|
||||
@@ -519,7 +413,7 @@ workspace together with its owning project.
|
||||
|
||||
---
|
||||
|
||||
## 7. Workspace Registry
|
||||
## 5. Workspace Registry
|
||||
|
||||
**Path:** `$PASEO_HOME/projects/workspaces.json`
|
||||
|
||||
@@ -553,7 +447,7 @@ than treating it as valid.
|
||||
|
||||
---
|
||||
|
||||
## 8. Push Token Store
|
||||
## 6. Push Token Store
|
||||
|
||||
**Path:** `$PASEO_HOME/push-tokens.json`
|
||||
|
||||
@@ -567,7 +461,7 @@ Simple set of Expo push notification tokens. Loaded with permissive parsing (fil
|
||||
|
||||
---
|
||||
|
||||
## 9. Daemon meta files
|
||||
## 7. Daemon meta files
|
||||
|
||||
These small files are not validated as full Zod schemas but are persisted under `$PASEO_HOME` for daemon identity and runtime coordination.
|
||||
|
||||
|
||||
+1
-1
@@ -223,7 +223,7 @@ Selected state on rows in a desktop list+detail uses `surfaceSidebarHover` as th
|
||||
|
||||
There is exactly one token per status signal — `statusSuccess`, `statusDanger`, `statusWarning`, `statusMerged` — and every status surface uses it: PR state icons, CI check icons and pies, diff stats, file-change icons, status pills, usage bars. A surface does not get a quieter or louder variant because of where it sits. If a dense list feels loud, that is a density or weight problem; fix the density, not the color. The tokens are generated, not hand-picked — see the rule in `packages/app/src/styles/theme.ts` and regenerate rather than nudging one value. The level is set by the densest consumer, the sidebar workspace list.
|
||||
|
||||
Status **dots** are the one exception, and they are a family of their own — `statusDotSuccess`, `statusDotDanger`, `statusDotWarning`, `statusDotRunning`, read only by `getStatusDotColor` (`packages/app/src/utils/status-dot-color.ts`). Same hues and the same generation rule, but their own band: pushed away from the surface rather than toward it (darker in light, lighter in dark) and at 90% of gamut chroma against the status family's 55–60%. A dot is a few points of solid color with no shape to read and no label attached, and the running one pulses, so at the status band's lightness the dots read dimmer than the metadata beside them — backwards, since the dot is the row's state. All four move together; regenerate the set, never one hue.
|
||||
Status **dots** are the one exception, and they are a family of their own — `statusDotSuccess`, `statusDotDanger`, `statusDotWarning`, `statusDotRunning`, read only by `getStatusDotColor` (`packages/app/src/utils/status-dot-color.ts`). Same hues and the same generation rule, but their own band: 90% of gamut chroma against the status family's 55–60%. A dot is a few points of solid color with no shape to read and no label attached, and the running one pulses, so at the status band's chroma the dots read dimmer than the metadata beside them — backwards, since the dot is the row's state. Lightness is set by hue separation rather than by distance from the surface: at 6pt four dark hues on a light surface all read as one dark blob no matter how much contrast they have. So the light band runs as bright as the contrast floor allows at L=0.62, the last step where all four clear 3:1 against the sidebar's `surface2`; the dark band sits at L=0.72, where danger turns pink above. All four move together; regenerate the set, never one hue.
|
||||
|
||||
Status pills are the status token as foreground on a 10%-alpha tint of the same token (`${token}1a`), with a 20% border (`${token}33`). The `<StatusBadge>` primitive (`packages/app/src/components/ui/status-badge.tsx`) is canonical; a pill never reaches into `palette`.
|
||||
|
||||
|
||||
+15
-9
@@ -23,6 +23,10 @@ Desktop dev launches its desktop-managed daemon with `PASEO_NODE_ENV=development
|
||||
so development-only providers such as Mock Load Test are available. Packaged
|
||||
desktop launches always force the daemon to production mode.
|
||||
|
||||
The web and desktop dev launchers pass the current Git branch to Metro as
|
||||
`EXPO_PUBLIC_PASEO_DEV_BUILD_LABEL`. The expanded desktop sidebar shows it in
|
||||
the titlebar row. Production builds leave the variable unset and show no label.
|
||||
|
||||
`npm run dev` is only a shorthand for `npm run dev:server`. Keep `127.0.0.1:6767` for the packaged app and production-style `~/.paseo` state.
|
||||
|
||||
## Nix desktop package
|
||||
@@ -127,9 +131,11 @@ desktop-only environment inherited by terminals opened inside Paseo from couplin
|
||||
a new worktree instance to the parent desktop instance's profile or single-instance
|
||||
lock.
|
||||
|
||||
Electron remains in the desktop dev runner's process group. Closing or stopping the
|
||||
workspace-script terminal must terminate Electron with Metro; detaching Electron
|
||||
leaves an orphan holding the worktree's single-instance lock and broken output pipes.
|
||||
The desktop workspace script `exec`s the dev runner so the terminal owns the runner
|
||||
PID. Terminal shutdown reaches the runner as `SIGHUP`; the runner stops Metro and
|
||||
asks Electron to quit through its normal app lifecycle. Do not add an npm wrapper or
|
||||
detach Electron: either change leaves an orphan holding the worktree's single-instance
|
||||
lock and broken output pipes.
|
||||
|
||||
With desktop dev running, verify the real BrowserWindow, titlebar clearance, fullscreen
|
||||
transition, and 751-pixel settings split with:
|
||||
@@ -260,8 +266,8 @@ the daemon-global Git process limits in `$PASEO_HOME/config.json`:
|
||||
}
|
||||
```
|
||||
|
||||
Restart the daemon with `paseo daemon restart`. If Paseo Desktop manages the daemon, fully quit and
|
||||
reopen the desktop app. Lower values reduce machine pressure but make Git-backed workspace state and
|
||||
Reload the daemon with `paseo reload`. Environment-variable overrides still require a restart because
|
||||
the launch environment remains authoritative. Lower values reduce machine pressure but make Git-backed workspace state and
|
||||
Git RPCs wait longer. See [Git process limits](data-model.md#git-process-limits) for defaults,
|
||||
semantics, and environment-variable overrides.
|
||||
|
||||
@@ -302,9 +308,9 @@ Lifecycle commands run in the worktree through a stable script shell: `bash`
|
||||
resolved from `PATH` on macOS/Linux, and PowerShell with `-NoProfile` on
|
||||
Windows. They inherit the daemon environment plus Paseo's lifecycle variables;
|
||||
login and interactive shell startup files are not loaded, and Bash's `BASH_ENV`
|
||||
hook is unset. Daemon-run loop verify checks and ACP single-string terminal
|
||||
commands use the same non-login Bash behavior on macOS/Linux, but preserve their
|
||||
existing `cmd.exe /c` string semantics on Windows. Service scripts are separate:
|
||||
hook is unset. ACP single-string terminal commands use the same non-login Bash
|
||||
behavior on macOS/Linux, but preserve their existing `cmd.exe /c` string semantics
|
||||
on Windows. Service scripts are separate:
|
||||
they launch in a terminal and receive the service environment described below.
|
||||
|
||||
Because the shell differs per platform, a lifecycle command that must run
|
||||
@@ -456,7 +462,7 @@ install.
|
||||
|
||||
Use `npm run cli` to run the in-repo CLI from source (`npx tsx packages/cli/src/index.ts`). The script wraps the CLI with `scripts/dev-home.sh`, so it automatically uses this checkout's `.dev/paseo-home` and dev daemon endpoint unless you pass an explicit override. The globally installed `paseo` binary on macOS is a symlink into the installed Paseo desktop app, not this checkout — use it to drive the desktop's built-in daemon, but use `npm run cli` when you want to talk to the CLI you are editing.
|
||||
|
||||
Canonical automation uses `paseo workspace create/ls/archive`, `paseo heartbeat create/update/delete`, and the full `paseo schedule` group. MCP heartbeat automation is intentionally smaller: create and delete only. Detach remains an explicit user lifecycle action rather than an agent tool. `paseo run --new-workspace local|worktree` composes workspace creation with agent creation. The old `paseo worktree` and `paseo run --worktree` forms are hidden compatibility aliases.
|
||||
Canonical automation uses `paseo workspace create/ls/rename/archive`, `paseo heartbeat create/update/delete`, and the full `paseo schedule` group. MCP heartbeat automation is intentionally smaller: create and delete only. Detach remains an explicit user lifecycle action rather than an agent tool. `paseo run --new-workspace local|worktree` composes workspace creation with agent creation. The old `paseo worktree` and `paseo run --worktree` forms are hidden compatibility aliases.
|
||||
|
||||
```bash
|
||||
npm run cli -- ls -a -g # List all agents globally
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# File observation
|
||||
|
||||
Use `packages/server/src/server/file-observer` for recursive filesystem observation. Create one observer service at the owning service boundary, subscribe roots through it, and close it during owner shutdown. Keep platform watchers, directory discovery, exclusion reconciliation, event batching, failure cleanup, diagnostics, and teardown inside the module. A consumer supplies a root and excluded subtrees; it must not branch on the platform or manage child watchers.
|
||||
|
||||
Do not replace the Linux implementation with `fs.watch({ recursive: true })`. Node 22 implements Linux recursion in JavaScript by walking the tree and watching every file and directory. Paseo's directory-only Linux watcher already exhausted resources before ignored-tree pruning and a watcher cap were added in [#794](https://github.com/getpaseo/paseo/pull/794).
|
||||
|
||||
Closing an observation must remain safe after its root is renamed or removed. Workspace archive removes owned worktrees while releasing observation references, so teardown cannot assume the watched path still exists.
|
||||
|
||||
`unsubscribe()` is an awaited barrier. Once it resolves, no scan, reconciliation, queued event, native handle, or callback may remain. `updateIgnore()` is the equivalent barrier for excluded paths: events queued below a newly excluded root cannot be delivered after it resolves. Treat path disappearance while scanning, attaching, or receiving a watcher error as rename/archive churn. Surface permission and resource errors so WorkspaceGit can enter bounded polling.
|
||||
|
||||
Linux topology repair is trailing-debounced and scoped to the renamed path. A file rename needs one classification stat; a new or moved directory scans only its subtree. Full-tree scans are reserved for startup and ignore-set replacement. The initial Linux scan conservatively emits existing files discovered before their directory watcher was installed; WorkspaceGit batches these into one refresh. Benchmark this startup cost with pre-populated trees.
|
||||
|
||||
Windows and macOS use one native recursive watcher plus an ignored-pruned, directory-indexed inventory. Native events deliver immediately. Named events trigger serialized shallow scans of their parent directories; known directory events also reconcile that subtree. This recovers concrete create and delete paths omitted by coalescing without walking the whole root for each edit. Startup and exclusion changes use an awaited full inventory barrier. Activity arms a full safety audit 30 seconds after it settles, with a 30-second minimum interval and a five-minute starvation bound; pathless native events also trigger an immediate shallow root scan. Observation fails into polling rather than retaining more than 250,000 file and directory entries per root.
|
||||
|
||||
Read aggregate health from the owning observer service. Runtime metrics include active observations, native handles, tracked native files, pending events and immediate reconciliation work, scoped and full reconciliation activity and latency, and failure counts. Delayed safety audits do not count as pending work. Closing the service releases every subscription and clears its diagnostics. Do not add path lists, watcher handles, or platform-specific controls to this interface.
|
||||
|
||||
Git owns Git-ignore evaluation. The observer accepts absolute excluded roots and applies updates without replacing the observation or exposing its watcher topology. This keeps tracked files inside otherwise ignored directories observable and keeps Git policy out of the filesystem module.
|
||||
|
||||
Workspace Git verifies each repository metadata subscription with a one-shot canary inside the Git directory. If the event does not round-trip through the subscription callback, treat the watcher as unavailable and enter degraded polling. Refresh working-tree Git-ignore exclusions from ignore-file events and watcher recovery, never from a healthy-watcher timer.
|
||||
|
||||
The real-filesystem contracts and daemon auto-archive lifecycle run in the normal server test suite. Use the scripts only for manual performance and soak work: `npm run measure:file-observer --workspace=@getpaseo/server` measures burst and sustained-create behavior, and `npm run repro:file-observer-teardown --workspace=@getpaseo/server` runs the teardown soak.
|
||||
+4
-2
@@ -24,14 +24,16 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.
|
||||
- **Agent session** — One running instance of an agent inside a workspace (one provider, one model, one cwd, one timeline). The conceptual unit; in the UI this opens as a tab. Moving toward this as the canonical term over "Agent". Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`).
|
||||
- **Session** — Two senses: (a) per-client connection to a daemon, internal; (b) user-facing agent session, see **Agent session**. Code: `Session` (`packages/server/src/server/session.ts`) for (a). Don't confuse with: provider-side agent session log.
|
||||
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing.
|
||||
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing. Three other things are called "profile" in this codebase, so always qualify: **Agent profile**, **Terminal profile**, and the provider aliases in [custom-providers.md](custom-providers.md). Bare "profile" is forbidden in UI copy.
|
||||
- **Agent profile** — A named launch bundle: provider, model, mode, thinking option, feature values, and free-text notes. Applying one writes those values into the **Agent controls** and is then forgotten — there is no selected profile, no default profile, and no drift tracking. The notes are written for orchestrating agents and are what the `list_profiles` MCP tool surfaces. Stored host-wide in daemon config as `agentProfiles`. UI: "Agent profiles". Code: `AgentProfile` (`packages/protocol/src/messages.ts:161`). Carries no system prompt, because a per-agent system prompt can only be set at creation. Forbidden: "Favourite", which this replaced.
|
||||
- **Terminal profile** — A named shell command a terminal can launch. Stored host-wide in daemon config as `terminalProfiles`. UI: "Terminal profiles". Code: `TerminalProfile` (`packages/protocol/src/messages.ts:135`), `resolveTerminalProfiles` (`packages/protocol/src/terminal-profiles.ts`). Also selectable as a New Workspace launch target, where the alternative is the chat agent.
|
||||
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, Oh My Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
|
||||
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
|
||||
- **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`).
|
||||
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`).
|
||||
- **Workspace script** — A named command a workspace can run, declared in `paseo.json`. Two types: `service` and `script` (a command that runs and finishes). UI: "Scripts" for the panel and the menu, both of which list either type. Code: `WorkspaceScriptPayload` (`packages/protocol/src/messages.ts:3126`). Don't confuse with **Service**, which is one of the two types rather than a synonym for the pair.
|
||||
- **Service** — A workspace script of type `service`: long-running, may bind a port, and carries `health` of `healthy | unhealthy | null` once the daemon has observed it. The only type a sidebar workspace row reports, because a running plain script says nothing about whether the workspace is up; the row names the service and turns danger when its health fails, and where several run the failing one takes the row's single slot. UI: "Services" in the sidebar's display preferences (**Show → Services**). Code: `selectWorkspaceServiceSummary` (`packages/app/src/components/sidebar/workspace-meta-row/service-summary.ts`), `WorkspaceScriptHealthSchema` (`packages/protocol/src/messages.ts:3124`). Forbidden: "Service" as a label for the Scripts panel, which lists both types.
|
||||
- **Schedule** — Cron-style trigger that creates new agents. UI: CLI/MCP (`paseo schedule`, `create_schedule`). Don't confuse with: Heartbeat (cron prompt back into the same agent) or Loop (iterative re-execution of one agent).
|
||||
- **Schedule** — Cron-style trigger that creates new agents. UI: CLI/MCP (`paseo schedule`, `create_schedule`). Don't confuse with: Heartbeat (cron prompt back into the same agent).
|
||||
- **Heartbeat** — Ephemeral cron prompt sent back into the same agent/conversation. Agent surfaces expose create, update cron, and delete only. Use for reminders and babysitting where status should return inline.
|
||||
- **Mode** — Provider-specific operational mode (plan, default, full-access, …). UI: icon-only. Code: `modeId` in `AgentSessionConfig` (`packages/protocol/src/messages.ts:257`).
|
||||
- **Attachment** — External or local context bound to an agent prompt: forge issue/change request, review context, uploaded file, text, or image. UI: "Attach issue or PR/MR". Code: `AgentAttachment` (`packages/protocol/src/messages.ts:782`).
|
||||
|
||||
+5
-5
@@ -95,11 +95,11 @@ Hub authentication rejection or close code `4403` permanently revokes the local
|
||||
daemon deletes its credential, stops reconnecting, and retains only the relationship ID, Hub origin,
|
||||
scopes, and a sanitized reason for status reporting.
|
||||
|
||||
`paseo hub disconnect` disables socket reconnect before requesting remote revocation. If the Hub is
|
||||
offline, the daemon persists `disconnecting` and retries revocation across daemon restarts without
|
||||
opening a Hub socket. This also covers an enrollment whose request may have succeeded but whose
|
||||
response was lost. `--force` removes local authority immediately and warns that remote revocation may
|
||||
still be pending.
|
||||
`paseo hub disconnect` disables socket reconnect and execution authority before making one bounded
|
||||
remote revocation request. The daemon then removes the local relationship whether the request
|
||||
succeeds or fails. A failed request returns a warning that server-side revocation may remain pending.
|
||||
`--force` skips the remote request. Legacy persisted `disconnecting` records are removed on startup;
|
||||
the daemon does not retry revocation in the background.
|
||||
|
||||
`paseo hub logout` removes only the active human CLI credential and preserves credentials for other origins. Interactive logout inspects and optionally disconnects a same-origin daemon before deleting the login; a failed requested disconnect preserves the login. JSON and noninteractive logout never prompt or disconnect implicitly.
|
||||
|
||||
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
# Local plugins
|
||||
|
||||
Local plugins contribute daemon RPCs, native app surfaces, and composer attachment sources from one
|
||||
`index.tsx`. Paseo executes the server contribution in a subprocess and evaluates the client
|
||||
contribution in the app runtime. Plugin code is trusted code; this first slice does not sandbox it.
|
||||
|
||||
## Install a directory source
|
||||
|
||||
Create a typecheckable plugin project, install its development dependencies, then install it into
|
||||
the daemon. `init` only writes the project files; it does not run the package manager.
|
||||
|
||||
```bash
|
||||
paseo plugin init /absolute/path/to/my-plugin
|
||||
cd /absolute/path/to/my-plugin
|
||||
npm install
|
||||
npm run typecheck
|
||||
paseo plugin install /absolute/path/to/my-plugin
|
||||
paseo plugin install /absolute/path/to/my-plugin --id another-runtime-id
|
||||
paseo plugin ls
|
||||
```
|
||||
|
||||
The daemon stores directory sources under the root `plugins` object:
|
||||
|
||||
```json
|
||||
{
|
||||
"pluginsEnabled": true,
|
||||
"plugins": {
|
||||
"my-plugin": {
|
||||
"source": "directory",
|
||||
"path": "/absolute/path/to/my-plugin",
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The plugin system is disabled unless `pluginsEnabled` is `true`.
|
||||
|
||||
The directory contains an identity-only manifest, one entry point, and local typechecking support:
|
||||
|
||||
```text
|
||||
my-plugin/
|
||||
paseo-plugin.json
|
||||
index.tsx
|
||||
paseo-plugin.d.ts
|
||||
package.json
|
||||
tsconfig.json
|
||||
```
|
||||
|
||||
Paseo compiles TSX when loading the plugin, so these packages are development dependencies only.
|
||||
The generated declaration file supplies `@paseo/plugin` types until the SDK is distributed as a
|
||||
public package. Regenerate new plugins with the matching Paseo CLI when the SDK contract changes.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my-plugin"
|
||||
}
|
||||
```
|
||||
|
||||
The config key is the runtime plugin ID. The manifest ID is the default selected during install;
|
||||
`--id` overrides it. Existing configuration is not renamed when the manifest changes, and the
|
||||
runtime does not compare the two IDs. The same directory can be installed under several config
|
||||
keys.
|
||||
|
||||
Source changes are explicit. Run `paseo plugin reload <id>` to stop and fully tear down the old
|
||||
plugin before compiling and starting from disk. A failed reload stays failed; Paseo does not restore
|
||||
the old code. Use `enable`, `disable`, and `remove` to manage one plugin. Remove deletes only its
|
||||
configuration, never its source directory. The global `pluginsEnabled` switch remains available.
|
||||
|
||||
## Contribute behavior and UI
|
||||
|
||||
Default export one contribution function. Paseo calls it with a plugin-scoped context. The compiler
|
||||
removes UI registrations from the server bundle and RPC registrations from the client bundle before
|
||||
resolving dependencies.
|
||||
|
||||
```tsx
|
||||
import { Text } from "react-native";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { z } from "zod";
|
||||
import { defineRpc, type PluginContext, useRpc } from "@paseo/plugin";
|
||||
|
||||
const greetRpc = defineRpc({
|
||||
name: "greet",
|
||||
input: z.object({ name: z.string() }),
|
||||
output: z.object({ message: z.string() }),
|
||||
});
|
||||
|
||||
function Greeting() {
|
||||
const greet = useRpc(greetRpc);
|
||||
const query = useQuery({
|
||||
queryKey: ["greeting", "Paseo"],
|
||||
queryFn: () => greet({ name: "Paseo" }),
|
||||
});
|
||||
return <Text>{query.data?.message}</Text>;
|
||||
}
|
||||
|
||||
export default function contribute(plugin: PluginContext) {
|
||||
plugin.handle(greetRpc, async ({ name }) => ({ message: `Hello, ${name}` }));
|
||||
plugin.addSurface("main", Greeting);
|
||||
plugin.addSidebarItem({
|
||||
id: "main",
|
||||
title: "Greeting",
|
||||
icon: "MessageCircle",
|
||||
surface: "main",
|
||||
});
|
||||
return () => undefined;
|
||||
}
|
||||
```
|
||||
|
||||
The contribution function must return cleanup. Paseo invokes it once when the plugin is reloaded,
|
||||
disabled, removed, disconnected, or shut down. Cleanup is for resources created by plugin code.
|
||||
Paseo removes registered contributions, unmounts surfaces, clears query state, rejects pending RPCs,
|
||||
and stops the subprocess. Cleanup errors are logged and do not interrupt host teardown.
|
||||
|
||||
Paseo owns the route, screen header, Lucide icon validation, close action, theme DTO, layout facts,
|
||||
and render error boundary. The contributed component owns the complete body below the header.
|
||||
|
||||
RPC contracts validate inputs and outputs in both the app and plugin subprocess. `useRpc` returns a
|
||||
typed async function. Use the host-provided `@tanstack/react-query` for request state and caching;
|
||||
Paseo gives each plugin installation its own query client.
|
||||
|
||||
When the same plugin contribution exists on multiple hosts, Paseo shows it once in the sidebar and
|
||||
adds a host picker to the screen header. The selected host supplies the bundle, RPC transport, and
|
||||
query cache. Plugin code cannot address another host.
|
||||
|
||||
## Contribute composer attachments
|
||||
|
||||
Register a declarative attachment source backed by a plugin RPC. Paseo owns the attachment menu,
|
||||
search picker, drafts, selected pill, and submission. The plugin returns complete text snapshots;
|
||||
credentials and vendor API calls stay in the daemon handler.
|
||||
|
||||
```tsx
|
||||
const searchIssues = defineRpc({
|
||||
name: "issues.search",
|
||||
input: z.object({ query: z.string() }),
|
||||
output: z.object({
|
||||
items: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
identifier: z.string(),
|
||||
title: z.string(),
|
||||
subtitle: z.string().optional(),
|
||||
url: z.string().url(),
|
||||
text: z.string(),
|
||||
resourceType: z.string(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
const issues = defineAttachmentSource({
|
||||
id: "issues",
|
||||
title: "Acme issue",
|
||||
icon: "CircleDot",
|
||||
pickerTitle: "Attach Acme issue",
|
||||
searchPlaceholder: "Search by identifier or title",
|
||||
search: searchIssues,
|
||||
});
|
||||
|
||||
export default function contribute(plugin: PluginContext) {
|
||||
plugin.handle(searchIssues, ({ query }) => searchAcmeIssues(query));
|
||||
plugin.addAttachmentSource(issues);
|
||||
return () => undefined;
|
||||
}
|
||||
```
|
||||
|
||||
Attachment sources stay scoped to the composer's host. Unlike sidebar contributions, equal sources
|
||||
on several hosts are not coalesced. The selected snapshot submits as a text attachment with neutral
|
||||
external-resource presentation, so it remains readable if the plugin is removed or an older peer
|
||||
drops the optional presentation fields.
|
||||
|
||||
See `plugin-examples/local-plugin` for a native surface and `plugin-examples/linear` for a complete
|
||||
attachment-source example.
|
||||
+1
-1
@@ -12,7 +12,7 @@ The development workflow is shifting from manually editing files to orchestratin
|
||||
|
||||
Freedom and flexibility. Every design decision follows from this:
|
||||
|
||||
- **Multi-provider** — Use any coding agent harness. Pick the right model for each job, switch freely as the landscape shifts. No vendor-lock in.
|
||||
- **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
|
||||
|
||||
@@ -85,6 +85,8 @@ Rewind accepts the canonical wire `messageId` and resolves it to the provider id
|
||||
|
||||
Submitted user-message wire items carry the same Paseo ID in `messageId` and `clientMessageId`. Provider adapters attach `clientMessageId` only to the echo for that foreground submission; provider history and externally initiated user rows do not have a Paseo client ID.
|
||||
|
||||
Provider adapters must terminalize every transient timeline row before emitting the turn's terminal event. Codex may omit the completed `contextCompaction` item when a turn ends during compaction, so its adapter closes any pending root compaction before forwarding `turn_completed`, `turn_failed`, or `turn_canceled`. A terminal turn must never leave the client showing an operation as still loading.
|
||||
|
||||
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.fetchCatalog`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. `fetchCatalog` is the single discovery API for models and modes — provider implementations may use one process, separate upstream calls, or static data internally, but callers outside the provider do not get separate runtime model/mode probes. Draft command listing and scratch-session feature listing require an explicit draft model. Do not resolve a default model through catalog discovery. A client-level `listFeatures` implementation may return features from an incomplete, model-less draft and owns which features are valid in that state.
|
||||
|
||||
Provider session import has its own contract. The picker calls `listImportableSessions` and receives rows only: provider handle, cwd, title, prompt previews, and last activity. Import calls `importSession({ providerHandleId, cwd })` for the selected row and must not call listing again. The provider returns the resumed session, storage config, persistence handle, and hydrated timeline for that one native session; `AgentManager.importProviderSession` seeds the daemon timeline and publishes the Paseo agent only after it is ready.
|
||||
@@ -103,6 +105,13 @@ Daemon bootstrap reconciles that ledger in the background, without blocking star
|
||||
|
||||
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
|
||||
multiple catalog requests must not apply this deadline separately to each request. The manager
|
||||
aborts the shared refresh signal at the deadline. Providers name active catalog operations and
|
||||
finish subprocess, server, or session cleanup before rejecting. Timeout errors list the operations
|
||||
that were still active when the deadline expired.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -19,23 +19,22 @@ The first carves shipped as **deep modules with a narrow Host seam**, not the `d
|
||||
|
||||
- **#1640 — VoiceSession** (`session/voice/voice-session.ts`, seam `VoiceSessionHost`): the STT/TTS/dictation/turn-detection subsystem. _(Originally landed at `server/voice/`; relocated under `session/` so all session subsystems share one home.)_
|
||||
- **#1644 — CheckoutSession, read side** (`session/checkout/checkout-session.ts`, seam `CheckoutSessionHost`, port `CheckoutDiffSubscriber`): status, branch validate/suggest, diff subscribe/unsubscribe, manual refresh. The workspace-git observer already delegates `emitStatusUpdate`/`scheduleDiffRefresh` to it.
|
||||
- **ScheduleSession** (`session/schedule/schedule-session.ts`, seam `ScheduleSessionHost`): scheduled-agent request handling.
|
||||
|
||||
**Next carve — CheckoutSession mutation side (Slice 4 below).** The 17 checkout _write_ handlers still inline in `session.ts` (`dispatchCheckoutMessage`, ~2010) — branch switch/rename, commit, merge, merge-from-base, pull, push, PR create/merge, github set-auto-merge/get-check-details, PR status, PR timeline, github search, stash save/pop/list — move into the existing `session/checkout/checkout-session.ts` behind `CheckoutSessionHost`. The Slice-3 observer entanglement the table fears is already resolved: #1644 moved the status/diff read side into CheckoutSession, so the workspace observer delegates today and this no longer blocks on the WorkspaceController split.
|
||||
|
||||
### Why this is safe at the dispatch seam (verified)
|
||||
|
||||
`dispatchInboundMessage` builds `a() ?? b() ?? ... ?? dispatchMiscMessage()` and short-circuits on the first non-`undefined` **Promise object** (not its resolved value). Message-type spaces are **disjoint** (no duplicate `case` labels across switches), so at most one dispatcher matches any message — collapsing to delegation cannot change which handler runs. `dispatchTerminalMessage` (2150-2153) already proves this. Two quirks preserved verbatim: schedule/\* is reached via the chat dispatcher's OWN `default` arm (2183), not the top-level `??`; and `start_workspace_script_request` (a workspace type) is special-cased before terminal delegation (2150).
|
||||
`dispatchInboundMessage` builds `a() ?? b() ?? ... ?? dispatchMiscMessage()` and short-circuits on the first non-`undefined` **Promise object** (not its resolved value). Message-type spaces are **disjoint** (no duplicate `case` labels across switches), so at most one dispatcher matches any message — collapsing to delegation cannot change which handler runs. `dispatchTerminalMessage` (2150-2153) already proves this. `start_workspace_script_request` (a workspace type) is special-cased before terminal delegation (2150).
|
||||
|
||||
Rejected alternatives: **feature-module** (free functions + wide context bag) cannot own the live state machines (workspaceUpdatesSubscription, agentUpdatesSubscription, ~25 voice fields) and adds a competing idiom; **mixin-composition** preserves the shared-`this` god object verbatim and requires widening ~325 private fields to protected.
|
||||
|
||||
## Slice ordering (least-coupled first)
|
||||
|
||||
The task recommended **git/checkout as the first slice — OVERRIDDEN.** Verification: `emitCheckoutStatusUpdate` is called from exactly ONE site (session.ts:4915), inside the workspace-owned `syncWorkspaceGitObserver` callback that ALSO fires workspace effects over shared watch-target maps. Extracting checkout first forces splitting the hardest workspace/git seam before workspace is touched. The strictly safer first cuts are **chat-schedule-loop** (only knot: `handleChatPostRequest`; touches no shared observer/git/voice state) and **provider-catalog** (one shared collaborator + injected predicates).
|
||||
The task recommended **git/checkout as the first slice — OVERRIDDEN.** Verification: `emitCheckoutStatusUpdate` is called from exactly ONE site (session.ts:4915), inside the workspace-owned `syncWorkspaceGitObserver` callback that ALSO fires workspace effects over shared watch-target maps. Extracting checkout first forces splitting the hardest workspace/git seam before workspace is touched. The safer first cut is **provider-catalog** (one shared collaborator + injected predicates).
|
||||
|
||||
| # | Slice | Effort | Risk |
|
||||
| --- | ----------------------------------------------------------------------------- | ------ | ------ |
|
||||
| 0 | Test net + disjointness tripwire (no extraction) | M | low |
|
||||
| 1 | ChatScheduleLoopController — **STOP FOR REVIEW after green** | M | low |
|
||||
| 2 | ProviderCatalogController | M | medium |
|
||||
| 3 | Split shared workspace-git observer + agent-subscribe fan-out (no controller) | M | high |
|
||||
| 4 | GitCheckoutController | L | medium |
|
||||
@@ -47,34 +46,6 @@ The task recommended **git/checkout as the first slice — OVERRIDDEN.** Verific
|
||||
|
||||
---
|
||||
|
||||
## Slice 0 — Test net + disjointness tripwire (prerequisite)
|
||||
|
||||
No production code moves. Add `session.dispatch-seam.test.ts`. This is the gate the whole plan rests on, because chat/schedule/loop have **zero** handleMessage coverage today (verified).
|
||||
|
||||
Write RED-then-GREEN against the **current in-place** Session:
|
||||
|
||||
- `chat/post` happy path (asserts `chat/post` response emitted) + fanout-limit error path (asserts the `chat/post` error envelope, NOT a bubbled `rpc_error`).
|
||||
- one `schedule/*` and one `loop/*` round-trip.
|
||||
- a handler that throws **synchronously** emits `rpc_error{code:"handler_error"}` + an `activity_log` error frame.
|
||||
- a handler that **rejects async** emits the SAME pair.
|
||||
- a table-driven assertion that the union of all controllers' owned-type `ReadonlySet`s is pairwise disjoint and covers the dispatched `SessionInboundMessage` union (grows as controllers land).
|
||||
|
||||
**Tests:** `session.dispatch-seam.test.ts`, `session.test.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Slice 1 — ChatScheduleLoopController ← STOP FOR HUMAN REVIEW after this ships green
|
||||
|
||||
**Move:** all 21 handlers (`handleChat*` ×7, `handleSchedule*` ×9, `handleLoop*` ×5), the three rpc-error emitters (`emitChatRpcError`/`emitScheduleRpcError`/`emitLoopRpcError` — **kept separate, not merged**), `toScheduleSummary` → `packages/server/src/server/chat/chat-schedule-loop-controller.ts`. Collapse `dispatchChatScheduleLoopMessage` + `dispatchScheduleMessage` to `return this.chatScheduleLoopController.dispatch(msg)`.
|
||||
|
||||
**SessionContext surface:** `emit`, `sessionLogger`, `clientId` (authorAgentId fallback), `chatService`, `scheduleService`, `loopService`, and a narrow agent-control port `{ listAgents, resolveAgentIdentifier, agentStorage.list }` for `handleChatPostRequest` mention fanout.
|
||||
|
||||
**Owned-type set MUST include all 7 `chat/*` + 5 `loop/*` + 9 `schedule/*` types** — schedule/\* is currently routed via the chat dispatcher's own `default` arm, so it must stay inside this one controller, or schedule requests silently no-op.
|
||||
|
||||
**Behavior note:** least-coupled domain. Move the three rpc-error emitters verbatim (they differ in default code + the `ChatServiceError` branch). **Tests:** `session.dispatch-seam.test.ts`, `loop-service.test.ts`, `session.test.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Slice 2 — ProviderCatalogController
|
||||
|
||||
**Move:** 7 provider handlers + `emitProviderDisabledResponse` + `getProviderSnapshotEntryForRead` → `packages/server/src/server/provider/provider-catalog-controller.ts`. Move the `providers_snapshot_update` PUSH wiring (1235-1254) into the controller's `start()`/`dispose()`. Collapse `dispatchProviderMessage`.
|
||||
|
||||
+28
-16
@@ -464,26 +464,37 @@ No prefix (`v`), no extra text. `Release Notes Sync` matches the `## X.Y.Z` (or
|
||||
## Changelog ownership
|
||||
|
||||
- **The agent running the release writes the changelog entry — beta or stable.** The release context and final wording stay with that agent.
|
||||
- **Commit history is only an index of the changes. Never draft the changelog from commit subjects or diffs alone.** For every PR in the release range, read the full PR description and every issue it links to before deciding what changed, why users care, or how changes should be grouped. Use the implementation only to verify the resulting understanding.
|
||||
- For the first beta or a direct stable release, draft from the previous stable tag to the release source. For later betas, draft from the previous beta tag to the release source. Promotion replaces the beta series with one entry drafted from the previous stable tag to the release source. Review the result against the changelog policy below, show it to the user, and wait for approval before committing it.
|
||||
|
||||
## Changelog voice
|
||||
## Changelog wording
|
||||
|
||||
The changelog is shown on the Paseo homepage. Write it for **end users**, not developers.
|
||||
The changelog is shown on the Paseo homepage. Each bullet is a compact factual record of
|
||||
product behavior that changed.
|
||||
|
||||
- **Frame everything from the user's perspective.** Describe what changed in the app, not what changed in the code. Users care that "workspaces load instantly" — not that a component no longer remounts.
|
||||
- **Never mention component names, internal modules, or implementation details.** No `WorkingIndicator`, no `accumulatedUsage`, no `reconcileAndEmitWorkspaceUpdates`. Also no "virtualized lists", no "remount", no "memoization", no "debounced", no "fuzzy ranking", no "controlled input", no "uncontrolled input" — these are implementation words masquerading as user-facing copy.
|
||||
- **Concrete WRONG → RIGHT examples** (real mistakes from past releases):
|
||||
- **Name the exact change.** Prefer `Added <capability>`, `Removed <behavior>`,
|
||||
`Changed <behavior>`, or `Fixed <failure> when <condition>`.
|
||||
- **Keep the scope exact.** A conditional bug is not a general reliability problem. Do not
|
||||
broaden one failure into claims that Paseo is now faster, smoother, responsive, or reliable.
|
||||
- **Use concrete product and runtime terms.** Git polling, persisted cache, provider catalog,
|
||||
and WebSocket reconnects can identify the affected behavior. Component names, internal
|
||||
modules, code symbols, and implementation techniques cannot: omit `WorkingIndicator`,
|
||||
`reconcileAndEmitWorkspaceUpdates`, remounts, memoization, and controlled inputs.
|
||||
- **State the consequence only when the change itself is unclear.** Keep the condition that
|
||||
makes the consequence true. Do not replace a precise change with a broad benefit claim.
|
||||
- **Do not invent context.** Mention an upgrade, platform, workload, or user action only when
|
||||
the PR or linked issue establishes that scope.
|
||||
|
||||
| Wrong (implementation-facing) | Right (user-facing) |
|
||||
| ----------------------------------------------------------------------------------- | ----------------------------------------------------------- |
|
||||
| Switching layouts no longer remounts the active agent | Splitting a pane no longer loses your scroll position |
|
||||
| Model, mode, and thinking pickers — searchable virtualized lists with fuzzy ranking | Mobile model selector is faster and more straightforward |
|
||||
| Text inputs in mobile sheets no longer flicker while typing fast | Typing in mobile sheets no longer flickers |
|
||||
| Compact web sheets no longer crash when swiped to dismiss | Sheets on mobile web no longer crash when swiped to dismiss |
|
||||
| Reduced re-renders in the agent list | Agent list scrolls smoothly |
|
||||
| Added debouncing to the search input | Search results no longer lag behind typing |
|
||||
| Avoid | Write |
|
||||
| ------------------------------------------------------------ | ----------------------------------------------------- |
|
||||
| Paseo stays responsive with many idle Git workspaces | Removed periodic Git polling for idle workspaces |
|
||||
| Incompatible saved app data no longer crashes after upgrades | Fixed crash when persisted cache was incompatible |
|
||||
| Splitting layouts no longer remounts the active agent | Fixed scroll position resetting when splitting a pane |
|
||||
| Mobile model selector is faster and more straightforward | Added search to the mobile model selector |
|
||||
|
||||
Test: would a non-developer reader recognise what changed when using the app? If they'd need an engineer to translate ("what's a remount?"), the bullet is still implementation-facing — rewrite it as the symptom the user experiences.
|
||||
Test each bullet against the source PR and issue: can a reviewer point to the exact behavior
|
||||
that changed, the failure that was fixed, or the capability that was added? If the bullet only
|
||||
claims a general improvement, rewrite it with the concrete change.
|
||||
|
||||
- **Use the entry's release scope.** Include changes within the matching range in **Changelog scope**.
|
||||
- **Collapse internal iterations within that scope.** Present a feature added and fixed in one range as working. A later beta can describe a fix to behavior delivered in an earlier beta; promotion folds the complete beta series into the final stable behavior.
|
||||
@@ -498,8 +509,7 @@ Every bullet must be scannable at a glance. The changelog is not release documen
|
||||
- **One line per bullet.** If a bullet wraps to three lines in a narrow column, it's too long.
|
||||
- **Split bullets that pack multiple distinct changes.** If a bullet uses "and", "plus", a comma list, or an em-dash to chain several independent improvements, break them into separate bullets — even when they share a theme or author. One bullet = one user-facing change.
|
||||
- **Trim qualifying clauses.** Drop "with a hint shown when…", "matching the CLI's behaviour", "across common install shapes". If the detail doesn't change whether a user cares, cut it.
|
||||
- **Lead with what the user can do, not the mechanism.** The reader cares about the capability, not how it works under the hood. Do not explain LAN vs WAN, TLS handshakes, IPC, the daemon-relay topology, or any internal concept the user has not asked about. "Self-hosted relays can use a different TLS setting for the public endpoint" — not "Self-hosted relays support a separate TLS setting for the public endpoint, so the daemon can reach the relay over the LAN while the phone reaches it over the public secure address." If a feature genuinely needs background to be understood, it belongs in product docs, with a one-line teaser in the changelog.
|
||||
- **Lead with the outcome.** "Windows: agents launch reliably from npm `.cmd` shims…" is better than "Windows: agents launch reliably across common install shapes. Claude, Codex, and OpenCode now start correctly…".
|
||||
- **Stop after identifying the change.** Do not explain LAN/WAN topology, TLS handshakes, IPC, or other architecture in a changelog bullet. Put necessary background in product docs.
|
||||
- **Attribution follows the split.** When you split a dense bullet, move each PR/author to the bullet it belongs to. Never duplicate the same PR across multiple bullets.
|
||||
|
||||
## Changelog attribution
|
||||
@@ -567,6 +577,7 @@ Each beta entry records what its testers receive. Promotion produces the single
|
||||
### Beta release
|
||||
|
||||
- [ ] The resolved release source is the intended commit (default `origin/main`) and its existing CI is green
|
||||
- [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog
|
||||
- [ ] Add a new `CHANGELOG.md` entry for this beta (heading `## X.Y.Z-beta.N - YYYY-MM-DD`), review it against the changelog policy, get approval, and commit it before cutting the release
|
||||
- [ ] The diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved
|
||||
- [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag
|
||||
@@ -587,6 +598,7 @@ Each beta entry records what its testers receive. Promotion produces the single
|
||||
- [ ] Run the pre-release sanity check (see above) and address any findings
|
||||
- [ ] The diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved
|
||||
- [ ] The resolved release source is the intended commit (default `origin/main`) and its existing CI is green
|
||||
- [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog
|
||||
- [ ] Ensure the approved release inputs are committed locally and the git worktree is clean before running any release command
|
||||
- [ ] Ensure local `npm run typecheck` passes on that exact commit before running any release command
|
||||
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). Promotion replaces every `## X.Y.Z-beta.N` entry in the series with one `## X.Y.Z - YYYY-MM-DD` entry covering the full release
|
||||
|
||||
+15
-9
@@ -85,16 +85,22 @@ recomposition while the runtime still owns the same directory snapshot and timel
|
||||
Removing the host from the registry is the destructive boundary: it stops the runtime and clears the
|
||||
session and host-scoped setup state together.
|
||||
|
||||
The durable replica cache is a display cache, not a synchronization checkpoint. Its timeline record
|
||||
contains only the focused `agentId` and a truncated item tail. It never persists a cursor, epoch,
|
||||
older-history availability, authority status, or sync generation because those facts would describe
|
||||
the complete source dataset rather than the truncated display dataset.
|
||||
The durable replica cache persists synchronization authority only when it can store the complete
|
||||
current canonical window losslessly. The stored range describes those exact items: `startSeq` drives
|
||||
older pagination and `endSeq` drives forward catch-up. Restore paints the items immediately, requests
|
||||
`after endSeq`, and requests `before startSeq` when the user loads older history.
|
||||
|
||||
Restoring that cache produces a painted timeline: the items may render immediately, but the first
|
||||
daemon timeline request is still `tail`. A successful tail response atomically establishes canonical
|
||||
items, range, and older-history availability. Live rows received between cache paint and that tail
|
||||
response stay in the separate live head, do not advance a cursor or trigger gap recovery, and are
|
||||
reconciled with the authoritative tail and subsequent catch-up.
|
||||
The first resume request is bounded. If it reports more newer history, fetch one latest bounded tail
|
||||
instead of replaying every missed page. Live gap recovery still pages forward until current.
|
||||
|
||||
If the canonical window exceeds the cache item limit, contains a discontiguous retained range, has a
|
||||
live head, or includes presentation data the cache cannot encode losslessly, persistence drops the
|
||||
range and keeps a display-only tail. Restore then uses the ordinary bounded `tail` bootstrap. Never
|
||||
slice items while retaining the pre-slice range; that falsely certifies discarded source rows.
|
||||
|
||||
Live rows received between cache paint and catch-up stay in the separate live head and reconcile with
|
||||
the authoritative range through the existing forward-page path. The cache does not persist sync
|
||||
generation or unreconciled local submissions.
|
||||
|
||||
Every daemon-derived live item carries its timeline epoch and sequence position. Bootstrap
|
||||
replacement keeps only positioned rows newer than the page it installs, while unresolved local
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-RCp5Ogd8AETLmJCZaUebcgSPRk+is26nkUY7+blDb/g=
|
||||
sha256-MgMvQC+EGJ8gGosqel8oC88pMzf2Mtetd2D8Yg5VFAM=
|
||||
|
||||
Generated
+1674
-661
File diff suppressed because it is too large
Load Diff
+8
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
@@ -23,6 +23,7 @@
|
||||
"workspaces": [
|
||||
"packages/expo-two-way-audio",
|
||||
"packages/highlight",
|
||||
"packages/plugin",
|
||||
"packages/protocol",
|
||||
"packages/client",
|
||||
"packages/server",
|
||||
@@ -46,19 +47,21 @@
|
||||
"build": "npm run build --workspaces --if-present",
|
||||
"build:highlight": "npm run build --workspace=@getpaseo/highlight",
|
||||
"build:highlight:clean": "npm run build:clean --workspace=@getpaseo/highlight",
|
||||
"build:plugin": "npm run build --workspace=@paseo/plugin",
|
||||
"build:plugin:clean": "npm run build:clean --workspace=@paseo/plugin",
|
||||
"build:relay": "npm run build --workspace=@getpaseo/relay",
|
||||
"build:relay:clean": "npm run build:clean --workspace=@getpaseo/relay",
|
||||
"build:protocol": "npm run build --workspace=@getpaseo/protocol",
|
||||
"build:protocol:clean": "npm run build:clean --workspace=@getpaseo/protocol",
|
||||
"build:client": "npm run build --workspace=@getpaseo/client",
|
||||
"build:client:clean": "npm run build:protocol:clean && npm run build:clean --workspace=@getpaseo/client",
|
||||
"build:server-deps": "concurrently --kill-others-on-fail --names highlight,relay,client --prefix-colors yellow,blue,cyan \"npm run build:highlight\" \"npm run build:relay\" \"npm run build:client\"",
|
||||
"build:server-deps:clean": "npm run build:highlight:clean && npm run build:relay:clean && npm run build:client:clean",
|
||||
"build:server-deps": "concurrently --kill-others-on-fail --names highlight,plugin,relay,client --prefix-colors yellow,green,blue,cyan \"npm run build:highlight\" \"npm run build:plugin\" \"npm run build:relay\" \"npm run build:client\"",
|
||||
"build:server-deps:clean": "npm run build:highlight:clean && npm run build:plugin:clean && npm run build:relay:clean && npm run build:client:clean",
|
||||
"build:server": "npm run build:server-deps && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli",
|
||||
"build:server:clean": "npm run build:server-deps:clean && npm run build:clean --workspace=@getpaseo/server && npm run build:clean --workspace=@getpaseo/cli",
|
||||
"build:daemon-web-ui": "node scripts/build-daemon-web-ui.mjs",
|
||||
"build:app-deps": "npm run build:highlight && npm run build:client && npm run build --workspace=@getpaseo/expo-two-way-audio",
|
||||
"build:app-deps:clean": "npm run build:highlight:clean && npm run build:client:clean && npm run build --workspace=@getpaseo/expo-two-way-audio",
|
||||
"build:app-deps": "npm run build:highlight && npm run build:plugin && npm run build:client && npm run build --workspace=@getpaseo/expo-two-way-audio",
|
||||
"build:app-deps:clean": "npm run build:highlight:clean && npm run build:plugin:clean && npm run build:client:clean && npm run build --workspace=@getpaseo/expo-two-way-audio",
|
||||
"watch:protocol": "npm run watch --workspace=@getpaseo/protocol",
|
||||
"watch:client": "tsc -p packages/client/tsconfig.json --watch --preserveWatchOutput",
|
||||
"typecheck": "npm run typecheck --workspaces --if-present",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const pkg = require("./package.json");
|
||||
const withAndroidAsyncStorageSize = require("./plugins/with-android-async-storage-size");
|
||||
const withAndroidProfileable = require("./plugins/with-android-profileable");
|
||||
const withFdroidAutolinking = require("./plugins/with-fdroid-autolinking");
|
||||
const withPasteInput = require("./plugins/with-paste-input");
|
||||
const { getNativeReleaseVersion } = require("./native-release-version");
|
||||
const appVariant = process.env.APP_VARIANT ?? "production";
|
||||
const isFdroidBuild = process.env.PASEO_FDROID_BUILD === "1";
|
||||
@@ -138,6 +140,8 @@ export default {
|
||||
},
|
||||
plugins: [
|
||||
"expo-router",
|
||||
withPasteInput,
|
||||
[withAndroidAsyncStorageSize, 64],
|
||||
...buildProfile.cameraPlugins,
|
||||
[
|
||||
"expo-splash-screen",
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
expectAddProjectPage,
|
||||
expectNewWorkspaceForAddedProject,
|
||||
openAddProjectFlow,
|
||||
openAddProjectHostSelection,
|
||||
} from "../support/helpers/add-project-flow";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
@@ -65,12 +66,14 @@ async function expectProjectHasNoWorkspaces(projectId: string): Promise<void> {
|
||||
test.describe("Add Project command-center flow", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("a single connected host opens directly on method selection", async ({ page }) => {
|
||||
test("method selection has no search field", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
|
||||
await openAddProjectFlow(page);
|
||||
|
||||
await expect(addProjectFlowMethod(page, "directory-search")).toBeVisible();
|
||||
await expect(addProjectFlowInput(page)).toHaveCount(0);
|
||||
await expect(addProjectFlow(page).getByRole("textbox")).toHaveCount(0);
|
||||
await expect(page.getByTestId("add-project-flow-page-host")).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -146,7 +149,7 @@ test.describe("Add Project command-center flow", () => {
|
||||
serverId: SECONDARY_HOST_ID,
|
||||
endpoint: `localhost:${secondaryHost.port}`,
|
||||
});
|
||||
await openAddProjectFlow(page, "host");
|
||||
await openAddProjectHostSelection(page);
|
||||
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await page.keyboard.press("Enter");
|
||||
@@ -155,7 +158,7 @@ test.describe("Add Project command-center flow", () => {
|
||||
await expect(addProjectFlow(page)).toContainText(SECONDARY_HOST_LABEL);
|
||||
});
|
||||
|
||||
test("Escape and Back restore page input and active selection before closing at the root", async ({
|
||||
test("Escape and Back restore searchable page input before closing at the root", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoAppShell(page);
|
||||
@@ -168,21 +171,20 @@ test.describe("Add Project command-center flow", () => {
|
||||
serverId: SECONDARY_HOST_ID,
|
||||
endpoint: `localhost:${secondaryHost.port}`,
|
||||
});
|
||||
await openAddProjectFlow(page, "host");
|
||||
await openAddProjectHostSelection(page);
|
||||
|
||||
await addProjectFlowInput(page).fill("o");
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await page.keyboard.press("Enter");
|
||||
await expectAddProjectPage(page, "method");
|
||||
|
||||
await addProjectFlowInput(page).fill("new");
|
||||
await page.keyboard.press("Enter");
|
||||
await chooseAddProjectMethod(page, "new-directory");
|
||||
await expectAddProjectPage(page, "new-directory-parent");
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await expectAddProjectPage(page, "method");
|
||||
await expect(addProjectFlowInput(page)).toHaveValue("new");
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(addProjectFlowInput(page)).toHaveCount(0);
|
||||
await chooseAddProjectMethod(page, "new-directory");
|
||||
await expectAddProjectPage(page, "new-directory-parent");
|
||||
await addProjectFlowBack(page).click();
|
||||
|
||||
@@ -216,7 +218,7 @@ test.describe("Add Project command-center flow", () => {
|
||||
serverId: SECONDARY_HOST_ID,
|
||||
endpoint: `localhost:${secondaryHost.port}`,
|
||||
});
|
||||
await openAddProjectFlow(page, "host");
|
||||
await openAddProjectHostSelection(page);
|
||||
await addProjectFlowHost(page, SECONDARY_HOST_ID).click();
|
||||
await expectAddProjectPage(page, "method");
|
||||
|
||||
|
||||
@@ -30,6 +30,12 @@ import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { delayBrowserAgentCreatedStatus } from "../support/helpers/new-workspace";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import { selectModel } from "../support/helpers/app";
|
||||
import { observeTimelineSubscriptions } from "../support/helpers/timeline-delivery";
|
||||
import {
|
||||
expectResumeOverflowFallsBackToOneTail,
|
||||
rememberTimelineRequestCounts,
|
||||
} from "../support/helpers/timeline-resume";
|
||||
import { workspaceDeckEntryLocator } from "../support/helpers/workspace-ui";
|
||||
import {
|
||||
scrollTimelineToNewestLoadedEdge,
|
||||
scrollTimelineUntilOlderHistoryIsReachable,
|
||||
@@ -336,6 +342,64 @@ async function expectInterruptedTurnOrderAfterReconnect(
|
||||
}
|
||||
}
|
||||
|
||||
async function expectHiddenStreamingSubmissionOrderAfterWorkspaceEviction(
|
||||
page: Page,
|
||||
testInfo: { workerIndex: number },
|
||||
): Promise<void> {
|
||||
const subscriptions = observeTimelineSubscriptions(page);
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const target = await seedMockAgentWorkspace({
|
||||
repoPrefix: `submission-hidden-stream-${testInfo.workerIndex}-`,
|
||||
title: "Hidden streaming submission",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
const evictionAgents = await Promise.all(
|
||||
Array.from({ length: 3 }, (_unused, index) =>
|
||||
seedMockAgentWorkspace({
|
||||
repoPrefix: `submission-workspace-eviction-${testInfo.workerIndex}-${index}-`,
|
||||
title: `Workspace eviction ${index + 1}`,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const prompt = "Keep this hidden image prompt before its streaming output.";
|
||||
const targetDeckEntry = workspaceDeckEntryLocator(page, getServerId(), target.workspaceId);
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, target);
|
||||
await expectComposerVisible(page);
|
||||
await subscriptions.waitForSubscribedAgents([target.agentId]);
|
||||
|
||||
const userMessageCount = gate.getAgentStreamItemCount("user_message");
|
||||
gate.setAgentStreamSuppressed(true);
|
||||
const promptRow = await submitMessageWithImage(page, prompt);
|
||||
await gate.waitForAgentStreamItem("user_message", userMessageCount + 1);
|
||||
|
||||
for (const evictionAgent of evictionAgents) {
|
||||
await openAgentRoute(page, evictionAgent);
|
||||
await expectComposerVisible(page);
|
||||
}
|
||||
await expect(targetDeckEntry).toHaveCount(0);
|
||||
await subscriptions.waitForSubscribedAgents([evictionAgents[2]!.agentId]);
|
||||
gate.setAgentStreamSuppressed(false);
|
||||
|
||||
await target.client.waitForFinish(target.agentId, 30_000);
|
||||
const requestsBeforeReturn = rememberTimelineRequestCounts(gate);
|
||||
await openAgentRoute(page, target);
|
||||
await expectComposerVisible(page);
|
||||
await subscriptions.waitForSubscribedAgents([target.agentId]);
|
||||
|
||||
const response = page.getByText("(end of synthetic stream)", { exact: true }).last();
|
||||
await expect(promptRow).toBeVisible();
|
||||
await expect(response).toBeVisible();
|
||||
await expectRenderedBefore(promptRow, response);
|
||||
expectResumeOverflowFallsBackToOneTail(gate, requestsBeforeReturn);
|
||||
} finally {
|
||||
gate.setAgentStreamSuppressed(false);
|
||||
gate.restore();
|
||||
await Promise.all([...evictionAgents.map((agent) => agent.cleanup()), target.cleanup()]);
|
||||
}
|
||||
}
|
||||
|
||||
async function expectCompletedSubmissionClearsAfterMissedRunningTransition(
|
||||
page: Page,
|
||||
testInfo: { workerIndex: number },
|
||||
@@ -1013,6 +1077,13 @@ test.describe("Agent message submission", () => {
|
||||
await expectInterruptedTurnOrderAfterReconnect(page, testInfo);
|
||||
});
|
||||
|
||||
test("keeps a streaming hidden submission before its output after workspace eviction", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
await expectHiddenStreamingSubmissionOrderAfterWorkspaceEviction(page, testInfo);
|
||||
});
|
||||
|
||||
test("clears an attachment-only submission when canonical history arrives after a missed running transition", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import path from "node:path";
|
||||
import type { FormPreferences } from "@/create-agent-preferences/preferences";
|
||||
import {
|
||||
applyProfileFromPicker,
|
||||
drillIntoProvider,
|
||||
openModelPicker,
|
||||
seedAgentProfiles,
|
||||
seedModelProvider,
|
||||
} from "../support/helpers/agent-profiles";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import {
|
||||
openGlobalNewWorkspaceComposer,
|
||||
selectNewWorkspaceProject,
|
||||
submitNewWorkspacePrompt,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
|
||||
const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences";
|
||||
const MODELESS_PROVIDER = "modeless-profile-e2e";
|
||||
const MODELESS_MODEL = "pi-profile-model";
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
interface CreateAgentRequestMessage {
|
||||
type: "create_agent_request";
|
||||
config?: {
|
||||
provider?: unknown;
|
||||
modeId?: unknown;
|
||||
featureValues?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
|
||||
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
const envelope = JSON.parse(rawMessage) as { type?: unknown; message?: unknown };
|
||||
return envelope.type === "session" && envelope.message && typeof envelope.message === "object"
|
||||
? (envelope.message as Record<string, unknown>)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function recordAndBlockCreateAgentRequest(page: Page): Promise<{
|
||||
waitForRequest(): Promise<CreateAgentRequestMessage>;
|
||||
}> {
|
||||
let resolveRequest: ((message: CreateAgentRequestMessage) => void) | null = null;
|
||||
const request = new Promise<CreateAgentRequestMessage>((resolve) => {
|
||||
resolveRequest = resolve;
|
||||
});
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
ws.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
if (sessionMessage?.type === "create_agent_request") {
|
||||
resolveRequest?.(sessionMessage as unknown as CreateAgentRequestMessage);
|
||||
return;
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
server.onMessage((message) => ws.send(message));
|
||||
});
|
||||
|
||||
return { waitForRequest: () => request };
|
||||
}
|
||||
|
||||
async function seedPoisonedModelessPreference(page: Page): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ preferencesKey, provider, model }) => {
|
||||
localStorage.setItem(
|
||||
preferencesKey,
|
||||
JSON.stringify({
|
||||
provider,
|
||||
providerPreferences: {
|
||||
[provider]: { model, mode: "full-access" },
|
||||
mock: { model: "ten-second-stream", mode: "load-test" },
|
||||
},
|
||||
} satisfies FormPreferences),
|
||||
);
|
||||
},
|
||||
{
|
||||
preferencesKey: CREATE_AGENT_PREFERENCES_KEY,
|
||||
provider: MODELESS_PROVIDER,
|
||||
model: MODELESS_MODEL,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function readProviderModePreference(page: Page, provider: string): Promise<unknown> {
|
||||
return page.evaluate(
|
||||
({ preferencesKey, providerId }) => {
|
||||
const raw = localStorage.getItem(preferencesKey);
|
||||
if (!raw) return null;
|
||||
const preferences = JSON.parse(raw) as FormPreferences;
|
||||
return preferences.providerPreferences?.[providerId]?.mode ?? null;
|
||||
},
|
||||
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY, providerId: provider },
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("Agent profiles repair modeless provider preferences", () => {
|
||||
test.describe.configure({ timeout: 240_000 });
|
||||
|
||||
test("applying a profile heals a stale mode before returning to Pi", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "profile-modeless-preferences-" });
|
||||
const profiles = await seedAgentProfiles([
|
||||
{
|
||||
id: "agent_profile_modeless_repair",
|
||||
name: "Approval work",
|
||||
provider: "mock",
|
||||
model: "one-minute-stream",
|
||||
modeId: "approval-test",
|
||||
},
|
||||
]);
|
||||
const provider = await seedModelProvider({
|
||||
id: MODELESS_PROVIDER,
|
||||
label: "Pi profile test",
|
||||
extends: "pi",
|
||||
command: [process.execPath, path.resolve("e2e/fixtures/fake-pi-rpc.mjs")],
|
||||
models: [
|
||||
{
|
||||
id: MODELESS_MODEL,
|
||||
label: "Pi profile model",
|
||||
description: "Modeless profile regression model",
|
||||
},
|
||||
],
|
||||
});
|
||||
await seedPoisonedModelessPreference(page);
|
||||
const createAgentRecorder = await recordAndBlockCreateAgentRequest(page);
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await selectNewWorkspaceProject(page, {
|
||||
projectKey: workspace.projectKey,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
});
|
||||
|
||||
await openModelPicker(page);
|
||||
await applyProfileFromPicker(page, "Approval work");
|
||||
|
||||
await expect
|
||||
.poll(() => readProviderModePreference(page, MODELESS_PROVIDER), { timeout: 10_000 })
|
||||
.toBeNull();
|
||||
await expect
|
||||
.poll(() => readProviderModePreference(page, "mock"), { timeout: 10_000 })
|
||||
.toBe("approval-test");
|
||||
|
||||
await openModelPicker(page);
|
||||
await drillIntoProvider(page, MODELESS_PROVIDER);
|
||||
await page.getByRole("button", { name: /Pi profile model/ }).click();
|
||||
|
||||
await submitNewWorkspacePrompt(page, "Create a Pi agent after repairing preferences.");
|
||||
const createAgentRequest = await createAgentRecorder.waitForRequest();
|
||||
expect(createAgentRequest).toMatchObject({
|
||||
config: { provider: MODELESS_PROVIDER },
|
||||
});
|
||||
expect(createAgentRequest.config).not.toHaveProperty("modeId");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
await provider.restore();
|
||||
await profiles.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("cross-provider profile features survive immediate workspace creation", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "profile-feature-transition-" });
|
||||
const featureProviderId = "profile-feature-e2e";
|
||||
const featureModelId = "claude-opus-5";
|
||||
const profiles = await seedAgentProfiles([
|
||||
{
|
||||
id: "agent_profile_feature_transition",
|
||||
name: "Fast profile",
|
||||
provider: featureProviderId,
|
||||
model: featureModelId,
|
||||
modeId: "bypassPermissions",
|
||||
featureValues: { fast_mode: true },
|
||||
},
|
||||
]);
|
||||
const provider = await seedModelProvider({
|
||||
id: featureProviderId,
|
||||
label: "Profile feature test",
|
||||
models: [
|
||||
{
|
||||
id: featureModelId,
|
||||
label: "Profile feature model",
|
||||
description: "Cross-provider feature regression model",
|
||||
},
|
||||
],
|
||||
});
|
||||
const createAgentRecorder = await recordAndBlockCreateAgentRequest(page);
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await selectNewWorkspaceProject(page, {
|
||||
projectKey: workspace.projectKey,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
});
|
||||
|
||||
await openModelPicker(page);
|
||||
await applyProfileFromPicker(page, "Fast profile");
|
||||
await submitNewWorkspacePrompt(page, "Create an agent with profile features.");
|
||||
|
||||
const createAgentRequest = await createAgentRecorder.waitForRequest();
|
||||
expect(createAgentRequest).toMatchObject({
|
||||
config: {
|
||||
provider: featureProviderId,
|
||||
featureValues: { fast_mode: true },
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
await provider.restore();
|
||||
await profiles.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import {
|
||||
applyProfileFromPicker,
|
||||
closeModelPicker,
|
||||
expectComposerDoesNotName,
|
||||
expectAgentProfilesEmptyPrompt,
|
||||
expectProfileEditTooltip,
|
||||
expectComposerMode,
|
||||
expectComposerModel,
|
||||
expectModelRowSelected,
|
||||
expectProfileEditIsPencilOnly,
|
||||
expectProfileVisibleForProvider,
|
||||
openModelPicker,
|
||||
openAgentProfilesFromEmptyPrompt,
|
||||
seedAgentProfiles,
|
||||
} from "../support/helpers/agent-profiles";
|
||||
import { expectWorkspaceAgentConfiguration } from "../support/helpers/command-center-agent-controls";
|
||||
import { expectComposerVisible } from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
const PROFILE = {
|
||||
id: "agent_profile_e2e_ui_work",
|
||||
name: "UI work",
|
||||
icon: "🎨",
|
||||
provider: "mock",
|
||||
model: "one-minute-stream",
|
||||
modeId: "approval-test",
|
||||
notes: "Use for UI work.",
|
||||
};
|
||||
|
||||
const PROFILE_SUMMARY = "Mock Load Test · One minute stream · Approval test";
|
||||
|
||||
test.describe("Agent profiles in the model picker", () => {
|
||||
test("an empty host still exposes agent profile settings from the picker", async ({ page }) => {
|
||||
const seed = await seedAgentProfiles([]);
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "agent-profiles-empty-",
|
||||
title: "Agent profiles empty",
|
||||
});
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, workspace);
|
||||
await expectComposerVisible(page);
|
||||
await openModelPicker(page);
|
||||
await expectAgentProfilesEmptyPrompt(page);
|
||||
await openAgentProfilesFromEmptyPrompt(page);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
await seed.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("applying a pinned profile materializes it into the composer and is then forgotten", async ({
|
||||
page,
|
||||
}) => {
|
||||
const seed = await seedAgentProfiles([PROFILE]);
|
||||
// A live agent is one provider's process, so the profile has to name that
|
||||
// same provider or the picker will not offer it at all.
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "agent-profiles-picker-",
|
||||
title: "Agent profiles picker",
|
||||
model: "ten-second-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
|
||||
try {
|
||||
await test.step("the agent starts on its seeded model and mode", async () => {
|
||||
await openAgentRoute(page, workspace);
|
||||
await expectComposerVisible(page);
|
||||
await expectComposerModel(page, "Ten second stream");
|
||||
await expectComposerMode(page, "Load test");
|
||||
});
|
||||
|
||||
await test.step("the sole provider opens directly", async () => {
|
||||
await openModelPicker(page);
|
||||
await expect(page.getByTestId("model-search-input").first()).toBeVisible();
|
||||
await expect(page.getByTestId("sheet-header-back")).toHaveCount(0);
|
||||
await expect(page.locator('[data-testid^="model-provider-"]')).toHaveCount(0);
|
||||
await expectProfileVisibleForProvider(page, {
|
||||
name: PROFILE.name,
|
||||
summary: PROFILE_SUMMARY,
|
||||
});
|
||||
await expectProfileEditIsPencilOnly(page);
|
||||
await expectProfileEditTooltip(page);
|
||||
});
|
||||
|
||||
await test.step("applying it writes its model and mode into the composer", async () => {
|
||||
await applyProfileFromPicker(page, PROFILE.name);
|
||||
await expectComposerModel(page, "One minute stream");
|
||||
await expectComposerMode(page, "Approval test");
|
||||
await expectWorkspaceAgentConfiguration(workspace, {
|
||||
id: workspace.agentId,
|
||||
provider: "mock",
|
||||
model: "one-minute-stream",
|
||||
modeId: "approval-test",
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("the composer names the model, never the profile", async () => {
|
||||
await expectComposerDoesNotName(page, PROFILE.name);
|
||||
});
|
||||
|
||||
await test.step("reopening returns directly to the provider models", async () => {
|
||||
await openModelPicker(page);
|
||||
await expect(page.getByTestId("model-search-input").first()).toBeVisible();
|
||||
await expectProfileVisibleForProvider(page, {
|
||||
name: PROFILE.name,
|
||||
summary: PROFILE_SUMMARY,
|
||||
});
|
||||
await expectModelRowSelected(page, { provider: "mock", modelId: "one-minute-stream" });
|
||||
await closeModelPicker(page);
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
await seed.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
createAgentProfile,
|
||||
createAgentProfileFromEmptyState,
|
||||
editAgentProfile,
|
||||
expectAgentProfile,
|
||||
expectAgentProfileForm,
|
||||
expectAgentProfileOrder,
|
||||
expectAgentProfileTagsGone,
|
||||
expectHostAgentProfiles,
|
||||
expectNoAgentProfiles,
|
||||
moveAgentProfileUp,
|
||||
openAgentProfileSettings,
|
||||
removeAgentProfile,
|
||||
seedAgentProfiles,
|
||||
stageLegacyFavoritesForHostMigration,
|
||||
} from "../support/helpers/agent-profiles";
|
||||
|
||||
const MOCK_PROVIDER_LABEL = "Mock Load Test";
|
||||
|
||||
test.describe("Agent profiles settings", () => {
|
||||
test("legacy model favourites migrate into provider-and-model-only host profiles", async ({
|
||||
page,
|
||||
}) => {
|
||||
const seed = await seedAgentProfiles([]);
|
||||
const migratedProfile = {
|
||||
id: "legacy_favorite:mock:one-minute-stream",
|
||||
name: "One minute stream",
|
||||
provider: "mock",
|
||||
model: "one-minute-stream",
|
||||
};
|
||||
|
||||
try {
|
||||
await stageLegacyFavoritesForHostMigration(page, [
|
||||
{ provider: "mock", modelId: "one-minute-stream" },
|
||||
]);
|
||||
|
||||
await expectHostAgentProfiles([migratedProfile]);
|
||||
await openAgentProfileSettings(page);
|
||||
await expectAgentProfile(page, {
|
||||
name: migratedProfile.name,
|
||||
tags: [MOCK_PROVIDER_LABEL, "One minute stream"],
|
||||
});
|
||||
} finally {
|
||||
await seed.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("host owner creates, edits, reorders and removes agent profiles", async ({ page }) => {
|
||||
// The journey owns the list, so it starts from an empty one whatever the
|
||||
// worker ran before, and hands it back untouched.
|
||||
const seed = await seedAgentProfiles([]);
|
||||
|
||||
try {
|
||||
await test.step("the section starts empty", async () => {
|
||||
await openAgentProfileSettings(page);
|
||||
await expectNoAgentProfiles(page);
|
||||
});
|
||||
|
||||
await test.step("create a profile from the modal", async () => {
|
||||
await createAgentProfileFromEmptyState(page, {
|
||||
name: "UI work",
|
||||
provider: MOCK_PROVIDER_LABEL,
|
||||
model: "Ten second stream",
|
||||
thinking: "High",
|
||||
mode: "Approval test",
|
||||
notes: "Use for UI work — components, layout and design tokens.",
|
||||
});
|
||||
await expectAgentProfile(page, {
|
||||
name: "UI work",
|
||||
tags: [MOCK_PROVIDER_LABEL, "Ten second stream", "Approval test", "High"],
|
||||
notes: "Use for UI work — components, layout and design tokens.",
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("editing to a model without thinking levels drops the level", async () => {
|
||||
await editAgentProfile(page, "UI work", {
|
||||
model: "One minute stream",
|
||||
notes: "Now for quick checks.",
|
||||
});
|
||||
await expectAgentProfile(page, {
|
||||
name: "UI work",
|
||||
tags: [MOCK_PROVIDER_LABEL, "One minute stream", "Approval test"],
|
||||
notes: "Now for quick checks.",
|
||||
});
|
||||
await expectAgentProfileTagsGone(page, {
|
||||
name: "UI work",
|
||||
tags: ["Ten second stream", "High"],
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("the edit form reopens on what was stored", async () => {
|
||||
await expectAgentProfileForm(page, "UI work", {
|
||||
provider: MOCK_PROVIDER_LABEL,
|
||||
model: "One minute stream",
|
||||
mode: "Approval test",
|
||||
notes: "Now for quick checks.",
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("a second profile lands below the first", async () => {
|
||||
await createAgentProfile(page, {
|
||||
name: "Deep review",
|
||||
provider: MOCK_PROVIDER_LABEL,
|
||||
model: "Five minute stream",
|
||||
});
|
||||
await expectAgentProfileOrder(page, ["UI work", "Deep review"]);
|
||||
});
|
||||
|
||||
await test.step("reorder puts the second profile on top", async () => {
|
||||
await moveAgentProfileUp(page, "Deep review");
|
||||
await expectAgentProfileOrder(page, ["Deep review", "UI work"]);
|
||||
});
|
||||
|
||||
await test.step("removing a profile confirms by name", async () => {
|
||||
await removeAgentProfile(page, "UI work");
|
||||
await expectAgentProfileOrder(page, ["Deep review"]);
|
||||
});
|
||||
|
||||
await test.step("removing the last profile returns the empty state", async () => {
|
||||
await removeAgentProfile(page, "Deep review");
|
||||
await expectNoAgentProfiles(page);
|
||||
});
|
||||
} finally {
|
||||
await seed.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -66,7 +66,7 @@ test("does not repeat an assistant block when the current app paginates a publis
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost]));
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||
},
|
||||
{ seededHost: host, preferences: buildCreateAgentPreferences(serverId) },
|
||||
{ seededHost: host, preferences: buildCreateAgentPreferences() },
|
||||
);
|
||||
|
||||
const history = await holdOlderHistoryPages(page, agent, daemon.port);
|
||||
|
||||
@@ -26,9 +26,41 @@ import {
|
||||
expectTimelineViewportAnchoredAfterPrepend,
|
||||
userNavigatesTimelineToHistoryStartWithKeyboard,
|
||||
userScrollsTimelineToHistoryStart,
|
||||
waitForPersistedCanonicalTimelineRange,
|
||||
} from "../support/helpers/timeline-pagination";
|
||||
import { trackAgentTimelineRequests } from "../support/helpers/agent-timeline-gate";
|
||||
import { seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
test.describe("Agent timeline pagination", () => {
|
||||
test("resumes a persisted canonical timeline after its exact end", async ({ page }) => {
|
||||
const prompt = "timeline persisted range resumes without tail replay";
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "timeline-persisted-range-",
|
||||
title: "Persisted timeline range",
|
||||
initialPrompt: prompt,
|
||||
});
|
||||
try {
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
await openAgentTimeline(page, agent);
|
||||
await expectTimelinePromptVisible(page, prompt);
|
||||
const range = await waitForPersistedCanonicalTimelineRange(page, agent.agentId);
|
||||
|
||||
const tracker = await trackAgentTimelineRequests(page, agent.agentId);
|
||||
await page.reload();
|
||||
await expectTimelinePromptVisible(page, prompt);
|
||||
|
||||
const request = await tracker.nextRequest();
|
||||
expect(request).toEqual({
|
||||
direction: "after",
|
||||
cursor: { epoch: range.epoch, seq: range.endSeq },
|
||||
});
|
||||
await tracker.waitForResponse();
|
||||
expect(tracker.requests().some((entry) => entry.direction === "tail")).toBe(false);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps a fixed history-start gutter before, during, and after pagination", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -13,9 +13,10 @@ import {
|
||||
import {
|
||||
commitTimelineTurnsWhileDisconnected,
|
||||
disconnectViewedTimeline,
|
||||
expectOneTailCheckWithoutCatchUpPages,
|
||||
expectOneResumeCheckWithoutTail,
|
||||
expectResumeOverflowFallsBackToOneTail,
|
||||
rememberTimelineRequestCounts,
|
||||
restoreViewedTimelineWithHeldTail,
|
||||
restoreViewedTimelineWithHeldResponse,
|
||||
} from "../support/helpers/timeline-resume";
|
||||
|
||||
test.describe("Agent timeline resume", () => {
|
||||
@@ -29,11 +30,11 @@ test.describe("Agent timeline resume", () => {
|
||||
const requests = rememberTimelineRequestCounts(gate);
|
||||
await disconnectViewedTimeline(page, gate);
|
||||
const background = await commitTimelineTurnsWhileDisconnected(agent, 24);
|
||||
await restoreViewedTimelineWithHeldTail(page, gate);
|
||||
await restoreViewedTimelineWithHeldResponse(page, gate);
|
||||
|
||||
await expectTimelinePromptVisible(page, background.newestPrompt);
|
||||
await expectTimelinePromptNotMounted(page, background.firstPrompt);
|
||||
expectOneTailCheckWithoutCatchUpPages(gate, requests);
|
||||
expectResumeOverflowFallsBackToOneTail(gate, requests);
|
||||
await scrollTimelineUntilOlderHistoryIsReachable(page, background.firstPrompt);
|
||||
} finally {
|
||||
gate.restore();
|
||||
@@ -53,10 +54,10 @@ test.describe("Agent timeline resume", () => {
|
||||
const requests = rememberTimelineRequestCounts(gate);
|
||||
|
||||
await disconnectViewedTimeline(page, gate);
|
||||
await restoreViewedTimelineWithHeldTail(page, gate);
|
||||
await restoreViewedTimelineWithHeldResponse(page, gate);
|
||||
|
||||
await expectTimelinePresentationUnchanged(page, presentation);
|
||||
expectOneTailCheckWithoutCatchUpPages(gate, requests);
|
||||
expectOneResumeCheckWithoutTail(gate, requests);
|
||||
} finally {
|
||||
gate.restore();
|
||||
await agent.cleanup();
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { openSettingsSection } from "../support/helpers/settings";
|
||||
|
||||
test("shows Pure black in the appearance picker", async ({ page }, testInfo) => {
|
||||
@@ -15,6 +18,33 @@ test("shows Pure black in the appearance picker", async ({ page }, testInfo) =>
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps the selected workspace visible in Pure black", async ({ page }, testInfo) => {
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "pure-black-selected-workspace-",
|
||||
title: "Selected workspace",
|
||||
});
|
||||
|
||||
try {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("@paseo:app-settings", JSON.stringify({ theme: "pureBlack" }));
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
|
||||
const row = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspace.workspaceId}`);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await row.click();
|
||||
|
||||
await expect(row).toHaveAttribute("aria-selected", "true");
|
||||
await expect(row).toHaveCSS("background-color", "rgb(22, 22, 22)");
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("pure-black-selected-workspace.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("applies the interface font size to settings text", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("@paseo:app-settings", JSON.stringify({ uiFontSize: 24 }));
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import { openAgentRoute } from "../support/helpers/mock-agent";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
archiveFinishedSubagents,
|
||||
expectArchiveFinishedInProgress,
|
||||
expectArchiveFinishedRetry,
|
||||
expectManagedSubagentArchived,
|
||||
expectManagedSubagentUnarchived,
|
||||
expectSubagentRowGone,
|
||||
expectSubagentRowVisible,
|
||||
holdManagedSubagentArchiveRequest,
|
||||
openSubagentsTrack,
|
||||
rejectNextManagedSubagentArchiveRequest,
|
||||
seedParentWithSubagent,
|
||||
} from "../support/helpers/subagents";
|
||||
|
||||
test.describe("Archive finished subagents", () => {
|
||||
let workspace: SeededWorkspace;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
workspace = await seedWorkspace({ repoPrefix: "archive-finished-subagents-" });
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await workspace?.cleanup();
|
||||
});
|
||||
|
||||
test("archives a finished managed child from the track", async ({ page }) => {
|
||||
const agents = await seedParentWithSubagent(workspace, {
|
||||
parentTitle: "Archive parent",
|
||||
childTitle: "Finished child",
|
||||
});
|
||||
await workspace.client.waitForAgentUpsert(
|
||||
agents.child.id,
|
||||
(snapshot) => snapshot.status === "idle",
|
||||
);
|
||||
|
||||
const archiveGate = await holdManagedSubagentArchiveRequest(page, agents.child.id);
|
||||
await openAgentRoute(page, { workspaceId: agents.workspaceId, agentId: agents.parent.id });
|
||||
await openSubagentsTrack(page);
|
||||
await expectSubagentRowVisible(page, agents.child.id);
|
||||
|
||||
await archiveFinishedSubagents(page);
|
||||
await archiveGate.waitForRequest();
|
||||
|
||||
await expectSubagentRowGone(page, agents.child.id);
|
||||
await expectArchiveFinishedInProgress(page, 0, 1);
|
||||
await expectManagedSubagentUnarchived(workspace, agents.child.id);
|
||||
archiveGate.release();
|
||||
await expectManagedSubagentArchived(workspace, agents.child.id);
|
||||
});
|
||||
|
||||
test("restores a failed child and retries the archive", async ({ page }) => {
|
||||
const agents = await seedParentWithSubagent(workspace, {
|
||||
parentTitle: "Retry parent",
|
||||
childTitle: "Retry child",
|
||||
});
|
||||
await workspace.client.waitForAgentUpsert(
|
||||
agents.child.id,
|
||||
(snapshot) => snapshot.status === "idle",
|
||||
);
|
||||
|
||||
const rejection = await rejectNextManagedSubagentArchiveRequest(page, agents.child.id);
|
||||
await openAgentRoute(page, { workspaceId: agents.workspaceId, agentId: agents.parent.id });
|
||||
await openSubagentsTrack(page);
|
||||
await expectSubagentRowVisible(page, agents.child.id);
|
||||
|
||||
await archiveFinishedSubagents(page);
|
||||
await rejection.waitForRejection();
|
||||
|
||||
await expectSubagentRowVisible(page, agents.child.id);
|
||||
await expectArchiveFinishedRetry(page, 1, 1);
|
||||
await archiveFinishedSubagents(page);
|
||||
|
||||
await expectSubagentRowGone(page, agents.child.id);
|
||||
await expectManagedSubagentArchived(workspace, agents.child.id);
|
||||
});
|
||||
});
|
||||
@@ -118,6 +118,53 @@ async function selectAssistantText(page: Page, text: string): Promise<void> {
|
||||
await selectAssistantTextRange(page, text, text);
|
||||
}
|
||||
|
||||
async function selectAssistantListItemFromMarker(
|
||||
page: Page,
|
||||
listTag: "ol" | "ul",
|
||||
itemText: string,
|
||||
endText: string,
|
||||
): Promise<void> {
|
||||
const assistantMessage = page.getByTestId("assistant-message").filter({
|
||||
hasText: "Direct matches:",
|
||||
});
|
||||
const item = assistantMessage
|
||||
.locator(`[data-paseo-markdown-tag="${listTag}"]`)
|
||||
.filter({ hasText: itemText })
|
||||
.locator(':scope > [data-paseo-markdown-tag="li"]')
|
||||
.filter({ hasText: itemText });
|
||||
|
||||
await item.evaluate((element, selectedEndText) => {
|
||||
const marker = element.querySelector(':scope > [data-paseo-markdown-list-marker="true"]');
|
||||
const markerText = marker?.firstChild;
|
||||
if (!(markerText instanceof Text)) {
|
||||
throw new Error("Expected rendered list marker text");
|
||||
}
|
||||
|
||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
||||
let endNode: Text | null = null;
|
||||
let endOffset = -1;
|
||||
while (walker.nextNode()) {
|
||||
const node = walker.currentNode;
|
||||
const offset = node.textContent?.indexOf(selectedEndText) ?? -1;
|
||||
if (node instanceof Text && offset >= 0) {
|
||||
endNode = node;
|
||||
endOffset = offset + selectedEndText.length;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!endNode) {
|
||||
throw new Error(`Could not find list item selection end: ${selectedEndText}`);
|
||||
}
|
||||
|
||||
const range = document.createRange();
|
||||
range.setStart(markerText, 0);
|
||||
range.setEnd(endNode, endOffset);
|
||||
const selection = window.getSelection();
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
}, endText);
|
||||
}
|
||||
|
||||
async function doubleClickAssistantMarkdownText(
|
||||
page: Page,
|
||||
tag: "code" | "strong",
|
||||
@@ -405,6 +452,24 @@ test("copying an assistant selection preserves Markdown structure and links", as
|
||||
'<div>- <strong><a href="https://example.com/issues/1">First issue</a></strong>',
|
||||
);
|
||||
|
||||
await selectAssistantListItemFromMarker(page, "ul", "First issue", "failure.");
|
||||
await copySelection(page);
|
||||
|
||||
const draggedBulletClipboard = await readRichClipboard(page);
|
||||
expect(draggedBulletClipboard.plainText).toBe(
|
||||
"- **[First issue](https://example.com/issues/1)**: exact `apply_patch` failure.",
|
||||
);
|
||||
expect(draggedBulletClipboard.html).toContain(
|
||||
'<div>- <strong><a href="https://example.com/issues/1">First issue</a></strong>',
|
||||
);
|
||||
|
||||
await selectAssistantListItemFromMarker(page, "ol", "Sixth item", "Sixth item");
|
||||
await copySelection(page);
|
||||
|
||||
const draggedNumberClipboard = await readRichClipboard(page);
|
||||
expect(draggedNumberClipboard.plainText).toBe("6. Sixth item");
|
||||
expect(draggedNumberClipboard.html).toContain("<div>6. Sixth item</div>");
|
||||
|
||||
await selectAssistantText(page, "docs");
|
||||
await copySelection(page);
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { unlink, writeFile } from "node:fs/promises";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFile, unlink, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { type Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute, buildSettingsSectionRoute } from "../../src/utils/host-routes";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { connectSeedClient } from "../support/helpers/seed-client";
|
||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||
@@ -15,6 +17,9 @@ interface DirtyWorkspace {
|
||||
|
||||
interface WorkspaceFixtureOptions {
|
||||
includeDeletedFile?: boolean;
|
||||
includeNestedFolders?: boolean;
|
||||
includeRenamedFile?: boolean;
|
||||
includeUntrackedFile?: boolean;
|
||||
}
|
||||
|
||||
interface CleanupTask {
|
||||
@@ -23,6 +28,39 @@ interface CleanupTask {
|
||||
|
||||
const cleanupTasks: CleanupTask[] = [];
|
||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||
|
||||
async function failNextDiscardRequest(page: Page): Promise<void> {
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (browserSocket) => {
|
||||
const serverSocket = browserSocket.connectToServer();
|
||||
browserSocket.onMessage((message) => {
|
||||
if (typeof message === "string") {
|
||||
const envelope = JSON.parse(message) as {
|
||||
message?: { type?: string; cwd?: string; requestId?: string };
|
||||
};
|
||||
if (envelope.message?.type === "checkout.discard_changes.request") {
|
||||
browserSocket.send(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "checkout.discard_changes.response",
|
||||
payload: {
|
||||
cwd: envelope.message.cwd,
|
||||
success: false,
|
||||
error: { code: "UNKNOWN", message: "Injected revert failure" },
|
||||
requestId: envelope.message.requestId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
serverSocket.send(message);
|
||||
});
|
||||
serverSocket.onMessage((message) => browserSocket.send(message));
|
||||
});
|
||||
}
|
||||
|
||||
const CHANGES_PREFERENCES_KEY = "@paseo:changes-preferences";
|
||||
|
||||
const BEFORE = `import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
@@ -209,9 +247,15 @@ test("changes file actions open below the right-click without a reserved kebab",
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await expect(page.getByTestId("diff-file-1")).toContainText("zz-deleted.ts");
|
||||
const deletedFileName = page.getByText("zz-deleted.ts", { exact: true });
|
||||
await expect(deletedFileName).toHaveCSS("user-select", "none");
|
||||
await deletedFileName.dblclick();
|
||||
expect(await page.evaluate(() => window.getSelection()?.toString() ?? "")).toBe("");
|
||||
await expect(page.getByTestId(/diff-file-\d+-actions/)).toHaveCount(0);
|
||||
await page.getByTestId("diff-file-1-toggle").click({ button: "right" });
|
||||
await expect(page.getByText("Copy path")).toBeVisible();
|
||||
await page.getByText("Copy path", { exact: true }).click({ button: "right" });
|
||||
await expect(page.getByText("Copy path")).toBeVisible();
|
||||
await expect(page.getByTestId("diff-file-1-open-file")).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
@@ -230,6 +274,183 @@ test("changes file actions open below the right-click without a reserved kebab",
|
||||
await expect(page.getByTestId("workspace-tab-file_src/use-mounted-tab-set.ts")).toBeVisible();
|
||||
});
|
||||
|
||||
test("changes context menus duplicate files and folders", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||
await page.getByTestId("diff-file-0-duplicate").click();
|
||||
await expect
|
||||
.poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set copy.ts"), "utf8"))
|
||||
.toBe(AFTER);
|
||||
|
||||
await page.getByTestId("changes-toggle-view-mode").click();
|
||||
await page.getByTestId("diff-folder-src-toggle").click({ button: "right" });
|
||||
await page.getByTestId("diff-folder-src-duplicate").click();
|
||||
await expect
|
||||
.poll(() => readFile(path.join(workspace.repoPath, "src copy/use-mounted-tab-set.ts"), "utf8"))
|
||||
.toBe(AFTER);
|
||||
});
|
||||
|
||||
test("changes context menu recursively collapses descendant folders", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff({ includeNestedFolders: true });
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await page.getByTestId("changes-toggle-view-mode").click();
|
||||
await expect(page.getByTestId("diff-folder-src/zz-folder")).toBeVisible();
|
||||
await expect(page.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible();
|
||||
|
||||
await page.getByTestId("diff-folder-src-toggle").click({ button: "right" });
|
||||
await page.getByTestId("diff-folder-src-collapse-folder").click();
|
||||
await expect(page.getByTestId("diff-folder-src/zz-folder")).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("diff-folder-src-toggle").click();
|
||||
await expect(page.getByTestId("diff-folder-src/zz-folder")).toBeVisible();
|
||||
await expect(page.getByText("root.ts", { exact: true })).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("diff-folder-src/zz-folder-toggle").click();
|
||||
await expect(page.getByText("root.ts", { exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible();
|
||||
await expect(page.getByText("changed.ts", { exact: true })).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("diff-folder-src/zz-folder/nested-toggle").click();
|
||||
await expect(page.getByText("changed.ts", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("changes context menus expose folder revert and restore a file after confirmation", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await page.getByTestId("changes-toggle-view-mode").click();
|
||||
await page.getByTestId("diff-folder-src-toggle").click({ button: "right" });
|
||||
const folderRevert = page.getByTestId("diff-folder-src-revert");
|
||||
await expect(folderRevert).toBeVisible();
|
||||
const revertLabelColor = await folderRevert
|
||||
.getByText("Discard changes", { exact: true })
|
||||
.evaluate((element) => getComputedStyle(element).color);
|
||||
await expect(folderRevert.locator("svg")).toHaveCSS("stroke", revertLabelColor);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||
const cancelledConfirmation = new Promise<string>((resolve) => {
|
||||
page.once("dialog", async (dialog) => {
|
||||
const message = dialog.message();
|
||||
await dialog.dismiss();
|
||||
resolve(message);
|
||||
});
|
||||
});
|
||||
await page.getByTestId("diff-file-0-revert").click();
|
||||
expect(await cancelledConfirmation).toContain("src/use-mounted-tab-set.ts");
|
||||
await expect(page.getByTestId("diff-file-0")).toBeVisible();
|
||||
await expect
|
||||
.poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), "utf8"))
|
||||
.toBe(AFTER);
|
||||
|
||||
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||
const confirmation = new Promise<string>((resolve) => {
|
||||
page.once("dialog", async (dialog) => {
|
||||
const message = dialog.message();
|
||||
await dialog.accept();
|
||||
resolve(message);
|
||||
});
|
||||
});
|
||||
await page.getByTestId("diff-file-0-revert").click();
|
||||
expect(await confirmation).toContain("src/use-mounted-tab-set.ts");
|
||||
|
||||
await expect(page.getByTestId("diff-file-0")).toHaveCount(0, { timeout: 30_000 });
|
||||
await expect
|
||||
.poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), "utf8"))
|
||||
.toBe(BEFORE);
|
||||
});
|
||||
|
||||
test("discarding a staged rename restores its source path", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff({ includeRenamedFile: true });
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
const renamedToggle = page
|
||||
.getByTestId(/^diff-file-\d+-toggle$/)
|
||||
.filter({ hasText: "zz-renamed.ts" });
|
||||
const toggleTestId = await renamedToggle.getAttribute("data-testid");
|
||||
expect(toggleTestId).not.toBeNull();
|
||||
const rowTestId = toggleTestId!.slice(0, -"-toggle".length);
|
||||
await renamedToggle.click({ button: "right" });
|
||||
const confirmation = new Promise<void>((resolve) => {
|
||||
page.once("dialog", async (dialog) => {
|
||||
await dialog.accept();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
await page.getByTestId(`${rowTestId}-revert`).click();
|
||||
await confirmation;
|
||||
|
||||
await expect(page.getByText("zz-renamed.ts", { exact: true })).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect
|
||||
.poll(() => readFile(path.join(workspace.repoPath, "src/rename-source.ts"), "utf8"))
|
||||
.toBe("export const renamed = true;\n");
|
||||
});
|
||||
|
||||
test("discarding an untracked file removes it from the working tree", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff({ includeUntrackedFile: true });
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
const untrackedToggle = page
|
||||
.getByTestId(/^diff-file-\d+-toggle$/)
|
||||
.filter({ hasText: "zz-untracked.txt" });
|
||||
const toggleTestId = await untrackedToggle.getAttribute("data-testid");
|
||||
expect(toggleTestId).not.toBeNull();
|
||||
const rowTestId = toggleTestId!.slice(0, -"-toggle".length);
|
||||
await untrackedToggle.click({ button: "right" });
|
||||
const confirmation = new Promise<void>((resolve) => {
|
||||
page.once("dialog", async (dialog) => {
|
||||
await dialog.accept();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
await page.getByTestId(`${rowTestId}-revert`).click();
|
||||
await confirmation;
|
||||
|
||||
await expect(page.getByText("zz-untracked.txt", { exact: true })).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(
|
||||
readFile(path.join(workspace.repoPath, "zz-untracked.txt"), "utf8"),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("shows a revert error returned by the daemon", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await failNextDiscardRequest(page);
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||
const confirmation = new Promise<void>((resolve) => {
|
||||
page.once("dialog", async (dialog) => {
|
||||
await dialog.accept();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
await page.getByTestId("diff-file-0-revert").click();
|
||||
await confirmation;
|
||||
|
||||
await expect(page.getByText("Injected revert failure", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByTestId("diff-file-0")).toBeVisible();
|
||||
await expect
|
||||
.poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), "utf8"))
|
||||
.toBe(AFTER);
|
||||
});
|
||||
|
||||
test("Changes switches between inline and full-tab navigation", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true });
|
||||
await useUnwrappedDiffLines(page);
|
||||
@@ -327,8 +548,48 @@ test("changes diff switches between flat and tree file lists", async ({ page })
|
||||
await scrollToLowerUnwrappedDiffRows(page);
|
||||
await page.getByTestId("changes-toggle-view-mode").click();
|
||||
await expect(page.getByTestId("diff-folder-src")).toBeVisible();
|
||||
await expect(page.getByTestId("diff-folder-src").getByText("src", { exact: true })).toHaveCSS(
|
||||
"user-select",
|
||||
"none",
|
||||
);
|
||||
await expect(page.getByTestId("diff-file-0")).toBeVisible();
|
||||
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||
await expect(page.getByTestId("diff-folder-src-toggle").locator("svg")).toHaveCount(1);
|
||||
await expect(page.getByTestId("diff-file-0-toggle").locator("svg")).toHaveCount(1);
|
||||
const folderToggleBounds = await page.getByTestId("diff-folder-src-toggle").boundingBox();
|
||||
const folderChevronBounds = await page
|
||||
.getByTestId("diff-folder-src-toggle")
|
||||
.locator("svg")
|
||||
.boundingBox();
|
||||
expect(folderToggleBounds).not.toBeNull();
|
||||
expect(folderChevronBounds).not.toBeNull();
|
||||
expect(folderChevronBounds!.y + folderChevronBounds!.height / 2).toBeCloseTo(
|
||||
folderToggleBounds!.y + folderToggleBounds!.height / 2,
|
||||
0,
|
||||
);
|
||||
const folderLabelBounds = await page
|
||||
.getByTestId("diff-folder-src")
|
||||
.getByText("src", { exact: true })
|
||||
.boundingBox();
|
||||
const fileLabelBounds = await page
|
||||
.getByTestId("diff-file-0")
|
||||
.getByText("use-mounted-tab-set.ts", { exact: true })
|
||||
.boundingBox();
|
||||
expect(folderLabelBounds).not.toBeNull();
|
||||
expect(fileLabelBounds).not.toBeNull();
|
||||
expect(fileLabelBounds!.x - folderLabelBounds!.x).toBeCloseTo(16, 0);
|
||||
|
||||
const folderToggle = page.getByTestId("diff-folder-src-toggle");
|
||||
await folderToggle.click();
|
||||
await expect(folderToggle).toHaveAttribute("aria-selected", "true");
|
||||
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
|
||||
await folderToggle.click();
|
||||
await expect(folderToggle).toHaveAttribute("aria-selected", "true");
|
||||
await expect(page.getByTestId("diff-file-0")).toBeVisible();
|
||||
|
||||
const fileToggle = page.getByTestId("diff-file-0-toggle");
|
||||
await fileToggle.click({ button: "right" });
|
||||
await expect(fileToggle).toHaveAttribute("aria-selected", "true");
|
||||
await expect(folderToggle).toHaveAttribute("aria-selected", "false");
|
||||
await expect(page.getByTestId("diff-file-0-context-menu")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
@@ -408,11 +669,13 @@ async function expectFlatFileList(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
async function expectDiffCodeFontSize(page: Page, fontSize: number): Promise<void> {
|
||||
const visibleCodeText = page
|
||||
.getByTestId(/^diff-code-text-/)
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return page
|
||||
.getByTestId("diff-code-text-1")
|
||||
.evaluate((text) => Number.parseFloat(getComputedStyle(text).fontSize));
|
||||
return visibleCodeText.evaluate((text) => Number.parseFloat(getComputedStyle(text).fontSize));
|
||||
})
|
||||
.toBe(fontSize);
|
||||
}
|
||||
@@ -488,6 +751,15 @@ async function createWorkspaceWithMountedTabDiff(
|
||||
if (options.includeDeletedFile) {
|
||||
files.push({ path: "src/zz-deleted.ts", content: "export const deleted = true;\n" });
|
||||
}
|
||||
if (options.includeRenamedFile) {
|
||||
files.push({ path: "src/rename-source.ts", content: "export const renamed = true;\n" });
|
||||
}
|
||||
if (options.includeNestedFolders) {
|
||||
files.push(
|
||||
{ path: "src/zz-folder/root.ts", content: "export const root = 1;\n" },
|
||||
{ path: "src/zz-folder/nested/changed.ts", content: "export const nested = 1;\n" },
|
||||
);
|
||||
}
|
||||
const repo = await createTempGitRepo("changes-pane-", { files });
|
||||
const client = await connectSeedClient();
|
||||
cleanupTasks.push({
|
||||
@@ -498,9 +770,24 @@ async function createWorkspaceWithMountedTabDiff(
|
||||
});
|
||||
|
||||
await writeFile(path.join(repo.path, "src/use-mounted-tab-set.ts"), AFTER);
|
||||
if (options.includeUntrackedFile) {
|
||||
await writeFile(path.join(repo.path, "zz-untracked.txt"), "remove me\n");
|
||||
}
|
||||
if (options.includeDeletedFile) {
|
||||
await unlink(path.join(repo.path, "src/zz-deleted.ts"));
|
||||
}
|
||||
if (options.includeRenamedFile) {
|
||||
execFileSync("git", ["mv", "src/rename-source.ts", "src/zz-renamed.ts"], {
|
||||
cwd: repo.path,
|
||||
});
|
||||
}
|
||||
if (options.includeNestedFolders) {
|
||||
await writeFile(path.join(repo.path, "src/zz-folder/root.ts"), "export const root = 2;\n");
|
||||
await writeFile(
|
||||
path.join(repo.path, "src/zz-folder/nested/changed.ts"),
|
||||
"export const nested = 2;\n",
|
||||
);
|
||||
}
|
||||
const createdWorkspace = await client.createWorkspace({
|
||||
source: { kind: "directory", path: repo.path },
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import type { FormPreferences } from "@/create-agent-preferences/preferences";
|
||||
import { closeCommandCenter, openCommandCenter } from "../support/helpers/command-center";
|
||||
import {
|
||||
applyCommandCenterAgentControls,
|
||||
@@ -12,17 +13,15 @@ import {
|
||||
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
|
||||
const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences";
|
||||
|
||||
async function seedMockDraftPreferences(page: import("@playwright/test").Page): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ preferencesKey, serverId }) => {
|
||||
({ preferencesKey }) => {
|
||||
localStorage.setItem(
|
||||
preferencesKey,
|
||||
JSON.stringify({
|
||||
serverId,
|
||||
provider: "mock",
|
||||
providerPreferences: {
|
||||
mock: {
|
||||
@@ -30,10 +29,10 @@ async function seedMockDraftPreferences(page: import("@playwright/test").Page):
|
||||
mode: "load-test",
|
||||
},
|
||||
},
|
||||
}),
|
||||
} satisfies FormPreferences),
|
||||
);
|
||||
},
|
||||
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY, serverId: getServerId() },
|
||||
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { openCommandCenter } from "../support/helpers/command-center";
|
||||
import {
|
||||
delayDirectorySuggestionResponses,
|
||||
expectOneLineTruncatedFileResult,
|
||||
expectStableCommandCenterLayout,
|
||||
failDirectorySuggestionRequests,
|
||||
startCommandCenterLayoutObservation,
|
||||
} from "../support/helpers/command-center-file-search";
|
||||
import { expectFileTabOpen } from "../support/helpers/file-explorer";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
|
||||
const FILE_PATH =
|
||||
"packages/app/src/command-center/features/workspace-file-search/layout-stability/needle-layout-stability-command-center.tsx";
|
||||
const FILE_NAME = "needle-layout-stability-command-center.tsx";
|
||||
const FILE_DIRECTORY =
|
||||
"packages/app/src/command-center/features/workspace-file-search/layout-stability";
|
||||
|
||||
function paseoSizedFiles(): Array<{ path: string; content: string }> {
|
||||
const files = Array.from({ length: 120 }, (_, index) => ({
|
||||
path: `packages/app/src/features/feature-${String(index).padStart(3, "0")}/index.ts`,
|
||||
content: `export const feature${index} = ${index};\n`,
|
||||
}));
|
||||
files.push({ path: FILE_PATH, content: "export const layoutNeedle = true;\n" });
|
||||
return files;
|
||||
}
|
||||
|
||||
async function seedMatchingWorkspaces(count: number): Promise<SeededWorkspace[]> {
|
||||
return Promise.all(
|
||||
Array.from({ length: count }, (_, index) =>
|
||||
seedWorkspace({
|
||||
repoPrefix: `command-center-file-search-error-${String(index).padStart(2, "0")}-`,
|
||||
title: `Home failure ${String(index).padStart(2, "0")}`,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
test.use({
|
||||
userAgent:
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/145.0 Safari/537.36",
|
||||
});
|
||||
|
||||
test("workspace file search stays geometrically stable through delayed loading and results", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const seeded = await seedWorkspace({
|
||||
repoPrefix: "command-center-file-search-",
|
||||
title: "Paseo-shaped file search",
|
||||
repo: { files: paseoSizedFiles() },
|
||||
});
|
||||
|
||||
try {
|
||||
await delayDirectorySuggestionResponses(page, 800);
|
||||
await gotoWorkspace(page, seeded.workspaceId);
|
||||
await page.keyboard.press("Meta+P");
|
||||
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
await expect(panel).toBeVisible({ timeout: 30_000 });
|
||||
await expect(panel.getByRole("button", { name: "Files" })).toBeVisible();
|
||||
await startCommandCenterLayoutObservation(page);
|
||||
|
||||
await panel.getByTestId("command-center-input").fill(FILE_NAME);
|
||||
const row = panel.getByRole("button", {
|
||||
name: `${FILE_NAME} ${FILE_DIRECTORY}`,
|
||||
});
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expectOneLineTruncatedFileResult(row, {
|
||||
filename: FILE_NAME,
|
||||
path: FILE_DIRECTORY,
|
||||
});
|
||||
|
||||
await panel.getByTestId("command-center-input").fill("needle-layout");
|
||||
await page.waitForTimeout(150);
|
||||
expect(await row.isVisible()).toBe(true);
|
||||
await expect(panel.getByTestId("command-center-file-search-loading")).toBeVisible();
|
||||
await expect(panel.getByText("Searching files...", { exact: true })).toHaveCount(0);
|
||||
await expect(panel.getByTestId("command-center-file-search-loading")).toBeHidden();
|
||||
await expectStableCommandCenterLayout(page);
|
||||
|
||||
await row.click();
|
||||
await expectFileTabOpen(page, FILE_PATH);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("dropping the files scope leaves the search row the same height", async ({ page }) => {
|
||||
const seeded = await seedWorkspace({
|
||||
repoPrefix: "command-center-file-search-scope-",
|
||||
title: "Scope chip height",
|
||||
});
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, seeded.workspaceId);
|
||||
await page.keyboard.press("Meta+P");
|
||||
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
await expect(panel).toBeVisible({ timeout: 30_000 });
|
||||
const header = panel.getByTestId("command-center-header");
|
||||
const scoped = await header.boundingBox();
|
||||
|
||||
await page.getByTestId("command-center-files-scope").click();
|
||||
await expect(page.getByTestId("command-center-files-scope")).toHaveCount(0);
|
||||
const unscoped = await header.boundingBox();
|
||||
|
||||
expect(scoped?.height).toBe(unscoped?.height);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("unscoped command search keeps commands visible and reports file-search failures", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const seeded = await seedMatchingWorkspaces(9);
|
||||
|
||||
try {
|
||||
await failDirectorySuggestionRequests(page);
|
||||
await gotoWorkspace(page, seeded[0].workspaceId);
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill("home");
|
||||
|
||||
await expect(panel.getByRole("button", { name: "Home", exact: true })).toBeVisible();
|
||||
await expect(panel.getByRole("button", { name: /Home failure 08/ })).toBeVisible();
|
||||
await expect(panel.getByTestId("command-center-file-search-error")).toHaveText(
|
||||
"Error: Test file search transport failure.",
|
||||
);
|
||||
await expect(panel.getByTestId("command-center-file-search-error")).toBeInViewport({
|
||||
ratio: 1,
|
||||
});
|
||||
await expect(panel.getByText("No matches", { exact: true })).toHaveCount(0);
|
||||
} finally {
|
||||
await Promise.allSettled(seeded.map((workspace) => workspace.cleanup()));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { openCommandCenter } from "../support/helpers/command-center";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
|
||||
const GROUP_BY_STATUS = "Group by status";
|
||||
const GROUP_BY_PROJECT = "Group by project";
|
||||
|
||||
// Result rows carry no testID of their own, so the entries are addressed by their visible label.
|
||||
async function runGroupingEntry(page: Page, label: string, absent: string): Promise<void> {
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill("group");
|
||||
|
||||
const entry = panel.getByText(label, { exact: true });
|
||||
await expect(entry).toBeVisible({ timeout: 30_000 });
|
||||
// The entry always names the mode you are not in, so its opposite must never be listed.
|
||||
await expect(panel.getByText(absent, { exact: true })).toHaveCount(0);
|
||||
|
||||
await entry.click();
|
||||
await expect(page.getByTestId("command-center-panel")).not.toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
test.describe("Command center sidebar grouping", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("flips sidebar grouping and persists the choice across a reload", async ({ page }) => {
|
||||
const seeded = await seedWorkspace({ repoPrefix: "command-center-grouping-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
const projectList = page.getByTestId("sidebar-project-workspace-list-scroll");
|
||||
const statusList = page.getByTestId("sidebar-status-list-scroll");
|
||||
await expect(projectList).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Query-only: the grouping entry stays out of the default empty-query list.
|
||||
const panel = await openCommandCenter(page);
|
||||
await expect(panel.getByText(GROUP_BY_STATUS, { exact: true })).toHaveCount(0);
|
||||
await expect(panel.getByText(GROUP_BY_PROJECT, { exact: true })).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(panel).not.toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await runGroupingEntry(page, GROUP_BY_STATUS, GROUP_BY_PROJECT);
|
||||
await expect(statusList).toBeVisible({ timeout: 30_000 });
|
||||
await expect(projectList).toHaveCount(0);
|
||||
|
||||
// The only assertion that catches a flip which never reached persisted storage.
|
||||
await page.reload();
|
||||
await expect(statusList).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Grouping persists, so the run must leave the sidebar back in project mode.
|
||||
await runGroupingEntry(page, GROUP_BY_PROJECT, GROUP_BY_STATUS);
|
||||
await expect(projectList).toBeVisible({ timeout: 30_000 });
|
||||
await expect(statusList).toHaveCount(0);
|
||||
} finally {
|
||||
await seeded.cleanup().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { hasGithubAuth, createTempGithubRepo } from "../support/helpers/github-fixtures";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { openFileExplorer } from "../support/helpers/file-explorer";
|
||||
|
||||
const MINIMAL_PNG = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
|
||||
@@ -294,6 +295,28 @@ test.describe("Composer attachments", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("Escape cancels file creation without interrupting the running agent", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await startRunningMockAgent(page, {
|
||||
prefix: "file-create-escape-",
|
||||
model: "one-minute-stream",
|
||||
prompt: "Stay running while a file draft is cancelled.",
|
||||
});
|
||||
try {
|
||||
await openFileExplorer(page);
|
||||
await page.getByTestId("files-new-file").click();
|
||||
const nameInput = page.getByTestId("file-explorer-name-input");
|
||||
await expect(nameInput).toBeVisible();
|
||||
|
||||
await nameInput.press("Escape");
|
||||
|
||||
await expect(nameInput).toBeHidden();
|
||||
await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible();
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("composer is locked while new workspace agent is being created", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = getServerId();
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { CDPSession } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { connectSeedClient } from "../support/helpers/seed-client";
|
||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
const RUN_DIFF_PERF = process.env.PASEO_DIFF_PERF_E2E === "1";
|
||||
const diffPerfDescribe = RUN_DIFF_PERF ? test.describe : test.describe.skip;
|
||||
const LINE_COUNT = 2_400;
|
||||
const CPU_SLOWDOWN = Number(process.env.PASEO_DIFF_PERF_CPU_SLOWDOWN ?? 6);
|
||||
const MAX_MOUNTED_CODE_ROWS = 220;
|
||||
const MAX_EXPANSION_MS = 6_000;
|
||||
const CHANGES_PREFERENCES_KEY = "@paseo:changes-preferences";
|
||||
|
||||
diffPerfDescribe("Diff rendering performance", () => {
|
||||
test("large unwrapped diffs keep mounting bounded under CPU slowdown", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(90_000);
|
||||
const repo = await createTempGitRepo("diff-perf-", {
|
||||
files: [{ path: "src/large.ts", content: "export const baseline = 0;\n" }],
|
||||
});
|
||||
const client = await connectSeedClient();
|
||||
let cdp: CDPSession | null = null;
|
||||
|
||||
try {
|
||||
const generatedLines = Array.from(
|
||||
{ length: LINE_COUNT },
|
||||
(_, index) => `export const generated_${index} = "${index}-${"wide".repeat(16)}";`,
|
||||
).join("\n");
|
||||
await writeFile(
|
||||
path.join(repo.path, "src/large.ts"),
|
||||
`export const baseline = 1;\n${generatedLines}\n`,
|
||||
);
|
||||
const created = await client.createWorkspace({
|
||||
source: { kind: "directory", path: repo.path },
|
||||
});
|
||||
if (!created.workspace) {
|
||||
throw new Error(created.error ?? "Failed to create diff performance workspace");
|
||||
}
|
||||
|
||||
await page.addInitScript(
|
||||
({ preferencesKey }) => {
|
||||
localStorage.setItem(
|
||||
preferencesKey,
|
||||
JSON.stringify({
|
||||
layout: "unified",
|
||||
viewMode: "flat",
|
||||
wrapLines: false,
|
||||
hideWhitespace: false,
|
||||
}),
|
||||
);
|
||||
},
|
||||
{ preferencesKey: CHANGES_PREFERENCES_KEY },
|
||||
);
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), created.workspace.id));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await page.getByRole("button", { name: "Open explorer" }).click();
|
||||
const fileHeader = page.getByTestId("diff-file-0");
|
||||
await expect(fileHeader).toContainText("large.ts", { timeout: 30_000 });
|
||||
|
||||
cdp = await page.context().newCDPSession(page);
|
||||
await cdp.send("Emulation.setCPUThrottlingRate", { rate: CPU_SLOWDOWN });
|
||||
const expansionStart = await page.evaluate(() => performance.now());
|
||||
await fileHeader.click();
|
||||
await expect(page.getByTestId("diff-file-0-body")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("diff-code-row-0")).toBeVisible({ timeout: 30_000 });
|
||||
const expansionMs = (await page.evaluate(() => performance.now())) - expansionStart;
|
||||
|
||||
const scroll = page.getByTestId("git-diff-scroll");
|
||||
const initial = await readDiffMountReport(page);
|
||||
expect(initial.mountedCodeRows).toBeLessThanOrEqual(MAX_MOUNTED_CODE_ROWS);
|
||||
expect(initial.mountedGutterRows).toBe(initial.mountedCodeRows);
|
||||
|
||||
await page.getByTestId("diff-gutter-row-1").hover();
|
||||
await page.getByTestId("diff-gutter-action-1").click();
|
||||
await expect(page.getByTestId("inline-review-editor")).toBeVisible();
|
||||
const withEditor = await readDiffMountReport(page);
|
||||
expect(withEditor.mountedCodeRows).toBeLessThanOrEqual(MAX_MOUNTED_CODE_ROWS);
|
||||
expect(withEditor.mountedGutterRows).toBe(withEditor.mountedCodeRows);
|
||||
expect(await readDiffRowAlignment(page, 2)).toBeCloseTo(0, 0);
|
||||
await expect(page.getByTestId("inline-review-editor-input")).toBeFocused();
|
||||
await page.getByTestId("inline-review-editor-cancel").click();
|
||||
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
|
||||
|
||||
await scroll.evaluate((element) => {
|
||||
element.scrollTop = element.scrollHeight - element.clientHeight;
|
||||
element.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||
});
|
||||
await expect
|
||||
.poll(async () => (await readDiffMountReport(page)).highestMountedRow)
|
||||
.toBeGreaterThan(LINE_COUNT - 100);
|
||||
const afterScroll = await readDiffMountReport(page);
|
||||
expect(afterScroll.mountedCodeRows).toBeLessThanOrEqual(MAX_MOUNTED_CODE_ROWS);
|
||||
expect(afterScroll.mountedGutterRows).toBe(afterScroll.mountedCodeRows);
|
||||
|
||||
const report = {
|
||||
cpuSlowdown: CPU_SLOWDOWN,
|
||||
sourceLines: LINE_COUNT,
|
||||
expansionMs: Math.round(expansionMs),
|
||||
initial,
|
||||
withEditor,
|
||||
afterScroll,
|
||||
};
|
||||
await testInfo.attach("diff-performance", {
|
||||
body: JSON.stringify(report, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
console.log(`[perf] Diff rendering: ${JSON.stringify(report)}`);
|
||||
expect(expansionMs).toBeLessThan(MAX_EXPANSION_MS);
|
||||
|
||||
await cdp.send("Emulation.setCPUThrottlingRate", { rate: 1 });
|
||||
await scroll.evaluate((element) => {
|
||||
element.scrollTop = 0;
|
||||
element.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||
});
|
||||
await page.getByTestId("changes-toggle-layout").click();
|
||||
const oldLine = page.getByText("export const baseline = 0;", { exact: true });
|
||||
const newLine = page.getByText("export const baseline = 1;", { exact: true });
|
||||
await expect(oldLine).toBeVisible();
|
||||
await expect(newLine).toBeVisible();
|
||||
const [bodyBox, oldBox, newBox] = await Promise.all([
|
||||
page.getByTestId("diff-file-0-body").boundingBox(),
|
||||
oldLine.boundingBox(),
|
||||
newLine.boundingBox(),
|
||||
]);
|
||||
expect(bodyBox).not.toBeNull();
|
||||
expect(oldBox).not.toBeNull();
|
||||
expect(newBox).not.toBeNull();
|
||||
expect(oldBox!.x).toBeLessThan(newBox!.x);
|
||||
expect(oldBox!.x).toBeGreaterThanOrEqual(bodyBox!.x);
|
||||
expect(newBox!.x).toBeGreaterThan(bodyBox!.x + bodyBox!.width / 2);
|
||||
expect(newBox!.x).toBeLessThan(bodyBox!.x + bodyBox!.width);
|
||||
} finally {
|
||||
await cdp?.send("Emulation.setCPUThrottlingRate", { rate: 1 }).catch(() => undefined);
|
||||
await cdp?.detach().catch(() => undefined);
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function readDiffRowAlignment(
|
||||
page: import("@playwright/test").Page,
|
||||
rowIndex: number,
|
||||
): Promise<number> {
|
||||
const gutterTop = await page
|
||||
.getByTestId(`diff-gutter-row-${rowIndex}`)
|
||||
.evaluate((row) => row.getBoundingClientRect().top);
|
||||
const codeTop = await page
|
||||
.getByTestId(`diff-code-row-${rowIndex}`)
|
||||
.evaluate((row) => row.getBoundingClientRect().top);
|
||||
return gutterTop - codeTop;
|
||||
}
|
||||
|
||||
async function readDiffMountReport(page: import("@playwright/test").Page): Promise<{
|
||||
mountedCodeRows: number;
|
||||
mountedGutterRows: number;
|
||||
highestMountedRow: number;
|
||||
bodyDomElements: number;
|
||||
}> {
|
||||
return page.getByTestId("diff-file-0-body").evaluate((body) => {
|
||||
const codeRows = Array.from(
|
||||
body.querySelectorAll<HTMLElement>('[data-testid^="diff-code-row-"]'),
|
||||
);
|
||||
const gutterRows = body.querySelectorAll('[data-testid^="diff-gutter-row-"]');
|
||||
return {
|
||||
mountedCodeRows: codeRows.length,
|
||||
mountedGutterRows: gutterRows.length,
|
||||
highestMountedRow: Math.max(
|
||||
-1,
|
||||
...codeRows.map((row) =>
|
||||
Number((row.getAttribute("data-testid") ?? "").slice("diff-code-row-".length)),
|
||||
),
|
||||
),
|
||||
bodyDomElements: body.querySelectorAll("*").length,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { openFileExplorer } from "../support/helpers/file-explorer";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
|
||||
let workspace: SeededWorkspace;
|
||||
|
||||
async function failNextDeleteRequest(page: Page): Promise<void> {
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (browserSocket) => {
|
||||
const serverSocket = browserSocket.connectToServer();
|
||||
browserSocket.onMessage((message) => {
|
||||
if (typeof message === "string") {
|
||||
const envelope = JSON.parse(message) as {
|
||||
message?: { type?: string; cwd?: string; path?: string; requestId?: string };
|
||||
};
|
||||
if (envelope.message?.type === "fs.entry.delete.request") {
|
||||
browserSocket.send(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "fs.entry.delete.response",
|
||||
payload: {
|
||||
cwd: envelope.message.cwd,
|
||||
path: envelope.message.path,
|
||||
success: false,
|
||||
error: "Injected delete failure",
|
||||
requestId: envelope.message.requestId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
serverSocket.send(message);
|
||||
});
|
||||
serverSocket.onMessage((message) => browserSocket.send(message));
|
||||
});
|
||||
}
|
||||
|
||||
async function hideContextActionCapabilities(page: Page): Promise<void> {
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (browserSocket) => {
|
||||
const serverSocket = browserSocket.connectToServer();
|
||||
browserSocket.onMessage((message) => serverSocket.send(message));
|
||||
serverSocket.onMessage((message) => {
|
||||
if (typeof message !== "string") {
|
||||
browserSocket.send(message);
|
||||
return;
|
||||
}
|
||||
const envelope = JSON.parse(message) as {
|
||||
message?: {
|
||||
type?: string;
|
||||
payload?: { status?: string; features?: Record<string, unknown> };
|
||||
};
|
||||
};
|
||||
if (
|
||||
envelope.message?.type === "status" &&
|
||||
envelope.message.payload?.status === "server_info"
|
||||
) {
|
||||
envelope.message.payload.features = {
|
||||
...envelope.message.payload.features,
|
||||
fsEntryOps: false,
|
||||
fsEntryDuplicate: false,
|
||||
checkoutDiscardChanges: false,
|
||||
};
|
||||
}
|
||||
browserSocket.send(JSON.stringify(envelope));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function disconnectNextContextAction(page: Page): Promise<{ wait: () => Promise<void> }> {
|
||||
let resolveRequest: (() => void) | undefined;
|
||||
const requestSeen = new Promise<void>((resolve) => {
|
||||
resolveRequest = resolve;
|
||||
});
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (browserSocket) => {
|
||||
const serverSocket = browserSocket.connectToServer();
|
||||
browserSocket.onMessage((message) => {
|
||||
if (typeof message === "string") {
|
||||
const envelope = JSON.parse(message) as { message?: { type?: string } };
|
||||
const type = envelope.message?.type;
|
||||
if (type?.startsWith("fs.entry.") || type === "checkout.discard_changes.request") {
|
||||
resolveRequest?.();
|
||||
void browserSocket.close({
|
||||
code: 1008,
|
||||
reason: "Context action connection failed",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
serverSocket.send(message);
|
||||
});
|
||||
serverSocket.onMessage((message) => browserSocket.send(message));
|
||||
});
|
||||
|
||||
return { wait: () => requestSeen };
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
workspace = await seedWorkspace({ repoPrefix: "file-explorer-context-actions-" });
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await workspace?.cleanup();
|
||||
});
|
||||
|
||||
test("creates, renames, copies, and deletes entries through the file explorer", async ({
|
||||
context,
|
||||
page,
|
||||
}) => {
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openFileExplorer(page);
|
||||
|
||||
const tree = page.getByTestId("file-explorer-tree-scroll");
|
||||
const entry = (name: string) => tree.getByText(name, { exact: true }).first();
|
||||
const nameInput = page.getByTestId("file-explorer-name-input");
|
||||
|
||||
await page.getByTestId("files-new-folder").click();
|
||||
await expect(nameInput).toBeVisible();
|
||||
const draftRow = nameInput.locator("xpath=..");
|
||||
const placeholderColor = await nameInput.evaluate(
|
||||
(input) => getComputedStyle(input, "::placeholder").color,
|
||||
);
|
||||
const extraMutedChevronColor = await draftRow
|
||||
.locator("svg")
|
||||
.evaluate((icon) => getComputedStyle(icon).stroke);
|
||||
expect(placeholderColor).toBe(extraMutedChevronColor);
|
||||
await nameInput.press("Tab");
|
||||
await expect(nameInput).toBeHidden();
|
||||
|
||||
await page.getByTestId("files-new-file").click();
|
||||
await nameInput.fill("README.md");
|
||||
await nameInput.press("Enter");
|
||||
await expect(page.getByText('"README.md" already exists', { exact: true })).toBeVisible();
|
||||
|
||||
const emptyArea = page.getByTestId("files-empty-area");
|
||||
await expect(emptyArea).toBeVisible();
|
||||
const emptyAreaBounds = await emptyArea.boundingBox();
|
||||
expect(emptyAreaBounds).not.toBeNull();
|
||||
await emptyArea.click({
|
||||
button: "right",
|
||||
position: { x: emptyAreaBounds!.width / 2, y: emptyAreaBounds!.height - 24 },
|
||||
});
|
||||
await expect(page.getByTestId("files-empty-area-new-file")).toBeVisible();
|
||||
await page.getByTestId("files-empty-area-new-folder").click();
|
||||
await nameInput.fill("folder");
|
||||
await nameInput.press("Enter");
|
||||
await expect(entry("folder")).toBeVisible();
|
||||
|
||||
await entry("folder").click({ button: "right" });
|
||||
await page.getByText("New file", { exact: true }).click();
|
||||
await nameInput.fill("child.txt");
|
||||
await nameInput.press("Enter");
|
||||
await expect(entry("child.txt")).toBeVisible();
|
||||
|
||||
await entry("folder").click({ button: "right" });
|
||||
await page.getByText("New folder", { exact: true }).click();
|
||||
await nameInput.fill("nested");
|
||||
await nameInput.press("Enter");
|
||||
await entry("nested").click({ button: "right" });
|
||||
await page.getByText("New folder", { exact: true }).click();
|
||||
await nameInput.fill("deep");
|
||||
await nameInput.press("Enter");
|
||||
await expect(entry("deep")).toBeVisible();
|
||||
|
||||
await entry("folder").click({ button: "right" });
|
||||
await page.getByText("Collapse folder", { exact: true }).click();
|
||||
await expect(entry("child.txt")).toBeHidden();
|
||||
await expect(entry("nested")).toBeHidden();
|
||||
await expect(entry("deep")).toBeHidden();
|
||||
|
||||
await entry("folder").click();
|
||||
await expect(entry("child.txt")).toBeVisible();
|
||||
await expect(entry("nested")).toBeVisible();
|
||||
await expect(entry("deep")).toBeHidden();
|
||||
await entry("nested").click();
|
||||
await expect(entry("deep")).toBeVisible();
|
||||
|
||||
await page.getByTestId("files-new-file").click();
|
||||
await nameInput.fill("created.txt");
|
||||
await nameInput.press("Enter");
|
||||
await expect(entry("created.txt")).toBeVisible();
|
||||
await expect(entry("created.txt")).toHaveCSS("user-select", "none");
|
||||
await entry("created.txt").dblclick();
|
||||
expect(await page.evaluate(() => window.getSelection()?.toString() ?? "")).toBe("");
|
||||
|
||||
const folderRow = entry("folder").locator(
|
||||
"xpath=ancestor::*[starts-with(@data-testid, 'file-explorer-row-')][1]",
|
||||
);
|
||||
const fileRow = entry("created.txt").locator(
|
||||
"xpath=ancestor::*[starts-with(@data-testid, 'file-explorer-row-')][1]",
|
||||
);
|
||||
await expect(folderRow.locator("svg")).toHaveCount(1);
|
||||
await expect(fileRow.locator("svg")).toHaveCount(1);
|
||||
const gitRow = entry(".git").locator(
|
||||
"xpath=ancestor::*[starts-with(@data-testid, 'file-explorer-row-')][1]",
|
||||
);
|
||||
const collapsedChevronBounds = await gitRow.locator("svg").first().boundingBox();
|
||||
const expandedChevronBounds = await folderRow.locator("svg").first().boundingBox();
|
||||
const collapsedChevronSlotBounds = await gitRow
|
||||
.locator("svg")
|
||||
.first()
|
||||
.locator("xpath=../..")
|
||||
.boundingBox();
|
||||
const expandedChevronSlotBounds = await folderRow
|
||||
.locator("svg")
|
||||
.first()
|
||||
.locator("xpath=../..")
|
||||
.boundingBox();
|
||||
expect(collapsedChevronBounds).not.toBeNull();
|
||||
expect(expandedChevronBounds).not.toBeNull();
|
||||
expect(collapsedChevronSlotBounds).not.toBeNull();
|
||||
expect(expandedChevronSlotBounds).not.toBeNull();
|
||||
const collapsedChevronCenter = collapsedChevronBounds!.x + collapsedChevronBounds!.width / 2;
|
||||
const expandedChevronCenter = expandedChevronBounds!.x + expandedChevronBounds!.width / 2;
|
||||
expect(collapsedChevronCenter).toBeCloseTo(expandedChevronCenter, 0);
|
||||
expect(collapsedChevronCenter).toBeCloseTo(
|
||||
collapsedChevronSlotBounds!.x + collapsedChevronSlotBounds!.width / 2,
|
||||
0,
|
||||
);
|
||||
expect(expandedChevronCenter).toBeCloseTo(
|
||||
expandedChevronSlotBounds!.x + expandedChevronSlotBounds!.width / 2,
|
||||
0,
|
||||
);
|
||||
const folderLabelBounds = await entry("folder").boundingBox();
|
||||
const fileLabelBounds = await entry("created.txt").boundingBox();
|
||||
expect(folderLabelBounds).not.toBeNull();
|
||||
expect(fileLabelBounds).not.toBeNull();
|
||||
expect(fileLabelBounds!.x).toBeCloseTo(folderLabelBounds!.x, 0);
|
||||
|
||||
await folderRow.click();
|
||||
await expect(folderRow).toHaveAttribute("aria-selected", "true");
|
||||
await expect(entry("child.txt")).toBeHidden();
|
||||
await folderRow.click();
|
||||
await expect(folderRow).toHaveAttribute("aria-selected", "true");
|
||||
await expect(entry("child.txt")).toBeVisible();
|
||||
|
||||
await entry("folder").click({ button: "right" });
|
||||
await page.getByText("Rename", { exact: true }).click();
|
||||
await nameInput.fill("renamed-folder");
|
||||
await nameInput.press("Tab");
|
||||
await expect(entry("renamed-folder")).toBeVisible();
|
||||
await expect(entry("folder")).toBeHidden();
|
||||
await expect(entry("child.txt")).toBeVisible();
|
||||
const renamedFolderRow = entry("renamed-folder").locator(
|
||||
"xpath=ancestor::*[starts-with(@data-testid, 'file-explorer-row-')][1]",
|
||||
);
|
||||
await expect(renamedFolderRow).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
await entry("created.txt").click({ button: "right" });
|
||||
await expect(fileRow).toHaveAttribute("aria-selected", "true");
|
||||
await expect(renamedFolderRow).toHaveAttribute("aria-selected", "false");
|
||||
const menu = page.getByTestId(/file-explorer-row-\d+-context-menu$/);
|
||||
await expect(menu).toBeVisible();
|
||||
|
||||
await page.getByText("Copy path", { exact: true }).click({ button: "right" });
|
||||
await expect(menu).toBeVisible();
|
||||
|
||||
const backdrop = page.getByTestId(/file-explorer-row-\d+-context-menu-backdrop$/);
|
||||
await backdrop.click({ button: "right" });
|
||||
await expect(menu).toBeHidden();
|
||||
|
||||
await entry("created.txt").click({ button: "right" });
|
||||
await page.getByText("Rename", { exact: true }).click();
|
||||
await expect(nameInput).toHaveValue("created.txt");
|
||||
await nameInput.press("Escape");
|
||||
await expect(entry("created.txt")).toBeVisible();
|
||||
|
||||
await entry("created.txt").click({ button: "right" });
|
||||
await page.getByText("Rename", { exact: true }).click();
|
||||
await nameInput.fill("README.md");
|
||||
await nameInput.press("Enter");
|
||||
await expect(page.getByText('"README.md" already exists', { exact: true })).toBeVisible();
|
||||
await expect(entry("created.txt")).toBeVisible();
|
||||
|
||||
await entry("created.txt").click({ button: "right" });
|
||||
await page.getByText("Rename", { exact: true }).click();
|
||||
await nameInput.fill("renamed.txt");
|
||||
await nameInput.press("Tab");
|
||||
await expect(entry("renamed.txt")).toBeVisible();
|
||||
await expect(entry("created.txt")).toBeHidden();
|
||||
|
||||
await entry("renamed.txt").click({ button: "right" });
|
||||
await page.getByText("Copy relative path", { exact: true }).click();
|
||||
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe("renamed.txt");
|
||||
|
||||
await entry("renamed.txt").click({ button: "right" });
|
||||
const deleteAction = page.getByTestId(/file-explorer-row-\d+-delete$/);
|
||||
const deleteLabelColor = await deleteAction
|
||||
.getByText("Delete", { exact: true })
|
||||
.evaluate((element) => getComputedStyle(element).color);
|
||||
await expect(deleteAction.locator("svg")).toHaveCSS("stroke", deleteLabelColor);
|
||||
const cancelledConfirmation = new Promise<string>((resolve) => {
|
||||
page.once("dialog", async (dialog) => {
|
||||
const message = dialog.message();
|
||||
await dialog.dismiss();
|
||||
resolve(message);
|
||||
});
|
||||
});
|
||||
await page.getByText("Delete", { exact: true }).click();
|
||||
expect(await cancelledConfirmation).toContain("renamed.txt");
|
||||
await expect(entry("renamed.txt")).toBeVisible();
|
||||
|
||||
await entry("renamed.txt").click({ button: "right" });
|
||||
const confirmation = new Promise<string>((resolve) => {
|
||||
page.once("dialog", async (dialog) => {
|
||||
const message = dialog.message();
|
||||
await dialog.accept();
|
||||
resolve(message);
|
||||
});
|
||||
});
|
||||
await page.getByText("Delete", { exact: true }).click();
|
||||
expect(await confirmation).toContain("renamed.txt");
|
||||
await expect(entry("renamed.txt")).toBeHidden();
|
||||
|
||||
await entry("renamed-folder").click({ button: "right" });
|
||||
const folderConfirmation = new Promise<string>((resolve) => {
|
||||
page.once("dialog", async (dialog) => {
|
||||
const message = dialog.message();
|
||||
await dialog.accept();
|
||||
resolve(message);
|
||||
});
|
||||
});
|
||||
await page.getByText("Delete", { exact: true }).click();
|
||||
expect(await folderConfirmation).toContain("renamed-folder");
|
||||
await expect(entry("renamed-folder")).toBeHidden();
|
||||
await expect(entry("child.txt")).toBeHidden();
|
||||
});
|
||||
|
||||
test("duplicates files and folders with collision-free names", async ({ page }) => {
|
||||
const sourceFilePath = path.join(workspace.repoPath, "duplicate-source.txt");
|
||||
const sourceFolderPath = path.join(workspace.repoPath, "duplicate-folder");
|
||||
await writeFile(sourceFilePath, "duplicate contents", "utf8");
|
||||
await mkdir(sourceFolderPath);
|
||||
await writeFile(path.join(sourceFolderPath, "nested.txt"), "nested contents", "utf8");
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openFileExplorer(page);
|
||||
|
||||
const tree = page.getByTestId("file-explorer-tree-scroll");
|
||||
const entry = (name: string) => tree.getByText(name, { exact: true }).first();
|
||||
|
||||
await entry("duplicate-source.txt").click({ button: "right" });
|
||||
await page.getByTestId(/file-explorer-row-\d+-duplicate$/).click();
|
||||
await expect(entry("duplicate-source copy.txt")).toBeVisible();
|
||||
await expect
|
||||
.poll(() => readFile(path.join(workspace.repoPath, "duplicate-source copy.txt"), "utf8"))
|
||||
.toBe("duplicate contents");
|
||||
|
||||
await entry("duplicate-source.txt").click({ button: "right" });
|
||||
await page.getByTestId(/file-explorer-row-\d+-duplicate$/).click();
|
||||
await expect(entry("duplicate-source copy 2.txt")).toBeVisible();
|
||||
|
||||
await entry("duplicate-folder").click({ button: "right" });
|
||||
await page.getByTestId(/file-explorer-row-\d+-duplicate$/).click();
|
||||
await expect(entry("duplicate-folder copy")).toBeVisible();
|
||||
await expect
|
||||
.poll(() =>
|
||||
readFile(path.join(workspace.repoPath, "duplicate-folder copy", "nested.txt"), "utf8"),
|
||||
)
|
||||
.toBe("nested contents");
|
||||
} finally {
|
||||
await Promise.all([
|
||||
rm(sourceFilePath, { force: true }),
|
||||
rm(path.join(workspace.repoPath, "duplicate-source copy.txt"), { force: true }),
|
||||
rm(path.join(workspace.repoPath, "duplicate-source copy 2.txt"), { force: true }),
|
||||
rm(sourceFolderPath, { recursive: true, force: true }),
|
||||
rm(path.join(workspace.repoPath, "duplicate-folder copy"), { recursive: true, force: true }),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
test("shows an actionable error when the connection drops during creation", async ({ page }) => {
|
||||
const disconnect = await disconnectNextContextAction(page);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openFileExplorer(page);
|
||||
|
||||
await page.getByTestId("files-new-file").click();
|
||||
const nameInput = page.getByTestId("file-explorer-name-input");
|
||||
await nameInput.fill("disconnected.txt");
|
||||
await nameInput.press("Enter");
|
||||
await disconnect.wait();
|
||||
|
||||
await expect(page.getByText(/Context action connection failed/i)).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(
|
||||
page.getByTestId("file-explorer-tree-scroll").getByText("disconnected.txt", { exact: true }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("keeps an entry visible when deletion fails", async ({ page }) => {
|
||||
await failNextDeleteRequest(page);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openFileExplorer(page);
|
||||
|
||||
const readme = page
|
||||
.getByTestId("file-explorer-tree-scroll")
|
||||
.getByText("README.md", { exact: true });
|
||||
await readme.click({ button: "right" });
|
||||
const confirmation = new Promise<void>((resolve) => {
|
||||
page.once("dialog", async (dialog) => {
|
||||
await dialog.accept();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
await page.getByText("Delete", { exact: true }).click();
|
||||
await confirmation;
|
||||
|
||||
await expect(page.getByText("Injected delete failure", { exact: true })).toBeVisible();
|
||||
await expect(readme).toBeVisible();
|
||||
});
|
||||
|
||||
test("hides unsupported file operations and revert actions", async ({ page }) => {
|
||||
await hideContextActionCapabilities(page);
|
||||
await writeFile(path.join(workspace.repoPath, "README.md"), "# Changed while unsupported\n");
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openFileExplorer(page);
|
||||
|
||||
await expect(page.getByTestId("files-new-file")).toHaveCount(0);
|
||||
await expect(page.getByTestId("files-new-folder")).toHaveCount(0);
|
||||
await page
|
||||
.getByTestId("file-explorer-tree-scroll")
|
||||
.getByText("README.md", { exact: true })
|
||||
.click({ button: "right" });
|
||||
const filesMenu = page.getByTestId(/file-explorer-row-\d+-context-menu$/);
|
||||
await expect(filesMenu.getByText("Rename", { exact: true })).toHaveCount(0);
|
||||
await expect(filesMenu.getByText("Duplicate", { exact: true })).toHaveCount(0);
|
||||
await expect(filesMenu.getByText("Delete", { exact: true })).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await page.getByTestId("explorer-tab-changes").click();
|
||||
await expect(page.getByTestId("diff-file-0")).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||
await expect(page.getByTestId("diff-file-0-duplicate")).toHaveCount(0);
|
||||
await expect(page.getByTestId("diff-file-0-revert")).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
expectCompletedDiagram,
|
||||
expectDiagramRemainsRenderedWhileStreaming,
|
||||
expectDiagramWithLabels,
|
||||
reloadConversation,
|
||||
requestDiagram,
|
||||
waitForDiagramTurnToComplete,
|
||||
} from "../support/helpers/diagram";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
const STREAMED_MERMAID = [
|
||||
"```mermaid",
|
||||
"flowchart LR",
|
||||
" Start --> Middle",
|
||||
' Middle --> Done["<i>Done</i>"]',
|
||||
" Middle --> Review",
|
||||
" Review --> Verify",
|
||||
" Verify --> Ship",
|
||||
" Ship --> Package",
|
||||
" Package --> Sign",
|
||||
" Sign --> Upload",
|
||||
" Upload --> Publish",
|
||||
" Publish --> Deploy",
|
||||
" Deploy --> Observe",
|
||||
" Observe --> Validate",
|
||||
" Validate --> Announce",
|
||||
" Announce --> Document",
|
||||
" Document --> Archive",
|
||||
" Archive --> Release",
|
||||
"```",
|
||||
].join("\n");
|
||||
|
||||
test("keeps a Mermaid diagram rendered while its message streams, completes, and reloads", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "mermaid-streaming-",
|
||||
title: "Streaming Mermaid",
|
||||
featureValues: {
|
||||
mockStreamingAssistantResponse: STREAMED_MERMAID,
|
||||
mockStreamingAssistantIntervalMs: 150,
|
||||
},
|
||||
});
|
||||
try {
|
||||
await test.step("Open the conversation and request a diagram", async () => {
|
||||
await openAgentRoute(page, agent);
|
||||
await requestDiagram(agent);
|
||||
});
|
||||
|
||||
await test.step("The diagram renders and stays rendered while tokens arrive", async () => {
|
||||
await expectDiagramWithLabels(page, ["Start", "Middle"]);
|
||||
await expectDiagramRemainsRenderedWhileStreaming(page);
|
||||
});
|
||||
|
||||
await test.step("The completed diagram shows the final streamed content", async () => {
|
||||
await waitForDiagramTurnToComplete(agent);
|
||||
await expectCompletedDiagram(page, ["Start", "Done", "Release"]);
|
||||
});
|
||||
|
||||
await test.step("The completed diagram remains rendered after reload", async () => {
|
||||
await reloadConversation(page);
|
||||
await expectCompletedDiagram(page, ["Start", "Done", "Release"]);
|
||||
});
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||
import {
|
||||
expectSettingsHeader,
|
||||
openHostSection,
|
||||
openSettingsHost,
|
||||
} from "../support/helpers/settings";
|
||||
|
||||
async function openMetadataGenerationSettings(page: Page) {
|
||||
const serverId = getServerId();
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, serverId);
|
||||
await openHostSection(page, serverId, "metadata");
|
||||
await expectSettingsHeader(page, "Metadata");
|
||||
}
|
||||
|
||||
async function openManualMetadataModelPicker(page: Page) {
|
||||
await page.getByRole("button", { name: "Manual", exact: true }).click();
|
||||
await page.getByRole("button", { name: /Select model/ }).click();
|
||||
}
|
||||
|
||||
test("chooses a metadata model and can return to automatic selection", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await openMetadataGenerationSettings(page);
|
||||
|
||||
await expect(page.getByText("Workspace titles", { exact: false })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Automatic", exact: true })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("metadata-automatic.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
|
||||
await openManualMetadataModelPicker(page);
|
||||
await page.getByText("Mock Load Test", { exact: true }).click();
|
||||
await expect(page.getByText("Ten second stream", { exact: true })).toBeVisible();
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("metadata-model-picker.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
await page.getByText("Ten second stream", { exact: true }).click();
|
||||
|
||||
await expect(page.getByRole("button", { name: /Ten second stream/ })).toBeVisible();
|
||||
await page.reload();
|
||||
await expect(page.getByRole("button", { name: "Manual", exact: true })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await expect(page.getByRole("button", { name: /Ten second stream/ })).toBeVisible();
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("metadata-manual-persisted.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
|
||||
await page.getByRole("button", { name: "Automatic", exact: true }).click();
|
||||
await page.reload();
|
||||
|
||||
await expect(page.getByRole("button", { name: "Automatic", exact: true })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await expect(page.getByRole("button", { name: /Ten second stream/ })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("replaces only the first configured metadata model", async ({ page }) => {
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previousConfig = await client.getDaemonConfig();
|
||||
|
||||
try {
|
||||
await client.patchDaemonConfig({
|
||||
metadataGeneration: {
|
||||
providers: [
|
||||
{ provider: "mock", model: "five-minute-stream" },
|
||||
{ provider: "mock", model: "thirty-minute-stream" },
|
||||
],
|
||||
},
|
||||
});
|
||||
await openMetadataGenerationSettings(page);
|
||||
|
||||
await expect(page.getByRole("button", { name: "Manual", exact: true })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await openManualMetadataModelPicker(page);
|
||||
await page.getByText("Ten second stream", { exact: true }).click();
|
||||
|
||||
await expect
|
||||
.poll(async () => (await client.getDaemonConfig()).config.metadataGeneration.providers)
|
||||
.toEqual([
|
||||
{ provider: "mock", model: "ten-second-stream" },
|
||||
{ provider: "mock", model: "thirty-minute-stream" },
|
||||
]);
|
||||
} finally {
|
||||
try {
|
||||
await client.patchDaemonConfig({
|
||||
metadataGeneration: {
|
||||
providers: previousConfig.config.metadataGeneration.providers,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
closeModelPicker,
|
||||
expectModelSearchEmptyState,
|
||||
expectModelPickerHeight,
|
||||
expectModelPickerWidth,
|
||||
expectModelSearchResult,
|
||||
expectPinnedProfilesHidden,
|
||||
openModelPicker,
|
||||
readModelPickerHeight,
|
||||
readModelPickerWidth,
|
||||
searchAllModels,
|
||||
seedAgentProfiles,
|
||||
seedModelProvider,
|
||||
expectSearchResultsVirtualized,
|
||||
} from "../support/helpers/agent-profiles";
|
||||
import { expectComposerVisible } from "../support/helpers/composer";
|
||||
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
|
||||
const MOCK_PROVIDER_LABEL = "Mock Load Test";
|
||||
|
||||
// Its fast model deliberately carries the same label as the mock provider's, so
|
||||
// the only thing telling the two result rows apart is the provider name.
|
||||
const STUDIO = {
|
||||
id: "mock-studio",
|
||||
label: "Mock Studio",
|
||||
models: [
|
||||
{ id: "studio-fast", label: "Ten second stream", description: "Studio quick pass" },
|
||||
{ id: "studio-deep", label: "Studio deep think", description: "Studio long pass" },
|
||||
],
|
||||
};
|
||||
|
||||
// The cross-provider search lives on the picker's root view, and a profile is
|
||||
// what keeps the picker opening there.
|
||||
const ROOT_VIEW_PROFILE = {
|
||||
id: "agent_profile_e2e_search_root",
|
||||
name: "Search anchor",
|
||||
provider: "mock",
|
||||
};
|
||||
|
||||
const LARGE_CATALOG_SIZE = 200;
|
||||
const LARGE_CATALOG = {
|
||||
id: "mock-bulk",
|
||||
label: "Mock Bulk",
|
||||
models: Array.from({ length: LARGE_CATALOG_SIZE }, (_, index) => ({
|
||||
id: `bulk-${index}`,
|
||||
label: `Bulk model ${index}`,
|
||||
description: `Bulk search result ${index}`,
|
||||
})),
|
||||
};
|
||||
|
||||
test.describe("Cross-provider model search", () => {
|
||||
test("one query over the picker root reaches every provider and names each result's provider", async ({
|
||||
page,
|
||||
}) => {
|
||||
const provider = await seedModelProvider(STUDIO);
|
||||
const profile = await seedAgentProfiles([ROOT_VIEW_PROFILE]);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "model-search-providers-" });
|
||||
|
||||
try {
|
||||
await test.step("open a draft composer that can reach every provider", async () => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
await openModelPicker(page);
|
||||
});
|
||||
const restingWidth = await readModelPickerWidth(page);
|
||||
|
||||
await test.step("one query returns the same model label from two providers", async () => {
|
||||
await searchAllModels(page, "ten second stream");
|
||||
await expectModelSearchResult(page, {
|
||||
provider: "mock",
|
||||
modelId: "ten-second-stream",
|
||||
modelLabel: "Ten second stream",
|
||||
providerLabel: MOCK_PROVIDER_LABEL,
|
||||
});
|
||||
await expectModelSearchResult(page, {
|
||||
provider: STUDIO.id,
|
||||
modelId: "studio-fast",
|
||||
modelLabel: "Ten second stream",
|
||||
providerLabel: STUDIO.label,
|
||||
});
|
||||
await expectModelPickerWidth(page, restingWidth);
|
||||
});
|
||||
|
||||
await test.step("searching by provider name reaches that provider's own models", async () => {
|
||||
await searchAllModels(page, "studio deep");
|
||||
await expectModelSearchResult(page, {
|
||||
provider: STUDIO.id,
|
||||
modelId: "studio-deep",
|
||||
modelLabel: "Studio deep think",
|
||||
providerLabel: STUDIO.label,
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("results replace the pinned profiles", async () => {
|
||||
await expectPinnedProfilesHidden(page);
|
||||
});
|
||||
|
||||
// Search falls back to subsequence matching, so "no matches" needs letters
|
||||
// that cannot be picked out of a model label or description in order.
|
||||
await test.step("a query with no matches repeats the query back", async () => {
|
||||
await searchAllModels(page, "zzz");
|
||||
await expectModelSearchEmptyState(page, "zzz");
|
||||
await closeModelPicker(page);
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
await profile.restore();
|
||||
await provider.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("desktop search keeps its frame stable and virtualizes a host-wide catalog", async ({
|
||||
page,
|
||||
}) => {
|
||||
const provider = await seedModelProvider(LARGE_CATALOG);
|
||||
const profile = await seedAgentProfiles([ROOT_VIEW_PROFILE]);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "model-search-large-catalog-" });
|
||||
|
||||
try {
|
||||
await test.step("open the host-wide model picker", async () => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
await openModelPicker(page);
|
||||
});
|
||||
|
||||
const restingHeight = await readModelPickerHeight(page);
|
||||
const restingWidth = await readModelPickerWidth(page);
|
||||
|
||||
await test.step("a broad query renders only the visible result window", async () => {
|
||||
await searchAllModels(page, "bulk model");
|
||||
await expectSearchResultsVirtualized(page, {
|
||||
provider: LARGE_CATALOG.id,
|
||||
total: LARGE_CATALOG_SIZE,
|
||||
});
|
||||
await expectModelPickerHeight(page, restingHeight);
|
||||
await expectModelPickerWidth(page, restingWidth);
|
||||
});
|
||||
|
||||
await test.step("narrowing to one result does not resize the picker", async () => {
|
||||
await searchAllModels(page, "bulk model 199");
|
||||
await expectModelSearchResult(page, {
|
||||
provider: LARGE_CATALOG.id,
|
||||
modelId: "bulk-199",
|
||||
modelLabel: "Bulk model 199",
|
||||
providerLabel: LARGE_CATALOG.label,
|
||||
});
|
||||
await expectModelPickerHeight(page, restingHeight);
|
||||
await expectModelPickerWidth(page, restingWidth);
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
await profile.restore();
|
||||
await provider.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import type { FormPreferences } from "@/create-agent-preferences/preferences";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import { openAgentRoute } from "../support/helpers/mock-agent";
|
||||
@@ -10,7 +11,6 @@ import {
|
||||
import { expectNoTruncation } from "../support/helpers/no-truncation";
|
||||
import { escapeRegex } from "../support/helpers/regex";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
|
||||
const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences";
|
||||
@@ -49,13 +49,12 @@ function getSessionMessage(message: WebSocketMessage): Record<string, unknown> |
|
||||
return maybeEnvelope.message as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function seedCodexDefaultPermissionPreferences(page: Page, serverId: string): Promise<void> {
|
||||
async function seedCodexDefaultPermissionPreferences(page: Page): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ preferencesKey, serverId: seededServerId }) => {
|
||||
({ preferencesKey }) => {
|
||||
localStorage.setItem(
|
||||
preferencesKey,
|
||||
JSON.stringify({
|
||||
serverId: seededServerId,
|
||||
provider: "codex",
|
||||
providerPreferences: {
|
||||
codex: {
|
||||
@@ -69,10 +68,10 @@ async function seedCodexDefaultPermissionPreferences(page: Page, serverId: strin
|
||||
model: "ten-second-stream",
|
||||
},
|
||||
},
|
||||
}),
|
||||
} satisfies FormPreferences),
|
||||
);
|
||||
},
|
||||
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY, serverId },
|
||||
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -155,10 +154,9 @@ test.describe("New workspace Codex mode preferences", () => {
|
||||
test("keeps Full Access as the global Codex mode after the workspace draft auto-submit handoff", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
const seeded = await seedWorkspace({ repoPrefix: "codex-mode-preferences-" });
|
||||
const createAgentRecorder = await recordAndBlockCreateAgentRequests(page);
|
||||
await seedCodexDefaultPermissionPreferences(page, serverId);
|
||||
await seedCodexDefaultPermissionPreferences(page);
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
@@ -194,9 +192,8 @@ test.describe("New workspace Codex mode preferences", () => {
|
||||
});
|
||||
|
||||
test("uses the live Codex agent mode as the next New Workspace default", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
const seeded = await seedWorkspace({ repoPrefix: "codex-live-mode-preferences-" });
|
||||
await seedCodexDefaultPermissionPreferences(page, serverId);
|
||||
await seedCodexDefaultPermissionPreferences(page);
|
||||
|
||||
try {
|
||||
const agent = await seeded.client.createAgent({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import type { FormPreferences } from "@/create-agent-preferences/preferences";
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import { openAgentRoute } from "../support/helpers/mock-agent";
|
||||
import {
|
||||
@@ -6,7 +7,6 @@ import {
|
||||
selectNewWorkspaceProject,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
|
||||
const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences";
|
||||
|
||||
@@ -62,13 +62,12 @@ async function recordSetAgentModeRequests(page: Page): Promise<{
|
||||
};
|
||||
}
|
||||
|
||||
async function seedCodexDefaultPreferences(page: Page, serverId: string): Promise<void> {
|
||||
async function seedCodexDefaultPreferences(page: Page): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ preferencesKey, serverId: seededServerId }) => {
|
||||
({ preferencesKey }) => {
|
||||
localStorage.setItem(
|
||||
preferencesKey,
|
||||
JSON.stringify({
|
||||
serverId: seededServerId,
|
||||
provider: "codex",
|
||||
providerPreferences: {
|
||||
codex: {
|
||||
@@ -78,10 +77,10 @@ async function seedCodexDefaultPreferences(page: Page, serverId: string): Promis
|
||||
},
|
||||
mock: { model: "ten-second-stream" },
|
||||
},
|
||||
}),
|
||||
} satisfies FormPreferences),
|
||||
);
|
||||
},
|
||||
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY, serverId },
|
||||
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -104,9 +103,8 @@ test.describe("New Workspace mode cycle safety", () => {
|
||||
// control and silently change that (possibly running) agent's mode — e.g. into a
|
||||
// permissive/bypass mode. See use-keyboard-action-handler.ts.
|
||||
test("Shift+Tab in New Workspace never changes a backgrounded agent's mode", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
const seeded = await seedWorkspace({ repoPrefix: "mode-cycle-safety-" });
|
||||
await seedCodexDefaultPreferences(page, serverId);
|
||||
await seedCodexDefaultPreferences(page);
|
||||
const modeRequests = await recordSetAgentModeRequests(page);
|
||||
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||
import {
|
||||
expectSettingsHeader,
|
||||
openHostSection,
|
||||
openSettingsHost,
|
||||
} from "../support/helpers/settings";
|
||||
|
||||
function observePluginCatalog(page: Page) {
|
||||
let responses = 0;
|
||||
page.on("websocket", (socket) => {
|
||||
socket.on("framereceived", ({ payload }) => {
|
||||
if (typeof payload !== "string") return;
|
||||
try {
|
||||
const envelope = JSON.parse(payload) as {
|
||||
type?: unknown;
|
||||
message?: { type?: unknown };
|
||||
};
|
||||
const message = envelope.type === "session" ? envelope.message : envelope;
|
||||
if (message?.type === "plugin.catalog.get.response") responses += 1;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
});
|
||||
});
|
||||
return {
|
||||
waitForInitialFetch: async () => {
|
||||
await expect.poll(() => responses).toBeGreaterThan(0);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function pluginSource(title: string, renderError?: string): string {
|
||||
return `import React from "react";
|
||||
import { Text } from "react-native";
|
||||
export default function contribute(plugin) {
|
||||
function Surface() {
|
||||
${renderError ? `throw new Error(${JSON.stringify(renderError)});` : ""}
|
||||
const cleanups = Number(globalThis.sessionStorage?.getItem("paseo-plugin-cleanups") || "0");
|
||||
return <Text>${title} cleanup {cleanups}</Text>;
|
||||
}
|
||||
plugin.addSurface("main", Surface);
|
||||
plugin.addSidebarItem({ id: "main", title: ${JSON.stringify(title)}, icon: "Blocks", surface: "main" });
|
||||
${
|
||||
renderError
|
||||
? `function HealthySurface() { return <Text>Healthy contribution</Text>; }
|
||||
plugin.addSurface("healthy", HealthySurface);
|
||||
plugin.addSidebarItem({ id: "healthy", title: "Healthy surface", icon: "Blocks", surface: "healthy" });`
|
||||
: ""
|
||||
}
|
||||
return () => {
|
||||
const storage = globalThis.sessionStorage;
|
||||
if (storage) {
|
||||
const cleanups = Number(storage.getItem("paseo-plugin-cleanups") || "0");
|
||||
storage.setItem("paseo-plugin-cleanups", String(cleanups + 1));
|
||||
}
|
||||
};
|
||||
}`;
|
||||
}
|
||||
|
||||
async function openPluginSettings(page: Page): Promise<void> {
|
||||
const serverId = getServerId();
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, serverId);
|
||||
await openHostSection(page, serverId, "plugins");
|
||||
await expectSettingsHeader(page, "Plugins");
|
||||
}
|
||||
|
||||
async function reloadPlugin(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Reload", exact: true }).click();
|
||||
await expect(page.getByText("Reloaded e2e-plugin", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectContributionBody(page: Page, expectedBody: string): Promise<void> {
|
||||
const body = page.getByText(expectedBody, { exact: true }).filter({ visible: true });
|
||||
await expect(body).toHaveCount(1);
|
||||
await expect(body).toBeVisible();
|
||||
}
|
||||
|
||||
async function openContribution(page: Page, title: string, expectedBody: string): Promise<void> {
|
||||
await page.getByRole("button", { name: title, exact: true }).click();
|
||||
await expectContributionBody(page, expectedBody);
|
||||
}
|
||||
|
||||
async function leavePluginSettings(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Back", exact: true }).click();
|
||||
await expect(page).not.toHaveURL(/\/settings\//);
|
||||
}
|
||||
|
||||
async function openContributionFromSettings(
|
||||
page: Page,
|
||||
title: string,
|
||||
expectedBody: string,
|
||||
): Promise<void> {
|
||||
await leavePluginSettings(page);
|
||||
await openContribution(page, title, expectedBody);
|
||||
}
|
||||
|
||||
async function reloadActiveContribution(
|
||||
page: Page,
|
||||
client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>,
|
||||
expectedBody: string,
|
||||
): Promise<void> {
|
||||
await client.reloadPlugin("e2e-plugin");
|
||||
await expectContributionBody(page, expectedBody);
|
||||
}
|
||||
|
||||
async function expectContributionRemoved(page: Page, title: string): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: title, exact: true })).not.toBeVisible();
|
||||
}
|
||||
|
||||
test("installs, reloads, recovers, disables, and removes a trusted local plugin", async ({
|
||||
page,
|
||||
}) => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-e2e-"));
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previous = await client.getDaemonConfig();
|
||||
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: "e2e-plugin" }));
|
||||
await writeFile(path.join(directory, "index.tsx"), pluginSource("Plugin v1"));
|
||||
|
||||
try {
|
||||
const catalog = observePluginCatalog(page);
|
||||
await gotoAppShell(page);
|
||||
await openPluginSettings(page);
|
||||
await catalog.waitForInitialFetch();
|
||||
await client.patchDaemonConfig({ pluginsEnabled: false });
|
||||
await page.getByRole("switch", { name: "Enable plugins" }).click();
|
||||
await expect(page.getByText("Plugins enabled", { exact: true })).toBeVisible();
|
||||
await page.getByLabel("Plugin directory").fill(directory);
|
||||
await page.getByLabel("Plugin installation ID").focus();
|
||||
await expect(page.getByLabel("Plugin installation ID")).toHaveValue("e2e-plugin");
|
||||
await page.getByRole("button", { name: "Install directory" }).click();
|
||||
await expect(page.getByText("Installed e2e-plugin", { exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
|
||||
await openContributionFromSettings(page, "Plugin v1", "Plugin v1 cleanup 0");
|
||||
|
||||
await openPluginSettings(page);
|
||||
await reloadPlugin(page);
|
||||
await openContributionFromSettings(page, "Plugin v1", "Plugin v1 cleanup 1");
|
||||
|
||||
await writeFile(path.join(directory, "index.tsx"), pluginSource("Plugin v2"));
|
||||
await openPluginSettings(page);
|
||||
await reloadPlugin(page);
|
||||
await openContributionFromSettings(page, "Plugin v2", "Plugin v2 cleanup 2");
|
||||
await expect(page.getByRole("button", { name: "Plugin v1", exact: true })).not.toBeVisible();
|
||||
|
||||
await writeFile(
|
||||
path.join(directory, "index.tsx"),
|
||||
pluginSource("Broken surface", "render exploded"),
|
||||
);
|
||||
await openPluginSettings(page);
|
||||
await reloadPlugin(page);
|
||||
await openContributionFromSettings(page, "Broken surface", "Plugin failed: render exploded");
|
||||
await openContribution(page, "Healthy surface", "Healthy contribution");
|
||||
await openContribution(page, "Broken surface", "Plugin failed: render exploded");
|
||||
|
||||
await writeFile(path.join(directory, "index.tsx"), pluginSource("Recovered surface"));
|
||||
await reloadActiveContribution(page, client, "Recovered surface cleanup 4");
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Broken surface", exact: true }),
|
||||
).not.toBeVisible();
|
||||
|
||||
await writeFile(path.join(directory, "index.tsx"), "export default broken syntax !!!");
|
||||
await openPluginSettings(page);
|
||||
await page.getByRole("button", { name: "Reload", exact: true }).click();
|
||||
await expect(page.getByTestId("plugin-management-feedback")).toContainText("Request failed");
|
||||
await expect(page.getByLabel("e2e-plugin failed")).toBeVisible();
|
||||
await leavePluginSettings(page);
|
||||
await expectContributionRemoved(page, "Recovered surface");
|
||||
|
||||
await writeFile(path.join(directory, "index.tsx"), pluginSource("Plugin v3"));
|
||||
await openPluginSettings(page);
|
||||
await page.getByRole("button", { name: "Reload", exact: true }).click();
|
||||
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
|
||||
await openContributionFromSettings(page, "Plugin v3", "Plugin v3 cleanup 5");
|
||||
|
||||
await openPluginSettings(page);
|
||||
await page.getByRole("button", { name: "Disable", exact: true }).click();
|
||||
await expect(page.getByLabel("e2e-plugin disabled")).toBeVisible();
|
||||
await leavePluginSettings(page);
|
||||
await expectContributionRemoved(page, "Plugin v3");
|
||||
|
||||
await openPluginSettings(page);
|
||||
await page.getByRole("button", { name: "Enable", exact: true }).click();
|
||||
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
|
||||
await openContributionFromSettings(page, "Plugin v3", "Plugin v3 cleanup 6");
|
||||
|
||||
await openPluginSettings(page);
|
||||
page.once("dialog", (dialog) => dialog.accept());
|
||||
await page.getByRole("button", { name: "Remove", exact: true }).click();
|
||||
await expect(page.getByText("No plugins configured", { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByLabel("Plugin directory").fill(directory);
|
||||
await page.getByRole("button", { name: "Install directory" }).click();
|
||||
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
|
||||
await gotoAppShell(page);
|
||||
await openContribution(page, "Plugin v3", "Plugin v3 cleanup 7");
|
||||
} finally {
|
||||
await client.removePlugin("e2e-plugin").catch(() => undefined);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false });
|
||||
await client.close().catch(() => undefined);
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -9,12 +9,14 @@ import {
|
||||
clickReloadProjectSettings,
|
||||
clickRetryProjectSettingsSave,
|
||||
clickSaveProjectSettings,
|
||||
commitPaseoConfig,
|
||||
corruptPaseoConfig,
|
||||
editWorktreeSetup,
|
||||
expectEmptyScriptList,
|
||||
expectProjectHostContextHidden,
|
||||
expectNoEditableTarget,
|
||||
expectNoProjectSettingsError,
|
||||
expectNoUncommittedSetupWarning,
|
||||
expectProjectEditFailed,
|
||||
expectProjectEditName,
|
||||
expectProjectEditSaved,
|
||||
@@ -27,6 +29,7 @@ import {
|
||||
expectSaveButtonDisabled,
|
||||
expectScriptRowCount,
|
||||
expectWriteFailedCalloutActions,
|
||||
expectUncommittedSetupWarning,
|
||||
fillProjectIconUrl,
|
||||
fillProjectName,
|
||||
installDaemonConnectionGate,
|
||||
@@ -216,9 +219,16 @@ test.describe("Projects settings", () => {
|
||||
test("user edits worktree setup from the projects page", async ({ page, editableProject }) => {
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
await expectNoUncommittedSetupWarning(page);
|
||||
await editWorktreeSetup(page, updatedSetup);
|
||||
await clickSaveProjectSettings(page);
|
||||
await expectProjectConfigSaved(editableProject);
|
||||
await expectUncommittedSetupWarning(page);
|
||||
|
||||
commitPaseoConfig(editableProject.path);
|
||||
await returnToProjectsList(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
await expectNoUncommittedSetupWarning(page);
|
||||
});
|
||||
|
||||
test("project navigation stays inside the selected host", async ({ page, editableProject }) => {
|
||||
@@ -439,5 +449,7 @@ test.describe("Projects settings — error UX", () => {
|
||||
|
||||
await expectScriptRowCount(page, 0);
|
||||
await expectEmptyScriptList(page);
|
||||
await clickSaveProjectSettings(page);
|
||||
await expectNoUncommittedSetupWarning(page);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -160,7 +160,6 @@ test.describe("provider settings overlay stack", () => {
|
||||
await commandCenter.getByText("Add project", { exact: true }).click();
|
||||
const addProject = page.getByTestId("add-project-flow");
|
||||
await expect(addProject).toBeVisible({ timeout: 10_000 });
|
||||
await expect(addProject.getByTestId("add-project-flow-input")).toBeFocused();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(addProject).not.toBeVisible({ timeout: 10_000 });
|
||||
await expect(selector).toBeVisible();
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { mkdtempSync, realpathSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import {
|
||||
cleanupRewindFlow,
|
||||
launchAgent,
|
||||
sendMessage,
|
||||
type AgentHandle,
|
||||
type RewindFlowProvider,
|
||||
} from "../support/helpers/rewind-flow";
|
||||
import {
|
||||
expectCompletedTaskActivity,
|
||||
expectTaskListEntries,
|
||||
openCompletedTaskList,
|
||||
} from "../support/helpers/task-list";
|
||||
import { submitMessage } from "../support/helpers/composer";
|
||||
|
||||
interface TaskScenario {
|
||||
provider: RewindFlowProvider;
|
||||
entries: [string, string];
|
||||
prompt: string;
|
||||
providerConfig?: Parameters<typeof launchAgent>[0]["providerConfig"];
|
||||
}
|
||||
|
||||
const scenarios: TaskScenario[] = [
|
||||
{
|
||||
provider: "claude",
|
||||
entries: ["Claude inventory alpha", "Claude inventory beta"],
|
||||
providerConfig: { model: "opus" },
|
||||
prompt:
|
||||
"Use TaskCreate to create exactly two tasks named Claude inventory alpha and Claude inventory beta. Use TaskUpdate to mark each in_progress and then completed. Do not use subagents, shell commands, or edit files. Finish with TASKS_COMPLETE.",
|
||||
},
|
||||
{
|
||||
provider: "codex",
|
||||
entries: ["Codex inventory alpha", "Codex inventory beta"],
|
||||
prompt:
|
||||
"Use update_plan to track exactly two steps named Codex inventory alpha and Codex inventory beta. Move each through in_progress and completed. Do not use subagents, shell commands, or edit files. Finish with TASKS_COMPLETE.",
|
||||
},
|
||||
{
|
||||
provider: "opencode",
|
||||
entries: ["OpenCode inventory alpha", "OpenCode inventory beta"],
|
||||
prompt:
|
||||
"Use TodoWrite to track exactly two tasks named OpenCode inventory alpha and OpenCode inventory beta. Move each through in_progress and completed. Do not use subagents, shell commands, or edit files. Finish with TASKS_COMPLETE.",
|
||||
},
|
||||
];
|
||||
|
||||
test.describe("real provider composer task lists", () => {
|
||||
test.setTimeout(600_000);
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
test(`${scenario.provider} shows native task progress above the composer`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const cwd = realpathSync(
|
||||
mkdtempSync(path.join(tmpdir(), `paseo-task-list-${scenario.provider}-`)),
|
||||
);
|
||||
let handle: AgentHandle | undefined;
|
||||
|
||||
try {
|
||||
handle = await launchAgent({
|
||||
page,
|
||||
provider: scenario.provider,
|
||||
cwd,
|
||||
mode: "full-access",
|
||||
providerConfig: scenario.providerConfig,
|
||||
});
|
||||
await sendMessage(handle, scenario.prompt);
|
||||
await openCompletedTaskList(page, scenario.entries.length);
|
||||
await expectTaskListEntries(page, scenario.entries);
|
||||
await expectCompletedTaskActivity(page, scenario.entries);
|
||||
await expect(page.getByText("TaskUpdate", { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByTestId("timeline-plan-card")).toHaveCount(0);
|
||||
|
||||
const screenshot = testInfo.outputPath(`${scenario.provider}-composer-tasks.png`);
|
||||
await page.screenshot({ path: screenshot });
|
||||
await testInfo.attach(`${scenario.provider} composer tasks`, {
|
||||
path: screenshot,
|
||||
contentType: "image/png",
|
||||
});
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("Codex genuine Plan mode keeps the existing plan card", async ({ page }, testInfo) => {
|
||||
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-genuine-codex-plan-")));
|
||||
let handle: AgentHandle | undefined;
|
||||
|
||||
try {
|
||||
handle = await launchAgent({
|
||||
page,
|
||||
provider: "codex",
|
||||
cwd,
|
||||
mode: "full-access",
|
||||
providerConfig: { featureValues: { plan_mode: true } },
|
||||
});
|
||||
await submitMessage(
|
||||
page,
|
||||
"Produce a concise implementation plan with exactly these steps: Inspect genuine plan boundary; Implement genuine plan boundary; Verify genuine plan boundary. Do not implement it.",
|
||||
);
|
||||
|
||||
const planCard = page.getByTestId("permission-plan-card");
|
||||
await expect(planCard).toBeVisible({ timeout: 120_000 });
|
||||
await expect(planCard).toContainText("Inspect genuine plan boundary");
|
||||
await expect(page.getByTestId("timeline-plan-card")).toHaveCount(0);
|
||||
|
||||
const screenshot = testInfo.outputPath("codex-genuine-plan.png");
|
||||
await page.screenshot({ path: screenshot });
|
||||
await testInfo.attach("Codex genuine plan", { path: screenshot, contentType: "image/png" });
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { expect, test as base } from "../support/fixtures";
|
||||
import { expectAgentIdle } from "../support/helpers/agent-stream";
|
||||
import { expectComposerVisible, submitMessage } from "../support/helpers/composer";
|
||||
import {
|
||||
openAgentRoute,
|
||||
seedMockAgentWorkspace,
|
||||
type MockAgentWorkspace,
|
||||
} from "../support/helpers/mock-agent";
|
||||
import {
|
||||
beginReplicaCacheMeasurement,
|
||||
observeReplicaCacheWrites,
|
||||
readReplicaCacheMeasurement,
|
||||
} from "../support/helpers/replica-cache-perf";
|
||||
|
||||
const RUN_REPLICA_CACHE_PERF = process.env.PASEO_REPLICA_CACHE_PERF_E2E === "1";
|
||||
const MAX_STREAM_WRITES = 12;
|
||||
const MAX_SERIALIZED_CHARS = 190_000;
|
||||
|
||||
const test = base.extend<{ replicaCacheAgent: MockAgentWorkspace }>({
|
||||
replicaCacheAgent: async ({ page: _page }, provide) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "replica-cache-perf-",
|
||||
title: "Replica cache performance",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
try {
|
||||
await provide(agent);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
},
|
||||
});
|
||||
const replicaCachePerfDescribe = RUN_REPLICA_CACHE_PERF ? test.describe : test.describe.skip;
|
||||
|
||||
replicaCachePerfDescribe("Replica cache performance", () => {
|
||||
test("streaming writes only persisted replica changes", async ({
|
||||
page,
|
||||
replicaCacheAgent: agent,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(60_000);
|
||||
await observeReplicaCacheWrites(page);
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
await beginReplicaCacheMeasurement(page);
|
||||
|
||||
await submitMessage(page, "Measure replica persistence during a sustained stream.");
|
||||
await agent.client.waitForFinish(agent.agentId, 20_000);
|
||||
await expectAgentIdle(page);
|
||||
await page.waitForTimeout(1_000);
|
||||
|
||||
const report = await readReplicaCacheMeasurement(page);
|
||||
await testInfo.attach("replica-cache-performance", {
|
||||
body: JSON.stringify(report, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
console.log(`[perf] Replica cache: ${JSON.stringify(report)}`);
|
||||
|
||||
expect(
|
||||
report.writes,
|
||||
"streaming should not continuously persist transient state",
|
||||
).toBeLessThanOrEqual(MAX_STREAM_WRITES);
|
||||
expect(report.redundantWrites).toBe(0);
|
||||
expect(report.serializedChars).toBeLessThanOrEqual(MAX_SERIALIZED_CHARS);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
fillComposerDraft,
|
||||
submitMessage,
|
||||
} from "../support/helpers/composer";
|
||||
import { readReplicaCache, writeReplicaCache } from "../support/helpers/replica-cache-storage";
|
||||
|
||||
// UI plumbing contract against the dev mock provider. Real-provider behavior is tested in `daemon-e2e/*-rewind.real.e2e.test.ts`.
|
||||
|
||||
@@ -27,39 +28,28 @@ async function expectUserMessageVisible(page: Page, text: string): Promise<void>
|
||||
|
||||
async function rewriteCachedMessageAsLegacyRow(page: Page, prompt: string): Promise<void> {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate((messageText) => {
|
||||
const raw = localStorage.getItem("@paseo:replica-cache");
|
||||
if (!raw) return false;
|
||||
const cache = JSON.parse(raw) as {
|
||||
hosts?: Array<{ timeline?: { items?: Array<Record<string, unknown>> } | null }>;
|
||||
};
|
||||
for (const host of cache.hosts ?? []) {
|
||||
for (const item of host.timeline?.items ?? []) {
|
||||
if (item.kind === "user_message" && item.text === messageText && item.messageId) {
|
||||
return true;
|
||||
}
|
||||
.poll(async () => {
|
||||
const cache = await readReplicaCache(page);
|
||||
if (!cache) return false;
|
||||
for (const host of cache.hosts ?? []) {
|
||||
for (const item of host.timeline?.items ?? []) {
|
||||
if (item.kind === "user_message" && item.text === prompt && item.messageId) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}, prompt),
|
||||
)
|
||||
}
|
||||
return false;
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
await page.evaluate((messageText) => {
|
||||
const key = "@paseo:replica-cache";
|
||||
const raw = localStorage.getItem(key);
|
||||
if (!raw) throw new Error("Replica cache was not persisted");
|
||||
const cache = JSON.parse(raw) as {
|
||||
hosts?: Array<{ timeline?: { items?: Array<Record<string, unknown>> } | null }>;
|
||||
};
|
||||
const cachedMessage = cache.hosts
|
||||
?.flatMap((host) => host.timeline?.items ?? [])
|
||||
.find((item) => item.kind === "user_message" && item.text === messageText);
|
||||
if (!cachedMessage) throw new Error("Cached user message was not found");
|
||||
delete cachedMessage.messageId;
|
||||
localStorage.setItem(key, JSON.stringify(cache));
|
||||
}, prompt);
|
||||
const cache = await readReplicaCache(page);
|
||||
if (!cache) throw new Error("Replica cache was not persisted");
|
||||
const cachedMessage = cache.hosts
|
||||
?.flatMap((host) => host.timeline?.items ?? [])
|
||||
.find((item) => item.kind === "user_message" && item.text === prompt);
|
||||
if (!cachedMessage) throw new Error("Cached user message was not found");
|
||||
delete cachedMessage.messageId;
|
||||
await writeReplicaCache(page, cache);
|
||||
}
|
||||
|
||||
async function waitForCurrentSubmissionExcludedFromCache(
|
||||
@@ -67,46 +57,34 @@ async function waitForCurrentSubmissionExcludedFromCache(
|
||||
prompt: string,
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate((messageText) => {
|
||||
const raw = localStorage.getItem("@paseo:replica-cache");
|
||||
if (!raw) return false;
|
||||
const cache = JSON.parse(raw) as {
|
||||
hosts?: Array<{ timeline?: { items?: Array<Record<string, unknown>> } | null }>;
|
||||
};
|
||||
return !cache.hosts
|
||||
?.flatMap((host) => host.timeline?.items ?? [])
|
||||
.some(
|
||||
(item) =>
|
||||
item.kind === "user_message" &&
|
||||
item.text === messageText &&
|
||||
typeof item.clientMessageId === "string" &&
|
||||
item.messageId === undefined,
|
||||
);
|
||||
}, prompt),
|
||||
)
|
||||
.poll(async () => {
|
||||
const cache = await readReplicaCache(page);
|
||||
if (!cache) return false;
|
||||
return !cache.hosts
|
||||
?.flatMap((host) => host.timeline?.items ?? [])
|
||||
.some(
|
||||
(item) =>
|
||||
item.kind === "user_message" &&
|
||||
item.text === prompt &&
|
||||
typeof item.clientMessageId === "string" &&
|
||||
item.messageId === undefined,
|
||||
);
|
||||
})
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
async function waitForCachedMessageWithoutProviderId(page: Page, prompt: string): Promise<void> {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate((messageText) => {
|
||||
const raw = localStorage.getItem("@paseo:replica-cache");
|
||||
if (!raw) return false;
|
||||
const cache = JSON.parse(raw) as {
|
||||
hosts?: Array<{ timeline?: { items?: Array<Record<string, unknown>> } | null }>;
|
||||
};
|
||||
return cache.hosts
|
||||
?.flatMap((host) => host.timeline?.items ?? [])
|
||||
.some(
|
||||
(item) =>
|
||||
item.kind === "user_message" &&
|
||||
item.text === messageText &&
|
||||
item.messageId === undefined,
|
||||
);
|
||||
}, prompt),
|
||||
)
|
||||
.poll(async () => {
|
||||
const cache = await readReplicaCache(page);
|
||||
if (!cache) return false;
|
||||
return cache.hosts
|
||||
?.flatMap((host) => host.timeline?.items ?? [])
|
||||
.some(
|
||||
(item) =>
|
||||
item.kind === "user_message" && item.text === prompt && item.messageId === undefined,
|
||||
);
|
||||
})
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
@@ -148,16 +126,13 @@ test.describe("Rewind sheet", () => {
|
||||
title: "Rewind cache upgrade e2e",
|
||||
initialPrompt: prompt,
|
||||
});
|
||||
let heldTimelineRequest = false;
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await expectUserMessageVisible(page, prompt);
|
||||
await gate.drop();
|
||||
await rewriteCachedMessageAsLegacyRow(page, prompt);
|
||||
gate.holdNextClientRequest("fetch_agent_timeline_request");
|
||||
await page.reload();
|
||||
await gate.waitForHeldClientRequest();
|
||||
heldTimelineRequest = true;
|
||||
|
||||
const restoredMessage = userMessage(page, prompt);
|
||||
await expect(restoredMessage).toBeVisible();
|
||||
@@ -165,7 +140,6 @@ test.describe("Rewind sheet", () => {
|
||||
await expect(restoredMessage.getByTestId("rewind-menu-trigger")).toHaveCount(0);
|
||||
await waitForCachedMessageWithoutProviderId(page, prompt);
|
||||
} finally {
|
||||
if (heldTimelineRequest) gate.releaseHeldClientRequest();
|
||||
gate.restore();
|
||||
await session.cleanup();
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ test("Sessions shows an empty placeholder when the host has no history", async (
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost]));
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||
},
|
||||
{ seededHost: host, preferences: buildCreateAgentPreferences(serverId) },
|
||||
{ seededHost: host, preferences: buildCreateAgentPreferences() },
|
||||
);
|
||||
|
||||
await gotoAppShell(page);
|
||||
|
||||
@@ -114,16 +114,6 @@ test.describe("Settings sidebar navigation", () => {
|
||||
await expectDirectHostUriHidden(page);
|
||||
});
|
||||
|
||||
test("browser web hides direct connection custom headers", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openAddHostFlow(page);
|
||||
await selectHostConnectionType(page, "direct");
|
||||
await toggleHostAdvanced(page);
|
||||
|
||||
await expect(page.getByTestId("direct-header-add")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("sidebar shows a Back to workspace row that leaves /settings", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
|
||||
@@ -18,7 +18,25 @@ async function visibleBoundingBox(row: Locator) {
|
||||
return box;
|
||||
}
|
||||
|
||||
async function quickDragFirstRowAfterSecond(rows: Locator) {
|
||||
async function pressProjectRow(rows: Locator) {
|
||||
await rows.page().mouse.down();
|
||||
}
|
||||
|
||||
async function pressWorkspaceRow(rows: Locator) {
|
||||
const solidScrimStop = rows
|
||||
.nth(0)
|
||||
.getByTestId("sidebar-workspace-trailing-scrim")
|
||||
.locator("stop")
|
||||
.nth(1);
|
||||
const hoverScrimColor = await solidScrimStop.getAttribute("stop-color");
|
||||
await rows.page().mouse.down();
|
||||
await expect.poll(() => solidScrimStop.getAttribute("stop-color")).not.toBe(hoverScrimColor);
|
||||
}
|
||||
|
||||
async function quickDragFirstRowAfterSecond(
|
||||
rows: Locator,
|
||||
pressRow: (rows: Locator) => Promise<void>,
|
||||
) {
|
||||
await expect(rows).toHaveCount(2);
|
||||
const before = await rowTestIds(rows);
|
||||
const sourceBox = await visibleBoundingBox(rows.nth(0));
|
||||
@@ -29,15 +47,19 @@ async function quickDragFirstRowAfterSecond(rows: Locator) {
|
||||
const target = { x: targetBox.x + targetBox.width / 2, y: targetBox.y + targetBox.height / 2 };
|
||||
|
||||
await page.mouse.move(source.x, source.y);
|
||||
await page.mouse.down();
|
||||
const trailingScrim = rows.nth(0).getByTestId("sidebar-workspace-trailing-scrim");
|
||||
await pressRow(rows);
|
||||
await page.mouse.move(source.x, source.y + 7);
|
||||
await expect(trailingScrim).toHaveCount(0);
|
||||
await page.mouse.move(target.x, target.y, { steps: 4 });
|
||||
await page.mouse.up();
|
||||
|
||||
await expect.poll(() => rowTestIds(rows)).toEqual([before[1], before[0]]);
|
||||
}
|
||||
|
||||
test("projects and workspaces reorder with an immediate mouse drag", async ({ page }) => {
|
||||
test("projects, workspaces, and pinned chats reorder with an immediate mouse drag", async ({
|
||||
page,
|
||||
}) => {
|
||||
const firstProject = await seedWorkspace({ repoPrefix: "sidebar-reorder-first-" });
|
||||
const secondProject = await seedWorkspace({ repoPrefix: "sidebar-reorder-second-" });
|
||||
|
||||
@@ -61,6 +83,7 @@ test("projects and workspaces reorder with an immediate mouse drag", async ({ pa
|
||||
const secondProjectTestId = `sidebar-project-row-${projectEquivalenceViewKey(secondProject.projectKey)}`;
|
||||
await quickDragFirstRowAfterSecond(
|
||||
page.locator(`[data-testid="${firstProjectTestId}"], [data-testid="${secondProjectTestId}"]`),
|
||||
pressProjectRow,
|
||||
);
|
||||
const firstWorkspaceTestId = `sidebar-workspace-row-${getServerId()}:${firstProject.workspaceId}`;
|
||||
const secondWorkspaceTestId = `sidebar-workspace-row-${getServerId()}:${secondWorkspace.workspace.id}`;
|
||||
@@ -68,6 +91,17 @@ test("projects and workspaces reorder with an immediate mouse drag", async ({ pa
|
||||
page.locator(
|
||||
`[data-testid="${firstWorkspaceTestId}"], [data-testid="${secondWorkspaceTestId}"]`,
|
||||
),
|
||||
pressWorkspaceRow,
|
||||
);
|
||||
|
||||
await firstProject.client.setWorkspacePinned(firstProject.workspaceId, true);
|
||||
await secondProject.client.setWorkspacePinned(secondProject.workspaceId, true);
|
||||
const secondProjectWorkspaceTestId = `sidebar-workspace-row-${getServerId()}:${secondProject.workspaceId}`;
|
||||
await quickDragFirstRowAfterSecond(
|
||||
page.locator(
|
||||
`[data-testid="${firstWorkspaceTestId}"], [data-testid="${secondProjectWorkspaceTestId}"]`,
|
||||
),
|
||||
pressWorkspaceRow,
|
||||
);
|
||||
} finally {
|
||||
await firstProject.cleanup();
|
||||
|
||||
@@ -7,6 +7,7 @@ import path from "node:path";
|
||||
import net from "node:net";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute, decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
|
||||
import type { FormPreferences } from "@/create-agent-preferences/preferences";
|
||||
import { metroTest as test } from "../support/fixtures";
|
||||
import { buildSeededHost } from "../support/helpers/daemon-registry";
|
||||
import { loadDaemonClientConstructor } from "../support/helpers/daemon-client-loader";
|
||||
@@ -348,12 +349,14 @@ async function seedBrowserForDaemon(page: Page, input: { serverId: string; port:
|
||||
{
|
||||
daemon: host,
|
||||
preferences: {
|
||||
serverId: input.serverId,
|
||||
provider: "codex",
|
||||
providerPreferences: {
|
||||
codex: { model: "gpt-5.4-mini", thinkingOptionId: "low" },
|
||||
codex: {
|
||||
model: "gpt-5.4-mini",
|
||||
thinkingByModel: { "gpt-5.4-mini": "low" },
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies FormPreferences,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ test.describe("Worktree restore after daemon restart", () => {
|
||||
label: "restart daemon",
|
||||
nowIso,
|
||||
}),
|
||||
preferences: buildCreateAgentPreferences(serverId),
|
||||
preferences: buildCreateAgentPreferences(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import readline from "node:readline";
|
||||
|
||||
const lines = readline.createInterface({ input: process.stdin });
|
||||
|
||||
for await (const line of lines) {
|
||||
const command = JSON.parse(line);
|
||||
let data = {};
|
||||
if (command.type === "get_available_models") {
|
||||
data = {
|
||||
models: [
|
||||
{
|
||||
provider: "e2e",
|
||||
id: "pi-profile-model",
|
||||
name: "Pi profile model",
|
||||
},
|
||||
],
|
||||
};
|
||||
} else if (command.type === "get_state") {
|
||||
data = {
|
||||
sessionId: "e2e-pi-session",
|
||||
thinkingLevel: "medium",
|
||||
isStreaming: false,
|
||||
isCompacting: false,
|
||||
messageCount: 0,
|
||||
pendingMessageCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
id: command.id,
|
||||
type: "response",
|
||||
command: command.type,
|
||||
success: true,
|
||||
data,
|
||||
})}\n`,
|
||||
);
|
||||
}
|
||||
@@ -132,7 +132,7 @@ const test = metroTest.extend<
|
||||
endpoint: `127.0.0.1:${daemonPort}`,
|
||||
nowIso,
|
||||
});
|
||||
const createAgentPreferences = buildCreateAgentPreferences(testDaemon.serverId);
|
||||
const createAgentPreferences = buildCreateAgentPreferences();
|
||||
|
||||
await page.addInitScript(
|
||||
({ daemon, preferences, seedNonce: nonce, extraHostsKey }) => {
|
||||
|
||||
@@ -43,13 +43,21 @@ export async function expectAddProjectPage(page: Page, kind: AddProjectFlowPage)
|
||||
return currentPage;
|
||||
}
|
||||
|
||||
export async function openAddProjectFlow(
|
||||
async function openAddProjectFlowSurface(
|
||||
page: Page,
|
||||
expectedPage: "host" | "method" = "method",
|
||||
expectedPage: "host" | "method",
|
||||
): Promise<void> {
|
||||
await page.getByTestId("sidebar-add-project").click();
|
||||
await expect(addProjectFlow(page)).toBeVisible({ timeout: 30_000 });
|
||||
await expectAddProjectPage(page, expectedPage);
|
||||
}
|
||||
|
||||
export async function openAddProjectFlow(page: Page): Promise<void> {
|
||||
await openAddProjectFlowSurface(page, "method");
|
||||
}
|
||||
|
||||
export async function openAddProjectHostSelection(page: Page): Promise<void> {
|
||||
await openAddProjectFlowSurface(page, "host");
|
||||
await expect(addProjectFlowInput(page)).toBeFocused();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,567 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import type { AgentProfile } from "@getpaseo/protocol/messages";
|
||||
import { buildSettingsHostSectionRoute } from "@/utils/host-routes";
|
||||
import { gotoAppShell, openSettings } from "./app";
|
||||
import { connectDaemonClient } from "./daemon-client-loader";
|
||||
import { getServerId } from "./server-id";
|
||||
import { expectAppRoute } from "./route-assertions";
|
||||
import { openSettingsHost } from "./settings";
|
||||
|
||||
// ─── Daemon-side seeding ───────────────────────────────────────────────────
|
||||
|
||||
interface AgentProfilesDaemonClient {
|
||||
connect(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
getDaemonConfig(): Promise<{ config: { agentProfiles?: AgentProfile[] } }>;
|
||||
patchDaemonConfig(config: {
|
||||
agentProfiles?: AgentProfile[];
|
||||
providers?: Record<string, Record<string, unknown>>;
|
||||
removeProviders?: string[];
|
||||
}): Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface HostSeed {
|
||||
/** Puts the host's config back the way the test found it and drops the client. */
|
||||
restore(): Promise<void>;
|
||||
}
|
||||
|
||||
async function connectAgentProfilesClient(): Promise<AgentProfilesDaemonClient> {
|
||||
return connectDaemonClient<AgentProfilesDaemonClient>({ clientIdPrefix: "agent-profiles-e2e" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes profiles into host config directly. Journeys that are *about* the
|
||||
* settings UI create theirs through it; every other journey starts from a host
|
||||
* that already has them.
|
||||
*/
|
||||
export async function seedAgentProfiles(profiles: AgentProfile[]): Promise<HostSeed> {
|
||||
const client = await connectAgentProfilesClient();
|
||||
const previous = (await client.getDaemonConfig()).config.agentProfiles ?? [];
|
||||
await client.patchDaemonConfig({ agentProfiles: profiles });
|
||||
return {
|
||||
async restore() {
|
||||
await client.patchDaemonConfig({ agentProfiles: previous }).catch(() => undefined);
|
||||
await client.close().catch(() => undefined);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function stageLegacyFavoritesForHostMigration(
|
||||
page: Page,
|
||||
favorites: Array<{ provider: string; modelId: string }>,
|
||||
): Promise<void> {
|
||||
const serverId = getServerId();
|
||||
await gotoAppShell(page);
|
||||
await page.evaluate(
|
||||
({ favorites: storedFavorites, serverId: hostId }) => {
|
||||
const preferencesKey = "@paseo:create-agent-preferences";
|
||||
const raw = localStorage.getItem(preferencesKey);
|
||||
const preferences = raw ? JSON.parse(raw) : {};
|
||||
localStorage.setItem(
|
||||
preferencesKey,
|
||||
JSON.stringify({ ...preferences, favoriteModels: storedFavorites }),
|
||||
);
|
||||
localStorage.removeItem(`@paseo:legacy-favorites-to-agent-profiles:v1:${hostId}`);
|
||||
|
||||
// Preserve this upgrade-shaped storage across the fixture's next-page
|
||||
// default seed; a reload is the startup boundary under test.
|
||||
const nonce = localStorage.getItem("@paseo:e2e-seed-nonce");
|
||||
if (nonce) {
|
||||
localStorage.setItem("@paseo:e2e-disable-default-seed-once", nonce);
|
||||
}
|
||||
},
|
||||
{ favorites, serverId },
|
||||
);
|
||||
await page.reload();
|
||||
}
|
||||
|
||||
export async function expectHostAgentProfiles(expected: AgentProfile[]): Promise<void> {
|
||||
const client = await connectAgentProfilesClient();
|
||||
try {
|
||||
await expect
|
||||
.poll(async () => (await client.getDaemonConfig()).config.agentProfiles ?? [], {
|
||||
timeout: 30_000,
|
||||
})
|
||||
.toEqual(expected);
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export interface SeededProviderModel {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a second provider that reports a fixed catalog. The E2E daemon has
|
||||
* exactly one provider with models (`mock`), so nothing cross-provider can be
|
||||
* proven without one more.
|
||||
*
|
||||
* `extends: "mock"` is rejected — the config validator only accepts the six
|
||||
* shipped providers plus `acp`. Extending `claude` with a replacement `models`
|
||||
* list is what avoids running anything: replacement models skip catalog
|
||||
* discovery, and Claude's static modes mean the provider never spawns. The
|
||||
* command only has to resolve for the availability probe, hence `node`.
|
||||
* Providers with dynamic catalogs can pass a small RPC fixture as `command`.
|
||||
*/
|
||||
export async function seedModelProvider(input: {
|
||||
id: string;
|
||||
label: string;
|
||||
models: SeededProviderModel[];
|
||||
extends?: "claude" | "pi";
|
||||
command?: string[];
|
||||
}): Promise<HostSeed> {
|
||||
const client = await connectAgentProfilesClient();
|
||||
await client.patchDaemonConfig({
|
||||
providers: {
|
||||
[input.id]: {
|
||||
extends: input.extends ?? "claude",
|
||||
label: input.label,
|
||||
description: `${input.label} test provider`,
|
||||
enabled: true,
|
||||
command: input.command ?? ["node"],
|
||||
models: input.models,
|
||||
},
|
||||
},
|
||||
});
|
||||
return {
|
||||
async restore() {
|
||||
await client.patchDaemonConfig({ removeProviders: [input.id] }).catch(() => undefined);
|
||||
await client.close().catch(() => undefined);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Settings: navigation ──────────────────────────────────────────────────
|
||||
|
||||
/** Reach agent profiles through the same visible Settings path a person uses. */
|
||||
export async function openAgentProfileSettings(page: Page): Promise<void> {
|
||||
const serverId = getServerId();
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, serverId);
|
||||
await page.getByRole("button", { name: "Agents", exact: true }).click();
|
||||
await expectAppRoute(page, buildSettingsHostSectionRoute(serverId, "agents"));
|
||||
await expect(page.getByTestId("agent-profiles-card")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function expectNoAgentProfiles(page: Page): Promise<void> {
|
||||
const card = page.getByTestId("agent-profiles-card");
|
||||
await expect(card.getByTestId("agent-profiles-empty")).toBeVisible();
|
||||
await expect(card.getByRole("button", { name: "New profile", exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
// ─── Settings: the profile list ────────────────────────────────────────────
|
||||
|
||||
export function agentProfileRow(page: Page, name: string): Locator {
|
||||
return page.locator('[data-testid^="agent-profile-row-"]').filter({ hasText: name });
|
||||
}
|
||||
|
||||
export async function expectAgentProfile(
|
||||
page: Page,
|
||||
expected: { name: string; tags: string[]; notes?: string },
|
||||
): Promise<void> {
|
||||
const row = agentProfileRow(page, expected.name);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(row.getByText(expected.tags.join(" · "), { exact: true })).toBeVisible();
|
||||
if (expected.notes !== undefined) {
|
||||
await expect(row.getByText(expected.notes, { exact: true })).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectAgentProfileTagsGone(
|
||||
page: Page,
|
||||
input: { name: string; tags: string[] },
|
||||
): Promise<void> {
|
||||
const row = agentProfileRow(page, input.name);
|
||||
for (const tag of input.tags) {
|
||||
await expect(row.getByText(tag, { exact: true })).toHaveCount(0);
|
||||
}
|
||||
}
|
||||
|
||||
/** Order is what the user sees, so it is measured on screen rather than in the DOM. */
|
||||
export async function expectAgentProfileOrder(page: Page, names: string[]): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const measured = await Promise.all(
|
||||
names.map(async (name) => ({ name, top: await boxTop(agentProfileRow(page, name)) })),
|
||||
);
|
||||
return measured.sort((a, b) => a.top - b.top).map((entry) => entry.name);
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.toEqual(names);
|
||||
}
|
||||
|
||||
export async function moveAgentProfileUp(page: Page, name: string): Promise<void> {
|
||||
await agentProfileRow(page, name).getByRole("button", { name: "Move up", exact: true }).click();
|
||||
}
|
||||
|
||||
export async function moveAgentProfileDown(page: Page, name: string): Promise<void> {
|
||||
await agentProfileRow(page, name).getByRole("button", { name: "Move down", exact: true }).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* The remove confirmation is `window.confirm` on browser web, so the dialog is
|
||||
* consumed here — and its text is asserted, since naming the profile is the
|
||||
* whole point of confirming.
|
||||
*/
|
||||
export async function removeAgentProfile(page: Page, name: string): Promise<void> {
|
||||
const dialogMessage = page.waitForEvent("dialog").then(async (dialog) => {
|
||||
const message = dialog.message();
|
||||
await dialog.accept();
|
||||
return message;
|
||||
});
|
||||
await agentProfileRow(page, name).getByRole("button", { name: "Remove", exact: true }).click();
|
||||
expect(await dialogMessage).toContain(`Remove "${name}"?`);
|
||||
await expect(agentProfileRow(page, name)).toHaveCount(0, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
// ─── Settings: the edit modal ──────────────────────────────────────────────
|
||||
|
||||
export interface AgentProfileDraft {
|
||||
name?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
thinking?: string;
|
||||
mode?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
function editModal(page: Page): Locator {
|
||||
return page.getByTestId("agent-profile-edit-modal");
|
||||
}
|
||||
|
||||
async function chooseSelectFieldOption(
|
||||
page: Page,
|
||||
input: { field: "provider" | "model" | "mode" | "thinking"; option: string },
|
||||
): Promise<void> {
|
||||
await editModal(page).getByTestId(`agent-profile-${input.field}-trigger`).click();
|
||||
const option = page
|
||||
.getByTestId("combobox-desktop-container")
|
||||
.getByRole("button", { name: input.option, exact: true });
|
||||
await expect(option).toBeVisible({ timeout: 30_000 });
|
||||
await option.click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: `${capitalize(input.field)} (${input.option})` }),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
function capitalize(value: string): string {
|
||||
return value.charAt(0).toUpperCase() + value.slice(1);
|
||||
}
|
||||
|
||||
async function fillAgentProfileForm(page: Page, draft: AgentProfileDraft): Promise<void> {
|
||||
const modal = editModal(page);
|
||||
if (draft.name !== undefined) {
|
||||
await modal.getByRole("textbox", { name: "Name", exact: true }).fill(draft.name);
|
||||
}
|
||||
// Provider first: it resets every provider-scoped field below it.
|
||||
if (draft.provider !== undefined) {
|
||||
await chooseSelectFieldOption(page, { field: "provider", option: draft.provider });
|
||||
}
|
||||
// Thinking after model: thinking levels hang off the model, and switching the
|
||||
// model drops a level the new one does not offer.
|
||||
if (draft.model !== undefined) {
|
||||
await chooseSelectFieldOption(page, { field: "model", option: draft.model });
|
||||
}
|
||||
if (draft.thinking !== undefined) {
|
||||
await chooseSelectFieldOption(page, { field: "thinking", option: draft.thinking });
|
||||
}
|
||||
if (draft.mode !== undefined) {
|
||||
await chooseSelectFieldOption(page, { field: "mode", option: draft.mode });
|
||||
}
|
||||
if (draft.notes !== undefined) {
|
||||
await modal.getByRole("textbox", { name: "Notes for agents", exact: true }).fill(draft.notes);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAgentProfile(page: Page): Promise<void> {
|
||||
await editModal(page).getByRole("button", { name: "Save", exact: true }).click();
|
||||
await expect(editModal(page)).toHaveCount(0, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function createAgentProfile(page: Page, draft: AgentProfileDraft): Promise<void> {
|
||||
await page.getByRole("button", { name: "Add agent profile", exact: true }).click();
|
||||
await expect(editModal(page)).toBeVisible({ timeout: 30_000 });
|
||||
await fillAgentProfileForm(page, draft);
|
||||
await saveAgentProfile(page);
|
||||
}
|
||||
|
||||
export async function createAgentProfileFromEmptyState(
|
||||
page: Page,
|
||||
draft: AgentProfileDraft,
|
||||
): Promise<void> {
|
||||
await page
|
||||
.getByTestId("agent-profiles-card")
|
||||
.getByRole("button", { name: "New profile", exact: true })
|
||||
.click();
|
||||
await expect(editModal(page)).toBeVisible({ timeout: 30_000 });
|
||||
await fillAgentProfileForm(page, draft);
|
||||
await saveAgentProfile(page);
|
||||
}
|
||||
|
||||
export async function editAgentProfile(
|
||||
page: Page,
|
||||
name: string,
|
||||
changes: AgentProfileDraft,
|
||||
): Promise<void> {
|
||||
await agentProfileRow(page, name)
|
||||
.getByRole("button", { name: "Edit profile", exact: true })
|
||||
.click();
|
||||
await expect(editModal(page)).toBeVisible({ timeout: 30_000 });
|
||||
await fillAgentProfileForm(page, changes);
|
||||
await saveAgentProfile(page);
|
||||
}
|
||||
|
||||
/** Opens the modal for a profile purely to read back what it stored. */
|
||||
export async function expectAgentProfileForm(
|
||||
page: Page,
|
||||
name: string,
|
||||
expected: { provider?: string; model?: string; mode?: string; notes?: string },
|
||||
): Promise<void> {
|
||||
await agentProfileRow(page, name)
|
||||
.getByRole("button", { name: "Edit profile", exact: true })
|
||||
.click();
|
||||
const modal = editModal(page);
|
||||
await expect(modal).toBeVisible({ timeout: 30_000 });
|
||||
await expect(modal.getByRole("textbox", { name: "Name", exact: true })).toHaveValue(name);
|
||||
for (const [field, value] of Object.entries(expected)) {
|
||||
if (field === "notes") {
|
||||
await expect(
|
||||
modal.getByRole("textbox", { name: "Notes for agents", exact: true }),
|
||||
).toHaveValue(value);
|
||||
continue;
|
||||
}
|
||||
await expect(
|
||||
modal.getByRole("button", { name: `${capitalize(field)} (${value})` }),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
await modal.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await expect(editModal(page)).toHaveCount(0, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
// ─── The model picker ──────────────────────────────────────────────────────
|
||||
|
||||
/** Desktop web renders the model browser inside the combobox popover. */
|
||||
function pickerViewport(page: Page): Locator {
|
||||
return page.getByTestId("combobox-desktop-container");
|
||||
}
|
||||
|
||||
export async function openModelPicker(page: Page): Promise<void> {
|
||||
await page.getByTestId("combined-model-selector").filter({ visible: true }).first().click();
|
||||
await expect(pickerViewport(page)).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function closeModelPicker(page: Page): Promise<void> {
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(pickerViewport(page)).toHaveCount(0, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
export function profilePickerRow(page: Page, name: string): Locator {
|
||||
return pickerViewport(page)
|
||||
.locator('[data-testid^="model-profile-row-"]')
|
||||
.filter({ hasText: name });
|
||||
}
|
||||
|
||||
/**
|
||||
* Profiles are pinned above the provider list, so "pinned" is an ordering claim:
|
||||
* the profile row's top edge sits above every provider row's.
|
||||
*/
|
||||
export async function expectProfilePinnedAboveProviders(
|
||||
page: Page,
|
||||
input: { name: string; summary: string },
|
||||
): Promise<void> {
|
||||
const viewport = pickerViewport(page);
|
||||
await expect(viewport.getByText("Profiles", { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
const row = profilePickerRow(page, input.name);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(row.getByText(input.summary, { exact: true })).toBeVisible();
|
||||
|
||||
const profileTop = await boxTop(row);
|
||||
const providerRows = viewport.locator('[data-testid^="model-provider-"]');
|
||||
await expect(providerRows.first()).toBeVisible();
|
||||
const providerCount = await providerRows.count();
|
||||
for (let index = 0; index < providerCount; index += 1) {
|
||||
expect(profileTop).toBeLessThan(await boxTop(providerRows.nth(index)));
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectProfileVisibleForProvider(
|
||||
page: Page,
|
||||
input: { name: string; summary: string },
|
||||
): Promise<void> {
|
||||
const viewport = pickerViewport(page);
|
||||
await expect(viewport.getByText("Profiles", { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
const row = profilePickerRow(page, input.name);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(row.getByText(input.summary, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectProfileEditIsPencilOnly(page: Page): Promise<void> {
|
||||
const viewport = pickerViewport(page);
|
||||
await expect(
|
||||
viewport.getByRole("button", { name: "Edit agent profiles", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(viewport.getByText("Edit", { exact: true })).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function expectAgentProfilesEmptyPrompt(page: Page): Promise<void> {
|
||||
const viewport = pickerViewport(page);
|
||||
await expect(viewport.getByText("Profiles", { exact: true })).toHaveCount(0);
|
||||
await expect(
|
||||
viewport.getByRole("button", { name: "Edit agent profiles", exact: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(viewport.getByRole("button", { name: "Create profile", exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectProfileEditTooltip(page: Page): Promise<void> {
|
||||
const edit = pickerViewport(page).getByRole("button", {
|
||||
name: "Edit agent profiles",
|
||||
exact: true,
|
||||
});
|
||||
await edit.hover();
|
||||
await expect(page.getByText("Edit agent profiles", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function openAgentProfilesFromEmptyPrompt(page: Page): Promise<void> {
|
||||
await pickerViewport(page).getByRole("button", { name: "Create profile", exact: true }).click();
|
||||
await expect(page.getByTestId("agent-profiles-card")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function boxTop(locator: Locator): Promise<number> {
|
||||
const box = await locator.boundingBox();
|
||||
if (!box) {
|
||||
throw new Error("Expected a laid-out element to measure");
|
||||
}
|
||||
return box.y;
|
||||
}
|
||||
|
||||
export async function applyProfileFromPicker(page: Page, name: string): Promise<void> {
|
||||
await profilePickerRow(page, name).click();
|
||||
await expect(pickerViewport(page)).toHaveCount(0, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Applying a profile materializes it and forgets it: nothing in the root view
|
||||
* claims selection. Model rows do carry `aria-selected`, so a count of zero here
|
||||
* is the absence of a checkmark, not the absence of the attribute everywhere.
|
||||
*/
|
||||
export async function expectNothingSelectedInPickerRoot(page: Page): Promise<void> {
|
||||
await expect(pickerViewport(page).locator("[aria-selected]")).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function expectAgentProfilesEditShortcut(page: Page): Promise<void> {
|
||||
await expect(
|
||||
pickerViewport(page).getByRole("button", { name: "Edit agent profiles", exact: true }),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function drillIntoProvider(page: Page, providerId: string): Promise<void> {
|
||||
await pickerViewport(page).getByTestId(`model-provider-${providerId}`).click();
|
||||
}
|
||||
|
||||
export async function expectModelRowSelected(
|
||||
page: Page,
|
||||
input: { provider: string; modelId: string },
|
||||
): Promise<void> {
|
||||
await expect(
|
||||
pickerViewport(page).getByTestId(`model-row-${input.provider}-${input.modelId}`),
|
||||
).toHaveAttribute("aria-selected", "true", { timeout: 30_000 });
|
||||
}
|
||||
|
||||
// ─── Cross-provider model search ───────────────────────────────────────────
|
||||
|
||||
export async function searchAllModels(page: Page, query: string): Promise<void> {
|
||||
const input = page.getByTestId("model-search-all-input");
|
||||
await expect(input).toBeVisible({ timeout: 30_000 });
|
||||
await input.fill(query);
|
||||
}
|
||||
|
||||
export async function expectModelSearchResult(
|
||||
page: Page,
|
||||
expected: { provider: string; modelId: string; providerLabel: string; modelLabel: string },
|
||||
): Promise<void> {
|
||||
const row = pickerViewport(page).getByTestId(
|
||||
`model-row-${expected.provider}-${expected.modelId}`,
|
||||
);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(row.getByText(expected.modelLabel, { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
row.getByText(new RegExp(`^${escapeForRegex(expected.providerLabel)}\\b`)),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
function escapeForRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/** Search results replace the whole root view, pinned profiles included. */
|
||||
export async function expectPinnedProfilesHidden(page: Page): Promise<void> {
|
||||
await expect(pickerViewport(page).locator('[data-testid^="model-profile-row-"]')).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function expectModelSearchEmptyState(page: Page, query: string): Promise<void> {
|
||||
const empty = page.getByTestId("model-search-empty");
|
||||
await expect(empty).toBeVisible({ timeout: 30_000 });
|
||||
await expect(empty.getByText(`No models match "${query}"`, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function readModelPickerHeight(page: Page): Promise<number> {
|
||||
const box = await pickerViewport(page).boundingBox();
|
||||
if (!box) {
|
||||
throw new Error("Expected the model picker to be laid out");
|
||||
}
|
||||
return box.height;
|
||||
}
|
||||
|
||||
export async function readModelPickerWidth(page: Page): Promise<number> {
|
||||
const box = await pickerViewport(page).boundingBox();
|
||||
if (!box) {
|
||||
throw new Error("Expected the model picker to be laid out");
|
||||
}
|
||||
return box.width;
|
||||
}
|
||||
|
||||
export async function expectModelPickerHeight(page: Page, expectedHeight: number): Promise<void> {
|
||||
await expect.poll(() => readModelPickerHeight(page)).toBe(expectedHeight);
|
||||
}
|
||||
|
||||
export async function expectModelPickerWidth(page: Page, expectedWidth: number): Promise<void> {
|
||||
await expect.poll(() => readModelPickerWidth(page)).toBe(expectedWidth);
|
||||
}
|
||||
|
||||
export async function expectSearchResultsVirtualized(
|
||||
page: Page,
|
||||
input: { provider: string; total: number },
|
||||
): Promise<void> {
|
||||
const mountedRows = pickerViewport(page).locator(`[data-testid^="model-row-${input.provider}-"]`);
|
||||
await expect.poll(() => mountedRows.count()).toBeGreaterThan(0);
|
||||
expect(await mountedRows.count()).toBeLessThan(input.total);
|
||||
}
|
||||
|
||||
// ─── Composer agent controls ───────────────────────────────────────────────
|
||||
|
||||
export async function expectComposerModel(page: Page, modelLabel: string): Promise<void> {
|
||||
await expect(
|
||||
page.getByRole("button", { name: `Select model (${modelLabel})`, exact: true }),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function expectComposerMode(page: Page, modeLabel: string): Promise<void> {
|
||||
await expect(
|
||||
page.getByRole("button", { name: `Select agent mode (${modeLabel})`, exact: true }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
/** The composer never shows the profile's name — a profile is not a selection. */
|
||||
export async function expectComposerDoesNotName(page: Page, profileName: string): Promise<void> {
|
||||
await expect(
|
||||
page.locator('[data-testid="message-input-root"]:visible').getByText(profileName),
|
||||
).toHaveCount(0);
|
||||
}
|
||||
@@ -31,6 +31,82 @@ export interface PromptJumpRequestTracker {
|
||||
requests(): Array<{ cursorSeq: number | null; limit: number | null; mergeWindow: boolean }>;
|
||||
}
|
||||
|
||||
export interface TimelineRequestTracker {
|
||||
nextRequest(): Promise<{
|
||||
direction: string | null;
|
||||
cursor: { epoch: string; seq: number } | null;
|
||||
}>;
|
||||
requests(): Array<{
|
||||
direction: string | null;
|
||||
cursor: { epoch: string; seq: number } | null;
|
||||
}>;
|
||||
waitForResponse(): Promise<void>;
|
||||
}
|
||||
|
||||
export async function trackAgentTimelineRequests(
|
||||
page: Page,
|
||||
agentId: string,
|
||||
): Promise<TimelineRequestTracker> {
|
||||
type Request = ReturnType<TimelineRequestTracker["requests"]>[number];
|
||||
const seen: Request[] = [];
|
||||
const waiters: Array<(request: Request) => void> = [];
|
||||
let responseSeen = false;
|
||||
let resolveResponse: (() => void) | null = null;
|
||||
const response = new Promise<void>((resolve) => {
|
||||
resolveResponse = resolve;
|
||||
});
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
ws.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
if (
|
||||
sessionMessage?.type === "fetch_agent_timeline_request" &&
|
||||
sessionMessage.agentId === agentId
|
||||
) {
|
||||
const rawCursor = sessionMessage.cursor;
|
||||
const cursor =
|
||||
rawCursor &&
|
||||
typeof rawCursor === "object" &&
|
||||
typeof (rawCursor as { epoch?: unknown }).epoch === "string" &&
|
||||
typeof (rawCursor as { seq?: unknown }).seq === "number"
|
||||
? {
|
||||
epoch: (rawCursor as { epoch: string }).epoch,
|
||||
seq: (rawCursor as { seq: number }).seq,
|
||||
}
|
||||
: null;
|
||||
const request = {
|
||||
direction: typeof sessionMessage.direction === "string" ? sessionMessage.direction : null,
|
||||
cursor,
|
||||
};
|
||||
seen.push(request);
|
||||
waiters.shift()?.(request);
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
server.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
const payload = sessionMessage ? getPayload(sessionMessage) : null;
|
||||
if (
|
||||
sessionMessage?.type === "fetch_agent_timeline_response" &&
|
||||
payload?.agentId === agentId
|
||||
) {
|
||||
responseSeen = true;
|
||||
resolveResponse?.();
|
||||
}
|
||||
ws.send(message);
|
||||
});
|
||||
});
|
||||
return {
|
||||
nextRequest() {
|
||||
const request = seen[0];
|
||||
if (request) return Promise.resolve(request);
|
||||
return new Promise((resolve) => waiters.push(resolve));
|
||||
},
|
||||
requests: () => [...seen],
|
||||
waitForResponse: () => (responseSeen ? Promise.resolve() : response),
|
||||
};
|
||||
}
|
||||
|
||||
export async function trackPromptJumpRequests(
|
||||
page: Page,
|
||||
agentId: string,
|
||||
|
||||
@@ -26,7 +26,7 @@ function buildSeededStoragePayload() {
|
||||
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||
nowIso,
|
||||
}),
|
||||
preferences: buildCreateAgentPreferences(getServerId()),
|
||||
preferences: buildCreateAgentPreferences(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { daemonWsRoutePattern } from "./daemon-port";
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
interface SurfaceRect {
|
||||
height: number;
|
||||
width: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface LayoutObservation {
|
||||
active: boolean;
|
||||
layoutShift: number;
|
||||
samples: Record<string, SurfaceRect[]>;
|
||||
}
|
||||
|
||||
type LayoutOracleWindow = Window & {
|
||||
__commandCenterLayoutObservation?: LayoutObservation;
|
||||
};
|
||||
|
||||
const SURFACE_SELECTORS = {
|
||||
panel: '[data-testid="command-center-panel"]',
|
||||
header: '[data-testid="command-center-header"]',
|
||||
input: '[data-testid="command-center-input"]',
|
||||
fileSearchStatus: '[data-testid="command-center-file-search-status"]',
|
||||
results: '[data-testid="command-center-results"]',
|
||||
scope: '[data-testid="command-center-files-scope"]',
|
||||
fileRow: '[data-testid^="command-center-file-row-"]',
|
||||
fileIcon: '[data-testid="command-center-file-icon"]',
|
||||
fileLine: '[data-testid="command-center-file-line"]',
|
||||
} as const;
|
||||
|
||||
function parseSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
|
||||
const raw = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
const envelope = JSON.parse(raw) as { type?: unknown; message?: unknown };
|
||||
return envelope.type === "session" && envelope.message && typeof envelope.message === "object"
|
||||
? (envelope.message as Record<string, unknown>)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function delayDirectorySuggestionResponses(page: Page, delayMs = 400): Promise<void> {
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
ws.onMessage((message) => server.send(message));
|
||||
server.onMessage((message) => {
|
||||
const sessionMessage = parseSessionMessage(message);
|
||||
if (sessionMessage?.type === "directory_suggestions_response") {
|
||||
setTimeout(() => ws.send(message), delayMs);
|
||||
return;
|
||||
}
|
||||
ws.send(message);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function failDirectorySuggestionRequests(page: Page): Promise<void> {
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
ws.onMessage((message) => {
|
||||
const sessionMessage = parseSessionMessage(message);
|
||||
if (sessionMessage?.type === "directory_suggestions_request") {
|
||||
const requestId = sessionMessage.requestId;
|
||||
if (typeof requestId === "string") {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "rpc_error",
|
||||
payload: {
|
||||
requestId,
|
||||
requestType: "directory_suggestions_request",
|
||||
error: "Test file search transport failure.",
|
||||
code: "transport",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
server.onMessage((message) => ws.send(message));
|
||||
});
|
||||
}
|
||||
|
||||
export async function startCommandCenterLayoutObservation(page: Page): Promise<void> {
|
||||
await page.evaluate((selectors) => {
|
||||
const oracleWindow = window as LayoutOracleWindow;
|
||||
const observation: LayoutObservation = { active: true, layoutShift: 0, samples: {} };
|
||||
oracleWindow.__commandCenterLayoutObservation = observation;
|
||||
|
||||
const observedElements = new WeakSet<Element>();
|
||||
const resizeObserver = new ResizeObserver(record);
|
||||
const mutationObserver = new MutationObserver(discover);
|
||||
|
||||
function record(): void {
|
||||
if (!observation.active) return;
|
||||
for (const [name, selector] of Object.entries(selectors)) {
|
||||
const element = document.querySelector<HTMLElement>(selector);
|
||||
if (!element) continue;
|
||||
const rect = element.getBoundingClientRect();
|
||||
(observation.samples[name] ??= []).push({
|
||||
height: rect.height,
|
||||
width: rect.width,
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function discover(): void {
|
||||
for (const selector of Object.values(selectors)) {
|
||||
const element = document.querySelector(selector);
|
||||
if (!element || observedElements.has(element)) continue;
|
||||
observedElements.add(element);
|
||||
resizeObserver.observe(element);
|
||||
}
|
||||
record();
|
||||
}
|
||||
|
||||
mutationObserver.observe(document.body, { attributes: true, childList: true, subtree: true });
|
||||
discover();
|
||||
|
||||
const sampleFrame = () => {
|
||||
if (!observation.active) {
|
||||
mutationObserver.disconnect();
|
||||
resizeObserver.disconnect();
|
||||
return;
|
||||
}
|
||||
record();
|
||||
requestAnimationFrame(sampleFrame);
|
||||
};
|
||||
requestAnimationFrame(sampleFrame);
|
||||
|
||||
if (typeof PerformanceObserver !== "undefined") {
|
||||
try {
|
||||
const performanceObserver = new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
observation.layoutShift += (entry as PerformanceEntry & { value?: number }).value ?? 0;
|
||||
}
|
||||
});
|
||||
performanceObserver.observe({ type: "layout-shift", buffered: false });
|
||||
} catch {
|
||||
// LayoutShift is diagnostic-only on engines that do not expose it.
|
||||
}
|
||||
}
|
||||
}, SURFACE_SELECTORS);
|
||||
}
|
||||
|
||||
export async function expectStableCommandCenterLayout(page: Page): Promise<void> {
|
||||
await page.waitForTimeout(500);
|
||||
const observation = await page.evaluate(() => {
|
||||
const state = (window as LayoutOracleWindow).__commandCenterLayoutObservation;
|
||||
if (!state) throw new Error("Command center layout observer was not installed");
|
||||
state.active = false;
|
||||
return state;
|
||||
});
|
||||
|
||||
for (const surface of Object.keys(SURFACE_SELECTORS)) {
|
||||
const samples = observation.samples[surface] ?? [];
|
||||
expect(samples.length, `${surface}: ${JSON.stringify(observation)}`).toBeGreaterThan(0);
|
||||
for (const dimension of ["height", "width", "x", "y"] as const) {
|
||||
const values = samples.map((sample) => sample[dimension]);
|
||||
expect(
|
||||
Math.max(...values) - Math.min(...values),
|
||||
`${surface}.${dimension}: ${JSON.stringify(samples)}`,
|
||||
).toBeLessThanOrEqual(1);
|
||||
}
|
||||
}
|
||||
expect(observation.layoutShift, JSON.stringify(observation)).toBeLessThanOrEqual(0.001);
|
||||
}
|
||||
|
||||
export async function expectOneLineTruncatedFileResult(
|
||||
row: Locator,
|
||||
input: { filename: string; path: string },
|
||||
): Promise<void> {
|
||||
await expect(row).toContainText(`${input.filename} ${input.path}`);
|
||||
await expect(row.getByTestId("command-center-file-icon")).toBeVisible();
|
||||
const line = row.getByTestId("command-center-file-line");
|
||||
const filename = row.getByTestId("command-center-file-name");
|
||||
const filePath = row.getByTestId("command-center-file-path");
|
||||
const geometry = await Promise.all([
|
||||
row.boundingBox(),
|
||||
line.boundingBox(),
|
||||
filename.boundingBox(),
|
||||
filePath.boundingBox(),
|
||||
line.evaluate((element) => ({
|
||||
clientWidth: element.clientWidth,
|
||||
overflow: getComputedStyle(element).overflow,
|
||||
scrollWidth: element.scrollWidth,
|
||||
whiteSpace: getComputedStyle(element).whiteSpace,
|
||||
})),
|
||||
]);
|
||||
const [rowBox, lineBox, filenameBox, pathBox, overflow] = geometry;
|
||||
expect(rowBox).not.toBeNull();
|
||||
expect(lineBox).not.toBeNull();
|
||||
expect(filenameBox).not.toBeNull();
|
||||
expect(pathBox).not.toBeNull();
|
||||
expect(rowBox?.height).toBe(36);
|
||||
expect(Math.abs((filenameBox?.y ?? 0) - (pathBox?.y ?? 0))).toBeLessThanOrEqual(1);
|
||||
expect(overflow.scrollWidth).toBeGreaterThan(overflow.clientWidth);
|
||||
expect(overflow.overflow).toBe("hidden");
|
||||
expect(overflow.whiteSpace).toBe("nowrap");
|
||||
}
|
||||
@@ -1,14 +1,23 @@
|
||||
import type {
|
||||
FormPreferences,
|
||||
ProviderPreferences,
|
||||
} from "../../../src/create-agent-preferences/preferences";
|
||||
|
||||
export const TEST_HOST_LABEL = "localhost";
|
||||
|
||||
export const TEST_PROVIDER_PREFERENCES = {
|
||||
claude: { model: "haiku" },
|
||||
codex: { model: "gpt-5.4-mini", thinkingOptionId: "low" },
|
||||
} as const;
|
||||
codex: { model: "gpt-5.4-mini", thinkingByModel: { "gpt-5.4-mini": "low" } },
|
||||
} satisfies Record<string, ProviderPreferences>;
|
||||
|
||||
export function buildDirectTcpConnection(endpoint: string) {
|
||||
export function buildDirectTcpConnection(endpoint: string): {
|
||||
id: string;
|
||||
type: "directTcp";
|
||||
endpoint: string;
|
||||
} {
|
||||
return {
|
||||
id: `direct:${endpoint}`,
|
||||
type: "directTcp" as const,
|
||||
type: "directTcp",
|
||||
endpoint,
|
||||
};
|
||||
}
|
||||
@@ -33,12 +42,11 @@ export function buildSeededHost(input: {
|
||||
export const TEST_MOCK_PROVIDER_PREFERENCES = {
|
||||
...TEST_PROVIDER_PREFERENCES,
|
||||
mock: { model: "ten-second-stream" },
|
||||
} as const;
|
||||
} satisfies Record<string, ProviderPreferences>;
|
||||
|
||||
export function buildCreateAgentPreferences(serverId: string) {
|
||||
export function buildCreateAgentPreferences() {
|
||||
return {
|
||||
serverId,
|
||||
provider: "mock" as const,
|
||||
provider: "mock",
|
||||
providerPreferences: TEST_MOCK_PROVIDER_PREFERENCES,
|
||||
};
|
||||
} satisfies FormPreferences;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import type { MockAgentWorkspace } from "./mock-agent";
|
||||
|
||||
const DIAGRAM_NAME = "Diagram";
|
||||
|
||||
function renderedDiagram(page: Page) {
|
||||
const diagram = page.getByRole("img", { name: DIAGRAM_NAME }).last();
|
||||
const svg = diagram.locator("iframe").contentFrame().locator("#diagram svg");
|
||||
return { diagram, svg };
|
||||
}
|
||||
|
||||
export async function requestDiagram(agent: MockAgentWorkspace): Promise<void> {
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Render the requested Mermaid diagram.");
|
||||
}
|
||||
|
||||
export async function expectDiagramWithLabels(
|
||||
page: Page,
|
||||
labels: readonly string[],
|
||||
): Promise<void> {
|
||||
const { diagram, svg } = renderedDiagram(page);
|
||||
await expect(diagram).toBeVisible({ timeout: 30_000 });
|
||||
await expect(svg).toBeVisible({ timeout: 30_000 });
|
||||
for (const label of labels) {
|
||||
await expect(svg).toContainText(label);
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectDiagramRemainsRenderedWhileStreaming(page: Page): Promise<void> {
|
||||
const { diagram, svg } = renderedDiagram(page);
|
||||
const samples = 80;
|
||||
for (let sample = 0; sample < samples; sample += 1) {
|
||||
await expect(diagram).toBeVisible({ timeout: 100 });
|
||||
await expect(svg).toBeVisible({ timeout: 500 });
|
||||
await page.waitForTimeout(25);
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForDiagramTurnToComplete(agent: MockAgentWorkspace): Promise<void> {
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
}
|
||||
|
||||
export async function expectCompletedDiagram(page: Page, labels: readonly string[]): Promise<void> {
|
||||
await expectDiagramWithLabels(page, labels);
|
||||
}
|
||||
|
||||
export async function reloadConversation(page: Page): Promise<void> {
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
}
|
||||
@@ -24,6 +24,8 @@ type NewWorkspaceDaemonClient = Pick<
|
||||
| "on"
|
||||
| "patchDaemonConfig"
|
||||
| "removeProject"
|
||||
| "removePlugin"
|
||||
| "reloadPlugin"
|
||||
>;
|
||||
|
||||
type CreateWorkspacePayload = Awaited<ReturnType<NewWorkspaceDaemonClient["createWorkspace"]>>;
|
||||
|
||||
@@ -17,6 +17,49 @@ interface PairingHostInput {
|
||||
endpoint: string;
|
||||
}
|
||||
|
||||
export async function prepareLocalPairingHost(
|
||||
page: Page,
|
||||
daemon: IsolatedHostDaemon | OutdatedDaemon,
|
||||
additionalHosts: PairingHostInput[] = [],
|
||||
): Promise<void> {
|
||||
await page.addInitScript((localServerId) => {
|
||||
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = {
|
||||
platform: "darwin",
|
||||
invoke: async (command: string) => {
|
||||
if (command === "desktop_daemon_status") {
|
||||
return {
|
||||
serverId: localServerId,
|
||||
status: "running",
|
||||
listen: null,
|
||||
hostname: null,
|
||||
pid: null,
|
||||
home: "",
|
||||
version: null,
|
||||
desktopManaged: true,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
if (command === "get_desktop_settings") {
|
||||
return {
|
||||
releaseChannel: "stable",
|
||||
daemon: { manageBuiltInDaemon: false, keepRunningAfterQuit: true },
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
getPendingOpenProject: async () => null,
|
||||
events: { on: async () => () => undefined },
|
||||
opener: {
|
||||
openUrl: async (url: string) => {
|
||||
localStorage.setItem("@paseo:e2e-opened-url", url);
|
||||
},
|
||||
},
|
||||
};
|
||||
}, daemon.serverId);
|
||||
|
||||
await preparePairingHost(page, daemon, additionalHosts);
|
||||
}
|
||||
|
||||
export async function preparePairingHost(
|
||||
page: Page,
|
||||
daemon: IsolatedHostDaemon | OutdatedDaemon,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { chmod, readFile, writeFile } from "node:fs/promises";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import type { WebSocketRoute } from "@playwright/test";
|
||||
@@ -177,6 +178,19 @@ export async function expectSaveButtonDisabled(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("save-button")).toBeDisabled();
|
||||
}
|
||||
|
||||
export async function expectUncommittedSetupWarning(page: Page): Promise<void> {
|
||||
const warning = page.getByRole("alert").filter({ hasText: "Commit paseo.json changes" });
|
||||
await expect(warning).toContainText("Commit paseo.json changes");
|
||||
await expect(warning).toContainText(
|
||||
"New worktrees use the setup script from the base branch you select.",
|
||||
);
|
||||
}
|
||||
|
||||
export async function expectNoUncommittedSetupWarning(page: Page): Promise<void> {
|
||||
const warning = page.getByRole("alert").filter({ hasText: "Commit paseo.json changes" });
|
||||
await expect(warning).toHaveCount(0);
|
||||
}
|
||||
|
||||
// --- Form-state assertions ---
|
||||
|
||||
export async function expectProjectSettingsFormVisible(page: Page): Promise<void> {
|
||||
@@ -249,6 +263,11 @@ export async function restorePaseoConfig(
|
||||
await writeFile(path.join(repoPath, "paseo.json"), JSON.stringify(config, null, 2) + "\n");
|
||||
}
|
||||
|
||||
export function commitPaseoConfig(repoPath: string): void {
|
||||
execFileSync("git", ["add", "paseo.json"], { cwd: repoPath });
|
||||
execFileSync("git", ["commit", "-m", "Update project config"], { cwd: repoPath });
|
||||
}
|
||||
|
||||
// The daemon writes atomically via a temp file + rename, so blocking writes requires
|
||||
// removing write permission from the *directory*, not just the file.
|
||||
export async function blockPaseoConfigWrites(repoPath: string): Promise<void> {
|
||||
|
||||
@@ -60,7 +60,7 @@ export async function connectDaemonWebAppOnlyThroughRelay(
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([storedHost]));
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||
},
|
||||
{ storedHost: host, preferences: buildCreateAgentPreferences(offer.serverId) },
|
||||
{ storedHost: host, preferences: buildCreateAgentPreferences() },
|
||||
);
|
||||
|
||||
const relaySocketOpened = new Promise<void>((resolve) => {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import {
|
||||
observeReplicaCacheStorageWrites,
|
||||
readReplicaCacheStorageWriteObserver,
|
||||
resetReplicaCacheStorageWriteObserver,
|
||||
} from "./replica-cache-storage";
|
||||
|
||||
export interface ReplicaCachePerfReport {
|
||||
elapsedMs: number;
|
||||
redundantWrites: number;
|
||||
serializedChars: number;
|
||||
storageDurationMs: number;
|
||||
writes: number;
|
||||
}
|
||||
|
||||
interface ReplicaCachePerfState {
|
||||
resetAt: number;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__replicaCachePerf?: ReplicaCachePerfState;
|
||||
}
|
||||
}
|
||||
|
||||
export async function observeReplicaCacheWrites(page: Page): Promise<void> {
|
||||
await observeReplicaCacheStorageWrites(page);
|
||||
await page.addInitScript(() => {
|
||||
window.__replicaCachePerf = { resetAt: performance.now() };
|
||||
});
|
||||
}
|
||||
|
||||
export async function beginReplicaCacheMeasurement(page: Page): Promise<void> {
|
||||
await resetReplicaCacheStorageWriteObserver(page);
|
||||
await page.evaluate(() => {
|
||||
window.__replicaCachePerf = { resetAt: performance.now() };
|
||||
});
|
||||
}
|
||||
|
||||
export async function readReplicaCacheMeasurement(page: Page): Promise<ReplicaCachePerfReport> {
|
||||
const storage = await readReplicaCacheStorageWriteObserver(page);
|
||||
const elapsedMs = await page.evaluate(() => {
|
||||
const state = window.__replicaCachePerf;
|
||||
if (!state) throw new Error("Replica cache performance observer is not installed");
|
||||
return performance.now() - state.resetAt;
|
||||
});
|
||||
return { elapsedMs, ...storage };
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
const DATABASE_NAME = "paseo-replica-cache";
|
||||
const STORE_NAME = "key-value";
|
||||
const STORAGE_KEY = "@paseo:replica-cache";
|
||||
|
||||
export interface ReplicaCacheRecord {
|
||||
version?: number;
|
||||
hosts?: Array<{
|
||||
timeline?: {
|
||||
agentId?: string;
|
||||
items?: Array<Record<string, unknown>>;
|
||||
range?: {
|
||||
endSeq?: number;
|
||||
epoch?: string;
|
||||
startSeq?: number;
|
||||
} | null;
|
||||
} | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface ReplicaCacheWriteObserverState {
|
||||
lastValue: string | null;
|
||||
redundantWrites: number;
|
||||
serializedChars: number;
|
||||
storageDurationMs: number;
|
||||
writes: number;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__replicaCacheWriteObserver?: ReplicaCacheWriteObserverState;
|
||||
}
|
||||
}
|
||||
|
||||
async function readStoredValue(input: {
|
||||
databaseName: string;
|
||||
storeName: string;
|
||||
storageKey: string;
|
||||
}): Promise<string | null> {
|
||||
const database = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open(input.databaseName, 1);
|
||||
request.addEventListener("success", () => resolve(request.result));
|
||||
request.addEventListener("error", () => reject(request.error));
|
||||
});
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = database
|
||||
.transaction(input.storeName, "readonly")
|
||||
.objectStore(input.storeName)
|
||||
.get(input.storageKey);
|
||||
request.addEventListener("success", () =>
|
||||
resolve(typeof request.result === "string" ? request.result : null),
|
||||
);
|
||||
request.addEventListener("error", () => reject(request.error));
|
||||
});
|
||||
}
|
||||
|
||||
export async function readReplicaCache(page: Page): Promise<ReplicaCacheRecord | null> {
|
||||
const raw = await page.evaluate(readStoredValue, {
|
||||
databaseName: DATABASE_NAME,
|
||||
storeName: STORE_NAME,
|
||||
storageKey: STORAGE_KEY,
|
||||
});
|
||||
return raw ? (JSON.parse(raw) as ReplicaCacheRecord) : null;
|
||||
}
|
||||
|
||||
export async function writeReplicaCache(page: Page, value: ReplicaCacheRecord): Promise<void> {
|
||||
await page.evaluate(
|
||||
async ({ databaseName, storeName, storageKey, raw }) => {
|
||||
const database = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open(databaseName, 1);
|
||||
request.addEventListener("success", () => resolve(request.result));
|
||||
request.addEventListener("error", () => reject(request.error));
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const transaction = database.transaction(storeName, "readwrite");
|
||||
transaction.objectStore(storeName).put(raw, storageKey);
|
||||
transaction.addEventListener("complete", () => resolve());
|
||||
transaction.addEventListener("error", () => reject(transaction.error));
|
||||
transaction.addEventListener("abort", () => reject(transaction.error));
|
||||
});
|
||||
},
|
||||
{
|
||||
databaseName: DATABASE_NAME,
|
||||
storeName: STORE_NAME,
|
||||
storageKey: STORAGE_KEY,
|
||||
raw: JSON.stringify(value),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function observeReplicaCacheStorageWrites(page: Page): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ databaseName, storeName, storageKey }) => {
|
||||
window.__replicaCacheWriteObserver = {
|
||||
lastValue: null,
|
||||
redundantWrites: 0,
|
||||
serializedChars: 0,
|
||||
storageDurationMs: 0,
|
||||
writes: 0,
|
||||
};
|
||||
const originalPut = IDBObjectStore.prototype.put;
|
||||
IDBObjectStore.prototype.put = function measuredPut(value: unknown, key?: IDBValidKey) {
|
||||
const request =
|
||||
key === undefined ? originalPut.call(this, value) : originalPut.call(this, value, key);
|
||||
if (
|
||||
this.transaction.db.name !== databaseName ||
|
||||
this.name !== storeName ||
|
||||
key !== storageKey ||
|
||||
typeof value !== "string"
|
||||
) {
|
||||
return request;
|
||||
}
|
||||
const startedAt = performance.now();
|
||||
request.addEventListener("success", () => {
|
||||
const state = window.__replicaCacheWriteObserver;
|
||||
if (!state) return;
|
||||
if (state.lastValue === value) state.redundantWrites += 1;
|
||||
state.lastValue = value;
|
||||
state.writes += 1;
|
||||
state.serializedChars += value.length;
|
||||
state.storageDurationMs += performance.now() - startedAt;
|
||||
});
|
||||
return request;
|
||||
};
|
||||
},
|
||||
{ databaseName: DATABASE_NAME, storeName: STORE_NAME, storageKey: STORAGE_KEY },
|
||||
);
|
||||
}
|
||||
|
||||
export async function resetReplicaCacheStorageWriteObserver(page: Page): Promise<void> {
|
||||
const lastValue = await page.evaluate(readStoredValue, {
|
||||
databaseName: DATABASE_NAME,
|
||||
storeName: STORE_NAME,
|
||||
storageKey: STORAGE_KEY,
|
||||
});
|
||||
await page.evaluate((value) => {
|
||||
window.__replicaCacheWriteObserver = {
|
||||
lastValue: value,
|
||||
redundantWrites: 0,
|
||||
serializedChars: 0,
|
||||
storageDurationMs: 0,
|
||||
writes: 0,
|
||||
};
|
||||
}, lastValue);
|
||||
}
|
||||
|
||||
export async function readReplicaCacheStorageWriteObserver(
|
||||
page: Page,
|
||||
): Promise<ReplicaCacheWriteObserverState> {
|
||||
return page.evaluate(() => {
|
||||
const state = window.__replicaCacheWriteObserver;
|
||||
if (!state) throw new Error("Replica cache write observer is not installed");
|
||||
return state;
|
||||
});
|
||||
}
|
||||
@@ -42,6 +42,7 @@ export interface SeedDaemonClient {
|
||||
fetchWorkspaces(options?: { filter?: { projectId?: string } }): Promise<{
|
||||
entries: SeedWorkspaceDescriptor[];
|
||||
}>;
|
||||
setWorkspacePinned(workspaceId: string, pinned: boolean): Promise<{ pinnedAt: string | null }>;
|
||||
listProjects(): Promise<{ projects: SeedProjectDescriptor[] }>;
|
||||
createWorkspace(input: {
|
||||
source:
|
||||
|
||||
@@ -36,9 +36,12 @@ type HostSection =
|
||||
| "connections"
|
||||
| "pair-device"
|
||||
| "agents"
|
||||
| "metadata"
|
||||
| "workspaces"
|
||||
| "providers"
|
||||
| "usage"
|
||||
| "terminals"
|
||||
| "plugins"
|
||||
| "host";
|
||||
|
||||
export async function openSettingsSection(page: Page, section: SettingsSection): Promise<void> {
|
||||
@@ -130,7 +133,7 @@ export async function seedSavedSettingsHosts(
|
||||
if (!firstHost) {
|
||||
throw new Error("Expected at least one settings host fixture.");
|
||||
}
|
||||
const preferences = buildCreateAgentPreferences(firstHost.serverId);
|
||||
const preferences = buildCreateAgentPreferences();
|
||||
|
||||
await page.evaluate(
|
||||
({ keys, storedRegistry, storedPreferences }) => {
|
||||
|
||||
@@ -106,7 +106,7 @@ class StartupScenario {
|
||||
if (!firstHost) {
|
||||
throw new Error("Expected at least one startup test host.");
|
||||
}
|
||||
const createAgentPreferences = buildStoredCreateAgentPreferences(firstHost.serverId);
|
||||
const createAgentPreferences = buildStoredCreateAgentPreferences();
|
||||
|
||||
await this.page.evaluate(
|
||||
({ keys, registry: storedRegistry, createAgentPreferences: storedPreferences }) => {
|
||||
@@ -245,6 +245,6 @@ function buildStoredHost(input: {
|
||||
return buildSeededHost(input);
|
||||
}
|
||||
|
||||
function buildStoredCreateAgentPreferences(serverId: string) {
|
||||
return buildCreateAgentPreferences(serverId);
|
||||
function buildStoredCreateAgentPreferences() {
|
||||
return buildCreateAgentPreferences();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { daemonWsRoutePattern } from "./daemon-port";
|
||||
import type { SeededWorkspace } from "./seed-client";
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
export interface ManagedSubagentArchiveGate {
|
||||
release(): void;
|
||||
waitForRequest(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface ManagedSubagentArchiveRejection {
|
||||
waitForRejection(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface SeededSubagentPair {
|
||||
parent: {
|
||||
id: string;
|
||||
@@ -132,6 +144,147 @@ export async function expectSubagentRowGone(page: Page, childId: string): Promis
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectManagedSubagentUnarchived(
|
||||
workspace: Pick<SeededWorkspace, "client">,
|
||||
childId: string,
|
||||
): Promise<void> {
|
||||
await expect(workspace.client.fetchAgent({ agentId: childId })).resolves.toMatchObject({
|
||||
agent: { id: childId, archivedAt: null },
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectManagedSubagentArchived(
|
||||
workspace: Pick<SeededWorkspace, "client">,
|
||||
childId: string,
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(() => workspace.client.fetchAgent({ agentId: childId }), { timeout: 30_000 })
|
||||
.toMatchObject({ agent: { id: childId, archivedAt: expect.any(String) } });
|
||||
}
|
||||
|
||||
export async function archiveFinishedSubagents(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Archive finished subagents" }).click();
|
||||
}
|
||||
|
||||
export async function expectArchiveFinishedInProgress(
|
||||
page: Page,
|
||||
completed: number,
|
||||
total: number,
|
||||
): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "Archive finished subagents" })).toBeDisabled();
|
||||
await expect(page.getByText(`${completed}/${total}`, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectArchiveFinishedRetry(
|
||||
page: Page,
|
||||
failed: number,
|
||||
total: number,
|
||||
): Promise<void> {
|
||||
await expect(page.getByText(`Retry (${failed}/${total})`, { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Archive finished subagents" })).toBeEnabled();
|
||||
}
|
||||
|
||||
export async function holdManagedSubagentArchiveRequest(
|
||||
page: Page,
|
||||
subagentId: string,
|
||||
): Promise<ManagedSubagentArchiveGate> {
|
||||
let released = false;
|
||||
let resolveRequest: (() => void) | undefined;
|
||||
const delayedForwards: Array<() => void> = [];
|
||||
const request = new Promise<void>((resolve) => {
|
||||
resolveRequest = resolve;
|
||||
});
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
ws.onMessage((message) => {
|
||||
if (isArchiveRequestForSubagent(message, subagentId) && !released) {
|
||||
delayedForwards.push(() => server.send(message));
|
||||
resolveRequest?.();
|
||||
return;
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
server.onMessage((message) => ws.send(message));
|
||||
});
|
||||
|
||||
return {
|
||||
release() {
|
||||
released = true;
|
||||
for (const forward of delayedForwards.splice(0)) forward();
|
||||
},
|
||||
waitForRequest: () => request,
|
||||
};
|
||||
}
|
||||
|
||||
export async function rejectNextManagedSubagentArchiveRequest(
|
||||
page: Page,
|
||||
subagentId: string,
|
||||
): Promise<ManagedSubagentArchiveRejection> {
|
||||
let resolveRejection: (() => void) | undefined;
|
||||
const rejection = new Promise<void>((resolve) => {
|
||||
resolveRejection = resolve;
|
||||
});
|
||||
let rejected = false;
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
ws.onMessage((message) => {
|
||||
const requestId = archiveRequestId(message, subagentId);
|
||||
if (requestId && !rejected) {
|
||||
rejected = true;
|
||||
// A daemon-rejected archive RPC: the request never reaches the daemon, and its normal
|
||||
// correlated error travels back through the real browser client and mutation rollback.
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "rpc_error",
|
||||
payload: {
|
||||
requestId,
|
||||
requestType: "archive_agent_request",
|
||||
error: "Archive rejected for test",
|
||||
code: "archive_rejected",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
resolveRejection?.();
|
||||
return;
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
server.onMessage((message) => ws.send(message));
|
||||
});
|
||||
|
||||
return { waitForRejection: () => rejection };
|
||||
}
|
||||
|
||||
function isArchiveRequestForSubagent(message: WebSocketMessage, subagentId: string): boolean {
|
||||
return archiveRequestId(message, subagentId) !== null;
|
||||
}
|
||||
|
||||
function archiveRequestId(message: WebSocketMessage, subagentId: string): string | null {
|
||||
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
const envelope = JSON.parse(rawMessage) as {
|
||||
type?: unknown;
|
||||
message?: { type?: unknown; agentId?: unknown; requestId?: unknown };
|
||||
};
|
||||
if (
|
||||
envelope.type === "session" &&
|
||||
envelope.message?.type === "archive_agent_request" &&
|
||||
envelope.message.agentId === subagentId &&
|
||||
typeof envelope.message.requestId === "string"
|
||||
) {
|
||||
return envelope.message.requestId;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function detachSubagentFromTrack(page: Page, childId: string): Promise<void> {
|
||||
const row = page.getByTestId(`subagents-track-row-${childId}`);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
export async function openCompletedTaskList(page: Page, count: number): Promise<void> {
|
||||
const taskButton = page.getByRole("button", { name: `${count}/${count} tasks` });
|
||||
await expect(taskButton).toBeVisible({ timeout: 60_000 });
|
||||
await taskButton.click();
|
||||
}
|
||||
|
||||
export async function expectTaskListEntries(page: Page, entries: readonly string[]): Promise<void> {
|
||||
for (const entry of entries) {
|
||||
await expect(page.getByLabel(entry, { exact: true })).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectCompletedTaskActivity(
|
||||
page: Page,
|
||||
entries: readonly string[],
|
||||
): Promise<void> {
|
||||
for (const entry of entries) {
|
||||
await expect(
|
||||
page.getByRole("button", { name: `Completed ${entry}`, exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
|
||||
import { readReplicaCache } from "./replica-cache-storage";
|
||||
import {
|
||||
delayAgentBootstrapTailResponse,
|
||||
delayAgentOlderTimelineResponse,
|
||||
@@ -39,6 +40,12 @@ interface TimelineViewportSnapshot {
|
||||
scrollTop: number;
|
||||
}
|
||||
|
||||
interface PersistedCanonicalTimelineRange {
|
||||
epoch: string;
|
||||
startSeq: number;
|
||||
endSeq: number;
|
||||
}
|
||||
|
||||
export interface TimelinePresentationSnapshot {
|
||||
marker: string;
|
||||
position: TimelinePromptPositionSnapshot;
|
||||
@@ -284,28 +291,45 @@ export async function reloadAgentTimelineFromPersistedReplica(
|
||||
agent: LongTimelineAgent,
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate((agentId) => {
|
||||
const raw = localStorage.getItem("@paseo:replica-cache");
|
||||
if (!raw) return false;
|
||||
const cache = JSON.parse(raw) as {
|
||||
hosts?: Array<{
|
||||
timeline?: {
|
||||
agentId?: string;
|
||||
items?: unknown[];
|
||||
} | null;
|
||||
}>;
|
||||
};
|
||||
const timeline = cache.hosts?.find((host) => host.timeline?.agentId === agentId)?.timeline;
|
||||
return timeline?.items?.length === 50;
|
||||
}, agent.agentId),
|
||||
)
|
||||
.poll(async () => {
|
||||
const cache = await readReplicaCache(page);
|
||||
const timeline = cache?.hosts?.find(
|
||||
(host) => host.timeline?.agentId === agent.agentId,
|
||||
)?.timeline;
|
||||
return timeline?.items?.length === 50;
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
await page.reload();
|
||||
await expectTimelinePromptVisible(page, agent.newestPrompt);
|
||||
}
|
||||
|
||||
export async function waitForPersistedCanonicalTimelineRange(
|
||||
page: Page,
|
||||
agentId: string,
|
||||
): Promise<PersistedCanonicalTimelineRange> {
|
||||
const readRange = async () => {
|
||||
const cache = await readReplicaCache(page);
|
||||
if (cache?.version !== 6) return null;
|
||||
const range = cache.hosts?.find((host) => host.timeline?.agentId === agentId)?.timeline?.range;
|
||||
if (
|
||||
typeof range?.epoch !== "string" ||
|
||||
typeof range.startSeq !== "number" ||
|
||||
typeof range.endSeq !== "number"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { epoch: range.epoch, startSeq: range.startSeq, endSeq: range.endSeq };
|
||||
};
|
||||
|
||||
await expect.poll(readRange).not.toBeNull();
|
||||
const range = await readRange();
|
||||
if (!range) {
|
||||
throw new Error(`Persisted canonical timeline range is missing for ${agentId}`);
|
||||
}
|
||||
return range;
|
||||
}
|
||||
|
||||
export async function holdNextOlderTimelinePage(
|
||||
page: Page,
|
||||
agent: LongTimelineAgent,
|
||||
|
||||
@@ -40,12 +40,20 @@ export function rememberTimelineRequestCounts(gate: DaemonWebSocketGate): Timeli
|
||||
};
|
||||
}
|
||||
|
||||
export function expectOneTailCheckWithoutCatchUpPages(
|
||||
export function expectOneResumeCheckWithoutTail(
|
||||
gate: DaemonWebSocketGate,
|
||||
before: TimelineRequestCounts,
|
||||
): void {
|
||||
expect(gate.getTimelineRequestCount("after") - before.after).toBe(1);
|
||||
expect(gate.getTimelineRequestCount("tail") - before.tail).toBe(0);
|
||||
}
|
||||
|
||||
export function expectResumeOverflowFallsBackToOneTail(
|
||||
gate: DaemonWebSocketGate,
|
||||
before: TimelineRequestCounts,
|
||||
): void {
|
||||
expect(gate.getTimelineRequestCount("after") - before.after).toBe(1);
|
||||
expect(gate.getTimelineRequestCount("tail") - before.tail).toBe(1);
|
||||
expect(gate.getTimelineRequestCount("after") - before.after).toBe(0);
|
||||
}
|
||||
|
||||
export async function disconnectViewedTimeline(
|
||||
@@ -56,7 +64,7 @@ export async function disconnectViewedTimeline(
|
||||
await expectReconnectingToastVisible(page);
|
||||
}
|
||||
|
||||
export async function restoreViewedTimelineWithHeldTail(
|
||||
export async function restoreViewedTimelineWithHeldResponse(
|
||||
page: Page,
|
||||
gate: DaemonWebSocketGate,
|
||||
): Promise<void> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -29,7 +29,8 @@
|
||||
"build:web": "npm --prefix ../.. run build:app-deps && expo export --platform web",
|
||||
"profile:workspace-tabs": "node ./scripts/profile-workspace-tabs.mjs",
|
||||
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main",
|
||||
"build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs"
|
||||
"build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs",
|
||||
"build:mermaid-runtime": "node ./src/components/markdown/fence/mermaid/build-runtime.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
@@ -47,6 +48,8 @@
|
||||
"@getpaseo/protocol": "*",
|
||||
"@gorhom/bottom-sheet": "^5.2.14",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@mattermost/react-native-paste-input": "2.0.1",
|
||||
"@paseo/plugin": "*",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-native-masked-view/masked-view": "^0.3.2",
|
||||
"@react-native/normalize-colors": "^0.81.5",
|
||||
@@ -92,6 +95,7 @@
|
||||
"i18next": "^26.3.0",
|
||||
"lucide-react-native": "^0.546.0",
|
||||
"markdown-it": "^10.0.0",
|
||||
"mermaid": "^11.16.0",
|
||||
"mnemonic-id": "^3.2.7",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19.1.0",
|
||||
@@ -117,6 +121,7 @@
|
||||
"turndown": "^7.2.4",
|
||||
"turndown-plugin-gfm": "^1.0.2",
|
||||
"use-sync-external-store": "^1.6.0",
|
||||
"yaml": "^2.8.4",
|
||||
"zod": "^4.4.3",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
@@ -134,6 +139,7 @@
|
||||
"@xterm/headless": "^6.1.0-beta.213",
|
||||
"dotenv": "^17.2.3",
|
||||
"eas-cli": "^16.24.1",
|
||||
"esbuild": "0.28.1",
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-config-expo": "~10.0.0",
|
||||
"expo-gradle-jvmargs": "^1.1.2",
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
const { withGradleProperties } = require("expo/config-plugins");
|
||||
|
||||
const ASYNC_STORAGE_SIZE_PROPERTY = "AsyncStorage_db_size_in_MB";
|
||||
|
||||
function withAndroidAsyncStorageSize(config, sizeInMegabytes) {
|
||||
return withGradleProperties(config, (modConfig) => {
|
||||
const existingProperty = modConfig.modResults.find(
|
||||
(property) => property.type === "property" && property.key === ASYNC_STORAGE_SIZE_PROPERTY,
|
||||
);
|
||||
if (existingProperty?.type === "property") {
|
||||
existingProperty.value = String(sizeInMegabytes);
|
||||
return modConfig;
|
||||
}
|
||||
|
||||
modConfig.modResults.push({
|
||||
type: "property",
|
||||
key: ASYNC_STORAGE_SIZE_PROPERTY,
|
||||
value: String(sizeInMegabytes),
|
||||
});
|
||||
return modConfig;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = withAndroidAsyncStorageSize;
|
||||
@@ -0,0 +1,82 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const {
|
||||
IOSConfig,
|
||||
withAppDelegate,
|
||||
withDangerousMod,
|
||||
withPodfile,
|
||||
} = require("expo/config-plugins");
|
||||
|
||||
const MODULE_SETUP = " PasteInputModule.setup(factory.rootViewFactory)";
|
||||
const REACT_NATIVE_START_END = " launchOptions: launchOptions)\n#endif";
|
||||
const BRIDGING_HEADER_IMPORT = "#import <react-native-paste-input/PasteInputModule.h>";
|
||||
const PODFILE_NEW_ARCH_DISABLED =
|
||||
"ENV['RCT_NEW_ARCH_ENABLED'] ||= '0' if podfile_properties['newArchEnabled'] == 'false'";
|
||||
const PODFILE_NEW_ARCH_ENABLED =
|
||||
"ENV['RCT_NEW_ARCH_ENABLED'] ||= '1' if podfile_properties['newArchEnabled'] == 'true'";
|
||||
|
||||
function configurePasteInputAppDelegate(contents) {
|
||||
const configured = contents.replace(`\n${MODULE_SETUP}`, "");
|
||||
if (!configured.includes(REACT_NATIVE_START_END)) {
|
||||
throw new Error("Could not register paste input after React Native starts");
|
||||
}
|
||||
return configured.replace(
|
||||
REACT_NATIVE_START_END,
|
||||
` launchOptions: launchOptions)\n${MODULE_SETUP}\n#endif`,
|
||||
);
|
||||
}
|
||||
|
||||
function configurePasteInputBridgingHeader(contents) {
|
||||
if (contents.includes(BRIDGING_HEADER_IMPORT)) {
|
||||
return contents;
|
||||
}
|
||||
return `${contents.trimEnd()}\n${BRIDGING_HEADER_IMPORT}\n`;
|
||||
}
|
||||
|
||||
function configurePasteInputPodfile(contents) {
|
||||
if (contents.includes(PODFILE_NEW_ARCH_ENABLED)) {
|
||||
return contents;
|
||||
}
|
||||
if (!contents.includes(PODFILE_NEW_ARCH_DISABLED)) {
|
||||
throw new Error("Could not configure the new architecture for paste input");
|
||||
}
|
||||
return contents.replace(
|
||||
PODFILE_NEW_ARCH_DISABLED,
|
||||
`${PODFILE_NEW_ARCH_DISABLED}\n${PODFILE_NEW_ARCH_ENABLED}`,
|
||||
);
|
||||
}
|
||||
|
||||
function withPasteInput(config) {
|
||||
const withDelegate = withAppDelegate(config, (modConfig) => {
|
||||
if (modConfig.modResults.language !== "swift") {
|
||||
throw new Error("Paste input setup requires a Swift AppDelegate");
|
||||
}
|
||||
modConfig.modResults.contents = configurePasteInputAppDelegate(modConfig.modResults.contents);
|
||||
return modConfig;
|
||||
});
|
||||
const withPods = withPodfile(withDelegate, (modConfig) => {
|
||||
modConfig.modResults.contents = configurePasteInputPodfile(modConfig.modResults.contents);
|
||||
return modConfig;
|
||||
});
|
||||
return withPasteInputBridgingHeader(withPods);
|
||||
}
|
||||
|
||||
function withPasteInputBridgingHeader(config) {
|
||||
return withDangerousMod(config, [
|
||||
"ios",
|
||||
async (modConfig) => {
|
||||
const projectRoot = modConfig.modRequest.projectRoot;
|
||||
const sourceRoot = IOSConfig.Paths.getSourceRoot(projectRoot);
|
||||
const projectName = IOSConfig.XcodeUtils.getProjectName(projectRoot);
|
||||
const headerPath = path.join(sourceRoot, `${projectName}-Bridging-Header.h`);
|
||||
const contents = fs.readFileSync(headerPath, "utf8");
|
||||
fs.writeFileSync(headerPath, configurePasteInputBridgingHeader(contents));
|
||||
return modConfig;
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
module.exports = withPasteInput;
|
||||
module.exports.configurePasteInputAppDelegate = configurePasteInputAppDelegate;
|
||||
module.exports.configurePasteInputBridgingHeader = configurePasteInputBridgingHeader;
|
||||
module.exports.configurePasteInputPodfile = configurePasteInputPodfile;
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const {
|
||||
configurePasteInputAppDelegate,
|
||||
configurePasteInputBridgingHeader,
|
||||
configurePasteInputPodfile,
|
||||
} = require("./with-paste-input");
|
||||
|
||||
describe("withPasteInput", () => {
|
||||
it("registers the module after React Native starts", () => {
|
||||
const source = [
|
||||
"import ReactAppDependencyProvider",
|
||||
"",
|
||||
" bindReactNativeFactory(factory)",
|
||||
" factory.startReactNative(",
|
||||
' withModuleName: "main",',
|
||||
" in: window,",
|
||||
" launchOptions: launchOptions)",
|
||||
"#endif",
|
||||
].join("\n");
|
||||
const configured = configurePasteInputAppDelegate(source);
|
||||
|
||||
expect(configured).toContain(
|
||||
" launchOptions: launchOptions)\n PasteInputModule.setup(factory.rootViewFactory)\n#endif",
|
||||
);
|
||||
expect(configurePasteInputAppDelegate(configured)).toBe(configured);
|
||||
});
|
||||
|
||||
it("exposes the Objective-C module to Swift through the bridging header", () => {
|
||||
const source = "// Swift bridging header\n";
|
||||
const configured = configurePasteInputBridgingHeader(source);
|
||||
|
||||
expect(configured).toContain("#import <react-native-paste-input/PasteInputModule.h>");
|
||||
expect(configurePasteInputBridgingHeader(configured)).toBe(configured);
|
||||
});
|
||||
|
||||
it("sets the new architecture environment before CocoaPods evaluates dependencies", () => {
|
||||
const source =
|
||||
"ENV['RCT_NEW_ARCH_ENABLED'] ||= '0' if podfile_properties['newArchEnabled'] == 'false'";
|
||||
const configured = configurePasteInputPodfile(source);
|
||||
|
||||
expect(configured).toContain(
|
||||
"ENV['RCT_NEW_ARCH_ENABLED'] ||= '1' if podfile_properties['newArchEnabled'] == 'true'",
|
||||
);
|
||||
expect(configurePasteInputPodfile(configured)).toBe(configured);
|
||||
});
|
||||
});
|
||||
@@ -39,9 +39,9 @@ describe("Add Project navigation", () => {
|
||||
expect(currentAddProjectPage(state)).toEqual({
|
||||
kind: "method",
|
||||
hostId: "host-1",
|
||||
query: "",
|
||||
activeIndex: 0,
|
||||
error: null,
|
||||
isSubmitting: false,
|
||||
});
|
||||
expect(backAddProjectPage(state)).toBeNull();
|
||||
});
|
||||
|
||||
@@ -17,15 +17,18 @@ export interface GithubRepositoryChoice {
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
interface SearchPageState {
|
||||
query: string;
|
||||
interface PageState {
|
||||
activeIndex: number;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface SearchPageState extends PageState {
|
||||
query: string;
|
||||
}
|
||||
|
||||
export type AddProjectPage =
|
||||
| ({ kind: "host" } & SearchPageState)
|
||||
| ({ kind: "method"; hostId: string } & SearchPageState)
|
||||
| ({ kind: "method"; hostId: string; isSubmitting: boolean } & PageState)
|
||||
| ({ kind: "directory-search"; hostId: string; isSubmitting: boolean } & SearchPageState)
|
||||
| ({ kind: "github-search"; hostId: string } & SearchPageState)
|
||||
| ({
|
||||
@@ -57,12 +60,14 @@ export interface OpenAddProjectFlowInput {
|
||||
preferredHostId?: string;
|
||||
}
|
||||
|
||||
function searchPage<TKind extends AddProjectPage["kind"]>(kind: TKind) {
|
||||
type SearchPageKind = Extract<AddProjectPage, { query: string }>["kind"];
|
||||
|
||||
function searchPage<TKind extends SearchPageKind>(kind: TKind) {
|
||||
return { kind, query: "", activeIndex: 0, error: null } as const;
|
||||
}
|
||||
|
||||
function methodPage(hostId: string): AddProjectPage {
|
||||
return { ...searchPage("method"), hostId };
|
||||
return { kind: "method", hostId, activeIndex: 0, error: null, isSubmitting: false };
|
||||
}
|
||||
|
||||
export function openAddProjectFlow(input: OpenAddProjectFlowInput): AddProjectFlowState {
|
||||
@@ -209,6 +214,7 @@ export function setAddProjectPageInput(
|
||||
if (current.kind === "new-directory-name") {
|
||||
return { ...current, name: value, activeIndex: 0, error: null };
|
||||
}
|
||||
if (current.kind === "method") return current;
|
||||
return { ...current, query: value, activeIndex: 0, error: null };
|
||||
});
|
||||
if (page.kind !== "github-location") return updated;
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Agent profiles: named bundles of composer settings (provider, model, mode,
|
||||
* thinking option, feature values, notes) stored host-wide in daemon config.
|
||||
*
|
||||
* Four capabilities leave this module — managing the list in settings, reading
|
||||
* it, pinning it to the model picker, and drawing a profile's glyph. Everything
|
||||
* else (the form model, the catalog and feature probes, the materialization
|
||||
* rules, the icon registry, the row and modal chrome) is internal; import from
|
||||
* `@/agent-profiles`, never a path inside it.
|
||||
*
|
||||
* `useAgentProfilePicker` deliberately hands the picker a flat row view model
|
||||
* and one `applyProfile(id)` callback rather than the profiles themselves: what
|
||||
* a profile contains, and how it reaches a live agent versus a draft, stays in
|
||||
* here.
|
||||
*/
|
||||
export type { AgentProfile } from "@getpaseo/protocol/messages";
|
||||
export type { MaterializedAgentProfile } from "./internal/materialize-profile";
|
||||
export { useAgentProfiles } from "./internal/use-agent-profiles";
|
||||
export {
|
||||
useAgentProfilePicker,
|
||||
type AgentProfileApplyTarget,
|
||||
type AgentProfilePicker,
|
||||
type AgentProfilePickerRow,
|
||||
type DraftAgentProfileControls,
|
||||
} from "./internal/use-agent-profile-picker";
|
||||
export { AgentProfileGlyph } from "./internal/agent-profile-glyph";
|
||||
export { AgentProfilesSection } from "./settings/agent-profiles-section";
|
||||
@@ -0,0 +1,128 @@
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
BookOpen,
|
||||
Bot,
|
||||
Boxes,
|
||||
Brain,
|
||||
Bug,
|
||||
Cloud,
|
||||
Code,
|
||||
Compass,
|
||||
Cpu,
|
||||
Database,
|
||||
Eye,
|
||||
Feather,
|
||||
FileText,
|
||||
FlaskConical,
|
||||
GitBranch,
|
||||
Globe,
|
||||
Hammer,
|
||||
Layers,
|
||||
Microscope,
|
||||
Package,
|
||||
Palette,
|
||||
Pencil,
|
||||
Rocket,
|
||||
Search,
|
||||
Server,
|
||||
Shield,
|
||||
Sparkles,
|
||||
Terminal,
|
||||
TestTube,
|
||||
Wrench,
|
||||
} from "lucide-react-native";
|
||||
import { identityForeground } from "@/styles/identity-colors";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import {
|
||||
AGENT_PROFILE_COLORS,
|
||||
resolveAgentProfileColor,
|
||||
resolveAgentProfileIconKey,
|
||||
type AgentProfileColor,
|
||||
type AgentProfileIconKey,
|
||||
} from "./profile-appearance";
|
||||
|
||||
/** Drawn when a profile names no icon, and as the "default" cell in the picker grid. */
|
||||
const ThemedDefaultIcon = withUnistyles(Bot);
|
||||
|
||||
/**
|
||||
* `withUnistyles` has to wrap each icon once at module scope, so the registry
|
||||
* stores the themed component rather than the raw lucide one.
|
||||
*/
|
||||
const THEMED_ICONS: Record<AgentProfileIconKey, typeof ThemedDefaultIcon> = {
|
||||
code: withUnistyles(Code),
|
||||
terminal: withUnistyles(Terminal),
|
||||
bug: withUnistyles(Bug),
|
||||
wrench: withUnistyles(Wrench),
|
||||
hammer: withUnistyles(Hammer),
|
||||
|
||||
flask: withUnistyles(FlaskConical),
|
||||
testTube: withUnistyles(TestTube),
|
||||
microscope: withUnistyles(Microscope),
|
||||
search: withUnistyles(Search),
|
||||
eye: withUnistyles(Eye),
|
||||
|
||||
palette: withUnistyles(Palette),
|
||||
feather: withUnistyles(Feather),
|
||||
pencil: withUnistyles(Pencil),
|
||||
fileText: withUnistyles(FileText),
|
||||
book: withUnistyles(BookOpen),
|
||||
|
||||
rocket: withUnistyles(Rocket),
|
||||
package: withUnistyles(Package),
|
||||
boxes: withUnistyles(Boxes),
|
||||
server: withUnistyles(Server),
|
||||
database: withUnistyles(Database),
|
||||
|
||||
cpu: withUnistyles(Cpu),
|
||||
cloud: withUnistyles(Cloud),
|
||||
globe: withUnistyles(Globe),
|
||||
gitBranch: withUnistyles(GitBranch),
|
||||
layers: withUnistyles(Layers),
|
||||
|
||||
compass: withUnistyles(Compass),
|
||||
brain: withUnistyles(Brain),
|
||||
sparkles: withUnistyles(Sparkles),
|
||||
shield: withUnistyles(Shield),
|
||||
};
|
||||
|
||||
const mutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
/**
|
||||
* One `uniProps` mapping per colour, built once. Building these inline would
|
||||
* hand `withUnistyles` a new function every render and defeat its tracking.
|
||||
*/
|
||||
const COLOR_MAPPINGS: Record<AgentProfileColor, (theme: Theme) => { color: string }> = (() => {
|
||||
const byColor = {} as Record<AgentProfileColor, (theme: Theme) => { color: string }>;
|
||||
for (const color of AGENT_PROFILE_COLORS) {
|
||||
byColor[color] =
|
||||
color === "none"
|
||||
? mutedMapping
|
||||
: (theme: Theme) => ({ color: identityForeground(color, theme.colorScheme) });
|
||||
}
|
||||
return byColor;
|
||||
})();
|
||||
|
||||
export function agentProfileColorMapping(
|
||||
color: AgentProfileColor,
|
||||
): (theme: Theme) => { color: string } {
|
||||
return COLOR_MAPPINGS[color];
|
||||
}
|
||||
|
||||
/**
|
||||
* How a profile is drawn everywhere it appears — settings list, model picker,
|
||||
* and the picker grid's own cells. One component so the three can't drift.
|
||||
*/
|
||||
export function AgentProfileGlyph({
|
||||
icon,
|
||||
color,
|
||||
size = ICON_SIZE.md,
|
||||
}: {
|
||||
icon?: string | undefined;
|
||||
color?: string | undefined;
|
||||
size?: number;
|
||||
}) {
|
||||
const iconKey = resolveAgentProfileIconKey(icon);
|
||||
const mapping = COLOR_MAPPINGS[resolveAgentProfileColor(color)];
|
||||
const Icon = iconKey ? THEMED_ICONS[iconKey] : ThemedDefaultIcon;
|
||||
return <Icon size={size} uniProps={mapping} />;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { supportsAgentProfiles } from "./capabilities";
|
||||
|
||||
describe("agent profile capabilities", () => {
|
||||
it("requires storage and live config application support", () => {
|
||||
expect(supportsAgentProfiles(undefined)).toBe(false);
|
||||
expect(supportsAgentProfiles({ agentProfiles: true })).toBe(false);
|
||||
expect(supportsAgentProfiles({ agentConfigApply: true })).toBe(false);
|
||||
expect(supportsAgentProfiles({ agentProfiles: true, agentConfigApply: true })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
interface AgentProfileCapabilities {
|
||||
agentProfiles?: boolean;
|
||||
agentConfigApply?: boolean;
|
||||
}
|
||||
|
||||
export function supportsAgentProfiles(features: AgentProfileCapabilities | undefined): boolean {
|
||||
return features?.agentProfiles === true && features.agentConfigApply === true;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentProfile } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
materializeAgentProfile,
|
||||
reconcileMaterializedProfileMode,
|
||||
toAgentConfigApply,
|
||||
} from "./materialize-profile";
|
||||
|
||||
function profile(overrides: Partial<AgentProfile> = {}): AgentProfile {
|
||||
return {
|
||||
id: "ui-work",
|
||||
name: "UI work",
|
||||
provider: "claude",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("materializeAgentProfile", () => {
|
||||
it("resolves every field a full profile names", () => {
|
||||
expect(
|
||||
materializeAgentProfile(
|
||||
profile({
|
||||
model: "claude-opus-5",
|
||||
modeId: "plan",
|
||||
thinkingOptionId: "think-hard",
|
||||
featureValues: { web_search: true },
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
provider: "claude",
|
||||
modelId: "claude-opus-5",
|
||||
modeId: "plan",
|
||||
thinkingOptionId: "think-hard",
|
||||
featureValues: { web_search: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("treats omitted and blank fields the same", () => {
|
||||
expect(materializeAgentProfile(profile({ model: " ", modeId: "" }))).toEqual({
|
||||
provider: "claude",
|
||||
modelId: "",
|
||||
modeId: "",
|
||||
thinkingOptionId: "",
|
||||
featureValues: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("trims stored ids", () => {
|
||||
expect(materializeAgentProfile(profile({ model: " claude-opus-5 " })).modelId).toBe(
|
||||
"claude-opus-5",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toAgentConfigApply", () => {
|
||||
it("sends every value the profile names", () => {
|
||||
expect(
|
||||
toAgentConfigApply(
|
||||
materializeAgentProfile(
|
||||
profile({
|
||||
model: "claude-opus-5",
|
||||
modeId: "plan",
|
||||
thinkingOptionId: "think-hard",
|
||||
featureValues: { web_search: true },
|
||||
}),
|
||||
),
|
||||
),
|
||||
).toEqual({
|
||||
modelId: "claude-opus-5",
|
||||
modeId: "plan",
|
||||
thinkingOptionId: "think-hard",
|
||||
featureValues: { web_search: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("omits what the profile leaves alone rather than clearing it", () => {
|
||||
expect(toAgentConfigApply(materializeAgentProfile(profile({ modeId: "plan" })))).toEqual({
|
||||
modeId: "plan",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits an empty feature map so the agent keeps its own feature values", () => {
|
||||
expect(
|
||||
toAgentConfigApply(materializeAgentProfile(profile({ featureValues: {} }))),
|
||||
).not.toHaveProperty("featureValues");
|
||||
});
|
||||
|
||||
it("never carries a provider, which a running agent cannot change", () => {
|
||||
expect(
|
||||
toAgentConfigApply(materializeAgentProfile(profile({ model: "claude-opus-5" }))),
|
||||
).not.toHaveProperty("provider");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reconcileMaterializedProfileMode", () => {
|
||||
it("waits when the current provider mode catalog is unknown", () => {
|
||||
expect(
|
||||
reconcileMaterializedProfileMode(materializeAgentProfile(profile({ modeId: "plan" })), null),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("drops a profile mode removed from the current provider catalog", () => {
|
||||
expect(
|
||||
reconcileMaterializedProfileMode(
|
||||
materializeAgentProfile(profile({ modeId: "removed-mode" })),
|
||||
["plan", "default"],
|
||||
),
|
||||
).toMatchObject({ modeId: "" });
|
||||
});
|
||||
|
||||
it("preserves a profile mode still offered by the provider", () => {
|
||||
expect(
|
||||
reconcileMaterializedProfileMode(materializeAgentProfile(profile({ modeId: "plan" })), [
|
||||
"plan",
|
||||
"default",
|
||||
]),
|
||||
).toMatchObject({ modeId: "plan" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { AgentConfigApply, AgentProfile } from "@getpaseo/protocol/messages";
|
||||
|
||||
/**
|
||||
* A profile with its blank fields resolved away. Storage keeps every field
|
||||
* optional and lets the user clear one to an empty string; applying has to know
|
||||
* the difference between "set this" and "leave whatever is there alone", and
|
||||
* every consumer would otherwise re-derive the same trim-and-drop rules.
|
||||
*/
|
||||
export interface MaterializedAgentProfile {
|
||||
provider: string;
|
||||
/** Empty when the profile names no model, meaning "leave the model alone". */
|
||||
modelId: string;
|
||||
modeId: string;
|
||||
thinkingOptionId: string;
|
||||
featureValues: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function trimmed(value: string | undefined): string {
|
||||
return value?.trim() ?? "";
|
||||
}
|
||||
|
||||
export function materializeAgentProfile(profile: AgentProfile): MaterializedAgentProfile {
|
||||
return {
|
||||
provider: trimmed(profile.provider),
|
||||
modelId: trimmed(profile.model),
|
||||
modeId: trimmed(profile.modeId),
|
||||
thinkingOptionId: trimmed(profile.thinkingOptionId),
|
||||
featureValues: profile.featureValues ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
export function reconcileMaterializedProfileMode(
|
||||
profile: MaterializedAgentProfile,
|
||||
availableModeIds: readonly string[] | null,
|
||||
): MaterializedAgentProfile | null {
|
||||
if (availableModeIds === null) {
|
||||
return null;
|
||||
}
|
||||
if (!profile.modeId || availableModeIds.includes(profile.modeId)) {
|
||||
return profile;
|
||||
}
|
||||
return { ...profile, modeId: "" };
|
||||
}
|
||||
|
||||
/**
|
||||
* The payload for a running agent. Omitted fields are left alone by the daemon,
|
||||
* so a profile that names no mode does not reset the agent's mode. Provider is
|
||||
* absent because a running agent cannot change the process it is.
|
||||
*/
|
||||
export function toAgentConfigApply(profile: MaterializedAgentProfile): AgentConfigApply {
|
||||
return {
|
||||
...(profile.modelId ? { modelId: profile.modelId } : {}),
|
||||
...(profile.modeId ? { modeId: profile.modeId } : {}),
|
||||
...(profile.thinkingOptionId ? { thinkingOptionId: profile.thinkingOptionId } : {}),
|
||||
...(Object.keys(profile.featureValues).length > 0
|
||||
? { featureValues: profile.featureValues }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { IDENTITY_COLOR_NAMES, type IdentityColorName } from "@/styles/identity-colors";
|
||||
|
||||
/**
|
||||
* A profile's icon is a key into a fixed registry, not a glyph. The value is
|
||||
* stored in daemon config and drawn by every client, so it has to survive being
|
||||
* rendered by an app version that ships a different icon set — an unknown key
|
||||
* falls back to the default glyph rather than showing the raw string.
|
||||
*
|
||||
* Order is what the grid draws, five to a row: related icons sit together so
|
||||
* scanning the grid is scanning categories, not thirty unrelated shapes.
|
||||
*/
|
||||
export const AGENT_PROFILE_ICON_KEYS = [
|
||||
"code",
|
||||
"terminal",
|
||||
"bug",
|
||||
"wrench",
|
||||
"hammer",
|
||||
|
||||
"flask",
|
||||
"testTube",
|
||||
"microscope",
|
||||
"search",
|
||||
"eye",
|
||||
|
||||
"palette",
|
||||
"feather",
|
||||
"pencil",
|
||||
"fileText",
|
||||
"book",
|
||||
|
||||
"rocket",
|
||||
"package",
|
||||
"boxes",
|
||||
"server",
|
||||
"database",
|
||||
|
||||
"cpu",
|
||||
"cloud",
|
||||
"globe",
|
||||
"gitBranch",
|
||||
"layers",
|
||||
|
||||
"compass",
|
||||
"brain",
|
||||
"sparkles",
|
||||
"shield",
|
||||
] as const;
|
||||
|
||||
export type AgentProfileIconKey = (typeof AGENT_PROFILE_ICON_KEYS)[number];
|
||||
|
||||
/** Reuses the host identity palette so a profile and a host name read as one family. */
|
||||
export type AgentProfileColor = "none" | IdentityColorName;
|
||||
|
||||
export const AGENT_PROFILE_COLORS: readonly AgentProfileColor[] = ["none", ...IDENTITY_COLOR_NAMES];
|
||||
|
||||
export function isAgentProfileIconKey(value: string | undefined): value is AgentProfileIconKey {
|
||||
return AGENT_PROFILE_ICON_KEYS.some((key) => key === value);
|
||||
}
|
||||
|
||||
export function isAgentProfileColor(value: string | undefined): value is AgentProfileColor {
|
||||
return AGENT_PROFILE_COLORS.some((color) => color === value);
|
||||
}
|
||||
|
||||
/** Anything unrecognised means "not chosen": the glyph and colour both default. */
|
||||
export function resolveAgentProfileIconKey(value: string | undefined): AgentProfileIconKey | null {
|
||||
return isAgentProfileIconKey(value) ? value : null;
|
||||
}
|
||||
|
||||
export function resolveAgentProfileColor(value: string | undefined): AgentProfileColor {
|
||||
return isAgentProfileColor(value) ? value : "none";
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user