mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 21:32:40 +00:00
Compare commits
84
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a3751f1bf | ||
|
|
cb4976289b | ||
|
|
2668f31e07 | ||
|
|
be523e09e9 | ||
|
|
1c41ba0bee | ||
|
|
90aa8bbefc | ||
|
|
c28836b4e5 | ||
|
|
8f38d04919 | ||
|
|
ce7b3a90c0 | ||
|
|
cefb71dcac | ||
|
|
49b257c55a | ||
|
|
1e0ce69c54 | ||
|
|
b9b3a24d58 | ||
|
|
b19db9537c | ||
|
|
1a117652fd | ||
|
|
638693a5d7 | ||
|
|
2740c8206f | ||
|
|
0906d1d7bd | ||
|
|
3d420720c5 | ||
|
|
375d1d6fb8 | ||
|
|
f0eb7cea4b | ||
|
|
b669dad221 | ||
|
|
7ac4303d8f | ||
|
|
e36842b234 | ||
|
|
7392e1b767 | ||
|
|
3196385526 | ||
|
|
b167ee02e1 | ||
|
|
cc5ce1038d | ||
|
|
8d3881f640 | ||
|
|
a7c7a6e482 | ||
|
|
082ddb2303 | ||
|
|
dc19b45f23 | ||
|
|
98d3281cad | ||
|
|
5e197ad0cf | ||
|
|
9b5f6f33f6 | ||
|
|
3ddebe7221 | ||
|
|
86ff0a18c4 | ||
|
|
bf38bd728c | ||
|
|
fcc54621ca | ||
|
|
f842537142 | ||
|
|
c9a9d8fdce | ||
|
|
6abfa242d8 | ||
|
|
a06e4d60e9 | ||
|
|
a5184fd08a | ||
|
|
75a5065bb3 | ||
|
|
84b735fbea | ||
|
|
64058722e1 | ||
|
|
3a6d6db9a5 | ||
|
|
c352632b74 | ||
|
|
292a9c4c8c | ||
|
|
b4357139bb | ||
|
|
ef6d7b3b9a | ||
|
|
1e03e2d120 | ||
|
|
42bfd4e04c | ||
|
|
a9034ec642 | ||
|
|
deb5e5c729 | ||
|
|
bb55e5bc0d | ||
|
|
9c05de6f12 | ||
|
|
f4e8522050 | ||
|
|
c1f7ed3198 | ||
|
|
e99eb7de3d | ||
|
|
2298e9efdf | ||
|
|
f2054cc701 | ||
|
|
73ba37a07a | ||
|
|
098122be8d | ||
|
|
5465d66edd | ||
|
|
e1b4058b77 | ||
|
|
c59b47b8a6 | ||
|
|
212cd00405 | ||
|
|
254e72ad50 | ||
|
|
eb95a6eea6 | ||
|
|
9f84522521 | ||
|
|
7f7e8923eb | ||
|
|
fb5cfb9fb9 | ||
|
|
0728148657 | ||
|
|
94b57f34ad | ||
|
|
5012607643 | ||
|
|
076d9751b7 | ||
|
|
5d0560d4c1 | ||
|
|
211489c658 | ||
|
|
48bdc5610f | ||
|
|
781274ed1a | ||
|
|
1c43e4eea1 | ||
|
|
aae9a474c2 |
@@ -1,13 +1,9 @@
|
||||
---
|
||||
name: release-beta
|
||||
description: Cut a beta release of Paseo. Use when the user says "release beta", "cut a beta", "ship a beta", "beta release", or "/release-beta". Betas are release candidates on the beta channel — they carry an in-place changelog entry, don't move the website download target, and publish npm only on the beta dist-tag.
|
||||
description: Cut a beta release of Paseo. Use when the user says "release beta", "cut a beta", "ship a beta", "beta release", or "/release-beta".
|
||||
user-invocable: true
|
||||
---
|
||||
|
||||
# Release beta
|
||||
|
||||
Read `docs/release.md` in the Paseo repo and follow the **Beta flow** section end-to-end. Run the **Beta release** completion checklist at the bottom of that doc.
|
||||
|
||||
During preparation, classify the previous-stable-to-`HEAD` diff as patch or minor and show the target version and rationale to the user. Agents never select a major version autonomously.
|
||||
|
||||
Each beta updates an in-place `CHANGELOG.md` entry (`## X.Y.Z-beta.N`) that gets overwritten at promotion, and npm publishes only on the explicit `beta` dist-tag.
|
||||
Follow the **Beta flow** and **Beta release** completion checklist in `docs/release.md` end-to-end.
|
||||
|
||||
@@ -6,8 +6,4 @@ user-invocable: true
|
||||
|
||||
# Release stable
|
||||
|
||||
Read `docs/release.md` in the Paseo repo and follow the **Standard release (stable)** flow if cutting fresh, or the **Beta flow** promotion step if promoting an existing beta. Run the **Stable release (or promotion)** completion checklist at the bottom of that doc.
|
||||
|
||||
For a fresh release, classify the previous-stable-to-`HEAD` diff as patch or minor and show the target version and rationale to the user. Agents never select a major version autonomously.
|
||||
|
||||
The doc covers the changelog, pre-release sanity check, and post-release babysit pattern. Don't skip steps.
|
||||
Follow the applicable flow and the **Stable release (or promotion)** completion checklist in `docs/release.md` end-to-end.
|
||||
|
||||
+38
-23
@@ -1,44 +1,49 @@
|
||||
# Changelog
|
||||
|
||||
## 0.3.0-beta.2 - 2026-08-05
|
||||
|
||||
### Fixed
|
||||
|
||||
- Terminal output keeps updating when you leave the app and come back ([#2896](https://github.com/getpaseo/paseo/pull/2896))
|
||||
|
||||
## 0.3.0-beta.1 - 2026-08-04
|
||||
## 0.3.0 - 2026-08-08
|
||||
|
||||
### Added
|
||||
|
||||
- New mobile terminal with text selection, copy, paste, and faster rendering ([#1607](https://github.com/getpaseo/paseo/pull/1607), [#2830](https://github.com/getpaseo/paseo/pull/2830))
|
||||
- Jump between prompts in a chat ([#2792](https://github.com/getpaseo/paseo/pull/2792))
|
||||
- Preview HTML files ([#2712](https://github.com/getpaseo/paseo/pull/2712) by [@nicholas-salgueiro-britecore](https://github.com/nicholas-salgueiro-britecore), [@nickmaglowsch](https://github.com/nickmaglowsch))
|
||||
- Fork an agent while it's running ([#2638](https://github.com/getpaseo/paseo/pull/2638) by [@kaspesi](https://github.com/kaspesi))
|
||||
- Redesigned sidebar with clearer status colours and rows that are quicker to scan ([#2340](https://github.com/getpaseo/paseo/pull/2340) by [@kaspesi](https://github.com/kaspesi), [#2335](https://github.com/getpaseo/paseo/pull/2335), [#2416](https://github.com/getpaseo/paseo/pull/2416) by [@nikuscs](https://github.com/nikuscs), [#2711](https://github.com/getpaseo/paseo/pull/2711))
|
||||
- Choose what a sidebar workspace row shows: host, pull request, checks, and scripts
|
||||
- Name each host and give it a colour ([#2790](https://github.com/getpaseo/paseo/pull/2790))
|
||||
- Search your history by workspace, agent, and branch ([#2995](https://github.com/getpaseo/paseo/pull/2995))
|
||||
- Use Paseo in Korean ([#2895](https://github.com/getpaseo/paseo/pull/2895) by [@himomohi](https://github.com/himomohi), [@Kesta-bos](https://github.com/Kesta-bos))
|
||||
- Run git and workspace actions from the Command Center ([#2749](https://github.com/getpaseo/paseo/pull/2749))
|
||||
- Change model, reasoning, mode, plan, and fast from the Command Center ([#2274](https://github.com/getpaseo/paseo/pull/2274) by [@kedrzu](https://github.com/kedrzu))
|
||||
- Fork an agent while it's running ([#2638](https://github.com/getpaseo/paseo/pull/2638) by [@kaspesi](https://github.com/kaspesi))
|
||||
- Launch a terminal directly from New Workspace ([#2941](https://github.com/getpaseo/paseo/pull/2941))
|
||||
- Jump between prompts in a chat ([#2792](https://github.com/getpaseo/paseo/pull/2792))
|
||||
- Preview HTML files ([#2712](https://github.com/getpaseo/paseo/pull/2712) by [@nicholas-salgueiro-britecore](https://github.com/nicholas-salgueiro-britecore), [@nickmaglowsch](https://github.com/nickmaglowsch))
|
||||
- See Claude workflows in the subagent track ([#2933](https://github.com/getpaseo/paseo/pull/2933))
|
||||
- Choose which orchestration skills Paseo installs ([#2680](https://github.com/getpaseo/paseo/pull/2680))
|
||||
- Give projects custom icons ([#2335](https://github.com/getpaseo/paseo/pull/2335), [#2416](https://github.com/getpaseo/paseo/pull/2416) by [@nikuscs](https://github.com/nikuscs))
|
||||
- Approve all ACP tool calls with one setting ([#2752](https://github.com/getpaseo/paseo/pull/2752))
|
||||
- Choose what a sidebar workspace row shows: host, pull request, checks, and scripts
|
||||
- Name each host and give it a colour ([#2790](https://github.com/getpaseo/paseo/pull/2790))
|
||||
- See a project's workspace status while it's collapsed ([#2340](https://github.com/getpaseo/paseo/pull/2340) by [@kaspesi](https://github.com/kaspesi))
|
||||
- See worktree names in workspace hover cards ([#2711](https://github.com/getpaseo/paseo/pull/2711))
|
||||
- Add custom HTTP headers to direct host connections ([#2922](https://github.com/getpaseo/paseo/pull/2922))
|
||||
- Pick a local branch or its origin counterpart as the base for a new worktree ([#2328](https://github.com/getpaseo/paseo/pull/2328) by [@mcowger](https://github.com/mcowger))
|
||||
- Paste images from the clipboard on mobile ([#2793](https://github.com/getpaseo/paseo/pull/2793))
|
||||
|
||||
### Improved
|
||||
|
||||
- Paseo starts faster
|
||||
- Coming back to a recent chat is instant ([#2842](https://github.com/getpaseo/paseo/pull/2842))
|
||||
- Provider data is cached in the client for a faster startup
|
||||
- Recent chats stay live in the background, so switching back shows current messages right away ([#2842](https://github.com/getpaseo/paseo/pull/2842))
|
||||
- Coming back to a workspace from Settings is faster ([#2791](https://github.com/getpaseo/paseo/pull/2791))
|
||||
- Workspace Git status stays responsive in large repositories ([#2979](https://github.com/getpaseo/paseo/pull/2979))
|
||||
- Paseo runs fewer git processes on busy machines ([#2797](https://github.com/getpaseo/paseo/pull/2797))
|
||||
- New worktrees start from the tracked upstream branch ([#2848](https://github.com/getpaseo/paseo/pull/2848))
|
||||
- Copying part of an assistant response keeps its lists, links, and code formatting ([#2808](https://github.com/getpaseo/paseo/pull/2808))
|
||||
- Copying part of an assistant response keeps its lists, links, and formatting ([#2808](https://github.com/getpaseo/paseo/pull/2808), [#2930](https://github.com/getpaseo/paseo/pull/2930), [#2935](https://github.com/getpaseo/paseo/pull/2935) by [@cleiter](https://github.com/cleiter))
|
||||
- See the full conversation from a native Claude subagent ([#2498](https://github.com/getpaseo/paseo/pull/2498) by [@ebg1223](https://github.com/ebg1223), [#2760](https://github.com/getpaseo/paseo/pull/2760))
|
||||
- OpenCode subagents show their task, type, model, and token usage ([#2909](https://github.com/getpaseo/paseo/pull/2909) by [@BrianAguilarWasco](https://github.com/BrianAguilarWasco))
|
||||
- Pi delegated tasks show their lifecycle status ([#2891](https://github.com/getpaseo/paseo/pull/2891))
|
||||
- Claude remembers model and thinking choices for new workspaces ([#2912](https://github.com/getpaseo/paseo/pull/2912))
|
||||
- OMP context usage updates while a turn is running ([#2503](https://github.com/getpaseo/paseo/pull/2503) by [@theslava](https://github.com/theslava))
|
||||
- OpenCode reports background activity from its own busy status ([#2696](https://github.com/getpaseo/paseo/pull/2696) by [@desflynn](https://github.com/desflynn))
|
||||
- Relay access is opt-in when you pair a device ([#2706](https://github.com/getpaseo/paseo/pull/2706))
|
||||
- Desktop browser tabs keep their state through focus changes and automation ([#2907](https://github.com/getpaseo/paseo/pull/2907))
|
||||
- New worktrees start from the tracked upstream branch ([#2848](https://github.com/getpaseo/paseo/pull/2848))
|
||||
- Generated workspace titles describe the task ([#2755](https://github.com/getpaseo/paseo/pull/2755))
|
||||
- Relay access is opt-in when you pair a device ([#2706](https://github.com/getpaseo/paseo/pull/2706))
|
||||
- Voice mode releases the audio session when recording and playback stop ([#2866](https://github.com/getpaseo/paseo/pull/2866) by [@kaspesi](https://github.com/kaspesi))
|
||||
- Workspace suggestions skip Git-ignored data ([#2902](https://github.com/getpaseo/paseo/pull/2902))
|
||||
- Reconnect messages distinguish daemon restarts from network interruptions ([#2931](https://github.com/getpaseo/paseo/pull/2931))
|
||||
- The file viewer shows one accurate status when a file changes or is deleted ([#2670](https://github.com/getpaseo/paseo/pull/2670), [#2694](https://github.com/getpaseo/paseo/pull/2694))
|
||||
- The Nix desktop package is smaller ([#2550](https://github.com/getpaseo/paseo/pull/2550) by [@colonelpanic8](https://github.com/colonelpanic8))
|
||||
- The Nix desktop app uses the right icon on Linux and macOS ([#2506](https://github.com/getpaseo/paseo/pull/2506), [#2783](https://github.com/getpaseo/paseo/pull/2783) by [@colonelpanic8](https://github.com/colonelpanic8))
|
||||
@@ -46,20 +51,30 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- Pi 0.84 agents no longer crash-loop on every prompt ([#2978](https://github.com/getpaseo/paseo/pull/2978) by [@dundunHa](https://github.com/dundunHa))
|
||||
- Workspace file watching no longer stalls the daemon ([#2858](https://github.com/getpaseo/paseo/pull/2858))
|
||||
- Messages no longer duplicate or arrive out of order after a reconnect or resume ([#2789](https://github.com/getpaseo/paseo/pull/2789), [#2718](https://github.com/getpaseo/paseo/pull/2718))
|
||||
- Git status and diffs pick up changes in nested folders on every desktop OS ([#2775](https://github.com/getpaseo/paseo/pull/2775))
|
||||
- Switching workspaces no longer scrolls a chat away from where you were reading ([#2838](https://github.com/getpaseo/paseo/pull/2838))
|
||||
- Cancelling an OpenCode turn no longer breaks the next one ([#2662](https://github.com/getpaseo/paseo/pull/2662))
|
||||
- Claude runtime failures report an error instead of leaving the workspace idle ([#2910](https://github.com/getpaseo/paseo/pull/2910) by [@nickmaglowsch](https://github.com/nickmaglowsch))
|
||||
- Claude replay no longer leaves stale running subagents ([#2876](https://github.com/getpaseo/paseo/pull/2876) by [@cleiter](https://github.com/cleiter))
|
||||
- The Fast toggle appears for Claude Opus 5 ([#2939](https://github.com/getpaseo/paseo/pull/2939) by [@cleiter](https://github.com/cleiter))
|
||||
- Agents stop with an error when their provider process exits ([#2757](https://github.com/getpaseo/paseo/pull/2757))
|
||||
- Cancelling an OpenCode turn no longer breaks the next one ([#2662](https://github.com/getpaseo/paseo/pull/2662))
|
||||
- ACP permission prompts no longer disappear mid-turn ([#2762](https://github.com/getpaseo/paseo/pull/2762))
|
||||
- A workspace no longer looks idle while its native subagents run ([#2777](https://github.com/getpaseo/paseo/pull/2777))
|
||||
- Terminal activity stops after an interrupted turn ([#2942](https://github.com/getpaseo/paseo/pull/2942))
|
||||
- Windows terminals handle deferred startup failures without crashing
|
||||
- Recreated workspace folders no longer remain incorrectly archived ([#2987](https://github.com/getpaseo/paseo/pull/2987))
|
||||
- Restoring a merged workspace no longer leaves it inactive ([#2714](https://github.com/getpaseo/paseo/pull/2714))
|
||||
- Switching workspaces no longer scrolls a chat away from where you were reading ([#2838](https://github.com/getpaseo/paseo/pull/2838))
|
||||
- Images stay anchored while a conversation reloads
|
||||
- Swipe and drag gestures work in the mobile sidebar ([#2709](https://github.com/getpaseo/paseo/pull/2709))
|
||||
- Dictated prompts no longer disappear when you submit them ([#2745](https://github.com/getpaseo/paseo/pull/2745))
|
||||
- The composer stays above the keyboard on Android
|
||||
- Typing with an IME on mobile no longer loses composed text
|
||||
- Restoring a merged workspace no longer leaves it inactive ([#2714](https://github.com/getpaseo/paseo/pull/2714))
|
||||
- The composer toolbar no longer flickers when switching tabs ([#2937](https://github.com/getpaseo/paseo/pull/2937))
|
||||
- Your selected project stays selected when you switch hosts ([#2700](https://github.com/getpaseo/paseo/pull/2700))
|
||||
- Host connection choices stay selected through Settings and desktop refreshes ([#2905](https://github.com/getpaseo/paseo/pull/2905))
|
||||
- The pull request panel updates after you switch branches ([#2699](https://github.com/getpaseo/paseo/pull/2699))
|
||||
- New Workspace shows its isolation controls right away ([#2702](https://github.com/getpaseo/paseo/pull/2702))
|
||||
- Sidebar shortcuts leave focus mode ([#2717](https://github.com/getpaseo/paseo/pull/2717))
|
||||
|
||||
@@ -40,6 +40,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
|
||||
| [docs/forge-providers.md](docs/forge-providers.md) | Adding a git forge: registry/manifest, drop-in checklist, self-host/GHES, the two facts tiers |
|
||||
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
|
||||
| [docs/plugins.md](docs/plugins.md) | Local plugin manifest, directory source config, RPCs, native surfaces, and attachment sources |
|
||||
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
|
||||
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
|
||||
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
|
||||
@@ -57,6 +58,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
|
||||
| [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 |
|
||||
| [public-docs/hub/security.md](public-docs/hub/security.md) | Public Hub guide — trust boundaries, untrusted triggers, provider controls, and output authority |
|
||||
|
||||
### Writing docs
|
||||
|
||||
|
||||
+2
-1
@@ -7,7 +7,8 @@
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> ·
|
||||
<a href="README.zh-CN.md">简体中文</a> ·
|
||||
<a href="README.ja.md">日本語</a>
|
||||
<a href="README.ja.md">日本語</a> ·
|
||||
<a href="README.ko.md">한국어</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
<p align="center">
|
||||
<img src="packages/website/public/logo.svg" width="64" height="64" alt="Paseo 로고">
|
||||
</p>
|
||||
|
||||
<h1 align="center">Paseo</h1>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> ·
|
||||
<a href="README.zh-CN.md">简体中文</a> ·
|
||||
<a href="README.ja.md">日本語</a> ·
|
||||
<a href="README.ko.md">한국어</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/getpaseo/paseo/stargazers">
|
||||
<img src="https://img.shields.io/github/stars/getpaseo/paseo?style=flat&logo=github" alt="GitHub stars">
|
||||
</a>
|
||||
<a href="https://github.com/getpaseo/paseo/releases">
|
||||
<img src="https://img.shields.io/github/v/release/getpaseo/paseo?style=flat&logo=github" alt="GitHub release">
|
||||
</a>
|
||||
<a href="https://x.com/moboudra">
|
||||
<img src="https://img.shields.io/badge/%40moboudra-555?logo=x" alt="X">
|
||||
</a>
|
||||
<a href="https://discord.gg/jz8T2uahpH">
|
||||
<img src="https://img.shields.io/badge/Discord-555?logo=discord" alt="Discord">
|
||||
</a>
|
||||
<a href="https://www.reddit.com/r/PaseoAI/">
|
||||
<img src="https://img.shields.io/badge/Reddit-555?logo=reddit" alt="Reddit">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">Claude Code, Codex, Copilot, OpenCode, Pi 에이전트를 위한 하나의 인터페이스</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://paseo.sh/hero-mockup.png" alt="Paseo app screenshot" width="100%">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://paseo.sh/mobile-mockup.png" alt="Paseo mobile app" width="100%">
|
||||
</p>
|
||||
|
||||
내 컴퓨터에서 에이전트를 병렬로 실행하세요. 데스크톱이나 휴대폰에서 배포하세요.
|
||||
|
||||
- **셀프 호스팅:** 에이전트는 완전한 개발 환경이 갖춰진 내 컴퓨터에서 실행됩니다. 평소 쓰던 도구, 설정, 스킬을 그대로 쓸 수 있습니다.
|
||||
- **여러 제공자 지원:** Claude Code, Codex, Copilot, OpenCode, Pi를 하나의 인터페이스에서 사용할 수 있습니다. 작업마다 알맞은 모델을 고를 수 있습니다.
|
||||
- **음성 제어:** 음성 모드에서 작업을 말로 지시하거나 문제를 음성으로 함께 검토할 수 있습니다. 손을 쓰지 않고 작업해야 할 때 유용합니다.
|
||||
- **여러 기기 지원:** iOS, Android, 데스크톱, 웹, CLI를 지원합니다. 데스크톱에서 시작해 휴대폰으로 확인하고 터미널에서 자동화할 수 있습니다.
|
||||
- **개인정보 보호 우선:** Paseo는 텔레메트리, 추적, 강제 로그인을 사용하지 않습니다.
|
||||
|
||||
## 시작하기
|
||||
|
||||
Paseo는 코딩 에이전트를 관리하는 로컬 서버인 데몬을 실행합니다. 데스크톱 앱, 모바일 앱, 웹 앱, CLI 같은 클라이언트가 이 데몬에 연결합니다.
|
||||
|
||||
### 준비 사항
|
||||
|
||||
아래 에이전트 CLI 중 하나 이상을 설치하고 인증 정보를 설정해야 합니다.
|
||||
|
||||
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code)
|
||||
- [Codex](https://github.com/openai/codex)
|
||||
- [GitHub Copilot](https://github.com/features/copilot/cli/)
|
||||
- [OpenCode](https://github.com/anomalyco/opencode)
|
||||
- [Pi](https://pi.dev)
|
||||
|
||||
### 데스크톱 앱(권장)
|
||||
|
||||
[paseo.sh/download](https://paseo.sh/download) 또는 [GitHub 릴리스 페이지](https://github.com/getpaseo/paseo/releases)에서 다운로드하세요. 앱을 열면 데몬이 자동으로 시작됩니다. 별도로 설치할 것은 없습니다.
|
||||
|
||||
휴대폰에서 연결하려면 **설정 → 호스트 → 기기 페어링**을 여세요.
|
||||
|
||||
### CLI / 헤드리스 환경
|
||||
|
||||
CLI를 설치하고 Paseo를 시작하세요.
|
||||
|
||||
```bash
|
||||
npm install -g @getpaseo/cli
|
||||
paseo
|
||||
```
|
||||
|
||||
Paseo가 로컬에서 시작된 뒤 기기 페어링을 위한 종단 간 암호화 릴레이를 켤지 묻습니다. 거절하면 TCP, Tailscale 또는 다른 VPN으로 직접 연결할 수 있습니다. 이 방식은 서버나 원격 머신에서 유용합니다.
|
||||
|
||||
자세한 설치와 설정은 아래 문서를 참고하세요.
|
||||
|
||||
- [문서](https://paseo.sh/docs)
|
||||
- [연결 가이드](https://paseo.sh/docs/connectivity)
|
||||
- [설정 레퍼런스](https://paseo.sh/docs/configuration)
|
||||
|
||||
### Docker
|
||||
|
||||
Docker에서 Paseo 데몬과 셀프 호스팅 웹 UI를 실행하세요:
|
||||
|
||||
```bash
|
||||
docker run -d --name paseo \
|
||||
-p 6767:6767 \
|
||||
-e PASEO_PASSWORD=change-me \
|
||||
-v "$PWD/paseo-home:/home/paseo" \
|
||||
-v "$PWD:/workspace" \
|
||||
ghcr.io/getpaseo/paseo:latest
|
||||
```
|
||||
|
||||
컨테이너가 시작되면 `http://localhost:6767`을 여세요. 사용하는 에이전트 CLI를 기본 이미지에 추가한 뒤, 환경 변수나 영구 `/home/paseo` 볼륨으로 인증 정보를 설정하세요. 자세한 내용은 [Docker 문서](docs/docker.md)를 참고하세요.
|
||||
|
||||
## CLI
|
||||
|
||||
앱에서 할 수 있는 모든 작업은 터미널에서도 할 수 있습니다.
|
||||
|
||||
```bash
|
||||
paseo run --provider claude/opus-4.6 "implement user authentication"
|
||||
paseo run --provider codex/gpt-5.4 --worktree feature-x "implement feature X"
|
||||
|
||||
paseo ls # 실행 중인 에이전트 목록
|
||||
paseo attach abc123 # 실시간 출력 스트리밍
|
||||
paseo send abc123 "also add tests" # 후속 작업 전송
|
||||
|
||||
# 원격 데몬에서 실행
|
||||
paseo --host workstation.local:6767 run "run the full test suite"
|
||||
```
|
||||
|
||||
자세한 내용은 [전체 CLI 레퍼런스](https://paseo.sh/docs/cli)를 참고하세요.
|
||||
|
||||
## 스킬
|
||||
|
||||
스킬은 에이전트가 Paseo를 통해 다른 에이전트를 오케스트레이션하는 방법을 알려 줍니다.
|
||||
|
||||
```bash
|
||||
npx skills add getpaseo/paseo
|
||||
```
|
||||
|
||||
그런 다음 어떤 에이전트 대화에서든 아래 명령을 사용할 수 있습니다.
|
||||
|
||||
- `/paseo-handoff` — 에이전트 간에 작업을 넘깁니다. Claude로 계획을 세운 뒤 Codex에 구현을 넘길 때 이 기능을 씁니다.
|
||||
- `/paseo-loop` — 명확한 완료 조건을 기준으로 에이전트를 반복 실행합니다(Ralph loop). 검증 에이전트를 추가할 수도 있습니다.
|
||||
- `/paseo-advisor` — 작업 자체를 넘기지 않고, 에이전트 하나를 조언자로 띄워 두 번째 의견을 받습니다.
|
||||
- `/paseo-committee` — 서로 다른 관점의 에이전트 두 개로 위원회를 구성해, 한 발 물러나 근본 원인을 분석하고 계획을 세웁니다.
|
||||
|
||||
## 개발
|
||||
|
||||
모노레포 패키지 구성은 다음과 같습니다.
|
||||
|
||||
- `packages/server`: Paseo 데몬(에이전트 프로세스 오케스트레이션, WebSocket API, MCP 서버 제공)
|
||||
- `packages/app`: Expo 클라이언트(iOS, Android, 웹)
|
||||
- `packages/cli`: `paseo` CLI(데몬과 에이전트 워크플로)
|
||||
- `packages/desktop`: Electron 데스크톱 앱
|
||||
- `packages/relay`: 데몬과 클라이언트가 쓰는 릴레이 전송 및 암호화 패키지
|
||||
- `packages/website`: 마케팅 사이트 및 문서(`paseo.sh`)
|
||||
|
||||
자주 쓰는 명령:
|
||||
|
||||
```bash
|
||||
# 모든 로컬 개발 서비스 실행
|
||||
npm run dev
|
||||
|
||||
# 개별 환경 실행
|
||||
npm run dev:server
|
||||
npm run dev:app
|
||||
npm run dev:desktop
|
||||
npm run dev:website
|
||||
|
||||
# 서버 스택 빌드
|
||||
npm run build:server
|
||||
|
||||
# 레포 전체 검사 실행
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
## 관련 프로젝트
|
||||
|
||||
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — Elixir로 작성한 공식 분산형 릴레이
|
||||
- [paseo-skins](https://github.com/huangguang1999/paseo-skins) — 커뮤니티 테마와 Agent Skill을 제공하고, 코드 수정 없이 쓸 수 있는 데스크톱 테마 로더
|
||||
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 확장 프로그램
|
||||
|
||||
## 라이선스
|
||||
|
||||
AGPL-3.0
|
||||
@@ -7,7 +7,8 @@
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> ·
|
||||
<a href="README.zh-CN.md">简体中文</a> ·
|
||||
<a href="README.ja.md">日本語</a>
|
||||
<a href="README.ja.md">日本語</a> ·
|
||||
<a href="README.ko.md">한국어</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
|
||||
+2
-1
@@ -7,7 +7,8 @@
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> ·
|
||||
<a href="README.zh-CN.md">简体中文</a> ·
|
||||
<a href="README.ja.md">日本語</a>
|
||||
<a href="README.ja.md">日本語</a> ·
|
||||
<a href="README.ko.md">한국어</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
|
||||
+10
-1
@@ -24,6 +24,15 @@ primed.
|
||||
Idle agents remain resident indefinitely. Runtime closure happens only through an explicit lifecycle
|
||||
action such as archive, replacement, reload, workspace teardown, or daemon shutdown.
|
||||
|
||||
A provider runtime can still die on its own — crash, OOM kill, host suspend. Work the agent parked
|
||||
inside that process dies with it: Claude Code's background Bash shells, `Monitor` watches, and
|
||||
workflows all live in the CLI process, and the completion notification that would have woken the
|
||||
agent never arrives. A runtime that dies mid-turn is reported by whatever is draining its stream, but
|
||||
between turns nothing is watching, so the agent sits at `idle` looking healthy while its background
|
||||
work is gone. Report that exit as a turn failure so the agent lands in `error` with a timeline entry.
|
||||
Only the Claude provider does this today; the others still report a death only when a turn happens to
|
||||
be in flight.
|
||||
|
||||
### Cancellation
|
||||
|
||||
Cancellation changes lifecycle state only after the provider acknowledges the interrupt or emits a terminal turn event. If the interrupt is rejected or times out, the agent remains `running` with its active foreground turn intact. Follow-up actions such as replacement, reload, rewind, and Stop must report that failure instead of accepting work they cannot perform. Synthesizing a local cancellation without provider acknowledgment creates a split-brain session: Paseo accepts a new prompt while the provider still owns the previous foreground turn.
|
||||
@@ -131,7 +140,7 @@ Provider descriptors may include one compact subtitle. The provider owns its con
|
||||
|
||||
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.
|
||||
- **Not every announced task belongs in the track.** Task subagents announce as `local_agent` and workflows as `local_workflow`; a backgrounded shell announces as `local_bash` with the same `tool_use_id` shape, and ambient housekeeping sets `skip_transcript`. The Claude provider normalizes a workflow to a generic provider-subagent descriptor titled `Workflow`, using Claude's summary as its description and timeline opener. Shared storage, protocol, and UI do not distinguish it from another provider subagent.
|
||||
- **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`.
|
||||
|
||||
@@ -5,7 +5,7 @@ It validates the compositor behavior that unit tests cannot see:
|
||||
|
||||
- the resident automation `<webview>` starts in the production parking state;
|
||||
- the parked guest remains paintable and has a copyable viewport frame;
|
||||
- the resident webview guest is sized to 1280x800 logical pixels;
|
||||
- a never-presented resident webview guest defaults to 1280x800 logical pixels;
|
||||
- multiple resident webviews are parked as an overlapping stack without per-capture
|
||||
stacking changes;
|
||||
- a newly attached resident webview whose first useful frame is delayed can be captured
|
||||
@@ -82,10 +82,19 @@ is usually saved as 2560x1600.
|
||||
|
||||
Electron captures copy from the guest web contents' compositor surface. A resident
|
||||
webview parked with `display:none`, offscreen coordinates, or `opacity:0` can lose its
|
||||
copyable surface. The production parking state keeps the host fixed at `left:0`, `top:0`,
|
||||
`width:1px`, `height:1px`, `overflow:hidden`, `opacity:1`, and `pointer-events:none`.
|
||||
The webviews inside stay full-size at 1280x800, `display:inline-flex`, and absolutely
|
||||
overlap at `left:0`, `top:0`.
|
||||
copyable surface. Each production webview keeps one permanent body-level surface. Presenting
|
||||
or parking changes that surface's geometry without reparenting the webview. The parking state
|
||||
uses `left:0`, `top:0`, `width:1px`, `height:1px`, `overflow:hidden`, `opacity:1`, and
|
||||
`pointer-events:none`. The webview stays at its resolved logical viewport, defaulting to
|
||||
1280x800 before first presentation, with `display:inline-flex` at `left:0`, `top:0`.
|
||||
Presentation resolves responsive guests to the pane's exact pixel dimensions after the surface
|
||||
has visible bounds. Do not apply percentage guest sizing against the parked surface: Electron
|
||||
exposes the 1x1 parking geometry as a real guest resize before expanding it again.
|
||||
|
||||
The permanent browser host and `overlay-root` are explicit sibling paint planes. The browser
|
||||
plane stays below the overlay plane regardless of body insertion order; menus keep their relative
|
||||
layering inside `overlay-root`. Activating a presented browser also focuses its registered guest
|
||||
`WebContents` in main so macOS assigns keyboard first-responder ownership to the page.
|
||||
|
||||
There is no renderer prep/restore handshake. Main disables guest background throttling
|
||||
once when the webview attaches, then screenshot capture uses the shared serialized queue,
|
||||
|
||||
@@ -696,7 +696,7 @@ Each entry in the `models` array:
|
||||
|
||||
The built-in `claude` provider appends concrete model IDs from `~/.claude/settings.json` to its first-party Claude model list. Paseo reads the top-level `model` field and these `env` keys: `ANTHROPIC_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, and `ANTHROPIC_DEFAULT_HAIKU_MODEL`.
|
||||
|
||||
This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Paseo. When `agents.providers.claude.models` is set it **replaces** both the hardcoded first-party Claude list and any settings.json-discovered entries; use `agents.providers.claude.additionalModels` to keep the first-party list and append curated entries on top.
|
||||
This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Paseo. Explicit model IDs are passed unchanged to Claude Code, even when the same string is a compatibility alias for a built-in model. When `agents.providers.claude.models` is set it **replaces** both the hardcoded first-party Claude list and any settings.json-discovered entries; use `agents.providers.claude.additionalModels` to keep the first-party list and append curated entries on top.
|
||||
|
||||
### Gotcha: `extends: "claude"` with third-party endpoints
|
||||
|
||||
|
||||
+1
-1
@@ -341,7 +341,7 @@ One file per schedule. ID is 8 hex characters.
|
||||
### Nested: ScheduleTarget (discriminated union on `type`)
|
||||
|
||||
- `{ type: "agent", agentId: string }` — send to existing agent
|
||||
- `{ type: "new-agent", config: { provider, cwd, modeId?, model?, thinkingOptionId?, title?, approvalPolicy?, sandboxMode?, networkAccess?, webSearch?, extra?, systemPrompt?, mcpServers? } }` — create a new agent
|
||||
- `{ type: "new-agent", config: { provider, cwd, modeId?, model?, thinkingOptionId?, title?, providerOptions?, featureValues?, systemPrompt?, mcpServers? } }` — create a new agent
|
||||
|
||||
### Nested: ScheduleRun
|
||||
|
||||
|
||||
+7
-3
@@ -221,11 +221,15 @@ Selected state on rows in a desktop list+detail uses `surfaceSidebarHover` as th
|
||||
|
||||
## 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.
|
||||
There is exactly one token per status signal — `statusSuccess`, `statusDanger`, `statusWarning`, `statusMerged` — and every status surface uses it: PR state icons, CI check icons and pies, diff stats, file-change icons, status pills, usage bars. A surface does not get a quieter or louder variant because of where it sits. If a dense list feels loud, that is a density or weight problem; fix the density, not the color. The tokens are generated, not hand-picked — see the rule in `packages/app/src/styles/theme.ts` and regenerate rather than nudging one value. The level is set by the densest consumer, the sidebar workspace list.
|
||||
|
||||
Status dots — the small filled circles next to a host or agent name — are `borderRadius.full` filled with the status color (`statusSuccess`, `statusWarning`, `statusDanger`, or `foregroundMuted`). They sit in the trailing slot of a sidebar row or as a leading marker on a status pill.
|
||||
Status **dots** are the one exception, and they are a family of their own — `statusDotSuccess`, `statusDotDanger`, `statusDotWarning`, `statusDotRunning`, read only by `getStatusDotColor` (`packages/app/src/utils/status-dot-color.ts`). Same hues and the same generation rule, but their own band: pushed away from the surface rather than toward it (darker in light, lighter in dark) and at 90% of gamut chroma against the status family's 55–60%. A dot is a few points of solid color with no shape to read and no label attached, and the running one pulses, so at the status band's lightness the dots read dimmer than the metadata beside them — backwards, since the dot is the row's state. All four move together; regenerate the set, never one hue.
|
||||
|
||||
Identity badges — the project icon and the sidebar host badge — do not use the theme palette. They draw from the fixed ten-color identity table in `packages/app/src/styles/identity-colors.ts`, whose hexes are held to one contrast band so a color identifies rather than ranks. Identity color is a fill-only contract: project icons use it as a fill with a white letter, while host badges use it on their icon and retain the normal muted text color. The table is theme-independent by design; do not fork it per theme, and do not add hexes to it without recomputing the band.
|
||||
Status pills are the status token as foreground on a 10%-alpha tint of the same token (`${token}1a`), with a 20% border (`${token}33`). The `<StatusBadge>` primitive (`packages/app/src/components/ui/status-badge.tsx`) is canonical; a pill never reaches into `palette`.
|
||||
|
||||
Status dots — the small filled circles next to a host or agent name — are `borderRadius.full` filled with the status token. Which token a given agent state maps to is owned by `getStatusDotColor` (`packages/app/src/utils/status-dot-color.ts`); a row, a group header, and a project icon all call it rather than restating the mapping. They sit in the trailing slot of a sidebar row or as a leading marker on a status pill.
|
||||
|
||||
Identity badges — the project icon, the sidebar host badge, and the PR-panel participant avatar — do not use the theme palette. They draw from the fixed ten-color identity table in `packages/app/src/styles/identity-colors.ts`, whose hexes are held to one contrast band so a color identifies rather than ranks. Project icons and PR avatars use it as a fill with a white letter — that is `identityColor`, one theme-independent hex per identity. Host badges use it as a _foreground_ on both the glyph and the label, which is a different contrast problem that the fill table cannot solve: no single hex clears 4.5:1 against both a near-white and a dark sidebar. Foregrounds therefore come from `identityForeground(name, colorScheme)`, one set per scheme, hue unchanged. That set is generated on the **status family's** lightness and chroma fraction, because a meta row puts a host badge beside a CI check and a diff stat, and two families at different lightness make the brighter one shout. Change the status band and this one changes with it. A host with no color assigned falls back to `foregroundMuted`. The table is theme-independent by design; do not fork it per theme, and do not add hexes to it without recomputing the band.
|
||||
|
||||
The bespoke pills in `packages/app/src/screens/settings/host-page.tsx:97-116`, `packages/app/src/components/agent-list.tsx:607-632`, and `packages/app/src/components/sidebar-workspace-list.tsx:2889-2894` are drift to be removed. New code uses `<StatusBadge>`.
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
|
||||
- **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`).
|
||||
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`).
|
||||
- **Workspace script** — A named command a workspace can run, declared in `paseo.json`. Two types: `service` and `script` (a command that runs and finishes). UI: "Scripts" for the panel and the menu, both of which list either type. Code: `WorkspaceScriptPayload` (`packages/protocol/src/messages.ts:3126`). Don't confuse with **Service**, which is one of the two types rather than a synonym for the pair.
|
||||
- **Service** — A workspace script of type `service`: long-running, may bind a port, and carries `health` of `healthy | unhealthy | null` once the daemon has observed it. The only type a sidebar workspace row reports, because a running plain script says nothing about whether the workspace is up; the row names the service and turns danger when its health fails, and where several run the failing one takes the row's single slot. UI: "Services" in the sidebar's display preferences (**Show → Services**). Code: `selectWorkspaceServiceSummary` (`packages/app/src/components/sidebar/workspace-meta-row/service-summary.ts`), `WorkspaceScriptHealthSchema` (`packages/protocol/src/messages.ts:3124`). Forbidden: "Service" as a label for the Scripts panel, which lists both types.
|
||||
- **Schedule** — Cron-style trigger that creates new agents. UI: CLI/MCP (`paseo schedule`, `create_schedule`). Don't confuse with: Heartbeat (cron prompt back into the same agent) or Loop (iterative re-execution of one agent).
|
||||
- **Heartbeat** — Ephemeral cron prompt sent back into the same agent/conversation. Agent surfaces expose create, update cron, and delete only. Use for reminders and babysitting where status should return inline.
|
||||
- **Mode** — Provider-specific operational mode (plan, default, full-access, …). UI: icon-only. Code: `modeId` in `AgentSessionConfig` (`packages/protocol/src/messages.ts:257`).
|
||||
|
||||
+36
-12
@@ -2,7 +2,9 @@
|
||||
|
||||
Paseo Hub is an explicit opt-in connection from one Paseo daemon to one Hub. Running a daemon does
|
||||
not register it with a Hub. The relationship begins only when a user runs
|
||||
`paseo hub connect <url> --token <token>` from the daemon machine.
|
||||
`paseo hub connect [url]` from the daemon machine with an explicit API key or matching stored CLI login.
|
||||
|
||||
The human CLI login and daemon relationship are separate identities. `paseo hub login [url]` stores a durable organization-scoped CLI credential keyed by normalized Hub origin under `PASEO_HOME`. Origin resolution uses explicit command input, `PASEO_HUB_URL`, active login, then `https://hub.paseo.sh`. Connect uses exact-origin authority to request a one-time enrollment token, then passes only that token to the daemon. The daemon generates and persists its own relationship credential.
|
||||
|
||||
## Connection and authority
|
||||
|
||||
@@ -44,12 +46,30 @@ daemon, execution, and agent identity with that terminal state. Paseo never stor
|
||||
replays the original prompt. A duplicate create returns the existing agent without starting another
|
||||
turn.
|
||||
|
||||
Hub creates use the same agent creation path as trusted clients. They may select any existing
|
||||
worktree target shape and carry optional MCP server configuration for the agent session. The daemon
|
||||
keeps that configuration in its private agent record so provider sessions can recover after a
|
||||
restart; neither ordinary client snapshots and updates nor Hub projections expose session
|
||||
configuration. Execution completion policy remains outside the daemon: a completed agent turn does
|
||||
not imply that the Hub execution is terminal.
|
||||
Every Hub execution creates a fresh Paseo workspace. The workspace owns the execution's agents and
|
||||
terminals. Local checkout and worktree targets select only the workspace backing and isolation; the
|
||||
Hub cannot select or reuse an existing workspace. Hub creates use the same agent creation path as
|
||||
trusted clients. They may select any worktree target shape and carry optional MCP server configuration and provider-native
|
||||
`providerOptions` for the agent session. The daemon keeps that configuration in its private agent
|
||||
record so provider sessions can recover after a restart; neither ordinary client snapshots and
|
||||
updates nor Hub projections expose session configuration. See [providers.md](providers.md) for the
|
||||
supported provider keys.
|
||||
|
||||
Hub tool preapproval is a private, structured list of `{ kind: "mcp", server, tool }` references.
|
||||
Every reference must name an MCP server injected by the same create request. The daemon translates
|
||||
only those identities into the selected provider's native approval configuration. The protocol
|
||||
cannot name or preapprove native tools such as Bash, Edit, or Write. Explicit local or managed ask
|
||||
and deny policy takes precedence. Providers without exact MCP preapproval support reject unattended
|
||||
Hub creation instead of broadening access or waiting for an invisible prompt.
|
||||
When a create request includes a tool policy, a successful response includes
|
||||
`toolPolicyApplied: true`; absence of that acknowledgement is not success for unattended execution.
|
||||
|
||||
A Hub workflow may use read-only provider settings for classifier steps. This is defense in depth,
|
||||
not a security boundary: classifier labels and prompt intent do not authorize tools. Exact MCP grants
|
||||
and the provider's local or managed policy remain the authorization controls.
|
||||
|
||||
Execution completion policy remains outside the daemon: a completed agent turn does not imply that
|
||||
the Hub execution is terminal.
|
||||
|
||||
The Hub ends an execution by sending `hub.execution.control.request` with the durable execution ID
|
||||
and either `interrupt` or `archive`. The daemon resolves the agent from the authenticated daemon
|
||||
@@ -59,11 +79,11 @@ If no execution exists for that authenticated daemon and execution ID, interrupt
|
||||
success because the requested stopped or archived state already holds. An execution owned by another
|
||||
daemon is indistinguishable from a missing execution and is never exposed or affected.
|
||||
|
||||
Interrupt uses the ordinary agent cancellation lifecycle. Archive first archives the owned agent.
|
||||
When that agent belongs to an active Paseo-owned worktree workspace, the daemon also archives the
|
||||
workspace through the shared workspace archive service, so the backing directory is removed only
|
||||
after its final active workspace reference disappears. Local and shared checkouts archive only the
|
||||
execution-owned agent.
|
||||
Interrupt uses the ordinary agent cancellation lifecycle. Archive resolves the execution agent's
|
||||
required workspaceId and sends it through the shared workspace archive service. The service archives
|
||||
that workspace's agents and terminals, then removes Paseo-owned backing directories only after their
|
||||
final active workspace reference disappears. Local checkouts remain on disk; sibling workspaces
|
||||
sharing a backing directory remain active.
|
||||
|
||||
## Disconnect and revocation
|
||||
|
||||
@@ -81,9 +101,13 @@ opening a Hub socket. This also covers an enrollment whose request may have succ
|
||||
response was lost. `--force` removes local authority immediately and warns that remote revocation may
|
||||
still be pending.
|
||||
|
||||
`paseo hub logout` removes only the active human CLI credential and preserves credentials for other origins. Interactive logout inspects and optionally disconnects a same-origin daemon before deleting the login; a failed requested disconnect preserves the login. JSON and noninteractive logout never prompt or disconnect implicitly.
|
||||
|
||||
## Cross-repository compatibility
|
||||
|
||||
The consumer implementation lives in Paseo Cloud. Cloud owns its copy of the Hub wire schemas and
|
||||
has no Paseo runtime or build dependency. Cross-repository end-to-end verification separately builds
|
||||
a Paseo source checkout and exercises the real daemon, CLI, direct WebSocket, Cloud service, and
|
||||
Postgres. That compatibility fixture is not a package dependency or fallback implementation.
|
||||
Its `hub-e2e` ACP provider accepts only exact tool names on the injected `hub` MCP server. Other
|
||||
custom ACP providers remain unsupported for unattended preapproval.
|
||||
|
||||
+4
-2
@@ -9,11 +9,12 @@ Paseo client UI translations live in `packages/app/src/i18n`.
|
||||
- `es`
|
||||
- `fr`
|
||||
- `ja`
|
||||
- `ko`
|
||||
- `pt-BR`
|
||||
- `ru`
|
||||
- `zh-CN`
|
||||
|
||||
The persisted app language setting is `"system" | "ar" | "en" | "es" | "fr" | "ja" | "pt-BR" | "ru" | "zh-CN"`. `"system"` follows the device or browser locale when it maps to a supported locale; unsupported system locales fall back to English. Japanese maps from system locales `ja` and Japanese regional locales. Brazilian Portuguese maps from system locales `pt-BR` and bare `pt`; other Portuguese regional locales remain unsupported until explicitly added.
|
||||
The persisted app language setting is `"system" | "ar" | "en" | "es" | "fr" | "ja" | "ko" | "pt-BR" | "ru" | "zh-CN"`. `"system"` follows the device or browser locale when it maps to a supported locale; unsupported system locales fall back to English. Japanese maps from system locales `ja` and Japanese regional locales. Korean maps from system locales `ko` and Korean regional locales. Brazilian Portuguese maps from system locales `pt-BR` and bare `pt`; other Portuguese regional locales remain unsupported until explicitly added.
|
||||
|
||||
## Translation Scope
|
||||
|
||||
@@ -23,7 +24,7 @@ Do not translate agent output, daemon output, terminal contents, file paths, pro
|
||||
|
||||
## Adding Copy
|
||||
|
||||
English source strings live in `packages/app/src/i18n/resources/en.ts`. Simplified Chinese strings live in `packages/app/src/i18n/resources/zh-CN.ts`.
|
||||
English source strings live in `packages/app/src/i18n/resources/en.ts`. Each supported locale has a matching resource in `packages/app/src/i18n/resources`.
|
||||
|
||||
For migrated screens and components, use `useTranslation()` and pass translated text into UI primitives. Low-level primitives such as `<Button>` do not import translation state unless they own the text they render.
|
||||
|
||||
@@ -89,3 +90,4 @@ Within a migrated surface, do not leave mixed-language neighboring labels when t
|
||||
- Batch 4Y tightened the translation boundary so React components and custom hooks use `useTranslation()` while pure helpers keep direct `i18n.t(...)` fallbacks, and migrated remaining small UI/accessibility fallbacks across message details, menu backdrops, startup errors, sidebar PR badges, settings/project accessibility labels, composer send/create/download fallbacks, client slash-command descriptions, terminal subscribe errors, and desktop update completion text. Provider catalog metadata, shortcut registry fallbacks, agent/daemon/protocol reasons, terminal contents, raw runtime errors, and user/project/workspace names remain untranslated.
|
||||
- Batch 4Z expanded the supported locale set to the six UN official languages: Arabic, Chinese, English, French, Russian, and Spanish. Arabic, French, Russian, and Spanish now have full client-owned UI resource coverage, with key parity, fallback-ratio, and interpolation-placeholder tests guarding the generated translations. Arabic does not enable RTL layout direction in this batch.
|
||||
- Batch 5A added Brazilian Portuguese (`pt-BR`) resource coverage, language selector labels, i18next registration, and system-locale mapping for `pt-BR` and bare `pt`. Non-Brazilian Portuguese regional locales remain unsupported until a matching resource is added.
|
||||
- Batch 5B added Korean (`ko`) resource coverage, language selector labels, i18next registration, and system-locale mapping for `ko` and Korean regional locales.
|
||||
|
||||
@@ -252,6 +252,25 @@ adb emu gsm call 5551234
|
||||
|
||||
Expected result: Paseo does not throw `RuntimeException: Audio focus request failed`; native audio reports an interruption and voice mode stops or pauses coherently.
|
||||
|
||||
### Releasing the audio session when idle
|
||||
|
||||
Paseo must not hold the OS audio session once it is neither capturing nor playing, or the user's
|
||||
background music stays paused. On iOS this is not just a "while recording" problem: the
|
||||
`.playAndRecord`/`.voiceChat` category is non-mixing and survives backgrounding, and iOS re-asserts
|
||||
it every time the app returns to the foreground — so one dictation turn kills music for the life of
|
||||
the process, on every open, until the app is force-quit. Android holds
|
||||
`AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE` with the same effect.
|
||||
|
||||
`createAudioEngine` (`packages/app/src/voice/audio-engine.native.ts`) calls
|
||||
`releaseAudioSession()` whenever capture stops and the playback queue drains, and the iOS module
|
||||
also releases on `OnAppEntersBackground`. The native side re-guards on `isRecording` /
|
||||
`speechPlayer.isPlaying`, because the native engine is a singleton shared by multiple JS engine
|
||||
wrappers (voice provider + dictation) and only it knows the true state.
|
||||
|
||||
This cannot be validated by a JS test — verify on a device: play music, open Paseo, use dictation
|
||||
once, stop, and confirm the music resumes; then background/foreground the app and confirm it keeps
|
||||
playing.
|
||||
|
||||
## Unistyles + Reanimated
|
||||
|
||||
### The crash
|
||||
@@ -320,6 +339,111 @@ Platform-specific stream edges belong on `StreamStrategy`:
|
||||
|
||||
If a chat footer looks duplicated or appears above the assistant message on mobile, start with `packages/app/src/agent-stream/layout.test.ts`. Do not add a React Native renderer test for this class of bug; make the pure layout invariant fail first.
|
||||
|
||||
## Local iOS device builds
|
||||
|
||||
Native changes (anything under `packages/expo-two-way-audio/ios/`, or any new Expo module) **cannot be
|
||||
tested over Metro or EAS Update**. Those ship JS only. A stale dev-client binary silently lacks the new
|
||||
native functions, so the feature no-ops and the test proves nothing. Rebuild the binary.
|
||||
|
||||
Symptom of a stale dev client: `Cannot find native module 'ExpoDocumentPicker'` at startup, followed by a
|
||||
cascade of `Route "./X.tsx" is missing the required default export` warnings. The route warnings are not a
|
||||
router bug — the module-level throw aborts `_layout.tsx` evaluation, so its default export never gets
|
||||
assigned. One missing native module, N confusing warnings.
|
||||
|
||||
**Prerequisite: Xcode needs a signed-in Apple ID for the signing team _with a valid keychain token_.** An
|
||||
account that merely appears in Xcode → Settings → Accounts is not enough — if its `Xcode-Token` is missing,
|
||||
the build below fails at code signing and blames a _certificate_ instead. See
|
||||
[Signing needs a working Apple ID token in Xcode](#signing-needs-a-working-apple-id-token-in-xcode) before
|
||||
you start, not after it fails.
|
||||
|
||||
```bash
|
||||
cd packages/app
|
||||
npm --prefix ../.. run build:client
|
||||
APP_VARIANT=development npx expo prebuild --platform ios
|
||||
APP_VARIANT=development npx expo run:ios --device
|
||||
```
|
||||
|
||||
`APP_VARIANT=development` is required. The `ios` npm script does not set it, and `app.config.js` defaults
|
||||
to `production` — so a bare `npm run ios` builds `sh.paseo` and collides with the App Store install instead
|
||||
of the `sh.paseo.debug` dev client. Ignore prebuild's `--non-interactive is not supported` warning; use
|
||||
`CI=1` if you need non-interactive.
|
||||
|
||||
### Signing needs a working Apple ID token in Xcode
|
||||
|
||||
Device builds fail with a trio of errors that all point away from the real cause:
|
||||
|
||||
```
|
||||
error: No Accounts: Add a new account in Accounts settings.
|
||||
error: Provisioning profile "iOS Team Provisioning Profile: *" doesn't include
|
||||
signing certificate "Apple Development: <name>".
|
||||
error: Provisioning profile "iOS Team Provisioning Profile: *" doesn't include
|
||||
the aps-environment entitlement.
|
||||
```
|
||||
|
||||
**`No Accounts` does not reliably mean no account is configured.** Check the line _above_ it in the build
|
||||
log before believing it:
|
||||
|
||||
```
|
||||
DVTDeveloperAccountManager: Failed to load credentials for <apple-id>:
|
||||
Invalid credentials in keychain for <apple-id>, missing Xcode-Token
|
||||
```
|
||||
|
||||
That is the actual failure: the Apple ID is registered in Xcode, but its `Xcode-Token` keychain item is gone
|
||||
(typically after an Apple ID password change or a keychain reset). Xcode reports it as `No Accounts`, then
|
||||
falls back to whatever stale profile is cached — and if the machine holds a dev cert newer than that
|
||||
profile, you get the _certificate_ mismatch as a second-order symptom. Chasing the certificate error is a
|
||||
dead end.
|
||||
|
||||
Verify what is actually configured rather than trusting the message:
|
||||
|
||||
```bash
|
||||
# Accounts Xcode knows about
|
||||
defaults read com.apple.dt.Xcode DVTDeveloperAccountManagerAppleIDLists
|
||||
|
||||
# The credential that is actually missing (error => no token)
|
||||
security find-generic-password -s "Xcode-Token"
|
||||
```
|
||||
|
||||
Fix: Xcode → Settings → Accounts, re-sign-in to the listed Apple ID (removing and re-adding forces a fresh
|
||||
token). That restores `-allowProvisioningUpdates`, profile regeneration, and push.
|
||||
|
||||
Signing an Xcode-_managed_ profile via `CODE_SIGN_STYLE=Manual` is not a workaround; Xcode rejects it with
|
||||
`is Xcode managed, but signing settings require a manually managed profile`.
|
||||
|
||||
If you need a build on the device before the token is fixed, build unsigned and sign by hand with a cert the
|
||||
cached profile already embeds (list them with `security cms -D -i <profile>.mobileprovision`, then compare
|
||||
against `security find-identity -v -p codesigning`):
|
||||
|
||||
```bash
|
||||
xcodebuild ... CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" build
|
||||
cp <profile>.mobileprovision "$APP/embedded.mobileprovision"
|
||||
# sign nested frameworks/dylibs first, then the bundle, then:
|
||||
xcrun devicectl device install app --device <udid> "$APP"
|
||||
```
|
||||
|
||||
This is a stopgap, not a fix: `expo prebuild` regenerates `packages/app/ios/` and discards it. It also
|
||||
requires stripping entitlements the cached profile lacks (e.g. `aps-environment`), so push is dead in such a
|
||||
build.
|
||||
|
||||
To verify only that native Swift compiles, skip signing entirely — the pods have their own schemes:
|
||||
|
||||
```bash
|
||||
cd packages/app/ios
|
||||
xcodebuild -workspace PaseoDebug.xcworkspace -scheme ExpoTwoWayAudio \
|
||||
-sdk iphoneos -destination 'generic/platform=iOS' CODE_SIGNING_ALLOWED=NO build
|
||||
```
|
||||
|
||||
Confirm the log compiled _your_ file and not a cached copy: the `SwiftCompile` line should reference
|
||||
`packages/expo-two-way-audio/ios/...`, not a path inside DerivedData.
|
||||
|
||||
### Headless launch cannot verify runtime behavior
|
||||
|
||||
`xcrun devicectl device process launch` reports `The app terminated with the exit code 0` about a second
|
||||
after launch when the phone's screen is off — the app is suspended and killed before the JS bundle loads.
|
||||
This is **not** a build defect. Confirm with a control: launch `com.apple.Preferences` the same way and
|
||||
watch it exit identically. Headless install and launch prove the binary is valid and that startup reaches
|
||||
RN init; anything about on-screen behavior needs a human holding the phone.
|
||||
|
||||
## iOS Simulator
|
||||
|
||||
```bash
|
||||
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
# Local plugins
|
||||
|
||||
Local plugins contribute daemon RPCs, native app surfaces, and composer attachment sources from one
|
||||
`index.tsx`. Paseo executes the server contribution in a subprocess and evaluates the client
|
||||
contribution in the app runtime. Plugin code is trusted code; this first slice does not sandbox it.
|
||||
|
||||
## Configure a directory source
|
||||
|
||||
Add the plugin to the root `plugins` object in `$PASEO_HOME/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugins": {
|
||||
"my-plugin": {
|
||||
"source": "directory",
|
||||
"path": "/absolute/path/to/my-plugin"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The directory contains an identity-only manifest and one entry point:
|
||||
|
||||
```text
|
||||
my-plugin/
|
||||
paseo-plugin.json
|
||||
index.tsx
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my-plugin"
|
||||
}
|
||||
```
|
||||
|
||||
The manifest ID must match the config key. Restart the development daemon after changing plugin
|
||||
source or configuration; directory watching is outside the first slice.
|
||||
|
||||
## Contribute behavior and UI
|
||||
|
||||
Default export one contribution function. Paseo calls it with a plugin-scoped context. The compiler
|
||||
removes UI registrations from the server bundle and RPC registrations from the client bundle before
|
||||
resolving dependencies.
|
||||
|
||||
```tsx
|
||||
import { Text } from "react-native";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { z } from "zod";
|
||||
import { defineRpc, type PluginContext, useRpc } from "@paseo/plugin";
|
||||
|
||||
const greetRpc = defineRpc({
|
||||
name: "greet",
|
||||
input: z.object({ name: z.string() }),
|
||||
output: z.object({ message: z.string() }),
|
||||
});
|
||||
|
||||
function Greeting() {
|
||||
const greet = useRpc(greetRpc);
|
||||
const query = useQuery({
|
||||
queryKey: ["greeting", "Paseo"],
|
||||
queryFn: () => greet({ name: "Paseo" }),
|
||||
});
|
||||
return <Text>{query.data?.message}</Text>;
|
||||
}
|
||||
|
||||
export default function contribute(plugin: PluginContext) {
|
||||
plugin.handle(greetRpc, async ({ name }) => ({ message: `Hello, ${name}` }));
|
||||
plugin.addSurface("main", Greeting);
|
||||
plugin.addSidebarItem({
|
||||
id: "main",
|
||||
title: "Greeting",
|
||||
icon: "MessageCircle",
|
||||
surface: "main",
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Paseo owns the route, screen header, Lucide icon validation, close action, theme DTO, layout facts,
|
||||
and render error boundary. The contributed component owns the complete body below the header.
|
||||
|
||||
RPC contracts validate inputs and outputs in both the app and plugin subprocess. `useRpc` returns a
|
||||
typed async function. Use the host-provided `@tanstack/react-query` for request state and caching;
|
||||
Paseo gives each plugin installation its own query client.
|
||||
|
||||
When the same plugin contribution exists on multiple hosts, Paseo shows it once in the sidebar and
|
||||
adds a host picker to the screen header. The selected host supplies the bundle, RPC transport, and
|
||||
query cache. Plugin code cannot address another host.
|
||||
|
||||
## Contribute composer attachments
|
||||
|
||||
Register a declarative attachment source backed by a plugin RPC. Paseo owns the attachment menu,
|
||||
search picker, drafts, selected pill, and submission. The plugin returns complete text snapshots;
|
||||
credentials and vendor API calls stay in the daemon handler.
|
||||
|
||||
```tsx
|
||||
const searchIssues = defineRpc({
|
||||
name: "issues.search",
|
||||
input: z.object({ query: z.string() }),
|
||||
output: z.object({
|
||||
items: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
identifier: z.string(),
|
||||
title: z.string(),
|
||||
subtitle: z.string().optional(),
|
||||
url: z.string().url(),
|
||||
text: z.string(),
|
||||
resourceType: z.string(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
const issues = defineAttachmentSource({
|
||||
id: "issues",
|
||||
title: "Acme issue",
|
||||
icon: "CircleDot",
|
||||
pickerTitle: "Attach Acme issue",
|
||||
searchPlaceholder: "Search by identifier or title",
|
||||
search: searchIssues,
|
||||
});
|
||||
|
||||
export default function contribute(plugin: PluginContext) {
|
||||
plugin.handle(searchIssues, ({ query }) => searchAcmeIssues(query));
|
||||
plugin.addAttachmentSource(issues);
|
||||
}
|
||||
```
|
||||
|
||||
Attachment sources stay scoped to the composer's host. Unlike sidebar contributions, equal sources
|
||||
on several hosts are not coalesced. The selected snapshot submits as a text attachment with neutral
|
||||
external-resource presentation, so it remains readable if the plugin is removed or an older peer
|
||||
drops the optional presentation fields.
|
||||
|
||||
See `examples/local-plugin` for a native surface and `extensions/linear` for a complete official
|
||||
attachment-source extension.
|
||||
@@ -2,6 +2,41 @@
|
||||
|
||||
This guide walks through adding a new agent provider end-to-end. There are two integration patterns, and this doc covers both.
|
||||
|
||||
## Provider-native session options
|
||||
|
||||
`AgentSessionConfig.providerOptions` carries JSON-safe configuration for the selected provider. The
|
||||
names and nesting are the provider's native contract; options are not portable between providers.
|
||||
Paseo validates the object with the selected provider's strict schema before constructing a session.
|
||||
Unknown keys fail with their `providerOptions.*` path. Paseo-owned controls such as cwd, model,
|
||||
prompt, environment, session identity, MCP transport, callbacks, and hooks cannot be passed here.
|
||||
|
||||
This Paseo version accepts these keys:
|
||||
|
||||
- **Codex:** `approval_policy`, `sandbox_mode`,
|
||||
`sandbox_workspace_write.{writable_roots,network_access,exclude_slash_tmp,exclude_tmpdir_env_var}`,
|
||||
`web_search`, `features.multi_agent_v2`, and `features.network_proxy`. A network proxy object may
|
||||
contain `enabled`, `proxy_url`, `socks_url`, `enable_socks5`, `enable_socks5_udp`,
|
||||
`allow_local_binding`, `allow_upstream_proxy`, `dangerously_allow_all_unix_sockets`,
|
||||
`dangerously_allow_non_loopback_proxy`, `domains`, and `unix_sockets`. See the
|
||||
[Codex configuration reference](https://developers.openai.com/codex/config-reference).
|
||||
- **Claude:** `allowedTools`, `disallowedTools`, `additionalDirectories`, `sandbox`, and `settings`.
|
||||
The accepted sandbox fields cover enablement, fail-if-unavailable behavior, excluded and
|
||||
unsandboxed commands, filesystem read/write rules, network domain/socket/local-binding rules,
|
||||
weaker nested sandboxing, ignored violations, and the ripgrep command. `settings` accepts native
|
||||
`permissions.{allow,ask,deny}` and sandbox settings. See the
|
||||
[Claude Agent SDK TypeScript reference](https://platform.claude.com/docs/en/agent-sdk/typescript)
|
||||
and [Claude settings reference](https://code.claude.com/docs/en/settings).
|
||||
- **OpenCode:** `permission`, either one `ask`/`allow`/`deny` action or the native per-tool rule
|
||||
object. Supported entries are `read`, `edit`, `glob`, `grep`, `list`, `bash`, `task`,
|
||||
`external_directory`, `todowrite`, `question`, `webfetch`, `websearch`, `codesearch`,
|
||||
`repo_clone`, `repo_overview`, `lsp`, `doom_loop`, and `skill`. See the
|
||||
[OpenCode permissions reference](https://opencode.ai/docs/permissions/). OpenCode permissions are
|
||||
application policy, not an OS sandbox.
|
||||
|
||||
Each provider definition owns its option schema and exact MCP preapproval mapping. A new provider
|
||||
must fail closed for Hub unattended execution until it can approve one exact injected MCP server
|
||||
and tool identity without approving native tools.
|
||||
|
||||
## Two Integration Patterns
|
||||
|
||||
### ACP (Agent Client Protocol) -- recommended
|
||||
|
||||
+116
-48
@@ -9,19 +9,17 @@ A release has exactly two steps. The agent does the first, the user authorizes t
|
||||
**Preparation** (local, reversible — agent does this):
|
||||
|
||||
- format, lint, typecheck all green
|
||||
- ACP provider catalog drift checked with `npm run acp:version-drift:check`;
|
||||
if stale package-runner pins are intentional, say so explicitly, otherwise run
|
||||
`npm run acp:version-drift:update` and commit the updated catalog
|
||||
- classify the previous-stable-to-`HEAD` diff as patch or minor, then show the
|
||||
- resolve the release source to one commit and confirm that commit's existing CI is green
|
||||
- classify the diff from the previous stable to the release source as patch or minor, then show the
|
||||
target version and rationale to the user
|
||||
- draft the changelog, show it to the user, wait for review
|
||||
- run the pre-release sanity check, surface findings to the user
|
||||
- confirm CI is green
|
||||
|
||||
**Go-ahead** (user says "go ahead"):
|
||||
|
||||
- commit the approved changelog
|
||||
- run the release
|
||||
- commit the approved release inputs locally
|
||||
- run the release, which publishes npm and pushes the prepared branch and tag
|
||||
- create the release heartbeat immediately and babysit it to completion
|
||||
|
||||
Rules that apply to both steps:
|
||||
|
||||
@@ -31,12 +29,43 @@ Rules that apply to both steps:
|
||||
- Invoking a release skill is intent to start the flow, not blanket authorization to publish.
|
||||
- If the user asks for a release preview, show the prospective changelog/release contents and answer questions, but do not commit, tag, publish, or run release commands until they explicitly authorize the release.
|
||||
|
||||
## Release source and CI
|
||||
|
||||
The default release source is `origin/main`. Fetch `origin`, then record the
|
||||
resolved commit. The default release checkout is a clean local `main` whose
|
||||
`HEAD` equals `origin/main`.
|
||||
|
||||
An explicit user instruction can select another ref, such as a hotfix commit or
|
||||
tag. Resolve that ref once and apply every source, diff, and CI check to that
|
||||
commit instead of `origin/main`.
|
||||
|
||||
Before making release-preparation commits, confirm the existing CI run for the
|
||||
resolved commit is green. Pending CI is watched to completion. Release
|
||||
preparation then stays local through the changelog, any explicitly requested ACP
|
||||
catalog update, lockfile preparation, and the version commit. After approval,
|
||||
commit the prepared inputs locally and run the release command. Its branch and
|
||||
tag push is the one remote release batch and starts CI for the complete release
|
||||
commit.
|
||||
|
||||
## ACP catalog updates
|
||||
|
||||
ACP catalog work enters a release through an explicit user request:
|
||||
|
||||
- **Check ACP drift** — run `npm run acp:version-drift:check`. When drift exists,
|
||||
run `npm run acp:version-drift:update`, verify the catalog, and include the
|
||||
update in the local release-preparation commits.
|
||||
- **Update ACP** — run `npm run acp:version-drift:update`, verify the catalog, and
|
||||
include the update in the local release-preparation commits.
|
||||
|
||||
The release authorization covers the requested ACP commit. It ships in the same
|
||||
release push as the changelog and version commit.
|
||||
|
||||
## Two paths
|
||||
|
||||
There are two supported ways to ship from `main`:
|
||||
There are two supported release paths:
|
||||
|
||||
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
|
||||
2. **Beta flow**: release candidates on the `beta` channel. Betas carry an in-place changelog entry (beta users check it), publish npm only on the explicit `beta` dist-tag, and never move the website download target off the latest stable.
|
||||
2. **Beta flow**: release candidates on the `beta` channel. Each beta carries its own changelog entry, publishes npm only on the explicit `beta` dist-tag, and stays behind the Stable/Beta switch on `/download`.
|
||||
|
||||
Paseo has one linear release track even though npm dist-tags are independent
|
||||
pointers. The npm invariant is:
|
||||
@@ -48,8 +77,9 @@ pointers. The npm invariant is:
|
||||
|
||||
## Release version decision
|
||||
|
||||
Every fresh release starts by classifying the full previous-stable-to-`HEAD`
|
||||
diff. The highest-impact change determines the version:
|
||||
Every fresh release starts by classifying the full diff from the previous
|
||||
stable to the resolved release source. The highest-impact change determines the
|
||||
version:
|
||||
|
||||
- **Minor** — a user would experience the release as a significant upgrade. This
|
||||
includes substantial new workflows, providers, forges, platforms, integrations,
|
||||
@@ -72,7 +102,7 @@ as described in **Fixing a failed release build**.
|
||||
|
||||
Before running any stable release command:
|
||||
|
||||
- Make sure the intended release commit is already committed to `main` and the working tree is clean.
|
||||
- Make sure the resolved release source passed CI, the approved release inputs are committed locally on the intended branch, and the working tree is clean.
|
||||
- **Run `npm run format`, `npm run lint`, and `npm run typecheck` and commit any resulting changes BEFORE you start any `release:*` command.** `release:check` runs `npm install --workspaces --include-workspace-root` as part of `release:prepare`, which can mutate `package-lock.json` (e.g. churning `"dev": true` markers on optional deps). The next step, `version:all:*`, runs `npm version` which aborts when the working tree is dirty. If this happens mid-flight you have to commit the lockfile churn before retrying — and the pre-commit format hook will reject a lockfile-only commit because oxfmt internally skips `package-lock.json` while lefthook's glob still matches it. Avoid the whole mess by running format/lint/typecheck first, then `release:prepare` once on its own to absorb any lockfile churn into a normal commit, then start the release.
|
||||
- Do not use a release command as a substitute for checking whether the current commit is actually ready.
|
||||
|
||||
@@ -133,7 +163,7 @@ npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z
|
||||
- `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the beta tag
|
||||
- Desktop assets now come from the Electron package at `packages/desktop`
|
||||
- Beta releases use Electron's `beta` update channel. Users on the stable channel only receive stable releases; users on the beta channel receive beta releases and the final stable release when it is published.
|
||||
- **Betas carry a changelog entry.** Beta users read release notes, so each beta updates an in-place `CHANGELOG.md` entry (`## X.Y.Z-beta.N`) that `Release Notes Sync` mirrors into the prerelease body on the tag push. The entry is intermediary: promotion overwrites it in place with the final stable entry, so no `-beta.N` heading is ever left behind. See the Changelog policy section.
|
||||
- **Each beta carries its own changelog entry.** `Release Notes Sync` mirrors the matching `## X.Y.Z-beta.N` entry into that prerelease body. Promotion collapses every beta entry for the version into one final stable entry. See the Changelog policy section.
|
||||
|
||||
Use the beta path when you need to:
|
||||
|
||||
@@ -291,11 +321,30 @@ Do not treat `build_ios: SUCCESS` or `submit_ios: SUCCESS` as a completed iOS re
|
||||
|
||||
To confirm the submission landed, inspect the EAS workflow with `npx eas workflow:view <workflow-run-id> --json`. App Store Connect (review state for the matching version/build) and the Play Console track are the final ground truth.
|
||||
|
||||
### Babysitting mobile after a release
|
||||
## Release completion and heartbeat
|
||||
|
||||
The user rarely opens the Expo dashboard. A failed EAS build or submit/review job can sit silently until users complain about a stale version. After every stable release, set up a long-delay babysit that re-checks GitHub Actions, EAS builds, and the EAS `Release Mobile` workflow for the release tag. If any build is `ERRORED`/`CANCELED`, any workflow is `FAILURE`, or any required submit/review job fails, surface it immediately. If all builds are `FINISHED` and all required submit/review jobs are `SUCCESS`, confirm and stop.
|
||||
A release is **in progress** after npm publication and tag push. Report it as
|
||||
**shipped** only after every applicable build, publication, asset, manifest, and
|
||||
store submission passes the completion checklist.
|
||||
|
||||
**Use `create_heartbeat`, never `create_schedule`, for release babysitting.** Babysitting fires back into the current conversation as a wake-up prompt. `create_schedule` starts a fresh agent the user has to find and read; `create_heartbeat` surfaces the build status inline in the conversation that owns the release, where it is impossible to miss. If you find yourself reaching for `create_schedule` for a release babysit, you are about to ship a status report into a void.
|
||||
Immediately after every beta, stable, or promotion tag push, create a heartbeat
|
||||
that resumes the release in the current conversation. Create it automatically
|
||||
with `create_heartbeat`. The heartbeat owns the release until it either reaches
|
||||
the completion checklist or finds a failure that needs new user authority.
|
||||
|
||||
Each heartbeat checks the release tag commit, all GitHub Actions runs for the
|
||||
release branch and tag, npm dist-tags, the GitHub Release body and assets,
|
||||
desktop updater manifests, the published Docker image, and the applicable EAS
|
||||
workflow. Inspect the GitHub Release itself and confirm that the macOS, Linux,
|
||||
Windows, and Android APK assets are present along with the channel manifests
|
||||
(`latest-mac.yml`, `latest-linux.yml`, and `latest.yml` for stable;
|
||||
`beta-mac.yml`, `beta-linux.yml`, and `beta.yml` for beta).
|
||||
|
||||
For stable releases, also confirm every required mobile build, upload, store
|
||||
submission, and review-submission job for the release commit. For betas, confirm
|
||||
the beta EAS workflow completed its TestFlight distribution and Beta App Review
|
||||
path. Delete the heartbeat only after every applicable checklist item passes,
|
||||
then report the release as shipped.
|
||||
|
||||
Pattern:
|
||||
|
||||
@@ -303,13 +352,16 @@ Pattern:
|
||||
// mcp__paseo__create_heartbeat arguments
|
||||
{
|
||||
"name": "vX.Y.Z release babysit heartbeat",
|
||||
"cron": "*/15 * * * *",
|
||||
"maxRuns": 8, // covers ~2h of build + store-submission window
|
||||
"prompt": "Heartbeat: check vX.Y.Z release. Run gh run list, eas build:list, eas workflow:runs, and eas workflow:view for the matching Release Mobile run. Report concisely. The release is not done until desktop/APK workflows are green, EAS builds are FINISHED, Android submit_android is SUCCESS, and iOS submit_ios + submit_ios_for_review are SUCCESS. Flag any ERRORED/FAILED/CANCELED/FAILURE loudly.",
|
||||
"cron": "*/10 * * * *",
|
||||
"timezone": "UTC",
|
||||
"maxRuns": 120,
|
||||
"expiresIn": "24h",
|
||||
"prompt": "Resume the vX.Y.Z release babysit for commit <sha>. Check npm tags; every GitHub Actions run for the release branch and tag; the published GitHub Release body, expected desktop/APK assets, and channel manifests; the Docker image; and the matching EAS workflow. Completion requires every applicable checklist item. For stable, require build_ios, submit_ios, submit_ios_for_review, build_android, and submit_android to succeed. For beta, require the beta TestFlight distribution and Beta App Review path. If work is pending, wait for the next heartbeat. If a failure can be retried safely for the same version, follow the failed-release procedure; otherwise report the blocker. When every applicable completion-checklist item passes, delete THIS heartbeat, report shipped, and stop.",
|
||||
}
|
||||
```
|
||||
|
||||
Tight cadence on purpose. The first run fires immediately, giving a near-real-time status check before the conversation closes. Subsequent runs at 15-minute intervals catch transitions quickly: a failed EAS build or failed App Store review submission at +20m should not wait until +50m to surface. Keep the prompt short — the heartbeat is a status probe, not a research task — and have it bail out as soon as every platform is actually on its store path so the remaining runs do not generate noise.
|
||||
Run an immediate status check after creating the heartbeat. The heartbeat handles
|
||||
later transitions and stops itself when the release is complete.
|
||||
|
||||
## Release notes on GitHub
|
||||
|
||||
@@ -317,10 +369,12 @@ The GitHub Release body is populated automatically by the `Release Notes Sync` w
|
||||
|
||||
## Website behavior
|
||||
|
||||
- The website download page points to GitHub's latest published **stable** release.
|
||||
- Published beta prereleases are public on GitHub Releases, but they do **not** become the website download target.
|
||||
- The download target only moves when you publish the final stable release tag like `v0.1.41`.
|
||||
- The public `/changelog` page renders `CHANGELOG.md` as-is, so the in-flight `-beta.N` entry shows there once it lands on `main` — that's intended, it's where beta users check what's coming. Only the **download target** stays pinned to the latest stable; the download links read GitHub's releases API, not the changelog, so a `-beta.N` heading on top never affects them.
|
||||
- The website download page defaults to GitHub's latest published **stable** release.
|
||||
- A published beta prerelease is offered behind the Stable/Beta switch on `/download` (`?channel=beta`), never as the default. The switch only appears while the newest prerelease leads stable on its core version, so promoting `X.Y.Z-beta.N` to `X.Y.Z` retires the beta channel from the page until the next beta line opens.
|
||||
- Homebrew, the Play Store, the App Store, and `app.paseo.sh` have no beta. The Beta view drops those rows, and the whole Web section, rather than showing an inert "stable only" placeholder. When a surface gains a beta path — say a public TestFlight link — add its row back in `packages/website/src/routes/download.tsx`.
|
||||
- The default download target only moves when you publish the final stable release tag like `v0.1.41`.
|
||||
- The public `/changelog` page renders `CHANGELOG.md` as-is, so the in-flight `-beta.N` entry shows there once it lands on `main` — that's intended, it's where beta users check what's coming. Only the **default download target** stays pinned to the latest stable; the download links read GitHub's releases API, not the changelog, so a `-beta.N` heading on top never affects them.
|
||||
- The download page's "What's new" link deep-links the **minor group** anchor (`/changelog#release-0.3`), not the exact entry: promotion collapses the beta entries into one stable entry, so the minor group remains the durable target. A version with no entry in the bundled changelog — a tag whose changelog commit hasn't redeployed the site yet — links the plain `/changelog` instead of a dead anchor.
|
||||
- The website itself is deployed by `Deploy Website` (Cloudflare Workers), which redeploys on `release: published` for non-prerelease releases and on pushes to `main` that touch `CHANGELOG.md` or `packages/website/**`.
|
||||
|
||||
## Fixing a failed release build
|
||||
@@ -401,17 +455,16 @@ No prefix (`v`), no extra text. `Release Notes Sync` matches the `## X.Y.Z` (or
|
||||
|
||||
## Changelog policy
|
||||
|
||||
- `CHANGELOG.md` includes stable releases and the current beta line.
|
||||
- `CHANGELOG.md` includes stable releases and every entry in the current beta series.
|
||||
- The first beta of a version inserts a top entry like `## 0.1.60-beta.1 - YYYY-MM-DD`.
|
||||
- Each subsequent beta updates that same top entry in place — bump the heading (`0.1.60-beta.1` → `0.1.60-beta.2`) and fold in whatever else landed.
|
||||
- Stable promotion updates that same entry in place one last time: heading to `0.1.60`, date to the promotion day.
|
||||
- One entry per version line. The `-beta.N` heading is intermediary — overwrite it, never append. Don't leave stale `-beta.N` entries behind and don't create a duplicate entry per beta.
|
||||
- It always covers the full diff from the previous stable tag, regardless of how many betas were cut in between.
|
||||
- Each subsequent beta inserts a new top entry with the next beta number. Its notes cover the changes since the previous beta tag.
|
||||
- Stable promotion replaces every beta entry for that version with one `## 0.1.60 - YYYY-MM-DD` entry.
|
||||
- The promoted stable entry covers the full diff from the previous stable tag and collapses internal iterations across the beta series.
|
||||
|
||||
## Changelog ownership
|
||||
|
||||
- **The agent running the release writes the changelog entry — beta or stable.** Do not hand the changelog to another model or agent. The release agent has the release context and owns the final wording.
|
||||
- Draft the entry from the previous-stable-to-`HEAD` diff, review it against the changelog policy below, show it to the user, and wait for approval before committing it. Each beta refreshes the same entry; promotion refreshes it one last time from the full previous-stable-to-`HEAD` diff.
|
||||
- **The agent running the release writes the changelog entry — beta or stable.** The release context and final wording stay with that agent.
|
||||
- For the first beta or a direct stable release, draft from the previous stable tag to the release source. For later betas, draft from the previous beta tag to the release source. Promotion replaces the beta series with one entry drafted from the previous stable tag to the release source. Review the result against the changelog policy below, show it to the user, and wait for approval before committing it.
|
||||
|
||||
## Changelog voice
|
||||
|
||||
@@ -432,10 +485,8 @@ The changelog is shown on the Paseo homepage. Write it for **end users**, not de
|
||||
|
||||
Test: would a non-developer reader recognise what changed when using the app? If they'd need an engineer to translate ("what's a remount?"), the bullet is still implementation-facing — rewrite it as the symptom the user experiences.
|
||||
|
||||
- **Collapse internal iterations.** If a feature was added and then fixed within the same release, just list the feature as working. Users never saw the broken version.
|
||||
- **Only list changes relative to the previous stable release.** The diff is `v(previous)..HEAD`. If something was introduced and fixed between those two tags, it never shipped — don't mention the fix.
|
||||
- **Common trap:** when drafting from `git log`, every commit looks like a separate bullet — including the "fix X" commits that landed on top of a brand-new feature in the same release window. Before listing a Fixed entry, check whether the thing being fixed was itself added in this same release. If so, drop the fix and fold it into the feature bullet.
|
||||
- **Example:** if the release adds an in-app browser and also contains a commit "fix: browser pane keyboard handling no longer steals shortcuts", do **not** list the keyboard fix under Fixed. The browser is shipping for the first time, so users will only ever see the working version. The Added entry covers it.
|
||||
- **Use the entry's release scope.** Include changes within the matching range in **Changelog scope**.
|
||||
- **Collapse internal iterations within that scope.** Present a feature added and fixed in one range as working. A later beta can describe a fix to behavior delivered in an earlier beta; promotion folds the complete beta series into the final stable behavior.
|
||||
- **Cut low-signal entries.** "Toolbar buttons have consistent sizing" is too granular. Combine small polish items or drop them.
|
||||
|
||||
## Changelog conciseness
|
||||
@@ -478,7 +529,7 @@ Rules:
|
||||
|
||||
This returns every distinct GitHub login that authored or co-authored a commit in the PR. Use those logins for attribution. Fall back to `gh pr view N --json author` only if the commits command returns nothing (which should not happen for merged PRs).
|
||||
|
||||
When listing PR numbers, `git log --format='%H %s' v<previous>..HEAD | grep -E '\(#[0-9]+\)$'` pulls the PR number out of squash commit subjects.
|
||||
When listing PR numbers, `git log --format='%H %s' v<previous>..<release-source-sha> | grep -E '\(#[0-9]+\)$'` pulls the PR number out of squash commit subjects.
|
||||
|
||||
## Changelog ordering
|
||||
|
||||
@@ -492,51 +543,68 @@ Entries within each section (Added, Improved, Fixed) are ordered by user impact:
|
||||
|
||||
Before cutting a **stable** release, the release agent reviews the diff as a last line of defence against shipping bugs. Skip this for betas — the beta itself is the smoke test, and gating each beta on a code review defeats the point of using betas as fast release candidates.
|
||||
|
||||
Review the diff between the latest release tag and `HEAD`. Focus on:
|
||||
Review the diff between the latest release tag and the resolved release source. Focus on:
|
||||
|
||||
1. **Breaking changes** — especially in the WebSocket protocol, agent lifecycle, and any server↔client contract.
|
||||
2. **Backward compatibility** — the important direction is old app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Flag anything that breaks old clients against new daemons or requires both sides to update in lockstep.
|
||||
3. **Regressions** — anything that looks like it could break existing functionality.
|
||||
|
||||
Use `git diff <latest-release-tag>..HEAD` as the review input. This is a deep sanity check, not a full code review. If anything looks risky, investigate before proceeding and surface the finding to the user.
|
||||
Use `git diff <latest-release-tag>..<release-source-sha>` as the review input. This is a deep sanity check, not a full code review. If anything looks risky, investigate before proceeding and surface the finding to the user.
|
||||
|
||||
## Changelog scope
|
||||
|
||||
The changelog always covers **previous-stable-to-`HEAD`**, beta and stable alike:
|
||||
Changelog scope follows the release being described:
|
||||
|
||||
- **Beta release**: the entry covers `previous stable tag → HEAD`. Update the current in-place beta entry; don't start a fresh one per beta.
|
||||
- **Stable promotion**: the same entry is promoted in place. It still captures the full delta from the previous stable release, not just what changed since the last beta.
|
||||
- **First beta**: `previous stable tag → release source`
|
||||
- **Later beta**: `previous beta tag → release source`
|
||||
- **Direct stable release**: `previous stable tag → release source`
|
||||
- **Stable promotion**: replace the full beta series with one entry covering `previous stable tag → release source`
|
||||
|
||||
Betas are checkpoints along the way; the entry is the single record for the jump from one stable version to the next, and beta users read it in the meantime.
|
||||
Each beta entry records what its testers receive. Promotion produces the single stable record for the full jump from one stable version to the next.
|
||||
|
||||
## Completion checklist
|
||||
|
||||
### Beta release
|
||||
|
||||
- [ ] Working tree is clean and the intended commit is on `main`
|
||||
- [ ] Update the in-place beta entry in `CHANGELOG.md` (heading `## X.Y.Z-beta.N - YYYY-MM-DD`), review it against the changelog policy, get approval, and commit it before cutting the release
|
||||
- [ ] The previous-stable-to-`HEAD` diff is classified as patch or minor, with the target version and rationale approved
|
||||
- [ ] The resolved release source is the intended commit (default `origin/main`) and its existing CI is green
|
||||
- [ ] Add a new `CHANGELOG.md` entry for this beta (heading `## X.Y.Z-beta.N - YYYY-MM-DD`), review it against the changelog policy, get approval, and commit it before cutting the release
|
||||
- [ ] The diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved
|
||||
- [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag
|
||||
- [ ] `npm run release:beta:patch`, `npm run release:beta:minor`, or `npm run release:beta:next` completes successfully
|
||||
- [ ] Every GitHub Actions run for the complete release commit and tag is green
|
||||
- [ ] npm shows the version under the `beta` dist-tag, not `latest`
|
||||
- [ ] The GitHub prerelease exists with the changelog body and every expected macOS, Linux, Windows, and Android APK asset
|
||||
- [ ] GitHub `Desktop Release` workflow for the `v*-beta.N` tag is green
|
||||
- [ ] The GitHub prerelease contains `beta-mac.yml`, `beta-linux.yml`, and `beta.yml`
|
||||
- [ ] GitHub `Android APK Release` workflow for the same tag is green
|
||||
- [ ] GitHub `Docker` workflow is green and the versioned beta image is published without moving `latest`
|
||||
- [ ] GitHub `Release Notes Sync` mirrored the beta entry into the prerelease body
|
||||
- [ ] EAS `Release iOS Beta` completed its build, TestFlight distribution, external beta group, and Beta App Review path
|
||||
- [ ] The release heartbeat was created after the tag push and deleted only after every item above passed
|
||||
|
||||
### Stable release (or promotion)
|
||||
|
||||
- [ ] Run the pre-release sanity check (see above) and address any findings
|
||||
- [ ] The previous-stable-to-`HEAD` diff is classified as patch or minor, with the target version and rationale approved
|
||||
- [ ] Ensure the intended release commit is already committed and the git worktree is clean before running any release command
|
||||
- [ ] The diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved
|
||||
- [ ] The resolved release source is the intended commit (default `origin/main`) and its existing CI is green
|
||||
- [ ] Ensure the approved release inputs are committed locally and the git worktree is clean before running any release command
|
||||
- [ ] Ensure local `npm run typecheck` passes on that exact commit before running any release command
|
||||
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). When promoting from beta, overwrite the existing `## X.Y.Z-beta.N` heading in place (heading → `X.Y.Z`, date → promotion day) — do not add a new entry on top of the beta one
|
||||
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). Promotion replaces every `## X.Y.Z-beta.N` entry in the series with one `## X.Y.Z - YYYY-MM-DD` entry covering the full release
|
||||
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
|
||||
- [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag
|
||||
- [ ] `npm run release:patch`, `npm run release:minor`, or `npm run release:promote` completes successfully
|
||||
- [ ] Every GitHub Actions run for the complete release commit and tag is green
|
||||
- [ ] Move npm's `beta` dist-tag to the new stable version for every published package and verify both `latest` and `beta` resolve to it
|
||||
- [ ] The published GitHub Release exists with the changelog body and every expected macOS, Linux, Windows, and Android APK asset
|
||||
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
|
||||
- [ ] The GitHub Release contains `latest-mac.yml`, `latest-linux.yml`, and `latest.yml`
|
||||
- [ ] GitHub `Android APK Release` workflow for the same tag is green
|
||||
- [ ] GitHub `Docker` workflow is green and both the versioned and `latest` images are published
|
||||
- [ ] GitHub `Release Notes Sync` is green and the release body matches the stable changelog entry
|
||||
- [ ] EAS `Release Mobile` workflow for the same tag is green
|
||||
- [ ] EAS iOS `build_ios` completes for the same tag
|
||||
- [ ] EAS iOS `submit_ios` succeeds, uploading the build to App Store Connect/TestFlight
|
||||
- [ ] EAS iOS `submit_ios_for_review` succeeds, putting the build into App Store review
|
||||
- [ ] EAS Android `build_android` completes for the same tag
|
||||
- [ ] EAS Android `submit_android` succeeds, putting the build on its Play Store track
|
||||
- [ ] The release heartbeat was created after the tag push and deleted only after every item above passed
|
||||
|
||||
@@ -53,6 +53,11 @@ Claude hook mapping:
|
||||
- `Stop`, `StopFailure`, `SessionEnd` → `idle`
|
||||
- `Notification` with `reason` or `matcher` equal to `idle_prompt` → `needs-input`
|
||||
|
||||
Claude does not run `Stop` when the user interrupts a turn. A standalone Ctrl-C or Escape input
|
||||
while terminal activity is working clears the activity without finished attention. The same
|
||||
fallback applies to every hooked terminal agent; exact input matching excludes escape sequences and
|
||||
pasted content, while later provider idle events remain authoritative.
|
||||
|
||||
Codex hook mapping:
|
||||
|
||||
- `UserPromptSubmit` → `running`
|
||||
|
||||
+1
-1
@@ -345,7 +345,7 @@ Gotchas:
|
||||
- **Narrow the discriminated union before spreading.** `updateTheme`'s updater returns the theme union; spreading the union widens `colorScheme` to `"light" | "dark"`, which is assignable to neither concrete member. Branch on `t.colorScheme` so each branch spreads a single narrowed theme type (no `as`).
|
||||
- **`lineHeight.diff` is the code/diff line-height axis** — it is coupled to the code-font-size control (≈ `codeFontSize * 1.5`). Do NOT use it for prose. Markdown body line-height scales with the UI ramp (`Math.round(theme.fontSize.base * 1.4)`); routing prose through `lineHeight.diff` clips text at small code sizes.
|
||||
- **High-churn draft values** (live-while-typing in the appearance preview) bypass the theme: apply them as inline styles marked with `inlineUnistylesStyle` so per-keystroke values don't grow the `#unistyles-web` CSS registry.
|
||||
- **Mounted parsed content uses `AppearanceStyleBoundary`.** Markdown, syntax-highlighted code, and tool-call detail bodies can contain memoized/custom renderer trees that do not naturally re-run when runtime-patched appearance tokens change. Wrap the parsed surface once with `packages/app/src/components/appearance-style-boundary.tsx`; do not add local "appearance key" props at each callsite.
|
||||
- **The app shell uses one `AppearanceStyleBoundary`.** Runtime-patched numeric theme values are baked into Unistyles web classes rather than CSS variables, while parsed/memoized content also does not naturally re-run when appearance tokens change. The boundary sits below stable runtime providers in `app/_layout.tsx` and remounts the visual shell once. `applyAppearance` patches the active theme before inactive registry entries so its subscribers receive the committed values in the same update. Do not add local appearance keys or nested boundaries.
|
||||
- **Dynamic font tokens stay widened.** `fontFamily`, `fontSize`, and `lineHeight` on `commonTheme` are annotated `string`/`number` (not narrowed by `as const`) so the updater's return assigns; the platform default stacks live in `DEFAULT_UI_FONT_STACK` / `DEFAULT_MONO_FONT_STACK`.
|
||||
|
||||
## Debugging
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { z } from "zod";
|
||||
import { defineRpc, type PluginContext, useRpc } from "@paseo/plugin";
|
||||
|
||||
const incrementRpc = defineRpc({
|
||||
name: "increment",
|
||||
input: z.object({ value: z.number() }),
|
||||
output: z.object({ value: z.number(), handledBy: z.string() }),
|
||||
});
|
||||
|
||||
interface ExampleTheme {
|
||||
colors: {
|
||||
surface0: string;
|
||||
foreground: string;
|
||||
foregroundMuted: string;
|
||||
accent: string;
|
||||
accentForeground: string;
|
||||
statusDanger: string;
|
||||
};
|
||||
}
|
||||
|
||||
function ExampleSurface({ theme }: { theme: ExampleTheme }) {
|
||||
const callIncrement = useRpc(incrementRpc);
|
||||
const { data, error, isPending, mutate } = useMutation({ mutationFn: callIncrement });
|
||||
const value = data?.value ?? 0;
|
||||
const styles = useMemo(
|
||||
() => ({
|
||||
screen: { flex: 1, padding: 24, gap: 16, backgroundColor: theme.colors.surface0 },
|
||||
title: { color: theme.colors.foreground, fontSize: 24 },
|
||||
detail: { color: theme.colors.foregroundMuted },
|
||||
button: { padding: 14, borderRadius: 10, backgroundColor: theme.colors.accent },
|
||||
buttonText: { color: theme.colors.accentForeground, textAlign: "center" },
|
||||
error: { color: theme.colors.statusDanger },
|
||||
}),
|
||||
[theme.colors],
|
||||
);
|
||||
const handleIncrement = useCallback(() => {
|
||||
mutate({ value });
|
||||
}, [mutate, value]);
|
||||
|
||||
return (
|
||||
<View style={styles.screen}>
|
||||
<Text style={styles.title}>Native plugin surface</Text>
|
||||
<Text style={styles.detail}>{data?.handledBy ?? "The RPC has not run yet."}</Text>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Increment plugin counter, currently ${value}`}
|
||||
onPress={handleIncrement}
|
||||
style={styles.button}
|
||||
>
|
||||
<Text style={styles.buttonText}>
|
||||
{isPending ? "Calling daemon…" : `RPC counter: ${value}`}
|
||||
</Text>
|
||||
</Pressable>
|
||||
{error ? <Text style={styles.error}>{error.message}</Text> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function contribute(plugin: PluginContext) {
|
||||
plugin.handle(incrementRpc, async (input) => ({
|
||||
value: input.value + 1,
|
||||
handledBy: "plugin subprocess",
|
||||
}));
|
||||
plugin.addSurface("main", ExampleSurface);
|
||||
plugin.addSidebarItem({
|
||||
id: "main",
|
||||
title: "Local plugin",
|
||||
icon: "Blocks",
|
||||
surface: "main",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"id": "local-example"
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
# Linear
|
||||
|
||||
The official Linear extension adds `Attach Linear issue` to the composer. Search by issue identifier
|
||||
or title, then send a stable issue snapshot to the agent.
|
||||
|
||||
Set a personal API key in the daemon environment:
|
||||
|
||||
```bash
|
||||
export LINEAR_API_KEY="lin_api_..."
|
||||
```
|
||||
|
||||
Register the extension in `$PASEO_HOME/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugins": {
|
||||
"linear": {
|
||||
"source": "directory",
|
||||
"path": "/absolute/path/to/paseo/extensions/linear"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Restart the development daemon after changing its environment or plugin configuration.
|
||||
|
||||
The entry point demonstrates the complete attachment-source pattern:
|
||||
|
||||
- define one validated search RPC;
|
||||
- handle it in the daemon subprocess;
|
||||
- register a declarative attachment source for the client;
|
||||
- keep credentials and vendor API calls out of the client bundle.
|
||||
@@ -0,0 +1,45 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
defineAttachmentSource,
|
||||
defineRpc,
|
||||
type PluginAttachmentSearchPayload,
|
||||
type PluginContext,
|
||||
} from "@paseo/plugin";
|
||||
import { createLinearIssueSearch } from "./linear";
|
||||
|
||||
const SearchPayloadSchema = z.object({
|
||||
items: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
identifier: z.string(),
|
||||
title: z.string(),
|
||||
subtitle: z.string().optional(),
|
||||
url: z.string().url(),
|
||||
text: z.string(),
|
||||
resourceType: z.string(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const searchIssues = defineRpc({
|
||||
name: "issues.search",
|
||||
input: z.object({ query: z.string() }),
|
||||
output: SearchPayloadSchema,
|
||||
});
|
||||
|
||||
const issueAttachments = defineAttachmentSource({
|
||||
id: "issues",
|
||||
title: "Linear issue",
|
||||
icon: "CircleDot",
|
||||
pickerTitle: "Attach Linear issue",
|
||||
searchPlaceholder: "Search by identifier or title",
|
||||
search: searchIssues,
|
||||
});
|
||||
|
||||
export default function contribute(plugin: PluginContext) {
|
||||
plugin.handle(searchIssues, async ({ query }): Promise<PluginAttachmentSearchPayload> => {
|
||||
const linear = createLinearIssueSearch({ apiKey: process.env.LINEAR_API_KEY ?? "" });
|
||||
return linear.search(query);
|
||||
});
|
||||
plugin.addAttachmentSource(issueAttachments);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createLinearIssueSearch } from "./linear";
|
||||
|
||||
const issue = {
|
||||
id: "issue-uuid",
|
||||
identifier: "ENG-123",
|
||||
title: "Plugin attachments",
|
||||
description: "Let extensions attach external context.",
|
||||
url: "https://linear.app/acme/issue/ENG-123/plugin-attachments",
|
||||
priorityLabel: "High",
|
||||
state: { name: "In Progress" },
|
||||
assignee: { name: "Mohamed" },
|
||||
project: { name: "Paseo" },
|
||||
labels: { nodes: [{ name: "Feature" }] },
|
||||
};
|
||||
|
||||
interface CapturedRequest {
|
||||
authorization: string | undefined;
|
||||
body: { query: string; variables: Record<string, unknown> };
|
||||
}
|
||||
|
||||
async function readRequest(request: IncomingMessage): Promise<CapturedRequest> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of request) chunks.push(Buffer.from(chunk));
|
||||
return {
|
||||
authorization: request.headers.authorization,
|
||||
body: JSON.parse(Buffer.concat(chunks).toString("utf8")) as CapturedRequest["body"],
|
||||
};
|
||||
}
|
||||
|
||||
async function withLinearServer<T>(
|
||||
respond: (request: CapturedRequest, response: ServerResponse) => void,
|
||||
run: (endpoint: string) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const server = createServer((request, response) => {
|
||||
void readRequest(request).then((captured) => respond(captured, response));
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address() as AddressInfo;
|
||||
try {
|
||||
return await run(`http://127.0.0.1:${address.port}/graphql`);
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function sendJson(response: ServerResponse, body: unknown): void {
|
||||
response.writeHead(200, { "Content-Type": "application/json" });
|
||||
response.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
describe("Linear issue search", () => {
|
||||
it("resolves an exact issue identifier and formats its agent snapshot", async () => {
|
||||
const result = await withLinearServer(
|
||||
(request, response) => {
|
||||
expect(request.authorization).toBe("lin_api_test");
|
||||
expect(request.body.variables).toEqual({ id: "ENG-123" });
|
||||
sendJson(response, { data: { issue } });
|
||||
},
|
||||
async (endpoint) => {
|
||||
const linear = createLinearIssueSearch({ apiKey: "lin_api_test", endpoint });
|
||||
return linear.search("eng-123");
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
items: [
|
||||
{
|
||||
id: "issue-uuid",
|
||||
identifier: "ENG-123",
|
||||
title: "Plugin attachments",
|
||||
subtitle: "In Progress · Mohamed",
|
||||
url: issue.url,
|
||||
resourceType: "issue",
|
||||
text: [
|
||||
"Linear issue ENG-123: Plugin attachments",
|
||||
`URL: ${issue.url}`,
|
||||
"Status: In Progress",
|
||||
"Priority: High",
|
||||
"Assignee: Mohamed",
|
||||
"Project: Paseo",
|
||||
"Labels: Feature",
|
||||
"",
|
||||
"Let extensions attach external context.",
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("searches issue titles through a case-insensitive filter", async () => {
|
||||
const result = await withLinearServer(
|
||||
(request, response) => {
|
||||
expect(request.body.variables).toEqual({
|
||||
filter: { title: { containsIgnoreCase: "plugin" } },
|
||||
});
|
||||
sendJson(response, { data: { issues: { nodes: [issue] } } });
|
||||
},
|
||||
async (endpoint) => {
|
||||
const linear = createLinearIssueSearch({ apiKey: "lin_api_test", endpoint });
|
||||
return linear.search(" plugin ");
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.items.map((item) => item.identifier)).toEqual(["ENG-123"]);
|
||||
});
|
||||
|
||||
it("surfaces GraphQL errors even when HTTP succeeds", async () => {
|
||||
await expect(
|
||||
withLinearServer(
|
||||
(_request, response) => sendJson(response, { errors: [{ message: "Token expired" }] }),
|
||||
async (endpoint) => {
|
||||
const linear = createLinearIssueSearch({ apiKey: "lin_api_test", endpoint });
|
||||
return linear.search("ENG-123");
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("Token expired");
|
||||
});
|
||||
|
||||
it("requires a daemon-side API key", () => {
|
||||
expect(() => createLinearIssueSearch({ apiKey: "" })).toThrow(
|
||||
"Set LINEAR_API_KEY in the daemon environment",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const LinearIssueSchema = z.object({
|
||||
id: z.string(),
|
||||
identifier: z.string(),
|
||||
title: z.string(),
|
||||
description: z.string().nullable(),
|
||||
url: z.string(),
|
||||
priorityLabel: z.string(),
|
||||
state: z.object({ name: z.string() }),
|
||||
assignee: z.object({ name: z.string() }).nullable(),
|
||||
project: z.object({ name: z.string() }).nullable(),
|
||||
labels: z.object({ nodes: z.array(z.object({ name: z.string() })) }),
|
||||
});
|
||||
|
||||
const LinearGraphqlErrorSchema = z.object({ message: z.string() }).passthrough();
|
||||
const ExactIssueResponseSchema = z.object({
|
||||
data: z.object({ issue: LinearIssueSchema.nullable() }).nullable().optional(),
|
||||
errors: z.array(LinearGraphqlErrorSchema).optional(),
|
||||
});
|
||||
const IssueSearchResponseSchema = z.object({
|
||||
data: z
|
||||
.object({ issues: z.object({ nodes: z.array(LinearIssueSchema) }) })
|
||||
.nullable()
|
||||
.optional(),
|
||||
errors: z.array(LinearGraphqlErrorSchema).optional(),
|
||||
});
|
||||
|
||||
const ISSUE_FIELDS = `
|
||||
id
|
||||
identifier
|
||||
title
|
||||
description
|
||||
url
|
||||
priorityLabel
|
||||
state { name }
|
||||
assignee { name }
|
||||
project { name }
|
||||
labels { nodes { name } }
|
||||
`;
|
||||
|
||||
const EXACT_ISSUE_QUERY = `
|
||||
query PaseoLinearIssue($id: String!) {
|
||||
issue(id: $id) { ${ISSUE_FIELDS} }
|
||||
}
|
||||
`;
|
||||
|
||||
const SEARCH_ISSUES_QUERY = `
|
||||
query PaseoLinearIssues($filter: IssueFilter) {
|
||||
issues(first: 20, filter: $filter, orderBy: updatedAt) {
|
||||
nodes { ${ISSUE_FIELDS} }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const LINEAR_IDENTIFIER = /^[A-Z][A-Z0-9]+-\d+$/i;
|
||||
|
||||
export interface LinearAttachmentItem {
|
||||
id: string;
|
||||
identifier: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
url: string;
|
||||
text: string;
|
||||
resourceType: string;
|
||||
}
|
||||
|
||||
export interface LinearIssueSearch {
|
||||
search(query: string): Promise<{ items: LinearAttachmentItem[] }>;
|
||||
}
|
||||
|
||||
interface LinearIssueSearchOptions {
|
||||
apiKey: string;
|
||||
endpoint?: string;
|
||||
request?: typeof fetch;
|
||||
}
|
||||
|
||||
interface GraphqlRequest {
|
||||
query: string;
|
||||
variables: Record<string, unknown>;
|
||||
}
|
||||
|
||||
class LinearApiError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "LinearApiError";
|
||||
}
|
||||
}
|
||||
|
||||
function issueSubtitle(issue: z.infer<typeof LinearIssueSchema>): string | undefined {
|
||||
const parts = [issue.state.name, issue.assignee?.name].filter(
|
||||
(part): part is string => typeof part === "string" && part.length > 0,
|
||||
);
|
||||
return parts.length > 0 ? parts.join(" · ") : undefined;
|
||||
}
|
||||
|
||||
function issueText(issue: z.infer<typeof LinearIssueSchema>): string {
|
||||
const labels = issue.labels.nodes.map((label) => label.name).join(", ");
|
||||
const lines = [
|
||||
`Linear issue ${issue.identifier}: ${issue.title}`,
|
||||
`URL: ${issue.url}`,
|
||||
`Status: ${issue.state.name}`,
|
||||
`Priority: ${issue.priorityLabel}`,
|
||||
];
|
||||
if (issue.assignee) lines.push(`Assignee: ${issue.assignee.name}`);
|
||||
if (issue.project) lines.push(`Project: ${issue.project.name}`);
|
||||
if (labels) lines.push(`Labels: ${labels}`);
|
||||
lines.push("", issue.description ?? "No description.");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function toAttachmentItem(issue: z.infer<typeof LinearIssueSchema>): LinearAttachmentItem {
|
||||
const subtitle = issueSubtitle(issue);
|
||||
return {
|
||||
id: issue.id,
|
||||
identifier: issue.identifier,
|
||||
title: issue.title,
|
||||
...(subtitle ? { subtitle } : {}),
|
||||
url: issue.url,
|
||||
text: issueText(issue),
|
||||
resourceType: "issue",
|
||||
};
|
||||
}
|
||||
|
||||
function describeHttpFailure(status: number): string {
|
||||
if (status === 401 || status === 403) return "Linear rejected LINEAR_API_KEY";
|
||||
if (status === 429) return "Linear rate limit reached. Try again shortly";
|
||||
return `Linear API request failed with HTTP ${status}`;
|
||||
}
|
||||
|
||||
function throwGraphqlErrors(errors: Array<{ message: string }> | undefined): void {
|
||||
if (!errors || errors.length === 0) return;
|
||||
throw new LinearApiError(errors.map((error) => error.message).join("; "));
|
||||
}
|
||||
|
||||
export function createLinearIssueSearch(options: LinearIssueSearchOptions): LinearIssueSearch {
|
||||
const apiKey = options.apiKey.trim();
|
||||
if (!apiKey) throw new LinearApiError("Set LINEAR_API_KEY in the daemon environment");
|
||||
const endpoint = options.endpoint ?? "https://api.linear.app/graphql";
|
||||
const request = options.request ?? fetch;
|
||||
|
||||
async function graphql(body: GraphqlRequest): Promise<unknown> {
|
||||
const response = await request(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: apiKey,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!response.ok) throw new LinearApiError(describeHttpFailure(response.status));
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function findExact(identifier: string): Promise<LinearAttachmentItem[]> {
|
||||
const response = ExactIssueResponseSchema.parse(
|
||||
await graphql({ query: EXACT_ISSUE_QUERY, variables: { id: identifier } }),
|
||||
);
|
||||
throwGraphqlErrors(response.errors);
|
||||
return response.data?.issue ? [toAttachmentItem(response.data.issue)] : [];
|
||||
}
|
||||
|
||||
async function searchTitles(query: string): Promise<LinearAttachmentItem[]> {
|
||||
const filter = query ? { title: { containsIgnoreCase: query } } : null;
|
||||
const response = IssueSearchResponseSchema.parse(
|
||||
await graphql({ query: SEARCH_ISSUES_QUERY, variables: { filter } }),
|
||||
);
|
||||
throwGraphqlErrors(response.errors);
|
||||
if (!response.data) throw new LinearApiError("Linear returned no issue data");
|
||||
return response.data.issues.nodes.map(toAttachmentItem);
|
||||
}
|
||||
|
||||
return {
|
||||
async search(query: string) {
|
||||
const normalized = query.trim();
|
||||
if (LINEAR_IDENTIFIER.test(normalized)) {
|
||||
return { items: await findExact(normalized.toUpperCase()) };
|
||||
}
|
||||
const items = await searchTitles(normalized);
|
||||
return { items };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"id": "linear"
|
||||
}
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-uCYkY9w6UQamIwez+2JwbG4zPp5X+G5sb7MfZDj2KgI=
|
||||
sha256-BfRbfVUFaHQEMtDOsWAo49txz+cueRpe/7Qxltg+HSg=
|
||||
|
||||
Generated
+563
-32
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.3.0-beta.2",
|
||||
"version": "0.3.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.3.0-beta.2",
|
||||
"version": "0.3.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -2291,6 +2291,15 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/standalone": {
|
||||
"version": "7.29.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/standalone/-/standalone-7.29.1.tgz",
|
||||
"integrity": "sha512-z42abD0C6fiHfgLyCWw8PYv6FCJ0IGVtSCxXk/NPykWO5LNIEGfdLDJ3HdYqlPcAhwtQ3oKH1PvNj2JGpTxQKg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
||||
@@ -12503,6 +12512,21 @@
|
||||
"@babel/types": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__standalone": {
|
||||
"version": "7.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__standalone/-/babel__standalone-7.1.9.tgz",
|
||||
"integrity": "sha512-IcCNPLqpevUD7UpV8QB0uwQPOyoOKACFf0YtYWRHcmxcakaje4Q7dbG2+jMqxw/I8Zk0NHvEps66WwS7z/UaaA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.25.6",
|
||||
"@babel/types": "^7.25.6",
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"@types/babel__generator": "^7.6.8",
|
||||
"@types/babel__template": "^7.4.4",
|
||||
"@types/babel__traverse": "^7.20.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__template": {
|
||||
"version": "7.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
|
||||
@@ -27721,16 +27745,6 @@
|
||||
"integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-pty": {
|
||||
"version": "1.2.0-beta.11",
|
||||
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.2.0-beta.11.tgz",
|
||||
"integrity": "sha512-THcUyu1WwdgoIyUvgXOZ70EOMXzheGa0q3tbEb5kUIfKgcpBJ+AJ9Q1kq0bKtYmQzr77usXiTORZTLmAUQlnoQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-addon-api": "^7.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.36",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
|
||||
@@ -35309,8 +35323,9 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.3.0-beta.2",
|
||||
"version": "0.3.0",
|
||||
"dependencies": {
|
||||
"@babel/standalone": "^7.29.1",
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/search": "6.7.1",
|
||||
@@ -35403,6 +35418,7 @@
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/babel__standalone": "^7.1.9",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "~19.2.0",
|
||||
@@ -36363,12 +36379,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.3.0-beta.2",
|
||||
"version": "0.3.0",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.3.0-beta.2",
|
||||
"@getpaseo/protocol": "0.3.0-beta.2",
|
||||
"@getpaseo/server": "0.3.0-beta.2",
|
||||
"@getpaseo/client": "0.3.0",
|
||||
"@getpaseo/protocol": "0.3.0",
|
||||
"@getpaseo/server": "0.3.0",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -36615,10 +36631,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.3.0-beta.2",
|
||||
"version": "0.3.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.3.0-beta.2",
|
||||
"@getpaseo/relay": "0.3.0-beta.2",
|
||||
"@getpaseo/protocol": "0.3.0",
|
||||
"@getpaseo/relay": "0.3.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -36629,7 +36645,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.3.0-beta.2",
|
||||
"version": "0.3.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -36872,7 +36888,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.3.0-beta.2",
|
||||
"version": "0.3.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -37768,7 +37784,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.3.0-beta.2",
|
||||
"version": "0.3.0",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -38000,7 +38016,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.3.0-beta.2",
|
||||
"version": "0.3.0",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -38013,7 +38029,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.3.0-beta.2",
|
||||
"version": "0.3.0",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38231,15 +38247,16 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.3.0-beta.2",
|
||||
"version": "0.3.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.3.0-beta.2",
|
||||
"@getpaseo/highlight": "0.3.0-beta.2",
|
||||
"@getpaseo/protocol": "0.3.0-beta.2",
|
||||
"@getpaseo/relay": "0.3.0-beta.2",
|
||||
"@babel/parser": "^7.29.8",
|
||||
"@getpaseo/client": "0.3.0",
|
||||
"@getpaseo/highlight": "0.3.0",
|
||||
"@getpaseo/protocol": "0.3.0",
|
||||
"@getpaseo/relay": "0.3.0",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -38249,11 +38266,12 @@
|
||||
"ajv": "^8.20.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"dotenv": "^17.2.3",
|
||||
"esbuild": "^0.25.12",
|
||||
"express": "^4.18.2",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"lru-cache": "^11.5.1",
|
||||
"mnemonic-id": "^3.2.7",
|
||||
"node-pty": "1.2.0-beta.11",
|
||||
"node-pty": "1.2.0-beta.15",
|
||||
"openai": "^6.44.0",
|
||||
"p-limit": "^7.3.0",
|
||||
"p-memoize": "^8.0.0",
|
||||
@@ -38412,6 +38430,468 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"packages/server/node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
||||
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@babel/parser": {
|
||||
"version": "7.29.8",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
|
||||
"integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.29.8"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@babel/types": {
|
||||
"version": "7.29.8",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
|
||||
"integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.29.7",
|
||||
"@babel/helper-validator-identifier": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/android-arm": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
|
||||
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/android-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
|
||||
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
|
||||
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
|
||||
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
|
||||
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
|
||||
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
|
||||
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
|
||||
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
|
||||
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@isaacs/ttlcache": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-2.1.4.tgz",
|
||||
@@ -38566,6 +39046,47 @@
|
||||
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/esbuild": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.25.12",
|
||||
"@esbuild/android-arm": "0.25.12",
|
||||
"@esbuild/android-arm64": "0.25.12",
|
||||
"@esbuild/android-x64": "0.25.12",
|
||||
"@esbuild/darwin-arm64": "0.25.12",
|
||||
"@esbuild/darwin-x64": "0.25.12",
|
||||
"@esbuild/freebsd-arm64": "0.25.12",
|
||||
"@esbuild/freebsd-x64": "0.25.12",
|
||||
"@esbuild/linux-arm": "0.25.12",
|
||||
"@esbuild/linux-arm64": "0.25.12",
|
||||
"@esbuild/linux-ia32": "0.25.12",
|
||||
"@esbuild/linux-loong64": "0.25.12",
|
||||
"@esbuild/linux-mips64el": "0.25.12",
|
||||
"@esbuild/linux-ppc64": "0.25.12",
|
||||
"@esbuild/linux-riscv64": "0.25.12",
|
||||
"@esbuild/linux-s390x": "0.25.12",
|
||||
"@esbuild/linux-x64": "0.25.12",
|
||||
"@esbuild/netbsd-arm64": "0.25.12",
|
||||
"@esbuild/netbsd-x64": "0.25.12",
|
||||
"@esbuild/openbsd-arm64": "0.25.12",
|
||||
"@esbuild/openbsd-x64": "0.25.12",
|
||||
"@esbuild/openharmony-arm64": "0.25.12",
|
||||
"@esbuild/sunos-x64": "0.25.12",
|
||||
"@esbuild/win32-arm64": "0.25.12",
|
||||
"@esbuild/win32-ia32": "0.25.12",
|
||||
"@esbuild/win32-x64": "0.25.12"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/isexe": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz",
|
||||
@@ -38584,6 +39105,16 @@
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/node-pty": {
|
||||
"version": "1.2.0-beta.15",
|
||||
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.2.0-beta.15.tgz",
|
||||
"integrity": "sha512-vORSzHXi4Ofl7HemVWpuudLqCPdaQb4LfpRCUpE5HPxhp4JYscl8zZwxh11p26v2wvW24WMwnMfLjhRLixrfxA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-addon-api": "^7.1.0"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/openai": {
|
||||
"version": "6.44.0",
|
||||
"resolved": "https://registry.npmjs.org/openai/-/openai-6.44.0.tgz",
|
||||
@@ -38778,7 +39309,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.3.0-beta.2",
|
||||
"version": "0.3.0",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.3.0-beta.2",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test as base } from "../support/fixtures";
|
||||
import { expect, test as base } from "../support/fixtures";
|
||||
import {
|
||||
appendSettledTimelineTurns,
|
||||
createNearTenMegabyteAssistantPng,
|
||||
@@ -45,6 +45,131 @@ test("switching between settled agent tabs keeps a real assistant PNG rendered",
|
||||
await switchAwayAndBackWithoutImageInstability(page, { image, imageAgent, otherAgent });
|
||||
});
|
||||
|
||||
test("reloading a timeline anchors near-tail assistant image growth", async ({
|
||||
imageWorkspace: workspace,
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const image = await createSmallAssistantPng(workspace, {
|
||||
alt: "Reload geometry image",
|
||||
fileName: "reload-geometry-image.png",
|
||||
});
|
||||
const imageAgent = await createSettledMockAgent(workspace, "Reload image geometry");
|
||||
await emitSettledAssistantImage(workspace.client, imageAgent, image);
|
||||
await appendSettledTimelineTurns(workspace.client, imageAgent, 3);
|
||||
|
||||
await page.addInitScript(
|
||||
({ accessibleName }) => {
|
||||
interface ImageGeometryEvent {
|
||||
atMs: number;
|
||||
complete: boolean;
|
||||
height: number;
|
||||
naturalHeight: number;
|
||||
naturalWidth: number;
|
||||
scrollTop: number;
|
||||
width: number;
|
||||
}
|
||||
const state = {
|
||||
accessibleName,
|
||||
events: [] as ImageGeometryEvent[],
|
||||
mutationObserver: null as MutationObserver | null,
|
||||
resizeObserver: null as ResizeObserver | null,
|
||||
target: null as Element | null,
|
||||
};
|
||||
const record = () => {
|
||||
const target = state.target;
|
||||
if (!target) return;
|
||||
const imageElement =
|
||||
target instanceof HTMLImageElement ? target : target.querySelector("img");
|
||||
const scroll = target.closest('[data-testid="agent-chat-scroll"]');
|
||||
const rect = target.getBoundingClientRect();
|
||||
const event: ImageGeometryEvent = {
|
||||
atMs: performance.now(),
|
||||
complete: imageElement?.complete ?? false,
|
||||
height: rect.height,
|
||||
naturalHeight: imageElement?.naturalHeight ?? 0,
|
||||
naturalWidth: imageElement?.naturalWidth ?? 0,
|
||||
scrollTop: scroll instanceof HTMLElement ? scroll.scrollTop : -1,
|
||||
width: rect.width,
|
||||
};
|
||||
const previous = state.events.at(-1);
|
||||
if (
|
||||
!previous ||
|
||||
previous.complete !== event.complete ||
|
||||
previous.height !== event.height ||
|
||||
previous.naturalHeight !== event.naturalHeight ||
|
||||
previous.naturalWidth !== event.naturalWidth ||
|
||||
previous.scrollTop !== event.scrollTop ||
|
||||
previous.width !== event.width
|
||||
) {
|
||||
state.events.push(event);
|
||||
}
|
||||
};
|
||||
const inspect = () => {
|
||||
const target = Array.from(document.querySelectorAll('[role="img"]')).find(
|
||||
(element) => element.getAttribute("aria-label") === accessibleName,
|
||||
);
|
||||
if (!target || target === state.target) {
|
||||
record();
|
||||
return;
|
||||
}
|
||||
state.target = target;
|
||||
state.resizeObserver?.disconnect();
|
||||
state.resizeObserver = new ResizeObserver(record);
|
||||
state.resizeObserver.observe(target);
|
||||
record();
|
||||
};
|
||||
const start = () => {
|
||||
state.mutationObserver = new MutationObserver(inspect);
|
||||
state.mutationObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["src", "style"],
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
inspect();
|
||||
};
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", start, { once: true });
|
||||
} else {
|
||||
start();
|
||||
}
|
||||
(
|
||||
window as unknown as {
|
||||
__paseoReloadImageGeometry?: typeof state;
|
||||
}
|
||||
).__paseoReloadImageGeometry = state;
|
||||
},
|
||||
{ accessibleName: image.alt },
|
||||
);
|
||||
|
||||
await openAssistantImageTimeline(page, imageAgent);
|
||||
await expectAssistantImageRendered(page, image);
|
||||
await page.reload();
|
||||
await expectAssistantImageRendered(page, image);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const events = await page.evaluate(() => {
|
||||
const state = (
|
||||
window as unknown as {
|
||||
__paseoReloadImageGeometry?: { events: Array<unknown> };
|
||||
}
|
||||
).__paseoReloadImageGeometry;
|
||||
if (!state) throw new Error("Reload image geometry observer was not installed");
|
||||
return state.events;
|
||||
});
|
||||
const geometryEvents = events as Array<{ height: number; scrollTop: number }>;
|
||||
const initialGeometry = geometryEvents.find((event) => event.height > 0);
|
||||
if (!initialGeometry)
|
||||
throw new Error(`Image geometry was never measurable: ${JSON.stringify(events)}`);
|
||||
const expandedGeometry = geometryEvents.find((event) => event.height > initialGeometry.height);
|
||||
if (!expandedGeometry)
|
||||
throw new Error(`Image geometry never expanded: ${JSON.stringify(events)}`);
|
||||
expect(expandedGeometry.scrollTop, JSON.stringify(events)).toBeGreaterThan(
|
||||
initialGeometry.scrollTop,
|
||||
);
|
||||
});
|
||||
|
||||
test("a real assistant PNG remains reachable through pagination and remount", async ({
|
||||
imageWorkspace: workspace,
|
||||
page,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { openSettingsSection } from "../support/helpers/settings";
|
||||
|
||||
test("shows Pure black in the appearance picker", async ({ page }, testInfo) => {
|
||||
await page.goto("/settings");
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||
await openSettingsSection(page, "appearance");
|
||||
|
||||
const themeTrigger = page.getByLabel("Theme: System", { exact: true });
|
||||
await themeTrigger.click();
|
||||
await expect(page.getByText("Pure black", { exact: true })).toBeVisible();
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("appearance-theme-picker.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("applies the interface font size to settings text", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("@paseo:app-settings", JSON.stringify({ uiFontSize: 24 }));
|
||||
});
|
||||
await page.goto("/settings");
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||
await openSettingsSection(page, "appearance");
|
||||
|
||||
const sectionTitle = page.getByText("Theme", { exact: true }).first();
|
||||
await expect(sectionTitle).toHaveCSS("font-size", "18px");
|
||||
|
||||
const fontSizeInput = page.getByLabel("Interface font size");
|
||||
await expect(fontSizeInput).toHaveValue("24");
|
||||
await fontSizeInput.fill("12");
|
||||
await fontSizeInput.press("Tab");
|
||||
|
||||
await expect(fontSizeInput).toHaveValue("12");
|
||||
await expect(sectionTitle).toHaveCSS("font-size", "9px");
|
||||
});
|
||||
@@ -2,7 +2,22 @@ import type { BrowserContext } from "@playwright/test";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
const CROSS_STRUCTURE_MARKDOWN = [
|
||||
"# P1 — High-value UI behavior",
|
||||
"",
|
||||
"5. Claude model and thinking preferences",
|
||||
"",
|
||||
" - Switch Claude models and confirm each model retains its own thinking choice.",
|
||||
" - Cover new-workspace drafts, queued launches, schedules, and editing existing schedules.",
|
||||
" - Confirm Opus 5 exposes the Fast toggle.",
|
||||
" - Smoke with an older saved Fabel/model alias and with daemon/client version drift.",
|
||||
"",
|
||||
"6. Chat, composer, and timeline stability",
|
||||
].join("\n");
|
||||
|
||||
const ASSISTANT_MARKDOWN = [
|
||||
CROSS_STRUCTURE_MARKDOWN,
|
||||
"",
|
||||
"Direct matches:",
|
||||
"",
|
||||
"Formatted **strong prose**, _emphasized prose_, and ~~struck prose~~.",
|
||||
@@ -36,6 +51,14 @@ const ASSISTANT_MARKDOWN = [
|
||||
"",
|
||||
"```typescript",
|
||||
'const answer = "yes";',
|
||||
" return answer;",
|
||||
"```",
|
||||
"",
|
||||
"After code.",
|
||||
"",
|
||||
"```bash",
|
||||
"echo trailing",
|
||||
"",
|
||||
"```",
|
||||
"",
|
||||
"````text",
|
||||
@@ -50,12 +73,20 @@ const ASSISTANT_MARKDOWN = [
|
||||
].join("\n");
|
||||
|
||||
const EXPECTED_WHOLE_SELECTION_MARKDOWN = ASSISTANT_MARKDOWN.replace(
|
||||
"<https://autolink.example.com>",
|
||||
"[https://autolink.example.com](https://autolink.example.com)",
|
||||
).replace(
|
||||
"3. Outer three\n\n 7. Inner seven\n 8. Inner eight\n 9. Inner nine",
|
||||
"3. Outer three\n 7. Inner seven\n 8. Inner eight\n 9. Inner nine",
|
||||
);
|
||||
`${CROSS_STRUCTURE_MARKDOWN}\n\n`,
|
||||
"",
|
||||
)
|
||||
.replace(
|
||||
"<https://autolink.example.com>",
|
||||
"[https://autolink.example.com](https://autolink.example.com)",
|
||||
)
|
||||
.replace(
|
||||
"3. Outer three\n\n 7. Inner seven\n 8. Inner eight\n 9. Inner nine",
|
||||
"3. Outer three\n 7. Inner seven\n 8. Inner eight\n 9. Inner nine",
|
||||
)
|
||||
// Rendering drops the blank line before a closing fence, so copying cannot bring it
|
||||
// back. The fence is here to cover a body that ends in more than one newline.
|
||||
.replace("```bash\necho trailing\n\n```", "```bash\necho trailing\n```");
|
||||
|
||||
interface ClipboardContent {
|
||||
html: string;
|
||||
@@ -66,12 +97,15 @@ async function allowRichClipboard(context: BrowserContext): Promise<void> {
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
}
|
||||
|
||||
async function selectAssistantMessage(page: Page): Promise<void> {
|
||||
async function selectStructuredClipboardFixture(page: Page): Promise<void> {
|
||||
await selectAssistantTextRange(page, "Direct matches:", "ready");
|
||||
}
|
||||
|
||||
async function selectAssistantElement(page: Page, selector: string): Promise<void> {
|
||||
const assistantMessage = page.getByTestId("assistant-message").filter({
|
||||
hasText: "Direct matches:",
|
||||
});
|
||||
await expect(assistantMessage).toBeVisible();
|
||||
await assistantMessage.evaluate((element) => {
|
||||
await assistantMessage.locator(selector).evaluate((element) => {
|
||||
const selection = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(element);
|
||||
@@ -84,6 +118,20 @@ async function selectAssistantText(page: Page, text: string): Promise<void> {
|
||||
await selectAssistantTextRange(page, text, text);
|
||||
}
|
||||
|
||||
async function doubleClickAssistantMarkdownText(
|
||||
page: Page,
|
||||
tag: "code" | "strong",
|
||||
text: string,
|
||||
): Promise<void> {
|
||||
const assistantMessage = page.getByTestId("assistant-message").filter({
|
||||
hasText: "Direct matches:",
|
||||
});
|
||||
await assistantMessage
|
||||
.locator(`[data-paseo-markdown-tag="${tag}"]`)
|
||||
.filter({ hasText: text })
|
||||
.dblclick({ position: { x: 5, y: 5 } });
|
||||
}
|
||||
|
||||
async function selectAssistantTextRange(
|
||||
page: Page,
|
||||
startText: string,
|
||||
@@ -134,10 +182,66 @@ async function selectAssistantTextRange(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select from `startText` into the middle of the next rendered code line.
|
||||
*
|
||||
* Highlighted code splits a line across one text node per syntax token and puts the
|
||||
* newline in a node of its own, so this is a partial selection that crosses a line
|
||||
* break — the shape whose whitespace Turndown would collapse.
|
||||
*/
|
||||
async function selectAssistantAcrossCodeLines(
|
||||
page: Page,
|
||||
startText: string,
|
||||
endText: string,
|
||||
): Promise<void> {
|
||||
const assistantMessage = page.getByTestId("assistant-message").filter({
|
||||
hasText: "Direct matches:",
|
||||
});
|
||||
await assistantMessage.evaluate(
|
||||
(element, selectedRange) => {
|
||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
||||
let startNode: Node | null = null;
|
||||
let startOffset = -1;
|
||||
let textNode = walker.nextNode();
|
||||
while (textNode) {
|
||||
if (!startNode) {
|
||||
const offset = textNode.textContent?.indexOf(selectedRange.startText) ?? -1;
|
||||
if (offset >= 0) {
|
||||
startNode = textNode;
|
||||
startOffset = offset;
|
||||
}
|
||||
textNode = walker.nextNode();
|
||||
continue;
|
||||
}
|
||||
const endOffset = textNode.textContent?.indexOf(selectedRange.endText) ?? -1;
|
||||
if (endOffset >= 0) {
|
||||
const range = document.createRange();
|
||||
range.setStart(startNode, startOffset);
|
||||
range.setEnd(textNode, endOffset + selectedRange.endText.length);
|
||||
const selection = window.getSelection();
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
return;
|
||||
}
|
||||
textNode = walker.nextNode();
|
||||
}
|
||||
throw new Error(
|
||||
`Could not find a code range: ${selectedRange.startText} — ${selectedRange.endText}`,
|
||||
);
|
||||
},
|
||||
{ startText, endText },
|
||||
);
|
||||
}
|
||||
|
||||
async function copySelection(page: Page): Promise<void> {
|
||||
await page.keyboard.press("ControlOrMeta+c");
|
||||
}
|
||||
|
||||
/** The Copy button writes text/plain only, so the rich reader would throw on it. */
|
||||
async function readPlainClipboard(page: Page): Promise<string> {
|
||||
return page.evaluate(() => navigator.clipboard.readText());
|
||||
}
|
||||
|
||||
async function readRichClipboard(page: Page): Promise<ClipboardContent> {
|
||||
return page.evaluate(async () => {
|
||||
const items = await navigator.clipboard.read();
|
||||
@@ -194,17 +298,19 @@ test("copying an assistant selection preserves Markdown structure and links", as
|
||||
.filter({ hasText: "apply_patch" });
|
||||
await expect(inlineCode).toHaveAttribute("data-pmono", "");
|
||||
|
||||
await selectAssistantMessage(page);
|
||||
await selectStructuredClipboardFixture(page);
|
||||
await copySelection(page);
|
||||
|
||||
const clipboard = await readRichClipboard(page);
|
||||
expect(clipboard.plainText).toBe(EXPECTED_WHOLE_SELECTION_MARKDOWN);
|
||||
expect(clipboard.html).toContain("<ul>");
|
||||
expect(clipboard.html).not.toContain("<ul>");
|
||||
expect(clipboard.html).not.toContain("<li>");
|
||||
expect(clipboard.html).toContain("<div>- ");
|
||||
expect(clipboard.html).toContain(
|
||||
'<strong><a href="https://example.com/issues/1">First issue</a></strong>',
|
||||
);
|
||||
expect(clipboard.html).toContain("<code>apply_patch</code>");
|
||||
expect(clipboard.html).toContain('<ol start="5">');
|
||||
expect(clipboard.html).toContain("<div>5. Fifth item</div>");
|
||||
expect(clipboard.html).toContain("A hard break<br>");
|
||||
expect(clipboard.html).toContain(
|
||||
'<a href="http://www.example.com/">www.example.com</a> stays plain Markdown text.',
|
||||
@@ -234,64 +340,111 @@ test("copying an assistant selection preserves Markdown structure and links", as
|
||||
expect(clipboard.html).toContain('<th style="text-align:right">Right</th>');
|
||||
expect(clipboard.html).toContain('<th style="text-align:center">Center</th>');
|
||||
|
||||
await doubleClickAssistantMarkdownText(page, "code", "apply_patch");
|
||||
await copySelection(page);
|
||||
|
||||
const doubleClickedCodeClipboard = await readRichClipboard(page);
|
||||
expect(doubleClickedCodeClipboard.plainText).toBe("apply_patch");
|
||||
expect(doubleClickedCodeClipboard.html).not.toContain("<code>");
|
||||
|
||||
await doubleClickAssistantMarkdownText(page, "strong", "strong prose");
|
||||
await copySelection(page);
|
||||
|
||||
const doubleClickedBoldClipboard = await readRichClipboard(page);
|
||||
expect(doubleClickedBoldClipboard.plainText).toBe("strong");
|
||||
expect(doubleClickedBoldClipboard.html).not.toContain("<strong>");
|
||||
|
||||
await selectAssistantTextRange(
|
||||
page,
|
||||
"P1 — High-value UI behavior",
|
||||
"Smoke with an older saved Fabel/model alias and with daemon/client version drift.",
|
||||
);
|
||||
await copySelection(page);
|
||||
|
||||
const headingAndListClipboard = await readRichClipboard(page);
|
||||
expect(headingAndListClipboard.plainText).toBe(
|
||||
[
|
||||
"P1 — High-value UI behavior",
|
||||
"===========================",
|
||||
"",
|
||||
"5. Claude model and thinking preferences",
|
||||
" - Switch Claude models and confirm each model retains its own thinking choice.",
|
||||
" - Cover new-workspace drafts, queued launches, schedules, and editing existing schedules.",
|
||||
" - Confirm Opus 5 exposes the Fast toggle.",
|
||||
" - Smoke with an older saved Fabel/model alias and with daemon/client version drift.",
|
||||
].join("\n"),
|
||||
);
|
||||
expect(headingAndListClipboard.html).toMatch(
|
||||
/<h1>P1 — High-value UI behavior<\/h1>\s*<div>5\. Claude model and thinking preferences\s*<\/div><div>- Switch Claude models/,
|
||||
);
|
||||
|
||||
await selectAssistantText(page, "First");
|
||||
await copySelection(page);
|
||||
|
||||
const partialClipboard = await readRichClipboard(page);
|
||||
expect(partialClipboard.plainText).toBe("- **[First](https://example.com/issues/1)**");
|
||||
expect(partialClipboard.html).toContain(
|
||||
'<li><strong><a href="https://example.com/issues/1">First</a></strong></li>',
|
||||
expect(partialClipboard.plainText).toBe("First");
|
||||
expect(partialClipboard.html).toContain("<p>First</p>");
|
||||
|
||||
await selectAssistantTextRange(page, "Direct matches:", "repeated sandbox setup.");
|
||||
await copySelection(page);
|
||||
|
||||
const paragraphAndListClipboard = await readRichClipboard(page);
|
||||
expect(paragraphAndListClipboard.plainText).toBe(
|
||||
[
|
||||
"Direct matches:",
|
||||
"",
|
||||
"Formatted **strong prose**, _emphasized prose_, and ~~struck prose~~.",
|
||||
"",
|
||||
"- **[First issue](https://example.com/issues/1)**: exact `apply_patch` failure.",
|
||||
"- [Second issue](https://example.com/issues/2): repeated sandbox setup.",
|
||||
].join("\n"),
|
||||
);
|
||||
expect(paragraphAndListClipboard.html).not.toContain("<ul>");
|
||||
expect(paragraphAndListClipboard.html).not.toContain("<li>");
|
||||
expect(paragraphAndListClipboard.html).toContain(
|
||||
'<div>- <strong><a href="https://example.com/issues/1">First issue</a></strong>',
|
||||
);
|
||||
|
||||
await selectAssistantText(page, "docs");
|
||||
await copySelection(page);
|
||||
|
||||
const titledLinkClipboard = await readRichClipboard(page);
|
||||
expect(titledLinkClipboard.plainText).toBe('[docs](https://docs.example.com "Reference")');
|
||||
expect(titledLinkClipboard.html).toContain(
|
||||
'<a href="https://docs.example.com/" title="Reference">docs</a>',
|
||||
);
|
||||
expect(titledLinkClipboard.plainText).toBe("docs");
|
||||
expect(titledLinkClipboard.html).not.toContain("<a ");
|
||||
|
||||
await selectAssistantText(page, "https://autolink.example.com");
|
||||
await copySelection(page);
|
||||
|
||||
const autolinkClipboard = await readRichClipboard(page);
|
||||
expect(autolinkClipboard.plainText).toBe(
|
||||
"[https://autolink.example.com](https://autolink.example.com)",
|
||||
);
|
||||
expect(autolinkClipboard.html).toContain(
|
||||
'<a href="https://autolink.example.com/">https://autolink.example.com</a>',
|
||||
);
|
||||
expect(autolinkClipboard.plainText).toBe("https://autolink.example.com");
|
||||
|
||||
await selectAssistantText(page, "workspace file");
|
||||
await copySelection(page);
|
||||
|
||||
const fileLinkClipboard = await readRichClipboard(page);
|
||||
expect(fileLinkClipboard.plainText).toBe("[workspace file](file:///tmp/paseo%20notes.md#L4)");
|
||||
expect(fileLinkClipboard.html).toContain(
|
||||
'<a href="file:///tmp/paseo%20notes.md#L4">workspace file</a>',
|
||||
);
|
||||
expect(fileLinkClipboard.plainText).toBe("workspace file");
|
||||
expect(fileLinkClipboard.html).not.toContain("<a ");
|
||||
|
||||
await selectAssistantTextRange(page, "Seventh item", "Eighth item");
|
||||
await copySelection(page);
|
||||
|
||||
const midListClipboard = await readRichClipboard(page);
|
||||
expect(midListClipboard.plainText).toBe("7. Seventh item\n8. Eighth item");
|
||||
expect(midListClipboard.html).toContain('<ol start="7">');
|
||||
expect(midListClipboard.plainText).toBe("Seventh item\n\n8. Eighth item");
|
||||
expect(midListClipboard.html).toContain("<div>8. Eighth item</div>");
|
||||
|
||||
await selectAssistantTextRange(page, "Inner eight", "Inner nine");
|
||||
await copySelection(page);
|
||||
|
||||
const nestedListClipboard = await readRichClipboard(page);
|
||||
expect(nestedListClipboard.plainText).toBe("3. 8. Inner eight\n 9. Inner nine");
|
||||
expect(nestedListClipboard.html).toContain('<ol start="8">');
|
||||
expect(nestedListClipboard.plainText).toBe("Inner eight\n\n9. Inner nine");
|
||||
expect(nestedListClipboard.html).toContain("<div>9. Inner nine</div>");
|
||||
|
||||
await selectAssistantTextRange(page, "Seventh item", "Nested list:");
|
||||
await copySelection(page);
|
||||
|
||||
const crossBlockClipboard = await readRichClipboard(page);
|
||||
expect(crossBlockClipboard.plainText).toBe("7. Seventh item\n8. Eighth item\n\nNested list:");
|
||||
expect(crossBlockClipboard.html).toContain('<ol start="7">');
|
||||
expect(crossBlockClipboard.plainText).toBe("Seventh item\n\n8. Eighth item\n\nNested list:");
|
||||
expect(crossBlockClipboard.html).toContain("<div>8. Eighth item</div>");
|
||||
|
||||
await selectAssistantText(page, "Current");
|
||||
await copySelection(page);
|
||||
@@ -309,6 +462,60 @@ test("copying an assistant selection preserves Markdown structure and links", as
|
||||
expect(columnSliceClipboard.html).not.toContain("<table>");
|
||||
expect(columnSliceClipboard.html).toContain("ready");
|
||||
expect(columnSliceClipboard.html).toContain("<s>obsolete</s>");
|
||||
|
||||
// A partial selection loses its `pre` wrapper, so the code reaches Turndown as
|
||||
// prose unless it is diverted first: the line break and the indentation go, and
|
||||
// Markdown-significant characters get escaped.
|
||||
await selectAssistantAcrossCodeLines(page, "const", "return");
|
||||
await copySelection(page);
|
||||
|
||||
const codeLinesClipboard = await readRichClipboard(page);
|
||||
expect(codeLinesClipboard.plainText).toBe('const answer = "yes";\n return');
|
||||
expect(codeLinesClipboard.html).toContain('<pre><code class="language-typescript">');
|
||||
|
||||
// Selecting inside code expresses an intent to copy code, even when the selection
|
||||
// contains every character in the block.
|
||||
await selectAssistantElement(
|
||||
page,
|
||||
'[data-paseo-markdown-language="typescript"] [data-paseo-markdown-tag="code"]',
|
||||
);
|
||||
await copySelection(page);
|
||||
|
||||
const completeCodeClipboard = await readRichClipboard(page);
|
||||
expect(completeCodeClipboard.plainText).toBe('const answer = "yes";\n return answer;');
|
||||
|
||||
// Crossing the block boundary expresses an intent to carry its Markdown structure.
|
||||
await selectAssistantAcrossCodeLines(page, "const", "After code.");
|
||||
await copySelection(page);
|
||||
|
||||
const crossedCodeClipboard = await readRichClipboard(page);
|
||||
expect(crossedCodeClipboard.plainText).toBe(
|
||||
'```typescript\nconst answer = "yes";\n return answer;\n```\n\nAfter code.',
|
||||
);
|
||||
|
||||
// Selecting a whole line runs off its end into the newline node. A trailing
|
||||
// newline auto-executes the line when it is pasted into a terminal.
|
||||
await selectAssistantAcrossCodeLines(page, "const", " ");
|
||||
await copySelection(page);
|
||||
|
||||
expect((await readRichClipboard(page)).plainText).toBe('const answer = "yes";');
|
||||
|
||||
// The block's own Copy button had the same hazard: a Markdown fence body ends in
|
||||
// a newline, and the button copied it raw.
|
||||
const typescriptFence = assistantMessage.locator('[data-paseo-markdown-language="typescript"]');
|
||||
// The button is opacity 0 / pointerEvents none until the fence is hovered.
|
||||
await typescriptFence.hover();
|
||||
await typescriptFence.locator("[data-paseo-markdown-ignore]").click();
|
||||
|
||||
expect(await readPlainClipboard(page)).toBe('const answer = "yes";\n return answer;');
|
||||
|
||||
// A blank line before the closing fence leaves the body ending in two newlines,
|
||||
// so stripping only the terminal one still hands the terminal an executable line.
|
||||
const bashFence = assistantMessage.locator('[data-paseo-markdown-language="bash"]');
|
||||
await bashFence.hover();
|
||||
await bashFence.locator("[data-paseo-markdown-ignore]").click();
|
||||
|
||||
expect(await readPlainClipboard(page)).toBe("echo trailing");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
|
||||
@@ -34,7 +34,10 @@ import {
|
||||
type LongTimelineAgent,
|
||||
} from "../support/helpers/timeline-pagination";
|
||||
|
||||
const WIDE_VIEWPORT = { width: 1280, height: 900 };
|
||||
// Wide enough that the timeline panel clears the rail's MIN_PANEL_WIDTH with room
|
||||
// to spare. At 1280 the panel measures 960, which sits too close to the threshold
|
||||
// for chrome-width changes elsewhere to stay out of these tests.
|
||||
const WIDE_VIEWPORT = { width: 1440, height: 900 };
|
||||
const LOADED_TURNS = 16;
|
||||
|
||||
test.describe("desktop chat outline", () => {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { mkdtempSync, realpathSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { submitMessage } from "../support/helpers/composer";
|
||||
import { cleanupRewindFlow, launchAgent, type AgentHandle } from "../support/helpers/rewind-flow";
|
||||
import { openSubagentsTrack } from "../support/helpers/subagents";
|
||||
|
||||
process.env.CLAUDE_CODE_WORKFLOWS = "1";
|
||||
|
||||
const WORKFLOW_SCRIPT = path.resolve(__dirname, "../fixtures/claude-workflow/inspect-project.js");
|
||||
const REPO_ROOT = path.resolve(__dirname, "../../../..");
|
||||
const DESCRIPTION = "Inspect Paseo and explain what the project does";
|
||||
|
||||
test.describe("real Claude project-inspection workflow", () => {
|
||||
test.setTimeout(300_000);
|
||||
|
||||
test("shows the workflow's actual report in the generic detail pane", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-project-inspection-")));
|
||||
let handle: AgentHandle | undefined;
|
||||
|
||||
try {
|
||||
handle = await launchAgent({ page, provider: "claude", cwd, mode: "full-access" });
|
||||
await submitMessage(
|
||||
page,
|
||||
`Use Claude Code's Workflow tool exactly once with scriptPath ${JSON.stringify(
|
||||
WORKFLOW_SCRIPT,
|
||||
)} and args ${JSON.stringify({ repoPath: REPO_ROOT })}. Wait for completion, then reply with exactly PROJECT_INSPECTION_DONE.`,
|
||||
);
|
||||
|
||||
await expect(page.getByTestId("subagents-track-header")).toBeVisible({ timeout: 60_000 });
|
||||
await openSubagentsTrack(page);
|
||||
const row = page
|
||||
.getByTestId(/^subagents-track-row-/)
|
||||
.filter({ has: page.getByText(DESCRIPTION, { exact: true }) });
|
||||
await expect(row).toBeVisible({ timeout: 60_000 });
|
||||
await expect(row.getByRole("progressbar", { name: "Agent running" })).toHaveCount(0, {
|
||||
timeout: 180_000,
|
||||
});
|
||||
await expect(
|
||||
page.getByTestId("assistant-message").filter({ hasText: "PROJECT_INSPECTION_DONE" }).last(),
|
||||
).toBeVisible({ timeout: 180_000 });
|
||||
|
||||
await row.click();
|
||||
const panel = page.getByTestId("provider-subagent-panel");
|
||||
await expect(panel).toBeVisible({ timeout: 30_000 });
|
||||
for (const heading of [
|
||||
"What Paseo is",
|
||||
"Who it is for",
|
||||
"How it works",
|
||||
"Repository structure",
|
||||
]) {
|
||||
await expect(panel.getByText(heading, { exact: true })).toBeVisible();
|
||||
}
|
||||
await expect(panel.getByText("export const meta", { exact: false })).toHaveCount(0);
|
||||
await expect(
|
||||
panel.getByText("Inspect the Paseo repository at", { exact: false }),
|
||||
).toHaveCount(0);
|
||||
await page.screenshot({ path: testInfo.outputPath("project-workflow-pane.png") });
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { mkdtempSync, realpathSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
askClaudeToRunWorkflow,
|
||||
expectSingleWorkflowParentCard,
|
||||
expectWorkflowCompleted,
|
||||
expectWorkflowRunning,
|
||||
openWorkflowTimeline,
|
||||
releaseWorkflow,
|
||||
} from "../support/helpers/claude-workflow";
|
||||
import { cleanupRewindFlow, launchAgent, type AgentHandle } from "../support/helpers/rewind-flow";
|
||||
|
||||
// Claude Code currently gates the Workflow tool behind its own rollout. The real-provider test
|
||||
// opts in explicitly so it tests Paseo's integration rather than the account's rollout cohort.
|
||||
process.env.CLAUDE_CODE_WORKFLOWS = "1";
|
||||
|
||||
const WORKFLOW_SCRIPT = path.resolve(__dirname, "../fixtures/claude-workflow/one-child.js");
|
||||
|
||||
test.describe("real Claude workflow subagent row", () => {
|
||||
test.setTimeout(300_000);
|
||||
|
||||
test("shows a real Claude workflow running and completed through the generic subagent UI", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-claude-workflow-row-")));
|
||||
const gatePath = path.join(cwd, "release-workflow");
|
||||
let handle: AgentHandle | undefined;
|
||||
|
||||
try {
|
||||
handle = await launchAgent({ page, provider: "claude", cwd, mode: "full-access" });
|
||||
|
||||
await test.step("ask Claude to run the workflow", async () => {
|
||||
await askClaudeToRunWorkflow(handle!, WORKFLOW_SCRIPT, gatePath);
|
||||
await expectWorkflowRunning(page);
|
||||
await page.screenshot({ path: testInfo.outputPath("workflow-running.png") });
|
||||
releaseWorkflow(gatePath);
|
||||
});
|
||||
|
||||
await test.step("see the workflow finish without leaving a running row", async () => {
|
||||
await expectWorkflowCompleted(page);
|
||||
await expectSingleWorkflowParentCard(page);
|
||||
await page.screenshot({ path: testInfo.outputPath("workflow-completed.png") });
|
||||
});
|
||||
|
||||
await test.step("open the workflow through the existing provider-subagent pane", async () => {
|
||||
await openWorkflowTimeline(page);
|
||||
await page.screenshot({ path: testInfo.outputPath("workflow-timeline.png") });
|
||||
});
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { openWorkspaceWithAgents } from "../support/helpers/archive-tab";
|
||||
import {
|
||||
expectNoCollapsedComposerToolbarFrame,
|
||||
recordComposerToolbarFrames,
|
||||
} from "../support/helpers/composer-control-density";
|
||||
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
|
||||
const SETTLE_MS = 1_000;
|
||||
|
||||
async function seedSettledMockAgent(workspace: SeededWorkspace, title: string) {
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
model: "ten-second-stream",
|
||||
modeId: "load-test",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title,
|
||||
});
|
||||
await workspace.client.waitForAgentUpsert(
|
||||
agent.id,
|
||||
(snapshot) => snapshot.status === "idle",
|
||||
30_000,
|
||||
);
|
||||
return { id: agent.id, title, cwd: workspace.repoPath, workspaceId: workspace.workspaceId };
|
||||
}
|
||||
|
||||
function visibleAgentTab(page: import("@playwright/test").Page, agentId: string) {
|
||||
return page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }).first();
|
||||
}
|
||||
|
||||
test.describe("Composer control density across tab switches", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("switching between agent tabs never paints a collapsed composer toolbar", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "composer-density-agents-" });
|
||||
try {
|
||||
const first = await seedSettledMockAgent(workspace, "First chat");
|
||||
const second = await seedSettledMockAgent(workspace, "Second chat");
|
||||
await openWorkspaceWithAgents(page, [first, second]);
|
||||
|
||||
await recordComposerToolbarFrames(page);
|
||||
await visibleAgentTab(page, first.id).click();
|
||||
await page.waitForTimeout(SETTLE_MS);
|
||||
await visibleAgentTab(page, second.id).click();
|
||||
await page.waitForTimeout(SETTLE_MS);
|
||||
|
||||
await expectNoCollapsedComposerToolbarFrame(page);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("switching between draft tabs never paints a collapsed composer toolbar", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "composer-density-drafts-" });
|
||||
try {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"@paseo:create-agent-preferences",
|
||||
JSON.stringify({
|
||||
provider: "mock",
|
||||
providerPreferences: { mock: { mode: "load-test" } },
|
||||
}),
|
||||
);
|
||||
});
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await clickNewChat(page);
|
||||
|
||||
const draftTabs = page
|
||||
.locator('[data-testid^="workspace-tab-draft"]')
|
||||
.filter({ visible: true });
|
||||
await expect(draftTabs).toHaveCount(2, { timeout: 30_000 });
|
||||
await expect(
|
||||
page.locator('[data-testid="mode-control"]').filter({ visible: true }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await recordComposerToolbarFrames(page);
|
||||
await draftTabs.nth(0).click();
|
||||
await page.waitForTimeout(SETTLE_MS);
|
||||
await draftTabs.nth(1).click();
|
||||
await page.waitForTimeout(SETTLE_MS);
|
||||
|
||||
await expectNoCollapsedComposerToolbarFrame(page);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { connectSeedClient, type SeedDaemonClient } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { addConnectedHostsAndReload } from "../support/helpers/hosts";
|
||||
import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui";
|
||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||
|
||||
@@ -29,6 +30,7 @@ interface CreatedProject {
|
||||
|
||||
interface CrossHostProjectScenario {
|
||||
contextWorkspaceId: string;
|
||||
primarySharedWorkspaceId: string;
|
||||
selectedProjectViewKey: string;
|
||||
sharedProjectKey: string;
|
||||
secondaryHost: {
|
||||
@@ -149,6 +151,7 @@ const test = base.extend<{ crossHostProject: CrossHostProjectScenario }>({
|
||||
|
||||
await provide({
|
||||
contextWorkspaceId: contextProject.workspaceId,
|
||||
primarySharedWorkspaceId: primarySharedProject.workspaceId,
|
||||
selectedProjectViewKey: projectPlacementViewKey(
|
||||
getServerId(),
|
||||
primarySharedProject.projectId,
|
||||
@@ -196,4 +199,33 @@ test.describe("New workspace host project preservation", () => {
|
||||
|
||||
await expectNewWorkspaceProjectSelected(page, SHARED_PROJECT_NAME);
|
||||
});
|
||||
|
||||
test("keeps the active workspace project selected when switching hosts", async ({
|
||||
page,
|
||||
crossHostProject,
|
||||
}) => {
|
||||
await openProjectDirectoryWithHosts(page, {
|
||||
hosts: [crossHostProject.secondaryHost],
|
||||
primaryLabel: PRIMARY_HOST_LABEL,
|
||||
});
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
workspaceId: crossHostProject.primarySharedWorkspaceId,
|
||||
});
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await expectNewWorkspaceProjectSelected(page, SHARED_PROJECT_NAME);
|
||||
|
||||
await selectNewWorkspaceHost(page, SECONDARY_HOST_LABEL);
|
||||
|
||||
await expectNewWorkspaceProjectSelected(page, SHARED_PROJECT_NAME);
|
||||
|
||||
await addConnectedHostsAndReload(page, [crossHostProject.secondaryHost], {
|
||||
primaryLabel: PRIMARY_HOST_LABEL,
|
||||
});
|
||||
await expectNewWorkspaceProjectSelected(page, SHARED_PROJECT_NAME);
|
||||
await selectNewWorkspaceHost(page, SECONDARY_HOST_LABEL);
|
||||
|
||||
await expectNewWorkspaceProjectSelected(page, SHARED_PROJECT_NAME);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
import {
|
||||
expectNewWorkspaceDraft,
|
||||
fillNewWorkspaceDraft,
|
||||
openNewWorkspaceComposer,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
attachButton,
|
||||
expectChatComposerActive,
|
||||
expectTerminalComposerActive,
|
||||
fillTerminalPrompt,
|
||||
seedTerminalProfiles,
|
||||
selectChatLaunch,
|
||||
selectLaunchOption,
|
||||
terminalPromptInput,
|
||||
type TerminalProfile,
|
||||
type TerminalProfileSeed,
|
||||
} from "../support/helpers/new-workspace-launch";
|
||||
|
||||
const PROFILE: TerminalProfile = {
|
||||
id: "e2e-composer-profile",
|
||||
name: "Composer Profile",
|
||||
command: "/bin/echo",
|
||||
args: ["{{{prompt}}}"],
|
||||
};
|
||||
|
||||
test.describe("New workspace: the composer is one control in two modes", () => {
|
||||
let workspace: SeededWorkspace;
|
||||
let profileSeed: TerminalProfileSeed;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
workspace = await seedWorkspace({ repoPrefix: "launch-composer-" });
|
||||
profileSeed = await seedTerminalProfiles([PROFILE]);
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await profileSeed.restore();
|
||||
await workspace?.cleanup();
|
||||
});
|
||||
|
||||
test("switching launch target does not leak draft text between chat and terminal, and drops the chat-only controls", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: workspace.projectKey,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
});
|
||||
|
||||
await test.step("a chat draft does not leak into the terminal prompt", async () => {
|
||||
await fillNewWorkspaceDraft(page, "a chat-only draft");
|
||||
await selectLaunchOption(page, PROFILE.id);
|
||||
await expectTerminalComposerActive(page);
|
||||
await expect(terminalPromptInput(page)).toHaveValue("");
|
||||
});
|
||||
|
||||
await test.step("the attachment button and mic are absent in terminal mode, and the submit button is inside the same surface", async () => {
|
||||
await expect(attachButton(page)).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Start dictation" })).toHaveCount(0);
|
||||
await expect(
|
||||
page.locator('[data-testid="message-input-root"]:visible').getByRole("button", {
|
||||
name: "Launch",
|
||||
}),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("a terminal draft does not leak back into chat, which keeps its own draft", async () => {
|
||||
await fillTerminalPrompt(page, "a terminal-only draft");
|
||||
await selectChatLaunch(page);
|
||||
await expectChatComposerActive(page);
|
||||
await expectNewWorkspaceDraft(page, "a chat-only draft");
|
||||
});
|
||||
|
||||
await test.step("each side keeps its own draft across toggles, without either leaking into the other", async () => {
|
||||
await selectLaunchOption(page, PROFILE.id);
|
||||
await expectTerminalComposerActive(page);
|
||||
await expect(terminalPromptInput(page)).toHaveValue("a terminal-only draft");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
import {
|
||||
openNewWorkspaceComposer,
|
||||
submitNewWorkspacePrompt,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
expectChatLaunchSelected,
|
||||
expectTerminalLaunchSelected,
|
||||
expectTerminalOutputContains,
|
||||
expectWorkspaceOpensWithTerminalTab,
|
||||
fillTerminalPrompt,
|
||||
patchTerminalProfiles,
|
||||
seedTerminalProfiles,
|
||||
selectChatLaunch,
|
||||
selectLaunchOption,
|
||||
submitTerminalLaunch,
|
||||
type TerminalProfile,
|
||||
type TerminalProfileSeed,
|
||||
} from "../support/helpers/new-workspace-launch";
|
||||
|
||||
// `sleep` keeps the terminal alive long enough for the UI to attach and
|
||||
// render output before the process exits — see new-workspace-launch-terminal.spec.ts.
|
||||
const PROMPT_PROFILE: TerminalProfile = {
|
||||
id: "e2e-memory-profile",
|
||||
name: "Memory Profile",
|
||||
command: "/bin/sh",
|
||||
args: ["-c", 'echo remembered: "$0"; sleep 10', "{{{prompt}}}"],
|
||||
};
|
||||
|
||||
function hasLeftNewWorkspaceRoute(url: URL): boolean {
|
||||
return !/\/new(?:\?.*)?$/.test(`${url.pathname}${url.search}`);
|
||||
}
|
||||
|
||||
test.describe("New workspace: launch target memory", () => {
|
||||
let workspace: SeededWorkspace;
|
||||
let profileSeed: TerminalProfileSeed;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
workspace = await seedWorkspace({ repoPrefix: "launch-memory-" });
|
||||
profileSeed = await seedTerminalProfiles([PROMPT_PROFILE]);
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await profileSeed.restore();
|
||||
await workspace?.cleanup();
|
||||
});
|
||||
|
||||
test("remembers the launch target across visits, and a removed profile falls back to chat instead of a dead entry", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
const openNewWorkspace = () =>
|
||||
openNewWorkspaceComposer(page, {
|
||||
projectKey: workspace.projectKey,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
});
|
||||
|
||||
await test.step("launching a terminal profile preselects it the next time New workspace opens", async () => {
|
||||
await openNewWorkspace();
|
||||
await selectLaunchOption(page, PROMPT_PROFILE.id);
|
||||
await fillTerminalPrompt(page, "remember me");
|
||||
await submitTerminalLaunch(page);
|
||||
await expectWorkspaceOpensWithTerminalTab(page);
|
||||
await expectTerminalOutputContains(page, "remembered: remember me");
|
||||
|
||||
await openNewWorkspace();
|
||||
await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name);
|
||||
});
|
||||
|
||||
await test.step("switching to Chat and submitting preselects Chat the next time", async () => {
|
||||
await selectChatLaunch(page);
|
||||
await expectChatLaunchSelected(page);
|
||||
await submitNewWorkspacePrompt(page, "hello from chat");
|
||||
// The chat handoff navigates asynchronously (draft -> created agent). Let
|
||||
// it fully settle before moving on, so it can't fire a late redirect
|
||||
// that races the next step's navigation.
|
||||
await page.waitForURL(hasLeftNewWorkspaceRoute, { timeout: 30_000 });
|
||||
|
||||
await openNewWorkspace();
|
||||
await expectChatLaunchSelected(page);
|
||||
});
|
||||
|
||||
await test.step("re-selecting the terminal profile, then removing it daemon-side, falls back to Chat rather than a dead entry", async () => {
|
||||
await selectLaunchOption(page, PROMPT_PROFILE.id);
|
||||
await fillTerminalPrompt(page, "second run");
|
||||
await submitTerminalLaunch(page);
|
||||
await expectWorkspaceOpensWithTerminalTab(page);
|
||||
|
||||
await patchTerminalProfiles([]);
|
||||
|
||||
await openNewWorkspace();
|
||||
await expectChatLaunchSelected(page);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
import { openNewWorkspaceComposer } from "../support/helpers/new-workspace";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
expectTerminalLaunchSelected,
|
||||
expectTerminalOutputContains,
|
||||
expectTerminalPreviewCommand,
|
||||
expectWorkspaceOpensWithTerminalTab,
|
||||
fillTerminalPrompt,
|
||||
seedTerminalProfiles,
|
||||
selectLaunchOption,
|
||||
submitTerminalLaunch,
|
||||
terminalLaunchSubmit,
|
||||
terminalPromptInput,
|
||||
type TerminalProfile,
|
||||
type TerminalProfileSeed,
|
||||
} from "../support/helpers/new-workspace-launch";
|
||||
|
||||
// Someone who wants a terminal agent instead of a chat: they pick a profile
|
||||
// from the meta-row control, type a prompt, and the daemon actually spawns the
|
||||
// resolved command with their prompt substituted in — no mocking Paseo's own
|
||||
// code, the "profile" here is a real, harmless binary the daemon really runs.
|
||||
//
|
||||
// The sentinel becomes `$0` (a positional shell parameter, not string
|
||||
// interpolation into the script source), so the prompt reaches the process
|
||||
// exactly as typed with no injection risk. `sleep` keeps the terminal alive
|
||||
// long enough for the UI to attach and render the output before it exits —
|
||||
// a bare `echo` exits before the frontend subscribes to its output.
|
||||
const PROMPT_PROFILE: TerminalProfile = {
|
||||
id: "e2e-echo-prompt",
|
||||
name: "Echo Prompt",
|
||||
command: "/bin/sh",
|
||||
args: ["-c", 'echo captured: "$0"; sleep 10', "{{{prompt}}}"],
|
||||
};
|
||||
|
||||
// No sentinel anywhere: takes no prompt, composer goes read-only.
|
||||
const BARE_PROFILE: TerminalProfile = {
|
||||
id: "e2e-echo-bare",
|
||||
name: "Bare Echo",
|
||||
command: "/bin/sh",
|
||||
args: ["-c", "echo bare-launch-static-line; sleep 10"],
|
||||
};
|
||||
|
||||
test.describe("New workspace: launching a terminal", () => {
|
||||
let workspace: SeededWorkspace;
|
||||
let profileSeed: TerminalProfileSeed;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
workspace = await seedWorkspace({ repoPrefix: "launch-terminal-" });
|
||||
profileSeed = await seedTerminalProfiles([PROMPT_PROFILE, BARE_PROFILE]);
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await profileSeed.restore();
|
||||
await workspace?.cleanup();
|
||||
});
|
||||
|
||||
test("a profile that takes a prompt launches with it as its trailing argument; a profile without the sentinel launches bare from Launch", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
await test.step("pick a profile that takes a prompt, type one, and submit", async () => {
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: workspace.projectKey,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
});
|
||||
await selectLaunchOption(page, PROMPT_PROFILE.id);
|
||||
await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name);
|
||||
|
||||
await fillTerminalPrompt(page, "fix the flaky test");
|
||||
await submitTerminalLaunch(page);
|
||||
});
|
||||
|
||||
await test.step("the workspace opens with a terminal tab, and the profile received the prompt", async () => {
|
||||
await expectWorkspaceOpensWithTerminalTab(page);
|
||||
await expectTerminalOutputContains(page, "captured: fix the flaky test");
|
||||
});
|
||||
|
||||
await test.step("a profile without the sentinel is read-only and shows the resolved command", async () => {
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: workspace.projectKey,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
});
|
||||
await selectLaunchOption(page, BARE_PROFILE.id);
|
||||
await expect(terminalPromptInput(page)).toHaveCount(0);
|
||||
await expectTerminalPreviewCommand(page, "/bin/sh -c echo bare-launch-static-line; sleep 10");
|
||||
await expect(terminalLaunchSubmit(page)).toHaveText("Launch");
|
||||
});
|
||||
|
||||
await test.step("Launch starts the profile bare, with no prompt to send", async () => {
|
||||
await submitTerminalLaunch(page);
|
||||
await expectWorkspaceOpensWithTerminalTab(page);
|
||||
await expectTerminalOutputContains(page, "bare-launch-static-line");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
|
||||
import {
|
||||
openNewWorkspaceComposer,
|
||||
openStartingRefPicker,
|
||||
selectBranchInPicker,
|
||||
selectWorkspaceIsolation,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { seedSavedSettingsHosts } from "../support/helpers/settings";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
|
||||
const LONG_HOST_NAME =
|
||||
"development-macbook-pro.local-connected-through-a-very-long-private-hostname";
|
||||
const LONG_BRANCH_NAME =
|
||||
"feat/app/add-amoled-theme-with-a-very-long-pull-request-and-branch-description";
|
||||
|
||||
function measureControlRightEdges(controls: HTMLElement[]) {
|
||||
const measurements: Array<{ label: string | null; right: number }> = [];
|
||||
for (const control of controls) {
|
||||
const rect = control.getBoundingClientRect();
|
||||
measurements.push({ label: control.getAttribute("aria-label"), right: rect.right });
|
||||
}
|
||||
return measurements;
|
||||
}
|
||||
|
||||
test.describe("New workspace metadata row layout", () => {
|
||||
let workspace: SeededWorkspace;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
workspace = await seedWorkspace({
|
||||
repoPrefix: "new-workspace-layout-",
|
||||
repo: { branches: [LONG_BRANCH_NAME] },
|
||||
});
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await workspace?.cleanup();
|
||||
});
|
||||
|
||||
test("long host and branch names stay inside the composer's right rail", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 2048, height: 878 });
|
||||
await seedSavedSettingsHosts(page, [
|
||||
{
|
||||
serverId: getServerId(),
|
||||
label: LONG_HOST_NAME,
|
||||
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||
},
|
||||
{
|
||||
serverId: "srv_e2e_layout_offline",
|
||||
label: "Offline host",
|
||||
endpoint: "127.0.0.1:9",
|
||||
},
|
||||
]);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: workspace.projectKey,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
});
|
||||
await selectWorkspaceIsolation(page, "worktree");
|
||||
await openStartingRefPicker(page);
|
||||
await selectBranchInPicker(page, LONG_BRANCH_NAME);
|
||||
|
||||
const composer = page.locator('[data-testid="message-input-root"]:visible');
|
||||
const metadataRow = page.getByTestId("new-workspace-ref-picker-row");
|
||||
await expect(composer).toBeVisible();
|
||||
await expect(metadataRow).toBeVisible();
|
||||
|
||||
const [composerBox, controlBoxes] = await Promise.all([
|
||||
composer.boundingBox(),
|
||||
metadataRow.getByRole("button").evaluateAll(measureControlRightEdges),
|
||||
]);
|
||||
expect(composerBox).not.toBeNull();
|
||||
const composerRightRail = composerBox!.x + composerBox!.width;
|
||||
|
||||
for (const control of controlBoxes) {
|
||||
expect(
|
||||
control.right,
|
||||
`${control.label ?? "Metadata control"} crossed the composer rail`,
|
||||
).toBeLessThanOrEqual(composerRightRail + 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,8 @@ interface ProviderSubagentCase {
|
||||
provider: RewindFlowProvider;
|
||||
sentinel: string;
|
||||
expectedName: string;
|
||||
expectedSubtitle?: RegExp;
|
||||
expectsUserMessage?: boolean;
|
||||
prompt: string;
|
||||
providerConfig?: Parameters<typeof launchAgent>[0]["providerConfig"];
|
||||
}
|
||||
@@ -32,16 +34,19 @@ const cases: ProviderSubagentCase[] = [
|
||||
provider: "codex",
|
||||
sentinel: "CODEX_CHILD_SENTINEL",
|
||||
expectedName: "Sentinel child",
|
||||
providerConfig: { extra: { codex: { features: { multi_agent_v2: true } } } },
|
||||
providerConfig: { providerOptions: { features: { multi_agent_v2: true } } },
|
||||
prompt:
|
||||
'Use the native collaboration.spawn_agent tool exactly once with task_name "sentinel_child" and fork_turns "none". Ask it to reply with exactly CODEX_CHILD_SENTINEL and do nothing else. Wait for it with collaboration.wait_agent, then reply ROOT_DONE. Do not use Paseo tools.',
|
||||
},
|
||||
{
|
||||
provider: "opencode",
|
||||
sentinel: "OPENCODE_CHILD_SENTINEL",
|
||||
expectedName: "Explore",
|
||||
expectedName: "Verify OpenCode descriptor",
|
||||
expectedSubtitle: /explore · gpt-5\.4(?: · [^\n·]+)? · \d+(?:\.\d+)?k? tokens/i,
|
||||
expectsUserMessage: false,
|
||||
providerConfig: { model: "openai/gpt-5.4" },
|
||||
prompt:
|
||||
"Use the task tool exactly once with the explore subagent. Ask it to reply with exactly OPENCODE_CHILD_SENTINEL and do nothing else. Wait for it, then reply ROOT_DONE.",
|
||||
'Use the task tool exactly once with description "Verify OpenCode descriptor" and the explore subagent. Ask it to reply with exactly OPENCODE_CHILD_SENTINEL and do nothing else. Wait for it, then reply ROOT_DONE.',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -51,7 +56,7 @@ test.describe("real provider subagent timelines", () => {
|
||||
for (const scenario of cases) {
|
||||
test(`${scenario.provider} exposes native child output from the subagent track`, async ({
|
||||
page,
|
||||
}) => {
|
||||
}, testInfo) => {
|
||||
const cwd = realpathSync(
|
||||
mkdtempSync(path.join(tmpdir(), `paseo-provider-subagent-${scenario.provider}-`)),
|
||||
);
|
||||
@@ -71,13 +76,52 @@ test.describe("real provider subagent timelines", () => {
|
||||
const rows = page.locator('[data-testid^="subagents-track-row-"]');
|
||||
await expect(rows).toHaveCount(1, { timeout: 60_000 });
|
||||
await expect(rows.first()).toContainText(scenario.expectedName);
|
||||
if (scenario.expectedSubtitle) {
|
||||
await expect(rows.first()).toContainText(scenario.expectedSubtitle);
|
||||
const desktopTrackScreenshot = testInfo.outputPath(
|
||||
`${scenario.provider}-subagent-track-desktop.png`,
|
||||
);
|
||||
await page.screenshot({ path: desktopTrackScreenshot });
|
||||
await testInfo.attach(`${scenario.provider} subagent track desktop`, {
|
||||
path: desktopTrackScreenshot,
|
||||
contentType: "image/png",
|
||||
});
|
||||
}
|
||||
await rows.first().click();
|
||||
|
||||
const panel = page.getByTestId("provider-subagent-panel");
|
||||
await expect(panel).toBeVisible({ timeout: 30_000 });
|
||||
await expect(
|
||||
panel.getByTestId("user-message").filter({ hasText: scenario.sentinel }),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
if (scenario.expectedSubtitle) {
|
||||
await expect(page.getByTestId("provider-subagent-pane-subtitle")).toHaveText(
|
||||
scenario.expectedSubtitle,
|
||||
);
|
||||
const desktopScreenshot = testInfo.outputPath(
|
||||
`${scenario.provider}-subagent-desktop.png`,
|
||||
);
|
||||
await page.screenshot({ path: desktopScreenshot });
|
||||
await testInfo.attach(`${scenario.provider} subagent desktop`, {
|
||||
path: desktopScreenshot,
|
||||
contentType: "image/png",
|
||||
});
|
||||
await page.setViewportSize({ width: 494, height: 862 });
|
||||
await expect(page.getByTestId("provider-subagent-pane-subtitle")).toHaveText(
|
||||
scenario.expectedSubtitle,
|
||||
);
|
||||
const compactScreenshot = testInfo.outputPath(
|
||||
`${scenario.provider}-subagent-compact.png`,
|
||||
);
|
||||
await page.screenshot({ path: compactScreenshot });
|
||||
await testInfo.attach(`${scenario.provider} subagent compact`, {
|
||||
path: compactScreenshot,
|
||||
contentType: "image/png",
|
||||
});
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
}
|
||||
if (scenario.expectsUserMessage !== false) {
|
||||
await expect(
|
||||
panel.getByTestId("user-message").filter({ hasText: scenario.sentinel }),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
await expect(
|
||||
panel.getByTestId("assistant-message").filter({ hasText: scenario.sentinel }),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { test } from "@playwright/test";
|
||||
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
||||
import { expectRunningAgentChrome } from "../support/helpers/agent-stream";
|
||||
import { startLocalElixirRelay } from "../support/helpers/local-elixir-relay";
|
||||
import { seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import {
|
||||
startPackagedWebDaemon,
|
||||
type PackagedWebDaemon,
|
||||
} from "../support/helpers/packaged-web-daemon";
|
||||
import {
|
||||
connectDaemonWebAppOnlyThroughRelay,
|
||||
measureRelayRestartDuringStream,
|
||||
} from "../support/helpers/relay-deployment";
|
||||
|
||||
test("a streaming chat recovers from a relay deployment without user action", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(180_000);
|
||||
const relay = await startLocalElixirRelay();
|
||||
let daemon: PackagedWebDaemon | null = null;
|
||||
let cleanupChat = async () => {};
|
||||
|
||||
try {
|
||||
daemon = await startPackagedWebDaemon({ relayEndpoint: relay.endpoint });
|
||||
const runningDaemon = daemon;
|
||||
await test.step("Connect the daemon-served app only through the relay", async () => {
|
||||
await connectDaemonWebAppOnlyThroughRelay(page, runningDaemon);
|
||||
});
|
||||
|
||||
await test.step("Open a running mock-provider chat", async () => {
|
||||
const chat = await seedMockAgentWorkspace({
|
||||
repoPrefix: "relay-deployment-reconnect-",
|
||||
title: "Relay deployment stream",
|
||||
port: runningDaemon.port,
|
||||
model: "five-minute-stream",
|
||||
initialPrompt: "Stream while the relay is deployed.",
|
||||
});
|
||||
cleanupChat = chat.cleanup;
|
||||
const route = buildHostAgentDetailRoute(
|
||||
runningDaemon.serverId,
|
||||
chat.agentId,
|
||||
chat.workspaceId,
|
||||
);
|
||||
await page.goto(new URL(route, runningDaemon.origin).toString());
|
||||
await expectRunningAgentChrome(page, "Relay deployment stream");
|
||||
});
|
||||
|
||||
await test.step("Restart the relay and measure the visible interruption", async () => {
|
||||
const measurements = await measureRelayRestartDuringStream({
|
||||
page,
|
||||
relay,
|
||||
agentTitle: "Relay deployment stream",
|
||||
});
|
||||
await testInfo.attach("relay-deployment-measurements", {
|
||||
body: JSON.stringify(measurements, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
console.log(`Relay deployment measurements: ${JSON.stringify(measurements)}`);
|
||||
});
|
||||
} finally {
|
||||
try {
|
||||
await cleanupChat();
|
||||
} finally {
|
||||
try {
|
||||
await daemon?.close();
|
||||
} finally {
|
||||
await relay.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -140,7 +140,8 @@ test.describe("Schedules", () => {
|
||||
await expectSettled(projectTrigger);
|
||||
await expect(modelTrigger).toContainText("Ten second stream", { timeout: 30_000 });
|
||||
await expectSettled(modelTrigger);
|
||||
await expect(thinkingTrigger).toHaveCount(0);
|
||||
await expect(thinkingTrigger).toContainText("Low");
|
||||
await expectSettled(thinkingTrigger);
|
||||
await expect(modeTrigger).toBeVisible({ timeout: 30_000 });
|
||||
await expectSettled(modeTrigger);
|
||||
await expect(page.getByTestId("cadence-mode")).toHaveCount(0);
|
||||
|
||||
@@ -217,7 +217,8 @@ test.describe("Schedules project target", () => {
|
||||
const modelTrigger = page.getByTestId("schedule-model-trigger");
|
||||
await expect(modelTrigger).toContainText("Ten second stream");
|
||||
await expectSettled(modelTrigger);
|
||||
await expect(page.getByTestId("schedule-thinking-trigger")).toHaveCount(0);
|
||||
await expect(page.getByTestId("schedule-thinking-trigger")).toContainText("Low");
|
||||
await expectSettled(page.getByTestId("schedule-thinking-trigger"));
|
||||
await expect(page.getByTestId("schedule-mode-trigger")).toBeVisible();
|
||||
await expectSettled(page.getByTestId("schedule-mode-trigger"));
|
||||
await expect(page.getByTestId("schedule-isolation-trigger")).toHaveCount(0);
|
||||
@@ -327,7 +328,8 @@ test.describe("Schedules project target", () => {
|
||||
await selectModelByLabel(page, "Ten second stream");
|
||||
await expect(modelTrigger).toContainText("Ten second stream");
|
||||
await expectSettled(modelTrigger);
|
||||
await expect(thinkingTrigger).toHaveCount(0);
|
||||
await expect(thinkingTrigger).toContainText("Low");
|
||||
await expectSettled(thinkingTrigger);
|
||||
await expect(modeTrigger).toBeVisible();
|
||||
await expectSettled(modeTrigger);
|
||||
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { openSessions } from "../support/helpers/archive-tab";
|
||||
import { createIdleAgent } from "../support/helpers/archive-tab";
|
||||
import { addConnectedHostAndReload, addOfflineHostAndReload } from "../support/helpers/hosts";
|
||||
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
||||
import { connectSeedClient, seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
// The constants module is plain TypeScript, so the Playwright runner can import
|
||||
// it — reaching through the `.tsx` picker would drag React Native into Node.
|
||||
import { ALL_HOSTS_OPTION_ID } from "@/components/hosts/host-picker-constants";
|
||||
|
||||
const AGENT_ROW = '[data-testid^="agent-row-"]';
|
||||
const PRIMARY_LABEL = "Primary box";
|
||||
const SECONDARY_LABEL = "Secondary box";
|
||||
|
||||
/** Scopes every assertion to this spec's sessions on a shared daemon. */
|
||||
const NONCE = `hsx${randomUUID().replaceAll("-", "").slice(0, 8)}`;
|
||||
|
||||
async function search(page: Page, query: string): Promise<void> {
|
||||
await page.getByTestId("sessions-search-input").fill(query);
|
||||
}
|
||||
|
||||
async function selectHost(page: Page, serverId: string): Promise<void> {
|
||||
await page.getByTestId("sessions-host-filter-trigger").click();
|
||||
await page.getByTestId(`sessions-host-filter-item-${serverId}`).click();
|
||||
}
|
||||
|
||||
async function expectRankedTitles(page: Page, titles: string[]): Promise<void> {
|
||||
const rows = page.locator(AGENT_ROW).filter({ hasText: NONCE });
|
||||
await expect(rows).toHaveCount(titles.length, { timeout: 30_000 });
|
||||
for (const [index, title] of titles.entries()) {
|
||||
await expect(rows.nth(index)).toContainText(title, { timeout: 30_000 });
|
||||
}
|
||||
}
|
||||
|
||||
// Searching across hosts is the case a single-daemon spec cannot see: each host
|
||||
// ranks only its own sessions, so the merge is the only thing that can put the
|
||||
// best match first.
|
||||
test.describe("History search across hosts", () => {
|
||||
test.describe.configure({ timeout: 420_000 });
|
||||
|
||||
test("ranks matches from every host together and narrows to one host on demand", async ({
|
||||
page,
|
||||
}) => {
|
||||
const secondaryServerId = `srv_hist_${randomUUID().replaceAll("-", "").slice(0, 12)}`;
|
||||
const secondaryDaemon = await startIsolatedHostDaemon(secondaryServerId);
|
||||
const primaryClient = await connectSeedClient();
|
||||
let primaryWorkspace: Awaited<ReturnType<typeof seedWorkspace>> | null = null;
|
||||
let secondaryWorkspace: Awaited<ReturnType<typeof seedWorkspace>> | null = null;
|
||||
|
||||
// The weak match is the newest session on the primary host and the strong
|
||||
// match is the oldest on the secondary, so only a real cross-host merge on
|
||||
// relevance can order them correctly.
|
||||
const weakTitle = `${NONCE} Unbilled usage report`;
|
||||
const strongTitle = `${NONCE} Bill the customer`;
|
||||
const unrelatedTitle = `${NONCE} Terminal resize fix`;
|
||||
|
||||
try {
|
||||
secondaryWorkspace = await seedWorkspace({
|
||||
repoPrefix: "sessions-search-secondary-",
|
||||
port: secondaryDaemon.port,
|
||||
});
|
||||
await createIdleAgent(secondaryWorkspace.client, {
|
||||
cwd: secondaryWorkspace.repoPath,
|
||||
workspaceId: secondaryWorkspace.workspaceId,
|
||||
title: strongTitle,
|
||||
});
|
||||
|
||||
primaryWorkspace = await seedWorkspace({ repoPrefix: "sessions-search-primary-" });
|
||||
for (const title of [unrelatedTitle, weakTitle]) {
|
||||
await createIdleAgent(primaryClient, {
|
||||
cwd: primaryWorkspace.repoPath,
|
||||
workspaceId: primaryWorkspace.workspaceId,
|
||||
title,
|
||||
});
|
||||
}
|
||||
|
||||
await gotoAppShell(page);
|
||||
await addConnectedHostAndReload(page, {
|
||||
serverId: secondaryDaemon.serverId,
|
||||
label: SECONDARY_LABEL,
|
||||
port: secondaryDaemon.port,
|
||||
primaryLabel: PRIMARY_LABEL,
|
||||
});
|
||||
await openSessions(page);
|
||||
|
||||
// All hosts: the stronger match leads despite being the older session on
|
||||
// the other daemon.
|
||||
await search(page, `${NONCE} bill`);
|
||||
await expectRankedTitles(page, [strongTitle, weakTitle]);
|
||||
|
||||
// One host: the other daemon's stronger match drops out entirely.
|
||||
await selectHost(page, getServerId());
|
||||
await expectRankedTitles(page, [weakTitle]);
|
||||
|
||||
await selectHost(page, secondaryDaemon.serverId);
|
||||
await expectRankedTitles(page, [strongTitle]);
|
||||
|
||||
// Back to all hosts, and clearing the query returns every seeded session.
|
||||
await selectHost(page, ALL_HOSTS_OPTION_ID);
|
||||
await page.getByTestId("sessions-search-clear").click();
|
||||
const rows = page.locator(AGENT_ROW).filter({ hasText: NONCE });
|
||||
await expect(rows).toHaveCount(3, { timeout: 30_000 });
|
||||
} finally {
|
||||
await primaryWorkspace?.cleanup().catch(() => undefined);
|
||||
await secondaryWorkspace?.cleanup().catch(() => undefined);
|
||||
await primaryClient.close().catch(() => undefined);
|
||||
await secondaryDaemon.close().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("names a host it could not reach instead of shrinking the results silently", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sessions-search-offline-" });
|
||||
const reachableTitle = `${NONCE} Bill the reachable customer`;
|
||||
|
||||
try {
|
||||
await createIdleAgent(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: reachableTitle,
|
||||
});
|
||||
|
||||
await gotoAppShell(page);
|
||||
await addOfflineHostAndReload(page, {
|
||||
serverId: "sessions-search-unreachable",
|
||||
label: SECONDARY_LABEL,
|
||||
primaryLabel: PRIMARY_LABEL,
|
||||
});
|
||||
await openSessions(page);
|
||||
|
||||
// The unreachable host's sessions were never searched. Reporting only the
|
||||
// matches that were found, with no word about the host that never
|
||||
// answered, is the failure this asserts against.
|
||||
await search(page, `${NONCE} bill`);
|
||||
await expect(page.getByTestId("sessions-host-errors")).toContainText(SECONDARY_LABEL, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectRankedTitles(page, [reachableTitle]);
|
||||
} finally {
|
||||
await workspace.cleanup().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { connectSeedClient } from "../support/helpers/seed-client";
|
||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||
import {
|
||||
createIdleAgent,
|
||||
openSessions,
|
||||
resetSeededPageState,
|
||||
} from "../support/helpers/archive-tab";
|
||||
|
||||
const AGENT_ROW = '[data-testid^="agent-row-"]';
|
||||
|
||||
/**
|
||||
* Every seeded title opens with the same nonce, so a query of "<nonce> term"
|
||||
* can only reach this spec's sessions. The daemon is shared with the rest of
|
||||
* the browser suite and its history is whatever those specs left behind.
|
||||
*/
|
||||
const NONCE = `hsq${randomUUID().replaceAll("-", "").slice(0, 8)}`;
|
||||
|
||||
const TITLES = {
|
||||
billing: `${NONCE} Add Stripe billing`,
|
||||
unbilled: `${NONCE} Unbilled usage report`,
|
||||
terminal: `${NONCE} Terminal resize fix`,
|
||||
} as const;
|
||||
|
||||
async function search(page: Page, query: string): Promise<void> {
|
||||
await page.getByTestId("sessions-search-input").fill(query);
|
||||
}
|
||||
|
||||
function rowTitles(page: Page) {
|
||||
return page.locator(AGENT_ROW);
|
||||
}
|
||||
|
||||
async function expectVisibleTitles(page: Page, titles: string[]): Promise<void> {
|
||||
const rows = rowTitles(page).filter({ hasText: NONCE });
|
||||
await expect(rows).toHaveCount(titles.length, { timeout: 30_000 });
|
||||
for (const [index, title] of titles.entries()) {
|
||||
await expect(rows.nth(index)).toContainText(title, { timeout: 30_000 });
|
||||
}
|
||||
}
|
||||
|
||||
test.describe("History search", () => {
|
||||
let client: Awaited<ReturnType<typeof connectSeedClient>>;
|
||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||
let projectId: string;
|
||||
|
||||
test.describe.configure({ timeout: 300_000 });
|
||||
|
||||
test.beforeAll(async () => {
|
||||
tempRepo = await createTempGitRepo("sessions-search-");
|
||||
client = await connectSeedClient();
|
||||
const created = await client.createWorkspace({
|
||||
source: { kind: "directory", path: tempRepo.path },
|
||||
});
|
||||
if (!created.workspace) {
|
||||
throw new Error(created.error ?? `Failed to create workspace ${tempRepo.path}`);
|
||||
}
|
||||
projectId = created.workspace.projectId;
|
||||
const workspaceId = created.workspace.id;
|
||||
|
||||
for (const title of [TITLES.terminal, TITLES.unbilled, TITLES.billing]) {
|
||||
await createIdleAgent(client, { cwd: tempRepo.path, workspaceId, title });
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await client?.removeProject(projectId).catch(() => undefined);
|
||||
await client?.close().catch(() => undefined);
|
||||
await tempRepo?.cleanup();
|
||||
});
|
||||
|
||||
test("typing narrows history to matching sessions and clearing restores them", async ({
|
||||
page,
|
||||
}) => {
|
||||
await resetSeededPageState(page);
|
||||
await openSessions(page);
|
||||
|
||||
// Seeded newest-first, and at rest history is chronological.
|
||||
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
|
||||
await expect(page.getByText("Today", { exact: true })).toHaveCount(1, { timeout: 30_000 });
|
||||
|
||||
await search(page, `${NONCE} billing`);
|
||||
await expectVisibleTitles(page, [TITLES.billing]);
|
||||
|
||||
// Ranked results are one flat list — a day heading would claim an order
|
||||
// the list no longer has.
|
||||
await expect(page.getByText("Today", { exact: true })).toHaveCount(0, { timeout: 30_000 });
|
||||
|
||||
await page.getByTestId("sessions-search-clear").click();
|
||||
await expect(page.getByTestId("sessions-search-input")).toHaveValue("");
|
||||
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
|
||||
await expect(page.getByText("Today", { exact: true })).toHaveCount(1, { timeout: 30_000 });
|
||||
});
|
||||
|
||||
test("ranks a whole-word hit above one buried inside a word", async ({ page }) => {
|
||||
await resetSeededPageState(page);
|
||||
await openSessions(page);
|
||||
|
||||
// "bill" starts a word in "billing" and hides inside "unbilled", so the
|
||||
// stronger match leads even though both sessions are equally recent.
|
||||
await search(page, `${NONCE} bill`);
|
||||
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled]);
|
||||
});
|
||||
|
||||
test("marks the characters each result matched on", async ({ page }) => {
|
||||
await resetSeededPageState(page);
|
||||
await openSessions(page);
|
||||
|
||||
// The mark is a nested Text run, so the matched slice is its own element.
|
||||
await search(page, `${NONCE} billing`);
|
||||
const row = page.locator(AGENT_ROW).filter({ hasText: NONCE }).first();
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(row.getByText("billing", { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// A typo has no characters in the text to point at, so the whole word it
|
||||
// resolved to is marked.
|
||||
await search(page, `${NONCE} bulling`);
|
||||
const typoRow = page.locator(AGENT_ROW).filter({ hasText: NONCE }).first();
|
||||
await expect(typoRow.getByText("billing", { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
test("finds a session through a typo", async ({ page }) => {
|
||||
await resetSeededPageState(page);
|
||||
await openSessions(page);
|
||||
|
||||
await search(page, `${NONCE} bulling`);
|
||||
await expectVisibleTitles(page, [TITLES.billing]);
|
||||
});
|
||||
|
||||
test("says the query found nothing, not that history is empty", async ({ page }) => {
|
||||
await resetSeededPageState(page);
|
||||
await openSessions(page);
|
||||
|
||||
await search(page, `${NONCE} kubernetes`);
|
||||
await expect(page.getByTestId("sessions-empty")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText("No sessions match")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.getByText("Clear search").click();
|
||||
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import { openSettings } from "../support/helpers/app";
|
||||
import { getE2EDaemonPort, wsRoutePatternForPort } from "../support/helpers/daemon-port";
|
||||
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
||||
import {
|
||||
addDirectHostFromSettings,
|
||||
clickSettingsBackToWorkspace,
|
||||
openSettingsHostSection,
|
||||
} from "../support/helpers/settings";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui";
|
||||
|
||||
test.describe("Settings host selection", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("entering Settings from a remote workspace selects that remote host", async ({ page }) => {
|
||||
const remoteDaemon = await startIsolatedHostDaemon("settings-host-selection-remote");
|
||||
const remoteWorkspace = await seedWorkspace({
|
||||
port: remoteDaemon.port,
|
||||
repoPrefix: "settings-host-selection-remote-workspace-",
|
||||
title: "Remote workspace",
|
||||
});
|
||||
|
||||
try {
|
||||
// The default local profile remains in the registry, but its daemon is offline.
|
||||
await page.routeWebSocket(wsRoutePatternForPort(getE2EDaemonPort()), async (ws) => {
|
||||
await ws.close({ code: 1008, reason: "The local daemon is disconnected." });
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
await openSettings(page);
|
||||
await addDirectHostFromSettings(page, {
|
||||
host: "127.0.0.1",
|
||||
port: remoteDaemon.port,
|
||||
});
|
||||
|
||||
await clickSettingsBackToWorkspace(page);
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId: remoteDaemon.serverId,
|
||||
workspaceId: remoteWorkspace.workspaceId,
|
||||
});
|
||||
|
||||
await openSettings(page);
|
||||
|
||||
await openSettingsHostSection(page, remoteDaemon.serverId, "connections");
|
||||
} finally {
|
||||
await remoteWorkspace.cleanup().catch(() => undefined);
|
||||
await remoteDaemon.close().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -21,3 +21,21 @@ test("Settings language selector switches General labels", async ({ page }) => {
|
||||
|
||||
await expect(page.getByText("Default send", { exact: true }).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("Settings language selector switches to Korean", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, "general");
|
||||
|
||||
await page.getByRole("button", { name: "System", exact: true }).click();
|
||||
await page.getByRole("button", { name: "한국어 - Korean", exact: true }).click();
|
||||
|
||||
await expect(page.getByText("기본 전송", { exact: true }).first()).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "한국어", exact: true }).click();
|
||||
await page.getByRole("button", { name: "English - 영어", exact: true }).click();
|
||||
|
||||
await expect(page.getByText("Default send", { exact: true }).first()).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -114,6 +114,16 @@ test.describe("Settings sidebar navigation", () => {
|
||||
await expectDirectHostUriHidden(page);
|
||||
});
|
||||
|
||||
test("browser web hides direct connection custom headers", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openAddHostFlow(page);
|
||||
await selectHostConnectionType(page, "direct");
|
||||
await toggleHostAdvanced(page);
|
||||
|
||||
await expect(page.getByTestId("direct-header-add")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("sidebar shows a Back to workspace row that leaves /settings", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
|
||||
@@ -7,7 +7,9 @@ const DISCORD_DESTINATION =
|
||||
const GITHUB_ISSUE_DESTINATION =
|
||||
/^https:\/\/github\.com\/(?:getpaseo\/paseo\/issues\/new(?:\/choose)?(?:[/?#]|$)|login\?return_to=https%3A%2F%2Fgithub\.com%2Fgetpaseo%2Fpaseo%2Fissues%2Fnew$)/;
|
||||
const CHANGELOG_DESTINATION = /^https:\/\/paseo\.sh\/changelog(?:[/?#]|$)/;
|
||||
const APP_VERSION = /^Paseo v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
||||
// The name and the version are separate cells of a key/value row, so they meet with no space
|
||||
// between them in the row's text content.
|
||||
const APP_VERSION = /^Paseo\s*v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
||||
|
||||
async function openHelpMenu(page: Page): Promise<void> {
|
||||
await page.getByTestId("sidebar-help").click();
|
||||
|
||||
@@ -8,7 +8,6 @@ async function expectBorderHighlight(page: Page, testID: string) {
|
||||
await expect(page.getByTestId(`${testID}-highlight`)).toHaveCount(0);
|
||||
|
||||
await handle.hover();
|
||||
await expect(page.getByTestId(`${testID}-highlight`)).toHaveCount(0);
|
||||
|
||||
const highlight = page.getByTestId(`${testID}-highlight`);
|
||||
await expect(highlight).toBeVisible();
|
||||
|
||||
@@ -304,7 +304,9 @@ test.describe("Half-screen desktop layout", () => {
|
||||
await gotoAppShell(page);
|
||||
|
||||
const openToggle = page.getByTestId("menu-button");
|
||||
const openBounds = await openToggle.locator("svg").first().boundingBox();
|
||||
const openIcon = openToggle.locator("svg").first();
|
||||
await expect(openIcon).toBeVisible();
|
||||
const openBounds = await openIcon.boundingBox();
|
||||
expect(openBounds).not.toBeNull();
|
||||
expect(openBounds?.x).toBeGreaterThan(12);
|
||||
|
||||
@@ -312,7 +314,9 @@ test.describe("Half-screen desktop layout", () => {
|
||||
await expect(page.getByTestId("sidebar-global-new-workspace")).not.toBeVisible();
|
||||
|
||||
const closedToggle = page.getByTestId("menu-button");
|
||||
const closedBounds = await closedToggle.locator("svg").first().boundingBox();
|
||||
const closedIcon = closedToggle.locator("svg").first();
|
||||
await expect(closedIcon).toBeVisible();
|
||||
const closedBounds = await closedIcon.boundingBox();
|
||||
expect(closedBounds).not.toBeNull();
|
||||
expect(closedBounds?.x).toBeCloseTo(12, 0);
|
||||
expect(closedBounds?.y).toBe(openBounds?.y);
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
expectTurnCopyButton,
|
||||
} from "../support/helpers/agent-stream";
|
||||
import {
|
||||
expectReconnectingToastDebounced,
|
||||
expectReconnectingToastGone,
|
||||
expectReconnectingToastVisible,
|
||||
} from "../support/helpers/workspace-ui";
|
||||
@@ -179,6 +180,7 @@ test.describe("Viewed agent timelines", () => {
|
||||
"true",
|
||||
);
|
||||
await gate.drop();
|
||||
await expectReconnectingToastDebounced(page);
|
||||
await expectReconnectingToastVisible(page);
|
||||
await commitMessage(scenario, scenario.firstAgentId, "Committed while the chat reconnects.");
|
||||
await expect(
|
||||
@@ -196,4 +198,23 @@ test.describe("Viewed agent timelines", () => {
|
||||
await scenario.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("preserves reconnecting toast through retained tab switches", async ({ page }) => {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const scenario = await seedViewedTimelineScenario();
|
||||
try {
|
||||
await openAgent(page, scenario, scenario.firstAgentId);
|
||||
await selectAgent(page, "Second viewed chat");
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
|
||||
await selectAgent(page, "First viewed chat");
|
||||
await gate.drop();
|
||||
await expectReconnectingToastVisible(page);
|
||||
|
||||
await selectAgent(page, "Second viewed chat");
|
||||
await expectReconnectingToastVisible(page, { timeout: 500 });
|
||||
} finally {
|
||||
gate.restore();
|
||||
await scenario.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
chooseCommandCenterAgentControl,
|
||||
submitDraftAgent,
|
||||
waitForDraftComposer,
|
||||
} from "../support/helpers/command-center-agent-controls";
|
||||
import { openCommandCenter } from "../support/helpers/command-center";
|
||||
import { selectModel } from "../support/helpers/app";
|
||||
|
||||
const DISABLE_DEFAULT_SEED_ONCE_KEY = "@paseo:e2e-disable-default-seed-once";
|
||||
const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce";
|
||||
|
||||
async function openNewAgentTab(page: Page): Promise<void> {
|
||||
await clickNewChat(page);
|
||||
await waitForDraftComposer(page);
|
||||
}
|
||||
|
||||
async function chooseDraftControl(page: Page, query: string, choice: string): Promise<void> {
|
||||
await openCommandCenter(page);
|
||||
await chooseCommandCenterAgentControl({ page, query, choice });
|
||||
}
|
||||
|
||||
async function expectThinkingSelected(page: Page, label: string): Promise<void> {
|
||||
await expect(
|
||||
page
|
||||
.getByRole("button", { name: `Select thinking option (${label})` })
|
||||
.filter({ visible: true }),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function expectModeSelected(page: Page, label: string): Promise<void> {
|
||||
await expect(
|
||||
page.getByRole("button", { name: `Select agent mode (${label})` }).filter({ visible: true }),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function reloadWithPersistedPreferences(page: Page): Promise<void> {
|
||||
await page.evaluate(
|
||||
({ disableKey, nonceKey }) => {
|
||||
const nonce = localStorage.getItem(nonceKey);
|
||||
if (!nonce) throw new Error("Expected the Playwright seed nonce before reload");
|
||||
localStorage.setItem(disableKey, nonce);
|
||||
},
|
||||
{ disableKey: DISABLE_DEFAULT_SEED_ONCE_KEY, nonceKey: SEED_NONCE_KEY },
|
||||
);
|
||||
await page.reload();
|
||||
await waitForDraftComposer(page);
|
||||
}
|
||||
|
||||
async function readRememberedThinking(page: Page, modelId: string): Promise<string | null> {
|
||||
return page.evaluate((selectedModelId) => {
|
||||
const raw = localStorage.getItem("@paseo:create-agent-preferences");
|
||||
if (!raw) return null;
|
||||
const preferences = JSON.parse(raw) as {
|
||||
providerPreferences?: { mock?: { thinkingByModel?: Record<string, string> } };
|
||||
};
|
||||
return preferences.providerPreferences?.mock?.thinkingByModel?.[selectedModelId] ?? null;
|
||||
}, modelId);
|
||||
}
|
||||
|
||||
async function seedLegacyModelPreference(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const storageKey = "@paseo:create-agent-preferences";
|
||||
const raw = localStorage.getItem(storageKey);
|
||||
const preferences = raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
|
||||
localStorage.setItem(
|
||||
storageKey,
|
||||
JSON.stringify({
|
||||
...preferences,
|
||||
provider: "mock",
|
||||
providerPreferences: {
|
||||
...(preferences.providerPreferences as Record<string, unknown> | undefined),
|
||||
mock: {
|
||||
model: "legacy-five-minute-stream",
|
||||
thinkingByModel: { "legacy-five-minute-stream": "medium" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("Workspace draft thinking preferences", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("restores a model's remembered thinking after another model and a reload", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "draft-thinking-preferences-" });
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openNewAgentTab(page);
|
||||
await chooseDraftControl(
|
||||
page,
|
||||
"five minute stream",
|
||||
"Model › Mock Load Test › Five minute stream",
|
||||
);
|
||||
await chooseDraftControl(page, "medium", "Thinking › Medium");
|
||||
await expectThinkingSelected(page, "Medium");
|
||||
await chooseDraftControl(
|
||||
page,
|
||||
"ten second stream",
|
||||
"Model › Mock Load Test › Ten second stream",
|
||||
);
|
||||
await chooseDraftControl(
|
||||
page,
|
||||
"five minute stream",
|
||||
"Model › Mock Load Test › Five minute stream",
|
||||
);
|
||||
await expectThinkingSelected(page, "Medium");
|
||||
await submitDraftAgent(page, "Remember medium thinking for this model");
|
||||
|
||||
await openNewAgentTab(page);
|
||||
await expectThinkingSelected(page, "Medium");
|
||||
await chooseDraftControl(
|
||||
page,
|
||||
"ten second stream",
|
||||
"Model › Mock Load Test › Ten second stream",
|
||||
);
|
||||
await submitDraftAgent(page, "Hello from the other model");
|
||||
|
||||
await openNewAgentTab(page);
|
||||
await reloadWithPersistedPreferences(page);
|
||||
await expect.poll(() => readRememberedThinking(page, "five-minute-stream")).toBe("medium");
|
||||
await chooseDraftControl(
|
||||
page,
|
||||
"five minute stream",
|
||||
"Model › Mock Load Test › Five minute stream",
|
||||
);
|
||||
|
||||
await expectThinkingSelected(page, "Medium");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("restores a provider's permission mode after another provider and a reload", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "draft-mode-preferences-" });
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openNewAgentTab(page);
|
||||
await chooseDraftControl(
|
||||
page,
|
||||
"five minute stream",
|
||||
"Model › Mock Load Test › Five minute stream",
|
||||
);
|
||||
await chooseDraftControl(page, "approval test", "Mode › Approval test");
|
||||
await expectModeSelected(page, "Approval test");
|
||||
await submitDraftAgent(page, "Remember this permission mode");
|
||||
|
||||
await openNewAgentTab(page);
|
||||
await expectModeSelected(page, "Approval test");
|
||||
await selectModel(page, "gpt-5.4-mini");
|
||||
|
||||
await openNewAgentTab(page);
|
||||
await reloadWithPersistedPreferences(page);
|
||||
await selectModel(page, "Five minute stream");
|
||||
|
||||
await expectModeSelected(page, "Approval test");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("canonicalizes a retired model preference and restores its thinking option", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "draft-model-alias-preferences-" });
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openNewAgentTab(page);
|
||||
await seedLegacyModelPreference(page);
|
||||
await reloadWithPersistedPreferences(page);
|
||||
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Select model (Five minute stream)" }),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expectThinkingSelected(page, "Medium");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -503,12 +503,16 @@ test.describe("Workspace model restart regressions", () => {
|
||||
createdWorkspaceId,
|
||||
]),
|
||||
)
|
||||
.toEqual({
|
||||
[seeded.workspaceA]: "running",
|
||||
.toMatchObject({
|
||||
[seeded.workspaceB]: "done",
|
||||
[createdWorkspaceId]: "done",
|
||||
});
|
||||
|
||||
// The restarted provider session may settle while the browser creates the sibling. Its
|
||||
// initial running status is asserted above; this phase verifies that ownership never moves.
|
||||
const workspaceStatuses = await fetchWorkspaceStatuses(client, [seeded.workspaceA]);
|
||||
expect(["running", "done"]).toContain(workspaceStatuses[seeded.workspaceA]);
|
||||
|
||||
await expectWorkspaceRowDoesNotShowIndicator(page, {
|
||||
serverId,
|
||||
workspaceId: seeded.workspaceB,
|
||||
@@ -519,11 +523,6 @@ test.describe("Workspace model restart regressions", () => {
|
||||
workspaceId: createdWorkspaceId,
|
||||
indicator: "running",
|
||||
});
|
||||
await expectWorkspaceRowInStatusBucket(page, {
|
||||
serverId,
|
||||
workspaceId: seeded.workspaceA,
|
||||
bucket: "running",
|
||||
});
|
||||
await expectWorkspaceRowInStatusBucket(page, {
|
||||
serverId,
|
||||
workspaceId: seeded.workspaceB,
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
expectReconnectingToastGone,
|
||||
switchWorkspaceViaSidebar,
|
||||
waitForSidebarHydration,
|
||||
waitForWorkspaceInSidebar,
|
||||
workspaceDeckEntryLocator,
|
||||
expectWorkspaceDeckEntryCount,
|
||||
} from "../support/helpers/workspace-ui";
|
||||
@@ -37,7 +38,8 @@ import { clickSettingsBackToWorkspace } from "../support/helpers/settings";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { expectAppRoute } from "../support/helpers/route-assertions";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import { addOfflineHostAndReload } from "../support/helpers/hosts";
|
||||
import { addConnectedHostAndReload, addOfflineHostAndReload } from "../support/helpers/hosts";
|
||||
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
||||
|
||||
const LOADING_WORKSPACE_TEXT_PATTERN = /Loading workspace/i;
|
||||
async function expectNoLoadingWorkspacePane(
|
||||
@@ -199,6 +201,62 @@ test.describe("Workspace navigation regression", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("does not show reconnecting for an inactive host workspace", async ({ page }) => {
|
||||
const secondaryHost = await startIsolatedHostDaemon("inactive-reconnecting-host");
|
||||
const primaryWorkspace = await seedWorkspace({ repoPrefix: "active-reconnecting-host-" });
|
||||
const secondaryWorkspace = await seedWorkspace({
|
||||
repoPrefix: "inactive-reconnecting-host-",
|
||||
port: secondaryHost.port,
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
createIdleAgent(primaryWorkspace.client, {
|
||||
cwd: primaryWorkspace.repoPath,
|
||||
workspaceId: primaryWorkspace.workspaceId,
|
||||
title: "Active host agent",
|
||||
}),
|
||||
createIdleAgent(secondaryWorkspace.client, {
|
||||
cwd: secondaryWorkspace.repoPath,
|
||||
workspaceId: secondaryWorkspace.workspaceId,
|
||||
title: "Inactive host agent",
|
||||
}),
|
||||
]);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await addConnectedHostAndReload(page, {
|
||||
serverId: secondaryHost.serverId,
|
||||
label: "Inactive host",
|
||||
port: secondaryHost.port,
|
||||
});
|
||||
await waitForWorkspaceInSidebar(page, {
|
||||
serverId: secondaryHost.serverId,
|
||||
workspaceId: secondaryWorkspace.workspaceId,
|
||||
});
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId: secondaryHost.serverId,
|
||||
workspaceId: secondaryWorkspace.workspaceId,
|
||||
});
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
workspaceId: primaryWorkspace.workspaceId,
|
||||
});
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
|
||||
await secondaryHost.close();
|
||||
await page.waitForTimeout(1_500);
|
||||
|
||||
await expectReconnectingToastGone(page, { timeout: 100 });
|
||||
} finally {
|
||||
await secondaryHost.close();
|
||||
await secondaryWorkspace.cleanup();
|
||||
await primaryWorkspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("cold offline workspace route gates the screen interior but keeps settings reachable", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { renameModalInput, renameModalSubmit } from "../support/helpers/rename";
|
||||
import type { SeedDaemonClient } from "../support/helpers/seed-client";
|
||||
|
||||
const MOBILE_VIEWPORT = { width: 390, height: 844 };
|
||||
|
||||
async function fetchAgentTitle(client: SeedDaemonClient, agentId: string): Promise<string | null> {
|
||||
const result = await client.fetchAgents({ scope: "active" });
|
||||
return result.entries.find((entry) => entry.agent.id === agentId)?.agent.title ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact "sessions dropdown" rename path. The desktop rename specs drive the right-click
|
||||
* `workspace-tab-context-*` menu, which portals into overlay-root and was never broken. This
|
||||
* exercises the tab switcher's per-session actions on a phone-sized viewport — the path that
|
||||
* regressed when the actions menu presented as a popover instead of a bottom sheet.
|
||||
*/
|
||||
test.describe("Workspace session rename (compact tab switcher)", () => {
|
||||
test("renames an agent session from the switcher's per-session actions sheet", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
await page.setViewportSize(MOBILE_VIEWPORT);
|
||||
const initialTitle = `switcher-rename-${randomUUID().slice(0, 8)}`;
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "workspace-switcher-rename-",
|
||||
title: initialTitle,
|
||||
});
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await expect(page.getByTestId("workspace-tab-switcher-trigger")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
// Open the sessions dropdown (a bottom sheet on compact).
|
||||
await page.getByRole("button", { name: /Switch tabs/ }).click();
|
||||
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" }).first()).toBeVisible(
|
||||
{
|
||||
timeout: 10_000,
|
||||
},
|
||||
);
|
||||
|
||||
// Open this session's "…" actions. Before the fix this tried to open a popover-Modal
|
||||
// that never surfaced over the sheet on native; it now opens as a stacked sheet.
|
||||
const menuBase = `workspace-tab-menu-agent_${session.agentId}`;
|
||||
const actionsTrigger = page.getByTestId(`${menuBase}-trigger`);
|
||||
await expect(actionsTrigger).toBeVisible({ timeout: 15_000 });
|
||||
await actionsTrigger.click();
|
||||
|
||||
const renameItem = page.getByTestId(`${menuBase}-rename`);
|
||||
await expect(renameItem).toBeVisible({ timeout: 10_000 });
|
||||
await renameItem.click();
|
||||
|
||||
const modalPrefix = `workspace-tab-rename-modal-agent-${session.agentId}`;
|
||||
const input = renameModalInput(page, modalPrefix);
|
||||
await expect(input).toBeVisible({ timeout: 10_000 });
|
||||
await expect(input).toHaveValue(initialTitle);
|
||||
|
||||
const renamed = "Renamed from the dropdown";
|
||||
await input.fill(renamed);
|
||||
await renameModalSubmit(page, modalPrefix).click();
|
||||
|
||||
await expect(input).toHaveCount(0, { timeout: 15_000 });
|
||||
await expect(page.getByTestId("workspace-tab-switcher-trigger")).toContainText(renamed, {
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect.poll(() => fetchAgentTitle(session.client, session.agentId)).toBe(renamed);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
export const meta = {
|
||||
name: "paseo-project-inspection",
|
||||
description: "Inspect Paseo and explain what the project does",
|
||||
whenToUse: "Paseo real-provider workflow detail QA",
|
||||
phases: [{ title: "Inspect", detail: "Read the product and architecture sources" }],
|
||||
};
|
||||
|
||||
phase("Inspect");
|
||||
let input = args;
|
||||
if (typeof input === "string") input = JSON.parse(input);
|
||||
const repoPath = input?.repoPath;
|
||||
if (typeof repoPath !== "string" || repoPath.length === 0) {
|
||||
throw new Error("repoPath is required");
|
||||
}
|
||||
const report = await agent(
|
||||
`Inspect the Paseo repository at ${JSON.stringify(repoPath)}.
|
||||
|
||||
Read README.md, docs/product.md, docs/architecture.md, and package.json. Do not edit anything. Return a concise plain-text report with exactly these headings:
|
||||
|
||||
What Paseo is
|
||||
Who it is for
|
||||
How it works
|
||||
Repository structure
|
||||
|
||||
Under each heading, give 1-3 concrete sentences grounded in those files. Mention the supported coding agents and the local-code/privacy model.`,
|
||||
{
|
||||
label: "project-inspector",
|
||||
phase: "Inspect",
|
||||
},
|
||||
);
|
||||
|
||||
return { report };
|
||||
@@ -0,0 +1,30 @@
|
||||
export const meta = {
|
||||
name: "paseo-workflow-row-qa",
|
||||
description: "Verify the workflow row lifecycle",
|
||||
whenToUse: "Paseo real-provider QA only",
|
||||
phases: [{ title: "Verify", detail: "one child returns a fixed marker" }],
|
||||
};
|
||||
|
||||
phase("Verify");
|
||||
let input = args;
|
||||
if (typeof input === "string") input = JSON.parse(input);
|
||||
const gatePath = input?.gatePath;
|
||||
if (typeof gatePath !== "string" || gatePath.length === 0) {
|
||||
throw new Error("gatePath is required");
|
||||
}
|
||||
const child = await agent(
|
||||
`First use Bash to run this command and wait for it to exit:
|
||||
while [ ! -f ${JSON.stringify(gatePath)} ]; do sleep 0.2; done
|
||||
|
||||
Then return exactly this JSON object and do nothing else: {"marker":"PASEO_WORKFLOW_ROW_OK"}`,
|
||||
{
|
||||
label: "workflow-row-child",
|
||||
phase: "Verify",
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["marker"],
|
||||
properties: { marker: { type: "string" } },
|
||||
},
|
||||
},
|
||||
);
|
||||
return { child };
|
||||
@@ -77,6 +77,9 @@ export async function expectNoChatOutlinePreview(page: Page): Promise<void> {
|
||||
|
||||
export async function expectNoChatOutlinePreviewWhileCrossingToSidebar(page: Page): Promise<void> {
|
||||
const railBox = await requireBoundingBox(chatOutlineRail(page));
|
||||
const clockStart = Date.now();
|
||||
await page.clock.install({ time: clockStart });
|
||||
await page.clock.pauseAt(clockStart + 60_000);
|
||||
await page.mouse.move(railBox.x + railBox.width + 2, railBox.y + railBox.height / 2);
|
||||
await page.evaluate(() => {
|
||||
document.body.dataset.chatOutlinePreviewObserved = String(
|
||||
@@ -91,19 +94,19 @@ export async function expectNoChatOutlinePreviewWhileCrossingToSidebar(page: Pag
|
||||
Object.assign(window, { __chatOutlinePreviewObserver: observer });
|
||||
});
|
||||
|
||||
for (let step = 0; step <= 10; step += 1) {
|
||||
// Keep the transit slower than the activation delay at every point while making the whole
|
||||
// crossing longer than it. Horizontal motion must keep postponing activation until leave
|
||||
// cancels the final pending timer.
|
||||
const transitSteps = 6;
|
||||
for (let step = 0; step < transitSteps; step += 1) {
|
||||
await page.mouse.move(
|
||||
railBox.x + railBox.width - 1 - (step * railBox.width) / 10,
|
||||
railBox.x + railBox.width - 1 - (step * railBox.width) / transitSteps,
|
||||
railBox.y + railBox.height / 2,
|
||||
);
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
||||
}),
|
||||
);
|
||||
await page.clock.runFor(100);
|
||||
}
|
||||
await page.mouse.move(railBox.x - 2, railBox.y + railBox.height / 2);
|
||||
await page.clock.runFor(200);
|
||||
|
||||
const previewAppeared = await page.evaluate(() => {
|
||||
const observer = Reflect.get(window, "__chatOutlinePreviewObserver") as
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { submitMessage } from "./composer";
|
||||
import type { AgentHandle } from "./rewind-flow";
|
||||
import { openSubagentsTrack } from "./subagents";
|
||||
|
||||
export const WORKFLOW_ROW_DESCRIPTION = "Verify the workflow row lifecycle";
|
||||
export const WORKFLOW_ROW_MARKER = "PASEO_WORKFLOW_ROW_OK";
|
||||
|
||||
export async function askClaudeToRunWorkflow(
|
||||
handle: AgentHandle,
|
||||
scriptPath: string,
|
||||
gatePath: string,
|
||||
): Promise<void> {
|
||||
await submitMessage(
|
||||
handle.page,
|
||||
`Use Claude Code's Workflow tool exactly once with scriptPath ${JSON.stringify(
|
||||
scriptPath,
|
||||
)} and args ${JSON.stringify({ gatePath })}. Wait for its completion notification, then reply with exactly ${WORKFLOW_ROW_MARKER}.`,
|
||||
);
|
||||
await expect(handle.page.getByTestId("subagents-track-header")).toBeVisible({ timeout: 60_000 });
|
||||
await openSubagentsTrack(handle.page);
|
||||
}
|
||||
|
||||
export function releaseWorkflow(gatePath: string): void {
|
||||
writeFileSync(gatePath, "release\n", "utf8");
|
||||
}
|
||||
|
||||
export function workflowRow(page: Page) {
|
||||
// The parent transcript's Workflow tool card has the same accessible name. The track has no
|
||||
// separate landmark, so its established row test id is the only unambiguous scope.
|
||||
return page
|
||||
.getByTestId(/^subagents-track-row-/)
|
||||
.filter({ has: page.getByText(WORKFLOW_ROW_DESCRIPTION, { exact: true }) });
|
||||
}
|
||||
|
||||
export async function expectWorkflowRunning(page: Page): Promise<void> {
|
||||
const row = workflowRow(page);
|
||||
await expect(row).toBeVisible({ timeout: 60_000 });
|
||||
await expect(row).toContainText(WORKFLOW_ROW_DESCRIPTION);
|
||||
await expect(row).toContainText(/Workflow(?: · .+)?/);
|
||||
await expect(row.getByRole("progressbar", { name: "Agent running" })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectWorkflowCompleted(page: Page): Promise<void> {
|
||||
const row = workflowRow(page);
|
||||
await expect(row.getByRole("progressbar", { name: "Agent running" })).toHaveCount(0, {
|
||||
timeout: 120_000,
|
||||
});
|
||||
await expect(page.getByTestId("subagents-track-archive-finished")).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("assistant-message").filter({ hasText: WORKFLOW_ROW_MARKER }).last(),
|
||||
).toBeVisible({ timeout: 120_000 });
|
||||
}
|
||||
|
||||
export async function expectSingleWorkflowParentCard(page: Page): Promise<void> {
|
||||
const parentToolCards = page.getByTestId("tool-call-badge");
|
||||
await expect(
|
||||
parentToolCards.filter({ has: page.getByText("Workflow", { exact: true }) }),
|
||||
).toHaveCount(1);
|
||||
await expect(parentToolCards.filter({ hasText: "Task Notification" })).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function openWorkflowTimeline(page: Page): Promise<void> {
|
||||
await workflowRow(page).click();
|
||||
const panel = page.getByTestId("provider-subagent-panel");
|
||||
await expect(panel).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("provider-subagent-pane-subtitle")).toHaveText(
|
||||
/Workflow(?: · .+)?/,
|
||||
);
|
||||
await expect(panel.getByText("Start chatting with this agent...", { exact: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expect(panel.getByText(WORKFLOW_ROW_MARKER, { exact: true })).toBeVisible();
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* The composer toolbar collapses its controls by measured width. A collapsed
|
||||
* frame is observable from the DOM: the mode trigger drops its label and
|
||||
* shrinks to the icon-only box, so a visible trigger with no text is a
|
||||
* collapsed paint.
|
||||
*/
|
||||
interface ToolbarFrame {
|
||||
atMs: number;
|
||||
labels: string[];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__composerToolbarFrames?: ToolbarFrame[];
|
||||
__composerToolbarStop?: () => void;
|
||||
}
|
||||
}
|
||||
|
||||
/** Records every painted frame's visible mode-trigger labels until stopped. */
|
||||
export async function recordComposerToolbarFrames(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const frames: ToolbarFrame[] = [];
|
||||
window.__composerToolbarFrames = frames;
|
||||
const start = performance.now();
|
||||
let running = true;
|
||||
const tick = () => {
|
||||
if (!running) return;
|
||||
const labels = Array.from(document.querySelectorAll('[data-testid="mode-control"]'))
|
||||
.filter((node) => node.getClientRects().length > 0)
|
||||
.map((node) => (node.textContent ?? "").trim());
|
||||
frames.push({ atMs: Math.round(performance.now() - start), labels });
|
||||
requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
window.__composerToolbarStop = () => {
|
||||
running = false;
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function readComposerToolbarFrames(page: Page): Promise<ToolbarFrame[]> {
|
||||
return page.evaluate(() => {
|
||||
window.__composerToolbarStop?.();
|
||||
return window.__composerToolbarFrames ?? [];
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectNoCollapsedComposerToolbarFrame(page: Page): Promise<void> {
|
||||
const frames = await readComposerToolbarFrames(page);
|
||||
expect(frames.length).toBeGreaterThan(0);
|
||||
const collapsed = frames.filter((frame) => frame.labels.some((label) => label === ""));
|
||||
expect(collapsed, `collapsed toolbar frames: ${JSON.stringify(collapsed)}`).toEqual([]);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { identityColor, type IdentityColorName } from "@/styles/identity-colors";
|
||||
import { identityForeground, type IdentityColorName } from "@/styles/identity-colors";
|
||||
import { openSettings } from "./app";
|
||||
import { buildSeededHost } from "./daemon-registry";
|
||||
import { clickSettingsBackToWorkspace, openHostSection, selectSettingsHost } from "./settings";
|
||||
@@ -226,6 +226,10 @@ export async function expectNoHostBadge(page: Page, target: HostBadgeTarget): Pr
|
||||
// The host sits on the meta line as plain secondary text, so its identity colour lives on the
|
||||
// server icon alone. The label stays muted like every other item on that line — asserting a
|
||||
// tinted label or a pill fill would re-assert the design the meta row replaced.
|
||||
//
|
||||
// The icon takes the *foreground* variant of the identity colour, not the fill one: it is a
|
||||
// stroked glyph on a surface, so it has to clear contrast against that surface rather than
|
||||
// behind white letters. That variant is per-scheme, and the browser project runs light.
|
||||
export async function expectHostBadgeTinted(
|
||||
page: Page,
|
||||
target: HostBadgeTarget & { color: IdentityColorName },
|
||||
@@ -233,7 +237,10 @@ export async function expectHostBadgeTinted(
|
||||
const badge = hostBadge(page, target);
|
||||
await expect(badge).toBeVisible({ timeout: 15_000 });
|
||||
await expect(badge).toHaveText(target.hostName);
|
||||
await expect(badge.locator("svg")).toHaveAttribute("stroke", identityColor(target.color));
|
||||
await expect(badge.locator("svg")).toHaveAttribute(
|
||||
"stroke",
|
||||
identityForeground(target.color, "light"),
|
||||
);
|
||||
}
|
||||
|
||||
export async function expectHostAppearancePreview(
|
||||
@@ -244,7 +251,10 @@ export async function expectHostAppearancePreview(
|
||||
await expect(preview).toBeVisible();
|
||||
const badge = preview.getByTestId(`host-badge-${input.serverId}`);
|
||||
await expect(badge).toHaveText(input.hostName);
|
||||
await expect(badge.locator("svg")).toHaveAttribute("stroke", identityColor(input.color));
|
||||
await expect(badge.locator("svg")).toHaveAttribute(
|
||||
"stroke",
|
||||
identityForeground(input.color, "light"),
|
||||
);
|
||||
}
|
||||
|
||||
// The auto-seed fixture rewrites the host registry on every navigation. Setting the
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { existsSync } from "node:fs";
|
||||
import net from "node:net";
|
||||
import path from "node:path";
|
||||
|
||||
export interface LocalElixirRelay {
|
||||
endpoint: string;
|
||||
port: number;
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
async function availablePort(): Promise<number> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
server.close(() => reject(new Error("Could not allocate a relay port")));
|
||||
return;
|
||||
}
|
||||
server.close(() => resolve(address.port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function stopProcess(child: ChildProcess): Promise<void> {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
child.kill("SIGTERM");
|
||||
const force = setTimeout(() => {
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
||||
}, 5_000);
|
||||
try {
|
||||
await once(child, "exit");
|
||||
} finally {
|
||||
clearTimeout(force);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitUntilReady(
|
||||
port: number,
|
||||
child: ChildProcess,
|
||||
recentOutput: () => string,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + 60_000;
|
||||
let lastError: unknown;
|
||||
while (Date.now() < deadline) {
|
||||
if (child.exitCode !== null) {
|
||||
throw new Error(
|
||||
`Elixir relay exited before becoming ready (exit ${child.exitCode}).\n${recentOutput()}`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/ready`, {
|
||||
signal: AbortSignal.timeout(1_000),
|
||||
});
|
||||
if (response.ok) return;
|
||||
lastError = new Error(`Relay readiness returned HTTP ${response.status}`);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error(
|
||||
`Elixir relay did not become ready on port ${port}: ${lastError instanceof Error ? lastError.message : String(lastError)}\n${recentOutput()}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function startLocalElixirRelay(): Promise<LocalElixirRelay> {
|
||||
const relayRoot =
|
||||
process.env.PASEO_RELAY_CHECKOUT ?? path.resolve(__dirname, "../../../../../..", "paseo-relay");
|
||||
if (!existsSync(path.join(relayRoot, "mix.exs"))) {
|
||||
throw new Error(
|
||||
`Expected the Elixir relay checkout at ${relayRoot}. Set PASEO_RELAY_CHECKOUT to override it.`,
|
||||
);
|
||||
}
|
||||
|
||||
const port = await availablePort();
|
||||
let child: ChildProcess | null = null;
|
||||
let output: string[] = [];
|
||||
|
||||
const start = async () => {
|
||||
if (child && child.exitCode === null && child.signalCode === null) {
|
||||
throw new Error("Elixir relay is already running");
|
||||
}
|
||||
output = [];
|
||||
child = spawn("asdf", ["exec", "mix", "run", "--no-halt"], {
|
||||
cwd: relayRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
MIX_ENV: "prod",
|
||||
PASEO_RELAY_HOST: "127.0.0.1",
|
||||
PASEO_RELAY_PORT: String(port),
|
||||
PASEO_RELAY_MIN_CLUSTER_SIZE: "1",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
const capture = (chunk: Buffer) => {
|
||||
output.push(chunk.toString("utf8"));
|
||||
output = output.join("").split("\n").slice(-80);
|
||||
};
|
||||
child.stdout?.on("data", capture);
|
||||
child.stderr?.on("data", capture);
|
||||
try {
|
||||
await waitUntilReady(port, child, () => output.join("\n"));
|
||||
} catch (error) {
|
||||
await stopProcess(child);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const stop = async () => {
|
||||
if (!child) return;
|
||||
await stopProcess(child);
|
||||
child = null;
|
||||
};
|
||||
|
||||
await start();
|
||||
return {
|
||||
endpoint: `127.0.0.1:${port}`,
|
||||
port,
|
||||
start,
|
||||
stop,
|
||||
close: stop,
|
||||
};
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export interface MockAgentWorkspace {
|
||||
export interface MockAgentOptions {
|
||||
repoPrefix: string;
|
||||
title: string;
|
||||
port?: number;
|
||||
initialPrompt?: string;
|
||||
model?: string;
|
||||
modeId?: string;
|
||||
@@ -28,7 +29,7 @@ export interface MockAgentOptions {
|
||||
export async function seedMockAgentWorkspace(
|
||||
options: MockAgentOptions,
|
||||
): Promise<MockAgentWorkspace> {
|
||||
const workspace = await seedWorkspace({ repoPrefix: options.repoPrefix });
|
||||
const workspace = await seedWorkspace({ repoPrefix: options.repoPrefix, port: options.port });
|
||||
try {
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import type { TerminalProfile } from "@getpaseo/protocol/messages";
|
||||
import { DEFAULT_TERMINAL_PROFILES } from "@getpaseo/protocol/terminal-profiles";
|
||||
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
import { waitForTerminalContent } from "./terminal-perf";
|
||||
|
||||
export type { TerminalProfile };
|
||||
|
||||
export interface TerminalProfileSeed {
|
||||
/** Restores the daemon's terminalProfiles to what they were before seeding, and closes the client. */
|
||||
restore(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overrides the connected host's terminal profiles for the duration of a
|
||||
* test. The daemon has no "unset" for a patched config field, so restoring
|
||||
* writes back either the exact previous array, or the shipped defaults when
|
||||
* the host had never customized profiles — functionally identical to the
|
||||
* absent state `resolveTerminalProfiles` would otherwise produce.
|
||||
*/
|
||||
export async function seedTerminalProfiles(
|
||||
profiles: TerminalProfile[],
|
||||
): Promise<TerminalProfileSeed> {
|
||||
const client = await connectNewWorkspaceDaemonClient();
|
||||
const previous = await client.getDaemonConfig();
|
||||
await client.patchDaemonConfig({ terminalProfiles: profiles });
|
||||
return {
|
||||
async restore() {
|
||||
await client.patchDaemonConfig({
|
||||
terminalProfiles: previous.config.terminalProfiles ?? [...DEFAULT_TERMINAL_PROFILES],
|
||||
});
|
||||
await client.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Mid-test profile changes (e.g. simulating a profile being removed). Does not manage restoration — pair with `seedTerminalProfiles` for that. */
|
||||
export async function patchTerminalProfiles(profiles: TerminalProfile[]): Promise<void> {
|
||||
const client = await connectNewWorkspaceDaemonClient();
|
||||
try {
|
||||
await client.patchDaemonConfig({ terminalProfiles: profiles });
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Locators ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** The single meta-row chip that chooses chat or a terminal. */
|
||||
export function launchTrigger(page: Page) {
|
||||
return page.getByTestId("new-workspace-launch-trigger");
|
||||
}
|
||||
|
||||
export function launchMenu(page: Page) {
|
||||
return page.getByTestId("new-workspace-launch-menu");
|
||||
}
|
||||
|
||||
export function launchOption(page: Page, optionId: string) {
|
||||
return page.getByTestId(`new-workspace-launch-option-${optionId}`);
|
||||
}
|
||||
|
||||
export function manageProfilesOption(page: Page) {
|
||||
return page.getByTestId("new-workspace-launch-manage-profiles");
|
||||
}
|
||||
|
||||
// Chat and terminal are the same composer in two modes, so they share
|
||||
// `message-input-root`. What tells them apart is which affordances that mode
|
||||
// renders: chat has the attachment button, terminal has the Launch button.
|
||||
export function composerRoot(page: Page) {
|
||||
return page.locator('[data-testid="message-input-root"]:visible');
|
||||
}
|
||||
|
||||
// Scoped to the visible composer: a workspace opened earlier in the test keeps
|
||||
// its own composer mounted but hidden in the deck, so an unscoped testid
|
||||
// matches two elements and trips strict mode.
|
||||
export function attachButton(page: Page) {
|
||||
return composerRoot(page).getByTestId("message-input-attach-button");
|
||||
}
|
||||
|
||||
export function terminalPromptInput(page: Page) {
|
||||
return composerRoot(page).getByRole("textbox", { name: "Terminal prompt" });
|
||||
}
|
||||
|
||||
/** The static command shown when the selected profile takes no prompt. */
|
||||
export function terminalCommandPreview(page: Page) {
|
||||
return composerRoot(page).getByTestId("composer-readonly-content");
|
||||
}
|
||||
|
||||
export function terminalLaunchSubmit(page: Page) {
|
||||
return composerRoot(page).getByTestId("new-workspace-launch-submit");
|
||||
}
|
||||
|
||||
// ─── Actions ───────────────────────────────────────────────────────────────
|
||||
|
||||
export async function openLaunchMenu(page: Page): Promise<void> {
|
||||
await launchTrigger(page).click();
|
||||
await expect(launchMenu(page)).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
/** `optionId` is `chat`, `blank`, or a terminal profile id. */
|
||||
export async function selectLaunchOption(page: Page, optionId: string): Promise<void> {
|
||||
await openLaunchMenu(page);
|
||||
const option = launchOption(page, optionId);
|
||||
await expect(option).toBeVisible({ timeout: 30_000 });
|
||||
await option.click();
|
||||
await expect(launchMenu(page)).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function selectChatLaunch(page: Page): Promise<void> {
|
||||
await selectLaunchOption(page, "chat");
|
||||
}
|
||||
|
||||
export async function fillTerminalPrompt(page: Page, text: string): Promise<void> {
|
||||
const input = terminalPromptInput(page);
|
||||
await expect(input).toBeVisible({ timeout: 30_000 });
|
||||
await input.fill(text);
|
||||
}
|
||||
|
||||
export async function submitTerminalLaunch(page: Page): Promise<void> {
|
||||
const submit = terminalLaunchSubmit(page);
|
||||
await expect(submit).toBeEnabled({ timeout: 30_000 });
|
||||
await submit.click();
|
||||
}
|
||||
|
||||
// ─── Assertions ────────────────────────────────────────────────────────────
|
||||
|
||||
export async function expectChatComposerActive(page: Page): Promise<void> {
|
||||
await expect(composerRoot(page)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(attachButton(page)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(terminalLaunchSubmit(page)).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function expectTerminalComposerActive(page: Page): Promise<void> {
|
||||
await expect(composerRoot(page)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(terminalLaunchSubmit(page)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(attachButton(page)).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function expectLaunchTriggerLabel(page: Page, label: string): Promise<void> {
|
||||
await expect(launchTrigger(page)).toContainText(label);
|
||||
}
|
||||
|
||||
export async function expectChatLaunchSelected(page: Page): Promise<void> {
|
||||
await expectLaunchTriggerLabel(page, "Chat");
|
||||
await expectChatComposerActive(page);
|
||||
}
|
||||
|
||||
export async function expectTerminalLaunchSelected(
|
||||
page: Page,
|
||||
profileLabel: string,
|
||||
): Promise<void> {
|
||||
await expectLaunchTriggerLabel(page, profileLabel);
|
||||
await expectTerminalComposerActive(page);
|
||||
}
|
||||
|
||||
export async function expectTerminalPreviewCommand(page: Page, command: string): Promise<void> {
|
||||
const preview = terminalCommandPreview(page);
|
||||
await expect(preview).toBeVisible({ timeout: 30_000 });
|
||||
await expect(preview).toHaveText(command);
|
||||
}
|
||||
|
||||
function terminalContentIncludes(expected: string) {
|
||||
return (text: string) => text.includes(expected);
|
||||
}
|
||||
|
||||
/** Waits for the spawned terminal's rendered output to contain `expected`. */
|
||||
export async function expectTerminalOutputContains(
|
||||
page: Page,
|
||||
expected: string,
|
||||
timeoutMs = 30_000,
|
||||
): Promise<void> {
|
||||
await waitForTerminalContent(page, terminalContentIncludes(expected), timeoutMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for the New workspace submit to land on a workspace with a terminal
|
||||
* tab open, and returns the terminal id. Scoped to visible elements: earlier
|
||||
* workspace decks stay mounted (offscreen) for fast switching, so an
|
||||
* unscoped terminal-surface locator can match more than one.
|
||||
*/
|
||||
export async function expectWorkspaceOpensWithTerminalTab(page: Page): Promise<string> {
|
||||
const terminalTab = page.locator('[data-testid^="workspace-tab-terminal_"]:visible');
|
||||
await expect(terminalTab.first()).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.locator('[data-testid="terminal-surface"]:visible').first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
const testId = await terminalTab.first().getAttribute("data-testid");
|
||||
const terminalId = testId?.replace("workspace-tab-terminal_", "") ?? "";
|
||||
if (!terminalId) {
|
||||
throw new Error(`Could not parse terminal id from tab testID: ${testId}`);
|
||||
}
|
||||
return terminalId;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import net from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export interface PackagedWebDaemon {
|
||||
home: string;
|
||||
origin: string;
|
||||
port: number;
|
||||
serverId: string;
|
||||
pairingOfferUrl(): Promise<string>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
async function availablePort(): Promise<number> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
server.close(() => reject(new Error("Could not allocate a daemon port")));
|
||||
return;
|
||||
}
|
||||
server.close(() => resolve(address.port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForWebUi(origin: string, home: string): Promise<void> {
|
||||
const deadline = Date.now() + 30_000;
|
||||
let lastError: unknown;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetch(origin, { signal: AbortSignal.timeout(1_000) });
|
||||
const body = await response.text();
|
||||
if (response.ok && body.includes("<html")) return;
|
||||
lastError = new Error(`Daemon web UI returned HTTP ${response.status}`);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
const log = await readFile(path.join(home, "daemon.log"), "utf8").catch(() => "");
|
||||
throw new Error(
|
||||
`Daemon web UI did not become ready: ${lastError instanceof Error ? lastError.message : String(lastError)}\n${log.split("\n").slice(-80).join("\n")}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function startPackagedWebDaemon(input: {
|
||||
relayEndpoint: string;
|
||||
}): Promise<PackagedWebDaemon> {
|
||||
const port = await availablePort();
|
||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-relay-deployment-e2e-"));
|
||||
const serverId = `relay-deployment-${Date.now().toString(36)}`;
|
||||
const paseo = path.resolve(__dirname, "../../../../../node_modules/.bin/paseo");
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
CI: "true",
|
||||
NODE_ENV: "development",
|
||||
PASEO_NODE_ENV: "development",
|
||||
PASEO_SERVER_ID: serverId,
|
||||
PASEO_RELAY_ENDPOINT: input.relayEndpoint,
|
||||
PASEO_RELAY_PUBLIC_ENDPOINT: input.relayEndpoint,
|
||||
PASEO_RELAY_USE_TLS: "false",
|
||||
PASEO_RELAY_PUBLIC_USE_TLS: "false",
|
||||
};
|
||||
|
||||
try {
|
||||
await execFileAsync(
|
||||
paseo,
|
||||
["daemon", "start", "--home", home, "--port", String(port), "--relay", "--web-ui"],
|
||||
{ env },
|
||||
);
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
await waitForWebUi(origin, home);
|
||||
|
||||
return {
|
||||
home,
|
||||
origin,
|
||||
port,
|
||||
serverId,
|
||||
pairingOfferUrl: async () => {
|
||||
const { stdout } = await execFileAsync(
|
||||
paseo,
|
||||
["daemon", "pair", "--home", home, "--relay", "--json"],
|
||||
{ env },
|
||||
);
|
||||
const result = JSON.parse(stdout) as { url?: unknown };
|
||||
if (typeof result.url !== "string") {
|
||||
throw new Error(`Pairing command returned no URL: ${stdout}`);
|
||||
}
|
||||
return result.url;
|
||||
},
|
||||
close: async () => {
|
||||
await execFileAsync(paseo, ["daemon", "stop", "--home", home], { env }).catch(
|
||||
() => undefined,
|
||||
);
|
||||
await rm(home, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await execFileAsync(paseo, ["daemon", "stop", "--home", home], { env }).catch(() => undefined);
|
||||
await rm(home, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -17,49 +17,6 @@ interface PairingHostInput {
|
||||
endpoint: string;
|
||||
}
|
||||
|
||||
export async function prepareLocalPairingHost(
|
||||
page: Page,
|
||||
daemon: IsolatedHostDaemon | OutdatedDaemon,
|
||||
additionalHosts: PairingHostInput[] = [],
|
||||
): Promise<void> {
|
||||
await page.addInitScript((localServerId) => {
|
||||
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = {
|
||||
platform: "darwin",
|
||||
invoke: async (command: string) => {
|
||||
if (command === "desktop_daemon_status") {
|
||||
return {
|
||||
serverId: localServerId,
|
||||
status: "running",
|
||||
listen: null,
|
||||
hostname: null,
|
||||
pid: null,
|
||||
home: "",
|
||||
version: null,
|
||||
desktopManaged: true,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
if (command === "get_desktop_settings") {
|
||||
return {
|
||||
releaseChannel: "stable",
|
||||
daemon: { manageBuiltInDaemon: false, keepRunningAfterQuit: true },
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
getPendingOpenProject: async () => null,
|
||||
events: { on: async () => () => undefined },
|
||||
opener: {
|
||||
openUrl: async (url: string) => {
|
||||
localStorage.setItem("@paseo:e2e-opened-url", url);
|
||||
},
|
||||
},
|
||||
};
|
||||
}, daemon.serverId);
|
||||
|
||||
await preparePairingHost(page, daemon, additionalHosts);
|
||||
}
|
||||
|
||||
export async function preparePairingHost(
|
||||
page: Page,
|
||||
daemon: IsolatedHostDaemon | OutdatedDaemon,
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { parseConnectionOfferFromUrl } from "@getpaseo/protocol/connection-offer";
|
||||
import type { LocalElixirRelay } from "./local-elixir-relay";
|
||||
import type { PackagedWebDaemon } from "./packaged-web-daemon";
|
||||
import { expectRunningAgentChrome } from "./agent-stream";
|
||||
import { buildCreateAgentPreferences } from "./daemon-registry";
|
||||
|
||||
export interface RelayDeploymentMeasurements {
|
||||
reconnectToastDelayMs: number;
|
||||
reconnectToastVisibleMs: number;
|
||||
totalReconnectMs: number;
|
||||
relayStartupMs: number;
|
||||
streamPausedWhileDisconnected: boolean;
|
||||
runningStatePreserved: boolean;
|
||||
streamResumedWithoutUserAction: boolean;
|
||||
}
|
||||
|
||||
export async function connectDaemonWebAppOnlyThroughRelay(
|
||||
page: Page,
|
||||
daemon: PackagedWebDaemon,
|
||||
): Promise<void> {
|
||||
const offerUrl = await daemon.pairingOfferUrl();
|
||||
const offer = parseConnectionOfferFromUrl(offerUrl);
|
||||
if (!offer) throw new Error("Daemon pairing command returned an invalid offer URL");
|
||||
const relayConnection = {
|
||||
id: `relay:${offer.relay.endpoint}`,
|
||||
type: "relay" as const,
|
||||
relayEndpoint: offer.relay.endpoint,
|
||||
useTls: offer.relay.useTls ?? false,
|
||||
daemonPublicKeyB64: offer.daemonPublicKeyB64,
|
||||
};
|
||||
const now = new Date().toISOString();
|
||||
const host = {
|
||||
serverId: offer.serverId,
|
||||
label: "Relay deployment host",
|
||||
connections: [relayConnection],
|
||||
preferredConnectionId: relayConnection.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await page.route(/:(6767)\b/, (route) => route.abort());
|
||||
await page.routeWebSocket(/:(6767)\b/, async (socket) => {
|
||||
await socket.close({ code: 1008, reason: "Blocked developer daemon during relay E2E" });
|
||||
});
|
||||
await page.route("**/*", async (route) => {
|
||||
if (route.request().resourceType() !== "document") {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
const response = await route.fetch();
|
||||
const html = await response.text();
|
||||
await route.fulfill({
|
||||
response,
|
||||
body: html.replace(/<script>window\.__PASEO_INITIAL_DAEMON_CONNECTION__=.*?<\/script>/, ""),
|
||||
});
|
||||
});
|
||||
await page.addInitScript(
|
||||
({ storedHost, preferences }) => {
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([storedHost]));
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||
},
|
||||
{ storedHost: host, preferences: buildCreateAgentPreferences(offer.serverId) },
|
||||
);
|
||||
|
||||
const relaySocketOpened = new Promise<void>((resolve) => {
|
||||
page.on("websocket", (socket) => {
|
||||
if (socket.url().includes(offer.relay.endpoint)) resolve();
|
||||
});
|
||||
});
|
||||
await page.goto(daemon.origin);
|
||||
await relaySocketOpened;
|
||||
await expect(page.getByTestId("sidebar-settings")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function latestAssistantText(page: Page): Promise<string> {
|
||||
return (await page.getByTestId("assistant-message").last().innerText()).trim();
|
||||
}
|
||||
|
||||
async function waitForAssistantTextToGrow(page: Page, previous: string): Promise<void> {
|
||||
await expect.poll(() => latestAssistantText(page), { timeout: 30_000 }).not.toBe(previous);
|
||||
}
|
||||
|
||||
async function expectRunningStatusPreserved(page: Page, agentTitle: string): Promise<void> {
|
||||
const tab = page.getByRole("button", { name: agentTitle, exact: true });
|
||||
await expect(tab.getByRole("progressbar", { name: "Agent running" })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function measureRelayRestartDuringStream(input: {
|
||||
page: Page;
|
||||
relay: LocalElixirRelay;
|
||||
agentTitle: string;
|
||||
}): Promise<RelayDeploymentMeasurements> {
|
||||
const { page, relay, agentTitle } = input;
|
||||
const toast = page.getByTestId("agent-reconnecting-toast");
|
||||
const beforeOutage = await latestAssistantText(page);
|
||||
await waitForAssistantTextToGrow(page, beforeOutage);
|
||||
|
||||
const outageStartedAt = performance.now();
|
||||
const toastVisibleAtPromise = toast
|
||||
.waitFor({ state: "visible", timeout: 30_000 })
|
||||
.then(() => performance.now());
|
||||
await relay.stop();
|
||||
const toastVisibleAt = await toastVisibleAtPromise;
|
||||
|
||||
await expectRunningStatusPreserved(page, agentTitle);
|
||||
await page.waitForTimeout(750);
|
||||
const disconnectedText = await latestAssistantText(page);
|
||||
await page.waitForTimeout(1_000);
|
||||
const streamPausedWhileDisconnected = (await latestAssistantText(page)) === disconnectedText;
|
||||
expect(streamPausedWhileDisconnected).toBe(true);
|
||||
|
||||
const relayStartAt = performance.now();
|
||||
await relay.start();
|
||||
const relayReadyAt = performance.now();
|
||||
await toast.waitFor({ state: "hidden", timeout: 30_000 });
|
||||
const reconnectedAt = performance.now();
|
||||
|
||||
await expectRunningAgentChrome(page, agentTitle);
|
||||
await waitForAssistantTextToGrow(page, disconnectedText);
|
||||
|
||||
return {
|
||||
reconnectToastDelayMs: Math.round(toastVisibleAt - outageStartedAt),
|
||||
reconnectToastVisibleMs: Math.round(reconnectedAt - toastVisibleAt),
|
||||
totalReconnectMs: Math.round(reconnectedAt - outageStartedAt),
|
||||
relayStartupMs: Math.round(relayReadyAt - relayStartAt),
|
||||
streamPausedWhileDisconnected,
|
||||
runningStatePreserved: true,
|
||||
streamResumedWithoutUserAction: true,
|
||||
};
|
||||
}
|
||||
@@ -160,7 +160,7 @@ export async function launchAgent(input: {
|
||||
model?: string;
|
||||
modeId?: string;
|
||||
featureValues?: Record<string, unknown>;
|
||||
extra?: { codex?: { features?: { multi_agent_v2?: boolean } } };
|
||||
providerOptions?: { features?: { multi_agent_v2?: boolean } };
|
||||
};
|
||||
}): Promise<AgentHandle> {
|
||||
execFileSync("git", ["init", "-b", "main"], { cwd: input.cwd, stdio: "ignore" });
|
||||
|
||||
@@ -86,6 +86,18 @@ export async function selectHostConnectionType(
|
||||
await page.getByRole("button", { name: label }).click();
|
||||
}
|
||||
|
||||
export async function addDirectHostFromSettings(
|
||||
page: Page,
|
||||
input: { host: string; port: number },
|
||||
): Promise<void> {
|
||||
await openAddHostFlow(page);
|
||||
await selectHostConnectionType(page, "direct");
|
||||
await page.getByTestId("direct-host-input").fill(input.host);
|
||||
await page.getByTestId("direct-port-input").fill(String(input.port));
|
||||
await page.getByTestId("direct-host-submit").click();
|
||||
await expect(page.getByTestId("add-host-modal")).toHaveCount(0, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function toggleHostAdvanced(page: Page): Promise<void> {
|
||||
await page.getByTestId("direct-host-advanced-toggle").click();
|
||||
}
|
||||
|
||||
@@ -95,9 +95,12 @@ export async function expectReconnectingToastVisible(
|
||||
page: Page,
|
||||
options?: { timeout?: number },
|
||||
): Promise<void> {
|
||||
await expect(page.getByTestId("agent-reconnecting-toast")).toBeVisible({
|
||||
const toast = page.getByTestId("agent-reconnecting-toast");
|
||||
await expect(toast).toBeVisible({
|
||||
timeout: options?.timeout ?? 30_000,
|
||||
});
|
||||
await expect(toast).toHaveText("Reconnecting");
|
||||
await expect(toast.getByTestId("agent-reconnecting-status-dot")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectReconnectingToastGone(
|
||||
@@ -109,6 +112,14 @@ export async function expectReconnectingToastGone(
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectReconnectingToastDebounced(
|
||||
page: Page,
|
||||
options?: { durationMs?: number },
|
||||
): Promise<void> {
|
||||
await page.waitForTimeout(options?.durationMs ?? 750);
|
||||
await expect(page.getByTestId("agent-reconnecting-toast")).toHaveCount(0, { timeout: 100 });
|
||||
}
|
||||
|
||||
export async function expectHostConnectingOrOffline(
|
||||
page: Page,
|
||||
options?: { timeout?: number },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.3.0-beta.2",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -23,6 +23,7 @@
|
||||
"test:browser": "vitest run --project browser",
|
||||
"test:e2e": "playwright test --project=browser",
|
||||
"test:e2e:real": "cross-env E2E_FORK_PASEO_HOME_FROM=../../.dev/paseo-home playwright test --project=real-provider",
|
||||
"test:e2e:relay-deployment": "npm --prefix ../.. run build:server && npm --prefix ../.. run build:daemon-web-ui && cross-env E2E_FORK_PASEO_HOME_FROM=../../.dev/paseo-home playwright test --project=relay-deployment e2e/browser/relay-deployment-reconnect.real.spec.ts",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"build": "npm run build:web",
|
||||
"build:web": "npm --prefix ../.. run build:app-deps && expo export --platform web",
|
||||
@@ -31,6 +32,7 @@
|
||||
"build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/standalone": "^7.29.1",
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/search": "6.7.1",
|
||||
@@ -123,6 +125,7 @@
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/babel__standalone": "^7.1.9",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "~19.2.0",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { defineConfig, devices } from "@playwright/test";
|
||||
// This allows multiple test runs in parallel across different worktrees
|
||||
const baseURL =
|
||||
process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
|
||||
const relayDeploymentSpec = "**/relay-deployment-reconnect.real.spec.ts";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e/browser",
|
||||
@@ -33,6 +34,12 @@ export default defineConfig({
|
||||
{
|
||||
name: "real-provider",
|
||||
testMatch: ["**/*.real.spec.ts"],
|
||||
testIgnore: [relayDeploymentSpec],
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
{
|
||||
name: "relay-deployment",
|
||||
testMatch: [relayDeploymentSpec],
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { ChatOutlineRailProps } from "./rail";
|
||||
// magnifying a slot must not move the box the pointer is resting on. See docs/hover.md.
|
||||
const RAIL_WIDTH = 36;
|
||||
const SLOT_HEIGHT = 8;
|
||||
const MIN_PANEL_WIDTH = 818;
|
||||
const MIN_PANEL_WIDTH = 918;
|
||||
const RESTING_PILL_HEIGHT = 2;
|
||||
const MAGNIFIED_PILL_HEIGHT = 4;
|
||||
const RESTING_PILL_WIDTH = 10;
|
||||
|
||||
@@ -1031,7 +1031,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
minHeight: 0,
|
||||
overflowX: "hidden",
|
||||
overflowY: scrollEnabled ? "auto" : "hidden",
|
||||
overflowAnchor: "none",
|
||||
overscrollBehaviorY: "contain",
|
||||
};
|
||||
}, [scrollEnabled]);
|
||||
|
||||
@@ -22,12 +22,7 @@ import { SyncedLoader } from "@/components/synced-loader";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
|
||||
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
|
||||
const workingIndicatorColorMapping = (theme: Theme) => ({
|
||||
color:
|
||||
theme.colorScheme === "light"
|
||||
? theme.colors.palette.amber[700]
|
||||
: theme.colors.palette.amber[500],
|
||||
});
|
||||
const workingIndicatorColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
|
||||
export type TurnContentStrategy = StreamStrategy;
|
||||
export type AssistantTurnForkHandler = (input: {
|
||||
|
||||
@@ -26,6 +26,7 @@ import { CommandCenterRootActions } from "@/command-center/root-registration";
|
||||
import { CommandCenterProvider } from "@/command-center/provider";
|
||||
import { CommandCenterWorkspaceActions } from "@/command-center/workspace-registration";
|
||||
import { AddProjectFlowHost } from "@/components/add-project-flow-host";
|
||||
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
|
||||
import { WorktreeSetupCalloutSource } from "@/components/worktree-setup-callout-source";
|
||||
import { DownloadToast } from "@/components/download-toast";
|
||||
import { QuittingOverlay } from "@/components/quitting-overlay";
|
||||
@@ -123,6 +124,7 @@ import {
|
||||
} from "@/utils/host-routes";
|
||||
import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
import { PluginCatalogSync } from "@/plugins/catalog-sync";
|
||||
import {
|
||||
ensureOsNotificationPermission,
|
||||
WEB_NOTIFICATION_CLICK_EVENT,
|
||||
@@ -265,7 +267,7 @@ function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
|
||||
|
||||
return (
|
||||
<SessionProvider key={daemon.serverId} serverId={daemon.serverId} client={client}>
|
||||
{null}
|
||||
<PluginCatalogSync serverId={daemon.serverId} client={client} />
|
||||
</SessionProvider>
|
||||
);
|
||||
}
|
||||
@@ -643,7 +645,7 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
|
||||
|
||||
// Apply font / size / syntax appearance settings on mount and when they change.
|
||||
// Sibling to the theme effect above; order is irrelevant because both patch all
|
||||
// six registered theme keys, so the active key is always current.
|
||||
// registered theme keys, so the active key is always current.
|
||||
useEffect(() => {
|
||||
if (settingsLoading) return;
|
||||
applyAppearance({
|
||||
@@ -668,7 +670,7 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
|
||||
<OfferLinkListener upsertDaemonFromOfferUrl={upsertConnectionFromOfferUrl} />
|
||||
<HostSessionManager />
|
||||
<FaviconStatusSync />
|
||||
{children}
|
||||
<AppearanceStyleBoundary>{children}</AppearanceStyleBoundary>
|
||||
</VoiceProvider>
|
||||
);
|
||||
}
|
||||
@@ -891,6 +893,7 @@ function RootStack() {
|
||||
return (
|
||||
<ThemedStack screenOptions={ROOT_STACK_SCREEN_OPTIONS}>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="runtime-component" />
|
||||
<Stack.Protected guard={storeReady}>
|
||||
<Stack.Screen name="welcome" />
|
||||
<Stack.Screen name="settings/index" />
|
||||
@@ -954,19 +957,17 @@ function RuntimeProviders({ children }: { children: ReactNode }) {
|
||||
// context and need one shared provider for sibling sheets to stack.
|
||||
function RootProviders({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<SafeAreaProvider>
|
||||
<WindowChromeProvider>
|
||||
<KeyboardProvider>
|
||||
<KeyboardShiftProvider>
|
||||
<ToastProvider>
|
||||
<PortalProvider>
|
||||
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
|
||||
</PortalProvider>
|
||||
</ToastProvider>
|
||||
</KeyboardShiftProvider>
|
||||
</KeyboardProvider>
|
||||
</WindowChromeProvider>
|
||||
</SafeAreaProvider>
|
||||
<WindowChromeProvider>
|
||||
<KeyboardProvider>
|
||||
<KeyboardShiftProvider>
|
||||
<ToastProvider>
|
||||
<PortalProvider>
|
||||
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
|
||||
</PortalProvider>
|
||||
</ToastProvider>
|
||||
</KeyboardShiftProvider>
|
||||
</KeyboardProvider>
|
||||
</WindowChromeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -998,9 +999,11 @@ export default function RootLayout() {
|
||||
return (
|
||||
<QueryProvider>
|
||||
<I18nProvider>
|
||||
<RootErrorBoundary>
|
||||
<RootAppTree />
|
||||
</RootErrorBoundary>
|
||||
<SafeAreaProvider>
|
||||
<RootErrorBoundary>
|
||||
<RootAppTree />
|
||||
</RootErrorBoundary>
|
||||
</SafeAreaProvider>
|
||||
</I18nProvider>
|
||||
</QueryProvider>
|
||||
);
|
||||
|
||||
@@ -41,6 +41,7 @@ function KnownHostRoute() {
|
||||
<Stack.Screen name="sessions" />
|
||||
<Stack.Screen name="open-project" />
|
||||
<Stack.Screen name="settings" />
|
||||
<Stack.Screen name="plugin/[pluginId]/[surfaceId]" />
|
||||
</ThemedStack>
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PluginSurfaceScreen } from "@/plugins/surface-screen";
|
||||
|
||||
export default function PluginSurfaceRoute() {
|
||||
return <PluginSurfaceScreen />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { RuntimeComponentPrototypeScreen } from "@/runtime-component/prototype-screen";
|
||||
|
||||
export default function RuntimeComponentRoute() {
|
||||
return <RuntimeComponentPrototypeScreen />;
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createAssistantSelectionClipboardContent } from "./content.web";
|
||||
|
||||
const fixture = `
|
||||
<div data-testid="assistant-message">
|
||||
<div data-paseo-markdown-tag="p">Prefix <span data-paseo-markdown-tag="strong">bold text</span> and <span data-paseo-markdown-tag="code">inline code</span> suffix.</div>
|
||||
<div data-paseo-markdown-tag="ul">
|
||||
<div data-paseo-markdown-tag="li"><span data-paseo-markdown-ignore="true">•</span><div><span>First bullet text</span></div></div>
|
||||
<div data-paseo-markdown-tag="li"><span data-paseo-markdown-ignore="true">•</span><div><span>Second bullet text</span></div></div>
|
||||
</div>
|
||||
<div data-paseo-markdown-tag="pre" data-paseo-markdown-language="ts"><span data-paseo-markdown-tag="code">const answer = true;</span></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
function mountFixture(): HTMLElement {
|
||||
const host = document.createElement("div");
|
||||
host.innerHTML = fixture;
|
||||
document.body.append(host);
|
||||
const message = host.querySelector<HTMLElement>('[data-testid="assistant-message"]');
|
||||
if (!message) {
|
||||
throw new Error("Expected assistant message fixture");
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
function fixtureElement<T extends Element = HTMLElement>(
|
||||
root: ParentNode,
|
||||
selector: string,
|
||||
index = 0,
|
||||
): T {
|
||||
const element = root.querySelectorAll<T>(selector).item(index);
|
||||
if (!element) {
|
||||
throw new Error(`Expected fixture element ${selector} at index ${index}`);
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
function textNode(element: Element): Text {
|
||||
const node = element.firstChild;
|
||||
if (!(node instanceof Text)) {
|
||||
throw new Error("Expected text node");
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
function selectText(element: Element, start: number, end: number): Selection {
|
||||
const node = textNode(element);
|
||||
const range = document.createRange();
|
||||
range.setStart(node, start);
|
||||
range.setEnd(node, end);
|
||||
const selection = window.getSelection();
|
||||
if (!selection) {
|
||||
throw new Error("Expected browser selection");
|
||||
}
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
return selection;
|
||||
}
|
||||
|
||||
function selectRange(
|
||||
startElement: Element,
|
||||
startOffset: number,
|
||||
endElement: Element,
|
||||
endOffset: number,
|
||||
): Selection {
|
||||
const range = document.createRange();
|
||||
range.setStart(textNode(startElement), startOffset);
|
||||
range.setEnd(textNode(endElement), endOffset);
|
||||
const selection = window.getSelection();
|
||||
if (!selection) {
|
||||
throw new Error("Expected browser selection");
|
||||
}
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
return selection;
|
||||
}
|
||||
|
||||
function selectNodeContents(element: Element): Selection {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(element);
|
||||
const selection = window.getSelection();
|
||||
if (!selection) {
|
||||
throw new Error("Expected browser selection");
|
||||
}
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
return selection;
|
||||
}
|
||||
|
||||
function copiedMarkdown(selection: Selection): string | null {
|
||||
return createAssistantSelectionClipboardContent(selection)?.plainText ?? null;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
window.getSelection()?.removeAllRanges();
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
describe("assistant selection copy ranges", () => {
|
||||
it("does not handle absent, collapsed, or non-assistant selections", () => {
|
||||
expect(createAssistantSelectionClipboardContent(null)).toBeNull();
|
||||
|
||||
const message = mountFixture();
|
||||
const strong = fixtureElement(message, '[data-paseo-markdown-tag="strong"]');
|
||||
expect(copiedMarkdown(selectText(strong, 2, 2))).toBeNull();
|
||||
|
||||
const outside = document.createElement("span");
|
||||
outside.textContent = "outside";
|
||||
document.body.append(outside);
|
||||
expect(copiedMarkdown(selectText(outside, 0, 7))).toBeNull();
|
||||
});
|
||||
|
||||
it("does not replace the browser clipboard for a range spanning assistant messages", () => {
|
||||
const firstMessage = mountFixture();
|
||||
const secondMessage = mountFixture();
|
||||
const firstText = fixtureElement(firstMessage, '[data-paseo-markdown-tag="strong"]');
|
||||
const secondText = fixtureElement(secondMessage, '[data-paseo-markdown-tag="strong"]');
|
||||
expect(copiedMarkdown(selectRange(firstText, 0, secondText, 4))).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves all structure when the complete assistant message is selected", () => {
|
||||
const message = mountFixture();
|
||||
const content = createAssistantSelectionClipboardContent(selectNodeContents(message));
|
||||
expect(content?.plainText).toBe(
|
||||
[
|
||||
"Prefix **bold text** and `inline code` suffix.",
|
||||
"",
|
||||
"- First bullet text",
|
||||
"- Second bullet text",
|
||||
"",
|
||||
"```ts",
|
||||
"const answer = true;",
|
||||
"```",
|
||||
].join("\n"),
|
||||
);
|
||||
expect(content?.html).not.toContain("<ul>");
|
||||
expect(content?.html).not.toContain("<li>");
|
||||
expect(content?.html).toContain("<div>- First bullet text</div>");
|
||||
expect(content?.html).toContain("<div>- Second bullet text</div>");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ tag: "strong", range: [1, 5], expected: "old", forbiddenHtml: "<strong>" },
|
||||
{ tag: "code", range: [2, 8], expected: "line c", forbiddenHtml: "<code>" },
|
||||
])(
|
||||
"copies a partial $tag range without expanding to its delimiters",
|
||||
({ tag, range, expected, forbiddenHtml }) => {
|
||||
const message = mountFixture();
|
||||
const element = fixtureElement(message, `[data-paseo-markdown-tag="${tag}"]`);
|
||||
const content = createAssistantSelectionClipboardContent(
|
||||
selectText(element, range[0], range[1]),
|
||||
);
|
||||
expect(content?.plainText).toBe(expected);
|
||||
expect(content?.html).not.toContain(forbiddenHtml);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["strong", "em", "s", "h2", "blockquote"])(
|
||||
"copies all content selected from inside a %s element without its syntax",
|
||||
(tag) => {
|
||||
const message = mountFixture();
|
||||
const element = fixtureElement(message, '[data-paseo-markdown-tag="strong"]');
|
||||
element.setAttribute("data-paseo-markdown-tag", tag);
|
||||
const content = createAssistantSelectionClipboardContent(
|
||||
selectText(element, 0, textNode(element).length),
|
||||
);
|
||||
expect(content?.plainText).toBe("bold text");
|
||||
expect(content?.html).not.toContain(`<${tag}>`);
|
||||
},
|
||||
);
|
||||
|
||||
it("copies complete inline code without delimiters when the selection stays inside", () => {
|
||||
const message = mountFixture();
|
||||
const element = fixtureElement(message, '[data-paseo-markdown-tag="code"]');
|
||||
const content = createAssistantSelectionClipboardContent(
|
||||
selectText(element, 0, textNode(element).length),
|
||||
);
|
||||
expect(content?.plainText).toBe("inline code");
|
||||
expect(content?.html).not.toContain("<code>");
|
||||
});
|
||||
|
||||
it("keeps a complete inline node but drops formatting from a partial node at the other edge", () => {
|
||||
const message = mountFixture();
|
||||
const strong = fixtureElement(message, '[data-paseo-markdown-tag="strong"]');
|
||||
const code = fixtureElement(message, '[data-paseo-markdown-tag="code"]');
|
||||
expect(copiedMarkdown(selectRange(strong, 0, code, 6))).toBe("**bold text** and inline");
|
||||
});
|
||||
|
||||
it("copies list-item text without inventing a bullet", () => {
|
||||
const message = mountFixture();
|
||||
const itemText = fixtureElement(message, '[data-paseo-markdown-tag="li"] div span');
|
||||
const content = createAssistantSelectionClipboardContent(
|
||||
selectText(itemText, 0, textNode(itemText).length),
|
||||
);
|
||||
expect(content?.plainText).toBe("First bullet text");
|
||||
expect(content?.html).toContain("<p>First bullet text</p>");
|
||||
expect(content?.html).not.toContain("<li>");
|
||||
});
|
||||
|
||||
it("retains a bullet when the range includes the marker and the complete item", () => {
|
||||
const message = mountFixture();
|
||||
const item = fixtureElement(message, '[data-paseo-markdown-tag="li"]');
|
||||
expect(copiedMarkdown(selectNodeContents(item))).toBe("- First bullet text");
|
||||
});
|
||||
|
||||
it("preserves paragraph breaks when rich HTML flattens a loose list item", () => {
|
||||
const message = mountFixture();
|
||||
const item = fixtureElement(message, '[data-paseo-markdown-tag="li"]');
|
||||
item.replaceChildren();
|
||||
item.insertAdjacentHTML(
|
||||
"beforeend",
|
||||
'<div data-paseo-markdown-tag="p">First paragraph</div><div data-paseo-markdown-tag="p">Second paragraph</div>',
|
||||
);
|
||||
|
||||
const content = createAssistantSelectionClipboardContent(selectNodeContents(item));
|
||||
expect(content?.plainText).toBe("- First paragraph\n\n Second paragraph");
|
||||
expect(content?.html).toMatch(/<div>-\s*First paragraph\s*<br><br>Second paragraph\s*<\/div>/);
|
||||
});
|
||||
|
||||
it("omits a partial leading bullet and retains the marker crossed before the trailing item", () => {
|
||||
const message = mountFixture();
|
||||
const selector = '[data-paseo-markdown-tag="li"] div span';
|
||||
const first = fixtureElement(message, selector);
|
||||
const second = fixtureElement(message, selector, 1);
|
||||
expect(copiedMarkdown(selectRange(first, 6, second, textNode(second).length))).toBe(
|
||||
"bullet text\n\n- Second bullet text",
|
||||
);
|
||||
});
|
||||
|
||||
it("copies part of a fenced code block without adding an inline or fenced delimiter", () => {
|
||||
const message = mountFixture();
|
||||
const blockCode = fixtureElement(
|
||||
message,
|
||||
'[data-paseo-markdown-tag="pre"] [data-paseo-markdown-tag="code"]',
|
||||
);
|
||||
const content = createAssistantSelectionClipboardContent(selectText(blockCode, 6, 12));
|
||||
expect(content?.plainText).toBe("answer");
|
||||
expect(content?.html).not.toContain("<code>");
|
||||
expect(content?.html).not.toContain("<pre>");
|
||||
});
|
||||
|
||||
it("copies code without a fence when every character is selected inside the block", () => {
|
||||
const message = mountFixture();
|
||||
const blockCode = fixtureElement(
|
||||
message,
|
||||
'[data-paseo-markdown-tag="pre"] [data-paseo-markdown-tag="code"]',
|
||||
);
|
||||
expect(copiedMarkdown(selectText(blockCode, 0, textNode(blockCode).length))).toBe(
|
||||
"const answer = true;",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The fixture above holds a code block in one text node. The real renderer splits a
|
||||
* highlighted line across one text node per syntax token and emits each newline as a
|
||||
* node of its own (`highlighted-code-block.tsx`), and it puts the hover Copy button
|
||||
* inside the `pre`. Line breaks and indentation only survive if the code skips
|
||||
* Turndown, so those cases need a fixture shaped like the real thing.
|
||||
*/
|
||||
function highlightedFixture(language: string | null): string {
|
||||
const languageAttribute =
|
||||
language === null ? "" : ` data-paseo-markdown-language="${escapeAttribute(language)}"`;
|
||||
return [
|
||||
'<div data-testid="assistant-message">',
|
||||
`<div data-paseo-markdown-tag="pre"${languageAttribute}>`,
|
||||
'<span data-paseo-markdown-tag="code">',
|
||||
"<span>const</span><span> answer</span><span> = 1;</span>",
|
||||
"<span>\n</span>",
|
||||
"<span> if</span><span> (answer)</span><span> {</span>",
|
||||
"<span>\n</span>",
|
||||
"<span> doThing();</span>",
|
||||
"</span>",
|
||||
'<div data-paseo-markdown-ignore="true"><span>Copy</span></div>',
|
||||
"</div>",
|
||||
'<div data-paseo-markdown-tag="p"><span>After the block.</span></div>',
|
||||
"</div>",
|
||||
].join("");
|
||||
}
|
||||
|
||||
function escapeAttribute(value: string): string {
|
||||
const holder = document.createElement("div");
|
||||
holder.setAttribute("data-value", value);
|
||||
return holder.outerHTML.slice('<div data-value="'.length, -"></div>".length);
|
||||
}
|
||||
|
||||
function mountHighlighted(language: string | null = "typescript"): HTMLElement {
|
||||
const host = document.createElement("div");
|
||||
host.innerHTML = highlightedFixture(language);
|
||||
document.body.append(host);
|
||||
const message = host.querySelector<HTMLElement>('[data-testid="assistant-message"]');
|
||||
if (!message) {
|
||||
throw new Error("Expected assistant message fixture");
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
/** The single text node whose content is exactly `text`. */
|
||||
function tokenText(root: Node, text: string): Text {
|
||||
const matches = allTextNodes(root, text);
|
||||
if (matches.length !== 1) {
|
||||
throw new Error(`Expected exactly one text node "${text}", found ${matches.length}`);
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
function allTextNodes(root: Node, text: string): Text[] {
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||
const matches: Text[] = [];
|
||||
while (walker.nextNode()) {
|
||||
if (walker.currentNode.textContent === text) {
|
||||
matches.push(walker.currentNode as Text);
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
/** The standalone `\n` node that ends rendered code line `index`. */
|
||||
function lineBreak(root: Node, index: number): Text {
|
||||
const breaks = allTextNodes(root, "\n");
|
||||
const node = breaks[index];
|
||||
if (!node) {
|
||||
throw new Error(`Expected a line break at index ${index}, found ${breaks.length}`);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
function copyBetween(start: [Text, number], end: [Text, number]) {
|
||||
const range = document.createRange();
|
||||
range.setStart(start[0], start[1]);
|
||||
range.setEnd(end[0], end[1]);
|
||||
const selection = window.getSelection();
|
||||
if (!selection) {
|
||||
throw new Error("Expected browser selection");
|
||||
}
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
return createAssistantSelectionClipboardContent(selection);
|
||||
}
|
||||
|
||||
/** Select from the start of one text node to the end of another. */
|
||||
function copyAcross(root: Node, startText: string, endText: string) {
|
||||
return copyBetween([tokenText(root, startText), 0], [tokenText(root, endText), endText.length]);
|
||||
}
|
||||
|
||||
describe("assistant selection copy inside highlighted code", () => {
|
||||
it("keeps the line break and the indentation of a partial multi-line selection", () => {
|
||||
const message = mountHighlighted();
|
||||
|
||||
const content = copyAcross(message, "const", " {");
|
||||
|
||||
expect(content?.plainText).toBe("const answer = 1;\n if (answer) {");
|
||||
});
|
||||
|
||||
it("keeps multi-line code as a block in the html half so the lines survive", () => {
|
||||
const message = mountHighlighted();
|
||||
|
||||
const content = copyAcross(message, "const", " {");
|
||||
|
||||
expect(content?.html).toBe(
|
||||
'<meta charset="utf-8"><pre><code class="language-typescript">const answer = 1;\n if (answer) {</code></pre>',
|
||||
);
|
||||
});
|
||||
|
||||
it("does not escape Markdown characters inside copied code", () => {
|
||||
const message = mountHighlighted();
|
||||
|
||||
const content = copyBetween([tokenText(message, " = 1;"), 1], [tokenText(message, " = 1;"), 5]);
|
||||
|
||||
expect(content?.plainText).toBe("= 1;");
|
||||
});
|
||||
|
||||
it("handles a common ancestor that is an element rather than a text node", () => {
|
||||
const message = mountHighlighted();
|
||||
|
||||
// Spanning two token spans makes `commonAncestorContainer` the `code` element.
|
||||
const content = copyBetween(
|
||||
[tokenText(message, "const"), 1],
|
||||
[tokenText(message, " answer"), 4],
|
||||
);
|
||||
|
||||
expect(content?.plainText).toBe("onst ans");
|
||||
});
|
||||
|
||||
it("drops a trailing line break swept in from the end of a line", () => {
|
||||
const message = mountHighlighted();
|
||||
|
||||
const content = copyBetween([tokenText(message, " = 1;"), 1], [lineBreak(message, 0), 1]);
|
||||
|
||||
expect(content?.plainText).toBe("= 1;");
|
||||
});
|
||||
|
||||
it("drops a trailing line break together with the indentation after it", () => {
|
||||
const message = mountHighlighted();
|
||||
|
||||
const content = copyBetween([tokenText(message, "const"), 0], [tokenText(message, " if"), 2]);
|
||||
|
||||
expect(content?.plainText).toBe("const answer = 1;");
|
||||
});
|
||||
|
||||
it("keeps line breaks that are not at the end of the selection", () => {
|
||||
const message = mountHighlighted();
|
||||
|
||||
const content = copyBetween([tokenText(message, "const"), 0], [tokenText(message, " if"), 4]);
|
||||
|
||||
expect(content?.plainText).toBe("const answer = 1;\n if");
|
||||
});
|
||||
|
||||
it("leaves the hover copy button out of the copied code", () => {
|
||||
const message = mountHighlighted();
|
||||
|
||||
const content = copyAcross(message, " answer", "Copy");
|
||||
|
||||
expect(content?.plainText).toBe(" answer = 1;\n if (answer) {\n doThing();");
|
||||
expect(content?.plainText).not.toContain("Copy");
|
||||
});
|
||||
|
||||
it("does not treat a selection inside the copy button as code", () => {
|
||||
const message = mountHighlighted();
|
||||
|
||||
expect(copyAcross(message, "Copy", "Copy")).toBeNull();
|
||||
});
|
||||
|
||||
it("omits the language class when the fence carries no info string", () => {
|
||||
const message = mountHighlighted(null);
|
||||
|
||||
const content = copyAcross(message, "const", " {");
|
||||
|
||||
expect(content?.html).toBe(
|
||||
'<meta charset="utf-8"><pre><code>const answer = 1;\n if (answer) {</code></pre>',
|
||||
);
|
||||
});
|
||||
|
||||
it("escapes a fence info string that would break out of the class attribute", () => {
|
||||
const message = mountHighlighted('ts" onload="x');
|
||||
|
||||
const content = copyAcross(message, "const", " {");
|
||||
|
||||
expect(content?.html).toContain('class="language-ts" onload="x"');
|
||||
expect(content?.html).not.toContain('onload="x"');
|
||||
});
|
||||
|
||||
it("copies a fully selected multi-line region as code when the selection stays inside", () => {
|
||||
const message = mountHighlighted();
|
||||
const blockCode = fixtureElement(
|
||||
message,
|
||||
'[data-paseo-markdown-tag="pre"] [data-paseo-markdown-tag="code"]',
|
||||
);
|
||||
|
||||
expect(copiedMarkdown(selectNodeContents(blockCode))).toBe(
|
||||
"const answer = 1;\n if (answer) {\n doThing();",
|
||||
);
|
||||
});
|
||||
|
||||
it("retains the fence when a complete code block is selected across its boundary", () => {
|
||||
const message = mountHighlighted();
|
||||
|
||||
const content = copyAcross(message, "const", "After the block.");
|
||||
|
||||
expect(content?.plainText).toBe(
|
||||
"```typescript\nconst answer = 1;\n if (answer) {\n doThing();\n```\n\nAfter the block.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import TurndownService from "turndown";
|
||||
import { gfm } from "turndown-plugin-gfm";
|
||||
import {
|
||||
createCodeClipboardContent,
|
||||
createMarkdownClipboardContent,
|
||||
type MarkdownClipboardContent,
|
||||
} from "@/utils/rich-clipboard";
|
||||
@@ -11,9 +12,12 @@ import {
|
||||
MARKDOWN_COPY_LIST_START_ATTRIBUTE,
|
||||
MARKDOWN_COPY_TAG_ATTRIBUTE,
|
||||
MARKDOWN_COPY_UNWRAP_ATTRIBUTE,
|
||||
TRAILING_CODE_LINE_BREAKS,
|
||||
} from "./markup";
|
||||
|
||||
const ASSISTANT_MESSAGE_SELECTOR = '[data-testid="assistant-message"]';
|
||||
const CODE_BLOCK_SELECTOR = `[${MARKDOWN_COPY_TAG_ATTRIBUTE}="pre"]`;
|
||||
const CODE_REGION_SELECTOR = `${CODE_BLOCK_SELECTOR}, [${MARKDOWN_COPY_TAG_ATTRIBUTE}="code"]`;
|
||||
|
||||
const turndown = new TurndownService({
|
||||
bulletListMarker: "-",
|
||||
@@ -65,66 +69,306 @@ export function createAssistantSelectionClipboardContent(
|
||||
return null;
|
||||
}
|
||||
|
||||
const partialCode = createPartialCodeContent(range, startMessage);
|
||||
if (partialCode) {
|
||||
return partialCode;
|
||||
}
|
||||
|
||||
const container = document.createElement("div");
|
||||
const selected = wrapSelectionWithAncestors(range, startMessage);
|
||||
normalizeOrderedListStarts(selected, range, startMessage);
|
||||
const selected = cloneMarkdownSelection(range, startMessage);
|
||||
container.append(selected);
|
||||
restoreMarkdownElements(container);
|
||||
|
||||
const markdown = turndown.turndown(container.innerHTML).trim();
|
||||
return markdown ? createMarkdownClipboardContent(markdown) : null;
|
||||
if (!markdown) {
|
||||
return null;
|
||||
}
|
||||
const content = createMarkdownClipboardContent(markdown);
|
||||
return { ...content, html: flattenClipboardListMarkup(content.html) };
|
||||
}
|
||||
|
||||
function wrapSelectionWithAncestors(range: Range, message: Element): Node {
|
||||
let selected: Node = range.cloneContents();
|
||||
/**
|
||||
* Partly-selected code copies as the code itself, never through Turndown.
|
||||
*
|
||||
* `removeUnselectedSemantics` strips the `pre`/`code` tags from a partial selection,
|
||||
* which is right for the Markdown syntax but leaves the code being serialized as
|
||||
* prose: Turndown collapses the indentation and the line breaks, and escapes
|
||||
* Markdown-significant characters inside it. Two selected lines arrive as one.
|
||||
*
|
||||
* A selection contained inside code always copies as code, even when it contains every
|
||||
* character. A selection that crosses the code boundary stays on the Markdown path so
|
||||
* a complete block retains its fence.
|
||||
*/
|
||||
function createPartialCodeContent(range: Range, message: Element): MarkdownClipboardContent | null {
|
||||
const region = closestCodeRegion(range.commonAncestorContainer, message);
|
||||
if (!region) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const code = selectedCodeText(range);
|
||||
if (!code) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// `closest` stops at the inner `code` span, but the fence language lives on the
|
||||
// outer `pre` wrapper.
|
||||
const fence = region.closest(CODE_BLOCK_SELECTOR);
|
||||
|
||||
// Only multi-line code earns markup in the html half, and only because HTML would
|
||||
// otherwise collapse the newlines. A single line stays undecorated, like every
|
||||
// other partial selection.
|
||||
const block = Boolean(fence) && code.includes("\n");
|
||||
return createCodeClipboardContent(code, {
|
||||
language: fence?.getAttribute(MARKDOWN_COPY_LANGUAGE_ATTRIBUTE),
|
||||
block,
|
||||
});
|
||||
}
|
||||
|
||||
function closestCodeRegion(node: Node, message: Element): Element | null {
|
||||
const element = node instanceof Element ? node : node.parentElement;
|
||||
const region = element?.closest(CODE_REGION_SELECTOR) ?? null;
|
||||
if (!region || !message.contains(region)) {
|
||||
return null;
|
||||
}
|
||||
// A selection sitting entirely inside the block's own hover Copy button is not
|
||||
// code. `cloneContents` of a single text node drops the ignore marker with the
|
||||
// element that carried it, so this has to be asked of the live DOM.
|
||||
return element?.closest(`[${MARKDOWN_COPY_IGNORE_ATTRIBUTE}]`) ? null : region;
|
||||
}
|
||||
|
||||
function selectedCodeText(range: Range): string {
|
||||
const fragment = range.cloneContents();
|
||||
// The hover Copy button lives inside the `pre`, so a selection that reaches the
|
||||
// end of the block sweeps it up too.
|
||||
for (const ignored of fragment.querySelectorAll(`[${MARKDOWN_COPY_IGNORE_ATTRIBUTE}]`)) {
|
||||
ignored.remove();
|
||||
}
|
||||
// Deliberately untrimmed — leading indentation is part of the code. Trailing line
|
||||
// breaks are the exception: newlines are their own text nodes between rendered
|
||||
// lines, so a selection that overshoots the end of a line sweeps one in.
|
||||
return (fragment.textContent ?? "").replace(TRAILING_CODE_LINE_BREAKS, "");
|
||||
}
|
||||
|
||||
function flattenClipboardListMarkup(html: string): string {
|
||||
const container = document.createElement("div");
|
||||
container.innerHTML = html;
|
||||
|
||||
const lists = Array.from(container.querySelectorAll("ul, ol")).toReversed();
|
||||
for (const list of lists) {
|
||||
const replacement = document.createDocumentFragment();
|
||||
const items = Array.from(list.children).filter((child) => child.tagName === "LI");
|
||||
const start = list.tagName === "OL" ? Number(list.getAttribute("start") ?? 1) : null;
|
||||
|
||||
items.forEach((item, index) => {
|
||||
const line = document.createElement("div");
|
||||
line.append(start === null ? "- " : `${start + index}. `);
|
||||
const nestedLines: HTMLDivElement[] = [];
|
||||
let hasParagraph = false;
|
||||
for (const child of Array.from(item.childNodes)) {
|
||||
if (child instanceof HTMLDivElement) {
|
||||
nestedLines.push(child);
|
||||
} else if (child instanceof HTMLParagraphElement) {
|
||||
if (hasParagraph) {
|
||||
line.append(document.createElement("br"), document.createElement("br"));
|
||||
}
|
||||
line.append(...child.childNodes);
|
||||
hasParagraph = true;
|
||||
} else {
|
||||
line.append(child);
|
||||
}
|
||||
}
|
||||
replacement.append(line, ...nestedLines);
|
||||
});
|
||||
list.replaceWith(replacement);
|
||||
}
|
||||
|
||||
return container.innerHTML;
|
||||
}
|
||||
|
||||
function cloneMarkdownSelection(range: Range, message: Element): Node {
|
||||
const clonedMessage = message.cloneNode(true) as Element;
|
||||
const clonedRange = cloneRangeInto(range, message, clonedMessage);
|
||||
removeUnselectedSemantics(range, message, clonedMessage);
|
||||
|
||||
let selected: Node = clonedRange.cloneContents();
|
||||
let ancestor =
|
||||
range.commonAncestorContainer instanceof Element
|
||||
? range.commonAncestorContainer
|
||||
: range.commonAncestorContainer.parentElement;
|
||||
|
||||
while (ancestor && ancestor !== message) {
|
||||
const wrapper = ancestor.cloneNode(false) as Element;
|
||||
wrapper.append(selected);
|
||||
selected = wrapper;
|
||||
if (shouldPreserveSemanticElement(range, ancestor)) {
|
||||
const wrapper = ancestor.cloneNode(false) as Element;
|
||||
normalizeOrderedListStart(wrapper, ancestor, range);
|
||||
wrapper.append(selected);
|
||||
selected = wrapper;
|
||||
}
|
||||
ancestor = ancestor.parentElement;
|
||||
}
|
||||
|
||||
return selected;
|
||||
}
|
||||
|
||||
function normalizeOrderedListStarts(selected: Node, range: Range, message: Element): void {
|
||||
const selector = `[${MARKDOWN_COPY_TAG_ATTRIBUTE}="ol"]`;
|
||||
const originals = Array.from(message.querySelectorAll(selector)).filter((list) =>
|
||||
range.intersectsNode(list),
|
||||
function cloneRangeInto(range: Range, source: Element, clone: Element): Range {
|
||||
const clonedRange = document.createRange();
|
||||
clonedRange.setStart(
|
||||
nodeAtPath(clone, nodePath(source, range.startContainer)),
|
||||
range.startOffset,
|
||||
);
|
||||
const copies = markedElements(selected, selector);
|
||||
|
||||
for (let index = 0; index < Math.min(originals.length, copies.length); index += 1) {
|
||||
const original = originals[index];
|
||||
const firstSelectedItem = Array.from(original.children).find(
|
||||
(child) =>
|
||||
child.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE) === "li" && range.intersectsNode(child),
|
||||
);
|
||||
if (!firstSelectedItem) {
|
||||
continue;
|
||||
}
|
||||
const omittedItems = Array.from(original.children)
|
||||
.slice(0, Array.from(original.children).indexOf(firstSelectedItem))
|
||||
.filter((child) => child.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE) === "li").length;
|
||||
const originalStart = Number(original.getAttribute(MARKDOWN_COPY_LIST_START_ATTRIBUTE) ?? 1);
|
||||
copies[index].setAttribute(
|
||||
MARKDOWN_COPY_LIST_START_ATTRIBUTE,
|
||||
String(originalStart + omittedItems),
|
||||
);
|
||||
}
|
||||
clonedRange.setEnd(nodeAtPath(clone, nodePath(source, range.endContainer)), range.endOffset);
|
||||
return clonedRange;
|
||||
}
|
||||
|
||||
function markedElements(root: Node, selector: string): Element[] {
|
||||
const elements = root instanceof Element && root.matches(selector) ? [root] : [];
|
||||
if ("querySelectorAll" in root) {
|
||||
elements.push(...Array.from((root as ParentNode).querySelectorAll(selector)));
|
||||
function nodePath(root: Node, node: Node): number[] {
|
||||
const path: number[] = [];
|
||||
let current = node;
|
||||
while (current !== root) {
|
||||
const parent = current.parentNode;
|
||||
if (!parent) {
|
||||
throw new Error("Selection boundary is outside its assistant message");
|
||||
}
|
||||
path.unshift(Array.from(parent.childNodes).findIndex((child) => child === current));
|
||||
current = parent;
|
||||
}
|
||||
return elements;
|
||||
return path;
|
||||
}
|
||||
|
||||
function nodeAtPath(root: Node, path: number[]): Node {
|
||||
let current = root;
|
||||
for (const index of path) {
|
||||
const child = current.childNodes[index];
|
||||
if (!child) {
|
||||
throw new Error("Cloned assistant message does not match the selection source");
|
||||
}
|
||||
current = child;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function removeUnselectedSemantics(range: Range, source: Element, clone: Element): void {
|
||||
const selector = `[${MARKDOWN_COPY_TAG_ATTRIBUTE}], a`;
|
||||
const originals = Array.from(source.querySelectorAll(selector));
|
||||
const copies = Array.from(clone.querySelectorAll(selector));
|
||||
|
||||
originals.forEach((original, index) => {
|
||||
const copy = copies[index];
|
||||
if (!copy || shouldPreserveSemanticElement(range, original)) {
|
||||
if (copy) {
|
||||
normalizeOrderedListStart(copy, original, range);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const tag = original.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE);
|
||||
if (tag && isBlockBoundary(tag)) {
|
||||
copy.setAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE, "p");
|
||||
return;
|
||||
}
|
||||
copy.removeAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE);
|
||||
if (copy.tagName === "A") {
|
||||
copy.setAttribute(MARKDOWN_COPY_UNWRAP_ATTRIBUTE, "true");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function shouldPreserveSemanticElement(range: Range, element: Element): boolean {
|
||||
if (!range.intersectsNode(element)) {
|
||||
return false;
|
||||
}
|
||||
const tag = element.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE);
|
||||
if (tag === "p" || isTableStructure(tag)) {
|
||||
return true;
|
||||
}
|
||||
const isSelectableSemantic = tag !== null && tag !== "ol" && tag !== "ul";
|
||||
if (
|
||||
(isSelectableSemantic || element.tagName === "A") &&
|
||||
selectionStaysInsideElement(range, element)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (tag === "ol" || tag === "ul") {
|
||||
const selectedItems = selectedListItems(element, range);
|
||||
return selectedItems.some((item) => hasSelectedAllContents(range, item, true));
|
||||
}
|
||||
return hasSelectedAllContents(range, element, tag === "li");
|
||||
}
|
||||
|
||||
function selectionStaysInsideElement(range: Range, element: Element): boolean {
|
||||
return (
|
||||
range.startContainer !== element &&
|
||||
range.endContainer !== element &&
|
||||
element.contains(range.startContainer) &&
|
||||
element.contains(range.endContainer)
|
||||
);
|
||||
}
|
||||
|
||||
function selectedListItems(list: Element, range: Range): Element[] {
|
||||
return Array.from(list.children).filter(
|
||||
(child) =>
|
||||
child.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE) === "li" &&
|
||||
(child.contains(range.startContainer) ||
|
||||
child.contains(range.endContainer) ||
|
||||
hasSelectedAllContents(range, child, true)),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeOrderedListStart(copy: Element, original: Element, range: Range): void {
|
||||
if (original.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE) !== "ol") {
|
||||
return;
|
||||
}
|
||||
const firstSelectedItem = selectedListItems(original, range)[0];
|
||||
if (!firstSelectedItem) {
|
||||
return;
|
||||
}
|
||||
const items = Array.from(original.children).filter(
|
||||
(child) => child.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE) === "li",
|
||||
);
|
||||
const omittedItems = items.indexOf(firstSelectedItem);
|
||||
const originalStart = Number(original.getAttribute(MARKDOWN_COPY_LIST_START_ATTRIBUTE) ?? 1);
|
||||
copy.setAttribute(MARKDOWN_COPY_LIST_START_ATTRIBUTE, String(originalStart + omittedItems));
|
||||
}
|
||||
|
||||
function hasSelectedAllContents(range: Range, element: Element, includeIgnored = false): boolean {
|
||||
const contents = document.createRange();
|
||||
contents.selectNodeContents(element);
|
||||
|
||||
if (range.compareBoundaryPoints(Range.START_TO_START, contents) > 0) {
|
||||
const before = contents.cloneRange();
|
||||
before.setEnd(range.startContainer, range.startOffset);
|
||||
if (hasMarkdownContent(before.cloneContents(), includeIgnored)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (range.compareBoundaryPoints(Range.END_TO_END, contents) < 0) {
|
||||
const after = contents.cloneRange();
|
||||
after.setStart(range.endContainer, range.endOffset);
|
||||
if (hasMarkdownContent(after.cloneContents(), includeIgnored)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function hasMarkdownContent(fragment: DocumentFragment, includeIgnored: boolean): boolean {
|
||||
if (!includeIgnored) {
|
||||
for (const ignored of fragment.querySelectorAll(`[${MARKDOWN_COPY_IGNORE_ATTRIBUTE}]`)) {
|
||||
ignored.remove();
|
||||
}
|
||||
}
|
||||
if (fragment.textContent) {
|
||||
return true;
|
||||
}
|
||||
const visibleVoidSelector = ["br", "hr"]
|
||||
.map((tag) => `[${MARKDOWN_COPY_TAG_ATTRIBUTE}="${tag}"]`)
|
||||
.join(",");
|
||||
return Boolean(fragment.querySelector(visibleVoidSelector));
|
||||
}
|
||||
|
||||
function isBlockBoundary(tag: string): boolean {
|
||||
return tag === "li" || tag === "blockquote" || tag === "pre" || /^h[1-6]$/.test(tag);
|
||||
}
|
||||
|
||||
function isTableStructure(tag: string | null): boolean {
|
||||
return ["table", "thead", "tbody", "tr", "th", "td"].includes(tag ?? "");
|
||||
}
|
||||
|
||||
function closestAssistantMessage(node: Node): Element | null {
|
||||
|
||||
@@ -5,6 +5,16 @@ export const MARKDOWN_COPY_LIST_START_ATTRIBUTE = "data-paseo-markdown-list-star
|
||||
export const MARKDOWN_COPY_LANGUAGE_ATTRIBUTE = "data-paseo-markdown-language";
|
||||
export const MARKDOWN_COPY_ALIGN_ATTRIBUTE = "data-paseo-markdown-align";
|
||||
|
||||
/**
|
||||
* Trailing line breaks, with any indentation that followed the last one.
|
||||
*
|
||||
* Both ways of copying code strip these, for the same reason: pasting a trailing
|
||||
* newline into a terminal runs the last line. A fence body always ends in one, and
|
||||
* ends in several when the author left blank lines before the closing fence; a
|
||||
* selection picks one up whenever it overshoots the end of a rendered line.
|
||||
*/
|
||||
export const TRAILING_CODE_LINE_BREAKS = /(\r?\n[ \t]*)+$/;
|
||||
|
||||
export const markdownCopyDataSet = {
|
||||
blockquote: { paseoMarkdownTag: "blockquote" },
|
||||
br: { paseoMarkdownTag: "br" },
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { getAgentAttachmentPillContent } from "./attachment-pill-content";
|
||||
|
||||
describe("agent attachment pill content", () => {
|
||||
it("presents external resources with their provider identity", () => {
|
||||
const content = getAgentAttachmentPillContent(
|
||||
{
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
title: "ENG-123 Plugin attachments",
|
||||
text: "Linear issue ENG-123: Plugin attachments",
|
||||
externalResource: {
|
||||
provider: "linear",
|
||||
providerLabel: "Linear issue",
|
||||
resourceType: "issue",
|
||||
id: "issue-uuid",
|
||||
identifier: "ENG-123",
|
||||
title: "Plugin attachments",
|
||||
url: "https://linear.app/acme/issue/ENG-123/plugin-attachments",
|
||||
},
|
||||
},
|
||||
i18n.t,
|
||||
);
|
||||
|
||||
expect(content.title).toBe("Plugin attachments");
|
||||
expect(content.subtitle).toBe("Linear issue ENG-123");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import React from "react";
|
||||
import {
|
||||
CircleDot,
|
||||
FileText,
|
||||
@@ -86,6 +87,13 @@ export function getAgentAttachmentPillContent(
|
||||
subtitle: `Issue #${attachment.number}`,
|
||||
};
|
||||
case "text":
|
||||
if (attachment.externalResource) {
|
||||
return {
|
||||
icon: attachmentGithubIssueIcon,
|
||||
title: attachment.externalResource.title,
|
||||
subtitle: `${attachment.externalResource.providerLabel} ${attachment.externalResource.identifier}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
icon: attachmentFileIcon,
|
||||
title: attachment.title ?? t("message.attachments.textAttachment"),
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
ForgeSearchItem,
|
||||
UploadedFileAttachment,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import type { PluginResourceComposerAttachment } from "@/plugins/attachments/model";
|
||||
|
||||
export type AttachmentStorageType = "web-indexeddb" | "desktop-file" | "native-file";
|
||||
|
||||
@@ -106,6 +107,7 @@ export type UserComposerAttachment =
|
||||
| { kind: "image"; metadata: AttachmentMetadata }
|
||||
| { kind: "file"; attachment: UploadedFileAttachment }
|
||||
| WorkspaceFileComposerAttachment
|
||||
| PluginResourceComposerAttachment
|
||||
| { kind: "forge_issue"; item: ForgeSearchItem }
|
||||
| { kind: "forge_change_request"; item: ForgeSearchItem }
|
||||
// COMPAT(githubAttachmentKinds): added in v0.1.106, remove after 2026-12-28 once daemon floor >= v0.1.106
|
||||
|
||||
@@ -6,7 +6,10 @@ import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/l
|
||||
import { GIT_ACTION_ICONS } from "@/git/action-icons";
|
||||
import { useGitActionRunner, useGitActions } from "@/git/use-actions";
|
||||
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
||||
import { resolveShortcutKeysForAction } from "@/keyboard/keyboard-shortcuts";
|
||||
import {
|
||||
resolveShortcutKeysForAction,
|
||||
type ShortcutOverrides,
|
||||
} from "@/keyboard/keyboard-shortcuts";
|
||||
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
|
||||
@@ -36,9 +39,7 @@ function staticIcon(element: ReactElement | undefined): CommandCenterIcon | unde
|
||||
return StaticIcon;
|
||||
}
|
||||
|
||||
function resolveWorkspaceShortcuts(
|
||||
overrides: Readonly<Record<string, string>>,
|
||||
): WorkspaceCommandCenterShortcuts {
|
||||
function resolveWorkspaceShortcuts(overrides: ShortcutOverrides): WorkspaceCommandCenterShortcuts {
|
||||
const platform = { isMac: getShortcutOs() === "mac", isDesktop: getIsElectron() };
|
||||
return {
|
||||
newAgent: resolveShortcutKeysForAction("workspace-tab-new", overrides, platform) ?? undefined,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useCallback, useMemo, useReducer, useState } from "react";
|
||||
import { useCallback, useMemo, useReducer, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Link2 } from "lucide-react-native";
|
||||
import { getIsElectron, isNative } from "@/constants/platform";
|
||||
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Link2, Plus, X } from "lucide-react-native";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
|
||||
import {
|
||||
@@ -12,6 +13,11 @@ import {
|
||||
serializeConnectionUriForStorage,
|
||||
} from "@/utils/daemon-endpoints";
|
||||
import { DaemonConnectionTestError } from "@/utils/test-daemon-connection";
|
||||
import {
|
||||
prepareConnectionHeaders,
|
||||
type ConnectionHeaderDraft,
|
||||
type ConnectionHeaderValidationIssue,
|
||||
} from "@/utils/connection-headers";
|
||||
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
@@ -123,6 +129,30 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
advancedContent: {
|
||||
gap: theme.spacing[4],
|
||||
},
|
||||
headerSection: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
headerTitleRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
headerList: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
headerRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
headerInput: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: "row",
|
||||
gap: theme.spacing[3],
|
||||
@@ -181,6 +211,94 @@ function prepareDirectConnection(
|
||||
};
|
||||
}
|
||||
|
||||
function headerIssueMessage(
|
||||
issue: ConnectionHeaderValidationIssue,
|
||||
t: ReturnType<typeof useTranslation>["t"],
|
||||
): string {
|
||||
if (issue.type === "missingName") return t("pairing.direct.headers.errors.missingName");
|
||||
if (issue.type === "invalidName") {
|
||||
return t("pairing.direct.headers.errors.invalidName", { name: issue.name });
|
||||
}
|
||||
if (issue.type === "invalidValue") {
|
||||
return t("pairing.direct.headers.errors.invalidValue", { name: issue.name });
|
||||
}
|
||||
return t("pairing.direct.headers.errors.duplicateName", { name: issue.name });
|
||||
}
|
||||
|
||||
interface ConnectionHeaderRowProps {
|
||||
draft: ConnectionHeaderDraft;
|
||||
index: number;
|
||||
disabled: boolean;
|
||||
placeholderTextColor: string;
|
||||
iconColor: string;
|
||||
onUpdate: (id: number, field: "name" | "value", value: string) => void;
|
||||
onRemove: (id: number) => void;
|
||||
}
|
||||
|
||||
function ConnectionHeaderRow({
|
||||
draft,
|
||||
index,
|
||||
disabled,
|
||||
placeholderTextColor,
|
||||
iconColor,
|
||||
onUpdate,
|
||||
onRemove,
|
||||
}: ConnectionHeaderRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const handleNameChange = useCallback(
|
||||
(value: string) => onUpdate(draft.id, "name", value),
|
||||
[draft.id, onUpdate],
|
||||
);
|
||||
const handleValueChange = useCallback(
|
||||
(value: string) => onUpdate(draft.id, "value", value),
|
||||
[draft.id, onUpdate],
|
||||
);
|
||||
const handleRemove = useCallback(() => onRemove(draft.id), [draft.id, onRemove]);
|
||||
|
||||
return (
|
||||
<View style={styles.headerRow}>
|
||||
<AdaptiveTextInput
|
||||
testID={`direct-header-name-${index}`}
|
||||
nativeID={`direct-header-name-${draft.id}`}
|
||||
accessibilityLabel={t("pairing.direct.headers.name")}
|
||||
initialValue={draft.name}
|
||||
value={draft.name}
|
||||
onChangeText={handleNameChange}
|
||||
placeholder={t("pairing.direct.headers.name")}
|
||||
placeholderTextColor={placeholderTextColor}
|
||||
style={[styles.input, styles.headerInput]}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
editable={!disabled}
|
||||
/>
|
||||
<AdaptiveTextInput
|
||||
testID={`direct-header-value-${index}`}
|
||||
nativeID={`direct-header-value-${draft.id}`}
|
||||
accessibilityLabel={t("pairing.direct.headers.value")}
|
||||
initialValue={draft.value}
|
||||
value={draft.value}
|
||||
onChangeText={handleValueChange}
|
||||
placeholder={t("pairing.direct.headers.value")}
|
||||
placeholderTextColor={placeholderTextColor}
|
||||
style={[styles.input, styles.headerInput]}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
editable={!disabled}
|
||||
/>
|
||||
<Pressable
|
||||
style={styles.iconButton}
|
||||
onPress={handleRemove}
|
||||
disabled={disabled}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("pairing.direct.headers.remove")}
|
||||
testID={`direct-header-remove-${index}`}
|
||||
>
|
||||
<X size={18} color={iconColor} />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function draftFromConnectionUri(uri: string): DirectConnectionDraft {
|
||||
const parsed = parseConnectionUri(uri);
|
||||
return {
|
||||
@@ -305,6 +423,8 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
|
||||
const [isAdvancedOpen, setIsAdvancedOpen] = useState(false);
|
||||
const [advancedUri, setAdvancedUri] = useState("");
|
||||
const [headerDrafts, setHeaderDrafts] = useState<ConnectionHeaderDraft[]>([]);
|
||||
const nextHeaderId = useRef(1);
|
||||
const [inputResetKey, bumpInputResetKey] = useReducer((key: number) => key + 1, 0);
|
||||
|
||||
const clearInput = useCallback(() => {
|
||||
@@ -315,6 +435,8 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
setIsPasswordVisible(false);
|
||||
setIsAdvancedOpen(false);
|
||||
setAdvancedUri("");
|
||||
setHeaderDrafts([]);
|
||||
nextHeaderId.current = 1;
|
||||
bumpInputResetKey();
|
||||
}, []);
|
||||
|
||||
@@ -369,11 +491,18 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
if (isSaving) return;
|
||||
|
||||
let connection: PreparedDirectConnection;
|
||||
let headers: Record<string, string> | undefined;
|
||||
try {
|
||||
connection = prepareDirectConnection(
|
||||
{ host, port, useTls, password },
|
||||
directConnectionLabels,
|
||||
);
|
||||
const preparedHeaders = prepareConnectionHeaders(headerDrafts);
|
||||
if (preparedHeaders.issue) {
|
||||
setErrorMessage(headerIssueMessage(preparedHeaders.issue, t));
|
||||
return;
|
||||
}
|
||||
headers = preparedHeaders.headers;
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : directConnectionLabels.invalidConnection;
|
||||
@@ -389,6 +518,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
endpoint: connection.endpoint,
|
||||
useTls: connection.useTls,
|
||||
...(connection.password ? { password: connection.password } : {}),
|
||||
...(headers ? { headers } : {}),
|
||||
});
|
||||
const isNewHost = !daemons.some((daemon) => daemon.serverId === serverId);
|
||||
|
||||
@@ -425,6 +555,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
daemons,
|
||||
directConnectionLabels,
|
||||
handleClose,
|
||||
headerDrafts,
|
||||
host,
|
||||
isMobile,
|
||||
isSaving,
|
||||
@@ -453,6 +584,22 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
setIsPasswordVisible((current) => !current);
|
||||
}, []);
|
||||
|
||||
const handleAddHeader = useCallback(() => {
|
||||
const id = nextHeaderId.current;
|
||||
nextHeaderId.current += 1;
|
||||
setHeaderDrafts((current) => [...current, { id, name: "", value: "" }]);
|
||||
}, []);
|
||||
|
||||
const handleUpdateHeader = useCallback((id: number, field: "name" | "value", value: string) => {
|
||||
setHeaderDrafts((current) =>
|
||||
current.map((draft) => (draft.id === id ? { ...draft, [field]: value } : draft)),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleRemoveHeader = useCallback((id: number) => {
|
||||
setHeaderDrafts((current) => current.filter((draft) => draft.id !== id));
|
||||
}, []);
|
||||
|
||||
const handleToggleAdvanced = useCallback(() => {
|
||||
if (!isAdvancedOpen) {
|
||||
try {
|
||||
@@ -609,24 +756,58 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
<Text style={styles.advancedText}>{t("pairing.direct.advanced.label")}</Text>
|
||||
</Pressable>
|
||||
{isAdvancedOpen ? (
|
||||
<AdaptiveTextInput
|
||||
testID="direct-host-uri-input"
|
||||
nativeID="direct-host-uri-input"
|
||||
accessibilityLabel={t("pairing.direct.fields.connectionUri")}
|
||||
initialValue={advancedUri}
|
||||
resetKey={`direct-host-uri-${inputResetKey}`}
|
||||
value={advancedUri}
|
||||
onChangeText={setAdvancedUri}
|
||||
placeholder="tcp://localhost:6767?ssl=true"
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
style={styles.input}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
editable={!isSaving}
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleToggleAdvanced}
|
||||
/>
|
||||
<View style={styles.advancedContent}>
|
||||
<AdaptiveTextInput
|
||||
testID="direct-host-uri-input"
|
||||
nativeID="direct-host-uri-input"
|
||||
accessibilityLabel={t("pairing.direct.fields.connectionUri")}
|
||||
initialValue={advancedUri}
|
||||
resetKey={`direct-host-uri-${inputResetKey}`}
|
||||
value={advancedUri}
|
||||
onChangeText={setAdvancedUri}
|
||||
placeholder="tcp://localhost:6767?ssl=true"
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
style={styles.input}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
editable={!isSaving}
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleToggleAdvanced}
|
||||
/>
|
||||
|
||||
{isNative || getIsElectron() ? (
|
||||
<View style={styles.headerSection}>
|
||||
<View style={styles.headerTitleRow}>
|
||||
<Text style={styles.label}>{t("pairing.direct.headers.title")}</Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={Plus}
|
||||
onPress={handleAddHeader}
|
||||
disabled={isSaving}
|
||||
testID="direct-header-add"
|
||||
>
|
||||
{t("pairing.direct.headers.add")}
|
||||
</Button>
|
||||
</View>
|
||||
<View style={styles.headerList}>
|
||||
{headerDrafts.map((draft, index) => (
|
||||
<ConnectionHeaderRow
|
||||
key={draft.id}
|
||||
draft={draft}
|
||||
index={index}
|
||||
disabled={isSaving}
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
iconColor={theme.colors.foregroundMuted}
|
||||
onUpdate={handleUpdateHeader}
|
||||
onRemove={handleRemoveHeader}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
{errorMessage ? <Text style={styles.error}>{errorMessage}</Text> : null}
|
||||
</View>
|
||||
|
||||
@@ -21,6 +21,9 @@ import { Archive, ChevronRight } from "lucide-react-native";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||
import { HighlightedText } from "@/components/ui/highlighted-text";
|
||||
import type { AgentSearchMatch } from "@getpaseo/protocol/messages";
|
||||
import type { MatchRange } from "@getpaseo/protocol/search/text-match";
|
||||
|
||||
interface AgentListProps {
|
||||
agents: AggregatedAgent[];
|
||||
@@ -32,6 +35,18 @@ interface AgentListProps {
|
||||
listFooterComponent?: ReactElement | null;
|
||||
showAttentionIndicator?: boolean;
|
||||
showHostColumn?: boolean;
|
||||
/**
|
||||
* Where a search matched each row, keyed by `serverId:agentId`. Rows mark the
|
||||
* spans so the list can explain why a result is in it — the subsequence and
|
||||
* typo tiers match characters the eye would not find on its own.
|
||||
*/
|
||||
searchMatchesByAgentKey?: Record<string, AgentSearchMatch[]>;
|
||||
/**
|
||||
* Renders one flat list in the given order instead of grouping by day. Day
|
||||
* headings claim the list is chronological, which is a lie once the caller
|
||||
* has ordered it by something else — relevance, for instance.
|
||||
*/
|
||||
flat?: boolean;
|
||||
}
|
||||
|
||||
type DateSectionKey = "today" | "yesterday" | "thisWeek" | "thisMonth" | "older";
|
||||
@@ -127,12 +142,14 @@ function SessionBadge({
|
||||
function WorkspaceTitlePrefix({
|
||||
visible,
|
||||
workspaceName,
|
||||
ranges,
|
||||
testID,
|
||||
iconSize,
|
||||
color,
|
||||
}: {
|
||||
visible: boolean;
|
||||
workspaceName: string;
|
||||
ranges?: readonly MatchRange[];
|
||||
testID: string;
|
||||
iconSize: number;
|
||||
color: string;
|
||||
@@ -143,9 +160,13 @@ function WorkspaceTitlePrefix({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text style={styles.workspaceTitleText} numberOfLines={1} testID={testID}>
|
||||
{workspaceName}
|
||||
</Text>
|
||||
<HighlightedText
|
||||
text={workspaceName}
|
||||
ranges={ranges}
|
||||
style={styles.workspaceTitleText}
|
||||
numberOfLines={1}
|
||||
testID={testID}
|
||||
/>
|
||||
<ChevronRight size={iconSize} color={color} />
|
||||
</>
|
||||
);
|
||||
@@ -203,6 +224,7 @@ function SessionRowTrailingAttention({
|
||||
|
||||
function SessionRow({
|
||||
agent,
|
||||
searchMatches,
|
||||
isMobile,
|
||||
selectedAgentId,
|
||||
showAttentionIndicator,
|
||||
@@ -211,6 +233,7 @@ function SessionRow({
|
||||
onLongPress,
|
||||
}: {
|
||||
agent: AggregatedAgent;
|
||||
searchMatches?: readonly AgentSearchMatch[];
|
||||
isMobile: boolean;
|
||||
selectedAgentId?: string;
|
||||
showAttentionIndicator: boolean;
|
||||
@@ -228,6 +251,11 @@ function SessionRow({
|
||||
const workspaceName = agent.projectPlacement?.workspaceName ?? "";
|
||||
const ProviderIcon = getProviderIcon(agent.provider);
|
||||
const pendingPermissionCount = agent.pendingPermissionCount ?? 0;
|
||||
const rangesFor = useCallback(
|
||||
(field: AgentSearchMatch["field"]) =>
|
||||
searchMatches?.find((match) => match.field === field)?.ranges,
|
||||
[searchMatches],
|
||||
);
|
||||
|
||||
const pressableStyle = useCallback(
|
||||
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
@@ -266,6 +294,7 @@ function SessionRow({
|
||||
<WorkspaceTitlePrefix
|
||||
visible={!isMobile && Boolean(workspaceName)}
|
||||
workspaceName={workspaceName}
|
||||
ranges={rangesFor("workspace")}
|
||||
testID={`agent-row-workspace-${agent.serverId}-${agent.id}`}
|
||||
iconSize={theme.iconSize.xs}
|
||||
color={theme.colors.foregroundMuted}
|
||||
@@ -273,9 +302,12 @@ function SessionRow({
|
||||
<View style={styles.providerIconWrap}>
|
||||
<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
<Text style={sessionTitleStyle} numberOfLines={1}>
|
||||
{agent.title || t("agentList.fallbackTitle")}
|
||||
</Text>
|
||||
<HighlightedText
|
||||
text={agent.title || t("agentList.fallbackTitle")}
|
||||
ranges={agent.title ? rangesFor("title") : undefined}
|
||||
style={sessionTitleStyle}
|
||||
numberOfLines={1}
|
||||
/>
|
||||
<SessionRowBadges
|
||||
agent={agent}
|
||||
archivedIcon={archivedIcon}
|
||||
@@ -285,29 +317,29 @@ function SessionRow({
|
||||
</View>
|
||||
{isMobile ? (
|
||||
<View style={styles.rowMetaRow}>
|
||||
<Text
|
||||
<HighlightedText
|
||||
text={projectName}
|
||||
ranges={rangesFor("project")}
|
||||
style={styles.sessionMetaText}
|
||||
numberOfLines={1}
|
||||
testID={`agent-row-project-${agent.serverId}-${agent.id}`}
|
||||
>
|
||||
{projectName}
|
||||
</Text>
|
||||
/>
|
||||
<Text style={styles.sessionMetaSeparator}>·</Text>
|
||||
<Text
|
||||
<HighlightedText
|
||||
text={branch}
|
||||
ranges={rangesFor("branch")}
|
||||
style={styles.sessionMetaText}
|
||||
numberOfLines={1}
|
||||
testID={`agent-row-branch-${agent.serverId}-${agent.id}`}
|
||||
>
|
||||
{branch}
|
||||
</Text>
|
||||
/>
|
||||
<Text style={styles.sessionMetaSeparator}>·</Text>
|
||||
<Text
|
||||
<HighlightedText
|
||||
text={workspaceName}
|
||||
ranges={rangesFor("workspace")}
|
||||
style={styles.sessionMetaText}
|
||||
numberOfLines={1}
|
||||
testID={`agent-row-workspace-${agent.serverId}-${agent.id}`}
|
||||
>
|
||||
{workspaceName}
|
||||
</Text>
|
||||
/>
|
||||
<Text style={styles.sessionMetaSeparator}>·</Text>
|
||||
<Text style={styles.sessionMetaText}>{timeAgo}</Text>
|
||||
{showHostColumn && agent.serverLabel ? (
|
||||
@@ -323,25 +355,25 @@ function SessionRow({
|
||||
</View>
|
||||
{!isMobile ? (
|
||||
<View style={styles.rowColumns}>
|
||||
<Text
|
||||
<HighlightedText
|
||||
text={projectName}
|
||||
ranges={rangesFor("project")}
|
||||
style={styles.columnMeta}
|
||||
numberOfLines={1}
|
||||
testID={`agent-row-project-${agent.serverId}-${agent.id}`}
|
||||
>
|
||||
{projectName}
|
||||
</Text>
|
||||
/>
|
||||
{showHostColumn ? (
|
||||
<Text style={styles.columnMetaHost} numberOfLines={1}>
|
||||
{agent.serverLabel}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text
|
||||
<HighlightedText
|
||||
text={branch}
|
||||
ranges={rangesFor("branch")}
|
||||
style={styles.columnMeta}
|
||||
numberOfLines={1}
|
||||
testID={`agent-row-branch-${agent.serverId}-${agent.id}`}
|
||||
>
|
||||
{branch}
|
||||
</Text>
|
||||
/>
|
||||
<Text style={styles.columnMetaFixed} numberOfLines={1}>
|
||||
{timeAgo}
|
||||
</Text>
|
||||
@@ -365,6 +397,8 @@ export function AgentList({
|
||||
listFooterComponent,
|
||||
showAttentionIndicator = true,
|
||||
showHostColumn = false,
|
||||
searchMatchesByAgentKey,
|
||||
flat = false,
|
||||
}: AgentListProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
@@ -432,6 +466,14 @@ export function AgentList({
|
||||
}, [actionAgent, actionClient, archiveAgent]);
|
||||
|
||||
const flatItems = useMemo((): FlatListItem[] => {
|
||||
if (flat) {
|
||||
return agents.map((agent) => ({
|
||||
type: "agent" as const,
|
||||
key: `${agent.serverId}:${agent.id}`,
|
||||
agent,
|
||||
}));
|
||||
}
|
||||
|
||||
const buckets = new Map<DateSectionKey, AggregatedAgent[]>();
|
||||
for (const agent of agents) {
|
||||
const section = deriveDateSectionKey(agent.lastActivityAt);
|
||||
@@ -452,7 +494,7 @@ export function AgentList({
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [agents]);
|
||||
}, [agents, flat]);
|
||||
|
||||
const renderItem: ListRenderItem<FlatListItem> = useCallback(
|
||||
({ item }) => {
|
||||
@@ -466,6 +508,7 @@ export function AgentList({
|
||||
return (
|
||||
<SessionRow
|
||||
agent={item.agent}
|
||||
searchMatches={searchMatchesByAgentKey?.[item.key]}
|
||||
isMobile={isMobile}
|
||||
selectedAgentId={selectedAgentId}
|
||||
showAttentionIndicator={showAttentionIndicator}
|
||||
@@ -479,6 +522,7 @@ export function AgentList({
|
||||
handleAgentLongPress,
|
||||
handleAgentPress,
|
||||
isMobile,
|
||||
searchMatchesByAgentKey,
|
||||
selectedAgentId,
|
||||
showAttentionIndicator,
|
||||
showHostColumn,
|
||||
|
||||
@@ -36,11 +36,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
additions: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.statusMutedSuccess,
|
||||
color: theme.colors.statusSuccess,
|
||||
},
|
||||
deletions: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.statusMutedDanger,
|
||||
color: theme.colors.statusDanger,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import Animated, { useAnimatedStyle, useSharedValue, runOnJS } from "react-native-reanimated";
|
||||
import { scheduleOnRN } from "react-native-worklets";
|
||||
import { Gesture } from "react-native-gesture-handler";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { X } from "lucide-react-native";
|
||||
@@ -36,6 +37,10 @@ import { RetainedPanelActivity } from "@/components/retained-panel";
|
||||
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
|
||||
import { buildWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
|
||||
import { resolveDesktopExplorerWidth } from "@/components/desktop-sidebar-layout";
|
||||
import {
|
||||
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
|
||||
SIDEBAR_RESIZE_FAIL_OFFSET,
|
||||
} from "@/components/sidebar-resize-handle-layout";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
import { resolveFocusedChatTarget } from "@/composer/focused-chat-target";
|
||||
@@ -167,6 +172,9 @@ export function ExplorerSidebar({
|
||||
});
|
||||
const startWidthRef = useRef(visibleExplorerWidth);
|
||||
const resizeWidth = useSharedValue(visibleExplorerWidth);
|
||||
const [resizePressed, setResizePressed] = useState(false);
|
||||
const showResizeGrip = useCallback(() => setResizePressed(true), []);
|
||||
const hideResizeGrip = useCallback(() => setResizePressed(false), []);
|
||||
|
||||
useEffect(() => {
|
||||
resizeWidth.value = visibleExplorerWidth;
|
||||
@@ -185,8 +193,15 @@ export function ExplorerSidebar({
|
||||
Gesture.Pan()
|
||||
.enabled(true)
|
||||
.hitSlop({ left: 8, right: 8, top: 0, bottom: 0 })
|
||||
.onStart(() => {
|
||||
startWidthRef.current = visibleExplorerWidth;
|
||||
.onBegin(() => {
|
||||
scheduleOnRN(showResizeGrip);
|
||||
})
|
||||
// See the left sidebar's gesture: horizontal intent only, with the start
|
||||
// width anchored to the activation translation so the threshold is free.
|
||||
.activeOffsetX([-SIDEBAR_RESIZE_ACTIVATION_OFFSET, SIDEBAR_RESIZE_ACTIVATION_OFFSET])
|
||||
.failOffsetY([-SIDEBAR_RESIZE_FAIL_OFFSET, SIDEBAR_RESIZE_FAIL_OFFSET])
|
||||
.onStart((event) => {
|
||||
startWidthRef.current = visibleExplorerWidth + event.translationX;
|
||||
resizeWidth.value = visibleExplorerWidth;
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
@@ -198,8 +213,18 @@ export function ExplorerSidebar({
|
||||
})
|
||||
.onEnd(() => {
|
||||
runOnJS(setExplorerWidth)(resizeWidth.value);
|
||||
})
|
||||
.onFinalize(() => {
|
||||
scheduleOnRN(hideResizeGrip);
|
||||
}),
|
||||
[resizeWidth, setExplorerWidth, viewportWidth, visibleExplorerWidth],
|
||||
[
|
||||
hideResizeGrip,
|
||||
resizeWidth,
|
||||
setExplorerWidth,
|
||||
showResizeGrip,
|
||||
viewportWidth,
|
||||
visibleExplorerWidth,
|
||||
],
|
||||
);
|
||||
|
||||
const resizeAnimatedStyle = useAnimatedStyle(() => ({
|
||||
@@ -220,6 +245,7 @@ export function ExplorerSidebar({
|
||||
<SidebarResizeHandle
|
||||
edge="left"
|
||||
gesture={resizeGesture}
|
||||
pressed={resizePressed}
|
||||
testID="explorer-sidebar-resize-handle"
|
||||
/>
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ import type { Theme } from "@/styles/theme";
|
||||
const ThemedSquarePlus = withUnistyles(SquarePlus);
|
||||
const ThemedSquareMinus = withUnistyles(SquareMinus);
|
||||
|
||||
const successMapping = (theme: Theme) => ({ color: theme.colors.statusMutedSuccess });
|
||||
const dangerMapping = (theme: Theme) => ({ color: theme.colors.statusMutedDanger });
|
||||
const successMapping = (theme: Theme) => ({ color: theme.colors.statusSuccess });
|
||||
const dangerMapping = (theme: Theme) => ({ color: theme.colors.statusDanger });
|
||||
|
||||
const ICON_SIZE = 14;
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import { highlightToKeyedLines, type KeyedLine } from "@/utils/highlight-cache";
|
||||
import {
|
||||
markdownCopyCodeBlockDataSet,
|
||||
markdownCopyDataSet,
|
||||
TRAILING_CODE_LINE_BREAKS,
|
||||
} from "@/assistant-selection-copy/markup";
|
||||
|
||||
interface HighlightedCodeBlockProps {
|
||||
@@ -82,7 +83,10 @@ export const HighlightedCodeBlock = React.memo(function HighlightedCodeBlock({
|
||||
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
|
||||
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
|
||||
const controlsVisible = isHovered || isNative || isCompact;
|
||||
const getCode = useCallback(() => code, [code]);
|
||||
// Copy the code without its trailing blank lines. A fence body ends in a newline,
|
||||
// and ends in more than one when the author left a blank line before the closing
|
||||
// fence; pasting any of them into a terminal runs the last line.
|
||||
const getCode = useCallback(() => code.replace(TRAILING_CODE_LINE_BREAKS, ""), [code]);
|
||||
|
||||
return (
|
||||
<View
|
||||
|
||||
@@ -1,31 +1,35 @@
|
||||
import { useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import {
|
||||
type HostRuntimeConnectionStatus,
|
||||
useHostRuntimeConnectionStatus,
|
||||
} from "@/runtime/host-runtime";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
function hostStatusDotColor(status: HostRuntimeConnectionStatus, theme: Theme) {
|
||||
if (status === "online") return theme.colors.palette.green[400];
|
||||
if (status === "connecting") return theme.colors.palette.amber[500];
|
||||
return theme.colors.palette.red[500];
|
||||
}
|
||||
|
||||
export function HostStatusDot({ serverId }: { serverId: string }) {
|
||||
const { theme } = useUnistyles();
|
||||
const status = useHostRuntimeConnectionStatus(serverId);
|
||||
const backgroundColor = hostStatusDotColor(status, theme);
|
||||
const dotStyle = useMemo(() => [styles.dot, { backgroundColor }], [backgroundColor]);
|
||||
|
||||
return <View style={dotStyle} />;
|
||||
return <View style={[styles.dot, statusStyle(status)]} />;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create(() => ({
|
||||
function statusStyle(status: HostRuntimeConnectionStatus) {
|
||||
if (status === "online") return styles.dotOnline;
|
||||
if (status === "connecting") return styles.dotConnecting;
|
||||
return styles.dotOffline;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
dot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
},
|
||||
dotOnline: {
|
||||
backgroundColor: theme.colors.statusSuccess,
|
||||
},
|
||||
dotConnecting: {
|
||||
backgroundColor: theme.colors.statusWarning,
|
||||
},
|
||||
dotOffline: {
|
||||
backgroundColor: theme.colors.statusDanger,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface HostFilterProps {
|
||||
selectedHost: string;
|
||||
onSelectHost: (serverId: string) => void;
|
||||
triggerTestID?: string;
|
||||
hostOptionTestID?: (serverId: string) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -32,6 +33,7 @@ export function HostFilter({
|
||||
selectedHost,
|
||||
onSelectHost,
|
||||
triggerTestID,
|
||||
hostOptionTestID,
|
||||
}: HostFilterProps): ReactElement {
|
||||
const [isFilterOpen, setIsFilterOpen] = useState(false);
|
||||
const filterAnchorRef = useRef<View>(null);
|
||||
@@ -64,6 +66,7 @@ export function HostFilter({
|
||||
searchable={false}
|
||||
title="Filter by host"
|
||||
desktopPlacement="bottom-start"
|
||||
hostOptionTestID={hostOptionTestID}
|
||||
>
|
||||
<View ref={filterAnchorRef} collapsable={false} style={styles.filterTriggerWrap}>
|
||||
<Pressable
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user