mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-15 13:52:39 +00:00
Compare commits
79
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d08ddba94 | ||
|
|
2f6b585a3a | ||
|
|
2c5c629db8 | ||
|
|
6ed364ffe2 | ||
|
|
74f2158c45 | ||
|
|
d012b6f071 | ||
|
|
5f5c585e68 | ||
|
|
23d7a8eaf9 | ||
|
|
7b167784d7 | ||
|
|
40bdb9a043 | ||
|
|
2f54c0e309 | ||
|
|
b9d7d4b18e | ||
|
|
1c905a114e | ||
|
|
163e7d1cc4 | ||
|
|
cdb1163145 | ||
|
|
f24664b882 | ||
|
|
a39959219f | ||
|
|
db6520bf36 | ||
|
|
b2ff359b60 | ||
|
|
308e9af845 | ||
|
|
07044136c8 | ||
|
|
b7ca1f7502 | ||
|
|
917e39495b | ||
|
|
8c003351d0 | ||
|
|
36a13601dd | ||
|
|
b599d38a77 | ||
|
|
7253228dc5 | ||
|
|
334bf62370 | ||
|
|
6aa9f92156 | ||
|
|
29ae25d7aa | ||
|
|
df9e8e8f06 | ||
|
|
94bda1f925 | ||
|
|
c5d5441c9c | ||
|
|
ca2b633286 | ||
|
|
a98d154b6b | ||
|
|
42f1d0b289 | ||
|
|
48e65b407f | ||
|
|
7954babca6 | ||
|
|
05a0cabc07 | ||
|
|
b7f137c2a0 | ||
|
|
02c4bc76a6 | ||
|
|
ccb4e444ed | ||
|
|
b0a7d493aa | ||
|
|
bfec7ac3ad | ||
|
|
5269c7b592 | ||
|
|
05dc2022c8 | ||
|
|
c0daf8e066 | ||
|
|
6ae6231d1f | ||
|
|
c77a6761af | ||
|
|
fd9cc5d693 | ||
|
|
7761d122d8 | ||
|
|
01a1d3b2b6 | ||
|
|
3bca9b7a92 | ||
|
|
8ffe7c75d5 | ||
|
|
74cf1931b1 | ||
|
|
90aa8bbefc | ||
|
|
c28836b4e5 | ||
|
|
8f38d04919 | ||
|
|
ce7b3a90c0 | ||
|
|
cefb71dcac | ||
|
|
49b257c55a | ||
|
|
1e0ce69c54 | ||
|
|
b9b3a24d58 | ||
|
|
b19db9537c | ||
|
|
1a117652fd | ||
|
|
638693a5d7 | ||
|
|
2740c8206f | ||
|
|
0906d1d7bd | ||
|
|
3d420720c5 | ||
|
|
375d1d6fb8 | ||
|
|
f0eb7cea4b | ||
|
|
b669dad221 | ||
|
|
7ac4303d8f | ||
|
|
e36842b234 | ||
|
|
7392e1b767 | ||
|
|
3196385526 | ||
|
|
b167ee02e1 | ||
|
|
cc5ce1038d | ||
|
|
8d3881f640 |
@@ -1,13 +1,9 @@
|
||||
---
|
||||
name: release-beta
|
||||
description: Cut a beta release of Paseo. Use when the user says "release beta", "cut a beta", "ship a beta", "beta release", or "/release-beta". Betas are release candidates on the beta channel — they carry an in-place changelog entry, don't move the website download target, and publish npm only on the beta dist-tag.
|
||||
description: Cut a beta release of Paseo. Use when the user says "release beta", "cut a beta", "ship a beta", "beta release", or "/release-beta".
|
||||
user-invocable: true
|
||||
---
|
||||
|
||||
# Release beta
|
||||
|
||||
Read `docs/release.md` in the Paseo repo and follow the **Beta flow** section end-to-end. Run the **Beta release** completion checklist at the bottom of that doc.
|
||||
|
||||
During preparation, classify the previous-stable-to-`HEAD` diff as patch or minor and show the target version and rationale to the user. Agents never select a major version autonomously.
|
||||
|
||||
Each beta updates an in-place `CHANGELOG.md` entry (`## X.Y.Z-beta.N`) that gets overwritten at promotion, and npm publishes only on the explicit `beta` dist-tag.
|
||||
Follow the **Beta flow** and **Beta release** completion checklist in `docs/release.md` end-to-end.
|
||||
|
||||
@@ -6,8 +6,4 @@ user-invocable: true
|
||||
|
||||
# Release stable
|
||||
|
||||
Read `docs/release.md` in the Paseo repo and follow the **Standard release (stable)** flow if cutting fresh, or the **Beta flow** promotion step if promoting an existing beta. Run the **Stable release (or promotion)** completion checklist at the bottom of that doc.
|
||||
|
||||
For a fresh release, classify the previous-stable-to-`HEAD` diff as patch or minor and show the target version and rationale to the user. Agents never select a major version autonomously.
|
||||
|
||||
The doc covers the changelog, pre-release sanity check, and post-release babysit pattern. Don't skip steps.
|
||||
Follow the applicable flow and the **Stable release (or promotion)** completion checklist in `docs/release.md` end-to-end.
|
||||
|
||||
@@ -18,7 +18,7 @@ Closes #
|
||||
|
||||
### Reasoning
|
||||
|
||||
<!-- A short description of the reasoning of this change in your own words. What was wrong, and how do you hope this PR fixes it. If you can't explain this briefly, the PR is probably too big. This description must be grounded in real user flows and framed as value provider to Paseo users. -->
|
||||
<!-- A short description of the reasoning of this change in your own words. What was wrong, and how do you hope this PR fixes it. If you can't explain this briefly, the PR is probably too big. This description must be grounded in real user flows and framed as value provided to Paseo users. -->
|
||||
|
||||
### Goals
|
||||
|
||||
|
||||
+56
-55
@@ -1,86 +1,77 @@
|
||||
# Changelog
|
||||
|
||||
## 0.3.0-beta.4 - 2026-08-07
|
||||
|
||||
### Fixed
|
||||
|
||||
- Pi responses keep streaming when cumulative messages are unavailable ([#2978](https://github.com/getpaseo/paseo/pull/2978) by [@dundunHa](https://github.com/dundunHa))
|
||||
- Desktop browser tabs remain interactive through focus and layout changes
|
||||
|
||||
## 0.3.0-beta.3 - 2026-08-07
|
||||
## 0.3.1 - 2026-08-09
|
||||
|
||||
### Added
|
||||
|
||||
- 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))
|
||||
- Add custom HTTP headers to direct host connections ([#2922](https://github.com/getpaseo/paseo/pull/2922))
|
||||
- Launch a terminal directly from New Workspace ([#2941](https://github.com/getpaseo/paseo/pull/2941))
|
||||
- See Claude workflows in the subagent track ([#2933](https://github.com/getpaseo/paseo/pull/2933))
|
||||
- Unassign keyboard shortcuts and see the effective bindings in shortcut help ([#2510](https://github.com/getpaseo/paseo/pull/2510) by [@cleiter](https://github.com/cleiter), [#2985](https://github.com/getpaseo/paseo/pull/2985) by [@cleiter](https://github.com/cleiter))
|
||||
- Choose a Pure black theme ([#3012](https://github.com/getpaseo/paseo/pull/3012) by [@shrimpwtf](https://github.com/shrimpwtf))
|
||||
|
||||
### Improved
|
||||
|
||||
- Workspace Git status stays responsive in large repositories ([#2979](https://github.com/getpaseo/paseo/pull/2979))
|
||||
- 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))
|
||||
- Copying part of an assistant response preserves its formatting and selection boundaries ([#2930](https://github.com/getpaseo/paseo/pull/2930), [#2935](https://github.com/getpaseo/paseo/pull/2935) by [@cleiter](https://github.com/cleiter))
|
||||
- Desktop browser tabs keep their state through focus changes and automation ([#2907](https://github.com/getpaseo/paseo/pull/2907))
|
||||
- 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))
|
||||
- Running agents use a clearer animated status ring ([c0daf8e](https://github.com/getpaseo/paseo/commit/c0daf8e066e0d7379723a2a6ddea20afc71ead20) by [@cleiter](https://github.com/cleiter))
|
||||
- Workspace Git updates stay responsive under file-watcher failures, heavy Git activity, and large shared-worktree repositories ([#3056](https://github.com/getpaseo/paseo/pull/3056), [#3033](https://github.com/getpaseo/paseo/pull/3033) by [@dwyanewang](https://github.com/dwyanewang), [#3078](https://github.com/getpaseo/paseo/pull/3078))
|
||||
- Kimi usage refreshes correctly and shows five-hour limits and reasoning levels ([#2743](https://github.com/getpaseo/paseo/pull/2743) by [@UnbrokenHunter](https://github.com/UnbrokenHunter))
|
||||
- Resize the desktop sidebar with touch ([#2953](https://github.com/getpaseo/paseo/pull/2953) by [@colonelpanic8](https://github.com/colonelpanic8))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Direct connections keep working through the browser relay ([#2976](https://github.com/getpaseo/paseo/pull/2976))
|
||||
- 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))
|
||||
- Terminal activity stops after an interrupted turn ([#2942](https://github.com/getpaseo/paseo/pull/2942))
|
||||
- Windows terminals handle deferred startup failures without crashing
|
||||
- Host connection choices stay selected through Settings and desktop refreshes ([#2905](https://github.com/getpaseo/paseo/pull/2905))
|
||||
- New Workspace keeps its launch selector alongside mobile controls
|
||||
- Images stay anchored while a conversation reloads
|
||||
- The composer toolbar no longer flickers when switching tabs ([#2937](https://github.com/getpaseo/paseo/pull/2937))
|
||||
- Recreated workspace folders no longer remain incorrectly archived ([#2987](https://github.com/getpaseo/paseo/pull/2987))
|
||||
- Direct LAN connections work again on macOS ([#3071](https://github.com/getpaseo/paseo/pull/3071))
|
||||
- Desktop update checks no longer report an error while platform downloads are still publishing ([#3076](https://github.com/getpaseo/paseo/pull/3076))
|
||||
- Copied lists keep their markers across selection boundaries ([#3055](https://github.com/getpaseo/paseo/pull/3055))
|
||||
- Switching hosts keeps the active project selected ([#3051](https://github.com/getpaseo/paseo/pull/3051))
|
||||
- New Workspace keeps project and isolation controls inside the composer ([#3050](https://github.com/getpaseo/paseo/pull/3050))
|
||||
- Session rename is reachable from the mobile sessions menu ([#2924](https://github.com/getpaseo/paseo/pull/2924) by [@kaspesi](https://github.com/kaspesi))
|
||||
- Desktop notification sounds play again ([#2582](https://github.com/getpaseo/paseo/pull/2582) by [@gstamp](https://github.com/gstamp))
|
||||
- Codex slash commands omit disabled skills ([#2759](https://github.com/getpaseo/paseo/pull/2759) by [@jasonhnd](https://github.com/jasonhnd))
|
||||
- Exact ignored file paths resolve correctly ([#3042](https://github.com/getpaseo/paseo/pull/3042) by [@cleiter](https://github.com/cleiter))
|
||||
- Claude question notifications summarize the requested input ([#2925](https://github.com/getpaseo/paseo/pull/2925) by [@SihyeonJeon](https://github.com/SihyeonJeon))
|
||||
- The crash screen stays readable and keeps its retry action reachable ([#3043](https://github.com/getpaseo/paseo/pull/3043))
|
||||
|
||||
## 0.3.0-beta.2 - 2026-08-05
|
||||
|
||||
### Fixed
|
||||
|
||||
- Terminal output keeps updating when you leave the app and come back ([#2896](https://github.com/getpaseo/paseo/pull/2896))
|
||||
|
||||
## 0.3.0-beta.1 - 2026-08-04
|
||||
## 0.3.0 - 2026-08-08
|
||||
|
||||
### Added
|
||||
|
||||
- New mobile terminal with text selection, copy, paste, and faster rendering ([#1607](https://github.com/getpaseo/paseo/pull/1607), [#2830](https://github.com/getpaseo/paseo/pull/2830))
|
||||
- Jump between prompts in a chat ([#2792](https://github.com/getpaseo/paseo/pull/2792))
|
||||
- Preview HTML files ([#2712](https://github.com/getpaseo/paseo/pull/2712) by [@nicholas-salgueiro-britecore](https://github.com/nicholas-salgueiro-britecore), [@nickmaglowsch](https://github.com/nickmaglowsch))
|
||||
- Fork an agent while it's running ([#2638](https://github.com/getpaseo/paseo/pull/2638) by [@kaspesi](https://github.com/kaspesi))
|
||||
- Redesigned sidebar with clearer status colours and rows that are quicker to scan ([#2340](https://github.com/getpaseo/paseo/pull/2340) by [@kaspesi](https://github.com/kaspesi), [#2335](https://github.com/getpaseo/paseo/pull/2335), [#2416](https://github.com/getpaseo/paseo/pull/2416) by [@nikuscs](https://github.com/nikuscs), [#2711](https://github.com/getpaseo/paseo/pull/2711))
|
||||
- Choose what a sidebar workspace row shows: host, pull request, checks, and scripts
|
||||
- Name each host and give it a colour ([#2790](https://github.com/getpaseo/paseo/pull/2790))
|
||||
- Search your history by workspace, agent, and branch ([#2995](https://github.com/getpaseo/paseo/pull/2995))
|
||||
- Use Paseo in Korean ([#2895](https://github.com/getpaseo/paseo/pull/2895) by [@himomohi](https://github.com/himomohi), [@Kesta-bos](https://github.com/Kesta-bos))
|
||||
- Run git and workspace actions from the Command Center ([#2749](https://github.com/getpaseo/paseo/pull/2749))
|
||||
- Change model, reasoning, mode, plan, and fast from the Command Center ([#2274](https://github.com/getpaseo/paseo/pull/2274) by [@kedrzu](https://github.com/kedrzu))
|
||||
- Fork an agent while it's running ([#2638](https://github.com/getpaseo/paseo/pull/2638) by [@kaspesi](https://github.com/kaspesi))
|
||||
- Launch a terminal directly from New Workspace ([#2941](https://github.com/getpaseo/paseo/pull/2941))
|
||||
- Jump between prompts in a chat ([#2792](https://github.com/getpaseo/paseo/pull/2792))
|
||||
- Preview HTML files ([#2712](https://github.com/getpaseo/paseo/pull/2712) by [@nicholas-salgueiro-britecore](https://github.com/nicholas-salgueiro-britecore), [@nickmaglowsch](https://github.com/nickmaglowsch))
|
||||
- See Claude workflows in the subagent track ([#2933](https://github.com/getpaseo/paseo/pull/2933))
|
||||
- Choose which orchestration skills Paseo installs ([#2680](https://github.com/getpaseo/paseo/pull/2680))
|
||||
- Give projects custom icons ([#2335](https://github.com/getpaseo/paseo/pull/2335), [#2416](https://github.com/getpaseo/paseo/pull/2416) by [@nikuscs](https://github.com/nikuscs))
|
||||
- Approve all ACP tool calls with one setting ([#2752](https://github.com/getpaseo/paseo/pull/2752))
|
||||
- Choose what a sidebar workspace row shows: host, pull request, checks, and scripts
|
||||
- Name each host and give it a colour ([#2790](https://github.com/getpaseo/paseo/pull/2790))
|
||||
- See a project's workspace status while it's collapsed ([#2340](https://github.com/getpaseo/paseo/pull/2340) by [@kaspesi](https://github.com/kaspesi))
|
||||
- See worktree names in workspace hover cards ([#2711](https://github.com/getpaseo/paseo/pull/2711))
|
||||
- Add custom HTTP headers to direct host connections ([#2922](https://github.com/getpaseo/paseo/pull/2922))
|
||||
- Pick a local branch or its origin counterpart as the base for a new worktree ([#2328](https://github.com/getpaseo/paseo/pull/2328) by [@mcowger](https://github.com/mcowger))
|
||||
- Paste images from the clipboard on mobile ([#2793](https://github.com/getpaseo/paseo/pull/2793))
|
||||
|
||||
### Improved
|
||||
|
||||
- Paseo starts faster
|
||||
- Coming back to a recent chat is instant ([#2842](https://github.com/getpaseo/paseo/pull/2842))
|
||||
- Provider data is cached in the client for a faster startup
|
||||
- Recent chats stay live in the background, so switching back shows current messages right away ([#2842](https://github.com/getpaseo/paseo/pull/2842))
|
||||
- Coming back to a workspace from Settings is faster ([#2791](https://github.com/getpaseo/paseo/pull/2791))
|
||||
- Workspace Git status stays responsive in large repositories ([#2979](https://github.com/getpaseo/paseo/pull/2979))
|
||||
- Paseo runs fewer git processes on busy machines ([#2797](https://github.com/getpaseo/paseo/pull/2797))
|
||||
- New worktrees start from the tracked upstream branch ([#2848](https://github.com/getpaseo/paseo/pull/2848))
|
||||
- Copying part of an assistant response keeps its lists, links, and code formatting ([#2808](https://github.com/getpaseo/paseo/pull/2808))
|
||||
- Copying part of an assistant response keeps its lists, links, and formatting ([#2808](https://github.com/getpaseo/paseo/pull/2808), [#2930](https://github.com/getpaseo/paseo/pull/2930), [#2935](https://github.com/getpaseo/paseo/pull/2935) by [@cleiter](https://github.com/cleiter))
|
||||
- See the full conversation from a native Claude subagent ([#2498](https://github.com/getpaseo/paseo/pull/2498) by [@ebg1223](https://github.com/ebg1223), [#2760](https://github.com/getpaseo/paseo/pull/2760))
|
||||
- OpenCode subagents show their task, type, model, and token usage ([#2909](https://github.com/getpaseo/paseo/pull/2909) by [@BrianAguilarWasco](https://github.com/BrianAguilarWasco))
|
||||
- Pi delegated tasks show their lifecycle status ([#2891](https://github.com/getpaseo/paseo/pull/2891))
|
||||
- Claude remembers model and thinking choices for new workspaces ([#2912](https://github.com/getpaseo/paseo/pull/2912))
|
||||
- OMP context usage updates while a turn is running ([#2503](https://github.com/getpaseo/paseo/pull/2503) by [@theslava](https://github.com/theslava))
|
||||
- OpenCode reports background activity from its own busy status ([#2696](https://github.com/getpaseo/paseo/pull/2696) by [@desflynn](https://github.com/desflynn))
|
||||
- Relay access is opt-in when you pair a device ([#2706](https://github.com/getpaseo/paseo/pull/2706))
|
||||
- Desktop browser tabs keep their state through focus changes and automation ([#2907](https://github.com/getpaseo/paseo/pull/2907))
|
||||
- New worktrees start from the tracked upstream branch ([#2848](https://github.com/getpaseo/paseo/pull/2848))
|
||||
- Generated workspace titles describe the task ([#2755](https://github.com/getpaseo/paseo/pull/2755))
|
||||
- Relay access is opt-in when you pair a device ([#2706](https://github.com/getpaseo/paseo/pull/2706))
|
||||
- Voice mode releases the audio session when recording and playback stop ([#2866](https://github.com/getpaseo/paseo/pull/2866) by [@kaspesi](https://github.com/kaspesi))
|
||||
- Workspace suggestions skip Git-ignored data ([#2902](https://github.com/getpaseo/paseo/pull/2902))
|
||||
- Reconnect messages distinguish daemon restarts from network interruptions ([#2931](https://github.com/getpaseo/paseo/pull/2931))
|
||||
- The file viewer shows one accurate status when a file changes or is deleted ([#2670](https://github.com/getpaseo/paseo/pull/2670), [#2694](https://github.com/getpaseo/paseo/pull/2694))
|
||||
- The Nix desktop package is smaller ([#2550](https://github.com/getpaseo/paseo/pull/2550) by [@colonelpanic8](https://github.com/colonelpanic8))
|
||||
- The Nix desktop app uses the right icon on Linux and macOS ([#2506](https://github.com/getpaseo/paseo/pull/2506), [#2783](https://github.com/getpaseo/paseo/pull/2783) by [@colonelpanic8](https://github.com/colonelpanic8))
|
||||
@@ -88,20 +79,30 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- Pi 0.84 agents no longer crash-loop on every prompt ([#2978](https://github.com/getpaseo/paseo/pull/2978) by [@dundunHa](https://github.com/dundunHa))
|
||||
- Workspace file watching no longer stalls the daemon ([#2858](https://github.com/getpaseo/paseo/pull/2858))
|
||||
- Messages no longer duplicate or arrive out of order after a reconnect or resume ([#2789](https://github.com/getpaseo/paseo/pull/2789), [#2718](https://github.com/getpaseo/paseo/pull/2718))
|
||||
- Git status and diffs pick up changes in nested folders on every desktop OS ([#2775](https://github.com/getpaseo/paseo/pull/2775))
|
||||
- Switching workspaces no longer scrolls a chat away from where you were reading ([#2838](https://github.com/getpaseo/paseo/pull/2838))
|
||||
- Cancelling an OpenCode turn no longer breaks the next one ([#2662](https://github.com/getpaseo/paseo/pull/2662))
|
||||
- Claude runtime failures report an error instead of leaving the workspace idle ([#2910](https://github.com/getpaseo/paseo/pull/2910) by [@nickmaglowsch](https://github.com/nickmaglowsch))
|
||||
- Claude replay no longer leaves stale running subagents ([#2876](https://github.com/getpaseo/paseo/pull/2876) by [@cleiter](https://github.com/cleiter))
|
||||
- The Fast toggle appears for Claude Opus 5 ([#2939](https://github.com/getpaseo/paseo/pull/2939) by [@cleiter](https://github.com/cleiter))
|
||||
- Agents stop with an error when their provider process exits ([#2757](https://github.com/getpaseo/paseo/pull/2757))
|
||||
- Cancelling an OpenCode turn no longer breaks the next one ([#2662](https://github.com/getpaseo/paseo/pull/2662))
|
||||
- ACP permission prompts no longer disappear mid-turn ([#2762](https://github.com/getpaseo/paseo/pull/2762))
|
||||
- A workspace no longer looks idle while its native subagents run ([#2777](https://github.com/getpaseo/paseo/pull/2777))
|
||||
- Terminal activity stops after an interrupted turn ([#2942](https://github.com/getpaseo/paseo/pull/2942))
|
||||
- Windows terminals handle deferred startup failures without crashing
|
||||
- Recreated workspace folders no longer remain incorrectly archived ([#2987](https://github.com/getpaseo/paseo/pull/2987))
|
||||
- Restoring a merged workspace no longer leaves it inactive ([#2714](https://github.com/getpaseo/paseo/pull/2714))
|
||||
- Switching workspaces no longer scrolls a chat away from where you were reading ([#2838](https://github.com/getpaseo/paseo/pull/2838))
|
||||
- Images stay anchored while a conversation reloads
|
||||
- Swipe and drag gestures work in the mobile sidebar ([#2709](https://github.com/getpaseo/paseo/pull/2709))
|
||||
- Dictated prompts no longer disappear when you submit them ([#2745](https://github.com/getpaseo/paseo/pull/2745))
|
||||
- The composer stays above the keyboard on Android
|
||||
- Typing with an IME on mobile no longer loses composed text
|
||||
- Restoring a merged workspace no longer leaves it inactive ([#2714](https://github.com/getpaseo/paseo/pull/2714))
|
||||
- The composer toolbar no longer flickers when switching tabs ([#2937](https://github.com/getpaseo/paseo/pull/2937))
|
||||
- Your selected project stays selected when you switch hosts ([#2700](https://github.com/getpaseo/paseo/pull/2700))
|
||||
- Host connection choices stay selected through Settings and desktop refreshes ([#2905](https://github.com/getpaseo/paseo/pull/2905))
|
||||
- The pull request panel updates after you switch branches ([#2699](https://github.com/getpaseo/paseo/pull/2699))
|
||||
- New Workspace shows its isolation controls right away ([#2702](https://github.com/getpaseo/paseo/pull/2702))
|
||||
- Sidebar shortcuts leave focus mode ([#2717](https://github.com/getpaseo/paseo/pull/2717))
|
||||
|
||||
@@ -46,6 +46,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
| [docs/protocol-compatibility.md](docs/protocol-compatibility.md) | Why app/daemon versions drift, protocol vs feature contract, capability gating, COMPAT tagging |
|
||||
| [docs/protocol-validation.md](docs/protocol-validation.md) | zod-aot generated inbound WebSocket validation, patched compiler regressions, schema-purity rules |
|
||||
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
|
||||
| [docs/file-observation.md](docs/file-observation.md) | Recursive watcher ownership, Linux constraints, teardown invariants, and Parcel comparison |
|
||||
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
|
||||
| [docs/qa.md](docs/qa.md) | QA evidence bar for pull requests — platform matrix, version drift, performance, UI proof |
|
||||
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
|
||||
@@ -57,6 +58,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
|
||||
| [docs/terminal-activity.md](docs/terminal-activity.md) | Terminal activity indicators — source-agnostic tracker, agent hook reporting, adding a new hook provider |
|
||||
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
|
||||
| [public-docs/hub/security.md](public-docs/hub/security.md) | Public Hub guide — trust boundaries, untrusted triggers, provider controls, and output authority |
|
||||
|
||||
### Writing docs
|
||||
|
||||
|
||||
+3
-3
@@ -6,7 +6,7 @@ Thank you for taking the time to contribute to Paseo.
|
||||
|
||||
Paseo is an opinionated product, built on freedom and flexibility: any agent provider, any device, running on your own machine. It is meant to be composable, so you can build the workflow you want. Read more about the product vision [here](docs/product.md).
|
||||
|
||||
Given Paseo's scope, contributing to to it takes a lot of context that is very hard to transfer. That's why product, design, architecture, and workflow decisions are currently all made by the maintainer.
|
||||
Given Paseo's scope, contributing to it takes a lot of context that is very hard to transfer. That's why product, design, architecture, and workflow decisions are currently all made by the maintainer.
|
||||
|
||||
I pick what to build based on whether it fits the product, how many workflows it improves, whether it keeps things composable, whether we can hold the quality bar, and whether I want to build it.
|
||||
|
||||
@@ -72,8 +72,8 @@ Here is the criteria I use to decide:
|
||||
- PRs that were explicitly approved in a discussion are preferred.
|
||||
- Unsolicited PR can be closed without a detailed review.
|
||||
- Your PR can be narrowed, refactored, or redesigned.
|
||||
- Your PR might be accpeted but not merged immediately.
|
||||
- You wil be attributed for your work in the changelog, even if I redesign your PR
|
||||
- Your PR might be accepted but not merged immediately.
|
||||
- You will be attributed for your work in the changelog, even if I redesign your PR
|
||||
|
||||
## QA evidence
|
||||
|
||||
|
||||
@@ -118,7 +118,6 @@ npx skills add getpaseo/paseo
|
||||
どのエージェントとの会話でも使用できます。
|
||||
|
||||
- `/paseo-handoff` — エージェント間で作業を引き継ぎます。私はこれを使って Claude で計画し、Codex に実装を引き継いでいます。
|
||||
- `/paseo-loop` — 明確な受け入れ基準に沿ってエージェントをループさせます(Ralph loops とも呼ばれます)。検証役を追加することもできます。
|
||||
- `/paseo-advisor` — 単一のエージェントをアドバイザーとして起動し、作業を委任せずにセカンドオピニオンを得ます。
|
||||
- `/paseo-committee` — 対照的な2つのエージェントで委員会を構成し、一歩引いた視点で根本原因を分析して計画を作成します。
|
||||
|
||||
|
||||
@@ -128,7 +128,6 @@ npx skills add getpaseo/paseo
|
||||
그런 다음 어떤 에이전트 대화에서든 아래 명령을 사용할 수 있습니다.
|
||||
|
||||
- `/paseo-handoff` — 에이전트 간에 작업을 넘깁니다. Claude로 계획을 세운 뒤 Codex에 구현을 넘길 때 이 기능을 씁니다.
|
||||
- `/paseo-loop` — 명확한 완료 조건을 기준으로 에이전트를 반복 실행합니다(Ralph loop). 검증 에이전트를 추가할 수도 있습니다.
|
||||
- `/paseo-advisor` — 작업 자체를 넘기지 않고, 에이전트 하나를 조언자로 띄워 두 번째 의견을 받습니다.
|
||||
- `/paseo-committee` — 서로 다른 관점의 에이전트 두 개로 위원회를 구성해, 한 발 물러나 근본 원인을 분석하고 계획을 세웁니다.
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ Everything you can do in the app, you can do from the terminal.
|
||||
|
||||
```bash
|
||||
paseo run --provider claude/opus-4.6 "implement user authentication"
|
||||
paseo run --provider codex/gpt-5.4 --worktree feature-x "implement feature X"
|
||||
paseo run --provider codex/gpt-5.5 --worktree feature-x "implement feature X"
|
||||
|
||||
paseo ls # list running agents
|
||||
paseo attach abc123 # stream live output
|
||||
@@ -117,6 +117,30 @@ paseo --host workstation.local:6767 run "run the full test suite"
|
||||
|
||||
See the [full CLI reference](https://paseo.sh/docs/cli) for more.
|
||||
|
||||
## TypeScript SDK
|
||||
|
||||
Build issue integrations, dashboards, and orchestration services with `@getpaseo/client`:
|
||||
|
||||
```ts
|
||||
import { createPaseoClient } from "@getpaseo/client";
|
||||
|
||||
const client = createPaseoClient({ url: "ws://127.0.0.1:6767/ws" });
|
||||
await client.connect();
|
||||
|
||||
const agent = await client.agents.create({
|
||||
config: { provider: "codex/gpt-5.5" },
|
||||
cwd: "/Users/me/dev/storefront",
|
||||
prompt: "Review the current diff and name the riskiest change.",
|
||||
});
|
||||
|
||||
const result = await agent.waitForFinish();
|
||||
console.log(result.lastMessage);
|
||||
|
||||
await client.close();
|
||||
```
|
||||
|
||||
See the [SDK quickstart](https://paseo.sh/docs/sdk/quickstart), [recipes](https://paseo.sh/docs/sdk/recipes), and [API reference](https://paseo.sh/docs/sdk/reference).
|
||||
|
||||
## Skills
|
||||
|
||||
Skills teach your agent to use Paseo to orchestrate other agents.
|
||||
@@ -128,7 +152,6 @@ npx skills add getpaseo/paseo
|
||||
Then use them in any agent conversation:
|
||||
|
||||
- `/paseo-handoff` — hand off work between agents. I use this to plan with Claude and then handoff to Codex to implement.
|
||||
- `/paseo-loop` — loop an agent against clear acceptance criteria (aka Ralph loops), optionally with a verifier.
|
||||
- `/paseo-advisor` — spin up a single agent as an advisor for a second opinion, without delegating the work itself.
|
||||
- `/paseo-committee` — form a committee of two contrasting agents to step back, do root cause analysis, and produce a plan.
|
||||
|
||||
|
||||
@@ -118,7 +118,6 @@ npx skills add getpaseo/paseo
|
||||
然后在任意 agent 对话中使用:
|
||||
|
||||
- `/paseo-handoff` — 在 agents 之间交接工作。我会用它先和 Claude 规划,再交给 Codex 实现。
|
||||
- `/paseo-loop` — 让 agent 按明确验收标准循环工作(也叫 Ralph loops),也可以加 verifier。
|
||||
- `/paseo-advisor` — 启动单个 agent 作为 advisor,提供第二意见,但不把工作委托出去。
|
||||
- `/paseo-committee` — 组建两个风格互补的 agents,让它们后退一步做根因分析并产出计划。
|
||||
|
||||
|
||||
@@ -49,6 +49,9 @@ Runtime ownership is resolved from explicit workspace ID and caller context, nev
|
||||
Users can also detach an existing subagent from the subagents track. Detach is deliberately a manual lifecycle gesture, not an agent-facing MCP tool. It removes the `paseo.parent-agent-id` label only: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive.
|
||||
|
||||
`notifyOnFinish` defaults to `true` for agent-scoped creation and background prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts.
|
||||
Permission requests are notification checkpoints, not the end of that subscription. The caller is notified again after a permission response when the child finishes, errors, or requests another permission.
|
||||
The permission notification includes the normalized request plus the child and request IDs, so the caller can inspect it and respond without fetching agent status.
|
||||
A watched child that closes before its finish event also notifies the caller so delegated work cannot disappear silently during archive or workspace teardown.
|
||||
|
||||
## Provider-managed child agents
|
||||
|
||||
|
||||
@@ -76,8 +76,6 @@ not retain non-Git directories.
|
||||
| `server/agent/providers/` | Provider adapters (see "Agent providers" below) |
|
||||
| `server/relay-transport.ts` | Outbound relay connection with E2E encryption |
|
||||
| `server/schedule/` | Cron-based scheduled agents |
|
||||
| `server/loop-service.ts` | Looping agent runs that retry until an exit condition |
|
||||
| `server/chat/` | Chat rooms for agent-to-agent and human-to-agent messaging |
|
||||
|
||||
### `packages/protocol` — Wire schemas and shared protocol types
|
||||
|
||||
@@ -119,13 +117,11 @@ Commander.js CLI with Docker-style commands. Common agent operations are also ex
|
||||
|
||||
- `paseo agent ls/run/import/attach/logs/stop/delete/send/inspect/wait/archive/reload/update/mode`
|
||||
- `paseo daemon start/stop/restart/status/pair/set-password`
|
||||
- `paseo chat ls/create/inspect/post/read/wait/delete`
|
||||
- `paseo terminal ls/create/capture/send-keys/kill`
|
||||
- `paseo script ls/start/stop`
|
||||
- `paseo loop run/ls/inspect/logs/stop`
|
||||
- `paseo schedule create/ls/inspect/update/pause/resume/run-once/logs/delete`
|
||||
- `paseo heartbeat create/update/delete`
|
||||
- `paseo workspace create/ls/archive`
|
||||
- `paseo workspace create/ls/rename/archive`
|
||||
- `paseo permit allow/deny/ls`
|
||||
- `paseo provider ls/models`
|
||||
- hidden legacy `paseo worktree create/ls/archive` compatibility alias
|
||||
@@ -384,9 +380,7 @@ $PASEO_HOME/
|
||||
├── projects/projects.json # Project registry
|
||||
├── projects/workspaces.json # Workspace registry
|
||||
├── projects/icons/ # Custom project icon images
|
||||
├── chat/ # Chat rooms
|
||||
├── schedules/ # Scheduled-agent definitions and runs
|
||||
├── loops/ # Loop runs and logs
|
||||
├── config.json # Daemon config (mutable)
|
||||
├── daemon-keypair.json # Daemon identity for relay/E2EE
|
||||
├── push-tokens.json # Mobile push tokens
|
||||
|
||||
@@ -245,6 +245,8 @@ requires_openai_auth = false
|
||||
|
||||
You can create multiple entries that extend the same built-in provider. Each gets its own entry in the provider list with independent credentials, models, and environment.
|
||||
|
||||
"Profile" here means a provider alias, and it is not an **Agent profile** — that is a named bundle of provider, model, mode, thinking option and features, stored under `daemon.agentProfiles`. See [glossary.md](glossary.md) for all four senses of the word.
|
||||
|
||||
Example: two different Anthropic accounts as separate profiles:
|
||||
|
||||
```json
|
||||
|
||||
+23
-136
@@ -54,10 +54,6 @@ $PASEO_HOME/
|
||||
│ └── {agentId}.json # One file per agent
|
||||
├── schedules/
|
||||
│ └── {scheduleId}.json # One file per schedule
|
||||
├── chat/
|
||||
│ └── rooms.json # All rooms + messages
|
||||
├── loops/
|
||||
│ └── loops.json # All loop records
|
||||
├── projects/
|
||||
│ ├── projects.json # Project registry
|
||||
│ ├── workspaces.json # Workspace registry
|
||||
@@ -100,7 +96,7 @@ Each agent is stored as a separate JSON file, grouped by project directory.
|
||||
| `requiresAttention` | `boolean?` | Whether the agent needs user attention |
|
||||
| `attentionReason` | `"finished" \| "error" \| "permission"?` | Why attention is needed |
|
||||
| `attentionTimestamp` | `string?` (ISO 8601) | When attention was flagged |
|
||||
| `internal` | `boolean?` | Whether this is a system-internal agent (loop workers, etc.) |
|
||||
| `internal` | `boolean?` | Whether this is a system-internal agent |
|
||||
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
|
||||
|
||||
### Nested: SerializableConfig
|
||||
@@ -189,6 +185,8 @@ Single file, validated with `PersistedConfigSchema`.
|
||||
mcp: { enabled: boolean, injectIntoAgents: boolean },
|
||||
git: { maxProcessesPerSecond: number, maxProcessConcurrency: number },
|
||||
appendSystemPrompt: string, // appended to supported provider system/developer prompts
|
||||
terminalProfiles: TerminalProfile[], // named shell commands; omitted means DEFAULT_TERMINAL_PROFILES
|
||||
agentProfiles: AgentProfile[], // named agent launch bundles; omitted means none
|
||||
cors: { allowedOrigins: string[] },
|
||||
relay: { enabled: boolean, endpoint: string, publicEndpoint: string, useTls: boolean, publicUseTls: boolean }, // new homes materialize enabled: false
|
||||
auth: { password: string } // bcrypt hash, optional
|
||||
@@ -235,6 +233,21 @@ Single file, validated with `PersistedConfigSchema`.
|
||||
|
||||
All fields are optional with sensible defaults.
|
||||
|
||||
### Profile lists
|
||||
|
||||
`terminalProfiles` and `agentProfiles` are both whole-list fields: a config patch replaces the
|
||||
array, never merges entries, so a client sends the complete next list on every add, edit, reorder
|
||||
and remove. List order is the display order.
|
||||
|
||||
Absent and empty mean different things for terminal profiles — omitting the key falls back to
|
||||
`DEFAULT_TERMINAL_PROFILES`, while `[]` means the user removed them all. Agent profiles have no
|
||||
defaults, so both mean none.
|
||||
|
||||
`PersistedConfigSchema` parses strictly, so a daemon that predates a field drops it on write
|
||||
rather than storing something it cannot describe. That is why the client gates the agent profiles
|
||||
UI on `server_info.features.agentProfiles` instead of letting a save appear to succeed against an
|
||||
older daemon.
|
||||
|
||||
### Git process limits
|
||||
|
||||
Git process limits are global to one daemon. The start-rate limit defaults to `64` processes per
|
||||
@@ -341,7 +354,7 @@ One file per schedule. ID is 8 hex characters.
|
||||
### Nested: ScheduleTarget (discriminated union on `type`)
|
||||
|
||||
- `{ type: "agent", agentId: string }` — send to existing agent
|
||||
- `{ type: "new-agent", config: { provider, cwd, modeId?, model?, thinkingOptionId?, title?, approvalPolicy?, sandboxMode?, networkAccess?, webSearch?, extra?, systemPrompt?, mcpServers? } }` — create a new agent
|
||||
- `{ type: "new-agent", config: { provider, cwd, modeId?, model?, thinkingOptionId?, title?, providerOptions?, featureValues?, systemPrompt?, mcpServers? } }` — create a new agent
|
||||
|
||||
### Nested: ScheduleRun
|
||||
|
||||
@@ -358,133 +371,7 @@ One file per schedule. ID is 8 hex characters.
|
||||
|
||||
---
|
||||
|
||||
## 4. Chat
|
||||
|
||||
**Path:** `$PASEO_HOME/chat/rooms.json`
|
||||
|
||||
Single file containing all rooms and messages.
|
||||
|
||||
```json
|
||||
{
|
||||
"rooms": [ ... ],
|
||||
"messages": [ ... ]
|
||||
}
|
||||
```
|
||||
|
||||
### ChatRoom
|
||||
|
||||
| Field | Type | Description |
|
||||
| ----------- | ------------------- | ----------------------------------- |
|
||||
| `id` | `string` (UUID) | |
|
||||
| `name` | `string` | Unique room name (case-insensitive) |
|
||||
| `purpose` | `string?` | Room description |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
| `updatedAt` | `string` (ISO 8601) | Updated on each new message |
|
||||
|
||||
### ChatMessage
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------------ | ------------------- | ----------------------------------- |
|
||||
| `id` | `string` (UUID) | |
|
||||
| `roomId` | `string` | FK to ChatRoom.id |
|
||||
| `authorAgentId` | `string` | Agent ID of the author |
|
||||
| `body` | `string` | Message text (supports `@mentions`) |
|
||||
| `replyToMessageId` | `string?` | FK to another ChatMessage.id |
|
||||
| `mentionAgentIds` | `string[]` | Extracted `@mention` agent IDs |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
|
||||
---
|
||||
|
||||
## 5. Loop
|
||||
|
||||
**Path:** `$PASEO_HOME/loops/loops.json`
|
||||
|
||||
Single file containing an array of all loop records. Writes are direct (not atomic) and serialized through an in-memory queue. On daemon startup any record with `status: "running"` is recovered as `"stopped"` with an interruption log entry.
|
||||
|
||||
| Field | Type | Description |
|
||||
| ----------------------- | --------------------------------------------------- | ------------------------------------------ |
|
||||
| `id` | `string` | 8-char UUID prefix |
|
||||
| `name` | `string?` | Human-readable name |
|
||||
| `prompt` | `string` | Worker prompt |
|
||||
| `cwd` | `string` | Working directory |
|
||||
| `provider` | `string` | Default provider |
|
||||
| `model` | `string?` | Default model |
|
||||
| `modeId` | `string?` | Default mode ID |
|
||||
| `workerProvider` | `string?` | Override provider for workers |
|
||||
| `workerModel` | `string?` | Override model for workers |
|
||||
| `verifierProvider` | `string?` | Override provider for verifiers |
|
||||
| `verifierModel` | `string?` | Override model for verifiers |
|
||||
| `verifierModeId` | `string?` | Override mode ID for verifiers |
|
||||
| `verifyPrompt` | `string?` | LLM verification prompt |
|
||||
| `verifyChecks` | `string[]` | Shell commands to run as checks |
|
||||
| `archive` | `boolean` | Whether to archive worker agents after use |
|
||||
| `sleepMs` | `number` | Delay between iterations (ms) |
|
||||
| `maxIterations` | `number?` | Cap on iterations |
|
||||
| `maxTimeMs` | `number?` | Total time budget (ms) |
|
||||
| `status` | `"running" \| "succeeded" \| "failed" \| "stopped"` | |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
| `updatedAt` | `string` (ISO 8601) | |
|
||||
| `startedAt` | `string` (ISO 8601) | |
|
||||
| `completedAt` | `string?` (ISO 8601) | |
|
||||
| `stopRequestedAt` | `string?` (ISO 8601) | |
|
||||
| `iterations` | `LoopIteration[]` | |
|
||||
| `logs` | `LoopLogEntry[]` | |
|
||||
| `nextLogSeq` | `number` | Monotonic log sequence counter |
|
||||
| `activeIteration` | `number?` | Currently executing iteration index |
|
||||
| `activeWorkerAgentId` | `string?` | Currently running worker agent |
|
||||
| `activeVerifierAgentId` | `string?` | Currently running verifier agent |
|
||||
|
||||
### Nested: LoopIteration
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------------- | --------------------------------------------------- | ------------------------ |
|
||||
| `index` | `number` | 1-based iteration index |
|
||||
| `workerAgentId` | `string?` | Agent ID of the worker |
|
||||
| `workerStartedAt` | `string` (ISO 8601) | |
|
||||
| `workerCompletedAt` | `string?` (ISO 8601) | |
|
||||
| `verifierAgentId` | `string?` | Agent ID of the verifier |
|
||||
| `status` | `"running" \| "succeeded" \| "failed" \| "stopped"` | |
|
||||
| `workerOutcome` | `"completed" \| "failed" \| "canceled"?` | |
|
||||
| `failureReason` | `string?` | |
|
||||
| `verifyChecks` | `LoopVerifyCheckResult[]` | Shell check results |
|
||||
| `verifyPrompt` | `LoopVerifyPromptResult?` | LLM verification result |
|
||||
|
||||
### Nested: LoopLogEntry
|
||||
|
||||
| Field | Type |
|
||||
| ----------- | ---------------------------------------------------- |
|
||||
| `seq` | `number` (monotonic) |
|
||||
| `timestamp` | `string` (ISO 8601) |
|
||||
| `iteration` | `number?` |
|
||||
| `source` | `"loop" \| "worker" \| "verifier" \| "verify-check"` |
|
||||
| `level` | `"info" \| "error"` |
|
||||
| `text` | `string` |
|
||||
|
||||
### Nested: LoopVerifyCheckResult
|
||||
|
||||
| Field | Type |
|
||||
| ------------- | ------------------- |
|
||||
| `command` | `string` |
|
||||
| `exitCode` | `number` |
|
||||
| `passed` | `boolean` |
|
||||
| `stdout` | `string` |
|
||||
| `stderr` | `string` |
|
||||
| `startedAt` | `string` (ISO 8601) |
|
||||
| `completedAt` | `string` (ISO 8601) |
|
||||
|
||||
### Nested: LoopVerifyPromptResult
|
||||
|
||||
| Field | Type |
|
||||
| ----------------- | ------------------- |
|
||||
| `passed` | `boolean` |
|
||||
| `reason` | `string` |
|
||||
| `verifierAgentId` | `string?` |
|
||||
| `startedAt` | `string` (ISO 8601) |
|
||||
| `completedAt` | `string` (ISO 8601) |
|
||||
|
||||
---
|
||||
|
||||
## 6. Project Registry
|
||||
## 4. Project Registry
|
||||
|
||||
**Path:** `$PASEO_HOME/projects/projects.json`
|
||||
|
||||
@@ -519,7 +406,7 @@ workspace together with its owning project.
|
||||
|
||||
---
|
||||
|
||||
## 7. Workspace Registry
|
||||
## 5. Workspace Registry
|
||||
|
||||
**Path:** `$PASEO_HOME/projects/workspaces.json`
|
||||
|
||||
@@ -553,7 +440,7 @@ than treating it as valid.
|
||||
|
||||
---
|
||||
|
||||
## 8. Push Token Store
|
||||
## 6. Push Token Store
|
||||
|
||||
**Path:** `$PASEO_HOME/push-tokens.json`
|
||||
|
||||
@@ -567,7 +454,7 @@ Simple set of Expo push notification tokens. Loaded with permissive parsing (fil
|
||||
|
||||
---
|
||||
|
||||
## 9. Daemon meta files
|
||||
## 7. Daemon meta files
|
||||
|
||||
These small files are not validated as full Zod schemas but are persisted under `$PASEO_HOME` for daemon identity and runtime coordination.
|
||||
|
||||
|
||||
+1
-1
@@ -223,7 +223,7 @@ Selected state on rows in a desktop list+detail uses `surfaceSidebarHover` as th
|
||||
|
||||
There is exactly one token per status signal — `statusSuccess`, `statusDanger`, `statusWarning`, `statusMerged` — and every status surface uses it: PR state icons, CI check icons and pies, diff stats, file-change icons, status pills, usage bars. A surface does not get a quieter or louder variant because of where it sits. If a dense list feels loud, that is a density or weight problem; fix the density, not the color. The tokens are generated, not hand-picked — see the rule in `packages/app/src/styles/theme.ts` and regenerate rather than nudging one value. The level is set by the densest consumer, the sidebar workspace list.
|
||||
|
||||
Status **dots** are the one exception, and they are a family of their own — `statusDotSuccess`, `statusDotDanger`, `statusDotWarning`, `statusDotRunning`, read only by `getStatusDotColor` (`packages/app/src/utils/status-dot-color.ts`). Same hues and the same generation rule, but their own band: pushed away from the surface rather than toward it (darker in light, lighter in dark) and at 90% of gamut chroma against the status family's 55–60%. A dot is a few points of solid color with no shape to read and no label attached, and the running one pulses, so at the status band's lightness the dots read dimmer than the metadata beside them — backwards, since the dot is the row's state. All four move together; regenerate the set, never one hue.
|
||||
Status **dots** are the one exception, and they are a family of their own — `statusDotSuccess`, `statusDotDanger`, `statusDotWarning`, `statusDotRunning`, read only by `getStatusDotColor` (`packages/app/src/utils/status-dot-color.ts`). Same hues and the same generation rule, but their own band: 90% of gamut chroma against the status family's 55–60%. A dot is a few points of solid color with no shape to read and no label attached, and the running one pulses, so at the status band's chroma the dots read dimmer than the metadata beside them — backwards, since the dot is the row's state. Lightness is set by hue separation rather than by distance from the surface: at 6pt four dark hues on a light surface all read as one dark blob no matter how much contrast they have. So the light band runs as bright as the contrast floor allows at L=0.62, the last step where all four clear 3:1 against the sidebar's `surface2`; the dark band sits at L=0.72, where danger turns pink above. All four move together; regenerate the set, never one hue.
|
||||
|
||||
Status pills are the status token as foreground on a 10%-alpha tint of the same token (`${token}1a`), with a 20% border (`${token}33`). The `<StatusBadge>` primitive (`packages/app/src/components/ui/status-badge.tsx`) is canonical; a pill never reaches into `palette`.
|
||||
|
||||
|
||||
+16
-8
@@ -23,6 +23,10 @@ Desktop dev launches its desktop-managed daemon with `PASEO_NODE_ENV=development
|
||||
so development-only providers such as Mock Load Test are available. Packaged
|
||||
desktop launches always force the daemon to production mode.
|
||||
|
||||
The web and desktop dev launchers pass the current Git branch to Metro as
|
||||
`EXPO_PUBLIC_PASEO_DEV_BUILD_LABEL`. The expanded desktop sidebar shows it in
|
||||
the titlebar row. Production builds leave the variable unset and show no label.
|
||||
|
||||
`npm run dev` is only a shorthand for `npm run dev:server`. Keep `127.0.0.1:6767` for the packaged app and production-style `~/.paseo` state.
|
||||
|
||||
## Nix desktop package
|
||||
@@ -127,9 +131,11 @@ desktop-only environment inherited by terminals opened inside Paseo from couplin
|
||||
a new worktree instance to the parent desktop instance's profile or single-instance
|
||||
lock.
|
||||
|
||||
Electron remains in the desktop dev runner's process group. Closing or stopping the
|
||||
workspace-script terminal must terminate Electron with Metro; detaching Electron
|
||||
leaves an orphan holding the worktree's single-instance lock and broken output pipes.
|
||||
The desktop workspace script `exec`s the dev runner so the terminal owns the runner
|
||||
PID. Terminal shutdown reaches the runner as `SIGHUP`; the runner stops Metro and
|
||||
asks Electron to quit through its normal app lifecycle. Do not add an npm wrapper or
|
||||
detach Electron: either change leaves an orphan holding the worktree's single-instance
|
||||
lock and broken output pipes.
|
||||
|
||||
With desktop dev running, verify the real BrowserWindow, titlebar clearance, fullscreen
|
||||
transition, and 751-pixel settings split with:
|
||||
@@ -291,7 +297,9 @@ another remote fails closed until the worktree records an explicit local target.
|
||||
the optional field and retain the previous local-first behavior; older worktree metadata without the
|
||||
exact ref also resolves through its stored branch name.
|
||||
|
||||
Worktrees inherit committed Git state only; uncommitted source-checkout changes are not copied.
|
||||
Worktrees inherit committed Git state. Before lifecycle setup, Paseo copies the source checkout's
|
||||
`paseo.json` over the worktree copy so saved Project Settings apply without a commit. Other
|
||||
uncommitted source-checkout changes are not copied.
|
||||
|
||||
## paseo.json service scripts
|
||||
|
||||
@@ -302,9 +310,9 @@ Lifecycle commands run in the worktree through a stable script shell: `bash`
|
||||
resolved from `PATH` on macOS/Linux, and PowerShell with `-NoProfile` on
|
||||
Windows. They inherit the daemon environment plus Paseo's lifecycle variables;
|
||||
login and interactive shell startup files are not loaded, and Bash's `BASH_ENV`
|
||||
hook is unset. Daemon-run loop verify checks and ACP single-string terminal
|
||||
commands use the same non-login Bash behavior on macOS/Linux, but preserve their
|
||||
existing `cmd.exe /c` string semantics on Windows. Service scripts are separate:
|
||||
hook is unset. ACP single-string terminal commands use the same non-login Bash
|
||||
behavior on macOS/Linux, but preserve their existing `cmd.exe /c` string semantics
|
||||
on Windows. Service scripts are separate:
|
||||
they launch in a terminal and receive the service environment described below.
|
||||
|
||||
Because the shell differs per platform, a lifecycle command that must run
|
||||
@@ -456,7 +464,7 @@ install.
|
||||
|
||||
Use `npm run cli` to run the in-repo CLI from source (`npx tsx packages/cli/src/index.ts`). The script wraps the CLI with `scripts/dev-home.sh`, so it automatically uses this checkout's `.dev/paseo-home` and dev daemon endpoint unless you pass an explicit override. The globally installed `paseo` binary on macOS is a symlink into the installed Paseo desktop app, not this checkout — use it to drive the desktop's built-in daemon, but use `npm run cli` when you want to talk to the CLI you are editing.
|
||||
|
||||
Canonical automation uses `paseo workspace create/ls/archive`, `paseo heartbeat create/update/delete`, and the full `paseo schedule` group. MCP heartbeat automation is intentionally smaller: create and delete only. Detach remains an explicit user lifecycle action rather than an agent tool. `paseo run --new-workspace local|worktree` composes workspace creation with agent creation. The old `paseo worktree` and `paseo run --worktree` forms are hidden compatibility aliases.
|
||||
Canonical automation uses `paseo workspace create/ls/rename/archive`, `paseo heartbeat create/update/delete`, and the full `paseo schedule` group. MCP heartbeat automation is intentionally smaller: create and delete only. Detach remains an explicit user lifecycle action rather than an agent tool. `paseo run --new-workspace local|worktree` composes workspace creation with agent creation. The old `paseo worktree` and `paseo run --worktree` forms are hidden compatibility aliases.
|
||||
|
||||
```bash
|
||||
npm run cli -- ls -a -g # List all agents globally
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# File observation
|
||||
|
||||
Use `packages/server/src/server/file-observer` for recursive filesystem observation. Create one observer service at the owning service boundary, subscribe roots through it, and close it during owner shutdown. Keep platform watchers, directory discovery, exclusion reconciliation, event batching, failure cleanup, diagnostics, and teardown inside the module. A consumer supplies a root and excluded subtrees; it must not branch on the platform or manage child watchers.
|
||||
|
||||
Do not replace the Linux implementation with `fs.watch({ recursive: true })`. Node 22 implements Linux recursion in JavaScript by walking the tree and watching every file and directory. Paseo's directory-only Linux watcher already exhausted resources before ignored-tree pruning and a watcher cap were added in [#794](https://github.com/getpaseo/paseo/pull/794).
|
||||
|
||||
Closing an observation must remain safe after its root is renamed or removed. Workspace archive removes owned worktrees while releasing observation references, so teardown cannot assume the watched path still exists.
|
||||
|
||||
`unsubscribe()` is an awaited barrier. Once it resolves, no scan, reconciliation, queued event, native handle, or callback may remain. `updateIgnore()` is the equivalent barrier for excluded paths: events queued below a newly excluded root cannot be delivered after it resolves. Treat path disappearance while scanning, attaching, or receiving a watcher error as rename/archive churn. Surface permission and resource errors so WorkspaceGit can enter bounded polling.
|
||||
|
||||
Linux topology repair is trailing-debounced and scoped to the renamed path. A file rename needs one classification stat; a new or moved directory scans only its subtree. Full-tree scans are reserved for startup and ignore-set replacement. The initial Linux scan conservatively emits existing files discovered before their directory watcher was installed; WorkspaceGit batches these into one refresh. Benchmark this startup cost with pre-populated trees.
|
||||
|
||||
Windows and macOS use one native recursive watcher plus an ignored-pruned, directory-indexed inventory. Native events deliver immediately. Named events trigger serialized shallow scans of their parent directories; known directory events also reconcile that subtree. This recovers concrete create and delete paths omitted by coalescing without walking the whole root for each edit. Startup and exclusion changes use an awaited full inventory barrier. Activity arms a full safety audit 30 seconds after it settles, with a 30-second minimum interval and a five-minute starvation bound; pathless native events also trigger an immediate shallow root scan. Observation fails into polling rather than retaining more than 250,000 file and directory entries per root.
|
||||
|
||||
Read aggregate health from the owning observer service. Runtime metrics include active observations, native handles, tracked native files, pending events and immediate reconciliation work, scoped and full reconciliation activity and latency, and failure counts. Delayed safety audits do not count as pending work. Closing the service releases every subscription and clears its diagnostics. Do not add path lists, watcher handles, or platform-specific controls to this interface.
|
||||
|
||||
Git owns Git-ignore evaluation. The observer accepts absolute excluded roots and applies updates without replacing the observation or exposing its watcher topology. This keeps tracked files inside otherwise ignored directories observable and keeps Git policy out of the filesystem module.
|
||||
|
||||
The real-filesystem contracts and daemon auto-archive lifecycle run in the normal server test suite. Use the scripts only for manual performance and soak work: `npm run measure:file-observer --workspace=@getpaseo/server` measures burst and sustained-create behavior, and `npm run repro:file-observer-teardown --workspace=@getpaseo/server` runs the teardown soak.
|
||||
+4
-2
@@ -24,14 +24,16 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.
|
||||
- **Agent session** — One running instance of an agent inside a workspace (one provider, one model, one cwd, one timeline). The conceptual unit; in the UI this opens as a tab. Moving toward this as the canonical term over "Agent". Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`).
|
||||
- **Session** — Two senses: (a) per-client connection to a daemon, internal; (b) user-facing agent session, see **Agent session**. Code: `Session` (`packages/server/src/server/session.ts`) for (a). Don't confuse with: provider-side agent session log.
|
||||
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing.
|
||||
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing. Three other things are called "profile" in this codebase, so always qualify: **Agent profile**, **Terminal profile**, and the provider aliases in [custom-providers.md](custom-providers.md). Bare "profile" is forbidden in UI copy.
|
||||
- **Agent profile** — A named launch bundle: provider, model, mode, thinking option, feature values, and free-text notes. Applying one writes those values into the **Agent controls** and is then forgotten — there is no selected profile, no default profile, and no drift tracking. The notes are written for orchestrating agents and are what the `list_profiles` MCP tool surfaces. Stored host-wide in daemon config as `agentProfiles`. UI: "Agent profiles". Code: `AgentProfile` (`packages/protocol/src/messages.ts:161`). Carries no system prompt, because a per-agent system prompt can only be set at creation. Forbidden: "Favourite", which this replaced.
|
||||
- **Terminal profile** — A named shell command a terminal can launch. Stored host-wide in daemon config as `terminalProfiles`. UI: "Terminal profiles". Code: `TerminalProfile` (`packages/protocol/src/messages.ts:135`), `resolveTerminalProfiles` (`packages/protocol/src/terminal-profiles.ts`). Also selectable as a New Workspace launch target, where the alternative is the chat agent.
|
||||
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, Oh My Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
|
||||
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
|
||||
- **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`).
|
||||
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`).
|
||||
- **Workspace script** — A named command a workspace can run, declared in `paseo.json`. Two types: `service` and `script` (a command that runs and finishes). UI: "Scripts" for the panel and the menu, both of which list either type. Code: `WorkspaceScriptPayload` (`packages/protocol/src/messages.ts:3126`). Don't confuse with **Service**, which is one of the two types rather than a synonym for the pair.
|
||||
- **Service** — A workspace script of type `service`: long-running, may bind a port, and carries `health` of `healthy | unhealthy | null` once the daemon has observed it. The only type a sidebar workspace row reports, because a running plain script says nothing about whether the workspace is up; the row names the service and turns danger when its health fails, and where several run the failing one takes the row's single slot. UI: "Services" in the sidebar's display preferences (**Show → Services**). Code: `selectWorkspaceServiceSummary` (`packages/app/src/components/sidebar/workspace-meta-row/service-summary.ts`), `WorkspaceScriptHealthSchema` (`packages/protocol/src/messages.ts:3124`). Forbidden: "Service" as a label for the Scripts panel, which lists both types.
|
||||
- **Schedule** — Cron-style trigger that creates new agents. UI: CLI/MCP (`paseo schedule`, `create_schedule`). Don't confuse with: Heartbeat (cron prompt back into the same agent) or Loop (iterative re-execution of one agent).
|
||||
- **Schedule** — Cron-style trigger that creates new agents. UI: CLI/MCP (`paseo schedule`, `create_schedule`). Don't confuse with: Heartbeat (cron prompt back into the same agent).
|
||||
- **Heartbeat** — Ephemeral cron prompt sent back into the same agent/conversation. Agent surfaces expose create, update cron, and delete only. Use for reminders and babysitting where status should return inline.
|
||||
- **Mode** — Provider-specific operational mode (plan, default, full-access, …). UI: icon-only. Code: `modeId` in `AgentSessionConfig` (`packages/protocol/src/messages.ts:257`).
|
||||
- **Attachment** — External or local context bound to an agent prompt: forge issue/change request, review context, uploaded file, text, or image. UI: "Attach issue or PR/MR". Code: `AgentAttachment` (`packages/protocol/src/messages.ts:782`).
|
||||
|
||||
+36
-12
@@ -2,7 +2,9 @@
|
||||
|
||||
Paseo Hub is an explicit opt-in connection from one Paseo daemon to one Hub. Running a daemon does
|
||||
not register it with a Hub. The relationship begins only when a user runs
|
||||
`paseo hub connect <url> --token <token>` from the daemon machine.
|
||||
`paseo hub connect [url]` from the daemon machine with an explicit API key or matching stored CLI login.
|
||||
|
||||
The human CLI login and daemon relationship are separate identities. `paseo hub login [url]` stores a durable organization-scoped CLI credential keyed by normalized Hub origin under `PASEO_HOME`. Origin resolution uses explicit command input, `PASEO_HUB_URL`, active login, then `https://hub.paseo.sh`. Connect uses exact-origin authority to request a one-time enrollment token, then passes only that token to the daemon. The daemon generates and persists its own relationship credential.
|
||||
|
||||
## Connection and authority
|
||||
|
||||
@@ -44,12 +46,30 @@ daemon, execution, and agent identity with that terminal state. Paseo never stor
|
||||
replays the original prompt. A duplicate create returns the existing agent without starting another
|
||||
turn.
|
||||
|
||||
Hub creates use the same agent creation path as trusted clients. They may select any existing
|
||||
worktree target shape and carry optional MCP server configuration for the agent session. The daemon
|
||||
keeps that configuration in its private agent record so provider sessions can recover after a
|
||||
restart; neither ordinary client snapshots and updates nor Hub projections expose session
|
||||
configuration. Execution completion policy remains outside the daemon: a completed agent turn does
|
||||
not imply that the Hub execution is terminal.
|
||||
Every Hub execution creates a fresh Paseo workspace. The workspace owns the execution's agents and
|
||||
terminals. Local checkout and worktree targets select only the workspace backing and isolation; the
|
||||
Hub cannot select or reuse an existing workspace. Hub creates use the same agent creation path as
|
||||
trusted clients. They may select any worktree target shape and carry optional MCP server configuration and provider-native
|
||||
`providerOptions` for the agent session. The daemon keeps that configuration in its private agent
|
||||
record so provider sessions can recover after a restart; neither ordinary client snapshots and
|
||||
updates nor Hub projections expose session configuration. See [providers.md](providers.md) for the
|
||||
supported provider keys.
|
||||
|
||||
Hub tool preapproval is a private, structured list of `{ kind: "mcp", server, tool }` references.
|
||||
Every reference must name an MCP server injected by the same create request. The daemon translates
|
||||
only those identities into the selected provider's native approval configuration. The protocol
|
||||
cannot name or preapprove native tools such as Bash, Edit, or Write. Explicit local or managed ask
|
||||
and deny policy takes precedence. Providers without exact MCP preapproval support reject unattended
|
||||
Hub creation instead of broadening access or waiting for an invisible prompt.
|
||||
When a create request includes a tool policy, a successful response includes
|
||||
`toolPolicyApplied: true`; absence of that acknowledgement is not success for unattended execution.
|
||||
|
||||
A Hub workflow may use read-only provider settings for classifier steps. This is defense in depth,
|
||||
not a security boundary: classifier labels and prompt intent do not authorize tools. Exact MCP grants
|
||||
and the provider's local or managed policy remain the authorization controls.
|
||||
|
||||
Execution completion policy remains outside the daemon: a completed agent turn does not imply that
|
||||
the Hub execution is terminal.
|
||||
|
||||
The Hub ends an execution by sending `hub.execution.control.request` with the durable execution ID
|
||||
and either `interrupt` or `archive`. The daemon resolves the agent from the authenticated daemon
|
||||
@@ -59,11 +79,11 @@ If no execution exists for that authenticated daemon and execution ID, interrupt
|
||||
success because the requested stopped or archived state already holds. An execution owned by another
|
||||
daemon is indistinguishable from a missing execution and is never exposed or affected.
|
||||
|
||||
Interrupt uses the ordinary agent cancellation lifecycle. Archive first archives the owned agent.
|
||||
When that agent belongs to an active Paseo-owned worktree workspace, the daemon also archives the
|
||||
workspace through the shared workspace archive service, so the backing directory is removed only
|
||||
after its final active workspace reference disappears. Local and shared checkouts archive only the
|
||||
execution-owned agent.
|
||||
Interrupt uses the ordinary agent cancellation lifecycle. Archive resolves the execution agent's
|
||||
required workspaceId and sends it through the shared workspace archive service. The service archives
|
||||
that workspace's agents and terminals, then removes Paseo-owned backing directories only after their
|
||||
final active workspace reference disappears. Local checkouts remain on disk; sibling workspaces
|
||||
sharing a backing directory remain active.
|
||||
|
||||
## Disconnect and revocation
|
||||
|
||||
@@ -81,9 +101,13 @@ opening a Hub socket. This also covers an enrollment whose request may have succ
|
||||
response was lost. `--force` removes local authority immediately and warns that remote revocation may
|
||||
still be pending.
|
||||
|
||||
`paseo hub logout` removes only the active human CLI credential and preserves credentials for other origins. Interactive logout inspects and optionally disconnects a same-origin daemon before deleting the login; a failed requested disconnect preserves the login. JSON and noninteractive logout never prompt or disconnect implicitly.
|
||||
|
||||
## Cross-repository compatibility
|
||||
|
||||
The consumer implementation lives in Paseo Cloud. Cloud owns its copy of the Hub wire schemas and
|
||||
has no Paseo runtime or build dependency. Cross-repository end-to-end verification separately builds
|
||||
a Paseo source checkout and exercises the real daemon, CLI, direct WebSocket, Cloud service, and
|
||||
Postgres. That compatibility fixture is not a package dependency or fallback implementation.
|
||||
Its `hub-e2e` ACP provider accepts only exact tool names on the injected `hub` MCP server. Other
|
||||
custom ACP providers remain unsupported for unattended preapproval.
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ The development workflow is shifting from manually editing files to orchestratin
|
||||
|
||||
Freedom and flexibility. Every design decision follows from this:
|
||||
|
||||
- **Multi-provider** — Use any coding agent harness. Pick the right model for each job, switch freely as the landscape shifts. No vendor-lock in.
|
||||
- **Multi-provider** — Use any coding agent harness. Pick the right model for each job, switch freely as the landscape shifts. No vendor lock-in.
|
||||
- **Cross-device** — Desktop, mobile, web, CLI. Start work at your desk, check progress from your phone, script from the terminal.
|
||||
- **Self-hosted** — The daemon runs on your machine. Your code, your keys, your environment. No inference markup, no cloud dependency.
|
||||
- **Respectful** - No telemetry, no forced cloud, no forced accounts
|
||||
|
||||
@@ -2,6 +2,41 @@
|
||||
|
||||
This guide walks through adding a new agent provider end-to-end. There are two integration patterns, and this doc covers both.
|
||||
|
||||
## Provider-native session options
|
||||
|
||||
`AgentSessionConfig.providerOptions` carries JSON-safe configuration for the selected provider. The
|
||||
names and nesting are the provider's native contract; options are not portable between providers.
|
||||
Paseo validates the object with the selected provider's strict schema before constructing a session.
|
||||
Unknown keys fail with their `providerOptions.*` path. Paseo-owned controls such as cwd, model,
|
||||
prompt, environment, session identity, MCP transport, callbacks, and hooks cannot be passed here.
|
||||
|
||||
This Paseo version accepts these keys:
|
||||
|
||||
- **Codex:** `approval_policy`, `sandbox_mode`,
|
||||
`sandbox_workspace_write.{writable_roots,network_access,exclude_slash_tmp,exclude_tmpdir_env_var}`,
|
||||
`web_search`, `features.multi_agent_v2`, and `features.network_proxy`. A network proxy object may
|
||||
contain `enabled`, `proxy_url`, `socks_url`, `enable_socks5`, `enable_socks5_udp`,
|
||||
`allow_local_binding`, `allow_upstream_proxy`, `dangerously_allow_all_unix_sockets`,
|
||||
`dangerously_allow_non_loopback_proxy`, `domains`, and `unix_sockets`. See the
|
||||
[Codex configuration reference](https://developers.openai.com/codex/config-reference).
|
||||
- **Claude:** `allowedTools`, `disallowedTools`, `additionalDirectories`, `sandbox`, and `settings`.
|
||||
The accepted sandbox fields cover enablement, fail-if-unavailable behavior, excluded and
|
||||
unsandboxed commands, filesystem read/write rules, network domain/socket/local-binding rules,
|
||||
weaker nested sandboxing, ignored violations, and the ripgrep command. `settings` accepts native
|
||||
`permissions.{allow,ask,deny}` and sandbox settings. See the
|
||||
[Claude Agent SDK TypeScript reference](https://platform.claude.com/docs/en/agent-sdk/typescript)
|
||||
and [Claude settings reference](https://code.claude.com/docs/en/settings).
|
||||
- **OpenCode:** `permission`, either one `ask`/`allow`/`deny` action or the native per-tool rule
|
||||
object. Supported entries are `read`, `edit`, `glob`, `grep`, `list`, `bash`, `task`,
|
||||
`external_directory`, `todowrite`, `question`, `webfetch`, `websearch`, `codesearch`,
|
||||
`repo_clone`, `repo_overview`, `lsp`, `doom_loop`, and `skill`. See the
|
||||
[OpenCode permissions reference](https://opencode.ai/docs/permissions/). OpenCode permissions are
|
||||
application policy, not an OS sandbox.
|
||||
|
||||
Each provider definition owns its option schema and exact MCP preapproval mapping. A new provider
|
||||
must fail closed for Hub unattended execution until it can approve one exact injected MCP server
|
||||
and tool identity without approving native tools.
|
||||
|
||||
## Two Integration Patterns
|
||||
|
||||
### ACP (Agent Client Protocol) -- recommended
|
||||
@@ -50,6 +85,8 @@ Rewind accepts the canonical wire `messageId` and resolves it to the provider id
|
||||
|
||||
Submitted user-message wire items carry the same Paseo ID in `messageId` and `clientMessageId`. Provider adapters attach `clientMessageId` only to the echo for that foreground submission; provider history and externally initiated user rows do not have a Paseo client ID.
|
||||
|
||||
Provider adapters must terminalize every transient timeline row before emitting the turn's terminal event. Codex may omit the completed `contextCompaction` item when a turn ends during compaction, so its adapter closes any pending root compaction before forwarding `turn_completed`, `turn_failed`, or `turn_canceled`. A terminal turn must never leave the client showing an operation as still loading.
|
||||
|
||||
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.fetchCatalog`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. `fetchCatalog` is the single discovery API for models and modes — provider implementations may use one process, separate upstream calls, or static data internally, but callers outside the provider do not get separate runtime model/mode probes. Draft command listing and scratch-session feature listing require an explicit draft model. Do not resolve a default model through catalog discovery. A client-level `listFeatures` implementation may return features from an incomplete, model-less draft and owns which features are valid in that state.
|
||||
|
||||
Provider session import has its own contract. The picker calls `listImportableSessions` and receives rows only: provider handle, cwd, title, prompt previews, and last activity. Import calls `importSession({ providerHandleId, cwd })` for the selected row and must not call listing again. The provider returns the resumed session, storage config, persistence handle, and hydrated timeline for that one native session; `AgentManager.importProviderSession` seeds the daemon timeline and publishes the Paseo agent only after it is ready.
|
||||
|
||||
@@ -19,23 +19,22 @@ The first carves shipped as **deep modules with a narrow Host seam**, not the `d
|
||||
|
||||
- **#1640 — VoiceSession** (`session/voice/voice-session.ts`, seam `VoiceSessionHost`): the STT/TTS/dictation/turn-detection subsystem. _(Originally landed at `server/voice/`; relocated under `session/` so all session subsystems share one home.)_
|
||||
- **#1644 — CheckoutSession, read side** (`session/checkout/checkout-session.ts`, seam `CheckoutSessionHost`, port `CheckoutDiffSubscriber`): status, branch validate/suggest, diff subscribe/unsubscribe, manual refresh. The workspace-git observer already delegates `emitStatusUpdate`/`scheduleDiffRefresh` to it.
|
||||
- **ScheduleSession** (`session/schedule/schedule-session.ts`, seam `ScheduleSessionHost`): scheduled-agent request handling.
|
||||
|
||||
**Next carve — CheckoutSession mutation side (Slice 4 below).** The 17 checkout _write_ handlers still inline in `session.ts` (`dispatchCheckoutMessage`, ~2010) — branch switch/rename, commit, merge, merge-from-base, pull, push, PR create/merge, github set-auto-merge/get-check-details, PR status, PR timeline, github search, stash save/pop/list — move into the existing `session/checkout/checkout-session.ts` behind `CheckoutSessionHost`. The Slice-3 observer entanglement the table fears is already resolved: #1644 moved the status/diff read side into CheckoutSession, so the workspace observer delegates today and this no longer blocks on the WorkspaceController split.
|
||||
|
||||
### Why this is safe at the dispatch seam (verified)
|
||||
|
||||
`dispatchInboundMessage` builds `a() ?? b() ?? ... ?? dispatchMiscMessage()` and short-circuits on the first non-`undefined` **Promise object** (not its resolved value). Message-type spaces are **disjoint** (no duplicate `case` labels across switches), so at most one dispatcher matches any message — collapsing to delegation cannot change which handler runs. `dispatchTerminalMessage` (2150-2153) already proves this. Two quirks preserved verbatim: schedule/\* is reached via the chat dispatcher's OWN `default` arm (2183), not the top-level `??`; and `start_workspace_script_request` (a workspace type) is special-cased before terminal delegation (2150).
|
||||
`dispatchInboundMessage` builds `a() ?? b() ?? ... ?? dispatchMiscMessage()` and short-circuits on the first non-`undefined` **Promise object** (not its resolved value). Message-type spaces are **disjoint** (no duplicate `case` labels across switches), so at most one dispatcher matches any message — collapsing to delegation cannot change which handler runs. `dispatchTerminalMessage` (2150-2153) already proves this. `start_workspace_script_request` (a workspace type) is special-cased before terminal delegation (2150).
|
||||
|
||||
Rejected alternatives: **feature-module** (free functions + wide context bag) cannot own the live state machines (workspaceUpdatesSubscription, agentUpdatesSubscription, ~25 voice fields) and adds a competing idiom; **mixin-composition** preserves the shared-`this` god object verbatim and requires widening ~325 private fields to protected.
|
||||
|
||||
## Slice ordering (least-coupled first)
|
||||
|
||||
The task recommended **git/checkout as the first slice — OVERRIDDEN.** Verification: `emitCheckoutStatusUpdate` is called from exactly ONE site (session.ts:4915), inside the workspace-owned `syncWorkspaceGitObserver` callback that ALSO fires workspace effects over shared watch-target maps. Extracting checkout first forces splitting the hardest workspace/git seam before workspace is touched. The strictly safer first cuts are **chat-schedule-loop** (only knot: `handleChatPostRequest`; touches no shared observer/git/voice state) and **provider-catalog** (one shared collaborator + injected predicates).
|
||||
The task recommended **git/checkout as the first slice — OVERRIDDEN.** Verification: `emitCheckoutStatusUpdate` is called from exactly ONE site (session.ts:4915), inside the workspace-owned `syncWorkspaceGitObserver` callback that ALSO fires workspace effects over shared watch-target maps. Extracting checkout first forces splitting the hardest workspace/git seam before workspace is touched. The safer first cut is **provider-catalog** (one shared collaborator + injected predicates).
|
||||
|
||||
| # | Slice | Effort | Risk |
|
||||
| --- | ----------------------------------------------------------------------------- | ------ | ------ |
|
||||
| 0 | Test net + disjointness tripwire (no extraction) | M | low |
|
||||
| 1 | ChatScheduleLoopController — **STOP FOR REVIEW after green** | M | low |
|
||||
| 2 | ProviderCatalogController | M | medium |
|
||||
| 3 | Split shared workspace-git observer + agent-subscribe fan-out (no controller) | M | high |
|
||||
| 4 | GitCheckoutController | L | medium |
|
||||
@@ -47,34 +46,6 @@ The task recommended **git/checkout as the first slice — OVERRIDDEN.** Verific
|
||||
|
||||
---
|
||||
|
||||
## Slice 0 — Test net + disjointness tripwire (prerequisite)
|
||||
|
||||
No production code moves. Add `session.dispatch-seam.test.ts`. This is the gate the whole plan rests on, because chat/schedule/loop have **zero** handleMessage coverage today (verified).
|
||||
|
||||
Write RED-then-GREEN against the **current in-place** Session:
|
||||
|
||||
- `chat/post` happy path (asserts `chat/post` response emitted) + fanout-limit error path (asserts the `chat/post` error envelope, NOT a bubbled `rpc_error`).
|
||||
- one `schedule/*` and one `loop/*` round-trip.
|
||||
- a handler that throws **synchronously** emits `rpc_error{code:"handler_error"}` + an `activity_log` error frame.
|
||||
- a handler that **rejects async** emits the SAME pair.
|
||||
- a table-driven assertion that the union of all controllers' owned-type `ReadonlySet`s is pairwise disjoint and covers the dispatched `SessionInboundMessage` union (grows as controllers land).
|
||||
|
||||
**Tests:** `session.dispatch-seam.test.ts`, `session.test.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Slice 1 — ChatScheduleLoopController ← STOP FOR HUMAN REVIEW after this ships green
|
||||
|
||||
**Move:** all 21 handlers (`handleChat*` ×7, `handleSchedule*` ×9, `handleLoop*` ×5), the three rpc-error emitters (`emitChatRpcError`/`emitScheduleRpcError`/`emitLoopRpcError` — **kept separate, not merged**), `toScheduleSummary` → `packages/server/src/server/chat/chat-schedule-loop-controller.ts`. Collapse `dispatchChatScheduleLoopMessage` + `dispatchScheduleMessage` to `return this.chatScheduleLoopController.dispatch(msg)`.
|
||||
|
||||
**SessionContext surface:** `emit`, `sessionLogger`, `clientId` (authorAgentId fallback), `chatService`, `scheduleService`, `loopService`, and a narrow agent-control port `{ listAgents, resolveAgentIdentifier, agentStorage.list }` for `handleChatPostRequest` mention fanout.
|
||||
|
||||
**Owned-type set MUST include all 7 `chat/*` + 5 `loop/*` + 9 `schedule/*` types** — schedule/\* is currently routed via the chat dispatcher's own `default` arm, so it must stay inside this one controller, or schedule requests silently no-op.
|
||||
|
||||
**Behavior note:** least-coupled domain. Move the three rpc-error emitters verbatim (they differ in default code + the `ChatServiceError` branch). **Tests:** `session.dispatch-seam.test.ts`, `loop-service.test.ts`, `session.test.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Slice 2 — ProviderCatalogController
|
||||
|
||||
**Move:** 7 provider handlers + `emitProviderDisabledResponse` + `getProviderSnapshotEntryForRead` → `packages/server/src/server/provider/provider-catalog-controller.ts`. Move the `providers_snapshot_update` PUSH wiring (1235-1254) into the controller's `start()`/`dispose()`. Collapse `dispatchProviderMessage`.
|
||||
|
||||
+111
-45
@@ -9,19 +9,17 @@ A release has exactly two steps. The agent does the first, the user authorizes t
|
||||
**Preparation** (local, reversible — agent does this):
|
||||
|
||||
- format, lint, typecheck all green
|
||||
- ACP provider catalog drift checked with `npm run acp:version-drift:check`;
|
||||
if stale package-runner pins are intentional, say so explicitly, otherwise run
|
||||
`npm run acp:version-drift:update` and commit the updated catalog
|
||||
- classify the previous-stable-to-`HEAD` diff as patch or minor, then show the
|
||||
- resolve the release source to one commit and confirm that commit's existing CI is green
|
||||
- classify the diff from the previous stable to the release source as patch or minor, then show the
|
||||
target version and rationale to the user
|
||||
- draft the changelog, show it to the user, wait for review
|
||||
- run the pre-release sanity check, surface findings to the user
|
||||
- confirm CI is green
|
||||
|
||||
**Go-ahead** (user says "go ahead"):
|
||||
|
||||
- commit the approved changelog
|
||||
- run the release
|
||||
- commit the approved release inputs locally
|
||||
- run the release, which publishes npm and pushes the prepared branch and tag
|
||||
- create the release heartbeat immediately and babysit it to completion
|
||||
|
||||
Rules that apply to both steps:
|
||||
|
||||
@@ -31,12 +29,43 @@ Rules that apply to both steps:
|
||||
- Invoking a release skill is intent to start the flow, not blanket authorization to publish.
|
||||
- If the user asks for a release preview, show the prospective changelog/release contents and answer questions, but do not commit, tag, publish, or run release commands until they explicitly authorize the release.
|
||||
|
||||
## Release source and CI
|
||||
|
||||
The default release source is `origin/main`. Fetch `origin`, then record the
|
||||
resolved commit. The default release checkout is a clean local `main` whose
|
||||
`HEAD` equals `origin/main`.
|
||||
|
||||
An explicit user instruction can select another ref, such as a hotfix commit or
|
||||
tag. Resolve that ref once and apply every source, diff, and CI check to that
|
||||
commit instead of `origin/main`.
|
||||
|
||||
Before making release-preparation commits, confirm the existing CI run for the
|
||||
resolved commit is green. Pending CI is watched to completion. Release
|
||||
preparation then stays local through the changelog, any explicitly requested ACP
|
||||
catalog update, lockfile preparation, and the version commit. After approval,
|
||||
commit the prepared inputs locally and run the release command. Its branch and
|
||||
tag push is the one remote release batch and starts CI for the complete release
|
||||
commit.
|
||||
|
||||
## ACP catalog updates
|
||||
|
||||
ACP catalog work enters a release through an explicit user request:
|
||||
|
||||
- **Check ACP drift** — run `npm run acp:version-drift:check`. When drift exists,
|
||||
run `npm run acp:version-drift:update`, verify the catalog, and include the
|
||||
update in the local release-preparation commits.
|
||||
- **Update ACP** — run `npm run acp:version-drift:update`, verify the catalog, and
|
||||
include the update in the local release-preparation commits.
|
||||
|
||||
The release authorization covers the requested ACP commit. It ships in the same
|
||||
release push as the changelog and version commit.
|
||||
|
||||
## Two paths
|
||||
|
||||
There are two supported ways to ship from `main`:
|
||||
There are two supported release paths:
|
||||
|
||||
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
|
||||
2. **Beta flow**: release candidates on the `beta` channel. Betas carry an in-place changelog entry (beta users check it), publish npm only on the explicit `beta` dist-tag, and never become the website's default download — they sit behind the Stable/Beta switch on `/download`.
|
||||
2. **Beta flow**: release candidates on the `beta` channel. Each beta carries its own changelog entry, publishes npm only on the explicit `beta` dist-tag, and stays behind the Stable/Beta switch on `/download`.
|
||||
|
||||
Paseo has one linear release track even though npm dist-tags are independent
|
||||
pointers. The npm invariant is:
|
||||
@@ -48,8 +77,9 @@ pointers. The npm invariant is:
|
||||
|
||||
## Release version decision
|
||||
|
||||
Every fresh release starts by classifying the full previous-stable-to-`HEAD`
|
||||
diff. The highest-impact change determines the version:
|
||||
Every fresh release starts by classifying the full diff from the previous
|
||||
stable to the resolved release source. The highest-impact change determines the
|
||||
version:
|
||||
|
||||
- **Minor** — a user would experience the release as a significant upgrade. This
|
||||
includes substantial new workflows, providers, forges, platforms, integrations,
|
||||
@@ -72,7 +102,7 @@ as described in **Fixing a failed release build**.
|
||||
|
||||
Before running any stable release command:
|
||||
|
||||
- Make sure the intended release commit is already committed to `main` and the working tree is clean.
|
||||
- Make sure the resolved release source passed CI, the approved release inputs are committed locally on the intended branch, and the working tree is clean.
|
||||
- **Run `npm run format`, `npm run lint`, and `npm run typecheck` and commit any resulting changes BEFORE you start any `release:*` command.** `release:check` runs `npm install --workspaces --include-workspace-root` as part of `release:prepare`, which can mutate `package-lock.json` (e.g. churning `"dev": true` markers on optional deps). The next step, `version:all:*`, runs `npm version` which aborts when the working tree is dirty. If this happens mid-flight you have to commit the lockfile churn before retrying — and the pre-commit format hook will reject a lockfile-only commit because oxfmt internally skips `package-lock.json` while lefthook's glob still matches it. Avoid the whole mess by running format/lint/typecheck first, then `release:prepare` once on its own to absorb any lockfile churn into a normal commit, then start the release.
|
||||
- Do not use a release command as a substitute for checking whether the current commit is actually ready.
|
||||
|
||||
@@ -133,7 +163,7 @@ npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z
|
||||
- `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the beta tag
|
||||
- Desktop assets now come from the Electron package at `packages/desktop`
|
||||
- Beta releases use Electron's `beta` update channel. Users on the stable channel only receive stable releases; users on the beta channel receive beta releases and the final stable release when it is published.
|
||||
- **Betas carry a changelog entry.** Beta users read release notes, so each beta updates an in-place `CHANGELOG.md` entry (`## X.Y.Z-beta.N`) that `Release Notes Sync` mirrors into the prerelease body on the tag push. The entry is intermediary: promotion overwrites it in place with the final stable entry, so no `-beta.N` heading is ever left behind. See the Changelog policy section.
|
||||
- **Each beta carries its own changelog entry.** `Release Notes Sync` mirrors the matching `## X.Y.Z-beta.N` entry into that prerelease body. Promotion collapses every beta entry for the version into one final stable entry. See the Changelog policy section.
|
||||
|
||||
Use the beta path when you need to:
|
||||
|
||||
@@ -291,11 +321,30 @@ Do not treat `build_ios: SUCCESS` or `submit_ios: SUCCESS` as a completed iOS re
|
||||
|
||||
To confirm the submission landed, inspect the EAS workflow with `npx eas workflow:view <workflow-run-id> --json`. App Store Connect (review state for the matching version/build) and the Play Console track are the final ground truth.
|
||||
|
||||
### Babysitting mobile after a release
|
||||
## Release completion and heartbeat
|
||||
|
||||
The user rarely opens the Expo dashboard. A failed EAS build or submit/review job can sit silently until users complain about a stale version. After every stable release, set up a long-delay babysit that re-checks GitHub Actions, EAS builds, and the EAS `Release Mobile` workflow for the release tag. If any build is `ERRORED`/`CANCELED`, any workflow is `FAILURE`, or any required submit/review job fails, surface it immediately. If all builds are `FINISHED` and all required submit/review jobs are `SUCCESS`, confirm and stop.
|
||||
A release is **in progress** after npm publication and tag push. Report it as
|
||||
**shipped** only after every applicable build, publication, asset, manifest, and
|
||||
store submission passes the completion checklist.
|
||||
|
||||
**Use `create_heartbeat`, never `create_schedule`, for release babysitting.** Babysitting fires back into the current conversation as a wake-up prompt. `create_schedule` starts a fresh agent the user has to find and read; `create_heartbeat` surfaces the build status inline in the conversation that owns the release, where it is impossible to miss. If you find yourself reaching for `create_schedule` for a release babysit, you are about to ship a status report into a void.
|
||||
Immediately after every beta, stable, or promotion tag push, create a heartbeat
|
||||
that resumes the release in the current conversation. Create it automatically
|
||||
with `create_heartbeat`. The heartbeat owns the release until it either reaches
|
||||
the completion checklist or finds a failure that needs new user authority.
|
||||
|
||||
Each heartbeat checks the release tag commit, all GitHub Actions runs for the
|
||||
release branch and tag, npm dist-tags, the GitHub Release body and assets,
|
||||
desktop updater manifests, the published Docker image, and the applicable EAS
|
||||
workflow. Inspect the GitHub Release itself and confirm that the macOS, Linux,
|
||||
Windows, and Android APK assets are present along with the channel manifests
|
||||
(`latest-mac.yml`, `latest-linux.yml`, and `latest.yml` for stable;
|
||||
`beta-mac.yml`, `beta-linux.yml`, and `beta.yml` for beta).
|
||||
|
||||
For stable releases, also confirm every required mobile build, upload, store
|
||||
submission, and review-submission job for the release commit. For betas, confirm
|
||||
the beta EAS workflow completed its TestFlight distribution and Beta App Review
|
||||
path. Delete the heartbeat only after every applicable checklist item passes,
|
||||
then report the release as shipped.
|
||||
|
||||
Pattern:
|
||||
|
||||
@@ -303,13 +352,16 @@ Pattern:
|
||||
// mcp__paseo__create_heartbeat arguments
|
||||
{
|
||||
"name": "vX.Y.Z release babysit heartbeat",
|
||||
"cron": "*/15 * * * *",
|
||||
"maxRuns": 8, // covers ~2h of build + store-submission window
|
||||
"prompt": "Heartbeat: check vX.Y.Z release. Run gh run list, eas build:list, eas workflow:runs, and eas workflow:view for the matching Release Mobile run. Report concisely. The release is not done until desktop/APK workflows are green, EAS builds are FINISHED, Android submit_android is SUCCESS, and iOS submit_ios + submit_ios_for_review are SUCCESS. Flag any ERRORED/FAILED/CANCELED/FAILURE loudly.",
|
||||
"cron": "*/10 * * * *",
|
||||
"timezone": "UTC",
|
||||
"maxRuns": 120,
|
||||
"expiresIn": "24h",
|
||||
"prompt": "Resume the vX.Y.Z release babysit for commit <sha>. Check npm tags; every GitHub Actions run for the release branch and tag; the published GitHub Release body, expected desktop/APK assets, and channel manifests; the Docker image; and the matching EAS workflow. Completion requires every applicable checklist item. For stable, require build_ios, submit_ios, submit_ios_for_review, build_android, and submit_android to succeed. For beta, require the beta TestFlight distribution and Beta App Review path. If work is pending, wait for the next heartbeat. If a failure can be retried safely for the same version, follow the failed-release procedure; otherwise report the blocker. When every applicable completion-checklist item passes, delete THIS heartbeat, report shipped, and stop.",
|
||||
}
|
||||
```
|
||||
|
||||
Tight cadence on purpose. The first run fires immediately, giving a near-real-time status check before the conversation closes. Subsequent runs at 15-minute intervals catch transitions quickly: a failed EAS build or failed App Store review submission at +20m should not wait until +50m to surface. Keep the prompt short — the heartbeat is a status probe, not a research task — and have it bail out as soon as every platform is actually on its store path so the remaining runs do not generate noise.
|
||||
Run an immediate status check after creating the heartbeat. The heartbeat handles
|
||||
later transitions and stops itself when the release is complete.
|
||||
|
||||
## Release notes on GitHub
|
||||
|
||||
@@ -322,7 +374,7 @@ The GitHub Release body is populated automatically by the `Release Notes Sync` w
|
||||
- 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 download page's "What's new" link deep-links the **minor group** anchor (`/changelog#release-0.3`), not the exact entry: promotion collapses the beta entries into one stable entry, so the minor group remains the durable target. A version with no entry in the bundled changelog — a tag whose changelog commit hasn't redeployed the site yet — links the plain `/changelog` instead of a dead anchor.
|
||||
- The website itself is deployed by `Deploy Website` (Cloudflare Workers), which redeploys on `release: published` for non-prerelease releases and on pushes to `main` that touch `CHANGELOG.md` or `packages/website/**`.
|
||||
|
||||
## Fixing a failed release build
|
||||
@@ -403,17 +455,16 @@ No prefix (`v`), no extra text. `Release Notes Sync` matches the `## X.Y.Z` (or
|
||||
|
||||
## Changelog policy
|
||||
|
||||
- `CHANGELOG.md` includes stable releases and the current beta line.
|
||||
- `CHANGELOG.md` includes stable releases and every entry in the current beta series.
|
||||
- The first beta of a version inserts a top entry like `## 0.1.60-beta.1 - YYYY-MM-DD`.
|
||||
- Each subsequent beta updates that same top entry in place — bump the heading (`0.1.60-beta.1` → `0.1.60-beta.2`) and fold in whatever else landed.
|
||||
- Stable promotion updates that same entry in place one last time: heading to `0.1.60`, date to the promotion day.
|
||||
- One entry per version line. The `-beta.N` heading is intermediary — overwrite it, never append. Don't leave stale `-beta.N` entries behind and don't create a duplicate entry per beta.
|
||||
- It always covers the full diff from the previous stable tag, regardless of how many betas were cut in between.
|
||||
- Each subsequent beta inserts a new top entry with the next beta number. Its notes cover the changes since the previous beta tag.
|
||||
- Stable promotion replaces every beta entry for that version with one `## 0.1.60 - YYYY-MM-DD` entry.
|
||||
- The promoted stable entry covers the full diff from the previous stable tag and collapses internal iterations across the beta series.
|
||||
|
||||
## Changelog ownership
|
||||
|
||||
- **The agent running the release writes the changelog entry — beta or stable.** Do not hand the changelog to another model or agent. The release agent has the release context and owns the final wording.
|
||||
- Draft the entry from the previous-stable-to-`HEAD` diff, review it against the changelog policy below, show it to the user, and wait for approval before committing it. Each beta refreshes the same entry; promotion refreshes it one last time from the full previous-stable-to-`HEAD` diff.
|
||||
- **The agent running the release writes the changelog entry — beta or stable.** The release context and final wording stay with that agent.
|
||||
- For the first beta or a direct stable release, draft from the previous stable tag to the release source. For later betas, draft from the previous beta tag to the release source. Promotion replaces the beta series with one entry drafted from the previous stable tag to the release source. Review the result against the changelog policy below, show it to the user, and wait for approval before committing it.
|
||||
|
||||
## Changelog voice
|
||||
|
||||
@@ -434,10 +485,8 @@ The changelog is shown on the Paseo homepage. Write it for **end users**, not de
|
||||
|
||||
Test: would a non-developer reader recognise what changed when using the app? If they'd need an engineer to translate ("what's a remount?"), the bullet is still implementation-facing — rewrite it as the symptom the user experiences.
|
||||
|
||||
- **Collapse internal iterations.** If a feature was added and then fixed within the same release, just list the feature as working. Users never saw the broken version.
|
||||
- **Only list changes relative to the previous stable release.** The diff is `v(previous)..HEAD`. If something was introduced and fixed between those two tags, it never shipped — don't mention the fix.
|
||||
- **Common trap:** when drafting from `git log`, every commit looks like a separate bullet — including the "fix X" commits that landed on top of a brand-new feature in the same release window. Before listing a Fixed entry, check whether the thing being fixed was itself added in this same release. If so, drop the fix and fold it into the feature bullet.
|
||||
- **Example:** if the release adds an in-app browser and also contains a commit "fix: browser pane keyboard handling no longer steals shortcuts", do **not** list the keyboard fix under Fixed. The browser is shipping for the first time, so users will only ever see the working version. The Added entry covers it.
|
||||
- **Use the entry's release scope.** Include changes within the matching range in **Changelog scope**.
|
||||
- **Collapse internal iterations within that scope.** Present a feature added and fixed in one range as working. A later beta can describe a fix to behavior delivered in an earlier beta; promotion folds the complete beta series into the final stable behavior.
|
||||
- **Cut low-signal entries.** "Toolbar buttons have consistent sizing" is too granular. Combine small polish items or drop them.
|
||||
|
||||
## Changelog conciseness
|
||||
@@ -480,7 +529,7 @@ Rules:
|
||||
|
||||
This returns every distinct GitHub login that authored or co-authored a commit in the PR. Use those logins for attribution. Fall back to `gh pr view N --json author` only if the commits command returns nothing (which should not happen for merged PRs).
|
||||
|
||||
When listing PR numbers, `git log --format='%H %s' v<previous>..HEAD | grep -E '\(#[0-9]+\)$'` pulls the PR number out of squash commit subjects.
|
||||
When listing PR numbers, `git log --format='%H %s' v<previous>..<release-source-sha> | grep -E '\(#[0-9]+\)$'` pulls the PR number out of squash commit subjects.
|
||||
|
||||
## Changelog ordering
|
||||
|
||||
@@ -494,51 +543,68 @@ Entries within each section (Added, Improved, Fixed) are ordered by user impact:
|
||||
|
||||
Before cutting a **stable** release, the release agent reviews the diff as a last line of defence against shipping bugs. Skip this for betas — the beta itself is the smoke test, and gating each beta on a code review defeats the point of using betas as fast release candidates.
|
||||
|
||||
Review the diff between the latest release tag and `HEAD`. Focus on:
|
||||
Review the diff between the latest release tag and the resolved release source. Focus on:
|
||||
|
||||
1. **Breaking changes** — especially in the WebSocket protocol, agent lifecycle, and any server↔client contract.
|
||||
2. **Backward compatibility** — the important direction is old app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Flag anything that breaks old clients against new daemons or requires both sides to update in lockstep.
|
||||
3. **Regressions** — anything that looks like it could break existing functionality.
|
||||
|
||||
Use `git diff <latest-release-tag>..HEAD` as the review input. This is a deep sanity check, not a full code review. If anything looks risky, investigate before proceeding and surface the finding to the user.
|
||||
Use `git diff <latest-release-tag>..<release-source-sha>` as the review input. This is a deep sanity check, not a full code review. If anything looks risky, investigate before proceeding and surface the finding to the user.
|
||||
|
||||
## Changelog scope
|
||||
|
||||
The changelog always covers **previous-stable-to-`HEAD`**, beta and stable alike:
|
||||
Changelog scope follows the release being described:
|
||||
|
||||
- **Beta release**: the entry covers `previous stable tag → HEAD`. Update the current in-place beta entry; don't start a fresh one per beta.
|
||||
- **Stable promotion**: the same entry is promoted in place. It still captures the full delta from the previous stable release, not just what changed since the last beta.
|
||||
- **First beta**: `previous stable tag → release source`
|
||||
- **Later beta**: `previous beta tag → release source`
|
||||
- **Direct stable release**: `previous stable tag → release source`
|
||||
- **Stable promotion**: replace the full beta series with one entry covering `previous stable tag → release source`
|
||||
|
||||
Betas are checkpoints along the way; the entry is the single record for the jump from one stable version to the next, and beta users read it in the meantime.
|
||||
Each beta entry records what its testers receive. Promotion produces the single stable record for the full jump from one stable version to the next.
|
||||
|
||||
## Completion checklist
|
||||
|
||||
### Beta release
|
||||
|
||||
- [ ] Working tree is clean and the intended commit is on `main`
|
||||
- [ ] Update the in-place beta entry in `CHANGELOG.md` (heading `## X.Y.Z-beta.N - YYYY-MM-DD`), review it against the changelog policy, get approval, and commit it before cutting the release
|
||||
- [ ] The previous-stable-to-`HEAD` diff is classified as patch or minor, with the target version and rationale approved
|
||||
- [ ] The resolved release source is the intended commit (default `origin/main`) and its existing CI is green
|
||||
- [ ] Add a new `CHANGELOG.md` entry for this beta (heading `## X.Y.Z-beta.N - YYYY-MM-DD`), review it against the changelog policy, get approval, and commit it before cutting the release
|
||||
- [ ] The diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved
|
||||
- [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag
|
||||
- [ ] `npm run release:beta:patch`, `npm run release:beta:minor`, or `npm run release:beta:next` completes successfully
|
||||
- [ ] Every GitHub Actions run for the complete release commit and tag is green
|
||||
- [ ] npm shows the version under the `beta` dist-tag, not `latest`
|
||||
- [ ] The GitHub prerelease exists with the changelog body and every expected macOS, Linux, Windows, and Android APK asset
|
||||
- [ ] GitHub `Desktop Release` workflow for the `v*-beta.N` tag is green
|
||||
- [ ] The GitHub prerelease contains `beta-mac.yml`, `beta-linux.yml`, and `beta.yml`
|
||||
- [ ] GitHub `Android APK Release` workflow for the same tag is green
|
||||
- [ ] GitHub `Docker` workflow is green and the versioned beta image is published without moving `latest`
|
||||
- [ ] GitHub `Release Notes Sync` mirrored the beta entry into the prerelease body
|
||||
- [ ] EAS `Release iOS Beta` completed its build, TestFlight distribution, external beta group, and Beta App Review path
|
||||
- [ ] The release heartbeat was created after the tag push and deleted only after every item above passed
|
||||
|
||||
### Stable release (or promotion)
|
||||
|
||||
- [ ] Run the pre-release sanity check (see above) and address any findings
|
||||
- [ ] The previous-stable-to-`HEAD` diff is classified as patch or minor, with the target version and rationale approved
|
||||
- [ ] Ensure the intended release commit is already committed and the git worktree is clean before running any release command
|
||||
- [ ] The diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved
|
||||
- [ ] The resolved release source is the intended commit (default `origin/main`) and its existing CI is green
|
||||
- [ ] Ensure the approved release inputs are committed locally and the git worktree is clean before running any release command
|
||||
- [ ] Ensure local `npm run typecheck` passes on that exact commit before running any release command
|
||||
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). When promoting from beta, overwrite the existing `## X.Y.Z-beta.N` heading in place (heading → `X.Y.Z`, date → promotion day) — do not add a new entry on top of the beta one
|
||||
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). Promotion replaces every `## X.Y.Z-beta.N` entry in the series with one `## X.Y.Z - YYYY-MM-DD` entry covering the full release
|
||||
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
|
||||
- [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag
|
||||
- [ ] `npm run release:patch`, `npm run release:minor`, or `npm run release:promote` completes successfully
|
||||
- [ ] Every GitHub Actions run for the complete release commit and tag is green
|
||||
- [ ] Move npm's `beta` dist-tag to the new stable version for every published package and verify both `latest` and `beta` resolve to it
|
||||
- [ ] The published GitHub Release exists with the changelog body and every expected macOS, Linux, Windows, and Android APK asset
|
||||
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
|
||||
- [ ] The GitHub Release contains `latest-mac.yml`, `latest-linux.yml`, and `latest.yml`
|
||||
- [ ] GitHub `Android APK Release` workflow for the same tag is green
|
||||
- [ ] GitHub `Docker` workflow is green and both the versioned and `latest` images are published
|
||||
- [ ] GitHub `Release Notes Sync` is green and the release body matches the stable changelog entry
|
||||
- [ ] EAS `Release Mobile` workflow for the same tag is green
|
||||
- [ ] EAS iOS `build_ios` completes for the same tag
|
||||
- [ ] EAS iOS `submit_ios` succeeds, uploading the build to App Store Connect/TestFlight
|
||||
- [ ] EAS iOS `submit_ios_for_review` succeeds, putting the build into App Store review
|
||||
- [ ] EAS Android `build_android` completes for the same tag
|
||||
- [ ] EAS Android `submit_android` succeeds, putting the build on its Play Store track
|
||||
- [ ] The release heartbeat was created after the tag push and deleted only after every item above passed
|
||||
|
||||
+1
-1
@@ -345,7 +345,7 @@ Gotchas:
|
||||
- **Narrow the discriminated union before spreading.** `updateTheme`'s updater returns the theme union; spreading the union widens `colorScheme` to `"light" | "dark"`, which is assignable to neither concrete member. Branch on `t.colorScheme` so each branch spreads a single narrowed theme type (no `as`).
|
||||
- **`lineHeight.diff` is the code/diff line-height axis** — it is coupled to the code-font-size control (≈ `codeFontSize * 1.5`). Do NOT use it for prose. Markdown body line-height scales with the UI ramp (`Math.round(theme.fontSize.base * 1.4)`); routing prose through `lineHeight.diff` clips text at small code sizes.
|
||||
- **High-churn draft values** (live-while-typing in the appearance preview) bypass the theme: apply them as inline styles marked with `inlineUnistylesStyle` so per-keystroke values don't grow the `#unistyles-web` CSS registry.
|
||||
- **Mounted parsed content uses `AppearanceStyleBoundary`.** Markdown, syntax-highlighted code, and tool-call detail bodies can contain memoized/custom renderer trees that do not naturally re-run when runtime-patched appearance tokens change. Wrap the parsed surface once with `packages/app/src/components/appearance-style-boundary.tsx`; do not add local "appearance key" props at each callsite.
|
||||
- **The app shell uses one `AppearanceStyleBoundary`.** Runtime-patched numeric theme values are baked into Unistyles web classes rather than CSS variables, while parsed/memoized content also does not naturally re-run when appearance tokens change. The boundary sits below stable runtime providers in `app/_layout.tsx` and remounts the visual shell once. `applyAppearance` patches the active theme before inactive registry entries so its subscribers receive the committed values in the same update. Do not add local appearance keys or nested boundaries.
|
||||
- **Dynamic font tokens stay widened.** `fontFamily`, `fontSize`, and `lineHeight` on `commonTheme` are annotated `string`/`number` (not narrowed by `as const`) so the updater's return assigns; the platform default stacks live in `DEFAULT_UI_FONT_STACK` / `DEFAULT_MONO_FONT_STACK`.
|
||||
|
||||
## Debugging
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-RYUhNxV9NestBCJ7HURmW0amsSA20KENoVEv7OTypAs=
|
||||
sha256-L73o8u6OsY3SsA5t2s5mWsMsMNc3qr5bCE4cd1NpbgI=
|
||||
|
||||
Generated
+1140
-666
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.3.0-beta.4",
|
||||
"version": "0.3.1",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const pkg = require("./package.json");
|
||||
const withAndroidAsyncStorageSize = require("./plugins/with-android-async-storage-size");
|
||||
const withAndroidProfileable = require("./plugins/with-android-profileable");
|
||||
const withFdroidAutolinking = require("./plugins/with-fdroid-autolinking");
|
||||
const { getNativeReleaseVersion } = require("./native-release-version");
|
||||
@@ -138,6 +139,7 @@ export default {
|
||||
},
|
||||
plugins: [
|
||||
"expo-router",
|
||||
[withAndroidAsyncStorageSize, 10],
|
||||
...buildProfile.cameraPlugins,
|
||||
[
|
||||
"expo-splash-screen",
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
expectAddProjectPage,
|
||||
expectNewWorkspaceForAddedProject,
|
||||
openAddProjectFlow,
|
||||
openAddProjectHostSelection,
|
||||
} from "../support/helpers/add-project-flow";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
@@ -65,12 +66,14 @@ async function expectProjectHasNoWorkspaces(projectId: string): Promise<void> {
|
||||
test.describe("Add Project command-center flow", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("a single connected host opens directly on method selection", async ({ page }) => {
|
||||
test("method selection has no search field", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
|
||||
await openAddProjectFlow(page);
|
||||
|
||||
await expect(addProjectFlowMethod(page, "directory-search")).toBeVisible();
|
||||
await expect(addProjectFlowInput(page)).toHaveCount(0);
|
||||
await expect(addProjectFlow(page).getByRole("textbox")).toHaveCount(0);
|
||||
await expect(page.getByTestId("add-project-flow-page-host")).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -146,7 +149,7 @@ test.describe("Add Project command-center flow", () => {
|
||||
serverId: SECONDARY_HOST_ID,
|
||||
endpoint: `localhost:${secondaryHost.port}`,
|
||||
});
|
||||
await openAddProjectFlow(page, "host");
|
||||
await openAddProjectHostSelection(page);
|
||||
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await page.keyboard.press("Enter");
|
||||
@@ -155,7 +158,7 @@ test.describe("Add Project command-center flow", () => {
|
||||
await expect(addProjectFlow(page)).toContainText(SECONDARY_HOST_LABEL);
|
||||
});
|
||||
|
||||
test("Escape and Back restore page input and active selection before closing at the root", async ({
|
||||
test("Escape and Back restore searchable page input before closing at the root", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoAppShell(page);
|
||||
@@ -168,21 +171,20 @@ test.describe("Add Project command-center flow", () => {
|
||||
serverId: SECONDARY_HOST_ID,
|
||||
endpoint: `localhost:${secondaryHost.port}`,
|
||||
});
|
||||
await openAddProjectFlow(page, "host");
|
||||
await openAddProjectHostSelection(page);
|
||||
|
||||
await addProjectFlowInput(page).fill("o");
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await page.keyboard.press("Enter");
|
||||
await expectAddProjectPage(page, "method");
|
||||
|
||||
await addProjectFlowInput(page).fill("new");
|
||||
await page.keyboard.press("Enter");
|
||||
await chooseAddProjectMethod(page, "new-directory");
|
||||
await expectAddProjectPage(page, "new-directory-parent");
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await expectAddProjectPage(page, "method");
|
||||
await expect(addProjectFlowInput(page)).toHaveValue("new");
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(addProjectFlowInput(page)).toHaveCount(0);
|
||||
await chooseAddProjectMethod(page, "new-directory");
|
||||
await expectAddProjectPage(page, "new-directory-parent");
|
||||
await addProjectFlowBack(page).click();
|
||||
|
||||
@@ -216,7 +218,7 @@ test.describe("Add Project command-center flow", () => {
|
||||
serverId: SECONDARY_HOST_ID,
|
||||
endpoint: `localhost:${secondaryHost.port}`,
|
||||
});
|
||||
await openAddProjectFlow(page, "host");
|
||||
await openAddProjectHostSelection(page);
|
||||
await addProjectFlowHost(page, SECONDARY_HOST_ID).click();
|
||||
await expectAddProjectPage(page, "method");
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
applyProfileFromPicker,
|
||||
closeModelPicker,
|
||||
drillIntoProvider,
|
||||
expectComposerDoesNotName,
|
||||
expectAgentProfilesEmptyPrompt,
|
||||
expectProfileEditTooltip,
|
||||
expectComposerMode,
|
||||
expectComposerModel,
|
||||
expectModelRowSelected,
|
||||
expectNothingSelectedInPickerRoot,
|
||||
expectProfileEditIsPencilOnly,
|
||||
expectProfilePinnedAboveProviders,
|
||||
expectProfileVisibleForProvider,
|
||||
openModelPicker,
|
||||
openAgentProfilesFromEmptyPrompt,
|
||||
seedAgentProfiles,
|
||||
} from "../support/helpers/agent-profiles";
|
||||
import { expectWorkspaceAgentConfiguration } from "../support/helpers/command-center-agent-controls";
|
||||
import { expectComposerVisible } from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
const PROFILE = {
|
||||
id: "agent_profile_e2e_ui_work",
|
||||
name: "UI work",
|
||||
icon: "🎨",
|
||||
provider: "mock",
|
||||
model: "one-minute-stream",
|
||||
modeId: "approval-test",
|
||||
notes: "Use for UI work.",
|
||||
};
|
||||
|
||||
const PROFILE_SUMMARY = "Mock Load Test · One minute stream · Approval test";
|
||||
|
||||
test.describe("Agent profiles in the model picker", () => {
|
||||
test("an empty host still exposes agent profile settings from the picker", async ({ page }) => {
|
||||
const seed = await seedAgentProfiles([]);
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "agent-profiles-empty-",
|
||||
title: "Agent profiles empty",
|
||||
});
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, workspace);
|
||||
await expectComposerVisible(page);
|
||||
await openModelPicker(page);
|
||||
await expectAgentProfilesEmptyPrompt(page);
|
||||
await openAgentProfilesFromEmptyPrompt(page);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
await seed.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("applying a pinned profile materializes it into the composer and is then forgotten", async ({
|
||||
page,
|
||||
}) => {
|
||||
const seed = await seedAgentProfiles([PROFILE]);
|
||||
// A live agent is one provider's process, so the profile has to name that
|
||||
// same provider or the picker will not offer it at all.
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "agent-profiles-picker-",
|
||||
title: "Agent profiles picker",
|
||||
model: "ten-second-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
|
||||
try {
|
||||
await test.step("the agent starts on its seeded model and mode", async () => {
|
||||
await openAgentRoute(page, workspace);
|
||||
await expectComposerVisible(page);
|
||||
await expectComposerModel(page, "Ten second stream");
|
||||
await expectComposerMode(page, "Load test");
|
||||
});
|
||||
|
||||
await test.step("the profile is pinned above the provider list", async () => {
|
||||
await openModelPicker(page);
|
||||
await expectProfilePinnedAboveProviders(page, {
|
||||
name: PROFILE.name,
|
||||
summary: PROFILE_SUMMARY,
|
||||
});
|
||||
await expectProfileEditIsPencilOnly(page);
|
||||
await expectProfileEditTooltip(page);
|
||||
});
|
||||
|
||||
await test.step("applying it writes its model and mode into the composer", async () => {
|
||||
await applyProfileFromPicker(page, PROFILE.name);
|
||||
await expectComposerModel(page, "One minute stream");
|
||||
await expectComposerMode(page, "Approval test");
|
||||
await expectWorkspaceAgentConfiguration(workspace, {
|
||||
id: workspace.agentId,
|
||||
provider: "mock",
|
||||
model: "one-minute-stream",
|
||||
modeId: "approval-test",
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("the composer names the model, never the profile", async () => {
|
||||
await expectComposerDoesNotName(page, PROFILE.name);
|
||||
});
|
||||
|
||||
await test.step("reopening the picker shows no profile marked as selected", async () => {
|
||||
await openModelPicker(page);
|
||||
await expectProfilePinnedAboveProviders(page, {
|
||||
name: PROFILE.name,
|
||||
summary: PROFILE_SUMMARY,
|
||||
});
|
||||
await expectNothingSelectedInPickerRoot(page);
|
||||
});
|
||||
|
||||
await test.step("the model it materialized is the one marked selected", async () => {
|
||||
await drillIntoProvider(page, "mock");
|
||||
await expectProfileVisibleForProvider(page, {
|
||||
name: PROFILE.name,
|
||||
summary: PROFILE_SUMMARY,
|
||||
});
|
||||
await expectModelRowSelected(page, { provider: "mock", modelId: "one-minute-stream" });
|
||||
await closeModelPicker(page);
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
await seed.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
createAgentProfile,
|
||||
createAgentProfileFromEmptyState,
|
||||
editAgentProfile,
|
||||
expectAgentProfile,
|
||||
expectAgentProfileForm,
|
||||
expectAgentProfileOrder,
|
||||
expectAgentProfileTagsGone,
|
||||
expectHostAgentProfiles,
|
||||
expectNoAgentProfiles,
|
||||
moveAgentProfileUp,
|
||||
openAgentProfileSettings,
|
||||
removeAgentProfile,
|
||||
seedAgentProfiles,
|
||||
stageLegacyFavoritesForHostMigration,
|
||||
} from "../support/helpers/agent-profiles";
|
||||
|
||||
const MOCK_PROVIDER_LABEL = "Mock Load Test";
|
||||
|
||||
test.describe("Agent profiles settings", () => {
|
||||
test("legacy model favourites migrate into provider-and-model-only host profiles", async ({
|
||||
page,
|
||||
}) => {
|
||||
const seed = await seedAgentProfiles([]);
|
||||
const migratedProfile = {
|
||||
id: "legacy_favorite:mock:one-minute-stream",
|
||||
name: "One minute stream",
|
||||
provider: "mock",
|
||||
model: "one-minute-stream",
|
||||
};
|
||||
|
||||
try {
|
||||
await stageLegacyFavoritesForHostMigration(page, [
|
||||
{ provider: "mock", modelId: "one-minute-stream" },
|
||||
]);
|
||||
|
||||
await expectHostAgentProfiles([migratedProfile]);
|
||||
await openAgentProfileSettings(page);
|
||||
await expectAgentProfile(page, {
|
||||
name: migratedProfile.name,
|
||||
tags: [MOCK_PROVIDER_LABEL, "One minute stream"],
|
||||
});
|
||||
} finally {
|
||||
await seed.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("host owner creates, edits, reorders and removes agent profiles", async ({ page }) => {
|
||||
// The journey owns the list, so it starts from an empty one whatever the
|
||||
// worker ran before, and hands it back untouched.
|
||||
const seed = await seedAgentProfiles([]);
|
||||
|
||||
try {
|
||||
await test.step("the section starts empty", async () => {
|
||||
await openAgentProfileSettings(page);
|
||||
await expectNoAgentProfiles(page);
|
||||
});
|
||||
|
||||
await test.step("create a profile from the modal", async () => {
|
||||
await createAgentProfileFromEmptyState(page, {
|
||||
name: "UI work",
|
||||
provider: MOCK_PROVIDER_LABEL,
|
||||
model: "Ten second stream",
|
||||
thinking: "High",
|
||||
mode: "Approval test",
|
||||
notes: "Use for UI work — components, layout and design tokens.",
|
||||
});
|
||||
await expectAgentProfile(page, {
|
||||
name: "UI work",
|
||||
tags: [MOCK_PROVIDER_LABEL, "Ten second stream", "Approval test", "High"],
|
||||
notes: "Use for UI work — components, layout and design tokens.",
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("editing to a model without thinking levels drops the level", async () => {
|
||||
await editAgentProfile(page, "UI work", {
|
||||
model: "One minute stream",
|
||||
notes: "Now for quick checks.",
|
||||
});
|
||||
await expectAgentProfile(page, {
|
||||
name: "UI work",
|
||||
tags: [MOCK_PROVIDER_LABEL, "One minute stream", "Approval test"],
|
||||
notes: "Now for quick checks.",
|
||||
});
|
||||
await expectAgentProfileTagsGone(page, {
|
||||
name: "UI work",
|
||||
tags: ["Ten second stream", "High"],
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("the edit form reopens on what was stored", async () => {
|
||||
await expectAgentProfileForm(page, "UI work", {
|
||||
provider: MOCK_PROVIDER_LABEL,
|
||||
model: "One minute stream",
|
||||
mode: "Approval test",
|
||||
notes: "Now for quick checks.",
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("a second profile lands below the first", async () => {
|
||||
await createAgentProfile(page, {
|
||||
name: "Deep review",
|
||||
provider: MOCK_PROVIDER_LABEL,
|
||||
model: "Five minute stream",
|
||||
});
|
||||
await expectAgentProfileOrder(page, ["UI work", "Deep review"]);
|
||||
});
|
||||
|
||||
await test.step("reorder puts the second profile on top", async () => {
|
||||
await moveAgentProfileUp(page, "Deep review");
|
||||
await expectAgentProfileOrder(page, ["Deep review", "UI work"]);
|
||||
});
|
||||
|
||||
await test.step("removing a profile confirms by name", async () => {
|
||||
await removeAgentProfile(page, "UI work");
|
||||
await expectAgentProfileOrder(page, ["Deep review"]);
|
||||
});
|
||||
|
||||
await test.step("removing the last profile returns the empty state", async () => {
|
||||
await removeAgentProfile(page, "Deep review");
|
||||
await expectNoAgentProfiles(page);
|
||||
});
|
||||
} finally {
|
||||
await seed.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { openSettingsSection } from "../support/helpers/settings";
|
||||
|
||||
test("shows Pure black in the appearance picker", async ({ page }, testInfo) => {
|
||||
await page.goto("/settings");
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||
await openSettingsSection(page, "appearance");
|
||||
|
||||
const themeTrigger = page.getByLabel("Theme: System", { exact: true });
|
||||
await themeTrigger.click();
|
||||
await expect(page.getByText("Pure black", { exact: true })).toBeVisible();
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("appearance-theme-picker.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps the selected workspace visible in Pure black", async ({ page }, testInfo) => {
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "pure-black-selected-workspace-",
|
||||
title: "Selected workspace",
|
||||
});
|
||||
|
||||
try {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("@paseo:app-settings", JSON.stringify({ theme: "pureBlack" }));
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
|
||||
const row = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspace.workspaceId}`);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await row.click();
|
||||
|
||||
await expect(row).toHaveAttribute("aria-selected", "true");
|
||||
await expect(row).toHaveCSS("background-color", "rgb(22, 22, 22)");
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("pure-black-selected-workspace.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("applies the interface font size to settings text", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("@paseo:app-settings", JSON.stringify({ uiFontSize: 24 }));
|
||||
});
|
||||
await page.goto("/settings");
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||
await openSettingsSection(page, "appearance");
|
||||
|
||||
const sectionTitle = page.getByText("Theme", { exact: true }).first();
|
||||
await expect(sectionTitle).toHaveCSS("font-size", "18px");
|
||||
|
||||
const fontSizeInput = page.getByLabel("Interface font size");
|
||||
await expect(fontSizeInput).toHaveValue("24");
|
||||
await fontSizeInput.fill("12");
|
||||
await fontSizeInput.press("Tab");
|
||||
|
||||
await expect(fontSizeInput).toHaveValue("12");
|
||||
await expect(sectionTitle).toHaveCSS("font-size", "9px");
|
||||
});
|
||||
@@ -118,6 +118,53 @@ async function selectAssistantText(page: Page, text: string): Promise<void> {
|
||||
await selectAssistantTextRange(page, text, text);
|
||||
}
|
||||
|
||||
async function selectAssistantListItemFromMarker(
|
||||
page: Page,
|
||||
listTag: "ol" | "ul",
|
||||
itemText: string,
|
||||
endText: string,
|
||||
): Promise<void> {
|
||||
const assistantMessage = page.getByTestId("assistant-message").filter({
|
||||
hasText: "Direct matches:",
|
||||
});
|
||||
const item = assistantMessage
|
||||
.locator(`[data-paseo-markdown-tag="${listTag}"]`)
|
||||
.filter({ hasText: itemText })
|
||||
.locator(':scope > [data-paseo-markdown-tag="li"]')
|
||||
.filter({ hasText: itemText });
|
||||
|
||||
await item.evaluate((element, selectedEndText) => {
|
||||
const marker = element.querySelector(':scope > [data-paseo-markdown-list-marker="true"]');
|
||||
const markerText = marker?.firstChild;
|
||||
if (!(markerText instanceof Text)) {
|
||||
throw new Error("Expected rendered list marker text");
|
||||
}
|
||||
|
||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
||||
let endNode: Text | null = null;
|
||||
let endOffset = -1;
|
||||
while (walker.nextNode()) {
|
||||
const node = walker.currentNode;
|
||||
const offset = node.textContent?.indexOf(selectedEndText) ?? -1;
|
||||
if (node instanceof Text && offset >= 0) {
|
||||
endNode = node;
|
||||
endOffset = offset + selectedEndText.length;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!endNode) {
|
||||
throw new Error(`Could not find list item selection end: ${selectedEndText}`);
|
||||
}
|
||||
|
||||
const range = document.createRange();
|
||||
range.setStart(markerText, 0);
|
||||
range.setEnd(endNode, endOffset);
|
||||
const selection = window.getSelection();
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
}, endText);
|
||||
}
|
||||
|
||||
async function doubleClickAssistantMarkdownText(
|
||||
page: Page,
|
||||
tag: "code" | "strong",
|
||||
@@ -405,6 +452,24 @@ test("copying an assistant selection preserves Markdown structure and links", as
|
||||
'<div>- <strong><a href="https://example.com/issues/1">First issue</a></strong>',
|
||||
);
|
||||
|
||||
await selectAssistantListItemFromMarker(page, "ul", "First issue", "failure.");
|
||||
await copySelection(page);
|
||||
|
||||
const draggedBulletClipboard = await readRichClipboard(page);
|
||||
expect(draggedBulletClipboard.plainText).toBe(
|
||||
"- **[First issue](https://example.com/issues/1)**: exact `apply_patch` failure.",
|
||||
);
|
||||
expect(draggedBulletClipboard.html).toContain(
|
||||
'<div>- <strong><a href="https://example.com/issues/1">First issue</a></strong>',
|
||||
);
|
||||
|
||||
await selectAssistantListItemFromMarker(page, "ol", "Sixth item", "Sixth item");
|
||||
await copySelection(page);
|
||||
|
||||
const draggedNumberClipboard = await readRichClipboard(page);
|
||||
expect(draggedNumberClipboard.plainText).toBe("6. Sixth item");
|
||||
expect(draggedNumberClipboard.html).toContain("<div>6. Sixth item</div>");
|
||||
|
||||
await selectAssistantText(page, "docs");
|
||||
await copySelection(page);
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { unlink, writeFile } from "node:fs/promises";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFile, unlink, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { type Page } from "@playwright/test";
|
||||
import { type Locator, type Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute, buildSettingsSectionRoute } from "../../src/utils/host-routes";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { connectSeedClient } from "../support/helpers/seed-client";
|
||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||
@@ -15,6 +17,9 @@ interface DirtyWorkspace {
|
||||
|
||||
interface WorkspaceFixtureOptions {
|
||||
includeDeletedFile?: boolean;
|
||||
includeNestedFolders?: boolean;
|
||||
includeRenamedFile?: boolean;
|
||||
includeUntrackedFile?: boolean;
|
||||
}
|
||||
|
||||
interface CleanupTask {
|
||||
@@ -23,8 +28,45 @@ interface CleanupTask {
|
||||
|
||||
const cleanupTasks: CleanupTask[] = [];
|
||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||
|
||||
async function failNextDiscardRequest(page: Page): Promise<void> {
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (browserSocket) => {
|
||||
const serverSocket = browserSocket.connectToServer();
|
||||
browserSocket.onMessage((message) => {
|
||||
if (typeof message === "string") {
|
||||
const envelope = JSON.parse(message) as {
|
||||
message?: { type?: string; cwd?: string; requestId?: string };
|
||||
};
|
||||
if (envelope.message?.type === "checkout.discard_changes.request") {
|
||||
browserSocket.send(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "checkout.discard_changes.response",
|
||||
payload: {
|
||||
cwd: envelope.message.cwd,
|
||||
success: false,
|
||||
error: { code: "UNKNOWN", message: "Injected revert failure" },
|
||||
requestId: envelope.message.requestId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
serverSocket.send(message);
|
||||
});
|
||||
serverSocket.onMessage((message) => browserSocket.send(message));
|
||||
});
|
||||
}
|
||||
|
||||
const CHANGES_PREFERENCES_KEY = "@paseo:changes-preferences";
|
||||
|
||||
function diffGroups(scope: Page | Locator, fileIndex = 0): Locator {
|
||||
return scope.getByTestId(new RegExp(`^diff-file-${fileIndex}-group-\\d+$`));
|
||||
}
|
||||
|
||||
const BEFORE = `import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
interface UseMountedTabSetInput {
|
||||
@@ -209,9 +251,15 @@ test("changes file actions open below the right-click without a reserved kebab",
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await expect(page.getByTestId("diff-file-1")).toContainText("zz-deleted.ts");
|
||||
const deletedFileName = page.getByText("zz-deleted.ts", { exact: true });
|
||||
await expect(deletedFileName).toHaveCSS("user-select", "none");
|
||||
await deletedFileName.dblclick();
|
||||
expect(await page.evaluate(() => window.getSelection()?.toString() ?? "")).toBe("");
|
||||
await expect(page.getByTestId(/diff-file-\d+-actions/)).toHaveCount(0);
|
||||
await page.getByTestId("diff-file-1-toggle").click({ button: "right" });
|
||||
await expect(page.getByText("Copy path")).toBeVisible();
|
||||
await page.getByText("Copy path", { exact: true }).click({ button: "right" });
|
||||
await expect(page.getByText("Copy path")).toBeVisible();
|
||||
await expect(page.getByTestId("diff-file-1-open-file")).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
@@ -230,6 +278,183 @@ test("changes file actions open below the right-click without a reserved kebab",
|
||||
await expect(page.getByTestId("workspace-tab-file_src/use-mounted-tab-set.ts")).toBeVisible();
|
||||
});
|
||||
|
||||
test("changes context menus duplicate files and folders", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||
await page.getByTestId("diff-file-0-duplicate").click();
|
||||
await expect
|
||||
.poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set copy.ts"), "utf8"))
|
||||
.toBe(AFTER);
|
||||
|
||||
await page.getByTestId("changes-toggle-view-mode").click();
|
||||
await page.getByTestId("diff-folder-src-toggle").click({ button: "right" });
|
||||
await page.getByTestId("diff-folder-src-duplicate").click();
|
||||
await expect
|
||||
.poll(() => readFile(path.join(workspace.repoPath, "src copy/use-mounted-tab-set.ts"), "utf8"))
|
||||
.toBe(AFTER);
|
||||
});
|
||||
|
||||
test("changes context menu recursively collapses descendant folders", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff({ includeNestedFolders: true });
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await page.getByTestId("changes-toggle-view-mode").click();
|
||||
await expect(page.getByTestId("diff-folder-src/zz-folder")).toBeVisible();
|
||||
await expect(page.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible();
|
||||
|
||||
await page.getByTestId("diff-folder-src-toggle").click({ button: "right" });
|
||||
await page.getByTestId("diff-folder-src-collapse-folder").click();
|
||||
await expect(page.getByTestId("diff-folder-src/zz-folder")).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("diff-folder-src-toggle").click();
|
||||
await expect(page.getByTestId("diff-folder-src/zz-folder")).toBeVisible();
|
||||
await expect(page.getByText("root.ts", { exact: true })).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("diff-folder-src/zz-folder-toggle").click();
|
||||
await expect(page.getByText("root.ts", { exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible();
|
||||
await expect(page.getByText("changed.ts", { exact: true })).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("diff-folder-src/zz-folder/nested-toggle").click();
|
||||
await expect(page.getByText("changed.ts", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("changes context menus expose folder revert and restore a file after confirmation", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await page.getByTestId("changes-toggle-view-mode").click();
|
||||
await page.getByTestId("diff-folder-src-toggle").click({ button: "right" });
|
||||
const folderRevert = page.getByTestId("diff-folder-src-revert");
|
||||
await expect(folderRevert).toBeVisible();
|
||||
const revertLabelColor = await folderRevert
|
||||
.getByText("Discard changes", { exact: true })
|
||||
.evaluate((element) => getComputedStyle(element).color);
|
||||
await expect(folderRevert.locator("svg")).toHaveCSS("stroke", revertLabelColor);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||
const cancelledConfirmation = new Promise<string>((resolve) => {
|
||||
page.once("dialog", async (dialog) => {
|
||||
const message = dialog.message();
|
||||
await dialog.dismiss();
|
||||
resolve(message);
|
||||
});
|
||||
});
|
||||
await page.getByTestId("diff-file-0-revert").click();
|
||||
expect(await cancelledConfirmation).toContain("src/use-mounted-tab-set.ts");
|
||||
await expect(page.getByTestId("diff-file-0")).toBeVisible();
|
||||
await expect
|
||||
.poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), "utf8"))
|
||||
.toBe(AFTER);
|
||||
|
||||
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||
const confirmation = new Promise<string>((resolve) => {
|
||||
page.once("dialog", async (dialog) => {
|
||||
const message = dialog.message();
|
||||
await dialog.accept();
|
||||
resolve(message);
|
||||
});
|
||||
});
|
||||
await page.getByTestId("diff-file-0-revert").click();
|
||||
expect(await confirmation).toContain("src/use-mounted-tab-set.ts");
|
||||
|
||||
await expect(page.getByTestId("diff-file-0")).toHaveCount(0, { timeout: 30_000 });
|
||||
await expect
|
||||
.poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), "utf8"))
|
||||
.toBe(BEFORE);
|
||||
});
|
||||
|
||||
test("discarding a staged rename restores its source path", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff({ includeRenamedFile: true });
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
const renamedToggle = page
|
||||
.getByTestId(/^diff-file-\d+-toggle$/)
|
||||
.filter({ hasText: "zz-renamed.ts" });
|
||||
const toggleTestId = await renamedToggle.getAttribute("data-testid");
|
||||
expect(toggleTestId).not.toBeNull();
|
||||
const rowTestId = toggleTestId!.slice(0, -"-toggle".length);
|
||||
await renamedToggle.click({ button: "right" });
|
||||
const confirmation = new Promise<void>((resolve) => {
|
||||
page.once("dialog", async (dialog) => {
|
||||
await dialog.accept();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
await page.getByTestId(`${rowTestId}-revert`).click();
|
||||
await confirmation;
|
||||
|
||||
await expect(page.getByText("zz-renamed.ts", { exact: true })).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect
|
||||
.poll(() => readFile(path.join(workspace.repoPath, "src/rename-source.ts"), "utf8"))
|
||||
.toBe("export const renamed = true;\n");
|
||||
});
|
||||
|
||||
test("discarding an untracked file removes it from the working tree", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff({ includeUntrackedFile: true });
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
const untrackedToggle = page
|
||||
.getByTestId(/^diff-file-\d+-toggle$/)
|
||||
.filter({ hasText: "zz-untracked.txt" });
|
||||
const toggleTestId = await untrackedToggle.getAttribute("data-testid");
|
||||
expect(toggleTestId).not.toBeNull();
|
||||
const rowTestId = toggleTestId!.slice(0, -"-toggle".length);
|
||||
await untrackedToggle.click({ button: "right" });
|
||||
const confirmation = new Promise<void>((resolve) => {
|
||||
page.once("dialog", async (dialog) => {
|
||||
await dialog.accept();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
await page.getByTestId(`${rowTestId}-revert`).click();
|
||||
await confirmation;
|
||||
|
||||
await expect(page.getByText("zz-untracked.txt", { exact: true })).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(
|
||||
readFile(path.join(workspace.repoPath, "zz-untracked.txt"), "utf8"),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("shows a revert error returned by the daemon", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await failNextDiscardRequest(page);
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||
const confirmation = new Promise<void>((resolve) => {
|
||||
page.once("dialog", async (dialog) => {
|
||||
await dialog.accept();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
await page.getByTestId("diff-file-0-revert").click();
|
||||
await confirmation;
|
||||
|
||||
await expect(page.getByText("Injected revert failure", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByTestId("diff-file-0")).toBeVisible();
|
||||
await expect
|
||||
.poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), "utf8"))
|
||||
.toBe(AFTER);
|
||||
});
|
||||
|
||||
test("Changes switches between inline and full-tab navigation", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true });
|
||||
await useUnwrappedDiffLines(page);
|
||||
@@ -244,12 +469,12 @@ test("Changes switches between inline and full-tab navigation", async ({ page })
|
||||
await expect(visiblePanel).toBeVisible();
|
||||
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible();
|
||||
await expect(visiblePanel).toContainText("zz-deleted.ts");
|
||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
|
||||
await expect(diffGroups(visiblePanel).first()).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-file-pane")).toHaveCount(0);
|
||||
await visiblePanel.getByTestId("diff-file-0-toggle").click();
|
||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).toHaveCount(0);
|
||||
await expect(diffGroups(visiblePanel)).toHaveCount(0);
|
||||
await visiblePanel.getByTestId("diff-file-0-toggle").click();
|
||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
|
||||
await expect(diffGroups(visiblePanel).first()).toBeVisible();
|
||||
const workingDiffLayoutToggle = visiblePanel.getByTestId("working-diff-toggle-layout");
|
||||
await expect(workingDiffLayoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
|
||||
await workingDiffLayoutToggle.click();
|
||||
@@ -265,14 +490,12 @@ test("Changes switches between inline and full-tab navigation", async ({ page })
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
await visiblePanel.getByTestId("working-diff-toggle-expand-all").click();
|
||||
await expect(visiblePanel.getByTestId(/^diff-file-\d+-body$/)).toHaveCount(0);
|
||||
await expect(visiblePanel.getByTestId(/^diff-file-\d+-group-\d+$/)).toHaveCount(0);
|
||||
await visiblePanel.getByTestId("working-diff-toggle-expand-all").click();
|
||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
|
||||
await expect(diffGroups(visiblePanel).first()).toBeVisible();
|
||||
|
||||
await page.getByTestId("explorer-content-area").getByTestId("diff-file-0-toggle").click();
|
||||
await expect(
|
||||
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
|
||||
).toHaveCount(0);
|
||||
await expect(diffGroups(page.getByTestId("explorer-content-area"))).toHaveCount(0);
|
||||
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
|
||||
|
||||
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE);
|
||||
@@ -295,13 +518,9 @@ test("Changes switches between inline and full-tab navigation", async ({ page })
|
||||
|
||||
await changesTabToggle.click();
|
||||
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
|
||||
).toBeVisible();
|
||||
await expect(diffGroups(page.getByTestId("explorer-content-area")).first()).toBeVisible();
|
||||
await page.getByTestId("explorer-content-area").getByTestId("diff-file-0-toggle").click();
|
||||
await expect(
|
||||
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
|
||||
).toHaveCount(0);
|
||||
await expect(diffGroups(page.getByTestId("explorer-content-area"))).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("changes diff switches between flat and tree file lists", async ({ page }) => {
|
||||
@@ -327,15 +546,44 @@ test("changes diff switches between flat and tree file lists", async ({ page })
|
||||
await scrollToLowerUnwrappedDiffRows(page);
|
||||
await page.getByTestId("changes-toggle-view-mode").click();
|
||||
await expect(page.getByTestId("diff-folder-src")).toBeVisible();
|
||||
await expect(page.getByTestId("diff-folder-src").getByText("src", { exact: true })).toHaveCSS(
|
||||
"user-select",
|
||||
"none",
|
||||
);
|
||||
await expect(page.getByTestId("diff-file-0")).toBeVisible();
|
||||
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||
await expect(page.getByTestId("diff-folder-src-toggle").locator("svg")).toHaveCount(2);
|
||||
await expect(page.getByTestId("diff-file-0-toggle").locator("svg")).toHaveCount(1);
|
||||
const folderLabelBounds = await page
|
||||
.getByTestId("diff-folder-src")
|
||||
.getByText("src", { exact: true })
|
||||
.boundingBox();
|
||||
const fileLabelBounds = await page
|
||||
.getByTestId("diff-file-0")
|
||||
.getByText("use-mounted-tab-set.ts", { exact: true })
|
||||
.boundingBox();
|
||||
expect(folderLabelBounds).not.toBeNull();
|
||||
expect(fileLabelBounds).not.toBeNull();
|
||||
expect(fileLabelBounds!.x - folderLabelBounds!.x).toBeCloseTo(14, 0);
|
||||
|
||||
const folderToggle = page.getByTestId("diff-folder-src-toggle");
|
||||
await folderToggle.click();
|
||||
await expect(folderToggle).toHaveAttribute("aria-selected", "true");
|
||||
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
|
||||
await folderToggle.click();
|
||||
await expect(folderToggle).toHaveAttribute("aria-selected", "true");
|
||||
await expect(page.getByTestId("diff-file-0")).toBeVisible();
|
||||
|
||||
const fileToggle = page.getByTestId("diff-file-0-toggle");
|
||||
await fileToggle.click({ button: "right" });
|
||||
await expect(fileToggle).toHaveAttribute("aria-selected", "true");
|
||||
await expect(folderToggle).toHaveAttribute("aria-selected", "false");
|
||||
await expect(page.getByTestId("diff-file-0-context-menu")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await page.getByRole("button", { name: "Collapse all" }).click();
|
||||
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "Expand all" }).click();
|
||||
await expect(page.getByTestId("diff-file-0-body")).toBeVisible();
|
||||
await expect(diffGroups(page).first()).toBeVisible();
|
||||
|
||||
await page.getByTestId("diff-folder-src-toggle").click();
|
||||
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
|
||||
@@ -359,6 +607,90 @@ test("changes diff applies code size changes to gutter and code typography", asy
|
||||
await expectVisibleDiffRowsShareTypography(page);
|
||||
});
|
||||
|
||||
const LARGE_DIFF_WIDE_LINE_INDEX = 900;
|
||||
|
||||
async function createWorkspaceWithLargeUnwrappedDiff(): Promise<DirtyWorkspace> {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
const addedLines = Array.from({ length: 1_200 }, (_, index) => {
|
||||
if (index === 0) return `\t\t\t\tconst tabbed_${index} = "tabbed";`;
|
||||
if (index === LARGE_DIFF_WIDE_LINE_INDEX) {
|
||||
return `const wide_${index} = "${"界".repeat(80)}";`;
|
||||
}
|
||||
return `const generated_${index} = ${index};`;
|
||||
}).join("\n");
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"),
|
||||
`${AFTER}${addedLines}\n`,
|
||||
);
|
||||
return workspace;
|
||||
}
|
||||
|
||||
async function scrollToWideDiffLine(page: Page): Promise<Locator> {
|
||||
await page.getByTestId("git-diff-scroll").evaluate((element, lineIndex) => {
|
||||
element.scrollTop = lineIndex * 18;
|
||||
element.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||
}, LARGE_DIFF_WIDE_LINE_INDEX);
|
||||
const wideLine = page.getByText(new RegExp(`const wide_${LARGE_DIFF_WIDE_LINE_INDEX}`));
|
||||
await expect(wideLine).toHaveCount(1);
|
||||
return wideLine;
|
||||
}
|
||||
|
||||
test("unwrapped virtualized rows use one horizontal file surface", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithLargeUnwrappedDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
const horizontalSurface = page.getByTestId("diff-horizontal-scroll");
|
||||
await expect(horizontalSurface).toHaveCount(1);
|
||||
await expect(page.getByTestId(/^diff-file-0-group-\d+-scroll$/)).toHaveCount(0);
|
||||
const codeBefore = await page.getByTestId("diff-code-row-0").boundingBox();
|
||||
const gutterBefore = await page.getByTestId("diff-gutter-row-0").boundingBox();
|
||||
expect(codeBefore).not.toBeNull();
|
||||
expect(gutterBefore).not.toBeNull();
|
||||
await horizontalSurface.evaluate((element, left) => {
|
||||
element.scrollLeft = left;
|
||||
element.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||
}, 120);
|
||||
await expect.poll(() => horizontalSurface.evaluate((element) => element.scrollLeft)).toBe(120);
|
||||
await expect
|
||||
.poll(async () => (await page.getByTestId("diff-code-row-0").boundingBox())?.x ?? 0)
|
||||
.toBeLessThanOrEqual(codeBefore!.x - 119);
|
||||
await expect
|
||||
.poll(async () => (await page.getByTestId("diff-gutter-row-0").boundingBox())?.x ?? 0)
|
||||
.toBeCloseTo(gutterBefore!.x, 0);
|
||||
const initiallyMountedGroups = await readMountedDiffGroupIds(page);
|
||||
await page.getByTestId("git-diff-scroll").evaluate((element) => {
|
||||
element.scrollTop = Math.max(0, element.scrollHeight - element.clientHeight);
|
||||
element.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||
});
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const mountedGroups = await readMountedDiffGroupIds(page);
|
||||
return mountedGroups.some((group) => !initiallyMountedGroups.includes(group));
|
||||
})
|
||||
.toBe(true);
|
||||
await expect.poll(() => horizontalSurface.evaluate((element) => element.scrollLeft)).toBe(120);
|
||||
await expect(page.getByTestId(/^diff-file-0-group-\d+-scroll$/)).toHaveCount(0);
|
||||
|
||||
const wideLine = await scrollToWideDiffLine(page);
|
||||
await horizontalSurface.evaluate((element) => {
|
||||
element.scrollLeft = element.scrollWidth;
|
||||
element.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||
});
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const surfaceBounds = await horizontalSurface.boundingBox();
|
||||
const wideTextRight = await wideLine.evaluate((element) => {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(element);
|
||||
return range.getBoundingClientRect().right;
|
||||
});
|
||||
if (!surfaceBounds) return false;
|
||||
return wideTextRight <= surfaceBounds.x + surfaceBounds.width + 1;
|
||||
})
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
async function useCodeFont(page: Page, codeFontSize: number): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ settingsKey, fontSize }) => {
|
||||
@@ -384,6 +716,12 @@ async function useCodeFont(page: Page, codeFontSize: number): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
async function readMountedDiffGroupIds(page: Page): Promise<(string | null)[]> {
|
||||
return diffGroups(page).evaluateAll((groups) =>
|
||||
groups.map((group) => group.getAttribute("data-testid")),
|
||||
);
|
||||
}
|
||||
|
||||
async function useUnwrappedDiffLines(page: Page): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ preferencesKey }) => {
|
||||
@@ -432,21 +770,22 @@ async function readVisibleDiffRowGeometry(page: Page): Promise<{
|
||||
codeLineHeight: number;
|
||||
}[];
|
||||
}> {
|
||||
return page.locator("body").evaluate(({ ownerDocument }) => {
|
||||
const root = ownerDocument.querySelector('[data-testid="diff-file-0-body"]');
|
||||
if (!root) {
|
||||
return diffGroups(page).evaluateAll((groups) => {
|
||||
if (groups.length === 0) {
|
||||
throw new Error("Expanded diff body is not mounted");
|
||||
}
|
||||
|
||||
const readRows = (prefix: string, textPrefix: string) =>
|
||||
Array.from(root.querySelectorAll<HTMLElement>(`[data-testid^="${prefix}"]`)).map((row) => {
|
||||
const testId = row.getAttribute("data-testid") ?? "";
|
||||
const index = Number(testId.slice(prefix.length));
|
||||
const rect = row.getBoundingClientRect();
|
||||
const text = root.querySelector<HTMLElement>(`[data-testid="${textPrefix}${index}"]`);
|
||||
const lineHeight = text ? Number.parseFloat(getComputedStyle(text).lineHeight) : 0;
|
||||
return { index, top: rect.top, height: rect.height, lineHeight };
|
||||
});
|
||||
groups.flatMap((group) =>
|
||||
Array.from(group.querySelectorAll<HTMLElement>(`[data-testid^="${prefix}"]`)).map((row) => {
|
||||
const testId = row.getAttribute("data-testid") ?? "";
|
||||
const index = Number(testId.slice(prefix.length));
|
||||
const rect = row.getBoundingClientRect();
|
||||
const text = row.querySelector<HTMLElement>(`[data-testid="${textPrefix}${index}"]`);
|
||||
const lineHeight = text ? Number.parseFloat(getComputedStyle(text).lineHeight) : 0;
|
||||
return { index, top: rect.top, height: rect.height, lineHeight };
|
||||
}),
|
||||
);
|
||||
|
||||
const gutters = new Map(
|
||||
readRows("diff-gutter-row-", "diff-gutter-text-").map((row) => [row.index, row]),
|
||||
@@ -488,6 +827,15 @@ async function createWorkspaceWithMountedTabDiff(
|
||||
if (options.includeDeletedFile) {
|
||||
files.push({ path: "src/zz-deleted.ts", content: "export const deleted = true;\n" });
|
||||
}
|
||||
if (options.includeRenamedFile) {
|
||||
files.push({ path: "src/rename-source.ts", content: "export const renamed = true;\n" });
|
||||
}
|
||||
if (options.includeNestedFolders) {
|
||||
files.push(
|
||||
{ path: "src/zz-folder/root.ts", content: "export const root = 1;\n" },
|
||||
{ path: "src/zz-folder/nested/changed.ts", content: "export const nested = 1;\n" },
|
||||
);
|
||||
}
|
||||
const repo = await createTempGitRepo("changes-pane-", { files });
|
||||
const client = await connectSeedClient();
|
||||
cleanupTasks.push({
|
||||
@@ -498,9 +846,24 @@ async function createWorkspaceWithMountedTabDiff(
|
||||
});
|
||||
|
||||
await writeFile(path.join(repo.path, "src/use-mounted-tab-set.ts"), AFTER);
|
||||
if (options.includeUntrackedFile) {
|
||||
await writeFile(path.join(repo.path, "zz-untracked.txt"), "remove me\n");
|
||||
}
|
||||
if (options.includeDeletedFile) {
|
||||
await unlink(path.join(repo.path, "src/zz-deleted.ts"));
|
||||
}
|
||||
if (options.includeRenamedFile) {
|
||||
execFileSync("git", ["mv", "src/rename-source.ts", "src/zz-renamed.ts"], {
|
||||
cwd: repo.path,
|
||||
});
|
||||
}
|
||||
if (options.includeNestedFolders) {
|
||||
await writeFile(path.join(repo.path, "src/zz-folder/root.ts"), "export const root = 2;\n");
|
||||
await writeFile(
|
||||
path.join(repo.path, "src/zz-folder/nested/changed.ts"),
|
||||
"export const nested = 2;\n",
|
||||
);
|
||||
}
|
||||
const createdWorkspace = await client.createWorkspace({
|
||||
source: { kind: "directory", path: repo.path },
|
||||
});
|
||||
@@ -526,7 +889,7 @@ async function openChangesInVisibleExplorer(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
async function expectExpandedMountedTabDiff(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("diff-file-0-body")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(diffGroups(page).first()).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText("function createInitialMountedTabIds")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
@@ -565,15 +928,9 @@ async function returnToWorkspaceChanges(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
async function scrollToLowerUnwrappedDiffRows(page: Page): Promise<void> {
|
||||
const lastRowIndex = await page.getByTestId("diff-file-0-body").evaluate((root) => {
|
||||
const rows = Array.from(root.querySelectorAll<HTMLElement>('[data-testid^="diff-code-row-"]'));
|
||||
if (rows.length === 0) {
|
||||
throw new Error("No unwrapped code rows are mounted");
|
||||
}
|
||||
return Math.max(
|
||||
...rows.map((row) => Number((row.getAttribute("data-testid") ?? "").slice(14))),
|
||||
);
|
||||
});
|
||||
await page.getByTestId(`diff-code-row-${lastRowIndex}`).scrollIntoViewIfNeeded();
|
||||
await expect(page.getByTestId(`diff-code-row-${lastRowIndex}`)).toBeVisible();
|
||||
const groups = diffGroups(page);
|
||||
await expect.poll(() => groups.count()).toBeGreaterThan(1);
|
||||
const lastMountedGroup = groups.last();
|
||||
await lastMountedGroup.scrollIntoViewIfNeeded();
|
||||
await expect(lastMountedGroup.locator('[data-testid^="diff-code-row-"]').last()).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { openCommandCenter } from "../support/helpers/command-center";
|
||||
import {
|
||||
delayDirectorySuggestionResponses,
|
||||
expectOneLineTruncatedFileResult,
|
||||
expectStableCommandCenterLayout,
|
||||
failDirectorySuggestionRequests,
|
||||
startCommandCenterLayoutObservation,
|
||||
} from "../support/helpers/command-center-file-search";
|
||||
import { expectFileTabOpen } from "../support/helpers/file-explorer";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
|
||||
const FILE_PATH =
|
||||
"packages/app/src/command-center/features/workspace-file-search/layout-stability/needle-layout-stability-command-center.tsx";
|
||||
const FILE_NAME = "needle-layout-stability-command-center.tsx";
|
||||
const FILE_DIRECTORY =
|
||||
"packages/app/src/command-center/features/workspace-file-search/layout-stability";
|
||||
|
||||
function paseoSizedFiles(): Array<{ path: string; content: string }> {
|
||||
const files = Array.from({ length: 120 }, (_, index) => ({
|
||||
path: `packages/app/src/features/feature-${String(index).padStart(3, "0")}/index.ts`,
|
||||
content: `export const feature${index} = ${index};\n`,
|
||||
}));
|
||||
files.push({ path: FILE_PATH, content: "export const layoutNeedle = true;\n" });
|
||||
return files;
|
||||
}
|
||||
|
||||
async function seedMatchingWorkspaces(count: number): Promise<SeededWorkspace[]> {
|
||||
return Promise.all(
|
||||
Array.from({ length: count }, (_, index) =>
|
||||
seedWorkspace({
|
||||
repoPrefix: `command-center-file-search-error-${String(index).padStart(2, "0")}-`,
|
||||
title: `Home failure ${String(index).padStart(2, "0")}`,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
test.use({
|
||||
userAgent:
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/145.0 Safari/537.36",
|
||||
});
|
||||
|
||||
test("workspace file search stays geometrically stable through delayed loading and results", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const seeded = await seedWorkspace({
|
||||
repoPrefix: "command-center-file-search-",
|
||||
title: "Paseo-shaped file search",
|
||||
repo: { files: paseoSizedFiles() },
|
||||
});
|
||||
|
||||
try {
|
||||
await delayDirectorySuggestionResponses(page, 800);
|
||||
await gotoWorkspace(page, seeded.workspaceId);
|
||||
await page.keyboard.press("Meta+P");
|
||||
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
await expect(panel).toBeVisible({ timeout: 30_000 });
|
||||
await expect(panel.getByRole("button", { name: "Files" })).toBeVisible();
|
||||
await startCommandCenterLayoutObservation(page);
|
||||
|
||||
await panel.getByTestId("command-center-input").fill(FILE_NAME);
|
||||
const row = panel.getByRole("button", {
|
||||
name: `${FILE_NAME} ${FILE_DIRECTORY}`,
|
||||
});
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expectOneLineTruncatedFileResult(row, {
|
||||
filename: FILE_NAME,
|
||||
path: FILE_DIRECTORY,
|
||||
});
|
||||
|
||||
await panel.getByTestId("command-center-input").fill("needle-layout");
|
||||
await page.waitForTimeout(150);
|
||||
expect(await row.isVisible()).toBe(true);
|
||||
await expect(panel.getByTestId("command-center-file-search-loading")).toBeVisible();
|
||||
await expect(panel.getByText("Searching files...", { exact: true })).toHaveCount(0);
|
||||
await expect(panel.getByTestId("command-center-file-search-loading")).toBeHidden();
|
||||
await expectStableCommandCenterLayout(page);
|
||||
|
||||
await row.click();
|
||||
await expectFileTabOpen(page, FILE_PATH);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("dropping the files scope leaves the search row the same height", async ({ page }) => {
|
||||
const seeded = await seedWorkspace({
|
||||
repoPrefix: "command-center-file-search-scope-",
|
||||
title: "Scope chip height",
|
||||
});
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, seeded.workspaceId);
|
||||
await page.keyboard.press("Meta+P");
|
||||
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
await expect(panel).toBeVisible({ timeout: 30_000 });
|
||||
const header = panel.getByTestId("command-center-header");
|
||||
const scoped = await header.boundingBox();
|
||||
|
||||
await page.getByTestId("command-center-files-scope").click();
|
||||
await expect(page.getByTestId("command-center-files-scope")).toHaveCount(0);
|
||||
const unscoped = await header.boundingBox();
|
||||
|
||||
expect(scoped?.height).toBe(unscoped?.height);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("unscoped command search keeps commands visible and reports file-search failures", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const seeded = await seedMatchingWorkspaces(9);
|
||||
|
||||
try {
|
||||
await failDirectorySuggestionRequests(page);
|
||||
await gotoWorkspace(page, seeded[0].workspaceId);
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill("home");
|
||||
|
||||
await expect(panel.getByRole("button", { name: "Home", exact: true })).toBeVisible();
|
||||
await expect(panel.getByRole("button", { name: /Home failure 08/ })).toBeVisible();
|
||||
await expect(panel.getByTestId("command-center-file-search-error")).toHaveText(
|
||||
"Error: Test file search transport failure.",
|
||||
);
|
||||
await expect(panel.getByTestId("command-center-file-search-error")).toBeInViewport({
|
||||
ratio: 1,
|
||||
});
|
||||
await expect(panel.getByText("No matches", { exact: true })).toHaveCount(0);
|
||||
} finally {
|
||||
await Promise.allSettled(seeded.map((workspace) => workspace.cleanup()));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { openCommandCenter } from "../support/helpers/command-center";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
|
||||
const GROUP_BY_STATUS = "Group by status";
|
||||
const GROUP_BY_PROJECT = "Group by project";
|
||||
|
||||
// Result rows carry no testID of their own, so the entries are addressed by their visible label.
|
||||
async function runGroupingEntry(page: Page, label: string, absent: string): Promise<void> {
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill("group");
|
||||
|
||||
const entry = panel.getByText(label, { exact: true });
|
||||
await expect(entry).toBeVisible({ timeout: 30_000 });
|
||||
// The entry always names the mode you are not in, so its opposite must never be listed.
|
||||
await expect(panel.getByText(absent, { exact: true })).toHaveCount(0);
|
||||
|
||||
await entry.click();
|
||||
await expect(page.getByTestId("command-center-panel")).not.toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
test.describe("Command center sidebar grouping", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("flips sidebar grouping and persists the choice across a reload", async ({ page }) => {
|
||||
const seeded = await seedWorkspace({ repoPrefix: "command-center-grouping-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
const projectList = page.getByTestId("sidebar-project-workspace-list-scroll");
|
||||
const statusList = page.getByTestId("sidebar-status-list-scroll");
|
||||
await expect(projectList).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Query-only: the grouping entry stays out of the default empty-query list.
|
||||
const panel = await openCommandCenter(page);
|
||||
await expect(panel.getByText(GROUP_BY_STATUS, { exact: true })).toHaveCount(0);
|
||||
await expect(panel.getByText(GROUP_BY_PROJECT, { exact: true })).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(panel).not.toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await runGroupingEntry(page, GROUP_BY_STATUS, GROUP_BY_PROJECT);
|
||||
await expect(statusList).toBeVisible({ timeout: 30_000 });
|
||||
await expect(projectList).toHaveCount(0);
|
||||
|
||||
// The only assertion that catches a flip which never reached persisted storage.
|
||||
await page.reload();
|
||||
await expect(statusList).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Grouping persists, so the run must leave the sidebar back in project mode.
|
||||
await runGroupingEntry(page, GROUP_BY_PROJECT, GROUP_BY_STATUS);
|
||||
await expect(projectList).toBeVisible({ timeout: 30_000 });
|
||||
await expect(statusList).toHaveCount(0);
|
||||
} finally {
|
||||
await seeded.cleanup().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -55,7 +55,7 @@ test("commit history shows dates and shares diff layout preferences", async ({
|
||||
await layoutToggle.click();
|
||||
await expect(layoutToggle).toHaveAccessibleName("Switch to unified diff");
|
||||
await expect(panel.getByTestId("diff-code-row-0")).toHaveCount(0);
|
||||
await expect(panel.getByTestId("diff-file-0-body")).toBeVisible();
|
||||
await expect(panel.getByTestId(/^diff-file-0-group-\d+$/).first()).toBeVisible();
|
||||
|
||||
await page.getByTestId(/^workspace-commit-diff-close-/).click();
|
||||
await expect(panel).toHaveCount(0);
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { openFileExplorer } from "../support/helpers/file-explorer";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
|
||||
let workspace: SeededWorkspace;
|
||||
|
||||
async function failNextDeleteRequest(page: Page): Promise<void> {
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (browserSocket) => {
|
||||
const serverSocket = browserSocket.connectToServer();
|
||||
browserSocket.onMessage((message) => {
|
||||
if (typeof message === "string") {
|
||||
const envelope = JSON.parse(message) as {
|
||||
message?: { type?: string; cwd?: string; path?: string; requestId?: string };
|
||||
};
|
||||
if (envelope.message?.type === "fs.entry.delete.request") {
|
||||
browserSocket.send(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "fs.entry.delete.response",
|
||||
payload: {
|
||||
cwd: envelope.message.cwd,
|
||||
path: envelope.message.path,
|
||||
success: false,
|
||||
error: "Injected delete failure",
|
||||
requestId: envelope.message.requestId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
serverSocket.send(message);
|
||||
});
|
||||
serverSocket.onMessage((message) => browserSocket.send(message));
|
||||
});
|
||||
}
|
||||
|
||||
async function hideContextActionCapabilities(page: Page): Promise<void> {
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (browserSocket) => {
|
||||
const serverSocket = browserSocket.connectToServer();
|
||||
browserSocket.onMessage((message) => serverSocket.send(message));
|
||||
serverSocket.onMessage((message) => {
|
||||
if (typeof message !== "string") {
|
||||
browserSocket.send(message);
|
||||
return;
|
||||
}
|
||||
const envelope = JSON.parse(message) as {
|
||||
message?: {
|
||||
type?: string;
|
||||
payload?: { status?: string; features?: Record<string, unknown> };
|
||||
};
|
||||
};
|
||||
if (
|
||||
envelope.message?.type === "status" &&
|
||||
envelope.message.payload?.status === "server_info"
|
||||
) {
|
||||
envelope.message.payload.features = {
|
||||
...envelope.message.payload.features,
|
||||
fsEntryOps: false,
|
||||
fsEntryDuplicate: false,
|
||||
checkoutDiscardChanges: false,
|
||||
};
|
||||
}
|
||||
browserSocket.send(JSON.stringify(envelope));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function disconnectNextContextAction(page: Page): Promise<{ wait: () => Promise<void> }> {
|
||||
let resolveRequest: (() => void) | undefined;
|
||||
const requestSeen = new Promise<void>((resolve) => {
|
||||
resolveRequest = resolve;
|
||||
});
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (browserSocket) => {
|
||||
const serverSocket = browserSocket.connectToServer();
|
||||
browserSocket.onMessage((message) => {
|
||||
if (typeof message === "string") {
|
||||
const envelope = JSON.parse(message) as { message?: { type?: string } };
|
||||
const type = envelope.message?.type;
|
||||
if (type?.startsWith("fs.entry.") || type === "checkout.discard_changes.request") {
|
||||
resolveRequest?.();
|
||||
void browserSocket.close({
|
||||
code: 1008,
|
||||
reason: "Context action connection failed",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
serverSocket.send(message);
|
||||
});
|
||||
serverSocket.onMessage((message) => browserSocket.send(message));
|
||||
});
|
||||
|
||||
return { wait: () => requestSeen };
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
workspace = await seedWorkspace({ repoPrefix: "file-explorer-context-actions-" });
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await workspace?.cleanup();
|
||||
});
|
||||
|
||||
test("creates, renames, copies, and deletes entries through the file explorer", async ({
|
||||
context,
|
||||
page,
|
||||
}) => {
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openFileExplorer(page);
|
||||
|
||||
const tree = page.getByTestId("file-explorer-tree-scroll");
|
||||
const entry = (name: string) => tree.getByText(name, { exact: true }).first();
|
||||
const nameInput = page.getByTestId("file-explorer-name-input");
|
||||
|
||||
await page.getByTestId("files-new-folder").click();
|
||||
await expect(nameInput).toBeVisible();
|
||||
await nameInput.press("Tab");
|
||||
await expect(nameInput).toBeHidden();
|
||||
|
||||
await page.getByTestId("files-new-file").click();
|
||||
await nameInput.fill("README.md");
|
||||
await nameInput.press("Enter");
|
||||
await expect(page.getByText('"README.md" already exists', { exact: true })).toBeVisible();
|
||||
|
||||
const emptyArea = page.getByTestId("files-empty-area");
|
||||
await expect(emptyArea).toBeVisible();
|
||||
const emptyAreaBounds = await emptyArea.boundingBox();
|
||||
expect(emptyAreaBounds).not.toBeNull();
|
||||
await emptyArea.click({
|
||||
button: "right",
|
||||
position: { x: emptyAreaBounds!.width / 2, y: emptyAreaBounds!.height - 24 },
|
||||
});
|
||||
await expect(page.getByTestId("files-empty-area-new-file")).toBeVisible();
|
||||
await page.getByTestId("files-empty-area-new-folder").click();
|
||||
await nameInput.fill("folder");
|
||||
await nameInput.press("Enter");
|
||||
await expect(entry("folder")).toBeVisible();
|
||||
|
||||
await entry("folder").click({ button: "right" });
|
||||
await page.getByText("New file", { exact: true }).click();
|
||||
await nameInput.fill("child.txt");
|
||||
await nameInput.press("Enter");
|
||||
await expect(entry("child.txt")).toBeVisible();
|
||||
|
||||
await entry("folder").click({ button: "right" });
|
||||
await page.getByText("New folder", { exact: true }).click();
|
||||
await nameInput.fill("nested");
|
||||
await nameInput.press("Enter");
|
||||
await entry("nested").click({ button: "right" });
|
||||
await page.getByText("New folder", { exact: true }).click();
|
||||
await nameInput.fill("deep");
|
||||
await nameInput.press("Enter");
|
||||
await expect(entry("deep")).toBeVisible();
|
||||
|
||||
await entry("folder").click({ button: "right" });
|
||||
await page.getByText("Collapse folder", { exact: true }).click();
|
||||
await expect(entry("child.txt")).toBeHidden();
|
||||
await expect(entry("nested")).toBeHidden();
|
||||
await expect(entry("deep")).toBeHidden();
|
||||
|
||||
await entry("folder").click();
|
||||
await expect(entry("child.txt")).toBeVisible();
|
||||
await expect(entry("nested")).toBeVisible();
|
||||
await expect(entry("deep")).toBeHidden();
|
||||
await entry("nested").click();
|
||||
await expect(entry("deep")).toBeVisible();
|
||||
|
||||
await page.getByTestId("files-new-file").click();
|
||||
await nameInput.fill("created.txt");
|
||||
await nameInput.press("Enter");
|
||||
await expect(entry("created.txt")).toBeVisible();
|
||||
await expect(entry("created.txt")).toHaveCSS("user-select", "none");
|
||||
await entry("created.txt").dblclick();
|
||||
expect(await page.evaluate(() => window.getSelection()?.toString() ?? "")).toBe("");
|
||||
|
||||
const folderRow = entry("folder").locator(
|
||||
"xpath=ancestor::*[starts-with(@data-testid, 'file-explorer-row-')][1]",
|
||||
);
|
||||
const fileRow = entry("created.txt").locator(
|
||||
"xpath=ancestor::*[starts-with(@data-testid, 'file-explorer-row-')][1]",
|
||||
);
|
||||
await expect(folderRow.locator("svg")).toHaveCount(2);
|
||||
await expect(fileRow.locator("svg")).toHaveCount(1);
|
||||
const gitRow = entry(".git").locator(
|
||||
"xpath=ancestor::*[starts-with(@data-testid, 'file-explorer-row-')][1]",
|
||||
);
|
||||
const collapsedChevronBounds = await gitRow.locator("svg").first().boundingBox();
|
||||
const expandedChevronBounds = await folderRow.locator("svg").first().boundingBox();
|
||||
expect(collapsedChevronBounds).not.toBeNull();
|
||||
expect(expandedChevronBounds).not.toBeNull();
|
||||
const collapsedChevronCenter = collapsedChevronBounds!.x + collapsedChevronBounds!.width / 2;
|
||||
const expandedChevronCenter = expandedChevronBounds!.x + expandedChevronBounds!.width / 2;
|
||||
expect(collapsedChevronCenter).toBeCloseTo(expandedChevronCenter, 0);
|
||||
const folderLabelBounds = await entry("folder").boundingBox();
|
||||
const fileLabelBounds = await entry("created.txt").boundingBox();
|
||||
expect(folderLabelBounds).not.toBeNull();
|
||||
expect(fileLabelBounds).not.toBeNull();
|
||||
expect(fileLabelBounds!.x).toBeCloseTo(folderLabelBounds!.x, 0);
|
||||
|
||||
await folderRow.click();
|
||||
await expect(folderRow).toHaveAttribute("aria-selected", "true");
|
||||
await expect(entry("child.txt")).toBeHidden();
|
||||
await folderRow.click();
|
||||
await expect(folderRow).toHaveAttribute("aria-selected", "true");
|
||||
await expect(entry("child.txt")).toBeVisible();
|
||||
|
||||
await entry("folder").click({ button: "right" });
|
||||
await page.getByText("Rename", { exact: true }).click();
|
||||
await nameInput.fill("renamed-folder");
|
||||
await nameInput.press("Tab");
|
||||
await expect(entry("renamed-folder")).toBeVisible();
|
||||
await expect(entry("folder")).toBeHidden();
|
||||
await expect(entry("child.txt")).toBeVisible();
|
||||
const renamedFolderRow = entry("renamed-folder").locator(
|
||||
"xpath=ancestor::*[starts-with(@data-testid, 'file-explorer-row-')][1]",
|
||||
);
|
||||
await expect(renamedFolderRow).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
await entry("created.txt").click({ button: "right" });
|
||||
await expect(fileRow).toHaveAttribute("aria-selected", "true");
|
||||
await expect(renamedFolderRow).toHaveAttribute("aria-selected", "false");
|
||||
const menu = page.getByTestId(/file-explorer-row-\d+-context-menu$/);
|
||||
await expect(menu).toBeVisible();
|
||||
|
||||
await page.getByText("Copy path", { exact: true }).click({ button: "right" });
|
||||
await expect(menu).toBeVisible();
|
||||
|
||||
const backdrop = page.getByTestId(/file-explorer-row-\d+-context-menu-backdrop$/);
|
||||
await backdrop.click({ button: "right" });
|
||||
await expect(menu).toBeHidden();
|
||||
|
||||
await entry("created.txt").click({ button: "right" });
|
||||
await page.getByText("Rename", { exact: true }).click();
|
||||
await expect(nameInput).toHaveValue("created.txt");
|
||||
await nameInput.press("Escape");
|
||||
await expect(entry("created.txt")).toBeVisible();
|
||||
|
||||
await entry("created.txt").click({ button: "right" });
|
||||
await page.getByText("Rename", { exact: true }).click();
|
||||
await nameInput.fill("README.md");
|
||||
await nameInput.press("Enter");
|
||||
await expect(page.getByText('"README.md" already exists', { exact: true })).toBeVisible();
|
||||
await expect(entry("created.txt")).toBeVisible();
|
||||
|
||||
await entry("created.txt").click({ button: "right" });
|
||||
await page.getByText("Rename", { exact: true }).click();
|
||||
await nameInput.fill("renamed.txt");
|
||||
await nameInput.press("Tab");
|
||||
await expect(entry("renamed.txt")).toBeVisible();
|
||||
await expect(entry("created.txt")).toBeHidden();
|
||||
|
||||
await entry("renamed.txt").click({ button: "right" });
|
||||
await page.getByText("Copy relative path", { exact: true }).click();
|
||||
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe("renamed.txt");
|
||||
|
||||
await entry("renamed.txt").click({ button: "right" });
|
||||
const deleteAction = page.getByTestId(/file-explorer-row-\d+-delete$/);
|
||||
const deleteLabelColor = await deleteAction
|
||||
.getByText("Delete", { exact: true })
|
||||
.evaluate((element) => getComputedStyle(element).color);
|
||||
await expect(deleteAction.locator("svg")).toHaveCSS("stroke", deleteLabelColor);
|
||||
const cancelledConfirmation = new Promise<string>((resolve) => {
|
||||
page.once("dialog", async (dialog) => {
|
||||
const message = dialog.message();
|
||||
await dialog.dismiss();
|
||||
resolve(message);
|
||||
});
|
||||
});
|
||||
await page.getByText("Delete", { exact: true }).click();
|
||||
expect(await cancelledConfirmation).toContain("renamed.txt");
|
||||
await expect(entry("renamed.txt")).toBeVisible();
|
||||
|
||||
await entry("renamed.txt").click({ button: "right" });
|
||||
const confirmation = new Promise<string>((resolve) => {
|
||||
page.once("dialog", async (dialog) => {
|
||||
const message = dialog.message();
|
||||
await dialog.accept();
|
||||
resolve(message);
|
||||
});
|
||||
});
|
||||
await page.getByText("Delete", { exact: true }).click();
|
||||
expect(await confirmation).toContain("renamed.txt");
|
||||
await expect(entry("renamed.txt")).toBeHidden();
|
||||
|
||||
await entry("renamed-folder").click({ button: "right" });
|
||||
const folderConfirmation = new Promise<string>((resolve) => {
|
||||
page.once("dialog", async (dialog) => {
|
||||
const message = dialog.message();
|
||||
await dialog.accept();
|
||||
resolve(message);
|
||||
});
|
||||
});
|
||||
await page.getByText("Delete", { exact: true }).click();
|
||||
expect(await folderConfirmation).toContain("renamed-folder");
|
||||
await expect(entry("renamed-folder")).toBeHidden();
|
||||
await expect(entry("child.txt")).toBeHidden();
|
||||
});
|
||||
|
||||
test("duplicates files and folders with collision-free names", async ({ page }) => {
|
||||
const sourceFilePath = path.join(workspace.repoPath, "duplicate-source.txt");
|
||||
const sourceFolderPath = path.join(workspace.repoPath, "duplicate-folder");
|
||||
await writeFile(sourceFilePath, "duplicate contents", "utf8");
|
||||
await mkdir(sourceFolderPath);
|
||||
await writeFile(path.join(sourceFolderPath, "nested.txt"), "nested contents", "utf8");
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openFileExplorer(page);
|
||||
|
||||
const tree = page.getByTestId("file-explorer-tree-scroll");
|
||||
const entry = (name: string) => tree.getByText(name, { exact: true }).first();
|
||||
|
||||
await entry("duplicate-source.txt").click({ button: "right" });
|
||||
await page.getByTestId(/file-explorer-row-\d+-duplicate$/).click();
|
||||
await expect(entry("duplicate-source copy.txt")).toBeVisible();
|
||||
await expect
|
||||
.poll(() => readFile(path.join(workspace.repoPath, "duplicate-source copy.txt"), "utf8"))
|
||||
.toBe("duplicate contents");
|
||||
|
||||
await entry("duplicate-source.txt").click({ button: "right" });
|
||||
await page.getByTestId(/file-explorer-row-\d+-duplicate$/).click();
|
||||
await expect(entry("duplicate-source copy 2.txt")).toBeVisible();
|
||||
|
||||
await entry("duplicate-folder").click({ button: "right" });
|
||||
await page.getByTestId(/file-explorer-row-\d+-duplicate$/).click();
|
||||
await expect(entry("duplicate-folder copy")).toBeVisible();
|
||||
await expect
|
||||
.poll(() =>
|
||||
readFile(path.join(workspace.repoPath, "duplicate-folder copy", "nested.txt"), "utf8"),
|
||||
)
|
||||
.toBe("nested contents");
|
||||
} finally {
|
||||
await Promise.all([
|
||||
rm(sourceFilePath, { force: true }),
|
||||
rm(path.join(workspace.repoPath, "duplicate-source copy.txt"), { force: true }),
|
||||
rm(path.join(workspace.repoPath, "duplicate-source copy 2.txt"), { force: true }),
|
||||
rm(sourceFolderPath, { recursive: true, force: true }),
|
||||
rm(path.join(workspace.repoPath, "duplicate-folder copy"), { recursive: true, force: true }),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
test("shows an actionable error when the connection drops during creation", async ({ page }) => {
|
||||
const disconnect = await disconnectNextContextAction(page);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openFileExplorer(page);
|
||||
|
||||
await page.getByTestId("files-new-file").click();
|
||||
const nameInput = page.getByTestId("file-explorer-name-input");
|
||||
await nameInput.fill("disconnected.txt");
|
||||
await nameInput.press("Enter");
|
||||
await disconnect.wait();
|
||||
|
||||
await expect(page.getByText(/Context action connection failed/i)).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(
|
||||
page.getByTestId("file-explorer-tree-scroll").getByText("disconnected.txt", { exact: true }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("keeps an entry visible when deletion fails", async ({ page }) => {
|
||||
await failNextDeleteRequest(page);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openFileExplorer(page);
|
||||
|
||||
const readme = page
|
||||
.getByTestId("file-explorer-tree-scroll")
|
||||
.getByText("README.md", { exact: true });
|
||||
await readme.click({ button: "right" });
|
||||
const confirmation = new Promise<void>((resolve) => {
|
||||
page.once("dialog", async (dialog) => {
|
||||
await dialog.accept();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
await page.getByText("Delete", { exact: true }).click();
|
||||
await confirmation;
|
||||
|
||||
await expect(page.getByText("Injected delete failure", { exact: true })).toBeVisible();
|
||||
await expect(readme).toBeVisible();
|
||||
});
|
||||
|
||||
test("hides unsupported file operations and revert actions", async ({ page }) => {
|
||||
await hideContextActionCapabilities(page);
|
||||
await writeFile(path.join(workspace.repoPath, "README.md"), "# Changed while unsupported\n");
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openFileExplorer(page);
|
||||
|
||||
await expect(page.getByTestId("files-new-file")).toHaveCount(0);
|
||||
await expect(page.getByTestId("files-new-folder")).toHaveCount(0);
|
||||
await page
|
||||
.getByTestId("file-explorer-tree-scroll")
|
||||
.getByText("README.md", { exact: true })
|
||||
.click({ button: "right" });
|
||||
const filesMenu = page.getByTestId(/file-explorer-row-\d+-context-menu$/);
|
||||
await expect(filesMenu.getByText("Rename", { exact: true })).toHaveCount(0);
|
||||
await expect(filesMenu.getByText("Duplicate", { exact: true })).toHaveCount(0);
|
||||
await expect(filesMenu.getByText("Delete", { exact: true })).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await page.getByTestId("explorer-tab-changes").click();
|
||||
await expect(page.getByTestId("diff-file-0")).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||
await expect(page.getByTestId("diff-file-0-duplicate")).toHaveCount(0);
|
||||
await expect(page.getByTestId("diff-file-0-revert")).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
expectCompletedDiagram,
|
||||
expectDiagramRemainsRenderedWhileStreaming,
|
||||
expectDiagramWithLabels,
|
||||
reloadConversation,
|
||||
requestDiagram,
|
||||
waitForDiagramTurnToComplete,
|
||||
} from "../support/helpers/diagram";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
const STREAMED_MERMAID = [
|
||||
"```mermaid",
|
||||
"flowchart LR",
|
||||
" Start --> Middle",
|
||||
' Middle --> Done["<i>Done</i>"]',
|
||||
" Middle --> Review",
|
||||
" Review --> Verify",
|
||||
" Verify --> Ship",
|
||||
" Ship --> Package",
|
||||
" Package --> Sign",
|
||||
" Sign --> Upload",
|
||||
" Upload --> Publish",
|
||||
" Publish --> Deploy",
|
||||
" Deploy --> Observe",
|
||||
" Observe --> Validate",
|
||||
" Validate --> Announce",
|
||||
" Announce --> Document",
|
||||
" Document --> Archive",
|
||||
" Archive --> Release",
|
||||
"```",
|
||||
].join("\n");
|
||||
|
||||
test("keeps a Mermaid diagram rendered while its message streams, completes, and reloads", async ({
|
||||
page,
|
||||
}) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "mermaid-streaming-",
|
||||
title: "Streaming Mermaid",
|
||||
featureValues: {
|
||||
mockStreamingAssistantResponse: STREAMED_MERMAID,
|
||||
mockStreamingAssistantIntervalMs: 150,
|
||||
},
|
||||
});
|
||||
try {
|
||||
await test.step("Open the conversation and request a diagram", async () => {
|
||||
await openAgentRoute(page, agent);
|
||||
await requestDiagram(agent);
|
||||
});
|
||||
|
||||
await test.step("The diagram renders and stays rendered while tokens arrive", async () => {
|
||||
await expectDiagramWithLabels(page, ["Start", "Middle"]);
|
||||
await expectDiagramRemainsRenderedWhileStreaming(page);
|
||||
});
|
||||
|
||||
await test.step("The completed diagram shows the final streamed content", async () => {
|
||||
await waitForDiagramTurnToComplete(agent);
|
||||
await expectCompletedDiagram(page, ["Start", "Done", "Release"]);
|
||||
});
|
||||
|
||||
await test.step("The completed diagram remains rendered after reload", async () => {
|
||||
await reloadConversation(page);
|
||||
await expectCompletedDiagram(page, ["Start", "Done", "Release"]);
|
||||
});
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||
import {
|
||||
expectSettingsHeader,
|
||||
openHostSection,
|
||||
openSettingsHost,
|
||||
} from "../support/helpers/settings";
|
||||
|
||||
async function openMetadataGenerationSettings(page: Page) {
|
||||
const serverId = getServerId();
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, serverId);
|
||||
await openHostSection(page, serverId, "metadata");
|
||||
await expectSettingsHeader(page, "Metadata");
|
||||
}
|
||||
|
||||
async function openManualMetadataModelPicker(page: Page) {
|
||||
await page.getByRole("button", { name: "Manual", exact: true }).click();
|
||||
await page.getByRole("button", { name: /Select model/ }).click();
|
||||
}
|
||||
|
||||
test("chooses a metadata model and can return to automatic selection", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await openMetadataGenerationSettings(page);
|
||||
|
||||
await expect(page.getByText("Workspace titles", { exact: false })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Automatic", exact: true })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("metadata-automatic.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
|
||||
await openManualMetadataModelPicker(page);
|
||||
await page.getByText("Mock Load Test", { exact: true }).click();
|
||||
await expect(page.getByText("Ten second stream", { exact: true })).toBeVisible();
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("metadata-model-picker.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
await page.getByText("Ten second stream", { exact: true }).click();
|
||||
|
||||
await expect(page.getByRole("button", { name: /Ten second stream/ })).toBeVisible();
|
||||
await page.reload();
|
||||
await expect(page.getByRole("button", { name: "Manual", exact: true })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await expect(page.getByRole("button", { name: /Ten second stream/ })).toBeVisible();
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("metadata-manual-persisted.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
|
||||
await page.getByRole("button", { name: "Automatic", exact: true }).click();
|
||||
await page.reload();
|
||||
|
||||
await expect(page.getByRole("button", { name: "Automatic", exact: true })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await expect(page.getByRole("button", { name: /Ten second stream/ })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("replaces only the first configured metadata model", async ({ page }) => {
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previousConfig = await client.getDaemonConfig();
|
||||
|
||||
try {
|
||||
await client.patchDaemonConfig({
|
||||
metadataGeneration: {
|
||||
providers: [
|
||||
{ provider: "mock", model: "five-minute-stream" },
|
||||
{ provider: "mock", model: "thirty-minute-stream" },
|
||||
],
|
||||
},
|
||||
});
|
||||
await openMetadataGenerationSettings(page);
|
||||
|
||||
await expect(page.getByRole("button", { name: "Manual", exact: true })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await openManualMetadataModelPicker(page);
|
||||
await page.getByText("Ten second stream", { exact: true }).click();
|
||||
|
||||
await expect
|
||||
.poll(async () => (await client.getDaemonConfig()).config.metadataGeneration.providers)
|
||||
.toEqual([
|
||||
{ provider: "mock", model: "ten-second-stream" },
|
||||
{ provider: "mock", model: "thirty-minute-stream" },
|
||||
]);
|
||||
} finally {
|
||||
try {
|
||||
await client.patchDaemonConfig({
|
||||
metadataGeneration: {
|
||||
providers: previousConfig.config.metadataGeneration.providers,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
closeModelPicker,
|
||||
expectModelSearchEmptyState,
|
||||
expectModelPickerHeight,
|
||||
expectModelPickerWidth,
|
||||
expectModelSearchResult,
|
||||
expectPinnedProfilesHidden,
|
||||
openModelPicker,
|
||||
readModelPickerHeight,
|
||||
readModelPickerWidth,
|
||||
searchAllModels,
|
||||
seedAgentProfiles,
|
||||
seedModelProvider,
|
||||
expectSearchResultsVirtualized,
|
||||
} from "../support/helpers/agent-profiles";
|
||||
import { expectComposerVisible } from "../support/helpers/composer";
|
||||
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
|
||||
const MOCK_PROVIDER_LABEL = "Mock Load Test";
|
||||
|
||||
// Its fast model deliberately carries the same label as the mock provider's, so
|
||||
// the only thing telling the two result rows apart is the provider name.
|
||||
const STUDIO = {
|
||||
id: "mock-studio",
|
||||
label: "Mock Studio",
|
||||
models: [
|
||||
{ id: "studio-fast", label: "Ten second stream", description: "Studio quick pass" },
|
||||
{ id: "studio-deep", label: "Studio deep think", description: "Studio long pass" },
|
||||
],
|
||||
};
|
||||
|
||||
// The cross-provider search lives on the picker's root view, and a profile is
|
||||
// what keeps the picker opening there.
|
||||
const ROOT_VIEW_PROFILE = {
|
||||
id: "agent_profile_e2e_search_root",
|
||||
name: "Search anchor",
|
||||
provider: "mock",
|
||||
};
|
||||
|
||||
const LARGE_CATALOG_SIZE = 200;
|
||||
const LARGE_CATALOG = {
|
||||
id: "mock-bulk",
|
||||
label: "Mock Bulk",
|
||||
models: Array.from({ length: LARGE_CATALOG_SIZE }, (_, index) => ({
|
||||
id: `bulk-${index}`,
|
||||
label: `Bulk model ${index}`,
|
||||
description: `Bulk search result ${index}`,
|
||||
})),
|
||||
};
|
||||
|
||||
test.describe("Cross-provider model search", () => {
|
||||
test("one query over the picker root reaches every provider and names each result's provider", async ({
|
||||
page,
|
||||
}) => {
|
||||
const provider = await seedModelProvider(STUDIO);
|
||||
const profile = await seedAgentProfiles([ROOT_VIEW_PROFILE]);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "model-search-providers-" });
|
||||
|
||||
try {
|
||||
await test.step("open a draft composer that can reach every provider", async () => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
await openModelPicker(page);
|
||||
});
|
||||
const restingWidth = await readModelPickerWidth(page);
|
||||
|
||||
await test.step("one query returns the same model label from two providers", async () => {
|
||||
await searchAllModels(page, "ten second stream");
|
||||
await expectModelSearchResult(page, {
|
||||
provider: "mock",
|
||||
modelId: "ten-second-stream",
|
||||
modelLabel: "Ten second stream",
|
||||
providerLabel: MOCK_PROVIDER_LABEL,
|
||||
});
|
||||
await expectModelSearchResult(page, {
|
||||
provider: STUDIO.id,
|
||||
modelId: "studio-fast",
|
||||
modelLabel: "Ten second stream",
|
||||
providerLabel: STUDIO.label,
|
||||
});
|
||||
await expectModelPickerWidth(page, restingWidth);
|
||||
});
|
||||
|
||||
await test.step("searching by provider name reaches that provider's own models", async () => {
|
||||
await searchAllModels(page, "studio deep");
|
||||
await expectModelSearchResult(page, {
|
||||
provider: STUDIO.id,
|
||||
modelId: "studio-deep",
|
||||
modelLabel: "Studio deep think",
|
||||
providerLabel: STUDIO.label,
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("results replace the pinned profiles", async () => {
|
||||
await expectPinnedProfilesHidden(page);
|
||||
});
|
||||
|
||||
// Search falls back to subsequence matching, so "no matches" needs letters
|
||||
// that cannot be picked out of a model label or description in order.
|
||||
await test.step("a query with no matches repeats the query back", async () => {
|
||||
await searchAllModels(page, "zzz");
|
||||
await expectModelSearchEmptyState(page, "zzz");
|
||||
await closeModelPicker(page);
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
await profile.restore();
|
||||
await provider.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("desktop search keeps its frame stable and virtualizes a host-wide catalog", async ({
|
||||
page,
|
||||
}) => {
|
||||
const provider = await seedModelProvider(LARGE_CATALOG);
|
||||
const profile = await seedAgentProfiles([ROOT_VIEW_PROFILE]);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "model-search-large-catalog-" });
|
||||
|
||||
try {
|
||||
await test.step("open the host-wide model picker", async () => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
await openModelPicker(page);
|
||||
});
|
||||
|
||||
const restingHeight = await readModelPickerHeight(page);
|
||||
const restingWidth = await readModelPickerWidth(page);
|
||||
|
||||
await test.step("a broad query renders only the visible result window", async () => {
|
||||
await searchAllModels(page, "bulk model");
|
||||
await expectSearchResultsVirtualized(page, {
|
||||
provider: LARGE_CATALOG.id,
|
||||
total: LARGE_CATALOG_SIZE,
|
||||
});
|
||||
await expectModelPickerHeight(page, restingHeight);
|
||||
await expectModelPickerWidth(page, restingWidth);
|
||||
});
|
||||
|
||||
await test.step("narrowing to one result does not resize the picker", async () => {
|
||||
await searchAllModels(page, "bulk model 199");
|
||||
await expectModelSearchResult(page, {
|
||||
provider: LARGE_CATALOG.id,
|
||||
modelId: "bulk-199",
|
||||
modelLabel: "Bulk model 199",
|
||||
providerLabel: LARGE_CATALOG.label,
|
||||
});
|
||||
await expectModelPickerHeight(page, restingHeight);
|
||||
await expectModelPickerWidth(page, restingWidth);
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
await profile.restore();
|
||||
await provider.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { connectSeedClient, type SeedDaemonClient } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { addConnectedHostsAndReload } from "../support/helpers/hosts";
|
||||
import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui";
|
||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||
|
||||
@@ -29,6 +30,7 @@ interface CreatedProject {
|
||||
|
||||
interface CrossHostProjectScenario {
|
||||
contextWorkspaceId: string;
|
||||
primarySharedWorkspaceId: string;
|
||||
selectedProjectViewKey: string;
|
||||
sharedProjectKey: string;
|
||||
secondaryHost: {
|
||||
@@ -149,6 +151,7 @@ const test = base.extend<{ crossHostProject: CrossHostProjectScenario }>({
|
||||
|
||||
await provide({
|
||||
contextWorkspaceId: contextProject.workspaceId,
|
||||
primarySharedWorkspaceId: primarySharedProject.workspaceId,
|
||||
selectedProjectViewKey: projectPlacementViewKey(
|
||||
getServerId(),
|
||||
primarySharedProject.projectId,
|
||||
@@ -196,4 +199,33 @@ test.describe("New workspace host project preservation", () => {
|
||||
|
||||
await expectNewWorkspaceProjectSelected(page, SHARED_PROJECT_NAME);
|
||||
});
|
||||
|
||||
test("keeps the active workspace project selected when switching hosts", async ({
|
||||
page,
|
||||
crossHostProject,
|
||||
}) => {
|
||||
await openProjectDirectoryWithHosts(page, {
|
||||
hosts: [crossHostProject.secondaryHost],
|
||||
primaryLabel: PRIMARY_HOST_LABEL,
|
||||
});
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
workspaceId: crossHostProject.primarySharedWorkspaceId,
|
||||
});
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await expectNewWorkspaceProjectSelected(page, SHARED_PROJECT_NAME);
|
||||
|
||||
await selectNewWorkspaceHost(page, SECONDARY_HOST_LABEL);
|
||||
|
||||
await expectNewWorkspaceProjectSelected(page, SHARED_PROJECT_NAME);
|
||||
|
||||
await addConnectedHostsAndReload(page, [crossHostProject.secondaryHost], {
|
||||
primaryLabel: PRIMARY_HOST_LABEL,
|
||||
});
|
||||
await expectNewWorkspaceProjectSelected(page, SHARED_PROJECT_NAME);
|
||||
await selectNewWorkspaceHost(page, SECONDARY_HOST_LABEL);
|
||||
|
||||
await expectNewWorkspaceProjectSelected(page, SHARED_PROJECT_NAME);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
|
||||
import {
|
||||
openNewWorkspaceComposer,
|
||||
openStartingRefPicker,
|
||||
selectBranchInPicker,
|
||||
selectWorkspaceIsolation,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { seedSavedSettingsHosts } from "../support/helpers/settings";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
|
||||
const LONG_HOST_NAME =
|
||||
"development-macbook-pro.local-connected-through-a-very-long-private-hostname";
|
||||
const LONG_BRANCH_NAME =
|
||||
"feat/app/add-amoled-theme-with-a-very-long-pull-request-and-branch-description";
|
||||
|
||||
function measureControlRightEdges(controls: HTMLElement[]) {
|
||||
const measurements: Array<{ label: string | null; right: number }> = [];
|
||||
for (const control of controls) {
|
||||
const rect = control.getBoundingClientRect();
|
||||
measurements.push({ label: control.getAttribute("aria-label"), right: rect.right });
|
||||
}
|
||||
return measurements;
|
||||
}
|
||||
|
||||
test.describe("New workspace metadata row layout", () => {
|
||||
let workspace: SeededWorkspace;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
workspace = await seedWorkspace({
|
||||
repoPrefix: "new-workspace-layout-",
|
||||
repo: { branches: [LONG_BRANCH_NAME] },
|
||||
});
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await workspace?.cleanup();
|
||||
});
|
||||
|
||||
test("long host and branch names stay inside the composer's right rail", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 2048, height: 878 });
|
||||
await seedSavedSettingsHosts(page, [
|
||||
{
|
||||
serverId: getServerId(),
|
||||
label: LONG_HOST_NAME,
|
||||
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||
},
|
||||
{
|
||||
serverId: "srv_e2e_layout_offline",
|
||||
label: "Offline host",
|
||||
endpoint: "127.0.0.1:9",
|
||||
},
|
||||
]);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: workspace.projectKey,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
});
|
||||
await selectWorkspaceIsolation(page, "worktree");
|
||||
await openStartingRefPicker(page);
|
||||
await selectBranchInPicker(page, LONG_BRANCH_NAME);
|
||||
|
||||
const composer = page.locator('[data-testid="message-input-root"]:visible');
|
||||
const metadataRow = page.getByTestId("new-workspace-ref-picker-row");
|
||||
await expect(composer).toBeVisible();
|
||||
await expect(metadataRow).toBeVisible();
|
||||
|
||||
const [composerBox, controlBoxes] = await Promise.all([
|
||||
composer.boundingBox(),
|
||||
metadataRow.getByRole("button").evaluateAll(measureControlRightEdges),
|
||||
]);
|
||||
expect(composerBox).not.toBeNull();
|
||||
const composerRightRail = composerBox!.x + composerBox!.width;
|
||||
|
||||
for (const control of controlBoxes) {
|
||||
expect(
|
||||
control.right,
|
||||
`${control.label ?? "Metadata control"} crossed the composer rail`,
|
||||
).toBeLessThanOrEqual(composerRightRail + 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -160,7 +160,6 @@ test.describe("provider settings overlay stack", () => {
|
||||
await commandCenter.getByText("Add project", { exact: true }).click();
|
||||
const addProject = page.getByTestId("add-project-flow");
|
||||
await expect(addProject).toBeVisible({ timeout: 10_000 });
|
||||
await expect(addProject.getByTestId("add-project-flow-input")).toBeFocused();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(addProject).not.toBeVisible({ timeout: 10_000 });
|
||||
await expect(selector).toBeVisible();
|
||||
|
||||
@@ -34,7 +34,7 @@ const cases: ProviderSubagentCase[] = [
|
||||
provider: "codex",
|
||||
sentinel: "CODEX_CHILD_SENTINEL",
|
||||
expectedName: "Sentinel child",
|
||||
providerConfig: { extra: { codex: { features: { multi_agent_v2: true } } } },
|
||||
providerConfig: { providerOptions: { features: { multi_agent_v2: true } } },
|
||||
prompt:
|
||||
'Use the native collaboration.spawn_agent tool exactly once with task_name "sentinel_child" and fork_turns "none". Ask it to reply with exactly CODEX_CHILD_SENTINEL and do nothing else. Wait for it with collaboration.wait_agent, then reply ROOT_DONE. Do not use Paseo tools.',
|
||||
},
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { mkdtempSync, realpathSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import {
|
||||
cleanupRewindFlow,
|
||||
launchAgent,
|
||||
sendMessage,
|
||||
type AgentHandle,
|
||||
type RewindFlowProvider,
|
||||
} from "../support/helpers/rewind-flow";
|
||||
import {
|
||||
expectCompletedTaskActivity,
|
||||
expectTaskListEntries,
|
||||
openCompletedTaskList,
|
||||
} from "../support/helpers/task-list";
|
||||
import { submitMessage } from "../support/helpers/composer";
|
||||
|
||||
interface TaskScenario {
|
||||
provider: RewindFlowProvider;
|
||||
entries: [string, string];
|
||||
prompt: string;
|
||||
providerConfig?: Parameters<typeof launchAgent>[0]["providerConfig"];
|
||||
}
|
||||
|
||||
const scenarios: TaskScenario[] = [
|
||||
{
|
||||
provider: "claude",
|
||||
entries: ["Claude inventory alpha", "Claude inventory beta"],
|
||||
providerConfig: { model: "opus" },
|
||||
prompt:
|
||||
"Use TaskCreate to create exactly two tasks named Claude inventory alpha and Claude inventory beta. Use TaskUpdate to mark each in_progress and then completed. Do not use subagents, shell commands, or edit files. Finish with TASKS_COMPLETE.",
|
||||
},
|
||||
{
|
||||
provider: "codex",
|
||||
entries: ["Codex inventory alpha", "Codex inventory beta"],
|
||||
prompt:
|
||||
"Use update_plan to track exactly two steps named Codex inventory alpha and Codex inventory beta. Move each through in_progress and completed. Do not use subagents, shell commands, or edit files. Finish with TASKS_COMPLETE.",
|
||||
},
|
||||
{
|
||||
provider: "opencode",
|
||||
entries: ["OpenCode inventory alpha", "OpenCode inventory beta"],
|
||||
prompt:
|
||||
"Use TodoWrite to track exactly two tasks named OpenCode inventory alpha and OpenCode inventory beta. Move each through in_progress and completed. Do not use subagents, shell commands, or edit files. Finish with TASKS_COMPLETE.",
|
||||
},
|
||||
];
|
||||
|
||||
test.describe("real provider composer task lists", () => {
|
||||
test.setTimeout(600_000);
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
test(`${scenario.provider} shows native task progress above the composer`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const cwd = realpathSync(
|
||||
mkdtempSync(path.join(tmpdir(), `paseo-task-list-${scenario.provider}-`)),
|
||||
);
|
||||
let handle: AgentHandle | undefined;
|
||||
|
||||
try {
|
||||
handle = await launchAgent({
|
||||
page,
|
||||
provider: scenario.provider,
|
||||
cwd,
|
||||
mode: "full-access",
|
||||
providerConfig: scenario.providerConfig,
|
||||
});
|
||||
await sendMessage(handle, scenario.prompt);
|
||||
await openCompletedTaskList(page, scenario.entries.length);
|
||||
await expectTaskListEntries(page, scenario.entries);
|
||||
await expectCompletedTaskActivity(page, scenario.entries);
|
||||
await expect(page.getByText("TaskUpdate", { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByTestId("timeline-plan-card")).toHaveCount(0);
|
||||
|
||||
const screenshot = testInfo.outputPath(`${scenario.provider}-composer-tasks.png`);
|
||||
await page.screenshot({ path: screenshot });
|
||||
await testInfo.attach(`${scenario.provider} composer tasks`, {
|
||||
path: screenshot,
|
||||
contentType: "image/png",
|
||||
});
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("Codex genuine Plan mode keeps the existing plan card", async ({ page }, testInfo) => {
|
||||
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-genuine-codex-plan-")));
|
||||
let handle: AgentHandle | undefined;
|
||||
|
||||
try {
|
||||
handle = await launchAgent({
|
||||
page,
|
||||
provider: "codex",
|
||||
cwd,
|
||||
mode: "full-access",
|
||||
providerConfig: { featureValues: { plan_mode: true } },
|
||||
});
|
||||
await submitMessage(
|
||||
page,
|
||||
"Produce a concise implementation plan with exactly these steps: Inspect genuine plan boundary; Implement genuine plan boundary; Verify genuine plan boundary. Do not implement it.",
|
||||
);
|
||||
|
||||
const planCard = page.getByTestId("permission-plan-card");
|
||||
await expect(planCard).toBeVisible({ timeout: 120_000 });
|
||||
await expect(planCard).toContainText("Inspect genuine plan boundary");
|
||||
await expect(page.getByTestId("timeline-plan-card")).toHaveCount(0);
|
||||
|
||||
const screenshot = testInfo.outputPath("codex-genuine-plan.png");
|
||||
await page.screenshot({ path: screenshot });
|
||||
await testInfo.attach("Codex genuine plan", { path: screenshot, contentType: "image/png" });
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { expect, test as base } from "../support/fixtures";
|
||||
import { expectAgentIdle } from "../support/helpers/agent-stream";
|
||||
import { expectComposerVisible, submitMessage } from "../support/helpers/composer";
|
||||
import {
|
||||
openAgentRoute,
|
||||
seedMockAgentWorkspace,
|
||||
type MockAgentWorkspace,
|
||||
} from "../support/helpers/mock-agent";
|
||||
import {
|
||||
beginReplicaCacheMeasurement,
|
||||
observeReplicaCacheWrites,
|
||||
readReplicaCacheMeasurement,
|
||||
} from "../support/helpers/replica-cache-perf";
|
||||
|
||||
const RUN_REPLICA_CACHE_PERF = process.env.PASEO_REPLICA_CACHE_PERF_E2E === "1";
|
||||
const MAX_STREAM_WRITES = 12;
|
||||
const MAX_SERIALIZED_CHARS = 190_000;
|
||||
|
||||
const test = base.extend<{ replicaCacheAgent: MockAgentWorkspace }>({
|
||||
replicaCacheAgent: async ({ page: _page }, provide) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "replica-cache-perf-",
|
||||
title: "Replica cache performance",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
try {
|
||||
await provide(agent);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
},
|
||||
});
|
||||
const replicaCachePerfDescribe = RUN_REPLICA_CACHE_PERF ? test.describe : test.describe.skip;
|
||||
|
||||
replicaCachePerfDescribe("Replica cache performance", () => {
|
||||
test("streaming writes only persisted replica changes", async ({
|
||||
page,
|
||||
replicaCacheAgent: agent,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(60_000);
|
||||
await observeReplicaCacheWrites(page);
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
await beginReplicaCacheMeasurement(page);
|
||||
|
||||
await submitMessage(page, "Measure replica persistence during a sustained stream.");
|
||||
await agent.client.waitForFinish(agent.agentId, 20_000);
|
||||
await expectAgentIdle(page);
|
||||
await page.waitForTimeout(1_000);
|
||||
|
||||
const report = await readReplicaCacheMeasurement(page);
|
||||
await testInfo.attach("replica-cache-performance", {
|
||||
body: JSON.stringify(report, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
console.log(`[perf] Replica cache: ${JSON.stringify(report)}`);
|
||||
|
||||
expect(
|
||||
report.writes,
|
||||
"streaming should not continuously persist transient state",
|
||||
).toBeLessThanOrEqual(MAX_STREAM_WRITES);
|
||||
expect(report.redundantWrites).toBe(0);
|
||||
expect(report.serializedChars).toBeLessThanOrEqual(MAX_SERIALIZED_CHARS);
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -114,16 +114,6 @@ test.describe("Settings sidebar navigation", () => {
|
||||
await expectDirectHostUriHidden(page);
|
||||
});
|
||||
|
||||
test("browser web hides direct connection custom headers", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openAddHostFlow(page);
|
||||
await selectHostConnectionType(page, "direct");
|
||||
await toggleHostAdvanced(page);
|
||||
|
||||
await expect(page.getByTestId("direct-header-add")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("sidebar shows a Back to workspace row that leaves /settings", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
|
||||
@@ -8,7 +8,6 @@ async function expectBorderHighlight(page: Page, testID: string) {
|
||||
await expect(page.getByTestId(`${testID}-highlight`)).toHaveCount(0);
|
||||
|
||||
await handle.hover();
|
||||
await expect(page.getByTestId(`${testID}-highlight`)).toHaveCount(0);
|
||||
|
||||
const highlight = page.getByTestId(`${testID}-highlight`);
|
||||
await expect(highlight).toBeVisible();
|
||||
|
||||
@@ -304,7 +304,9 @@ test.describe("Half-screen desktop layout", () => {
|
||||
await gotoAppShell(page);
|
||||
|
||||
const openToggle = page.getByTestId("menu-button");
|
||||
const openBounds = await openToggle.locator("svg").first().boundingBox();
|
||||
const openIcon = openToggle.locator("svg").first();
|
||||
await expect(openIcon).toBeVisible();
|
||||
const openBounds = await openIcon.boundingBox();
|
||||
expect(openBounds).not.toBeNull();
|
||||
expect(openBounds?.x).toBeGreaterThan(12);
|
||||
|
||||
@@ -312,7 +314,9 @@ test.describe("Half-screen desktop layout", () => {
|
||||
await expect(page.getByTestId("sidebar-global-new-workspace")).not.toBeVisible();
|
||||
|
||||
const closedToggle = page.getByTestId("menu-button");
|
||||
const closedBounds = await closedToggle.locator("svg").first().boundingBox();
|
||||
const closedIcon = closedToggle.locator("svg").first();
|
||||
await expect(closedIcon).toBeVisible();
|
||||
const closedBounds = await closedIcon.boundingBox();
|
||||
expect(closedBounds).not.toBeNull();
|
||||
expect(closedBounds?.x).toBeCloseTo(12, 0);
|
||||
expect(closedBounds?.y).toBe(openBounds?.y);
|
||||
|
||||
@@ -503,12 +503,16 @@ test.describe("Workspace model restart regressions", () => {
|
||||
createdWorkspaceId,
|
||||
]),
|
||||
)
|
||||
.toEqual({
|
||||
[seeded.workspaceA]: "running",
|
||||
.toMatchObject({
|
||||
[seeded.workspaceB]: "done",
|
||||
[createdWorkspaceId]: "done",
|
||||
});
|
||||
|
||||
// The restarted provider session may settle while the browser creates the sibling. Its
|
||||
// initial running status is asserted above; this phase verifies that ownership never moves.
|
||||
const workspaceStatuses = await fetchWorkspaceStatuses(client, [seeded.workspaceA]);
|
||||
expect(["running", "done"]).toContain(workspaceStatuses[seeded.workspaceA]);
|
||||
|
||||
await expectWorkspaceRowDoesNotShowIndicator(page, {
|
||||
serverId,
|
||||
workspaceId: seeded.workspaceB,
|
||||
@@ -519,11 +523,6 @@ test.describe("Workspace model restart regressions", () => {
|
||||
workspaceId: createdWorkspaceId,
|
||||
indicator: "running",
|
||||
});
|
||||
await expectWorkspaceRowInStatusBucket(page, {
|
||||
serverId,
|
||||
workspaceId: seeded.workspaceA,
|
||||
bucket: "running",
|
||||
});
|
||||
await expectWorkspaceRowInStatusBucket(page, {
|
||||
serverId,
|
||||
workspaceId: seeded.workspaceB,
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { renameModalInput, renameModalSubmit } from "../support/helpers/rename";
|
||||
import type { SeedDaemonClient } from "../support/helpers/seed-client";
|
||||
|
||||
const MOBILE_VIEWPORT = { width: 390, height: 844 };
|
||||
|
||||
async function fetchAgentTitle(client: SeedDaemonClient, agentId: string): Promise<string | null> {
|
||||
const result = await client.fetchAgents({ scope: "active" });
|
||||
return result.entries.find((entry) => entry.agent.id === agentId)?.agent.title ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact "sessions dropdown" rename path. The desktop rename specs drive the right-click
|
||||
* `workspace-tab-context-*` menu, which portals into overlay-root and was never broken. This
|
||||
* exercises the tab switcher's per-session actions on a phone-sized viewport — the path that
|
||||
* regressed when the actions menu presented as a popover instead of a bottom sheet.
|
||||
*/
|
||||
test.describe("Workspace session rename (compact tab switcher)", () => {
|
||||
test("renames an agent session from the switcher's per-session actions sheet", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
await page.setViewportSize(MOBILE_VIEWPORT);
|
||||
const initialTitle = `switcher-rename-${randomUUID().slice(0, 8)}`;
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "workspace-switcher-rename-",
|
||||
title: initialTitle,
|
||||
});
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await expect(page.getByTestId("workspace-tab-switcher-trigger")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
// Open the sessions dropdown (a bottom sheet on compact).
|
||||
await page.getByRole("button", { name: /Switch tabs/ }).click();
|
||||
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" }).first()).toBeVisible(
|
||||
{
|
||||
timeout: 10_000,
|
||||
},
|
||||
);
|
||||
|
||||
// Open this session's "…" actions. Before the fix this tried to open a popover-Modal
|
||||
// that never surfaced over the sheet on native; it now opens as a stacked sheet.
|
||||
const menuBase = `workspace-tab-menu-agent_${session.agentId}`;
|
||||
const actionsTrigger = page.getByTestId(`${menuBase}-trigger`);
|
||||
await expect(actionsTrigger).toBeVisible({ timeout: 15_000 });
|
||||
await actionsTrigger.click();
|
||||
|
||||
const renameItem = page.getByTestId(`${menuBase}-rename`);
|
||||
await expect(renameItem).toBeVisible({ timeout: 10_000 });
|
||||
await renameItem.click();
|
||||
|
||||
const modalPrefix = `workspace-tab-rename-modal-agent-${session.agentId}`;
|
||||
const input = renameModalInput(page, modalPrefix);
|
||||
await expect(input).toBeVisible({ timeout: 10_000 });
|
||||
await expect(input).toHaveValue(initialTitle);
|
||||
|
||||
const renamed = "Renamed from the dropdown";
|
||||
await input.fill(renamed);
|
||||
await renameModalSubmit(page, modalPrefix).click();
|
||||
|
||||
await expect(input).toHaveCount(0, { timeout: 15_000 });
|
||||
await expect(page.getByTestId("workspace-tab-switcher-trigger")).toContainText(renamed, {
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect.poll(() => fetchAgentTitle(session.client, session.agentId)).toBe(renamed);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -43,13 +43,21 @@ export async function expectAddProjectPage(page: Page, kind: AddProjectFlowPage)
|
||||
return currentPage;
|
||||
}
|
||||
|
||||
export async function openAddProjectFlow(
|
||||
async function openAddProjectFlowSurface(
|
||||
page: Page,
|
||||
expectedPage: "host" | "method" = "method",
|
||||
expectedPage: "host" | "method",
|
||||
): Promise<void> {
|
||||
await page.getByTestId("sidebar-add-project").click();
|
||||
await expect(addProjectFlow(page)).toBeVisible({ timeout: 30_000 });
|
||||
await expectAddProjectPage(page, expectedPage);
|
||||
}
|
||||
|
||||
export async function openAddProjectFlow(page: Page): Promise<void> {
|
||||
await openAddProjectFlowSurface(page, "method");
|
||||
}
|
||||
|
||||
export async function openAddProjectHostSelection(page: Page): Promise<void> {
|
||||
await openAddProjectFlowSurface(page, "host");
|
||||
await expect(addProjectFlowInput(page)).toBeFocused();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,564 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import type { AgentProfile } from "@getpaseo/protocol/messages";
|
||||
import { buildSettingsHostSectionRoute } from "@/utils/host-routes";
|
||||
import { gotoAppShell, openSettings } from "./app";
|
||||
import { connectDaemonClient } from "./daemon-client-loader";
|
||||
import { getServerId } from "./server-id";
|
||||
import { expectAppRoute } from "./route-assertions";
|
||||
import { openSettingsHost } from "./settings";
|
||||
|
||||
// ─── Daemon-side seeding ───────────────────────────────────────────────────
|
||||
|
||||
interface AgentProfilesDaemonClient {
|
||||
connect(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
getDaemonConfig(): Promise<{ config: { agentProfiles?: AgentProfile[] } }>;
|
||||
patchDaemonConfig(config: {
|
||||
agentProfiles?: AgentProfile[];
|
||||
providers?: Record<string, Record<string, unknown>>;
|
||||
removeProviders?: string[];
|
||||
}): Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface HostSeed {
|
||||
/** Puts the host's config back the way the test found it and drops the client. */
|
||||
restore(): Promise<void>;
|
||||
}
|
||||
|
||||
async function connectAgentProfilesClient(): Promise<AgentProfilesDaemonClient> {
|
||||
return connectDaemonClient<AgentProfilesDaemonClient>({ clientIdPrefix: "agent-profiles-e2e" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes profiles into host config directly. Journeys that are *about* the
|
||||
* settings UI create theirs through it; every other journey starts from a host
|
||||
* that already has them.
|
||||
*/
|
||||
export async function seedAgentProfiles(profiles: AgentProfile[]): Promise<HostSeed> {
|
||||
const client = await connectAgentProfilesClient();
|
||||
const previous = (await client.getDaemonConfig()).config.agentProfiles ?? [];
|
||||
await client.patchDaemonConfig({ agentProfiles: profiles });
|
||||
return {
|
||||
async restore() {
|
||||
await client.patchDaemonConfig({ agentProfiles: previous }).catch(() => undefined);
|
||||
await client.close().catch(() => undefined);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function stageLegacyFavoritesForHostMigration(
|
||||
page: Page,
|
||||
favorites: Array<{ provider: string; modelId: string }>,
|
||||
): Promise<void> {
|
||||
const serverId = getServerId();
|
||||
await gotoAppShell(page);
|
||||
await page.evaluate(
|
||||
({ favorites: storedFavorites, serverId: hostId }) => {
|
||||
const preferencesKey = "@paseo:create-agent-preferences";
|
||||
const raw = localStorage.getItem(preferencesKey);
|
||||
const preferences = raw ? JSON.parse(raw) : {};
|
||||
localStorage.setItem(
|
||||
preferencesKey,
|
||||
JSON.stringify({ ...preferences, favoriteModels: storedFavorites }),
|
||||
);
|
||||
localStorage.removeItem(`@paseo:legacy-favorites-to-agent-profiles:v1:${hostId}`);
|
||||
|
||||
// Preserve this upgrade-shaped storage across the fixture's next-page
|
||||
// default seed; a reload is the startup boundary under test.
|
||||
const nonce = localStorage.getItem("@paseo:e2e-seed-nonce");
|
||||
if (nonce) {
|
||||
localStorage.setItem("@paseo:e2e-disable-default-seed-once", nonce);
|
||||
}
|
||||
},
|
||||
{ favorites, serverId },
|
||||
);
|
||||
await page.reload();
|
||||
}
|
||||
|
||||
export async function expectHostAgentProfiles(expected: AgentProfile[]): Promise<void> {
|
||||
const client = await connectAgentProfilesClient();
|
||||
try {
|
||||
await expect
|
||||
.poll(async () => (await client.getDaemonConfig()).config.agentProfiles ?? [], {
|
||||
timeout: 30_000,
|
||||
})
|
||||
.toEqual(expected);
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export interface SeededProviderModel {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a second provider that reports a fixed catalog. The E2E daemon has
|
||||
* exactly one provider with models (`mock`), so nothing cross-provider can be
|
||||
* proven without one more.
|
||||
*
|
||||
* `extends: "mock"` is rejected — the config validator only accepts the six
|
||||
* shipped providers plus `acp`. Extending `claude` with a replacement `models`
|
||||
* list is what avoids running anything: replacement models skip catalog
|
||||
* discovery, and Claude's static modes mean the provider never spawns. The
|
||||
* command only has to resolve for the availability probe, hence `node`.
|
||||
*/
|
||||
export async function seedModelProvider(input: {
|
||||
id: string;
|
||||
label: string;
|
||||
models: SeededProviderModel[];
|
||||
}): Promise<HostSeed> {
|
||||
const client = await connectAgentProfilesClient();
|
||||
await client.patchDaemonConfig({
|
||||
providers: {
|
||||
[input.id]: {
|
||||
extends: "claude",
|
||||
label: input.label,
|
||||
description: `${input.label} test provider`,
|
||||
enabled: true,
|
||||
command: ["node"],
|
||||
models: input.models,
|
||||
},
|
||||
},
|
||||
});
|
||||
return {
|
||||
async restore() {
|
||||
await client.patchDaemonConfig({ removeProviders: [input.id] }).catch(() => undefined);
|
||||
await client.close().catch(() => undefined);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Settings: navigation ──────────────────────────────────────────────────
|
||||
|
||||
/** Reach agent profiles through the same visible Settings path a person uses. */
|
||||
export async function openAgentProfileSettings(page: Page): Promise<void> {
|
||||
const serverId = getServerId();
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, serverId);
|
||||
await page.getByRole("button", { name: "Agents", exact: true }).click();
|
||||
await expectAppRoute(page, buildSettingsHostSectionRoute(serverId, "agents"));
|
||||
await expect(page.getByTestId("agent-profiles-card")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function expectNoAgentProfiles(page: Page): Promise<void> {
|
||||
const card = page.getByTestId("agent-profiles-card");
|
||||
await expect(card.getByTestId("agent-profiles-empty")).toBeVisible();
|
||||
await expect(card.getByRole("button", { name: "New profile", exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
// ─── Settings: the profile list ────────────────────────────────────────────
|
||||
|
||||
export function agentProfileRow(page: Page, name: string): Locator {
|
||||
return page.locator('[data-testid^="agent-profile-row-"]').filter({ hasText: name });
|
||||
}
|
||||
|
||||
export async function expectAgentProfile(
|
||||
page: Page,
|
||||
expected: { name: string; tags: string[]; notes?: string },
|
||||
): Promise<void> {
|
||||
const row = agentProfileRow(page, expected.name);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(row.getByText(expected.tags.join(" · "), { exact: true })).toBeVisible();
|
||||
if (expected.notes !== undefined) {
|
||||
await expect(row.getByText(expected.notes, { exact: true })).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectAgentProfileTagsGone(
|
||||
page: Page,
|
||||
input: { name: string; tags: string[] },
|
||||
): Promise<void> {
|
||||
const row = agentProfileRow(page, input.name);
|
||||
for (const tag of input.tags) {
|
||||
await expect(row.getByText(tag, { exact: true })).toHaveCount(0);
|
||||
}
|
||||
}
|
||||
|
||||
/** Order is what the user sees, so it is measured on screen rather than in the DOM. */
|
||||
export async function expectAgentProfileOrder(page: Page, names: string[]): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const measured = await Promise.all(
|
||||
names.map(async (name) => ({ name, top: await boxTop(agentProfileRow(page, name)) })),
|
||||
);
|
||||
return measured.sort((a, b) => a.top - b.top).map((entry) => entry.name);
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.toEqual(names);
|
||||
}
|
||||
|
||||
export async function moveAgentProfileUp(page: Page, name: string): Promise<void> {
|
||||
await agentProfileRow(page, name).getByRole("button", { name: "Move up", exact: true }).click();
|
||||
}
|
||||
|
||||
export async function moveAgentProfileDown(page: Page, name: string): Promise<void> {
|
||||
await agentProfileRow(page, name).getByRole("button", { name: "Move down", exact: true }).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* The remove confirmation is `window.confirm` on browser web, so the dialog is
|
||||
* consumed here — and its text is asserted, since naming the profile is the
|
||||
* whole point of confirming.
|
||||
*/
|
||||
export async function removeAgentProfile(page: Page, name: string): Promise<void> {
|
||||
const dialogMessage = page.waitForEvent("dialog").then(async (dialog) => {
|
||||
const message = dialog.message();
|
||||
await dialog.accept();
|
||||
return message;
|
||||
});
|
||||
await agentProfileRow(page, name).getByRole("button", { name: "Remove", exact: true }).click();
|
||||
expect(await dialogMessage).toContain(`Remove "${name}"?`);
|
||||
await expect(agentProfileRow(page, name)).toHaveCount(0, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
// ─── Settings: the edit modal ──────────────────────────────────────────────
|
||||
|
||||
export interface AgentProfileDraft {
|
||||
name?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
thinking?: string;
|
||||
mode?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
function editModal(page: Page): Locator {
|
||||
return page.getByTestId("agent-profile-edit-modal");
|
||||
}
|
||||
|
||||
async function chooseSelectFieldOption(
|
||||
page: Page,
|
||||
input: { field: "provider" | "model" | "mode" | "thinking"; option: string },
|
||||
): Promise<void> {
|
||||
await editModal(page).getByTestId(`agent-profile-${input.field}-trigger`).click();
|
||||
const option = page
|
||||
.getByTestId("combobox-desktop-container")
|
||||
.getByRole("button", { name: input.option, exact: true });
|
||||
await expect(option).toBeVisible({ timeout: 30_000 });
|
||||
await option.click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: `${capitalize(input.field)} (${input.option})` }),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
function capitalize(value: string): string {
|
||||
return value.charAt(0).toUpperCase() + value.slice(1);
|
||||
}
|
||||
|
||||
async function fillAgentProfileForm(page: Page, draft: AgentProfileDraft): Promise<void> {
|
||||
const modal = editModal(page);
|
||||
if (draft.name !== undefined) {
|
||||
await modal.getByRole("textbox", { name: "Name", exact: true }).fill(draft.name);
|
||||
}
|
||||
// Provider first: it resets every provider-scoped field below it.
|
||||
if (draft.provider !== undefined) {
|
||||
await chooseSelectFieldOption(page, { field: "provider", option: draft.provider });
|
||||
}
|
||||
// Thinking after model: thinking levels hang off the model, and switching the
|
||||
// model drops a level the new one does not offer.
|
||||
if (draft.model !== undefined) {
|
||||
await chooseSelectFieldOption(page, { field: "model", option: draft.model });
|
||||
}
|
||||
if (draft.thinking !== undefined) {
|
||||
await chooseSelectFieldOption(page, { field: "thinking", option: draft.thinking });
|
||||
}
|
||||
if (draft.mode !== undefined) {
|
||||
await chooseSelectFieldOption(page, { field: "mode", option: draft.mode });
|
||||
}
|
||||
if (draft.notes !== undefined) {
|
||||
await modal.getByRole("textbox", { name: "Notes for agents", exact: true }).fill(draft.notes);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAgentProfile(page: Page): Promise<void> {
|
||||
await editModal(page).getByRole("button", { name: "Save", exact: true }).click();
|
||||
await expect(editModal(page)).toHaveCount(0, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function createAgentProfile(page: Page, draft: AgentProfileDraft): Promise<void> {
|
||||
await page.getByRole("button", { name: "Add agent profile", exact: true }).click();
|
||||
await expect(editModal(page)).toBeVisible({ timeout: 30_000 });
|
||||
await fillAgentProfileForm(page, draft);
|
||||
await saveAgentProfile(page);
|
||||
}
|
||||
|
||||
export async function createAgentProfileFromEmptyState(
|
||||
page: Page,
|
||||
draft: AgentProfileDraft,
|
||||
): Promise<void> {
|
||||
await page
|
||||
.getByTestId("agent-profiles-card")
|
||||
.getByRole("button", { name: "New profile", exact: true })
|
||||
.click();
|
||||
await expect(editModal(page)).toBeVisible({ timeout: 30_000 });
|
||||
await fillAgentProfileForm(page, draft);
|
||||
await saveAgentProfile(page);
|
||||
}
|
||||
|
||||
export async function editAgentProfile(
|
||||
page: Page,
|
||||
name: string,
|
||||
changes: AgentProfileDraft,
|
||||
): Promise<void> {
|
||||
await agentProfileRow(page, name)
|
||||
.getByRole("button", { name: "Edit profile", exact: true })
|
||||
.click();
|
||||
await expect(editModal(page)).toBeVisible({ timeout: 30_000 });
|
||||
await fillAgentProfileForm(page, changes);
|
||||
await saveAgentProfile(page);
|
||||
}
|
||||
|
||||
/** Opens the modal for a profile purely to read back what it stored. */
|
||||
export async function expectAgentProfileForm(
|
||||
page: Page,
|
||||
name: string,
|
||||
expected: { provider?: string; model?: string; mode?: string; notes?: string },
|
||||
): Promise<void> {
|
||||
await agentProfileRow(page, name)
|
||||
.getByRole("button", { name: "Edit profile", exact: true })
|
||||
.click();
|
||||
const modal = editModal(page);
|
||||
await expect(modal).toBeVisible({ timeout: 30_000 });
|
||||
await expect(modal.getByRole("textbox", { name: "Name", exact: true })).toHaveValue(name);
|
||||
for (const [field, value] of Object.entries(expected)) {
|
||||
if (field === "notes") {
|
||||
await expect(
|
||||
modal.getByRole("textbox", { name: "Notes for agents", exact: true }),
|
||||
).toHaveValue(value);
|
||||
continue;
|
||||
}
|
||||
await expect(
|
||||
modal.getByRole("button", { name: `${capitalize(field)} (${value})` }),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
await modal.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await expect(editModal(page)).toHaveCount(0, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
// ─── The model picker ──────────────────────────────────────────────────────
|
||||
|
||||
/** Desktop web renders the model browser inside the combobox popover. */
|
||||
function pickerViewport(page: Page): Locator {
|
||||
return page.getByTestId("combobox-desktop-container");
|
||||
}
|
||||
|
||||
export async function openModelPicker(page: Page): Promise<void> {
|
||||
await page.getByTestId("combined-model-selector").filter({ visible: true }).first().click();
|
||||
await expect(pickerViewport(page)).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function closeModelPicker(page: Page): Promise<void> {
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(pickerViewport(page)).toHaveCount(0, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
export function profilePickerRow(page: Page, name: string): Locator {
|
||||
return pickerViewport(page)
|
||||
.locator('[data-testid^="model-profile-row-"]')
|
||||
.filter({ hasText: name });
|
||||
}
|
||||
|
||||
/**
|
||||
* Profiles are pinned above the provider list, so "pinned" is an ordering claim:
|
||||
* the profile row's top edge sits above every provider row's.
|
||||
*/
|
||||
export async function expectProfilePinnedAboveProviders(
|
||||
page: Page,
|
||||
input: { name: string; summary: string },
|
||||
): Promise<void> {
|
||||
const viewport = pickerViewport(page);
|
||||
await expect(viewport.getByText("Profiles", { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
const row = profilePickerRow(page, input.name);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(row.getByText(input.summary, { exact: true })).toBeVisible();
|
||||
|
||||
const profileTop = await boxTop(row);
|
||||
const providerRows = viewport.locator('[data-testid^="model-provider-"]');
|
||||
await expect(providerRows.first()).toBeVisible();
|
||||
const providerCount = await providerRows.count();
|
||||
for (let index = 0; index < providerCount; index += 1) {
|
||||
expect(profileTop).toBeLessThan(await boxTop(providerRows.nth(index)));
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectProfileVisibleForProvider(
|
||||
page: Page,
|
||||
input: { name: string; summary: string },
|
||||
): Promise<void> {
|
||||
const viewport = pickerViewport(page);
|
||||
await expect(viewport.getByText("Profiles", { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
const row = profilePickerRow(page, input.name);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(row.getByText(input.summary, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectProfileEditIsPencilOnly(page: Page): Promise<void> {
|
||||
const viewport = pickerViewport(page);
|
||||
await expect(
|
||||
viewport.getByRole("button", { name: "Edit agent profiles", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(viewport.getByText("Edit", { exact: true })).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function expectAgentProfilesEmptyPrompt(page: Page): Promise<void> {
|
||||
const viewport = pickerViewport(page);
|
||||
await expect(viewport.getByText("Profiles", { exact: true })).toHaveCount(0);
|
||||
await expect(
|
||||
viewport.getByRole("button", { name: "Edit agent profiles", exact: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(viewport.getByRole("button", { name: "Create profile", exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectProfileEditTooltip(page: Page): Promise<void> {
|
||||
const edit = pickerViewport(page).getByRole("button", {
|
||||
name: "Edit agent profiles",
|
||||
exact: true,
|
||||
});
|
||||
await edit.hover();
|
||||
await expect(page.getByText("Edit agent profiles", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function openAgentProfilesFromEmptyPrompt(page: Page): Promise<void> {
|
||||
await pickerViewport(page).getByRole("button", { name: "Create profile", exact: true }).click();
|
||||
await expect(page.getByTestId("agent-profiles-card")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function boxTop(locator: Locator): Promise<number> {
|
||||
const box = await locator.boundingBox();
|
||||
if (!box) {
|
||||
throw new Error("Expected a laid-out element to measure");
|
||||
}
|
||||
return box.y;
|
||||
}
|
||||
|
||||
export async function applyProfileFromPicker(page: Page, name: string): Promise<void> {
|
||||
await profilePickerRow(page, name).click();
|
||||
await expect(pickerViewport(page)).toHaveCount(0, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Applying a profile materializes it and forgets it: nothing in the root view
|
||||
* claims selection. Model rows do carry `aria-selected`, so a count of zero here
|
||||
* is the absence of a checkmark, not the absence of the attribute everywhere.
|
||||
*/
|
||||
export async function expectNothingSelectedInPickerRoot(page: Page): Promise<void> {
|
||||
await expect(pickerViewport(page).locator("[aria-selected]")).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function expectAgentProfilesEditShortcut(page: Page): Promise<void> {
|
||||
await expect(
|
||||
pickerViewport(page).getByRole("button", { name: "Edit agent profiles", exact: true }),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function drillIntoProvider(page: Page, providerId: string): Promise<void> {
|
||||
await pickerViewport(page).getByTestId(`model-provider-${providerId}`).click();
|
||||
}
|
||||
|
||||
export async function expectModelRowSelected(
|
||||
page: Page,
|
||||
input: { provider: string; modelId: string },
|
||||
): Promise<void> {
|
||||
await expect(
|
||||
pickerViewport(page).getByTestId(`model-row-${input.provider}-${input.modelId}`),
|
||||
).toHaveAttribute("aria-selected", "true", { timeout: 30_000 });
|
||||
}
|
||||
|
||||
// ─── Cross-provider model search ───────────────────────────────────────────
|
||||
|
||||
export async function searchAllModels(page: Page, query: string): Promise<void> {
|
||||
const input = page.getByTestId("model-search-all-input");
|
||||
await expect(input).toBeVisible({ timeout: 30_000 });
|
||||
await input.fill(query);
|
||||
}
|
||||
|
||||
export async function expectModelSearchResult(
|
||||
page: Page,
|
||||
expected: { provider: string; modelId: string; providerLabel: string; modelLabel: string },
|
||||
): Promise<void> {
|
||||
const row = pickerViewport(page).getByTestId(
|
||||
`model-row-${expected.provider}-${expected.modelId}`,
|
||||
);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(row.getByText(expected.modelLabel, { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
row.getByText(new RegExp(`^${escapeForRegex(expected.providerLabel)}\\b`)),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
function escapeForRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/** Search results replace the whole root view, pinned profiles included. */
|
||||
export async function expectPinnedProfilesHidden(page: Page): Promise<void> {
|
||||
await expect(pickerViewport(page).locator('[data-testid^="model-profile-row-"]')).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function expectModelSearchEmptyState(page: Page, query: string): Promise<void> {
|
||||
const empty = page.getByTestId("model-search-empty");
|
||||
await expect(empty).toBeVisible({ timeout: 30_000 });
|
||||
await expect(empty.getByText(`No models match "${query}"`, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function readModelPickerHeight(page: Page): Promise<number> {
|
||||
const box = await pickerViewport(page).boundingBox();
|
||||
if (!box) {
|
||||
throw new Error("Expected the model picker to be laid out");
|
||||
}
|
||||
return box.height;
|
||||
}
|
||||
|
||||
export async function readModelPickerWidth(page: Page): Promise<number> {
|
||||
const box = await pickerViewport(page).boundingBox();
|
||||
if (!box) {
|
||||
throw new Error("Expected the model picker to be laid out");
|
||||
}
|
||||
return box.width;
|
||||
}
|
||||
|
||||
export async function expectModelPickerHeight(page: Page, expectedHeight: number): Promise<void> {
|
||||
await expect.poll(() => readModelPickerHeight(page)).toBe(expectedHeight);
|
||||
}
|
||||
|
||||
export async function expectModelPickerWidth(page: Page, expectedWidth: number): Promise<void> {
|
||||
await expect.poll(() => readModelPickerWidth(page)).toBe(expectedWidth);
|
||||
}
|
||||
|
||||
export async function expectSearchResultsVirtualized(
|
||||
page: Page,
|
||||
input: { provider: string; total: number },
|
||||
): Promise<void> {
|
||||
const mountedRows = pickerViewport(page).locator(`[data-testid^="model-row-${input.provider}-"]`);
|
||||
await expect.poll(() => mountedRows.count()).toBeGreaterThan(0);
|
||||
expect(await mountedRows.count()).toBeLessThan(input.total);
|
||||
}
|
||||
|
||||
// ─── Composer agent controls ───────────────────────────────────────────────
|
||||
|
||||
export async function expectComposerModel(page: Page, modelLabel: string): Promise<void> {
|
||||
await expect(
|
||||
page.getByRole("button", { name: `Select model (${modelLabel})`, exact: true }),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function expectComposerMode(page: Page, modeLabel: string): Promise<void> {
|
||||
await expect(
|
||||
page.getByRole("button", { name: `Select agent mode (${modeLabel})`, exact: true }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
/** The composer never shows the profile's name — a profile is not a selection. */
|
||||
export async function expectComposerDoesNotName(page: Page, profileName: string): Promise<void> {
|
||||
await expect(
|
||||
page.locator('[data-testid="message-input-root"]:visible').getByText(profileName),
|
||||
).toHaveCount(0);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { daemonWsRoutePattern } from "./daemon-port";
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
interface SurfaceRect {
|
||||
height: number;
|
||||
width: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface LayoutObservation {
|
||||
active: boolean;
|
||||
layoutShift: number;
|
||||
samples: Record<string, SurfaceRect[]>;
|
||||
}
|
||||
|
||||
type LayoutOracleWindow = Window & {
|
||||
__commandCenterLayoutObservation?: LayoutObservation;
|
||||
};
|
||||
|
||||
const SURFACE_SELECTORS = {
|
||||
panel: '[data-testid="command-center-panel"]',
|
||||
header: '[data-testid="command-center-header"]',
|
||||
input: '[data-testid="command-center-input"]',
|
||||
fileSearchStatus: '[data-testid="command-center-file-search-status"]',
|
||||
results: '[data-testid="command-center-results"]',
|
||||
scope: '[data-testid="command-center-files-scope"]',
|
||||
fileRow: '[data-testid^="command-center-file-row-"]',
|
||||
fileIcon: '[data-testid="command-center-file-icon"]',
|
||||
fileLine: '[data-testid="command-center-file-line"]',
|
||||
} as const;
|
||||
|
||||
function parseSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
|
||||
const raw = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
const envelope = JSON.parse(raw) as { type?: unknown; message?: unknown };
|
||||
return envelope.type === "session" && envelope.message && typeof envelope.message === "object"
|
||||
? (envelope.message as Record<string, unknown>)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function delayDirectorySuggestionResponses(page: Page, delayMs = 400): Promise<void> {
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
ws.onMessage((message) => server.send(message));
|
||||
server.onMessage((message) => {
|
||||
const sessionMessage = parseSessionMessage(message);
|
||||
if (sessionMessage?.type === "directory_suggestions_response") {
|
||||
setTimeout(() => ws.send(message), delayMs);
|
||||
return;
|
||||
}
|
||||
ws.send(message);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function failDirectorySuggestionRequests(page: Page): Promise<void> {
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
ws.onMessage((message) => {
|
||||
const sessionMessage = parseSessionMessage(message);
|
||||
if (sessionMessage?.type === "directory_suggestions_request") {
|
||||
const requestId = sessionMessage.requestId;
|
||||
if (typeof requestId === "string") {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "rpc_error",
|
||||
payload: {
|
||||
requestId,
|
||||
requestType: "directory_suggestions_request",
|
||||
error: "Test file search transport failure.",
|
||||
code: "transport",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
server.onMessage((message) => ws.send(message));
|
||||
});
|
||||
}
|
||||
|
||||
export async function startCommandCenterLayoutObservation(page: Page): Promise<void> {
|
||||
await page.evaluate((selectors) => {
|
||||
const oracleWindow = window as LayoutOracleWindow;
|
||||
const observation: LayoutObservation = { active: true, layoutShift: 0, samples: {} };
|
||||
oracleWindow.__commandCenterLayoutObservation = observation;
|
||||
|
||||
const observedElements = new WeakSet<Element>();
|
||||
const resizeObserver = new ResizeObserver(record);
|
||||
const mutationObserver = new MutationObserver(discover);
|
||||
|
||||
function record(): void {
|
||||
if (!observation.active) return;
|
||||
for (const [name, selector] of Object.entries(selectors)) {
|
||||
const element = document.querySelector<HTMLElement>(selector);
|
||||
if (!element) continue;
|
||||
const rect = element.getBoundingClientRect();
|
||||
(observation.samples[name] ??= []).push({
|
||||
height: rect.height,
|
||||
width: rect.width,
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function discover(): void {
|
||||
for (const selector of Object.values(selectors)) {
|
||||
const element = document.querySelector(selector);
|
||||
if (!element || observedElements.has(element)) continue;
|
||||
observedElements.add(element);
|
||||
resizeObserver.observe(element);
|
||||
}
|
||||
record();
|
||||
}
|
||||
|
||||
mutationObserver.observe(document.body, { attributes: true, childList: true, subtree: true });
|
||||
discover();
|
||||
|
||||
const sampleFrame = () => {
|
||||
if (!observation.active) {
|
||||
mutationObserver.disconnect();
|
||||
resizeObserver.disconnect();
|
||||
return;
|
||||
}
|
||||
record();
|
||||
requestAnimationFrame(sampleFrame);
|
||||
};
|
||||
requestAnimationFrame(sampleFrame);
|
||||
|
||||
if (typeof PerformanceObserver !== "undefined") {
|
||||
try {
|
||||
const performanceObserver = new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
observation.layoutShift += (entry as PerformanceEntry & { value?: number }).value ?? 0;
|
||||
}
|
||||
});
|
||||
performanceObserver.observe({ type: "layout-shift", buffered: false });
|
||||
} catch {
|
||||
// LayoutShift is diagnostic-only on engines that do not expose it.
|
||||
}
|
||||
}
|
||||
}, SURFACE_SELECTORS);
|
||||
}
|
||||
|
||||
export async function expectStableCommandCenterLayout(page: Page): Promise<void> {
|
||||
await page.waitForTimeout(500);
|
||||
const observation = await page.evaluate(() => {
|
||||
const state = (window as LayoutOracleWindow).__commandCenterLayoutObservation;
|
||||
if (!state) throw new Error("Command center layout observer was not installed");
|
||||
state.active = false;
|
||||
return state;
|
||||
});
|
||||
|
||||
for (const surface of Object.keys(SURFACE_SELECTORS)) {
|
||||
const samples = observation.samples[surface] ?? [];
|
||||
expect(samples.length, `${surface}: ${JSON.stringify(observation)}`).toBeGreaterThan(0);
|
||||
for (const dimension of ["height", "width", "x", "y"] as const) {
|
||||
const values = samples.map((sample) => sample[dimension]);
|
||||
expect(
|
||||
Math.max(...values) - Math.min(...values),
|
||||
`${surface}.${dimension}: ${JSON.stringify(samples)}`,
|
||||
).toBeLessThanOrEqual(1);
|
||||
}
|
||||
}
|
||||
expect(observation.layoutShift, JSON.stringify(observation)).toBeLessThanOrEqual(0.001);
|
||||
}
|
||||
|
||||
export async function expectOneLineTruncatedFileResult(
|
||||
row: Locator,
|
||||
input: { filename: string; path: string },
|
||||
): Promise<void> {
|
||||
await expect(row).toContainText(`${input.filename} ${input.path}`);
|
||||
await expect(row.getByTestId("command-center-file-icon")).toBeVisible();
|
||||
const line = row.getByTestId("command-center-file-line");
|
||||
const filename = row.getByTestId("command-center-file-name");
|
||||
const filePath = row.getByTestId("command-center-file-path");
|
||||
const geometry = await Promise.all([
|
||||
row.boundingBox(),
|
||||
line.boundingBox(),
|
||||
filename.boundingBox(),
|
||||
filePath.boundingBox(),
|
||||
line.evaluate((element) => ({
|
||||
clientWidth: element.clientWidth,
|
||||
overflow: getComputedStyle(element).overflow,
|
||||
scrollWidth: element.scrollWidth,
|
||||
whiteSpace: getComputedStyle(element).whiteSpace,
|
||||
})),
|
||||
]);
|
||||
const [rowBox, lineBox, filenameBox, pathBox, overflow] = geometry;
|
||||
expect(rowBox).not.toBeNull();
|
||||
expect(lineBox).not.toBeNull();
|
||||
expect(filenameBox).not.toBeNull();
|
||||
expect(pathBox).not.toBeNull();
|
||||
expect(rowBox?.height).toBe(36);
|
||||
expect(Math.abs((filenameBox?.y ?? 0) - (pathBox?.y ?? 0))).toBeLessThanOrEqual(1);
|
||||
expect(overflow.scrollWidth).toBeGreaterThan(overflow.clientWidth);
|
||||
expect(overflow.overflow).toBe("hidden");
|
||||
expect(overflow.whiteSpace).toBe("nowrap");
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import type { MockAgentWorkspace } from "./mock-agent";
|
||||
|
||||
const DIAGRAM_NAME = "Diagram";
|
||||
|
||||
function renderedDiagram(page: Page) {
|
||||
const diagram = page.getByRole("img", { name: DIAGRAM_NAME }).last();
|
||||
const svg = diagram.locator("iframe").contentFrame().locator("#diagram svg");
|
||||
return { diagram, svg };
|
||||
}
|
||||
|
||||
export async function requestDiagram(agent: MockAgentWorkspace): Promise<void> {
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Render the requested Mermaid diagram.");
|
||||
}
|
||||
|
||||
export async function expectDiagramWithLabels(
|
||||
page: Page,
|
||||
labels: readonly string[],
|
||||
): Promise<void> {
|
||||
const { diagram, svg } = renderedDiagram(page);
|
||||
await expect(diagram).toBeVisible({ timeout: 30_000 });
|
||||
await expect(svg).toBeVisible({ timeout: 30_000 });
|
||||
for (const label of labels) {
|
||||
await expect(svg).toContainText(label);
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectDiagramRemainsRenderedWhileStreaming(page: Page): Promise<void> {
|
||||
const { diagram, svg } = renderedDiagram(page);
|
||||
const samples = 80;
|
||||
for (let sample = 0; sample < samples; sample += 1) {
|
||||
await expect(diagram).toBeVisible({ timeout: 100 });
|
||||
await expect(svg).toBeVisible({ timeout: 100 });
|
||||
await page.waitForTimeout(25);
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForDiagramTurnToComplete(agent: MockAgentWorkspace): Promise<void> {
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
}
|
||||
|
||||
export async function expectCompletedDiagram(page: Page, labels: readonly string[]): Promise<void> {
|
||||
await expectDiagramWithLabels(page, labels);
|
||||
}
|
||||
|
||||
export async function reloadConversation(page: Page): Promise<void> {
|
||||
await page.reload();
|
||||
}
|
||||
@@ -17,6 +17,49 @@ interface PairingHostInput {
|
||||
endpoint: string;
|
||||
}
|
||||
|
||||
export async function prepareLocalPairingHost(
|
||||
page: Page,
|
||||
daemon: IsolatedHostDaemon | OutdatedDaemon,
|
||||
additionalHosts: PairingHostInput[] = [],
|
||||
): Promise<void> {
|
||||
await page.addInitScript((localServerId) => {
|
||||
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = {
|
||||
platform: "darwin",
|
||||
invoke: async (command: string) => {
|
||||
if (command === "desktop_daemon_status") {
|
||||
return {
|
||||
serverId: localServerId,
|
||||
status: "running",
|
||||
listen: null,
|
||||
hostname: null,
|
||||
pid: null,
|
||||
home: "",
|
||||
version: null,
|
||||
desktopManaged: true,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
if (command === "get_desktop_settings") {
|
||||
return {
|
||||
releaseChannel: "stable",
|
||||
daemon: { manageBuiltInDaemon: false, keepRunningAfterQuit: true },
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
getPendingOpenProject: async () => null,
|
||||
events: { on: async () => () => undefined },
|
||||
opener: {
|
||||
openUrl: async (url: string) => {
|
||||
localStorage.setItem("@paseo:e2e-opened-url", url);
|
||||
},
|
||||
},
|
||||
};
|
||||
}, daemon.serverId);
|
||||
|
||||
await preparePairingHost(page, daemon, additionalHosts);
|
||||
}
|
||||
|
||||
export async function preparePairingHost(
|
||||
page: Page,
|
||||
daemon: IsolatedHostDaemon | OutdatedDaemon,
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
const REPLICA_CACHE_STORAGE_KEY = "@paseo:replica-cache";
|
||||
|
||||
export interface ReplicaCachePerfReport {
|
||||
elapsedMs: number;
|
||||
redundantWrites: number;
|
||||
serializedChars: number;
|
||||
storageDurationMs: number;
|
||||
writes: number;
|
||||
}
|
||||
|
||||
interface ReplicaCachePerfState {
|
||||
lastValue: string | null;
|
||||
resetAt: number;
|
||||
redundantWrites: number;
|
||||
serializedChars: number;
|
||||
storageDurationMs: number;
|
||||
writes: number;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__replicaCachePerf?: ReplicaCachePerfState;
|
||||
}
|
||||
}
|
||||
|
||||
export async function observeReplicaCacheWrites(page: Page): Promise<void> {
|
||||
await page.addInitScript((storageKey) => {
|
||||
const originalSetItem = Storage.prototype.setItem;
|
||||
window.__replicaCachePerf = {
|
||||
lastValue: localStorage.getItem(storageKey),
|
||||
resetAt: performance.now(),
|
||||
redundantWrites: 0,
|
||||
serializedChars: 0,
|
||||
storageDurationMs: 0,
|
||||
writes: 0,
|
||||
};
|
||||
Storage.prototype.setItem = function measuredSetItem(key: string, value: string): void {
|
||||
const startedAt = performance.now();
|
||||
originalSetItem.call(this, key, value);
|
||||
if (key !== storageKey) return;
|
||||
const state = window.__replicaCachePerf;
|
||||
if (!state) return;
|
||||
if (state.lastValue === value) state.redundantWrites += 1;
|
||||
state.lastValue = value;
|
||||
state.writes += 1;
|
||||
state.serializedChars += value.length;
|
||||
state.storageDurationMs += performance.now() - startedAt;
|
||||
};
|
||||
}, REPLICA_CACHE_STORAGE_KEY);
|
||||
}
|
||||
|
||||
export async function beginReplicaCacheMeasurement(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const lastValue = window.__replicaCachePerf?.lastValue ?? null;
|
||||
window.__replicaCachePerf = {
|
||||
lastValue,
|
||||
resetAt: performance.now(),
|
||||
redundantWrites: 0,
|
||||
serializedChars: 0,
|
||||
storageDurationMs: 0,
|
||||
writes: 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function readReplicaCacheMeasurement(page: Page): Promise<ReplicaCachePerfReport> {
|
||||
return page.evaluate(() => {
|
||||
const state = window.__replicaCachePerf;
|
||||
if (!state) throw new Error("Replica cache performance observer is not installed");
|
||||
return {
|
||||
elapsedMs: performance.now() - state.resetAt,
|
||||
redundantWrites: state.redundantWrites,
|
||||
serializedChars: state.serializedChars,
|
||||
storageDurationMs: state.storageDurationMs,
|
||||
writes: state.writes,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -160,7 +160,7 @@ export async function launchAgent(input: {
|
||||
model?: string;
|
||||
modeId?: string;
|
||||
featureValues?: Record<string, unknown>;
|
||||
extra?: { codex?: { features?: { multi_agent_v2?: boolean } } };
|
||||
providerOptions?: { features?: { multi_agent_v2?: boolean } };
|
||||
};
|
||||
}): Promise<AgentHandle> {
|
||||
execFileSync("git", ["init", "-b", "main"], { cwd: input.cwd, stdio: "ignore" });
|
||||
|
||||
@@ -36,9 +36,11 @@ type HostSection =
|
||||
| "connections"
|
||||
| "pair-device"
|
||||
| "agents"
|
||||
| "metadata"
|
||||
| "workspaces"
|
||||
| "providers"
|
||||
| "usage"
|
||||
| "terminals"
|
||||
| "host";
|
||||
|
||||
export async function openSettingsSection(page: Page, section: SettingsSection): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
export async function openCompletedTaskList(page: Page, count: number): Promise<void> {
|
||||
const taskButton = page.getByRole("button", { name: `${count}/${count} tasks` });
|
||||
await expect(taskButton).toBeVisible({ timeout: 60_000 });
|
||||
await taskButton.click();
|
||||
}
|
||||
|
||||
export async function expectTaskListEntries(page: Page, entries: readonly string[]): Promise<void> {
|
||||
for (const entry of entries) {
|
||||
await expect(page.getByLabel(entry, { exact: true })).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectCompletedTaskActivity(
|
||||
page: Page,
|
||||
entries: readonly string[],
|
||||
): Promise<void> {
|
||||
for (const entry of entries) {
|
||||
await expect(
|
||||
page.getByRole("button", { name: `Completed ${entry}`, exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.3.0-beta.4",
|
||||
"version": "0.3.1",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -29,7 +29,8 @@
|
||||
"build:web": "npm --prefix ../.. run build:app-deps && expo export --platform web",
|
||||
"profile:workspace-tabs": "node ./scripts/profile-workspace-tabs.mjs",
|
||||
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main",
|
||||
"build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs"
|
||||
"build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs",
|
||||
"build:mermaid-runtime": "node ./src/components/markdown/fence/mermaid/build-runtime.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
@@ -92,6 +93,7 @@
|
||||
"i18next": "^26.3.0",
|
||||
"lucide-react-native": "^0.546.0",
|
||||
"markdown-it": "^10.0.0",
|
||||
"mermaid": "^11.16.0",
|
||||
"mnemonic-id": "^3.2.7",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19.1.0",
|
||||
@@ -117,6 +119,7 @@
|
||||
"turndown": "^7.2.4",
|
||||
"turndown-plugin-gfm": "^1.0.2",
|
||||
"use-sync-external-store": "^1.6.0",
|
||||
"yaml": "^2.8.4",
|
||||
"zod": "^4.4.3",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
@@ -134,6 +137,7 @@
|
||||
"@xterm/headless": "^6.1.0-beta.213",
|
||||
"dotenv": "^17.2.3",
|
||||
"eas-cli": "^16.24.1",
|
||||
"esbuild": "0.28.1",
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-config-expo": "~10.0.0",
|
||||
"expo-gradle-jvmargs": "^1.1.2",
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
const { withGradleProperties } = require("expo/config-plugins");
|
||||
|
||||
const ASYNC_STORAGE_SIZE_PROPERTY = "AsyncStorage_db_size_in_MB";
|
||||
|
||||
function withAndroidAsyncStorageSize(config, sizeInMegabytes) {
|
||||
return withGradleProperties(config, (modConfig) => {
|
||||
const existingProperty = modConfig.modResults.find(
|
||||
(property) => property.type === "property" && property.key === ASYNC_STORAGE_SIZE_PROPERTY,
|
||||
);
|
||||
if (existingProperty?.type === "property") {
|
||||
existingProperty.value = String(sizeInMegabytes);
|
||||
return modConfig;
|
||||
}
|
||||
|
||||
modConfig.modResults.push({
|
||||
type: "property",
|
||||
key: ASYNC_STORAGE_SIZE_PROPERTY,
|
||||
value: String(sizeInMegabytes),
|
||||
});
|
||||
return modConfig;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = withAndroidAsyncStorageSize;
|
||||
@@ -39,9 +39,9 @@ describe("Add Project navigation", () => {
|
||||
expect(currentAddProjectPage(state)).toEqual({
|
||||
kind: "method",
|
||||
hostId: "host-1",
|
||||
query: "",
|
||||
activeIndex: 0,
|
||||
error: null,
|
||||
isSubmitting: false,
|
||||
});
|
||||
expect(backAddProjectPage(state)).toBeNull();
|
||||
});
|
||||
|
||||
@@ -17,15 +17,18 @@ export interface GithubRepositoryChoice {
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
interface SearchPageState {
|
||||
query: string;
|
||||
interface PageState {
|
||||
activeIndex: number;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface SearchPageState extends PageState {
|
||||
query: string;
|
||||
}
|
||||
|
||||
export type AddProjectPage =
|
||||
| ({ kind: "host" } & SearchPageState)
|
||||
| ({ kind: "method"; hostId: string } & SearchPageState)
|
||||
| ({ kind: "method"; hostId: string; isSubmitting: boolean } & PageState)
|
||||
| ({ kind: "directory-search"; hostId: string; isSubmitting: boolean } & SearchPageState)
|
||||
| ({ kind: "github-search"; hostId: string } & SearchPageState)
|
||||
| ({
|
||||
@@ -57,12 +60,14 @@ export interface OpenAddProjectFlowInput {
|
||||
preferredHostId?: string;
|
||||
}
|
||||
|
||||
function searchPage<TKind extends AddProjectPage["kind"]>(kind: TKind) {
|
||||
type SearchPageKind = Extract<AddProjectPage, { query: string }>["kind"];
|
||||
|
||||
function searchPage<TKind extends SearchPageKind>(kind: TKind) {
|
||||
return { kind, query: "", activeIndex: 0, error: null } as const;
|
||||
}
|
||||
|
||||
function methodPage(hostId: string): AddProjectPage {
|
||||
return { ...searchPage("method"), hostId };
|
||||
return { kind: "method", hostId, activeIndex: 0, error: null, isSubmitting: false };
|
||||
}
|
||||
|
||||
export function openAddProjectFlow(input: OpenAddProjectFlowInput): AddProjectFlowState {
|
||||
@@ -209,6 +214,7 @@ export function setAddProjectPageInput(
|
||||
if (current.kind === "new-directory-name") {
|
||||
return { ...current, name: value, activeIndex: 0, error: null };
|
||||
}
|
||||
if (current.kind === "method") return current;
|
||||
return { ...current, query: value, activeIndex: 0, error: null };
|
||||
});
|
||||
if (page.kind !== "github-location") return updated;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Agent profiles: named bundles of composer settings (provider, model, mode,
|
||||
* thinking option, feature values, notes) stored host-wide in daemon config.
|
||||
*
|
||||
* Four capabilities leave this module — managing the list in settings, reading
|
||||
* it, pinning it to the model picker, and drawing a profile's glyph. Everything
|
||||
* else (the form model, the catalog and feature probes, the materialization
|
||||
* rules, the icon registry, the row and modal chrome) is internal; import from
|
||||
* `@/agent-profiles`, never a path inside it.
|
||||
*
|
||||
* `useAgentProfilePicker` deliberately hands the picker a flat row view model
|
||||
* and one `applyProfile(id)` callback rather than the profiles themselves: what
|
||||
* a profile contains, and how it reaches a live agent versus a draft, stays in
|
||||
* here.
|
||||
*/
|
||||
export type { AgentProfile } from "@getpaseo/protocol/messages";
|
||||
export { useAgentProfiles } from "./internal/use-agent-profiles";
|
||||
export {
|
||||
useAgentProfilePicker,
|
||||
type AgentProfileApplyTarget,
|
||||
type AgentProfilePicker,
|
||||
type AgentProfilePickerRow,
|
||||
type DraftAgentProfileControls,
|
||||
} from "./internal/use-agent-profile-picker";
|
||||
export { AgentProfileGlyph } from "./internal/agent-profile-glyph";
|
||||
export { AgentProfilesSection } from "./settings/agent-profiles-section";
|
||||
@@ -0,0 +1,128 @@
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
BookOpen,
|
||||
Bot,
|
||||
Boxes,
|
||||
Brain,
|
||||
Bug,
|
||||
Cloud,
|
||||
Code,
|
||||
Compass,
|
||||
Cpu,
|
||||
Database,
|
||||
Eye,
|
||||
Feather,
|
||||
FileText,
|
||||
FlaskConical,
|
||||
GitBranch,
|
||||
Globe,
|
||||
Hammer,
|
||||
Layers,
|
||||
Microscope,
|
||||
Package,
|
||||
Palette,
|
||||
Pencil,
|
||||
Rocket,
|
||||
Search,
|
||||
Server,
|
||||
Shield,
|
||||
Sparkles,
|
||||
Terminal,
|
||||
TestTube,
|
||||
Wrench,
|
||||
} from "lucide-react-native";
|
||||
import { identityForeground } from "@/styles/identity-colors";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import {
|
||||
AGENT_PROFILE_COLORS,
|
||||
resolveAgentProfileColor,
|
||||
resolveAgentProfileIconKey,
|
||||
type AgentProfileColor,
|
||||
type AgentProfileIconKey,
|
||||
} from "./profile-appearance";
|
||||
|
||||
/** Drawn when a profile names no icon, and as the "default" cell in the picker grid. */
|
||||
const ThemedDefaultIcon = withUnistyles(Bot);
|
||||
|
||||
/**
|
||||
* `withUnistyles` has to wrap each icon once at module scope, so the registry
|
||||
* stores the themed component rather than the raw lucide one.
|
||||
*/
|
||||
const THEMED_ICONS: Record<AgentProfileIconKey, typeof ThemedDefaultIcon> = {
|
||||
code: withUnistyles(Code),
|
||||
terminal: withUnistyles(Terminal),
|
||||
bug: withUnistyles(Bug),
|
||||
wrench: withUnistyles(Wrench),
|
||||
hammer: withUnistyles(Hammer),
|
||||
|
||||
flask: withUnistyles(FlaskConical),
|
||||
testTube: withUnistyles(TestTube),
|
||||
microscope: withUnistyles(Microscope),
|
||||
search: withUnistyles(Search),
|
||||
eye: withUnistyles(Eye),
|
||||
|
||||
palette: withUnistyles(Palette),
|
||||
feather: withUnistyles(Feather),
|
||||
pencil: withUnistyles(Pencil),
|
||||
fileText: withUnistyles(FileText),
|
||||
book: withUnistyles(BookOpen),
|
||||
|
||||
rocket: withUnistyles(Rocket),
|
||||
package: withUnistyles(Package),
|
||||
boxes: withUnistyles(Boxes),
|
||||
server: withUnistyles(Server),
|
||||
database: withUnistyles(Database),
|
||||
|
||||
cpu: withUnistyles(Cpu),
|
||||
cloud: withUnistyles(Cloud),
|
||||
globe: withUnistyles(Globe),
|
||||
gitBranch: withUnistyles(GitBranch),
|
||||
layers: withUnistyles(Layers),
|
||||
|
||||
compass: withUnistyles(Compass),
|
||||
brain: withUnistyles(Brain),
|
||||
sparkles: withUnistyles(Sparkles),
|
||||
shield: withUnistyles(Shield),
|
||||
};
|
||||
|
||||
const mutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
/**
|
||||
* One `uniProps` mapping per colour, built once. Building these inline would
|
||||
* hand `withUnistyles` a new function every render and defeat its tracking.
|
||||
*/
|
||||
const COLOR_MAPPINGS: Record<AgentProfileColor, (theme: Theme) => { color: string }> = (() => {
|
||||
const byColor = {} as Record<AgentProfileColor, (theme: Theme) => { color: string }>;
|
||||
for (const color of AGENT_PROFILE_COLORS) {
|
||||
byColor[color] =
|
||||
color === "none"
|
||||
? mutedMapping
|
||||
: (theme: Theme) => ({ color: identityForeground(color, theme.colorScheme) });
|
||||
}
|
||||
return byColor;
|
||||
})();
|
||||
|
||||
export function agentProfileColorMapping(
|
||||
color: AgentProfileColor,
|
||||
): (theme: Theme) => { color: string } {
|
||||
return COLOR_MAPPINGS[color];
|
||||
}
|
||||
|
||||
/**
|
||||
* How a profile is drawn everywhere it appears — settings list, model picker,
|
||||
* and the picker grid's own cells. One component so the three can't drift.
|
||||
*/
|
||||
export function AgentProfileGlyph({
|
||||
icon,
|
||||
color,
|
||||
size = ICON_SIZE.md,
|
||||
}: {
|
||||
icon?: string | undefined;
|
||||
color?: string | undefined;
|
||||
size?: number;
|
||||
}) {
|
||||
const iconKey = resolveAgentProfileIconKey(icon);
|
||||
const mapping = COLOR_MAPPINGS[resolveAgentProfileColor(color)];
|
||||
const Icon = iconKey ? THEMED_ICONS[iconKey] : ThemedDefaultIcon;
|
||||
return <Icon size={size} uniProps={mapping} />;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { supportsAgentProfiles } from "./capabilities";
|
||||
|
||||
describe("agent profile capabilities", () => {
|
||||
it("requires storage and live config application support", () => {
|
||||
expect(supportsAgentProfiles(undefined)).toBe(false);
|
||||
expect(supportsAgentProfiles({ agentProfiles: true })).toBe(false);
|
||||
expect(supportsAgentProfiles({ agentConfigApply: true })).toBe(false);
|
||||
expect(supportsAgentProfiles({ agentProfiles: true, agentConfigApply: true })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
interface AgentProfileCapabilities {
|
||||
agentProfiles?: boolean;
|
||||
agentConfigApply?: boolean;
|
||||
}
|
||||
|
||||
export function supportsAgentProfiles(features: AgentProfileCapabilities | undefined): boolean {
|
||||
return features?.agentProfiles === true && features.agentConfigApply === true;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentProfile } from "@getpaseo/protocol/messages";
|
||||
import { materializeAgentProfile, toAgentConfigApply } from "./materialize-profile";
|
||||
|
||||
function profile(overrides: Partial<AgentProfile> = {}): AgentProfile {
|
||||
return {
|
||||
id: "ui-work",
|
||||
name: "UI work",
|
||||
provider: "claude",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("materializeAgentProfile", () => {
|
||||
it("resolves every field a full profile names", () => {
|
||||
expect(
|
||||
materializeAgentProfile(
|
||||
profile({
|
||||
model: "claude-opus-5",
|
||||
modeId: "plan",
|
||||
thinkingOptionId: "think-hard",
|
||||
featureValues: { web_search: true },
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
provider: "claude",
|
||||
modelId: "claude-opus-5",
|
||||
modeId: "plan",
|
||||
thinkingOptionId: "think-hard",
|
||||
featureValues: { web_search: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("treats omitted and blank fields the same", () => {
|
||||
expect(materializeAgentProfile(profile({ model: " ", modeId: "" }))).toEqual({
|
||||
provider: "claude",
|
||||
modelId: "",
|
||||
modeId: "",
|
||||
thinkingOptionId: "",
|
||||
featureValues: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("trims stored ids", () => {
|
||||
expect(materializeAgentProfile(profile({ model: " claude-opus-5 " })).modelId).toBe(
|
||||
"claude-opus-5",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toAgentConfigApply", () => {
|
||||
it("sends every value the profile names", () => {
|
||||
expect(
|
||||
toAgentConfigApply(
|
||||
materializeAgentProfile(
|
||||
profile({
|
||||
model: "claude-opus-5",
|
||||
modeId: "plan",
|
||||
thinkingOptionId: "think-hard",
|
||||
featureValues: { web_search: true },
|
||||
}),
|
||||
),
|
||||
),
|
||||
).toEqual({
|
||||
modelId: "claude-opus-5",
|
||||
modeId: "plan",
|
||||
thinkingOptionId: "think-hard",
|
||||
featureValues: { web_search: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("omits what the profile leaves alone rather than clearing it", () => {
|
||||
expect(toAgentConfigApply(materializeAgentProfile(profile({ modeId: "plan" })))).toEqual({
|
||||
modeId: "plan",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits an empty feature map so the agent keeps its own feature values", () => {
|
||||
expect(
|
||||
toAgentConfigApply(materializeAgentProfile(profile({ featureValues: {} }))),
|
||||
).not.toHaveProperty("featureValues");
|
||||
});
|
||||
|
||||
it("never carries a provider, which a running agent cannot change", () => {
|
||||
expect(
|
||||
toAgentConfigApply(materializeAgentProfile(profile({ model: "claude-opus-5" }))),
|
||||
).not.toHaveProperty("provider");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { AgentConfigApply, AgentProfile } from "@getpaseo/protocol/messages";
|
||||
|
||||
/**
|
||||
* A profile with its blank fields resolved away. Storage keeps every field
|
||||
* optional and lets the user clear one to an empty string; applying has to know
|
||||
* the difference between "set this" and "leave whatever is there alone", and
|
||||
* every consumer would otherwise re-derive the same trim-and-drop rules.
|
||||
*/
|
||||
export interface MaterializedAgentProfile {
|
||||
provider: string;
|
||||
/** Empty when the profile names no model, meaning "leave the model alone". */
|
||||
modelId: string;
|
||||
modeId: string;
|
||||
thinkingOptionId: string;
|
||||
featureValues: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function trimmed(value: string | undefined): string {
|
||||
return value?.trim() ?? "";
|
||||
}
|
||||
|
||||
export function materializeAgentProfile(profile: AgentProfile): MaterializedAgentProfile {
|
||||
return {
|
||||
provider: trimmed(profile.provider),
|
||||
modelId: trimmed(profile.model),
|
||||
modeId: trimmed(profile.modeId),
|
||||
thinkingOptionId: trimmed(profile.thinkingOptionId),
|
||||
featureValues: profile.featureValues ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The payload for a running agent. Omitted fields are left alone by the daemon,
|
||||
* so a profile that names no mode does not reset the agent's mode. Provider is
|
||||
* absent because a running agent cannot change the process it is.
|
||||
*/
|
||||
export function toAgentConfigApply(profile: MaterializedAgentProfile): AgentConfigApply {
|
||||
return {
|
||||
...(profile.modelId ? { modelId: profile.modelId } : {}),
|
||||
...(profile.modeId ? { modeId: profile.modeId } : {}),
|
||||
...(profile.thinkingOptionId ? { thinkingOptionId: profile.thinkingOptionId } : {}),
|
||||
...(Object.keys(profile.featureValues).length > 0
|
||||
? { featureValues: profile.featureValues }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { IDENTITY_COLOR_NAMES, type IdentityColorName } from "@/styles/identity-colors";
|
||||
|
||||
/**
|
||||
* A profile's icon is a key into a fixed registry, not a glyph. The value is
|
||||
* stored in daemon config and drawn by every client, so it has to survive being
|
||||
* rendered by an app version that ships a different icon set — an unknown key
|
||||
* falls back to the default glyph rather than showing the raw string.
|
||||
*
|
||||
* Order is what the grid draws, five to a row: related icons sit together so
|
||||
* scanning the grid is scanning categories, not thirty unrelated shapes.
|
||||
*/
|
||||
export const AGENT_PROFILE_ICON_KEYS = [
|
||||
"code",
|
||||
"terminal",
|
||||
"bug",
|
||||
"wrench",
|
||||
"hammer",
|
||||
|
||||
"flask",
|
||||
"testTube",
|
||||
"microscope",
|
||||
"search",
|
||||
"eye",
|
||||
|
||||
"palette",
|
||||
"feather",
|
||||
"pencil",
|
||||
"fileText",
|
||||
"book",
|
||||
|
||||
"rocket",
|
||||
"package",
|
||||
"boxes",
|
||||
"server",
|
||||
"database",
|
||||
|
||||
"cpu",
|
||||
"cloud",
|
||||
"globe",
|
||||
"gitBranch",
|
||||
"layers",
|
||||
|
||||
"compass",
|
||||
"brain",
|
||||
"sparkles",
|
||||
"shield",
|
||||
] as const;
|
||||
|
||||
export type AgentProfileIconKey = (typeof AGENT_PROFILE_ICON_KEYS)[number];
|
||||
|
||||
/** Reuses the host identity palette so a profile and a host name read as one family. */
|
||||
export type AgentProfileColor = "none" | IdentityColorName;
|
||||
|
||||
export const AGENT_PROFILE_COLORS: readonly AgentProfileColor[] = ["none", ...IDENTITY_COLOR_NAMES];
|
||||
|
||||
export function isAgentProfileIconKey(value: string | undefined): value is AgentProfileIconKey {
|
||||
return AGENT_PROFILE_ICON_KEYS.some((key) => key === value);
|
||||
}
|
||||
|
||||
export function isAgentProfileColor(value: string | undefined): value is AgentProfileColor {
|
||||
return AGENT_PROFILE_COLORS.some((color) => color === value);
|
||||
}
|
||||
|
||||
/** Anything unrecognised means "not chosen": the glyph and colour both default. */
|
||||
export function resolveAgentProfileIconKey(value: string | undefined): AgentProfileIconKey | null {
|
||||
return isAgentProfileIconKey(value) ? value : null;
|
||||
}
|
||||
|
||||
export function resolveAgentProfileColor(value: string | undefined): AgentProfileColor {
|
||||
return isAgentProfileColor(value) ? value : "none";
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildFeatureRequestKey,
|
||||
openAgentProfileForm,
|
||||
type AgentProfileFormModel,
|
||||
} from "./profile-form-model";
|
||||
|
||||
const CLAUDE: ProviderSnapshotEntry = {
|
||||
provider: "claude",
|
||||
status: "ready",
|
||||
enabled: true,
|
||||
label: "Claude Code",
|
||||
defaultModeId: "plan",
|
||||
modes: [
|
||||
{ id: "plan", label: "Plan" },
|
||||
{ id: "accept-edits", label: "Accept edits" },
|
||||
],
|
||||
models: [
|
||||
{
|
||||
provider: "claude",
|
||||
id: "claude-opus-5",
|
||||
label: "Opus 5",
|
||||
isDefault: true,
|
||||
thinkingOptions: [
|
||||
{ id: "think", label: "Think" },
|
||||
{ id: "think-hard", label: "Think hard" },
|
||||
],
|
||||
},
|
||||
{
|
||||
provider: "claude",
|
||||
id: "claude-haiku-4-5",
|
||||
label: "Haiku 4.5",
|
||||
thinkingOptions: [{ id: "think", label: "Think" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const CODEX: ProviderSnapshotEntry = {
|
||||
provider: "codex",
|
||||
status: "ready",
|
||||
enabled: true,
|
||||
label: "Codex",
|
||||
modes: [{ id: "read-only", label: "Read only" }],
|
||||
models: [{ provider: "codex", id: "gpt-5.2-codex", label: "GPT-5.2 Codex", isDefault: true }],
|
||||
};
|
||||
|
||||
const DISABLED: ProviderSnapshotEntry = {
|
||||
provider: "pi",
|
||||
status: "ready",
|
||||
enabled: false,
|
||||
label: "Pi",
|
||||
};
|
||||
|
||||
const ENTRIES = [CLAUDE, CODEX, DISABLED];
|
||||
|
||||
function openWithCatalog(
|
||||
snapshot: Parameters<typeof openAgentProfileForm>[0],
|
||||
): AgentProfileFormModel {
|
||||
const model = openAgentProfileForm(snapshot);
|
||||
model.applyProviderCatalog(ENTRIES);
|
||||
return model;
|
||||
}
|
||||
|
||||
function selectClaude(model: AgentProfileFormModel): void {
|
||||
model.setProvider("claude", { label: "Claude Code" });
|
||||
}
|
||||
|
||||
function optionValues(options: readonly { value: string }[]): string[] {
|
||||
return options.map((option) => option.value);
|
||||
}
|
||||
|
||||
describe("openAgentProfileForm", () => {
|
||||
it("starts a create form empty and cannot submit", () => {
|
||||
const model = openWithCatalog({ mode: "create" });
|
||||
const state = model.getState();
|
||||
|
||||
expect(state.name).toBe("");
|
||||
expect(state.provider).toBe("");
|
||||
expect(state.canSubmit).toBe(false);
|
||||
expect(state.submitValue).toBeNull();
|
||||
expect(state.disclosure.showModelField).toBe(false);
|
||||
});
|
||||
|
||||
it("lists only enabled providers", () => {
|
||||
const model = openWithCatalog({ mode: "create" });
|
||||
|
||||
expect(optionValues(model.getState().providerOptions)).toEqual(["claude", "codex"]);
|
||||
});
|
||||
|
||||
it("requires a name and a provider before it can submit", () => {
|
||||
const model = openWithCatalog({ mode: "create" });
|
||||
|
||||
model.setName("UI work");
|
||||
expect(model.getState().canSubmit).toBe(false);
|
||||
|
||||
selectClaude(model);
|
||||
expect(model.getState().canSubmit).toBe(true);
|
||||
expect(model.getState().submitValue).toMatchObject({ name: "UI work", provider: "claude" });
|
||||
});
|
||||
|
||||
it("omits blank text fields from the submitted value but never a selection", () => {
|
||||
const model = openWithCatalog({ mode: "create" });
|
||||
model.setName(" Cheap grunt ");
|
||||
model.setProvider("codex", { label: "Codex" });
|
||||
model.setNotes(" ");
|
||||
|
||||
// Codex declares no thinking options, so that key is genuinely absent —
|
||||
// model and mode are seeded, never left for the host to decide.
|
||||
expect(model.getState().submitValue).toEqual({
|
||||
name: "Cheap grunt",
|
||||
provider: "codex",
|
||||
model: "gpt-5.2-codex",
|
||||
modeId: "read-only",
|
||||
});
|
||||
});
|
||||
|
||||
it("seeds edit mode from the stored profile before the catalog lands", () => {
|
||||
const model = openAgentProfileForm({
|
||||
mode: "edit",
|
||||
profile: {
|
||||
id: "p1",
|
||||
name: "UI work",
|
||||
icon: "palette",
|
||||
provider: "claude",
|
||||
model: "claude-opus-5",
|
||||
modeId: "plan",
|
||||
thinkingOptionId: "think-hard",
|
||||
featureValues: { webSearch: true },
|
||||
notes: "Visual work only.",
|
||||
},
|
||||
});
|
||||
const state = model.getState();
|
||||
|
||||
expect(state.name).toBe("UI work");
|
||||
expect(state.icon).toBe("palette");
|
||||
// Ids double as labels until the catalog arrives.
|
||||
expect(state.modelDisplay).toEqual({ label: "claude-opus-5" });
|
||||
expect(state.thinkingDisplay).toEqual({ label: "think-hard" });
|
||||
expect(state.catalogResolution).toBe("idle");
|
||||
});
|
||||
|
||||
it("upgrades seeded displays to catalog labels without touching selections", () => {
|
||||
const model = openAgentProfileForm({
|
||||
mode: "edit",
|
||||
profile: {
|
||||
id: "p1",
|
||||
name: "UI work",
|
||||
provider: "claude",
|
||||
model: "claude-opus-5",
|
||||
modeId: "plan",
|
||||
thinkingOptionId: "think-hard",
|
||||
},
|
||||
});
|
||||
model.applyProviderCatalog(ENTRIES);
|
||||
const state = model.getState();
|
||||
|
||||
expect(state.providerDisplay).toEqual({ label: "Claude Code" });
|
||||
expect(state.modelDisplay).toEqual({ label: "Opus 5" });
|
||||
expect(state.modeDisplay).toEqual({ label: "Plan" });
|
||||
expect(state.thinkingDisplay).toEqual({ label: "Think hard" });
|
||||
expect(state.modelId).toBe("claude-opus-5");
|
||||
expect(state.catalogResolution).toBe("complete");
|
||||
});
|
||||
|
||||
it("keeps a stored value the catalog does not know", () => {
|
||||
const model = openAgentProfileForm({
|
||||
mode: "edit",
|
||||
profile: { id: "p1", name: "Legacy", provider: "claude", model: "claude-retired-3" },
|
||||
});
|
||||
model.applyProviderCatalog(ENTRIES);
|
||||
|
||||
expect(model.getState().modelId).toBe("claude-retired-3");
|
||||
expect(model.getState().modelDisplay).toEqual({ label: "claude-retired-3" });
|
||||
expect(model.getState().disclosure.showModelField).toBe(true);
|
||||
});
|
||||
|
||||
describe("provider cascade", () => {
|
||||
it("reseeds every provider-scoped selection when the provider changes", () => {
|
||||
const model = openWithCatalog({ mode: "create" });
|
||||
model.setName("Test writer");
|
||||
selectClaude(model);
|
||||
model.setModel("claude-opus-5", { label: "Opus 5" });
|
||||
model.setThinking("think-hard", { label: "Think hard" });
|
||||
model.applyFeatures(model.getState().featureRequestKey ?? "", [
|
||||
{ type: "toggle", id: "webSearch", label: "Web search", value: false },
|
||||
]);
|
||||
model.setFeatureValue("webSearch", true);
|
||||
|
||||
model.setProvider("codex", { label: "Codex" });
|
||||
const state = model.getState();
|
||||
|
||||
// Claude's ids mean nothing under Codex, so each field lands on Codex's own default.
|
||||
expect(state.modelId).toBe("gpt-5.2-codex");
|
||||
expect(state.modelDisplay).toEqual({ label: "GPT-5.2 Codex" });
|
||||
expect(state.thinkingOptionId).toBe("");
|
||||
expect(state.thinkingDisplay).toBeNull();
|
||||
expect(state.featureValues).toEqual({});
|
||||
expect(state.features).toEqual([]);
|
||||
expect(state.name).toBe("Test writer");
|
||||
});
|
||||
|
||||
it("seeds each provider's default mode", () => {
|
||||
const model = openWithCatalog({ mode: "create" });
|
||||
selectClaude(model);
|
||||
|
||||
expect(model.getState().modeId).toBe("plan");
|
||||
|
||||
// Codex declares no defaultModeId, so the first mode it offers stands in.
|
||||
model.setProvider("codex", { label: "Codex" });
|
||||
expect(model.getState().modeId).toBe("read-only");
|
||||
});
|
||||
|
||||
it("re-populates model, mode and thinking options from the new provider", () => {
|
||||
const model = openWithCatalog({ mode: "create" });
|
||||
selectClaude(model);
|
||||
|
||||
expect(optionValues(model.getState().modelOptions)).toEqual([
|
||||
"claude-opus-5",
|
||||
"claude-haiku-4-5",
|
||||
]);
|
||||
expect(optionValues(model.getState().thinkingOptions)).toEqual(["think", "think-hard"]);
|
||||
|
||||
model.setProvider("codex", { label: "Codex" });
|
||||
|
||||
expect(optionValues(model.getState().modelOptions)).toEqual(["gpt-5.2-codex"]);
|
||||
expect(optionValues(model.getState().modeOptions)).toEqual(["read-only"]);
|
||||
// GPT-5.2 Codex declares no thinking options, so the field disappears.
|
||||
expect(model.getState().thinkingOptions).toEqual([]);
|
||||
expect(model.getState().disclosure.showThinkingField).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores a no-op provider selection", () => {
|
||||
const model = openWithCatalog({ mode: "create" });
|
||||
selectClaude(model);
|
||||
model.setModel("claude-opus-5", { label: "Opus 5" });
|
||||
|
||||
selectClaude(model);
|
||||
|
||||
expect(model.getState().modelId).toBe("claude-opus-5");
|
||||
});
|
||||
});
|
||||
|
||||
describe("model cascade", () => {
|
||||
it("reseeds a thinking level the new model does not offer", () => {
|
||||
const model = openWithCatalog({ mode: "create" });
|
||||
selectClaude(model);
|
||||
model.setModel("claude-opus-5", { label: "Opus 5" });
|
||||
model.setThinking("think-hard", { label: "Think hard" });
|
||||
|
||||
// Haiku offers only "think", so "think-hard" cannot survive the switch.
|
||||
model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" });
|
||||
|
||||
expect(model.getState().thinkingOptionId).toBe("think");
|
||||
expect(model.getState().thinkingDisplay).toEqual({ label: "Think" });
|
||||
});
|
||||
|
||||
it("keeps a thinking level the new model still offers", () => {
|
||||
const model = openWithCatalog({ mode: "create" });
|
||||
selectClaude(model);
|
||||
model.setModel("claude-opus-5", { label: "Opus 5" });
|
||||
model.setThinking("think", { label: "Think" });
|
||||
|
||||
model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" });
|
||||
|
||||
expect(model.getState().thinkingOptionId).toBe("think");
|
||||
});
|
||||
|
||||
it("seeds the provider's default model and reads thinking options from it", () => {
|
||||
const model = openWithCatalog({ mode: "create" });
|
||||
selectClaude(model);
|
||||
|
||||
expect(model.getState().modelId).toBe("claude-opus-5");
|
||||
expect(optionValues(model.getState().thinkingOptions)).toEqual(["think", "think-hard"]);
|
||||
expect(model.getState().thinkingOptionId).toBe("think");
|
||||
});
|
||||
|
||||
it("offers no way to unset a selection", () => {
|
||||
const model = openWithCatalog({ mode: "create" });
|
||||
selectClaude(model);
|
||||
const state = model.getState();
|
||||
|
||||
const everyOption = [...state.modelOptions, ...state.modeOptions, ...state.thinkingOptions];
|
||||
|
||||
expect(everyOption.length).toBeGreaterThan(0);
|
||||
expect(optionValues(everyOption)).not.toContain("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("features", () => {
|
||||
it("asks for features per provider/model/mode/thinking combination", () => {
|
||||
const model = openWithCatalog({ mode: "create" });
|
||||
selectClaude(model);
|
||||
|
||||
expect(model.getState().featureRequest).toEqual({
|
||||
provider: "claude",
|
||||
model: "claude-opus-5",
|
||||
modeId: "plan",
|
||||
thinkingOptionId: "think",
|
||||
});
|
||||
const firstKey = model.getState().featureRequestKey;
|
||||
|
||||
model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" });
|
||||
|
||||
expect(model.getState().featureRequestKey).not.toBe(firstKey);
|
||||
expect(model.getState().featureRequest).toEqual({
|
||||
provider: "claude",
|
||||
model: "claude-haiku-4-5",
|
||||
modeId: "plan",
|
||||
thinkingOptionId: "think",
|
||||
});
|
||||
});
|
||||
|
||||
it("moves from pending to complete only for the current request", () => {
|
||||
const model = openWithCatalog({ mode: "create" });
|
||||
selectClaude(model);
|
||||
const key = model.getState().featureRequestKey ?? "";
|
||||
expect(model.getState().featureResolution).toBe("pending");
|
||||
|
||||
model.applyFeatures("stale-key", [
|
||||
{ type: "toggle", id: "webSearch", label: "Web search", value: false },
|
||||
]);
|
||||
expect(model.getState().featureResolution).toBe("pending");
|
||||
expect(model.getState().features).toEqual([]);
|
||||
|
||||
model.applyFeatures(key, [
|
||||
{ type: "toggle", id: "webSearch", label: "Web search", value: false },
|
||||
]);
|
||||
expect(model.getState().featureResolution).toBe("complete");
|
||||
expect(model.getState().disclosure.showFeaturesField).toBe(true);
|
||||
});
|
||||
|
||||
it("reflects a set feature value back into the rendered feature", () => {
|
||||
const model = openWithCatalog({ mode: "create" });
|
||||
selectClaude(model);
|
||||
model.applyFeatures(model.getState().featureRequestKey ?? "", [
|
||||
{ type: "toggle", id: "webSearch", label: "Web search", value: false },
|
||||
]);
|
||||
|
||||
model.setFeatureValue("webSearch", true);
|
||||
|
||||
expect(model.getState().features[0]).toMatchObject({ id: "webSearch", value: true });
|
||||
expect(model.getState().featureValues).toEqual({ webSearch: true });
|
||||
});
|
||||
|
||||
it("keeps stored feature values while the feature list is still pending", () => {
|
||||
const model = openAgentProfileForm({
|
||||
mode: "edit",
|
||||
profile: {
|
||||
id: "p1",
|
||||
name: "UI work",
|
||||
provider: "claude",
|
||||
featureValues: { webSearch: true },
|
||||
},
|
||||
});
|
||||
model.applyProviderCatalog(ENTRIES);
|
||||
|
||||
expect(model.getState().featureResolution).toBe("pending");
|
||||
expect(model.getState().submitValue?.featureValues).toEqual({ webSearch: true });
|
||||
});
|
||||
|
||||
it("prunes stored values the provider no longer reports", () => {
|
||||
const model = openAgentProfileForm({
|
||||
mode: "edit",
|
||||
profile: {
|
||||
id: "p1",
|
||||
name: "UI work",
|
||||
provider: "claude",
|
||||
featureValues: { webSearch: true, retiredFlag: true },
|
||||
},
|
||||
});
|
||||
model.applyProviderCatalog(ENTRIES);
|
||||
model.applyFeatures(model.getState().featureRequestKey ?? "", [
|
||||
{ type: "toggle", id: "webSearch", label: "Web search", value: false },
|
||||
]);
|
||||
|
||||
expect(model.getState().featureValues).toEqual({ webSearch: true });
|
||||
});
|
||||
|
||||
it("resolves an unavailable feature listing without showing the field", () => {
|
||||
const model = openWithCatalog({ mode: "create" });
|
||||
selectClaude(model);
|
||||
|
||||
model.applyFeaturesUnavailable(model.getState().featureRequestKey ?? "");
|
||||
|
||||
expect(model.getState().featureResolution).toBe("complete");
|
||||
expect(model.getState().disclosure.showFeaturesField).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks submission while a save is in flight", () => {
|
||||
const model = openWithCatalog({ mode: "create" });
|
||||
model.setName("UI work");
|
||||
selectClaude(model);
|
||||
|
||||
model.setSubmitting(true);
|
||||
expect(model.getState().canSubmit).toBe(false);
|
||||
|
||||
model.setSubmitting(false);
|
||||
expect(model.getState().canSubmit).toBe(true);
|
||||
});
|
||||
|
||||
it("stops publishing after close", () => {
|
||||
const model = openWithCatalog({ mode: "create" });
|
||||
let notifications = 0;
|
||||
model.subscribe(() => {
|
||||
notifications += 1;
|
||||
});
|
||||
|
||||
model.setName("a");
|
||||
expect(notifications).toBe(1);
|
||||
|
||||
model.close();
|
||||
model.setName("b");
|
||||
expect(notifications).toBe(1);
|
||||
expect(model.getState().name).toBe("a");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildFeatureRequestKey", () => {
|
||||
it("is null without a provider", () => {
|
||||
expect(buildFeatureRequestKey(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("separates the fields so a value cannot bleed across positions", () => {
|
||||
expect(buildFeatureRequestKey({ provider: "claude", model: "a|b" })).toBe("claude|a|b||");
|
||||
expect(buildFeatureRequestKey({ provider: "claude", modeId: "a" })).not.toBe(
|
||||
buildFeatureRequestKey({ provider: "claude", model: "a" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,594 @@
|
||||
import type {
|
||||
AgentFeature,
|
||||
AgentMode,
|
||||
AgentModelDefinition,
|
||||
AgentSelectOption,
|
||||
ProviderSnapshotEntry,
|
||||
} from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentProfile } from "@getpaseo/protocol/messages";
|
||||
import { formatAgentModeLabel, formatThinkingOptionLabel } from "@/agent-controls/labels";
|
||||
import { applyFeatureValues, pruneFeatureValues } from "@/hooks/feature-preferences";
|
||||
import { filterSelectableModels } from "@/provider-selection/model-catalog";
|
||||
|
||||
/**
|
||||
* The persisted profile minus the id; the list owns identity.
|
||||
*
|
||||
* `Omit` does not work here. `AgentProfileSchema` is `.passthrough()`, so
|
||||
* `AgentProfile` carries a `[key: string]: unknown` index signature, and
|
||||
* `Exclude<keyof AgentProfile, "id">` stays `string | number` — the named keys
|
||||
* come back as `unknown` and `name`/`provider` stop being required. Mapping
|
||||
* `keyof` with an `as` filter drops the index signature and the id together.
|
||||
*/
|
||||
export type AgentProfileValue = {
|
||||
[K in keyof AgentProfile as string extends K
|
||||
? never
|
||||
: K extends "id"
|
||||
? never
|
||||
: K]: AgentProfile[K];
|
||||
};
|
||||
|
||||
export interface AgentProfileFormDisplay {
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface AgentProfileFormOption {
|
||||
id: string;
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
testID: string;
|
||||
}
|
||||
|
||||
export interface AgentProfileFormSnapshot {
|
||||
mode: "create" | "edit";
|
||||
profile?: AgentProfile;
|
||||
}
|
||||
|
||||
/**
|
||||
* The inputs a feature listing needs. Features are provider-scoped and change
|
||||
* with model/mode/thinking, so every one of those is part of the request.
|
||||
*/
|
||||
export interface AgentProfileFeatureRequest {
|
||||
provider: string;
|
||||
model?: string;
|
||||
modeId?: string;
|
||||
thinkingOptionId?: string;
|
||||
}
|
||||
|
||||
export type AgentProfileResolutionStatus = "idle" | "pending" | "complete";
|
||||
|
||||
export interface AgentProfileFormDisclosure {
|
||||
showModelField: boolean;
|
||||
showModeField: boolean;
|
||||
showThinkingField: boolean;
|
||||
showFeaturesField: boolean;
|
||||
}
|
||||
|
||||
export interface AgentProfileFormState {
|
||||
mode: "create" | "edit";
|
||||
name: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
notes: string;
|
||||
provider: string;
|
||||
modelId: string;
|
||||
modeId: string;
|
||||
thinkingOptionId: string;
|
||||
featureValues: Record<string, unknown>;
|
||||
|
||||
providerOptions: AgentProfileFormOption[];
|
||||
modelOptions: AgentProfileFormOption[];
|
||||
modeOptions: AgentProfileFormOption[];
|
||||
thinkingOptions: AgentProfileFormOption[];
|
||||
features: AgentFeature[];
|
||||
|
||||
providerDisplay: AgentProfileFormDisplay | null;
|
||||
modelDisplay: AgentProfileFormDisplay | null;
|
||||
modeDisplay: AgentProfileFormDisplay | null;
|
||||
thinkingDisplay: AgentProfileFormDisplay | null;
|
||||
|
||||
catalogResolution: AgentProfileResolutionStatus;
|
||||
featureResolution: AgentProfileResolutionStatus;
|
||||
featureRequest: AgentProfileFeatureRequest | null;
|
||||
featureRequestKey: string | null;
|
||||
|
||||
disclosure: AgentProfileFormDisclosure;
|
||||
isSubmitting: boolean;
|
||||
submitError: string | null;
|
||||
canSubmit: boolean;
|
||||
submitValue: AgentProfileValue | null;
|
||||
}
|
||||
|
||||
export interface AgentProfileFormModel {
|
||||
getState: () => AgentProfileFormState;
|
||||
subscribe: (listener: () => void) => () => void;
|
||||
close: () => void;
|
||||
/** Late input: the host-scoped provider catalog. Never touches selections. */
|
||||
applyProviderCatalog: (entries: readonly ProviderSnapshotEntry[]) => void;
|
||||
/** Late input: the feature list for one request. Stale keys are ignored. */
|
||||
applyFeatures: (requestKey: string, features: readonly AgentFeature[]) => void;
|
||||
/** Resolve a request that produced no usable features (provider error). */
|
||||
applyFeaturesUnavailable: (requestKey: string) => void;
|
||||
setName: (value: string) => void;
|
||||
setAppearance: (value: { icon: string; color: string }) => void;
|
||||
setNotes: (value: string) => void;
|
||||
setProvider: (providerId: string, display: AgentProfileFormDisplay) => void;
|
||||
setModel: (modelId: string, display: AgentProfileFormDisplay | null) => void;
|
||||
setMode: (modeId: string, display: AgentProfileFormDisplay | null) => void;
|
||||
setThinking: (thinkingOptionId: string, display: AgentProfileFormDisplay | null) => void;
|
||||
setFeatureValue: (featureId: string, value: unknown) => void;
|
||||
setSubmitting: (value: boolean) => void;
|
||||
setSubmitError: (value: string | null) => void;
|
||||
}
|
||||
|
||||
function findEntry(
|
||||
entries: readonly ProviderSnapshotEntry[],
|
||||
provider: string,
|
||||
): ProviderSnapshotEntry | null {
|
||||
if (!provider) {
|
||||
return null;
|
||||
}
|
||||
return entries.find((entry) => entry.provider === provider) ?? null;
|
||||
}
|
||||
|
||||
function resolveModels(
|
||||
entries: readonly ProviderSnapshotEntry[],
|
||||
provider: string,
|
||||
): AgentModelDefinition[] {
|
||||
return filterSelectableModels(findEntry(entries, provider)?.models ?? null) ?? [];
|
||||
}
|
||||
|
||||
function resolveModes(entries: readonly ProviderSnapshotEntry[], provider: string): AgentMode[] {
|
||||
return findEntry(entries, provider)?.modes ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Thinking options hang off a model, so an unset model still has to resolve to
|
||||
* the model the daemon would pick — otherwise the field disappears whenever the
|
||||
* user leaves the model on "provider default".
|
||||
*/
|
||||
function resolveEffectiveModel(
|
||||
models: readonly AgentModelDefinition[],
|
||||
modelId: string,
|
||||
): AgentModelDefinition | null {
|
||||
const trimmed = modelId.trim();
|
||||
if (trimmed) {
|
||||
return models.find((model) => model.id === trimmed) ?? null;
|
||||
}
|
||||
return models.find((model) => model.isDefault) ?? models[0] ?? null;
|
||||
}
|
||||
|
||||
function resolveThinkingOptions(
|
||||
entries: readonly ProviderSnapshotEntry[],
|
||||
provider: string,
|
||||
modelId: string,
|
||||
): AgentSelectOption[] {
|
||||
return resolveEffectiveModel(resolveModels(entries, provider), modelId)?.thinkingOptions ?? [];
|
||||
}
|
||||
|
||||
/** Every real option is selected by its own id; only the unset row differs. */
|
||||
function formOption(input: {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string | undefined;
|
||||
testID: string;
|
||||
}): AgentProfileFormOption {
|
||||
return {
|
||||
id: input.id,
|
||||
value: input.id,
|
||||
label: input.label,
|
||||
...(input.description ? { description: input.description } : {}),
|
||||
testID: input.testID,
|
||||
};
|
||||
}
|
||||
|
||||
function buildProviderOptions(entries: readonly ProviderSnapshotEntry[]): AgentProfileFormOption[] {
|
||||
return entries
|
||||
.filter((entry) => entry.enabled)
|
||||
.map((entry) =>
|
||||
formOption({
|
||||
id: entry.provider,
|
||||
label: entry.label ?? entry.provider,
|
||||
description: entry.description,
|
||||
testID: `agent-profile-provider-option-${entry.provider}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function buildModelOptions(models: readonly AgentModelDefinition[]): AgentProfileFormOption[] {
|
||||
return models.map((model) =>
|
||||
formOption({
|
||||
id: model.id,
|
||||
label: model.label,
|
||||
// Models are labelled by family, so the id is the only thing that
|
||||
// distinguishes two entries with the same label.
|
||||
description: model.description ?? model.id,
|
||||
testID: `agent-profile-model-option-${model.id}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function buildModeOptions(modes: readonly AgentMode[]): AgentProfileFormOption[] {
|
||||
return modes.map((mode) =>
|
||||
formOption({
|
||||
id: mode.id,
|
||||
label: formatAgentModeLabel(mode),
|
||||
description: mode.description,
|
||||
testID: `agent-profile-mode-option-${mode.id}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function buildThinkingOptions(options: readonly AgentSelectOption[]): AgentProfileFormOption[] {
|
||||
return options.map((option) =>
|
||||
formOption({
|
||||
id: option.id,
|
||||
label: formatThinkingOptionLabel(option),
|
||||
description: option.description,
|
||||
testID: `agent-profile-thinking-option-${option.id}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every selection resolves to a concrete id.
|
||||
*
|
||||
* Each list used to carry a synthetic "Provider default" row. It read as a value
|
||||
* you could choose but stored an absent one, so a profile that looked fully
|
||||
* specified would apply whatever the host preferred at the time — and two hosts
|
||||
* would materialize the same profile differently. The schedule form never
|
||||
* offered that row; this form now matches it and seeds the catalog's own default
|
||||
* instead, so what the form shows is what the profile stores.
|
||||
*/
|
||||
function defaultModelId(models: readonly AgentModelDefinition[]): string {
|
||||
return models.find((model) => model.isDefault)?.id ?? models[0]?.id ?? "";
|
||||
}
|
||||
|
||||
function defaultModeId(entry: ProviderSnapshotEntry | null, modes: readonly AgentMode[]): string {
|
||||
return entry?.defaultModeId ?? modes[0]?.id ?? "";
|
||||
}
|
||||
|
||||
function defaultThinkingOptionId(model: AgentModelDefinition | null): string {
|
||||
const options = model?.thinkingOptions ?? [];
|
||||
return (
|
||||
model?.defaultThinkingOptionId ??
|
||||
options.find((option) => option.isDefault)?.id ??
|
||||
options[0]?.id ??
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
function seedSelections(
|
||||
next: AgentProfileFormState,
|
||||
input: {
|
||||
entry: ProviderSnapshotEntry | null;
|
||||
models: readonly AgentModelDefinition[];
|
||||
modes: readonly AgentMode[];
|
||||
},
|
||||
): AgentProfileFormState {
|
||||
const modelId = next.modelId || defaultModelId(input.models);
|
||||
const modeId = next.modeId || defaultModeId(input.entry, input.modes);
|
||||
const thinkingOptionId =
|
||||
next.thinkingOptionId || defaultThinkingOptionId(resolveEffectiveModel(input.models, modelId));
|
||||
if (
|
||||
modelId === next.modelId &&
|
||||
modeId === next.modeId &&
|
||||
thinkingOptionId === next.thinkingOptionId
|
||||
) {
|
||||
return next;
|
||||
}
|
||||
return { ...next, modelId, modeId, thinkingOptionId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Selected labels upgrade from the stored id to the catalog label once the
|
||||
* catalog lands, and fall back to the id when the catalog does not know the
|
||||
* value. They never blank out — a profile pointing at a retired model still
|
||||
* reads as that model.
|
||||
*/
|
||||
function refineDisplay(
|
||||
current: AgentProfileFormDisplay | null,
|
||||
selectedId: string,
|
||||
resolvedLabel: string | null,
|
||||
): AgentProfileFormDisplay | null {
|
||||
if (!selectedId) {
|
||||
return null;
|
||||
}
|
||||
if (resolvedLabel) {
|
||||
return { label: resolvedLabel };
|
||||
}
|
||||
return current ?? { label: selectedId };
|
||||
}
|
||||
|
||||
function buildFeatureRequest(state: AgentProfileFormState): AgentProfileFeatureRequest | null {
|
||||
if (!state.provider) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
provider: state.provider,
|
||||
...(state.modelId ? { model: state.modelId } : {}),
|
||||
...(state.modeId ? { modeId: state.modeId } : {}),
|
||||
...(state.thinkingOptionId ? { thinkingOptionId: state.thinkingOptionId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildFeatureRequestKey(request: AgentProfileFeatureRequest | null): string | null {
|
||||
if (!request) {
|
||||
return null;
|
||||
}
|
||||
return [
|
||||
request.provider,
|
||||
request.model ?? "",
|
||||
request.modeId ?? "",
|
||||
request.thinkingOptionId ?? "",
|
||||
].join("|");
|
||||
}
|
||||
|
||||
function buildSubmitValue(state: AgentProfileFormState): AgentProfileValue | null {
|
||||
const name = state.name.trim();
|
||||
const notes = state.notes.trim();
|
||||
if (!name || !state.provider) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
name,
|
||||
...(state.icon ? { icon: state.icon } : {}),
|
||||
...(state.color ? { color: state.color } : {}),
|
||||
provider: state.provider,
|
||||
...(state.modelId ? { model: state.modelId } : {}),
|
||||
...(state.modeId ? { modeId: state.modeId } : {}),
|
||||
...(state.thinkingOptionId ? { thinkingOptionId: state.thinkingOptionId } : {}),
|
||||
...(Object.keys(state.featureValues).length > 0 ? { featureValues: state.featureValues } : {}),
|
||||
...(notes ? { notes } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveFeatureStatus(
|
||||
featureRequestKey: string | null,
|
||||
featuresAreCurrent: boolean,
|
||||
): AgentProfileResolutionStatus {
|
||||
if (featureRequestKey === null) {
|
||||
return "idle";
|
||||
}
|
||||
return featuresAreCurrent ? "complete" : "pending";
|
||||
}
|
||||
|
||||
const BLANK_PROFILE: AgentProfileValue = { name: "", provider: "" };
|
||||
|
||||
/** A stored id doubles as its own label until the catalog can upgrade it. */
|
||||
function seedDisplay(value: string | undefined): AgentProfileFormDisplay | null {
|
||||
return value ? { label: value } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit mode seeds every value AND display from the stored profile alone: the
|
||||
* catalog is not loaded yet, so `applyProviderCatalog` does the upgrading later.
|
||||
*/
|
||||
function buildInitialState(snapshot: AgentProfileFormSnapshot): AgentProfileFormState {
|
||||
const profile = snapshot.profile ?? BLANK_PROFILE;
|
||||
return {
|
||||
mode: snapshot.mode,
|
||||
name: profile.name,
|
||||
icon: profile.icon ?? "",
|
||||
color: profile.color ?? "",
|
||||
notes: profile.notes ?? "",
|
||||
provider: profile.provider,
|
||||
modelId: profile.model ?? "",
|
||||
modeId: profile.modeId ?? "",
|
||||
thinkingOptionId: profile.thinkingOptionId ?? "",
|
||||
featureValues: { ...profile.featureValues },
|
||||
providerOptions: [],
|
||||
modelOptions: [],
|
||||
modeOptions: [],
|
||||
thinkingOptions: [],
|
||||
features: [],
|
||||
providerDisplay: seedDisplay(profile.provider),
|
||||
modelDisplay: seedDisplay(profile.model),
|
||||
modeDisplay: seedDisplay(profile.modeId),
|
||||
thinkingDisplay: seedDisplay(profile.thinkingOptionId),
|
||||
catalogResolution: "idle",
|
||||
featureResolution: "idle",
|
||||
featureRequest: null,
|
||||
featureRequestKey: null,
|
||||
disclosure: {
|
||||
showModelField: false,
|
||||
showModeField: false,
|
||||
showThinkingField: false,
|
||||
showFeaturesField: false,
|
||||
},
|
||||
isSubmitting: false,
|
||||
submitError: null,
|
||||
canSubmit: false,
|
||||
submitValue: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentProfileFormModel {
|
||||
let entries: readonly ProviderSnapshotEntry[] = [];
|
||||
let catalogResolution: AgentProfileResolutionStatus = "idle";
|
||||
let resolvedFeatureKey: string | null = null;
|
||||
let resolvedFeatures: AgentFeature[] = [];
|
||||
let listeners = new Set<() => void>();
|
||||
let closed = false;
|
||||
|
||||
function derive(incoming: AgentProfileFormState): AgentProfileFormState {
|
||||
const models = resolveModels(entries, incoming.provider);
|
||||
const modes = resolveModes(entries, incoming.provider);
|
||||
const next = seedSelections(incoming, {
|
||||
entry: findEntry(entries, incoming.provider),
|
||||
models,
|
||||
modes,
|
||||
});
|
||||
const thinking = resolveThinkingOptions(entries, next.provider, next.modelId);
|
||||
const featureRequest = buildFeatureRequest(next);
|
||||
const featureRequestKey = buildFeatureRequestKey(featureRequest);
|
||||
const featuresAreCurrent =
|
||||
featureRequestKey !== null && featureRequestKey === resolvedFeatureKey;
|
||||
const features = featuresAreCurrent
|
||||
? applyFeatureValues(resolvedFeatures, next.featureValues)
|
||||
: [];
|
||||
const featureResolution = resolveFeatureStatus(featureRequestKey, featuresAreCurrent);
|
||||
|
||||
const withOptions: AgentProfileFormState = {
|
||||
...next,
|
||||
providerOptions: buildProviderOptions(entries),
|
||||
modelOptions: buildModelOptions(models),
|
||||
modeOptions: buildModeOptions(modes),
|
||||
thinkingOptions: buildThinkingOptions(thinking),
|
||||
features,
|
||||
providerDisplay: refineDisplay(
|
||||
next.providerDisplay,
|
||||
next.provider,
|
||||
findEntry(entries, next.provider)?.label ?? null,
|
||||
),
|
||||
modelDisplay: refineDisplay(
|
||||
next.modelDisplay,
|
||||
next.modelId,
|
||||
models.find((model) => model.id === next.modelId)?.label ?? null,
|
||||
),
|
||||
modeDisplay: refineDisplay(
|
||||
next.modeDisplay,
|
||||
next.modeId,
|
||||
(() => {
|
||||
const mode = modes.find((entry) => entry.id === next.modeId);
|
||||
return mode ? formatAgentModeLabel(mode) : null;
|
||||
})(),
|
||||
),
|
||||
thinkingDisplay: refineDisplay(
|
||||
next.thinkingDisplay,
|
||||
next.thinkingOptionId,
|
||||
(() => {
|
||||
const option = thinking.find((entry) => entry.id === next.thinkingOptionId);
|
||||
return option ? formatThinkingOptionLabel(option) : null;
|
||||
})(),
|
||||
),
|
||||
catalogResolution,
|
||||
featureResolution,
|
||||
featureRequest,
|
||||
featureRequestKey,
|
||||
};
|
||||
|
||||
// A field appears once the catalog can populate it, and stays visible while
|
||||
// a stored value needs a home even if this host cannot resolve it.
|
||||
const hasProvider = Boolean(withOptions.provider);
|
||||
const disclosure: AgentProfileFormDisclosure = {
|
||||
showModelField: hasProvider && (models.length > 0 || Boolean(withOptions.modelId)),
|
||||
showModeField: hasProvider && (modes.length > 0 || Boolean(withOptions.modeId)),
|
||||
showThinkingField:
|
||||
hasProvider && (thinking.length > 0 || Boolean(withOptions.thinkingOptionId)),
|
||||
showFeaturesField: hasProvider && features.length > 0,
|
||||
};
|
||||
const canSubmit =
|
||||
withOptions.name.trim().length > 0 &&
|
||||
withOptions.provider.length > 0 &&
|
||||
!withOptions.isSubmitting;
|
||||
const resolved: AgentProfileFormState = { ...withOptions, disclosure, canSubmit };
|
||||
return { ...resolved, submitValue: canSubmit ? buildSubmitValue(resolved) : null };
|
||||
}
|
||||
|
||||
let state: AgentProfileFormState = derive(buildInitialState(snapshot));
|
||||
|
||||
function publish(mutate: (current: AgentProfileFormState) => AgentProfileFormState): void {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
state = derive(mutate(state));
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getState: () => state,
|
||||
subscribe: (listener) => {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
close: () => {
|
||||
closed = true;
|
||||
listeners = new Set();
|
||||
},
|
||||
applyProviderCatalog: (nextEntries) => {
|
||||
entries = [...nextEntries];
|
||||
catalogResolution = "complete";
|
||||
publish((current) => current);
|
||||
},
|
||||
applyFeatures: (requestKey, features) => {
|
||||
if (requestKey !== state.featureRequestKey) {
|
||||
return;
|
||||
}
|
||||
resolvedFeatureKey = requestKey;
|
||||
resolvedFeatures = [...features];
|
||||
publish((current) => ({
|
||||
...current,
|
||||
featureValues: pruneFeatureValues(current.featureValues, resolvedFeatures),
|
||||
}));
|
||||
},
|
||||
applyFeaturesUnavailable: (requestKey) => {
|
||||
if (requestKey !== state.featureRequestKey) {
|
||||
return;
|
||||
}
|
||||
resolvedFeatureKey = requestKey;
|
||||
resolvedFeatures = [];
|
||||
publish((current) => current);
|
||||
},
|
||||
setName: (value) => publish((current) => ({ ...current, name: value })),
|
||||
setAppearance: (value) =>
|
||||
publish((current) => ({ ...current, icon: value.icon, color: value.color })),
|
||||
setNotes: (value) => publish((current) => ({ ...current, notes: value })),
|
||||
setProvider: (providerId, display) =>
|
||||
publish((current) => {
|
||||
if (current.provider === providerId) {
|
||||
return current;
|
||||
}
|
||||
// Everything below the provider is provider-scoped: a model id, mode id,
|
||||
// thinking id or feature id from the old provider means nothing here.
|
||||
// Clearing them is enough — `derive` seeds the new provider's defaults.
|
||||
return {
|
||||
...current,
|
||||
provider: providerId,
|
||||
providerDisplay: display,
|
||||
modelId: "",
|
||||
modelDisplay: null,
|
||||
modeId: "",
|
||||
modeDisplay: null,
|
||||
thinkingOptionId: "",
|
||||
thinkingDisplay: null,
|
||||
featureValues: {},
|
||||
};
|
||||
}),
|
||||
setModel: (modelId, display) =>
|
||||
publish((current) => {
|
||||
if (current.modelId === modelId) {
|
||||
return current;
|
||||
}
|
||||
// Thinking options are a property of the model, so a level the new model
|
||||
// does not offer has to go.
|
||||
const nextThinking = resolveThinkingOptions(entries, current.provider, modelId);
|
||||
const keepsThinking =
|
||||
current.thinkingOptionId.length > 0 &&
|
||||
nextThinking.some((option) => option.id === current.thinkingOptionId);
|
||||
return {
|
||||
...current,
|
||||
modelId,
|
||||
modelDisplay: display,
|
||||
thinkingOptionId: keepsThinking ? current.thinkingOptionId : "",
|
||||
thinkingDisplay: keepsThinking ? current.thinkingDisplay : null,
|
||||
};
|
||||
}),
|
||||
setMode: (modeId, display) =>
|
||||
publish((current) => ({ ...current, modeId, modeDisplay: display })),
|
||||
setThinking: (thinkingOptionId, display) =>
|
||||
publish((current) => ({ ...current, thinkingOptionId, thinkingDisplay: display })),
|
||||
setFeatureValue: (featureId, value) =>
|
||||
publish((current) => ({
|
||||
...current,
|
||||
featureValues: { ...current.featureValues, [featureId]: value },
|
||||
})),
|
||||
setSubmitting: (value) => publish((current) => ({ ...current, isSubmitting: value })),
|
||||
setSubmitError: (value) => publish((current) => ({ ...current, submitError: value })),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentProfile } from "@getpaseo/protocol/messages";
|
||||
import { formatAgentModeLabel, formatThinkingOptionLabel } from "@/agent-controls/labels";
|
||||
|
||||
export interface AgentProfileTag {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
function findEntry(
|
||||
entries: readonly ProviderSnapshotEntry[],
|
||||
provider: string,
|
||||
): ProviderSnapshotEntry | null {
|
||||
return entries.find((entry) => entry.provider === provider) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one-line résumé of a profile in the settings list. Every label falls back
|
||||
* to the stored id: a row must stay readable on a host whose provider catalog
|
||||
* is offline, or that no longer ships the model the profile names.
|
||||
*/
|
||||
export function buildAgentProfileTags(input: {
|
||||
profile: AgentProfile;
|
||||
entries: readonly ProviderSnapshotEntry[] | undefined;
|
||||
formatFeatureCount: (count: number) => string;
|
||||
}): AgentProfileTag[] {
|
||||
const entries = input.entries ?? [];
|
||||
const entry = findEntry(entries, input.profile.provider);
|
||||
const tags: AgentProfileTag[] = [
|
||||
{ id: "provider", label: entry?.label ?? input.profile.provider },
|
||||
];
|
||||
|
||||
const modelId = input.profile.model?.trim();
|
||||
if (modelId) {
|
||||
const model = entry?.models?.find((candidate) => candidate.id === modelId);
|
||||
tags.push({ id: "model", label: model?.label ?? modelId });
|
||||
}
|
||||
|
||||
const modeId = input.profile.modeId?.trim();
|
||||
if (modeId) {
|
||||
const mode = entry?.modes?.find((candidate) => candidate.id === modeId);
|
||||
tags.push({ id: "mode", label: mode ? formatAgentModeLabel(mode) : modeId });
|
||||
}
|
||||
|
||||
const thinkingOptionId = input.profile.thinkingOptionId?.trim();
|
||||
if (thinkingOptionId) {
|
||||
tags.push({ id: "thinking", label: formatThinkingOptionLabel({ id: thinkingOptionId }) });
|
||||
}
|
||||
|
||||
const featureCount = Object.keys(input.profile.featureValues ?? {}).length;
|
||||
if (featureCount > 0) {
|
||||
tags.push({ id: "features", label: input.formatFeatureCount(featureCount) });
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentProfile } from "@getpaseo/protocol/messages";
|
||||
import { mergeCreateAgentSelectionPreferences } from "@/create-agent-preferences/preferences";
|
||||
import { useFormPreferences } from "@/hooks/use-form-preferences";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { showProviderNoticeToast } from "@/utils/provider-notice-toast";
|
||||
import { materializeAgentProfile, toAgentConfigApply } from "./materialize-profile";
|
||||
import { buildAgentProfileTags } from "./profile-summary";
|
||||
import { useAgentProfiles } from "./use-agent-profiles";
|
||||
|
||||
/** The draft composer's own setters. Applying a profile drives them like a user would. */
|
||||
export interface DraftAgentProfileControls {
|
||||
selectProvider: (provider: AgentProvider) => void;
|
||||
selectProviderAndModel: (provider: AgentProvider, modelId: string) => void;
|
||||
selectMode: (modeId: string) => void;
|
||||
selectThinkingOption: (thinkingOptionId: string) => void;
|
||||
setFeature?: (featureId: string, value: unknown) => void;
|
||||
}
|
||||
|
||||
export type AgentProfileApplyTarget =
|
||||
| { kind: "agent"; agentId: string }
|
||||
| { kind: "draft"; controls: DraftAgentProfileControls };
|
||||
|
||||
/** Everything the model picker renders for one profile. It never sees the profile itself. */
|
||||
export interface AgentProfilePickerRow {
|
||||
id: string;
|
||||
provider: string;
|
||||
/** Icon registry key and identity colour; either may be empty for the default glyph. */
|
||||
icon: string;
|
||||
color: string;
|
||||
name: string;
|
||||
/** "Claude Code · Opus 5 · Plan · Think hard" */
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface AgentProfilePicker {
|
||||
rows: AgentProfilePickerRow[];
|
||||
applyProfile: (profileId: string) => void;
|
||||
}
|
||||
|
||||
export interface UseAgentProfilePickerInput {
|
||||
serverId: string | null;
|
||||
/**
|
||||
* Providers this composer can actually run; pass a stable reference. A profile
|
||||
* naming anything else is hidden rather than shown as a row that cannot do
|
||||
* what it says — a live agent is one provider's process and cannot switch, and
|
||||
* the draft form ignores a provider the host does not offer.
|
||||
*/
|
||||
availableProviders: readonly string[];
|
||||
target: AgentProfileApplyTarget;
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent-profiles section of the model picker: the rows to draw, and what
|
||||
* pressing one does. A supported host returns an empty row list when there is
|
||||
* nothing applicable so the picker can retain its settings shortcut without
|
||||
* pinning an empty section. Unsupported or still-loading hosts return `null`.
|
||||
*/
|
||||
export function useAgentProfilePicker(
|
||||
input: UseAgentProfilePickerInput,
|
||||
): AgentProfilePicker | null {
|
||||
const { serverId, availableProviders, target } = input;
|
||||
const { t } = useTranslation();
|
||||
const { profiles, isSupported } = useAgentProfiles(serverId);
|
||||
// Profiles are host config, so their labels read from the host-wide catalog
|
||||
// rather than a workspace's. That is also the key the settings section uses,
|
||||
// so every composer on a host shares one query instead of adding its own.
|
||||
const { entries } = useProvidersSnapshot(serverId, { cwd: null });
|
||||
const { updatePreferences } = useFormPreferences();
|
||||
const client = useSessionStore((state) => state.sessions[serverId ?? ""]?.client ?? null);
|
||||
const toast = useToast();
|
||||
|
||||
const applicableProfiles = useMemo(() => {
|
||||
if (!isSupported || !profiles) {
|
||||
return [];
|
||||
}
|
||||
const available = new Set(availableProviders);
|
||||
return profiles.filter((profile) => available.has(profile.provider));
|
||||
}, [availableProviders, isSupported, profiles]);
|
||||
|
||||
const formatFeatureCount = useCallback(
|
||||
(count: number) =>
|
||||
count === 1
|
||||
? t("settings.host.agentProfiles.featureCountOne", { count })
|
||||
: t("settings.host.agentProfiles.featureCount", { count }),
|
||||
[t],
|
||||
);
|
||||
|
||||
const rows = useMemo<AgentProfilePickerRow[]>(
|
||||
() =>
|
||||
applicableProfiles.map((profile) => ({
|
||||
id: profile.id,
|
||||
provider: profile.provider,
|
||||
icon: profile.icon ?? "",
|
||||
color: profile.color ?? "",
|
||||
name: profile.name,
|
||||
summary: buildAgentProfileTags({ profile, entries, formatFeatureCount })
|
||||
.map((tag) => tag.label)
|
||||
.join(" · "),
|
||||
})),
|
||||
[applicableProfiles, entries, formatFeatureCount],
|
||||
);
|
||||
|
||||
const persistSelection = useCallback(
|
||||
(profile: AgentProfile) => {
|
||||
const resolved = materializeAgentProfile(profile);
|
||||
void updatePreferences((current) =>
|
||||
mergeCreateAgentSelectionPreferences({
|
||||
preferences: current,
|
||||
provider: resolved.provider,
|
||||
modelId: resolved.modelId,
|
||||
modeId: resolved.modeId,
|
||||
thinkingOptionId: resolved.thinkingOptionId,
|
||||
...(Object.keys(resolved.featureValues).length > 0
|
||||
? { featureValues: resolved.featureValues }
|
||||
: {}),
|
||||
}),
|
||||
).catch((error) => {
|
||||
console.warn("[useAgentProfilePicker] persist profile selection failed", error);
|
||||
});
|
||||
},
|
||||
[updatePreferences],
|
||||
);
|
||||
|
||||
const applyProfile = useCallback(
|
||||
(profileId: string) => {
|
||||
const profile = applicableProfiles.find((entry) => entry.id === profileId);
|
||||
if (!profile) {
|
||||
return;
|
||||
}
|
||||
const resolved = materializeAgentProfile(profile);
|
||||
|
||||
// Persist on both paths, before driving the controls. The draft form drops
|
||||
// its local feature values when the provider changes, so preferences are
|
||||
// the only channel that survives a profile that switches provider.
|
||||
persistSelection(profile);
|
||||
|
||||
if (target.kind === "draft") {
|
||||
const { controls } = target;
|
||||
if (resolved.modelId) {
|
||||
controls.selectProviderAndModel(resolved.provider, resolved.modelId);
|
||||
} else {
|
||||
controls.selectProvider(resolved.provider);
|
||||
}
|
||||
if (resolved.modeId) {
|
||||
controls.selectMode(resolved.modeId);
|
||||
}
|
||||
if (resolved.thinkingOptionId) {
|
||||
controls.selectThinkingOption(resolved.thinkingOptionId);
|
||||
}
|
||||
for (const [featureId, value] of Object.entries(resolved.featureValues)) {
|
||||
controls.setFeature?.(featureId, value);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
void client
|
||||
.applyAgentConfig(target.agentId, toAgentConfigApply(resolved))
|
||||
.then((notice) => showProviderNoticeToast(toast, notice))
|
||||
.catch((error) => {
|
||||
console.warn("[useAgentProfilePicker] applyAgentConfig failed", error);
|
||||
toast.error(toErrorMessage(error));
|
||||
});
|
||||
},
|
||||
[applicableProfiles, client, persistSelection, target, toast],
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => (isSupported && profiles !== null ? { rows, applyProfile } : null),
|
||||
[applyProfile, isSupported, profiles, rows],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useCallback } from "react";
|
||||
import type { AgentProfile } from "@getpaseo/protocol/messages";
|
||||
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { supportsAgentProfiles } from "./capabilities";
|
||||
|
||||
export interface UseAgentProfilesResult {
|
||||
/** `null` until the daemon config has arrived. */
|
||||
profiles: AgentProfile[] | null;
|
||||
/** False on daemons that predate agent profiles, or while disconnected. */
|
||||
isSupported: boolean;
|
||||
/** Writes the whole list; there is no per-profile RPC. */
|
||||
saveProfiles: (next: AgentProfile[]) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useAgentProfiles(serverId: string | null): UseAgentProfilesResult {
|
||||
const { config, patchConfig } = useDaemonConfig(serverId);
|
||||
const isSupported = useSessionStore((state) => {
|
||||
return supportsAgentProfiles(state.sessions[serverId ?? ""]?.serverInfo?.features);
|
||||
});
|
||||
|
||||
const saveProfiles = useCallback(
|
||||
async (next: AgentProfile[]) => {
|
||||
await patchConfig({ agentProfiles: next });
|
||||
},
|
||||
[patchConfig],
|
||||
);
|
||||
|
||||
return {
|
||||
profiles: config ? (config.agentProfiles ?? []) : null,
|
||||
isSupported,
|
||||
saveProfiles,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFetchQuery } from "@/data/query";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import type { AgentProfileFormModel, AgentProfileFormState } from "./profile-form-model";
|
||||
|
||||
/**
|
||||
* Agent profiles are host-wide, so the catalog is probed in the daemon's home
|
||||
* directory (`cwd: null` for the snapshot, `~` for the feature listing) rather
|
||||
* than in a workspace. A profile that resolved against one project's directory
|
||||
* would name models the next project cannot launch.
|
||||
*/
|
||||
const HOME_SCOPE_CWD = "~";
|
||||
|
||||
export function useAgentProfileFormCatalog(input: {
|
||||
serverId: string;
|
||||
model: AgentProfileFormModel;
|
||||
}): void {
|
||||
const { entries } = useProvidersSnapshot(input.serverId, { cwd: null });
|
||||
|
||||
useEffect(() => {
|
||||
if (!entries) {
|
||||
return;
|
||||
}
|
||||
input.model.applyProviderCatalog(entries);
|
||||
}, [entries, input.model]);
|
||||
}
|
||||
|
||||
export function useAgentProfileFormFeatures(input: {
|
||||
serverId: string;
|
||||
model: AgentProfileFormModel;
|
||||
state: AgentProfileFormState;
|
||||
}): void {
|
||||
const { t } = useTranslation();
|
||||
const client = useHostRuntimeClient(input.serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(input.serverId);
|
||||
const request = input.state.featureRequest;
|
||||
const requestKey = input.state.featureRequestKey;
|
||||
|
||||
// `dataShape: "value"` and not `"list"`: a list shape keeps the previous
|
||||
// page's data while the next request is in flight, which would hand the
|
||||
// features of one provider/model combination to the key of another.
|
||||
const featuresQuery = useFetchQuery({
|
||||
queryKey: ["agentProfileFeatures", input.serverId, requestKey],
|
||||
dataShape: "value",
|
||||
staleTimeMs: 5 * 60 * 1000,
|
||||
enabled: Boolean(client && isConnected && request),
|
||||
retry: false,
|
||||
queryFn: async () => {
|
||||
if (!client || !request) {
|
||||
throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
}
|
||||
const payload = await client.listProviderFeatures({ ...request, cwd: HOME_SCOPE_CWD });
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return payload.features ?? [];
|
||||
},
|
||||
});
|
||||
|
||||
const { data, isError } = featuresQuery;
|
||||
const { model } = input;
|
||||
|
||||
useEffect(() => {
|
||||
if (!requestKey || !data) {
|
||||
return;
|
||||
}
|
||||
model.applyFeatures(requestKey, data);
|
||||
}, [data, model, requestKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!requestKey || !isError) {
|
||||
return;
|
||||
}
|
||||
model.applyFeaturesUnavailable(requestKey);
|
||||
}, [isError, model, requestKey]);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useEffect, useState, useSyncExternalStore } from "react";
|
||||
import {
|
||||
openAgentProfileForm,
|
||||
type AgentProfileFormModel,
|
||||
type AgentProfileFormSnapshot,
|
||||
type AgentProfileFormState,
|
||||
} from "./profile-form-model";
|
||||
|
||||
/**
|
||||
* One model per mount. The modal keys its body on mode + profile id, so create
|
||||
* and edit never share an instance and reopening always starts from the record.
|
||||
*/
|
||||
export function useAgentProfileFormModel(
|
||||
snapshot: AgentProfileFormSnapshot,
|
||||
): AgentProfileFormModel {
|
||||
const [model] = useState(() => openAgentProfileForm(snapshot));
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
model.close();
|
||||
};
|
||||
}, [model]);
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
export function useAgentProfileFormState(model: AgentProfileFormModel): AgentProfileFormState {
|
||||
return useSyncExternalStore(model.subscribe, model.getState, model.getState);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentProfile } from "@getpaseo/protocol/messages";
|
||||
import { LegacyFavoriteProfileMigration } from ".";
|
||||
|
||||
class MemoryStorage {
|
||||
private readonly values = new Map<string, string>();
|
||||
|
||||
constructor(initial: Record<string, unknown> = {}) {
|
||||
for (const [key, value] of Object.entries(initial)) {
|
||||
this.values.set(key, JSON.stringify(value));
|
||||
}
|
||||
}
|
||||
|
||||
async getItem(key: string): Promise<string | null> {
|
||||
return this.values.get(key) ?? null;
|
||||
}
|
||||
|
||||
async setItem(key: string, value: string): Promise<void> {
|
||||
this.values.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeProfileHost {
|
||||
readonly patches: AgentProfile[][] = [];
|
||||
config: { agentProfiles?: AgentProfile[] };
|
||||
shouldFailPatch = false;
|
||||
shouldFailCatalog = false;
|
||||
catalogLoadingReads = 0;
|
||||
|
||||
constructor(
|
||||
profiles: AgentProfile[] = [],
|
||||
readonly supported = true,
|
||||
) {
|
||||
this.config = { agentProfiles: profiles };
|
||||
}
|
||||
|
||||
getLastServerInfoMessage() {
|
||||
return {
|
||||
features: this.supported
|
||||
? { agentProfiles: true, agentConfigApply: true }
|
||||
: { agentProfiles: false, agentConfigApply: false },
|
||||
};
|
||||
}
|
||||
|
||||
async getDaemonConfig() {
|
||||
return { requestId: "config", config: this.config };
|
||||
}
|
||||
|
||||
async patchDaemonConfig(patch: { agentProfiles?: AgentProfile[] }) {
|
||||
if (this.shouldFailPatch) {
|
||||
throw new Error("host write failed");
|
||||
}
|
||||
const profiles = patch.agentProfiles ?? [];
|
||||
this.patches.push(profiles);
|
||||
this.config = { ...this.config, agentProfiles: profiles };
|
||||
return { requestId: "patch", config: this.config };
|
||||
}
|
||||
|
||||
async getProvidersSnapshot() {
|
||||
if (this.shouldFailCatalog) {
|
||||
throw new Error("catalog unavailable");
|
||||
}
|
||||
if (this.catalogLoadingReads > 0) {
|
||||
this.catalogLoadingReads -= 1;
|
||||
return {
|
||||
entries: [
|
||||
{
|
||||
provider: "claude" as const,
|
||||
status: "loading" as const,
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
generatedAt: new Date(0).toISOString(),
|
||||
requestId: "providers-loading",
|
||||
};
|
||||
}
|
||||
return {
|
||||
entries: [
|
||||
{
|
||||
provider: "claude" as const,
|
||||
status: "ready" as const,
|
||||
enabled: true,
|
||||
label: "Claude Code",
|
||||
models: [
|
||||
{ provider: "claude" as const, id: "opus", label: "Opus" },
|
||||
{ provider: "claude" as const, id: "sonnet", label: "Sonnet" },
|
||||
],
|
||||
},
|
||||
],
|
||||
generatedAt: new Date(0).toISOString(),
|
||||
requestId: "providers",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const PREFERENCES_KEY = "@paseo:create-agent-preferences";
|
||||
|
||||
function migrationStorage() {
|
||||
return new MemoryStorage({
|
||||
[PREFERENCES_KEY]: {
|
||||
favoriteModels: [
|
||||
{ provider: "claude", modelId: "opus" },
|
||||
{ provider: "claude", modelId: "sonnet" },
|
||||
{ provider: "claude", modelId: "opus" },
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("legacy favourite profile migration", () => {
|
||||
it("appends provider-and-model-only profiles with useful catalog names", async () => {
|
||||
const existing: AgentProfile = {
|
||||
id: "hand-written",
|
||||
name: "My Opus",
|
||||
provider: "claude",
|
||||
model: "opus",
|
||||
modeId: "plan",
|
||||
};
|
||||
const host = new FakeProfileHost([existing]);
|
||||
const migration = new LegacyFavoriteProfileMigration(migrationStorage());
|
||||
|
||||
await migration.migrateHost("host-a", host);
|
||||
|
||||
expect(host.patches).toEqual([
|
||||
[
|
||||
existing,
|
||||
{
|
||||
id: "legacy_favorite:claude:sonnet",
|
||||
name: "Sonnet",
|
||||
provider: "claude",
|
||||
model: "sonnet",
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it("runs once per host, even if the imported profiles are later deleted", async () => {
|
||||
const storage = migrationStorage();
|
||||
const migration = new LegacyFavoriteProfileMigration(storage);
|
||||
const firstHost = new FakeProfileHost();
|
||||
|
||||
await migration.migrateHost("host-a", firstHost);
|
||||
firstHost.config = { agentProfiles: [] };
|
||||
await migration.migrateHost("host-a", firstHost);
|
||||
|
||||
const secondHost = new FakeProfileHost();
|
||||
await migration.migrateHost("host-b", secondHost);
|
||||
|
||||
expect(firstHost.patches).toHaveLength(1);
|
||||
expect(secondHost.patches).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("waits for profile capabilities without consuming the host migration", async () => {
|
||||
const storage = migrationStorage();
|
||||
const migration = new LegacyFavoriteProfileMigration(storage);
|
||||
const oldHost = new FakeProfileHost([], false);
|
||||
|
||||
await migration.migrateHost("host-a", oldHost);
|
||||
|
||||
const upgradedHost = new FakeProfileHost();
|
||||
await migration.migrateHost("host-a", upgradedHost);
|
||||
expect(oldHost.patches).toHaveLength(0);
|
||||
expect(upgradedHost.patches).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("retries after a failed host write", async () => {
|
||||
const host = new FakeProfileHost();
|
||||
const migration = new LegacyFavoriteProfileMigration(migrationStorage());
|
||||
host.shouldFailPatch = true;
|
||||
|
||||
await expect(migration.migrateHost("host-a", host)).rejects.toThrow("host write failed");
|
||||
host.shouldFailPatch = false;
|
||||
await migration.migrateHost("host-a", host);
|
||||
|
||||
expect(host.patches).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("retries catalog resolution instead of permanently storing raw ids", async () => {
|
||||
const host = new FakeProfileHost();
|
||||
const migration = new LegacyFavoriteProfileMigration(migrationStorage());
|
||||
host.shouldFailCatalog = true;
|
||||
|
||||
await expect(migration.migrateHost("host-a", host)).rejects.toThrow("catalog unavailable");
|
||||
host.shouldFailCatalog = false;
|
||||
await migration.migrateHost("host-a", host);
|
||||
|
||||
expect(host.patches[0]?.map((profile) => profile.name)).toEqual(["Opus", "Sonnet"]);
|
||||
});
|
||||
|
||||
it("waits for relevant provider models to finish loading", async () => {
|
||||
const host = new FakeProfileHost();
|
||||
const migration = new LegacyFavoriteProfileMigration(migrationStorage());
|
||||
host.catalogLoadingReads = 1;
|
||||
|
||||
await migration.migrateHost("host-a", host);
|
||||
|
||||
expect(host.patches[0]?.map((profile) => profile.name)).toEqual(["Opus", "Sonnet"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { z } from "zod";
|
||||
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentProfile } from "@getpaseo/protocol/messages";
|
||||
|
||||
const PREFERENCES_KEY = "@paseo:create-agent-preferences";
|
||||
const COMPLETION_KEY_PREFIX = "@paseo:legacy-favorites-to-agent-profiles:v1:";
|
||||
const CATALOG_LOADING_RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000, 4_000] as const;
|
||||
|
||||
const legacyPreferencesSchema = z.object({
|
||||
favoriteModels: z
|
||||
.array(
|
||||
z.object({
|
||||
provider: z.string().trim().min(1),
|
||||
modelId: z.string().trim().min(1),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
type LegacyFavorite = NonNullable<
|
||||
z.infer<typeof legacyPreferencesSchema>["favoriteModels"]
|
||||
>[number];
|
||||
|
||||
export interface LegacyFavoriteMigrationStorage {
|
||||
getItem(key: string): Promise<string | null>;
|
||||
setItem(key: string, value: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface LegacyFavoriteMigrationHost {
|
||||
getLastServerInfoMessage(): {
|
||||
features?: { agentProfiles?: boolean; agentConfigApply?: boolean };
|
||||
} | null;
|
||||
getDaemonConfig(): Promise<{ config: { agentProfiles?: AgentProfile[] } }>;
|
||||
patchDaemonConfig(patch: {
|
||||
agentProfiles: AgentProfile[];
|
||||
}): Promise<{ config: { agentProfiles?: AgentProfile[] } }>;
|
||||
getProvidersSnapshot(): Promise<{ entries: ProviderSnapshotEntry[] }>;
|
||||
}
|
||||
|
||||
function supportsMigration(host: LegacyFavoriteMigrationHost): boolean {
|
||||
const features = host.getLastServerInfoMessage()?.features;
|
||||
return features?.agentProfiles === true && features.agentConfigApply === true;
|
||||
}
|
||||
|
||||
function completionKey(serverId: string): string {
|
||||
return `${COMPLETION_KEY_PREFIX}${serverId}`;
|
||||
}
|
||||
|
||||
function parseStoredPreferences(raw: string | null): LegacyFavorite[] {
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const result = legacyPreferencesSchema.safeParse(JSON.parse(raw));
|
||||
return result.success ? (result.data.favoriteModels ?? []) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function favoriteKey(favorite: Pick<LegacyFavorite, "provider" | "modelId">): string {
|
||||
return `${favorite.provider}\u0000${favorite.modelId}`;
|
||||
}
|
||||
|
||||
function migratedProfileId(favorite: LegacyFavorite): string {
|
||||
return `legacy_favorite:${encodeURIComponent(favorite.provider)}:${encodeURIComponent(favorite.modelId)}`;
|
||||
}
|
||||
|
||||
function deduplicateFavorites(favorites: readonly LegacyFavorite[]): LegacyFavorite[] {
|
||||
const seen = new Set<string>();
|
||||
return favorites.filter((favorite) => {
|
||||
const key = favoriteKey(favorite);
|
||||
if (seen.has(key)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function findCatalogLabels(
|
||||
favorite: LegacyFavorite,
|
||||
entries: readonly ProviderSnapshotEntry[],
|
||||
): { model: string; provider: string } {
|
||||
const provider = entries.find((entry) => entry.provider === favorite.provider);
|
||||
const model = provider?.models?.find((entry) => entry.id === favorite.modelId);
|
||||
return {
|
||||
model: model?.label?.trim() || favorite.modelId,
|
||||
provider: provider?.label?.trim() || favorite.provider,
|
||||
};
|
||||
}
|
||||
|
||||
function uniqueProfileName(input: {
|
||||
favorite: LegacyFavorite;
|
||||
entries: readonly ProviderSnapshotEntry[];
|
||||
usedNames: Set<string>;
|
||||
}): string {
|
||||
const labels = findCatalogLabels(input.favorite, input.entries);
|
||||
const candidates = [
|
||||
labels.model,
|
||||
`${labels.provider} · ${labels.model}`,
|
||||
`${labels.provider} · ${labels.model} (${input.favorite.modelId})`,
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (!input.usedNames.has(candidate)) {
|
||||
input.usedNames.add(candidate);
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
let suffix = 2;
|
||||
while (input.usedNames.has(`${candidates[2]} ${suffix}`)) {
|
||||
suffix += 1;
|
||||
}
|
||||
const name = `${candidates[2]} ${suffix}`;
|
||||
input.usedNames.add(name);
|
||||
return name;
|
||||
}
|
||||
|
||||
function buildMigratedProfiles(input: {
|
||||
favorites: readonly LegacyFavorite[];
|
||||
existing: readonly AgentProfile[];
|
||||
entries: readonly ProviderSnapshotEntry[];
|
||||
}): AgentProfile[] {
|
||||
const existingSelections = new Set(
|
||||
input.existing.map((profile) =>
|
||||
favoriteKey({ provider: profile.provider, modelId: profile.model?.trim() ?? "" }),
|
||||
),
|
||||
);
|
||||
const existingIds = new Set(input.existing.map((profile) => profile.id));
|
||||
const usedNames = new Set(input.existing.map((profile) => profile.name));
|
||||
|
||||
return deduplicateFavorites(input.favorites).flatMap((favorite) => {
|
||||
const id = migratedProfileId(favorite);
|
||||
if (existingSelections.has(favoriteKey(favorite)) || existingIds.has(id)) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
id,
|
||||
name: uniqueProfileName({ favorite, entries: input.entries, usedNames }),
|
||||
provider: favorite.provider,
|
||||
model: favorite.modelId,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function catalogIsLoading(
|
||||
entries: readonly ProviderSnapshotEntry[],
|
||||
favorites: readonly LegacyFavorite[],
|
||||
): boolean {
|
||||
const providers = new Set(favorites.map((favorite) => favorite.provider));
|
||||
return entries.some((entry) => providers.has(entry.provider) && entry.status === "loading");
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function readProviderCatalog(
|
||||
host: LegacyFavoriteMigrationHost,
|
||||
favorites: readonly LegacyFavorite[],
|
||||
): Promise<ProviderSnapshotEntry[]> {
|
||||
for (const retryDelay of CATALOG_LOADING_RETRY_DELAYS_MS) {
|
||||
const entries = (await host.getProvidersSnapshot()).entries;
|
||||
if (!catalogIsLoading(entries, favorites)) {
|
||||
return entries;
|
||||
}
|
||||
await delay(retryDelay);
|
||||
}
|
||||
const entries = (await host.getProvidersSnapshot()).entries;
|
||||
if (catalogIsLoading(entries, favorites)) {
|
||||
throw new Error("Provider catalog remained loading during legacy favourite migration");
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time bridge from device-local model favourites to host-wide profiles.
|
||||
* The module owns every migration invariant; callers only announce a connected
|
||||
* host and may safely call again after reconnects or React remounts.
|
||||
*/
|
||||
export class LegacyFavoriteProfileMigration {
|
||||
private readonly inFlight = new Map<string, Promise<void>>();
|
||||
|
||||
constructor(private readonly storage: LegacyFavoriteMigrationStorage) {}
|
||||
|
||||
migrateHost(serverId: string, host: LegacyFavoriteMigrationHost): Promise<void> {
|
||||
const active = this.inFlight.get(serverId);
|
||||
if (active) {
|
||||
return active;
|
||||
}
|
||||
const migration = this.run(serverId, host).finally(() => {
|
||||
if (this.inFlight.get(serverId) === migration) {
|
||||
this.inFlight.delete(serverId);
|
||||
}
|
||||
});
|
||||
this.inFlight.set(serverId, migration);
|
||||
return migration;
|
||||
}
|
||||
|
||||
private async run(serverId: string, host: LegacyFavoriteMigrationHost): Promise<void> {
|
||||
if (!supportsMigration(host)) {
|
||||
return;
|
||||
}
|
||||
const markerKey = completionKey(serverId);
|
||||
if ((await this.storage.getItem(markerKey)) === "1") {
|
||||
return;
|
||||
}
|
||||
|
||||
const favorites = parseStoredPreferences(await this.storage.getItem(PREFERENCES_KEY));
|
||||
if (favorites.length === 0) {
|
||||
await this.storage.setItem(markerKey, "1");
|
||||
return;
|
||||
}
|
||||
|
||||
const [{ config }, entries] = await Promise.all([
|
||||
host.getDaemonConfig(),
|
||||
readProviderCatalog(host, favorites),
|
||||
]);
|
||||
const existing = config.agentProfiles ?? [];
|
||||
const migrated = buildMigratedProfiles({ favorites, existing, entries });
|
||||
if (migrated.length > 0) {
|
||||
await host.patchDaemonConfig({ agentProfiles: [...existing, ...migrated] });
|
||||
}
|
||||
await this.storage.setItem(markerKey, "1");
|
||||
}
|
||||
}
|
||||
|
||||
export const legacyFavoriteProfileMigration = new LegacyFavoriteProfileMigration(AsyncStorage);
|
||||
@@ -0,0 +1,384 @@
|
||||
import { useCallback, useMemo, useRef, useState, type ReactElement } from "react";
|
||||
import { Pressable, View, type PressableStateCallbackType } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Check, ChevronDown } from "lucide-react-native";
|
||||
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import {
|
||||
createControlGeometry,
|
||||
resolveControlInteractionStyles,
|
||||
type FieldControlSize,
|
||||
} from "@/components/ui/control-geometry";
|
||||
import { Field } from "@/components/ui/form-field";
|
||||
import { identityForeground } from "@/styles/identity-colors";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import { AgentProfileGlyph } from "../internal/agent-profile-glyph";
|
||||
import {
|
||||
AGENT_PROFILE_COLORS,
|
||||
AGENT_PROFILE_ICON_KEYS,
|
||||
resolveAgentProfileColor,
|
||||
resolveAgentProfileIconKey,
|
||||
type AgentProfileColor,
|
||||
type AgentProfileIconKey,
|
||||
} from "../internal/profile-appearance";
|
||||
|
||||
const CELL_SIZE = 40;
|
||||
const CELL_GAP = 4;
|
||||
const GRID_COLUMNS = 5;
|
||||
const CELL_ROW_WIDTH = GRID_COLUMNS * CELL_SIZE + (GRID_COLUMNS - 1) * CELL_GAP;
|
||||
/** Matches `styles.body.padding`; both derive from `theme.spacing[3]`. */
|
||||
const CELL_GRID_PADDING = 12;
|
||||
/** Swatches are dots, not targets the eye has to land on precisely, so they pack tighter. */
|
||||
const COLOR_CELL_SIZE = 32;
|
||||
const SWATCH_SIZE = 20;
|
||||
|
||||
const EMPTY_COMBOBOX_OPTIONS: ComboboxOption[] = [];
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
const ThemedCheck = withUnistyles(Check);
|
||||
|
||||
const foregroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
const onIdentityMapping = (theme: Theme) => ({ color: theme.colors.background });
|
||||
|
||||
/**
|
||||
* Locked, not a floor: an unlocked popover stretches to its 400px maximum and
|
||||
* the wrapping grid follows it to eight columns. Two spare pixels so a frame
|
||||
* border can never push the fifth cell onto its own row.
|
||||
*/
|
||||
const DESKTOP_WIDTH = CELL_ROW_WIDTH + CELL_GRID_PADDING * 2 + 2;
|
||||
|
||||
/** Colours are named in host appearance; profiles reuse both the palette and its labels. */
|
||||
function colorLabel(t: TFunction, color: AgentProfileColor): string {
|
||||
return t(`settings.host.appearance.color.options.${color}`);
|
||||
}
|
||||
|
||||
function IconCell({
|
||||
iconKey,
|
||||
selected,
|
||||
color,
|
||||
onSelect,
|
||||
}: {
|
||||
iconKey: AgentProfileIconKey | null;
|
||||
selected: boolean;
|
||||
color: AgentProfileColor;
|
||||
onSelect: (iconKey: AgentProfileIconKey | null) => void;
|
||||
}) {
|
||||
const handlePress = useCallback(() => onSelect(iconKey), [iconKey, onSelect]);
|
||||
const accessibilityState = useMemo(() => ({ selected }), [selected]);
|
||||
const cellStyle = useCallback(
|
||||
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.cell,
|
||||
Boolean(hovered) && styles.cellHovered,
|
||||
pressed && styles.cellPressed,
|
||||
selected && styles.cellSelected,
|
||||
],
|
||||
[selected],
|
||||
);
|
||||
return (
|
||||
<Pressable
|
||||
onPress={handlePress}
|
||||
style={cellStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={accessibilityState}
|
||||
accessibilityLabel={iconKey ?? "default"}
|
||||
testID={`agent-profile-icon-cell-${iconKey ?? "default"}`}
|
||||
>
|
||||
<AgentProfileGlyph
|
||||
{...(iconKey ? { icon: iconKey } : {})}
|
||||
color={color}
|
||||
size={ICON_SIZE.lg}
|
||||
/>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function ColorCell({
|
||||
color,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
color: AgentProfileColor;
|
||||
selected: boolean;
|
||||
onSelect: (color: AgentProfileColor) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const handlePress = useCallback(() => onSelect(color), [color, onSelect]);
|
||||
const accessibilityState = useMemo(() => ({ selected }), [selected]);
|
||||
const swatchStyle = useMemo(
|
||||
() => [styles.swatch, color === "none" ? styles.swatchNone : identitySwatch(color)],
|
||||
[color],
|
||||
);
|
||||
const cellStyle = useCallback(
|
||||
({ pressed }: PressableStateCallbackType) => [styles.colorCell, pressed && styles.cellPressed],
|
||||
[],
|
||||
);
|
||||
return (
|
||||
<Pressable
|
||||
onPress={handlePress}
|
||||
style={cellStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={accessibilityState}
|
||||
accessibilityLabel={colorLabel(t, color)}
|
||||
testID={`agent-profile-color-cell-${color}`}
|
||||
>
|
||||
<View style={swatchStyle}>
|
||||
{selected ? (
|
||||
<ThemedCheck
|
||||
size={ICON_SIZE.sm}
|
||||
uniProps={color === "none" ? foregroundMutedMapping : onIdentityMapping}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon and colour in one popover rather than two fields: the pair is a single
|
||||
* visual decision, and picking them apart means never seeing the result until
|
||||
* both are closed. The grid redraws in the chosen colour as you switch colours,
|
||||
* so the swatch row sits above it.
|
||||
*/
|
||||
export function AgentProfileAppearanceField({
|
||||
label,
|
||||
icon,
|
||||
color,
|
||||
onChange,
|
||||
size = "md",
|
||||
disabled = false,
|
||||
testID,
|
||||
triggerTestID,
|
||||
}: {
|
||||
label: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
onChange: (next: { icon: string; color: string }) => void;
|
||||
size?: FieldControlSize;
|
||||
disabled?: boolean;
|
||||
testID?: string;
|
||||
triggerTestID?: string;
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const anchorRef = useRef<View>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const selectedIcon = resolveAgentProfileIconKey(icon);
|
||||
const selectedColor = resolveAgentProfileColor(color);
|
||||
|
||||
const handleTriggerPress = useCallback(() => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
setOpen((current) => !current);
|
||||
}, [disabled]);
|
||||
|
||||
// Colour keeps the popover open — you are still choosing. An icon is the last
|
||||
// decision, so it commits and closes.
|
||||
const handleColorSelect = useCallback(
|
||||
(next: AgentProfileColor) => onChange({ icon, color: next === "none" ? "" : next }),
|
||||
[icon, onChange],
|
||||
);
|
||||
const handleIconSelect = useCallback(
|
||||
(next: AgentProfileIconKey | null) => {
|
||||
onChange({ icon: next ?? "", color });
|
||||
setOpen(false);
|
||||
},
|
||||
[color, onChange],
|
||||
);
|
||||
|
||||
const triggerStyle = useCallback(
|
||||
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.trigger,
|
||||
size === "sm" ? styles.triggerSm : styles.triggerMd,
|
||||
styles.triggerIconOnly,
|
||||
resolveControlInteractionStyles(
|
||||
{
|
||||
controlRest: styles.controlRest,
|
||||
controlHover: styles.controlHover,
|
||||
controlActive: styles.controlActive,
|
||||
controlDisabled: styles.controlDisabled,
|
||||
},
|
||||
{ hovered: Boolean(hovered), active: pressed || open, disabled },
|
||||
),
|
||||
],
|
||||
[disabled, open, size],
|
||||
);
|
||||
|
||||
const header = useMemo(() => ({ title: label }), [label]);
|
||||
|
||||
return (
|
||||
<Field label={label} testID={testID}>
|
||||
<View ref={anchorRef} collapsable={false}>
|
||||
<Pressable
|
||||
onPress={handleTriggerPress}
|
||||
disabled={disabled}
|
||||
style={triggerStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`${label} (${colorLabel(t, selectedColor)})`}
|
||||
testID={triggerTestID}
|
||||
>
|
||||
<AgentProfileGlyph
|
||||
{...(selectedIcon ? { icon: selectedIcon } : {})}
|
||||
color={selectedColor}
|
||||
size={ICON_SIZE.md}
|
||||
/>
|
||||
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={foregroundMutedMapping} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<Combobox
|
||||
options={EMPTY_COMBOBOX_OPTIONS}
|
||||
value=""
|
||||
onSelect={noop}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
anchorRef={anchorRef}
|
||||
header={header}
|
||||
desktopMinWidth={DESKTOP_WIDTH}
|
||||
desktopLockWidth
|
||||
>
|
||||
<View style={styles.body} testID="agent-profile-appearance-picker">
|
||||
<View style={styles.colorRow}>
|
||||
{AGENT_PROFILE_COLORS.map((option) => (
|
||||
<ColorCell
|
||||
key={option}
|
||||
color={option}
|
||||
selected={option === selectedColor}
|
||||
onSelect={handleColorSelect}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
<View style={styles.grid}>
|
||||
<IconCell
|
||||
iconKey={null}
|
||||
selected={selectedIcon === null}
|
||||
color={selectedColor}
|
||||
onSelect={handleIconSelect}
|
||||
/>
|
||||
{AGENT_PROFILE_ICON_KEYS.map((key) => (
|
||||
<IconCell
|
||||
key={key}
|
||||
iconKey={key}
|
||||
selected={key === selectedIcon}
|
||||
color={selectedColor}
|
||||
onSelect={handleIconSelect}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</Combobox>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
function noop() {}
|
||||
|
||||
/**
|
||||
* One named rule per hue. Unistyles cannot resolve a colour computed at render,
|
||||
* so the palette has to be spelled out as static styles.
|
||||
*/
|
||||
function identitySwatch(color: Exclude<AgentProfileColor, "none">) {
|
||||
return styles[IDENTITY_SWATCH_STYLE_KEYS[color]];
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => {
|
||||
const geometry = createControlGeometry(theme);
|
||||
|
||||
return {
|
||||
trigger: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[1],
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
triggerSm: { ...geometry.fieldControlSm },
|
||||
triggerMd: { ...geometry.fieldControlMd },
|
||||
// The field geometry pads for a line of text. This trigger holds a glyph and
|
||||
// a chevron, so it keeps the shared height and drops the text-sized inset.
|
||||
triggerIconOnly: {
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
},
|
||||
controlRest: { ...geometry.controlRest },
|
||||
controlHover: { ...geometry.controlHover },
|
||||
controlActive: { ...geometry.controlActive },
|
||||
controlDisabled: { ...geometry.controlDisabled },
|
||||
|
||||
body: {
|
||||
padding: CELL_GRID_PADDING,
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
colorRow: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
gap: CELL_GAP,
|
||||
paddingBottom: theme.spacing[3],
|
||||
borderBottomWidth: theme.borderWidth[1],
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
colorCell: {
|
||||
width: COLOR_CELL_SIZE,
|
||||
height: COLOR_CELL_SIZE,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
grid: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
gap: CELL_GAP,
|
||||
},
|
||||
cell: {
|
||||
width: CELL_SIZE,
|
||||
height: CELL_SIZE,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: "transparent",
|
||||
},
|
||||
cellHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
cellPressed: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
cellSelected: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
swatch: {
|
||||
width: SWATCH_SIZE,
|
||||
height: SWATCH_SIZE,
|
||||
borderRadius: SWATCH_SIZE / 2,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
swatchNone: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
swatchViolet: { backgroundColor: identityForeground("violet", theme.colorScheme) },
|
||||
swatchSky: { backgroundColor: identityForeground("sky", theme.colorScheme) },
|
||||
swatchEmerald: { backgroundColor: identityForeground("emerald", theme.colorScheme) },
|
||||
swatchOrange: { backgroundColor: identityForeground("orange", theme.colorScheme) },
|
||||
swatchPink: { backgroundColor: identityForeground("pink", theme.colorScheme) },
|
||||
swatchIndigo: { backgroundColor: identityForeground("indigo", theme.colorScheme) },
|
||||
swatchTeal: { backgroundColor: identityForeground("teal", theme.colorScheme) },
|
||||
swatchRed: { backgroundColor: identityForeground("red", theme.colorScheme) },
|
||||
swatchAmber: { backgroundColor: identityForeground("amber", theme.colorScheme) },
|
||||
swatchBlue: { backgroundColor: identityForeground("blue", theme.colorScheme) },
|
||||
};
|
||||
});
|
||||
|
||||
const IDENTITY_SWATCH_STYLE_KEYS = {
|
||||
violet: "swatchViolet",
|
||||
sky: "swatchSky",
|
||||
emerald: "swatchEmerald",
|
||||
orange: "swatchOrange",
|
||||
pink: "swatchPink",
|
||||
indigo: "swatchIndigo",
|
||||
teal: "swatchTeal",
|
||||
red: "swatchRed",
|
||||
amber: "swatchAmber",
|
||||
blue: "swatchBlue",
|
||||
} as const;
|
||||
@@ -0,0 +1,549 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactElement } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { AgentFeature } from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentProfile } from "@getpaseo/protocol/messages";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { type FieldControlSize } from "@/components/ui/control-geometry";
|
||||
import { Field, FormTextInput } from "@/components/ui/form-field";
|
||||
import {
|
||||
SelectField,
|
||||
type SelectFieldDisplay,
|
||||
type SelectFieldOption,
|
||||
} from "@/components/ui/select-field";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { AgentProfileAppearanceField } from "./agent-profile-appearance-field";
|
||||
import type {
|
||||
AgentProfileFormModel,
|
||||
AgentProfileFormOption,
|
||||
AgentProfileValue,
|
||||
} from "../internal/profile-form-model";
|
||||
import {
|
||||
useAgentProfileFormCatalog,
|
||||
useAgentProfileFormFeatures,
|
||||
} from "../internal/use-profile-form-inputs";
|
||||
import {
|
||||
useAgentProfileFormModel,
|
||||
useAgentProfileFormState,
|
||||
} from "../internal/use-profile-form-model";
|
||||
|
||||
export interface AgentProfileEditModalProps {
|
||||
serverId: string;
|
||||
visible: boolean;
|
||||
mode: "create" | "edit";
|
||||
profile?: AgentProfile;
|
||||
onClose: () => void;
|
||||
onSave: (value: AgentProfileValue) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Only reachable before the catalog lands. Every select is seeded to a concrete
|
||||
* id as soon as one exists, and none of them offer an "unset" row.
|
||||
*/
|
||||
const UNSET_VALUE = "";
|
||||
|
||||
function openKey(props: AgentProfileEditModalProps): string {
|
||||
return props.mode === "edit" ? `edit:${props.profile?.id ?? ""}` : "create";
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and edit never share a mounted instance: the body is keyed on mode plus
|
||||
* profile id, so every open constructs a form model seeded from that record.
|
||||
*/
|
||||
export function AgentProfileEditModal(props: AgentProfileEditModalProps): ReactElement | null {
|
||||
const [renderedProps, setRenderedProps] = useState<AgentProfileEditModalProps | null>(() =>
|
||||
props.visible ? props : null,
|
||||
);
|
||||
const [sheetVisible, setSheetVisible] = useState(props.visible);
|
||||
const livePropsRef = useRef(props);
|
||||
const closeRequestedRef = useRef(false);
|
||||
livePropsRef.current = props;
|
||||
|
||||
useEffect(() => {
|
||||
if (props.visible) {
|
||||
if (closeRequestedRef.current) {
|
||||
return;
|
||||
}
|
||||
setRenderedProps(props);
|
||||
setSheetVisible(true);
|
||||
return;
|
||||
}
|
||||
if (renderedProps) {
|
||||
setSheetVisible(false);
|
||||
}
|
||||
}, [props, renderedProps]);
|
||||
|
||||
const requestClose = useCallback(() => {
|
||||
closeRequestedRef.current = true;
|
||||
setSheetVisible(false);
|
||||
}, []);
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
const dismissedProps = livePropsRef.current;
|
||||
closeRequestedRef.current = false;
|
||||
setRenderedProps(null);
|
||||
setSheetVisible(false);
|
||||
if (dismissedProps.visible) {
|
||||
dismissedProps.onClose();
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!renderedProps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<OpenAgentProfileEditModal
|
||||
key={openKey(renderedProps)}
|
||||
{...renderedProps}
|
||||
visible={sheetVisible}
|
||||
onClose={requestClose}
|
||||
onDismiss={handleDismiss}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function toSelectOptions(options: AgentProfileFormOption[]): SelectFieldOption<string>[] {
|
||||
return options.map((option) => ({
|
||||
id: option.id,
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
...(option.description ? { description: option.description } : {}),
|
||||
testID: option.testID,
|
||||
}));
|
||||
}
|
||||
|
||||
function OpenAgentProfileEditModal({
|
||||
serverId,
|
||||
visible,
|
||||
mode,
|
||||
profile,
|
||||
onClose,
|
||||
onDismiss,
|
||||
onSave,
|
||||
}: AgentProfileEditModalProps & { onDismiss: () => void }): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const controlSize: FieldControlSize = useIsCompactFormFactor() ? "md" : "sm";
|
||||
const snapshot = useMemo(() => ({ mode, ...(profile ? { profile } : {}) }), [mode, profile]);
|
||||
const model = useAgentProfileFormModel(snapshot);
|
||||
const state = useAgentProfileFormState(model);
|
||||
useAgentProfileFormCatalog({ serverId, model });
|
||||
useAgentProfileFormFeatures({ serverId, model, state });
|
||||
|
||||
const sheetHeader = useMemo<SheetHeader>(
|
||||
() => ({
|
||||
title:
|
||||
mode === "edit"
|
||||
? t("settings.host.agentProfiles.editProfileTitle")
|
||||
: t("settings.host.agentProfiles.addProfileTitle"),
|
||||
}),
|
||||
[mode, t],
|
||||
);
|
||||
|
||||
const providerOptions = useMemo(
|
||||
() => toSelectOptions(state.providerOptions),
|
||||
[state.providerOptions],
|
||||
);
|
||||
const modelOptions = useMemo(() => toSelectOptions(state.modelOptions), [state.modelOptions]);
|
||||
const modeOptions = useMemo(() => toSelectOptions(state.modeOptions), [state.modeOptions]);
|
||||
const thinkingOptions = useMemo(
|
||||
() => toSelectOptions(state.thinkingOptions),
|
||||
[state.thinkingOptions],
|
||||
);
|
||||
|
||||
const handleAppearanceChange = useCallback(
|
||||
(value: { icon: string; color: string }) => model.setAppearance(value),
|
||||
[model],
|
||||
);
|
||||
const handleProviderChange = useCallback(
|
||||
(value: string, display: SelectFieldDisplay) => model.setProvider(value, display),
|
||||
[model],
|
||||
);
|
||||
const handleModelChange = useCallback(
|
||||
(value: string, display: SelectFieldDisplay) => model.setModel(value, value ? display : null),
|
||||
[model],
|
||||
);
|
||||
const handleModeChange = useCallback(
|
||||
(value: string, display: SelectFieldDisplay) => model.setMode(value, value ? display : null),
|
||||
[model],
|
||||
);
|
||||
const handleThinkingChange = useCallback(
|
||||
(value: string, display: SelectFieldDisplay) =>
|
||||
model.setThinking(value, value ? display : null),
|
||||
[model],
|
||||
);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const value = model.getState().submitValue;
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
model.setSubmitError(null);
|
||||
model.setSubmitting(true);
|
||||
try {
|
||||
await onSave(value);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
model.setSubmitError(toErrorMessage(error));
|
||||
} finally {
|
||||
model.setSubmitting(false);
|
||||
}
|
||||
}, [model, onClose, onSave]);
|
||||
|
||||
const handleSavePress = useCallback(() => {
|
||||
void handleSave();
|
||||
}, [handleSave]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
if (state.isSubmitting) {
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
}, [onClose, state.isSubmitting]);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
visible={visible}
|
||||
header={sheetHeader}
|
||||
onClose={handleCancel}
|
||||
onDismiss={onDismiss}
|
||||
desktopMaxWidth={520}
|
||||
testID="agent-profile-edit-modal"
|
||||
>
|
||||
<View style={styles.body}>
|
||||
<View style={styles.nameRow}>
|
||||
<View style={styles.iconField}>
|
||||
<AgentProfileAppearanceField
|
||||
label={t("settings.host.agentProfiles.iconLabel")}
|
||||
icon={state.icon}
|
||||
color={state.color}
|
||||
onChange={handleAppearanceChange}
|
||||
disabled={state.isSubmitting}
|
||||
size={controlSize}
|
||||
testID="agent-profile-icon-field"
|
||||
triggerTestID="agent-profile-icon-trigger"
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.nameField}>
|
||||
<Field
|
||||
label={t("settings.host.agentProfiles.nameLabel")}
|
||||
testID="agent-profile-name-field"
|
||||
>
|
||||
<FormTextInput
|
||||
initialValue={profile?.name ?? ""}
|
||||
onChangeText={model.setName}
|
||||
placeholder={t("settings.host.agentProfiles.namePlaceholder")}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
editable={!state.isSubmitting}
|
||||
size={controlSize}
|
||||
accessibilityLabel={t("settings.host.agentProfiles.nameLabel")}
|
||||
testID="agent-profile-name-input"
|
||||
/>
|
||||
</Field>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<SelectField
|
||||
label={t("settings.host.agentProfiles.providerLabel")}
|
||||
value={state.provider || null}
|
||||
selectedDisplay={state.providerDisplay}
|
||||
options={providerOptions}
|
||||
onChange={handleProviderChange}
|
||||
placeholder={t("settings.host.agentProfiles.providerPlaceholder")}
|
||||
emptyText={t("settings.host.agentProfiles.noProviders")}
|
||||
loading={state.catalogResolution !== "complete"}
|
||||
disabled={state.isSubmitting}
|
||||
searchable={providerOptions.length > 6}
|
||||
title={t("settings.host.agentProfiles.providerLabel")}
|
||||
size={controlSize}
|
||||
testID="agent-profile-provider-field"
|
||||
triggerTestID="agent-profile-provider-trigger"
|
||||
/>
|
||||
|
||||
{state.disclosure.showModelField ? (
|
||||
<SelectField
|
||||
label={t("settings.host.agentProfiles.modelLabel")}
|
||||
value={state.modelId || UNSET_VALUE}
|
||||
selectedDisplay={state.modelDisplay}
|
||||
options={modelOptions}
|
||||
onChange={handleModelChange}
|
||||
placeholder={t("settings.host.agentProfiles.modelLabel")}
|
||||
emptyText={t("settings.host.agentProfiles.noModels")}
|
||||
disabled={state.isSubmitting}
|
||||
searchable={modelOptions.length > 6}
|
||||
title={t("settings.host.agentProfiles.modelLabel")}
|
||||
size={controlSize}
|
||||
testID="agent-profile-model-field"
|
||||
triggerTestID="agent-profile-model-trigger"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{state.disclosure.showModeField ? (
|
||||
<SelectField
|
||||
label={t("settings.host.agentProfiles.modeLabel")}
|
||||
value={state.modeId || UNSET_VALUE}
|
||||
selectedDisplay={state.modeDisplay}
|
||||
options={modeOptions}
|
||||
onChange={handleModeChange}
|
||||
placeholder={t("settings.host.agentProfiles.modeLabel")}
|
||||
emptyText={t("settings.host.agentProfiles.noModes")}
|
||||
disabled={state.isSubmitting}
|
||||
searchable={modeOptions.length > 6}
|
||||
title={t("settings.host.agentProfiles.modeLabel")}
|
||||
size={controlSize}
|
||||
testID="agent-profile-mode-field"
|
||||
triggerTestID="agent-profile-mode-trigger"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{state.disclosure.showThinkingField ? (
|
||||
<SelectField
|
||||
label={t("settings.host.agentProfiles.thinkingLabel")}
|
||||
value={state.thinkingOptionId || UNSET_VALUE}
|
||||
selectedDisplay={state.thinkingDisplay}
|
||||
options={thinkingOptions}
|
||||
onChange={handleThinkingChange}
|
||||
placeholder={t("settings.host.agentProfiles.thinkingLabel")}
|
||||
emptyText={t("settings.host.agentProfiles.noThinkingOptions")}
|
||||
disabled={state.isSubmitting}
|
||||
searchable={thinkingOptions.length > 6}
|
||||
title={t("settings.host.agentProfiles.thinkingLabel")}
|
||||
size={controlSize}
|
||||
testID="agent-profile-thinking-field"
|
||||
triggerTestID="agent-profile-thinking-trigger"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{state.disclosure.showFeaturesField ? (
|
||||
<Field
|
||||
label={t("settings.host.agentProfiles.featuresLabel")}
|
||||
testID="agent-profile-features-field"
|
||||
>
|
||||
<View style={styles.featureCard} testID="agent-profile-features-card">
|
||||
{state.features.map((feature, index) => (
|
||||
<AgentProfileFeatureRow
|
||||
key={feature.id}
|
||||
feature={feature}
|
||||
isFirst={index === 0}
|
||||
disabled={state.isSubmitting}
|
||||
size={controlSize}
|
||||
model={model}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</Field>
|
||||
) : null}
|
||||
|
||||
<Field
|
||||
label={t("settings.host.agentProfiles.notesLabel")}
|
||||
hint={t("settings.host.agentProfiles.notesHint")}
|
||||
testID="agent-profile-notes-field"
|
||||
>
|
||||
<FormTextInput
|
||||
initialValue={profile?.notes ?? ""}
|
||||
onChangeText={model.setNotes}
|
||||
placeholder={t("settings.host.agentProfiles.notesPlaceholder")}
|
||||
multiline
|
||||
numberOfLines={4}
|
||||
style={styles.notesInput}
|
||||
editable={!state.isSubmitting}
|
||||
size={controlSize}
|
||||
accessibilityLabel={t("settings.host.agentProfiles.notesLabel")}
|
||||
testID="agent-profile-notes-input"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{state.submitError ? (
|
||||
<Text style={styles.submitError} testID="agent-profile-submit-error">
|
||||
{state.submitError}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
style={styles.actionButton}
|
||||
onPress={handleCancel}
|
||||
disabled={state.isSubmitting}
|
||||
testID="agent-profile-cancel-button"
|
||||
>
|
||||
{t("common.actions.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
style={styles.actionButton}
|
||||
onPress={handleSavePress}
|
||||
disabled={!state.canSubmit}
|
||||
testID="agent-profile-save-button"
|
||||
>
|
||||
{state.isSubmitting
|
||||
? t("settings.host.agentProfiles.saving")
|
||||
: t("settings.host.agentProfiles.save")}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentProfileFeatureRow({
|
||||
feature,
|
||||
isFirst,
|
||||
disabled,
|
||||
size,
|
||||
model,
|
||||
}: {
|
||||
feature: AgentFeature;
|
||||
isFirst: boolean;
|
||||
disabled: boolean;
|
||||
size: FieldControlSize;
|
||||
model: AgentProfileFormModel;
|
||||
}): ReactElement {
|
||||
const rowStyle = useMemo(
|
||||
() => [styles.featureRow, isFirst ? null : styles.featureRowBorder],
|
||||
[isFirst],
|
||||
);
|
||||
const handleToggle = useCallback(
|
||||
(value: boolean) => model.setFeatureValue(feature.id, value),
|
||||
[feature.id, model],
|
||||
);
|
||||
const handleSelect = useCallback(
|
||||
(value: string) => model.setFeatureValue(feature.id, value),
|
||||
[feature.id, model],
|
||||
);
|
||||
const selectOptions = useMemo<SelectFieldOption<string>[]>(
|
||||
() =>
|
||||
feature.type === "select"
|
||||
? feature.options.map((option) => ({
|
||||
id: option.id,
|
||||
value: option.id,
|
||||
label: option.label,
|
||||
...(option.description ? { description: option.description } : {}),
|
||||
testID: `agent-profile-feature-option-${feature.id}-${option.id}`,
|
||||
}))
|
||||
: [],
|
||||
[feature],
|
||||
);
|
||||
const selectedDisplay = useMemo<SelectFieldDisplay | null>(() => {
|
||||
if (feature.type !== "select" || !feature.value) {
|
||||
return null;
|
||||
}
|
||||
const option = feature.options.find((candidate) => candidate.id === feature.value);
|
||||
return { label: option?.label ?? feature.value };
|
||||
}, [feature]);
|
||||
|
||||
if (feature.type === "select") {
|
||||
return (
|
||||
<View style={rowStyle} testID={`agent-profile-feature-row-${feature.id}`}>
|
||||
<SelectField
|
||||
label={feature.label}
|
||||
value={feature.value}
|
||||
selectedDisplay={selectedDisplay}
|
||||
options={selectOptions}
|
||||
onChange={handleSelect}
|
||||
placeholder={feature.label}
|
||||
emptyText={feature.label}
|
||||
disabled={disabled}
|
||||
{...(feature.description ? { hint: feature.description } : {})}
|
||||
title={feature.label}
|
||||
size={size}
|
||||
testID={`agent-profile-feature-field-${feature.id}`}
|
||||
triggerTestID={`agent-profile-feature-trigger-${feature.id}`}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={rowStyle} testID={`agent-profile-feature-row-${feature.id}`}>
|
||||
<View style={styles.featureMeta}>
|
||||
<Text style={styles.featureLabel} numberOfLines={1}>
|
||||
{feature.label}
|
||||
</Text>
|
||||
{feature.description ? (
|
||||
<Text style={styles.featureDescription} numberOfLines={2}>
|
||||
{feature.description}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<Switch
|
||||
value={feature.value === true}
|
||||
onValueChange={handleToggle}
|
||||
disabled={disabled}
|
||||
accessibilityLabel={feature.label}
|
||||
testID={`agent-profile-feature-switch-${feature.id}`}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
body: {
|
||||
gap: theme.spacing[4],
|
||||
paddingBottom: theme.spacing[2],
|
||||
},
|
||||
nameRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
// No width: the trigger is a glyph and a chevron, so it sizes to them.
|
||||
iconField: {
|
||||
flexShrink: 0,
|
||||
},
|
||||
nameField: {
|
||||
flex: 1,
|
||||
},
|
||||
featureCard: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
},
|
||||
featureRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[3],
|
||||
},
|
||||
featureRowBorder: {
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.colors.border,
|
||||
},
|
||||
featureMeta: {
|
||||
flex: 1,
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
featureLabel: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
featureDescription: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
notesInput: {
|
||||
minHeight: 88,
|
||||
textAlignVertical: "top",
|
||||
},
|
||||
submitError: {
|
||||
color: theme.colors.statusDanger,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
marginTop: theme.spacing[2],
|
||||
},
|
||||
actionButton: {
|
||||
flex: 1,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useCallback, useMemo, type ReactElement } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowDown, ArrowUp, FileText, Pencil, Trash2 } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentProfile } from "@getpaseo/protocol/messages";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import { AgentProfileGlyph } from "../internal/agent-profile-glyph";
|
||||
import { buildAgentProfileTags } from "../internal/profile-summary";
|
||||
|
||||
const ThemedArrowUp = withUnistyles(ArrowUp);
|
||||
const ThemedArrowDown = withUnistyles(ArrowDown);
|
||||
const ThemedPencil = withUnistyles(Pencil);
|
||||
const ThemedTrash2 = withUnistyles(Trash2);
|
||||
const ThemedFileText = withUnistyles(FileText);
|
||||
|
||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
const destructiveColorMapping = (theme: Theme) => ({ color: theme.colors.destructive });
|
||||
|
||||
const moveUpIcon = <ThemedArrowUp size={ICON_SIZE.sm} uniProps={mutedColorMapping} />;
|
||||
const moveDownIcon = <ThemedArrowDown size={ICON_SIZE.sm} uniProps={mutedColorMapping} />;
|
||||
const editIcon = <ThemedPencil size={ICON_SIZE.sm} uniProps={mutedColorMapping} />;
|
||||
const removeIcon = <ThemedTrash2 size={ICON_SIZE.sm} uniProps={destructiveColorMapping} />;
|
||||
|
||||
export interface AgentProfileRowProps {
|
||||
profile: AgentProfile;
|
||||
entries: readonly ProviderSnapshotEntry[] | undefined;
|
||||
isFirst: boolean;
|
||||
isLast: boolean;
|
||||
onEdit: (id: string) => void;
|
||||
onRemove: (id: string) => void;
|
||||
onMoveUp: (id: string) => void;
|
||||
onMoveDown: (id: string) => void;
|
||||
}
|
||||
|
||||
export function AgentProfileRow({
|
||||
profile,
|
||||
entries,
|
||||
isFirst,
|
||||
isLast,
|
||||
onEdit,
|
||||
onRemove,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
}: AgentProfileRowProps): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleEdit = useCallback(() => onEdit(profile.id), [onEdit, profile.id]);
|
||||
const handleRemove = useCallback(() => onRemove(profile.id), [onRemove, profile.id]);
|
||||
const handleMoveUp = useCallback(() => onMoveUp(profile.id), [onMoveUp, profile.id]);
|
||||
const handleMoveDown = useCallback(() => onMoveDown(profile.id), [onMoveDown, profile.id]);
|
||||
|
||||
const formatFeatureCount = useCallback(
|
||||
(count: number) =>
|
||||
count === 1
|
||||
? t("settings.host.agentProfiles.featureCountOne", { count })
|
||||
: t("settings.host.agentProfiles.featureCount", { count }),
|
||||
[t],
|
||||
);
|
||||
const tags = useMemo(
|
||||
() => buildAgentProfileTags({ profile, entries, formatFeatureCount }),
|
||||
[entries, formatFeatureCount, profile],
|
||||
);
|
||||
const summary = useMemo(() => tags.map((tag) => tag.label).join(" · "), [tags]);
|
||||
|
||||
const rowStyle = useMemo(
|
||||
() => [settingsStyles.row, isFirst ? null : settingsStyles.rowBorder, styles.row],
|
||||
[isFirst],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={rowStyle} testID={`agent-profile-row-${profile.id}`}>
|
||||
<View style={styles.iconWrapper}>
|
||||
<AgentProfileGlyph icon={profile.icon} color={profile.color} size={ICON_SIZE.md} />
|
||||
</View>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<View style={styles.titleLine}>
|
||||
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
|
||||
{profile.name}
|
||||
</Text>
|
||||
<Text style={styles.summary} numberOfLines={1}>
|
||||
{summary}
|
||||
</Text>
|
||||
</View>
|
||||
{profile.notes ? (
|
||||
<View style={styles.notes}>
|
||||
<ThemedFileText size={ICON_SIZE.xs} uniProps={mutedColorMapping} />
|
||||
<Text
|
||||
style={styles.notesText}
|
||||
numberOfLines={2}
|
||||
testID={`agent-profile-notes-${profile.id}`}
|
||||
>
|
||||
{profile.notes}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
<View style={styles.rowActions}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={moveUpIcon}
|
||||
onPress={handleMoveUp}
|
||||
disabled={isFirst}
|
||||
accessibilityLabel={t("settings.host.agentProfiles.moveUp")}
|
||||
testID={`agent-profile-move-up-${profile.id}`}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={moveDownIcon}
|
||||
onPress={handleMoveDown}
|
||||
disabled={isLast}
|
||||
accessibilityLabel={t("settings.host.agentProfiles.moveDown")}
|
||||
testID={`agent-profile-move-down-${profile.id}`}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={editIcon}
|
||||
onPress={handleEdit}
|
||||
accessibilityLabel={t("settings.host.agentProfiles.editProfile")}
|
||||
testID={`agent-profile-edit-${profile.id}`}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={removeIcon}
|
||||
onPress={handleRemove}
|
||||
accessibilityLabel={t("settings.host.agentProfiles.remove")}
|
||||
testID={`agent-profile-remove-${profile.id}`}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
row: {
|
||||
gap: theme.spacing[2],
|
||||
minHeight: 56,
|
||||
alignItems: "center",
|
||||
paddingVertical: theme.spacing[3],
|
||||
},
|
||||
iconWrapper: {
|
||||
width: theme.iconSize.md,
|
||||
alignSelf: "flex-start",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginTop: theme.spacing[1],
|
||||
},
|
||||
titleLine: {
|
||||
flexDirection: "row",
|
||||
alignItems: "baseline",
|
||||
gap: theme.spacing[2],
|
||||
minWidth: 0,
|
||||
},
|
||||
summary: {
|
||||
flexShrink: 1,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
notes: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
gap: theme.spacing[1],
|
||||
marginTop: theme.spacing[1],
|
||||
},
|
||||
notesText: {
|
||||
flex: 1,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
rowActions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,219 @@
|
||||
import { useCallback, useMemo, useState, type ReactElement } from "react";
|
||||
import { Alert, Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import type { AgentProfile } from "@getpaseo/protocol/messages";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { useAgentProfiles } from "../internal/use-agent-profiles";
|
||||
import type { AgentProfileValue } from "../internal/profile-form-model";
|
||||
import { AgentProfileEditModal } from "./agent-profile-edit-modal";
|
||||
import { AgentProfileRow } from "./agent-profile-row";
|
||||
|
||||
const ThemedPlus = withUnistyles(Plus);
|
||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
const addIcon = <ThemedPlus size={ICON_SIZE.sm} uniProps={mutedColorMapping} />;
|
||||
|
||||
function generateAgentProfileId(): string {
|
||||
return `agent_profile_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
interface EditTarget {
|
||||
mode: "create" | "edit";
|
||||
profile?: AgentProfile;
|
||||
}
|
||||
|
||||
export function AgentProfilesSection({ serverId }: { serverId: string }): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const { profiles, isSupported, saveProfiles } = useAgentProfiles(serverId);
|
||||
const { entries } = useProvidersSnapshot(serverId, { cwd: null });
|
||||
const [editTarget, setEditTarget] = useState<EditTarget | null>(null);
|
||||
|
||||
const handleAddOpen = useCallback(() => setEditTarget({ mode: "create" }), []);
|
||||
const handleEditClose = useCallback(() => setEditTarget(null), []);
|
||||
|
||||
const handleEditOpen = useCallback(
|
||||
(id: string) => {
|
||||
const profile = profiles?.find((entry) => entry.id === id);
|
||||
if (!profile) {
|
||||
return;
|
||||
}
|
||||
setEditTarget({ mode: "edit", profile });
|
||||
},
|
||||
[profiles],
|
||||
);
|
||||
|
||||
const handleSave = useCallback(
|
||||
async (value: AgentProfileValue) => {
|
||||
const current = profiles ?? [];
|
||||
const editing = editTarget?.mode === "edit" ? editTarget.profile : undefined;
|
||||
// The edited profile is replaced, not merged: `value` omits the fields the
|
||||
// user cleared, so spreading it over the stored record would silently keep
|
||||
// the old model, mode, thinking option or notes.
|
||||
const next: AgentProfile[] = editing
|
||||
? current.map((entry) => (entry.id === editing.id ? { id: entry.id, ...value } : entry))
|
||||
: [...current, { id: generateAgentProfileId(), ...value }];
|
||||
await saveProfiles(next);
|
||||
},
|
||||
[editTarget, profiles, saveProfiles],
|
||||
);
|
||||
|
||||
const reorder = useCallback(
|
||||
async (id: string, offset: -1 | 1) => {
|
||||
if (!profiles) {
|
||||
return;
|
||||
}
|
||||
const index = profiles.findIndex((entry) => entry.id === id);
|
||||
const target = index + offset;
|
||||
if (index < 0 || target < 0 || target >= profiles.length) {
|
||||
return;
|
||||
}
|
||||
const next = [...profiles];
|
||||
const [item] = next.splice(index, 1);
|
||||
next.splice(target, 0, item);
|
||||
try {
|
||||
await saveProfiles(next);
|
||||
} catch (error) {
|
||||
Alert.alert(
|
||||
t("common.errors.unableToSave"),
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
},
|
||||
[profiles, saveProfiles, t],
|
||||
);
|
||||
|
||||
const handleMoveUp = useCallback((id: string) => void reorder(id, -1), [reorder]);
|
||||
const handleMoveDown = useCallback((id: string) => void reorder(id, 1), [reorder]);
|
||||
|
||||
const handleRemove = useCallback(
|
||||
(id: string) => {
|
||||
const profile = profiles?.find((entry) => entry.id === id);
|
||||
if (!profile) {
|
||||
return;
|
||||
}
|
||||
void confirmDialog({
|
||||
title: t("settings.host.agentProfiles.removeConfirmTitle"),
|
||||
message: t("settings.host.agentProfiles.removeConfirmMessage", { name: profile.name }),
|
||||
confirmLabel: t("settings.host.agentProfiles.remove"),
|
||||
cancelLabel: t("common.actions.cancel"),
|
||||
destructive: true,
|
||||
}).then(async (confirmed) => {
|
||||
if (!confirmed || !profiles) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await saveProfiles(profiles.filter((entry) => entry.id !== id));
|
||||
} catch (error) {
|
||||
Alert.alert(
|
||||
t("common.errors.unableToSave"),
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
return;
|
||||
});
|
||||
},
|
||||
[profiles, saveProfiles, t],
|
||||
);
|
||||
|
||||
const addButton = useMemo(
|
||||
() => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={addIcon}
|
||||
onPress={handleAddOpen}
|
||||
disabled={!profiles}
|
||||
accessibilityLabel={t("settings.host.agentProfiles.addProfileTitle")}
|
||||
testID="agent-profiles-add-button"
|
||||
/>
|
||||
),
|
||||
[handleAddOpen, profiles, t],
|
||||
);
|
||||
|
||||
if (!isConnected || !isSupported) {
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t("settings.host.agentProfiles.sectionTitle")}
|
||||
testID="agent-profiles-section"
|
||||
>
|
||||
<View style={settingsStyles.card} testID="agent-profiles-unavailable">
|
||||
<View style={styles.emptyCard}>
|
||||
<Text style={styles.emptyText}>
|
||||
{isConnected
|
||||
? t("settings.host.agentProfiles.unsupported")
|
||||
: t("settings.host.agentProfiles.unavailable")}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection
|
||||
title={t("settings.host.agentProfiles.sectionTitle")}
|
||||
trailing={addButton}
|
||||
testID="agent-profiles-section"
|
||||
>
|
||||
<View style={settingsStyles.card} testID="agent-profiles-card">
|
||||
{profiles && profiles.length > 0 ? (
|
||||
profiles.map((profile, index) => (
|
||||
<AgentProfileRow
|
||||
key={profile.id}
|
||||
profile={profile}
|
||||
entries={entries}
|
||||
isFirst={index === 0}
|
||||
isLast={index === profiles.length - 1}
|
||||
onEdit={handleEditOpen}
|
||||
onRemove={handleRemove}
|
||||
onMoveUp={handleMoveUp}
|
||||
onMoveDown={handleMoveDown}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<View style={styles.emptyCard}>
|
||||
<Text style={styles.emptyText} testID="agent-profiles-empty">
|
||||
{t("settings.host.agentProfiles.emptyState")}
|
||||
</Text>
|
||||
<Button size="sm" leftIcon={addIcon} onPress={handleAddOpen} disabled={!profiles}>
|
||||
{t("settings.host.agentProfiles.newProfile")}
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</SettingsSection>
|
||||
|
||||
<AgentProfileEditModal
|
||||
serverId={serverId}
|
||||
visible={editTarget !== null}
|
||||
mode={editTarget?.mode ?? "create"}
|
||||
{...(editTarget?.profile ? { profile: editTarget.profile } : {})}
|
||||
onClose={handleEditClose}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
emptyCard: {
|
||||
paddingVertical: theme.spacing[6],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
emptyText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
textAlign: "center",
|
||||
},
|
||||
}));
|
||||
@@ -143,6 +143,30 @@ function findLayoutItem(layout: StreamLayout, id: string): StreamLayoutItem {
|
||||
}
|
||||
|
||||
describe("layoutStream", () => {
|
||||
it.each(["web", "android"] as const)(
|
||||
"marks only the active live-head assistant block as streaming on %s",
|
||||
(platform) => {
|
||||
const completed = assistantMessage("turn:block:0", 2, { groupId: "turn", index: 0 });
|
||||
const live = assistantMessage("turn:block:1", 3, { groupId: "turn", index: 1 });
|
||||
const active = layoutFor({
|
||||
platform,
|
||||
isTurnActive: true,
|
||||
tail: [userMessage("u1", 1), completed],
|
||||
head: [live],
|
||||
});
|
||||
const complete = layoutFor({
|
||||
platform,
|
||||
isTurnActive: false,
|
||||
tail: [userMessage("u1", 1), completed],
|
||||
head: [live],
|
||||
});
|
||||
|
||||
expect(findLayoutItem(active, completed.id).phase).toBe("complete");
|
||||
expect(findLayoutItem(active, live.id).phase).toBe("streaming");
|
||||
expect(findLayoutItem(complete, live.id).phase).toBe("complete");
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["web", "android"] as const)(
|
||||
"keeps split assistant block spacing identical to unsplit history on %s",
|
||||
(platform) => {
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface StreamLayoutItem {
|
||||
isLastInUserGroup: boolean;
|
||||
isLastInToolSequence: boolean;
|
||||
frameOrder: StreamFrameChildOrder;
|
||||
phase: "streaming" | "complete";
|
||||
}
|
||||
|
||||
export interface StreamLayout {
|
||||
@@ -53,6 +54,7 @@ interface LayoutSegmentInput {
|
||||
boundaryBelowItem: StreamItem | null;
|
||||
boundaryAboveItems: StreamItem[] | null;
|
||||
boundaryAboveIndex: number | null;
|
||||
phase: "streaming" | "complete";
|
||||
}
|
||||
|
||||
interface AssistantFooterSource {
|
||||
@@ -273,6 +275,7 @@ function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] {
|
||||
isLastInUserGroup: item.kind === "user_message" && belowItem?.kind !== "user_message",
|
||||
isLastInToolSequence: isToolSequenceItem(item) && !isToolSequenceItem(belowItem),
|
||||
frameOrder: input.frameOrder,
|
||||
phase: input.phase,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -324,6 +327,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
||||
boundaryBelowItem: liveHeadBoundaryItem,
|
||||
boundaryAboveItems: null,
|
||||
boundaryAboveIndex: null,
|
||||
phase: "complete",
|
||||
});
|
||||
byKey.set(historyCacheKey, history);
|
||||
}
|
||||
@@ -342,6 +346,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
||||
boundaryBelowItem: null,
|
||||
boundaryAboveItems: input.history,
|
||||
boundaryAboveIndex: historyBoundaryIndex,
|
||||
phase: input.isTurnActive ? "streaming" : "complete",
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -206,6 +206,78 @@ describe("createWebStreamStrategy", () => {
|
||||
expect(renderLiveHeadRow).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("keeps a row mounted when it moves from the live head into mounted history", () => {
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: false });
|
||||
const viewportRef = React.createRef<StreamViewportHandle>();
|
||||
const item = userMessage(1);
|
||||
const mounted = vi.fn();
|
||||
const unmounted = vi.fn();
|
||||
function StatefulRow() {
|
||||
React.useEffect(() => {
|
||||
mounted();
|
||||
return unmounted;
|
||||
}, []);
|
||||
return <div>{item.id}</div>;
|
||||
}
|
||||
const renderers: StreamSegmentRenderers = {
|
||||
...createRenderers(vi.fn()),
|
||||
renderHistoryMountedRow: () => <StatefulRow />,
|
||||
renderLiveHeadRow: () => <StatefulRow />,
|
||||
};
|
||||
const renderInput: Omit<StreamRenderInput, "segments" | "boundary"> = {
|
||||
agentId: "agent",
|
||||
renderers,
|
||||
listEmptyComponent: null,
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest: null,
|
||||
isAuthoritativeHistoryReady: true,
|
||||
onNearBottomChange: vi.fn(),
|
||||
onNearHistoryStart: vi.fn().mockReturnValue(true),
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
olderHistoryProgressKey: null,
|
||||
scrollEnabled: true,
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
};
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root?.render(
|
||||
strategy.render({
|
||||
...renderInput,
|
||||
segments: { historyVirtualized: [], historyMounted: [], liveHead: [item] },
|
||||
boundary: {
|
||||
hasVirtualizedHistory: false,
|
||||
hasMountedHistory: false,
|
||||
hasLiveHead: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(mounted).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => {
|
||||
root?.render(
|
||||
strategy.render({
|
||||
...renderInput,
|
||||
segments: { historyVirtualized: [], historyMounted: [item], liveHead: [] },
|
||||
boundary: {
|
||||
hasVirtualizedHistory: false,
|
||||
hasMountedHistory: true,
|
||||
hasLiveHead: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(mounted).toHaveBeenCalledTimes(1);
|
||||
expect(unmounted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports the live-head row as the reading position", () => {
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: false });
|
||||
const viewportRef = React.createRef<StreamViewportHandle>();
|
||||
|
||||
@@ -1068,6 +1068,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
</div>
|
||||
));
|
||||
}, [liveHeadRowRevision, renderLiveHeadRow, segments.liveHead]);
|
||||
const mountedRows = useMemo(
|
||||
() => [...mountedHistoryRows, ...liveHeadRows],
|
||||
[liveHeadRows, mountedHistoryRows],
|
||||
);
|
||||
const liveAuxiliary = useMemo(() => {
|
||||
return renderLiveAuxiliary();
|
||||
}, [renderLiveAuxiliary]);
|
||||
@@ -1119,8 +1123,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{mountedHistoryRows}
|
||||
{liveHeadRows}
|
||||
{mountedRows}
|
||||
{liveAuxiliary}
|
||||
{shouldRenderEmpty ? listEmptyComponent : null}
|
||||
</div>
|
||||
|
||||
@@ -653,6 +653,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
serverId={resolvedServerId}
|
||||
client={client}
|
||||
spacing={layoutItem.assistantSpacing}
|
||||
phase={layoutItem.phase}
|
||||
/>
|
||||
</AssistantFileLinkResolverProvider>
|
||||
);
|
||||
@@ -798,7 +799,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
);
|
||||
|
||||
case "todo_list":
|
||||
return <TodoListCard items={item.items} />;
|
||||
return <TodoListCard items={item.items} activity={item.activity} />;
|
||||
|
||||
case "compaction":
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("Android AsyncStorage configuration", () => {
|
||||
it("writes a 10 MiB database limit into Gradle properties", () => {
|
||||
const appDirectory = path.resolve(import.meta.dirname, "..");
|
||||
const output = execFileSync(
|
||||
process.execPath,
|
||||
[
|
||||
"-e",
|
||||
`
|
||||
const plugin = require("./plugins/with-android-async-storage-size");
|
||||
const config = plugin({ name: "Paseo", slug: "paseo" }, 10);
|
||||
const mod = config.mods.android.gradleProperties;
|
||||
mod({ ...config, modResults: [], modRequest: {}, modRawConfig: config })
|
||||
.then((result) => process.stdout.write(JSON.stringify(result.modResults)));
|
||||
`,
|
||||
],
|
||||
{ cwd: appDirectory, encoding: "utf8" },
|
||||
);
|
||||
|
||||
expect(JSON.parse(output)).toEqual([
|
||||
{ type: "property", key: "AsyncStorage_db_size_in_MB", value: "10" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,7 @@ import { CommandCenterRootActions } from "@/command-center/root-registration";
|
||||
import { CommandCenterProvider } from "@/command-center/provider";
|
||||
import { CommandCenterWorkspaceActions } from "@/command-center/workspace-registration";
|
||||
import { AddProjectFlowHost } from "@/components/add-project-flow-host";
|
||||
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
|
||||
import { WorktreeSetupCalloutSource } from "@/components/worktree-setup-callout-source";
|
||||
import { DownloadToast } from "@/components/download-toast";
|
||||
import { QuittingOverlay } from "@/components/quitting-overlay";
|
||||
@@ -71,6 +72,7 @@ import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-rout
|
||||
import { ThemedStack } from "@/navigation/themed-stack";
|
||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
import { AgentNavigationListener } from "@/desktop/agent-navigation";
|
||||
import { legacyFavoriteProfileMigration } from "@/agent-profiles/migration";
|
||||
import { listenToDesktopEvent } from "@/desktop/electron/events";
|
||||
import { updateDesktopWindowControls } from "@/desktop/electron/window";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
@@ -100,13 +102,15 @@ import {
|
||||
useHostRegistryLoaded,
|
||||
useHostMutations,
|
||||
useHostRuntimeClient,
|
||||
useHostRuntimeIsConnected,
|
||||
useHosts,
|
||||
} from "@/runtime/host-runtime";
|
||||
import { getDaemonStartService } from "@/runtime/daemon-start-service";
|
||||
import { applyAppearance } from "@/screens/settings/appearance/apply-appearance";
|
||||
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
|
||||
import { flushDraftPersistStorage } from "@/stores/draft-store";
|
||||
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
|
||||
import { getNextThemePreference, THEME_TO_UNISTYLES } from "@/styles/theme";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { installWebScrollbarStyles } from "@/styles/install-web-scrollbar-styles";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
|
||||
@@ -265,11 +269,33 @@ function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
|
||||
|
||||
return (
|
||||
<SessionProvider key={daemon.serverId} serverId={daemon.serverId} client={client}>
|
||||
{null}
|
||||
<LegacyFavoriteProfileMigrationBootstrap serverId={daemon.serverId} client={client} />
|
||||
</SessionProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function LegacyFavoriteProfileMigrationBootstrap({
|
||||
serverId,
|
||||
client,
|
||||
}: {
|
||||
serverId: string;
|
||||
client: NonNullable<ReturnType<typeof useHostRuntimeClient>>;
|
||||
}) {
|
||||
const serverInfo = useSessionStore((state) => state.sessions[serverId]?.serverInfo ?? null);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!serverInfo || !isConnected) {
|
||||
return;
|
||||
}
|
||||
void legacyFavoriteProfileMigration.migrateHost(serverId, client).catch((error) => {
|
||||
console.warn("[AgentProfiles] Failed to migrate legacy favourites", error);
|
||||
});
|
||||
}, [client, isConnected, serverId, serverInfo]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function HostSessionManager() {
|
||||
const hosts = useHosts();
|
||||
|
||||
@@ -423,7 +449,6 @@ interface AppContainerProps {
|
||||
chromeEnabled?: boolean;
|
||||
}
|
||||
|
||||
const THEME_CYCLE_ORDER: ThemeName[] = ["dark", "zinc", "midnight", "claude", "ghostty", "light"];
|
||||
const WINDOW_SIDEBAR_TOGGLE_HORIZONTAL_PADDING = 12;
|
||||
|
||||
function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppContainerProps) {
|
||||
@@ -443,9 +468,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
const { width: viewportWidth } = useWindowDimensions();
|
||||
|
||||
const cycleTheme = useCallback(() => {
|
||||
const currentIndex = THEME_CYCLE_ORDER.indexOf(settings.theme as ThemeName);
|
||||
const nextIndex = (currentIndex + 1) % THEME_CYCLE_ORDER.length;
|
||||
void updateSettings({ theme: THEME_CYCLE_ORDER[nextIndex] });
|
||||
void updateSettings({ theme: getNextThemePreference(settings.theme) });
|
||||
}, [settings.theme, updateSettings]);
|
||||
|
||||
const isCompactLayout = useIsCompactFormFactor();
|
||||
@@ -643,7 +666,7 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
|
||||
|
||||
// Apply font / size / syntax appearance settings on mount and when they change.
|
||||
// Sibling to the theme effect above; order is irrelevant because both patch all
|
||||
// six registered theme keys, so the active key is always current.
|
||||
// registered theme keys, so the active key is always current.
|
||||
useEffect(() => {
|
||||
if (settingsLoading) return;
|
||||
applyAppearance({
|
||||
@@ -668,7 +691,7 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
|
||||
<OfferLinkListener upsertDaemonFromOfferUrl={upsertConnectionFromOfferUrl} />
|
||||
<HostSessionManager />
|
||||
<FaviconStatusSync />
|
||||
{children}
|
||||
<AppearanceStyleBoundary>{children}</AppearanceStyleBoundary>
|
||||
</VoiceProvider>
|
||||
);
|
||||
}
|
||||
@@ -954,19 +977,17 @@ function RuntimeProviders({ children }: { children: ReactNode }) {
|
||||
// context and need one shared provider for sibling sheets to stack.
|
||||
function RootProviders({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<SafeAreaProvider>
|
||||
<WindowChromeProvider>
|
||||
<KeyboardProvider>
|
||||
<KeyboardShiftProvider>
|
||||
<ToastProvider>
|
||||
<PortalProvider>
|
||||
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
|
||||
</PortalProvider>
|
||||
</ToastProvider>
|
||||
</KeyboardShiftProvider>
|
||||
</KeyboardProvider>
|
||||
</WindowChromeProvider>
|
||||
</SafeAreaProvider>
|
||||
<WindowChromeProvider>
|
||||
<KeyboardProvider>
|
||||
<KeyboardShiftProvider>
|
||||
<ToastProvider>
|
||||
<PortalProvider>
|
||||
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
|
||||
</PortalProvider>
|
||||
</ToastProvider>
|
||||
</KeyboardShiftProvider>
|
||||
</KeyboardProvider>
|
||||
</WindowChromeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -998,9 +1019,11 @@ export default function RootLayout() {
|
||||
return (
|
||||
<QueryProvider>
|
||||
<I18nProvider>
|
||||
<RootErrorBoundary>
|
||||
<RootAppTree />
|
||||
</RootErrorBoundary>
|
||||
<SafeAreaProvider>
|
||||
<RootErrorBoundary>
|
||||
<RootAppTree />
|
||||
</RootErrorBoundary>
|
||||
</SafeAreaProvider>
|
||||
</I18nProvider>
|
||||
</QueryProvider>
|
||||
);
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
import { useMemo, useState, type CSSProperties, type MouseEvent, type ReactNode } from "react";
|
||||
import {
|
||||
Platform,
|
||||
Pressable,
|
||||
Text,
|
||||
View,
|
||||
type StyleProp,
|
||||
type TextStyle,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { useMemo, type CSSProperties, type MouseEvent, type ReactNode } from "react";
|
||||
import { Platform, Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { MarkdownTextSpan } from "@/components/markdown-text";
|
||||
import { MarkdownLinkText } from "@/components/markdown/link-text";
|
||||
import { AssistantLinkPressProvider, type AssistantLinkPress } from "./link-press-context";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
@@ -39,7 +32,6 @@ export function AssistantMarkdownLink({
|
||||
monoSurface,
|
||||
children,
|
||||
}: AssistantMarkdownLinkProps) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const { target, onHoverIn, onPress, onAuxPress } = useFileLink(source);
|
||||
const { configRef } = useAssistantFileLinkResolverContext();
|
||||
const workspaceRoot = configRef.current.workspaceRoot;
|
||||
@@ -55,15 +47,6 @@ export function AssistantMarkdownLink({
|
||||
event.stopPropagation();
|
||||
onAuxPress();
|
||||
});
|
||||
const handleHoverIn = useStableEvent(() => {
|
||||
setHovered(true);
|
||||
onHoverIn();
|
||||
});
|
||||
const handleHoverOut = useStableEvent(() => setHovered(false));
|
||||
const hoveredTextStyle = useMemo<StyleProp<TextStyle>>(
|
||||
() => [style, hovered && { textDecorationLine: "underline" as const }],
|
||||
[style, hovered],
|
||||
);
|
||||
const linkPress = useMemo<AssistantLinkPress>(
|
||||
() => ({ onPress, accessibilityRole: "link" }),
|
||||
[onPress],
|
||||
@@ -114,19 +97,14 @@ export function AssistantMarkdownLink({
|
||||
onAuxClickCapture={preventAnchorNavigation}
|
||||
style={LINK_ANCHOR_STYLE}
|
||||
>
|
||||
<Pressable
|
||||
accessibilityRole="link"
|
||||
<MarkdownLinkText
|
||||
dataSet={monoSurface ? MARKDOWN_CODE_LINK_DATASET : undefined}
|
||||
style={style}
|
||||
onPress={onPress}
|
||||
onHoverIn={handleHoverIn}
|
||||
onHoverOut={handleHoverOut}
|
||||
onHoverIn={onHoverIn}
|
||||
>
|
||||
<Text
|
||||
dataSet={monoSurface ? MARKDOWN_CODE_LINK_DATASET : undefined}
|
||||
style={hoveredTextStyle}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
</Pressable>
|
||||
{children}
|
||||
</MarkdownLinkText>
|
||||
</a>
|
||||
);
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ 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 data-paseo-markdown-tag="li"><span data-paseo-markdown-ignore="true" data-paseo-markdown-list-marker="true">•</span><div><span>First bullet text</span></div></div>
|
||||
<div data-paseo-markdown-tag="li"><span data-paseo-markdown-ignore="true" data-paseo-markdown-list-marker="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>
|
||||
@@ -203,6 +203,97 @@ describe("assistant selection copy ranges", () => {
|
||||
expect(copiedMarkdown(selectNodeContents(item))).toBe("- First bullet text");
|
||||
});
|
||||
|
||||
it("retains a bullet when a drag selects from the rendered marker through the item text", () => {
|
||||
const message = mountFixture();
|
||||
const marker = fixtureElement(message, '[data-paseo-markdown-list-marker="true"]');
|
||||
const itemText = fixtureElement(message, '[data-paseo-markdown-tag="li"] div span');
|
||||
|
||||
const content = createAssistantSelectionClipboardContent(
|
||||
selectRange(marker, 0, itemText, textNode(itemText).length),
|
||||
);
|
||||
expect(content?.plainText).toBe("- First bullet text");
|
||||
expect(content?.html).toContain("<div>- First bullet text</div>");
|
||||
expect(content?.html).not.toContain("<ul>");
|
||||
expect(content?.html).not.toContain("<li>");
|
||||
});
|
||||
|
||||
it("retains a bullet when a drag includes the marker and part of the item text", () => {
|
||||
const message = mountFixture();
|
||||
const marker = fixtureElement(message, '[data-paseo-markdown-list-marker="true"]');
|
||||
const itemText = fixtureElement(message, '[data-paseo-markdown-tag="li"] div span');
|
||||
|
||||
expect(copiedMarkdown(selectRange(marker, 0, itemText, 5))).toBe("- First");
|
||||
});
|
||||
|
||||
it("does not invent a bullet when a drag starts after the rendered marker", () => {
|
||||
const message = mountFixture();
|
||||
const marker = fixtureElement(message, '[data-paseo-markdown-list-marker="true"]');
|
||||
const itemText = fixtureElement(message, '[data-paseo-markdown-tag="li"] div span');
|
||||
|
||||
expect(
|
||||
copiedMarkdown(
|
||||
selectRange(marker, textNode(marker).length, itemText, textNode(itemText).length),
|
||||
),
|
||||
).toBe("First bullet text");
|
||||
});
|
||||
|
||||
it("retains every selected marker across a partial multi-item drag", () => {
|
||||
const message = mountFixture();
|
||||
const markerSelector = '[data-paseo-markdown-list-marker="true"]';
|
||||
const textSelector = '[data-paseo-markdown-tag="li"] div span';
|
||||
const firstMarker = fixtureElement(message, markerSelector);
|
||||
const secondText = fixtureElement(message, textSelector, 1);
|
||||
|
||||
expect(copiedMarkdown(selectRange(firstMarker, 0, secondText, 6))).toBe(
|
||||
"- First bullet text\n- Second",
|
||||
);
|
||||
});
|
||||
|
||||
it("retains the original number when a marker drag starts mid-list", () => {
|
||||
const message = mountFixture();
|
||||
const list = fixtureElement(message, '[data-paseo-markdown-tag="ul"]');
|
||||
list.setAttribute("data-paseo-markdown-tag", "ol");
|
||||
list.setAttribute("data-paseo-markdown-list-start", "5");
|
||||
const markers = list.querySelectorAll<HTMLElement>('[data-paseo-markdown-list-marker="true"]');
|
||||
markers.item(0).textContent = "5.";
|
||||
markers.item(1).textContent = "6.";
|
||||
const secondMarker = fixtureElement(list, '[data-paseo-markdown-list-marker="true"]', 1);
|
||||
const secondText = fixtureElement(list, '[data-paseo-markdown-tag="li"] div span', 1);
|
||||
|
||||
expect(
|
||||
copiedMarkdown(selectRange(secondMarker, 0, secondText, textNode(secondText).length)),
|
||||
).toBe("6. Second bullet text");
|
||||
});
|
||||
|
||||
it("retains nested markers when a drag includes the complete outer item", () => {
|
||||
const message = mountFixture();
|
||||
const firstItem = fixtureElement(message, '[data-paseo-markdown-tag="li"]');
|
||||
firstItem.innerHTML = [
|
||||
'<span data-paseo-markdown-ignore="true" data-paseo-markdown-list-marker="true">•</span>',
|
||||
"<div>",
|
||||
"<span>Outer text</span>",
|
||||
'<div data-paseo-markdown-tag="ul">',
|
||||
'<div data-paseo-markdown-tag="li">',
|
||||
'<span data-paseo-markdown-ignore="true" data-paseo-markdown-list-marker="true">•</span>',
|
||||
"<div><span>Inner text</span></div>",
|
||||
"</div>",
|
||||
"</div>",
|
||||
"</div>",
|
||||
].join("");
|
||||
const outerMarker = fixtureElement(
|
||||
firstItem,
|
||||
':scope > [data-paseo-markdown-list-marker="true"]',
|
||||
);
|
||||
const innerText = fixtureElement(
|
||||
firstItem,
|
||||
':scope [data-paseo-markdown-tag="ul"] > [data-paseo-markdown-tag="li"] > div > span',
|
||||
);
|
||||
|
||||
expect(copiedMarkdown(selectRange(outerMarker, 0, innerText, textNode(innerText).length))).toBe(
|
||||
"- Outer text\n - Inner 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"]');
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
MARKDOWN_COPY_ALIGN_ATTRIBUTE,
|
||||
MARKDOWN_COPY_IGNORE_ATTRIBUTE,
|
||||
MARKDOWN_COPY_LANGUAGE_ATTRIBUTE,
|
||||
MARKDOWN_COPY_LIST_MARKER_ATTRIBUTE,
|
||||
MARKDOWN_COPY_LIST_START_ATTRIBUTE,
|
||||
MARKDOWN_COPY_TAG_ATTRIBUTE,
|
||||
MARKDOWN_COPY_UNWRAP_ATTRIBUTE,
|
||||
@@ -278,7 +279,7 @@ function shouldPreserveSemanticElement(range: Range, element: Element): boolean
|
||||
if (tag === "p" || isTableStructure(tag)) {
|
||||
return true;
|
||||
}
|
||||
const isSelectableSemantic = tag !== null && tag !== "ol" && tag !== "ul";
|
||||
const isSelectableSemantic = tag !== null && tag !== "li" && tag !== "ol" && tag !== "ul";
|
||||
if (
|
||||
(isSelectableSemantic || element.tagName === "A") &&
|
||||
selectionStaysInsideElement(range, element)
|
||||
@@ -287,7 +288,12 @@ function shouldPreserveSemanticElement(range: Range, element: Element): boolean
|
||||
}
|
||||
if (tag === "ol" || tag === "ul") {
|
||||
const selectedItems = selectedListItems(element, range);
|
||||
return selectedItems.some((item) => hasSelectedAllContents(range, item, true));
|
||||
return selectedItems.some(
|
||||
(item) => hasSelectedListMarker(range, item) || hasSelectedAllContents(range, item, true),
|
||||
);
|
||||
}
|
||||
if (tag === "li" && hasSelectedListMarker(range, element)) {
|
||||
return true;
|
||||
}
|
||||
return hasSelectedAllContents(range, element, tag === "li");
|
||||
}
|
||||
@@ -311,6 +317,31 @@ function selectedListItems(list: Element, range: Range): Element[] {
|
||||
);
|
||||
}
|
||||
|
||||
function hasSelectedListMarker(range: Range, item: Element): boolean {
|
||||
const marker = item.querySelector(`:scope > [${MARKDOWN_COPY_LIST_MARKER_ATTRIBUTE}]`);
|
||||
if (!marker) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const markerContents = document.createRange();
|
||||
markerContents.selectNodeContents(marker);
|
||||
const selectionEndsBeforeMarker =
|
||||
range.compareBoundaryPoints(Range.END_TO_START, markerContents) >= 0;
|
||||
const selectionStartsAfterMarker =
|
||||
range.compareBoundaryPoints(Range.START_TO_END, markerContents) <= 0;
|
||||
if (selectionEndsBeforeMarker || selectionStartsAfterMarker) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (range.compareBoundaryPoints(Range.START_TO_START, markerContents) > 0) {
|
||||
markerContents.setStart(range.startContainer, range.startOffset);
|
||||
}
|
||||
if (range.compareBoundaryPoints(Range.END_TO_END, markerContents) < 0) {
|
||||
markerContents.setEnd(range.endContainer, range.endOffset);
|
||||
}
|
||||
return hasMarkdownContent(markerContents.cloneContents(), true);
|
||||
}
|
||||
|
||||
function normalizeOrderedListStart(copy: Element, original: Element, range: Range): void {
|
||||
if (original.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE) !== "ol") {
|
||||
return;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export const MARKDOWN_COPY_TAG_ATTRIBUTE = "data-paseo-markdown-tag";
|
||||
export const MARKDOWN_COPY_IGNORE_ATTRIBUTE = "data-paseo-markdown-ignore";
|
||||
export const MARKDOWN_COPY_LIST_MARKER_ATTRIBUTE = "data-paseo-markdown-list-marker";
|
||||
export const MARKDOWN_COPY_UNWRAP_ATTRIBUTE = "data-paseo-markdown-unwrap";
|
||||
export const MARKDOWN_COPY_LIST_START_ATTRIBUTE = "data-paseo-markdown-list-start";
|
||||
export const MARKDOWN_COPY_LANGUAGE_ATTRIBUTE = "data-paseo-markdown-language";
|
||||
@@ -28,6 +29,7 @@ export const markdownCopyDataSet = {
|
||||
hr: { paseoMarkdownTag: "hr" },
|
||||
ignore: { paseoMarkdownIgnore: "true" },
|
||||
li: { paseoMarkdownTag: "li" },
|
||||
listMarker: { paseoMarkdownIgnore: "true", paseoMarkdownListMarker: "true" },
|
||||
ol: { paseoMarkdownTag: "ol" },
|
||||
p: { paseoMarkdownTag: "p" },
|
||||
pre: { paseoMarkdownTag: "pre" },
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from "react-native";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, ChevronRight, Folder } from "lucide-react-native";
|
||||
import { Check, ChevronRight, Folder, X } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
type BottomSheetFlatListMethods,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { AgentStatusDot } from "@/components/agent-status-dot";
|
||||
import { MaterialFileIcon } from "@/components/material-file-icon";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import {
|
||||
IsolatedBottomSheetModal,
|
||||
@@ -37,7 +39,10 @@ import {
|
||||
useWebOverlayRegistration,
|
||||
} from "@/lib/overlay-root";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import {
|
||||
useKeyboardShortcutsStore,
|
||||
type CommandCenterScope,
|
||||
} from "@/stores/keyboard-shortcuts-store";
|
||||
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import {
|
||||
@@ -56,11 +61,13 @@ import {
|
||||
preserveActiveResultId,
|
||||
projectCommandCenterRows,
|
||||
type CommandCenterAgentResult,
|
||||
type CommandCenterFileResult,
|
||||
type CommandCenterListRow,
|
||||
type CommandCenterResult,
|
||||
type CommandCenterResultSection,
|
||||
type CommandCenterWorkspaceResult,
|
||||
} from "./results";
|
||||
import { useWorkspaceFileSearch } from "./workspace-file-search";
|
||||
|
||||
const ThemedBottomSheetTextInput = withUnistyles(BottomSheetTextInput, (theme) => ({
|
||||
placeholderTextColor: theme.colors.foregroundMuted,
|
||||
@@ -73,6 +80,10 @@ const ThemedCheck = withUnistyles(Check, (theme) => ({ color: theme.colors.foreg
|
||||
const ThemedChevronRight = withUnistyles(ChevronRight, (theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
}));
|
||||
const ThemedX = withUnistyles(X, (theme) => ({ color: theme.colors.foregroundMuted }));
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner, (theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
}));
|
||||
const COMMAND_CENTER_SNAP_POINTS = ["60%", "90%"];
|
||||
const KEYBOARD_SHOULD_PERSIST_TAPS = "always" as const;
|
||||
const DEFAULT_CATEGORY_RESULT_LIMIT = 5;
|
||||
@@ -191,6 +202,8 @@ function useBuiltInSections(open: boolean, query: string): CommandCenterResultSe
|
||||
|
||||
interface CommandCenterState {
|
||||
open: boolean;
|
||||
scope: CommandCenterScope;
|
||||
clearScope(): void;
|
||||
query: string;
|
||||
setQuery(query: string): void;
|
||||
activeId: string | null;
|
||||
@@ -199,27 +212,58 @@ interface CommandCenterState {
|
||||
rowIndexByResultId: ReadonlyMap<string, number>;
|
||||
offsets: readonly number[];
|
||||
inputRef: React.RefObject<TextInput | null>;
|
||||
fileSearchLoading: boolean;
|
||||
fileSearchError: string | null;
|
||||
close(): void;
|
||||
select(result: CommandCenterResult): void;
|
||||
key(key: string): boolean;
|
||||
}
|
||||
|
||||
function useCommandCenterState(): CommandCenterState {
|
||||
const { t } = useTranslation();
|
||||
const open = useKeyboardShortcutsStore((state) => state.commandCenterOpen);
|
||||
const scope = useKeyboardShortcutsStore((state) => state.commandCenterScope);
|
||||
const setOpen = useKeyboardShortcutsStore((state) => state.setCommandCenterOpen);
|
||||
const setScope = useKeyboardShortcutsStore((state) => state.setCommandCenterScope);
|
||||
const snapshot = useCommandCenterContributions();
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const previousOpenRef = useRef(open);
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const builtInSections = useBuiltInSections(open, query);
|
||||
const {
|
||||
entries: fileSearchEntries,
|
||||
loading: fileSearchLoading,
|
||||
error: fileSearchError,
|
||||
openFile,
|
||||
} = useWorkspaceFileSearch({
|
||||
enabled: open && (scope === "files" || Boolean(query.trim())),
|
||||
query,
|
||||
});
|
||||
const fileSections = useMemo<CommandCenterResultSection[]>(() => {
|
||||
const results = fileSearchEntries.map<CommandCenterFileResult>((entry) => ({
|
||||
kind: "file",
|
||||
id: `file:${entry.path}`,
|
||||
filePath: entry.path,
|
||||
title: entry.name,
|
||||
subtitle: entry.directory,
|
||||
searchText: entry.path.toLowerCase(),
|
||||
run: () => openFile(entry.path),
|
||||
}));
|
||||
return [{ id: "files", rank: 4, title: t("shell.commandCenter.files"), results }];
|
||||
}, [fileSearchEntries, openFile, t]);
|
||||
const contributionSections = useMemo(
|
||||
() => buildContributionSections(snapshot.contributions, query),
|
||||
[query, snapshot.contributions],
|
||||
);
|
||||
const projection = useMemo(
|
||||
() => projectCommandCenterRows([...contributionSections, ...builtInSections]),
|
||||
[builtInSections, contributionSections],
|
||||
() =>
|
||||
projectCommandCenterRows(
|
||||
scope === "files"
|
||||
? fileSections
|
||||
: [...contributionSections, ...fileSections, ...builtInSections],
|
||||
),
|
||||
[builtInSections, contributionSections, fileSections, scope],
|
||||
);
|
||||
const resolvedActiveId = preserveActiveResultId(activeId, projection.selectableResults);
|
||||
|
||||
@@ -239,6 +283,10 @@ function useCommandCenterState(): CommandCenterState {
|
||||
close();
|
||||
return true;
|
||||
}
|
||||
if (pressed === "Backspace" && !query.trim() && scope) {
|
||||
setScope(null);
|
||||
return true;
|
||||
}
|
||||
if (pressed === "Enter") {
|
||||
const selected = results.find((result) => result.id === resolvedActiveId);
|
||||
if (!selected) return false;
|
||||
@@ -251,7 +299,7 @@ function useCommandCenterState(): CommandCenterState {
|
||||
setActiveId(moveActiveResultId(resolvedActiveId, results, direction));
|
||||
return true;
|
||||
},
|
||||
[close, open, projection.selectableResults, resolvedActiveId, select],
|
||||
[close, open, projection.selectableResults, query, resolvedActiveId, scope, select, setScope],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -277,6 +325,8 @@ function useCommandCenterState(): CommandCenterState {
|
||||
|
||||
return {
|
||||
open,
|
||||
scope,
|
||||
clearScope: () => setScope(null),
|
||||
query,
|
||||
setQuery,
|
||||
activeId: resolvedActiveId,
|
||||
@@ -285,6 +335,8 @@ function useCommandCenterState(): CommandCenterState {
|
||||
rowIndexByResultId: projection.rowIndexByResultId,
|
||||
offsets: projection.offsets,
|
||||
inputRef,
|
||||
fileSearchLoading,
|
||||
fileSearchError,
|
||||
close,
|
||||
select,
|
||||
key,
|
||||
@@ -303,7 +355,10 @@ const ResultRow = memo(function ResultRow({ result, active, onSelect }: ResultRo
|
||||
result.kind === "contribution" && result.contribution.presentation.kind === "choice"
|
||||
? result.contribution.presentation
|
||||
: null;
|
||||
const accessibilityLabel = choice?.path.join(" › ");
|
||||
const accessibilityLabel =
|
||||
result.kind === "file"
|
||||
? [result.title, result.subtitle].filter(Boolean).join(" ")
|
||||
: choice?.path.join(" › ");
|
||||
const accessibilityState = useMemo(
|
||||
() => (isNative && choice ? { selected: choice.selected } : undefined),
|
||||
[choice],
|
||||
@@ -329,7 +384,9 @@ const ResultRow = memo(function ResultRow({ result, active, onSelect }: ResultRo
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
accessibilityState={accessibilityState}
|
||||
aria-pressed={isWeb ? choice?.selected : undefined}
|
||||
testID={choice?.testId}
|
||||
testID={
|
||||
result.kind === "file" ? `command-center-file-row-${result.filePath}` : choice?.testId
|
||||
}
|
||||
>
|
||||
<ResultContent result={result} />
|
||||
</Pressable>
|
||||
@@ -337,6 +394,28 @@ const ResultRow = memo(function ResultRow({ result, active, onSelect }: ResultRo
|
||||
});
|
||||
|
||||
function ResultContent({ result }: { result: CommandCenterResult }) {
|
||||
if (result.kind === "file") {
|
||||
return (
|
||||
<View style={styles.rowContent}>
|
||||
<View style={styles.rowMain}>
|
||||
<View style={styles.iconSlot} testID="command-center-file-icon">
|
||||
<MaterialFileIcon fileName={result.title} size={16} />
|
||||
</View>
|
||||
<Text style={styles.fileLine} numberOfLines={1} testID="command-center-file-line">
|
||||
<Text style={styles.fileName} testID="command-center-file-name">
|
||||
{result.title}
|
||||
</Text>
|
||||
{result.subtitle ? (
|
||||
<Text style={styles.filePath} testID="command-center-file-path">
|
||||
{" "}
|
||||
{result.subtitle}
|
||||
</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (result.kind === "agent") {
|
||||
const agent = result.agent;
|
||||
return (
|
||||
@@ -545,8 +624,24 @@ export function CommandCenter() {
|
||||
);
|
||||
const keyExtractor = useCallback((row: CommandCenterListRow) => row.key, []);
|
||||
const empty = useMemo(
|
||||
() => <Text style={styles.emptyText}>{t("shell.commandCenter.noMatches")}</Text>,
|
||||
[t],
|
||||
() =>
|
||||
state.fileSearchError || state.fileSearchLoading ? null : (
|
||||
<Text style={styles.emptyText}>{t("shell.commandCenter.noMatches")}</Text>
|
||||
),
|
||||
[state.fileSearchError, state.fileSearchLoading, t],
|
||||
);
|
||||
const fileSearchError = useMemo(
|
||||
() =>
|
||||
state.fileSearchError ? (
|
||||
<Text
|
||||
accessibilityLiveRegion="polite"
|
||||
style={styles.errorText}
|
||||
testID="command-center-file-search-error"
|
||||
>
|
||||
{t("common.errors.error")}: {state.fileSearchError}
|
||||
</Text>
|
||||
) : null,
|
||||
[state.fileSearchError, t],
|
||||
);
|
||||
const commonListProps = {
|
||||
data: state.rows,
|
||||
@@ -554,6 +649,8 @@ export function CommandCenter() {
|
||||
keyExtractor,
|
||||
getItemLayout,
|
||||
ListEmptyComponent: empty,
|
||||
style: styles.results,
|
||||
testID: "command-center-results",
|
||||
keyboardShouldPersistTaps: KEYBOARD_SHOULD_PERSIST_TAPS,
|
||||
showsVerticalScrollIndicator: false,
|
||||
initialNumToRender: 12,
|
||||
@@ -606,7 +703,10 @@ export function CommandCenter() {
|
||||
keyboardBlurBehavior="restore"
|
||||
accessible={false}
|
||||
>
|
||||
<View style={styles.bottomSheetHeader}>
|
||||
<View style={[styles.bottomSheetHeader, styles.searchRow]} testID="command-center-header">
|
||||
{state.scope === "files" ? (
|
||||
<ScopeChip label={t("shell.commandCenter.files")} onRemove={state.clearScope} />
|
||||
) : null}
|
||||
<ThemedBottomSheetTextInput
|
||||
testID="command-center-input"
|
||||
ref={bottomSheetInputRef}
|
||||
@@ -614,13 +714,22 @@ export function CommandCenter() {
|
||||
onChangeText={state.setQuery}
|
||||
onKeyPress={keyPress}
|
||||
onSubmitEditing={submit}
|
||||
placeholder={t("shell.commandCenter.placeholder")}
|
||||
style={styles.input}
|
||||
placeholder={
|
||||
state.scope === "files"
|
||||
? t("shell.commandCenter.filePlaceholder")
|
||||
: t("shell.commandCenter.placeholder")
|
||||
}
|
||||
style={[styles.input, styles.growingInput]}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
/>
|
||||
<FileSearchLoadingIndicator
|
||||
loading={state.fileSearchLoading}
|
||||
label={t("shell.commandCenter.searchingFiles")}
|
||||
/>
|
||||
</View>
|
||||
{fileSearchError}
|
||||
<BottomSheetFlatList ref={bottomSheetListRef} {...commonListProps} />
|
||||
</IsolatedBottomSheetModal>
|
||||
);
|
||||
@@ -632,20 +741,32 @@ export function CommandCenter() {
|
||||
<View style={styles.overlay}>
|
||||
<Pressable style={styles.backdrop} onPress={state.close} />
|
||||
<View ref={setWebOverlayScope} testID="command-center-panel" style={styles.panel}>
|
||||
<View style={styles.header}>
|
||||
<View style={[styles.header, styles.searchRow]} testID="command-center-header">
|
||||
{state.scope === "files" ? (
|
||||
<ScopeChip label={t("shell.commandCenter.files")} onRemove={state.clearScope} />
|
||||
) : null}
|
||||
<ThemedTextInput
|
||||
testID="command-center-input"
|
||||
ref={state.inputRef}
|
||||
value={state.query}
|
||||
onChangeText={state.setQuery}
|
||||
placeholder={t("shell.commandCenter.placeholder")}
|
||||
style={styles.input}
|
||||
placeholder={
|
||||
state.scope === "files"
|
||||
? t("shell.commandCenter.filePlaceholder")
|
||||
: t("shell.commandCenter.placeholder")
|
||||
}
|
||||
style={[styles.input, styles.growingInput]}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
/>
|
||||
<FileSearchLoadingIndicator
|
||||
loading={state.fileSearchLoading}
|
||||
label={t("shell.commandCenter.searchingFiles")}
|
||||
/>
|
||||
</View>
|
||||
<FlatList ref={listRef} style={styles.results} {...commonListProps} />
|
||||
{fileSearchError}
|
||||
<FlatList ref={listRef} {...commonListProps} />
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
@@ -653,6 +774,41 @@ export function CommandCenter() {
|
||||
);
|
||||
}
|
||||
|
||||
function FileSearchLoadingIndicator({ loading, label }: { loading: boolean; label: string }) {
|
||||
return (
|
||||
<View style={styles.fileSearchStatus} testID="command-center-file-search-status">
|
||||
{loading ? (
|
||||
<View
|
||||
accessibilityLabel={label}
|
||||
accessibilityRole="progressbar"
|
||||
testID="command-center-file-search-loading"
|
||||
>
|
||||
<ThemedLoadingSpinner size={14} />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// The chip sits at the input's own height and type size so it reads as the scope of the field
|
||||
// rather than a badge dropped into the query. Its 28px matches the input's line box, so showing or
|
||||
// dropping the scope cannot resize the header.
|
||||
function ScopeChip({ label, onRemove }: { label: string; onRemove(): void }) {
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={label}
|
||||
onPress={onRemove}
|
||||
style={styles.scopeChip}
|
||||
testID="command-center-files-scope"
|
||||
>
|
||||
<ThemedFolder size={14} strokeWidth={2.2} />
|
||||
<Text style={styles.scopeChipLabel}>{label}</Text>
|
||||
<ThemedX size={12} strokeWidth={2.2} />
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
@@ -663,6 +819,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
backdrop: { ...StyleSheet.absoluteFillObject, backgroundColor: "rgba(0, 0, 0, 0.5)" },
|
||||
panel: {
|
||||
width: 640,
|
||||
height: 560,
|
||||
maxWidth: "92%",
|
||||
maxHeight: "80%",
|
||||
borderWidth: 1,
|
||||
@@ -690,7 +847,29 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
outlineWidth: 0,
|
||||
},
|
||||
results: { flexGrow: 0 },
|
||||
growingInput: { flex: 1, minWidth: 0 },
|
||||
searchRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] },
|
||||
fileSearchStatus: { width: 14, height: 14, alignItems: "center", justifyContent: "center" },
|
||||
scopeChip: {
|
||||
// Stretching to the row rather than setting a height keeps the chip exactly as tall as the
|
||||
// input's line box, so showing or dropping the scope can never resize the header.
|
||||
alignSelf: "stretch",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1.5],
|
||||
marginRight: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
scopeChipLabel: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: "500",
|
||||
},
|
||||
results: { flex: 1 },
|
||||
sectionLabel: {
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingBottom: theme.spacing[2],
|
||||
@@ -726,6 +905,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
lineHeight: 18,
|
||||
flexShrink: 1,
|
||||
},
|
||||
fileLine: { flex: 1, minWidth: 0, fontSize: theme.fontSize.sm, lineHeight: 20 },
|
||||
fileName: { color: theme.colors.foreground },
|
||||
filePath: { color: theme.colors.foregroundMuted },
|
||||
subtitle: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, lineHeight: 16 },
|
||||
iconSlot: { width: 16, height: 20, alignItems: "center", justifyContent: "center" },
|
||||
rowShortcut: { flexShrink: 0 },
|
||||
@@ -755,6 +937,14 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
errorText: {
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[3],
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
color: theme.colors.statusDanger,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
sheetBackground: { backgroundColor: theme.colors.surface0 },
|
||||
sheetHandle: { backgroundColor: theme.colors.palette.zinc[600] },
|
||||
titledSection: { height: 32, justifyContent: "flex-end" },
|
||||
|
||||
@@ -20,6 +20,16 @@ export interface CommandCenterAgentResult {
|
||||
run(): void;
|
||||
}
|
||||
|
||||
export interface CommandCenterFileResult {
|
||||
kind: "file";
|
||||
id: string;
|
||||
filePath: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
searchText: string;
|
||||
run(): void;
|
||||
}
|
||||
|
||||
export interface CommandCenterContributionResult {
|
||||
kind: "contribution";
|
||||
id: string;
|
||||
@@ -31,6 +41,7 @@ export interface CommandCenterContributionResult {
|
||||
export type CommandCenterResult =
|
||||
| CommandCenterWorkspaceResult
|
||||
| CommandCenterAgentResult
|
||||
| CommandCenterFileResult
|
||||
| CommandCenterContributionResult;
|
||||
|
||||
export interface CommandCenterResultSection {
|
||||
@@ -78,6 +89,7 @@ function contributionSearchText(contribution: CommandCenterContribution): string
|
||||
|
||||
function resultHeight(result: CommandCenterResult): number {
|
||||
if (result.kind === "workspace" || result.kind === "agent") return 56;
|
||||
if (result.kind === "file") return 36;
|
||||
if (result.contribution.presentation.kind === "action") {
|
||||
return result.contribution.presentation.subtitle ? 56 : 36;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user