chore: merge origin/main into timeline integration

This commit is contained in:
Mohamed Boudra
2026-08-01 01:47:15 +02:00
414 changed files with 25271 additions and 8594 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ body:
- type: markdown
attributes:
value: |
I'm a solo maintainer and don't always keep up with GitHub Issues daily. If something is urgent or blocking you, [Discord](https://discord.gg/jz8T2uahpH) is the fastest place to reach me.
Issues are only for bugs. Product discussions go in [GitHub Discussions](https://github.com/getpaseo/paseo/discussions) or `#product` in [Discord](https://discord.gg/jz8T2uahpH) — feature requests opened as issues get closed automatically.
Before opening, please:
+5 -2
View File
@@ -1,5 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Discord
- name: Product discussion on Discord
url: https://discord.gg/jz8T2uahpH
about: Urgent or blocking issues, quick questions, sharing a video of a bug, or anything that's better as a chat.
about: Product discussion, including feature requests
- name: Product discussion on GitHub
url: https://github.com/getpaseo/paseo/discussions
about: Product discussion, including feature requests
@@ -1,43 +0,0 @@
name: Feature request
description: Propose a new feature or a change to existing behavior.
title: "feat: "
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Paseo is opinionated and maintained by one person. Feature requests are welcome, but they get evaluated against product fit, not just usefulness, and the bar is whether the change keeps the product lean enough for one person to maintain.
Big ideas are better discussed in [Discord](https://discord.gg/jz8T2uahpH) first. And please don't open a feature request and a PR at the same time, get alignment on the idea before writing code.
- type: checkboxes
id: prior-search
attributes:
label: Prior search
options:
- label: I searched existing issues and discussions, and this isn't already proposed.
required: true
- type: textarea
id: problem
attributes:
label: What's the problem
description: What are you actually trying to do, and why is the current behavior in the way?
placeholder: |
When I'm doing X, I want to Y, but Paseo currently Z.
validations:
required: true
- type: textarea
id: proposal
attributes:
label: What would solve it
description: A rough sketch of the change. Mockups, screenshots from other apps, or a short video are very welcome, especially for UI proposals.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives you considered
description: Optional. What else did you try, and why doesn't it work?
+7 -10
View File
@@ -1,20 +1,19 @@
<!--
Please follow this template. The PR template applies whether you opened the PR via the web UI, `gh pr create`, or any other tool.
If you're fixing an objective bug or a small focused issue, this should be quick. Big PRs without a prior issue or design discussion are likely to be closed or scoped down. See CONTRIBUTING.md.
You MUST read CONTRIBUTING.md before sending a PR.
-->
### Linked issue
Closes #
<!-- Bug fixes and behavior changes should reference an issue. Pure docs and refactors can skip this. -->
### Type of change
- [ ] Bug fix
- [ ] New feature (with prior issue + design alignment)
- [ ] Refactor / code improvement
- [ ] New feature
- [ ] Enhancement
- [ ] Refactor
- [ ] Docs
### What does this PR do
@@ -29,15 +28,13 @@ This is the section I read most carefully. I need to see that *you* tested this,
- For UI changes: a screenshot or short video on every affected platform (mobile, web, desktop). UI claims without visual proof are not enough.
- For behavior changes: the actual steps you ran, and what you observed.
- For bug fixes: how you reproduced the bug before, and confirmed it's fixed after.
AI-generated PR descriptions are fine in principle. AI-generated *verification claims* with no actual testing behind them are not, and they're easy to spot.
-->
### Checklist
- [ ] One focused change. Unrelated cleanups split out.
- [ ] One focused change
- [ ] `npm run typecheck` passes
- [ ] `npm run lint` passes
- [ ] `npm run format` ran (Biome)
- [ ] UI changes include screenshots or video for every affected platform
- [ ] `npm run format` passes
- [ ] QA evidence
- [ ] Tests added or updated where it made sense
+2 -3
View File
@@ -130,7 +130,7 @@
]
}
},
// Raw query burn-down: 35 files total; the 29 files below still enforce the useUnistyles ban.
// Raw query burn-down: 34 files total; the 28 files below still enforce the useUnistyles ban.
{
"files": [
"packages/app/src/assistant-file-links/use-file-link.ts",
@@ -154,10 +154,9 @@
"packages/app/src/hooks/use-is-local-daemon.ts",
"packages/app/src/hooks/use-keyboard-shortcut-overrides.ts",
"packages/app/src/hooks/use-preferred-editor.ts",
"packages/app/src/hooks/use-project-icon-query.ts",
"packages/app/src/hooks/use-settings/index.ts",
"packages/app/src/panels/terminal-panel.tsx",
"packages/app/src/projects/project-icons.ts",
"packages/app/src/projects/icons.ts",
"packages/app/src/provider-usage/use-provider-usage.ts",
"packages/app/src/screens/project-settings-screen.tsx",
"packages/app/src/screens/workspace/use-workspace-checkout-status.ts",
+35
View File
@@ -1,5 +1,40 @@
# Changelog
## 0.2.5 - 2026-07-30
### Fixed
- Fixed the Linux Debian package ([#2654](https://github.com/getpaseo/paseo/pull/2654) by [@Neumannzc](https://github.com/Neumannzc))
## 0.2.4 - 2026-07-30
### Added
- Set an agent's thinking level from the CLI ([#2533](https://github.com/getpaseo/paseo/pull/2533))
- Switch projects while creating a workspace with ⌘P / Ctrl+P ([#2110](https://github.com/getpaseo/paseo/pull/2110) by [@turtleDev](https://github.com/turtleDev))
- Open a project or workspace folder from the sidebar ([#2491](https://github.com/getpaseo/paseo/pull/2491) by [@PTK030](https://github.com/PTK030))
- Flick up on the chat to dismiss the keyboard ([#2417](https://github.com/getpaseo/paseo/pull/2417) by [@nllptrx](https://github.com/nllptrx))
- Custom providers can read the agent's working directory ([#2563](https://github.com/getpaseo/paseo/pull/2563))
### Fixed
- The same repository added on more than one machine appears as one project in the sidebar ([#2565](https://github.com/getpaseo/paseo/pull/2565))
- Idle agents keep their background work instead of shutting down when unused ([#2590](https://github.com/getpaseo/paseo/pull/2590))
- Desktop no longer crashes at startup while restoring your file tree ([#2595](https://github.com/getpaseo/paseo/pull/2595))
- Plan approval only shows the latest proposal ([#2534](https://github.com/getpaseo/paseo/pull/2534))
- Opus 5 appears once in the model list, with its full 1M context ([#2497](https://github.com/getpaseo/paseo/pull/2497))
- The context meter no longer blanks out partway through a conversation ([#2494](https://github.com/getpaseo/paseo/pull/2494) by [@theslava](https://github.com/theslava))
- A very large working diff no longer drops your session ([#2488](https://github.com/getpaseo/paseo/pull/2488) by [@nikuscs](https://github.com/nikuscs))
- Interrupting Pi no longer surfaces a stream error ([#2311](https://github.com/getpaseo/paseo/pull/2311) by [@mcowger](https://github.com/mcowger))
- Grok usage shows again in Settings ([#2353](https://github.com/getpaseo/paseo/pull/2353) by [@jasonhnd](https://github.com/jasonhnd))
- OMP models that report no context window now load ([#2406](https://github.com/getpaseo/paseo/pull/2406) by [@astartsky](https://github.com/astartsky))
- Paseo's own tools are available directly in OMP ([#2418](https://github.com/getpaseo/paseo/pull/2418) by [@perezd](https://github.com/perezd))
- Codex finds the skills defined in your project ([#2423](https://github.com/getpaseo/paseo/pull/2423) by [@dwyanewang](https://github.com/dwyanewang))
- Pull request comments containing HTML render correctly ([#2432](https://github.com/getpaseo/paseo/pull/2432) by [@mcowger](https://github.com/mcowger))
- Self-hosted forge links keep their port ([#2478](https://github.com/getpaseo/paseo/pull/2478) by [@muzhi1991](https://github.com/muzhi1991))
- The Linux AppImage launches when opened from your desktop ([#2439](https://github.com/getpaseo/paseo/pull/2439) by [@stonegray](https://github.com/stonegray))
- Repository search works with older versions of the GitHub CLI ([#2611](https://github.com/getpaseo/paseo/pull/2611))
## 0.2.3 - 2026-07-27
### Added
+33 -19
View File
@@ -19,7 +19,7 @@ This is an npm workspace monorepo:
`docs/` is the source of truth for system-level and process-level knowledge. **"The docs", "check the docs", or "check the X docs" always mean this directory — not the web.** Look here before fetching anything online; the docs capture gotchas and conventions you cannot derive from the code or external sources.
At the start of non-trivial work, list `docs/` and skim anything relevant to the task. When you learn something meta worth preserving — a gotcha, a convention, a workflow, a piece of system context that will outlive the current task — update an existing doc or propose a new one. Code-level facts belong in inline comments next to the code; system, process, and gotcha-level facts belong in `docs/`.
At the start of non-trivial work, list `docs/` and skim anything relevant to the task.
| Doc | What's in it |
| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
@@ -29,7 +29,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/data-model.md](docs/data-model.md) | File-based JSON persistence, Zod schemas, atomic writes, no migrations |
| [docs/glossary.md](docs/glossary.md) | Authoritative terminology — UI label wins, no synonyms |
| [docs/coding-standards.md](docs/coding-standards.md) | Type hygiene, error handling, state design, React patterns, file organization |
| [docs/design.md](docs/design.md) | Theme tokens — colors, fonts, spacing, radii, icons |
| [docs/design.md](docs/design.md) | Design system — tokens, buttons, hierarchy, density, alignment rails, states, what's forbidden |
| [docs/forms.md](docs/forms.md) | Form architecture — non-React form model, form kit, load-state gating; the schedule form is the golden example |
| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it |
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
@@ -42,9 +42,11 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [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/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 |
| [docs/mobile-panels.md](docs/mobile-panels.md) | Compact left/center/right panel ownership, worklet motion, gesture revisions, and Fabric constraints |
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
@@ -55,6 +57,30 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/terminal-activity.md](docs/terminal-activity.md) | Terminal activity indicators — source-agnostic tracker, agent hook reporting, adding a new hook provider |
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
### Writing docs
- **Integrate, don't append.** Find the doc that owns the subject and rewrite the part that is now wrong. The standard failure is finishing a task and adding a paragraph to the bottom of the closest-looking doc; ten tasks later the doc is a pile of paragraphs in discovery order. `docs/custom-providers.md` is what that looks like.
- **Don't document logic.** Prose that restates code drifts from the code and loses. Write down what the code can't tell you: why something is shaped the way it is, the gotcha that cost an afternoon, conventions nothing enforces, constraints that span packages or versions. If a reader could get it in two minutes by opening the file, cut it.
- **One fact, one doc.** Every other mention is a link. If you are about to write the same paragraph in two docs, one of them is a link.
- **Respect the layers.** `CONTRIBUTING.md` and this file name things and link out. Activity docs like `docs/qa.md` and `docs/testing.md` set the bar for a kind of work. Subject docs like `docs/unistyles.md` own one thing completely. A layer never re-explains the one below it.
- **One subject per doc.** If the subject doesn't fit in a sentence, split the doc. A section per provider, vendor, or platform is a table plus one worked example.
- **Delete.** Obsolete sections go. Prefer a `packages/app/src/thing.ts:120` reference over a pasted block.
- **New doc?** Add a row to the table above and link it from the docs that should send readers there.
- Code-level facts belong in comments next to the code, not here.
### Doc voice
Plain and short. Second person. State the rule, then the reason when the reason isn't obvious. Match the doc you're editing.
Do not:
- Write a sentence to land a point. "It's not X, it's Y", "That's not a Z, that's a W", and every other setup-and-punchline shape.
- Add a clause that only asserts importance: "and that matters", "which is what keeps it working", "this is critical".
- Use "honest", "robust", "seamless", "powerful", "simply", "just", "delightful".
- Restate something you already said, in different words, for emphasis.
- Hedge with "generally", "typically", or "you may want to" when the answer is "do this".
- Clear your throat: "It's worth noting that", "In order to", "This section covers".
## Quick start
```bash
@@ -94,23 +120,11 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir
- **Always use npm scripts for linting and formatting.** Do not run tools directly with `npx eslint`, `npx oxfmt`, `npx oxlint`, or package-local binaries. For targeted checks, pass file paths through the npm script:
- `npm run lint -- packages/app/src/components/message.tsx`
- `npm run format:files -- CLAUDE.md packages/app/src/components/message.tsx`
- **The protocol stays backward-compatible. Features don't have to.** Two separate contracts:
- **Protocol contract (always):** schema changes must not break parsing in either direction. An old client must still parse messages from a new daemon; a new daemon must still parse messages from an old client.
- New fields: `.optional()` with a sensible default.
- Never flip optional → required, remove fields, or narrow types (`string` → `enum`, `nullable` → non-null).
- Removed fields stay accepted (we stop sending them, not stop reading them).
- Test with: "does a 6-month-old client still parse this?" and "does a 6-month-old daemon still send something this client accepts?"
- Wire schemas are pure structural declarations. Do not add `.transform()`, `.catch()`, or `.preprocess()` to WebSocket message schemas; put normalization in an explicit post-validation pass.
- Plain `z.union()` is forbidden when every branch has a shared literal tag. Use `z.discriminatedUnion()` unless generated-code regression tests prove that specific shape is miscompiled.
- `.default()` is acceptable on primitive leaves only. Never put defaults on item schemas for large arrays or big inbound containers.
- **Feature contract (per-feature):** a new feature may require a new daemon capability. The client detects whether the capability is present and either runs the feature or shows "Update the host to use this." That's it.
- **No fallback paths.** Don't write a degraded version of a new feature that runs on old daemons. Don't fan out across legacy RPCs to simulate a missing capability. The user upgrades or doesn't get the feature.
- **No defensive branches scattered through the feature.** Capability detection happens in one place; downstream code reads a clean shape.
- **Capability flags live in `server_info.features.*`** with a single `// COMPAT(featureName): added in v0.1.X, drop the gate when floor >= v0.1.X` comment marking the cleanup site.
- Existing functionality keeps working across versions — that's the protocol contract doing its job. New-feature degradation is not the goal.
- **New RPCs use dotted namespaces with direction suffixes.** Follow [docs/rpc-namespacing.md](docs/rpc-namespacing.md): `domain.provider.operation.request` pairs with `domain.provider.operation.response`. Existing flat RPC names will migrate over time; don't add new ones.
- **All back-compat shims are tagged and dated for cleanup.** Every shim that exists for old-client/old-daemon support carries a `COMPAT(name)` comment with the version it was added in and a target removal date (typically 6 months out). One grep — `rg "COMPAT\("` — should produce the full list of cleanup work. Don't bury back-compat in untagged `??`-fallbacks or optional-chain tunnels — that's how it stops being deletable.
- **The protocol stays backward-compatible. Features don't have to.** Read [docs/protocol-compatibility.md](docs/protocol-compatibility.md) before touching `packages/protocol`. The short version:
- **Protocol contract (always):** an old client parses messages from a new daemon, and a new daemon parses messages from an old client. New fields are optional; never narrow, never remove, never require. Wire schemas stay pure — no `.transform()`, `.catch()`, or `.preprocess()`.
- **Feature contract (per-feature):** gate the capability once on `server_info.features.*`, then run the feature or tell the user to update the host. No fallback paths, no defensive branches.
- **Every shim is tagged.** `// COMPAT(name): added in vX, remove after <date>` at the site that has to be deleted. `rg "COMPAT\("` is the cleanup backlog; untagged back-compat is permanent by accident.
- **New RPCs use dotted namespaces with direction suffixes.** Follow [docs/rpc-namespacing.md](docs/rpc-namespacing.md): `domain.provider.operation.request` pairs with `domain.provider.operation.response`. Existing flat RPC names will migrate over time; don't add new ones.
## Platform gating
+116 -32
View File
@@ -1,56 +1,140 @@
# Contributing to Paseo
Paseo is an opinionated product maintained by one person right now.
Thank you for taking the time to contribute to Paseo.
The product covers a lot of surface: mobile, desktop, web, the daemon, the relay, and both self-hosted and hosted setups.
## Philosophy
Contributing 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.
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).
## Becoming a maintainer
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.
There's no formal process to become a maintainer, if you consistently contribute and help out, you'll become one.
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.
Here's the progression:
## Report bugs in GitHub issues or Discord
1. Get involved in the community: answer questions in Discord and on GitHub
2. Triage bugs: replicate and help fix them
3. Work on maintainer-approved features
Open an [issue](https://github.com/getpaseo/paseo/issues) or post in [Discord](https://discord.gg/jz8T2uahpH).
The reason for this progression is so that you can gain all the context you need to take on more responsibility, so that I can see if you have what it takes to be a maintainer.
> [!IMPORTANT]
> Feature requests opened as issues will get closed automatically
Learning on the job is fine, I do not care how many years of experience you have, what I care about is that you get the vision and want to contribute.
If you used an agent to investigate, paste the raw evidence and repro steps it collected, not its diagnosis.
You may submit a PR to fix a bug, please read the PR guidelines below.
## Product discussions go in GitHub Discussions or Discord
There's no feature request backlog. Open a product discussion in [Discussions](https://github.com/getpaseo/paseo/discussions) or `#product` in [Discord](https://discord.gg/jz8T2uahpH), and frame it as a workflow:
- What are you trying to do?
- How do you do it today?
- Where does Paseo get in the way?
- What would the flow look like if it worked for you?
"Can you build X" is not useful because it doesn't tell me about your problem.
A discussion also lets other people add their own version of the same problem, and that's what I read when I decide what to build.
Due to the volume and my limited bandwidth, I may not participate in all the discussions, if something gains interest it will make it's way to me.
## Pull requests
✅ Will be accepted
Anyone can open a pull request, but there are no guarantees of it getting merged, only submit one if you're okay with it being closed.
- Keep it to one focused change
- Link to an issue
- Explain the problem you're solving
- Include repro steps if it's a bug
- Include QA/testing evidence
- UI changes need screenshots or video for every affected platform: iOS, Android, desktop, and web
- If you only tested one platform, say that clearly
Open it as a draft if the work isn't ready for review, if you want to run the checks against it, or if you want feedback on the direction before you finish it. Mark it ready when you want it looked at.
Here is the criteria I use to decide:
✅ Likely to be accepted
- Fits the product vision
- One focused change
- Links the bug it fixes, or the discussion behind it if there is one
- Addresses bot reviews
- Explains the problem you're solving
- QA evidence
- Automated tests
- Screenshots or video for UI changes, on every affected platform
- Says which platforms you tested and which you didn't
- Maintainer edits enabled
⛔️ Will be rejected
- Bundle unrelated changes
- Fail basic checks like typecheck, formatting or linting
- Add a feature or design change that wasn't discussed first
- Submit no evidence of testing
- Skip the linked issue
- Bundles unrelated changes
- Fails checks
- Ignores bot reviews
- Takes a feature or the design in a direction I don't want
- No QA evidence
- No tests
- Clearly fully AI-generated PR
## Requesting features
### What to expect
If you need a feature implemented, create a Github issue or a thread in Discord.
- 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
Explain the problem you want to solve: your use case, where Paseo falls short today, and the flow you expect.
## QA evidence
## AI assistance
QA is the main bottleneck of Paseo's product development, and it's not just whether the feature works, it's also whether it meets the quality bar.
Using AI to help write code is fine, but you must:
Pull requests without evidence will be closed.
- Ensure your agents read the docs
- Understand the code you submit
- Review and test the code yourself
What's accepted:
- The shell commands you ran, pasted with their output
- The tests you added and their results
- Before and after screenshots
- A video of the whole interaction
- Logs, requests, responses
Bugs and features both need automated tests, and the tests have to exercise the real thing. UI changes need a video, or a screenshot if it's static.
The [QA guide](docs/qa.md) covers what to check for each of these and the tooling to do it.
## I want to become a maintainer
There's no formal process to become a maintainer, just consistently get involved with the project by answering questions, testing, reproducing bugs, discussing workflows with the community and offer to implement features when we reach an agreement.
## FAQ
### I'm blocked, where do I go?
Join [Discord](https://discord.gg/jz8T2uahpH), ask your questions there.
### Do you prefer Github or Discord?
I am more active on Discord for general questions.
I prefer Github for bugs because the template helps collect information more efficiently.
### Why was my feature request closed?
Issues are only for bugs. Open it as a product discussion instead, framed as a workflow.
### Why didn't my PR get a detailed review?
Writing a review is very time consuming and Paseo receives a lot of PRs, I cannot give detailed explanations at volume.
### How long until someone looks at my pull request?
It depends on what else is going on. QA takes longer than reading the diff. You are welcome to join Discord to ask.
### Why do you want tests and a video for a small change?
Because it's faster and safer than me checking out and running every PR myself to test it manually.
QA evidence help me take your PR seriously for a deeper review and maintainer QA.
### I only have one platform, can I still contribute?
Yes. Test what you have, and say what you didn't test.
### Can I use an agent to write my contribution?
Yes, as long as you understand what you're submitting and you tested it yourself.
### Is there a roadmap I can read?
[docs/product.md](docs/product.md) covers the direction. There's no public list of what I'm building next.
+2 -2
View File
@@ -64,7 +64,7 @@ You need at least one agent CLI installed and configured with your credentials:
Download it from [paseo.sh/download](https://paseo.sh/download) or the [GitHub releases page](https://github.com/getpaseo/paseo/releases). Open the app and the daemon starts automatically. Nothing else to install.
To connect from your phone, open **Settings → your host → Connections → Pair a device**.
To connect from your phone, open **Settings → your host → Pair Device**.
### CLI / headless
@@ -75,7 +75,7 @@ npm install -g @getpaseo/cli
paseo
```
This shows a QR code in the terminal. Connect from any client. This path is useful for servers and remote machines.
Paseo starts locally, then asks whether to enable the end-to-end encrypted relay for device pairing. If you decline, connect directly over TCP, Tailscale, or another VPN. This path is useful for servers and remote machines.
For full setup and configuration, see:
-6
View File
@@ -50,12 +50,6 @@ The daemon also supports an optional shared-secret password (set via `auth.passw
Connected clients are trusted operators of the daemon user. File previews follow that authority: a preview request may read any regular file the daemon process can read, while keeping path normalization and symlink checks in the daemon file service. Workspace-relative paths remain a UI convenience, not a security boundary.
An explicit `symlink <path>` entry in a repository's .worktreeinclude intentionally gives a
Paseo-created worktree live access to that source-checkout file or directory. It is useful for
local dependencies and caches, but it weakens the usual worktree isolation: agents and lifecycle
scripts can modify the source through the link. Paseo validates entries and refuses traversal or
destination-link escapes, but the linked source is a deliberate shared-data boundary.
If you expose the daemon beyond loopback, such as by binding to `0.0.0.0`, forwarding it through a tunnel or reverse proxy, or publishing it from a Docker container, you are responsible for restricting and securing that access. Setting a password is strongly recommended in that case.
In Docker, the official image runs the daemon and agents as the non-root
+14
View File
@@ -123,6 +123,20 @@ Clicking either kind opens a workspace tab. A Paseo subagent tab is a normal int
Provider timelines use the same structural timeline item format but deliberately have a separate lifecycle and transport. A provider thread/session identifier is not a Paseo agent identifier, and closing its tab is always layout-only.
Provider descriptors may include one compact subtitle. The provider owns its contents and formatting; clients display and truncate it without interpreting provider-specific model, thinking, or usage fields.
### Claude provider subagents: the task protocol
Claude Code announces subagent lifecycle on the SDK stream (`task_started` / `task_updated` / `task_notification` / `task_progress`), and Paseo reads those announcements rather than reconstructing them from sidechain frames. The live source (`subagents/live-source.ts`) and the replay source (`subagents/replay-source.ts`) both translate into one observation vocabulary (`subagents/observation.ts`), so a fact is derived once for both paths instead of once per path. Gotchas that are not obvious from the SDK types:
- **Not every announced task is a subagent.** A backgrounded shell announces as `task_type: "local_bash"` with a `tool_use_id` and no `subagent_type`; workflows announce as `local_workflow`; ambient housekeeping sets `skip_transcript`. Filtering on the presence of a `tool_use_id` alone puts `sleep 20` in the subagents track.
- **A task that was never declared gets no descriptor, by any route.** Filtered tasks still emit `task_notification`s carrying a `tool_use_id`, and still emit frames carrying `parent_tool_use_id`. Attributing either produces a descriptor with no identity and a defaulted `running` status — a nameless row that never finishes. Status, presentation updates, and sidechain frames all route through the declaration table.
- **Task ids are session-scoped, not turn-scoped.** Cancelling a turn must not clear the routing table: a backgrounded child settles after the interrupt and needs its descriptor to still exist. Cancellation instead terminalizes the declared children that were running in the foreground, and a later `task_notification` is free to correct that guess. Backgrounded children are identified by `task_updated.patch.is_backgrounded`.
- **Effort is only reachable through hooks.** It appears nowhere on the message stream at any depth, and the level Paseo requests is not necessarily the level that runs — a model that does not support it is silently downgraded. A hook firing inside a subagent reports the active post-downgrade level next to its `agent_id`, which is the same id `task_started` calls `task_id`.
- **Backgrounded subagents emit no frames carrying `parent_tool_use_id` at all.** Everything keyed off that field sees nothing for one; they are visible only because the task protocol announces them.
- **On replay, `<session>/subagents/` holds every descendant, not just this session's children.** `agent-<id>.meta.json` carries `spawnDepth`: `1` is a direct child, `2+` was spawned by another subagent and its `toolUseId` names a Task call made inside its parent's session, which nothing in this transcript can resolve. Replaying those adds rows the live stream never showed, each with no Task card and no recoverable outcome, so they render as running forever. One recorded session showed 10 subagents live and would have replayed 22.
- **Replay `totalTokens` is a context-size reading, not cumulative spend.** Claude Code finalizes a subagent by summing the _last_ assistant message's usage block and shipping that as `usage.total_tokens`. Summing per-entry usage instead multiplies the cached prefix by the turn count and reports a number several times larger than the live path.
Archived Paseo subagents disappear from the track, by design. To remove one from the track without closing its tab, use the **archive button** on the row — it opens a confirm dialog and archives the subagent on confirm. Provider-owned rows have no individual Paseo lifecycle controls.
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.
+1 -1
View File
@@ -118,7 +118,7 @@ cd android
PASEO_FDROID_BUILD=1 ./gradlew assembleRelease --no-daemon --max-workers=1 -Dorg.gradle.parallel=false
```
The flag must be present for both prebuild and Gradle because Gradle starts Metro for the release bundle. Keep the source build serial and daemon-free as shown above: compiling every Expo module can exhaust memory when Gradle workers run in parallel. The profile enables source-built Expo modules, excludes the proprietary camera, Firebase notification, and Expo development-client native modules, disables EAS updates and Gradle dependency metadata, and substitutes JavaScript stubs for camera and notifications. The resulting app supports direct and pasted-link pairing but not QR scanning or push notifications.
The flag must be present for both prebuild and Gradle because Gradle starts Metro for the release bundle. Keep the source build serial and daemon-free as shown above: compiling every Expo module can exhaust memory when Gradle workers run in parallel. The profile enables source-built Expo modules, excludes the proprietary camera, Firebase notification, and Expo development-client native modules, disables Gradle dependency metadata, and substitutes JavaScript stubs for camera and notifications. The resulting app supports direct and pasted-link pairing but not QR scanning or push notifications.
For a single-ABI APK, pass React Native's architecture property to Gradle:
+2
View File
@@ -141,6 +141,7 @@ Enables remote access when the daemon is behind a firewall.
- The relay is zero-knowledge — it routes encrypted bytes and cannot read content
- Client and daemon channels with identical API (`createClientChannel`, `createDaemonChannel`)
- Pairing via QR code transfers the daemon's public key to the client
- New homes keep relay disabled until pairing consent. `DaemonConfigStore` persists the desired state, while the relay runtime starts or stops the outbound transport live; pairing reads that current state instead of a startup snapshot.
- Optional E2EE capability negotiation preserves application frame kind: text plaintext uses base64 ciphertext text frames, while binary plaintext uses raw ciphertext binary frames; mixed-version peers remain base64-only
- Self-hosted relays opt into TLS with `daemon.relay.useTls` or `PASEO_RELAY_USE_TLS=true`; the public (client-facing) TLS setting can be overridden independently via `daemon.relay.publicUseTls` or `PASEO_RELAY_PUBLIC_USE_TLS`
@@ -382,6 +383,7 @@ $PASEO_HOME/
├── agents/{cwd-with-dashes}/{agent-id}.json # Agent record + persisted timeline rows
├── 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
+4
View File
@@ -14,6 +14,10 @@ For testing rules, see [testing.md](testing.md).
- **`interface`** over `type` when both work.
- **No `index.ts` barrel files** that only re-export — they create indirection and circular-dep risk. Import from the source.
## Shell scripts
- Bash scripts always use `#!/usr/bin/env bash`. Never hard-code `/bin/bash` or `/usr/bin/bash`; those paths are not portable to environments such as NixOS.
## Comments and noise
- Delete any comment where removing it loses zero information. Comments explain _why_, not _what_.
+30 -15
View File
@@ -4,9 +4,15 @@
Projects are allocated for the exact root selected by the caller, normalized lexically with `path.resolve` (never `realpath`). New project IDs are opaque `prj_<16 hex>` values. Existing remote-shaped or path-shaped IDs are retained as readable compatibility records and are never rekeyed. An active exact root is idempotent; archived-only matches do not resurrect an old project. Workspace `projectId` is stable membership: reconciliation may update git-derived kind and branch metadata, but never rehomes a workspace or changes a project's root, ID, or default name.
`kind` is mutable metadata, not identity. Workspace reconciliation watches active project roots and
updates only a project's `kind` and `updatedAt` when `.git` appears or disappears, preserving its
ID, root path, names, and workspace foreign keys. Attached workspaces are independently refreshed
`projectKey` is a persisted, opaque equivalence key used only to group the same logical project
across hosts. It is separate from the host-local `projectId`; today's producer prefers a normalized
Git remote and otherwise uses the local project root. Consumers never derive it from live Git.
Creation persists it with the project, and normal boot reconciliation fills it for
older records where the field is absent—there is no migration.
`kind` and `projectKey` are mutable metadata, not identity. Workspace reconciliation watches active project roots and
updates those fields and `updatedAt` when Git facts change, preserving the project's ID, root path,
names, and workspace foreign keys. Attached workspaces are independently refreshed
from their own cwd, so an explicit project root never implies a workspace checkout. Empty projects
are observed too.
@@ -54,7 +60,8 @@ $PASEO_HOME/
│ └── loops.json # All loop records
├── projects/
│ ├── projects.json # Project registry
│ └── workspaces.json # Workspace registry
│ ├── workspaces.json # Workspace registry
│ └── icons/ # Host-local custom project icon images
├── runtime/
│ └── managed-processes/
│ └── {recordId}.json # Helper processes owned by Paseo; reconciled on daemon bootstrap
@@ -182,7 +189,7 @@ Single file, validated with `PersistedConfigSchema`.
mcp: { enabled: boolean, injectIntoAgents: boolean },
appendSystemPrompt: string, // appended to supported provider system/developer prompts
cors: { allowedOrigins: string[] },
relay: { enabled: boolean, endpoint: string, publicEndpoint: string, useTls: boolean, publicUseTls: boolean },
relay: { enabled: boolean, endpoint: string, publicEndpoint: string, useTls: boolean, publicUseTls: boolean }, // new homes materialize enabled: false
auth: { password: string } // bcrypt hash, optional
},
app: {
@@ -448,16 +455,24 @@ Single file containing an array of all loop records. Writes are direct (not atom
Array of project records.
| Field | Type | Description |
| ------------- | --------------------------- | -------------------------------------------------------------------------------- |
| `projectId` | `string` | Primary key; new records use opaque `prj_<16 hex>` IDs |
| `rootPath` | `string` | Exact lexically normalized selected root; never realpathed |
| `kind` | `"git" \| "non_git"` | Mutable Git observation about `rootPath`, never a membership key |
| `displayName` | `string` | Selected-root basename, stable across remote and Git changes |
| `customName` | `string \| null` | User-set override layered over `displayName`. Null means "use the derived name". |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete timestamp; required nullable |
| Field | Type | Description |
| -------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `projectId` | `string` | Host-local primary key; new records use opaque `prj_<16 hex>` IDs |
| `projectKey` | `string \| null` | Persisted opaque cross-host grouping key; reconciliation backfills absent values |
| `rootPath` | `string` | Exact lexically normalized selected root; never realpathed |
| `kind` | `"git" \| "non_git"` | Mutable Git observation about `rootPath`, never a membership key |
| `displayName` | `string` | Selected-root basename, stable across remote and Git changes |
| `customName` | `string \| null` | User-set override layered over `displayName`. Null means "use the derived name". |
| `customIconRevision` | `string \| null` | Identifies the host-local custom icon stored under `projects/icons/`. Null means the icon is discovered by scanning the project directory. |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete timestamp; required nullable |
Uploading a file and pasting a website or image URL are two ways of _acquiring_ the same custom
icon. The client fetches URL imports and sends their bytes through the upload RPC. The daemon never
receives or fetches the URL; it validates the uploaded bytes, stores them, and records a new
`customIconRevision`. Going back to automatic deletes the stored image, as does removing the
project.
Active exact roots are idempotent using lexical platform-equivalence semantics. Existing legacy
remote-shaped and path-shaped IDs remain readable, including duplicate roots; reconciliation never
+24 -8
View File
@@ -98,7 +98,7 @@ Five primitives. The pick is determined by option count, the need to search, and
`<AdaptiveModalSheet>` is for a focused task. Multi-field forms (`packages/app/src/components/add-host-modal.tsx`, `packages/app/src/components/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx`), confirmations with detail, anything that earns a backdrop. Bottom sheet on compact, centered card on desktop. Raw `Modal` is wrong for any of these.
`<AdaptiveModalSheet>` owns compact bottom safe-area padding inside the sheet so the sheet background still reaches the screen bottom. If a sheet's first snap point is shorter than its header, content, and safe-area clearance, raise that snap point rather than moving the sheet container.
`<AdaptiveModalSheet>` owns the presentation. Its content inset — the gutter that puts sheet content on the same rails as the sheet header — and compact bottom safe-area padding are the sheet's, not the caller's. A caller declares layout intent through `contentStyle` and never branches on form factor to add its own margins. If a sheet's first snap point is shorter than its header, content, and safe-area clearance, raise that snap point rather than moving the sheet container.
`confirmDialog` is for destructive yes/no and imperative confirmation. Promise-based: `await confirmDialog({ destructive: true, ... })`. Anything where a wrong click loses work.
@@ -122,7 +122,21 @@ The whitespace is the design.
---
## 8. Responsiveness
## 8. Alignment
Things align to their glyphs, not to their boxes. A row's leading icon, its title, and the label of the button in its trailing slot sit on the same rails — the ink lines up, not the padding, not the touch target, not the hover background.
Pick the rails from the content, then hold them. A settings card establishes a leading rail at the icon's left edge and a trailing rail at the last glyph's right edge; every row in that card uses the same two. A row whose icon is absent still starts its title on the leading rail. Indentation is a new rail, not an arbitrary offset.
The pressable is bigger than the glyph, and that is fine. Hit areas grow outward from the aligned content — they never move it. A button that looks two pixels off because its padding is asymmetric is misaligned even though its box is correct.
Optical alignment beats arithmetic when a glyph disagrees with its bounding box. Icons with visual weight on one side, chevrons, and single-character labels usually need a small nudge to look centered. Trust the eye, then leave a comment saying the offset is optical.
One row off the rail makes the whole card look unconsidered.
---
## 9. Responsiveness
Compact-first. The small case is designed; the large case adds chrome around it.
@@ -143,7 +157,7 @@ A new list+detail feature copies the settings shell. A new workspace-shaped feat
---
## 9. Copy and voice
## 10. Copy and voice
Sentence case. "Pair a device", "Danger zone", "Restart daemon", "Inject Paseo tools", "No sessions yet", "Load more". Proper nouns retain casing — Paseo, Beta, Stable, Local. Title case is wrong.
@@ -165,7 +179,7 @@ Terminology:
---
## 10. States
## 11. States
Loading is inline by default. `<LoadingSpinner size={14} color={foregroundMuted} />` sits next to the thing it relates to (`packages/app/src/screens/settings/providers-section.tsx:227-231`). Page-level loading is a centered `<LoadingSpinner size="large">` (`packages/app/src/screens/sessions-screen.tsx:69-72`). Card-level loading is a single short line, not a spinner. In-row dropdown items use `<DropdownMenuItem status="pending" pendingLabel="Removing...">`; the menu item handles its own pending state.
@@ -185,9 +199,11 @@ Partial failure (a list mostly fine but one source errored) is a bordered banner
State surfaces at the smallest scope it affects. Field error stays under the field; page error is a banner; flow-stopping error is an `Alert`.
Changing state must not move the layout. A row that grows when its badge arrives, a card that reflows when a count resolves, a list that jumps as data streams in — all wrong. Reserve the space the loaded state will need, so the skeleton, the spinner, and the content occupy the same box. A surface that shifts under the user stops feeling calm.
---
## 11. List rows
## 12. List rows
The row anatomy is a content column with an optional trailing slot. Inside a card the row is `settingsStyles.row`. Inside a sidebar list the row carries its own padding and `borderRadius.lg` per item (`packages/app/src/components/sidebar-workspace-list.tsx:2614-2625`).
@@ -203,7 +219,7 @@ Selected state on rows in a desktop list+detail uses `surfaceSidebarHover` as th
---
## 12. Status pills and badges
## 13. Status pills and badges
Status pills are `palette.<color>[300]` foreground on a 10%-alpha background of the same color. Success uses green, warning uses amber, danger uses red, muted uses zinc. The `<StatusBadge>` primitive (`packages/app/src/components/ui/status-badge.tsx`) is canonical.
@@ -213,7 +229,7 @@ The bespoke pills in `packages/app/src/screens/settings/host-page.tsx:97-116`, `
---
## 13. Forbidden
## 14. Forbidden
- `fontWeight.medium` on row titles, body text, button labels, badge text, or `<SidebarCallout>` titles. Medium is reserved for the structural-label tier described in §3 — section labels, modal/sheet titles, dense metadata emphasis, and tight action labels. Anything else is `normal`. `<ScreenTitle>` is responsive `400/300` and is never overridden.
- `<Pressable>` wrapping `<Text>` to make a button. `<Button>` exists.
@@ -233,7 +249,7 @@ The bespoke pills in `packages/app/src/screens/settings/host-page.tsx:97-116`, `
---
## 14. Canonical surfaces by pattern
## 15. Canonical surfaces by pattern
| Pattern | Reference |
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-1
View File
@@ -33,7 +33,6 @@ desktop launches always force the daemon to production mode.
- **Repo dev scripts** default to `$ROOT/.dev/paseo-home`, where `$ROOT` is the current checkout or worktree root. This keeps all dev state scoped to the checkout instead of the packaged desktop app.
- **`npm run cli -- ...`** runs through the same dev-home wrapper as the dev scripts, so the in-repo CLI automatically targets the current checkout's `.dev/paseo-home` and configured dev daemon endpoint.
- **Paseo-created worktrees** seed `$PASEO_WORKTREE_PATH/.dev/paseo-home` from `$PASEO_SOURCE_CHECKOUT_PATH/.dev/paseo-home` by copying durable JSON metadata. Runtime files like pid files, sockets, and logs are not copied.
- **Paseo-created worktrees** read `.worktreeinclude` from the live source checkout before creation. Bare paths and `copy <path>` copy a snapshot into the new worktree; `symlink <path>` creates a live source link. Missing paths, malformed entries, unsafe paths, incompatible include overlaps, destination conflicts, unavailable platform links, and ordinary read/write failures are skipped individually and reported in the daemon log, so the rest of the plan still runs. A source symlink is allowed only when its resolved target remains inside the active source checkout. If that checkout is itself Paseo-managed, its own paths remain eligible while other managed worktree paths stay protected; `copy` snapshots that resolved target, while `symlink` links directly to it. Hard links are ordinary files. Each include is staged before it is committed; Paseo aborts creation only if it cannot safely clean up partial materialization state (or Git/worktree setup itself fails). Materialization finishes before `worktree.setup` runs.
- **This repo's worktree setup** also best-effort seeds `packages/app/ios` and the newest `.dev/ios-build` entry from the source checkout so iOS simulator services can reuse native project and Xcode cache state when it is safe enough to do so.
Override knobs:
+3 -2
View File
@@ -2,7 +2,7 @@
Authoritative terminology. UI label wins. Don't invent synonyms; use what's here.
- **Project** — A stable, exact selected-root record. New IDs are opaque `prj_<16 hex>` values; older remote-shaped and path-shaped IDs remain readable compatibility records. Git facts can update mutable kind metadata but never project identity, root, or default display name. UI: "Project" / "Add project". Forbidden: "Repo", "Repository" as UI label.
- **Project** — A stable, exact selected-root record. Its host-local `projectId` is an opaque `prj_<16 hex>` value. Its persisted `projectKey` is an opaque equivalence key that may group the logical project across hosts. A normalized Git remote is the current key producer, but consumers must not parse or rederive it. Git facts can update mutable kind and grouping metadata but never the ID, root, or default display name. UI: "Project" / "Add project". Forbidden: "Repo", "Repository" as UI label.
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. Its `id` is opaque workspace identity; its `cwd` is the filesystem directory. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
- **Archive workspace** — Removes one workspace from active use and archives everything it owns. UI, CLI, and MCP always say "Archive workspace", regardless of backing. The daemon leaves ordinary directories intact and removes a Paseo-owned worktree only when no active workspace still references it.
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. The git-derived, persisted property of a workspace, used across its lifetime (archive safety, sidebar, grouping). Derived from the cwd's git reality by `deriveWorkspaceKind` in `workspace-registry-model.ts`, not stored from a user choice. Don't confuse with **Isolation** (the create-time intent).
@@ -17,7 +17,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Change request** — Forge-neutral term for a proposed branch-to-branch code change. UI normally renders the forge noun instead: GitHub/Gitea/Forgejo "PR", GitLab "MR". Code: `forge_change_request` attachments, `checkoutSource: { kind: "change_request" }`, and PR/MR status payloads.
- **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo.
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym.
- **Repository / Remote** — Internal Git observations. They may affect mutable kind/branch metadata but never project identity, root, display name, or workspace membership. No UI label.
- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label.
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
- **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`.
@@ -41,6 +41,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Subagent** — User-facing term for an agent session related to a parent agent session. Use **subagents** in UI copy and docs. Internal daemon/provider plumbing may say "child agent" or `child_session`, especially for provider-managed imports; do not surface "child agent" as a product term.
- **Attachment tray** — The selected-attachments row inside the composer input, above the text input. Code: `renderAttachmentTray` (`packages/app/src/composer/index.tsx`). Forbidden: "Attachment bar".
- **Conflict** — Two distinct senses; do NOT use the bare word in UI copy without qualifying which: (a) **stale-write conflict** on `paseo.json` ("Config changed on disk", code `stale_project_config`, `packages/app/src/screens/project-settings-screen.tsx:593`); (b) **git merge conflict** (no current UI string).
- **Product discussion** — Where non-bug product talk goes: [GitHub Discussions](https://github.com/getpaseo/paseo/discussions) and `#product` in Discord. Covers workflows, features, and product direction. The container term; the ask inside it is always a **workflow** — what you're doing, how you do it today, where Paseo gets in the way. Issues are bugs only. Forbidden: "Feature request" as a Paseo surface, label, or invitation; the phrase is only used when answering someone who arrived with it. Don't invent "feature discussion", "idea", or "workflow thread" as synonyms. See [CONTRIBUTING.md](../CONTRIBUTING.md).
## Inconsistencies (documented, not papered over)
+59
View File
@@ -0,0 +1,59 @@
# Protocol Compatibility
The app and the daemon are separate products that ship separately. A user updates the app from an app store or a desktop auto-update; they update the daemon when they feel like it. Every combination happens in the wild: new app against an old daemon, old app against a new daemon, and both sides months apart.
In development both sides are always the same version, which is why this is the constraint contributors miss most often.
Two contracts follow from it.
## The protocol contract: always compatible
A schema change must not break parsing in either direction. An old app still parses messages from a new daemon. A new daemon still parses messages from an old app.
- New fields are `.optional()` with a sensible default.
- Never flip optional to required, remove a field, or narrow a type. `string` to `enum` and nullable to non-null are both narrowing.
- A field you stop sending stays accepted. You stop writing it, you don't stop reading it.
- Wire schemas are pure structural declarations. No `.transform()`, `.catch()`, or `.preprocess()` on WebSocket message schemas — normalization happens in an explicit pass after validation. The reason is in [protocol-validation.md](protocol-validation.md): inbound validators are generated, and the generator only compiles pure schemas.
- Plain `z.union()` is forbidden when every branch shares a literal tag. Use `z.discriminatedUnion()`.
- `.default()` belongs on primitive leaves only, never on item schemas inside large arrays or big inbound containers.
Two questions to ask before you commit a schema change:
1. Does a six-month-old app still parse this message?
2. Does a six-month-old daemon still send something this app accepts?
If you can't answer both with yes, the change isn't done.
Schemas live in `packages/protocol/src/messages.ts`. New RPC names follow [rpc-namespacing.md](rpc-namespacing.md).
## The feature contract: per feature, gated once
Features don't have to work across versions. A new feature usually needs a new daemon capability, and old daemons don't have it.
The app checks for the capability and either runs the feature or tells the user to update the host.
- **No fallback paths.** Don't build a degraded version of the feature for old daemons. Don't fan out across legacy RPCs to simulate a capability that isn't there. The user updates or doesn't get the feature.
- **No defensive branches spread through the feature.** Detection happens in one place, and everything downstream reads a clean shape.
- Capability flags live in `features` on the `server_info` message (`packages/protocol/src/messages.ts`, the `server_info` schema).
Existing functionality keeps working across versions because of the protocol contract. Gating a new feature never substitutes for that.
## Every shim is tagged and dated
A shim that exists for old-app or old-daemon support carries a comment naming it, the version it arrived in, and when it can go:
```ts
// COMPAT(workspaceFileEditing): added in v0.2.0, remove after 2027-01-18 once daemon floor >= v0.2.0.
```
`rg "COMPAT\("` is the full cleanup backlog, so:
- One tag per shim, at the site that has to be deleted.
- Give it a name, a version, and a removal condition or date. Six months out is the usual default.
- Never bury compatibility in an untagged `??` fallback or an optional-chain tunnel. Untagged back-compat never gets removed, because nobody can find it.
When a tag's condition is met, delete the shim and the tag in the same change.
## QA
Tests don't fully cover compatibility. If you touched `packages/protocol`, say in the pull request why an older app still parses your message and why an older daemon still satisfies your app. See [qa.md](qa.md).
+8
View File
@@ -460,3 +460,11 @@ Tests use `isProviderAvailable(provider)` to skip when the binary or credentials
**`defaultCommand` is a tuple.** The first element is the binary name, the rest are default arguments. The base class uses this to find the executable and spawn the process.
**Runtime settings can override the command.** Users can configure custom binary paths or environment variables per provider via `ProviderRuntimeSettings`. Your factory in the registry should pass `runtimeSettings?.["your-provider"]` through to the constructor.
**Session-scoped cancellation needs a stop boundary inside the provider.** Some agents cancel the whole session rather than one turn — OpenCode's `session.abort` is the example. A cancel that is still in flight when the next run starts will kill that replacement run, which is what makes "stop, then immediately prompt again" (`replaceRunning`, `notifyOnFinish` wakes, schedules) flaky. Own this in the provider session, not in `AgentManager`:
- Model the stop as an explicit `stopping` turn-state variant carrying the canceled run's terminal and the cancellation the caller is still owed. Pressing Stop again retries the stop already in progress rather than opening a second one; never fire a detached retry, it will outlive its boundary.
- **Scope the cancel settlement the way the provider scopes the cancel.** If cancellation is session-scoped, so is its settlement: track it on the session, accumulating every request issued, and let it outlive the stop that issued it. A request still in flight lands on the runner whenever the server gets to it — however many stops have come and gone since. Scoping it to the current stop looks right and quietly drops older requests from the gate. Only the newest may hold the gate closed, since recovering from a failed cancel is what pressing Stop again is for.
- Gate the operations **the daemon issues** (prompt, slash command, summarize) on both the terminal and cancel settlement. Permission and question responses are not runner operations and must stay outside the gate, or an auto-approve deadlocks the stop. Runs the _provider_ starts on its own — plugin or autonomous wakes — are observed, not gated: the daemon does not choose when they begin, and holding their events back does not protect them from a cancel already in flight, it only hides a run that may already be dead.
- Fail closed: if the cancel never succeeded you never proved the run stopped, so refuse new runs until the next Stop issues a fresh cancel. `AgentManager` already turns a rejected `interrupt()` into a refused cancel.
- Suppress the canceled run's residue only until its authoritative terminal. Anything the provider publishes after that terminal is a new run by construction and must take the normal live path — buffering it and replaying it later is how autonomous/plugin wakes get lost.
+77
View File
@@ -0,0 +1,77 @@
# QA
QA is the main bottleneck of Paseo's product development.
The bar is four questions:
1. Does it work well?
2. Does it regress anything else?
3. Was it tested on every platform it affects?
4. Is there automated coverage, and is that coverage real?
Every pull request needs evidence for the questions its change touches. Pull requests without evidence get closed.
## Evidence
Evidence is something someone else can look at:
- The commands you ran, pasted with their output
- The tests you added and their results
- Before and after screenshots
- A video of the interaction
- Logs, requests, responses
Redact what you need to, keep the technical details. If an agent did the work, submit its raw output. A summary drops the details someone else needs to check it.
## Does it work well
A feature that works but stutters, shifts the layout, or misaligns by two pixels is not done.
For UI, that means matching the design system in visuals, in interaction, and in performance. [design.md](design.md) is the reference.
The two things that go wrong most often:
- **Layout shift.** Content that jumps as data arrives, a row that resizes when a badge appears, a panel that reflows on first render. Watch the change on a slow connection and on first load, not just after everything is warm.
- **Alignment.** Things align to their glyphs, not to their touch targets. See the alignment rules in [design.md](design.md).
## Does it regress anything else
Paseo is composable by design, which means your change sits next to features you didn't touch. Open the surfaces around it. A change to the agent list affects archive, subagents, and tabs; a change to git actions affects worktrees and the checkout flow.
Performance is part of this. The app is Expo React Native, not a web app in a native shell. You are not writing CSS, styles resolve differently, and performance characteristics differ per platform. What feels instant in a desktop dev build can be visibly slow on a phone.
If your change touches a hot path such as the terminal, the message list, or git polling, submit before and after numbers. [terminal-performance.md](terminal-performance.md) has the terminal pipeline and its benchmarks, and [development.md](development.md) covers renderer and React profiling.
## Every platform it affects
Your code does not only run on the platform you tested it on. The same app ships to iOS, Android, browser web, and Electron on macOS, Windows, and Linux, and the daemon runs on all three desktop operating systems plus Docker.
You aren't expected to own every device. You are expected to say what you covered:
| Platform | Tested | Notes |
| --------------- | ------ | ----- |
| iOS | | |
| Android | | |
| Web | | |
| Desktop macOS | | |
| Desktop Windows | | |
| Desktop Linux | | |
Install what you reasonably can. An iOS simulator and an Android emulator cover most of the gap on a single machine, see [development.md](development.md) and [android.md](android.md).
For the rules about which code runs where, read the platform gating section in [CLAUDE.md](../CLAUDE.md). The recurring traps have their own docs: [hover.md](hover.md), [unistyles.md](unistyles.md), [floating-panels.md](floating-panels.md), [mobile-panels.md](mobile-panels.md), [expo-router.md](expo-router.md).
App and daemon versions also drift, in both directions. That has its own contract: [protocol-compatibility.md](protocol-compatibility.md).
## Automated coverage that means something
Tests are evidence only when they exercise the real thing.
- A bug fix needs a regression test that fails on the old code for the reported reason.
- A feature needs tests that go through the same interface a user or a caller uses.
- Web flows need an actual Playwright run, not a mocked-out approximation of one.
- Agent provider work needs manual verification against the real provider by someone who can tell that it worked. A fixture and a mock prove the fixture parses, nothing more.
Tests that mock away the behavior, assert on internals, or pass against the broken code claim coverage that isn't there.
[testing.md](testing.md) is the standard, including how to run suites without freezing your machine. For driving a real daemon in a test, see [ad-hoc-daemon-testing.md](ad-hoc-daemon-testing.md). For mobile flows, [mobile-testing.md](mobile-testing.md). For Electron screenshots, [browser-capture-harness.md](browser-capture-harness.md).
+7 -2
View File
@@ -129,7 +129,12 @@ This mistake once produced tens of thousands of warnings from retained sidebar r
## Main Gotcha: `contentContainerStyle`
`ScrollView.contentContainerStyle` is the canonical trap. It looks like a style prop, but it is not the same prop that Unistyles' remapped native component registers by default. The upstream tutorial calls this out directly in its [ScrollView Background Issue](https://www.unistyl.es/v3/tutorial/settings-screen#scrollview-background-issue) section.
`ScrollView.contentContainerStyle` is the canonical trap. It looks like a style prop, but it is not the `style` prop Unistyles registers. How badly that bites depends on who owns the component:
- **A remapped React Native `ScrollView`** still picks the style up — the Babel plugin rewrites the import and the factory tracks `contentContainerStyle` alongside `style`. The failure mode here is staleness: on first mount it can paint with the theme that was active at mount time and never repaint. The upstream tutorial calls this out in its [ScrollView Background Issue](https://www.unistyl.es/v3/tutorial/settings-screen#scrollview-background-issue) section.
- **A third-party scroller** — `BottomSheetScrollView`, `FlashList`, anything Unistyles never rewrote — drops the style entirely on web. Nothing throws and the value looks right in JS, but the class Unistyles emitted is never applied, so the padding, gap, or background is simply absent. `AdaptiveModalSheet` shipped its compact sheet content flush to the screen edges for exactly this reason while the desktop card, which uses a real `ScrollView`, looked correct.
The second case is why the wrapper-`View` fix below is not optional styling advice. A themed inset belongs on a `View` you own; a third-party scroller only gets theme-free layout.
Avoid this pattern when the style depends on the theme:
@@ -144,7 +149,7 @@ const styles = StyleSheet.create((theme) => ({
}));
```
On first mount this can paint with the current adaptive or initial theme. If app settings later load a persisted theme and call [`UnistylesRuntime.setTheme`](https://www.unistyl.es/v3/guides/theming#change-theme), the JS-side style proxy may report the new theme while the native content container keeps the old background. That is how the welcome screen ended up with a light background and dark foreground/buttons.
If app settings later load a persisted theme and call [`UnistylesRuntime.setTheme`](https://www.unistyl.es/v3/guides/theming#change-theme), the JS-side style proxy reports the new theme while the native content container keeps the old background. That is how the welcome screen ended up with a light background and dark foreground/buttons.
This applies broadly to non-`style` props that carry theme-dependent values, such as component props named `color`, `trackColor`, `tintColor`, `backgroundStyle`, `handleIndicatorStyle`, and other library-specific style props. The [3rd-party view decision algorithm](https://www.unistyl.es/v3/references/3rd-party-views) recommends explicit handling for these cases, and [issue #1030](https://github.com/jpudysz/react-native-unistyles/issues/1030) shows a related native-prop update edge case around `Image.tintColor`. Treat these values as React props unless wrapped with `withUnistyles`.
+15
View File
@@ -33,6 +33,21 @@ buildNpmPackage rec {
&& !(lib.hasPrefix "/packages/app/ios" relPath)
# Website is unrelated to the desktop app
&& !(lib.hasPrefix "/packages/website" relPath)
# Documentation, CI definitions and agent/editor configuration. None of
# these reach the build, but every one of them is part of `src`, so a
# docs-only or workflow-only commit currently invalidates the whole
# desktop derivation and pays for a full Expo export to produce a
# byte-identical result.
&& !(lib.hasPrefix "/docs" relPath)
&& !(lib.hasPrefix "/.github" relPath)
&& !(lib.hasPrefix "/.agents" relPath)
&& !(lib.hasPrefix "/.claude" relPath)
&& !(lib.hasPrefix "/.codex" relPath)
&& !(lib.hasPrefix "/docker" relPath)
# Top-level prose only (README, CHANGELOG, AGENTS...). Deeper markdown is
# not necessarily documentation: skills/*/SKILL.md is a runtime file the
# installPhase copies into the output.
&& builtins.match "/[^/]+\\.md" relPath == null
# Test fixtures and build artifacts
&& !(lib.hasSuffix ".test.ts" baseName)
&& !(lib.hasSuffix ".e2e.test.ts" baseName)
-1
View File
@@ -230,7 +230,6 @@ in
'';
environment = {
NODE_ENV = "production";
PASEO_HOME = cfg.dataDir;
PASEO_LISTEN = "${cfg.listenAddress}:${toString cfg.port}";
} // lib.optionalAttrs cfg.inheritUserEnvironment (
+1 -1
View File
@@ -1 +1 @@
sha256-n7k3zQ1NOm7dGmpqKE6RaEkl50/M2eFek6XIQJbYCEc=
sha256-5t8wlskctuVk/xt8rAUDN9sexSimURt3it9mmMRXaUQ=
+17 -1
View File
@@ -37,6 +37,21 @@ buildNpmPackage rec {
&& !(lib.hasPrefix "/packages/website/public" relPath)
&& !(lib.hasPrefix "/packages/desktop/src" relPath)
&& !(lib.hasPrefix "/packages/desktop/src-tauri" relPath)
# Documentation, CI definitions and agent/editor configuration. None of
# these reach the build. Excluding them here also matters for the desktop
# derivation, which inherits this package's npmDeps: leaving them in makes
# a docs-only commit produce a new npm-deps .drv, and so a new desktop
# .drv, and so a full rebuild for a byte-identical result.
&& !(lib.hasPrefix "/docs" relPath)
&& !(lib.hasPrefix "/.github" relPath)
&& !(lib.hasPrefix "/.agents" relPath)
&& !(lib.hasPrefix "/.claude" relPath)
&& !(lib.hasPrefix "/.codex" relPath)
&& !(lib.hasPrefix "/docker" relPath)
# Top-level prose only (README, CHANGELOG, AGENTS...). Deeper markdown is
# not necessarily documentation: skills/*/SKILL.md is a runtime file the
# daemon's trace script copies into the output.
&& builtins.match "/[^/]+\\.md" relPath == null
# Exclude test fixtures and debug files
&& !(lib.hasSuffix ".test.ts" baseName)
&& !(lib.hasSuffix ".e2e.test.ts" baseName)
@@ -114,9 +129,10 @@ buildNpmPackage rec {
# Create wrapper for the server entry point (for systemd / direct use)
mkdir -p $out/bin
# Keep Paseo's runtime mode separate from NODE_ENV, which belongs to spawned agents.
makeWrapper ${nodejs}/bin/node $out/bin/paseo-server \
--add-flags "$out/lib/paseo/packages/server/dist/scripts/supervisor-entrypoint.js" \
--set NODE_ENV production
--set PASEO_NODE_ENV production
# Create wrapper for the CLI
makeWrapper ${nodejs}/bin/node $out/bin/paseo \
+21 -213
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.2.3",
"version": "0.2.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.2.3",
"version": "0.2.5",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -19610,12 +19610,6 @@
"expo": "*"
}
},
"node_modules/expo-eas-client": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/expo-eas-client/-/expo-eas-client-1.0.8.tgz",
"integrity": "sha512-5or11NJhSeDoHHI6zyvQDW2cz/yFyE+1Cz8NTs5NK8JzC7J0JrkUgptWtxyfB6Xs/21YRNifd3qgbBN3hfKVgA==",
"license": "MIT"
},
"node_modules/expo-file-system": {
"version": "19.0.21",
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.21.tgz",
@@ -20455,12 +20449,6 @@
"node": ">=8.0"
}
},
"node_modules/expo-structured-headers": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/expo-structured-headers/-/expo-structured-headers-5.0.0.tgz",
"integrity": "sha512-RmrBtnSphk5REmZGV+lcdgdpxyzio5rJw8CXviHE6qH5pKQQ83fhMEcigvrkBdsn2Efw2EODp4Yxl1/fqMvOZw==",
"license": "MIT"
},
"node_modules/expo-system-ui": {
"version": "6.0.9",
"resolved": "https://registry.npmjs.org/expo-system-ui/-/expo-system-ui-6.0.9.tgz",
@@ -20487,36 +20475,6 @@
"integrity": "sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g==",
"license": "MIT"
},
"node_modules/expo-updates": {
"version": "29.0.16",
"resolved": "https://registry.npmjs.org/expo-updates/-/expo-updates-29.0.16.tgz",
"integrity": "sha512-E9/fxRz/Eurtc7hxeI/6ZPyHH3To9Xoccm1kXoICZTRojmuTo+dx0Xv53UHyHn4G5zGMezyaKF2Qtj3AKcT93w==",
"license": "MIT",
"dependencies": {
"@expo/code-signing-certificates": "^0.0.6",
"@expo/plist": "^0.4.8",
"@expo/spawn-async": "^1.7.2",
"arg": "4.1.0",
"chalk": "^4.1.2",
"debug": "^4.3.4",
"expo-eas-client": "~1.0.8",
"expo-manifests": "~1.0.10",
"expo-structured-headers": "~5.0.0",
"expo-updates-interface": "~2.0.0",
"getenv": "^2.0.0",
"glob": "^13.0.0",
"ignore": "^5.3.1",
"resolve-from": "^5.0.0"
},
"bin": {
"expo-updates": "bin/cli.js"
},
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*"
}
},
"node_modules/expo-updates-interface": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/expo-updates-interface/-/expo-updates-interface-2.0.0.tgz",
@@ -20526,155 +20484,6 @@
"expo": "*"
}
},
"node_modules/expo-updates/node_modules/@expo/code-signing-certificates": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz",
"integrity": "sha512-iNe0puxwBNEcuua9gmTGzq+SuMDa0iATai1FlFTMHJ/vUmKvN/V//drXoLJkVb5i5H3iE/n/qIJxyoBnXouD0w==",
"license": "MIT",
"dependencies": {
"node-forge": "^1.3.3"
}
},
"node_modules/expo-updates/node_modules/@expo/plist": {
"version": "0.4.8",
"resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.4.8.tgz",
"integrity": "sha512-pfNtErGGzzRwHP+5+RqswzPDKkZrx+Cli0mzjQaus1ZWFsog5ibL+nVT3NcporW51o8ggnt7x813vtRbPiyOrQ==",
"license": "MIT",
"dependencies": {
"@xmldom/xmldom": "^0.8.8",
"base64-js": "^1.2.3",
"xmlbuilder": "^15.1.1"
}
},
"node_modules/expo-updates/node_modules/@xmldom/xmldom": {
"version": "0.8.11",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz",
"integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/expo-updates/node_modules/arg": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.0.tgz",
"integrity": "sha512-ZWc51jO3qegGkVh8Hwpv636EkbesNV5ZNQPCtRa+0qytRYPEs9IYT9qITY9buezqUH5uqyzlWLcufrzU2rffdg==",
"license": "MIT"
},
"node_modules/expo-updates/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/expo-updates/node_modules/brace-expansion": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/expo-updates/node_modules/getenv": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz",
"integrity": "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/expo-updates/node_modules/glob": {
"version": "13.0.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
"integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
"license": "BlueOak-1.0.0",
"dependencies": {
"minimatch": "^10.2.2",
"minipass": "^7.1.3",
"path-scurry": "^2.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/expo-updates/node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/expo-updates/node_modules/lru-cache": {
"version": "11.2.6",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
"integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/expo-updates/node_modules/minimatch": {
"version": "10.2.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/expo-updates/node_modules/node-forge": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz",
"integrity": "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==",
"license": "(BSD-3-Clause OR GPL-2.0)",
"engines": {
"node": ">= 6.13.0"
}
},
"node_modules/expo-updates/node_modules/path-scurry": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
"integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^11.0.0",
"minipass": "^7.1.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/expo-updates/node_modules/xmlbuilder": {
"version": "15.1.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
"integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==",
"license": "MIT",
"engines": {
"node": ">=8.0"
}
},
"node_modules/expo/node_modules/@babel/code-frame": {
"version": "7.10.4",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz",
@@ -35211,7 +35020,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.2.3",
"version": "0.2.5",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -35268,7 +35077,6 @@
"expo-sharing": "^14.0.8",
"expo-splash-screen": "~31.0.10",
"expo-system-ui": "~6.0.7",
"expo-updates": "~29.0.12",
"fast-deep-equal": "^3.1.3",
"htmlparser2": "^12.0.0",
"i18next": "^26.3.0",
@@ -36236,12 +36044,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.2.3",
"version": "0.2.5",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.2.3",
"@getpaseo/protocol": "0.2.3",
"@getpaseo/server": "0.2.3",
"@getpaseo/client": "0.2.5",
"@getpaseo/protocol": "0.2.5",
"@getpaseo/server": "0.2.5",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -36488,10 +36296,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.2.3",
"version": "0.2.5",
"dependencies": {
"@getpaseo/protocol": "0.2.3",
"@getpaseo/relay": "0.2.3",
"@getpaseo/protocol": "0.2.5",
"@getpaseo/relay": "0.2.5",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -36502,7 +36310,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.2.3",
"version": "0.2.5",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -36745,7 +36553,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.2.3",
"version": "0.2.5",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -37641,7 +37449,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.2.3",
"version": "0.2.5",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -37873,7 +37681,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.2.3",
"version": "0.2.5",
"dependencies": {
"zod": "^4.4.3"
},
@@ -37886,7 +37694,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.2.3",
"version": "0.2.5",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38104,15 +37912,15 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.2.3",
"version": "0.2.5",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.2.3",
"@getpaseo/highlight": "0.2.3",
"@getpaseo/protocol": "0.2.3",
"@getpaseo/relay": "0.2.3",
"@getpaseo/client": "0.2.5",
"@getpaseo/highlight": "0.2.5",
"@getpaseo/protocol": "0.2.5",
"@getpaseo/relay": "0.2.5",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -38649,7 +38457,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.2.3",
"version": "0.2.5",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.2.3",
"version": "0.2.5",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
-9
View File
@@ -15,7 +15,6 @@ const buildProfile = isFdroidBuild
cameraPlugins: [],
fdroidPlugins: [withFdroidAutolinking],
notificationPlugins: [],
updates: { enabled: false },
}
: {
androidPermissions: [
@@ -44,7 +43,6 @@ const buildProfile = isFdroidBuild
},
],
],
updates: {},
};
function getNativeBuildVersionCode(version) {
@@ -125,13 +123,6 @@ export default {
scheme: "paseo",
userInterfaceStyle: "automatic",
newArchEnabled: true,
runtimeVersion: {
policy: "appVersion",
},
updates: {
url: "https://u.expo.dev/0e7f65ce-0367-46c8-a238-2b65963d235a",
...buildProfile.updates,
},
ios: {
supportsTablet: true,
infoPlist: {
@@ -0,0 +1,101 @@
import { expect, test, type Page } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import {
readRails,
readSheetHeaderRails,
readVerticalGap,
waitForSettledPosition,
} from "./helpers/sheet-layout";
import {
openCompactSkillsIntegrations,
openSkillSelection,
openSkillsIntegrations,
startSkillsSandbox,
type SkillsSandbox,
} from "./helpers/skills-selection";
const COMPACT_VIEWPORT = { width: 390, height: 844 };
const DESKTOP_VIEWPORT = { width: 1280, height: 900 };
const sandboxes: SkillsSandbox[] = [];
test.afterEach(async () => {
for (const sandbox of sandboxes.splice(0)) {
await sandbox.cleanup();
}
});
async function openChooseSkillsOnPhone(page: Page): Promise<void> {
sandboxes.push(await startSkillsSandbox(page));
await gotoAppShell(page);
await openCompactSkillsIntegrations(page);
await openSkillSelection(page);
await waitForSettledPosition(page.getByTestId("skill-selection-sheet"));
}
async function openChooseSkillsOnDesktop(page: Page): Promise<void> {
sandboxes.push(await startSkillsSandbox(page));
await gotoAppShell(page);
await openSkillsIntegrations(page);
await openSkillSelection(page);
}
function sheetHeaderRails(page: Page) {
return readSheetHeaderRails(page, "skill-selection-sheet", "Choose skills");
}
function allSkillsCard(page: Page) {
return page.getByTestId("skill-selection-all-card");
}
function skillListCard(page: Page) {
return page.getByTestId("skill-selection-list-card");
}
function bundledSkillsLabel(page: Page) {
return page.getByText("Bundled skills", { exact: true });
}
test.describe("Choose skills sheet on a compact form factor", () => {
test.use({ viewport: COMPACT_VIEWPORT });
test("keeps its cards on the sheet header rails", async ({ page }) => {
await openChooseSkillsOnPhone(page);
const header = await sheetHeaderRails(page);
expect(await readRails(allSkillsCard(page))).toEqual(header);
expect(await readRails(skillListCard(page))).toEqual(header);
});
test("gives the Bundled skills label more room above it than below", async ({ page }) => {
await openChooseSkillsOnPhone(page);
expect({
aboveLabel: await readVerticalGap(allSkillsCard(page), bundledSkillsLabel(page)),
belowLabel: await readVerticalGap(bundledSkillsLabel(page), skillListCard(page)),
}).toEqual({ aboveLabel: 16, belowLabel: 12 });
});
});
test.describe("Choose skills sheet on a desktop form factor", () => {
test.use({ viewport: DESKTOP_VIEWPORT });
test("keeps its cards on the sheet header rails", async ({ page }) => {
await openChooseSkillsOnDesktop(page);
const header = await sheetHeaderRails(page);
expect(await readRails(allSkillsCard(page))).toEqual(header);
expect(await readRails(skillListCard(page))).toEqual(header);
});
test("gives the Bundled skills label more room above it than below", async ({ page }) => {
await openChooseSkillsOnDesktop(page);
expect({
aboveLabel: await readVerticalGap(allSkillsCard(page), bundledSkillsLabel(page)),
belowLabel: await readVerticalGap(bundledSkillsLabel(page), skillListCard(page)),
}).toEqual({ aboveLabel: 16, belowLabel: 12 });
});
});
+21 -24
View File
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
import { mkdtemp, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { test, expect, type Page } from "./fixtures";
import { test, expect } from "./fixtures";
import {
addProjectFlow,
addProjectFlowBack,
@@ -13,36 +13,21 @@ import {
expectAddProjectPage,
expectNewWorkspaceForAddedProject,
openAddProjectFlow,
waitForConnectedHost,
} from "./helpers/add-project-flow";
import { gotoAppShell } from "./helpers/app";
import { buildSeededHost } from "./helpers/daemon-registry";
import { addOfflineHostAndReload } from "./helpers/hosts";
import {
addConnectedHostAndReload,
addOfflineHostAndReload,
waitForConnectedHost,
} from "./helpers/hosts";
import { type IsolatedHostDaemon, startIsolatedHostDaemon } from "./helpers/isolated-host-daemon";
import { expectOpenedProject } from "./helpers/project-picker-ui";
import { connectSeedClient } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
const EXTRA_HOSTS_KEY = "@paseo:e2e-extra-hosts";
const SECONDARY_HOST_ID = "add-project-flow-secondary";
const SECONDARY_HOST_LABEL = "Secondary Host";
async function addConnectedHostAndReload(page: Page, host: IsolatedHostDaemon): Promise<void> {
const registryEntry = buildSeededHost({
serverId: host.serverId,
label: SECONDARY_HOST_LABEL,
endpoint: `127.0.0.1:${host.port}`,
nowIso: new Date().toISOString(),
});
await page.evaluate(
({ key, entry }) => {
localStorage.setItem(key, JSON.stringify([entry]));
},
{ key: EXTRA_HOSTS_KEY, entry: registryEntry },
);
await page.reload();
}
async function expectProjectDirectory(pathname: string): Promise<void> {
await expect.poll(async () => (await stat(pathname)).isDirectory()).toBe(true);
}
@@ -149,7 +134,11 @@ test.describe("Add Project command-center flow", () => {
test("keyboard selection chooses the second host", async ({ page }) => {
await gotoAppShell(page);
await addConnectedHostAndReload(page, secondaryHost);
await addConnectedHostAndReload(page, {
serverId: secondaryHost.serverId,
label: SECONDARY_HOST_LABEL,
port: secondaryHost.port,
});
await waitForConnectedHost(page, {
serverId: SECONDARY_HOST_ID,
endpoint: `localhost:${secondaryHost.port}`,
@@ -167,7 +156,11 @@ test.describe("Add Project command-center flow", () => {
page,
}) => {
await gotoAppShell(page);
await addConnectedHostAndReload(page, secondaryHost);
await addConnectedHostAndReload(page, {
serverId: secondaryHost.serverId,
label: SECONDARY_HOST_LABEL,
port: secondaryHost.port,
});
await waitForConnectedHost(page, {
serverId: SECONDARY_HOST_ID,
endpoint: `localhost:${secondaryHost.port}`,
@@ -211,7 +204,11 @@ test.describe("Add Project command-center flow", () => {
try {
await gotoAppShell(page);
await addConnectedHostAndReload(page, secondaryHost);
await addConnectedHostAndReload(page, {
serverId: secondaryHost.serverId,
label: SECONDARY_HOST_LABEL,
port: secondaryHost.port,
});
await waitForConnectedHost(page, {
serverId: SECONDARY_HOST_ID,
endpoint: `localhost:${secondaryHost.port}`,
@@ -0,0 +1,110 @@
import { test } from "./fixtures";
import { closeCommandCenter, openCommandCenter } from "./helpers/command-center";
import {
applyCommandCenterAgentControls,
chooseCommandCenterAgentControl,
expectCommandCenterAgentControlSelected,
expectFocusedAgentControls,
expectWorkspaceAgentConfiguration,
submitDraftAgent,
waitForDraftComposer,
} from "./helpers/command-center-agent-controls";
import { clickNewChat, gotoWorkspace } from "./helpers/launcher";
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
import { seedWorkspace } from "./helpers/seed-client";
import { getServerId } from "./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 }) => {
localStorage.setItem(
preferencesKey,
JSON.stringify({
serverId,
provider: "mock",
providerPreferences: {
mock: {
model: "five-minute-stream",
mode: "load-test",
},
},
}),
);
},
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY, serverId: getServerId() },
);
}
test.describe("Command Center agent controls", () => {
test.describe.configure({ timeout: 180_000 });
test("changes a running agent setting and preserves its selected row", async ({ page }) => {
const workspace = await seedMockAgentWorkspace({
repoPrefix: "command-center-live-controls-",
title: "Command Center live controls",
model: "five-minute-stream",
});
try {
// A stale runtime mode makes selecting the provider's supported mode
// exercise the live setting RPC instead of the selected-row no-op.
await workspace.client.setAgentMode(workspace.agentId, "legacy-mode");
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
});
await openCommandCenter(page);
await chooseCommandCenterAgentControl({
page,
query: "load test",
choice: "Mode › Load test",
});
await expectWorkspaceAgentConfiguration(workspace, {
id: workspace.agentId,
provider: "mock",
model: "five-minute-stream",
modeId: "load-test",
});
await openCommandCenter(page);
await expectCommandCenterAgentControlSelected({
page,
query: "load test",
choice: "Mode › Load test",
});
} finally {
await workspace.cleanup();
}
});
test("applies draft model and setting choices to the created agent", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "command-center-draft-controls-" });
await seedMockDraftPreferences(page);
try {
await openDraftComposer(page, workspace.workspaceId);
await applyCommandCenterAgentControls(page, DRAFT_AGENT_CONTROL_CHOICES);
await closeCommandCenter(page);
await submitDraftAgent(page, "Create an agent with Command Center draft choices");
await expectFocusedAgentControls(page, DRAFT_AGENT_CONTROL_CHOICES);
await expectWorkspaceAgentConfiguration(workspace, {
provider: "mock",
model: "ten-second-stream",
modeId: "load-test",
});
} finally {
await workspace.cleanup();
}
});
});
const DRAFT_AGENT_CONTROL_CHOICES = [
{ query: "ten second stream", choice: "Model › Mock Load Test › Ten second stream" },
{ query: "load test", choice: "Mode › Load test" },
] as const;
async function openDraftComposer(page: import("@playwright/test").Page, workspaceId: string) {
await gotoWorkspace(page, workspaceId);
await clickNewChat(page);
await waitForDraftComposer(page);
}
@@ -207,7 +207,7 @@ test.describe("Composer attachments", () => {
});
await openNewWorkspaceComposer(page, {
projectKey: workspace.projectId,
projectKey: workspace.projectKey,
projectDisplayName: workspace.projectDisplayName,
});
@@ -293,7 +293,7 @@ test.describe("Composer attachments", () => {
});
await openNewWorkspaceComposer(page, {
projectKey: workspace.projectId,
projectKey: workspace.projectKey,
projectDisplayName: workspace.projectDisplayName,
});
await fillComposerDraft(page, "lock test prompt");
+24 -15
View File
@@ -10,6 +10,7 @@ import {
import { expectOpenedProject } from "./helpers/project-picker-ui";
import { connectSeedClient, seedWorkspace } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { projectEquivalenceViewKey } from "./helpers/project-view-key";
import { createTempGitRepo } from "./helpers/workspace";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
@@ -32,12 +33,12 @@ async function archiveWorkspaceFromSidebar(page: Page, workspaceId: string): Pro
await archiveItem.click();
}
async function removeProjectFromSidebar(page: Page, projectId: string): Promise<void> {
const projectRow = page.getByTestId(`sidebar-project-row-${projectId}`);
async function removeProjectFromSidebar(page: Page, projectViewKey: string): Promise<void> {
const projectRow = page.getByTestId(`sidebar-project-row-${projectViewKey}`);
await expect(projectRow).toBeVisible({ timeout: 30_000 });
await projectRow.hover();
const kebab = page.getByTestId(`sidebar-project-kebab-${projectId}`);
const kebab = page.getByTestId(`sidebar-project-kebab-${projectViewKey}`);
await expect(kebab).toBeVisible({ timeout: 10_000 });
await kebab.click();
@@ -45,7 +46,7 @@ async function removeProjectFromSidebar(page: Page, projectId: string): Promise<
// user-confirmed removal proceeds deterministically.
page.once("dialog", (dialog) => void dialog.accept());
const removeItem = page.getByTestId(`sidebar-project-menu-remove-${projectId}`);
const removeItem = page.getByTestId(`sidebar-project-menu-remove-${projectViewKey}`);
await expect(removeItem).toBeVisible({ timeout: 10_000 });
await removeItem.click();
}
@@ -152,9 +153,10 @@ test.describe("Project with no workspaces persists", () => {
const workspace = await seedWorkspace({ repoPrefix: "empty-project-persists-" });
try {
const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectId}`);
const projectViewKey = projectEquivalenceViewKey(workspace.projectKey);
const projectRow = page.getByTestId(`sidebar-project-row-${projectViewKey}`);
const newWorkspaceRow = page.getByTestId(
`sidebar-project-new-workspace-row-${workspace.projectId}`,
`sidebar-project-new-workspace-row-${projectViewKey}`,
);
const globalNewWorkspace = page.getByTestId("sidebar-global-new-workspace");
@@ -195,9 +197,11 @@ test.describe("Project with no workspaces persists", () => {
test.describe("Project remove", () => {
test("removing a project from project actions removes it from the sidebar", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "project-remove-sidebar-" });
let readdedProjectId: string | null = null;
try {
const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectId}`);
const projectViewKey = projectEquivalenceViewKey(workspace.projectKey);
const projectRow = page.getByTestId(`sidebar-project-row-${projectViewKey}`);
await gotoAppShell(page);
await waitForSidebarHydration(page);
@@ -206,7 +210,7 @@ test.describe("Project remove", () => {
timeout: 30_000,
});
await removeProjectFromSidebar(page, workspace.projectId);
await removeProjectFromSidebar(page, projectViewKey);
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toHaveCount(0, {
timeout: 30_000,
@@ -220,21 +224,26 @@ test.describe("Project remove", () => {
const readded = await workspace.client.addProject(workspace.repoPath);
expect(readded.error).toBeNull();
expect(readded.project).not.toBeNull();
const readdedProjectId = readded.project?.projectId ?? "";
readdedProjectId = readded.project?.projectId ?? "";
const readdedProjectKey = readded.project?.projectKey ?? "";
expect(readdedProjectId).not.toBe(workspace.projectId);
expect(readdedProjectKey).toBe(workspace.projectKey);
expect(readded.project?.projectDisplayName).toBe(workspace.projectDisplayName);
await page.reload();
await waitForSidebarHydration(page);
await expect(projectRow).toHaveCount(0, { timeout: 30_000 });
const readdedProjectRow = page.getByTestId(`sidebar-project-row-${readdedProjectId}`);
await expect(readdedProjectRow).toBeVisible({ timeout: 30_000 });
await expect(readdedProjectRow).toContainText(workspace.projectDisplayName);
await expect(readdedProjectRow).not.toContainText(workspace.repoPath);
await expect(projectRow).toBeVisible({ timeout: 30_000 });
await expect(projectRow).toContainText(workspace.projectDisplayName);
await expect(projectRow).not.toContainText(workspace.repoPath);
await expect(
page.getByTestId(`sidebar-project-new-workspace-row-${readdedProjectId}`),
page.getByTestId(
`sidebar-project-new-workspace-row-${projectEquivalenceViewKey(readdedProjectKey)}`,
),
).toBeVisible({ timeout: 30_000 });
} finally {
if (readdedProjectId) {
await workspace.client.removeProject(readdedProjectId).catch(() => undefined);
}
await workspace.cleanup();
}
});
@@ -0,0 +1,359 @@
import { mkdir, readFile, rename, rm, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "./fixtures";
import { expectFileTabOpen, openFileExplorer, openFileFromExplorer } from "./helpers/file-explorer";
import type { WithWorkspace } from "./helpers/with-workspace";
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
import {
expectFileCalloutWasRendered,
expectNoFileCalloutWasRendered,
recordFileCallouts,
} from "./helpers/file-callouts";
function visibleEditor(page: Page) {
return page.getByTestId("file-source-editor").filter({ visible: true }).locator(".cm-content");
}
function filePane(page: Page) {
return page.getByTestId("workspace-file-pane").filter({ visible: true });
}
function fileCallout(page: Page) {
return filePane(page).getByRole("alert");
}
async function openFile(page: Page, filename: string): Promise<void> {
await openFileExplorer(page);
await openFileFromExplorer(page, filename);
await expectFileTabOpen(page, filename);
}
async function openTrackedFile(
page: Page,
withWorkspace: WithWorkspace,
input: { prefix: string; relativePath: string; content: string | Uint8Array },
): Promise<string> {
const workspace = await withWorkspace({ prefix: input.prefix });
const filePath = path.join(workspace.repoPath, input.relativePath);
if (typeof input.content === "string") {
await writeFile(filePath, input.content, "utf8");
} else {
await writeFile(filePath, input.content);
}
await workspace.navigateTo();
await openFile(page, input.relativePath);
return filePath;
}
async function replaceEditorText(page: Page, content: string): Promise<void> {
const editor = visibleEditor(page);
await editor.click();
await editor.press("Control+A");
await editor.type(content);
}
async function replaceFileAfterDeletionWasObserved(input: {
page: Page;
gate: Awaited<ReturnType<typeof installDaemonWebSocketGate>>;
filePath: string;
relativePath: string;
content: string;
}): Promise<void> {
const { page, gate, filePath, relativePath, content } = input;
// Recreate the observed race without faking file state: the daemon publishes the
// deletion, then a real read succeeds after the replacement while its ready event waits.
await gate.waitForFileSubscription(relativePath);
gate.holdFileReads(relativePath);
await unlink(filePath);
await gate.waitForHeldFileRead();
await expect(fileCallout(page)).toHaveCount(0);
gate.releaseHeldFileRead();
await expectOnlyFileCallout(page, "File deleted on disk");
gate.holdNextReadyFileUpdate(relativePath);
await writeFile(filePath, content, "utf8");
await gate.waitForHeldReadyFileUpdate();
await expect.poll(() => readFile(filePath, "utf8")).toBe(content);
}
async function expectOnlyFileCallout(page: Page, title: string): Promise<void> {
await expect(fileCallout(page)).toHaveCount(1);
await expect(fileCallout(page)).toContainText(title);
await expect(filePane(page).getByText(title, { exact: true })).toHaveCount(1);
}
async function expectCleanReplacementCanReload(page: Page): Promise<void> {
await expectOnlyFileCallout(page, "Changed on disk");
await expect(filePane(page).getByText("File deleted on disk", { exact: true })).toHaveCount(0);
await expect(fileCallout(page).getByRole("button", { name: "Overwrite" })).toHaveCount(0);
await expect(fileCallout(page).getByRole("button", { name: "Reload" })).toBeEnabled();
await fileCallout(page).getByRole("button", { name: "Reload" }).click();
await expect(filePane(page).getByText("After", { exact: true })).toBeVisible();
await expect(fileCallout(page)).toHaveCount(0);
}
async function expectDeletedFileNotice(page: Page): Promise<void> {
await expectOnlyFileCallout(page, "File deleted on disk");
await expect(fileCallout(page)).toContainText("The open copy is preserved.");
await expect(filePane(page).getByText("Changed on disk", { exact: true })).toHaveCount(0);
await expect(fileCallout(page).getByRole("button", { name: "Overwrite" })).toHaveCount(0);
await expect(fileCallout(page).getByRole("button", { name: "Reload" })).toHaveCount(0);
}
async function expectDirtyConflictCanReload(page: Page): Promise<void> {
await expectOnlyFileCallout(page, "Changed on disk");
await expect(fileCallout(page).getByRole("button", { name: "Overwrite" })).toBeEnabled();
await expect(fileCallout(page).getByRole("button", { name: "Reload" })).toBeEnabled();
page.once("dialog", (dialog) => dialog.accept());
await fileCallout(page).getByRole("button", { name: "Reload" }).click();
await expect(visibleEditor(page)).toContainText("const external = true;");
await expect(fileCallout(page)).toHaveCount(0);
}
async function restoreFileAfterWatcherObservedTemporaryAbsence(input: {
gate: Awaited<ReturnType<typeof installDaemonWebSocketGate>>;
filePath: string;
relativePath: string;
}): Promise<void> {
const parkedPath = `${input.filePath}.saving`;
input.gate.holdFileReads(input.relativePath);
await rename(input.filePath, parkedPath);
await input.gate.waitForFileUpdate(input.relativePath, "missing");
await input.gate.waitForHeldFileRead();
await writeFile(input.filePath, await readFile(parkedPath));
await unlink(parkedPath);
input.gate.releaseHeldFileRead();
}
test.describe("Workspace file change conflicts", () => {
test("a temporary write gap never renders a disk-change callout", async ({
page,
withWorkspace,
}) => {
const gate = await installDaemonWebSocketGate(page);
const relativePath = "temporary-gap.ts";
const filePath = await openTrackedFile(page, withWorkspace, {
prefix: "file-temporary-gap-",
relativePath,
content: "const preserved = true;\n",
});
await gate.waitForFileSubscription(relativePath);
await recordFileCallouts(page);
await restoreFileAfterWatcherObservedTemporaryAbsence({ gate, filePath, relativePath });
await replaceEditorText(page, "const local = true;\n");
await expect.poll(() => readFile(filePath, "utf8")).toContain("const local = true;");
await expect(fileCallout(page)).toHaveCount(0);
await expectNoFileCalloutWasRendered(page);
});
test("an identical atomic rewrite never renders a disk-change callout", async ({
page,
withWorkspace,
}) => {
const gate = await installDaemonWebSocketGate(page);
const relativePath = "identical-rewrite.ts";
const content = "const preserved = true;\n";
const filePath = await openTrackedFile(page, withWorkspace, {
prefix: "file-identical-rewrite-",
relativePath,
content,
});
await gate.waitForFileSubscription(relativePath);
await recordFileCallouts(page);
gate.holdNextReadyFileUpdate(relativePath);
await writeFile(`${filePath}.tmp`, content, "utf8");
await rename(`${filePath}.tmp`, filePath);
await gate.waitForHeldReadyFileUpdate();
await replaceEditorText(page, "const local = true;\n");
gate.releaseHeldReadyFileUpdate();
await expect.poll(() => readFile(filePath, "utf8")).toContain("const local = true;");
await expect(fileCallout(page)).toHaveCount(0);
await expectNoFileCalloutWasRendered(page);
});
test("a save watcher refresh never replays the pre-save buffer", async ({
page,
withWorkspace,
}) => {
const gate = await installDaemonWebSocketGate(page);
const relativePath = "save-refresh.ts";
const filePath = await openTrackedFile(page, withWorkspace, {
prefix: "file-save-refresh-",
relativePath,
content: "const before = true;\n",
});
await gate.waitForFileSubscription(relativePath);
await recordFileCallouts(page);
gate.holdFileReads(relativePath);
await replaceEditorText(page, "const saved = true;\n");
await expect.poll(() => readFile(filePath, "utf8")).toContain("const saved = true;");
await gate.waitForHeldFileRead();
await expect(visibleEditor(page)).toContainText("const saved = true;");
await expect(fileCallout(page)).toHaveCount(0);
await expectNoFileCalloutWasRendered(page);
gate.releaseHeldFileRead();
});
test("a clean file replaced on disk offers one working reload action", async ({
page,
withWorkspace,
}) => {
const gate = await installDaemonWebSocketGate(page);
const filePath = await openTrackedFile(page, withWorkspace, {
prefix: "file-clean-replacement-",
relativePath: "inventory.md",
content: "# Before\n",
});
await expect(filePane(page).getByText("Before", { exact: true })).toBeVisible();
await replaceFileAfterDeletionWasObserved({
page,
gate,
filePath,
relativePath: "inventory.md",
content: "# After\n",
});
gate.releaseHeldReadyFileUpdate();
await expectCleanReplacementCanReload(page);
});
test("a deleted file shows one explanatory notice and no resolution actions", async ({
page,
withWorkspace,
}) => {
const filePath = await openTrackedFile(page, withWorkspace, {
prefix: "file-deleted-",
relativePath: "deleted.md",
content: "# Present\n",
});
await expect(filePane(page).getByText("Present", { exact: true })).toBeVisible();
await recordFileCallouts(page);
await unlink(filePath);
await expectDeletedFileNotice(page);
await expectFileCalloutWasRendered(page, "File deleted on disk");
});
test("a file check error offers a working retry", async ({ page, withWorkspace }) => {
const gate = await installDaemonWebSocketGate(page);
const relativePath = "retry.md";
const filePath = await openTrackedFile(page, withWorkspace, {
prefix: "file-check-retry-",
relativePath,
content: "# Before\n",
});
await gate.waitForFileSubscription(relativePath);
await expect(filePane(page).getByText("Before", { exact: true })).toBeVisible();
await unlink(filePath);
await mkdir(filePath);
await expectOnlyFileCallout(page, "Couldn't check file on disk");
await expect(fileCallout(page).getByRole("button", { name: "Retry" })).toBeEnabled();
await expect(fileCallout(page).getByRole("button", { name: "Reload" })).toHaveCount(0);
await expect(fileCallout(page).getByRole("button", { name: "Overwrite" })).toHaveCount(0);
gate.holdNextReadyFileUpdate(relativePath);
await rm(filePath, { recursive: true });
await writeFile(filePath, "# After\n", "utf8");
await gate.waitForHeldReadyFileUpdate();
gate.holdFileReads(relativePath);
await fileCallout(page).getByRole("button", { name: "Retry" }).click();
await gate.waitForHeldFileRead();
await expect(fileCallout(page).getByRole("button", { name: "Retry" })).toBeDisabled();
gate.releaseHeldFileRead();
await expectCleanReplacementCanReload(page);
gate.releaseHeldReadyFileUpdate();
});
test("retry clears a transient error when the file is unchanged", async ({
page,
withWorkspace,
}) => {
const gate = await installDaemonWebSocketGate(page);
const relativePath = "unchanged.md";
const filePath = await openTrackedFile(page, withWorkspace, {
prefix: "file-check-unchanged-",
relativePath,
content: "# Unchanged\n",
});
const parkedPath = `${filePath}.parked`;
await gate.waitForFileSubscription(relativePath);
await expect(filePane(page).getByText("Unchanged", { exact: true })).toBeVisible();
await rename(filePath, parkedPath);
await mkdir(filePath);
await expectOnlyFileCallout(page, "Couldn't check file on disk");
gate.holdNextReadyFileUpdate(relativePath);
await rm(filePath, { recursive: true });
await rename(parkedPath, filePath);
await gate.waitForHeldReadyFileUpdate();
await fileCallout(page).getByRole("button", { name: "Retry" }).click();
await expect(fileCallout(page)).toHaveCount(0);
await expect(filePane(page).getByText("Changed on disk", { exact: true })).toHaveCount(0);
gate.releaseHeldReadyFileUpdate();
});
test("a read-only file error preserves its message and offers retry", async ({
page,
withWorkspace,
}) => {
const gate = await installDaemonWebSocketGate(page);
const relativePath = "artifact.bin";
const binaryContent = new Uint8Array([0, 1, 2, 3, 255]);
const filePath = await openTrackedFile(page, withWorkspace, {
prefix: "file-read-only-retry-",
relativePath,
content: binaryContent,
});
await gate.waitForFileSubscription(relativePath);
await expect(
filePane(page).getByText("Binary preview unavailable", { exact: true }),
).toBeVisible();
await unlink(filePath);
await mkdir(filePath);
await expect(
filePane(page).getByText("Requested path is not a file", { exact: true }),
).toBeVisible();
await expect(filePane(page).getByRole("button", { name: "Retry" })).toBeEnabled();
gate.holdNextReadyFileUpdate(relativePath);
await rm(filePath, { recursive: true });
await writeFile(filePath, binaryContent);
await gate.waitForHeldReadyFileUpdate();
gate.holdFileReads(relativePath);
await filePane(page).getByRole("button", { name: "Retry" }).click();
await gate.waitForHeldFileRead();
await expect(
filePane(page).getByText("Requested path is not a file", { exact: true }),
).toHaveCount(0);
gate.releaseHeldFileRead();
await expect(
filePane(page).getByText("Binary preview unavailable", { exact: true }),
).toBeVisible();
await expect(
filePane(page).getByText("Requested path is not a file", { exact: true }),
).toHaveCount(0);
gate.releaseHeldReadyFileUpdate();
});
test("a changed file with local edits offers overwrite and a working reload", async ({
page,
withWorkspace,
}) => {
const filePath = await openTrackedFile(page, withWorkspace, {
prefix: "file-dirty-conflict-",
relativePath: "source.ts",
content: "const before = true;\n",
});
await replaceEditorText(page, "const local = true;\n");
await writeFile(filePath, "const external = true;\n", "utf8");
await expectDirtyConflictCanReload(page);
});
});
+9
View File
@@ -25,6 +25,7 @@ const test = base.extend<{
paseoE2ESetup: void;
outdatedDaemon: OutdatedDaemon;
desktopManagedOutdatedDaemon: OutdatedDaemon;
relayConfigOutdatedDaemon: OutdatedDaemon;
projectPickerFixture: TrackedProjectPickerFixture;
withWorkspace: WithWorkspace;
}>({
@@ -129,6 +130,14 @@ const test = base.extend<{
await provide(daemon);
await daemon.close();
},
relayConfigOutdatedDaemon: async ({}, provide) => {
const daemon = await startOutdatedDaemon({
daemonStatusRpcCapability: false,
relayConfigCapability: false,
});
await provide(daemon);
await daemon.close();
},
projectPickerFixture: async ({}, provide) => {
const resource = await createProjectPickerFixture();
const { fixture } = resource;
+1
View File
@@ -780,6 +780,7 @@ function startDaemon(args: DaemonSpawnArgs): ChildProcess {
PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath,
PASEO_SERVER_ID: "srv_e2e_test_daemon",
PASEO_LISTEN: `0.0.0.0:${args.port}`,
PASEO_RELAY_ENABLED: "true",
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
PASEO_NODE_ENV: "development",
@@ -37,17 +37,6 @@ export function addProjectFlowMethod(page: Page, method: AddProjectMethod): Loca
return page.getByTestId(`add-project-flow-method-${method}`);
}
export async function waitForConnectedHost(
page: Page,
input: { serverId: string; endpoint: string },
): Promise<void> {
await page.getByTestId("sidebar-hosts-trigger").click();
const host = page.getByTestId(`sidebar-host-row-${input.serverId}`);
await expect(host).toContainText(input.endpoint, { timeout: 30_000 });
await page.keyboard.press("Escape");
await expect(host).not.toBeVisible();
}
export async function expectAddProjectPage(page: Page, kind: AddProjectFlowPage): Promise<Locator> {
const currentPage = page.getByTestId(`add-project-flow-page-${kind}`);
await expect(currentPage).toBeVisible({ timeout: 30_000 });
@@ -0,0 +1,95 @@
import { expect, type Page } from "@playwright/test";
import type { SeededWorkspace } from "./seed-client";
import { openCommandCenter } from "./command-center";
export interface CommandCenterAgentControlChoice {
query: string;
choice: string;
}
export async function chooseCommandCenterAgentControl(input: {
page: Page;
query: string;
choice: string;
}): Promise<void> {
const panel = input.page.getByTestId("command-center-panel");
await panel.getByRole("textbox").fill(input.query);
const choice = panel.getByRole("button", { name: input.choice });
await expect(choice).toBeVisible({ timeout: 30_000 });
await choice.click();
}
export async function expectCommandCenterAgentControlSelected(input: {
page: Page;
query: string;
choice: string;
}): Promise<void> {
const panel = input.page.getByTestId("command-center-panel");
await panel.getByRole("textbox").fill(input.query);
const choice = panel.getByRole("button", { name: input.choice });
await expect(choice).toHaveRole("button");
await expect(choice).toHaveAttribute("aria-pressed", "true");
}
export async function applyCommandCenterAgentControls(
page: Page,
choices: readonly CommandCenterAgentControlChoice[],
): Promise<void> {
for (const choice of choices) {
await openCommandCenter(page);
await chooseCommandCenterAgentControl({ page, ...choice });
}
}
export async function expectFocusedAgentControls(
page: Page,
choices: readonly CommandCenterAgentControlChoice[],
): Promise<void> {
await openCommandCenter(page);
for (const choice of choices) {
await expectCommandCenterAgentControlSelected({ page, ...choice });
}
}
export async function submitDraftAgent(page: Page, prompt: string): Promise<void> {
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
await expect(composer).toBeEditable({ timeout: 30_000 });
await composer.fill(prompt);
await composer.press("Enter");
await expect(page.getByText(prompt, { exact: true }).first()).toBeVisible({ timeout: 30_000 });
}
export async function waitForDraftComposer(page: Page): Promise<void> {
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeEditable({
timeout: 30_000,
});
}
export async function expectWorkspaceAgentConfiguration(
workspace: Pick<SeededWorkspace, "client" | "workspaceId">,
expected: { id?: string; provider: string; model: string | null; modeId: string | null },
): Promise<void> {
await expect
.poll(
async () => {
const entries = (await workspace.client.fetchAgents({ scope: "active" })).entries;
const agent = entries
.map((entry) => entry.agent)
.find(
(candidate) =>
candidate.workspaceId === workspace.workspaceId &&
(expected.id === undefined || candidate.id === expected.id),
);
return agent
? {
id: agent.id,
provider: agent.provider,
model: agent.model,
modeId: agent.currentModeId,
}
: null;
},
{ timeout: 30_000 },
)
.toMatchObject(expected);
}
@@ -7,3 +7,8 @@ export async function openCommandCenter(page: Page): Promise<Locator> {
await expect(panel).toBeVisible({ timeout: 30_000 });
return panel;
}
export async function closeCommandCenter(page: Page): Promise<void> {
await page.keyboard.press("Escape");
await expect(page.getByTestId("command-center-panel")).not.toBeVisible();
}
@@ -24,6 +24,8 @@ type OutdatedDaemonMessage = OutdatedDaemonReadyMessage | OutdatedDaemonErrorMes
export async function startOutdatedDaemon(options?: {
desktopManaged?: boolean;
daemonStatusRpcCapability?: boolean;
relayConfigCapability?: boolean;
}): Promise<OutdatedDaemon> {
const metroPort = process.env.E2E_METRO_PORT;
if (!metroPort) {
@@ -37,6 +39,8 @@ export async function startOutdatedDaemon(options?: {
...process.env,
E2E_METRO_PORT: metroPort,
E2E_DESKTOP_MANAGED: options?.desktopManaged === true ? "1" : "0",
E2E_DAEMON_STATUS_RPC_CAPABILITY: options?.daemonStatusRpcCapability === false ? "0" : "1",
E2E_RELAY_CONFIG_CAPABILITY: options?.relayConfigCapability === false ? "0" : "1",
},
execArgv: ["--import", "tsx"],
stdio: ["ignore", "pipe", "pipe", "ipc"],
+191 -55
View File
@@ -18,6 +18,21 @@ interface ClientRequest {
subscribe?: unknown;
page?: { cursor?: unknown };
payload?: unknown;
mode?: unknown;
path?: unknown;
}
interface ServerMessage {
type?: unknown;
payload?: {
initial?: {
path?: unknown;
};
version?: {
status?: unknown;
path?: unknown;
};
};
}
interface HeldAgentUpdate {
@@ -34,9 +49,8 @@ interface HeldServerMessage {
}
function readSessionMessage(message: string | Buffer): ClientRequest | null {
if (typeof message !== "string") return null;
try {
const envelope = JSON.parse(message) as {
const envelope = JSON.parse(typeof message === "string" ? message : message.toString()) as {
type?: unknown;
message?: ClientRequest;
};
@@ -180,6 +194,27 @@ function matchesAgentUpdate(message: ClientRequest | null, agentUpdate: HeldAgen
return agent?.id === agentUpdate.agentId && agent.status === agentUpdate.status;
}
function recordClientRequest(
request: ClientRequest | null,
clientRequestCounts: Map<string, number>,
timelineRequestCounts: Map<string, number>,
directoryStarts: DirectoryRequestStartCounts,
): void {
if (typeof request?.type !== "string") return;
clientRequestCounts.set(request.type, (clientRequestCounts.get(request.type) ?? 0) + 1);
if (request.type === "fetch_agent_timeline_request" && typeof request.direction === "string") {
timelineRequestCounts.set(
request.direction,
(timelineRequestCounts.get(request.direction) ?? 0) + 1,
);
}
const directory = directoryForRequest(request);
if (!directory) return;
const subscription = request.subscribe === undefined ? "unsubscribed" : "subscribed";
directoryStarts[subscription][directory] += 1;
directoryStarts.total[directory] += 1;
}
export async function installDaemonWebSocketGate(page: Page) {
let acceptingConnections = true;
let reconnectWithFreshClient = false;
@@ -209,6 +244,31 @@ export async function installDaemonWebSocketGate(page: Page) {
const agentStreamEventCounts = new Map<string, number>();
const agentStreamItemCounts = new Map<string, number>();
const serverMessageWaiters = new Set<() => void>();
const subscribedFilePaths = new Set<string>();
const fileSubscriptionWaiters = new Map<string, () => void>();
const observedFileUpdates = new Set<string>();
const fileUpdateWaiters = new Map<string, () => void>();
let fileReadPathToHold: string | null = null;
let heldFileReads: Array<() => void> = [];
let resolveHeldFileRead: (() => void) | null = null;
let heldFileReadPromise = Promise.resolve();
let readyFileUpdatePathToHold: string | null = null;
let heldReadyFileUpdate: (() => void) | null = null;
let resolveHeldReadyFileUpdate: (() => void) | null = null;
let heldReadyFileUpdatePromise = Promise.resolve();
const recordFileUpdate = (message: ServerMessage): void => {
if (
message.type !== "fs.file.update" ||
typeof message.payload?.version?.status !== "string" ||
typeof message.payload.version.path !== "string"
) {
return;
}
const key = `${message.payload.version.path}:${message.payload.version.status}`;
observedFileUpdates.add(key);
fileUpdateWaiters.get(key)?.();
fileUpdateWaiters.delete(key);
};
const recordServerMessage = (message: ClientRequest | null): void => {
const messageType = typeof message?.type === "string" ? message.type : null;
@@ -225,6 +285,78 @@ export async function installDaemonWebSocketGate(page: Page) {
serverMessageWaiters.clear();
};
const observeFileMessage = (message: ServerMessage | null): void => {
if (
message?.type === "fs.file.subscribe.response" &&
typeof message.payload?.initial?.path === "string"
) {
const path = message.payload.initial.path;
subscribedFilePaths.add(path);
fileSubscriptionWaiters.get(path)?.();
fileSubscriptionWaiters.delete(path);
}
if (message) recordFileUpdate(message);
};
const holdServerMessage = (input: {
browser: WebSocketRoute;
message: string | Buffer;
parsed: ClientRequest | null;
}): boolean => {
const agentId = readAgentStreamAgentId(input.parsed);
const blocked = agentId
? heldServerMessages.find((held) => held.blockedAgentId === agentId)
: undefined;
const suppressed = shouldSuppressServerMessage({
message: input.parsed,
messageTypes: suppressedServerMessageTypes,
agentStreamEventTypes: suppressedAgentStreamEventTypes,
agentStreamItemTypes: suppressedAgentStreamItemTypes,
suppressAgentStream,
});
if (blocked) {
if (!suppressed) blocked.agentStreamFollowers.push(input.message);
return true;
}
const matchedHold = Array.from(pendingServerMessageHolds).find(([, matches]) =>
matches(input.parsed),
);
if (!matchedHold) return suppressed;
const [key] = matchedHold;
pendingServerMessageHolds.delete(key);
heldServerMessages.push({
browser: input.browser,
message: input.message,
key,
agentStreamFollowers: [],
blockedAgentId:
readAgentStreamEventType(input.parsed) === "turn_started"
? (agentId ?? undefined)
: undefined,
});
for (const resolve of heldServerMessageWaiters) resolve();
heldServerMessageWaiters.clear();
return true;
};
const holdReadyFileUpdate = (
browser: WebSocketRoute,
message: string | Buffer,
fileMessage: ServerMessage | null,
): boolean => {
if (
fileMessage?.type !== "fs.file.update" ||
fileMessage.payload?.version?.status !== "ready" ||
fileMessage.payload.version.path !== readyFileUpdatePathToHold ||
heldReadyFileUpdate
) {
return false;
}
heldReadyFileUpdate = () => browser.send(message);
resolveHeldReadyFileUpdate?.();
return true;
};
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
if (!acceptingConnections) {
void ws.close({ code: 1008, reason: "Blocked by reconnect test." });
@@ -248,30 +380,22 @@ export async function installDaemonWebSocketGate(page: Page) {
}
}
const request = readClientRequest(message);
if (typeof request?.type === "string") {
clientRequestCounts.set(request.type, (clientRequestCounts.get(request.type) ?? 0) + 1);
if (
request.type === "fetch_agent_timeline_request" &&
typeof request.direction === "string"
) {
timelineRequestCounts.set(
request.direction,
(timelineRequestCounts.get(request.direction) ?? 0) + 1,
);
}
const directory = directoryForRequest(request);
if (directory) {
const subscription = request.subscribe === undefined ? "unsubscribed" : "subscribed";
directoryStarts[subscription][directory] += 1;
directoryStarts.total[directory] += 1;
}
}
recordClientRequest(request, clientRequestCounts, timelineRequestCounts, directoryStarts);
if (request?.type === heldClientRequestType) {
heldClientRequest = { server, message };
resolveHeldClientRequest?.();
resolveHeldClientRequest = null;
return;
}
if (
request?.type === "file_explorer_request" &&
request.mode === "file" &&
request.path === fileReadPathToHold
) {
heldFileReads.push(() => server.send(message));
resolveHeldFileRead?.();
return;
}
try {
server.send(message);
} catch {
@@ -282,6 +406,8 @@ export async function installDaemonWebSocketGate(page: Page) {
server.onMessage((message) => {
if (!acceptingConnections) return;
const serverMessage = readSessionMessage(message);
const fileMessage = serverMessage as ServerMessage | null;
observeFileMessage(fileMessage);
let outboundMessage = stripAssistantMessageId(
message,
stripAssistantMessageIds,
@@ -297,42 +423,9 @@ export async function installDaemonWebSocketGate(page: Page) {
outboundMessage = forceTimelineReset(outboundMessage, shouldForceTimelineReset);
if (shouldForceTimelineReset) forceTimelineEpochReset = false;
recordServerMessage(serverMessage);
const suppressed = shouldSuppressServerMessage({
message: serverMessage,
messageTypes: suppressedServerMessageTypes,
agentStreamEventTypes: suppressedAgentStreamEventTypes,
agentStreamItemTypes: suppressedAgentStreamItemTypes,
suppressAgentStream,
});
const agentStreamAgentId = readAgentStreamAgentId(serverMessage);
const blockedAgentStream = agentStreamAgentId
? heldServerMessages.find((held) => held.blockedAgentId === agentStreamAgentId)
: undefined;
if (blockedAgentStream) {
if (!suppressed) blockedAgentStream.agentStreamFollowers.push(outboundMessage);
if (holdServerMessage({ browser: ws, message: outboundMessage, parsed: serverMessage }))
return;
}
const matchedHold = Array.from(pendingServerMessageHolds).find(([, matches]) =>
matches(serverMessage),
);
if (matchedHold) {
const [key] = matchedHold;
pendingServerMessageHolds.delete(key);
heldServerMessages.push({
browser: ws,
message: outboundMessage,
key,
agentStreamFollowers: [],
blockedAgentId:
readAgentStreamEventType(serverMessage) === "turn_started"
? (agentStreamAgentId ?? undefined)
: undefined,
});
for (const resolve of heldServerMessageWaiters) resolve();
heldServerMessageWaiters.clear();
return;
}
if (suppressed) return;
if (holdReadyFileUpdate(ws, outboundMessage, fileMessage)) return;
try {
ws.send(outboundMessage);
} catch {
@@ -342,6 +435,49 @@ export async function installDaemonWebSocketGate(page: Page) {
});
return {
waitForFileSubscription(path: string): Promise<void> {
if (subscribedFilePaths.has(path)) return Promise.resolve();
return new Promise((resolve) => fileSubscriptionWaiters.set(path, resolve));
},
waitForFileUpdate(path: string, status: "ready" | "missing" | "error"): Promise<void> {
const key = `${path}:${status}`;
if (observedFileUpdates.has(key)) return Promise.resolve();
return new Promise((resolve) => fileUpdateWaiters.set(key, resolve));
},
holdFileReads(path: string): void {
fileReadPathToHold = path;
heldFileReads = [];
heldFileReadPromise = new Promise((resolve) => {
resolveHeldFileRead = resolve;
});
},
waitForHeldFileRead(): Promise<void> {
return heldFileReadPromise;
},
releaseHeldFileRead(): void {
const forwards = heldFileReads;
heldFileReads = [];
fileReadPathToHold = null;
resolveHeldFileRead = null;
for (const forward of forwards) forward();
},
holdNextReadyFileUpdate(path: string): void {
readyFileUpdatePathToHold = path;
heldReadyFileUpdate = null;
heldReadyFileUpdatePromise = new Promise((resolve) => {
resolveHeldReadyFileUpdate = resolve;
});
},
waitForHeldReadyFileUpdate(): Promise<void> {
return heldReadyFileUpdatePromise;
},
releaseHeldReadyFileUpdate(): void {
const forward = heldReadyFileUpdate;
heldReadyFileUpdate = null;
readyFileUpdatePathToHold = null;
resolveHeldReadyFileUpdate = null;
forward?.();
},
async drop(): Promise<void> {
acceptingConnections = false;
const sockets = Array.from(activeSockets);
+48
View File
@@ -0,0 +1,48 @@
import type { Page } from "@playwright/test";
import { expect } from "../fixtures";
export async function recordFileCallouts(page: Page): Promise<void> {
await page.evaluate(() => {
const callouts: string[] = [];
const recordAlert = (alert: Element) => {
const text = alert.textContent?.trim();
if (text && !callouts.includes(text)) callouts.push(text);
};
const record = (records: MutationRecord[]) => {
for (const mutation of records) {
if (
!(mutation.target instanceof Element) ||
!mutation.target.closest('[data-testid="workspace-file-pane"]')
) {
continue;
}
for (const node of mutation.addedNodes) {
if (!(node instanceof Element)) continue;
if (node.matches('[role="alert"]')) recordAlert(node);
for (const alert of node.querySelectorAll('[role="alert"]')) recordAlert(alert);
}
}
};
const observer = new MutationObserver(record);
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
Object.assign(globalThis, { __PASEO_RECORDED_FILE_CALLOUTS__: callouts });
});
}
export async function expectFileCalloutWasRendered(page: Page, title: string): Promise<void> {
await expect
.poll(() => recordedFileCallouts(page))
.toEqual(expect.arrayContaining([expect.stringContaining(title)]));
}
export async function expectNoFileCalloutWasRendered(page: Page): Promise<void> {
await expect.poll(() => recordedFileCallouts(page)).toEqual([]);
}
function recordedFileCallouts(page: Page): Promise<string[]> {
return page.evaluate(
() =>
(globalThis as typeof globalThis & { __PASEO_RECORDED_FILE_CALLOUTS__?: string[] })
.__PASEO_RECORDED_FILE_CALLOUTS__ ?? [],
);
}
+63
View File
@@ -57,6 +57,69 @@ export async function addOfflineHostAndReload(
await page.reload();
}
export async function addConnectedHostAndReload(
page: Page,
input: { serverId: string; label: string; port: number; primaryLabel?: string },
): Promise<void> {
await addConnectedHostsAndReload(page, [input], { primaryLabel: input.primaryLabel });
}
export async function addConnectedHostsAndReload(
page: Page,
inputs: Array<{ serverId: string; label: string; port: number }>,
options?: { primaryLabel?: string },
): Promise<void> {
const connectedHosts = inputs.map((input) =>
buildSeededHost({
serverId: input.serverId,
label: input.label,
endpoint: `127.0.0.1:${input.port}`,
nowIso: new Date().toISOString(),
}),
);
await page.evaluate(
({ hosts, keys, primaryLabel }) => {
const nonce = localStorage.getItem(keys.nonce);
if (!nonce) {
throw new Error("Expected the e2e seed nonce before overriding the host registry.");
}
const raw = localStorage.getItem(keys.registry);
const registry: Array<{ serverId: string; label?: string }> = raw ? JSON.parse(raw) : [];
if (primaryLabel && registry[0]) registry[0].label = primaryLabel;
for (const host of hosts) {
if (!registry.some((entry) => entry.serverId === host.serverId)) {
registry.push(host);
}
}
localStorage.setItem(keys.registry, JSON.stringify(registry));
localStorage.setItem(keys.disableSeedOnce, nonce);
},
{
hosts: connectedHosts,
keys: {
registry: REGISTRY_KEY,
nonce: SEED_NONCE_KEY,
disableSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY,
},
primaryLabel: options?.primaryLabel,
},
);
await page.reload();
}
export async function waitForConnectedHost(
page: Page,
input: { serverId: string; endpoint: string },
): Promise<void> {
await page.getByTestId("sidebar-hosts-trigger").click();
const host = page.getByTestId(`sidebar-host-row-${input.serverId}`);
await expect(host).toContainText(input.endpoint, { timeout: 30_000 });
await page.keyboard.press("Escape");
await expect(host).not.toBeVisible();
}
export async function openSidebarDisplayPreferences(page: Page): Promise<void> {
await page.getByTestId("sidebar-display-preferences-menu").click();
await expect(page.getByTestId("sidebar-display-preferences-content")).toBeVisible({
@@ -1,6 +1,6 @@
import { once } from "node:events";
import { spawn, execSync, type ChildProcess } from "node:child_process";
import { mkdtemp, rm } from "node:fs/promises";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import net from "node:net";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -9,10 +9,19 @@ import { withDisabledE2ESpeechEnv } from "./speech-env";
export interface IsolatedHostDaemon {
serverId: string;
port: number;
paseoHome: string;
getPid(): number | undefined;
restart(): Promise<void>;
close(): Promise<void>;
}
interface IsolatedHostDaemonOptions {
mutableRelay?: {
enabled: boolean;
endpoint?: string;
};
}
async function getAvailablePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
@@ -75,7 +84,10 @@ async function stopProcess(child: ChildProcess): Promise<void> {
}
}
export async function startIsolatedHostDaemon(serverId: string): Promise<IsolatedHostDaemon> {
export async function startIsolatedHostDaemon(
serverId: string,
options: IsolatedHostDaemonOptions = {},
): Promise<IsolatedHostDaemon> {
const primaryPort = Number(process.env.E2E_DAEMON_PORT ?? 0);
let port = await getAvailablePort();
while (port === 6767 || port === primaryPort) port = await getAvailablePort();
@@ -84,6 +96,26 @@ export async function startIsolatedHostDaemon(serverId: string): Promise<Isolate
if (!metroPort) throw new Error("E2E_METRO_PORT is required to start an isolated host daemon");
const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-e2e-secondary-host-"));
if (options.mutableRelay) {
const endpoint =
options.mutableRelay.endpoint ??
(process.env.E2E_RELAY_PORT ? `127.0.0.1:${process.env.E2E_RELAY_PORT}` : "127.0.0.1:9");
await writeFile(
path.join(paseoHome, "config.json"),
`${JSON.stringify({
version: 1,
daemon: {
relay: {
enabled: options.mutableRelay.enabled,
endpoint,
publicEndpoint: endpoint,
useTls: false,
publicUseTls: false,
},
},
})}\n`,
);
}
const serverDir = path.resolve(__dirname, "../../../server");
const tsxBin = execSync("which tsx").toString().trim();
const spawnDaemon = async (): Promise<ChildProcess> => {
@@ -95,7 +127,7 @@ export async function startIsolatedHostDaemon(serverId: string): Promise<Isolate
PASEO_SERVER_ID: serverId,
PASEO_LISTEN: `127.0.0.1:${port}`,
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
PASEO_RELAY_ENABLED: "0",
PASEO_RELAY_ENABLED: options.mutableRelay ? undefined : "0",
PASEO_NODE_ENV: "development",
NODE_ENV: "development",
}),
@@ -133,6 +165,8 @@ export async function startIsolatedHostDaemon(serverId: string): Promise<Isolate
return {
serverId,
port,
paseoHome,
getPid: () => child.pid,
restart: async () => {
if (closed) throw new Error(`Cannot restart closed isolated daemon ${serverId}`);
await stopProcess(child);
+51 -13
View File
@@ -3,6 +3,7 @@ import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/inte
import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
import { connectDaemonClient } from "./daemon-client-loader";
import { daemonWsRoutePattern } from "./daemon-port";
import { projectEquivalenceViewKey } from "./project-view-key";
import { expectWorkspaceHeader } from "./workspace-ui";
type NewWorkspaceDaemonClient = Pick<
@@ -18,6 +19,7 @@ type NewWorkspaceDaemonClient = Pick<
| "getPaseoWorktreeList"
| "getDaemonConfig"
| "inspectWorkspaceRecovery"
| "listProjects"
| "on"
| "patchDaemonConfig"
| "removeProject"
@@ -29,6 +31,7 @@ type WorkspaceDescriptor = NonNullable<CreateWorkspacePayload["workspace"]>;
export interface OpenedProject {
workspaceId: string;
projectId: string;
projectKey: string;
projectDisplayName: string;
workspaceName: string;
@@ -45,10 +48,19 @@ function requireWorkspace(payload: WorkspacePayload) {
return payload.workspace;
}
function openedProjectFromWorkspace(workspace: WorkspaceDescriptor): OpenedProject {
async function openedProjectFromWorkspace(
client: NewWorkspaceDaemonClient,
workspace: WorkspaceDescriptor,
): Promise<OpenedProject> {
const payload = await client.listProjects();
const project = payload.projects.find((candidate) => candidate.projectId === workspace.projectId);
if (!project?.projectKey) {
throw new Error(`Project ${workspace.projectId} has no project key`);
}
return {
workspaceId: workspace.id,
projectKey: workspace.projectId,
projectId: workspace.projectId,
projectKey: project.projectKey,
projectDisplayName: workspace.projectDisplayName,
workspaceName: workspace.name,
workspaceDirectory: workspace.workspaceDirectory,
@@ -107,7 +119,7 @@ export async function openProjectViaDaemon(
source: { kind: "directory", path: repoPath },
}),
);
return openedProjectFromWorkspace(workspace);
return openedProjectFromWorkspace(client, workspace);
}
export async function archiveWorkspaceFromDaemon(
@@ -149,18 +161,19 @@ export async function createWorktreeViaDaemon(
worktreeSlug: input.slug,
});
const workspace = requireWorkspace(payload);
return openedProjectFromWorkspace(workspace);
return openedProjectFromWorkspace(client, workspace);
}
export async function openNewWorkspaceComposer(
page: Page,
input: { projectKey: string; projectDisplayName: string },
): Promise<void> {
const projectRow = page.getByTestId(`sidebar-project-row-${input.projectKey}`).first();
const projectViewKey = projectEquivalenceViewKey(input.projectKey);
const projectRow = page.getByTestId(`sidebar-project-row-${projectViewKey}`).first();
await expect(projectRow).toBeVisible({ timeout: 30_000 });
await projectRow.hover();
const button = page.getByTestId(`sidebar-project-new-worktree-${input.projectKey}`).first();
const button = page.getByTestId(`sidebar-project-new-worktree-${projectViewKey}`).first();
await expect(button).toBeVisible({ timeout: 30_000 });
await button.click();
@@ -177,6 +190,29 @@ export async function openGlobalNewWorkspaceComposer(page: Page): Promise<void>
});
}
export async function openMissingProjectNewWorkspaceComposer(
page: Page,
input: { serverId: string; projectId: string; sourceDirectory: string },
): Promise<void> {
const query = new URLSearchParams({
serverId: input.serverId,
projectId: input.projectId,
dir: input.sourceDirectory,
name: "Missing project",
});
await page.goto(`/new?${query.toString()}`);
await expect(page).toHaveURL(/\/new\?.*projectId=/u, { timeout: 30_000 });
}
export async function expectNewWorkspaceControlsEnabled(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Workspace project" })).toBeEnabled({
timeout: 30_000,
});
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeEditable({
timeout: 30_000,
});
}
export async function openNewWorkspaceProjectPickerWithShortcut(page: Page): Promise<void> {
await page.keyboard.press("Control+P");
@@ -205,8 +241,10 @@ export async function expectNewWorkspaceDraft(page: Page, draft: string): Promis
}
export async function selectNewWorkspaceHost(page: Page, hostLabel: string): Promise<void> {
await page.getByTestId("host-picker-trigger").click();
await page.getByText(hostLabel, { exact: true }).click();
const trigger = page.getByTestId("host-picker-trigger");
await trigger.click();
await page.getByRole("button", { name: hostLabel, exact: true }).click();
await expect(trigger).toContainText(hostLabel);
}
export async function submitNewWorkspacePrompt(
@@ -233,13 +271,14 @@ export async function clickNewWorkspaceButton(
export async function selectNewWorkspaceProject(
page: Page,
input: { projectKey: string; projectDisplayName: string },
input: { projectKey: string; projectDisplayName: string; projectViewKey?: string },
): Promise<void> {
const trigger = page.getByTestId("new-workspace-project-picker-trigger");
await expect(trigger).toBeVisible({ timeout: 30_000 });
await trigger.click();
const option = page.getByTestId(`new-workspace-project-picker-option-${input.projectKey}`);
const projectViewKey = input.projectViewKey ?? projectEquivalenceViewKey(input.projectKey);
const option = page.getByTestId(`new-workspace-project-picker-option-${projectViewKey}`);
await expect(option).toBeVisible({ timeout: 30_000 });
await option.click();
@@ -270,9 +309,8 @@ export async function selectWorkspaceIsolation(
await expect(trigger).toBeVisible({ timeout: 30_000 });
await trigger.click();
// "New worktree" is only listed once the checkout status query confirms the
// selected project is a git repo, so wait for the option to appear before
// clicking it.
// Isolation options are derived from project capability. Wait for the option
// so this helper also covers route-to-project reconciliation.
const option = page.getByTestId(`workspace-create-isolation-${isolation}`);
await expect(option).toBeVisible({ timeout: 30_000 });
await option.click();
+218
View File
@@ -0,0 +1,218 @@
import { expect, type Page } from "@playwright/test";
import type { IsolatedHostDaemon } from "./isolated-host-daemon";
import type { OutdatedDaemon } from "./daemon-update";
import { openSettings, gotoAppShell } from "./app";
import {
openSettingsHost,
openSettingsHostSection,
seedSavedSettingsHosts,
selectSettingsHost,
} from "./settings";
import { expectAppRoute } from "./route-assertions";
import { buildSettingsHostSectionRoute } from "@/utils/host-routes";
interface PairingHostInput {
serverId: string;
label: string;
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,
additionalHosts: PairingHostInput[] = [],
): Promise<void> {
await seedSavedSettingsHosts(page, [
{
serverId: daemon.serverId,
label: "Local pairing host",
endpoint: "port" in daemon ? `127.0.0.1:${daemon.port}` : daemon.endpoint,
},
...additionalHosts,
]);
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, daemon.serverId);
await expect(page.getByTestId("host-page-pair-device-row")).toHaveCount(0);
await openSettingsHostSection(page, daemon.serverId, "pair-device");
await expect(page.getByTestId("host-page-pair-device-row")).toBeVisible();
}
export async function openPairDeviceModal(page: Page): Promise<void> {
await page.getByRole("button", { name: /Pair a device/ }).click();
await expect(page.getByTestId("host-page-pair-device-card")).toBeVisible();
}
export async function expectRelayConsent(page: Page): Promise<void> {
const modal = page.getByTestId("host-page-pair-device-card");
await expect(modal.getByText("Enable relay?", { exact: true })).toBeVisible();
await expect(modal.getByText(/end-to-end encrypted/)).toBeVisible();
await expect(modal.getByRole("link", { name: "Read how Paseo relay works" })).toBeVisible();
await expect(modal.getByText(/TCP, Tailscale, or another VPN/)).toBeVisible();
await expect(modal.getByRole("img", { name: "Pairing QR code" })).toHaveCount(0);
await expect(modal.getByRole("textbox", { name: "Pairing link" })).toHaveCount(0);
await expect(modal.getByRole("button", { name: "Copy", exact: true })).toHaveCount(0);
}
export async function declineRelay(page: Page): Promise<void> {
await page.getByRole("button", { name: "Not now", exact: true }).click();
await expect(page.getByTestId("host-page-pair-device-card")).toHaveCount(0);
}
export async function enableRelayAndExpectOffer(page: Page): Promise<void> {
const enableButton = page.getByRole("button", { name: "Enable relay", exact: true });
await enableButton.click();
await expect(page.getByRole("button", { name: "Enabling...", exact: true })).toBeDisabled();
await expect(page.getByRole("img", { name: "Pairing QR code" })).toBeVisible();
await expect(page.getByRole("textbox", { name: "Pairing link" })).toHaveValue(/#offer=/);
await expect(page.getByText("Enable relay?", { exact: true })).toHaveCount(0);
}
export async function expectPairingOffer(page: Page): Promise<void> {
await expect(page.getByRole("img", { name: "Pairing QR code" })).toBeVisible();
await expect(page.getByRole("textbox", { name: "Pairing link" })).toHaveValue(/#offer=/);
await expect(page.getByRole("button", { name: "Copy", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Enable relay", exact: true })).toHaveCount(0);
}
export async function closePairDeviceModal(page: Page): Promise<void> {
await page
.getByTestId("host-page-pair-device-card")
.getByRole("button", { name: "Close", exact: true })
.click();
await expect(page.getByTestId("host-page-pair-device-card")).toHaveCount(0);
}
export async function reloadAndOpenPairDevice(page: Page): Promise<void> {
await page.evaluate(() => {
const nonce = localStorage.getItem("@paseo:e2e-seed-nonce");
if (!nonce) throw new Error("Expected e2e seed nonce");
localStorage.setItem("@paseo:e2e-disable-default-seed-once", nonce);
});
await page.reload();
await openPairDeviceModal(page);
}
export async function enableRelayAndExpectFailure(
page: Page,
expectedError = "Relay is controlled by a daemon launch override",
): Promise<void> {
await page.getByRole("button", { name: "Enable relay", exact: true }).click();
const modal = page.getByTestId("host-page-pair-device-card");
await expect(modal.getByRole("alert")).toContainText(expectedError);
await expect(modal.getByRole("button", { name: "Retry", exact: true })).toBeEnabled();
await expect(modal.getByRole("textbox", { name: "Pairing link" })).toHaveCount(0);
}
export async function retryRelayAndExpectFailure(
page: Page,
expectedError = "Relay is controlled by a daemon launch override",
): Promise<void> {
const modal = page.getByTestId("host-page-pair-device-card");
await modal.getByRole("button", { name: "Retry", exact: true }).click();
await expect(modal.getByRole("alert")).toContainText(expectedError);
await expect(modal.getByRole("button", { name: "Retry", exact: true })).toBeEnabled();
}
export async function openPairDeviceFromHome(page: Page): Promise<void> {
await page.evaluate(() => {
const nonce = localStorage.getItem("@paseo:e2e-seed-nonce");
if (!nonce) throw new Error("Expected e2e seed nonce");
localStorage.setItem("@paseo:e2e-disable-default-seed-once", nonce);
});
await page.goto("/open-project");
await page.getByTestId("open-project-pair-device").click();
await expect(page.getByTestId("open-project-pair-device-modal")).toBeVisible();
}
export async function expectRelayUpdateRequired(page: Page): Promise<void> {
const modal = page.getByTestId("host-page-pair-device-card");
await expect(
modal.getByText("Update the host to enable relay from Paseo Desktop."),
).toBeVisible();
await expect(modal.getByRole("button", { name: "Enable relay", exact: true })).toHaveCount(0);
await expect(modal.getByRole("textbox", { name: "Pairing link" })).toHaveCount(0);
}
export function observePairingOfferRequests(page: Page, daemon: OutdatedDaemon): () => void {
let requestCount = 0;
const port = daemon.endpoint.slice(daemon.endpoint.lastIndexOf(":") + 1);
page.on("websocket", (socket) => {
if (!socket.url().includes(`:${port}`)) return;
socket.on("framesent", ({ payload }) => {
const message = typeof payload === "string" ? payload : payload.toString();
if (message.includes('"type":"daemon.get_pairing_offer.request"')) requestCount += 1;
});
});
return () => expect(requestCount).toBe(0);
}
export async function expectPairingDisconnected(page: Page): Promise<void> {
const modal = page.getByTestId("host-page-pair-device-card");
await expect(modal.getByRole("alert")).toContainText("Host is not connected");
await expect(modal.getByRole("button", { name: "Retry", exact: true })).toBeVisible();
}
export async function switchPairDeviceToHost(page: Page, serverId: string): Promise<void> {
await selectSettingsHost(page, serverId);
await expectAppRoute(page, buildSettingsHostSectionRoute(serverId, "pair-device"));
await expect(page.getByTestId("host-page-pair-device-row")).toBeVisible();
}
export async function openRelaySecurityDocs(page: Page): Promise<void> {
await page.getByRole("link", { name: "Read how Paseo relay works" }).click();
await expect
.poll(() => page.evaluate(() => localStorage.getItem("@paseo:e2e-opened-url")))
.toBe("https://paseo.sh/docs/security");
}
export function expectDaemonPidUnchanged(
before: number | undefined,
after: number | undefined,
): void {
expect(before).toBeDefined();
expect(after).toBe(before);
}
@@ -0,0 +1,136 @@
import { expect, type Locator, type Page } from "@playwright/test";
import { gotoAppShell } from "./app";
import { addConnectedHostsAndReload, waitForConnectedHost } from "./hosts";
import { openProjectSettings } from "./project-settings";
import { selectSettingsHost } from "./settings";
import { waitForSidebarHydration } from "./workspace-ui";
import { buildProjectsSettingsRoute } from "@/utils/host-routes";
const PROJECT_VISIBILITY_TIMEOUT = 30_000;
function projectGroup(page: Page, projectName: string): Locator {
return page.getByRole("group", { name: projectName, exact: true });
}
function projectGroupWithWorkspace(
page: Page,
projectName: string,
workspaceName: string,
): Locator {
return projectGroup(page, projectName).filter({ hasText: workspaceName });
}
export async function openProjectDirectory(page: Page): Promise<void> {
await gotoAppShell(page);
await waitForSidebarHydration(page);
}
export async function openProjectDirectoryWithHosts(
page: Page,
input: {
hosts: Array<{ serverId: string; label: string; port: number }>;
primaryLabel?: string;
},
): Promise<void> {
await gotoAppShell(page);
await addConnectedHostsAndReload(page, input.hosts, { primaryLabel: input.primaryLabel });
for (const host of input.hosts) {
await waitForConnectedHost(page, {
serverId: host.serverId,
endpoint: `localhost:${host.port}`,
});
}
await waitForSidebarHydration(page);
}
export async function expectProjectContainsWorkspaces(
page: Page,
input: { projectName: string; workspaceNames: string[] },
): Promise<void> {
const group = projectGroup(page, input.projectName);
await expect(group).toHaveCount(1, { timeout: PROJECT_VISIBILITY_TIMEOUT });
for (const workspaceName of input.workspaceNames) {
await expect(group.getByRole("button").filter({ hasText: workspaceName })).toBeVisible({
timeout: PROJECT_VISIBILITY_TIMEOUT,
});
}
}
export async function expectSeparateProjects(
page: Page,
projects: Array<{ projectName: string; workspaceName: string }>,
): Promise<void> {
for (const project of projects) {
await expect(
projectGroupWithWorkspace(page, project.projectName, project.workspaceName),
).toHaveCount(1, { timeout: PROJECT_VISIBILITY_TIMEOUT });
}
}
export async function beginWorkspaceFromProject(page: Page, projectName: string): Promise<void> {
const group = projectGroup(page, projectName);
await expect(group).toBeVisible({ timeout: PROJECT_VISIBILITY_TIMEOUT });
await group.hover();
await group.getByLabel(`Create a new workspace for ${projectName}`).click();
await expect(page.getByRole("button", { name: "Workspace project", exact: true })).toContainText(
projectName,
{ timeout: PROJECT_VISIBILITY_TIMEOUT },
);
}
export async function selectWorkspaceHost(page: Page, hostName: string): Promise<void> {
await page.getByRole("button", { name: "Host", exact: true }).click();
await page.getByRole("button", { name: hostName, exact: true }).click();
await expect(page.getByRole("button", { name: "Host", exact: true })).toContainText(hostName);
}
export async function createWorkspaceWithoutAgent(page: Page): Promise<void> {
await page.getByRole("button", { name: "Create", exact: true }).click();
}
export async function expectProjectWorkspaceCountForHost(
page: Page,
input: { projectName: string; hostName: string; count: number },
): Promise<void> {
const group = projectGroup(page, input.projectName);
await expect(group.getByRole("button").filter({ hasText: input.hostName })).toHaveCount(
input.count,
{ timeout: PROJECT_VISIBILITY_TIMEOUT },
);
}
export async function renameProject(page: Page, name: string): Promise<void> {
await page.getByRole("button", { name: "Edit project", exact: true }).click();
await page.getByRole("textbox", { name: "Project name", exact: true }).fill(name);
await page.getByRole("button", { name: "Save changes", exact: true }).click();
await expect(page.getByText("Project updated", { exact: true })).toBeVisible({
timeout: PROJECT_VISIBILITY_TIMEOUT,
});
}
export async function openGroupedProjectSettings(
page: Page,
input: { serverId: string; projectName: string },
): Promise<void> {
await page.getByRole("button", { name: "Settings", exact: true }).click();
await expect(page).toHaveURL(/\/settings\/general$/);
await selectSettingsHost(page, input.serverId);
await page.locator('[data-testid="settings-host-section-projects"]:visible').click();
await expect(page).toHaveURL(/\/settings\/hosts\/[^/]+\/projects$/);
await openProjectSettings(page, input.projectName);
}
export async function openProjectsForSettingsHost(
page: Page,
input: { serverId: string; projectName: string },
): Promise<void> {
await selectSettingsHost(page, input.serverId);
await expect(page).toHaveURL(buildProjectsSettingsRoute(input.serverId));
await openProjectSettings(page, input.projectName);
}
export async function expectProjectSettingsName(page: Page, projectName: string): Promise<void> {
await expect(page.getByRole("main").getByText(projectName, { exact: true })).toBeVisible({
timeout: PROJECT_VISIBILITY_TIMEOUT,
});
}
@@ -7,7 +7,8 @@ export async function expectOpenedProject(page: Page, projectName: string): Prom
.first();
await expect(projectRow).toBeVisible({ timeout: 30_000 });
const testId = await projectRow.getAttribute("data-testid");
expect(testId).not.toBeNull();
return testId!.replace("sidebar-project-row-", "");
await expect(page).toHaveURL(/\/new\?.*projectId=/u, { timeout: 30_000 });
const projectId = new URL(page.url()).searchParams.get("projectId");
expect(projectId).not.toBeNull();
return projectId!;
}
+78 -11
View File
@@ -4,6 +4,8 @@ import { expect, type Page } from "@playwright/test";
import type { WebSocketRoute } from "@playwright/test";
import { gotoAppShell, openSettings } from "./app";
import { daemonWsRoutePattern } from "./daemon-port";
import { getServerId } from "./server-id";
import { buildProjectsSettingsRoute } from "@/utils/host-routes";
type WebSocketMessage = string | Buffer;
@@ -33,8 +35,8 @@ function getSessionMessage(message: WebSocketMessage): Record<string, unknown> |
export async function openProjects(page: Page): Promise<void> {
await gotoAppShell(page);
await openSettings(page);
await page.getByTestId("settings-projects").click();
await expect(page).toHaveURL(/\/settings\/projects$/);
await page.getByRole("button", { name: "Projects", exact: true }).click();
await expect(page).toHaveURL(buildProjectsSettingsRoute(getServerId()));
}
export async function openProjectSettings(page: Page, projectName: string): Promise<void> {
@@ -48,6 +50,21 @@ export async function navigateToProjectSettings(page: Page, projectName: string)
await page.getByRole("button", { name: `Edit ${projectName}`, exact: true }).click();
}
export async function returnToProjectsList(page: Page): Promise<void> {
await page.getByRole("button", { name: "Back to projects", exact: true }).click();
await expect(page).toHaveURL(buildProjectsSettingsRoute(getServerId()));
}
export async function expectProjectSettingsHistoryRoundTrip(
page: Page,
projectName: string,
): Promise<void> {
await page.goBack();
await expect(page).toHaveURL(buildProjectsSettingsRoute(getServerId()));
await page.goForward();
await expectProjectTitle(page, projectName);
}
// --- Form interactions ---
export async function editWorktreeSetup(page: Page, setupCommands: string[]): Promise<void> {
@@ -57,7 +74,7 @@ export async function editWorktreeSetup(page: Page, setupCommands: string[]): Pr
}
export async function clickSaveProjectSettings(page: Page): Promise<void> {
await page.getByRole("button", { name: "Save" }).click();
await page.getByTestId("save-button").click();
}
export async function clickRetryProjectSettingsSave(page: Page): Promise<void> {
@@ -72,6 +89,61 @@ export async function clickReloadProjectSettings(page: Page): Promise<void> {
await page.locator('[data-testid$="-callout"]').getByRole("button", { name: "Reload" }).click();
}
// --- Project edit sheet (name + icon) ---
export async function openProjectEditSheet(page: Page): Promise<void> {
await page.getByRole("button", { name: "Edit project", exact: true }).click();
await expect(page.getByRole("textbox", { name: "Project name" })).toBeVisible();
}
export async function fillProjectName(page: Page, name: string): Promise<void> {
await page.getByRole("textbox", { name: "Project name" }).fill(name);
}
export async function chooseProjectIconImage(
page: Page,
file: { name: string; mimeType: string; buffer: Buffer },
): Promise<void> {
const chooserPromise = page.waitForEvent("filechooser", { timeout: 10_000 });
await page.getByRole("button", { name: "Choose image" }).click();
await (await chooserPromise).setFiles([file]);
await expect(page.getByText(file.name, { exact: true })).toBeVisible();
}
export async function fillProjectIconUrl(page: Page, url: string): Promise<void> {
await page.getByRole("textbox", { name: "Image or website URL" }).fill(url);
}
export async function useAutomaticProjectIcon(page: Page): Promise<void> {
await page.getByRole("button", { name: "Use automatic" }).click();
}
export async function saveProjectEdits(page: Page): Promise<void> {
await page.getByRole("button", { name: "Save changes" }).click();
}
export async function expectProjectEditName(page: Page, name: string): Promise<void> {
await expect(page.getByRole("textbox", { name: "Project name" })).toHaveValue(name);
}
export async function expectProjectEditsSaveDisabled(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Save changes" })).toBeDisabled();
}
export async function expectProjectEditSaved(page: Page): Promise<void> {
await expect(page.getByTestId("app-toast-message")).toHaveText("Project updated");
}
// The sheet keeps the user's input on a failed save so the value stays editable.
export async function expectProjectEditFailed(page: Page, detail: string): Promise<void> {
await expect(page.getByText(detail, { exact: true })).toBeVisible();
await expect(page.getByRole("textbox", { name: "Project name" })).toBeVisible();
}
export async function expectProjectTitle(page: Page, projectName: string): Promise<void> {
await expect(page.getByRole("main").getByText(projectName, { exact: true })).toBeVisible();
}
// --- Error-state assertions ---
type ErrorKind = "stale" | "invalid" | "write_failed" | "transport" | "read_failed";
@@ -102,7 +174,7 @@ export async function expectWriteFailedCalloutActions(page: Page): Promise<void>
}
export async function expectSaveButtonDisabled(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Save" })).toBeDisabled();
await expect(page.getByTestId("save-button")).toBeDisabled();
}
// --- Form-state assertions ---
@@ -121,13 +193,8 @@ export async function expectNoEditableTarget(page: Page): Promise<void> {
await expect(page.getByTestId("project-settings-back-button")).toBeVisible({ timeout: 30_000 });
}
// --- Host-section assertions ---
export async function expectHostIndicatorVisible(page: Page): Promise<void> {
await expect(page.getByTestId("host-indicator")).toBeVisible();
}
export async function expectHostPickerHidden(page: Page): Promise<void> {
export async function expectProjectHostContextHidden(page: Page): Promise<void> {
await expect(page.getByTestId("host-indicator")).not.toBeVisible();
await expect(page.getByTestId("host-picker")).not.toBeVisible();
}
@@ -0,0 +1,7 @@
export function projectEquivalenceViewKey(projectKey: string): string {
return projectKey;
}
export function projectPlacementViewKey(serverId: string, projectId: string): string {
return JSON.stringify([serverId, projectId]);
}
@@ -16,6 +16,7 @@ type SessionRequest = Record<string, unknown> & { type?: string; requestId?: str
export interface FakeScheduleHostWorkspace {
serverId: string;
projectId: string;
projectKey: string;
projectDisplayName: string;
workspace: Record<string, unknown>;
}
@@ -120,6 +121,7 @@ export async function buildFakeScheduleHostWorkspace(
return {
serverId: "schedule-fake-host",
projectId,
projectKey: workspace.projectKey,
projectDisplayName: FAKE_HOST_PROJECT_DISPLAY_NAME,
workspace: {
...baseWorkspace,
@@ -139,6 +141,7 @@ export async function installFakeScheduleHost(input: {
port: string;
serverId: string;
workspace: Record<string, unknown>;
project: Pick<FakeScheduleHostWorkspace, "projectId" | "projectKey" | "projectDisplayName">;
schedules?: FakeScheduleSummary[];
}): Promise<void> {
await input.page.routeWebSocket(wsRoutePatternForPort(input.port), (ws) => {
@@ -154,6 +157,7 @@ export async function installFakeScheduleHost(input: {
features: {
providersSnapshot: true,
workspaceMultiplicity: true,
projectList: true,
projectAdd: true,
projectRemove: true,
workspaceRecovery: true,
@@ -197,6 +201,22 @@ export async function installFakeScheduleHost(input: {
}),
);
return;
case "project.list.request":
ws.send(
buildSessionMessage("project.list.response", {
requestId,
projects: [
{
projectId: input.project.projectId,
projectKey: input.project.projectKey,
projectDisplayName: input.project.projectDisplayName,
projectRootPath: String(input.workspace.projectRootPath),
projectKind: "git",
},
],
}),
);
return;
case "fetch_agents_request":
ws.send(
buildSessionMessage("fetch_agents_response", {
+18 -1
View File
@@ -13,6 +13,11 @@ export interface SeedWorkspaceDescriptor {
workspaceDirectory: string;
}
interface SeedProjectDescriptor {
projectId: string;
projectKey?: string;
}
/**
* The general-purpose E2E daemon client used to seed and drive state out of
* band (workspaces, agents, terminals) while the UI is exercised through the
@@ -25,15 +30,18 @@ export interface SeedDaemonClient {
addProject(cwd: string): Promise<{
project: {
projectId: string;
projectKey?: string;
projectDisplayName: string;
projectRootPath: string;
} | null;
error: string | null;
}>;
removeProject(projectId: string): Promise<{ removedWorkspaceIds: string[] }>;
renameProject(projectId: string, customName: string | null): Promise<void>;
fetchWorkspaces(options?: { filter?: { projectId?: string } }): Promise<{
entries: SeedWorkspaceDescriptor[];
}>;
listProjects(): Promise<{ projects: SeedProjectDescriptor[] }>;
createWorkspace(input: {
source:
| { kind: "directory"; path: string; projectId?: string }
@@ -121,6 +129,7 @@ export interface SeedDaemonClient {
}>;
}>;
updateAgent(agentId: string, updates: { name?: string }): Promise<void>;
setAgentMode(agentId: string, modeId: string): Promise<unknown>;
waitForAgentUpsert(
agentId: string,
predicate: (snapshot: { status: string }) => boolean,
@@ -179,8 +188,10 @@ export interface SeededWorkspace {
workspaceId: string;
workspaceName: string;
workspaceDirectory: string;
/** Stable project identity the daemon groups workspaces under. */
/** Host-local identity used by daemon project operations. */
projectId: string;
/** Opaque cross-host key used by grouped project UI. */
projectKey: string;
/** Project label the UI shows (owner/repo for known remotes, else basename). */
projectDisplayName: string;
cleanup(): Promise<void>;
@@ -208,6 +219,11 @@ export async function seedWorkspace(options: {
throw new Error(created.error ?? `Failed to create workspace ${project.path}`);
}
const workspace = created.workspace;
const listed = await client.listProjects();
const descriptor = listed.projects.find((item) => item.projectId === workspace.projectId);
if (!descriptor?.projectKey) {
throw new Error(`Created project ${workspace.projectId} has no project key`);
}
return {
client,
repoPath: project.path,
@@ -215,6 +231,7 @@ export async function seedWorkspace(options: {
workspaceName: workspace.name,
workspaceDirectory: workspace.workspaceDirectory,
projectId: workspace.projectId,
projectKey: descriptor.projectKey,
projectDisplayName: workspace.projectDisplayName,
cleanup: async () => {
await client.removeProject(workspace.projectId).catch(() => undefined);
+15 -14
View File
@@ -3,7 +3,6 @@ import { buildCreateAgentPreferences, buildSeededHost, TEST_HOST_LABEL } from ".
import { getServerId } from "./server-id";
import { expectAppRoute } from "./route-assertions";
import {
buildProjectsSettingsRoute,
buildSettingsHostSectionRoute,
buildSettingsRoute,
buildSettingsSectionRoute,
@@ -30,20 +29,22 @@ const SECTION_LABELS = {
about: "About",
} as const;
export type SettingsSection = keyof typeof SECTION_LABELS | "projects";
export type SettingsSection = keyof typeof SECTION_LABELS;
type HostSection = "connections" | "agents" | "workspaces" | "providers" | "usage" | "host";
type HostSection =
| "projects"
| "connections"
| "pair-device"
| "agents"
| "workspaces"
| "providers"
| "usage"
| "host";
export async function openSettingsSection(page: Page, section: SettingsSection): Promise<void> {
const sidebar = page.getByTestId("settings-sidebar");
await expect(sidebar).toBeVisible();
if (section === "projects") {
await page.getByTestId("settings-projects").click();
await expectAppRoute(page, buildProjectsSettingsRoute());
return;
}
await sidebar.getByRole("button", { name: SECTION_LABELS[section], exact: true }).click();
await expectAppRoute(page, buildSettingsSectionRoute(section));
}
@@ -143,8 +144,8 @@ export async function seedSavedSettingsHosts(
}
export async function selectSettingsHost(page: Page, serverId: string): Promise<void> {
await page.getByTestId("settings-host-picker").click();
await page.getByTestId(`settings-host-picker-item-${serverId}`).click();
await page.locator('[data-testid="settings-host-picker"]:visible').click();
await page.locator(`[data-testid="settings-host-picker-item-${serverId}"]:visible`).click();
}
export async function expectSettingsHostPickerLabel(page: Page, label: string): Promise<void> {
@@ -171,7 +172,7 @@ export async function expectSettingsSidebarHidden(page: Page): Promise<void> {
export async function expectSettingsSidebarSections(
page: Page,
sections: Array<Exclude<SettingsSection, "projects">>,
sections: SettingsSection[],
): Promise<void> {
const sidebar = page.getByTestId("settings-sidebar");
for (const section of sections) {
@@ -359,8 +360,7 @@ export async function expectProviderInstalledInSettings(
).toBeVisible();
}
export async function expectHostNoLocalOnlyRows(page: Page): Promise<void> {
await expect(page.getByTestId("host-page-pair-device-row")).toHaveCount(0);
export async function expectHostNoDaemonLifecycleRow(page: Page): Promise<void> {
await expect(page.getByTestId("host-page-daemon-lifecycle-card")).toHaveCount(0);
}
@@ -376,6 +376,7 @@ export async function expectRetiredSidebarSectionsAbsent(page: Page): Promise<vo
// Host group rows are now flat top-level sections (no drill-in).
await expect(sidebar.getByTestId("settings-host-section-connections")).toBeVisible();
await expect(sidebar.getByTestId("settings-host-section-projects")).toBeVisible();
await expect(sidebar.getByTestId("settings-host-section-agents")).toBeVisible();
await expect(sidebar.getByTestId("settings-host-section-workspaces")).toBeVisible();
await expect(sidebar.getByTestId("settings-host-section-providers")).toBeVisible();
+68
View File
@@ -0,0 +1,68 @@
import { expect, type Locator, type Page } from "@playwright/test";
export interface Rails {
left: number;
right: number;
}
interface Box {
x: number;
y: number;
width: number;
height: number;
}
async function readBox(locator: Locator): Promise<Box> {
const box = await locator.boundingBox();
expect(box, "Expected the element to be laid out").not.toBeNull();
return box as Box;
}
/** Leading and trailing edges of an element, in whole pixels. */
export async function readRails(locator: Locator): Promise<Rails> {
const box = await readBox(locator);
return { left: Math.round(box.x), right: Math.round(box.x + box.width) };
}
/**
* The two rails an AdaptiveModalSheet header indents to: the title's leading
* glyph and the close button's trailing edge. Sheet content sits on the same
* two rails, in every presentation.
*/
export async function readSheetHeaderRails(
page: Page,
sheetTestID: string,
title: string,
): Promise<Rails> {
const header = page.getByTestId(sheetTestID);
const [titleRails, closeRails] = await Promise.all([
readRails(header.getByText(title, { exact: true })),
readRails(header.getByRole("button", { name: "Close" })),
]);
return { left: titleRails.left, right: closeRails.right };
}
/** Vertical space between the bottom of one element and the top of the next. */
export async function readVerticalGap(upper: Locator, lower: Locator): Promise<number> {
const [above, below] = await Promise.all([readBox(upper), readBox(lower)]);
return Math.round(below.y - (above.y + above.height));
}
/**
* A compact sheet slides up into place, so anything measured across two reads
* has to wait for the surface to stop moving first.
*/
export async function waitForSettledPosition(locator: Locator): Promise<void> {
let previousTop: number | null = null;
await expect
.poll(
async () => {
const { y } = await readBox(locator);
const settled = previousTop === y;
previousTop = y;
return settled;
},
{ message: "Expected the sheet to stop moving" },
)
.toBe(true);
}
@@ -0,0 +1,410 @@
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { expect, type Page } from "@playwright/test";
import { buildOpenProjectRoute } from "@/utils/host-routes";
import { openSettings } from "./app";
import { injectDesktopBridge } from "./desktop-updates";
import { getServerId } from "./server-id";
import { openCompactSettings, openSettingsSection } from "./settings";
// The desktop skills module is the real command surface: the same handlers the
// Electron main process registers, running against a temp bundle and temp user
// data. Nothing about persistence or convergence is simulated in the browser.
import type {
SkillSelection,
SkillTargets,
} from "../../../desktop/src/integrations/skills/operations.js";
import { createSkillSelectionStore } from "../../../desktop/src/integrations/skills/selection-store.js";
import { createSkillsCommandHandlers } from "../../../desktop/src/integrations/skills/skills-commands.js";
import { createSkillsController } from "../../../desktop/src/integrations/skills/skills-controller.js";
const SKILL_COMMANDS = [
"get_skills_status",
"install_skills",
"update_skills",
"uninstall_skills",
"save_skills_selection",
] as const;
declare global {
interface Window {
__paseoSkillsInvoke?: (
command: string,
args: Record<string, unknown> | null,
) => Promise<unknown>;
}
}
export interface SkillsSandbox {
targets: SkillTargets;
userDataPath: string;
bundledSkills: string[];
/** Writes a managed skill directory the way an external tool would. */
placeSkillOnDisk: (skill: string, target: SkillTargetName) => Promise<void>;
/** Commits a selection through the real controller, as another window would. */
commitSelectionExternally: (
selection: SkillSelection,
confirmedRemovals?: readonly string[],
) => Promise<void>;
/** Waits until a held save reaches the desktop command boundary. */
waitForHeldSave: () => Promise<void>;
/** Releases a save held at the desktop command boundary. */
releaseHeldSave: () => void;
/** Test-only command-boundary gate used to keep the renderer in its pending state. */
holdSaveResponse?: () => Promise<void>;
cleanup: () => Promise<void>;
}
export interface SkillsSandboxOptions {
bundledSkills?: string[];
/**
* Puts a regular file where the `.agents` skills tree has to go, so the real
* convergence fails with ENOTDIR instead of a stubbed rejection.
*/
blockAgentsDir?: boolean;
/** Converge and commit this selection before the app loads, through the real controller. */
installed?: SkillSelection;
/**
* Leaves a skill installed in one agent home only, the way a partial install
* or a hand-deleted directory leaves it.
*/
keepOnlyIn?: { skill: string; target: SkillTargetName };
/**
* Writes a skill directory straight into one agent home after the selection is
* committed, the way a manual reinstall or an external sync leaves a directory
* the saved preference does not mention.
*/
placeOnDisk?: { skill: string; target: SkillTargetName; files: Record<string, string> };
/** Holds the save response so dismissal behavior can be asserted while pending. */
holdSave?: boolean;
}
export type SkillTargetName = "agents" | "claude" | "codex";
function targetDir(targets: SkillTargets, target: SkillTargetName): string {
if (target === "agents") return targets.agentsDir;
if (target === "claude") return targets.claudeDir;
return targets.codexDir;
}
export async function createSkillsSandbox(
options: SkillsSandboxOptions = {},
): Promise<SkillsSandbox> {
let releaseHeldSave: (() => void) | null = null;
let markSaveHeld: (() => void) | null = null;
const heldSave = new Promise<void>((resolve) => {
markSaveHeld = resolve;
});
const saveRelease = new Promise<void>((resolve) => {
releaseHeldSave = resolve;
});
const bundledSkills = options.bundledSkills ?? ["paseo", "paseo-advisor", "paseo-loop"];
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-e2e-skills-"));
const targets: SkillTargets = {
sourceDir: path.join(root, "bundle"),
agentsDir: path.join(root, "home", ".agents", "skills"),
claudeDir: path.join(root, "home", ".claude", "skills"),
codexDir: path.join(root, "home", ".codex", "skills"),
};
for (const name of bundledSkills) {
await mkdir(path.join(targets.sourceDir, name), { recursive: true });
await writeFile(path.join(targets.sourceDir, name, "SKILL.md"), `# ${name}\n`, "utf8");
}
const userDataPath = path.join(root, "user-data");
if (options.installed) {
await createSkillsController({
resolveTargets: () => targets,
selectionStore: createSkillSelectionStore({ userDataPath }),
}).save(options.installed);
}
if (options.keepOnlyIn) {
const kept = targetDir(targets, options.keepOnlyIn.target);
for (const dir of [targets.agentsDir, targets.claudeDir, targets.codexDir]) {
if (dir === kept) continue;
await rm(path.join(dir, options.keepOnlyIn.skill), { recursive: true, force: true });
}
}
if (options.placeOnDisk) {
const dir = path.join(
targetDir(targets, options.placeOnDisk.target),
options.placeOnDisk.skill,
);
for (const [rel, contents] of Object.entries(options.placeOnDisk.files)) {
const file = path.join(dir, rel);
await mkdir(path.dirname(file), { recursive: true });
await writeFile(file, contents, "utf8");
}
}
if (options.blockAgentsDir) {
await mkdir(path.dirname(targets.agentsDir), { recursive: true });
await writeFile(targets.agentsDir, "not a directory", "utf8");
}
return {
targets,
userDataPath,
bundledSkills,
placeSkillOnDisk: async (skill, target) => {
const dir = path.join(targetDir(targets, target), skill);
await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, "SKILL.md"), `# ${skill}\n`, "utf8");
},
commitSelectionExternally: async (selection, confirmedRemovals = []) => {
await createSkillsController({
resolveTargets: () => targets,
selectionStore: createSkillSelectionStore({ userDataPath }),
}).save({ ...selection, confirmedRemovals: [...confirmedRemovals] });
},
waitForHeldSave: () => heldSave,
releaseHeldSave: () => releaseHeldSave?.(),
holdSaveResponse: options.holdSave
? async () => {
markSaveHeld?.();
await saveRelease;
}
: undefined,
// The save path atomically renames skill directories. On Linux/overlayfs,
// recursive removal can briefly observe a repopulated directory and fail
// with ENOTEMPTY even after the command has completed. Node only retries
// that transient when maxRetries is set.
cleanup: () => rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }),
};
}
/** Reads the committed selection back through the same store the app writes. */
export async function expectSavedSkillSelection(
sandbox: SkillsSandbox,
expected: SkillSelection,
): Promise<void> {
const store = createSkillSelectionStore({ userDataPath: sandbox.userDataPath });
await expect.poll(() => store.get(), { timeout: 15_000 }).toEqual(expected);
}
/**
* Routes the skills desktop commands to the real handlers in Node, leaving every
* other command on the injected desktop bridge. Call after `injectDesktopBridge`
* and before navigation.
*/
export async function serveRealSkillsCommands(page: Page, sandbox: SkillsSandbox): Promise<void> {
const handlers = createSkillsCommandHandlers({
controller: createSkillsController({
resolveTargets: () => sandbox.targets,
selectionStore: createSkillSelectionStore({ userDataPath: sandbox.userDataPath }),
}),
});
await page.exposeFunction(
"__paseoSkillsInvoke",
async (command: string, args: Record<string, unknown> | null) => {
const handler = handlers[command];
if (!handler) throw new Error(`Unknown skills command: ${command}`);
const result = await handler(args ?? undefined);
if (command === "save_skills_selection") {
await sandbox.holdSaveResponse?.();
}
return result;
},
);
await page.addInitScript((commands: readonly string[]) => {
const bridge = (window as unknown as { paseoDesktop?: { invoke: unknown } }).paseoDesktop;
if (!bridge) throw new Error("Desktop bridge must be injected before the skills bridge.");
const skillCommands = new Set(commands);
const inner = bridge.invoke as (
command: string,
args?: Record<string, unknown>,
) => Promise<unknown>;
bridge.invoke = (command: string, args?: Record<string, unknown>) =>
skillCommands.has(command)
? window.__paseoSkillsInvoke!(command, args ?? null)
: inner(command, args);
}, SKILL_COMMANDS);
}
/** Real skills commands over a temp bundle, wired to this page's desktop bridge. */
export async function startSkillsSandbox(
page: Page,
options: SkillsSandboxOptions = {},
): Promise<SkillsSandbox> {
const sandbox = await createSkillsSandbox(options);
await injectDesktopBridge(page, { serverId: getServerId(), confirmShouldAccept: false });
await serveRealSkillsCommands(page, sandbox);
return sandbox;
}
export async function openSkillsIntegrations(page: Page): Promise<void> {
await openSettings(page);
await openSettingsSection(page, "integrations");
await expect(page.getByText("Orchestration skills", { exact: true })).toBeVisible();
}
/** Settings on a compact form factor stacks list and detail, so it needs the menu first. */
export async function openCompactSkillsIntegrations(page: Page): Promise<void> {
await openCompactSettings(page, buildOpenProjectRoute());
await openSettingsSection(page, "integrations");
await expect(page.getByText("Orchestration skills", { exact: true })).toBeVisible();
}
export async function openSkillSelection(page: Page): Promise<void> {
await page.getByRole("button", { name: "Choose skills", exact: true }).click();
await expectSkillSelectionOpen(page);
}
export async function expectSkillSelectionOpen(page: Page): Promise<void> {
await expect(page.getByRole("switch", { name: "All skills", exact: true })).toBeVisible();
}
export async function expectSkillSelectionClosed(page: Page): Promise<void> {
await expect(page.getByRole("switch", { name: "All skills", exact: true })).toHaveCount(0);
}
export async function chooseAllSkills(page: Page): Promise<void> {
const allSkills = page.getByRole("switch", { name: "All skills", exact: true });
await expect(allSkills).not.toBeChecked();
await allSkills.click();
await expect(allSkills).toBeChecked();
}
export async function chooseCustomSkills(page: Page, skills: string[]): Promise<void> {
const allSkills = page.getByRole("switch", { name: "All skills", exact: true });
await expect(allSkills).toBeChecked();
await allSkills.click();
await expect(allSkills).not.toBeChecked();
const keep = new Set(skills);
for (const name of await listSkillChoices(page)) {
if (keep.has(name)) continue;
await page.getByRole("checkbox", { name, exact: true }).click();
}
await expectSkillChoices(page, skills);
}
export async function toggleSkill(page: Page, name: string): Promise<void> {
await page.getByRole("checkbox", { name, exact: true }).click();
}
export async function saveSkillSelection(page: Page): Promise<void> {
await page.getByRole("button", { name: "Save", exact: true }).click();
}
export async function cancelSkillSelection(page: Page): Promise<void> {
await page.getByRole("button", { name: "Cancel", exact: true }).click();
await expectSkillSelectionClosed(page);
}
export async function listSkillChoices(page: Page): Promise<string[]> {
const rows = page.getByRole("checkbox");
const names = await rows.evaluateAll((elements) =>
elements.map((element) => element.getAttribute("aria-label") ?? ""),
);
return names.filter((name) => name.length > 0);
}
/** Asserts exactly which skills are checked in the open sheet. */
export async function expectSkillChoices(page: Page, skills: string[]): Promise<void> {
const expected = new Set(skills);
for (const name of await listSkillChoices(page)) {
const choice = page.getByRole("checkbox", { name, exact: true });
if (expected.has(name)) {
await expect(choice).toBeChecked();
continue;
}
await expect(choice).not.toBeChecked();
}
}
/** Reopens the saved selection and asserts what the user sees, then closes it. */
export async function expectSelectedSkills(page: Page, skills: string[]): Promise<void> {
await openSkillSelection(page);
await expect(page.getByRole("switch", { name: "All skills", exact: true })).not.toBeChecked();
await expectSkillChoices(page, skills);
await cancelSkillSelection(page);
}
export async function expectAllSkillsSelected(page: Page): Promise<void> {
await openSkillSelection(page);
const allSkills = page.getByRole("switch", { name: "All skills", exact: true });
await expect(allSkills).toBeChecked();
await expectSkillChoices(page, await listSkillChoices(page));
await cancelSkillSelection(page);
}
export async function expectSaveErrorKeepsSheetOpen(page: Page): Promise<void> {
await expect(page.getByText("Could not save your skill selection.", { exact: true })).toBeVisible(
{ timeout: 15_000 },
);
await expectSkillSelectionOpen(page);
}
/**
* The removal warning is the desktop confirm dialog, which has no DOM to query.
* The injected bridge records the call the same way the daemon-management tests
* assert their confirmation copy.
*/
export async function expectRemovalWarning(page: Page, skills: string[]): Promise<void> {
await page.waitForFunction(() => !!window.__capturedDialogCall, { timeout: 15_000 });
const call = await page.evaluate(() => window.__capturedDialogCall);
expect(call?.title).toBe("Remove deselected skills?");
for (const name of skills) {
expect(call?.message).toContain(name);
}
for (const dir of ["~/.agents", "~/.claude", "~/.codex"]) {
expect(call?.message).toContain(dir);
}
expect(call?.message).toContain("Anything you added inside those skill folders is deleted too.");
}
export async function expectNoRemovalWarning(page: Page): Promise<void> {
expect(await page.evaluate(() => window.__capturedDialogCall)).toBeUndefined();
}
export async function expectSkillPresentIn(
sandbox: SkillsSandbox,
target: SkillTargetName,
skill: string,
): Promise<void> {
await expect
.poll(() => readInstalledSkills(targetDir(sandbox.targets, target)), { timeout: 15_000 })
.toContain(skill);
}
export async function expectSkillFilePresent(
sandbox: SkillsSandbox,
target: SkillTargetName,
skill: string,
relativePath: string,
): Promise<void> {
const file = path.join(targetDir(sandbox.targets, target), skill, relativePath);
await expect
.poll(() => readFile(file, "utf8").catch(() => null), { timeout: 15_000 })
.not.toBeNull();
}
export async function expectSkillsInstalled(
sandbox: SkillsSandbox,
skills: string[],
): Promise<void> {
const dirs = [
sandbox.targets.agentsDir,
sandbox.targets.claudeDir,
sandbox.targets.codexDir,
] as const;
for (const dir of dirs) {
await expect
.poll(async () => readInstalledSkills(dir), { timeout: 15_000 })
.toEqual([...skills].sort());
}
}
async function readInstalledSkills(dir: string): Promise<string[]> {
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
return entries
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort();
}
@@ -64,6 +64,13 @@ async function seedPaseoWorkspaceWithOpenCodeSession(): Promise<OpenCodeImportSc
if (!createdWorkspace.workspace) {
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${PASEO_REPO_PATH}`);
}
const descriptor = (await client.listProjects()).projects.find(
(project) => project.projectId === createdWorkspace.workspace?.projectId,
);
const projectKey = descriptor?.projectKey;
if (!projectKey) {
throw new Error(`Created workspace ${createdWorkspace.workspace.id} has no project key`);
}
return {
prompt,
promptPreview,
@@ -75,6 +82,7 @@ async function seedPaseoWorkspaceWithOpenCodeSession(): Promise<OpenCodeImportSc
workspaceName: createdWorkspace.workspace.name,
workspaceDirectory: createdWorkspace.workspace.workspaceDirectory,
projectId: createdWorkspace.workspace.projectId,
projectKey,
projectDisplayName: createdWorkspace.workspace.projectDisplayName,
cleanup: async () => {
await client.close().catch(() => undefined);
@@ -165,7 +165,7 @@ test.describe("New workspace Codex mode preferences", () => {
await waitForSidebarHydration(page);
await openGlobalNewWorkspaceComposer(page);
await selectNewWorkspaceProject(page, {
projectKey: seeded.projectId,
projectKey: seeded.projectKey,
projectDisplayName: seeded.projectDisplayName,
});
@@ -221,7 +221,7 @@ test.describe("New workspace Codex mode preferences", () => {
await openGlobalNewWorkspaceComposer(page);
await selectNewWorkspaceProject(page, {
projectKey: seeded.projectId,
projectKey: seeded.projectKey,
projectDisplayName: seeded.projectDisplayName,
});
@@ -34,7 +34,7 @@ test.describe("New workspace composer draft", () => {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, {
projectKey: firstProject.projectId,
projectKey: firstProject.projectKey,
projectDisplayName: firstProject.projectDisplayName,
});
await expectNewWorkspaceProjectSelected(page, firstProject.projectDisplayName);
@@ -42,7 +42,7 @@ test.describe("New workspace composer draft", () => {
await fillNewWorkspaceDraft(page, DRAFT);
await selectNewWorkspaceProject(page, {
projectKey: secondProject.projectId,
projectKey: secondProject.projectKey,
projectDisplayName: secondProject.projectDisplayName,
});
+29 -12
View File
@@ -2,8 +2,10 @@ import { expect, test } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import {
connectNewWorkspaceDaemonClient,
expectNewWorkspaceControlsEnabled,
expectNewWorkspaceProjectSelected,
openGlobalNewWorkspaceComposer,
openMissingProjectNewWorkspaceComposer,
openNewWorkspaceComposer,
openNewWorkspaceProjectPickerWithShortcut,
} from "./helpers/new-workspace";
@@ -11,8 +13,9 @@ import { getE2EDaemonPort } from "./helpers/daemon-port";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
import { seedSavedSettingsHosts } from "./helpers/settings";
import { getServerId } from "./helpers/server-id";
import { projectEquivalenceViewKey } from "./helpers/project-view-key";
import { clickArchiveWorkspaceMenuItem, expectWorkspaceAbsentFromSidebar } from "./helpers/sidebar";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
import { waitForNoProjectsInSidebar, waitForSidebarHydration } from "./helpers/workspace-ui";
// Model B entry points into the New Workspace screen. The surviving entries are
// the global button (universal) and each project's per-row New workspace icon
@@ -22,7 +25,7 @@ import { waitForSidebarHydration } from "./helpers/workspace-ui";
// screen, and non-git projects never offer the worktree Isolation control.
function projectRow(page: import("@playwright/test").Page, projectKey: string) {
return page.getByTestId(`sidebar-project-row-${projectKey}`);
return page.getByTestId(`sidebar-project-row-${projectEquivalenceViewKey(projectKey)}`);
}
test.describe("New workspace entry points", () => {
@@ -107,6 +110,18 @@ test.describe("New workspace entry points", () => {
}
});
test("a stale project route keeps the New Workspace controls enabled", async ({ page }) => {
await gotoAppShell(page);
await waitForNoProjectsInSidebar(page);
await openMissingProjectNewWorkspaceComposer(page, {
serverId: getServerId(),
projectId: "missing-project",
sourceDirectory: "/tmp/missing-project",
});
await expectNewWorkspaceControlsEnabled(page);
});
test("Ctrl+P opens the project picker with search focused", async ({ page }) => {
const seeded: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-shortcut-" });
@@ -179,13 +194,13 @@ test.describe("New workspace entry points", () => {
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await expect(projectRow(page, projectA.projectId)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, projectB.projectId)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, projectC.projectId)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, projectA.projectKey)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, projectB.projectKey)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, projectC.projectKey)).toBeVisible({ timeout: 30_000 });
// Project A's row icon opens New Workspace with A preselected.
await openNewWorkspaceComposer(page, {
projectKey: projectA.projectId,
projectKey: projectA.projectKey,
projectDisplayName: projectA.projectDisplayName,
});
await expectNewWorkspaceProjectSelected(page, projectA.projectDisplayName);
@@ -194,7 +209,9 @@ test.describe("New workspace entry points", () => {
// manualProjectKey is what the reused 'new' screen must reset when the next
// route-driven navigation targets a different project.
await page.getByTestId("new-workspace-project-picker-trigger").click();
const optionC = page.getByTestId(`new-workspace-project-picker-option-${projectC.projectId}`);
const optionC = page.getByTestId(
`new-workspace-project-picker-option-${projectEquivalenceViewKey(projectC.projectKey)}`,
);
await expect(optionC).toBeVisible({ timeout: 30_000 });
await optionC.click();
await expectNewWorkspaceProjectSelected(page, projectC.projectDisplayName);
@@ -203,7 +220,7 @@ test.describe("New workspace entry points", () => {
// because the stale manual choice (C) was reset on the route change. If the
// reset were missing, the trigger would still read C.
await openNewWorkspaceComposer(page, {
projectKey: projectB.projectId,
projectKey: projectB.projectKey,
projectDisplayName: projectB.projectDisplayName,
});
await expectNewWorkspaceProjectSelected(page, projectB.projectDisplayName);
@@ -226,8 +243,8 @@ test.describe("New workspace entry points", () => {
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await expect(projectRow(page, gitProject.projectId)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, nonGitProject.projectId)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, gitProject.projectKey)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, nonGitProject.projectKey)).toBeVisible({ timeout: 30_000 });
// Open New Workspace for the non-git project via the global button, then
// select it in the picker (the per-row icon would preselect it too).
@@ -236,7 +253,7 @@ test.describe("New workspace entry points", () => {
await expect(trigger).toBeVisible({ timeout: 30_000 });
await trigger.click();
const nonGitOption = page.getByTestId(
`new-workspace-project-picker-option-${nonGitProject.projectId}`,
`new-workspace-project-picker-option-${projectEquivalenceViewKey(nonGitProject.projectKey)}`,
);
await expect(nonGitOption).toBeVisible({ timeout: 30_000 });
await nonGitOption.click();
@@ -249,7 +266,7 @@ test.describe("New workspace entry points", () => {
// Switching to the git project on the same screen reveals the Isolation row.
await trigger.click();
const gitOption = page.getByTestId(
`new-workspace-project-picker-option-${gitProject.projectId}`,
`new-workspace-project-picker-option-${projectEquivalenceViewKey(gitProject.projectKey)}`,
);
await expect(gitOption).toBeVisible({ timeout: 30_000 });
await gitOption.click();
@@ -0,0 +1,196 @@
import { expect, test as base } from "./fixtures";
import { openProjectDirectoryWithHosts } from "./helpers/project-grouping";
import { projectPlacementViewKey } from "./helpers/project-view-key";
import { type IsolatedHostDaemon, startIsolatedHostDaemon } from "./helpers/isolated-host-daemon";
import {
expectNewWorkspaceProjectSelected,
openGlobalNewWorkspaceComposer,
selectNewWorkspaceHost,
selectNewWorkspaceProject,
} from "./helpers/new-workspace";
import { connectSeedClient, type SeedDaemonClient } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { switchWorkspaceViaSidebar } from "./helpers/workspace-ui";
import { createTempGitRepo } from "./helpers/workspace";
const PRIMARY_HOST_LABEL = "Primary host";
const SECONDARY_HOST_LABEL = "Secondary host";
const SHARED_PROJECT_NAME = "Paseo";
const SHARED_REMOTE_URL = "https://github.com/getpaseo/paseo.git";
interface CreatedProject {
projectId: string;
projectKey: string;
workspaceId: string;
}
interface CrossHostProjectScenario {
contextWorkspaceId: string;
selectedProjectViewKey: string;
sharedProjectKey: string;
secondaryHost: {
serverId: string;
label: string;
port: number;
};
}
async function createProject(
client: SeedDaemonClient,
input: { projectPath: string; projectName: string; workspaceName: string },
): Promise<CreatedProject> {
const created = await client.createWorkspace({
source: { kind: "directory", path: input.projectPath },
title: input.workspaceName,
});
if (!created.workspace) {
throw new Error(created.error ?? `Failed to create project ${input.projectName}`);
}
await client.renameProject(created.workspace.projectId, input.projectName);
const listed = await client.listProjects();
const project = listed.projects.find(
(candidate) => candidate.projectId === created.workspace?.projectId,
);
if (!project?.projectKey) {
throw new Error(`Created project ${created.workspace.projectId} has no project key`);
}
return {
projectId: created.workspace.projectId,
projectKey: project.projectKey,
workspaceId: created.workspace.id,
};
}
async function openNewWorkspaceFromContextProject(
page: Parameters<typeof openProjectDirectoryWithHosts>[0],
scenario: CrossHostProjectScenario,
): Promise<void> {
await openProjectDirectoryWithHosts(page, {
hosts: [scenario.secondaryHost],
primaryLabel: PRIMARY_HOST_LABEL,
});
await expect(
page.getByTestId(`sidebar-project-row-${scenario.selectedProjectViewKey}`),
).toBeVisible();
await switchWorkspaceViaSidebar({
page,
serverId: getServerId(),
workspaceId: scenario.contextWorkspaceId,
});
await openGlobalNewWorkspaceComposer(page);
await expectNewWorkspaceProjectSelected(page, "Context project");
}
const test = base.extend<{ crossHostProject: CrossHostProjectScenario }>({
crossHostProject: async ({ page: _page }, provide) => {
const secondaryDaemon: IsolatedHostDaemon = await startIsolatedHostDaemon(
"new-workspace-project-preservation-secondary",
);
const contextRepo = await createTempGitRepo("new-workspace-context-");
const primarySharedRepo = await createTempGitRepo("new-workspace-paseo-primary-", {
originUrl: SHARED_REMOTE_URL,
});
const primaryDuplicateRepo = await createTempGitRepo("new-workspace-paseo-duplicate-", {
originUrl: SHARED_REMOTE_URL,
});
const secondaryFallbackRepo = await createTempGitRepo("new-workspace-fallback-");
const secondarySharedRepo = await createTempGitRepo("new-workspace-paseo-secondary-", {
originUrl: SHARED_REMOTE_URL,
});
const primaryClient = await connectSeedClient();
const secondaryClient = await connectSeedClient({ port: secondaryDaemon.port });
const primaryProjects: CreatedProject[] = [];
const secondaryProjects: CreatedProject[] = [];
try {
const contextProject = await createProject(primaryClient, {
projectPath: contextRepo.path,
projectName: "Context project",
workspaceName: "Context workspace",
});
primaryProjects.push(contextProject);
const primarySharedProject = await createProject(primaryClient, {
projectPath: primarySharedRepo.path,
projectName: SHARED_PROJECT_NAME,
workspaceName: "Primary Paseo workspace",
});
primaryProjects.push(primarySharedProject);
const primaryDuplicateProject = await createProject(primaryClient, {
projectPath: primaryDuplicateRepo.path,
projectName: "Paseo duplicate",
workspaceName: "Duplicate Paseo workspace",
});
primaryProjects.push(primaryDuplicateProject);
secondaryProjects.push(
await createProject(secondaryClient, {
projectPath: secondaryFallbackRepo.path,
projectName: "Aardvark project",
workspaceName: "Secondary fallback workspace",
}),
);
const secondarySharedProject = await createProject(secondaryClient, {
projectPath: secondarySharedRepo.path,
projectName: SHARED_PROJECT_NAME,
workspaceName: "Secondary Paseo workspace",
});
secondaryProjects.push(secondarySharedProject);
if (
primarySharedProject.projectKey !== primaryDuplicateProject.projectKey ||
primarySharedProject.projectKey !== secondarySharedProject.projectKey
) {
throw new Error("Expected the Paseo clones to have the same equivalence key");
}
await provide({
contextWorkspaceId: contextProject.workspaceId,
selectedProjectViewKey: projectPlacementViewKey(
getServerId(),
primarySharedProject.projectId,
),
sharedProjectKey: primarySharedProject.projectKey,
secondaryHost: {
serverId: secondaryDaemon.serverId,
label: SECONDARY_HOST_LABEL,
port: secondaryDaemon.port,
},
});
} finally {
for (const project of primaryProjects) {
await primaryClient.removeProject(project.projectId).catch(() => undefined);
}
for (const project of secondaryProjects) {
await secondaryClient.removeProject(project.projectId).catch(() => undefined);
}
await primaryClient.close().catch(() => undefined);
await secondaryClient.close().catch(() => undefined);
await contextRepo.cleanup().catch(() => undefined);
await primarySharedRepo.cleanup().catch(() => undefined);
await primaryDuplicateRepo.cleanup().catch(() => undefined);
await secondaryFallbackRepo.cleanup().catch(() => undefined);
await secondarySharedRepo.cleanup().catch(() => undefined);
await secondaryDaemon.close().catch(() => undefined);
}
},
});
test.describe("New workspace host project preservation", () => {
test.describe.configure({ timeout: 180_000 });
test("keeps the equivalent project selected when one host has another clone", async ({
page,
crossHostProject,
}) => {
await openNewWorkspaceFromContextProject(page, crossHostProject);
await selectNewWorkspaceProject(page, {
projectKey: crossHostProject.sharedProjectKey,
projectViewKey: crossHostProject.selectedProjectViewKey,
projectDisplayName: SHARED_PROJECT_NAME,
});
await selectNewWorkspaceHost(page, SECONDARY_HOST_LABEL);
await expectNewWorkspaceProjectSelected(page, SHARED_PROJECT_NAME);
});
});
@@ -126,7 +126,7 @@ test.describe("New Workspace mode cycle safety", () => {
// so its handler is still registered when we cycle here.
await openGlobalNewWorkspaceComposer(page);
await selectNewWorkspaceProject(page, {
projectKey: seeded.projectId,
projectKey: seeded.projectKey,
projectDisplayName: seeded.projectDisplayName,
});
+7
View File
@@ -195,6 +195,7 @@ async function submitNewWorkspaceWithoutPrompt(page: import("@playwright/test").
test.describe("New workspace flow", () => {
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
const localWorkspaceIds = new Set<string>();
const localProjectIds = new Set<string>();
const createdWorktreeDirectories = new Set<string>();
const localGithubFixtures = new Set<LocalGhPrFixture>();
@@ -212,9 +213,13 @@ test.describe("New workspace flow", () => {
for (const workspaceId of localWorkspaceIds) {
await archiveLocalWorkspaceFromDaemon(client, workspaceId).catch(() => undefined);
}
for (const projectId of localProjectIds) {
await client.removeProject(projectId).catch(() => undefined);
}
}
createdWorktreeDirectories.clear();
localWorkspaceIds.clear();
localProjectIds.clear();
await client?.close().catch(() => undefined);
});
@@ -830,6 +835,7 @@ test.describe("New workspace flow", () => {
const openedProject = await openProjectViaDaemon(client, mainCheckout.path);
localWorkspaceIds.add(openedProject.workspaceId);
localProjectIds.add(openedProject.projectId);
await gotoAppShell(page);
await waitForSidebarHydration(page);
@@ -881,6 +887,7 @@ test.describe("New workspace flow", () => {
const openedProject = await openProjectViaDaemon(client, mainCheckout.path);
localWorkspaceIds.add(openedProject.workspaceId);
localProjectIds.add(openedProject.projectId);
await gotoAppShell(page);
await waitForSidebarHydration(page);
+192
View File
@@ -0,0 +1,192 @@
import { expect, test } from "./fixtures";
import { startIsolatedHostDaemon, type IsolatedHostDaemon } from "./helpers/isolated-host-daemon";
import { connectDaemonClient } from "./helpers/daemon-client-loader";
import {
closePairDeviceModal,
declineRelay,
enableRelayAndExpectFailure,
enableRelayAndExpectOffer,
expectDaemonPidUnchanged,
expectPairingOffer,
expectPairingDisconnected,
expectRelayUpdateRequired,
expectRelayConsent,
openPairDeviceModal,
openPairDeviceFromHome,
openRelaySecurityDocs,
observePairingOfferRequests,
prepareLocalPairingHost,
preparePairingHost,
reloadAndOpenPairDevice,
retryRelayAndExpectFailure,
switchPairDeviceToHost,
} from "./helpers/pair-device";
interface RelayConfigDaemonClient {
close(): Promise<void>;
connect(): Promise<void>;
patchDaemonConfig(config: { relay: { enabled: boolean } }): Promise<unknown>;
}
test.describe("local device relay pairing", () => {
let relayOffDaemon: IsolatedHostDaemon;
let relayEnableDaemon: IsolatedHostDaemon;
let relayOverrideDaemon: IsolatedHostDaemon;
let relayEnabledDaemon: IsolatedHostDaemon;
let invalidRelayDaemon: IsolatedHostDaemon;
test.beforeAll(async () => {
[
relayOffDaemon,
relayEnableDaemon,
relayOverrideDaemon,
relayEnabledDaemon,
invalidRelayDaemon,
] = await Promise.all([
startIsolatedHostDaemon("pair-device-relay-off", {
mutableRelay: { enabled: false },
}),
startIsolatedHostDaemon("pair-device-relay-enable", {
mutableRelay: { enabled: false },
}),
startIsolatedHostDaemon("pair-device-relay-override"),
startIsolatedHostDaemon("pair-device-relay-enabled", {
mutableRelay: { enabled: true },
}),
startIsolatedHostDaemon("pair-device-relay-invalid", {
mutableRelay: { enabled: false, endpoint: "invalid-endpoint" },
}),
]);
});
test.afterAll(async () => {
await Promise.all([
relayOffDaemon.close(),
relayEnableDaemon.close(),
relayOverrideDaemon.close(),
relayEnabledDaemon.close(),
invalidRelayDaemon.close(),
]);
});
test("asks for consent and creates no QR when declined", async ({ page }) => {
await prepareLocalPairingHost(page, relayOffDaemon);
await openPairDeviceModal(page);
await expectRelayConsent(page);
await declineRelay(page);
await openPairDeviceModal(page);
await expectRelayConsent(page);
});
test("opens relay security documentation through the desktop opener", async ({ page }) => {
await prepareLocalPairingHost(page, relayOffDaemon);
await openPairDeviceModal(page);
await openRelaySecurityDocs(page);
});
test("opens the same relay consent dialog from the home screen", async ({ page }) => {
await prepareLocalPairingHost(page, relayOffDaemon);
await openPairDeviceFromHome(page);
const modal = page.getByTestId("open-project-pair-device-modal");
await expect(modal.getByText("Enable relay?", { exact: true })).toBeVisible();
await expect(modal.getByRole("button", { name: "Enable relay", exact: true })).toBeVisible();
});
test("opens relay consent in browser web", async ({ page }) => {
await preparePairingHost(page, relayOffDaemon);
await openPairDeviceModal(page);
await expectRelayConsent(page);
});
test("shows an actionable error when the daemon disconnects", async ({ page }) => {
const daemon = await startIsolatedHostDaemon("pair-device-disconnect", {
mutableRelay: { enabled: false },
});
try {
await prepareLocalPairingHost(page, daemon);
await openPairDeviceModal(page);
await expectRelayConsent(page);
await daemon.close();
await expectPairingDisconnected(page);
} finally {
await daemon.close();
}
});
test("pairs a device through the selected remote host", async ({ page }) => {
await prepareLocalPairingHost(page, relayOffDaemon, [
{
serverId: relayEnabledDaemon.serverId,
label: "Remote pairing host",
endpoint: `127.0.0.1:${relayEnabledDaemon.port}`,
},
]);
await switchPairDeviceToHost(page, relayEnabledDaemon.serverId);
await openPairDeviceModal(page);
await expectPairingOffer(page);
});
test("enables relay live and keeps the saved offer after reload", async ({ page }) => {
const daemonPid = relayEnableDaemon.getPid();
await prepareLocalPairingHost(page, relayEnableDaemon);
await openPairDeviceModal(page);
await enableRelayAndExpectOffer(page);
expectDaemonPidUnchanged(daemonPid, relayEnableDaemon.getPid());
await closePairDeviceModal(page);
await reloadAndOpenPairDevice(page);
await expectPairingOffer(page);
});
test("keeps consent actionable when a launch override rejects enable", async ({ page }) => {
await prepareLocalPairingHost(page, relayOverrideDaemon);
await openPairDeviceModal(page);
await expectRelayConsent(page);
await enableRelayAndExpectFailure(page);
await retryRelayAndExpectFailure(page);
});
test("keeps consent actionable when relay transport startup fails", async ({ page }) => {
await prepareLocalPairingHost(page, invalidRelayDaemon);
await openPairDeviceModal(page);
await expectRelayConsent(page);
await enableRelayAndExpectFailure(page, "Invalid host:port");
await retryRelayAndExpectFailure(page, "Invalid host:port");
});
test("shows an offer immediately when relay is already enabled", async ({ page }) => {
await prepareLocalPairingHost(page, relayEnabledDaemon);
await openPairDeviceModal(page);
await expectPairingOffer(page);
});
test("refreshes the mounted offer when another client disables relay", async ({ page }) => {
const daemon = await startIsolatedHostDaemon("pair-device-live-config-push", {
mutableRelay: { enabled: true },
});
const client = await connectDaemonClient<RelayConfigDaemonClient>({
clientIdPrefix: "pair-device-live-config-push",
port: daemon.port,
});
try {
await prepareLocalPairingHost(page, daemon);
await openPairDeviceModal(page);
await expectPairingOffer(page);
await client.patchDaemonConfig({ relay: { enabled: false } });
await expectRelayConsent(page);
} finally {
await client.close();
await daemon.close();
}
});
test("asks for a host update when live relay config is unsupported", async ({
page,
relayConfigOutdatedDaemon,
}) => {
const expectNoUnsupportedRequest = observePairingOfferRequests(page, relayConfigOutdatedDaemon);
await prepareLocalPairingHost(page, relayConfigOutdatedDaemon);
await openPairDeviceModal(page);
await expectRelayUpdateRequired(page);
expectNoUnsupportedRequest();
});
});
@@ -1,8 +1,10 @@
import { test, expect } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { injectDesktopBridge, waitForDirectoryDialog } from "./helpers/desktop-updates";
import { getE2EDaemonPort } from "./helpers/daemon-port";
import { waitForConnectedHost } from "./helpers/hosts";
import { expectOpenedProject } from "./helpers/project-picker-ui";
import { expectNewWorkspaceForAddedProject } from "./helpers/add-project-flow";
import { expectNewWorkspaceForAddedProject, openAddProjectFlow } from "./helpers/add-project-flow";
import { getServerId } from "./helpers/server-id";
import { connectSeedClient } from "./helpers/seed-client";
@@ -18,8 +20,12 @@ test("Browse opens the folder selected by the desktop dialog", async ({
dialogOpenResult: projectPickerFixture.projectPath,
});
await gotoAppShell(page);
await waitForConnectedHost(page, {
serverId: getServerId(),
endpoint: `localhost:${getE2EDaemonPort()}`,
});
await page.getByTestId("sidebar-add-project").click();
await openAddProjectFlow(page);
const browse = page.getByRole("button", { name: /^Browse/ });
await expect(browse).toBeVisible({ timeout: 30_000 });
await browse.click();
@@ -50,8 +56,12 @@ test("canceling Browse returns to the Add Project methods", async ({
dialogOpenResult: null,
});
await gotoAppShell(page);
await waitForConnectedHost(page, {
serverId: getServerId(),
endpoint: `localhost:${getE2EDaemonPort()}`,
});
await page.getByTestId("sidebar-add-project").click();
await openAddProjectFlow(page);
const browse = page.getByRole("button", { name: /^Browse/ });
await expect(browse).toBeVisible({ timeout: 30_000 });
await browse.click();
+98 -5
View File
@@ -5,29 +5,40 @@ import { connectSeedClient, seedWorkspace } from "./helpers/seed-client";
import {
blockPaseoConfigWrites,
bumpPaseoConfigOnDisk,
chooseProjectIconImage,
clickReloadProjectSettings,
clickRetryProjectSettingsSave,
clickSaveProjectSettings,
corruptPaseoConfig,
editWorktreeSetup,
expectEmptyScriptList,
expectHostIndicatorVisible,
expectHostPickerHidden,
expectProjectHostContextHidden,
expectNoEditableTarget,
expectNoProjectSettingsError,
expectProjectEditFailed,
expectProjectEditName,
expectProjectEditSaved,
expectProjectEditsSaveDisabled,
expectProjectSettingsError,
expectProjectSettingsFormHidden,
expectProjectSettingsFormVisible,
expectProjectTitle,
expectProjectSettingsHistoryRoundTrip,
expectSaveButtonDisabled,
expectScriptRowCount,
expectWriteFailedCalloutActions,
fillProjectIconUrl,
fillProjectName,
installDaemonConnectionGate,
installReadTransportFailure,
navigateToProjectSettings,
openProjectEditSheet,
openProjectSettings,
openProjects,
removeProjectScript,
restorePaseoConfig,
returnToProjectsList,
saveProjectEdits,
unblockPaseoConfigWrites,
} from "./helpers/project-settings";
import { gotoAppShell } from "./helpers/app";
@@ -40,6 +51,12 @@ import { createTempGitRepo } from "./helpers/workspace";
const updatedSetup = ["npm install", "npm run build"];
// Smallest valid square PNG the daemon will accept as a custom project icon.
const PNG_1X1 = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00,
]);
interface ProjectsSettingsProject {
name: string;
path: string;
@@ -204,6 +221,15 @@ test.describe("Projects settings", () => {
await expectProjectConfigSaved(editableProject);
});
test("project navigation stays inside the selected host", async ({ page, editableProject }) => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
await expectProjectHostContextHidden(page);
await returnToProjectsList(page);
await openProjectSettings(page, editableProject.name);
await expectProjectSettingsHistoryRoundTrip(page, editableProject.name);
});
test("user edits worktree setup on a non-GitHub remote project", async ({
page,
gitlabRemoteProject,
@@ -215,6 +241,74 @@ test.describe("Projects settings", () => {
await clickSaveProjectSettings(page);
await expectProjectConfigSaved(gitlabRemoteProject);
});
test("user renames a project from the edit sheet", async ({ page, editableProject }) => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
await openProjectEditSheet(page);
await fillProjectName(page, "Renamed project");
await saveProjectEdits(page);
await expectProjectEditSaved(page);
await expectProjectTitle(page, "Renamed project");
});
test("reopening the edit sheet seeds from the saved project", async ({
page,
editableProject,
}) => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
await openProjectEditSheet(page);
await fillProjectName(page, "Renamed project");
await saveProjectEdits(page);
await expectProjectEditSaved(page);
await openProjectEditSheet(page);
await expectProjectEditName(page, "Renamed project");
await expectProjectEditsSaveDisabled(page);
});
test("user picks a custom project icon from a file", async ({ page, editableProject }) => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
await openProjectEditSheet(page);
await chooseProjectIconImage(page, {
name: "logo.png",
mimeType: "image/png",
buffer: PNG_1X1,
});
await saveProjectEdits(page);
await expectProjectEditSaved(page);
});
test("user sets a project name and icon in one save", async ({ page, editableProject }) => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
await openProjectEditSheet(page);
await fillProjectName(page, "Both at once");
await chooseProjectIconImage(page, {
name: "logo.png",
mimeType: "image/png",
buffer: PNG_1X1,
});
await saveProjectEdits(page);
await expectProjectEditSaved(page);
await expectProjectTitle(page, "Both at once");
});
test("project edit keeps a rejected icon URL actionable", async ({ page, editableProject }) => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
await openProjectEditSheet(page);
await fillProjectIconUrl(page, "file:///etc/passwd");
await saveProjectEdits(page);
await expectProjectEditFailed(page, "URL must use HTTP or HTTPS without credentials");
});
});
test.describe("Projects settings — error UX", () => {
@@ -322,15 +416,14 @@ test.describe("Projects settings — error UX", () => {
await expectNoEditableTarget(page);
});
test("single-host project renders static host indicator, not a picker chip", async ({
test("project detail does not render a second host selector", async ({
page,
editableProject,
}) => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
await expectHostIndicatorVisible(page);
await expectHostPickerHidden(page);
await expectProjectHostContextHidden(page);
});
test("script removal via kebab menu removes the row from the form", async ({
@@ -163,6 +163,7 @@ test.describe("Schedules", () => {
port: fakePort,
serverId: fakeHost.serverId,
workspace: fakeHost.workspace,
project: fakeHost,
schedules: [
buildFakeHostSchedule({
id: scheduleId,
@@ -7,6 +7,7 @@ import {
} from "./helpers/schedule-fake-host";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { projectEquivalenceViewKey } from "./helpers/project-view-key";
import { escapeRegex } from "./helpers/regex";
import { expectNoTruncation } from "./helpers/no-truncation";
import { expectSettled, expectStableHeight } from "./helpers/settled";
@@ -205,7 +206,9 @@ test.describe("Schedules project target", () => {
await expect(formSheet.getByText("Cron", { exact: true })).toHaveCount(0);
await page.getByRole("button", { name: /select project/i }).click();
await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click();
await page
.getByTestId(`schedule-project-option-${projectEquivalenceViewKey(workspace.projectKey)}`)
.click();
const projectTrigger = page.getByTestId("schedule-project-trigger");
await expect(projectTrigger).toContainText(workspace.projectDisplayName);
await expectSettled(projectTrigger);
@@ -245,6 +248,7 @@ test.describe("Schedules project target", () => {
port: fakePort,
serverId: fakeHost.serverId,
workspace: fakeHost.workspace,
project: fakeHost,
});
await addFakeScheduleHostAndReload({
page,
@@ -277,6 +281,7 @@ test.describe("Schedules project target", () => {
port: fakePort,
serverId: fakeHost.serverId,
workspace: fakeHost.workspace,
project: fakeHost,
});
await gotoAppShell(page);
@@ -313,7 +318,9 @@ test.describe("Schedules project target", () => {
await expectSettled(hostTrigger);
await projectTrigger.click();
await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click();
await page
.getByTestId(`schedule-project-option-${projectEquivalenceViewKey(workspace.projectKey)}`)
.click();
await expect(projectTrigger).toContainText(workspace.projectDisplayName);
await expectSettled(projectTrigger);
@@ -335,8 +342,9 @@ test.describe("Schedules project target", () => {
await expectSettled(projectTrigger);
await projectTrigger.click();
await expect(page.getByTestId(`schedule-project-option-${workspace.projectId}`)).toHaveCount(0);
await page.getByTestId(`schedule-project-option-${fakeHost.projectId}`).click();
await page
.getByTestId(`schedule-project-option-${projectEquivalenceViewKey(fakeHost.projectKey)}`)
.click();
await expect(projectTrigger).toContainText(fakeHost.projectDisplayName);
await expectSettled(projectTrigger);
await expect(modelTrigger).toContainText(/select model/i);
@@ -362,7 +370,9 @@ test.describe("Schedules project target", () => {
await page.getByTestId("schedules-empty-new").click();
await page.getByRole("button", { name: /select project/i }).click();
await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click();
await page
.getByTestId(`schedule-project-option-${projectEquivalenceViewKey(workspace.projectKey)}`)
.click();
await selectModelByLabel(page, "Ten second stream");
await expect(
page.getByText("Off keeps each run's workspace in the sidebar for inspection."),
+4 -4
View File
@@ -14,7 +14,7 @@ import {
expectHostInjectMcpCard,
expectHostActionCards,
expectHostProvidersCard,
expectHostNoLocalOnlyRows,
expectHostNoDaemonLifecycleRow,
expectRetiredSidebarSectionsAbsent,
expectHostPageVisible,
expectLocalHostEntryFirst,
@@ -126,7 +126,7 @@ test.describe("Settings host page", () => {
await expectHostLabelEditMode(page, TEST_HOST_LABEL);
});
test("host section does not render pair-device or daemon-lifecycle rows for a remote daemon", async ({
test("host section does not render daemon lifecycle controls for a remote daemon", async ({
page,
}) => {
const serverId = getServerId();
@@ -136,8 +136,8 @@ test.describe("Settings host page", () => {
await openSettingsHost(page, serverId);
await openHostSection(page, serverId, "host");
// TODO: add local-daemon fixture for positive Pair/Daemon coverage.
await expectHostNoLocalOnlyRows(page);
// TODO: add a local-daemon fixture for positive daemon lifecycle coverage.
await expectHostNoDaemonLifecycleRow(page);
});
test("settings sidebar exposes the flat App and Host section rows", async ({ page }) => {
+18 -8
View File
@@ -4,6 +4,7 @@ import { gotoWorkspace, clickNewTerminal } from "./helpers/launcher";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
import { seedMockAgentWorkspace } from "./helpers/mock-agent";
import { getServerId } from "./helpers/server-id";
import { projectEquivalenceViewKey } from "./helpers/project-view-key";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
import { getVisibleWorkspaceAgentTabIds } from "./helpers/workspace-tabs";
@@ -17,11 +18,11 @@ function workspaceRow(page: Page, workspaceId: string) {
}
function projectRow(page: Page, projectKey: string) {
return page.getByTestId(`sidebar-project-row-${projectKey}`);
return page.getByTestId(`sidebar-project-row-${projectEquivalenceViewKey(projectKey)}`);
}
function projectNewWorktreeIcon(page: Page, projectKey: string) {
return page.getByTestId(`sidebar-project-new-worktree-${projectKey}`);
return page.getByTestId(`sidebar-project-new-worktree-${projectEquivalenceViewKey(projectKey)}`);
}
async function seedSecondWorkspace(seeded: SeededWorkspace, title: string): Promise<string> {
@@ -53,8 +54,8 @@ test.describe("Model B sidebar shape", () => {
// Both projects are expandable parents — the non-git one is NOT flattened
// into a bare workspace link.
await expect(projectRow(page, gitProject.projectId)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, nonGitProject.projectId)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, gitProject.projectKey)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, nonGitProject.projectKey)).toBeVisible({ timeout: 30_000 });
// Each parent shows both of its workspace rows underneath.
await expect(workspaceRow(page, gitProject.workspaceId)).toBeVisible({ timeout: 30_000 });
@@ -65,12 +66,12 @@ test.describe("Model B sidebar shape", () => {
// Both projects show a per-row New workspace icon (revealed on hover): the
// git project can branch off a worktree, and the non-git project can add
// another workspace because the host supports workspaceMultiplicity.
await projectRow(page, gitProject.projectId).hover();
await expect(projectNewWorktreeIcon(page, gitProject.projectId)).toBeVisible({
await projectRow(page, gitProject.projectKey).hover();
await expect(projectNewWorktreeIcon(page, gitProject.projectKey)).toBeVisible({
timeout: 30_000,
});
await projectRow(page, nonGitProject.projectId).hover();
await expect(projectNewWorktreeIcon(page, nonGitProject.projectId)).toBeVisible({
await projectRow(page, nonGitProject.projectKey).hover();
await expect(projectNewWorktreeIcon(page, nonGitProject.projectKey)).toBeVisible({
timeout: 30_000,
});
@@ -122,6 +123,7 @@ test.describe("Model B sidebar shape", () => {
repoPrefix: "model-b-status-active-",
title: "Working workspace",
initialPrompt: "stay busy",
model: "one-minute-stream",
});
try {
@@ -152,6 +154,14 @@ test.describe("Model B sidebar shape", () => {
// the status view.
await expect(sidebar.locator('[data-testid^="workspace-tab-"]')).toHaveCount(0);
// Status mode drops the project grouping, so each row leads its subtitle
// with the project's icon to keep projects distinguishable.
for (const workspaceId of [idleProject.workspaceId, activeMock.workspaceId]) {
await expect(
page.getByTestId(`sidebar-row-project-icon-${getServerId()}:${workspaceId}`).first(),
).toBeVisible({ timeout: 60_000 });
}
// The busy workspace is grouped under Working, the idle one under Done:
// changing one workspace's status moved only that row.
const workingRows = page.getByTestId("sidebar-status-group-rows-running");
@@ -0,0 +1,412 @@
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { test as base } from "./fixtures";
import {
beginWorkspaceFromProject,
createWorkspaceWithoutAgent,
expectProjectContainsWorkspaces,
expectProjectSettingsName,
expectProjectWorkspaceCountForHost,
expectSeparateProjects,
openGroupedProjectSettings,
openProjectsForSettingsHost,
openProjectDirectory,
openProjectDirectoryWithHosts,
renameProject,
selectWorkspaceHost,
} from "./helpers/project-grouping";
import { type IsolatedHostDaemon, startIsolatedHostDaemon } from "./helpers/isolated-host-daemon";
import { connectSeedClient, type SeedDaemonClient } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { createTempGitRepo } from "./helpers/workspace";
const PRIMARY_HOST_LABEL = "Primary Host";
const SECONDARY_HOST_LABEL = "Secondary Host";
const LEGACY_PRIMARY_HOST_LABEL = "Legacy Primary Host";
const LEGACY_SECONDARY_HOST_LABEL = "Legacy Secondary Host";
const GROUPED_PROJECT_NAME = "paseo-e2e/grouped-project";
const SHARED_REMOTE_URL = "https://github.com/paseo-e2e/grouped-project.git";
const SUBDIRECTORY = path.join("packages", "app");
const REPO_FILES = [{ path: path.join(SUBDIRECTORY, "package.json"), content: "{}\n" }];
interface HostConnection {
serverId: string;
label: string;
port: number;
}
interface ProjectDirectoryScenario {
hosts: HostConnection[];
primaryLabel?: string;
}
interface CreatedProject {
projectId: string;
}
async function createProject(
client: SeedDaemonClient,
input: {
projectPath: string;
serverId: string;
workspaceName: string;
projectName?: string;
},
): Promise<CreatedProject> {
const created = await client.createWorkspace({
source: { kind: "directory", path: input.projectPath },
title: input.workspaceName,
});
if (!created.workspace) {
throw new Error(created.error ?? `Failed to create project on ${input.serverId}`);
}
if (input.projectName) {
await client.renameProject(created.workspace.projectId, input.projectName);
}
return { projectId: created.workspace.projectId };
}
async function removePersistedProjectKeys(host: IsolatedHostDaemon): Promise<void> {
const projectsPath = path.join(host.paseoHome, "projects", "projects.json");
const projects = JSON.parse(await readFile(projectsPath, "utf8")) as Array<
Record<string, unknown>
>;
for (const project of projects) delete project.projectKey;
await writeFile(projectsPath, JSON.stringify(projects));
}
const test = base.extend<{
crossHostProject: ProjectDirectoryScenario;
reconciledCrossHostProject: ProjectDirectoryScenario;
rootAndSubdirectoryProjects: ProjectDirectoryScenario;
crossHostSubdirectoryProject: ProjectDirectoryScenario;
sameHostClones: ProjectDirectoryScenario;
}>({
crossHostProject: async ({ page: _page }, provide) => {
const secondaryHost = await startIsolatedHostDaemon("project-grouping-secondary");
const primaryRepo = await createTempGitRepo("grouped-primary-", {
originUrl: SHARED_REMOTE_URL,
});
const secondaryRepo = await createTempGitRepo("grouped-secondary-", {
originUrl: SHARED_REMOTE_URL,
});
const primaryClient = await connectSeedClient();
const secondaryClient = await connectSeedClient({ port: secondaryHost.port });
let primary: CreatedProject | null = null;
let secondary: CreatedProject | null = null;
try {
primary = await createProject(primaryClient, {
projectPath: primaryRepo.path,
serverId: getServerId(),
workspaceName: "Primary workspace",
projectName: GROUPED_PROJECT_NAME,
});
secondary = await createProject(secondaryClient, {
projectPath: secondaryRepo.path,
serverId: secondaryHost.serverId,
workspaceName: "Secondary workspace",
projectName: GROUPED_PROJECT_NAME,
});
await provide({
primaryLabel: PRIMARY_HOST_LABEL,
hosts: [
{
serverId: secondaryHost.serverId,
label: SECONDARY_HOST_LABEL,
port: secondaryHost.port,
},
],
});
} finally {
if (primary) await primaryClient.removeProject(primary.projectId).catch(() => undefined);
if (secondary)
await secondaryClient.removeProject(secondary.projectId).catch(() => undefined);
await primaryClient.close().catch(() => undefined);
await secondaryClient.close().catch(() => undefined);
await primaryRepo.cleanup().catch(() => undefined);
await secondaryRepo.cleanup().catch(() => undefined);
await secondaryHost.close().catch(() => undefined);
}
},
reconciledCrossHostProject: async ({ page: _page }, provide) => {
const primaryHost = await startIsolatedHostDaemon("project-grouping-legacy-primary");
const secondaryHost = await startIsolatedHostDaemon("project-grouping-legacy-secondary");
const primaryRepo = await createTempGitRepo("grouped-legacy-primary-", {
originUrl: SHARED_REMOTE_URL,
});
const secondaryRepo = await createTempGitRepo("grouped-legacy-secondary-", {
originUrl: SHARED_REMOTE_URL,
});
const primaryClient = await connectSeedClient({ port: primaryHost.port });
const secondaryClient = await connectSeedClient({ port: secondaryHost.port });
try {
await createProject(primaryClient, {
projectPath: primaryRepo.path,
serverId: primaryHost.serverId,
workspaceName: "Recovered primary workspace",
projectName: GROUPED_PROJECT_NAME,
});
await createProject(secondaryClient, {
projectPath: secondaryRepo.path,
serverId: secondaryHost.serverId,
workspaceName: "Recovered secondary workspace",
projectName: GROUPED_PROJECT_NAME,
});
await primaryClient.close();
await secondaryClient.close();
await removePersistedProjectKeys(primaryHost);
await removePersistedProjectKeys(secondaryHost);
await Promise.all([primaryHost.restart(), secondaryHost.restart()]);
await provide({
hosts: [
{
serverId: primaryHost.serverId,
label: LEGACY_PRIMARY_HOST_LABEL,
port: primaryHost.port,
},
{
serverId: secondaryHost.serverId,
label: LEGACY_SECONDARY_HOST_LABEL,
port: secondaryHost.port,
},
],
});
} finally {
await primaryClient.close().catch(() => undefined);
await secondaryClient.close().catch(() => undefined);
await primaryHost.close().catch(() => undefined);
await secondaryHost.close().catch(() => undefined);
await primaryRepo.cleanup().catch(() => undefined);
await secondaryRepo.cleanup().catch(() => undefined);
}
},
rootAndSubdirectoryProjects: async ({ page: _page }, provide) => {
const repo = await createTempGitRepo("grouped-root-subdir-", {
originUrl: SHARED_REMOTE_URL,
files: REPO_FILES,
});
const client = await connectSeedClient();
const projects: CreatedProject[] = [];
try {
projects.push(
await createProject(client, {
projectPath: repo.path,
serverId: getServerId(),
workspaceName: "Repository root workspace",
projectName: "Repository root project",
}),
);
projects.push(
await createProject(client, {
projectPath: path.join(repo.path, SUBDIRECTORY),
serverId: getServerId(),
workspaceName: "App package workspace",
projectName: "App package project",
}),
);
await provide({ hosts: [] });
} finally {
for (const project of projects) {
await client.removeProject(project.projectId).catch(() => undefined);
}
await client.close().catch(() => undefined);
await repo.cleanup().catch(() => undefined);
}
},
crossHostSubdirectoryProject: async ({ page: _page }, provide) => {
const secondaryHost = await startIsolatedHostDaemon("project-grouping-subdir-secondary");
const primaryRepo = await createTempGitRepo("grouped-subdir-primary-", {
originUrl: SHARED_REMOTE_URL,
files: REPO_FILES,
});
const secondaryRepo = await createTempGitRepo("grouped-subdir-secondary-", {
originUrl: SHARED_REMOTE_URL,
files: REPO_FILES,
});
const primaryClient = await connectSeedClient();
const secondaryClient = await connectSeedClient({ port: secondaryHost.port });
let primary: CreatedProject | null = null;
let secondary: CreatedProject | null = null;
try {
primary = await createProject(primaryClient, {
projectPath: path.join(primaryRepo.path, SUBDIRECTORY),
serverId: getServerId(),
workspaceName: "Primary app workspace",
projectName: GROUPED_PROJECT_NAME,
});
secondary = await createProject(secondaryClient, {
projectPath: path.join(secondaryRepo.path, SUBDIRECTORY),
serverId: secondaryHost.serverId,
workspaceName: "Secondary app workspace",
projectName: GROUPED_PROJECT_NAME,
});
await provide({
primaryLabel: PRIMARY_HOST_LABEL,
hosts: [
{
serverId: secondaryHost.serverId,
label: SECONDARY_HOST_LABEL,
port: secondaryHost.port,
},
],
});
} finally {
if (primary) await primaryClient.removeProject(primary.projectId).catch(() => undefined);
if (secondary)
await secondaryClient.removeProject(secondary.projectId).catch(() => undefined);
await primaryClient.close().catch(() => undefined);
await secondaryClient.close().catch(() => undefined);
await primaryRepo.cleanup().catch(() => undefined);
await secondaryRepo.cleanup().catch(() => undefined);
await secondaryHost.close().catch(() => undefined);
}
},
sameHostClones: async ({ page: _page }, provide) => {
const firstRepo = await createTempGitRepo("grouped-clone-first-", {
originUrl: SHARED_REMOTE_URL,
});
const secondRepo = await createTempGitRepo("grouped-clone-second-", {
originUrl: SHARED_REMOTE_URL,
});
const client = await connectSeedClient();
const projects: CreatedProject[] = [];
try {
projects.push(
await createProject(client, {
projectPath: firstRepo.path,
serverId: getServerId(),
workspaceName: "First clone workspace",
projectName: "First clone",
}),
);
projects.push(
await createProject(client, {
projectPath: secondRepo.path,
serverId: getServerId(),
workspaceName: "Second clone workspace",
projectName: "Second clone",
}),
);
await provide({ hosts: [] });
} finally {
for (const project of projects) {
await client.removeProject(project.projectId).catch(() => undefined);
}
await client.close().catch(() => undefined);
await firstRepo.cleanup().catch(() => undefined);
await secondRepo.cleanup().catch(() => undefined);
}
},
});
async function openScenario(
page: Parameters<typeof openProjectDirectory>[0],
scenario: ProjectDirectoryScenario,
): Promise<void> {
if (scenario.hosts.length === 0) {
await openProjectDirectory(page);
return;
}
await openProjectDirectoryWithHosts(page, scenario);
}
test.describe("Sidebar project grouping", () => {
test.describe.configure({ timeout: 120_000 });
test("groups projects with the same Git remote across hosts", async ({
page,
crossHostProject,
}) => {
await openScenario(page, crossHostProject);
await expectProjectContainsWorkspaces(page, {
projectName: GROUPED_PROJECT_NAME,
workspaceNames: ["Primary workspace", "Secondary workspace"],
});
});
test("groups persisted projects missing project keys after boot", async ({
page,
reconciledCrossHostProject,
}) => {
await openScenario(page, reconciledCrossHostProject);
await expectProjectContainsWorkspaces(page, {
projectName: GROUPED_PROJECT_NAME,
workspaceNames: ["Recovered primary workspace", "Recovered secondary workspace"],
});
});
test("keeps a repository root and its subdirectory as separate projects", async ({
page,
rootAndSubdirectoryProjects,
}) => {
await openScenario(page, rootAndSubdirectoryProjects);
await expectSeparateProjects(page, [
{ projectName: "Repository root project", workspaceName: "Repository root workspace" },
{ projectName: "App package project", workspaceName: "App package workspace" },
]);
});
test("groups the same repository subdirectory across hosts", async ({
page,
crossHostSubdirectoryProject,
}) => {
await openScenario(page, crossHostSubdirectoryProject);
await expectProjectContainsWorkspaces(page, {
projectName: GROUPED_PROJECT_NAME,
workspaceNames: ["Primary app workspace", "Secondary app workspace"],
});
});
test("keeps two clones of the same repository on one host separate", async ({
page,
sameHostClones,
}) => {
await openScenario(page, sameHostClones);
await expectSeparateProjects(page, [
{ projectName: "First clone", workspaceName: "First clone workspace" },
{ projectName: "Second clone", workspaceName: "Second clone workspace" },
]);
});
test("renames only the selected host's grouped project", async ({ page, crossHostProject }) => {
await openScenario(page, crossHostProject);
await openGroupedProjectSettings(page, {
serverId: getServerId(),
projectName: GROUPED_PROJECT_NAME,
});
await renameProject(page, "Primary-only project name");
await expectProjectSettingsName(page, "Primary-only project name");
const secondaryHost = crossHostProject.hosts[0];
if (!secondaryHost) throw new Error("Expected a secondary host");
await openProjectsForSettingsHost(page, {
serverId: secondaryHost.serverId,
projectName: GROUPED_PROJECT_NAME,
});
await expectProjectSettingsName(page, GROUPED_PROJECT_NAME);
});
test("creates a workspace on the selected host from a grouped project", async ({
page,
crossHostProject,
}) => {
await openScenario(page, crossHostProject);
await beginWorkspaceFromProject(page, GROUPED_PROJECT_NAME);
await selectWorkspaceHost(page, SECONDARY_HOST_LABEL);
await createWorkspaceWithoutAgent(page);
await expectProjectWorkspaceCountForHost(page, {
projectName: GROUPED_PROJECT_NAME,
hostName: SECONDARY_HOST_LABEL,
count: 2,
});
});
});
+60 -6
View File
@@ -11,6 +11,7 @@ import {
import { seedWorkspace } from "./helpers/seed-client";
import { expectWorkspaceHeader } from "./helpers/workspace-ui";
import { getServerId } from "./helpers/server-id";
import { projectEquivalenceViewKey } from "./helpers/project-view-key";
import { escapeRegex } from "./helpers/regex";
const GITHUB_REMOTE_URL = "https://github.com/test-owner/test-repo.git";
@@ -46,6 +47,51 @@ async function waitForSidebarWorkspace(page: import("@playwright/test").Page, wo
return row;
}
async function openWorkspaceHoverCard(page: import("@playwright/test").Page, workspaceId: string) {
const row = await waitForSidebarWorkspace(page, workspaceId);
await row.hover();
const hoverCard = page.getByRole("menu", { name: "Workspace scripts" });
await expect(hoverCard).toBeVisible({ timeout: 30_000 });
return hoverCard;
}
interface PaseoOwnedWorktree {
projectName: string;
workspaceId: string;
worktreeSlug: string;
}
async function withPaseoOwnedWorktree(
run: (workspace: PaseoOwnedWorktree) => Promise<void>,
): Promise<void> {
const project = await seedWorkspace({ repoPrefix: "sidebar-hover-owned-worktree-" });
const worktreeSlug = "hover-card-owned-worktree";
try {
const created = await project.client.createWorkspace({
source: {
kind: "worktree",
cwd: project.repoPath,
projectId: project.projectId,
worktreeSlug,
},
});
if (!created.workspace) {
throw new Error(created.error ?? "Failed to create Paseo-owned worktree");
}
expect(path.basename(created.workspace.workspaceDirectory)).toBe(worktreeSlug);
await run({
projectName: path.basename(project.repoPath),
workspaceId: created.workspace.id,
worktreeSlug,
});
} finally {
await project.cleanup();
}
}
test.describe("Sidebar workspace list", () => {
test("project with GitHub remote shows its selected folder name in sidebar", async ({ page }) => {
const workspace = await seedWorkspace({
@@ -145,17 +191,23 @@ test.describe("Sidebar workspace list", () => {
await gotoAppShell(page);
await waitForSidebarProject(page, path.basename(workspace.repoPath));
const row = await waitForSidebarWorkspace(page, workspace.workspaceId);
await row.hover();
const hoverCard = page.getByTestId("workspace-hover-card");
await expect(hoverCard).toBeVisible({ timeout: 30_000 });
const hoverCard = await openWorkspaceHoverCard(page, workspace.workspaceId);
await expect(page.getByTestId("hover-card-workspace-host")).toHaveText("localhost");
await expect(hoverCard).not.toContainText(/\b(Online|Connecting|Offline|Error|Idle)\b/);
} finally {
await workspace.cleanup();
}
});
test("Paseo-owned worktree hover card shows the worktree directory name", async ({ page }) => {
await withPaseoOwnedWorktree(async ({ projectName, workspaceId, worktreeSlug }) => {
await gotoAppShell(page);
await waitForSidebarProject(page, projectName);
await openWorkspaceHoverCard(page, workspaceId);
await expect(page.getByTestId("hover-card-workspace-cwd")).toHaveText(worktreeSlug);
});
});
});
test.describe("Mobile sidebar panelState transition", () => {
@@ -219,7 +271,9 @@ test.describe("Half-screen desktop layout", () => {
}
await gotoAppShell(page);
await page.getByTestId(`sidebar-project-show-more-${workspace.projectId}`).click();
await page
.getByTestId(`sidebar-project-show-more-${projectEquivalenceViewKey(workspace.projectKey)}`)
.click();
await waitForSidebarWorkspace(page, lastWorkspaceId);
const sidebarScroll = page.getByTestId("sidebar-project-workspace-list-scroll");
+333
View File
@@ -0,0 +1,333 @@
import { test as base } from "./fixtures";
import { expect } from "@playwright/test";
import { gotoAppShell } from "./helpers/app";
import { injectDesktopBridge } from "./helpers/desktop-updates";
import { getServerId } from "./helpers/server-id";
import {
cancelSkillSelection,
chooseAllSkills,
chooseCustomSkills,
createSkillsSandbox,
expectAllSkillsSelected,
expectNoRemovalWarning,
expectRemovalWarning,
expectSavedSkillSelection,
expectSaveErrorKeepsSheetOpen,
expectSelectedSkills,
expectSkillChoices,
expectSkillFilePresent,
expectSkillPresentIn,
expectSkillSelectionOpen,
expectSkillsInstalled,
openSkillsIntegrations,
openSkillSelection,
saveSkillSelection,
toggleSkill,
serveRealSkillsCommands,
type SkillsSandbox,
type SkillsSandboxOptions,
} from "./helpers/skills-selection";
interface SkillsSandboxSetup extends SkillsSandboxOptions {
/** What the desktop confirm dialog answers when the removal warning fires. */
confirmRemoval?: boolean;
}
type StartSkills = (setup?: SkillsSandboxSetup) => Promise<SkillsSandbox>;
// Every skills command runs against the real desktop handlers over a temp bundle
// and temp user data, so the assertions cover persistence and convergence, not a
// browser stand-in for them.
const test = base.extend<{ startSkills: StartSkills }>({
startSkills: async ({ page }, provide) => {
const sandboxes: SkillsSandbox[] = [];
await provide(async (setup = {}) => {
const { confirmRemoval, ...options } = setup;
const sandbox = await createSkillsSandbox(options);
await injectDesktopBridge(page, {
serverId: getServerId(),
confirmShouldAccept: confirmRemoval ?? false,
});
await serveRealSkillsCommands(page, sandbox);
sandboxes.push(sandbox);
return sandbox;
});
for (const sandbox of sandboxes) {
await sandbox.cleanup();
}
},
});
test.describe("Choosing installed skills", () => {
test("installs only the skills selected in Settings", async ({ page, startSkills }) => {
const skills = await startSkills();
await gotoAppShell(page);
await openSkillsIntegrations(page);
await openSkillSelection(page);
await chooseCustomSkills(page, ["paseo", "paseo-loop"]);
await saveSkillSelection(page);
await expectSkillsInstalled(skills, ["paseo", "paseo-loop"]);
await expectSelectedSkills(page, ["paseo", "paseo-loop"]);
});
test("all skills includes every available skill", async ({ page, startSkills }) => {
const skills = await startSkills();
await gotoAppShell(page);
await openSkillsIntegrations(page);
await openSkillSelection(page);
await chooseCustomSkills(page, ["paseo"]);
await saveSkillSelection(page);
await openSkillSelection(page);
await chooseAllSkills(page);
await saveSkillSelection(page);
await expectSkillsInstalled(skills, skills.bundledSkills);
await expectAllSkillsSelected(page);
});
test("cancelling leaves the installed skills untouched", async ({ page, startSkills }) => {
const skills = await startSkills();
await gotoAppShell(page);
await openSkillsIntegrations(page);
await openSkillSelection(page);
await chooseCustomSkills(page, ["paseo"]);
await saveSkillSelection(page);
await openSkillSelection(page);
await toggleSkill(page, "paseo-loop");
await cancelSkillSelection(page);
await expectSkillsInstalled(skills, ["paseo"]);
await expectSelectedSkills(page, ["paseo"]);
});
test("a failed save keeps the sheet open with an error", async ({ page, startSkills }) => {
const skills = await startSkills({ blockAgentsDir: true });
await gotoAppShell(page);
await openSkillsIntegrations(page);
await openSkillSelection(page);
await chooseCustomSkills(page, ["paseo"]);
await saveSkillSelection(page);
await expectSaveErrorKeepsSheetOpen(page);
await expectSkillsInstalled(skills, []);
});
test("an empty selection does not offer an Install action that cannot install anything", async ({
page,
startSkills,
}) => {
await startSkills({ installed: { mode: "all" }, confirmRemoval: true });
await gotoAppShell(page);
await openSkillsIntegrations(page);
await openSkillSelection(page);
await chooseCustomSkills(page, []);
await saveSkillSelection(page);
// The remaining Install action belongs to the command-line integration.
await expect(page.getByRole("button", { name: "Install", exact: true })).toHaveCount(1);
await expect(page.getByRole("button", { name: "Choose skills", exact: true })).toBeVisible();
});
test("a selection containing only retired skills does not offer a no-op Install", async ({
page,
startSkills,
}) => {
await startSkills({ installed: { mode: "custom", skills: ["retired-skill"] } });
await gotoAppShell(page);
await openSkillsIntegrations(page);
// The remaining Install action belongs to the command-line integration.
await expect(page.getByRole("button", { name: "Install", exact: true })).toHaveCount(1);
await expect(page.getByRole("button", { name: "Choose skills", exact: true })).toBeVisible();
});
test("an empty bundled catalog does not offer a no-op Install", async ({ page, startSkills }) => {
await startSkills({ bundledSkills: [] });
await gotoAppShell(page);
await openSkillsIntegrations(page);
// The remaining Install action belongs to the command-line integration.
await expect(page.getByRole("button", { name: "Install", exact: true })).toHaveCount(1);
await expect(page.getByRole("button", { name: "Choose skills", exact: true })).toBeVisible();
});
test("cannot dismiss the sheet while Save is pending", async ({ page, startSkills }) => {
const skills = await startSkills({ holdSave: true });
await gotoAppShell(page);
await openSkillsIntegrations(page);
await openSkillSelection(page);
await chooseCustomSkills(page, ["paseo"]);
await saveSkillSelection(page);
await skills.waitForHeldSave();
await page.keyboard.press("Escape");
await expectSkillSelectionOpen(page);
skills.releaseHeldSave();
await expect(page.getByRole("switch", { name: "All skills", exact: true })).toHaveCount(0);
});
});
test.describe("Removing an installed skill", () => {
test("delete-only drift stays in Choose skills instead of Update", async ({
page,
startSkills,
}) => {
await startSkills({
installed: { mode: "custom", skills: ["paseo"] },
placeOnDisk: {
skill: "paseo-loop",
target: "claude",
files: { "SKILL.md": "# paseo-loop\n", "notes/mine.md": "my notes" },
},
});
await gotoAppShell(page);
await openSkillsIntegrations(page);
await expect(page.getByText("Update available", { exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Update", exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Choose skills", exact: true })).toBeVisible();
});
test("warns before deleting the skill folders", async ({ page, startSkills }) => {
const skills = await startSkills({ installed: { mode: "all" }, confirmRemoval: false });
await gotoAppShell(page);
await openSkillsIntegrations(page);
await openSkillSelection(page);
await chooseCustomSkills(page, ["paseo", "paseo-advisor"]);
await saveSkillSelection(page);
await expectRemovalWarning(page, ["paseo-loop"]);
await expectSkillSelectionOpen(page);
await expectSkillsInstalled(skills, skills.bundledSkills);
await expectSavedSkillSelection(skills, { mode: "all" });
});
test("confirming the warning removes the skill", async ({ page, startSkills }) => {
const skills = await startSkills({ installed: { mode: "all" }, confirmRemoval: true });
await gotoAppShell(page);
await openSkillsIntegrations(page);
await openSkillSelection(page);
await chooseCustomSkills(page, ["paseo", "paseo-advisor"]);
await saveSkillSelection(page);
await expectRemovalWarning(page, ["paseo-loop"]);
await expectSkillsInstalled(skills, ["paseo", "paseo-advisor"]);
await expectSelectedSkills(page, ["paseo", "paseo-advisor"]);
});
test("warns for a skill installed in only one agent directory", async ({ page, startSkills }) => {
const skills = await startSkills({
installed: { mode: "all" },
keepOnlyIn: { skill: "paseo-loop", target: "claude" },
confirmRemoval: false,
});
await gotoAppShell(page);
await openSkillsIntegrations(page);
await openSkillSelection(page);
await chooseCustomSkills(page, ["paseo", "paseo-advisor"]);
await saveSkillSelection(page);
await expectRemovalWarning(page, ["paseo-loop"]);
await expectSkillSelectionOpen(page);
await expectSkillPresentIn(skills, "claude", "paseo-loop");
await expectSavedSkillSelection(skills, { mode: "all" });
});
test("warns for a skill on disk that the saved selection never mentioned", async ({
page,
startSkills,
}) => {
// The committed preference excludes paseo-loop, but the directory is back —
// a manual reinstall or an external sync. Saving any edit deletes it.
const skills = await startSkills({
installed: { mode: "custom", skills: ["paseo"] },
placeOnDisk: {
skill: "paseo-loop",
target: "claude",
files: { "SKILL.md": "# paseo-loop\n", "notes/mine.md": "my notes" },
},
confirmRemoval: false,
});
await gotoAppShell(page);
await openSkillsIntegrations(page);
await openSkillSelection(page);
await toggleSkill(page, "paseo-advisor");
await saveSkillSelection(page);
await expectRemovalWarning(page, ["paseo-loop"]);
await expectSkillSelectionOpen(page);
await expectSkillFilePresent(skills, "claude", "paseo-loop", "notes/mine.md");
await expectSavedSkillSelection(skills, { mode: "custom", skills: ["paseo"] });
});
test("warns about a directory that appears after the sheet was opened", async ({
page,
startSkills,
}) => {
const skills = await startSkills({
installed: { mode: "custom", skills: ["paseo"] },
confirmRemoval: false,
});
await gotoAppShell(page);
await openSkillsIntegrations(page);
await openSkillSelection(page);
// Nothing to delete when the sheet opened; the host looks again at save time.
await skills.placeSkillOnDisk("paseo-loop", "claude");
await toggleSkill(page, "paseo-advisor");
await saveSkillSelection(page);
await expectRemovalWarning(page, ["paseo-loop"]);
await expectSkillSelectionOpen(page);
await expectSkillPresentIn(skills, "claude", "paseo-loop");
await expectSavedSkillSelection(skills, { mode: "custom", skills: ["paseo"] });
});
test("keeps draft choices when fresher status arrives while the sheet is open", async ({
page,
startSkills,
}) => {
const skills = await startSkills({ installed: { mode: "all" }, confirmRemoval: false });
await gotoAppShell(page);
await openSkillsIntegrations(page);
await openSkillSelection(page);
await chooseCustomSkills(page, ["paseo", "paseo-advisor"]);
// Another window commits a different selection. Saving picks that up, so the
// sheet is handed a selection it did not start from while an edit is open.
await skills.commitSelectionExternally({ mode: "custom", skills: ["paseo", "paseo-loop"] }, [
"paseo-advisor",
]);
await saveSkillSelection(page);
await expectRemovalWarning(page, ["paseo-loop"]);
await expectSkillChoices(page, ["paseo", "paseo-advisor"]);
});
test("adding a skill saves without a warning", async ({ page, startSkills }) => {
const skills = await startSkills({
installed: { mode: "custom", skills: ["paseo"] },
confirmRemoval: false,
});
await gotoAppShell(page);
await openSkillsIntegrations(page);
await openSkillSelection(page);
await toggleSkill(page, "paseo-loop");
await saveSkillSelection(page);
await expectSkillsInstalled(skills, ["paseo", "paseo-loop"]);
await expectNoRemovalWarning(page);
});
});
@@ -32,8 +32,12 @@ interface RestartDaemonClient {
name: string;
status: string;
workspaceDirectory: string;
projectId: string;
}>;
}>;
listProjects(): Promise<{
projects: Array<{ projectId: string; projectKey?: string }>;
}>;
fetchAgents(options?: { scope?: "active" }): Promise<{
entries: Array<{
agent: {
@@ -461,9 +465,16 @@ test.describe("Workspace model restart regressions", () => {
.poll(() => getVisibleWorkspaceAgentTabIds(page), { timeout: 30_000 })
.toContain(`workspace-tab-agent_${LEGACY_AGENT_ID}`);
const reconciledProjectKey = (await client.listProjects()).projects.find(
(project) => project.projectId === seeded.projectId,
);
if (!reconciledProjectKey?.projectKey) {
throw new Error(`Workspace ${seeded.workspaceA} was not reconciled with a project key`);
}
await openGlobalNewWorkspaceComposer(page);
await selectNewWorkspaceProject(page, {
projectKey: seeded.projectId,
projectKey: reconciledProjectKey.projectKey,
projectDisplayName: seeded.projectDisplayName,
});
await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName);
@@ -12,6 +12,7 @@ import {
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
import { expectExplorerEntryVisible } from "./helpers/file-explorer";
import { getServerId } from "./helpers/server-id";
import { projectEquivalenceViewKey } from "./helpers/project-view-key";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
// Model B reshape: a workspace is the unit, its isolation (local checkout or
@@ -84,7 +85,7 @@ test.describe("Workspace multiplicity creation flow", () => {
try {
const project = {
projectKey: seeded.projectId,
projectKey: seeded.projectKey,
projectDisplayName: seeded.projectDisplayName,
};
@@ -136,7 +137,7 @@ test.describe("Workspace multiplicity creation flow", () => {
try {
const project = {
projectKey: seeded.projectId,
projectKey: seeded.projectKey,
projectDisplayName: seeded.projectDisplayName,
};
@@ -182,7 +183,7 @@ test.describe("Workspace multiplicity creation flow", () => {
try {
const project = {
projectKey: seeded.projectId,
projectKey: seeded.projectKey,
projectDisplayName: seeded.projectDisplayName,
};
@@ -190,9 +191,9 @@ test.describe("Workspace multiplicity creation flow", () => {
await waitForSidebarHydration(page);
// Model B: a non-git project is an expandable parent like any other, with
// its single workspace already rendered as its own row underneath.
await expect(page.getByTestId(`sidebar-project-row-${seeded.projectId}`)).toBeVisible({
timeout: 30_000,
});
await expect(
page.getByTestId(`sidebar-project-row-${projectEquivalenceViewKey(seeded.projectKey)}`),
).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId(workspaceRowTestId(seeded.workspaceId))).toBeVisible({
timeout: 30_000,
});
@@ -210,9 +211,9 @@ test.describe("Workspace multiplicity creation flow", () => {
// Both the original and the new workspace render as distinct rows under
// the same expandable parent.
await expect(page.getByTestId(`sidebar-project-row-${seeded.projectId}`)).toBeVisible({
timeout: 30_000,
});
await expect(
page.getByTestId(`sidebar-project-row-${projectEquivalenceViewKey(seeded.projectKey)}`),
).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId(workspaceRowTestId(seeded.workspaceId))).toBeVisible({
timeout: 30_000,
});
-2
View File
@@ -7,7 +7,6 @@
"development": {
"developmentClient": true,
"distribution": "internal",
"channel": "development",
"env": {
"APP_VARIANT": "development"
},
@@ -16,7 +15,6 @@
}
},
"production": {
"channel": "production",
"env": {
"APP_VARIANT": "production"
},
+25
View File
@@ -3,6 +3,31 @@
This directory contains local mobile UI flows. Keep flows small enough that a
failure screenshot proves the intended behavior, not just that the app launched.
## iOS Sidebar Close Regression
`ios-sidebar-close-regression.yaml` exercises close swipes over a semantic header
and the nested workspace list without activating the content below the swipe.
Start the `sh.paseo.debug` dev client against the intended Metro server first;
the flow preserves that running connection.
```bash
maestro test packages/app/maestro/ios-sidebar-close-regression.yaml --udid <simulator-udid>
```
## iOS Sidebar Drag Cancellation Regression
`test-sidebar-drag-cancellation-ios.sh` creates three temporary projects and
uses real native slow swipes to cover successful reorder, a second touch during
the drop spring, horizontal close cancellation, outer-list scroll recovery, and
a subsequent reorder. The harness removes its daemon projects and local fixture
directories on exit.
Start the debug dev client against the intended Metro server, then run:
```bash
bash packages/app/maestro/test-sidebar-drag-cancellation-ios.sh
```
## New Workspace Android Flow
Use these files when debugging or extending workspace creation on Android:
@@ -0,0 +1,40 @@
appId: sh.paseo.debug
---
# Establish History with the sidebar closed. The host filter then proves a close
# swipe did not activate the row below it.
- tapOn:
id: "menu-button"
- tapOn:
id: "sidebar-sessions"
- assertVisible:
id: "sessions-host-filter-trigger"
# A close swipe over a semantic header row must close without navigating.
- swipe:
direction: RIGHT
duration: 300
- assertVisible:
id: "sidebar-schedules"
- swipe:
start: "85%,19%"
end: "10%,19%"
duration: 300
- assertNotVisible:
id: "sidebar-schedules"
- assertVisible:
id: "sessions-host-filter-trigger"
# A close swipe over the nested workspace list must beat horizontal list intent.
- swipe:
direction: RIGHT
duration: 300
- assertVisible:
id: "sidebar-project-workspace-list-scroll"
- swipe:
start: "85%,46%"
end: "10%,46%"
duration: 300
- assertNotVisible:
id: "sidebar-project-workspace-list-scroll"
- assertVisible:
id: "sessions-host-filter-trigger"
@@ -0,0 +1,105 @@
appId: ${PASEO_MAESTRO_APP_ID}
---
- launchApp:
clearState: true
- runFlow: flows/dev-client.yaml
- runFlow: flows/connect-direct-if-welcome.yaml
- extendedWaitUntil:
visible:
id: "menu-button"
timeout: 60000
- tapOn:
id: "menu-button"
- extendedWaitUntil:
visible:
id: "sidebar-project-list"
timeout: 10000
- extendedWaitUntil:
visible:
text: ".*${PASEO_MAESTRO_DRAG_A_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_A_NAME}, Project actions"
timeout: 30000
- extendedWaitUntil:
visible:
text: ".*${PASEO_MAESTRO_DRAG_B_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_B_NAME}, Project actions"
timeout: 30000
# A slow swipe stays inside the stationary slop long enough to arm the native
# long-press drag before it moves the second project above the first.
- assertVisible:
text: ".*${PASEO_MAESTRO_DRAG_A_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_A_NAME}, Project actions"
above:
text: ".*${PASEO_MAESTRO_DRAG_B_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_B_NAME}, Project actions"
- swipe:
from:
text: ".*${PASEO_MAESTRO_DRAG_B_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_B_NAME}, Project actions"
direction: UP
duration: 20000
waitToSettleTimeoutMs: 0
# A second touch during the drop spring must not cancel the committed reorder.
- tapOn:
text: ".*${PASEO_MAESTRO_DRAG_B_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_B_NAME}, Project actions"
- assertVisible:
text: ".*${PASEO_MAESTRO_DRAG_B_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_B_NAME}, Project actions"
above:
text: ".*${PASEO_MAESTRO_DRAG_A_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_A_NAME}, Project actions"
# Horizontal intent after the lift belongs to panel close. It must cancel the
# drag without committing another reorder or leaving the outer list locked.
- swipe:
from:
text: ".*${PASEO_MAESTRO_DRAG_B_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_B_NAME}, Project actions"
direction: LEFT
duration: 20000
- assertNotVisible:
id: "sidebar-project-list"
- tapOn:
id: "menu-button"
- extendedWaitUntil:
visible:
id: "sidebar-project-list"
timeout: 10000
- assertVisible:
text: ".*${PASEO_MAESTRO_DRAG_B_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_B_NAME}, Project actions"
above:
text: ".*${PASEO_MAESTRO_DRAG_A_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_A_NAME}, Project actions"
# Reaching a fixture at the bottom proves the nestable outer scroll was
# unlocked by cancellation.
- scrollUntilVisible:
element:
text: ".*${PASEO_MAESTRO_DRAG_Z_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_Z_NAME}, Project actions"
direction: DOWN
timeout: 30000
speed: 40
visibilityPercentage: 50
centerElement: true
- assertVisible:
text: ".*${PASEO_MAESTRO_DRAG_Z_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_Z_NAME}, Project actions"
- scrollUntilVisible:
element:
text: ".*${PASEO_MAESTRO_DRAG_A_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_A_NAME}, Project actions"
direction: UP
timeout: 30000
speed: 40
visibilityPercentage: 50
centerElement: true
# A real reorder after cancellation proves the list is not merely scrollable;
# its drag recognizer and semantic order update both recovered.
- swipe:
from:
text: ".*${PASEO_MAESTRO_DRAG_A_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_A_NAME}, Project actions"
direction: UP
duration: 20000
- assertVisible:
text: ".*${PASEO_MAESTRO_DRAG_A_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_A_NAME}, Project actions"
above:
text: ".*${PASEO_MAESTRO_DRAG_B_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_B_NAME}, Project actions"
+170
View File
@@ -0,0 +1,170 @@
#!/usr/bin/env bash
# iOS native regression harness for sidebar drag cancellation and recovery.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
FLOW_TEMPLATE="$REPO_ROOT/packages/app/maestro/sidebar-drag-cancellation-regression.yaml"
FLOW_TEMPLATE_DIR="$REPO_ROOT/packages/app/maestro"
OUT_DIR="/tmp/paseo-sidebar-drag-cancellation-$(date +%s)"
CLIENT_EXPORTS="$REPO_ROOT/packages/client/dist/daemon-client.js"
RELAY_EXPORTS="$REPO_ROOT/node_modules/@getpaseo/relay/dist/e2ee.js"
FIXTURE_ROOT=""
PROJECT_IDS_FILE="$OUT_DIR/project-ids.json"
export PASEO_MAESTRO_APP_ID="${PASEO_MAESTRO_APP_ID:-sh.paseo.debug}"
export PASEO_MAESTRO_DIRECT_ENDPOINT="${PASEO_MAESTRO_DIRECT_ENDPOINT:-127.0.0.1:6767}"
export PASEO_MAESTRO_DAEMON_WS_URL="${PASEO_MAESTRO_DAEMON_WS_URL:-ws://127.0.0.1:6767/ws}"
export PASEO_MAESTRO_DAEMON_HEALTH_URL="${PASEO_MAESTRO_DAEMON_HEALTH_URL:-http://127.0.0.1:6767/api/health}"
require_command() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "Missing required command: $1" >&2
exit 1
fi
}
require_command git
require_command curl
require_command maestro
require_command node
require_command perl
require_command xcrun
if [ ! -f "$CLIENT_EXPORTS" ] || [ ! -f "$RELAY_EXPORTS" ]; then
echo "Missing client or relay build artifacts." >&2
echo "Run: npm run build:server" >&2
exit 1
fi
if ! curl --fail --silent --show-error --max-time 3 "$PASEO_MAESTRO_DAEMON_HEALTH_URL" >/dev/null; then
echo "Paseo daemon is unavailable at $PASEO_MAESTRO_DAEMON_HEALTH_URL" >&2
exit 1
fi
FIXTURE_ROOT="$(mktemp -d /tmp/paseo-sidebar-drag-fixture-XXXXXX)"
export PASEO_MAESTRO_DRAG_A_NAME="000-paseo-drag-a-$(basename "$FIXTURE_ROOT")"
export PASEO_MAESTRO_DRAG_B_NAME="001-paseo-drag-b-$(basename "$FIXTURE_ROOT")"
export PASEO_MAESTRO_DRAG_Z_NAME="zzz-paseo-drag-z-$(basename "$FIXTURE_ROOT")"
mkdir -p "$OUT_DIR/flows"
if [ -z "${PASEO_MAESTRO_IOS_UDID:-}" ]; then
export PASEO_MAESTRO_IOS_UDID
PASEO_MAESTRO_IOS_UDID="$({ xcrun simctl list devices booted -j || true; } | node -e '
let input = "";
process.stdin.on("data", (chunk) => (input += chunk));
process.stdin.on("end", () => {
const devices = Object.values(JSON.parse(input).devices ?? {}).flat();
const booted = devices.find((device) => device.state === "Booted");
if (booted?.udid) process.stdout.write(booted.udid);
});
')"
fi
if [ -z "$PASEO_MAESTRO_IOS_UDID" ]; then
echo "No booted iOS simulator found." >&2
exit 1
fi
render_flow() {
local source="$1"
local target="$2"
perl -0pe '
s/^appId: sh\.paseo$/appId: $ENV{PASEO_MAESTRO_APP_ID}/m;
s/\$\{PASEO_MAESTRO_APP_ID\}/$ENV{PASEO_MAESTRO_APP_ID}/g;
s/\$\{PASEO_MAESTRO_DIRECT_ENDPOINT\}/$ENV{PASEO_MAESTRO_DIRECT_ENDPOINT}/g;
s/\$\{PASEO_MAESTRO_DRAG_A_NAME\}/$ENV{PASEO_MAESTRO_DRAG_A_NAME}/g;
s/\$\{PASEO_MAESTRO_DRAG_B_NAME\}/$ENV{PASEO_MAESTRO_DRAG_B_NAME}/g;
s/\$\{PASEO_MAESTRO_DRAG_Z_NAME\}/$ENV{PASEO_MAESTRO_DRAG_Z_NAME}/g;
' "$source" > "$target"
}
for project_name in \
"$PASEO_MAESTRO_DRAG_A_NAME" \
"$PASEO_MAESTRO_DRAG_B_NAME" \
"$PASEO_MAESTRO_DRAG_Z_NAME"; do
project_path="$FIXTURE_ROOT/$project_name"
mkdir -p "$project_path"
git -C "$project_path" init >/dev/null
git -C "$project_path" checkout -b main >/dev/null 2>&1 || true
git -C "$project_path" config user.name "Paseo Maestro"
git -C "$project_path" config user.email "maestro@getpaseo.local"
printf '# Sidebar drag cancellation fixture\n' > "$project_path/README.md"
git -C "$project_path" add README.md
git -C "$project_path" commit -m "Initial commit" >/dev/null
done
cleanup() {
if [ -s "$PROJECT_IDS_FILE" ]; then
REPO_ROOT="$REPO_ROOT" PROJECT_IDS_FILE="$PROJECT_IDS_FILE" node --input-type=module <<'NODE' || true
import { readFile } from "node:fs/promises";
import { pathToFileURL } from "node:url";
import WebSocket from "ws";
const moduleUrl = pathToFileURL(`${process.env.REPO_ROOT}/packages/client/dist/daemon-client.js`).href;
const { DaemonClient } = await import(moduleUrl);
const projectIds = JSON.parse(await readFile(process.env.PROJECT_IDS_FILE, "utf8"));
const client = new DaemonClient({
url: process.env.PASEO_MAESTRO_DAEMON_WS_URL,
clientId: `maestro-sidebar-drag-cleanup-${Date.now()}`,
clientType: "cli",
webSocketFactory: (url, options) => new WebSocket(url, { headers: options?.headers }),
});
try {
await client.connect();
for (const projectId of projectIds) {
await client.removeProject(projectId).catch(() => undefined);
}
} finally {
await client.close().catch(() => undefined);
}
NODE
fi
rm -rf "$FIXTURE_ROOT"
}
trap cleanup EXIT
REPO_ROOT="$REPO_ROOT" FIXTURE_ROOT="$FIXTURE_ROOT" PROJECT_IDS_FILE="$PROJECT_IDS_FILE" node --input-type=module <<'NODE'
import { writeFile } from "node:fs/promises";
import { pathToFileURL } from "node:url";
import WebSocket from "ws";
const moduleUrl = pathToFileURL(`${process.env.REPO_ROOT}/packages/client/dist/daemon-client.js`).href;
const { DaemonClient } = await import(moduleUrl);
const projectNames = [
process.env.PASEO_MAESTRO_DRAG_A_NAME,
process.env.PASEO_MAESTRO_DRAG_B_NAME,
process.env.PASEO_MAESTRO_DRAG_Z_NAME,
];
const client = new DaemonClient({
url: process.env.PASEO_MAESTRO_DAEMON_WS_URL,
clientId: `maestro-sidebar-drag-setup-${Date.now()}`,
clientType: "cli",
webSocketFactory: (url, options) => new WebSocket(url, { headers: options?.headers }),
});
try {
await client.connect();
const projectIds = [];
for (const projectName of projectNames) {
const payload = await client.addProject(`${process.env.FIXTURE_ROOT}/${projectName}`);
if (payload.error || !payload.project) {
throw new Error(payload.error ?? `addProject returned no project for ${projectName}`);
}
projectIds.push(payload.project.id);
await writeFile(process.env.PROJECT_IDS_FILE, JSON.stringify(projectIds));
}
} finally {
await client.close().catch(() => undefined);
}
NODE
FLOW="$OUT_DIR/sidebar-drag-cancellation-regression.rendered.yaml"
render_flow "$FLOW_TEMPLATE" "$FLOW"
render_flow "$FLOW_TEMPLATE_DIR/flows/dev-client.yaml" "$OUT_DIR/flows/dev-client.yaml"
render_flow "$FLOW_TEMPLATE_DIR/flows/connect-direct-if-welcome.yaml" "$OUT_DIR/flows/connect-direct-if-welcome.yaml"
echo "Running sidebar drag cancellation regression on $PASEO_MAESTRO_IOS_UDID"
echo "Artifacts: $OUT_DIR"
(cd "$OUT_DIR" && maestro test "$FLOW" --udid "$PASEO_MAESTRO_IOS_UDID")
+1 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.2.3",
"version": "0.2.5",
"private": true,
"main": "index.ts",
"scripts": {
@@ -87,7 +87,6 @@
"expo-sharing": "^14.0.8",
"expo-splash-screen": "~31.0.10",
"expo-system-ui": "~6.0.7",
"expo-updates": "~29.0.12",
"fast-deep-equal": "^3.1.3",
"htmlparser2": "^12.0.0",
"i18next": "^26.3.0",
@@ -14,7 +14,6 @@ export interface GithubRepositoryChoice {
cloneUrl: string;
cloneProtocol?: "https" | "ssh";
description: string | null;
visibility: string | null;
updatedAt: string | null;
}
@@ -101,7 +101,6 @@ export function buildManualGithubRepositoryChoices(query: string): GithubReposit
nameWithOwner: identity?.repo ?? remoteName,
cloneUrl: repo,
description: "Clone this repository URL",
visibility: null,
updatedAt: null,
},
];
@@ -116,7 +115,6 @@ export function buildManualGithubRepositoryChoices(query: string): GithubReposit
cloneUrl: nameWithOwner,
cloneProtocol,
description: `Clone owner/repo via ${cloneProtocol.toUpperCase()}`,
visibility: null,
updatedAt: null,
}));
}
+65
View File
@@ -0,0 +1,65 @@
import type { ComponentType } from "react";
import {
Bot,
Brain,
ListTodo,
Settings2,
Shield,
ShieldAlert,
ShieldCheck,
ShieldEllipsis,
ShieldOff,
ShieldPlus,
ShieldQuestionMark,
Zap,
} from "lucide-react-native";
import { getModeVisuals, type AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
export interface AgentControlIconProps {
size: number;
color: string;
}
export type AgentControlIcon = ComponentType<AgentControlIconProps>;
export const ThinkingIcon = Brain;
export const PlanModeIcon = ListTodo;
const MODE_ICONS: Record<string, AgentControlIcon> = {
Bot,
Shield,
ShieldAlert,
ShieldCheck,
ShieldEllipsis,
ShieldOff,
ShieldPlus,
ShieldQuestionMark,
};
const FEATURE_ICONS: Record<string, AgentControlIcon> = {
"list-todo": ListTodo,
"shield-check": ShieldCheck,
zap: Zap,
};
export function getAgentModeIcon(
provider: string,
modeId: string,
providerDefinitions: AgentProviderDefinition[],
): AgentControlIcon {
const icon = getModeVisuals(provider, modeId, providerDefinitions)?.icon;
return (icon ? MODE_ICONS[icon] : undefined) ?? Bot;
}
export function getAgentModeOptionIcon(
provider: string,
modeId: string,
providerDefinitions: AgentProviderDefinition[],
): AgentControlIcon | undefined {
const icon = getModeVisuals(provider, modeId, providerDefinitions)?.icon;
return icon ? MODE_ICONS[icon] : undefined;
}
export function getAgentFeatureIcon(icon?: string): AgentControlIcon {
return (icon ? FEATURE_ICONS[icon] : undefined) ?? Settings2;
}
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { formatAgentModeLabel, formatThinkingOptionLabel } from "./labels";
describe("formatAgentModeLabel", () => {
it("sentence-cases provider mode labels", () => {
expect(formatAgentModeLabel({ id: "plan", label: "Plan" })).toBe("Plan");
expect(formatAgentModeLabel({ id: "full-access", label: "Full Access" })).toBe("Full access");
expect(formatAgentModeLabel({ id: "auto-review", label: "Auto-review" })).toBe("Auto-review");
expect(formatAgentModeLabel({ id: "read_only", label: "read_only" })).toBe("Read only");
expect(formatAgentModeLabel({ id: "acceptEdits", label: "acceptEdits" })).toBe("Accept edits");
});
it("splits compact mode ids when no provider label is available", () => {
expect(formatAgentModeLabel({ id: "auto-review" })).toBe("Auto review");
});
});
describe("formatThinkingOptionLabel", () => {
it("formats compact thinking option labels for display", () => {
expect(formatThinkingOptionLabel({ id: "none", label: "none" })).toBe("None");
expect(formatThinkingOptionLabel({ id: "low", label: "low" })).toBe("Low");
expect(formatThinkingOptionLabel({ id: "medium", label: "medium" })).toBe("Medium");
expect(formatThinkingOptionLabel({ id: "high", label: "high" })).toBe("High");
expect(formatThinkingOptionLabel({ id: "xhigh", label: "xhigh" })).toBe("Extra high");
});
it("sentence-cases split provider labels", () => {
expect(formatThinkingOptionLabel({ id: "extra_high", label: "extra_high" })).toBe("Extra high");
expect(formatThinkingOptionLabel({ id: "think-hard", label: "think-hard" })).toBe("Think hard");
expect(formatThinkingOptionLabel({ id: "xhigh", label: "XHigh" })).toBe("Extra high");
});
});
+45
View File
@@ -0,0 +1,45 @@
import { i18n } from "@/i18n/i18next";
interface ControlLabelInput {
id: string;
label?: string | null;
}
function sentenceCase(value: string): string {
if (!value) {
return value;
}
return value.charAt(0).toUpperCase() + value.slice(1).toLowerCase();
}
function splitCompactLabel(value: string, splitHyphen: boolean): string {
const separatorPattern = splitHyphen ? /[_-]+/g : /_+/g;
return value
.replace(separatorPattern, " ")
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
.replace(/\s+/g, " ")
.trim();
}
function formatControlLabel(option: ControlLabelInput, splitHyphen: boolean): string {
const rawLabel = (option.label ?? option.id).trim();
return sentenceCase(splitCompactLabel(rawLabel, splitHyphen));
}
export function formatAgentModeLabel(mode: ControlLabelInput): string {
return formatControlLabel(mode, mode.label == null);
}
export function formatThinkingOptionLabel(option: ControlLabelInput): string {
const rawLabel = (option.label ?? option.id).trim();
const compactId = option.id.replace(/[\s_-]+/g, "").toLowerCase();
const compactLabel = rawLabel.replace(/[\s_-]+/g, "").toLowerCase();
if (compactId === "xhigh" || compactLabel === "xhigh") {
return i18n.t("agentControls.thinking.extraHigh");
}
return formatControlLabel(option, true);
}
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import type { AgentMode } from "@getpaseo/protocol/agent-types";
import { isPlanningAgentMode, resolveNonPlanningModeId } from "./policy";
describe("isPlanningAgentMode", () => {
it("prefers planning metadata and recognizes existing provider ids", () => {
expect(isPlanningAgentMode({ id: "research", colorTier: "planning" })).toBe(true);
expect(isPlanningAgentMode({ id: "plan" })).toBe(true);
expect(
isPlanningAgentMode({
id: "https://agentclientprotocol.com/protocol/session-modes#plan",
}),
).toBe(true);
expect(isPlanningAgentMode({ id: "default", colorTier: "safe" })).toBe(false);
});
});
describe("resolveNonPlanningModeId", () => {
const modes = [
{ id: "plan", label: "Plan", colorTier: "planning" },
{ id: "default", label: "Default", colorTier: "safe" },
{ id: "full", label: "Full", colorTier: "dangerous" },
] satisfies AgentMode[];
it("uses a non-planning provider default", () => {
expect(resolveNonPlanningModeId(modes, "full")).toBe("full");
});
it("does not use a planning or stale provider default", () => {
expect(resolveNonPlanningModeId(modes, "plan")).toBe("default");
expect(resolveNonPlanningModeId(modes, "deleted")).toBe("default");
});
it("returns null when no non-planning mode exists", () => {
expect(resolveNonPlanningModeId([modes[0]], "plan")).toBeNull();
});
});
+19
View File
@@ -0,0 +1,19 @@
import type { AgentMode } from "@getpaseo/protocol/agent-types";
export const PLAN_MODE_FEATURE_ID = "plan_mode";
export const FAST_MODE_FEATURE_ID = "fast_mode";
export function isPlanningAgentMode(mode: Pick<AgentMode, "id" | "colorTier">): boolean {
return mode.colorTier === "planning" || mode.id === "plan" || mode.id.endsWith("#plan");
}
export function resolveNonPlanningModeId(
modes: readonly AgentMode[],
defaultModeId: string | null,
): string | null {
const defaultMode = modes.find((mode) => mode.id === defaultModeId);
if (defaultMode && !isPlanningAgentMode(defaultMode)) {
return defaultMode.id;
}
return modes.find((mode) => !isPlanningAgentMode(mode))?.id ?? null;
}
+2 -2
View File
@@ -882,8 +882,6 @@ function RootStack() {
<Stack.Screen name="welcome" />
<Stack.Screen name="settings/index" />
<Stack.Screen name="settings/[section]" />
<Stack.Screen name="settings/projects/index" />
<Stack.Screen name="settings/projects/[projectKey]" />
<Stack.Screen name="new" />
<Stack.Screen name="open-project" />
<Stack.Screen name="sessions" />
@@ -893,6 +891,8 @@ function RootStack() {
<Stack.Screen name="h/[serverId]" />
<Stack.Screen name="settings/hosts/[serverId]/index" />
<Stack.Screen name="settings/hosts/[serverId]/[hostSection]" />
<Stack.Screen name="settings/hosts/[serverId]/projects/index" />
<Stack.Screen name="settings/hosts/[serverId]/projects/[projectId]" />
</ThemedStack>
);
}
@@ -0,0 +1,24 @@
import { useLocalSearchParams } from "expo-router";
import { useMemo } from "react";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import SettingsScreen from "@/screens/settings-screen";
import { normalizeProjectSettingsRouteId } from "@/utils/host-routes";
export default function SettingsHostProjectDetailRoute() {
const params = useLocalSearchParams<{
serverId?: string | string[];
projectId?: string | string[];
}>();
const serverId = normalizeProjectSettingsRouteId(params.serverId);
const projectId = normalizeProjectSettingsRouteId(params.projectId);
const view = useMemo(
() => ({ kind: "project" as const, serverId, projectId }),
[projectId, serverId],
);
return (
<HostRouteBootstrapBoundary>
<SettingsScreen view={view} />
</HostRouteBootstrapBoundary>
);
}
@@ -0,0 +1,20 @@
import { useLocalSearchParams } from "expo-router";
import { useMemo } from "react";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import SettingsScreen from "@/screens/settings-screen";
import { normalizeProjectSettingsRouteId } from "@/utils/host-routes";
export default function SettingsHostProjectsRoute() {
const params = useLocalSearchParams<{ serverId?: string | string[] }>();
const serverId = normalizeProjectSettingsRouteId(params.serverId);
const view = useMemo(
() => ({ kind: "host" as const, serverId, section: "projects" as const }),
[serverId],
);
return (
<HostRouteBootstrapBoundary>
<SettingsScreen view={view} />
</HostRouteBootstrapBoundary>
);
}
@@ -1,12 +0,0 @@
import { useLocalSearchParams } from "expo-router";
import { useMemo } from "react";
import SettingsScreen from "@/screens/settings-screen";
export default function SettingsProjectDetailRoute() {
const params = useLocalSearchParams<{ projectKey?: string | string[] }>();
const rawProjectKey = Array.isArray(params.projectKey) ? params.projectKey[0] : params.projectKey;
const projectKey = typeof rawProjectKey === "string" ? decodeURIComponent(rawProjectKey) : "";
const view = useMemo(() => ({ kind: "project" as const, projectKey }), [projectKey]);
return <SettingsScreen view={view} />;
}
@@ -1,7 +0,0 @@
import SettingsScreen from "@/screens/settings-screen";
const PROJECTS_VIEW = { kind: "projects" as const };
export default function SettingsProjectsIndexRoute() {
return <SettingsScreen view={PROJECTS_VIEW} />;
}
@@ -0,0 +1,400 @@
import { describe, expect, it } from "vitest";
import type { AgentFeature, AgentMode, AgentSelectOption } from "@getpaseo/protocol/agent-types";
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
import type { CommandCenterContribution } from "./contributions";
import {
buildAgentControlContributions,
type AgentControlContributionSource,
} from "./agent-control-contributions";
function TestIcon() {
return null;
}
const LABELS: AgentControlContributionSource["labels"] = {
model: "Model",
thinking: "Thinking",
mode: "Mode",
planMode: "Plan mode",
fast: "Fast",
on: "On",
off: "Off",
modelKeywords: "model switch",
thinkingKeywords: "reasoning effort",
modeKeywords: "access permission",
planKeywords: "plan planning",
fastKeywords: "fast speed",
};
const ICONS: AgentControlContributionSource["icons"] = {
provider: () => TestIcon,
thinking: TestIcon,
planMode: TestIcon,
mode: () => TestIcon,
feature: () => TestIcon,
};
function provider(input: {
id: string;
label: string;
state?: "models" | "error";
}): ProviderSelectorProvider {
if (input.state === "error") {
return {
id: input.id,
label: input.label,
modelSelection: { kind: "error", message: "Unavailable" },
};
}
return {
id: input.id,
label: input.label,
modelSelection: {
kind: "models",
rows: [
{
favoriteKey: `${input.id}:shared`,
provider: input.id,
providerLabel: input.label,
modelId: "shared",
modelLabel: `${input.label} model`,
description: undefined,
},
],
},
};
}
function toggleFeature(id: string, value = false, icon?: string): AgentFeature {
return { type: "toggle", id, label: id, value, icon };
}
function makeSource(
overrides: {
provider?: string | null;
models?: Partial<AgentControlContributionSource["models"]>;
thinking?: Partial<AgentControlContributionSource["thinking"]>;
modes?: Partial<NonNullable<AgentControlContributionSource["modes"]>>;
features?: Partial<AgentControlContributionSource["features"]>;
} = {},
): AgentControlContributionSource {
return {
serverId: "host",
ownerKey: "agent",
provider: overrides.provider === undefined ? "claude" : overrides.provider,
labels: LABELS,
icons: ICONS,
models: {
providers: [],
selectedProvider: null,
selectedModelId: null,
select: () => undefined,
...overrides.models,
},
thinking: {
options: [],
selectedId: null,
select: () => undefined,
...overrides.thinking,
},
modes: {
options: [],
selectedId: null,
defaultModeId: null,
select: () => undefined,
...overrides.modes,
},
features: {
list: [],
set: () => undefined,
...overrides.features,
},
};
}
function selected(contribution: CommandCenterContribution): boolean {
return contribution.presentation.kind === "choice" ? contribution.presentation.selected : false;
}
const THINKING_OPTIONS: AgentSelectOption[] = [
{ id: "low", label: "Low" },
{ id: "high", label: "High" },
{ id: "xhigh", label: "Extra High" },
];
const CLAUDE_MODES: AgentMode[] = [
{ id: "plan", label: "Plan Mode" },
{ id: "default", label: "Always Ask" },
{ id: "acceptEdits", label: "Accept File Edits" },
];
describe("Command Center agent-control contributions", () => {
it("omits mode-derived choices when no mode control is available", () => {
const contributions = buildAgentControlContributions({
...makeSource({
models: { providers: [provider({ id: "claude", label: "Claude" })] },
features: { list: [toggleFeature("plan_mode", true)] },
}),
modes: undefined,
});
const groups = contributions.map((contribution) => contribution.group);
expect(groups).toContain("models");
expect(groups).toContain("plan-mode");
expect(groups).not.toContain("modes");
});
it("builds unique cross-provider model choices and skips unavailable or empty models", () => {
const selections: string[] = [];
const contributions = buildAgentControlContributions(
makeSource({
provider: null,
models: {
providers: [
provider({ id: "claude", label: "Claude" }),
provider({ id: "codex", label: "Codex" }),
provider({ id: "unavailable", label: "Unavailable", state: "error" }),
{
id: "empty",
label: "Empty",
modelSelection: {
kind: "models",
rows: [
{
favoriteKey: "empty:",
provider: "empty",
providerLabel: "Empty",
modelId: "",
modelLabel: "Default",
description: undefined,
},
],
},
},
],
selectedProvider: "codex",
selectedModelId: "shared",
select: (selectedProvider, modelId) => {
selections.push(`${selectedProvider}:${modelId}`);
},
},
}),
);
expect(
contributions.map((contribution) => ({
id: contribution.id,
selected: selected(contribution),
})),
).toEqual([
{ id: "models:claude:shared", selected: false },
{ id: "models:codex:shared", selected: true },
]);
expect(contributions[0].presentation).toMatchObject({
path: ["Model", "Claude", "Claude model"],
testId: "command-center-model-host:claude:shared",
});
contributions[0].run();
contributions[1].run();
expect(selections).toEqual(["claude:shared"]);
});
it("publishes thinking choices only when selection is meaningful", () => {
const selections: string[] = [];
const contributions = buildAgentControlContributions(
makeSource({
thinking: {
options: THINKING_OPTIONS,
selectedId: "high",
select: (id) => {
selections.push(id);
},
},
}),
).filter((contribution) => contribution.group === "thinking");
expect(contributions.map((contribution) => contribution.id)).toEqual([
"thinking:low",
"thinking:high",
"thinking:xhigh",
]);
expect(contributions.map(selected)).toEqual([false, true, false]);
for (const contribution of contributions) contribution.run();
expect(selections).toEqual(["low", "xhigh"]);
const singleOption = buildAgentControlContributions(
makeSource({
thinking: {
options: [{ id: "high", label: "High" }],
selectedId: "high",
},
}),
);
expect(singleOption.filter((contribution) => contribution.group === "thinking")).toEqual([]);
});
it("formats access modes, excludes planning modes, and marks the current mode", () => {
const selections: string[] = [];
const contributions = buildAgentControlContributions(
makeSource({
modes: {
options: CLAUDE_MODES,
selectedId: "default",
defaultModeId: "default",
select: (id) => {
selections.push(id);
},
},
}),
).filter((contribution) => contribution.group === "modes");
expect(
contributions.map((contribution) => ({
id: contribution.id,
path:
contribution.presentation.kind === "choice" ? contribution.presentation.path : undefined,
selected: selected(contribution),
})),
).toEqual([
{ id: "modes:default", path: ["Mode", "Always ask"], selected: true },
{ id: "modes:acceptEdits", path: ["Mode", "Accept file edits"], selected: false },
]);
contributions[1].run();
expect(selections).toEqual(["acceptEdits"]);
});
it("toggles mode-based Plan through the plan mode and provider default", () => {
const selections: string[] = [];
const contributions = buildAgentControlContributions(
makeSource({
modes: {
options: CLAUDE_MODES,
selectedId: "plan",
defaultModeId: "default",
select: (id) => {
selections.push(id);
},
},
}),
).filter((contribution) => contribution.group === "plan-mode");
expect(
contributions.map((contribution) => ({
id: contribution.id,
selected: selected(contribution),
})),
).toEqual([
{ id: "plan-mode:on", selected: true },
{ id: "plan-mode:off", selected: false },
]);
contributions[1].run();
expect(selections).toEqual(["default"]);
});
it("does not use a planning provider default as the Plan off target", () => {
const selections: string[] = [];
const contributions = buildAgentControlContributions(
makeSource({
modes: {
options: [
{ id: "research", label: "Research", colorTier: "planning" },
{ id: "default", label: "Default", colorTier: "safe" },
],
selectedId: "research",
defaultModeId: "research",
select: (id) => {
selections.push(id);
},
},
}),
).filter((contribution) => contribution.group === "plan-mode");
contributions[1].run();
expect(selections).toEqual(["default"]);
});
it("prefers the plan_mode feature and reads its canonical value", () => {
const calls: [string, unknown][] = [];
const contributions = buildAgentControlContributions(
makeSource({
modes: {
options: CLAUDE_MODES,
selectedId: "default",
defaultModeId: "default",
},
features: {
list: [toggleFeature("plan_mode", true, "list-todo")],
set: (id, value) => {
calls.push([id, value]);
},
},
}),
).filter((contribution) => contribution.group === "plan-mode");
expect(contributions.map(selected)).toEqual([true, false]);
contributions[1].run();
expect(calls).toEqual([["plan_mode", false]]);
});
it("publishes Fast only for an available toggle and ignores unrelated features", () => {
const calls: [string, unknown][] = [];
const contributions = buildAgentControlContributions(
makeSource({
features: {
list: [toggleFeature("fast_mode", false, "zap"), toggleFeature("auto_accept", true)],
set: (id, value) => {
calls.push([id, value]);
},
},
}),
);
const fast = contributions.filter((contribution) => contribution.group === "fast-mode");
expect(fast.map(selected)).toEqual([false, true]);
expect(contributions.some((contribution) => contribution.id.includes("auto_accept"))).toBe(
false,
);
fast[0].run();
expect(calls).toEqual([["fast_mode", true]]);
const unavailable = buildAgentControlContributions(makeSource());
expect(unavailable.filter((contribution) => contribution.group === "fast-mode")).toEqual([]);
});
it("centralizes query-only group ordering between built-in result sections", () => {
const contributions = buildAgentControlContributions(
makeSource({
models: {
providers: [provider({ id: "claude", label: "Claude" })],
selectedProvider: "claude",
selectedModelId: "shared",
},
thinking: { options: THINKING_OPTIONS, selectedId: "high" },
modes: {
options: CLAUDE_MODES,
selectedId: "default",
defaultModeId: "default",
},
features: { list: [toggleFeature("fast_mode")] },
}),
);
const ranks = new Map<string, number>();
for (const contribution of contributions) {
ranks.set(contribution.group, contribution.groupRank);
expect(contribution.visibility).toBe("query");
}
expect([...ranks.entries()]).toEqual([
["models", 1],
["thinking", 1.1],
["modes", 1.2],
["plan-mode", 1.3],
["fast-mode", 1.4],
]);
for (const rank of ranks.values()) expect(rank).toBeLessThan(2);
expect(new Set(contributions.map((contribution) => contribution.id)).size).toBe(
contributions.length,
);
});
});
@@ -0,0 +1,319 @@
import type {
AgentFeature,
AgentMode,
AgentProvider,
AgentSelectOption,
} from "@getpaseo/protocol/agent-types";
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
import { formatAgentModeLabel, formatThinkingOptionLabel } from "@/agent-controls/labels";
import {
FAST_MODE_FEATURE_ID,
isPlanningAgentMode,
PLAN_MODE_FEATURE_ID,
resolveNonPlanningModeId,
} from "@/agent-controls/policy";
import type { CommandCenterContribution, CommandCenterIcon } from "./contributions";
const GROUP_RANK = {
models: 1,
thinking: 1.1,
modes: 1.2,
planMode: 1.3,
fastMode: 1.4,
} as const;
export interface AgentControlContributionLabels {
model: string;
thinking: string;
mode: string;
planMode: string;
fast: string;
on: string;
off: string;
modelKeywords: string;
thinkingKeywords: string;
modeKeywords: string;
planKeywords: string;
fastKeywords: string;
}
export function buildAgentControlContributionLabels(
t: (key: string) => string,
): AgentControlContributionLabels {
return {
model: t("shell.commandCenter.modelGroupLabel"),
thinking: t("shell.commandCenter.thinkingGroupLabel"),
mode: t("shell.commandCenter.modeGroupLabel"),
planMode: t("shell.commandCenter.planModeGroupLabel"),
fast: t("shell.commandCenter.fastModeGroupLabel"),
on: t("shell.commandCenter.settingOn"),
off: t("shell.commandCenter.settingOff"),
modelKeywords: t("shell.commandCenter.modelSearchKeywords"),
thinkingKeywords: t("shell.commandCenter.thinkingSearchKeywords"),
modeKeywords: t("shell.commandCenter.modeSearchKeywords"),
planKeywords: t("shell.commandCenter.planModeSearchKeywords"),
fastKeywords: t("shell.commandCenter.fastModeSearchKeywords"),
};
}
export interface AgentControlContributionIcons {
provider(provider: AgentProvider): CommandCenterIcon;
thinking: CommandCenterIcon;
planMode: CommandCenterIcon;
mode(modeId: string): CommandCenterIcon;
feature(feature: AgentFeature): CommandCenterIcon;
}
interface CommandCenterChoice {
id: string;
path: readonly string[];
keywords?: readonly string[];
icon?: CommandCenterIcon;
selected: boolean;
testId?: string;
select(): void | Promise<void>;
}
interface CommandCenterChoiceGroup {
id: string;
rank: number;
label: string;
keywords: readonly string[];
choices: readonly CommandCenterChoice[];
}
export interface AgentControlContributionSource {
serverId: string;
ownerKey: string;
provider: string | null;
labels: AgentControlContributionLabels;
icons: AgentControlContributionIcons;
models: {
providers: readonly ProviderSelectorProvider[];
selectedProvider: string | null;
selectedModelId: string | null;
select(provider: AgentProvider, modelId: string): void | Promise<void>;
};
thinking: {
options: readonly AgentSelectOption[];
selectedId: string | null;
select(optionId: string): void | Promise<void>;
};
modes?: {
options: readonly AgentMode[];
selectedId: string | null;
defaultModeId: string | null;
select(modeId: string): void | Promise<void>;
};
features: {
list: readonly AgentFeature[];
set(featureId: string, value: unknown): void | Promise<void>;
};
}
function buildContributions(
groups: readonly CommandCenterChoiceGroup[],
): CommandCenterContribution[] {
const contributions: CommandCenterContribution[] = [];
for (const group of groups) {
for (const [rank, choice] of group.choices.entries()) {
contributions.push({
id: `${group.id}:${choice.id}`,
group: group.id,
groupRank: group.rank,
rank,
keywords: [...group.keywords, ...(choice.keywords ?? [])],
visibility: "query",
run: () => {
if (!choice.selected) return choice.select();
},
presentation: {
kind: "choice",
path: [group.label, ...choice.path],
icon: choice.icon,
selected: choice.selected,
testId: choice.testId,
},
});
}
}
return contributions;
}
function buildModelGroup(source: AgentControlContributionSource): CommandCenterChoiceGroup {
const choices: CommandCenterChoice[] = [];
for (const provider of source.models.providers) {
if (provider.modelSelection.kind !== "models") continue;
const agentProvider = provider.id;
const icon = source.icons.provider(agentProvider);
for (const model of provider.modelSelection.rows) {
if (!model.modelId) continue;
const modelId = model.modelId;
choices.push({
id: `${provider.id}:${modelId}`,
path: [provider.label, model.modelLabel],
keywords: [modelId],
icon,
selected:
source.models.selectedProvider === provider.id &&
source.models.selectedModelId === modelId,
testId: `command-center-model-${source.serverId}:${provider.id}:${modelId}`,
select: () => source.models.select(agentProvider, modelId),
});
}
}
return {
id: "models",
rank: GROUP_RANK.models,
label: source.labels.model,
keywords: [source.labels.modelKeywords],
choices,
};
}
function buildThinkingGroup(source: AgentControlContributionSource): CommandCenterChoiceGroup {
const choices =
source.thinking.options.length > 1
? source.thinking.options.map(
(option): CommandCenterChoice => ({
id: option.id,
path: [formatThinkingOptionLabel(option)],
icon: source.icons.thinking,
selected: option.id === source.thinking.selectedId,
testId: `command-center-thinking-${source.serverId}:${source.ownerKey}:${option.id}`,
select: () => source.thinking.select(option.id),
}),
)
: [];
return {
id: "thinking",
rank: GROUP_RANK.thinking,
label: source.labels.thinking,
keywords: [source.labels.thinkingKeywords],
choices,
};
}
function buildModeGroup(source: AgentControlContributionSource): CommandCenterChoiceGroup {
const modes = source.modes;
const choices = modes
? modes.options
.filter((mode) => !isPlanningAgentMode(mode))
.map(
(mode): CommandCenterChoice => ({
id: mode.id,
path: [formatAgentModeLabel(mode)],
icon: source.icons.mode(mode.id),
selected: mode.id === modes.selectedId,
testId: `command-center-mode-${source.serverId}:${source.ownerKey}:${mode.id}`,
select: () => modes.select(mode.id),
}),
)
: [];
return {
id: "modes",
rank: GROUP_RANK.modes,
label: source.labels.mode,
keywords: [source.labels.modeKeywords],
choices,
};
}
function toggleChoices(input: {
source: AgentControlContributionSource;
key: "plan" | "fast";
icon: CommandCenterIcon;
isOn: boolean;
turnOn(): void | Promise<void>;
turnOff(): void | Promise<void>;
}): CommandCenterChoice[] {
const states = [
{ id: "on", label: input.source.labels.on, selected: input.isOn, select: input.turnOn },
{ id: "off", label: input.source.labels.off, selected: !input.isOn, select: input.turnOff },
];
return states.map((state) => ({
id: state.id,
path: [state.label],
icon: input.icon,
selected: state.selected,
testId: `command-center-${input.key}-${input.source.serverId}:${input.source.ownerKey}:${state.id}`,
select: state.select,
}));
}
function buildPlanModeGroup(source: AgentControlContributionSource): CommandCenterChoiceGroup {
const planFeature = source.features.list.find(
(feature) => feature.id === PLAN_MODE_FEATURE_ID && feature.type === "toggle",
);
let choices: CommandCenterChoice[] = [];
if (planFeature?.type === "toggle") {
choices = toggleChoices({
source,
key: "plan",
icon: source.icons.feature(planFeature),
isOn: planFeature.value,
turnOn: () => source.features.set(PLAN_MODE_FEATURE_ID, true),
turnOff: () => source.features.set(PLAN_MODE_FEATURE_ID, false),
});
} else if (source.modes) {
const modes = source.modes;
const planMode = modes.options.find(isPlanningAgentMode);
const offModeId = resolveNonPlanningModeId(modes.options, modes.defaultModeId);
if (planMode && offModeId) {
choices = toggleChoices({
source,
key: "plan",
icon: source.icons.planMode,
isOn: modes.selectedId === planMode.id,
turnOn: () => modes.select(planMode.id),
turnOff: () => modes.select(offModeId),
});
}
}
return {
id: "plan-mode",
rank: GROUP_RANK.planMode,
label: source.labels.planMode,
keywords: [source.labels.planKeywords],
choices,
};
}
function buildFastModeGroup(source: AgentControlContributionSource): CommandCenterChoiceGroup {
const fastFeature = source.features.list.find(
(feature) => feature.id === FAST_MODE_FEATURE_ID && feature.type === "toggle",
);
const choices =
fastFeature?.type === "toggle"
? toggleChoices({
source,
key: "fast",
icon: source.icons.feature(fastFeature),
isOn: fastFeature.value,
turnOn: () => source.features.set(FAST_MODE_FEATURE_ID, true),
turnOff: () => source.features.set(FAST_MODE_FEATURE_ID, false),
})
: [];
return {
id: "fast-mode",
rank: GROUP_RANK.fastMode,
label: source.labels.fast,
keywords: [source.labels.fastKeywords],
choices,
};
}
export function buildAgentControlContributions(
source: AgentControlContributionSource,
): CommandCenterContribution[] {
const groups = [buildModelGroup(source)];
if (source.provider) {
groups.push(
buildThinkingGroup(source),
buildModeGroup(source),
buildPlanModeGroup(source),
buildFastModeGroup(source),
);
}
return buildContributions(groups);
}
@@ -0,0 +1,156 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import type {
AgentFeature,
AgentMode,
AgentProvider,
AgentSelectOption,
} from "@getpaseo/protocol/agent-types";
import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
import {
getAgentFeatureIcon,
getAgentModeIcon,
PlanModeIcon,
ThinkingIcon,
} from "@/agent-controls/icons";
import { getProviderIcon } from "@/components/provider-icons";
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
import {
buildAgentControlContributions,
buildAgentControlContributionLabels,
type AgentControlContributionSource,
} from "./agent-control-contributions";
import { getCommandCenterIcon } from "./icon";
import { useCommandCenterActions } from "./provider";
export interface AgentControlCommandCenterSource {
serverId: string;
ownerKey: string;
provider: string | null | undefined;
providerDefinitions: AgentProviderDefinition[];
models: {
providers: readonly ProviderSelectorProvider[];
selectedProvider: string | null | undefined;
selectedModelId: string | null | undefined;
select(provider: AgentProvider, modelId: string): void | Promise<void>;
};
thinking: {
options: readonly AgentSelectOption[] | null | undefined;
selectedId: string | null | undefined;
select(optionId: string): void | Promise<void>;
};
modes?: {
options: readonly AgentMode[];
selectedId: string | null | undefined;
select(modeId: string): void | Promise<void>;
};
features: {
list: readonly AgentFeature[] | undefined;
set?: (featureId: string, value: unknown) => void | Promise<void>;
};
}
function ignoreAgentFeatureChange(): void {
return;
}
function resolveDefaultModeId(
provider: string | null | undefined,
providerDefinitions: AgentProviderDefinition[],
): string | null {
return (
providerDefinitions.find((definition) => definition.id === provider)?.defaultModeId ?? null
);
}
function buildModeContributionSource(
options: readonly AgentMode[] | undefined,
selectedId: string | null | undefined,
select: ((modeId: string) => void | Promise<void>) | undefined,
defaultModeId: string | null,
): Pick<AgentControlContributionSource, "modes"> {
if (!options || !select) return {};
return {
modes: {
options,
selectedId: selectedId ?? null,
defaultModeId,
select,
},
};
}
export function useAgentControlCommandCenterActions(input: {
sourceId: string;
enabled: boolean;
controls: AgentControlCommandCenterSource;
}): void {
const { t } = useTranslation();
const { controls } = input;
const { features, models, modes, thinking } = controls;
const actions = useMemo(
() =>
buildAgentControlContributions({
serverId: controls.serverId,
ownerKey: controls.ownerKey,
provider: controls.provider ?? null,
labels: buildAgentControlContributionLabels(t),
icons: {
provider: (provider) => getCommandCenterIcon(getProviderIcon(provider)),
thinking: getCommandCenterIcon(ThinkingIcon),
planMode: getCommandCenterIcon(PlanModeIcon),
mode: (modeId) =>
getCommandCenterIcon(
getAgentModeIcon(controls.provider ?? "", modeId, controls.providerDefinitions),
),
feature: (feature) => getCommandCenterIcon(getAgentFeatureIcon(feature.icon)),
},
models: {
providers: models.providers,
selectedProvider: models.selectedProvider ?? null,
selectedModelId: models.selectedModelId ?? null,
select: models.select,
},
thinking: {
options: thinking.options ?? [],
selectedId: thinking.selectedId ?? null,
select: thinking.select,
},
...buildModeContributionSource(
modes?.options,
modes?.selectedId,
modes?.select,
resolveDefaultModeId(controls.provider, controls.providerDefinitions),
),
features: {
list: features.set ? (features.list ?? []) : [],
set: features.set ?? ignoreAgentFeatureChange,
},
}),
[
controls.ownerKey,
controls.provider,
controls.providerDefinitions,
controls.serverId,
features.list,
features.set,
models.providers,
models.select,
models.selectedModelId,
models.selectedProvider,
modes?.options,
modes?.select,
modes?.selectedId,
thinking.options,
thinking.select,
thinking.selectedId,
t,
],
);
useCommandCenterActions({
sourceId: input.sourceId,
enabled: input.enabled,
actions,
});
}
@@ -401,6 +401,15 @@ interface ResultRowProps {
const ResultRow = memo(function ResultRow({ result, active, onSelect }: ResultRowProps) {
const press = useCallback(() => onSelect(result), [onSelect, result]);
const choice =
result.kind === "contribution" && result.contribution.presentation.kind === "choice"
? result.contribution.presentation
: null;
const accessibilityLabel = choice?.path.join(" › ");
const accessibilityState = useMemo(
() => (isNative && choice ? { selected: choice.selected } : undefined),
[choice],
);
const style = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.row,
@@ -415,7 +424,15 @@ const ResultRow = memo(function ResultRow({ result, active, onSelect }: ResultRo
[active, result],
);
return (
<Pressable style={style} onPress={press}>
<Pressable
style={style}
onPress={press}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
accessibilityState={accessibilityState}
aria-pressed={isWeb ? choice?.selected : undefined}
testID={choice?.testId}
>
<ResultContent result={result} />
</Pressable>
);
@@ -497,7 +514,7 @@ function ResultContent({ result }: { result: CommandCenterResult }) {
);
}
return (
<View style={styles.rowContent} testID={presentation.testId}>
<View style={styles.rowContent}>
<View style={styles.rowMain}>
{Icon ? (
<View style={styles.iconSlot}>
+19
View File
@@ -0,0 +1,19 @@
import { withUnistyles } from "react-native-unistyles";
import type { AgentControlIcon } from "@/agent-controls/icons";
import type { CommandCenterIcon, CommandCenterIconProps } from "./contributions";
const commandCenterIcons = new Map<AgentControlIcon, CommandCenterIcon>();
export function getCommandCenterIcon(Component: AgentControlIcon): CommandCenterIcon {
const cached = commandCenterIcons.get(Component);
if (cached) return cached;
const ThemedIcon = withUnistyles(Component, (theme) => ({
color: theme.colors.foregroundMuted,
}));
function CommandCenterIconAdapter({ size }: CommandCenterIconProps) {
return <ThemedIcon size={size} />;
}
commandCenterIcons.set(Component, CommandCenterIconAdapter);
return CommandCenterIconAdapter;
}
@@ -1,104 +0,0 @@
import { describe, expect, it } from "vitest";
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
import { buildModelChoiceContributions } from "./model-contributions";
function TestIcon() {
return null;
}
function provider(input: {
id: string;
label: string;
state: "models" | "error";
}): ProviderSelectorProvider {
if (input.state === "error") {
return {
id: input.id,
label: input.label,
modelSelection: { kind: "error", message: "Unavailable" },
};
}
return {
id: input.id,
label: input.label,
modelSelection: {
kind: "models",
rows: [
{
favoriteKey: `${input.id}:model`,
provider: input.id,
providerLabel: input.label,
modelId: "model",
modelLabel: `${input.label} model`,
description: undefined,
},
],
},
};
}
describe("Command Center model choices", () => {
it("publishes selectable providers and executes the draft owner's command", () => {
const selections: string[] = [];
const choices = buildModelChoiceContributions({
serverId: "host",
providers: [
provider({ id: "claude", label: "Claude", state: "models" }),
provider({ id: "codex", label: "Codex", state: "models" }),
],
selectedProvider: "codex",
selectedModelId: "model",
groupLabel: "Model",
searchKeywords: "model switch",
getIcon: () => TestIcon,
select: (selectedProvider, modelId) => selections.push(`${selectedProvider}:${modelId}`),
});
expect(
choices.map((choice) => ({
id: choice.id,
selected: choice.presentation.kind === "choice" ? choice.presentation.selected : false,
})),
).toEqual([
{ id: "host:claude:model", selected: false },
{ id: "host:codex:model", selected: true },
]);
choices[0].run();
choices[1].run();
expect(selections).toEqual(["claude:model"]);
});
it("does not publish unavailable providers or no-op default rows", () => {
const choices = buildModelChoiceContributions({
serverId: "host",
providers: [
provider({ id: "unavailable", label: "Unavailable", state: "error" }),
{
id: "empty",
label: "Empty",
modelSelection: {
kind: "models",
rows: [
{
favoriteKey: "empty:",
provider: "empty",
providerLabel: "Empty",
modelId: "",
modelLabel: "Default",
description: undefined,
},
],
},
},
],
selectedProvider: null,
selectedModelId: null,
groupLabel: "Model",
searchKeywords: "model switch",
getIcon: () => TestIcon,
select: () => undefined,
});
expect(choices).toEqual([]);
});
});
@@ -1,51 +0,0 @@
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
import type { CommandCenterContribution, CommandCenterIcon } from "./contributions";
interface ModelChoiceInput {
serverId: string;
providers: readonly ProviderSelectorProvider[];
selectedProvider: string | null;
selectedModelId: string | null;
groupLabel: string;
searchKeywords: string;
getIcon(provider: AgentProvider): CommandCenterIcon;
select(provider: AgentProvider, modelId: string): void;
}
export function buildModelChoiceContributions(
input: ModelChoiceInput,
): CommandCenterContribution[] {
const contributions: CommandCenterContribution[] = [];
let rank = 0;
for (const provider of input.providers) {
if (provider.modelSelection.kind !== "models") continue;
const agentProvider = provider.id;
const icon = input.getIcon(agentProvider);
for (const model of provider.modelSelection.rows) {
if (!model.modelId) continue;
const modelId = model.modelId;
const selected = input.selectedProvider === provider.id && input.selectedModelId === modelId;
contributions.push({
id: `${input.serverId}:${provider.id}:${modelId}`,
group: "models",
groupRank: 1,
rank,
keywords: [modelId, input.searchKeywords],
visibility: "query",
run: () => {
if (!selected) input.select(agentProvider, modelId);
},
presentation: {
kind: "choice",
path: [input.groupLabel, provider.label, model.modelLabel],
icon,
selected,
testId: `command-center-model-${input.serverId}:${provider.id}:${modelId}`,
},
});
rank += 1;
}
}
return contributions;
}

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