mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-20 08:12:53 +00:00
Compare commits
92
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
d227ff3e83 | ||
|
|
7808a2a7d8 | ||
|
|
669253bdf6 | ||
|
|
0b624b5585 | ||
|
|
dfd18a44a7 | ||
|
|
d7f72d2805 | ||
|
|
9916e3d185 | ||
|
|
34c538602a | ||
|
|
62f8b14804 | ||
|
|
8ac7cd476c | ||
|
|
d191fa6685 | ||
|
|
adf508a1c8 | ||
|
|
dac255cb99 | ||
|
|
7dbeff8290 | ||
|
|
8e5b6876b4 | ||
|
|
9194449952 | ||
|
|
1e392557f2 | ||
|
|
b020a1d1ed | ||
|
|
7b6965856b | ||
|
|
238047eaa8 | ||
|
|
683f38443c | ||
|
|
bfec5a05b9 | ||
|
|
8b9314b70a | ||
|
|
22cb7f9996 | ||
|
|
bf6cd09d6c | ||
|
|
56cb70fa59 | ||
|
|
dc6f13e31e | ||
|
|
32705cb3f2 | ||
|
|
e90832eedd | ||
|
|
df32e6ddcd | ||
|
|
e238fea158 | ||
|
|
98c9878c57 |
@@ -1,5 +1,85 @@
|
||||
# Changelog
|
||||
|
||||
## 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))
|
||||
- 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))
|
||||
- Approve all ACP tool calls with one setting ([#2752](https://github.com/getpaseo/paseo/pull/2752))
|
||||
- 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
|
||||
|
||||
- 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))
|
||||
- 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))
|
||||
- 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))
|
||||
- Paseo Desktop builds from the Nix flake on macOS ([#2556](https://github.com/getpaseo/paseo/pull/2556) by [@colonelpanic8](https://github.com/colonelpanic8))
|
||||
|
||||
### 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))
|
||||
- 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
|
||||
- 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))
|
||||
- Agents started by the NixOS service no longer run in production mode ([#2697](https://github.com/getpaseo/paseo/pull/2697) by [@shin-sakata](https://github.com/shin-sakata))
|
||||
|
||||
## 0.2.5 - 2026-07-30
|
||||
|
||||
### Fixed
|
||||
|
||||
+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`.
|
||||
|
||||
@@ -67,6 +67,17 @@ npm run android:production # Release build
|
||||
npm run android:clear # Remove generated Android project
|
||||
```
|
||||
|
||||
For a production-ID release APK that local Android profiling tools can attach to:
|
||||
|
||||
```bash
|
||||
PASEO_PROFILE_BUILD=1 npm run android:production
|
||||
```
|
||||
|
||||
This keeps the `sh.paseo` package id, release Hermes bundle, and release optimizations. It adds
|
||||
`<profileable android:shell="true" />` and enables local Android trace markers for workspace mounts
|
||||
and daemon WebSocket traffic. The markers contain message types and sizes, never payload contents,
|
||||
and emit only while a system trace records the `sh.paseo` app (`perfetto -a sh.paseo ...`).
|
||||
|
||||
Or from `packages/app`:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+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`).
|
||||
|
||||
@@ -96,6 +96,21 @@ Any gap between A and B (margins between siblings inside the same parent) is par
|
||||
|
||||
If A and B genuinely can't share a parent — B portals into a different layer, floats above other content — see [Section: real gaps](#real-gaps-with-floating-panels) below.
|
||||
|
||||
### Failure mode 4 — A hover-revealed trigger unmounts its own open menu
|
||||
|
||||
A kebab that only exists while its row is hovered opens a menu, and that menu takes the pointer
|
||||
off the row: a sheet slides up over it, a popover covers it. The row un-hovers, the trailing
|
||||
overlay unmounts, and the trigger goes with it — taking the menu's open state, which lived
|
||||
inside the trigger's subtree. The surface itself is already presented in a portal, so nothing
|
||||
unmounts it and nothing is left that can close it. On a bottom sheet that means a full-screen
|
||||
backdrop swallowing every click until reload.
|
||||
|
||||
Lift the menu's open state to whatever decides to render the trigger, and keep the trigger
|
||||
rendered while the menu is up. `useOpenKebabMenuVisibility`
|
||||
(`packages/app/src/components/sidebar/use-open-kebab-menu-visibility.ts`) is that shape for the
|
||||
sidebar rows: it owns `open`, hands the menu its controlled props, and ORs `open` into the
|
||||
row's own `showKebab`.
|
||||
|
||||
## Native fallback
|
||||
|
||||
Hover doesn't exist on touch devices. Anything you hide behind hover must have a non-hover path on native and compact layouts:
|
||||
@@ -134,5 +149,6 @@ Before opening a PR that touches hover:
|
||||
- [ ] Hovered state does **not** change the trigger's outer geometry (`width`, `height`, `padding`, `borderWidth`, mount/unmount of siblings that shift it). Internal swaps fit inside a fixed `minHeight` / `minWidth`.
|
||||
- [ ] Revealed content inside the trigger uses `opacity` + `pointerEvents`, not conditional rendering, if mounting it would reflow the trigger.
|
||||
- [ ] Visibility on native and compact layouts works without hover (`isHovered || isNative || isCompact`).
|
||||
- [ ] A menu opened from the revealed trigger keeps the trigger rendered while it is open, so losing hover can't strand it.
|
||||
- [ ] If the revealed content sits in a separate layer (portal, floating panel), `useHoverSafeZone` is wired up.
|
||||
- [ ] You opened the dev server, hovered the trigger, and slowly moved the mouse along **every** revealed element — including any visible gaps — without losing hover state.
|
||||
|
||||
+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.
|
||||
|
||||
+37
-10
@@ -26,6 +26,17 @@ when you have actually looked at it on a phone.
|
||||
`ContextMenu` is the exception: it defaults to `compactMode="sheet"` and enables native long press.
|
||||
Disable mobile triggering explicitly on draggable rows, where long press belongs to drag instead.
|
||||
|
||||
## Selecting an item on iOS
|
||||
|
||||
An item that closes the menu runs its action after a fixed grace period on iOS, not immediately:
|
||||
a native presenter launched while UIKit is still tearing down the surface can hang. Both surfaces
|
||||
unmount the moment the menu closes, so the wait is a timer rather than the surface's own dismissal
|
||||
callback — a callback fired from inside a subtree that is being removed is racing its own removal,
|
||||
and it loses. Selections used to be dropped entirely for exactly that reason.
|
||||
|
||||
The consequence for callers: an `onSelect` on iOS runs a beat after the press. Don't add a second
|
||||
delay on top of it, and don't read state that the same press mutated.
|
||||
|
||||
## Pages
|
||||
|
||||
A submenu is a page, declared as data on the surface and reached by a `MenuSubTrigger` whose `id`
|
||||
@@ -86,16 +97,32 @@ and a column of icons there is decoration competing with the values you actually
|
||||
|
||||
## Surface details
|
||||
|
||||
- The hover fill is **inset by the same amount on every side and rounded** — a chip inside the
|
||||
menu, not a band across it. The inset is taken _out of_ the row, never added to it: horizontally
|
||||
padding gives up what margin takes so labels don't move, and vertically the fill is shorter by
|
||||
the same amount so the row pitch is unchanged. Insetting by growing the row is how the menu ends
|
||||
up taller than it started.
|
||||
- The fill's height only holds because the label's `lineHeight` is pinned. Leave it to the platform
|
||||
and content outgrows `minHeight`, which then does nothing.
|
||||
- **The item's inset is the menu's only spacing.** Rows sit apart because each holds itself in, and
|
||||
a separator gets clearance from its neighbours for free. Don't give the separator a margin of its
|
||||
own — that is two numbers controlling one gap, and they will disagree.
|
||||
- The hover fill is **inset from the surface's edges and rounded** — a chip inside the menu, not a
|
||||
band across it. The inset is taken _out of_ the row, never added to it: padding gives up what
|
||||
margin takes, so labels sit at the same 13pt they always did. Insetting by growing the row is how
|
||||
the menu ends up taller than it started.
|
||||
- **A row is as tall as what is driving it** — 28pt for a pointer, 40pt below `md` for a thumb. The
|
||||
split is on breakpoint, not on `presentation`: the compact popover that `compactMode` defaults to
|
||||
is worked with a thumb just as a sheet is, and sizing off the sheet would leave it at the desktop
|
||||
height. `md` is where `useIsCompactFormFactor` divides, so row height and the popover/sheet choice
|
||||
turn over together.
|
||||
- The desktop height only holds because the label's `lineHeight` is pinned — 18 line + 8 padding +
|
||||
2 border is exactly 28. Leave it to the platform and content outgrows `minHeight`, which then does
|
||||
nothing. Compact is the other way round: `minHeight` leads and the label centres in it.
|
||||
- **A row owns its fill; `MenuPage` owns the spacing between rows.** The vertical inset above the
|
||||
first row and below the last, and the gap between rows, are the page's — one knob each,
|
||||
`MENU_ROW_GAP` being the one a redesign turns. A row that carried vertical margin would be
|
||||
serving both at once, and since margins don't collapse it would land as one unit at the edges and
|
||||
two between rows, so shrinking the gap would eat the inset with it. Horizontal inset does stay on
|
||||
the row: there is one left edge and one right edge, so nothing is doubling up.
|
||||
- `MenuPage` is also what provides a page's depth, so the popover and the sheet cannot disagree
|
||||
about what a page is. Every entry point goes through it — root, flyout, pushed sheet page.
|
||||
- The gap reaches a page's **direct children**. A group of rows wrapped in a `View` of its own drops
|
||||
out of it; at the current gap of zero there is nothing to lose, but anything above zero wants that
|
||||
wrapper to carry the page style too.
|
||||
- The separator is the one row-level thing that asks for vertical space of its own, and now that
|
||||
rows carry none, that is still one number controlling one gap. It takes no horizontal margin and
|
||||
the page has no horizontal padding, so the rule runs the full width of the surface.
|
||||
- Separators use `borderAccent`, the same colour the surface outlines itself with. `border` sits
|
||||
between `surface1` and `surface2`, which put it within a hair of the hover fill and made
|
||||
separators disappear against a hovered row.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -70,6 +70,8 @@ The daemon keeps provider snapshots per resolved working directory, with a separ
|
||||
|
||||
Snapshot reads may probe providers only while the requested cwd scope is cold. Once an entry is warm, its `ready`, `error`, or `unavailable` state stays cached until an explicit refresh. Do not add TTL revalidation, focus-triggered refreshes, selector-open refreshes, or config-reload refreshes. Selector-open refetches may read an already-loading or stale React Query, but they must not force provider probing on their own.
|
||||
|
||||
Capable clients receive a compact, content-addressed snapshot. Model rows derive their provider from the containing entry and reference snapshot-level thinking sets. The app persists that compact shape per server and cwd, then sends its hash on the next pull; an unchanged response carries no catalog body. Keep the legacy encoding for clients without the capability. The hash covers the complete client-visible compact snapshot, including status and `fetchedAt`, so explicit refreshes invalidate it even when the discovered catalog is otherwise equal.
|
||||
|
||||
Settings refresh is the user-facing "forget stale provider knowledge everywhere" action. A settings refresh clears provider snapshot caches and in-flight loads across all cwd scopes, then immediately refreshes only the global snapshot with `force: true`. Workspace snapshots are re-probed lazily on the next scoped read; do not fan out a settings refresh across every known workspace.
|
||||
|
||||
Registry/config replacement may update visible metadata such as label, description, default mode, enabled state, and provider membership, but it must not spawn provider processes. If a provider needs to be re-probed after a config change, route that through the explicit settings refresh path.
|
||||
|
||||
+13
-9
@@ -36,7 +36,7 @@ Rules that apply to both steps:
|
||||
There are two supported ways to ship from `main`:
|
||||
|
||||
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. Betas carry an in-place changelog entry (beta users check it), publish npm only on the explicit `beta` dist-tag, and never become the website's default download — they sit 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:
|
||||
@@ -82,7 +82,7 @@ npm run release:patch
|
||||
npm run release:minor
|
||||
```
|
||||
|
||||
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, `Docker`, and `Release Notes Sync` on GitHub Actions. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no `release-mobile.yml` in this repo.
|
||||
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, `Docker`, and `Release Notes Sync` on GitHub Actions. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no mobile-release workflow under `.github/workflows`.
|
||||
|
||||
After the stable release succeeds, move npm's `beta` pointer to the new stable
|
||||
version for every published package. This changes dist-tags only; do not
|
||||
@@ -129,7 +129,7 @@ npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z
|
||||
|
||||
- Beta tags are published GitHub prereleases like `v0.1.41-beta.1`
|
||||
- Betas publish npm packages with `--tag beta`, so `npm install @getpaseo/cli@beta` opts in while plain `npm install @getpaseo/cli` stays on `latest`
|
||||
- Betas publish desktop assets and APKs for testing, but they do not trigger the production web/mobile release flows
|
||||
- Betas publish desktop assets and APKs for testing. They also build iOS, upload it to TestFlight, add it to the `Paseo Beta` external group, and submit it for Beta App Review. They do not submit mobile builds to the production stores.
|
||||
- `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.
|
||||
@@ -238,9 +238,11 @@ iOS and Android store builds are not in `.github/workflows`. They are triggered
|
||||
- **iOS (TestFlight + App Store)** — EAS builds with profile `production`, uploads to TestFlight, and a Fastlane lane submits the build for App Store review.
|
||||
- **Android APK (GitHub Release asset)** — separate, via `.github/workflows/android-apk-release.yml`. This is the only Android-related workflow that lives in this repo.
|
||||
|
||||
EAS uses the local app version source. `packages/app/app.config.js` derives Android `versionCode` and iOS `buildNumber` from the package version as `major * 1_000_000 + minor * 1_000 + patch`, ignoring prerelease metadata. Rebuilding the same tag produces the same native build number; if a store has already accepted a binary and you need a different binary, cut a new patch instead of relying on EAS remote auto-increment.
|
||||
EAS uses the local app version source. `packages/app/app.config.js` derives the native version from the package version. Android `versionCode` is `major * 1_000_000 + minor * 1_000 + patch`. iOS reserves 1,000 build slots per app version: beta `N` uses slot `N`, and stable uses slot `999`. For example, `0.2.6-beta.2` appears in App Store Connect as version `0.2.6` build `2006002`; stable uses build `2006999`. Rebuilding the same tag produces the same native build number; if a store has already accepted a binary and you need a different binary, cut the next beta or patch instead of relying on EAS remote auto-increment.
|
||||
|
||||
There is no `release-mobile.yml` in this repo. Earlier versions of these docs referenced one — that workflow was removed and the EAS GitHub app handles tag triggering directly.
|
||||
Beta tags run `Release iOS Beta`. The workflow uploads the build to TestFlight, distributes it to the persistent `Paseo Beta` external group, and submits it for Beta App Review. Testers and the group are managed once in App Store Connect; releases require no dashboard action.
|
||||
|
||||
There is no mobile-release workflow under `.github/workflows`. The EAS GitHub app reads the workflows under `packages/app/.eas/workflows` and handles tag triggering directly.
|
||||
|
||||
### Watching mobile builds from the terminal
|
||||
|
||||
@@ -315,10 +317,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 rewrites the `-beta.N` entry in place, so that anchor dies while the group survives. 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
|
||||
|
||||
@@ -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
@@ -1 +1 @@
|
||||
sha256-WOVtBB7AQke7RM9FDJ1mUDU0sLMf03QOrvajrXSFwPs=
|
||||
sha256-MDuVQQ5DmNoPlb9+LlrZYxGbP+22rYpfhcDJQxmIUsU=
|
||||
|
||||
Generated
+47
-47
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.2.5",
|
||||
"version": "0.3.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.2.5",
|
||||
"version": "0.3.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -27721,16 +27721,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",
|
||||
@@ -29874,20 +29864,6 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-keyboard-controller": {
|
||||
"version": "1.20.7",
|
||||
"resolved": "https://registry.npmjs.org/react-native-keyboard-controller/-/react-native-keyboard-controller-1.20.7.tgz",
|
||||
"integrity": "sha512-G8S5jz1FufPrcL1vPtReATx+jJhT/j+sTqxMIb30b1z7cYEfMlkIzOCyaHgf6IMB2KA9uBmnA5M6ve2A9Ou4kw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-native-is-edge-to-edge": "^1.2.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-native": "*",
|
||||
"react-native-reanimated": ">=3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-markdown-display": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-native-markdown-display/-/react-native-markdown-display-7.0.2.tgz",
|
||||
@@ -35323,7 +35299,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.2.5",
|
||||
"version": "0.3.0",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -35394,7 +35370,7 @@
|
||||
"react-native-draggable-flatlist": "^4.0.3",
|
||||
"react-native-edge-to-edge": "^1.7.0",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-keyboard-controller": "^1.19.2",
|
||||
"react-native-keyboard-controller": "1.21.12",
|
||||
"react-native-markdown-display": "^7.0.2",
|
||||
"react-native-nitro-modules": "0.35.5",
|
||||
"react-native-reanimated": "~4.3.1",
|
||||
@@ -36259,6 +36235,20 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"packages/app/node_modules/react-native-keyboard-controller": {
|
||||
"version": "1.21.12",
|
||||
"resolved": "https://registry.npmjs.org/react-native-keyboard-controller/-/react-native-keyboard-controller-1.21.12.tgz",
|
||||
"integrity": "sha512-pZJNXS//AhXDD9RvkNBZF5MsHv9qbFpNeHMIZ9ESSUqubNsACU7GyTE4GekIcJ1MBu+r9/TNZQuEfpT9v01HZg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-native-is-edge-to-edge": "^1.2.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-native": "*",
|
||||
"react-native-reanimated": ">=3.0.0"
|
||||
}
|
||||
},
|
||||
"packages/app/node_modules/turndown": {
|
||||
"version": "7.2.4",
|
||||
"resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.4.tgz",
|
||||
@@ -36363,12 +36353,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.2.5",
|
||||
"version": "0.3.0",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.2.5",
|
||||
"@getpaseo/protocol": "0.2.5",
|
||||
"@getpaseo/server": "0.2.5",
|
||||
"@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 +36605,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.2.5",
|
||||
"version": "0.3.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.2.5",
|
||||
"@getpaseo/relay": "0.2.5",
|
||||
"@getpaseo/protocol": "0.3.0",
|
||||
"@getpaseo/relay": "0.3.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -36629,7 +36619,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.2.5",
|
||||
"version": "0.3.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -36872,7 +36862,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.2.5",
|
||||
"version": "0.3.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -37768,7 +37758,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.2.5",
|
||||
"version": "0.3.0",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -38000,7 +37990,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.2.5",
|
||||
"version": "0.3.0",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -38013,7 +38003,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.2.5",
|
||||
"version": "0.3.0",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38231,15 +38221,15 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.2.5",
|
||||
"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.2.5",
|
||||
"@getpaseo/highlight": "0.2.5",
|
||||
"@getpaseo/protocol": "0.2.5",
|
||||
"@getpaseo/relay": "0.2.5",
|
||||
"@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",
|
||||
@@ -38253,7 +38243,7 @@
|
||||
"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",
|
||||
@@ -38584,6 +38574,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 +38778,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.2.5",
|
||||
"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.2.5",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
name: Release iOS Beta
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*-beta.*"
|
||||
|
||||
jobs:
|
||||
build_ios:
|
||||
name: Build iOS beta
|
||||
type: build
|
||||
params:
|
||||
platform: ios
|
||||
profile: production
|
||||
|
||||
distribute_ios_beta:
|
||||
name: Distribute iOS beta
|
||||
needs: [build_ios]
|
||||
type: testflight
|
||||
params:
|
||||
build_id: ${{ needs.build_ios.outputs.build_id }}
|
||||
profile: production
|
||||
external_groups:
|
||||
- Paseo Beta
|
||||
submit_beta_review: true
|
||||
@@ -1,9 +1,12 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const pkg = require("./package.json");
|
||||
const withAndroidProfileable = require("./plugins/with-android-profileable");
|
||||
const withFdroidAutolinking = require("./plugins/with-fdroid-autolinking");
|
||||
const { getNativeReleaseVersion } = require("./native-release-version");
|
||||
const appVariant = process.env.APP_VARIANT ?? "production";
|
||||
const isFdroidBuild = process.env.PASEO_FDROID_BUILD === "1";
|
||||
const isProfileBuild = process.env.PASEO_PROFILE_BUILD === "1";
|
||||
|
||||
const buildProfile = isFdroidBuild
|
||||
? {
|
||||
@@ -45,30 +48,6 @@ const buildProfile = isFdroidBuild
|
||||
],
|
||||
};
|
||||
|
||||
function getNativeBuildVersionCode(version) {
|
||||
const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(version);
|
||||
if (!match) {
|
||||
throw new Error(`Cannot derive Android versionCode from non-semver version: ${version}`);
|
||||
}
|
||||
|
||||
const [, majorText, minorText, patchText] = match;
|
||||
const major = Number(majorText);
|
||||
const minor = Number(minorText);
|
||||
const patch = Number(patchText);
|
||||
|
||||
if (minor > 999 || patch > 999) {
|
||||
throw new Error(`Cannot derive collision-free Android versionCode from version: ${version}`);
|
||||
}
|
||||
|
||||
const versionCode = major * 1_000_000 + minor * 1_000 + patch;
|
||||
|
||||
if (!Number.isSafeInteger(versionCode) || versionCode <= 0 || versionCode > 2_100_000_000) {
|
||||
throw new Error(`Derived Android versionCode is out of range: ${versionCode}`);
|
||||
}
|
||||
|
||||
return versionCode;
|
||||
}
|
||||
|
||||
function resolveSecretFile(params) {
|
||||
const fromEnv = process.env[params.envKey];
|
||||
if (typeof fromEnv === "string" && fromEnv.trim().length > 0) {
|
||||
@@ -111,13 +90,13 @@ const variants = {
|
||||
};
|
||||
|
||||
const variant = variants[appVariant] ?? variants.production;
|
||||
const nativeBuildVersionCode = getNativeBuildVersionCode(pkg.version);
|
||||
const nativeReleaseVersion = getNativeReleaseVersion(pkg.version);
|
||||
|
||||
export default {
|
||||
expo: {
|
||||
name: variant.name,
|
||||
slug: "voice-mobile",
|
||||
version: pkg.version,
|
||||
version: nativeReleaseVersion.appVersion,
|
||||
orientation: "portrait",
|
||||
icon: "./assets/images/icon.png",
|
||||
scheme: "paseo",
|
||||
@@ -133,7 +112,7 @@ export default {
|
||||
...(variant.googleServiceInfoPlist
|
||||
? { googleServicesFile: variant.googleServiceInfoPlist }
|
||||
: {}),
|
||||
buildNumber: String(nativeBuildVersionCode),
|
||||
buildNumber: nativeReleaseVersion.iosBuildNumber,
|
||||
},
|
||||
android: {
|
||||
adaptiveIcon: {
|
||||
@@ -147,7 +126,7 @@ export default {
|
||||
usesCleartextTraffic: true,
|
||||
permissions: buildProfile.androidPermissions,
|
||||
package: variant.packageId,
|
||||
versionCode: nativeBuildVersionCode,
|
||||
versionCode: nativeReleaseVersion.androidVersionCode,
|
||||
...(variant.googleServicesFile ? { googleServicesFile: variant.googleServicesFile } : {}),
|
||||
},
|
||||
web: {
|
||||
@@ -193,6 +172,7 @@ export default {
|
||||
},
|
||||
],
|
||||
...buildProfile.fdroidPlugins,
|
||||
...(isProfileBuild ? [withAndroidProfileable] : []),
|
||||
],
|
||||
experiments: {
|
||||
typedRoutes: true,
|
||||
@@ -201,6 +181,7 @@ export default {
|
||||
},
|
||||
extra: {
|
||||
fdroidBuild: isFdroidBuild,
|
||||
profileBuild: isProfileBuild,
|
||||
router: {},
|
||||
eas: {
|
||||
projectId: "0e7f65ce-0367-46c8-a238-2b65963d235a",
|
||||
|
||||
@@ -915,39 +915,6 @@ test.describe("Agent message submission", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("reconciles an interrupted submission when live acknowledgements are missed", async ({
|
||||
page,
|
||||
}) => {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const title = "Missed submission acknowledgements";
|
||||
const prompt = "Interrupt without delivering live acknowledgements.";
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "submission-missed-acknowledgements-",
|
||||
title,
|
||||
model: "one-minute-stream",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
gate.setAgentStreamItemSuppressed("user_message", true);
|
||||
gate.setAgentStreamEventSuppressed("turn_canceled", true);
|
||||
|
||||
await submitMessage(page, prompt);
|
||||
await gate.waitForAgentStreamItem("user_message");
|
||||
await expectRunningAgentChrome(page, title);
|
||||
await cancelAgent(page);
|
||||
await gate.waitForAgentStreamEvent("turn_canceled");
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
|
||||
await expect(page.getByTestId("user-message").filter({ hasText: prompt })).toHaveCount(1);
|
||||
await expectAgentSurfacesIdle(page, title);
|
||||
} finally {
|
||||
gate.setAgentStreamItemSuppressed("user_message", false);
|
||||
gate.setAgentStreamEventSuppressed("turn_canceled", false);
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps layout stable when submitting to an agent with existing history", async ({
|
||||
page,
|
||||
submissionScenario,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { expectComposerVisible } from "../support/helpers/composer";
|
||||
import { expectAgentIdle } from "../support/helpers/agent-stream";
|
||||
import {
|
||||
openAttachmentMenu,
|
||||
expectAttachmentSheetRowsOnTitleRail,
|
||||
openGithubPickerFromMenu,
|
||||
attachImageFromMenu,
|
||||
expectAttachmentPill,
|
||||
@@ -63,6 +64,22 @@ test.describe("Composer attachments", () => {
|
||||
await expect(page.getByTestId("message-input-attachment-menu-item-github")).toBeVisible();
|
||||
});
|
||||
|
||||
test("compact Plus menu aligns attachment rows with its sheet title", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
const workspace = await withWorkspace({ prefix: "attach-sheet-rails-" });
|
||||
await workspace.navigateTo();
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
|
||||
await openAttachmentMenu(page);
|
||||
|
||||
await expectAttachmentSheetRowsOnTitleRail(page);
|
||||
});
|
||||
|
||||
test("GitHub combobox does not render until the picker is opened", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -108,13 +108,12 @@ test.describe("Host appearance", () => {
|
||||
await leaveHostAppearanceSettings(page);
|
||||
|
||||
const row = page.getByTestId(`sidebar-workspace-row-${workspaceKey}`);
|
||||
const badge = row.getByTestId(`sidebar-host-badge-${twoHostSidebar.secondaryServerId}`);
|
||||
const badge = row.getByTestId(`host-badge-${twoHostSidebar.secondaryServerId}`);
|
||||
await expect(badge).toHaveText(hostName);
|
||||
const [badgeBox, rowBox] = await Promise.all([badge.boundingBox(), row.boundingBox()]);
|
||||
|
||||
expect(badgeBox).not.toBeNull();
|
||||
expect(rowBox).not.toBeNull();
|
||||
await expect(badge).toHaveCSS("flex-shrink", "1");
|
||||
expect(badgeBox!.width).toBeGreaterThan(96);
|
||||
expect(badgeBox!.x + badgeBox!.width).toBeLessThanOrEqual(rowBox!.x + rowBox!.width);
|
||||
const labelWidths = await badge.getByText(hostName, { exact: true }).evaluate((label) => ({
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
connectNewWorkspaceDaemonClient,
|
||||
expectNewWorkspaceControlsEnabled,
|
||||
expectNewWorkspaceProjectSelected,
|
||||
expectNewWorkspaceTriggerLabelsAligned,
|
||||
openGlobalNewWorkspaceComposer,
|
||||
openMissingProjectNewWorkspaceComposer,
|
||||
openNewWorkspaceComposer,
|
||||
@@ -18,7 +19,10 @@ import {
|
||||
clickArchiveWorkspaceMenuItem,
|
||||
expectWorkspaceAbsentFromSidebar,
|
||||
} from "../support/helpers/sidebar";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
import {
|
||||
switchWorkspaceViaSidebar,
|
||||
waitForSidebarHydration,
|
||||
} from "../support/helpers/workspace-ui";
|
||||
|
||||
// Model B entry points into the New Workspace screen. The surviving entries are
|
||||
// the global button (universal) and each project's per-row New workspace icon
|
||||
@@ -66,6 +70,11 @@ test.describe("New workspace entry points", () => {
|
||||
await expect(
|
||||
page.getByTestId(`sidebar-workspace-row-${getServerId()}:${seeded.workspaceId}`),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
workspaceId: seeded.workspaceId,
|
||||
});
|
||||
|
||||
const globalButton = page.getByTestId("sidebar-global-new-workspace");
|
||||
await expect(globalButton).toBeVisible({ timeout: 30_000 });
|
||||
@@ -77,6 +86,10 @@ test.describe("New workspace entry points", () => {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByTestId("host-picker-trigger")).toBeVisible({ timeout: 30_000 });
|
||||
await expectNewWorkspaceTriggerLabelsAligned(page, {
|
||||
projectLabel: seeded.projectDisplayName,
|
||||
hostLabel: "localhost",
|
||||
});
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
type LocalGhPrFixture,
|
||||
} from "../support/helpers/github-fixtures";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { selectSidebarStatusGrouping } from "../support/helpers/sidebar";
|
||||
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
|
||||
import { chooseAddProjectMethod, expectAddProjectPage } from "../support/helpers/add-project-flow";
|
||||
import { seedSavedSettingsHosts } from "../support/helpers/settings";
|
||||
@@ -78,9 +79,7 @@ interface WorkspaceStatusGroupEvent {
|
||||
}
|
||||
|
||||
async function switchSidebarToStatusGrouping(page: import("@playwright/test").Page) {
|
||||
await page.getByTestId("sidebar-display-preferences-menu").click();
|
||||
await page.getByTestId("sidebar-display-grouping").click();
|
||||
await page.getByTestId("sidebar-grouping-status").click();
|
||||
await selectSidebarStatusGrouping(page);
|
||||
await expect(page.getByTestId("sidebar-status-group-done")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ interface ProviderSubagentCase {
|
||||
provider: RewindFlowProvider;
|
||||
sentinel: string;
|
||||
expectedName: string;
|
||||
expectedSubtitle?: RegExp;
|
||||
expectsUserMessage?: boolean;
|
||||
prompt: string;
|
||||
providerConfig?: Parameters<typeof launchAgent>[0]["providerConfig"];
|
||||
}
|
||||
@@ -39,9 +41,12 @@ const cases: ProviderSubagentCase[] = [
|
||||
{
|
||||
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();
|
||||
|
||||
@@ -5,6 +5,7 @@ import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-cli
|
||||
import { seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { projectEquivalenceViewKey } from "../support/helpers/project-view-key";
|
||||
import { selectSidebarStatusGrouping } from "../support/helpers/sidebar";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
import { getVisibleWorkspaceAgentTabIds } from "../support/helpers/workspace-tabs";
|
||||
|
||||
@@ -132,9 +133,7 @@ test.describe("Model B sidebar shape", () => {
|
||||
await expect(workspaceRow(page, idleProject.workspaceId)).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Switch to status grouping.
|
||||
await page.getByTestId("sidebar-display-preferences-menu").click();
|
||||
await page.getByTestId("sidebar-display-grouping").click();
|
||||
await page.getByTestId("sidebar-grouping-status").click();
|
||||
await selectSidebarStatusGrouping(page);
|
||||
|
||||
const sidebar = page.getByTestId("sidebar-sessions").filter({ visible: true }).first();
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { gotoAppShell } from "../support/helpers/app";
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { selectSidebarStatusGrouping } from "../support/helpers/sidebar";
|
||||
|
||||
// The pin shortcut used to be registered by the sidebar row itself, so it silently did nothing
|
||||
// whenever the row was unmounted — a collapsed project section being the common case. It now
|
||||
@@ -39,9 +40,7 @@ async function collapseProjectSection(page: Page, project: SeededWorkspace): Pro
|
||||
}
|
||||
|
||||
async function switchToStatusGrouping(page: Page): Promise<void> {
|
||||
await page.getByTestId("sidebar-display-preferences-menu").click();
|
||||
await page.getByTestId("sidebar-display-grouping").click();
|
||||
await page.getByTestId("sidebar-grouping-status").click();
|
||||
await selectSidebarStatusGrouping(page);
|
||||
await expect(page.getByTestId("sidebar-status-list-scroll")).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
expectMobileAgentSidebarHidden,
|
||||
expectMobileAgentSidebarVisible,
|
||||
openMobileAgentSidebar,
|
||||
pinWorkspaceFromSidebar,
|
||||
} from "../support/helpers/sidebar";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { expectWorkspaceHeader } from "../support/helpers/workspace-ui";
|
||||
@@ -227,18 +228,19 @@ test.describe("Mobile sidebar panelState transition", () => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-retained-pin-" });
|
||||
|
||||
try {
|
||||
// Pinning behavior has its own E2E suite. Seed this test's precondition directly so it only
|
||||
// exercises whether the retained compact sidebar keeps the pinned row mounted while closed.
|
||||
await workspace.client.setWorkspacePinned(workspace.workspaceId, true);
|
||||
await gotoAppShell(page);
|
||||
await openMobileAgentSidebar(page);
|
||||
await expectMobileAgentSidebarVisible(page);
|
||||
|
||||
const row = page.getByTestId(getWorkspaceRowTestId(workspace.workspaceId));
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await pinWorkspaceFromSidebar(page, workspace.workspaceId);
|
||||
await expect(page.getByTestId("sidebar-pinned-section")).toBeVisible();
|
||||
|
||||
await closeMobileAgentSidebar(page);
|
||||
await expectMobileAgentSidebarHidden(page);
|
||||
|
||||
await expect(page.getByTestId(getWorkspaceRowTestId(workspace.workspaceId))).toHaveCount(1);
|
||||
await expect(row).toHaveCount(1);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { test, expect, type Page } from "../support/fixtures";
|
||||
import { TerminalE2EHarness } from "../support/helpers/terminal-dsl";
|
||||
import { TerminalE2EHarness, withTerminalInApp } from "../support/helpers/terminal-dsl";
|
||||
import { getTerminalBufferText } from "../support/helpers/terminal-perf";
|
||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
|
||||
@@ -97,6 +98,15 @@ async function captureTerminalText(
|
||||
return capture.lines.join("\n");
|
||||
}
|
||||
|
||||
async function waitForTerminalMarker(page: Page, marker: string): Promise<void> {
|
||||
await expect
|
||||
.poll(() => getTerminalBufferText(page), {
|
||||
message: "terminal stream should deliver PTY output after focus returns",
|
||||
timeout: 10_000,
|
||||
})
|
||||
.toContain(marker);
|
||||
}
|
||||
|
||||
test.describe("terminal PTY size claim under lost window focus", () => {
|
||||
let harness: TerminalE2EHarness;
|
||||
|
||||
@@ -174,4 +184,22 @@ test.describe("terminal PTY size claim under lost window focus", () => {
|
||||
)
|
||||
.toEqual({ rows: rendered.rows, cols: rendered.cols });
|
||||
});
|
||||
|
||||
test("terminal output remains subscribed after window focus returns", async ({ page }) => {
|
||||
await withTerminalInApp(page, harness, { name: "focus-return-output" }, async (terminal) => {
|
||||
const marker = `OUTPUT_AFTER_FOCUS_${Date.now()}`;
|
||||
|
||||
await setWindowFocused(page, false);
|
||||
await page.waitForTimeout(50);
|
||||
await setWindowFocused(page, true);
|
||||
await page.waitForTimeout(50);
|
||||
|
||||
harness.client.sendTerminalInput(terminal.id, {
|
||||
type: "input",
|
||||
data: `printf "\\n${marker}\\n"\n`,
|
||||
});
|
||||
|
||||
await waitForTerminalMarker(page, marker);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
submitNewWorkspacePrompt,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { selectSidebarStatusGrouping } from "../support/helpers/sidebar";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
expectSubagentRowVisible,
|
||||
@@ -82,9 +83,7 @@ async function fetchAgentStatus(seeded: SeededWorkspace, agentId: string): Promi
|
||||
}
|
||||
|
||||
async function switchSidebarToStatusGrouping(page: import("@playwright/test").Page) {
|
||||
await page.getByTestId("sidebar-display-preferences-menu").click();
|
||||
await page.getByTestId("sidebar-display-grouping").click();
|
||||
await page.getByTestId("sidebar-grouping-status").click();
|
||||
await selectSidebarStatusGrouping(page);
|
||||
await expect(page.locator('[data-testid^="sidebar-status-group-"]').first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
selectNewWorkspaceProject,
|
||||
submitNewWorkspaceEmpty,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { selectSidebarStatusGrouping } from "../support/helpers/sidebar";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
import { getVisibleWorkspaceAgentTabIds } from "../support/helpers/workspace-tabs";
|
||||
|
||||
@@ -397,9 +398,7 @@ async function expectWorkspaceRowInStatusBucket(
|
||||
page: Page,
|
||||
input: { serverId: string; workspaceId: string; bucket: string },
|
||||
) {
|
||||
await page.getByTestId("sidebar-display-preferences-menu").click();
|
||||
await page.getByTestId("sidebar-display-grouping").click();
|
||||
await page.getByTestId("sidebar-grouping-status").click();
|
||||
await selectSidebarStatusGrouping(page);
|
||||
await expect(
|
||||
page
|
||||
.getByTestId(`sidebar-status-group-rows-${input.bucket}`)
|
||||
|
||||
@@ -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,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 };
|
||||
@@ -102,6 +102,8 @@ export async function waitForScrollableChat(
|
||||
.toBeGreaterThan(input.minScrollableDistance);
|
||||
}
|
||||
|
||||
const WHEEL_SETTLE_TIMEOUT_MS = 5_000;
|
||||
|
||||
export async function scrollChatAwayFromBottom(
|
||||
page: Page,
|
||||
input: { deltaY: number; minDistanceFromBottom: number },
|
||||
@@ -111,31 +113,41 @@ export async function scrollChatAwayFromBottom(
|
||||
if (!box) {
|
||||
throw new Error("Agent chat scroll container is not visible");
|
||||
}
|
||||
const wheelSettled = scroll.evaluate(
|
||||
(root: Element) =>
|
||||
new Promise<void>((resolve) => {
|
||||
root.addEventListener(
|
||||
"wheel",
|
||||
() => {
|
||||
const scrollElement = root as HTMLElement;
|
||||
let previousOffset = scrollElement.scrollTop;
|
||||
let stableFrames = 0;
|
||||
const sample = () => {
|
||||
const currentOffset = scrollElement.scrollTop;
|
||||
stableFrames = Math.abs(currentOffset - previousOffset) <= 0.5 ? stableFrames + 1 : 0;
|
||||
previousOffset = currentOffset;
|
||||
if (stableFrames >= 3) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(sample);
|
||||
};
|
||||
requestAnimationFrame(sample);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}),
|
||||
);
|
||||
// Letting the scroll animation stop before measuring is an optimization, so it must never be
|
||||
// the thing that decides the test — the assertion below is. The old wait could not say that:
|
||||
// it resolved only from a `requestAnimationFrame` sampler started by a `wheel` listener, and
|
||||
// rAF does not fire at all while the page is occluded, which is reachable on a CI runner and
|
||||
// stalls the sampler on its first frame. A missed wheel event did the same. Either way the
|
||||
// test hung until its own timeout with nothing to read. A timed sampler with a deadline
|
||||
// cannot: worst case it gives up and the poll below reports what the scroll actually did.
|
||||
const wheelSettled = scroll.evaluate((root: Element, timeoutMs: number) => {
|
||||
const scrollElement = root as HTMLElement;
|
||||
return new Promise<void>((resolve) => {
|
||||
const startedAt = Date.now();
|
||||
let sawWheel = false;
|
||||
let previousOffset = scrollElement.scrollTop;
|
||||
let stableSamples = 0;
|
||||
root.addEventListener(
|
||||
"wheel",
|
||||
() => {
|
||||
sawWheel = true;
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
const sample = () => {
|
||||
const currentOffset = scrollElement.scrollTop;
|
||||
const isStable = sawWheel && Math.abs(currentOffset - previousOffset) <= 0.5;
|
||||
stableSamples = isStable ? stableSamples + 1 : 0;
|
||||
previousOffset = currentOffset;
|
||||
if (stableSamples >= 3 || Date.now() - startedAt >= timeoutMs) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
setTimeout(sample, 16);
|
||||
};
|
||||
sample();
|
||||
});
|
||||
}, WHEEL_SETTLE_TIMEOUT_MS);
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.wheel(0, input.deltaY);
|
||||
await wheelSettled;
|
||||
|
||||
@@ -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([]);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { connectWorkspaceSetupClient } from "./workspace-setup";
|
||||
import { selectWorkspaceInSidebar } from "./sidebar";
|
||||
import { getServerId } from "./server-id";
|
||||
import { waitForTabBar } from "./launcher";
|
||||
import { waitForSettledPosition } from "./sheet-layout";
|
||||
|
||||
function composerInput(page: Page) {
|
||||
return page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
@@ -71,7 +72,28 @@ export async function pressInterruptShortcut(page: Page): Promise<void> {
|
||||
|
||||
export async function openAttachmentMenu(page: Page): Promise<void> {
|
||||
await page.getByTestId("message-input-attach-button").filter({ visible: true }).first().click();
|
||||
await expect(page.getByTestId("message-input-attachment-menu")).toBeVisible({ timeout: 5_000 });
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("message-input-attachment-menu")
|
||||
.or(page.getByTestId("message-input-attachment-menu-content")),
|
||||
).toBeVisible({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
export async function expectAttachmentSheetRowsOnTitleRail(page: Page): Promise<void> {
|
||||
const title = page.getByText("Add attachment", { exact: true });
|
||||
const firstItemGlyph = page
|
||||
.getByRole("button", { name: "Add image", exact: true })
|
||||
.locator("svg")
|
||||
.first();
|
||||
await waitForSettledPosition(title);
|
||||
const [titleBox, glyphBox] = await Promise.all([
|
||||
title.boundingBox(),
|
||||
firstItemGlyph.boundingBox(),
|
||||
]);
|
||||
if (!titleBox || !glyphBox) {
|
||||
throw new Error("Attachment sheet geometry could not be measured");
|
||||
}
|
||||
expect(Math.abs(glyphBox.x - titleBox.x)).toBeLessThanOrEqual(1);
|
||||
}
|
||||
|
||||
export async function expectAttachButtonDisabled(page: Page): Promise<void> {
|
||||
|
||||
@@ -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";
|
||||
@@ -123,11 +123,17 @@ export async function waitForConnectedHost(
|
||||
await expect(host).not.toBeVisible();
|
||||
}
|
||||
|
||||
export async function openSidebarHostFilter(page: Page): Promise<void> {
|
||||
export async function openSidebarDisplayPreferences(page: Page): Promise<void> {
|
||||
await page.getByTestId("sidebar-display-preferences-menu").click();
|
||||
await expect(page.getByTestId("sidebar-display-preferences-content")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
// Host filters live a page below the display-preferences root, so the filter rows only exist
|
||||
// after walking into that branch.
|
||||
export async function openSidebarHostFilter(page: Page): Promise<void> {
|
||||
await openSidebarDisplayPreferences(page);
|
||||
await page.getByTestId("sidebar-display-host-filter").click();
|
||||
}
|
||||
|
||||
@@ -189,7 +195,7 @@ export async function chooseHostBadgeDisplay(
|
||||
function hostBadge(page: Page, input: HostBadgeTarget) {
|
||||
return page
|
||||
.getByTestId(`sidebar-workspace-row-${input.serverId}:${input.workspaceId}`)
|
||||
.getByTestId(`sidebar-host-badge-${input.serverId}`);
|
||||
.getByTestId(`host-badge-${input.serverId}`);
|
||||
}
|
||||
|
||||
interface HostBadgeTarget {
|
||||
@@ -217,13 +223,24 @@ export async function expectNoHostBadge(page: Page, target: HostBadgeTarget): Pr
|
||||
await expect(hostBadge(page, target)).toHaveCount(0, { timeout: 15_000 });
|
||||
}
|
||||
|
||||
// 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 },
|
||||
): Promise<void> {
|
||||
const badge = hostBadge(page, target);
|
||||
await expect(badge).toBeVisible({ timeout: 15_000 });
|
||||
await expect(badge.locator("svg")).toHaveAttribute("stroke", identityColor(target.color));
|
||||
await expect(badge).toHaveText(target.hostName);
|
||||
await expect(badge.locator("svg")).toHaveAttribute(
|
||||
"stroke",
|
||||
identityForeground(target.color, "light"),
|
||||
);
|
||||
}
|
||||
|
||||
export async function expectHostAppearancePreview(
|
||||
@@ -232,9 +249,12 @@ export async function expectHostAppearancePreview(
|
||||
): Promise<void> {
|
||||
const preview = page.getByTestId("host-appearance-preview");
|
||||
await expect(preview).toBeVisible();
|
||||
const badge = preview.getByTestId(`sidebar-host-badge-${input.serverId}`);
|
||||
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;
|
||||
}
|
||||
@@ -235,6 +235,33 @@ export async function expectNewWorkspaceProjectSelected(
|
||||
await expect(projectPicker).toContainText(projectDisplayName);
|
||||
}
|
||||
|
||||
export async function expectNewWorkspaceTriggerLabelsAligned(
|
||||
page: Page,
|
||||
input: { projectLabel: string; hostLabel: string },
|
||||
): Promise<void> {
|
||||
const projectTrigger = page.getByRole("button", { name: "Workspace project" });
|
||||
const hostTrigger = page.getByRole("button", { name: "Host", exact: true });
|
||||
const projectLabel = projectTrigger.getByText(input.projectLabel, { exact: true });
|
||||
const hostLabel = hostTrigger.getByText(input.hostLabel, { exact: true });
|
||||
await Promise.all([
|
||||
expect(projectLabel).toBeVisible({ timeout: 30_000 }),
|
||||
expect(hostLabel).toBeVisible({ timeout: 30_000 }),
|
||||
]);
|
||||
const [projectTriggerBox, projectLabelBox, hostTriggerBox, hostLabelBox] = await Promise.all([
|
||||
projectTrigger.boundingBox(),
|
||||
projectLabel.boundingBox(),
|
||||
hostTrigger.boundingBox(),
|
||||
hostLabel.boundingBox(),
|
||||
]);
|
||||
if (!projectTriggerBox || !projectLabelBox || !hostTriggerBox || !hostLabelBox) {
|
||||
throw new Error("New Workspace trigger geometry could not be measured");
|
||||
}
|
||||
|
||||
const projectLabelInset = projectLabelBox.x - projectTriggerBox.x;
|
||||
const hostLabelInset = hostLabelBox.x - hostTriggerBox.x;
|
||||
expect(hostLabelInset).toBeCloseTo(projectLabelInset, 0);
|
||||
}
|
||||
|
||||
export async function fillNewWorkspaceDraft(page: Page, draft: string): Promise<void> {
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await expect(composer).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -39,7 +39,6 @@ export interface SeedDaemonClient {
|
||||
}>;
|
||||
removeProject(projectId: string): Promise<{ removedWorkspaceIds: string[] }>;
|
||||
renameProject(projectId: string, customName: string | null): Promise<void>;
|
||||
setWorkspacePinned(workspaceId: string, pinned: boolean): Promise<{ pinnedAt: string | null }>;
|
||||
fetchWorkspaces(options?: { filter?: { projectId?: string } }): Promise<{
|
||||
entries: SeedWorkspaceDescriptor[];
|
||||
}>;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -166,6 +166,19 @@ export async function expectWorkspaceAbsentFromSidebar(
|
||||
).toHaveCount(0, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
// The display-preferences menu is one row per decision, and the options sit a page below that
|
||||
// row. Every caller has to walk the same path, so it lives here: when the menu's shape moves
|
||||
// again, this is the only place that has to follow.
|
||||
export async function openSidebarDisplayPage(page: Page, branchTestID: string): Promise<void> {
|
||||
await page.getByTestId("sidebar-display-preferences-menu").click();
|
||||
await page.getByTestId(branchTestID).click();
|
||||
}
|
||||
|
||||
export async function selectSidebarStatusGrouping(page: Page): Promise<void> {
|
||||
await openSidebarDisplayPage(page, "sidebar-display-grouping");
|
||||
await page.getByTestId("sidebar-grouping-status").click();
|
||||
}
|
||||
|
||||
export async function openMobileAgentSidebar(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Open menu" }).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 },
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
apply plugin: 'com.android.library'
|
||||
|
||||
group = 'sh.paseo.trace'
|
||||
version = '0.1.0'
|
||||
|
||||
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
|
||||
apply from: expoModulesCorePlugin
|
||||
applyKotlinExpoModulesCorePlugin()
|
||||
useCoreDependencies()
|
||||
useExpoPublishing()
|
||||
useDefaultAndroidSdkVersions()
|
||||
|
||||
android {
|
||||
namespace "sh.paseo.trace"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package sh.paseo.trace
|
||||
|
||||
import android.os.Trace
|
||||
import expo.modules.kotlin.modules.Module
|
||||
import expo.modules.kotlin.modules.ModuleDefinition
|
||||
|
||||
class PaseoNativeTraceModule : Module() {
|
||||
override fun definition() = ModuleDefinition {
|
||||
Name("PaseoNativeTrace")
|
||||
|
||||
Function("beginSection") { name: String ->
|
||||
Trace.beginSection(name.take(127))
|
||||
}
|
||||
|
||||
Function("endSection") {
|
||||
Trace.endSection()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"platforms": ["android"],
|
||||
"android": {
|
||||
"modules": ["sh.paseo.trace.PaseoNativeTraceModule"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "paseo-native-trace",
|
||||
"version": "0.1.0",
|
||||
"private": true
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
const versionPattern = /^(\d+)\.(\d+)\.(\d+)(?:-beta\.(\d+))?$/;
|
||||
const stableIosBuildSlot = 999;
|
||||
|
||||
function getNativeReleaseVersion(version) {
|
||||
const match = versionPattern.exec(version);
|
||||
if (!match) {
|
||||
throw new Error(`Cannot derive native release version from unsupported version: ${version}`);
|
||||
}
|
||||
|
||||
const [, majorText, minorText, patchText, betaText] = match;
|
||||
const major = Number(majorText);
|
||||
const minor = Number(minorText);
|
||||
const patch = Number(patchText);
|
||||
const betaNumber = betaText === undefined ? null : Number(betaText);
|
||||
|
||||
if (minor > 999 || patch > 999) {
|
||||
throw new Error(`Cannot derive collision-free native version from: ${version}`);
|
||||
}
|
||||
if (betaNumber !== null && (betaNumber < 1 || betaNumber >= stableIosBuildSlot)) {
|
||||
throw new Error(`iOS beta number must be between 1 and 998: ${version}`);
|
||||
}
|
||||
|
||||
const versionCode = major * 1_000_000 + minor * 1_000 + patch;
|
||||
if (!Number.isSafeInteger(versionCode) || versionCode <= 0 || versionCode > 2_100_000_000) {
|
||||
throw new Error(`Derived Android versionCode is out of range: ${versionCode}`);
|
||||
}
|
||||
|
||||
const iosBuildSlot = betaNumber ?? stableIosBuildSlot;
|
||||
const iosBuildNumber = versionCode * 1_000 + iosBuildSlot;
|
||||
if (!Number.isSafeInteger(iosBuildNumber)) {
|
||||
throw new Error(`Derived iOS buildNumber is out of range: ${iosBuildNumber}`);
|
||||
}
|
||||
|
||||
return {
|
||||
appVersion: `${major}.${minor}.${patch}`,
|
||||
androidVersionCode: versionCode,
|
||||
iosBuildNumber: String(iosBuildNumber),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { getNativeReleaseVersion };
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const { getNativeReleaseVersion } = require("./native-release-version");
|
||||
|
||||
describe("native release version", () => {
|
||||
it("reserves the final iOS build slot for a stable release", () => {
|
||||
expect(getNativeReleaseVersion("0.2.6")).toEqual({
|
||||
appVersion: "0.2.6",
|
||||
androidVersionCode: 2006,
|
||||
iosBuildNumber: "2006999",
|
||||
});
|
||||
});
|
||||
|
||||
it("gives each beta a unique iOS build slot under the stable app version", () => {
|
||||
expect(getNativeReleaseVersion("0.2.6-beta.2")).toEqual({
|
||||
appVersion: "0.2.6",
|
||||
androidVersionCode: 2006,
|
||||
iosBuildNumber: "2006002",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects beta numbers that consume the stable iOS build slot", () => {
|
||||
expect(() => getNativeReleaseVersion("0.2.6-beta.999")).toThrow(
|
||||
"iOS beta number must be between 1 and 998",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.2.5",
|
||||
"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",
|
||||
@@ -100,7 +101,7 @@
|
||||
"react-native-draggable-flatlist": "^4.0.3",
|
||||
"react-native-edge-to-edge": "^1.7.0",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-keyboard-controller": "^1.19.2",
|
||||
"react-native-keyboard-controller": "1.21.12",
|
||||
"react-native-markdown-display": "^7.0.2",
|
||||
"react-native-nitro-modules": "0.35.5",
|
||||
"react-native-reanimated": "~4.3.1",
|
||||
|
||||
@@ -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"] },
|
||||
},
|
||||
],
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
const { withAndroidManifest } = require("expo/config-plugins");
|
||||
|
||||
function withAndroidProfileable(config) {
|
||||
return withAndroidManifest(config, (modConfig) => {
|
||||
const application = modConfig.modResults.manifest.application?.[0];
|
||||
if (!application) {
|
||||
throw new Error("Could not enable Android profiling without an application manifest entry");
|
||||
}
|
||||
|
||||
application.profileable = [{ $: { "android:shell": "true" } }];
|
||||
return modConfig;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = withAndroidProfileable;
|
||||
@@ -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: {
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -266,6 +266,7 @@ export function HostPicker({
|
||||
active={active}
|
||||
selected={selected}
|
||||
onPress={onPress}
|
||||
testID={hostOptionTestID?.(option.id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,6 +42,23 @@ describe("resolveProviderDiscoveredModels", () => {
|
||||
expect(refreshing.models).toEqual([grokModel]);
|
||||
});
|
||||
|
||||
it("excludes compatibility-only models from display and cache", () => {
|
||||
const compatibilityModel: AgentModelDefinition = {
|
||||
...piModel,
|
||||
id: "pi/model-legacy",
|
||||
label: "Pi Model legacy",
|
||||
isSelectable: false,
|
||||
};
|
||||
|
||||
const result = resolveModels({
|
||||
provider: "pi",
|
||||
currentModels: [piModel, compatibilityModel],
|
||||
});
|
||||
|
||||
expect(result.models).toEqual([piModel]);
|
||||
expect(result.cache?.models).toEqual([piModel]);
|
||||
});
|
||||
|
||||
it("does not show one provider's cached models while another provider loads", () => {
|
||||
const ready = resolveModels({ provider: "pi", currentModels: [piModel] });
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user