mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 20:22:45 +00:00
Compare commits
117
Commits
next
...
v0.7.0-beta.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e60c2880b | ||
|
|
510074f555 | ||
|
|
ed628ff82e | ||
|
|
667664847d | ||
|
|
7ea4288117 | ||
|
|
42b6d99d60 | ||
|
|
23debf3af1 | ||
|
|
25eb322ef7 | ||
|
|
deb846eed6 | ||
|
|
a1ed5a8bad | ||
|
|
3d69b0f458 | ||
|
|
51a435d3cf | ||
|
|
fb67d2cdc8 | ||
|
|
fe803f6e70 | ||
|
|
a501e1e051 | ||
|
|
1fc5250544 | ||
|
|
1860a6f3af | ||
|
|
b6641d7066 | ||
|
|
5c7d62df8c | ||
|
|
96cb70f5ce | ||
|
|
a8734a9724 | ||
|
|
cda441d3b2 | ||
|
|
2d7896999c | ||
|
|
12e3acd188 | ||
|
|
8e0af11b66 | ||
|
|
3377997f30 | ||
|
|
7ae5133ed2 | ||
|
|
b6d18d0c01 | ||
|
|
4e52125f5b | ||
|
|
f82886f284 | ||
|
|
b0ce40804d | ||
|
|
2755596b9f | ||
|
|
657152c47a | ||
|
|
4c46f20179 | ||
|
|
da930b4c7d | ||
|
|
ecfb77b014 | ||
|
|
4de913fb82 | ||
|
|
0ad69d3eb0 | ||
|
|
46b401eac6 | ||
|
|
96cf0f2ae5 | ||
|
|
59e6232784 | ||
|
|
d46267aed6 | ||
|
|
5a38a33345 | ||
|
|
5c9a04d333 | ||
|
|
d3b10f0299 | ||
|
|
0f68e55eca | ||
|
|
579c6a58f5 | ||
|
|
f59db03d67 | ||
|
|
fd22784e21 | ||
|
|
f6288629ff | ||
|
|
4a5716360b | ||
|
|
e22ad117fd | ||
|
|
e19d3914ae | ||
|
|
996d4ec526 | ||
|
|
20d7efc46a | ||
|
|
59aa8433d4 | ||
|
|
4bfb682cd9 | ||
|
|
c4dc701411 | ||
|
|
f73f65cd78 | ||
|
|
bffb33ed32 | ||
|
|
8bfae15855 | ||
|
|
c778c47b30 | ||
|
|
6da9fa43fa | ||
|
|
cd8c868b32 | ||
|
|
6f6466d4cb | ||
|
|
6147991519 | ||
|
|
bbe3f17327 | ||
|
|
c914bcb44b | ||
|
|
54299a2e6c | ||
|
|
f4b5f32862 | ||
|
|
7db654aa0a | ||
|
|
4092c29265 | ||
|
|
5cedc210a8 | ||
|
|
4724d1d9b7 | ||
|
|
8fdca94ead | ||
|
|
88f410567d | ||
|
|
f998ee1691 | ||
|
|
7a47ec0ee9 | ||
|
|
4f796181bf | ||
|
|
f517493591 | ||
|
|
fb74f766c8 | ||
|
|
e268d1a894 | ||
|
|
b5f5832214 | ||
|
|
b8a31034ca | ||
|
|
68275f1e0f | ||
|
|
3979371901 | ||
|
|
71e375c2bc | ||
|
|
35cde075ae | ||
|
|
2124115c8e | ||
|
|
4094eeb566 | ||
|
|
8d92e1cc17 | ||
|
|
5123b23f41 | ||
|
|
c3594b2079 | ||
|
|
dc9fb9aa1b | ||
|
|
7c430777bf | ||
|
|
acad9f6881 | ||
|
|
fc214b20f1 | ||
|
|
c998b24c5f | ||
|
|
28df3f259a | ||
|
|
cc2012be7c | ||
|
|
b139046a3e | ||
|
|
482bbb2910 | ||
|
|
4a5a8d469c | ||
|
|
c60fa098ac | ||
|
|
33cf6cf337 | ||
|
|
65fbe38dd3 | ||
|
|
a69dca2907 | ||
|
|
3029b5991a | ||
|
|
b1455fb4b3 | ||
|
|
b4e0ff7685 | ||
|
|
4c6b79dc44 | ||
|
|
a687c42f33 | ||
|
|
7beff9cb0b | ||
|
|
c763b80824 | ||
|
|
8905ad4160 | ||
|
|
60a5344cc6 | ||
|
|
8f0c35e924 |
@@ -348,12 +348,12 @@ jobs:
|
|||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
run: npx playwright install chromium
|
run: npx playwright install chromium
|
||||||
|
|
||||||
- name: Build app dependencies
|
|
||||||
run: npm run build:app-deps
|
|
||||||
|
|
||||||
- name: Build server stack
|
- name: Build server stack
|
||||||
run: npm run build:server
|
run: npm run build:server
|
||||||
|
|
||||||
|
- name: Build native app dependency
|
||||||
|
run: npm run build --workspace=@getpaseo/expo-two-way-audio
|
||||||
|
|
||||||
- name: Install agent CLIs for provider tests
|
- name: Install agent CLIs for provider tests
|
||||||
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
||||||
|
|
||||||
|
|||||||
+102
@@ -1,5 +1,107 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.7.0-beta.2 - 2026-08-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added SSH connections to existing remote daemons from Desktop and CLI ([#3989](https://github.com/getpaseo/paseo/pull/3989) by [@reidlevesque](https://github.com/reidlevesque))
|
||||||
|
- Added workspace management and layout actions to the Command Center ([#3013](https://github.com/getpaseo/paseo/pull/3013) by [@cleiter](https://github.com/cleiter))
|
||||||
|
- Added contextual composer-pill contributions for plugins ([#3956](https://github.com/getpaseo/paseo/pull/3956))
|
||||||
|
- Added host UI primitives for client plugins ([#3967](https://github.com/getpaseo/paseo/pull/3967))
|
||||||
|
- Added PR and MR number search to the Command Center ([#3008](https://github.com/getpaseo/paseo/pull/3008) by [@cleiter](https://github.com/cleiter))
|
||||||
|
- Added session commands to agent SDK handles ([#3719](https://github.com/getpaseo/paseo/pull/3719) by [@gpambrozio](https://github.com/gpambrozio), [@marvin-ambrozio](https://github.com/marvin-ambrozio))
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
|
||||||
|
- Batched GitHub pull request polling within the reserved API budget ([#3825](https://github.com/getpaseo/paseo/pull/3825) by [@dezchai](https://github.com/dezchai))
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed daemon reconnects stalling while Git processes were queued ([#3945](https://github.com/getpaseo/paseo/pull/3945))
|
||||||
|
- Fixed restored workspace tabs entering reconciliation loops ([#3987](https://github.com/getpaseo/paseo/pull/3987))
|
||||||
|
- Fixed immediate dictation submissions omitting the newest speech segment ([#3968](https://github.com/getpaseo/paseo/pull/3968))
|
||||||
|
- Fixed archived workspaces returning from durable cache ([#3975](https://github.com/getpaseo/paseo/pull/3975))
|
||||||
|
- Fixed commits and squash merges bypassing configured Git signing ([#3976](https://github.com/getpaseo/paseo/pull/3976))
|
||||||
|
- Fixed Markdown literal characters being replaced by typographic symbols ([#3253](https://github.com/getpaseo/paseo/pull/3253) by [@cleiter](https://github.com/cleiter))
|
||||||
|
|
||||||
|
## 0.7.0-beta.1 - 2026-08-27
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Changed the project license to Apache-2.0 ([#3944](https://github.com/getpaseo/paseo/pull/3944))
|
||||||
|
- Changed Cmd/Ctrl+E to toggle Explorer visibility without selecting a view ([#3896](https://github.com/getpaseo/paseo/pull/3896))
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added plugin installation and updates directly from Git repositories ([#3920](https://github.com/getpaseo/paseo/pull/3920))
|
||||||
|
- Added plugin-defined timeline transformations and rendering ([#3940](https://github.com/getpaseo/paseo/pull/3940))
|
||||||
|
- Added opening child folders directly in the preferred desktop editor ([#3615](https://github.com/getpaseo/paseo/pull/3615) by [@caikovsky](https://github.com/caikovsky))
|
||||||
|
- Added host-rendered icons to plugin client surfaces ([#3903](https://github.com/getpaseo/paseo/pull/3903) by [@omercnet](https://github.com/omercnet))
|
||||||
|
- Added semantic surface, border, success, and warning colors to plugin themes ([#3898](https://github.com/getpaseo/paseo/pull/3898) by [@omercnet](https://github.com/omercnet))
|
||||||
|
- Added registered project listing to the public plugin SDK ([#3899](https://github.com/getpaseo/paseo/pull/3899) by [@omercnet](https://github.com/omercnet))
|
||||||
|
- Added F-Droid release metadata and stable changelog generation ([#2501](https://github.com/getpaseo/paseo/pull/2501) by [@antonok-edm](https://github.com/antonok-edm))
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
|
||||||
|
- Streamed assistant text at a steady visual rate instead of in arrival lumps ([#3612](https://github.com/getpaseo/paseo/pull/3612) by [@Tommypop2](https://github.com/Tommypop2))
|
||||||
|
- Painted cached conversations before unrelated cache restoration and network loading ([#3907](https://github.com/getpaseo/paseo/pull/3907))
|
||||||
|
- Added manual, action-required, and warning states to GitLab and Gitea checks ([#2337](https://github.com/getpaseo/paseo/pull/2337) by [@nllptrx](https://github.com/nllptrx))
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed rewinding long conversations replaying the complete rebuilt timeline ([#3642](https://github.com/getpaseo/paseo/pull/3642))
|
||||||
|
- Fixed rewound prompts remaining blank in the mounted iOS composer ([#3946](https://github.com/getpaseo/paseo/pull/3946))
|
||||||
|
- Fixed native plugin setup and async client callbacks failing on iOS ([#3942](https://github.com/getpaseo/paseo/pull/3942))
|
||||||
|
- Fixed ACP agents reporting the terminal capability as unavailable ([#3910](https://github.com/getpaseo/paseo/pull/3910) by [@pmilanez](https://github.com/pmilanez))
|
||||||
|
- Fixed canceled Claude permission requests leaving stale permission cards ([#3792](https://github.com/getpaseo/paseo/pull/3792))
|
||||||
|
- Fixed plugin daemon sessions hiding agents from non-legacy providers ([#3902](https://github.com/getpaseo/paseo/pull/3902) by [@omercnet](https://github.com/omercnet))
|
||||||
|
- Fixed manually admitted desktop updates disappearing during readiness checks ([#3865](https://github.com/getpaseo/paseo/pull/3865))
|
||||||
|
- Fixed mobile Changes and pull-request actions opening outside Explorer ([#3867](https://github.com/getpaseo/paseo/pull/3867))
|
||||||
|
- Fixed older desktop builds deleting settings written by newer builds ([#3909](https://github.com/getpaseo/paseo/pull/3909) by [@cleiter](https://github.com/cleiter))
|
||||||
|
- Fixed composer height flashing after returning from Settings ([#3943](https://github.com/getpaseo/paseo/pull/3943))
|
||||||
|
- Fixed selected workspaces having no visible highlight in the Light theme ([#3922](https://github.com/getpaseo/paseo/pull/3922) by [@wdaubney](https://github.com/wdaubney))
|
||||||
|
- Fixed the scrolling diff order disagreeing with the Changes tree ([#3913](https://github.com/getpaseo/paseo/pull/3913) by [@cleiter](https://github.com/cleiter))
|
||||||
|
- Fixed incorrect Spanish translations for Close actions ([#3934](https://github.com/getpaseo/paseo/pull/3934) by [@antonio](https://github.com/antonio))
|
||||||
|
|
||||||
|
## 0.6.1 - 2026-08-25
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
|
||||||
|
- Command Center now matches partial multi-word queries in any order and ranks visible results by relevance ([#2971](https://github.com/getpaseo/paseo/pull/2971) by [@cleiter](https://github.com/cleiter))
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed upgrades placing persisted Side panel tabs inside Explorer instead of an ordinary side pane ([#3861](https://github.com/getpaseo/paseo/pull/3861))
|
||||||
|
- Fixed Escape leaving Settings open on desktop and web ([#2828](https://github.com/getpaseo/paseo/pull/2828) by [@mcowger](https://github.com/mcowger))
|
||||||
|
- Fixed branch switches hiding the current pull request or arming auto-archive from an already-merged pull request ([#3799](https://github.com/getpaseo/paseo/pull/3799))
|
||||||
|
|
||||||
|
## 0.6.0 - 2026-08-25
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Restored the dedicated Explorer sidebar with Files and Changes by default, replacing the empty user-directed Side panel introduced in 0.5.0 ([#3826](https://github.com/getpaseo/paseo/pull/3826))
|
||||||
|
- Changed content opened to the side to use an ordinary closable workspace pane instead of the Explorer sidebar ([#3826](https://github.com/getpaseo/paseo/pull/3826))
|
||||||
|
- Restored the resizable combined Explorer dock on tablets ([#3826](https://github.com/getpaseo/paseo/pull/3826))
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added per-action settings for opening files, changes, subagents, and pull requests in the main panel or on the side ([#3826](https://github.com/getpaseo/paseo/pull/3826))
|
||||||
|
- Added New tab and drag support for compatible files, diffs, agents, terminals, pull requests, and plugin panels in Explorer ([#3826](https://github.com/getpaseo/paseo/pull/3826))
|
||||||
|
- Added standalone Diff tabs while keeping an optional persisted inline-diff mode in Changes ([#3826](https://github.com/getpaseo/paseo/pull/3826))
|
||||||
|
- Added custom Windows and Linux window controls ([#3826](https://github.com/getpaseo/paseo/pull/3826))
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed OpenCode turns failing when the first event-stream connection stalls during startup ([#3814](https://github.com/getpaseo/paseo/pull/3814))
|
||||||
|
- Fixed OpenCode prompts starting before the provider reports a ready connection ([#3821](https://github.com/getpaseo/paseo/pull/3821))
|
||||||
|
|
||||||
|
## 0.5.2 - 2026-08-24
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed workspaces crashing on Android tablets and foldables in non-compact layouts ([#3760](https://github.com/getpaseo/paseo/pull/3760) by [@jeid64](https://github.com/jeid64))
|
||||||
|
- Fixed app settings resetting when builds sharing a profile recognize different settings ([#3787](https://github.com/getpaseo/paseo/pull/3787))
|
||||||
|
|
||||||
## 0.5.1 - 2026-08-23
|
## 0.5.1 - 2026-08-23
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
|||||||
| [docs/qa.md](docs/qa.md) | QA evidence bar for pull requests — platform matrix, version drift, performance, UI proof |
|
| [docs/qa.md](docs/qa.md) | QA evidence bar for pull requests — platform matrix, version drift, performance, UI proof |
|
||||||
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
|
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
|
||||||
| [docs/mobile-panels.md](docs/mobile-panels.md) | Compact left/center/right panel ownership, worklet motion, gesture revisions, and Fabric constraints |
|
| [docs/mobile-panels.md](docs/mobile-panels.md) | Compact left/center/right panel ownership, worklet motion, gesture revisions, and Fabric constraints |
|
||||||
| [docs/side-panel.md](docs/side-panel.md) | The Side panel surface — its three renderings, tab placement intents, pane lifecycle, and the routing preference |
|
| [docs/explorer-sidebar.md](docs/explorer-sidebar.md) | Explorer sidebar and ordinary side-pane host contracts, lifecycle, placement, and routing preferences |
|
||||||
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
|
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
|
||||||
| [docs/browser-capture-harness.md](docs/browser-capture-harness.md) | Real-Electron browser screenshot harness and compositor-surface gotcha |
|
| [docs/browser-capture-harness.md](docs/browser-capture-harness.md) | Real-Electron browser screenshot harness and compositor-surface gotcha |
|
||||||
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows, version codes, F-Droid source builds and store metadata |
|
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows, version codes, F-Droid source builds and store metadata |
|
||||||
|
|||||||
@@ -2,670 +2,210 @@ Copyright (c) 2025-present Mohamed Boudra
|
|||||||
|
|
||||||
Portions of this software are licensed as follows:
|
Portions of this software are licensed as follows:
|
||||||
|
|
||||||
* All third party components incorporated into the Paseo Software are
|
* Third-party components incorporated into Paseo remain licensed under
|
||||||
licensed under the original license provided by the owner of the
|
the licenses provided by their respective copyright holders.
|
||||||
applicable component.
|
* Except for those third-party components, Paseo is licensed under the
|
||||||
* All content outside of the above mentioned restrictions is available
|
Apache License, Version 2.0, reproduced below.
|
||||||
under the "AGPLv3" license as defined below.
|
|
||||||
|
|
||||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
Apache License
|
||||||
Version 3, 19 November 2007
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
|
||||||
Everyone is permitted to copy and distribute verbatim copies
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
of this license document, but changing it is not allowed.
|
|
||||||
|
1. Definitions.
|
||||||
Preamble
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
The GNU Affero General Public License is a free, copyleft license for
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
software and other kinds of works, specifically designed to ensure
|
|
||||||
cooperation with the community in the case of network server software.
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
The licenses for most software and other practical works are designed
|
|
||||||
to take away your freedom to share and change the works. By contrast,
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
our General Public Licenses are intended to guarantee your freedom to
|
other entities that control, are controlled by, or are under common
|
||||||
share and change all versions of a program--to make sure it remains free
|
control with that entity. For the purposes of this definition,
|
||||||
software for all its users.
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
When we speak of free software, we are referring to freedom, not
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
price. Our General Public Licenses are designed to make sure that you
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
have the freedom to distribute copies of free software (and charge for
|
|
||||||
them if you wish), that you receive source code or can get it if you
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
want it, that you can change the software or use pieces of it in new
|
exercising permissions granted by this License.
|
||||||
free programs, and that you know you can do these things.
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
Developers that use our General Public Licenses protect your rights
|
including but not limited to software source code, documentation
|
||||||
with two steps: (1) assert copyright on the software, and (2) offer
|
source, and configuration files.
|
||||||
you this License which gives you legal permission to copy, distribute
|
|
||||||
and/or modify the software.
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
A secondary benefit of defending all users' freedom is that
|
not limited to compiled object code, generated documentation,
|
||||||
improvements made in alternate versions of the program, if they
|
and conversions to other media types.
|
||||||
receive widespread use, become available for other developers to
|
|
||||||
incorporate. Many developers of free software are heartened and
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
encouraged by the resulting cooperation. However, in the case of
|
Object form, made available under the License, as indicated by a
|
||||||
software used on network servers, this result may fail to come about.
|
copyright notice that is included in or attached to the work
|
||||||
The GNU General Public License permits making a modified version and
|
(an example is provided in the Appendix below).
|
||||||
letting the public access it on a server without ever releasing its
|
|
||||||
source code to the public.
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
The GNU Affero General Public License is designed specifically to
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
ensure that, in such cases, the modified source code becomes available
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
to the community. It requires the operator of a network server to
|
of this License, Derivative Works shall not include works that remain
|
||||||
provide the source code of the modified version running there to the
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
users of that server. Therefore, public use of a modified version, on
|
the Work and Derivative Works thereof.
|
||||||
a publicly accessible server, gives the public access to the source
|
|
||||||
code of the modified version.
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
An older license, called the Affero General Public License and
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
published by Affero, was designed to accomplish similar goals. This is
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
a different license, not a version of the Affero GPL, but Affero has
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
released a new version of the Affero GPL which permits relicensing under
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
this license.
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
The precise terms and conditions for copying, distribution and
|
communication on electronic mailing lists, source code control systems,
|
||||||
modification follow.
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
TERMS AND CONDITIONS
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
0. Definitions.
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
|
||||||
works, such as semiconductor masks.
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
"The Program" refers to any copyrightable work licensed under this
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
License. Each licensee is addressed as "you". "Licensees" and
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
"recipients" may be individuals or organizations.
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
To "modify" a work means to copy from or adapt all or part of the work
|
|
||||||
in a fashion requiring copyright permission, other than the making of an
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
exact copy. The resulting work is called a "modified version" of the
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
earlier work or a work "based on" the earlier work.
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
A "covered work" means either the unmodified Program or a work based
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
on the Program.
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
To "propagate" a work means to do anything with it that, without
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
permission, would make you directly or secondarily liable for
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
infringement under applicable copyright law, except executing it on a
|
institute patent litigation against any entity (including a
|
||||||
computer or modifying a private copy. Propagation includes copying,
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
distribution (with or without modification), making available to the
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
public, and in some countries other activities as well.
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
To "convey" a work means any kind of propagation that enables other
|
as of the date such litigation is filed.
|
||||||
parties to make or receive copies. Mere interaction with a user through
|
|
||||||
a computer network, with no transfer of a copy, is not conveying.
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
An interactive user interface displays "Appropriate Legal Notices"
|
modifications, and in Source or Object form, provided that You
|
||||||
to the extent that it includes a convenient and prominently visible
|
meet the following conditions:
|
||||||
feature that (1) displays an appropriate copyright notice, and (2)
|
|
||||||
tells the user that there is no warranty for the work (except to the
|
(a) You must give any other recipients of the Work or
|
||||||
extent that warranties are provided), that licensees may convey the
|
Derivative Works a copy of this License; and
|
||||||
work under this License, and how to view a copy of this License. If
|
|
||||||
the interface presents a list of user commands or options, such as a
|
(b) You must cause any modified files to carry prominent notices
|
||||||
menu, a prominent item in the list meets this criterion.
|
stating that You changed the files; and
|
||||||
|
|
||||||
1. Source Code.
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
The "source code" for a work means the preferred form of the work
|
attribution notices from the Source form of the Work,
|
||||||
for making modifications to it. "Object code" means any non-source
|
excluding those notices that do not pertain to any part of
|
||||||
form of a work.
|
the Derivative Works; and
|
||||||
|
|
||||||
A "Standard Interface" means an interface that either is an official
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
standard defined by a recognized standards body, or, in the case of
|
distribution, then any Derivative Works that You distribute must
|
||||||
interfaces specified for a particular programming language, one that
|
include a readable copy of the attribution notices contained
|
||||||
is widely used among developers working in that language.
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
The "System Libraries" of an executable work include anything, other
|
of the following places: within a NOTICE text file distributed
|
||||||
than the work as a whole, that (a) is included in the normal form of
|
as part of the Derivative Works; within the Source form or
|
||||||
packaging a Major Component, but which is not part of that Major
|
documentation, if provided along with the Derivative Works; or,
|
||||||
Component, and (b) serves only to enable use of the work with that
|
within a display generated by the Derivative Works, if and
|
||||||
Major Component, or to implement a Standard Interface for which an
|
wherever such third-party notices normally appear. The contents
|
||||||
implementation is available to the public in source code form. A
|
of the NOTICE file are for informational purposes only and
|
||||||
"Major Component", in this context, means a major essential component
|
do not modify the License. You may add Your own attribution
|
||||||
(kernel, window system, and so on) of the specific operating system
|
notices within Derivative Works that You distribute, alongside
|
||||||
(if any) on which the executable work runs, or a compiler used to
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
produce the work, or an object code interpreter used to run it.
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
The "Corresponding Source" for a work in object code form means all
|
|
||||||
the source code needed to generate, install, and (for an executable
|
You may add Your own copyright statement to Your modifications and
|
||||||
work) run the object code and to modify the work, including scripts to
|
may provide additional or different license terms and conditions
|
||||||
control those activities. However, it does not include the work's
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
System Libraries, or general-purpose tools or generally available free
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
programs which are used unmodified in performing those activities but
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
which are not part of the work. For example, Corresponding Source
|
the conditions stated in this License.
|
||||||
includes interface definition files associated with source files for
|
|
||||||
the work, and the source code for shared libraries and dynamically
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
linked subprograms that the work is specifically designed to require,
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
such as by intimate data communication or control flow between those
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
subprograms and other parts of the work.
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
The Corresponding Source need not include anything that users
|
the terms of any separate license agreement you may have executed
|
||||||
can regenerate automatically from other parts of the Corresponding
|
with Licensor regarding such Contributions.
|
||||||
Source.
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
The Corresponding Source for a work in source code form is that
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
same work.
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
2. Basic Permissions.
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
All rights granted under this License are granted for the term of
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
copyright on the Program, and are irrevocable provided the stated
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
conditions are met. This License explicitly affirms your unlimited
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
permission to run the unmodified Program. The output from running a
|
implied, including, without limitation, any warranties or conditions
|
||||||
covered work is covered by this License only if the output, given its
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
content, constitutes a covered work. This License acknowledges your
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
rights of fair use or other equivalent, as provided by copyright law.
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
You may make, run and propagate covered works that you do not
|
|
||||||
convey, without conditions so long as your license otherwise remains
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
in force. You may convey covered works to others for the sole purpose
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
of having them make modifications exclusively for you, or provide you
|
unless required by applicable law (such as deliberate and grossly
|
||||||
with facilities for running those works, provided that you comply with
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
the terms of this License in conveying all material for which you do
|
liable to You for damages, including any direct, indirect, special,
|
||||||
not control copyright. Those thus making or running the covered works
|
incidental, or consequential damages of any character arising as a
|
||||||
for you must do so exclusively on your behalf, under your direction
|
result of this License or out of the use or inability to use the
|
||||||
and control, on terms that prohibit them from making any copies of
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
your copyrighted material outside their relationship with you.
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
Conveying under any other circumstances is permitted solely under
|
has been advised of the possibility of such damages.
|
||||||
the conditions stated below. Sublicensing is not allowed; section 10
|
|
||||||
makes it unnecessary.
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
No covered work shall be deemed part of an effective technological
|
License. However, in accepting such obligations, You may act only
|
||||||
measure under any applicable law fulfilling obligations under article
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
similar laws prohibiting or restricting circumvention of such
|
defend, and hold each Contributor harmless for any liability
|
||||||
measures.
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
When you convey a covered work, you waive any legal power to forbid
|
|
||||||
circumvention of technological measures to the extent such circumvention
|
END OF TERMS AND CONDITIONS
|
||||||
is effected by exercising rights under this License with respect to
|
|
||||||
the covered work, and you disclaim any intention to limit operation or
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
modification of the work as a means of enforcing, against the work's
|
|
||||||
users, your or third parties' legal rights to forbid circumvention of
|
To apply the Apache License to your work, attach the following
|
||||||
technological measures.
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
4. Conveying Verbatim Copies.
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
You may convey verbatim copies of the Program's source code as you
|
file or class name and description of purpose be included on the
|
||||||
receive it, in any medium, provided that you conspicuously and
|
same "printed page" as the copyright notice for easier
|
||||||
appropriately publish on each copy an appropriate copyright notice;
|
identification within third-party archives.
|
||||||
keep intact all notices stating that this License and any
|
|
||||||
non-permissive terms added in accord with section 7 apply to the code;
|
Copyright [yyyy] [name of copyright owner]
|
||||||
keep intact all notices of the absence of any warranty; and give all
|
|
||||||
recipients a copy of this License along with the Program.
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
You may charge any price or no price for each copy that you convey,
|
You may obtain a copy of the License at
|
||||||
and you may offer support or warranty protection for a fee.
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
5. Conveying Modified Source Versions.
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
You may convey a work based on the Program, or the modifications to
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
produce it from the Program, in the form of source code under the
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
terms of section 4, provided that you also meet all of these conditions:
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
a) The work must carry prominent notices stating that you modified
|
|
||||||
it, and giving a relevant date.
|
|
||||||
|
|
||||||
b) The work must carry prominent notices stating that it is
|
|
||||||
released under this License and any conditions added under section
|
|
||||||
7. This requirement modifies the requirement in section 4 to
|
|
||||||
"keep intact all notices".
|
|
||||||
|
|
||||||
c) You must license the entire work, as a whole, under this
|
|
||||||
License to anyone who comes into possession of a copy. This
|
|
||||||
License will therefore apply, along with any applicable section 7
|
|
||||||
additional terms, to the whole of the work, and all its parts,
|
|
||||||
regardless of how they are packaged. This License gives no
|
|
||||||
permission to license the work in any other way, but it does not
|
|
||||||
invalidate such permission if you have separately received it.
|
|
||||||
|
|
||||||
d) If the work has interactive user interfaces, each must display
|
|
||||||
Appropriate Legal Notices; however, if the Program has interactive
|
|
||||||
interfaces that do not display Appropriate Legal Notices, your
|
|
||||||
work need not make them do so.
|
|
||||||
|
|
||||||
A compilation of a covered work with other separate and independent
|
|
||||||
works, which are not by their nature extensions of the covered work,
|
|
||||||
and which are not combined with it such as to form a larger program,
|
|
||||||
in or on a volume of a storage or distribution medium, is called an
|
|
||||||
"aggregate" if the compilation and its resulting copyright are not
|
|
||||||
used to limit the access or legal rights of the compilation's users
|
|
||||||
beyond what the individual works permit. Inclusion of a covered work
|
|
||||||
in an aggregate does not cause this License to apply to the other
|
|
||||||
parts of the aggregate.
|
|
||||||
|
|
||||||
6. Conveying Non-Source Forms.
|
|
||||||
|
|
||||||
You may convey a covered work in object code form under the terms
|
|
||||||
of sections 4 and 5, provided that you also convey the
|
|
||||||
machine-readable Corresponding Source under the terms of this License,
|
|
||||||
in one of these ways:
|
|
||||||
|
|
||||||
a) Convey the object code in, or embodied in, a physical product
|
|
||||||
(including a physical distribution medium), accompanied by the
|
|
||||||
Corresponding Source fixed on a durable physical medium
|
|
||||||
customarily used for software interchange.
|
|
||||||
|
|
||||||
b) Convey the object code in, or embodied in, a physical product
|
|
||||||
(including a physical distribution medium), accompanied by a
|
|
||||||
written offer, valid for at least three years and valid for as
|
|
||||||
long as you offer spare parts or customer support for that product
|
|
||||||
model, to give anyone who possesses the object code either (1) a
|
|
||||||
copy of the Corresponding Source for all the software in the
|
|
||||||
product that is covered by this License, on a durable physical
|
|
||||||
medium customarily used for software interchange, for a price no
|
|
||||||
more than your reasonable cost of physically performing this
|
|
||||||
conveying of source, or (2) access to copy the
|
|
||||||
Corresponding Source from a network server at no charge.
|
|
||||||
|
|
||||||
c) Convey individual copies of the object code with a copy of the
|
|
||||||
written offer to provide the Corresponding Source. This
|
|
||||||
alternative is allowed only occasionally and noncommercially, and
|
|
||||||
only if you received the object code with such an offer, in accord
|
|
||||||
with subsection 6b.
|
|
||||||
|
|
||||||
d) Convey the object code by offering access from a designated
|
|
||||||
place (gratis or for a charge), and offer equivalent access to the
|
|
||||||
Corresponding Source in the same way through the same place at no
|
|
||||||
further charge. You need not require recipients to copy the
|
|
||||||
Corresponding Source along with the object code. If the place to
|
|
||||||
copy the object code is a network server, the Corresponding Source
|
|
||||||
may be on a different server (operated by you or a third party)
|
|
||||||
that supports equivalent copying facilities, provided you maintain
|
|
||||||
clear directions next to the object code saying where to find the
|
|
||||||
Corresponding Source. Regardless of what server hosts the
|
|
||||||
Corresponding Source, you remain obligated to ensure that it is
|
|
||||||
available for as long as needed to satisfy these requirements.
|
|
||||||
|
|
||||||
e) Convey the object code using peer-to-peer transmission, provided
|
|
||||||
you inform other peers where the object code and Corresponding
|
|
||||||
Source of the work are being offered to the general public at no
|
|
||||||
charge under subsection 6d.
|
|
||||||
|
|
||||||
A separable portion of the object code, whose source code is excluded
|
|
||||||
from the Corresponding Source as a System Library, need not be
|
|
||||||
included in conveying the object code work.
|
|
||||||
|
|
||||||
A "User Product" is either (1) a "consumer product", which means any
|
|
||||||
tangible personal property which is normally used for personal, family,
|
|
||||||
or household purposes, or (2) anything designed or sold for incorporation
|
|
||||||
into a dwelling. In determining whether a product is a consumer product,
|
|
||||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
|
||||||
product received by a particular user, "normally used" refers to a
|
|
||||||
typical or common use of that class of product, regardless of the status
|
|
||||||
of the particular user or of the way in which the particular user
|
|
||||||
actually uses, or expects or is expected to use, the product. A product
|
|
||||||
is a consumer product regardless of whether the product has substantial
|
|
||||||
commercial, industrial or non-consumer uses, unless such uses represent
|
|
||||||
the only significant mode of use of the product.
|
|
||||||
|
|
||||||
"Installation Information" for a User Product means any methods,
|
|
||||||
procedures, authorization keys, or other information required to install
|
|
||||||
and execute modified versions of a covered work in that User Product from
|
|
||||||
a modified version of its Corresponding Source. The information must
|
|
||||||
suffice to ensure that the continued functioning of the modified object
|
|
||||||
code is in no case prevented or interfered with solely because
|
|
||||||
modification has been made.
|
|
||||||
|
|
||||||
If you convey an object code work under this section in, or with, or
|
|
||||||
specifically for use in, a User Product, and the conveying occurs as
|
|
||||||
part of a transaction in which the right of possession and use of the
|
|
||||||
User Product is transferred to the recipient in perpetuity or for a
|
|
||||||
fixed term (regardless of how the transaction is characterized), the
|
|
||||||
Corresponding Source conveyed under this section must be accompanied
|
|
||||||
by the Installation Information. But this requirement does not apply
|
|
||||||
if neither you nor any third party retains the ability to install
|
|
||||||
modified object code on the User Product (for example, the work has
|
|
||||||
been installed in ROM).
|
|
||||||
|
|
||||||
The requirement to provide Installation Information does not include a
|
|
||||||
requirement to continue to provide support service, warranty, or updates
|
|
||||||
for a work that has been modified or installed by the recipient, or for
|
|
||||||
the User Product in which it has been modified or installed. Access to a
|
|
||||||
network may be denied when the modification itself materially and
|
|
||||||
adversely affects the operation of the network or violates the rules and
|
|
||||||
protocols for communication across the network.
|
|
||||||
|
|
||||||
Corresponding Source conveyed, and Installation Information provided,
|
|
||||||
in accord with this section must be in a format that is publicly
|
|
||||||
documented (and with an implementation available to the public in
|
|
||||||
source code form), and must require no special password or key for
|
|
||||||
unpacking, reading or copying.
|
|
||||||
|
|
||||||
7. Additional Terms.
|
|
||||||
|
|
||||||
"Additional permissions" are terms that supplement the terms of this
|
|
||||||
License by making exceptions from one or more of its conditions.
|
|
||||||
Additional permissions that are applicable to the entire Program shall
|
|
||||||
be treated as though they were included in this License, to the extent
|
|
||||||
that they are valid under applicable law. If additional permissions
|
|
||||||
apply only to part of the Program, that part may be used separately
|
|
||||||
under those permissions, but the entire Program remains governed by
|
|
||||||
this License without regard to the additional permissions.
|
|
||||||
|
|
||||||
When you convey a copy of a covered work, you may at your option
|
|
||||||
remove any additional permissions from that copy, or from any part of
|
|
||||||
it. (Additional permissions may be written to require their own
|
|
||||||
removal in certain cases when you modify the work.) You may place
|
|
||||||
additional permissions on material, added by you to a covered work,
|
|
||||||
for which you have or can give appropriate copyright permission.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, for material you
|
|
||||||
add to a covered work, you may (if authorized by the copyright holders of
|
|
||||||
that material) supplement the terms of this License with terms:
|
|
||||||
|
|
||||||
a) Disclaiming warranty or limiting liability differently from the
|
|
||||||
terms of sections 15 and 16 of this License; or
|
|
||||||
|
|
||||||
b) Requiring preservation of specified reasonable legal notices or
|
|
||||||
author attributions in that material or in the Appropriate Legal
|
|
||||||
Notices displayed by works containing it; or
|
|
||||||
|
|
||||||
c) Prohibiting misrepresentation of the origin of that material, or
|
|
||||||
requiring that modified versions of such material be marked in
|
|
||||||
reasonable ways as different from the original version; or
|
|
||||||
|
|
||||||
d) Limiting the use for publicity purposes of names of licensors or
|
|
||||||
authors of the material; or
|
|
||||||
|
|
||||||
e) Declining to grant rights under trademark law for use of some
|
|
||||||
trade names, trademarks, or service marks; or
|
|
||||||
|
|
||||||
f) Requiring indemnification of licensors and authors of that
|
|
||||||
material by anyone who conveys the material (or modified versions of
|
|
||||||
it) with contractual assumptions of liability to the recipient, for
|
|
||||||
any liability that these contractual assumptions directly impose on
|
|
||||||
those licensors and authors.
|
|
||||||
|
|
||||||
All other non-permissive additional terms are considered "further
|
|
||||||
restrictions" within the meaning of section 10. If the Program as you
|
|
||||||
received it, or any part of it, contains a notice stating that it is
|
|
||||||
governed by this License along with a term that is a further
|
|
||||||
restriction, you may remove that term. If a license document contains
|
|
||||||
a further restriction but permits relicensing or conveying under this
|
|
||||||
License, you may add to a covered work material governed by the terms
|
|
||||||
of that license document, provided that the further restriction does
|
|
||||||
not survive such relicensing or conveying.
|
|
||||||
|
|
||||||
If you add terms to a covered work in accord with this section, you
|
|
||||||
must place, in the relevant source files, a statement of the
|
|
||||||
additional terms that apply to those files, or a notice indicating
|
|
||||||
where to find the applicable terms.
|
|
||||||
|
|
||||||
Additional terms, permissive or non-permissive, may be stated in the
|
|
||||||
form of a separately written license, or stated as exceptions;
|
|
||||||
the above requirements apply either way.
|
|
||||||
|
|
||||||
8. Termination.
|
|
||||||
|
|
||||||
You may not propagate or modify a covered work except as expressly
|
|
||||||
provided under this License. Any attempt otherwise to propagate or
|
|
||||||
modify it is void, and will automatically terminate your rights under
|
|
||||||
this License (including any patent licenses granted under the third
|
|
||||||
paragraph of section 11).
|
|
||||||
|
|
||||||
However, if you cease all violation of this License, then your
|
|
||||||
license from a particular copyright holder is reinstated (a)
|
|
||||||
provisionally, unless and until the copyright holder explicitly and
|
|
||||||
finally terminates your license, and (b) permanently, if the copyright
|
|
||||||
holder fails to notify you of the violation by some reasonable means
|
|
||||||
prior to 60 days after the cessation.
|
|
||||||
|
|
||||||
Moreover, your license from a particular copyright holder is
|
|
||||||
reinstated permanently if the copyright holder notifies you of the
|
|
||||||
violation by some reasonable means, this is the first time you have
|
|
||||||
received notice of violation of this License (for any work) from that
|
|
||||||
copyright holder, and you cure the violation prior to 30 days after
|
|
||||||
your receipt of the notice.
|
|
||||||
|
|
||||||
Termination of your rights under this section does not terminate the
|
|
||||||
licenses of parties who have received copies or rights from you under
|
|
||||||
this License. If your rights have been terminated and not permanently
|
|
||||||
reinstated, you do not qualify to receive new licenses for the same
|
|
||||||
material under section 10.
|
|
||||||
|
|
||||||
9. Acceptance Not Required for Having Copies.
|
|
||||||
|
|
||||||
You are not required to accept this License in order to receive or
|
|
||||||
run a copy of the Program. Ancillary propagation of a covered work
|
|
||||||
occurring solely as a consequence of using peer-to-peer transmission
|
|
||||||
to receive a copy likewise does not require acceptance. However,
|
|
||||||
nothing other than this License grants you permission to propagate or
|
|
||||||
modify any covered work. These actions infringe copyright if you do
|
|
||||||
not accept this License. Therefore, by modifying or propagating a
|
|
||||||
covered work, you indicate your acceptance of this License to do so.
|
|
||||||
|
|
||||||
10. Automatic Licensing of Downstream Recipients.
|
|
||||||
|
|
||||||
Each time you convey a covered work, the recipient automatically
|
|
||||||
receives a license from the original licensors, to run, modify and
|
|
||||||
propagate that work, subject to this License. You are not responsible
|
|
||||||
for enforcing compliance by third parties with this License.
|
|
||||||
|
|
||||||
An "entity transaction" is a transaction transferring control of an
|
|
||||||
organization, or substantially all assets of one, or subdividing an
|
|
||||||
organization, or merging organizations. If propagation of a covered
|
|
||||||
work results from an entity transaction, each party to that
|
|
||||||
transaction who receives a copy of the work also receives whatever
|
|
||||||
licenses to the work the party's predecessor in interest had or could
|
|
||||||
give under the previous paragraph, plus a right to possession of the
|
|
||||||
Corresponding Source of the work from the predecessor in interest, if
|
|
||||||
the predecessor has it or can get it with reasonable efforts.
|
|
||||||
|
|
||||||
You may not impose any further restrictions on the exercise of the
|
|
||||||
rights granted or affirmed under this License. For example, you may
|
|
||||||
not impose a license fee, royalty, or other charge for exercise of
|
|
||||||
rights granted under this License, and you may not initiate litigation
|
|
||||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
|
||||||
any patent claim is infringed by making, using, selling, offering for
|
|
||||||
sale, or importing the Program or any portion of it.
|
|
||||||
|
|
||||||
11. Patents.
|
|
||||||
|
|
||||||
A "contributor" is a copyright holder who authorizes use under this
|
|
||||||
License of the Program or a work on which the Program is based. The
|
|
||||||
work thus licensed is called the contributor's "contributor version".
|
|
||||||
|
|
||||||
A contributor's "essential patent claims" are all patent claims
|
|
||||||
owned or controlled by the contributor, whether already acquired or
|
|
||||||
hereafter acquired, that would be infringed by some manner, permitted
|
|
||||||
by this License, of making, using, or selling its contributor version,
|
|
||||||
but do not include claims that would be infringed only as a
|
|
||||||
consequence of further modification of the contributor version. For
|
|
||||||
purposes of this definition, "control" includes the right to grant
|
|
||||||
patent sublicenses in a manner consistent with the requirements of
|
|
||||||
this License.
|
|
||||||
|
|
||||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
|
||||||
patent license under the contributor's essential patent claims, to
|
|
||||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
|
||||||
propagate the contents of its contributor version.
|
|
||||||
|
|
||||||
In the following three paragraphs, a "patent license" is any express
|
|
||||||
agreement or commitment, however denominated, not to enforce a patent
|
|
||||||
(such as an express permission to practice a patent or covenant not to
|
|
||||||
sue for patent infringement). To "grant" such a patent license to a
|
|
||||||
party means to make such an agreement or commitment not to enforce a
|
|
||||||
patent against the party.
|
|
||||||
|
|
||||||
If you convey a covered work, knowingly relying on a patent license,
|
|
||||||
and the Corresponding Source of the work is not available for anyone
|
|
||||||
to copy, free of charge and under the terms of this License, through a
|
|
||||||
publicly available network server or other readily accessible means,
|
|
||||||
then you must either (1) cause the Corresponding Source to be so
|
|
||||||
available, or (2) arrange to deprive yourself of the benefit of the
|
|
||||||
patent license for this particular work, or (3) arrange, in a manner
|
|
||||||
consistent with the requirements of this License, to extend the patent
|
|
||||||
license to downstream recipients. "Knowingly relying" means you have
|
|
||||||
actual knowledge that, but for the patent license, your conveying the
|
|
||||||
covered work in a country, or your recipient's use of the covered work
|
|
||||||
in a country, would infringe one or more identifiable patents in that
|
|
||||||
country that you have reason to believe are valid.
|
|
||||||
|
|
||||||
If, pursuant to or in connection with a single transaction or
|
|
||||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
|
||||||
covered work, and grant a patent license to some of the parties
|
|
||||||
receiving the covered work authorizing them to use, propagate, modify
|
|
||||||
or convey a specific copy of the covered work, then the patent license
|
|
||||||
you grant is automatically extended to all recipients of the covered
|
|
||||||
work and works based on it.
|
|
||||||
|
|
||||||
A patent license is "discriminatory" if it does not include within
|
|
||||||
the scope of its coverage, prohibits the exercise of, or is
|
|
||||||
conditioned on the non-exercise of one or more of the rights that are
|
|
||||||
specifically granted under this License. You may not convey a covered
|
|
||||||
work if you are a party to an arrangement with a third party that is
|
|
||||||
in the business of distributing software, under which you make payment
|
|
||||||
to the third party based on the extent of your activity of conveying
|
|
||||||
the work, and under which the third party grants, to any of the
|
|
||||||
parties who would receive the covered work from you, a discriminatory
|
|
||||||
patent license (a) in connection with copies of the covered work
|
|
||||||
conveyed by you (or copies made from those copies), or (b) primarily
|
|
||||||
for and in connection with specific products or compilations that
|
|
||||||
contain the covered work, unless you entered into that arrangement,
|
|
||||||
or that patent license was granted, prior to 28 March 2007.
|
|
||||||
|
|
||||||
Nothing in this License shall be construed as excluding or limiting
|
|
||||||
any implied license or other defenses to infringement that may
|
|
||||||
otherwise be available to you under applicable patent law.
|
|
||||||
|
|
||||||
12. No Surrender of Others' Freedom.
|
|
||||||
|
|
||||||
If conditions are imposed on you (whether by court order, agreement or
|
|
||||||
otherwise) that contradict the conditions of this License, they do not
|
|
||||||
excuse you from the conditions of this License. If you cannot convey a
|
|
||||||
covered work so as to satisfy simultaneously your obligations under this
|
|
||||||
License and any other pertinent obligations, then as a consequence you may
|
|
||||||
not convey it at all. For example, if you agree to terms that obligate you
|
|
||||||
to collect a royalty for further conveying from those to whom you convey
|
|
||||||
the Program, the only way you could satisfy both those terms and this
|
|
||||||
License would be to refrain entirely from conveying the Program.
|
|
||||||
|
|
||||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, if you modify the
|
|
||||||
Program, your modified version must prominently offer all users
|
|
||||||
interacting with it remotely through a computer network (if your version
|
|
||||||
supports such interaction) an opportunity to receive the Corresponding
|
|
||||||
Source of your version by providing access to the Corresponding Source
|
|
||||||
from a network server at no charge, through some standard or customary
|
|
||||||
means of facilitating copying of software. This Corresponding Source
|
|
||||||
shall include the Corresponding Source for any work covered by version 3
|
|
||||||
of the GNU General Public License that is incorporated pursuant to the
|
|
||||||
following paragraph.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, you have
|
|
||||||
permission to link or combine any covered work with a work licensed
|
|
||||||
under version 3 of the GNU General Public License into a single
|
|
||||||
combined work, and to convey the resulting work. The terms of this
|
|
||||||
License will continue to apply to the part which is the covered work,
|
|
||||||
but the work with which it is combined will remain governed by version
|
|
||||||
3 of the GNU General Public License.
|
|
||||||
|
|
||||||
14. Revised Versions of this License.
|
|
||||||
|
|
||||||
The Free Software Foundation may publish revised and/or new versions of
|
|
||||||
the GNU Affero General Public License from time to time. Such new versions
|
|
||||||
will be similar in spirit to the present version, but may differ in detail to
|
|
||||||
address new problems or concerns.
|
|
||||||
|
|
||||||
Each version is given a distinguishing version number. If the
|
|
||||||
Program specifies that a certain numbered version of the GNU Affero General
|
|
||||||
Public License "or any later version" applies to it, you have the
|
|
||||||
option of following the terms and conditions either of that numbered
|
|
||||||
version or of any later version published by the Free Software
|
|
||||||
Foundation. If the Program does not specify a version number of the
|
|
||||||
GNU Affero General Public License, you may choose any version ever published
|
|
||||||
by the Free Software Foundation.
|
|
||||||
|
|
||||||
If the Program specifies that a proxy can decide which future
|
|
||||||
versions of the GNU Affero General Public License can be used, that proxy's
|
|
||||||
public statement of acceptance of a version permanently authorizes you
|
|
||||||
to choose that version for the Program.
|
|
||||||
|
|
||||||
Later license versions may give you additional or different
|
|
||||||
permissions. However, no additional obligations are imposed on any
|
|
||||||
author or copyright holder as a result of your choosing to follow a
|
|
||||||
later version.
|
|
||||||
|
|
||||||
15. Disclaimer of Warranty.
|
|
||||||
|
|
||||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
|
||||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
|
||||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
|
||||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
|
||||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
|
||||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
|
||||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
|
||||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
|
||||||
|
|
||||||
16. Limitation of Liability.
|
|
||||||
|
|
||||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
|
||||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
|
||||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
|
||||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
|
||||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
|
||||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
|
||||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
|
||||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
|
||||||
SUCH DAMAGES.
|
|
||||||
|
|
||||||
17. Interpretation of Sections 15 and 16.
|
|
||||||
|
|
||||||
If the disclaimer of warranty and limitation of liability provided
|
|
||||||
above cannot be given local legal effect according to their terms,
|
|
||||||
reviewing courts shall apply local law that most closely approximates
|
|
||||||
an absolute waiver of all civil liability in connection with the
|
|
||||||
Program, unless a warranty or assumption of liability accompanies a
|
|
||||||
copy of the Program in return for a fee.
|
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
How to Apply These Terms to Your New Programs
|
|
||||||
|
|
||||||
If you develop a new program, and you want it to be of the greatest
|
|
||||||
possible use to the public, the best way to achieve this is to make it
|
|
||||||
free software which everyone can redistribute and change under these terms.
|
|
||||||
|
|
||||||
To do so, attach the following notices to the program. It is safest
|
|
||||||
to attach them to the start of each source file to most effectively
|
|
||||||
state the exclusion of warranty; and each file should have at least
|
|
||||||
the "copyright" line and a pointer to where the full notice is found.
|
|
||||||
|
|
||||||
<one line to give the program's name and a brief idea of what it does.>
|
|
||||||
Copyright (C) <year> <name of author>
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
(at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
Also add information on how to contact you by electronic and paper mail.
|
|
||||||
|
|
||||||
If your software can interact with users remotely through a computer
|
|
||||||
network, you should also make sure that it provides a way for users to
|
|
||||||
get its source. For example, if your program is a web application, its
|
|
||||||
interface could display a "Source" link that leads users to an archive
|
|
||||||
of the code. There are many ways you could offer source, and different
|
|
||||||
solutions will be better for different programs; see section 13 for the
|
|
||||||
specific requirements.
|
|
||||||
|
|
||||||
You should also get your employer (if you work as a programmer) or school,
|
|
||||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
|
||||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
|
||||||
<https://www.gnu.org/licenses/>.
|
|
||||||
|
|||||||
+1
-2
@@ -154,9 +154,8 @@ npm run typecheck
|
|||||||
## 関連プロジェクト
|
## 関連プロジェクト
|
||||||
|
|
||||||
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — Elixir 製の公式分散リレー
|
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — Elixir 製の公式分散リレー
|
||||||
- [paseo-skins](https://github.com/huangguang1999/paseo-skins) — Paseo デスクトップ向けコミュニティテーマと、Agent Skill 対応のゼロパッチテーマローダー
|
|
||||||
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 拡張機能
|
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 拡張機能
|
||||||
|
|
||||||
## ライセンス
|
## ライセンス
|
||||||
|
|
||||||
AGPL-3.0
|
Apache-2.0
|
||||||
|
|||||||
+1
-1
@@ -169,4 +169,4 @@ npm run typecheck
|
|||||||
|
|
||||||
## 라이선스
|
## 라이선스
|
||||||
|
|
||||||
AGPL-3.0
|
Apache-2.0
|
||||||
|
|||||||
@@ -111,8 +111,8 @@ paseo ls # list running agents
|
|||||||
paseo attach abc123 # stream live output
|
paseo attach abc123 # stream live output
|
||||||
paseo send abc123 "also add tests" # follow-up task
|
paseo send abc123 "also add tests" # follow-up task
|
||||||
|
|
||||||
# run on a remote daemon
|
# run on a remote daemon; --cwd is a path on that host
|
||||||
paseo --host workstation.local:6767 run "run the full test suite"
|
paseo run --host workstation.local:6767 --cwd /workspace "run the full test suite"
|
||||||
```
|
```
|
||||||
|
|
||||||
See the [full CLI reference](https://paseo.sh/docs/cli) for more.
|
See the [full CLI reference](https://paseo.sh/docs/cli) for more.
|
||||||
@@ -188,9 +188,8 @@ npm run typecheck
|
|||||||
## Related projects
|
## Related projects
|
||||||
|
|
||||||
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — official distributed relay, written in Elixir
|
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — official distributed relay, written in Elixir
|
||||||
- [paseo-skins](https://github.com/huangguang1999/paseo-skins) — community themes and a zero-patch desktop theme loader with an Agent Skill
|
|
||||||
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code extension
|
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code extension
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
AGPL-3.0
|
Apache-2.0
|
||||||
|
|||||||
+1
-2
@@ -154,7 +154,6 @@ npm run typecheck
|
|||||||
## 相关项目
|
## 相关项目
|
||||||
|
|
||||||
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — 官方分布式 relay,使用 Elixir 编写
|
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — 官方分布式 relay,使用 Elixir 编写
|
||||||
- [paseo-skins](https://github.com/huangguang1999/paseo-skins) — Paseo 桌面端社区主题与零 patch 换肤工具,支持 Agent Skill
|
|
||||||
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 扩展
|
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 扩展
|
||||||
|
|
||||||
### 自托管 relay TLS
|
### 自托管 relay TLS
|
||||||
@@ -205,4 +204,4 @@ server {
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
AGPL-3.0
|
Apache-2.0
|
||||||
|
|||||||
+19
-15
@@ -103,9 +103,9 @@ host's client; plugin subprocesses use the same facade over a host-owned IPC tra
|
|||||||
Cross-platform React Native app that connects to one or more daemons.
|
Cross-platform React Native app that connects to one or more daemons.
|
||||||
|
|
||||||
- Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.). The `workspaceId` URL segment is an opaque workspace id, not a directly meaningful filesystem path.
|
- Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.). The `workspaceId` URL segment is an opaque workspace id, not a directly meaningful filesystem path.
|
||||||
- `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state
|
- `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state. Direct TCP and relay connections use the ordinary client transport; desktop socket, pipe, and SSH connections cross one Electron-owned transport boundary. SSH only tunnels to an already-running daemon.
|
||||||
- `runtime/replica-cache` keeps the complete project, workspace, and active-agent directory plus one short focused timeline tail in a row store. It restores before navigation becomes ready and leaves remote hydration flags false.
|
- `runtime/replica-cache` is typed storage behind the directory and timeline owners. It never observes or mutates `SessionStore`.
|
||||||
- `runtime/directory-sync` owns directory reconciliation. On reconnect it passes the persisted per-entity cursor through `project.list`, `fetch_workspaces`, and `fetch_agents`; the daemon returns each entity's latest projection when its sequence is newer, plus tombstones.
|
- `runtime/directory-sync` owns directory cache selection and network reconciliation. On demand it paints accepted rows for one host, then passes the persisted per-entity cursor through `project.list`, `fetch_workspaces`, and `fetch_agents`; the daemon returns each entity's latest projection when its sequence is newer, plus tombstones.
|
||||||
- `workspace-labels` owns one sequenced catalog replica per connected host, the deterministic cross-host projection that surfaces spanning hosts use (the filter page, the manager), and the per-host resolution a workspace row's chips use. Two hosts may give one name different colors, so a row resolves against its own host's catalog and a merged answer would be wrong there. Catalogs never synchronize between hosts; assignment creates a missing definition only on the target host. On the daemon, catalog and assignment rewrites share a journaled commit boundary. Startup recovery completes that commit before workspace or catalog publication.
|
- `workspace-labels` owns one sequenced catalog replica per connected host, the deterministic cross-host projection that surfaces spanning hosts use (the filter page, the manager), and the per-host resolution a workspace row's chips use. Two hosts may give one name different colors, so a row resolves against its own host's catalog and a merged answer would be wrong there. Catalogs never synchronize between hosts; assignment creates a missing definition only on the target host. On the daemon, catalog and assignment rewrites share a journaled commit boundary. Startup recovery completes that commit before workspace or catalog publication.
|
||||||
- `SessionContext` wraps the daemon client for the active session
|
- `SessionContext` wraps the daemon client for the active session
|
||||||
- Composer UI and submit/draft behavior live in `packages/app/src/composer/`; screens and panels should integrate it from there instead of dropping composer internals into `components/`, `hooks/`, or `screens/workspace/`
|
- Composer UI and submit/draft behavior live in `packages/app/src/composer/`; screens and panels should integrate it from there instead of dropping composer internals into `components/`, `hooks/`, or `screens/workspace/`
|
||||||
@@ -113,17 +113,21 @@ Cross-platform React Native app that connects to one or more daemons.
|
|||||||
- Timeline sync correctness is documented in [docs/timeline-sync.md](timeline-sync.md): live streams are for immediacy, `fetch_agent_timeline_request` is authoritative, and catch-up is paged but complete.
|
- Timeline sync correctness is documented in [docs/timeline-sync.md](timeline-sync.md): live streams are for immediacy, `fetch_agent_timeline_request` is authoritative, and catch-up is paged but complete.
|
||||||
- Voice features: dictation (STT) and voice agent (realtime)
|
- Voice features: dictation (STT) and voice agent (realtime)
|
||||||
|
|
||||||
The replica cache paints stale data immediately while the host connects. Directory cursors are
|
Consumers request directory or timeline data without choosing memory, cache, or network. The owner
|
||||||
reconciliation checkpoints; cached entities remain non-authoritative until the daemon answers.
|
publishes an accepted cache hit and then reconciles it over the existing network path. A miss or an
|
||||||
Pending permission requests are not restored from it. The row store is not encrypted, so the cached
|
invalid row uses that same path. Offline demand still publishes an accepted cache hit and defers
|
||||||
timeline tail may contain source code, prompts, and tool output; encrypted-at-rest storage is a
|
network reconciliation. Cache loading is demand-driven: opening a chat reads its agent row and
|
||||||
separate product/security decision. Agents, workspaces, and projects have individual rows; the
|
focused timeline row, plus the workspace and project rows needed by the route; opening a directory
|
||||||
focused timeline tail and directory checkpoint each have one row per host. Writes serialize only
|
reads directory rows for that host and establishes its live subscription when connected. Accepted
|
||||||
rows whose in-memory identities changed. Restore validates every row before hydration; one invalid
|
cached rows satisfy the same consumer-readiness projection as network rows. Host registry startup and
|
||||||
row clears the cache and starts cold. The 32 MiB byte budget is tracked incrementally from row
|
host connection do not create directory demand or install replicas. The directory owner retains
|
||||||
payload lengths and evicts whole hosts in least-recently-written order. After restart, that order is
|
declared surface demand and re-establishes network reconciliation after reconnect; React does not
|
||||||
an approximation rebuilt from the row store's `readAll()` order. Browser and Electron builds use
|
track connection generations. Late cache reads cannot replace state already advanced by live or
|
||||||
IndexedDB. Native builds use expo-sqlite.
|
authoritative network data. Owners explicitly persist accepted commits; directory rows and their
|
||||||
|
checkpoint share one storage transaction. See
|
||||||
|
[data-model.md](data-model.md#replica-row-store)
|
||||||
|
for the storage shape and [timeline-sync.md](timeline-sync.md#client-replica-lifetime) for timeline
|
||||||
|
resume behavior.
|
||||||
|
|
||||||
The three directory entity types have independent monotonic sequences and share one daemon
|
The three directory entity types have independent monotonic sequences and share one daemon
|
||||||
generation. The daemon retains only the latest projection per entity and bounded tombstones, not an
|
generation. The daemon retains only the latest projection per entity and bounded tombstones, not an
|
||||||
@@ -158,7 +162,7 @@ Communicates with the daemon via the same WebSocket protocol as the app.
|
|||||||
|
|
||||||
Enables remote access when the daemon is behind a firewall.
|
Enables remote access when the daemon is behind a firewall.
|
||||||
|
|
||||||
- Curve25519 ECDH key exchange + XSalsa20-Poly1305 (NaCl `box`) encryption
|
- Curve25519 establishes the relay-session secret; NaCl `box` protects each payload with XSalsa20-Poly1305
|
||||||
- The relay is zero-knowledge — it routes encrypted bytes and cannot read content
|
- The relay is zero-knowledge — it routes encrypted bytes and cannot read content
|
||||||
- Client and daemon channels with identical API (`createClientChannel`, `createDaemonChannel`)
|
- Client and daemon channels with identical API (`createClientChannel`, `createDaemonChannel`)
|
||||||
- Pairing via QR code transfers the daemon's public key to the client
|
- Pairing via QR code transfers the daemon's public key to the client
|
||||||
|
|||||||
@@ -98,6 +98,14 @@ For testing rules, see [testing.md](testing.md).
|
|||||||
- Use stable ids for `key`, never array index for reorderable/filterable lists.
|
- Use stable ids for `key`, never array index for reorderable/filterable lists.
|
||||||
- Context for stable values (theme, auth). Store with selectors for state that changes.
|
- Context for stable values (theme, auth). Store with selectors for state that changes.
|
||||||
|
|
||||||
|
### Retained panel measurements
|
||||||
|
|
||||||
|
Inactive retained panels use `display: none`, so DOM and layout APIs report zero width and height. A non-positive measurement means the surface is not rendered; it is never a valid compact layout.
|
||||||
|
|
||||||
|
- Suspend geometry-derived updates while either measured dimension is non-positive. Preserve the last valid state until the surface reports positive geometry again.
|
||||||
|
- Enforce the rule at the measurement boundary shared by every trigger. Guarding only an `onLayout` or `ResizeObserver` callback still lets effects, content changes, and imperative remeasurement publish hidden geometry.
|
||||||
|
- Test the hide-and-reveal path through a real retained surface. Settled assertions do not catch stale geometry painted during the first returned frames.
|
||||||
|
|
||||||
## Naming
|
## Naming
|
||||||
|
|
||||||
- Names describe meaning, not mechanics. `submitForm` over `handleOnClickButtonSubmit`. `running` over `filteredArrayOfRunningAgents`.
|
- Names describe meaning, not mechanics. `submitForm` over `handleOnClickButtonSubmit`. `running` over `filteredArrayOfRunningAgents`.
|
||||||
|
|||||||
+14
-12
@@ -501,25 +501,25 @@ Paseo tools such as subagent creation come from the shared internal tool catalog
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
ACP agents execute filesystem and terminal operations in their own environment
|
ACP agents execute filesystem operations in their own environment by default,
|
||||||
by default. To let a compliant agent delegate those operations to Paseo instead,
|
while terminal operations run through Paseo on the host. To customize which
|
||||||
enable the corresponding client capabilities:
|
operations Paseo handles, configure client capabilities in provider params:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"agents": {
|
"agents": {
|
||||||
"providers": {
|
"providers": {
|
||||||
"local-agent": {
|
"container-agent": {
|
||||||
"extends": "acp",
|
"extends": "acp",
|
||||||
"label": "Local Agent",
|
"label": "Container Agent",
|
||||||
"command": ["local-agent", "acp"],
|
"command": ["container-agent", "acp"],
|
||||||
"params": {
|
"params": {
|
||||||
"clientCapabilities": {
|
"clientCapabilities": {
|
||||||
"fs": {
|
"fs": {
|
||||||
"readTextFile": true,
|
"readTextFile": false,
|
||||||
"writeTextFile": true
|
"writeTextFile": false
|
||||||
},
|
},
|
||||||
"terminal": true
|
"terminal": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -528,9 +528,11 @@ enable the corresponding client capabilities:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Only enable capabilities Paseo should execute. When the agent and Paseo run in
|
When an agent runs in a container or remote environment that manages its own
|
||||||
different environments, configure equivalent absolute workspace paths before
|
terminal, set `terminal: false` to keep command execution inside the agent
|
||||||
delegating filesystem or terminal operations to Paseo.
|
container. When delegating filesystem operations to Paseo (`fs.readTextFile: true`
|
||||||
|
or `fs.writeTextFile: true`), ensure the agent and Paseo share equivalent
|
||||||
|
absolute workspace paths.
|
||||||
|
|
||||||
### Generic ACP diagnostics
|
### Generic ACP diagnostics
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,9 @@ $PASEO_HOME/
|
|||||||
├── runtime/
|
├── runtime/
|
||||||
│ └── managed-processes/
|
│ └── managed-processes/
|
||||||
│ └── {recordId}.json # Helper processes owned by Paseo; reconciled on daemon bootstrap
|
│ └── {recordId}.json # Helper processes owned by Paseo; reconciled on daemon bootstrap
|
||||||
|
├── plugins/
|
||||||
|
│ ├── sources.json # Git origin, ref, commit, and managed checkout ownership
|
||||||
|
│ └── {pluginId}/{version}/checkout/ # Source checkout for one installed Git commit
|
||||||
└── push-tokens.json # Expo push notification tokens
|
└── push-tokens.json # Expo push notification tokens
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -251,6 +254,12 @@ snapshot so a mixed edit can apply its live subset and still name the paths that
|
|||||||
|
|
||||||
All fields are optional with sensible defaults.
|
All fields are optional with sensible defaults.
|
||||||
|
|
||||||
|
Git-managed plugins still appear as directory sources in `config.json`. This keeps the plugin
|
||||||
|
runtime and protocol config compatible with directory-only clients. `plugins/sources.json` owns the
|
||||||
|
Git-specific origin, tracking ref, installed commit, repository subdirectory, and checkout root.
|
||||||
|
Paseo writes it atomically. An update creates and validates a new version directory before changing
|
||||||
|
the configured directory path; successful activation removes the old version.
|
||||||
|
|
||||||
### Profile lists
|
### Profile lists
|
||||||
|
|
||||||
`terminalProfiles` and `agentProfiles` are both whole-list fields: a config patch replaces the
|
`terminalProfiles` and `agentProfiles` are both whole-list fields: a config patch replaces the
|
||||||
@@ -521,6 +530,26 @@ Right-sidebar client state splits on whether it is determined by the directory o
|
|||||||
- **Directory-backed** (shared by same-`cwd` workspaces): keyed by `(serverId, cwd)`. Git status/diff, GitHub PR status, PR timeline, file preview content. These are TanStack Query caches, not persisted stores.
|
- **Directory-backed** (shared by same-`cwd` workspaces): keyed by `(serverId, cwd)`. Git status/diff, GitHub PR status, PR timeline, file preview content. These are TanStack Query caches, not persisted stores.
|
||||||
- **Workspace-owned** (independent per workspace): keyed by `workspaceId`, with `cwd` used only as a fallback when no `workspaceId` is present. Review draft comments (`@paseo:review-draft-store`), diff-mode overrides (in-memory), workspace composer attachments, and file-explorer nav/expand state. The `workspaceId` part of these keys is **opaque** — never parse it back into a path.
|
- **Workspace-owned** (independent per workspace): keyed by `workspaceId`, with `cwd` used only as a fallback when no `workspaceId` is present. Review draft comments (`@paseo:review-draft-store`), diff-mode overrides (in-memory), workspace composer attachments, and file-explorer nav/expand state. The `workspaceId` part of these keys is **opaque** — never parse it back into a path.
|
||||||
|
|
||||||
|
### Replica row store
|
||||||
|
|
||||||
|
The durable client replica uses IndexedDB on browser/Electron and expo-sqlite on native. Rows use the
|
||||||
|
compound key `(serverId, kind, id)`; kinds are `agent`, `workspace`, `project`, `timeline`, and
|
||||||
|
`checkpoint`. Directory entities have individual rows. Timeline and checkpoint use the singleton id
|
||||||
|
and have at most one row per host.
|
||||||
|
|
||||||
|
The store is a typed persistence boundary. It returns values to directory and timeline owners and
|
||||||
|
accepts their explicit commits; it never reads or writes UI state. Reads are scoped to the requested
|
||||||
|
host, kinds, and ids. Opening a cached workspace uses exact workspace and project keys rather than a
|
||||||
|
directory scan. One invalid row is deleted and returned as a miss without affecting other rows.
|
||||||
|
An invalid directory row and its affected checkpoint cursor are repaired in one transaction, so a
|
||||||
|
later launch cannot accept a checkpoint for a partial baseline. Directory changes and their
|
||||||
|
checkpoint are also applied in one transaction.
|
||||||
|
|
||||||
|
The cache is capped at 32 MiB and evicts whole hosts in least-recently-written order. Budget
|
||||||
|
bookkeeping may scan opaque row sizes during a deferred write, never during host registry startup or
|
||||||
|
before a requested cache row can paint. The row store is not encrypted. A cached timeline can contain
|
||||||
|
source code, prompts, and tool output; encrypted-at-rest storage is a separate security decision.
|
||||||
|
|
||||||
### Draft Store
|
### Draft Store
|
||||||
|
|
||||||
**AsyncStorage key:** `paseo-drafts` (version 2)
|
**AsyncStorage key:** `paseo-drafts` (version 2)
|
||||||
|
|||||||
@@ -62,6 +62,10 @@ The button is `<Button>` (`packages/app/src/components/ui/button.tsx`). It has f
|
|||||||
|
|
||||||
`ghost` is structural and non-committal — no border, no fill. Back arrows, header toggles, "Load more" footers (`packages/app/src/screens/sessions-screen.tsx:54-63`), more-affordances. Ghost is used when the affordance is part of the chrome, not a decision.
|
`ghost` is structural and non-committal — no border, no fill. Back arrows, header toggles, "Load more" footers (`packages/app/src/screens/sessions-screen.tsx:54-63`), more-affordances. Ghost is used when the affordance is part of the chrome, not a decision.
|
||||||
|
|
||||||
|
Header and toolbar controls use `interactionHighlight` for hovered, pressed, open, and selected
|
||||||
|
backgrounds. It is a translucent semantic fill so the same control works over the main surface and
|
||||||
|
the sidebar. Apply it as `backgroundColor`; setting `opacity` on the control also fades its content.
|
||||||
|
|
||||||
`destructive` is filled with `destructive`. It only appears inside a confirm. The button on the page is `outline`; the destructive button is the confirm button inside the dialog.
|
`destructive` is filled with `destructive`. It only appears inside a confirm. The button on the page is `outline`; the destructive button is the confirm button inside the dialog.
|
||||||
|
|
||||||
Sizes: `xs` for ultra-tight inline triggers. `sm` for any button sitting in a row. `md` is the page default. `lg` is reserved for large standalone CTAs.
|
Sizes: `xs` for ultra-tight inline triggers. `sm` for any button sitting in a row. `md` is the page default. `lg` is reserved for large standalone CTAs.
|
||||||
@@ -155,6 +159,8 @@ On a narrow desktop route, app navigation yields to the rendered content topolog
|
|||||||
|
|
||||||
Electron window controls are top-corner obstructions, not a compact-layout condition. Rendered surfaces declare which top corners they physically occupy; only those corners receive clearance. Full-window overlays redeclare both corners. A focused split pane owns both corners; if focus restoration temporarily exposes the full split tree, the split boundary reserves one top strip instead of assigning a control rectangle to an arbitrarily narrow leaf. The 720px desktop breakpoint preserves the default 320px sidebar and target 400px center width when the Explorer is closed; it is product policy, not an obstruction gate.
|
Electron window controls are top-corner obstructions, not a compact-layout condition. Rendered surfaces declare which top corners they physically occupy; only those corners receive clearance. Full-window overlays redeclare both corners. A focused split pane owns both corners; if focus restoration temporarily exposes the full split tree, the split boundary reserves one top strip instead of assigning a control rectangle to an arbitrarily narrow leaf. The 720px desktop breakpoint preserves the default 320px sidebar and target 400px center width when the Explorer is closed; it is product policy, not an obstruction gate.
|
||||||
|
|
||||||
|
Windows and Linux controls are fixed window chrome, outside scrolling header content. A tab rail that reaches them ends at their obstruction and shows the shared overflow fade. On macOS, the Explorer toggle occupies a fixed top-right window slot so opening and closing Explorer does not move the pointer target.
|
||||||
|
|
||||||
A new list+detail feature copies the settings shell. A new workspace-shaped feature copies the workspace shell. Inventing a third shape happens in design review, not in a PR.
|
A new list+detail feature copies the settings shell. A new workspace-shaped feature copies the workspace shell. Inventing a third shape happens in design review, not in a PR.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+18
-4
@@ -242,13 +242,13 @@ focused invalidation capture; React Native's generated stacks make that mode hig
|
|||||||
Set `PASEO_PROFILE_TRACE_FOCUS=1` to include focus targets, durations, and JavaScript call stacks in
|
Set `PASEO_PROFILE_TRACE_FOCUS=1` to include focus targets, durations, and JavaScript call stacks in
|
||||||
the scenario report. This mode wraps `HTMLElement.focus`, so use it only for diagnosis.
|
the scenario report. This mode wraps `HTMLElement.focus`, so use it only for diagnosis.
|
||||||
|
|
||||||
For the desktop Side panel toggle, run the app against the root checkout's daemon and use:
|
For the desktop Explorer sidebar toggle, run the app against the root checkout's daemon and use:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run profile:explorer-toggle --workspace=@getpaseo/app
|
npm run profile:explorer-toggle --workspace=@getpaseo/app
|
||||||
```
|
```
|
||||||
|
|
||||||
The harness verifies port `6768`, opens the Paseo workspace, creates and warms the Side panel pane,
|
The harness verifies port `6768`, opens the Paseo workspace, creates and warms the Explorer pane,
|
||||||
records an idle control, then measures settled and 50 ms burst Cmd+E toggles. It reports
|
records an idle control, then measures settled and 50 ms burst Cmd+E toggles. It reports
|
||||||
input-to-DOM and input-to-paint latency, React commits, mounts, unmounts, and DOM mutations. Set
|
input-to-DOM and input-to-paint latency, React commits, mounts, unmounts, and DOM mutations. Set
|
||||||
`PASEO_PROFILE_TRACE_PATH=/tmp/explorer-toggle.trace.json` or
|
`PASEO_PROFILE_TRACE_PATH=/tmp/explorer-toggle.trace.json` or
|
||||||
@@ -278,6 +278,17 @@ That report includes input and composer height changes plus React work grouped i
|
|||||||
stream, and ancestor/root scopes. Ancestor/root timings include descendant work because the Profiler
|
stream, and ancestor/root scopes. Ancestor/root timings include descendant work because the Profiler
|
||||||
boundaries are nested. A printable key after an empty newline should not change either height.
|
boundaries are nested. A printable key after an empty newline should not change either height.
|
||||||
|
|
||||||
|
### Preview Windows and Linux window controls on macOS
|
||||||
|
|
||||||
|
Desktop development can replace native macOS traffic lights with Paseo's custom controls:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PASEO_DESKTOP_WINDOW_CONTROLS=windows npm run dev:desktop
|
||||||
|
PASEO_DESKTOP_WINDOW_CONTROLS=linux npm run dev:desktop
|
||||||
|
```
|
||||||
|
|
||||||
|
The override is rejected in packaged builds. Restart the desktop process when changing it.
|
||||||
|
|
||||||
### Desktop macOS compositor watchdog
|
### Desktop macOS compositor watchdog
|
||||||
|
|
||||||
macOS display sleep can leave Chromium's GPU-process display link — the vsync
|
macOS display sleep can leave Chromium's GPU-process display link — the vsync
|
||||||
@@ -545,12 +556,15 @@ npm run cli -- daemon status # Check daemon status
|
|||||||
npm run cli -- clone owner/repo --dir ~/workspace # Clone GitHub repo and register project
|
npm run cli -- clone owner/repo --dir ~/workspace # Clone GitHub repo and register project
|
||||||
```
|
```
|
||||||
|
|
||||||
Use `--host <host:port>` to point the CLI at a different daemon:
|
Use `--host` to point the CLI at a different daemon:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run cli -- --host localhost:7777 ls -a
|
npm run cli -- ls -a --host localhost:7777
|
||||||
|
npm run cli -- ls -a --host ssh://user@host
|
||||||
```
|
```
|
||||||
|
|
||||||
|
In an SSH URI, the URL port is the SSH server port. The remote daemon defaults to `127.0.0.1:6767`; use `?daemonPort=7777` to override it. The transport runs non-interactively through the local OpenSSH client and never installs, starts, or configures the remote daemon. User-facing setup and troubleshooting live in [public-docs/connectivity.md](../public-docs/connectivity.md#ssh).
|
||||||
|
|
||||||
Desktop integrations can focus an existing agent without creating one or
|
Desktop integrations can focus an existing agent without creating one or
|
||||||
sending a message. Use `paseo://h/<server-id>/agent/<agent-id>`, or run
|
sending a message. Use `paseo://h/<server-id>/agent/<agent-id>`, or run
|
||||||
`paseo agent open <agent-id>`. The CLI reads the local daemon's server ID by
|
`paseo agent open <agent-id>`. The CLI reads the local daemon's server ID by
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# Explorer sidebar and side pane
|
||||||
|
|
||||||
|
The Explorer sidebar and the side pane share panel implementations, but they have different shell
|
||||||
|
contracts.
|
||||||
|
|
||||||
|
| Surface | Purpose | Lifecycle |
|
||||||
|
| ---------------- | ---------------------------- | ------------------------------------------ |
|
||||||
|
| Explorer sidebar | Files and Changes navigation | Cmd+E shows or hides the dedicated dock |
|
||||||
|
| Side pane | Ordinary workspace content | Created and closed like any workspace pane |
|
||||||
|
|
||||||
|
## Panel host contract
|
||||||
|
|
||||||
|
Every desktop panel registers its supported `PaneHost` values and presentation. Launchers derive
|
||||||
|
fixed-target labels and icons from that registration, filter by host, and never substitute one
|
||||||
|
panel type for another. Tab moves reject unsupported destinations, and placement resolves only to
|
||||||
|
a compatible pane.
|
||||||
|
|
||||||
|
Files and Changes are the Explorer defaults and its singleton navigation views. Other compatible
|
||||||
|
tabs, including agents, terminals, files, and diffs, can move between Explorer and main panes.
|
||||||
|
Keep panel implementations independent of either shell. `WorkspacePanelHost` owns mounting and
|
||||||
|
retention, while each shell owns its tabs, focus, dragging, resizing, and shortcuts.
|
||||||
|
|
||||||
|
## Explorer sidebar
|
||||||
|
|
||||||
|
`packages/app/src/workspace-tabs/explorer-sidebar.ts` owns show, hide, toggle, and view selection.
|
||||||
|
On desktop, the shell is rendered outside the workspace split canvas so it divides the full
|
||||||
|
workspace, including the header. It has its own persisted width and resize handle. Main-pane splits
|
||||||
|
never read or modify that width.
|
||||||
|
|
||||||
|
`packages/app/src/workspace-tabs/open-supporting-view.ts` owns semantic Changes and pull-request
|
||||||
|
opens. Compact and wide native layouts select the matching Explorer tab. Desktop Changes opens
|
||||||
|
follow the shared diff preference. Desktop pull requests use their Main panel, On the side, or
|
||||||
|
Explorer sidebar setting. Callers request the content and never choose the shell.
|
||||||
|
The composer Changes pill is a two-stage desktop action: it first reveals Explorer on Changes, then
|
||||||
|
routes later presses to the working diff through the shared diff preference.
|
||||||
|
|
||||||
|
The persisted layout still contains the Explorer pane so tabs survive reloads. The renderer removes
|
||||||
|
that pane from the workspace split tree and docks it separately. Persisted identifiers retain the
|
||||||
|
literal `"explorer"` pane id and `explorerPaneIdByWorkspace` key for compatibility.
|
||||||
|
|
||||||
|
The tab rail has no inline add or close controls. Its context menu opens a New Tab launcher and
|
||||||
|
toggles the singleton Files and Changes views. Individual tab menus close instances or move
|
||||||
|
compatible tabs to main. Explorer tabs can be reordered, but the dock cannot be split. Selecting
|
||||||
|
an Explorer tab does not change workspace focus.
|
||||||
|
|
||||||
|
Cmd+E shows or hides Explorer without changing its selected view. Compact layouts use the combined
|
||||||
|
full-screen Explorer overlay for Changes, Files, and pull requests, and close it after a file opens.
|
||||||
|
Wide native layouts without pane splits use the same combined content in a resizable inline dock;
|
||||||
|
opening a file leaves that dock visible. Both presentations keep their selection in the panel store
|
||||||
|
and reuse the layout store's per-workspace Explorer width. They do not create a second Explorer
|
||||||
|
lifecycle.
|
||||||
|
|
||||||
|
## Side pane
|
||||||
|
|
||||||
|
`packages/app/src/workspace-tabs/open-beside.ts` owns content opened beside the user's work. The
|
||||||
|
layout store remembers one ordinary pane per workspace. The first side open creates a full-height
|
||||||
|
right split around the workspace root; later side opens reuse it.
|
||||||
|
|
||||||
|
Closing the pane or moving away its final tab removes it normally and clears the remembered id. A
|
||||||
|
later side open creates a new pane. There is no hidden side-pane lifecycle.
|
||||||
|
|
||||||
|
Placement intent still controls existing tabs:
|
||||||
|
|
||||||
|
| Mode | New target | Existing target |
|
||||||
|
| --------- | --------------------------- | --------------------------------- |
|
||||||
|
| `pane` | opens in the requested pane | moves to the requested pane |
|
||||||
|
| `prefer` | opens in the requested pane | stays where the user placed it |
|
||||||
|
| `focused` | opens in the focused pane | focuses it where it already lives |
|
||||||
|
| `ambient` | opens in a compatible pane | focuses it where it already lives |
|
||||||
|
|
||||||
|
Explicit **Open to Side** uses `pane`. Implicit opens use `prefer`, so a preference affects only a
|
||||||
|
new target and never yanks an existing tab out of a user-selected pane.
|
||||||
|
|
||||||
|
## Routing preferences
|
||||||
|
|
||||||
|
Desktop **Settings → Layout → Open location** has independent Main panel or On the side choices for
|
||||||
|
Explorer Files, diffs, chat files, files opened from diffs, and subagents. They default to Main
|
||||||
|
panel. Mobile ignores them.
|
||||||
|
|
||||||
|
Pull requests have a three-way open location: Main panel, On the side, or Explorer sidebar. Explorer
|
||||||
|
sidebar is the default. Compact layouts always open pull requests in Explorer regardless of this
|
||||||
|
desktop preference.
|
||||||
|
|
||||||
|
Panels request an implicit open through the narrow `openPreferredTarget(target, source)` pane
|
||||||
|
contract. Entry points outside panels use `openPreferredWorkspaceTarget`. Do not branch on a
|
||||||
|
specific shell inside a panel.
|
||||||
+3
-1
@@ -67,7 +67,9 @@ that decision; if no host route is mounted yet, it falls back to ordinary route
|
|||||||
navigation.
|
navigation.
|
||||||
|
|
||||||
Those hidden entries are not harmless: composer floating panels can measure
|
Those hidden entries are not harmless: composer floating panels can measure
|
||||||
against the wrong deck and disappear offscreen.
|
against the wrong deck and disappear offscreen. Follow the
|
||||||
|
[retained panel measurement rules](coding-standards.md#retained-panel-measurements)
|
||||||
|
for components that intentionally remain mounted while hidden.
|
||||||
|
|
||||||
Hidden host routes may keep their local params while an app-wide route is
|
Hidden host routes may keep their local params while an app-wide route is
|
||||||
foregrounded. Active-workspace observers must prefer the current pathname and
|
foregrounded. Active-workspace observers must prefer the current pathname and
|
||||||
|
|||||||
@@ -218,3 +218,8 @@ are stale, run `npm run build:server`.
|
|||||||
(`manual`) from blocking jobs (`manual` plus `action_required`). A failed
|
(`manual`) from blocking jobs (`manual` plus `action_required`). A failed
|
||||||
allowed-failure job carries the `warning` trait, and `canceling` remains active
|
allowed-failure job carries the `warning` trait, and `canceling` remains active
|
||||||
until GitLab reports a terminal status.
|
until GitLab reports a terminal status.
|
||||||
|
- GitHub PR polling owns one account-wide GraphQL budget. Coordinate retained
|
||||||
|
targets per host, batch their reads, and stop until GitHub's reset time when
|
||||||
|
the reserve is exhausted. Never add a per-target GitHub request to the poll
|
||||||
|
path. Resolve fork PRs through their parent repository without abandoning the
|
||||||
|
shared batch.
|
||||||
|
|||||||
+2
-1
@@ -19,7 +19,8 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
|||||||
- **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo.
|
- **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo.
|
||||||
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym.
|
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym.
|
||||||
- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label.
|
- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label.
|
||||||
- **Side panel** — The companion surface beside the user's work, holding Changes, Files, the pull request, and tabs an agent opens alongside its output. UI: "Side panel". Code: `packages/app/src/workspace-tabs/side-panel.ts`; the persisted pane id and layout key keep the pre-rename `explorer` spelling. See [side-panel.md](side-panel.md). Forbidden: "Explorer" as the name of the surface — **File Explorer** is only the Files view inside it.
|
- **Explorer sidebar** — The dedicated navigation dock containing the Files and Changes trees. UI: "Explorer sidebar". Code: `packages/app/src/workspace-tabs/explorer-sidebar.ts`; persisted pane identifiers retain the `explorer` spelling. See [explorer-sidebar.md](explorer-sidebar.md).
|
||||||
|
- **Side pane** — An ordinary right-side workspace pane used for content opened beside the user's work. It closes and participates in workspace splits like every other main-host pane. See [explorer-sidebar.md](explorer-sidebar.md#side-pane). Forbidden: "Side panel".
|
||||||
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||||
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||||
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.
|
- **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`.
|
||||||
|
|||||||
+111
-16
@@ -1,9 +1,9 @@
|
|||||||
# Local plugins
|
# Plugins
|
||||||
|
|
||||||
Local plugins contribute daemon RPCs, native app surfaces, workspace panels, Command Center items,
|
Local plugins contribute daemon RPCs, native app surfaces, workspace panels, Command Center items,
|
||||||
app themes, and composer attachment sources from one `index.ts`. Paseo executes the server contribution in a
|
composer pills, app themes, and composer attachment sources from one `index.ts`. Paseo executes the server contribution in a
|
||||||
subprocess and evaluates the client contribution in the app runtime. Plugin code is trusted code;
|
subprocess and evaluates the client contribution in the app runtime. Plugin code is trusted code;
|
||||||
this first slice does not sandbox it.
|
Paseo does not sandbox it.
|
||||||
|
|
||||||
## Install a directory source
|
## Install a directory source
|
||||||
|
|
||||||
@@ -74,8 +74,33 @@ backend code can access the daemon machine, while client contributions run insid
|
|||||||
|
|
||||||
Source changes are explicit. Run `paseo plugin reload <id>` to stop and fully tear down the old
|
Source changes are explicit. Run `paseo plugin reload <id>` to stop and fully tear down the old
|
||||||
plugin before compiling and starting from disk. A failed reload stays failed; Paseo does not restore
|
plugin before compiling and starting from disk. A failed reload stays failed; Paseo does not restore
|
||||||
the old code. Use `enable`, `disable`, and `remove` to manage one plugin. Remove deletes only its
|
the old code. Use `enable`, `disable`, and `remove` to manage one plugin. Removing a directory source
|
||||||
configuration, never its source directory. The global `pluginsEnabled` switch remains available.
|
never deletes it. The global `pluginsEnabled` switch remains available.
|
||||||
|
|
||||||
|
## Install a Git source
|
||||||
|
|
||||||
|
GitHub repositories use an `owner/repository` shorthand. Other hosts use a Git URL. An existing
|
||||||
|
directory always wins over shorthand resolution.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
paseo plugin add owner/repository
|
||||||
|
paseo plugin add https://git.example.com/owner/repository.git
|
||||||
|
paseo plugin add owner/monorepo --path plugins/review
|
||||||
|
paseo plugin add owner/repository --ref main
|
||||||
|
paseo plugin status
|
||||||
|
paseo plugin update review
|
||||||
|
paseo plugin update --all
|
||||||
|
```
|
||||||
|
|
||||||
|
Omitting `--ref` tracks the remote's default branch. A branch passed with `--ref` also tracks;
|
||||||
|
tags and commits stay pinned. `status` fetches tracked refs and reports the installed and available
|
||||||
|
commits. `update` checks out a new version, validates its manifest, compiles both bundles, and starts
|
||||||
|
it before changing the configured path. A compilation or startup failure restores the running
|
||||||
|
version. Removing a Git source deletes Paseo's managed checkout.
|
||||||
|
|
||||||
|
Git installation does not run a package manager or install script. A distributable source plugin
|
||||||
|
uses Paseo's host-provided modules or commits any other bundled source it needs. Native React Native
|
||||||
|
dependencies work only when the Paseo app already ships the native module.
|
||||||
|
|
||||||
Server contributions can write to stdout and stderr with normal Node logging. Paseo adds `[paseo]`
|
Server contributions can write to stdout and stderr with normal Node logging. Paseo adds `[paseo]`
|
||||||
entries for loading, ready, stopping, and stopped transitions. Compilation and load failures are
|
entries for loading, ready, stopping, and stopped transitions. Compilation and load failures are
|
||||||
@@ -96,15 +121,20 @@ code lives behind filename boundaries:
|
|||||||
| `*.server.ts` | Node APIs, filesystem and process access, credentials, and handlers. |
|
| `*.server.ts` | Node APIs, filesystem and process access, credentials, and handlers. |
|
||||||
| `*.shared.ts` | Zod RPC contracts and plain values used by both runtimes. |
|
| `*.shared.ts` | Zod RPC contracts and plain values used by both runtimes. |
|
||||||
|
|
||||||
Shared files import contracts from `@getpaseo/plugin/server`. Client files import hooks from
|
Shared files import contracts from `@getpaseo/plugin/server`. Client files import Paseo UI from
|
||||||
`@getpaseo/plugin`. Plugin UI runs on desktop and mobile across multiple themes: color every
|
`@getpaseo/plugin/react-native`. Its `Icon` resolves a Lucide name using the client's installed icon
|
||||||
`Text` from `theme.colors.foreground` or `theme.colors.foregroundMuted`, and size layout from
|
set; an unknown name renders nothing so it cannot break the plugin surface.
|
||||||
`layout.compact`. See `public-docs/plugins/reference.md`.
|
Its controlled modal keeps presentation metadata on `<Modal title="…" icon={…}>` and body UI in
|
||||||
|
`<Modal.Content>`.
|
||||||
|
Plugin UI runs on desktop and mobile across multiple themes: color every `Text` from
|
||||||
|
`theme.colors.foreground` or `theme.colors.foregroundMuted`, and size layout from `layout.compact`.
|
||||||
|
See `public-docs/plugins/reference.md`.
|
||||||
|
|
||||||
| Module | Use it for |
|
| Module | Use it for |
|
||||||
| ------------------------- | -------------------------------------------------------- |
|
| ------------------------------- | -------------------------------------------------------- |
|
||||||
| `@getpaseo/plugin` | hooks and UI types |
|
| `@getpaseo/plugin` | contribution contracts and client data hooks |
|
||||||
| `@getpaseo/plugin/server` | `defineRpc`, `defineAttachmentSource`, and handler types |
|
| `@getpaseo/plugin/react-native` | Paseo React Native components and UI hooks |
|
||||||
|
| `@getpaseo/plugin/server` | `defineRpc`, `defineAttachmentSource`, and handler types |
|
||||||
|
|
||||||
The compiler removes client registrations and imports from the server entry point, and server
|
The compiler removes client registrations and imports from the server entry point, and server
|
||||||
registrations and imports from the client entry point. Importing a `*.server` module from a client
|
registrations and imports from the client entry point. Importing a `*.server` module from a client
|
||||||
@@ -139,8 +169,8 @@ RPC contracts validate inputs and outputs in both the app and plugin subprocess.
|
|||||||
typed async function. Use the host-provided `@tanstack/react-query` for request state and caching;
|
typed async function. Use the host-provided `@tanstack/react-query` for request state and caching;
|
||||||
Paseo gives each plugin installation its own query client.
|
Paseo gives each plugin installation its own query client.
|
||||||
|
|
||||||
`usePaseo()` and the handler's `{ paseo }` context expose the same `PaseoApi`: workspaces, agents,
|
`usePaseo()` and the handler's `{ paseo }` context expose the same `PaseoApi`: projects,
|
||||||
providers, and daemon config. They do not expose connection lifecycle. A surface borrows the
|
workspaces, agents, providers, and daemon config. They do not expose connection lifecycle. A surface borrows the
|
||||||
selected host's existing connection; switching the screen's host changes both `usePaseo()` and
|
selected host's existing connection; switching the screen's host changes both `usePaseo()` and
|
||||||
`useRpc()` to that host. An offline selected host fails there and never falls through to another
|
`useRpc()` to that host. An offline selected host fails there and never falls through to another
|
||||||
installation. A server handler owns an IPC-backed daemon session for the life of its subprocess.
|
installation. A server handler owns an IPC-backed daemon session for the life of its subprocess.
|
||||||
@@ -165,12 +195,76 @@ RPC. Snapshot DTOs are deeply readonly and frozen at runtime so plugin code cann
|
|||||||
app state or a memoized selection. Panels use one persisted
|
app state or a memoized selection. Panels use one persisted
|
||||||
`plugin` workspace-tab target, so reload, disable, removal, and restoration resolve through the
|
`plugin` workspace-tab target, so reload, disable, removal, and restoration resolve through the
|
||||||
current installed-plugin catalog. A missing contribution renders unavailable inside the tab.
|
current installed-plugin catalog. A missing contribution renders unavailable inside the tab.
|
||||||
|
Panels declare `locations: ["workspace", "explorer"]` to opt into Explorer hosting; omission means
|
||||||
|
workspace only. Location controls hosting, not context. An agent panel target keeps its `agentId`
|
||||||
|
when moved between hosts. Explorer configuration can create workspace-context panels and remove
|
||||||
|
existing agent-context instances, but it cannot create an agent panel without an agent-aware command.
|
||||||
|
|
||||||
Command Center callbacks use the selected host's existing `PaseoApi` for normal Paseo operations.
|
Command Center callbacks use the selected host's existing `PaseoApi` for normal Paseo operations.
|
||||||
They use typed plugin RPC only for plugin-specific backend work. Navigation is limited to the
|
They use typed plugin RPC only for plugin-specific backend work. Navigation is limited to the
|
||||||
plugin's registered global surfaces and workspace panels; plugins do not receive Expo Router or
|
plugin's registered global surfaces and workspace panels; plugins do not receive Expo Router or
|
||||||
workspace-layout store access.
|
workspace-layout store access.
|
||||||
|
|
||||||
|
## Contribute composer pills
|
||||||
|
|
||||||
|
Register a headless client entrypoint, then add and remove targeted pills from that client
|
||||||
|
lifecycle. `addClientSide` runs once per plugin installation in each connected app and never runs
|
||||||
|
in the daemon subprocess. It can subscribe to the client API, call plugin RPCs, and own arbitrary
|
||||||
|
client state without mounting a panel or surface.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export function contributeClient(client: PluginClientContext) {
|
||||||
|
const pills = new Map<string, () => void>();
|
||||||
|
const unsubscribe = client.paseo.agents.subscribe((update) => {
|
||||||
|
if (update.kind !== "upsert" || !update.agent.workspaceId) return;
|
||||||
|
const { id: agentId, workspaceId } = update.agent;
|
||||||
|
pills.get(agentId)?.();
|
||||||
|
pills.set(
|
||||||
|
agentId,
|
||||||
|
client.addComposerPill({
|
||||||
|
id: "review",
|
||||||
|
title: "Open review",
|
||||||
|
workspaceId,
|
||||||
|
agentId,
|
||||||
|
Component: ReviewPill,
|
||||||
|
async onPress() {
|
||||||
|
await client.rpc(refreshReview, { agentId });
|
||||||
|
client.openPanel("review", { workspaceId, agentId });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
for (const remove of pills.values()) remove();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Wire it from `index.ts` with `plugin.addClientSide(contributeClient)`. `addComposerPill` exists only
|
||||||
|
on `PluginClientContext`; it returns an idempotent removal function. A pill appears only in the
|
||||||
|
matching workspace and agent track bar alongside Tasks and Subagents. Paseo owns the pressable,
|
||||||
|
shared chrome, pending state, error reporting, and placement. The component owns its icon and text;
|
||||||
|
the callback is client code by construction. Removing the pill, reloading the plugin, disconnecting
|
||||||
|
the host, or unloading the app tears down the contribution.
|
||||||
|
|
||||||
|
## Contribute timeline items
|
||||||
|
|
||||||
|
Timeline transformers and renderers are client contributions. The daemon's canonical rows and
|
||||||
|
built-in projection stay unchanged. The app transforms fetched projected history before building
|
||||||
|
its render model. A matching live event requests a fresh projected tail, so lifecycle deltas are
|
||||||
|
collapsed before the transformer replaces anything.
|
||||||
|
|
||||||
|
`query.itemType` selects one public `AgentTimelineItem.type`. The callback owns any detailed
|
||||||
|
recognition and returns plain plugin item objects. `undefined` keeps the source item, `items`
|
||||||
|
replaces it, and an empty array removes it. Output `data` must be JSON-compatible. Paseo adds the
|
||||||
|
runtime plugin ID, preserves the source timeline cursor, validates renderer data with its Zod
|
||||||
|
schema, and mounts the component inside the normal plugin runtime and error boundary.
|
||||||
|
|
||||||
|
Transformers run synchronously and must be deterministic. When several transformers match, the
|
||||||
|
first one that returns a result owns that source item. Plugin and registration ordering is stable.
|
||||||
|
See `plugin-examples/timeline-items` for the complete contract.
|
||||||
|
|
||||||
## Contribute composer attachments
|
## Contribute composer attachments
|
||||||
|
|
||||||
Register a declarative attachment source backed by a plugin RPC. Paseo owns the attachment menu,
|
Register a declarative attachment source backed by a plugin RPC. Paseo owns the attachment menu,
|
||||||
@@ -218,4 +312,5 @@ against the installed catalog on every change; an id nothing contributes falls b
|
|||||||
preference instead of painting the reserved slot's placeholder colors.
|
preference instead of painting the reserved slot's placeholder colors.
|
||||||
|
|
||||||
See `plugin-examples/local-plugin` for a native surface, `plugin-examples/linear` for a complete
|
See `plugin-examples/local-plugin` for a native surface, `plugin-examples/linear` for a complete
|
||||||
attachment-source example, and `plugin-examples/catppuccin` for a theme.
|
attachment-source example, `plugin-examples/timeline-items` for timeline projection, and
|
||||||
|
`plugin-examples/catppuccin` for a theme.
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@ Freedom and flexibility. Every design decision follows from this:
|
|||||||
- **Cross-device** — Desktop, mobile, web, CLI. Start work at your desk, check progress from your phone, script from the terminal.
|
- **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.
|
- **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
|
- **Respectful** - No telemetry, no forced cloud, no forced accounts
|
||||||
- **Open source** — AGPL-3.0. Users can inspect, fork, and contribute.
|
- **Open source** — Apache-2.0. Users can inspect, fork, and contribute.
|
||||||
- **BYOK** — Bring your own keys. Use your subsidized plans and first-party provider pricing. Paseo adds zero cost on top.
|
- **BYOK** — Bring your own keys. Use your subsidized plans and first-party provider pricing. Paseo adds zero cost on top.
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
# Side panel
|
|
||||||
|
|
||||||
The companion surface beside the user's work: Changes, Files, the pull request, and
|
|
||||||
anything an agent asks to show alongside its output.
|
|
||||||
|
|
||||||
Everything that opens, reveals, hides, or asks about it goes through
|
|
||||||
`packages/app/src/workspace-tabs/side-panel.ts`. Nothing else branches on which
|
|
||||||
surface the current layout uses.
|
|
||||||
|
|
||||||
## Three renderings, two facts
|
|
||||||
|
|
||||||
| Layout | Rendering |
|
|
||||||
| ---------------------- | ----------------------------------------------- |
|
|
||||||
| compact | slide-over overlay, owned by the panel store |
|
|
||||||
| non-compact + splits | dedicated right pane, owned by the layout store |
|
|
||||||
| non-compact, no splits | ordinary tabs in the focused pane |
|
|
||||||
|
|
||||||
The third case is the one that bites. A native tablet renders one pane at a time and
|
|
||||||
the tab row only lists the focused pane's tabs, so anything routed into a separate
|
|
||||||
side panel pane there disappears with no way back.
|
|
||||||
|
|
||||||
## Placement
|
|
||||||
|
|
||||||
Where a tab lands is an intent the caller declares, not something the store infers
|
|
||||||
from a `paneId` that happens to be in scope. `WorkspaceTabPlacement`
|
|
||||||
(`packages/app/src/stores/workspace-layout-actions.ts`) has four modes, and the
|
|
||||||
difference between them only shows up when the tab is already open somewhere:
|
|
||||||
|
|
||||||
| Mode | New tab | Tab already open |
|
|
||||||
| --------- | ----------------- | ---------------------------- |
|
|
||||||
| `pane` | opens in the pane | **moves** to the pane |
|
|
||||||
| `prefer` | opens in the pane | stays where the user left it |
|
|
||||||
| `focused` | focused pane | focuses it in place |
|
|
||||||
| `ambient` | focused pane | focuses it in place |
|
|
||||||
|
|
||||||
`pane` is a pane-local affordance — that pane's `+` menu, its empty-state launcher.
|
|
||||||
The user is placing the tab and expects it to arrive.
|
|
||||||
|
|
||||||
`prefer` is an implicit supporting open — an agent's file link or a failed workspace
|
|
||||||
setup. It has an opinion about new tabs only. Yanking a tab out of the pane the user
|
|
||||||
deliberately moved it to is the failure this mode exists to prevent.
|
|
||||||
|
|
||||||
`ambient` differs from `focused` in one rule: reconciliation opens tabs with nobody
|
|
||||||
behind the click, and the side panel can hold focus from an earlier reveal, so an
|
|
||||||
ambient open of something the user works _in_ (an agent, a terminal, a browser) is
|
|
||||||
pushed out of the side panel. A `focused` open honours the focused pane whatever it
|
|
||||||
is, because the user is standing in it.
|
|
||||||
|
|
||||||
Deduplication lives in `openTabInLayoutFocused`, so it obeys the placement intent for
|
|
||||||
every caller. Do not re-place a tab after opening it.
|
|
||||||
|
|
||||||
## Lifecycle
|
|
||||||
|
|
||||||
The side panel lives in the ordinary split tree, so it is a pane like any other until
|
|
||||||
it empties or the user dismisses it:
|
|
||||||
|
|
||||||
- **Close pane** on the side panel **hides** it; its tabs are waiting on the next reveal.
|
|
||||||
- **Close pane** on any other pane **removes** it.
|
|
||||||
- Closing the Side panel's final tab hides the pane. Revealing it returns the empty launcher.
|
|
||||||
This is close-only: claiming or dragging that tab to another pane leaves the empty Side panel
|
|
||||||
visible.
|
|
||||||
- Nothing takes the last visible pane away. `closePane` will not remove it, and
|
|
||||||
neither `closePane` nor `hideSidePanel` will hide it — including the header toggle
|
|
||||||
reaching `hideSidePanel` after every other pane is gone. The gate is one check in
|
|
||||||
`setPaneHiddenInLayout`; do not add a second one upstream.
|
|
||||||
|
|
||||||
`closePane` in the layout store owns that distinction. React renders the button and
|
|
||||||
does not decide what it means.
|
|
||||||
|
|
||||||
Dragging a pane's last tab into another pane collapses the source pane — except the
|
|
||||||
side panel, which the user summons and expects to find again, split size and all.
|
|
||||||
|
|
||||||
## What the side panel never does
|
|
||||||
|
|
||||||
It does not seed itself. Revealing it opens an empty pane with the launcher; the user
|
|
||||||
picks what goes in. Detecting a pull request makes the launcher's card available and
|
|
||||||
opens nothing. A failed workspace setup adds a background tab to the main pane.
|
|
||||||
Running and successful setup never seed tabs.
|
|
||||||
|
|
||||||
## Routing preference
|
|
||||||
|
|
||||||
**Settings → General → Open supporting tabs in the Side panel**, on by default,
|
|
||||||
persisted in client app settings as `openSupportingTabsInSidePanel`. Off means new
|
|
||||||
supporting tabs land in the focused pane instead. Mobile ignores it. Explicit actions
|
|
||||||
— the header toggle, a pane's `+` menu, the empty-state launcher — are never subject
|
|
||||||
to it. The Changes keyboard shortcut and the diff-stat pill above an agent composer are
|
|
||||||
supporting opens: they follow the preference and focus an existing Changes tab wherever
|
|
||||||
the user left it. When Changes is already visible in the Side panel, the diff-stat pill
|
|
||||||
hides the panel without closing the tab. A different visible Side panel tab switches to
|
|
||||||
Changes instead.
|
|
||||||
|
|
||||||
## The name
|
|
||||||
|
|
||||||
The product surface is the **Side panel**. **File Explorer** is still the name of the
|
|
||||||
Files view inside it. Persisted identifiers keep their pre-rename spelling: the pane
|
|
||||||
id is the literal string `"explorer"` (`SIDE_PANEL_PANE_ID`) and the persisted layout
|
|
||||||
key is `explorerPaneIdByWorkspace`. The persisted state schema is strict, so renaming
|
|
||||||
either would fail every saved blob and wipe the layout it describes.
|
|
||||||
@@ -186,9 +186,13 @@ Test suites in this repo are heavy. Running them in bulk freezes the machine, es
|
|||||||
- For full-suite confidence, push to CI and check GitHub Actions.
|
- For full-suite confidence, push to CI and check GitHub Actions.
|
||||||
- Never run the full Playwright E2E suite locally — defer whole-suite verification to CI. Targeted Playwright specs are allowed when you changed or need to prove that specific flow.
|
- Never run the full Playwright E2E suite locally — defer whole-suite verification to CI. Targeted Playwright specs are allowed when you changed or need to prove that specific flow.
|
||||||
- App Playwright shares one warmed Metro server per run and gives every Playwright worker its own isolated daemon and `PASEO_HOME`. Spec files run concurrently without exposing one file's projects, agents, terminals, history, or provider configuration to another worker; tests within a file remain together so file-level setup is not repeated.
|
- App Playwright shares one warmed Metro server per run and gives every Playwright worker its own isolated daemon and `PASEO_HOME`. Spec files run concurrently without exposing one file's projects, agents, terminals, history, or provider configuration to another worker; tests within a file remain together so file-level setup is not repeated.
|
||||||
|
- Playwright specs that exercise only the daemon import `daemonTest` from the shared fixtures so they do not create a browser context or page.
|
||||||
- Helpers that create projects or workspaces own those records until cleanup. Their clients remove the daemon project on close, and an automatic fixture fails any test that still leaks a project record. Deleting only the temporary directory is not cleanup. Agent helpers pass the intended `workspaceId` through to agent creation; they never infer ownership from `cwd`.
|
- Helpers that create projects or workspaces own those records until cleanup. Their clients remove the daemon project on close, and an automatic fixture fails any test that still leaks a project record. Deleting only the temporary directory is not cleanup. Agent helpers pass the intended `workspaceId` through to agent creation; they never infer ownership from `cwd`.
|
||||||
- Tests whose subject is daemon-global state, such as an empty history or daemon restart, start a dedicated host explicitly. Filenames and directories describe product behavior, never execution order or isolation mechanics.
|
- Tests whose subject is daemon-global state, such as an empty history or daemon restart, start a dedicated host explicitly. Filenames and directories describe product behavior, never execution order or isolation mechanics.
|
||||||
- Global setup accepts Metro as ready only when `/status` returns `packager-status:running`, then fetches the document's scripts so the cold bundle compilation finishes before Playwright's per-test timeout starts. A generic TCP listener is not sufficient readiness evidence. The browser suite uses direct local daemon connections and does not start a relay.
|
- Global setup accepts Metro as ready only when `/status` returns `packager-status:running`, then fetches the document's scripts so the cold bundle compilation finishes before Playwright's per-test timeout starts. A generic TCP listener is not sufficient readiness evidence. The browser suite uses direct local daemon connections and does not start a relay.
|
||||||
|
- The app Playwright harness boots on Windows as well as POSIX. Spawn Node entrypoints through `process.execPath`, not `npx` or `node_modules/.bin` shims: Node refuses to spawn `.cmd` or `.bat` without `shell: true`, and shell mode concatenates argv without escaping and sends kill signals to `cmd.exe` instead of the real child.
|
||||||
|
- Teardown kills the process tree, because a Windows signal reaches only the direct child and leaves forked workers holding the listening port.
|
||||||
|
- The `asdf`-backed local Elixir relay stays POSIX-only, so the `relay-deployment` Playwright project is unavailable on Windows.
|
||||||
|
|
||||||
## Pull-request test routing
|
## Pull-request test routing
|
||||||
|
|
||||||
|
|||||||
+12
-10
@@ -90,26 +90,28 @@ history start; a response requested from a pre-replacement range is stale and is
|
|||||||
|
|
||||||
## Client replica lifetime
|
## Client replica lifetime
|
||||||
|
|
||||||
The host runtime owns each session replica for as long as the host remains registered. React
|
The session projection remains host-scoped for as long as the host is registered. The viewed-timeline
|
||||||
providers attach message handlers and UI integrations to that replica, but mounting or unmounting a
|
owner wraps cached preparation, network catch-up, accepted timeline application, and persistence
|
||||||
provider must not create or clear it. A provider can remount during Fast Refresh or ordinary UI
|
behind one interface. React supplies transport and projection operations without selecting a cache
|
||||||
recomposition while the runtime still owns the same directory snapshot and timeline cursors.
|
path or issuing a separate persistence notification.
|
||||||
|
|
||||||
Removing the host from the registry is the destructive boundary: it stops the runtime and clears the
|
Removing the host from the registry is the destructive boundary: it stops the runtime and clears the
|
||||||
session and host-scoped setup state together.
|
session and host-scoped setup state together.
|
||||||
|
|
||||||
The durable replica cache persists synchronization authority only when it can store the complete
|
The timeline owner asks durable replica storage for an agent when that agent becomes visible. An
|
||||||
current canonical window losslessly. The stored range describes those exact items: `startSeq` drives
|
accepted row paints immediately before subscription acknowledgement or timeline fetch. The stored
|
||||||
older pagination and `endSeq` drives forward catch-up. Restore paints the items immediately, requests
|
range describes those exact items: `startSeq` drives older pagination and `endSeq` drives forward
|
||||||
`after endSeq`, and requests `before startSeq` when the user loads older history.
|
catch-up. The owner requests `after endSeq`, and requests `before startSeq` when the user loads older
|
||||||
|
history. Code outside the owner does not distinguish cached and network timelines.
|
||||||
|
|
||||||
The first resume request is bounded. If it reports more newer history, fetch one latest bounded tail
|
The first resume request is bounded. If it reports more newer history, fetch one latest bounded tail
|
||||||
instead of replaying every missed page. Live gap recovery still pages forward until current.
|
instead of replaying every missed page. Live gap recovery still pages forward until current.
|
||||||
|
|
||||||
If the canonical window exceeds the cache item limit, contains a discontiguous retained range, has a
|
If the canonical window exceeds the cache item limit, contains a discontiguous retained range, has a
|
||||||
live head, or includes presentation data the cache cannot encode losslessly, persistence drops the
|
live head, or includes presentation data the cache cannot encode losslessly, persistence drops the
|
||||||
range and keeps a display-only tail. Restore then uses the ordinary bounded `tail` bootstrap. Never
|
range and keeps a display-only tail. Never slice items while retaining the pre-slice range; that
|
||||||
slice items while retaining the pre-slice range; that falsely certifies discarded source rows.
|
falsely certifies discarded source rows. A display-only row paints without granting synchronization
|
||||||
|
authority, so the owner uses the ordinary bounded `tail` bootstrap.
|
||||||
|
|
||||||
Live rows received between cache paint and catch-up stay in the separate live head and reconcile with
|
Live rows received between cache paint and catch-up stay in the separate live head and reconcile with
|
||||||
the authoritative range through the existing forward-page path. The cache does not persist sync
|
the authoritative range through the existing forward-page path. The cache does not persist sync
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
sha256-13Q44pEU+V2M/wnitVAlBarYxAqWkGkXrE+w5qUCf7o=
|
sha256-zn0UJm7RPR5mlCvKTddcq0bIugrp6VcRBPdwJX8kJIg=
|
||||||
|
|||||||
Generated
+158
-156
@@ -1,14 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.5.1",
|
"version": "0.7.0-beta.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.5.1",
|
"version": "0.7.0-beta.2",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "Apache-2.0",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/expo-two-way-audio",
|
"packages/expo-two-way-audio",
|
||||||
"packages/highlight",
|
"packages/highlight",
|
||||||
@@ -123,6 +123,134 @@
|
|||||||
"url": "https://github.com/sponsors/antfu"
|
"url": "https://github.com/sponsors/antfu"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@anthropic-ai/claude-agent-sdk": {
|
||||||
|
"version": "0.3.246",
|
||||||
|
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.246.tgz",
|
||||||
|
"integrity": "sha512-FtR0HoHHNqeqJWjZN8qLUAzZVFUI9ztXYNPPwv98Ecmv9qq2QTauI8IzkY26CC0mleWAqb9RQEW2C0OtiUliug==",
|
||||||
|
"license": "SEE LICENSE IN README.md",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.246",
|
||||||
|
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.246",
|
||||||
|
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.246",
|
||||||
|
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.246",
|
||||||
|
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.246",
|
||||||
|
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.246",
|
||||||
|
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.246",
|
||||||
|
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.246"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@anthropic-ai/sdk": ">=0.93.0",
|
||||||
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
|
"zod": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": {
|
||||||
|
"version": "0.3.246",
|
||||||
|
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.246.tgz",
|
||||||
|
"integrity": "sha512-pYDv2RT+UVOvEapMEWKf7Gm5a87Si1Zf+XR23IMGYbG5mESatNJ8Gz2PmkrwfTezZknb0ljWZe8zgcTFITM3dA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "SEE LICENSE IN LICENSE.md",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": {
|
||||||
|
"version": "0.3.246",
|
||||||
|
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.246.tgz",
|
||||||
|
"integrity": "sha512-yl+gMMFUQ8++SBaxUe2JxBqU0LOJO4HZTsWWTOrH5kRlTRhdQDF/lESsoNXCHgfF/+77YNKgef0h+rl/D12Jfg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "SEE LICENSE IN LICENSE.md",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": {
|
||||||
|
"version": "0.3.246",
|
||||||
|
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.246.tgz",
|
||||||
|
"integrity": "sha512-t7AEewD7bcjZYMmrfFLjsUSxPo4z+5J5O5cC9V2zOvAhNkefdMeGWI2/1cdqZxuaOiFVt46byjlZuiNNunF+bQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "SEE LICENSE IN LICENSE.md",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": {
|
||||||
|
"version": "0.3.246",
|
||||||
|
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.246.tgz",
|
||||||
|
"integrity": "sha512-AeiDXveuA9dVRDouSRiQzL40P3dOuJPfZOlwwsFNbVUN0D+06WtwgmeWYnyRG4dxsM67Pf4lYcRZ0x9pfEZUwQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "SEE LICENSE IN LICENSE.md",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": {
|
||||||
|
"version": "0.3.246",
|
||||||
|
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.246.tgz",
|
||||||
|
"integrity": "sha512-BIbBlJ6x7Cys6UFvahUfce26H+U+Q4tLWjKRD39AaV7JvRav8/vfdFytzWq6E+h9hT1OOWYuPYx2gMn5vDhZhQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "SEE LICENSE IN LICENSE.md",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": {
|
||||||
|
"version": "0.3.246",
|
||||||
|
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.246.tgz",
|
||||||
|
"integrity": "sha512-+3PrWX3qXgakO3tZImr7keKFry5XIkbHBEaUxgi3/RtPmxGwS+JdVAr0xxzOoC2F6Y66Kn0lUnztRnZHORBc+Q==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "SEE LICENSE IN LICENSE.md",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": {
|
||||||
|
"version": "0.3.246",
|
||||||
|
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.246.tgz",
|
||||||
|
"integrity": "sha512-JV8b28OJ8d+EMVEVIWlN3aEkT8uSehO3ZN/PJzXFG7Jgn56oVYUew4aY9vK4jao/4E4mdfzvs358w4HcTBUzVw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "SEE LICENSE IN LICENSE.md",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": {
|
||||||
|
"version": "0.3.246",
|
||||||
|
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.246.tgz",
|
||||||
|
"integrity": "sha512-omI65bYGynLpE1Ybijk56K1wBnt0H6WujNwlg1LzIdeHrYmpUxoqncK92xKH5kb9XWUz3byHqArbv7wsT+M/2A==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "SEE LICENSE IN LICENSE.md",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/@anthropic-ai/sdk": {
|
"node_modules/@anthropic-ai/sdk": {
|
||||||
"version": "0.104.2",
|
"version": "0.104.2",
|
||||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.104.2.tgz",
|
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.104.2.tgz",
|
||||||
@@ -36170,7 +36298,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.5.1",
|
"version": "0.7.0-beta.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/commands": "6.10.4",
|
"@codemirror/commands": "6.10.4",
|
||||||
"@codemirror/language": "6.12.4",
|
"@codemirror/language": "6.12.4",
|
||||||
@@ -37235,12 +37363,12 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@getpaseo/cli",
|
"name": "@getpaseo/cli",
|
||||||
"version": "0.5.1",
|
"version": "0.7.0-beta.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/client": "0.5.1",
|
"@getpaseo/client": "0.7.0-beta.2",
|
||||||
"@getpaseo/protocol": "0.5.1",
|
"@getpaseo/protocol": "0.7.0-beta.2",
|
||||||
"@getpaseo/server": "0.5.1",
|
"@getpaseo/server": "0.7.0-beta.2",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
@@ -37487,10 +37615,10 @@
|
|||||||
},
|
},
|
||||||
"packages/client": {
|
"packages/client": {
|
||||||
"name": "@getpaseo/client",
|
"name": "@getpaseo/client",
|
||||||
"version": "0.5.1",
|
"version": "0.7.0-beta.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/protocol": "0.5.1",
|
"@getpaseo/protocol": "0.7.0-beta.2",
|
||||||
"@getpaseo/relay": "0.5.1",
|
"@getpaseo/relay": "0.7.0-beta.2",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -37501,8 +37629,8 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@getpaseo/desktop",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.5.1",
|
"version": "0.7.0-beta.2",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/cli": "*",
|
"@getpaseo/cli": "*",
|
||||||
"@getpaseo/server": "*",
|
"@getpaseo/server": "*",
|
||||||
@@ -37744,7 +37872,7 @@
|
|||||||
},
|
},
|
||||||
"packages/expo-two-way-audio": {
|
"packages/expo-two-way-audio": {
|
||||||
"name": "@getpaseo/expo-two-way-audio",
|
"name": "@getpaseo/expo-two-way-audio",
|
||||||
"version": "0.5.1",
|
"version": "0.7.0-beta.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
@@ -38640,7 +38768,7 @@
|
|||||||
},
|
},
|
||||||
"packages/highlight": {
|
"packages/highlight": {
|
||||||
"name": "@getpaseo/highlight",
|
"name": "@getpaseo/highlight",
|
||||||
"version": "0.5.1",
|
"version": "0.7.0-beta.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/language": "6.12.4",
|
"@codemirror/language": "6.12.4",
|
||||||
"@codemirror/legacy-modes": "^6.5.3",
|
"@codemirror/legacy-modes": "^6.5.3",
|
||||||
@@ -38669,12 +38797,13 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin": {
|
"packages/plugin": {
|
||||||
"name": "@getpaseo/plugin",
|
"name": "@getpaseo/plugin",
|
||||||
"version": "0.5.1",
|
"version": "0.7.0-beta.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"use-sync-external-store": "^1.6.0"
|
"use-sync-external-store": "^1.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@getpaseo/client": "0.5.1",
|
"@getpaseo/client": "0.7.0-beta.2",
|
||||||
|
"@getpaseo/protocol": "0.7.0-beta.2",
|
||||||
"@types/node": "^20.9.0",
|
"@types/node": "^20.9.0",
|
||||||
"@types/react": "~19.2.0",
|
"@types/react": "~19.2.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
@@ -38683,14 +38812,15 @@
|
|||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@getpaseo/client": "0.5.1",
|
"@getpaseo/client": "0.7.0-beta.2",
|
||||||
|
"@getpaseo/protocol": "0.7.0-beta.2",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/protocol": {
|
"packages/protocol": {
|
||||||
"name": "@getpaseo/protocol",
|
"name": "@getpaseo/protocol",
|
||||||
"version": "0.5.1",
|
"version": "0.7.0-beta.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
@@ -38703,7 +38833,7 @@
|
|||||||
},
|
},
|
||||||
"packages/relay": {
|
"packages/relay": {
|
||||||
"name": "@getpaseo/relay",
|
"name": "@getpaseo/relay",
|
||||||
"version": "0.5.1",
|
"version": "0.7.0-beta.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"base64-js": "^1.5.1",
|
"base64-js": "^1.5.1",
|
||||||
"tweetnacl": "^1.0.3",
|
"tweetnacl": "^1.0.3",
|
||||||
@@ -38921,17 +39051,17 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@getpaseo/server",
|
"name": "@getpaseo/server",
|
||||||
"version": "0.5.1",
|
"version": "0.7.0-beta.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.17.1",
|
"@agentclientprotocol/sdk": "^0.17.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "0.3.220",
|
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||||
"@anthropic-ai/sdk": "^0.104.2",
|
"@anthropic-ai/sdk": "^0.104.2",
|
||||||
"@babel/parser": "^7.29.8",
|
"@babel/parser": "^7.29.8",
|
||||||
"@getpaseo/client": "0.5.1",
|
"@getpaseo/client": "0.7.0-beta.2",
|
||||||
"@getpaseo/highlight": "0.5.1",
|
"@getpaseo/highlight": "0.7.0-beta.2",
|
||||||
"@getpaseo/plugin": "0.5.1",
|
"@getpaseo/plugin": "0.7.0-beta.2",
|
||||||
"@getpaseo/protocol": "0.5.1",
|
"@getpaseo/protocol": "0.7.0-beta.2",
|
||||||
"@getpaseo/relay": "0.5.1",
|
"@getpaseo/relay": "0.7.0-beta.2",
|
||||||
"@isaacs/ttlcache": "^2.1.4",
|
"@isaacs/ttlcache": "^2.1.4",
|
||||||
"@modelcontextprotocol/sdk": "1.29.0",
|
"@modelcontextprotocol/sdk": "1.29.0",
|
||||||
"@opencode-ai/sdk": "1.14.46",
|
"@opencode-ai/sdk": "1.14.46",
|
||||||
@@ -38976,134 +39106,6 @@
|
|||||||
"vitest": "^4.1.6"
|
"vitest": "^4.1.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk": {
|
|
||||||
"version": "0.3.220",
|
|
||||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.220.tgz",
|
|
||||||
"integrity": "sha512-glc7SdwPkOkLw8oxwLo9PKTdLJGqW/PIR4urWXFoRtX9YllwozsEVc5Tc1+EvLSkfrsxPJqQWqOgpjUOQXf1oA==",
|
|
||||||
"license": "SEE LICENSE IN README.md",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.0.0"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.220",
|
|
||||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.220",
|
|
||||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.220",
|
|
||||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.220",
|
|
||||||
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.220",
|
|
||||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.220",
|
|
||||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.220",
|
|
||||||
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.220"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@anthropic-ai/sdk": ">=0.93.0",
|
|
||||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
||||||
"zod": "^4.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": {
|
|
||||||
"version": "0.3.220",
|
|
||||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.220.tgz",
|
|
||||||
"integrity": "sha512-7VxlbEosK7DODiOnsjoVd0DSJzbnaPrM2jelMHI0y8zx1UnLS3WC6EFUXbvy74F2sXqEznh2tzn7EKWInaRN6Q==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "SEE LICENSE IN LICENSE.md",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": {
|
|
||||||
"version": "0.3.220",
|
|
||||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.220.tgz",
|
|
||||||
"integrity": "sha512-X9RwDsSmbF6ultKZroaip+DL8WRgC64gHbrAwrRlAFSPNZV7zmJyP2ur8rW7KrxqmtuehdMMkw8+SAC/6hD2PA==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "SEE LICENSE IN LICENSE.md",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": {
|
|
||||||
"version": "0.3.220",
|
|
||||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.220.tgz",
|
|
||||||
"integrity": "sha512-WkROPwWskqhKR9XgnmseHQ6rLi9zM9qt57IWoToIjL/eXOqDWipp7JXZ1L5ud+LrA42dunHPZfBwD/vXZ+A7LA==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "SEE LICENSE IN LICENSE.md",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": {
|
|
||||||
"version": "0.3.220",
|
|
||||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.220.tgz",
|
|
||||||
"integrity": "sha512-OHoZOZ8Cf2TBr6oXIXPwyvUxj9jrq2w8E4poA8dMpacXszcPSPiCQCMuuOh4aWJzfeJE1+TtWxhKMVb2csXyZQ==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "SEE LICENSE IN LICENSE.md",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": {
|
|
||||||
"version": "0.3.220",
|
|
||||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.220.tgz",
|
|
||||||
"integrity": "sha512-tkTJFnpR9VifvWX2fmkCAPkT6+8Wk/gVu8B5jsVekKZPiZoWRHmMXO30BnZn+f0TZhgYP+82PSX3S8crH1kn+w==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "SEE LICENSE IN LICENSE.md",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": {
|
|
||||||
"version": "0.3.220",
|
|
||||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.220.tgz",
|
|
||||||
"integrity": "sha512-K+FWj+LcGhC1Z7wqeWoLxm1iemcba5xKpLLFVwYm4V6HyMx3ruYd/2r2TiQtjT+JWeNFWIys0ScHiItR6vWAiA==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "SEE LICENSE IN LICENSE.md",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": {
|
|
||||||
"version": "0.3.220",
|
|
||||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.220.tgz",
|
|
||||||
"integrity": "sha512-rIwgq0UwQExWl6KrHUyC4w5KwpL9l6nd95aUTx6RitexaAuEw//xtfTVLnuE4hDDQZFkzEwpdKc3nxDWoGcUbA==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "SEE LICENSE IN LICENSE.md",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": {
|
|
||||||
"version": "0.3.220",
|
|
||||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.220.tgz",
|
|
||||||
"integrity": "sha512-MuOuXhbr66HlGaWXD2f3w0k2PsvmnbkwcUZ0dAe2poFLdl72GC2dapwwOBefxm9QmoNqk9+jmv/dSKGOVWyvLw==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "SEE LICENSE IN LICENSE.md",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"packages/server/node_modules/@babel/helper-string-parser": {
|
"packages/server/node_modules/@babel/helper-string-parser": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||||
@@ -39983,7 +39985,7 @@
|
|||||||
},
|
},
|
||||||
"packages/website": {
|
"packages/website": {
|
||||||
"name": "@getpaseo/website",
|
"name": "@getpaseo/website",
|
||||||
"version": "0.5.1",
|
"version": "0.7.0-beta.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "^1.29.1",
|
"@cloudflare/vite-plugin": "^1.29.1",
|
||||||
"@cloudflare/workers-types": "^4.20260317.1",
|
"@cloudflare/workers-types": "^4.20260317.1",
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.5.1",
|
"version": "0.7.0-beta.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
"voice-assistant"
|
"voice-assistant"
|
||||||
],
|
],
|
||||||
"homepage": "https://paseo.sh",
|
"homepage": "https://paseo.sh",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "Apache-2.0",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Mohamed Boudra",
|
"name": "Mohamed Boudra",
|
||||||
"email": "hello@moboudra.com"
|
"email": "hello@moboudra.com"
|
||||||
|
|||||||
@@ -66,54 +66,21 @@ async function expectProjectHasNoWorkspaces(projectId: string): Promise<void> {
|
|||||||
test.describe("Add Project command-center flow", () => {
|
test.describe("Add Project command-center flow", () => {
|
||||||
test.describe.configure({ timeout: 180_000 });
|
test.describe.configure({ timeout: 180_000 });
|
||||||
|
|
||||||
test("method selection has no search field", async ({ page }) => {
|
test("method selection shows the daemon's available project sources without search", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
|
|
||||||
await openAddProjectFlow(page);
|
await openAddProjectFlow(page);
|
||||||
|
|
||||||
await expect(addProjectFlowMethod(page, "directory-search")).toBeVisible();
|
await expect(addProjectFlowMethod(page, "directory-search")).toBeVisible();
|
||||||
|
await expect(addProjectFlowMethod(page, "github")).toContainText("Clone from GitHub");
|
||||||
|
await expect(addProjectFlowMethod(page, "new-directory")).toContainText("New directory");
|
||||||
await expect(addProjectFlowInput(page)).toHaveCount(0);
|
await expect(addProjectFlowInput(page)).toHaveCount(0);
|
||||||
await expect(addProjectFlow(page).getByRole("textbox")).toHaveCount(0);
|
await expect(addProjectFlow(page).getByRole("textbox")).toHaveCount(0);
|
||||||
await expect(page.getByTestId("add-project-flow-page-host")).toHaveCount(0);
|
await expect(page.getByTestId("add-project-flow-page-host")).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the back arrow, search input, and result glyph share one left edge", async ({ page }) => {
|
|
||||||
await gotoAppShell(page);
|
|
||||||
await openAddProjectFlow(page);
|
|
||||||
|
|
||||||
await page.keyboard.press("Enter");
|
|
||||||
await expectAddProjectPage(page, "directory-search");
|
|
||||||
await addProjectFlowInput(page).fill("/tmp");
|
|
||||||
|
|
||||||
const backGlyph = addProjectFlowBack(page).locator("svg");
|
|
||||||
const resultGlyph = addProjectFlow(page)
|
|
||||||
.locator('[data-testid^="add-project-flow-path-"]')
|
|
||||||
.first()
|
|
||||||
.locator("svg");
|
|
||||||
await expect(resultGlyph).toBeVisible();
|
|
||||||
|
|
||||||
const [backBox, inputBox, resultBox, titleBox, resultsBox, footerBox] = await Promise.all([
|
|
||||||
backGlyph.boundingBox(),
|
|
||||||
addProjectFlowInput(page).boundingBox(),
|
|
||||||
resultGlyph.boundingBox(),
|
|
||||||
page.getByTestId("add-project-flow-title").boundingBox(),
|
|
||||||
page.getByTestId("add-project-flow-results").boundingBox(),
|
|
||||||
page.getByTestId("add-project-flow-footer").boundingBox(),
|
|
||||||
]);
|
|
||||||
expect(backBox).not.toBeNull();
|
|
||||||
expect(inputBox).not.toBeNull();
|
|
||||||
expect(resultBox).not.toBeNull();
|
|
||||||
expect(titleBox).not.toBeNull();
|
|
||||||
expect(resultsBox).not.toBeNull();
|
|
||||||
expect(footerBox).not.toBeNull();
|
|
||||||
if (!backBox || !inputBox || !resultBox || !titleBox || !resultsBox || !footerBox) return;
|
|
||||||
|
|
||||||
expect(Math.abs(backBox.x - inputBox.x)).toBeLessThanOrEqual(2);
|
|
||||||
expect(Math.abs(resultBox.x - inputBox.x)).toBeLessThanOrEqual(2);
|
|
||||||
expect(titleBox.height).toBeLessThanOrEqual(24);
|
|
||||||
expect(resultsBox.y + resultsBox.height).toBeLessThanOrEqual(footerBox.y + 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("an offline extra host neither appears nor forces host selection", async ({ page }) => {
|
test("an offline extra host neither appears nor forces host selection", async ({ page }) => {
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await addOfflineHostAndReload(page, {
|
await addOfflineHostAndReload(page, {
|
||||||
@@ -273,14 +240,6 @@ test.describe("Add Project command-center flow", () => {
|
|||||||
await expectProjectHasNoWorkspaces(projectId);
|
await expectProjectHasNoWorkspaces(projectId);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the current daemon advertises Clone from GitHub and New directory", async ({ page }) => {
|
|
||||||
await gotoAppShell(page);
|
|
||||||
await openAddProjectFlow(page);
|
|
||||||
|
|
||||||
await expect(addProjectFlowMethod(page, "github")).toContainText("Clone from GitHub");
|
|
||||||
await expect(addProjectFlowMethod(page, "new-directory")).toContainText("New directory");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a complete repository URL remains selectable without a GitHub search result", async ({
|
test("a complete repository URL remains selectable without a GitHub search result", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -442,21 +442,40 @@ function formatFrames(frames: TurnFrame[], centers: number | number[]): string {
|
|||||||
.join("\n");
|
.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function submittedRowContentTop(frame: TurnFrame | undefined): number | undefined {
|
||||||
|
const viewportTop = frame?.userRow.rect?.top;
|
||||||
|
const scrollTop = frame?.scroll.scrollTop;
|
||||||
|
return viewportTop == null || scrollTop == null ? undefined : viewportTop + scrollTop;
|
||||||
|
}
|
||||||
|
|
||||||
function expectRowContinuity(frames: TurnFrame[]): void {
|
function expectRowContinuity(frames: TurnFrame[]): void {
|
||||||
const first = frames.findIndex((frame) => hasPaintedLayout(frame.userRow));
|
const first = frames.findIndex((frame) => hasPaintedLayout(frame.userRow));
|
||||||
const baseline = frames[first]?.userRow.rect?.top;
|
const baselineFrame = frames[first];
|
||||||
|
const baselineContentTop = submittedRowContentTop(baselineFrame);
|
||||||
const disappeared = frames.findIndex(
|
const disappeared = frames.findIndex(
|
||||||
(frame, index) => index > first && !hasPaintedLayout(frame.userRow),
|
(frame, index) => index > first && !hasPaintedLayout(frame.userRow),
|
||||||
);
|
);
|
||||||
const reappeared = frames.findIndex(
|
const reappeared = frames.findIndex(
|
||||||
(frame, index) => index > disappeared && disappeared >= 0 && hasPaintedLayout(frame.userRow),
|
(frame, index) => index > disappeared && disappeared >= 0 && hasPaintedLayout(frame.userRow),
|
||||||
);
|
);
|
||||||
const shifted = frames.findIndex(
|
const contentShifted = frames.findIndex((frame, index) => {
|
||||||
(frame, index) =>
|
const contentTop = submittedRowContentTop(frame);
|
||||||
|
return (
|
||||||
index > first &&
|
index > first &&
|
||||||
hasPaintedLayout(frame.userRow) &&
|
hasPaintedLayout(frame.userRow) &&
|
||||||
baseline !== undefined &&
|
baselineContentTop !== undefined &&
|
||||||
Math.abs((frame.userRow.rect?.top ?? baseline) - baseline) > 1,
|
contentTop !== undefined &&
|
||||||
|
Math.abs(contentTop - baselineContentTop) > 1
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const viewportChanged = frames.findIndex(
|
||||||
|
(frame, index) =>
|
||||||
|
index > first &&
|
||||||
|
[
|
||||||
|
geometryChanged(frame.scroll.rect?.top, baselineFrame?.scroll.rect?.top),
|
||||||
|
geometryChanged(frame.scroll.rect?.height, baselineFrame?.scroll.rect?.height),
|
||||||
|
geometryChanged(frame.scroll.clientHeight, baselineFrame?.scroll.clientHeight),
|
||||||
|
].some(Boolean),
|
||||||
);
|
);
|
||||||
const attachmentMissing = frames.findIndex(
|
const attachmentMissing = frames.findIndex(
|
||||||
(frame, index) => index >= first && !hasPaintedLayout(frame.attachment),
|
(frame, index) => index >= first && !hasPaintedLayout(frame.attachment),
|
||||||
@@ -468,16 +487,18 @@ function expectRowContinuity(frames: TurnFrame[]): void {
|
|||||||
`submitted row disappeared for ${Math.max(0, reappeared - disappeared)} recorded frames (${Math.max(0, (frames[reappeared]?.at ?? frames.at(-1)?.at ?? 0) - frames[disappeared].at).toFixed(1)}ms) before authoritative recovery`,
|
`submitted row disappeared for ${Math.max(0, reappeared - disappeared)} recorded frames (${Math.max(0, (frames[reappeared]?.at ?? frames.at(-1)?.at ?? 0) - frames[disappeared].at).toFixed(1)}ms) before authoritative recovery`,
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
...(shifted >= 0
|
...(contentShifted >= 0
|
||||||
? [
|
? [
|
||||||
`submitted row moved from ${baseline?.toFixed(1)} to ${frames[shifted].userRow.rect?.top.toFixed(1)}`,
|
`submitted row moved in scroll content from ${baselineContentTop?.toFixed(1)} to ${submittedRowContentTop(frames[contentShifted])?.toFixed(1)}`,
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
|
...(viewportChanged >= 0 ? ["scroll viewport geometry changed"] : []),
|
||||||
...(attachmentMissing >= 0 ? ["submitted image attachment left the painted layout"] : []),
|
...(attachmentMissing >= 0 ? ["submitted image attachment left the painted layout"] : []),
|
||||||
];
|
];
|
||||||
let failure = Math.max(0, first);
|
let failure = Math.max(0, first);
|
||||||
if (attachmentMissing >= 0) failure = attachmentMissing;
|
if (attachmentMissing >= 0) failure = attachmentMissing;
|
||||||
if (shifted >= 0) failure = shifted;
|
if (viewportChanged >= 0) failure = viewportChanged;
|
||||||
|
if (contentShifted >= 0) failure = contentShifted;
|
||||||
if (disappeared >= 0) failure = disappeared;
|
if (disappeared >= 0) failure = disappeared;
|
||||||
expect(
|
expect(
|
||||||
violations,
|
violations,
|
||||||
|
|||||||
@@ -103,6 +103,18 @@ async function readProviderModePreference(page: Page, provider: string): Promise
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readProviderFeaturePreferences(page: Page, provider: string): Promise<unknown> {
|
||||||
|
return page.evaluate(
|
||||||
|
({ preferencesKey, providerId }) => {
|
||||||
|
const raw = localStorage.getItem(preferencesKey);
|
||||||
|
if (!raw) return null;
|
||||||
|
const preferences = JSON.parse(raw) as FormPreferences;
|
||||||
|
return preferences.providerPreferences?.[providerId]?.featureValues ?? null;
|
||||||
|
},
|
||||||
|
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY, providerId: provider },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
test.describe("Agent profiles repair modeless provider preferences", () => {
|
test.describe("Agent profiles repair modeless provider preferences", () => {
|
||||||
test.describe.configure({ timeout: 240_000 });
|
test.describe.configure({ timeout: 240_000 });
|
||||||
|
|
||||||
@@ -207,6 +219,9 @@ test.describe("Agent profiles repair modeless provider preferences", () => {
|
|||||||
|
|
||||||
await openModelPicker(page);
|
await openModelPicker(page);
|
||||||
await applyProfileFromPicker(page, "Fast profile");
|
await applyProfileFromPicker(page, "Fast profile");
|
||||||
|
await expect
|
||||||
|
.poll(() => readProviderFeaturePreferences(page, featureProviderId), { timeout: 10_000 })
|
||||||
|
.toEqual({ fast_mode: true });
|
||||||
await submitNewWorkspacePrompt(page, "Create an agent with profile features.");
|
await submitNewWorkspacePrompt(page, "Create an agent with profile features.");
|
||||||
|
|
||||||
const createAgentRequest = await createAgentRecorder.waitForRequest();
|
const createAgentRequest = await createAgentRecorder.waitForRequest();
|
||||||
|
|||||||
@@ -18,6 +18,33 @@ test("shows Pure black in the appearance picker", async ({ page }, testInfo) =>
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("keeps the selected workspace visible in Light", async ({ page }, testInfo) => {
|
||||||
|
const workspace = await seedWorkspace({
|
||||||
|
repoPrefix: "light-selected-workspace-",
|
||||||
|
title: "Selected workspace",
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem("@paseo:app-settings", JSON.stringify({ theme: "light" }));
|
||||||
|
});
|
||||||
|
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(228, 228, 231)");
|
||||||
|
await page.screenshot({
|
||||||
|
path: testInfo.outputPath("light-selected-workspace.png"),
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("keeps the selected workspace visible in Pure black", async ({ page }, testInfo) => {
|
test("keeps the selected workspace visible in Pure black", async ({ page }, testInfo) => {
|
||||||
const workspace = await seedWorkspace({
|
const workspace = await seedWorkspace({
|
||||||
repoPrefix: "pure-black-selected-workspace-",
|
repoPrefix: "pure-black-selected-workspace-",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ async function openMockAgentAtMobileBreakpoint(page: Page) {
|
|||||||
repoPrefix: "bottom-sheet-reopen-",
|
repoPrefix: "bottom-sheet-reopen-",
|
||||||
title: "Bottom sheet reopen e2e",
|
title: "Bottom sheet reopen e2e",
|
||||||
initialPrompt: "Prepare a bottom sheet reopen test agent.",
|
initialPrompt: "Prepare a bottom sheet reopen test agent.",
|
||||||
|
model: "ten-second-stream",
|
||||||
});
|
});
|
||||||
await openAgentRoute(page, session);
|
await openAgentRoute(page, session);
|
||||||
await expect(page.getByTestId("workspace-tab-switcher-trigger")).toBeVisible({
|
await expect(page.getByTestId("workspace-tab-switcher-trigger")).toBeVisible({
|
||||||
@@ -22,16 +23,6 @@ async function openMockAgentAtMobileBreakpoint(page: Page) {
|
|||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function withMobileMockAgent(page: Page, run: () => Promise<void>) {
|
|
||||||
const session = await openMockAgentAtMobileBreakpoint(page);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await run();
|
|
||||||
} finally {
|
|
||||||
await session.cleanup();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function bottomSheetBackdrop(page: Page) {
|
function bottomSheetBackdrop(page: Page) {
|
||||||
return page.getByRole("button", { name: "Bottom sheet backdrop" }).first();
|
return page.getByRole("button", { name: "Bottom sheet backdrop" }).first();
|
||||||
}
|
}
|
||||||
@@ -108,35 +99,36 @@ async function openAndCloseModelSelectorTwice(page: Page) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test.describe("mobile bottom sheet reopen", () => {
|
test.describe("mobile bottom sheet reopen", () => {
|
||||||
test("tab switcher can open, close, reopen, and close again", async ({ page }) => {
|
test("sheets reopen and model search returns to configuration", async ({ page }) => {
|
||||||
await withMobileMockAgent(page, async () => {
|
const session = await openMockAgentAtMobileBreakpoint(page);
|
||||||
await openAndCloseTabSwitcherTwice(page);
|
try {
|
||||||
});
|
await test.step("tab switcher opens, closes, and reopens", async () => {
|
||||||
});
|
await openAndCloseTabSwitcherTwice(page);
|
||||||
|
|
||||||
test("model selector can open, close, reopen, and close again", async ({ page }) => {
|
|
||||||
await withMobileMockAgent(page, async () => {
|
|
||||||
await openAndCloseModelSelectorTwice(page);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("search selection returns to configuration options", async ({ page }) => {
|
|
||||||
await withMobileMockAgent(page, async () => {
|
|
||||||
await openModelSelector(page);
|
|
||||||
const sheet = page.getByTestId("agent-controls-model-sheet");
|
|
||||||
|
|
||||||
await page.getByTestId("model-search-all-input").click();
|
|
||||||
const model = page.getByRole("button", { name: /^Ten second stream/ });
|
|
||||||
await expect(model).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await model.click();
|
await test.step("model selector opens, closes, and reopens", async () => {
|
||||||
|
await openAndCloseModelSelectorTwice(page);
|
||||||
|
});
|
||||||
|
|
||||||
await expect(sheet).toBeVisible();
|
await test.step("model search returns to configuration", async () => {
|
||||||
await expect(page.getByTestId("agent-controls-settings-list")).toBeVisible();
|
await openModelSelector(page);
|
||||||
await expect(page.getByTestId("agent-controls-model")).toContainText("Ten second stream");
|
const sheet = page.getByTestId("agent-controls-model-sheet");
|
||||||
await expect(page.getByTestId("agent-controls-model-browser-sheet")).not.toBeVisible();
|
|
||||||
});
|
await page.getByTestId("model-search-all-input").click();
|
||||||
|
const model = page.getByRole("button", { name: /^Ten second stream/ });
|
||||||
|
await expect(model).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await model.click();
|
||||||
|
|
||||||
|
await expect(sheet).toBeVisible();
|
||||||
|
await expect(page.getByTestId("agent-controls-settings-list")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("agent-controls-model")).toContainText("Ten second stream");
|
||||||
|
await expect(page.getByTestId("agent-controls-model-browser-sheet")).not.toBeVisible();
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await session.cleanup();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -46,38 +46,6 @@ test.describe("Branch switcher", () => {
|
|||||||
// releases stale sessions from the previous spec; one retry stabilizes it.
|
// releases stale sessions from the previous spec; one retry stabilizes it.
|
||||||
test.describe.configure({ retries: 1 });
|
test.describe.configure({ retries: 1 });
|
||||||
|
|
||||||
test("switches the workspace branch from the git diff panel for an opaque workspace id", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
test.setTimeout(90_000);
|
|
||||||
const serverId = getServerId();
|
|
||||||
const workspace = await seedWorkspace({
|
|
||||||
repoPrefix: "branch-switch-",
|
|
||||||
repo: { branches: ["main", "dev"] },
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await gotoAppShell(page);
|
|
||||||
await waitForSidebarHydration(page);
|
|
||||||
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: workspace.workspaceId });
|
|
||||||
|
|
||||||
await openChangesPanel(page);
|
|
||||||
await expectWorkspaceBranch(page, "main");
|
|
||||||
await switchBranchFromChangesPanel(page, { from: "main", to: "dev" });
|
|
||||||
await expectWorkspaceBranch(page, "dev");
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () =>
|
|
||||||
(await readWorktreeBranchInfo({ worktreePath: workspace.repoPath })).currentBranch,
|
|
||||||
{ timeout: 30_000 },
|
|
||||||
)
|
|
||||||
.toBe("dev");
|
|
||||||
} finally {
|
|
||||||
await workspace.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a custom workspace title stays in the header while the diff panel switches the real branch", async ({
|
test("a custom workspace title stays in the header while the diff panel switches the real branch", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ interface WorkspaceFixtureOptions {
|
|||||||
includeDeletedFile?: boolean;
|
includeDeletedFile?: boolean;
|
||||||
includeNestedFolders?: boolean;
|
includeNestedFolders?: boolean;
|
||||||
includeRenamedFile?: boolean;
|
includeRenamedFile?: boolean;
|
||||||
|
// A root-level file whose name sorts BEFORE the root-level "src" directory.
|
||||||
|
// zz-untracked.txt cannot stand in for it: "src" < "zz", so that file lands
|
||||||
|
// last whether paths are compared whole or segment by segment.
|
||||||
|
includeRootFileSortingFirst?: boolean;
|
||||||
includeUntrackedFile?: boolean;
|
includeUntrackedFile?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,11 +34,7 @@ const cleanupTasks: CleanupTask[] = [];
|
|||||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||||
|
|
||||||
function changesTree(page: Page) {
|
function changesTree(page: Page) {
|
||||||
return page.getByTestId("changes-tree-rail-tree");
|
return page.getByTestId("changes-file-tree").filter({ visible: true });
|
||||||
}
|
|
||||||
|
|
||||||
function changesContent(page: Page) {
|
|
||||||
return page.getByTestId("changes-tree-rail-content");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readFileIfPresent(filePath: string): Promise<string | null> {
|
async function readFileIfPresent(filePath: string): Promise<string | null> {
|
||||||
@@ -264,7 +264,8 @@ test("Changes opens the populated committed comparison for a clean checkout", as
|
|||||||
await openWorkspaceChangesSurface(page, workspace);
|
await openWorkspaceChangesSurface(page, workspace);
|
||||||
|
|
||||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||||
await expect(panel.getByTestId("changes-diff-status-trigger")).toContainText("Committed");
|
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
|
||||||
|
await expect(tree.getByTestId("changes-diff-status-trigger")).toContainText("Committed");
|
||||||
await expect(panel.getByText("committed-only.ts", { exact: true })).toBeVisible();
|
await expect(panel.getByText("committed-only.ts", { exact: true })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -272,19 +273,19 @@ test("Changes expires a manual comparison when checkout dirtiness changes", asyn
|
|||||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||||
await openWorkspaceChanges(page, workspace);
|
await openWorkspaceChanges(page, workspace);
|
||||||
|
|
||||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
|
||||||
const mode = panel.getByTestId("changes-diff-status-trigger");
|
const mode = tree.getByTestId("changes-diff-status-trigger");
|
||||||
await expect(mode).toContainText("Uncommitted");
|
await expect(mode).toContainText("Uncommitted");
|
||||||
|
|
||||||
await mode.click();
|
await mode.click();
|
||||||
await page.getByTestId("changes-diff-mode-committed").click();
|
await page.getByTestId("changes-diff-mode-committed").click();
|
||||||
await expect(mode).toContainText("Committed");
|
await expect(mode).toContainText("Committed");
|
||||||
await expect(panel.getByRole("button", { name: "See uncommitted changes" })).toBeVisible();
|
await expect(tree.getByRole("button", { name: "See uncommitted changes" })).toBeVisible();
|
||||||
|
|
||||||
execFileSync("git", ["add", "--all"], { cwd: workspace.repoPath });
|
execFileSync("git", ["add", "--all"], { cwd: workspace.repoPath });
|
||||||
execFileSync("git", ["commit", "-m", "Commit working changes"], { cwd: workspace.repoPath });
|
execFileSync("git", ["commit", "-m", "Commit working changes"], { cwd: workspace.repoPath });
|
||||||
await expect(mode).toContainText("Committed");
|
await expect(mode).toContainText("Committed");
|
||||||
await expect(panel.getByRole("button", { name: "See uncommitted changes" })).toHaveCount(0, {
|
await expect(tree.getByRole("button", { name: "See uncommitted changes" })).toHaveCount(0, {
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -297,7 +298,8 @@ test("an empty Changes comparison links to the populated comparison", async ({ p
|
|||||||
await openWorkspaceChangesSurface(page, workspace);
|
await openWorkspaceChangesSurface(page, workspace);
|
||||||
|
|
||||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||||
const mode = panel.getByTestId("changes-diff-status-trigger");
|
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
|
||||||
|
const mode = tree.getByTestId("changes-diff-status-trigger");
|
||||||
await mode.click();
|
await mode.click();
|
||||||
await page.getByTestId("changes-diff-mode-committed").click();
|
await page.getByTestId("changes-diff-mode-committed").click();
|
||||||
await expect(panel.getByText("committed-only.ts", { exact: true })).toBeVisible();
|
await expect(panel.getByText("committed-only.ts", { exact: true })).toBeVisible();
|
||||||
@@ -305,8 +307,8 @@ test("an empty Changes comparison links to the populated comparison", async ({ p
|
|||||||
await mode.click();
|
await mode.click();
|
||||||
await page.getByTestId("changes-diff-mode-uncommitted").click();
|
await page.getByTestId("changes-diff-mode-uncommitted").click();
|
||||||
|
|
||||||
await expect(panel.getByText("No uncommitted changes", { exact: true })).toBeVisible();
|
await expect(tree.getByText("No uncommitted changes", { exact: true })).toBeVisible();
|
||||||
const seeCommitted = panel.getByRole("button", { name: "See committed changes" });
|
const seeCommitted = tree.getByRole("button", { name: "See committed changes" });
|
||||||
await expect(seeCommitted).toBeVisible();
|
await expect(seeCommitted).toBeVisible();
|
||||||
await seeCommitted.click();
|
await seeCommitted.click();
|
||||||
await expect(mode).toContainText("Committed");
|
await expect(mode).toContainText("Committed");
|
||||||
@@ -321,7 +323,7 @@ test("changes file actions open below the right-click without a reserved kebab",
|
|||||||
await openWorkspaceChanges(page, workspace);
|
await openWorkspaceChanges(page, workspace);
|
||||||
|
|
||||||
await expect(page.getByTestId("diff-file-1")).toContainText("zz-deleted.ts");
|
await expect(page.getByTestId("diff-file-1")).toContainText("zz-deleted.ts");
|
||||||
const deletedFileName = page.getByText("zz-deleted.ts", { exact: true });
|
const deletedFileName = page.getByTestId("diff-file-1-name");
|
||||||
await expect(deletedFileName).toHaveCSS("user-select", "none");
|
await expect(deletedFileName).toHaveCSS("user-select", "none");
|
||||||
await deletedFileName.dblclick();
|
await deletedFileName.dblclick();
|
||||||
expect(await page.evaluate(() => window.getSelection()?.toString() ?? "")).toBe("");
|
expect(await page.evaluate(() => window.getSelection()?.toString() ?? "")).toBe("");
|
||||||
@@ -432,7 +434,6 @@ test("changes context menus duplicate files and folders", async ({ page }) => {
|
|||||||
.poll(() => readFileIfPresent(path.join(workspace.repoPath, "src/use-mounted-tab-set copy.ts")))
|
.poll(() => readFileIfPresent(path.join(workspace.repoPath, "src/use-mounted-tab-set copy.ts")))
|
||||||
.toBe(AFTER);
|
.toBe(AFTER);
|
||||||
|
|
||||||
await page.getByTestId("changes-toggle-tree").click();
|
|
||||||
await changesTree(page).getByTestId("diff-folder-src-toggle").click({ button: "right" });
|
await changesTree(page).getByTestId("diff-folder-src-toggle").click({ button: "right" });
|
||||||
await page.getByTestId("diff-folder-src-duplicate").click();
|
await page.getByTestId("diff-folder-src-duplicate").click();
|
||||||
await expect
|
await expect
|
||||||
@@ -447,8 +448,6 @@ test("changes tree aligns every file status after its diff stat", async ({ page
|
|||||||
});
|
});
|
||||||
await useUnwrappedDiffLines(page);
|
await useUnwrappedDiffLines(page);
|
||||||
await openWorkspaceChanges(page, workspace);
|
await openWorkspaceChanges(page, workspace);
|
||||||
await page.getByTestId("changes-toggle-tree").click();
|
|
||||||
|
|
||||||
const tree = changesTree(page);
|
const tree = changesTree(page);
|
||||||
const modifiedRow = tree.getByTestId("diff-tree-file-0");
|
const modifiedRow = tree.getByTestId("diff-tree-file-0");
|
||||||
const deletedRow = tree.getByTestId("diff-tree-file-1");
|
const deletedRow = tree.getByTestId("diff-tree-file-1");
|
||||||
@@ -466,12 +465,36 @@ test("changes tree aligns every file status after its diff stat", async ({ page
|
|||||||
expect(statusBounds.x - (statBounds.x + statBounds.width)).toBeGreaterThanOrEqual(8);
|
expect(statusBounds.x - (statBounds.x + statBounds.width)).toBeGreaterThanOrEqual(8);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("the scrolling diff lists files in changes tree order", async ({ page }) => {
|
||||||
|
const workspace = await createWorkspaceWithMountedTabDiff({
|
||||||
|
includeNestedFolders: true,
|
||||||
|
includeRootFileSortingFirst: true,
|
||||||
|
});
|
||||||
|
await useUnwrappedDiffLines(page);
|
||||||
|
await openWorkspaceChanges(page, workspace);
|
||||||
|
|
||||||
|
const tree = changesTree(page);
|
||||||
|
const treeNames = tree.locator('[data-testid^="diff-tree-file-"][data-testid$="-name"]');
|
||||||
|
const diffNames = page.locator('[data-testid^="diff-file-"][data-testid$="-name"]');
|
||||||
|
|
||||||
|
// Directories sort before files at every level, so the root note lands last
|
||||||
|
// even though "a-root-note.txt" compares below "src/..." as a whole string.
|
||||||
|
const expected = ["changed.ts", "root.ts", "use-mounted-tab-set.ts", "a-root-note.txt"];
|
||||||
|
|
||||||
|
// Equal counts also prove no folder is collapsed: flattenDiffTree drops the
|
||||||
|
// descendants of a collapsed folder, which would make the tree a subsequence
|
||||||
|
// of the diff rather than a match, and mask a real ordering difference.
|
||||||
|
await expect(treeNames).toHaveCount(expected.length);
|
||||||
|
await expect(diffNames).toHaveCount(expected.length);
|
||||||
|
await expect(treeNames).toHaveText(expected);
|
||||||
|
await expect(diffNames).toHaveText(expected);
|
||||||
|
});
|
||||||
|
|
||||||
test("changes context menu recursively collapses descendant folders", async ({ page }) => {
|
test("changes context menu recursively collapses descendant folders", async ({ page }) => {
|
||||||
const workspace = await createWorkspaceWithMountedTabDiff({ includeNestedFolders: true });
|
const workspace = await createWorkspaceWithMountedTabDiff({ includeNestedFolders: true });
|
||||||
await useUnwrappedDiffLines(page);
|
await useUnwrappedDiffLines(page);
|
||||||
await openWorkspaceChanges(page, workspace);
|
await openWorkspaceChanges(page, workspace);
|
||||||
|
|
||||||
await page.getByTestId("changes-toggle-tree").click();
|
|
||||||
const tree = changesTree(page);
|
const tree = changesTree(page);
|
||||||
await expect(tree.getByTestId("diff-folder-src/zz-folder")).toBeVisible();
|
await expect(tree.getByTestId("diff-folder-src/zz-folder")).toBeVisible();
|
||||||
await expect(tree.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible();
|
await expect(tree.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible();
|
||||||
@@ -516,7 +539,6 @@ test("changes context menus expose folder revert and restore a file after confir
|
|||||||
await useUnwrappedDiffLines(page);
|
await useUnwrappedDiffLines(page);
|
||||||
await openWorkspaceChanges(page, workspace);
|
await openWorkspaceChanges(page, workspace);
|
||||||
|
|
||||||
await page.getByTestId("changes-toggle-tree").click();
|
|
||||||
const tree = changesTree(page);
|
const tree = changesTree(page);
|
||||||
await tree.getByTestId("diff-folder-src-toggle").click({ button: "right" });
|
await tree.getByTestId("diff-folder-src-toggle").click({ button: "right" });
|
||||||
const folderRevert = page.getByTestId("diff-folder-src-revert");
|
const folderRevert = page.getByTestId("diff-folder-src-revert");
|
||||||
@@ -649,6 +671,11 @@ test("Changes keeps review navigation and controls inside its workspace tab", as
|
|||||||
|
|
||||||
const visiblePanel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
const visiblePanel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||||
await expect(visiblePanel).toBeVisible();
|
await expect(visiblePanel).toBeVisible();
|
||||||
|
await expect(visiblePanel.getByTestId("changes-repository-header")).toHaveCount(0);
|
||||||
|
await expect(visiblePanel.getByTestId("changes-branch-switcher")).toHaveCount(0);
|
||||||
|
await expect(visiblePanel.getByTestId("changes-diff-status-trigger")).toHaveCount(0);
|
||||||
|
await expect(visiblePanel.getByTestId("changes-selected-diff-stat")).toHaveCount(0);
|
||||||
|
await expect(visiblePanel.getByTestId("changes-header")).toHaveCount(1);
|
||||||
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible();
|
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible();
|
||||||
await expect(visiblePanel).toContainText("zz-deleted.ts");
|
await expect(visiblePanel).toContainText("zz-deleted.ts");
|
||||||
await expect(visiblePanel.getByTestId("changes-primary-cta")).toHaveCount(0);
|
await expect(visiblePanel.getByTestId("changes-primary-cta")).toHaveCount(0);
|
||||||
@@ -658,34 +685,30 @@ test("Changes keeps review navigation and controls inside its workspace tab", as
|
|||||||
await visiblePanel.getByTestId("diff-file-0-toggle").click();
|
await visiblePanel.getByTestId("diff-file-0-toggle").click();
|
||||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).not.toBeVisible();
|
await expect(visiblePanel.getByTestId("diff-file-0-body")).not.toBeVisible();
|
||||||
|
|
||||||
// Collapse-all and the layout switch are overflow-menu items, not toolbar icons.
|
await expect(visiblePanel.getByRole("button", { name: "Diff options" })).toHaveCount(0);
|
||||||
const diffOptions = visiblePanel.getByRole("button", { name: "Diff options" });
|
|
||||||
await expect(page.getByTestId("changes-toggle-collapse-all")).toHaveCount(0);
|
|
||||||
await diffOptions.click();
|
|
||||||
// A workspace tab has nowhere to open itself, so the menu withholds that item.
|
|
||||||
await expect(page.getByTestId("changes-open-tab")).toHaveCount(0);
|
await expect(page.getByTestId("changes-open-tab")).toHaveCount(0);
|
||||||
await page.getByTestId("changes-toggle-collapse-all").click();
|
const collapseFiles = visiblePanel.getByTestId("changes-toggle-collapse-all");
|
||||||
await diffOptions.click();
|
await expect(collapseFiles).toHaveAttribute("aria-label", "Collapse all files");
|
||||||
await expect(page.getByTestId("changes-toggle-collapse-all")).toContainText("Expand all files");
|
await collapseFiles.click();
|
||||||
await page.getByTestId("changes-toggle-collapse-all").click();
|
await expect(collapseFiles).toHaveAttribute("aria-label", "Expand all files");
|
||||||
|
await collapseFiles.click();
|
||||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
|
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
|
||||||
|
|
||||||
const layoutItem = page.getByTestId("changes-toggle-layout");
|
const layout = visiblePanel.getByTestId("changes-toggle-layout");
|
||||||
await diffOptions.click();
|
await expect(layout).toHaveAttribute("aria-label", "Switch to side-by-side diff");
|
||||||
await expect(layoutItem).toContainText("Side-by-side diff");
|
await layout.click();
|
||||||
// Leading icon only: the layout is still unified, so no trailing check.
|
await expect(layout).toHaveAttribute("aria-label", "Switch to unified diff");
|
||||||
await expect(layoutItem.locator("svg")).toHaveCount(1);
|
|
||||||
await layoutItem.click();
|
|
||||||
await diffOptions.click();
|
|
||||||
await expect(layoutItem.locator("svg")).toHaveCount(2);
|
|
||||||
|
|
||||||
await expect(page.getByText("Hide whitespace", { exact: true })).toBeVisible();
|
const whitespace = visiblePanel.getByTestId("changes-toggle-whitespace");
|
||||||
await expect(page.getByText("Wrap long lines", { exact: true })).toBeVisible();
|
await expect(whitespace).toHaveAttribute("aria-label", "Hide whitespace");
|
||||||
await expect(page.getByTestId("changes-refresh")).toHaveText("Refresh");
|
const wrapLines = visiblePanel.getByTestId("changes-toggle-wrap-lines");
|
||||||
await page.getByText("Wrap long lines", { exact: true }).click();
|
await expect(wrapLines).toHaveAttribute("aria-label", "Wrap long lines");
|
||||||
await diffOptions.click();
|
await expect(visiblePanel.getByTestId("changes-refresh")).toHaveAttribute(
|
||||||
await expect(page.getByText("Scroll long lines", { exact: true })).toBeVisible();
|
"aria-label",
|
||||||
await page.keyboard.press("Escape");
|
"Refresh",
|
||||||
|
);
|
||||||
|
await wrapLines.click();
|
||||||
|
await expect(wrapLines).toHaveAttribute("aria-label", "Scroll long lines");
|
||||||
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
|
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
|
||||||
|
|
||||||
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE);
|
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE);
|
||||||
@@ -701,151 +724,23 @@ test("Changes keeps review navigation and controls inside its workspace tab", as
|
|||||||
await expect(visiblePanel.getByRole("img", { name: "Deleted" })).toBeVisible();
|
await expect(visiblePanel.getByRole("img", { name: "Deleted" })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("desktop Changes toggles a navigation tree beside the expanded diff document", async ({
|
|
||||||
page,
|
|
||||||
}, testInfo) => {
|
|
||||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
|
||||||
await useUnwrappedDiffLines(page);
|
|
||||||
await openWorkspaceChanges(page, workspace);
|
|
||||||
|
|
||||||
await expectFlatFileList(page);
|
|
||||||
await expect(page.getByTestId("changes-layout-unified")).toHaveCount(0);
|
|
||||||
await expect(page.getByTestId("changes-layout-split")).toHaveCount(0);
|
|
||||||
// The tree toggle is the one icon action that stays outside the overflow menu.
|
|
||||||
await expect(page.getByTestId("changes-toggle-tree")).toBeVisible();
|
|
||||||
const [changesToolbarBox, treeToggleBox, optionsBox, treeGlyphBox, optionsGlyphBox] =
|
|
||||||
await Promise.all([
|
|
||||||
page.getByTestId("changes-header").boundingBox(),
|
|
||||||
page.getByTestId("changes-toggle-tree").boundingBox(),
|
|
||||||
page.getByRole("button", { name: "Diff options" }).boundingBox(),
|
|
||||||
page.getByTestId("changes-toggle-tree").locator("svg").boundingBox(),
|
|
||||||
page.getByRole("button", { name: "Diff options" }).locator("svg").boundingBox(),
|
|
||||||
]);
|
|
||||||
if (!changesToolbarBox || !treeToggleBox || !optionsBox || !treeGlyphBox || !optionsGlyphBox) {
|
|
||||||
throw new Error("Changes toolbar geometry could not be measured");
|
|
||||||
}
|
|
||||||
expect(changesToolbarBox.height).toBe(36);
|
|
||||||
expect(treeToggleBox.width).toBe(24);
|
|
||||||
expect(treeToggleBox.height).toBe(24);
|
|
||||||
expect(optionsBox.width).toBe(24);
|
|
||||||
expect(optionsBox.height).toBe(24);
|
|
||||||
expect(treeGlyphBox.width).toBe(14);
|
|
||||||
expect(treeGlyphBox.height).toBe(14);
|
|
||||||
expect(optionsGlyphBox.width).toBe(14);
|
|
||||||
expect(optionsGlyphBox.height).toBe(14);
|
|
||||||
const diffOptions = page.getByRole("button", { name: "Diff options" });
|
|
||||||
const diffOptionsRestBackground = await diffOptions.evaluate(
|
|
||||||
(element) => getComputedStyle(element).backgroundColor,
|
|
||||||
);
|
|
||||||
await diffOptions.hover();
|
|
||||||
const diffOptionsHoverBackground = await diffOptions.evaluate(
|
|
||||||
(element) => getComputedStyle(element).backgroundColor,
|
|
||||||
);
|
|
||||||
expect(diffOptionsHoverBackground).not.toBe(diffOptionsRestBackground);
|
|
||||||
await page.getByTestId("changes-toggle-tree").hover();
|
|
||||||
expect(
|
|
||||||
await page
|
|
||||||
.getByTestId("changes-toggle-tree")
|
|
||||||
.evaluate((element) => getComputedStyle(element).backgroundColor),
|
|
||||||
).toBe(diffOptionsHoverBackground);
|
|
||||||
await expect(page.getByTestId("changes-toggle-collapse-all")).toHaveCount(0);
|
|
||||||
await expect(page.getByTestId("changes-toggle-layout")).toHaveCount(0);
|
|
||||||
|
|
||||||
await page.getByTestId("changes-options-menu").click();
|
|
||||||
await expect(page.getByTestId("changes-options-menu-content")).toBeVisible();
|
|
||||||
await expect(page.getByTestId("changes-toggle-collapse-all")).toContainText("Collapse all files");
|
|
||||||
await expect(page.getByTestId("changes-toggle-layout")).toContainText("Side-by-side diff");
|
|
||||||
await expect(page.getByTestId("changes-toggle-whitespace")).toContainText("Hide whitespace");
|
|
||||||
await expect(page.getByTestId("changes-toggle-wrap-lines")).toContainText("Wrap long lines");
|
|
||||||
await expect(page.getByTestId("changes-refresh")).toHaveText("Refresh");
|
|
||||||
await page.getByTestId("changes-toggle-whitespace").click();
|
|
||||||
await page.getByTestId("changes-options-menu").click();
|
|
||||||
await expect(page.getByTestId("changes-toggle-whitespace")).toContainText("Show whitespace");
|
|
||||||
await page.keyboard.press("Escape");
|
|
||||||
|
|
||||||
await scrollToLowerUnwrappedDiffRows(page);
|
|
||||||
await page.getByTestId("changes-toggle-tree").click();
|
|
||||||
await expect(page.getByTestId("changes-toggle-tree")).toHaveAttribute("aria-selected", "true");
|
|
||||||
expect(
|
|
||||||
await page
|
|
||||||
.getByTestId("changes-toggle-tree")
|
|
||||||
.evaluate((element) => getComputedStyle(element).backgroundColor),
|
|
||||||
).toBe(diffOptionsHoverBackground);
|
|
||||||
const tree = changesTree(page);
|
|
||||||
const content = changesContent(page);
|
|
||||||
await expect(tree.getByTestId("diff-folder-src")).toBeVisible();
|
|
||||||
await expect(tree.getByTestId("diff-folder-src").getByText("src", { exact: true })).toHaveCSS(
|
|
||||||
"user-select",
|
|
||||||
"none",
|
|
||||||
);
|
|
||||||
await expect(tree.getByTestId("diff-tree-file-0")).toBeVisible();
|
|
||||||
await expect(tree.getByTestId("diff-folder-src-toggle").locator("svg")).toHaveCount(1);
|
|
||||||
const folderToggleBounds = await tree.getByTestId("diff-folder-src-toggle").boundingBox();
|
|
||||||
const folderChevronBounds = await tree
|
|
||||||
.getByTestId("diff-folder-src-toggle")
|
|
||||||
.locator("svg")
|
|
||||||
.boundingBox();
|
|
||||||
expect(folderToggleBounds).not.toBeNull();
|
|
||||||
expect(folderChevronBounds).not.toBeNull();
|
|
||||||
expect(folderChevronBounds!.y + folderChevronBounds!.height / 2).toBeCloseTo(
|
|
||||||
folderToggleBounds!.y + folderToggleBounds!.height / 2,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
const folderLabelBounds = await tree
|
|
||||||
.getByTestId("diff-folder-src")
|
|
||||||
.getByText("src", { exact: true })
|
|
||||||
.boundingBox();
|
|
||||||
const fileLabelBounds = await tree
|
|
||||||
.getByTestId("diff-tree-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(12, 0);
|
|
||||||
|
|
||||||
const folderToggle = tree.getByTestId("diff-folder-src-toggle");
|
|
||||||
await folderToggle.click();
|
|
||||||
await expect(folderToggle).toHaveAttribute("aria-selected", "true");
|
|
||||||
await expect(tree.getByTestId("diff-tree-file-0")).toHaveCount(0);
|
|
||||||
await folderToggle.click();
|
|
||||||
await expect(folderToggle).toHaveAttribute("aria-selected", "true");
|
|
||||||
await expect(tree.getByTestId("diff-tree-file-0")).toBeVisible();
|
|
||||||
|
|
||||||
const fileToggle = tree.getByTestId("diff-tree-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-tree-file-0-context-menu")).toBeVisible();
|
|
||||||
await page.keyboard.press("Escape");
|
|
||||||
|
|
||||||
await fileToggle.click();
|
|
||||||
await expect(content.getByTestId("diff-file-0-body")).toBeVisible();
|
|
||||||
const contentBounds = await content.boundingBox();
|
|
||||||
const focusedFileBounds = await content.getByTestId("diff-file-0").boundingBox();
|
|
||||||
expect(contentBounds).not.toBeNull();
|
|
||||||
expect(focusedFileBounds).not.toBeNull();
|
|
||||||
expect(focusedFileBounds!.y).toBeGreaterThanOrEqual(contentBounds!.y);
|
|
||||||
expect(focusedFileBounds!.y).toBeLessThan(contentBounds!.y + 80);
|
|
||||||
|
|
||||||
await tree.getByTestId("diff-folder-src-toggle").click();
|
|
||||||
await expect(tree.getByTestId("diff-tree-file-0")).toHaveCount(0);
|
|
||||||
|
|
||||||
await page.getByTestId("changes-toggle-tree").click();
|
|
||||||
await expect(page.getByTestId("changes-toggle-tree")).toHaveAttribute("aria-selected", "false");
|
|
||||||
await expectFlatFileList(page);
|
|
||||||
const screenshot = testInfo.outputPath("changes-toolbar.png");
|
|
||||||
await page.screenshot({ path: screenshot });
|
|
||||||
await testInfo.attach("changes-toolbar", { path: screenshot, contentType: "image/png" });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("compact Changes keeps its actions compact and menu-only", async ({ page }) => {
|
test("compact Changes keeps its actions compact and menu-only", async ({ page }) => {
|
||||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||||
await useUnwrappedDiffLines(page);
|
await useUnwrappedDiffLines(page);
|
||||||
await openWorkspaceChanges(page, workspace);
|
await openWorkspaceChanges(page, workspace);
|
||||||
await page.setViewportSize({ width: 480, height: 900 });
|
await page.setViewportSize({ width: 480, height: 900 });
|
||||||
|
|
||||||
const actions = page.getByTestId("changes-actions-menu-trigger").filter({ visible: true });
|
const compactChangesTab = page.getByTestId("explorer-tab-changes").filter({ visible: true });
|
||||||
const options = page.getByRole("button", { name: "Diff options" }).filter({ visible: true });
|
if (!(await compactChangesTab.isVisible())) {
|
||||||
|
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||||
|
}
|
||||||
|
await expect(compactChangesTab).toBeVisible();
|
||||||
|
await compactChangesTab.click();
|
||||||
|
const compactExplorer = page.getByTestId("explorer-content-area").filter({ visible: true });
|
||||||
|
await expect(compactExplorer.getByTestId("changes-header")).toBeVisible();
|
||||||
|
|
||||||
|
const actions = compactExplorer.getByTestId("changes-actions-menu-trigger");
|
||||||
|
const options = compactExplorer.getByRole("button", { name: "Diff options" });
|
||||||
const [actionsBox, optionsBox, glyphBox] = await Promise.all([
|
const [actionsBox, optionsBox, glyphBox] = await Promise.all([
|
||||||
actions.boundingBox(),
|
actions.boundingBox(),
|
||||||
options.boundingBox(),
|
options.boundingBox(),
|
||||||
@@ -873,7 +768,9 @@ test("compact Changes keeps its actions compact and menu-only", async ({ page })
|
|||||||
await expect(wrapLines).toBeVisible();
|
await expect(wrapLines).toBeVisible();
|
||||||
await wrapLines.click();
|
await wrapLines.click();
|
||||||
await options.click();
|
await options.click();
|
||||||
await expect(page.getByTestId("changes-toggle-wrap-lines")).toContainText("Scroll long lines");
|
await expect(
|
||||||
|
page.getByTestId("changes-options-menu-content").getByTestId("changes-toggle-wrap-lines"),
|
||||||
|
).toContainText("Scroll long lines");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("canvas diff stays sharp while its workspace pane is resized", async ({ page }) => {
|
test("canvas diff stays sharp while its workspace pane is resized", async ({ page }) => {
|
||||||
@@ -883,7 +780,9 @@ test("canvas diff stays sharp while its workspace pane is resized", async ({ pag
|
|||||||
|
|
||||||
const canvas = page.getByTestId("git-diff-canvas");
|
const canvas = page.getByTestId("git-diff-canvas");
|
||||||
const root = page.getByTestId("git-diff-canvas-root");
|
const root = page.getByTestId("git-diff-canvas-root");
|
||||||
const handle = page.getByTestId("workspace-split-resize-handle").getByRole("separator");
|
const handle = page
|
||||||
|
.getByTestId("workspace-explorer-sidebar-resize-handle")
|
||||||
|
.getByRole("separator");
|
||||||
await expect(handle).toBeVisible();
|
await expect(handle).toBeVisible();
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
@@ -904,11 +803,11 @@ test("canvas diff stays sharp while its workspace pane is resized", async ({ pag
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
if (!handleBounds) throw new Error("Workspace split resize handle has no bounds");
|
if (!handleBounds) throw new Error("Explorer sidebar resize handle has no bounds");
|
||||||
|
|
||||||
await page.mouse.move(handleBounds.x + handleBounds.width / 2, handleBounds.y + 120);
|
await page.mouse.move(handleBounds.x + handleBounds.width / 2, handleBounds.y + 120);
|
||||||
await page.mouse.down();
|
await page.mouse.down();
|
||||||
await page.mouse.move(handleBounds.x - 120, handleBounds.y + 120);
|
await page.mouse.move(handleBounds.x + 120, handleBounds.y + 120);
|
||||||
await page.evaluate(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())));
|
await page.evaluate(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())));
|
||||||
|
|
||||||
const duringDrag = await Promise.all([
|
const duringDrag = await Promise.all([
|
||||||
@@ -1074,6 +973,7 @@ test("canvas diff uses the overlay scrollbar and its thumb controls vertical scr
|
|||||||
if (!bounds) throw new Error("Diff overlay scrollbar thumb has no bounds");
|
if (!bounds) throw new Error("Diff overlay scrollbar thumb has no bounds");
|
||||||
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
|
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
|
||||||
await page.mouse.down();
|
await page.mouse.down();
|
||||||
|
await expect(grab).toHaveCSS("cursor", "grabbing");
|
||||||
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2 + 180);
|
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2 + 180);
|
||||||
await page.mouse.up();
|
await page.mouse.up();
|
||||||
|
|
||||||
@@ -1313,24 +1213,20 @@ async function setOpenChangesPresentation(
|
|||||||
page: Page,
|
page: Page,
|
||||||
requestedPresentation: { layout: "unified" | "split"; wrapLines: boolean },
|
requestedPresentation: { layout: "unified" | "split"; wrapLines: boolean },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const options = page.getByRole("button", { name: "Diff options" }).filter({ visible: true });
|
const diffPanel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||||
|
const layoutItem = diffPanel.getByTestId("changes-toggle-layout");
|
||||||
await options.click();
|
const currentLayout =
|
||||||
const layoutItem = page.getByTestId("changes-toggle-layout");
|
(await layoutItem.getAttribute("aria-label")) === "Switch to unified diff"
|
||||||
const currentLayout = (await layoutItem.locator("svg").count()) === 2 ? "split" : "unified";
|
? "split"
|
||||||
|
: "unified";
|
||||||
if (currentLayout !== requestedPresentation.layout) {
|
if (currentLayout !== requestedPresentation.layout) {
|
||||||
await layoutItem.click();
|
await layoutItem.click();
|
||||||
} else {
|
|
||||||
await page.keyboard.press("Escape");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await options.click();
|
const wrapItem = diffPanel.getByTestId("changes-toggle-wrap-lines");
|
||||||
const wrapItem = page.getByTestId("changes-toggle-wrap-lines");
|
const currentWrapLines = (await wrapItem.getAttribute("aria-label")) === "Scroll long lines";
|
||||||
const currentWrapLines = (await wrapItem.locator("svg").count()) === 2;
|
|
||||||
if (currentWrapLines !== requestedPresentation.wrapLines) {
|
if (currentWrapLines !== requestedPresentation.wrapLines) {
|
||||||
await wrapItem.click();
|
await wrapItem.click();
|
||||||
} else {
|
|
||||||
await page.keyboard.press("Escape");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1365,12 +1261,6 @@ async function releaseBrowserFontLoads(page: Page): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function expectFlatFileList(page: Page): Promise<void> {
|
|
||||||
await expect(page.locator('[data-testid^="diff-folder-"]')).toHaveCount(0);
|
|
||||||
await expect(page.getByTestId("diff-file-0")).toContainText("use-mounted-tab-set.ts");
|
|
||||||
await expect(page.getByTestId("diff-file-0")).toContainText("src");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function expectDiffCodeFontSize(page: Page, fontSize: number): Promise<void> {
|
async function expectDiffCodeFontSize(page: Page, fontSize: number): Promise<void> {
|
||||||
const canvas = page.getByTestId("git-diff-canvas");
|
const canvas = page.getByTestId("git-diff-canvas");
|
||||||
await expect
|
await expect
|
||||||
@@ -1424,6 +1314,9 @@ async function createWorkspaceWithMountedTabDiff(
|
|||||||
if (options.includeUntrackedFile) {
|
if (options.includeUntrackedFile) {
|
||||||
await writeFile(path.join(repo.path, "zz-untracked.txt"), "remove me\n");
|
await writeFile(path.join(repo.path, "zz-untracked.txt"), "remove me\n");
|
||||||
}
|
}
|
||||||
|
if (options.includeRootFileSortingFirst) {
|
||||||
|
await writeFile(path.join(repo.path, "a-root-note.txt"), "root note\n");
|
||||||
|
}
|
||||||
if (options.includeDeletedFile) {
|
if (options.includeDeletedFile) {
|
||||||
await unlink(path.join(repo.path, "src/zz-deleted.ts"));
|
await unlink(path.join(repo.path, "src/zz-deleted.ts"));
|
||||||
}
|
}
|
||||||
@@ -1491,6 +1384,7 @@ async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Prom
|
|||||||
await page.setViewportSize({ width: 1400, height: 900 });
|
await page.setViewportSize({ width: 1400, height: 900 });
|
||||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
||||||
await waitForWorkspaceTabsVisible(page);
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||||
await openChangesInVisibleExplorer(page);
|
await openChangesInVisibleExplorer(page);
|
||||||
await expectExpandedMountedTabDiff(page);
|
await expectExpandedMountedTabDiff(page);
|
||||||
}
|
}
|
||||||
@@ -1507,13 +1401,27 @@ async function openSelectionWorkspaceChanges(page: Page, workspace: DirtyWorkspa
|
|||||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
||||||
await waitForWorkspaceTabsVisible(page);
|
await waitForWorkspaceTabsVisible(page);
|
||||||
await openChangesPanel(page);
|
await openChangesPanel(page);
|
||||||
await expect(page.getByText("selection.ts")).toBeVisible({ timeout: 30_000 });
|
const diffPanel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||||
|
await expect(diffPanel.getByTestId("diff-file-0-name")).toHaveText("selection.ts", {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
await expectExpandedMountedTabDiff(page);
|
await expectExpandedMountedTabDiff(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openChangesInVisibleExplorer(page: Page): Promise<void> {
|
async function openChangesInVisibleExplorer(page: Page): Promise<void> {
|
||||||
await openChangesPanel(page);
|
const explorer = page.getByTestId("workspace-explorer-sidebar");
|
||||||
await expect(page.getByText("use-mounted-tab-set.ts")).toBeVisible({ timeout: 30_000 });
|
await expect(explorer).toBeVisible({ timeout: 30_000 });
|
||||||
|
const changesTab = explorer.getByRole("button", { name: /Working tree diff/i }).first();
|
||||||
|
await changesTab.click();
|
||||||
|
const changedFile = explorer
|
||||||
|
.locator('[data-testid^="diff-tree-file-"][data-testid$="-toggle"]')
|
||||||
|
.filter({ visible: true })
|
||||||
|
.first();
|
||||||
|
await expect(changedFile).toBeVisible({ timeout: 30_000 });
|
||||||
|
await changedFile.click();
|
||||||
|
await expect(page.getByTestId("working-diff-panel").filter({ visible: true })).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function expectExpandedMountedTabDiff(page: Page): Promise<void> {
|
async function expectExpandedMountedTabDiff(page: Page): Promise<void> {
|
||||||
|
|||||||
@@ -1,21 +1,12 @@
|
|||||||
import { expect, test, type Page } from "../support/fixtures";
|
import { expect, test } from "../support/fixtures";
|
||||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||||
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||||
|
|
||||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
|
||||||
const CHANGES_SHORTCUT = `${process.platform === "darwin" ? "Meta" : "Control"}+Shift+G`;
|
const CHANGES_SHORTCUT = `${process.platform === "darwin" ? "Meta" : "Control"}+Shift+G`;
|
||||||
|
|
||||||
function mainPane(page: Page) {
|
test("Changes shortcut reveals the Changes tree in Explorer", async ({ page }) => {
|
||||||
return page.getByTestId("workspace-pane-main").filter({ visible: true });
|
const workspace = await seedWorkspace({ repoPrefix: "changes-shortcut-explorer-" });
|
||||||
}
|
|
||||||
|
|
||||||
function sidePanel(page: Page) {
|
|
||||||
return page.getByTestId("workspace-side-panel").filter({ visible: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
test("Changes shortcut follows the default Side panel placement", async ({ page }) => {
|
|
||||||
const workspace = await seedWorkspace({ repoPrefix: "changes-shortcut-side-" });
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await page.setViewportSize({ width: 1400, height: 900 });
|
await page.setViewportSize({ width: 1400, height: 900 });
|
||||||
@@ -24,34 +15,12 @@ test("Changes shortcut follows the default Side panel placement", async ({ page
|
|||||||
|
|
||||||
await page.keyboard.press(CHANGES_SHORTCUT);
|
await page.keyboard.press(CHANGES_SHORTCUT);
|
||||||
|
|
||||||
await expect(sidePanel(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
|
const explorer = page.getByTestId("workspace-explorer-sidebar").filter({ visible: true });
|
||||||
|
await expect(explorer.getByTestId("explorer-sidebar-tab-changes_tree")).toBeVisible({
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
await expect(mainPane(page).getByTestId("workspace-tab-working_diff")).toHaveCount(0);
|
await expect(explorer.getByTestId("changes-tree-panel")).toBeVisible();
|
||||||
} finally {
|
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(0);
|
||||||
await workspace.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Changes shortcut uses the focused pane when Side panel placement is off", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await page.addInitScript((settingsKey) => {
|
|
||||||
localStorage.setItem(settingsKey, JSON.stringify({ openSupportingTabsInSidePanel: false }));
|
|
||||||
}, APP_SETTINGS_KEY);
|
|
||||||
const workspace = await seedWorkspace({ repoPrefix: "changes-shortcut-pane-" });
|
|
||||||
|
|
||||||
try {
|
|
||||||
await page.setViewportSize({ width: 1400, height: 900 });
|
|
||||||
await gotoWorkspace(page, workspace.workspaceId);
|
|
||||||
await waitForWorkspaceTabsVisible(page);
|
|
||||||
|
|
||||||
await page.keyboard.press(CHANGES_SHORTCUT);
|
|
||||||
|
|
||||||
await expect(mainPane(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
|
|
||||||
timeout: 30_000,
|
|
||||||
});
|
|
||||||
await expect(sidePanel(page)).toHaveCount(0);
|
|
||||||
} finally {
|
} finally {
|
||||||
await workspace.cleanup();
|
await workspace.cleanup();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
clickChatOutlineRowEdge,
|
clickChatOutlineRowEdge,
|
||||||
disableChatOutlineFromAppearance,
|
disableChatOutlineFromAppearance,
|
||||||
expectActiveChatOutlinePrompt,
|
expectActiveChatOutlinePrompt,
|
||||||
|
expectActiveChatOutlinePromptMovedFrom,
|
||||||
expectChatOutlinePreview,
|
expectChatOutlinePreview,
|
||||||
expectChatOutlinePrompts,
|
expectChatOutlinePrompts,
|
||||||
expectChatOutlinePromptToRemainBare,
|
expectChatOutlinePromptToRemainBare,
|
||||||
@@ -29,7 +30,6 @@ import {
|
|||||||
openAgentTimeline,
|
openAgentTimeline,
|
||||||
scrollThroughOlderHistoryPages,
|
scrollThroughOlderHistoryPages,
|
||||||
scrollTimelineToNewestLoadedEdge,
|
scrollTimelineToNewestLoadedEdge,
|
||||||
scrollTimelineToOldestLoadedEdge,
|
|
||||||
seedLongMockAgentTimeline,
|
seedLongMockAgentTimeline,
|
||||||
type LongTimelineAgent,
|
type LongTimelineAgent,
|
||||||
} from "../support/helpers/timeline-pagination";
|
} from "../support/helpers/timeline-pagination";
|
||||||
@@ -130,11 +130,12 @@ test.describe("desktop chat outline", () => {
|
|||||||
await expectOneActiveChatOutlinePrompt(page);
|
await expectOneActiveChatOutlinePrompt(page);
|
||||||
|
|
||||||
await clickChatOutlineRowEdge(page, 4);
|
await clickChatOutlineRowEdge(page, 4);
|
||||||
await expectTimelinePromptVisible(page, agent.prompts[3]);
|
await expectTimelinePromptLandedBelowTop(page, agent.prompts[3]);
|
||||||
await expectActiveChatOutlinePrompt(page, 4);
|
await expectActiveChatOutlinePrompt(page, 4);
|
||||||
|
|
||||||
await scrollTimelineToOldestLoadedEdge(page);
|
await scrollTimelineToNewestLoadedEdge(page);
|
||||||
await expectActiveChatOutlinePrompt(page, 1);
|
await expectTimelinePromptVisible(page, agent.newestPrompt);
|
||||||
|
await expectActiveChatOutlinePromptMovedFrom(page, 4);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("keeps a clicked prompt free of persistent selection chrome", async ({ page }) => {
|
test("keeps a clicked prompt free of persistent selection chrome", async ({ page }) => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { closeCommandCenter, openCommandCenter } from "../support/helpers/comman
|
|||||||
import {
|
import {
|
||||||
applyCommandCenterAgentControls,
|
applyCommandCenterAgentControls,
|
||||||
chooseCommandCenterAgentControl,
|
chooseCommandCenterAgentControl,
|
||||||
|
openCommandCenterForAgent,
|
||||||
expectCommandCenterAgentControlSelected,
|
expectCommandCenterAgentControlSelected,
|
||||||
expectFocusedAgentControls,
|
expectFocusedAgentControls,
|
||||||
expectWorkspaceAgentConfiguration,
|
expectWorkspaceAgentConfiguration,
|
||||||
@@ -12,7 +13,9 @@ import {
|
|||||||
} from "../support/helpers/command-center-agent-controls";
|
} from "../support/helpers/command-center-agent-controls";
|
||||||
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
||||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||||
|
import { expectAppRoute } from "../support/helpers/route-assertions";
|
||||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||||
|
import { buildSchedulesRoute } from "@/utils/host-routes";
|
||||||
|
|
||||||
const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences";
|
const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences";
|
||||||
|
|
||||||
@@ -76,6 +79,107 @@ test.describe("Command Center agent controls", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("activates the best-matching row, not the highest-ranked group", async ({ page }) => {
|
||||||
|
// "Mode › Load Test" matches "load" at offset 0; every "Model › Mock Load Test › …" row
|
||||||
|
// matches at offset 5. Models used to outrank modes by fixed group rank, so Enter picked a
|
||||||
|
// model. Asserting both model and mode catches either row winning.
|
||||||
|
const workspace = await seedMockAgentWorkspace({
|
||||||
|
repoPrefix: "command-center-relevance-",
|
||||||
|
title: "Command Center relevance",
|
||||||
|
model: "five-minute-stream",
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await workspace.client.setAgentMode(workspace.agentId, "approval-test");
|
||||||
|
const input = await openCommandCenterForAgent(page, workspace);
|
||||||
|
await input.fill("load");
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
|
||||||
|
await expectWorkspaceAgentConfiguration(workspace, {
|
||||||
|
id: workspace.agentId,
|
||||||
|
provider: "mock",
|
||||||
|
model: "five-minute-stream",
|
||||||
|
modeId: "load-test",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("matches query tokens in any order", async ({ page }) => {
|
||||||
|
// "Mode › Load test" flattens to "mode load test", so the reversed query "test load" is never
|
||||||
|
// a substring of it. Only per-token matching finds this row.
|
||||||
|
const workspace = await seedMockAgentWorkspace({
|
||||||
|
repoPrefix: "command-center-tokenized-",
|
||||||
|
title: "Command Center tokenized search",
|
||||||
|
model: "five-minute-stream",
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await workspace.client.setAgentMode(workspace.agentId, "legacy-mode");
|
||||||
|
await openAgentRoute(page, {
|
||||||
|
workspaceId: workspace.workspaceId,
|
||||||
|
agentId: workspace.agentId,
|
||||||
|
});
|
||||||
|
await openCommandCenter(page);
|
||||||
|
await chooseCommandCenterAgentControl({
|
||||||
|
page,
|
||||||
|
query: "test load",
|
||||||
|
choice: "Mode › Load test",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expectWorkspaceAgentConfiguration(workspace, {
|
||||||
|
id: workspace.agentId,
|
||||||
|
provider: "mock",
|
||||||
|
model: "five-minute-stream",
|
||||||
|
modeId: "load-test",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps an action first when the action is the best match", async ({ page }) => {
|
||||||
|
const workspace = await seedMockAgentWorkspace({
|
||||||
|
repoPrefix: "command-center-action-priority-",
|
||||||
|
title: "Command Center action priority",
|
||||||
|
model: "five-minute-stream",
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const input = await openCommandCenterForAgent(page, workspace);
|
||||||
|
await input.fill("sched");
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
|
||||||
|
await expectAppRoute(page, buildSchedulesRoute(), { timeout: 30_000 });
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("drops an arrow-key selection once the query changes", async ({ page }) => {
|
||||||
|
const workspace = await seedMockAgentWorkspace({
|
||||||
|
repoPrefix: "command-center-active-reset-",
|
||||||
|
title: "Command Center active reset",
|
||||||
|
model: "five-minute-stream",
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await workspace.client.setAgentMode(workspace.agentId, "approval-test");
|
||||||
|
const input = await openCommandCenterForAgent(page, workspace);
|
||||||
|
await input.fill("loa");
|
||||||
|
// Moves the highlight onto a model row, which still matches after the next keystroke.
|
||||||
|
await page.keyboard.press("ArrowDown");
|
||||||
|
await input.fill("load");
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
|
||||||
|
await expectWorkspaceAgentConfiguration(workspace, {
|
||||||
|
id: workspace.agentId,
|
||||||
|
provider: "mock",
|
||||||
|
model: "five-minute-stream",
|
||||||
|
modeId: "load-test",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("applies draft model and setting choices to the created agent", async ({ page }) => {
|
test("applies draft model and setting choices to the created agent", async ({ page }) => {
|
||||||
const workspace = await seedWorkspace({ repoPrefix: "command-center-draft-controls-" });
|
const workspace = await seedWorkspace({ repoPrefix: "command-center-draft-controls-" });
|
||||||
await seedMockDraftPreferences(page);
|
await seedMockDraftPreferences(page);
|
||||||
|
|||||||
@@ -1,53 +1,14 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { expect } from "@playwright/test";
|
|
||||||
import { test } from "../support/fixtures";
|
import { test } from "../support/fixtures";
|
||||||
import { gotoAppShell } from "../support/helpers/app";
|
import { gotoAppShell } from "../support/helpers/app";
|
||||||
import { createIdleAgent } from "../support/helpers/archive-tab";
|
import { createIdleAgent } from "../support/helpers/archive-tab";
|
||||||
import { openCommandCenter } from "../support/helpers/command-center";
|
import { openCommandCenter } from "../support/helpers/command-center";
|
||||||
import { addOfflineHostAndReload } from "../support/helpers/hosts";
|
|
||||||
import { expectAgentTabActive } from "../support/helpers/launcher";
|
import { expectAgentTabActive } from "../support/helpers/launcher";
|
||||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||||
import { getServerId } from "../support/helpers/server-id";
|
|
||||||
|
|
||||||
const PRIMARY_HOST_LABEL = "Primary Host";
|
|
||||||
const SECONDARY_HOST_ID = "host-command-center-secondary";
|
|
||||||
|
|
||||||
test.describe("Command center host labels", () => {
|
test.describe("Command center host labels", () => {
|
||||||
test.describe.configure({ timeout: 180_000 });
|
test.describe.configure({ timeout: 180_000 });
|
||||||
|
|
||||||
test("agent results show the host they live on when more than one host exists", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
const seeded = await seedWorkspace({ repoPrefix: "command-center-host-" });
|
|
||||||
const title = `cc-host-${randomUUID().slice(0, 8)}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const agent = await createIdleAgent(seeded.client, {
|
|
||||||
cwd: seeded.repoPath,
|
|
||||||
workspaceId: seeded.workspaceId,
|
|
||||||
title,
|
|
||||||
});
|
|
||||||
|
|
||||||
// A second (offline) host makes the view multi-host, which is when the host label earns its space.
|
|
||||||
await gotoAppShell(page);
|
|
||||||
await addOfflineHostAndReload(page, {
|
|
||||||
serverId: SECONDARY_HOST_ID,
|
|
||||||
label: "Secondary Host",
|
|
||||||
primaryLabel: PRIMARY_HOST_LABEL,
|
|
||||||
});
|
|
||||||
|
|
||||||
const panel = await openCommandCenter(page);
|
|
||||||
|
|
||||||
// The shared daemon may carry agents from other specs, so target this agent by its id.
|
|
||||||
const row = panel.getByTestId(`command-center-agent-${getServerId()}:${agent.id}`);
|
|
||||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
|
||||||
await expect(row).toContainText(title);
|
|
||||||
await expect(row).toContainText(PRIMARY_HOST_LABEL);
|
|
||||||
} finally {
|
|
||||||
await seeded.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("selecting an agent result opens its workspace tab", async ({ page }) => {
|
test("selecting an agent result opens its workspace tab", async ({ page }) => {
|
||||||
const seeded = await seedWorkspace({ repoPrefix: "command-center-agent-navigation-" });
|
const seeded = await seedWorkspace({ repoPrefix: "command-center-agent-navigation-" });
|
||||||
const title = `cc-navigation-${randomUUID().slice(0, 8)}`;
|
const title = `cc-navigation-${randomUUID().slice(0, 8)}`;
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import type { Locator } from "@playwright/test";
|
||||||
|
import { test, expect, type Page } from "../support/fixtures";
|
||||||
|
import { gotoAppShell } from "../support/helpers/app";
|
||||||
|
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||||
|
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||||
|
import { getServerId } from "../support/helpers/server-id";
|
||||||
|
|
||||||
|
// These actions used to be reachable only from the sidebar workspace ⋯ menu, the workspace header
|
||||||
|
// menu, or an unlisted keybind. Rename was the worst of them: its dialog lived inside the sidebar
|
||||||
|
// row, so it vanished whenever that row was unmounted — a collapsed section, or focus mode.
|
||||||
|
|
||||||
|
function workspaceRow(page: Page, workspaceId: string): Locator {
|
||||||
|
return page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function action(panel: Locator, title: string): Locator {
|
||||||
|
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
return panel.getByRole("button", { name: new RegExp(`^${escaped}(?:\\s|$)`) });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openWorkspace(page: Page, workspaceId: string): Promise<void> {
|
||||||
|
const row = workspaceRow(page, workspaceId);
|
||||||
|
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||||
|
await row.click();
|
||||||
|
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collapsing the project section unmounts every workspace row under it, which is exactly the state
|
||||||
|
// that used to make the sidebar-owned rename dialog unreachable.
|
||||||
|
async function collapseProjectSection(page: Page, project: SeededWorkspace): Promise<void> {
|
||||||
|
const header = page
|
||||||
|
.locator('[data-testid^="sidebar-project-row-"]')
|
||||||
|
.filter({ hasText: project.projectDisplayName });
|
||||||
|
await expect(header).toHaveCount(1, { timeout: 30_000 });
|
||||||
|
await header.click();
|
||||||
|
await expect(workspaceRow(page, project.workspaceId)).toHaveCount(0, { timeout: 10_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// The shared helper clicks the sidebar's search button, which is inside the Workspaces section
|
||||||
|
// header — absent on /settings and once every workspace has moved to Pinned. Cmd-K is the
|
||||||
|
// surface under test anyway, and it works from every route.
|
||||||
|
async function openCommandCenter(page: Page): Promise<Locator> {
|
||||||
|
await page.keyboard.press("ControlOrMeta+K");
|
||||||
|
const panel = page.getByTestId("command-center-panel");
|
||||||
|
await expect(panel).toBeVisible({ timeout: 30_000 });
|
||||||
|
return panel;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runCommand(page: Page, query: string, title: string): Promise<void> {
|
||||||
|
const panel = await openCommandCenter(page);
|
||||||
|
await panel.getByTestId("command-center-input").fill(query);
|
||||||
|
const entry = action(panel, title);
|
||||||
|
await expect(entry).toBeVisible({ timeout: 15_000 });
|
||||||
|
await entry.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readWorkspaceLabels(workspace: SeededWorkspace): Promise<string[] | undefined> {
|
||||||
|
const workspaces = await workspace.client.fetchWorkspaces();
|
||||||
|
for (const entry of workspaces.entries) {
|
||||||
|
if (entry.id === workspace.workspaceId) return entry.labels?.slice().sort();
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rejectNextLabelAssignment(page: Page): Promise<void> {
|
||||||
|
let rejectNext = true;
|
||||||
|
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
|
||||||
|
const server = browser.connectToServer();
|
||||||
|
browser.onMessage((message) => {
|
||||||
|
const raw = typeof message === "string" ? message : message.toString("utf8");
|
||||||
|
let request: { type?: string; requestId?: string } | null = null;
|
||||||
|
try {
|
||||||
|
const envelope = JSON.parse(raw) as {
|
||||||
|
type?: string;
|
||||||
|
message?: { type?: string; requestId?: string };
|
||||||
|
};
|
||||||
|
request = envelope.type === "session" ? (envelope.message ?? null) : null;
|
||||||
|
} catch {
|
||||||
|
server.send(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
rejectNext &&
|
||||||
|
request?.type === "workspace.label.assignment.set.request" &&
|
||||||
|
request.requestId
|
||||||
|
) {
|
||||||
|
rejectNext = false;
|
||||||
|
browser.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "session",
|
||||||
|
message: {
|
||||||
|
type: "rpc_error",
|
||||||
|
payload: {
|
||||||
|
requestId: request.requestId,
|
||||||
|
requestType: request.type,
|
||||||
|
error: "Injected label mutation failure.",
|
||||||
|
code: "handler_error",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
server.send(message);
|
||||||
|
});
|
||||||
|
server.onMessage((message) => browser.send(message));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("Command center workspace management", () => {
|
||||||
|
test("renames the active workspace while its sidebar row is unmounted", async ({ page }) => {
|
||||||
|
const workspace = await seedWorkspace({ repoPrefix: "cc-rename-" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await openWorkspace(page, workspace.workspaceId);
|
||||||
|
await collapseProjectSection(page, workspace);
|
||||||
|
|
||||||
|
await runCommand(page, "rename", "Rename workspace");
|
||||||
|
|
||||||
|
// Focused, not merely visible. A modal that mounts behind the closing palette, or loses the
|
||||||
|
// focus race with its focus-restore, still renders — you just cannot type into it.
|
||||||
|
const input = page.getByTestId("workspace-rename-modal-global-input");
|
||||||
|
await expect(input).toBeFocused({ timeout: 15_000 });
|
||||||
|
await expect(input).toHaveValue(workspace.workspaceName);
|
||||||
|
|
||||||
|
const customTitle = "Renamed From Palette";
|
||||||
|
await input.fill(customTitle);
|
||||||
|
await page.getByTestId("workspace-rename-modal-global-submit").click();
|
||||||
|
await expect(input).toHaveCount(0, { timeout: 15_000 });
|
||||||
|
|
||||||
|
// Re-expand the section to read the row back.
|
||||||
|
const header = page
|
||||||
|
.locator('[data-testid^="sidebar-project-row-"]')
|
||||||
|
.filter({ hasText: workspace.projectDisplayName });
|
||||||
|
await header.click();
|
||||||
|
await expect(workspaceRow(page, workspace.workspaceId)).toContainText(customTitle, {
|
||||||
|
timeout: 15_000,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("copies the workspace path and confirms with a toast", async ({ page }) => {
|
||||||
|
const workspace = await seedWorkspace({ repoPrefix: "cc-copy-path-" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await openWorkspace(page, workspace.workspaceId);
|
||||||
|
|
||||||
|
await runCommand(page, "copy path", "Copy workspace path");
|
||||||
|
|
||||||
|
// toast.copied wraps its label: "Copied {{label}}" with label "Path copied".
|
||||||
|
await expect(page.getByText("Copied Path copied", { exact: true })).toBeVisible({
|
||||||
|
timeout: 15_000,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("flips the pin entry to Unpin after pinning from the palette", async ({ page }) => {
|
||||||
|
const workspace = await seedWorkspace({ repoPrefix: "cc-pin-" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await openWorkspace(page, workspace.workspaceId);
|
||||||
|
|
||||||
|
await runCommand(page, "pin", "Pin to top");
|
||||||
|
await expect(page.getByTestId("sidebar-pinned-section")).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
const panel = await openCommandCenter(page);
|
||||||
|
await panel.getByTestId("command-center-input").fill("pin");
|
||||||
|
await expect(action(panel, "Unpin")).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(action(panel, "Pin to top")).toHaveCount(0);
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Regression guard for the registration split. Toggle Explorer sidebar and Toggle focus mode are
|
||||||
|
// handled only by workspace-screen.tsx behind `enabled: isRouteFocused && ...`, so listing them
|
||||||
|
// off a workspace route would give the user two entries that close the palette and do nothing.
|
||||||
|
// Toggle left sidebar calls the panel store directly and works everywhere, so it stays listed.
|
||||||
|
test("lists only the globally-handled toggle off a workspace route", async ({ page }) => {
|
||||||
|
const workspace = await seedWorkspace({ repoPrefix: "cc-toggle-scope-" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await openWorkspace(page, workspace.workspaceId);
|
||||||
|
|
||||||
|
// All three are listed while a workspace route owns the handlers.
|
||||||
|
const workspacePanel = await openCommandCenter(page);
|
||||||
|
await workspacePanel.getByTestId("command-center-input").fill("toggle");
|
||||||
|
await expect(action(workspacePanel, "Toggle left sidebar")).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(action(workspacePanel, "Toggle Explorer sidebar")).toBeVisible();
|
||||||
|
await expect(action(workspacePanel, "Toggle focus mode")).toBeVisible();
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(page.getByTestId("command-center-panel")).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.locator('[data-testid="sidebar-settings"]:visible').first().click();
|
||||||
|
await expect(page.getByTestId("settings-sidebar")).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
const panel = await openCommandCenter(page);
|
||||||
|
await panel.getByTestId("command-center-input").fill("toggle");
|
||||||
|
|
||||||
|
await expect(action(panel, "Toggle left sidebar")).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(action(panel, "Toggle Explorer sidebar")).toHaveCount(0);
|
||||||
|
await expect(action(panel, "Toggle focus mode")).toHaveCount(0);
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("toggles a label on the active workspace from the palette", async ({ page }) => {
|
||||||
|
const workspace = await seedWorkspace({ repoPrefix: "cc-label-" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await workspace.client.setWorkspaceLabel({
|
||||||
|
workspaceId: workspace.workspaceId,
|
||||||
|
label: { name: "Urgent", color: "red" },
|
||||||
|
assigned: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await openWorkspace(page, workspace.workspaceId);
|
||||||
|
|
||||||
|
const panel = await openCommandCenter(page);
|
||||||
|
await panel.getByTestId("command-center-input").fill("urgent");
|
||||||
|
await panel.getByTestId("command-center-workspace-label-Urgent").click();
|
||||||
|
// Unassigning the only label drops the field entirely rather than leaving an empty array.
|
||||||
|
await expect.poll(() => readWorkspaceLabels(workspace)).toEqual(undefined);
|
||||||
|
|
||||||
|
const reopened = await openCommandCenter(page);
|
||||||
|
await reopened.getByTestId("command-center-input").fill("urgent");
|
||||||
|
await reopened.getByTestId("command-center-workspace-label-Urgent").click();
|
||||||
|
await expect.poll(() => readWorkspaceLabels(workspace)).toEqual(["Urgent"]);
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows an error toast when assigning a label fails", async ({ page }) => {
|
||||||
|
const workspace = await seedWorkspace({ repoPrefix: "cc-label-failure-" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await rejectNextLabelAssignment(page);
|
||||||
|
await workspace.client.setWorkspaceLabel({
|
||||||
|
workspaceId: workspace.workspaceId,
|
||||||
|
label: { name: "Urgent", color: "red" },
|
||||||
|
assigned: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await openWorkspace(page, workspace.workspaceId);
|
||||||
|
|
||||||
|
const panel = await openCommandCenter(page);
|
||||||
|
await panel.getByTestId("command-center-input").fill("urgent");
|
||||||
|
await panel.getByTestId("command-center-workspace-label-Urgent").click();
|
||||||
|
|
||||||
|
await expect(page.getByTestId("app-toast-message")).toContainText(
|
||||||
|
"Injected label mutation failure.",
|
||||||
|
);
|
||||||
|
await expect.poll(() => readWorkspaceLabels(workspace)).toEqual(["Urgent"]);
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,7 +3,7 @@ import { writeFile } from "node:fs/promises";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { Locator } from "@playwright/test";
|
import type { Locator } from "@playwright/test";
|
||||||
import { test, expect } from "../support/fixtures";
|
import { test, expect } from "../support/fixtures";
|
||||||
import { openChangesPanel } from "../support/helpers/workspace-tabs";
|
import { openChangesTreePanel } from "../support/helpers/workspace-tabs";
|
||||||
|
|
||||||
const COMMIT_SUBJECT = "Show commit timestamps";
|
const COMMIT_SUBJECT = "Show commit timestamps";
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ test("commit history explains when the workspace has no commits ahead of its bas
|
|||||||
execFileSync("git", ["checkout", "-b", "feature"], { cwd: workspace.repoPath, stdio: "ignore" });
|
execFileSync("git", ["checkout", "-b", "feature"], { cwd: workspace.repoPath, stdio: "ignore" });
|
||||||
await workspace.navigateTo();
|
await workspace.navigateTo();
|
||||||
|
|
||||||
await openChangesPanel(page);
|
await openChangesTreePanel(page);
|
||||||
const commitsSection = page.getByRole("button", { name: /Commits/i });
|
const commitsSection = page.getByRole("button", { name: /Commits/i });
|
||||||
await expect(commitsSection).toBeVisible({ timeout: 30_000 });
|
await expect(commitsSection).toBeVisible({ timeout: 30_000 });
|
||||||
await commitsSection.click();
|
await commitsSection.click();
|
||||||
@@ -35,7 +35,7 @@ test("commit history shows dates and shares diff layout preferences", async ({
|
|||||||
await page.setViewportSize({ width: 1400, height: 900 });
|
await page.setViewportSize({ width: 1400, height: 900 });
|
||||||
await workspace.navigateTo();
|
await workspace.navigateTo();
|
||||||
|
|
||||||
await openChangesPanel(page);
|
await openChangesTreePanel(page);
|
||||||
const commitsSection = page.getByRole("button", { name: /Commits/i });
|
const commitsSection = page.getByRole("button", { name: /Commits/i });
|
||||||
await expect(commitsSection).toBeVisible({ timeout: 30_000 });
|
await expect(commitsSection).toBeVisible({ timeout: 30_000 });
|
||||||
await commitsSection.click();
|
await commitsSection.click();
|
||||||
@@ -60,8 +60,8 @@ test("commit history shows dates and shares diff layout preferences", async ({
|
|||||||
throw new Error("Commit-diff toolbar geometry could not be measured");
|
throw new Error("Commit-diff toolbar geometry could not be measured");
|
||||||
}
|
}
|
||||||
expect(commitToolbarBox.height).toBe(36);
|
expect(commitToolbarBox.height).toBe(36);
|
||||||
expect(layoutToggleBox.width).toBe(24);
|
expect(layoutToggleBox.width).toBe(20);
|
||||||
expect(layoutToggleBox.height).toBe(24);
|
expect(layoutToggleBox.height).toBe(20);
|
||||||
expect(layoutToggleGlyphBox.width).toBe(14);
|
expect(layoutToggleGlyphBox.width).toBe(14);
|
||||||
expect(layoutToggleGlyphBox.height).toBe(14);
|
expect(layoutToggleGlyphBox.height).toBe(14);
|
||||||
await expect(layoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
|
await expect(layoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { rm, writeFile } from "node:fs/promises";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { expect, test, type Page } from "../support/fixtures";
|
import { expect, test, type Page } from "../support/fixtures";
|
||||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||||
import { openFilesPanel } from "../support/helpers/workspace-tabs";
|
import { ensureExplorerSidebar, openFilesPanel } from "../support/helpers/workspace-tabs";
|
||||||
|
|
||||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||||
|
|
||||||
@@ -10,6 +10,22 @@ function visibleMainPane(page: Page) {
|
|||||||
return page.getByTestId("workspace-pane-main").filter({ visible: true });
|
return page.getByTestId("workspace-pane-main").filter({ visible: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function composerChangesPill(page: Page) {
|
||||||
|
return page.getByTestId("composer-diff-stat-pill");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revealComposerChangesInExplorer(page: Page) {
|
||||||
|
await composerChangesPill(page).click();
|
||||||
|
|
||||||
|
const explorer = await ensureExplorerSidebar(page);
|
||||||
|
await expect(explorer.getByTestId("changes-tree-panel")).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openComposerDiff(page: Page) {
|
||||||
|
await composerChangesPill(page).click();
|
||||||
|
}
|
||||||
|
|
||||||
async function seedChangedAgent(repoPrefix: string) {
|
async function seedChangedAgent(repoPrefix: string) {
|
||||||
const workspace = await seedMockAgentWorkspace({
|
const workspace = await seedMockAgentWorkspace({
|
||||||
repoPrefix,
|
repoPrefix,
|
||||||
@@ -38,7 +54,12 @@ async function seedChangedAgent(repoPrefix: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test("composer diff stat toggles Changes in the preferred Side panel", async ({ page }) => {
|
test("composer diff stat reveals Changes, then opens the diff in the configured side pane", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.addInitScript((settingsKey) => {
|
||||||
|
localStorage.setItem(settingsKey, JSON.stringify({ openInSidePane: { diffs: true } }));
|
||||||
|
}, APP_SETTINGS_KEY);
|
||||||
const workspace = await seedChangedAgent("composer-diff-stat-side-");
|
const workspace = await seedChangedAgent("composer-diff-stat-side-");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -48,37 +69,31 @@ test("composer diff stat toggles Changes in the preferred Side panel", async ({
|
|||||||
agentId: workspace.agentId,
|
agentId: workspace.agentId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const pill = page.getByTestId("composer-diff-stat-pill");
|
const pill = composerChangesPill(page);
|
||||||
await expect(pill).toBeVisible({ timeout: 30_000 });
|
await expect(pill).toBeVisible({ timeout: 30_000 });
|
||||||
await expect(pill).toContainText("+2");
|
await expect(pill).toContainText("+2");
|
||||||
await expect(pill).toContainText("-0");
|
await expect(pill).toContainText("-0");
|
||||||
await pill.click();
|
await revealComposerChangesInExplorer(page);
|
||||||
|
await openComposerDiff(page);
|
||||||
|
|
||||||
const sidePanel = page.getByTestId("workspace-side-panel").filter({ visible: true });
|
const sidePane = page
|
||||||
await expect(sidePanel.getByTestId("workspace-tab-working_diff")).toBeVisible({
|
.locator('[data-testid^="workspace-pane-"]')
|
||||||
|
.filter({ visible: true })
|
||||||
|
.filter({ has: page.getByTestId("working-diff-panel") });
|
||||||
|
await expect(sidePane.getByTestId("workspace-tab-working_diff")).toBeVisible({
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
await expect(sidePanel.getByTestId("working-diff-panel")).toBeVisible({ timeout: 30_000 });
|
await expect(sidePane.getByTestId("working-diff-panel")).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(visibleMainPane(page).getByTestId("working-diff-panel")).toHaveCount(0);
|
||||||
|
|
||||||
await test.step("switching from another Side panel view reveals Changes", async () => {
|
await test.step("Explorer navigation does not replace the side pane", async () => {
|
||||||
await openFilesPanel(page);
|
await openFilesPanel(page);
|
||||||
await expect(sidePanel.getByTestId("file-explorer-tree-scroll")).toBeVisible();
|
await expect(page.getByTestId("workspace-explorer-sidebar")).toContainText("Files");
|
||||||
|
|
||||||
await pill.click();
|
await pill.click();
|
||||||
await expect(sidePanel.getByTestId("working-diff-panel")).toBeVisible();
|
await expect(sidePane.getByTestId("working-diff-panel")).toBeVisible();
|
||||||
});
|
|
||||||
|
|
||||||
await test.step("clicking visible Changes hides the panel without closing its tab", async () => {
|
|
||||||
await pill.click();
|
|
||||||
await expect(sidePanel).toHaveCount(0);
|
|
||||||
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(1);
|
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step("clicking again restores Changes", async () => {
|
|
||||||
await pill.click();
|
|
||||||
await expect(sidePanel.getByTestId("workspace-tab-working_diff")).toBeVisible();
|
|
||||||
await expect(sidePanel.getByTestId("working-diff-panel")).toBeVisible();
|
|
||||||
});
|
|
||||||
} finally {
|
} finally {
|
||||||
await workspace.cleanup();
|
await workspace.cleanup();
|
||||||
}
|
}
|
||||||
@@ -94,21 +109,26 @@ test("composer diff stat opens the compact explorer instead of a Changes tab", a
|
|||||||
agentId: workspace.agentId,
|
agentId: workspace.agentId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const closeExplorer = page
|
||||||
|
.getByTestId("explorer-header")
|
||||||
|
.getByRole("button", { name: "Close Explorer sidebar" });
|
||||||
|
await expect(closeExplorer).not.toBeInViewport();
|
||||||
|
|
||||||
await page.getByTestId("composer-diff-stat-pill").click();
|
await page.getByTestId("composer-diff-stat-pill").click();
|
||||||
|
|
||||||
await expect(page.getByTestId("changes-header")).toBeVisible({ timeout: 30_000 });
|
await expect(closeExplorer).toBeInViewport({ timeout: 30_000 });
|
||||||
|
await expect(page.getByTestId("changes-header").filter({ visible: true }).first()).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(0);
|
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(0);
|
||||||
} finally {
|
} finally {
|
||||||
await workspace.cleanup();
|
await workspace.cleanup();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("composer diff stat opens Changes in the focused pane when Side panel routing is off", async ({
|
test("composer diff stat reveals Changes, then opens the diff in the focused pane by default", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await page.addInitScript((settingsKey) => {
|
|
||||||
localStorage.setItem(settingsKey, JSON.stringify({ openSupportingTabsInSidePanel: false }));
|
|
||||||
}, APP_SETTINGS_KEY);
|
|
||||||
const workspace = await seedChangedAgent("composer-diff-stat-tab-");
|
const workspace = await seedChangedAgent("composer-diff-stat-tab-");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -118,14 +138,17 @@ test("composer diff stat opens Changes in the focused pane when Side panel routi
|
|||||||
agentId: workspace.agentId,
|
agentId: workspace.agentId,
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.getByTestId("composer-diff-stat-pill").click();
|
await revealComposerChangesInExplorer(page);
|
||||||
|
await openComposerDiff(page);
|
||||||
|
|
||||||
const mainPane = visibleMainPane(page);
|
const mainPane = visibleMainPane(page);
|
||||||
await expect(mainPane.getByTestId("workspace-tab-working_diff")).toBeVisible({
|
await expect(mainPane.getByTestId("workspace-tab-working_diff")).toBeVisible({
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
await expect(mainPane.getByTestId("working-diff-panel")).toBeVisible({ timeout: 30_000 });
|
await expect(mainPane.getByTestId("working-diff-panel")).toBeVisible({ timeout: 30_000 });
|
||||||
await expect(page.getByTestId("workspace-side-panel")).toHaveCount(0);
|
await expect(
|
||||||
|
page.locator('[data-testid^="workspace-pane-"]').filter({ visible: true }),
|
||||||
|
).toHaveCount(1);
|
||||||
} finally {
|
} finally {
|
||||||
await workspace.cleanup();
|
await workspace.cleanup();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ test("composer growth keeps a bottom-pinned chat at the bottom", async ({ page }
|
|||||||
repoPrefix: "composer-bottom-anchor-",
|
repoPrefix: "composer-bottom-anchor-",
|
||||||
title: "Composer bottom anchor",
|
title: "Composer bottom anchor",
|
||||||
initialPrompt: "Produce enough content to make the chat scrollable.",
|
initialPrompt: "Produce enough content to make the chat scrollable.",
|
||||||
|
model: "ten-second-stream",
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { expect, test } from "../support/fixtures";
|
import { expect, test } from "../support/fixtures";
|
||||||
|
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
|
||||||
import { gotoWorkspace, pressDirectNewTabShortcut } from "../support/helpers/launcher";
|
import { gotoWorkspace, pressDirectNewTabShortcut } from "../support/helpers/launcher";
|
||||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||||
import { openChangesPanel, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||||
|
|
||||||
test.describe("Direct terminal shortcut pane placement", () => {
|
test.describe("Direct terminal shortcut pane placement", () => {
|
||||||
test("opens a terminal in the focused pane", async ({ page }) => {
|
test("opens a terminal in the focused pane", async ({ page }) => {
|
||||||
@@ -11,21 +12,31 @@ test.describe("Direct terminal shortcut pane placement", () => {
|
|||||||
await gotoWorkspace(page, workspace.workspaceId);
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
await waitForWorkspaceTabsVisible(page);
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
|
||||||
await openChangesPanel(page);
|
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||||
const focusedPane = page
|
const focusedPaneChild = page
|
||||||
.getByTestId("workspace-tabs-row")
|
.getByTestId("split-group-child")
|
||||||
.filter({ has: page.getByTestId("workspace-tab-working_diff") })
|
.filter({ has: page.getByTestId("workspace-new-tab-panel") });
|
||||||
.first();
|
const originalPaneChild = page
|
||||||
const primaryPane = page
|
.getByTestId("split-group-child")
|
||||||
.getByTestId("workspace-tabs-row")
|
.filter({ hasNot: page.getByTestId("workspace-new-tab-panel") });
|
||||||
.filter({ hasNot: page.getByTestId("workspace-tab-working_diff") })
|
await expect(focusedPaneChild).toBeVisible();
|
||||||
.first();
|
const focusedPaneId = await focusedPaneChild
|
||||||
|
.locator('[data-testid^="workspace-pane-"]')
|
||||||
|
.getAttribute("data-testid");
|
||||||
|
const originalPaneId = await originalPaneChild
|
||||||
|
.locator('[data-testid^="workspace-pane-"]')
|
||||||
|
.getAttribute("data-testid");
|
||||||
|
expect(focusedPaneId).not.toBeNull();
|
||||||
|
expect(originalPaneId).not.toBeNull();
|
||||||
|
const focusedPane = page.getByTestId(focusedPaneId!);
|
||||||
|
const originalPane = page.getByTestId(originalPaneId!);
|
||||||
|
|
||||||
await pressDirectNewTabShortcut(page, "t");
|
await pressDirectNewTabShortcut(page, "t");
|
||||||
|
|
||||||
await expect(focusedPane.locator('[data-testid^="workspace-tab-terminal_"]')).toHaveCount(1, {
|
await expect(focusedPane.locator('[data-testid^="workspace-tab-terminal_"]')).toHaveCount(1, {
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
await expect(primaryPane.locator('[data-testid^="workspace-tab-terminal_"]')).toHaveCount(0);
|
await expect(originalPane.locator('[data-testid^="workspace-tab-terminal_"]')).toHaveCount(0);
|
||||||
} finally {
|
} finally {
|
||||||
await workspace.cleanup();
|
await workspace.cleanup();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,38 +13,25 @@ async function expectLauncherSelection(launcher: Locator, name: string): Promise
|
|||||||
await expect(launcher.getByRole("button", { name, exact: true })).toBeFocused();
|
await expect(launcher.getByRole("button", { name, exact: true })).toBeFocused();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openSidePanelWithKeyboard(page: Page): Promise<Locator> {
|
async function openExplorerWithKeyboard(page: Page): Promise<Locator> {
|
||||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||||
await page.keyboard.press(`${modifier}+E`);
|
await page.keyboard.press(`${modifier}+E`);
|
||||||
const launcher = page
|
const explorer = page.getByTestId("workspace-explorer-sidebar").filter({ visible: true });
|
||||||
.getByTestId("workspace-side-panel")
|
await expect(explorer).toBeVisible({ timeout: 30_000 });
|
||||||
.filter({ visible: true })
|
return explorer;
|
||||||
.getByTestId("workspace-new-tab-panel");
|
|
||||||
await expect(launcher).toBeVisible({ timeout: 30_000 });
|
|
||||||
return launcher;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test.describe("New tab keyboard launcher", () => {
|
test.describe("New tab keyboard launcher", () => {
|
||||||
test("Cmd+E focuses the launcher and Enter opens the selected view", async ({ page }) => {
|
test("Cmd+E reveals the repository Changes tree", async ({ page }) => {
|
||||||
const workspace = await seedWorkspace({ repoPrefix: "empty-pane-side-panel-keyboard-" });
|
const workspace = await seedWorkspace({ repoPrefix: "explorer-keyboard-" });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await gotoWorkspace(page, workspace.workspaceId);
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
await waitForWorkspaceTabsVisible(page);
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
|
||||||
const launcher = await openSidePanelWithKeyboard(page);
|
const explorer = await openExplorerWithKeyboard(page);
|
||||||
await expectLauncherSelection(launcher, "Changes");
|
await expect(explorer.getByTestId("explorer-sidebar-tab-changes_tree")).toBeVisible();
|
||||||
|
await expect(explorer.getByTestId("changes-tree-panel")).toBeVisible();
|
||||||
await page.keyboard.press("ArrowDown");
|
|
||||||
await expectLauncherSelection(launcher, "Files");
|
|
||||||
await page.keyboard.press("Enter");
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
page
|
|
||||||
.getByTestId("workspace-side-panel")
|
|
||||||
.filter({ visible: true })
|
|
||||||
.getByTestId("workspace-tab-files"),
|
|
||||||
).toBeVisible({ timeout: 30_000 });
|
|
||||||
} finally {
|
} finally {
|
||||||
await workspace.cleanup();
|
await workspace.cleanup();
|
||||||
}
|
}
|
||||||
@@ -65,14 +52,12 @@ test.describe("New tab keyboard launcher", () => {
|
|||||||
await page.keyboard.press("ArrowDown");
|
await page.keyboard.press("ArrowDown");
|
||||||
await expectLauncherSelection(launcher, "Terminal");
|
await expectLauncherSelection(launcher, "Terminal");
|
||||||
await page.keyboard.press("ArrowDown");
|
await page.keyboard.press("ArrowDown");
|
||||||
await expectLauncherSelection(launcher, "Changes");
|
await expectLauncherSelection(launcher, "Diff");
|
||||||
await page.keyboard.press("ArrowDown");
|
|
||||||
await expectLauncherSelection(launcher, "Files");
|
|
||||||
await page.keyboard.press("Enter");
|
await page.keyboard.press("Enter");
|
||||||
|
|
||||||
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toBeVisible({
|
await expect(
|
||||||
timeout: 30_000,
|
page.getByTestId("workspace-tab-working_diff").filter({ visible: true }),
|
||||||
});
|
).toBeVisible({ timeout: 30_000 });
|
||||||
} finally {
|
} finally {
|
||||||
await workspace.cleanup();
|
await workspace.cleanup();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ import { expect, type Locator, type Page } from "@playwright/test";
|
|||||||
import { test } from "../support/fixtures";
|
import { test } from "../support/fixtures";
|
||||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||||
import { openChangesPanel, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
import {
|
||||||
|
openChangesTreePanel,
|
||||||
|
waitForWorkspaceTabsVisible,
|
||||||
|
} from "../support/helpers/workspace-tabs";
|
||||||
|
|
||||||
function visible(page: Page, testId: string): Locator {
|
function visible(page: Page, testId: string): Locator {
|
||||||
return page.getByTestId(testId).filter({ visible: true });
|
return page.getByTestId(testId).filter({ visible: true });
|
||||||
@@ -22,23 +25,18 @@ function draftTabChip(page: Page): Locator {
|
|||||||
return page.locator('[data-testid^="workspace-tab-draft_"]').filter({ visible: true });
|
return page.locator('[data-testid^="workspace-tab-draft_"]').filter({ visible: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The explorer pane is the only tab row carrying the Changes tab. */
|
/** The Explorer has its own fixed tab rail, separate from workspace pane rows. */
|
||||||
function explorerTabRow(page: Page): Locator {
|
function explorerTabRow(page: Page): Locator {
|
||||||
return visible(page, "workspace-tabs-row").filter({
|
return visible(page, "explorer-sidebar-tab-rail");
|
||||||
has: page.getByTestId("workspace-tab-working_diff"),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function mainTabRow(page: Page): Locator {
|
function mainTabRow(page: Page): Locator {
|
||||||
return visible(page, "workspace-tabs-row").filter({
|
return visible(page, "workspace-tabs-row");
|
||||||
hasNot: page.getByTestId("workspace-tab-working_diff"),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function focusExplorerPane(page: Page): Promise<void> {
|
async function selectExplorerChanges(page: Page): Promise<void> {
|
||||||
await openChangesPanel(page);
|
await openChangesTreePanel(page);
|
||||||
const changesTab = visible(page, "workspace-tab-working_diff").first();
|
await expect(visible(page, "changes-tree-panel").first()).toBeVisible();
|
||||||
await expect(changesTab).toHaveAttribute("aria-selected", "true");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function closeSeededDraftInMainPane(page: Page): Promise<void> {
|
async function closeSeededDraftInMainPane(page: Page): Promise<void> {
|
||||||
@@ -115,8 +113,8 @@ test.describe("explorer pane tab placement", () => {
|
|||||||
await expect(draftTabChip(page).first()).toBeVisible({ timeout: 30_000 });
|
await expect(draftTabChip(page).first()).toBeVisible({ timeout: 30_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step("focus the explorer pane", async () => {
|
await test.step("select Changes in Explorer", async () => {
|
||||||
await focusExplorerPane(page);
|
await selectExplorerChanges(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step("an agent appearing now opens in the main pane, not the explorer pane", async () => {
|
await test.step("an agent appearing now opens in the main pane, not the explorer pane", async () => {
|
||||||
@@ -140,11 +138,8 @@ test.describe("explorer pane tab placement", () => {
|
|||||||
await expect(
|
await expect(
|
||||||
explorerTabRow(page).first().getByTestId(`workspace-tab-agent_${agentId}`),
|
explorerTabRow(page).first().getByTestId(`workspace-tab-agent_${agentId}`),
|
||||||
).toHaveCount(0);
|
).toHaveCount(0);
|
||||||
// The background open must not steal the explorer pane's focus.
|
// The background open must not change the selected Explorer view.
|
||||||
await expect(visible(page, "workspace-tab-working_diff").first()).toHaveAttribute(
|
await expect(visible(page, "changes-tree-panel").first()).toBeVisible();
|
||||||
"aria-selected",
|
|
||||||
"true",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step("close the draft; the agent is the main pane's only tab", async () => {
|
await test.step("close the draft; the agent is the main pane's only tab", async () => {
|
||||||
@@ -170,8 +165,9 @@ test.describe("explorer pane tab placement", () => {
|
|||||||
body: await page.screenshot(),
|
body: await page.screenshot(),
|
||||||
contentType: "image/png",
|
contentType: "image/png",
|
||||||
});
|
});
|
||||||
// The split must have taken: agent pane + New main + explorer = 3 tab rows.
|
// The split must have taken: agent pane + New main. Explorer keeps its own rail.
|
||||||
await expect(visible(page, "workspace-tabs-row")).toHaveCount(3, { timeout: 10_000 });
|
await expect(visible(page, "workspace-tabs-row")).toHaveCount(2, { timeout: 10_000 });
|
||||||
|
await expect(explorerTabRow(page)).toHaveCount(1);
|
||||||
await expect(page.getByTestId("workspace-new-tab-panel")).toBeVisible();
|
await expect(page.getByTestId("workspace-new-tab-panel")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { expect, test } from "../support/fixtures";
|
||||||
|
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||||
|
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||||
|
import {
|
||||||
|
ensureExplorerSidebar,
|
||||||
|
openFilesPanel,
|
||||||
|
waitForWorkspaceTabsVisible,
|
||||||
|
} from "../support/helpers/workspace-tabs";
|
||||||
|
|
||||||
|
function explorerSidebar(page: Parameters<typeof ensureExplorerSidebar>[0]) {
|
||||||
|
return page.getByTestId("workspace-explorer-sidebar").filter({ visible: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("Explorer sidebar", () => {
|
||||||
|
test("starts with Files and Changes, switches views, and toggles without changing main", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const workspace = await seedWorkspace({ repoPrefix: "explorer-sidebar-defaults-" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
const mainTabsBefore = await page
|
||||||
|
.getByTestId("workspace-pane-main")
|
||||||
|
.locator('[data-testid^="workspace-tab-"]')
|
||||||
|
.count();
|
||||||
|
|
||||||
|
const explorer = await ensureExplorerSidebar(page);
|
||||||
|
await expect(explorer.getByTestId("explorer-sidebar-tab-files")).toBeVisible();
|
||||||
|
await expect(explorer.getByTestId("explorer-sidebar-tab-changes_tree")).toBeVisible();
|
||||||
|
await expect(explorer.getByTestId("workspace-new-tab-button")).toHaveCount(0);
|
||||||
|
|
||||||
|
await openFilesPanel(page);
|
||||||
|
await expect(explorer.getByTestId("file-explorer-tree-scroll")).toBeVisible();
|
||||||
|
|
||||||
|
await explorer.getByTestId("explorer-sidebar-tab-changes_tree").click();
|
||||||
|
await expect(explorer.getByTestId("changes-tree-panel")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||||
|
await expect(explorerSidebar(page)).toHaveCount(0);
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("workspace-pane-main").locator('[data-testid^="workspace-tab-"]'),
|
||||||
|
).toHaveCount(mainTabsBefore);
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,14 +19,8 @@ function readPersistedPanelState(page: Page): Promise<string | null> {
|
|||||||
return page.evaluate(() => localStorage.getItem("panel-state"));
|
return page.evaluate(() => localStorage.getItem("panel-state"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Only the docked sidebar ever rendered `explorer-header` on a wide layout. */
|
function explorerSidebar(page: Page): Locator {
|
||||||
function dockedSidebar(page: Page): Locator {
|
return visible(page, "workspace-explorer-sidebar");
|
||||||
return visible(page, "explorer-header");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The desktop companion surface is a hideable side panel. */
|
|
||||||
function sidePanel(page: Page): Locator {
|
|
||||||
return visible(page, "workspace-side-panel");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Seed exactly what a pre-pane build wrote after the user opened the sidebar once. */
|
/** Seed exactly what a pre-pane build wrote after the user opened the sidebar once. */
|
||||||
@@ -65,18 +59,18 @@ test.describe("explorer surface after upgrading from the docked sidebar", () =>
|
|||||||
await gotoWorkspace(page, workspace.workspaceId);
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
await waitForWorkspaceTabsVisible(page);
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
|
||||||
await test.step("no docked sidebar, on load or after toggling", async () => {
|
await test.step("the retired flag does not reveal Explorer", async () => {
|
||||||
await expect(dockedSidebar(page)).toHaveCount(0);
|
await expect(explorerSidebar(page)).toHaveCount(0);
|
||||||
|
|
||||||
const toggle = visible(page, "workspace-explorer-toggle").first();
|
const toggle = visible(page, "workspace-explorer-toggle").first();
|
||||||
await toggle.click();
|
await toggle.click();
|
||||||
await expect(sidePanel(page)).toHaveCount(1, { timeout: 15_000 });
|
await expect(explorerSidebar(page)).toHaveCount(1, { timeout: 15_000 });
|
||||||
await expect(sidePanel(page).getByTestId("workspace-new-tab-panel")).toBeVisible();
|
await expect(explorerSidebar(page).getByTestId("explorer-sidebar-tab-files")).toBeVisible();
|
||||||
await expect(dockedSidebar(page)).toHaveCount(0);
|
await expect(
|
||||||
|
explorerSidebar(page).getByTestId("explorer-sidebar-tab-changes_tree"),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
await toggle.click();
|
await toggle.click();
|
||||||
await expect(sidePanel(page)).toHaveCount(0, { timeout: 15_000 });
|
await expect(explorerSidebar(page)).toHaveCount(0, { timeout: 15_000 });
|
||||||
await expect(dockedSidebar(page)).toHaveCount(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step("the retired keys are migrated out of storage", async () => {
|
await test.step("the retired keys are migrated out of storage", async () => {
|
||||||
@@ -107,22 +101,22 @@ test.describe("explorer surface after upgrading from the docked sidebar", () =>
|
|||||||
|
|
||||||
await test.step("open the explorer so both sides are showing", async () => {
|
await test.step("open the explorer so both sides are showing", async () => {
|
||||||
await toggle.click();
|
await toggle.click();
|
||||||
await expect(sidePanel(page)).toHaveCount(1, { timeout: 15_000 });
|
await expect(explorerSidebar(page)).toHaveCount(1, { timeout: 15_000 });
|
||||||
await expect(sidePanel(page).getByTestId("workspace-new-tab-panel")).toBeVisible();
|
await expect(explorerSidebar(page).getByTestId("explorer-sidebar-tab-files")).toBeVisible();
|
||||||
await expect(agentList).toHaveCount(1);
|
await expect(agentList).toHaveCount(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step("the shortcut collapses both", async () => {
|
await test.step("the shortcut collapses both", async () => {
|
||||||
await page.keyboard.press(`${modifier}+.`);
|
await page.keyboard.press(`${modifier}+.`);
|
||||||
await expect(sidePanel(page)).toHaveCount(0, { timeout: 15_000 });
|
await expect(explorerSidebar(page)).toHaveCount(0, { timeout: 15_000 });
|
||||||
await expect(agentList).toHaveCount(0);
|
await expect(agentList).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step("the shortcut restores both", async () => {
|
await test.step("the shortcut restores both", async () => {
|
||||||
await page.keyboard.press(`${modifier}+.`);
|
await page.keyboard.press(`${modifier}+.`);
|
||||||
await expect(agentList).toHaveCount(1, { timeout: 15_000 });
|
await expect(agentList).toHaveCount(1, { timeout: 15_000 });
|
||||||
await expect(sidePanel(page)).toHaveCount(1, { timeout: 15_000 });
|
await expect(explorerSidebar(page)).toHaveCount(1, { timeout: 15_000 });
|
||||||
await expect(sidePanel(page).getByTestId("workspace-new-tab-panel")).toBeVisible();
|
await expect(explorerSidebar(page).getByTestId("explorer-sidebar-tab-files")).toBeVisible();
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
await workspace.cleanup();
|
await workspace.cleanup();
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||||
|
|
||||||
|
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||||
|
|
||||||
const RED_PIXEL = Buffer.from(
|
const RED_PIXEL = Buffer.from(
|
||||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII=",
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII=",
|
||||||
"base64",
|
"base64",
|
||||||
@@ -218,6 +220,12 @@ test.describe("CodeMirror workspace file editing", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("clicking the editor focuses its pane beside an agent", async ({ page }) => {
|
test("clicking the editor focuses its pane beside an agent", async ({ page }) => {
|
||||||
|
await page.addInitScript((settingsKey) => {
|
||||||
|
localStorage.setItem(
|
||||||
|
settingsKey,
|
||||||
|
JSON.stringify({ openInSidePane: { explorerFiles: true } }),
|
||||||
|
);
|
||||||
|
}, APP_SETTINGS_KEY);
|
||||||
const target = "target.ts:42";
|
const target = "target.ts:42";
|
||||||
const session = await seedAgentWithFileLink({
|
const session = await seedAgentWithFileLink({
|
||||||
target,
|
target,
|
||||||
|
|||||||
@@ -1,142 +0,0 @@
|
|||||||
// The file panel's tree rail had no way to close: the changes panel's toolbar
|
|
||||||
// carries a folder-tree toggle, the file toolbar carried nothing, and the rail
|
|
||||||
// was permanent on desktop.
|
|
||||||
import { expect } from "@playwright/test";
|
|
||||||
import { test } from "../support/fixtures";
|
|
||||||
import { openFileExplorer, openFileFromExplorer } from "../support/helpers/file-explorer";
|
|
||||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
|
||||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
|
||||||
|
|
||||||
let workspace: SeededWorkspace;
|
|
||||||
|
|
||||||
async function effectiveBackground(locator: import("@playwright/test").Locator): Promise<string> {
|
|
||||||
return locator.evaluate((element) => {
|
|
||||||
let current: Element | null = element;
|
|
||||||
while (current) {
|
|
||||||
const color = getComputedStyle(current).backgroundColor;
|
|
||||||
if (color !== "rgba(0, 0, 0, 0)" && color !== "transparent") return color;
|
|
||||||
current = current.parentElement;
|
|
||||||
}
|
|
||||||
return "transparent";
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test.beforeAll(async () => {
|
|
||||||
workspace = await seedWorkspace({
|
|
||||||
repoPrefix: "file-tree-rail-toggle-",
|
|
||||||
repo: { files: [{ path: "docs/guide.md", content: "# Guide\n" }] },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.afterAll(async () => {
|
|
||||||
await workspace?.cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("File panel tree rail", () => {
|
|
||||||
test("the Files panel starts with an empty content pane beside the tree", async ({ page }) => {
|
|
||||||
await gotoWorkspace(page, workspace.workspaceId);
|
|
||||||
await openFileExplorer(page);
|
|
||||||
|
|
||||||
const rail = page.getByTestId("files-tree-rail").filter({ visible: true });
|
|
||||||
await expect(rail).toBeVisible({ timeout: 30_000 });
|
|
||||||
const content = rail.getByTestId("files-tree-rail-content");
|
|
||||||
const tree = rail.getByTestId("files-tree-rail-tree");
|
|
||||||
await expect(content).toHaveText("Choose a file");
|
|
||||||
await expect(tree).toContainText("docs");
|
|
||||||
expect(await effectiveBackground(tree.getByTestId("file-explorer-tree-scroll"))).toBe(
|
|
||||||
await effectiveBackground(content),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the file toolbar toggle closes and reopens the tree", async ({ page }, testInfo) => {
|
|
||||||
await gotoWorkspace(page, workspace.workspaceId);
|
|
||||||
await openFileExplorer(page);
|
|
||||||
await openFileFromExplorer(page, "docs");
|
|
||||||
await openFileFromExplorer(page, "guide.md");
|
|
||||||
|
|
||||||
const rail = page.getByTestId("file-tree-rail-tree").filter({ visible: true });
|
|
||||||
const toggle = page.getByTestId("file-toggle-tree").filter({ visible: true }).first();
|
|
||||||
const editorToolbar = page.getByTestId("file-panel-bar").filter({ visible: true });
|
|
||||||
const filesToolbar = page.getByTestId("files-pane-header").filter({ visible: true });
|
|
||||||
const refresh = page.getByRole("button", { name: "Refresh files" }).filter({ visible: true });
|
|
||||||
const hiddenFiles = page
|
|
||||||
.getByRole("button", { name: "Hide hidden files" })
|
|
||||||
.filter({ visible: true });
|
|
||||||
const shoot = async (name: string) => {
|
|
||||||
const path = testInfo.outputPath(`${name}.png`);
|
|
||||||
await page.screenshot({ path });
|
|
||||||
await testInfo.attach(name, { path, contentType: "image/png" });
|
|
||||||
};
|
|
||||||
await expect(rail).toBeVisible({ timeout: 30_000 });
|
|
||||||
await expect(toggle).toHaveAttribute("aria-selected", "true");
|
|
||||||
const [
|
|
||||||
editorToolbarBox,
|
|
||||||
filesToolbarBox,
|
|
||||||
treeToggleBox,
|
|
||||||
refreshBox,
|
|
||||||
treeGlyphBox,
|
|
||||||
refreshGlyphBox,
|
|
||||||
] = await Promise.all([
|
|
||||||
editorToolbar.boundingBox(),
|
|
||||||
filesToolbar.boundingBox(),
|
|
||||||
toggle.boundingBox(),
|
|
||||||
refresh.boundingBox(),
|
|
||||||
toggle.locator("svg").boundingBox(),
|
|
||||||
refresh.locator("svg").boundingBox(),
|
|
||||||
]);
|
|
||||||
if (
|
|
||||||
!editorToolbarBox ||
|
|
||||||
!filesToolbarBox ||
|
|
||||||
!treeToggleBox ||
|
|
||||||
!refreshBox ||
|
|
||||||
!treeGlyphBox ||
|
|
||||||
!refreshGlyphBox
|
|
||||||
) {
|
|
||||||
throw new Error("Pane-content toolbar geometry could not be measured");
|
|
||||||
}
|
|
||||||
expect(editorToolbarBox.height).toBe(36);
|
|
||||||
expect(filesToolbarBox.height).toBe(36);
|
|
||||||
expect(editorToolbarBox.y + editorToolbarBox.height).toBe(
|
|
||||||
filesToolbarBox.y + filesToolbarBox.height,
|
|
||||||
);
|
|
||||||
expect(treeToggleBox.width).toBe(24);
|
|
||||||
expect(treeToggleBox.height).toBe(24);
|
|
||||||
expect(refreshBox.width).toBe(24);
|
|
||||||
expect(refreshBox.height).toBe(24);
|
|
||||||
expect(treeGlyphBox.width).toBe(14);
|
|
||||||
expect(treeGlyphBox.height).toBe(14);
|
|
||||||
expect(refreshGlyphBox.width).toBe(14);
|
|
||||||
expect(refreshGlyphBox.height).toBe(14);
|
|
||||||
|
|
||||||
const restBackground = await refresh.evaluate(
|
|
||||||
(element) => getComputedStyle(element).backgroundColor,
|
|
||||||
);
|
|
||||||
const treeToggleSelectedBackground = await toggle.evaluate(
|
|
||||||
(element) => getComputedStyle(element).backgroundColor,
|
|
||||||
);
|
|
||||||
await refresh.hover();
|
|
||||||
const refreshHoverBackground = await refresh.evaluate(
|
|
||||||
(element) => getComputedStyle(element).backgroundColor,
|
|
||||||
);
|
|
||||||
expect(refreshHoverBackground).not.toBe(restBackground);
|
|
||||||
expect(refreshHoverBackground).toBe(treeToggleSelectedBackground);
|
|
||||||
await hiddenFiles.click();
|
|
||||||
const hiddenFilesSelected = page
|
|
||||||
.getByRole("button", { name: "Show hidden files" })
|
|
||||||
.filter({ visible: true });
|
|
||||||
expect(
|
|
||||||
await hiddenFilesSelected.evaluate((element) => getComputedStyle(element).backgroundColor),
|
|
||||||
).toBe(refreshHoverBackground);
|
|
||||||
await shoot("tree-open");
|
|
||||||
|
|
||||||
await toggle.click();
|
|
||||||
await expect(toggle).toHaveAttribute("aria-selected", "false");
|
|
||||||
await expect(rail).toHaveCount(0);
|
|
||||||
await shoot("tree-closed");
|
|
||||||
|
|
||||||
await toggle.click();
|
|
||||||
await expect(toggle).toHaveAttribute("aria-selected", "true");
|
|
||||||
await expect(rail).toBeVisible();
|
|
||||||
await shoot("tree-reopened");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import { test, expect } from "../support/fixtures";
|
|
||||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
|
||||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
|
||||||
import {
|
|
||||||
expectExplorerNodeRetained,
|
|
||||||
hideExplorerSplit,
|
|
||||||
measureExplorerSplit,
|
|
||||||
openExplorerSplit,
|
|
||||||
resizeExplorerSplit,
|
|
||||||
stampExplorerNode,
|
|
||||||
} from "../support/helpers/split-pane";
|
|
||||||
import {
|
|
||||||
createAgentTabFromMenu,
|
|
||||||
waitForWorkspaceTabsVisible,
|
|
||||||
} from "../support/helpers/workspace-tabs";
|
|
||||||
|
|
||||||
test.describe("Hidden split pane retention", () => {
|
|
||||||
test("releases all layout space without unmounting and restores its dragged width", async ({
|
|
||||||
page,
|
|
||||||
}, testInfo) => {
|
|
||||||
const workspace = await seedWorkspace({ repoPrefix: "hidden-split-pane-" });
|
|
||||||
|
|
||||||
try {
|
|
||||||
await gotoWorkspace(page, workspace.workspaceId);
|
|
||||||
await waitForWorkspaceTabsVisible(page);
|
|
||||||
await createAgentTabFromMenu(page);
|
|
||||||
await openExplorerSplit(page);
|
|
||||||
|
|
||||||
// Drag to a non-default width first: restoring the default would prove nothing.
|
|
||||||
await resizeExplorerSplit(page, 137);
|
|
||||||
await stampExplorerNode(page);
|
|
||||||
const open = await measureExplorerSplit(page);
|
|
||||||
expect(open.explorerWidth).toBeGreaterThan(250);
|
|
||||||
expect(open.separatorCount).toBe(1);
|
|
||||||
|
|
||||||
await hideExplorerSplit(page);
|
|
||||||
const hidden = await measureExplorerSplit(page);
|
|
||||||
expect(hidden.explorerWidth).toBe(0);
|
|
||||||
// The sibling must absorb the whole row, not just collapse the hidden pane.
|
|
||||||
expect(hidden.visibleWidth).toBeCloseTo(hidden.containerWidth, 5);
|
|
||||||
expect(hidden.occupiedWidth).toBeCloseTo(hidden.containerWidth, 5);
|
|
||||||
expect(hidden.separatorCount).toBe(0);
|
|
||||||
// Same DOM node, so a teardown-and-rebuild cannot pass as retention.
|
|
||||||
await expectExplorerNodeRetained(page);
|
|
||||||
|
|
||||||
await openExplorerSplit(page);
|
|
||||||
await expectExplorerNodeRetained(page);
|
|
||||||
const reopened = await measureExplorerSplit(page);
|
|
||||||
expect(reopened.explorerWidth).toBeCloseTo(open.explorerWidth, 5);
|
|
||||||
expect(reopened.separatorCount).toBe(1);
|
|
||||||
|
|
||||||
await testInfo.attach("split-pane-geometry", {
|
|
||||||
body: JSON.stringify({ open, hidden, reopened }, null, 2),
|
|
||||||
contentType: "application/json",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
await workspace.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
countTabsOfKind,
|
countTabsOfKind,
|
||||||
waitForTabBar,
|
waitForTabBar,
|
||||||
pressNewTabShortcut,
|
pressNewTabShortcut,
|
||||||
|
pressDirectNewTabShortcut,
|
||||||
getTabTestIds,
|
getTabTestIds,
|
||||||
waitForTabWithTitle,
|
waitForTabWithTitle,
|
||||||
measureTileTransition,
|
measureTileTransition,
|
||||||
@@ -177,13 +178,11 @@ test.describe("Tab creation", () => {
|
|||||||
const panel = page.getByTestId("workspace-new-tab-panel").filter({ visible: true });
|
const panel = page.getByTestId("workspace-new-tab-panel").filter({ visible: true });
|
||||||
const agent = panel.getByRole("button", { name: /^Agent/ });
|
const agent = panel.getByRole("button", { name: /^Agent/ });
|
||||||
const terminal = panel.getByRole("button", { name: /^Terminal/ });
|
const terminal = panel.getByRole("button", { name: /^Terminal/ });
|
||||||
const changes = panel.getByRole("button", { name: /Changes/ });
|
const diff = panel.getByRole("button", { name: /Diff/ });
|
||||||
const files = panel.getByRole("button", { name: /Files/ });
|
|
||||||
const shortcutPrefix = process.platform === "darwin" ? /⇧⌘/ : /Ctrl.*Shift/;
|
const shortcutPrefix = process.platform === "darwin" ? /⇧⌘/ : /Ctrl.*Shift/;
|
||||||
await expect(agent).toContainText(new RegExp(`${shortcutPrefix.source}.*A`));
|
await expect(agent).toContainText(new RegExp(`${shortcutPrefix.source}.*A`));
|
||||||
await expect(terminal).toContainText(new RegExp(`${shortcutPrefix.source}.*T`));
|
await expect(terminal).toContainText(new RegExp(`${shortcutPrefix.source}.*T`));
|
||||||
await expect(changes).toContainText(new RegExp(`${shortcutPrefix.source}.*G`));
|
await expect(diff).toContainText(new RegExp(`${shortcutPrefix.source}.*G`));
|
||||||
await expect(files).toContainText(new RegExp(`${shortcutPrefix.source}.*E`));
|
|
||||||
await expect(agent).toBeFocused();
|
await expect(agent).toBeFocused();
|
||||||
|
|
||||||
await page.keyboard.press("ArrowDown");
|
await page.keyboard.press("ArrowDown");
|
||||||
@@ -191,6 +190,12 @@ test.describe("Tab creation", () => {
|
|||||||
await page.keyboard.press("ArrowUp");
|
await page.keyboard.press("ArrowUp");
|
||||||
await expect(agent).toBeFocused();
|
await expect(agent).toBeFocused();
|
||||||
|
|
||||||
|
await pressDirectNewTabShortcut(page, "e");
|
||||||
|
await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("file-explorer-tree-scroll").filter({ visible: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
await page.locator("body").click({ position: { x: 1, y: 1 } });
|
await page.locator("body").click({ position: { x: 1, y: 1 } });
|
||||||
await panel.click({ position: { x: 20, y: 20 } });
|
await panel.click({ position: { x: 20, y: 20 } });
|
||||||
await expect(agent).toBeFocused();
|
await expect(agent).toBeFocused();
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ test.describe("New workspace: launch target memory", () => {
|
|||||||
await test.step("launching a terminal profile preselects it the next time New workspace opens", async () => {
|
await test.step("launching a terminal profile preselects it the next time New workspace opens", async () => {
|
||||||
await openNewWorkspace();
|
await openNewWorkspace();
|
||||||
await selectLaunchOption(page, PROMPT_PROFILE.id);
|
await selectLaunchOption(page, PROMPT_PROFILE.id);
|
||||||
|
await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name);
|
||||||
await fillTerminalPrompt(page, "remember me");
|
await fillTerminalPrompt(page, "remember me");
|
||||||
await submitTerminalLaunch(page);
|
await submitTerminalLaunch(page);
|
||||||
await expectWorkspaceOpensWithTerminalTab(page);
|
await expectWorkspaceOpensWithTerminalTab(page);
|
||||||
@@ -88,6 +89,7 @@ test.describe("New workspace: launch target memory", () => {
|
|||||||
|
|
||||||
await test.step("re-selecting the terminal profile, then removing it daemon-side, falls back to Chat rather than a dead entry", async () => {
|
await test.step("re-selecting the terminal profile, then removing it daemon-side, falls back to Chat rather than a dead entry", async () => {
|
||||||
await selectLaunchOption(page, PROMPT_PROFILE.id);
|
await selectLaunchOption(page, PROMPT_PROFILE.id);
|
||||||
|
await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name);
|
||||||
await fillTerminalPrompt(page, "second run");
|
await fillTerminalPrompt(page, "second run");
|
||||||
await submitTerminalLaunch(page);
|
await submitTerminalLaunch(page);
|
||||||
await expectWorkspaceOpensWithTerminalTab(page);
|
await expectWorkspaceOpensWithTerminalTab(page);
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { expect, test } from "../support/fixtures";
|
||||||
|
import { waitForPermissionPrompt } from "../support/helpers/permissions";
|
||||||
|
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||||
|
|
||||||
|
// The plan card has to pass its own markdown parser. Drop that prop and
|
||||||
|
// react-native-markdown-display silently supplies one with `typographer: true`
|
||||||
|
// (node_modules/react-native-markdown-display/src/index.js:139-141), which
|
||||||
|
// turns (c) into ©, curls straight quotes, and rewrites --- as an em dash.
|
||||||
|
// No unit test can catch that, because the defect lives in the JSX.
|
||||||
|
//
|
||||||
|
// Nothing here is provider-specific: the mock provider drives it, and the
|
||||||
|
// behavior is the same for every agent.
|
||||||
|
test.describe("Plan card markdown", () => {
|
||||||
|
test("renders plan text verbatim", async ({ page }) => {
|
||||||
|
test.setTimeout(180_000);
|
||||||
|
|
||||||
|
const session = await seedMockAgentWorkspace({
|
||||||
|
repoPrefix: "plan-card-markdown-",
|
||||||
|
title: "Plan card markdown e2e",
|
||||||
|
initialPrompt: "Emit synthetic plan approval.",
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await openAgentRoute(page, session);
|
||||||
|
await waitForPermissionPrompt(page, 120_000);
|
||||||
|
|
||||||
|
const planCard = page.getByTestId("permission-plan-card");
|
||||||
|
await expect(planCard).toContainText("(c)");
|
||||||
|
await expect(planCard).toContainText('--name="my repo"');
|
||||||
|
await expect(planCard).toContainText("---buzz");
|
||||||
|
await expect(planCard).not.toContainText("©");
|
||||||
|
await expect(planCard).not.toContainText("—buzz");
|
||||||
|
} finally {
|
||||||
|
await session.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { expect, test, type Page } from "../support/fixtures";
|
||||||
|
import { gotoAppShell } from "../support/helpers/app";
|
||||||
|
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||||
|
import {
|
||||||
|
expectMobileAgentSidebarVisible,
|
||||||
|
openMobileAgentSidebar,
|
||||||
|
} from "../support/helpers/sidebar";
|
||||||
|
|
||||||
|
const PLUGIN_ID = "plugin-host-ui-e2e";
|
||||||
|
|
||||||
|
const PLUGIN_SOURCE = `import { usePaseo } from "@getpaseo/plugin";
|
||||||
|
import { Icon, Modal, useToast } from "@getpaseo/plugin/react-native";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { Pressable, Text, View } from "react-native";
|
||||||
|
|
||||||
|
function ModalBody({ onSaved }) {
|
||||||
|
usePaseo();
|
||||||
|
useQueryClient();
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
toast.show("Issue saved", { variant: "success" });
|
||||||
|
onSaved();
|
||||||
|
}
|
||||||
|
|
||||||
|
return <View>
|
||||||
|
<Text>Plugin modal contexts ready</Text>
|
||||||
|
<Pressable accessibilityRole="button" onPress={save}>
|
||||||
|
<Text>Save issue</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Surface() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
return <View>
|
||||||
|
<Pressable accessibilityRole="button" onPress={() => setOpen(true)}>
|
||||||
|
<View style={{ flexDirection: "row" }}>
|
||||||
|
<Icon name="Pencil" size={18} />
|
||||||
|
<Text>Open plugin modal</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
<Modal
|
||||||
|
title="Edit plugin issue"
|
||||||
|
icon={<Icon name="Pencil" size={18} />}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
>
|
||||||
|
<Modal.Content>
|
||||||
|
<ModalBody onSaved={() => setOpen(false)} />
|
||||||
|
</Modal.Content>
|
||||||
|
</Modal>
|
||||||
|
</View>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function contribute(plugin) {
|
||||||
|
plugin.addSurface("main", Surface);
|
||||||
|
plugin.addSidebarItem({
|
||||||
|
id: "main",
|
||||||
|
title: "Host UI",
|
||||||
|
icon: "PanelsTopLeft",
|
||||||
|
surface: "main",
|
||||||
|
});
|
||||||
|
return () => {};
|
||||||
|
}`;
|
||||||
|
|
||||||
|
async function openHostUiPlugin(page: Page): Promise<void> {
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await page.getByRole("button", { name: "Host UI", exact: true }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function useNonCompactLayout(page: Page): Promise<void> {
|
||||||
|
await page.setViewportSize({ width: 1100, height: 800 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function useCompactLayout(page: Page): Promise<void> {
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reopenHostUiPluginFromCompactSidebar(page: Page): Promise<void> {
|
||||||
|
await openMobileAgentSidebar(page);
|
||||||
|
await expectMobileAgentSidebarVisible(page);
|
||||||
|
await page.getByRole("button", { name: "Host UI", exact: true }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openPluginModal(page: Page): Promise<void> {
|
||||||
|
await page.getByRole("button", { name: "Open plugin modal", exact: true }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectCenteredPluginDialog(page: Page): Promise<void> {
|
||||||
|
const dialog = page.getByRole("dialog");
|
||||||
|
await expect(dialog.getByText("Edit plugin issue", { exact: true })).toBeVisible();
|
||||||
|
await expect(dialog.getByText("Plugin modal contexts ready", { exact: true })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeCenteredPluginDialog(page: Page): Promise<void> {
|
||||||
|
const dialog = page.getByRole("dialog");
|
||||||
|
await dialog.getByRole("button", { name: "Close", exact: true }).click();
|
||||||
|
await expect(dialog).not.toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectCompactPluginSheet(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByText("Edit plugin issue", { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByText("Plugin modal contexts ready", { exact: true })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePluginIssue(page: Page): Promise<void> {
|
||||||
|
await page.getByRole("button", { name: "Save issue", exact: true }).click();
|
||||||
|
await expect(page.getByText("Issue saved", { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByText("Plugin modal contexts ready", { exact: true })).not.toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("plugin modal adapts its presentation and preserves host contexts", async ({ page }) => {
|
||||||
|
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-host-ui-e2e-"));
|
||||||
|
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||||
|
const previousConfig = await client.getDaemonConfig();
|
||||||
|
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: PLUGIN_ID }));
|
||||||
|
await writeFile(path.join(directory, "index.tsx"), PLUGIN_SOURCE);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||||
|
await client.installDirectoryPlugin(directory);
|
||||||
|
await useNonCompactLayout(page);
|
||||||
|
await openHostUiPlugin(page);
|
||||||
|
|
||||||
|
await test.step("non-compact layouts use a centered dialog", async () => {
|
||||||
|
await openPluginModal(page);
|
||||||
|
await expectCenteredPluginDialog(page);
|
||||||
|
await closeCenteredPluginDialog(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("compact layouts preserve host contexts inside a sheet", async () => {
|
||||||
|
await useCompactLayout(page);
|
||||||
|
await reopenHostUiPluginFromCompactSidebar(page);
|
||||||
|
await openPluginModal(page);
|
||||||
|
await expectCompactPluginSheet(page);
|
||||||
|
await savePluginIssue(page);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await client.removePlugin(PLUGIN_ID).catch(() => undefined);
|
||||||
|
await client
|
||||||
|
.patchDaemonConfig({ pluginsEnabled: previousConfig.config.pluginsEnabled ?? false })
|
||||||
|
.catch(() => undefined);
|
||||||
|
await client.close().catch(() => undefined);
|
||||||
|
await rm(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -5,6 +5,7 @@ import type { TestInfo } from "@playwright/test";
|
|||||||
import { expect, test, type Page } from "../support/fixtures";
|
import { expect, test, type Page } from "../support/fixtures";
|
||||||
import { gotoAppShell } from "../support/helpers/app";
|
import { gotoAppShell } from "../support/helpers/app";
|
||||||
import { openCommandCenter } from "../support/helpers/command-center";
|
import { openCommandCenter } from "../support/helpers/command-center";
|
||||||
|
import { submitMessage } from "../support/helpers/composer";
|
||||||
import { addConnectedHostAndReload } from "../support/helpers/hosts";
|
import { addConnectedHostAndReload } from "../support/helpers/hosts";
|
||||||
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
||||||
import { buildAgentRoute } from "../support/helpers/mock-agent";
|
import { buildAgentRoute } from "../support/helpers/mock-agent";
|
||||||
@@ -31,7 +32,15 @@ function isSettledWorkspaceUrl(url: URL): boolean {
|
|||||||
function pluginSource(): string {
|
function pluginSource(): string {
|
||||||
return `import React, { useRef } from "react";
|
return `import React, { useRef } from "react";
|
||||||
import { Text, View } from "react-native";
|
import { Text, View } from "react-native";
|
||||||
import { useAgent, useWorkspace } from "@getpaseo/plugin";
|
import { Icon, useAgent, useWorkspace } from "@getpaseo/plugin";
|
||||||
|
import { defineRpc } from "@getpaseo/plugin/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const recordComposerOpen = defineRpc({
|
||||||
|
name: "composer.open",
|
||||||
|
input: z.object({ workspaceId: z.string() }),
|
||||||
|
output: z.object({ opened: z.boolean() }),
|
||||||
|
});
|
||||||
|
|
||||||
function WorkspacePanel({ workspaceId, host, layout }) {
|
function WorkspacePanel({ workspaceId, host, layout }) {
|
||||||
const workspace = useWorkspace(workspaceId, (value) => ({ id: value.id }));
|
const workspace = useWorkspace(workspaceId, (value) => ({ id: value.id }));
|
||||||
@@ -54,7 +63,56 @@ function SidebarCollisionSurface() {
|
|||||||
return <View><Text>Sidebar collision surface</Text></View>;
|
return <View><Text>Sidebar collision surface</Text></View>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ComposerPill({ theme, workspaceId, agentId }) {
|
||||||
|
const workspace = useWorkspace(workspaceId, (value) => ({ title: value.title }));
|
||||||
|
const agent = useAgent(agentId, (value) => ({ title: value.title }));
|
||||||
|
return <><Icon name="Scan" size={14} color={theme.colors.foregroundMuted} /><Text numberOfLines={1} style={{ color: theme.colors.foregroundMuted, flexShrink: 1 }}>Review {workspace?.title}:{agent?.title}</Text></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function contributeClient(client) {
|
||||||
|
const pills = new Map();
|
||||||
|
const remove = (agentId) => {
|
||||||
|
pills.get(agentId)?.();
|
||||||
|
pills.delete(agentId);
|
||||||
|
};
|
||||||
|
const unsubscribe = client.paseo.agents.subscribe((update) => {
|
||||||
|
if (update.kind === "remove") {
|
||||||
|
remove(update.agentId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const agent = update.agent;
|
||||||
|
if (agent.title !== "Plugin panel context agent" || !agent.workspaceId) return;
|
||||||
|
remove(agent.id);
|
||||||
|
let removePill = () => {};
|
||||||
|
removePill = client.addComposerPill({
|
||||||
|
id: "review",
|
||||||
|
title: "Open composer review",
|
||||||
|
workspaceId: agent.workspaceId,
|
||||||
|
agentId: agent.id,
|
||||||
|
Component: ComposerPill,
|
||||||
|
async onPress() {
|
||||||
|
await client.rpc(recordComposerOpen, { workspaceId: agent.workspaceId });
|
||||||
|
removePill();
|
||||||
|
client.openPanel("agent", {
|
||||||
|
workspaceId: agent.workspaceId,
|
||||||
|
agentId: agent.id,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
pills.set(agent.id, removePill);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
for (const removePill of pills.values()) removePill();
|
||||||
|
pills.clear();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default function contribute(plugin) {
|
export default function contribute(plugin) {
|
||||||
|
plugin.handle(recordComposerOpen, async ({ workspaceId }, { paseo }) => {
|
||||||
|
await paseo.workspaces.ref(workspaceId).setTitle("Opened from composer pill");
|
||||||
|
return { opened: true };
|
||||||
|
});
|
||||||
plugin.addSurface("collision", DirectCollisionSurface);
|
plugin.addSurface("collision", DirectCollisionSurface);
|
||||||
plugin.addSurface("sidebar-destination", SidebarCollisionSurface);
|
plugin.addSurface("sidebar-destination", SidebarCollisionSurface);
|
||||||
plugin.addSidebarItem({ id: "collision", title: "Collision sidebar", icon: "Blocks", surface: "sidebar-destination" });
|
plugin.addSidebarItem({ id: "collision", title: "Collision sidebar", icon: "Blocks", surface: "sidebar-destination" });
|
||||||
@@ -64,6 +122,7 @@ export default function contribute(plugin) {
|
|||||||
plugin.addCommandCenterItem({ id: "surface", title: "Open direct collision surface", icon: "Blocks", context: "workspace", onSelect({ openSurface }) { openSurface("collision"); } });
|
plugin.addCommandCenterItem({ id: "surface", title: "Open direct collision surface", icon: "Blocks", context: "workspace", onSelect({ openSurface }) { openSurface("collision"); } });
|
||||||
plugin.addCommandCenterItem({ id: "workspace", title: "Open plugin workspace", icon: "PanelsTopLeft", context: "workspace", onSelect({ openPanel }) { openPanel("workspace"); } });
|
plugin.addCommandCenterItem({ id: "workspace", title: "Open plugin workspace", icon: "PanelsTopLeft", context: "workspace", onSelect({ openPanel }) { openPanel("workspace"); } });
|
||||||
plugin.addCommandCenterItem({ id: "agent", title: "Open plugin agent", icon: "PanelTop", context: "agent", onSelect({ openPanel }) { openPanel("agent"); } });
|
plugin.addCommandCenterItem({ id: "agent", title: "Open plugin agent", icon: "PanelTop", context: "agent", onSelect({ openPanel }) { openPanel("agent"); } });
|
||||||
|
plugin.addClientSide(contributeClient);
|
||||||
return () => {};
|
return () => {};
|
||||||
}`;
|
}`;
|
||||||
}
|
}
|
||||||
@@ -207,7 +266,29 @@ test.describe("plugin workspace panels and Command Center", () => {
|
|||||||
});
|
});
|
||||||
await page.goto(buildAgentRoute(primary.workspaceId, agent.id));
|
await page.goto(buildAgentRoute(primary.workspaceId, agent.id));
|
||||||
await page.waitForURL(isSettledWorkspaceUrl, { timeout: 60_000 });
|
await page.waitForURL(isSettledWorkspaceUrl, { timeout: 60_000 });
|
||||||
|
await submitMessage(page, "emit 1 agent stream updates");
|
||||||
|
await expect(page.getByRole("button", { name: "1/1 tasks" })).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
const composerPill = page.getByRole("button", { name: "Open composer review" });
|
||||||
|
await expect(composerPill).toContainText(
|
||||||
|
"Review Unrelated title update:Plugin panel context agent",
|
||||||
|
);
|
||||||
|
await capture(page, testInfo, "plugin-composer-pill-wide");
|
||||||
await page.setViewportSize(COMPACT_VIEWPORT);
|
await page.setViewportSize(COMPACT_VIEWPORT);
|
||||||
|
await expect(page.getByRole("button", { name: "Open composer review" })).toBeVisible();
|
||||||
|
await capture(page, testInfo, "plugin-composer-pill-compact");
|
||||||
|
await page.getByRole("button", { name: "Open composer review" }).click();
|
||||||
|
await expect(page.getByTestId("workspace-header-title")).toHaveText(
|
||||||
|
"Opened from composer pill",
|
||||||
|
);
|
||||||
|
await expect(page.getByText(`Agent bridge ${agent.id}`)).toBeVisible();
|
||||||
|
await expect(page.getByText("Layout compact", { exact: true })).toBeVisible();
|
||||||
|
await capture(page, testInfo, "plugin-agent-panel-compact");
|
||||||
|
|
||||||
|
await page.goto(buildAgentRoute(primary.workspaceId, agent.id));
|
||||||
|
await page.waitForURL(isSettledWorkspaceUrl, { timeout: 60_000 });
|
||||||
|
await expect(page.getByRole("button", { name: "Open composer review" })).toHaveCount(0);
|
||||||
await openCompactSidebar(page);
|
await openCompactSidebar(page);
|
||||||
await runCommand(page, "Open plugin agent");
|
await runCommand(page, "Open plugin agent");
|
||||||
await closeMobileAgentSidebar(page);
|
await closeMobileAgentSidebar(page);
|
||||||
@@ -218,7 +299,6 @@ test.describe("plugin workspace panels and Command Center", () => {
|
|||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await expect(page.getByText(`Host ${getServerId()}`, { exact: true })).toBeVisible();
|
await expect(page.getByText(`Host ${getServerId()}`, { exact: true })).toBeVisible();
|
||||||
await expect(page.getByText("Layout compact", { exact: true })).toBeVisible();
|
await expect(page.getByText("Layout compact", { exact: true })).toBeVisible();
|
||||||
await capture(page, testInfo, "plugin-agent-panel-compact");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step("removing the active plugin renders the panel unavailable", async () => {
|
await test.step("removing the active plugin renders the panel unavailable", async () => {
|
||||||
|
|||||||
@@ -26,15 +26,35 @@ async function expectUserMessageVisible(page: Page, text: string): Promise<void>
|
|||||||
await expect(userMessage(page, text)).toBeVisible();
|
await expect(userMessage(page, text)).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function rewriteCachedMessageAsLegacyRow(page: Page, prompt: string): Promise<void> {
|
async function rewriteCachedMessageAsLegacyRow(
|
||||||
|
page: Page,
|
||||||
|
input: { prompt: string; agentId: string; workspaceId: string },
|
||||||
|
): Promise<void> {
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
const cache = await readReplicaCache(page);
|
const cache = await readReplicaCache(page);
|
||||||
if (!cache) return false;
|
if (!cache) return false;
|
||||||
for (const host of cache.hosts ?? []) {
|
for (const host of cache.hosts ?? []) {
|
||||||
for (const item of host.timeline?.items ?? []) {
|
const hasAgent = host.agents.some(
|
||||||
if (item.kind === "user_message" && item.text === prompt && item.messageId) {
|
(agent) =>
|
||||||
return true;
|
agent.snapshot &&
|
||||||
|
typeof agent.snapshot === "object" &&
|
||||||
|
Reflect.get(agent.snapshot, "id") === input.agentId,
|
||||||
|
);
|
||||||
|
const hasWorkspace = host.workspaces.some(
|
||||||
|
(workspace) => workspace.id === input.workspaceId,
|
||||||
|
);
|
||||||
|
if (!hasAgent || !hasWorkspace) continue;
|
||||||
|
for (const timeline of host.timelines) {
|
||||||
|
for (const item of timeline.items ?? []) {
|
||||||
|
if (
|
||||||
|
timeline.agentId === input.agentId &&
|
||||||
|
item.kind === "user_message" &&
|
||||||
|
item.text === input.prompt &&
|
||||||
|
item.messageId
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -45,8 +65,8 @@ async function rewriteCachedMessageAsLegacyRow(page: Page, prompt: string): Prom
|
|||||||
const cache = await readReplicaCache(page);
|
const cache = await readReplicaCache(page);
|
||||||
if (!cache) throw new Error("Replica cache was not persisted");
|
if (!cache) throw new Error("Replica cache was not persisted");
|
||||||
const cachedMessage = cache.hosts
|
const cachedMessage = cache.hosts
|
||||||
?.flatMap((host) => host.timeline?.items ?? [])
|
?.flatMap((host) => host.timelines.flatMap((timeline) => timeline.items ?? []))
|
||||||
.find((item) => item.kind === "user_message" && item.text === prompt);
|
.find((item) => item.kind === "user_message" && item.text === input.prompt);
|
||||||
if (!cachedMessage) throw new Error("Cached user message was not found");
|
if (!cachedMessage) throw new Error("Cached user message was not found");
|
||||||
delete cachedMessage.messageId;
|
delete cachedMessage.messageId;
|
||||||
await writeReplicaCache(page, cache);
|
await writeReplicaCache(page, cache);
|
||||||
@@ -61,7 +81,7 @@ async function waitForCurrentSubmissionExcludedFromCache(
|
|||||||
const cache = await readReplicaCache(page);
|
const cache = await readReplicaCache(page);
|
||||||
if (!cache) return false;
|
if (!cache) return false;
|
||||||
return !cache.hosts
|
return !cache.hosts
|
||||||
?.flatMap((host) => host.timeline?.items ?? [])
|
?.flatMap((host) => host.timelines.flatMap((timeline) => timeline.items ?? []))
|
||||||
.some(
|
.some(
|
||||||
(item) =>
|
(item) =>
|
||||||
item.kind === "user_message" &&
|
item.kind === "user_message" &&
|
||||||
@@ -79,7 +99,7 @@ async function waitForCachedMessageWithoutProviderId(page: Page, prompt: string)
|
|||||||
const cache = await readReplicaCache(page);
|
const cache = await readReplicaCache(page);
|
||||||
if (!cache) return false;
|
if (!cache) return false;
|
||||||
return cache.hosts
|
return cache.hosts
|
||||||
?.flatMap((host) => host.timeline?.items ?? [])
|
?.flatMap((host) => host.timelines.flatMap((timeline) => timeline.items ?? []))
|
||||||
.some(
|
.some(
|
||||||
(item) =>
|
(item) =>
|
||||||
item.kind === "user_message" && item.text === prompt && item.messageId === undefined,
|
item.kind === "user_message" && item.text === prompt && item.messageId === undefined,
|
||||||
@@ -131,14 +151,18 @@ test.describe("Rewind sheet", () => {
|
|||||||
await openAgentRoute(page, session);
|
await openAgentRoute(page, session);
|
||||||
await expectUserMessageVisible(page, prompt);
|
await expectUserMessageVisible(page, prompt);
|
||||||
await gate.drop();
|
await gate.drop();
|
||||||
await rewriteCachedMessageAsLegacyRow(page, prompt);
|
await rewriteCachedMessageAsLegacyRow(page, {
|
||||||
|
prompt,
|
||||||
|
agentId: session.agentId,
|
||||||
|
workspaceId: session.workspaceId,
|
||||||
|
});
|
||||||
await page.reload();
|
await page.reload();
|
||||||
|
await waitForCachedMessageWithoutProviderId(page, prompt);
|
||||||
|
|
||||||
const restoredMessage = userMessage(page, prompt);
|
const restoredMessage = userMessage(page, prompt);
|
||||||
await expect(restoredMessage).toBeVisible();
|
await expect(restoredMessage).toBeVisible();
|
||||||
await restoredMessage.hover();
|
await restoredMessage.hover();
|
||||||
await expect(restoredMessage.getByTestId("rewind-menu-trigger")).toHaveCount(0);
|
await expect(restoredMessage.getByTestId("rewind-menu-trigger")).toHaveCount(0);
|
||||||
await waitForCachedMessageWithoutProviderId(page, prompt);
|
|
||||||
} finally {
|
} finally {
|
||||||
gate.restore();
|
gate.restore();
|
||||||
await session.cleanup();
|
await session.cleanup();
|
||||||
@@ -153,6 +177,7 @@ test.describe("Rewind sheet", () => {
|
|||||||
repoPrefix: "rewind-e2e-",
|
repoPrefix: "rewind-e2e-",
|
||||||
title: "Rewind e2e",
|
title: "Rewind e2e",
|
||||||
initialPrompt: firstPrompt,
|
initialPrompt: firstPrompt,
|
||||||
|
model: "ten-second-stream",
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -70,73 +70,55 @@ test.describe("History search", () => {
|
|||||||
await tempRepo?.cleanup();
|
await tempRepo?.cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("typing narrows history to matching sessions and clearing restores them", async ({
|
test("searches, ranks, highlights, and clears session history", async ({ page }) => {
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await resetSeededPageState(page);
|
await resetSeededPageState(page);
|
||||||
await openSessions(page);
|
await openSessions(page);
|
||||||
|
|
||||||
// Seeded newest-first, and at rest history is chronological.
|
await test.step("narrows history and restores chronological grouping", async () => {
|
||||||
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
|
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
|
||||||
await expect(page.getByText("Today", { exact: true })).toHaveCount(1, { timeout: 30_000 });
|
await expect(page.getByText("Today", { exact: true })).toHaveCount(1, {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
await search(page, `${NONCE} billing`);
|
await search(page, `${NONCE} billing`);
|
||||||
await expectVisibleTitles(page, [TITLES.billing]);
|
await expectVisibleTitles(page, [TITLES.billing]);
|
||||||
|
await expect(page.getByText("Today", { exact: true })).toHaveCount(0, {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
// Ranked results are one flat list — a day heading would claim an order
|
await page.getByTestId("sessions-search-clear").click();
|
||||||
// the list no longer has.
|
await expect(page.getByTestId("sessions-search-input")).toHaveValue("");
|
||||||
await expect(page.getByText("Today", { exact: true })).toHaveCount(0, { timeout: 30_000 });
|
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
|
||||||
|
await expect(page.getByText("Today", { exact: true })).toHaveCount(1, {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
await page.getByTestId("sessions-search-clear").click();
|
await test.step("ranks whole-word matches above substrings", async () => {
|
||||||
await expect(page.getByTestId("sessions-search-input")).toHaveValue("");
|
await search(page, `${NONCE} bill`);
|
||||||
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
|
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled]);
|
||||||
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 test.step("highlights exact and typo-resolved matches", async () => {
|
||||||
await resetSeededPageState(page);
|
await search(page, `${NONCE} billing`);
|
||||||
await openSessions(page);
|
const row = page.locator(AGENT_ROW).filter({ hasText: NONCE }).first();
|
||||||
|
await expect(row.getByText("billing", { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
// "bill" starts a word in "billing" and hides inside "unbilled", so the
|
await search(page, `${NONCE} bulling`);
|
||||||
// stronger match leads even though both sessions are equally recent.
|
await expectVisibleTitles(page, [TITLES.billing]);
|
||||||
await search(page, `${NONCE} bill`);
|
const typoRow = page.locator(AGENT_ROW).filter({ hasText: NONCE }).first();
|
||||||
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled]);
|
await expect(typoRow.getByText("billing", { exact: true })).toBeVisible({
|
||||||
});
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("marks the characters each result matched on", async ({ page }) => {
|
await test.step("distinguishes no matches from empty history", async () => {
|
||||||
await resetSeededPageState(page);
|
await search(page, `${NONCE} kubernetes`);
|
||||||
await openSessions(page);
|
await expect(page.getByTestId("sessions-empty")).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(page.getByText("No sessions match")).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
// The mark is a nested Text run, so the matched slice is its own element.
|
await page.getByText("Clear search").click();
|
||||||
await search(page, `${NONCE} billing`);
|
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
|
||||||
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]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -121,6 +121,41 @@ test.describe("Settings sidebar navigation", () => {
|
|||||||
await clickSettingsBackToWorkspace(page);
|
await clickSettingsBackToWorkspace(page);
|
||||||
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
|
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("pressing Escape closes settings", async ({ page }) => {
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await openSettings(page);
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Escape lets settings dropdowns and modals close before leaving settings", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await openSettings(page);
|
||||||
|
|
||||||
|
await test.step("a dropdown owns Escape", async () => {
|
||||||
|
await openSettingsSection(page, "appearance");
|
||||||
|
await page.getByLabel(/Theme:/).click();
|
||||||
|
await expect(page.getByRole("menuitem", { name: "System", exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
|
||||||
|
await expect(page.getByRole("menuitem", { name: "System", exact: true })).toHaveCount(0);
|
||||||
|
await expect(page).toHaveURL(/\/settings(\/|$)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("a modal owns Escape", async () => {
|
||||||
|
await openAddHostFlow(page);
|
||||||
|
await expect(page.getByText("Add connection", { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
|
||||||
|
await expect(page.getByText("Add connection", { exact: true })).toHaveCount(0);
|
||||||
|
await expect(page).toHaveURL(/\/settings(\/|$)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("Settings — compact master-detail", () => {
|
test.describe("Settings — compact master-detail", () => {
|
||||||
|
|||||||
@@ -10,6 +10,58 @@ async function pressSettingsToggleShortcut(page: import("@playwright/test").Page
|
|||||||
await page.keyboard.press(`${modifier}+Comma`);
|
await page.keyboard.press(`${modifier}+Comma`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ComposerFrame {
|
||||||
|
at: number;
|
||||||
|
visible: boolean;
|
||||||
|
rootHeight: number;
|
||||||
|
inputHeight: number;
|
||||||
|
inputWidth: number;
|
||||||
|
inputStyleHeight: string;
|
||||||
|
inputStyleMaxHeight: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startComposerFrameCapture(page: import("@playwright/test").Page) {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const capture = { frames: [] as ComposerFrame[], stopped: false };
|
||||||
|
Object.assign(window, { __composerFrameCapture: capture });
|
||||||
|
|
||||||
|
const sample = () => {
|
||||||
|
if (capture.stopped) return;
|
||||||
|
const root = document.querySelector<HTMLElement>('[data-testid="message-input-root"]');
|
||||||
|
const input = root?.querySelector<HTMLTextAreaElement>("textarea");
|
||||||
|
if (root && input) {
|
||||||
|
const style = getComputedStyle(input);
|
||||||
|
capture.frames.push({
|
||||||
|
at: performance.now(),
|
||||||
|
visible: root.offsetParent !== null,
|
||||||
|
rootHeight: root.getBoundingClientRect().height,
|
||||||
|
inputHeight: input.getBoundingClientRect().height,
|
||||||
|
inputWidth: input.clientWidth,
|
||||||
|
inputStyleHeight: style.height,
|
||||||
|
inputStyleMaxHeight: style.maxHeight,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
requestAnimationFrame(sample);
|
||||||
|
};
|
||||||
|
requestAnimationFrame(sample);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopComposerFrameCapture(
|
||||||
|
page: import("@playwright/test").Page,
|
||||||
|
): Promise<ComposerFrame[]> {
|
||||||
|
return page.evaluate(() => {
|
||||||
|
const capture = (
|
||||||
|
window as typeof window & {
|
||||||
|
__composerFrameCapture?: { frames: ComposerFrame[]; stopped: boolean };
|
||||||
|
}
|
||||||
|
).__composerFrameCapture;
|
||||||
|
if (!capture) throw new Error("Composer frame capture was not installed");
|
||||||
|
capture.stopped = true;
|
||||||
|
return capture.frames;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function expectSendBehavior(
|
async function expectSendBehavior(
|
||||||
page: import("@playwright/test").Page,
|
page: import("@playwright/test").Page,
|
||||||
expected: "interrupt" | "queue" | "steer",
|
expected: "interrupt" | "queue" | "steer",
|
||||||
@@ -107,6 +159,65 @@ test.describe("Settings toggle tab regression", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("returning from settings does not flash the composer at maximum height", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const workspace = await seedWorkspace({ repoPrefix: "settings-composer-height-" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const firstAgent = await createIdleAgent(workspace.client, {
|
||||||
|
cwd: workspace.repoPath,
|
||||||
|
workspaceId: workspace.workspaceId,
|
||||||
|
title: `settings-composer-height-a-${Date.now()}`,
|
||||||
|
});
|
||||||
|
const secondAgent = await createIdleAgent(workspace.client, {
|
||||||
|
cwd: workspace.repoPath,
|
||||||
|
workspaceId: workspace.workspaceId,
|
||||||
|
title: `settings-composer-height-b-${Date.now()}`,
|
||||||
|
});
|
||||||
|
await openWorkspaceWithAgents(page, [firstAgent, secondAgent]);
|
||||||
|
await waitForTabBar(page);
|
||||||
|
|
||||||
|
const input = page.getByRole("textbox", { name: "Message agent..." });
|
||||||
|
await input.fill("Keep this short draft in the composer");
|
||||||
|
const root = page.getByTestId("message-input-root").filter({ visible: true }).first();
|
||||||
|
const baselineHeight = (await root.boundingBox())?.height;
|
||||||
|
expect(baselineHeight).toBeDefined();
|
||||||
|
|
||||||
|
await startComposerFrameCapture(page);
|
||||||
|
await pressSettingsToggleShortcut(page);
|
||||||
|
await expect(page).toHaveURL(/\/settings\/general$/);
|
||||||
|
await pressSettingsToggleShortcut(page);
|
||||||
|
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
|
||||||
|
await expect(input).toHaveValue("Keep this short draft in the composer");
|
||||||
|
await page.waitForTimeout(250);
|
||||||
|
|
||||||
|
const frames = await stopComposerFrameCapture(page);
|
||||||
|
const visibleFrames = frames.filter((frame) => frame.visible);
|
||||||
|
const tallestIndex = visibleFrames.reduce(
|
||||||
|
(index, frame, candidateIndex) =>
|
||||||
|
frame.rootHeight > visibleFrames[index].rootHeight ? candidateIndex : index,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const tallestFrame = visibleFrames[tallestIndex];
|
||||||
|
const tallestFrameIndex = frames.findIndex((frame) => frame.at === tallestFrame.at);
|
||||||
|
expect(
|
||||||
|
tallestFrame.rootHeight,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
baselineHeight,
|
||||||
|
tallestFrame,
|
||||||
|
nearbyFrames: frames.slice(Math.max(0, tallestFrameIndex - 3), tallestFrameIndex + 4),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
).toBeLessThanOrEqual((baselineHeight ?? 0) + 5);
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("refresh after navigating between agent routes keeps the latest agent focused", async ({
|
test("refresh after navigating between agent routes keeps the latest agent focused", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -1,238 +0,0 @@
|
|||||||
import { expect, type Locator, type Page, type TestInfo } from "@playwright/test";
|
|
||||||
import { test } from "../support/fixtures";
|
|
||||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
|
||||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
|
||||||
import { ensureSidePanel, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
|
||||||
|
|
||||||
async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
|
|
||||||
const path = testInfo.outputPath(`${name}.png`);
|
|
||||||
await page.screenshot({ path });
|
|
||||||
await testInfo.attach(name, { path, contentType: "image/png" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function sidePanel(page: Page): Locator {
|
|
||||||
return page.getByTestId("workspace-side-panel").filter({ visible: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
function mainPane(page: Page): Locator {
|
|
||||||
return page.getByTestId("workspace-pane-main").filter({ visible: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
function newTabPanel(scope: Locator): Locator {
|
|
||||||
return scope.getByTestId("workspace-new-tab-panel");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function launcherButtonLabels(launcher: Locator): Promise<(string | null)[]> {
|
|
||||||
return launcher
|
|
||||||
.getByRole("button")
|
|
||||||
.evaluateAll((buttons) => buttons.map((button) => button.getAttribute("aria-label")));
|
|
||||||
}
|
|
||||||
|
|
||||||
function sidePanelToggle(page: Page): Locator {
|
|
||||||
return page.getByTestId("workspace-explorer-toggle").first();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Drags the pane's only tab into the left edge of its own pane, splitting it out. */
|
|
||||||
async function splitTabOutOfMainPane(page: Page, chip: Locator): Promise<void> {
|
|
||||||
const paneBox = await mainPane(page).boundingBox();
|
|
||||||
const chipBox = await chip.boundingBox();
|
|
||||||
expect(paneBox).not.toBeNull();
|
|
||||||
expect(chipBox).not.toBeNull();
|
|
||||||
if (!paneBox || !chipBox) throw new Error("Unable to measure the main pane");
|
|
||||||
|
|
||||||
await page.mouse.move(chipBox.x + chipBox.width / 2, chipBox.y + chipBox.height / 2);
|
|
||||||
await page.mouse.down();
|
|
||||||
await page.mouse.move(chipBox.x + chipBox.width / 2 + 12, chipBox.y + chipBox.height / 2 + 4);
|
|
||||||
await page.mouse.move(paneBox.x + paneBox.width * 0.06, paneBox.y + paneBox.height * 0.6, {
|
|
||||||
steps: 20,
|
|
||||||
});
|
|
||||||
await page.mouse.up();
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe("Side panel", () => {
|
|
||||||
test("reveals empty, launches a view, and hides back out of the way", async ({
|
|
||||||
page,
|
|
||||||
}, testInfo) => {
|
|
||||||
const workspace = await seedWorkspace({ repoPrefix: "side-panel-reveal-" });
|
|
||||||
|
|
||||||
try {
|
|
||||||
await gotoWorkspace(page, workspace.workspaceId);
|
|
||||||
await waitForWorkspaceTabsVisible(page);
|
|
||||||
await expect(sidePanel(page)).toHaveCount(0);
|
|
||||||
|
|
||||||
await test.step("revealing the Side panel seeds nothing", async () => {
|
|
||||||
await sidePanelToggle(page).click();
|
|
||||||
await expect(sidePanel(page)).toBeVisible({ timeout: 30_000 });
|
|
||||||
const launcher = newTabPanel(sidePanel(page));
|
|
||||||
await expect(launcher).toBeVisible();
|
|
||||||
expect((await launcherButtonLabels(launcher)).slice(0, 4)).toEqual([
|
|
||||||
"Changes",
|
|
||||||
"Files",
|
|
||||||
"Terminal",
|
|
||||||
"Agent",
|
|
||||||
]);
|
|
||||||
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(0);
|
|
||||||
await capture(page, testInfo, "01-side-panel-reveals-empty");
|
|
||||||
});
|
|
||||||
|
|
||||||
await test.step("Changes opens in the Side panel that offered it", async () => {
|
|
||||||
await newTabPanel(sidePanel(page)).getByRole("button", { name: "Changes" }).click();
|
|
||||||
await expect(sidePanel(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
|
|
||||||
timeout: 30_000,
|
|
||||||
});
|
|
||||||
await expect(page.getByTestId("working-diff-panel").filter({ visible: true })).toBeVisible({
|
|
||||||
timeout: 30_000,
|
|
||||||
});
|
|
||||||
await capture(page, testInfo, "02-changes-opens-in-side-panel");
|
|
||||||
});
|
|
||||||
|
|
||||||
await test.step("closing its final tab hides the Side panel, and it reopens empty", async () => {
|
|
||||||
const changesTab = sidePanel(page).getByTestId("workspace-tab-working_diff");
|
|
||||||
await changesTab.hover();
|
|
||||||
await page
|
|
||||||
.locator('[data-testid^="workspace-working-diff-close-"]')
|
|
||||||
.filter({ visible: true })
|
|
||||||
.click();
|
|
||||||
await expect(sidePanel(page)).toHaveCount(0, { timeout: 15_000 });
|
|
||||||
await capture(page, testInfo, "03-side-panel-hidden-after-final-tab-close");
|
|
||||||
|
|
||||||
await sidePanelToggle(page).click();
|
|
||||||
await expect(sidePanel(page)).toBeVisible({ timeout: 15_000 });
|
|
||||||
await expect(newTabPanel(sidePanel(page))).toBeVisible();
|
|
||||||
await capture(page, testInfo, "04-side-panel-revealed-empty");
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
await workspace.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("an emptied ordinary pane is removed rather than hidden", async ({ page }, testInfo) => {
|
|
||||||
const workspace = await seedWorkspace({ repoPrefix: "side-panel-close-main-" });
|
|
||||||
|
|
||||||
try {
|
|
||||||
await gotoWorkspace(page, workspace.workspaceId);
|
|
||||||
await waitForWorkspaceTabsVisible(page);
|
|
||||||
await ensureSidePanel(page);
|
|
||||||
|
|
||||||
// Dragging the pane's only tab into its own left edge splits that tab into a
|
|
||||||
// new pane and leaves the one it came from empty.
|
|
||||||
const draftTab = page
|
|
||||||
.locator('[data-testid^="workspace-tab-draft_"]')
|
|
||||||
.filter({ visible: true })
|
|
||||||
.first();
|
|
||||||
await expect(draftTab).toBeVisible({ timeout: 30_000 });
|
|
||||||
await splitTabOutOfMainPane(page, draftTab);
|
|
||||||
const draftPane = page
|
|
||||||
.locator('[data-testid^="workspace-pane-pane_"]')
|
|
||||||
.filter({ visible: true })
|
|
||||||
.first();
|
|
||||||
await expect(draftPane).toBeVisible({ timeout: 15_000 });
|
|
||||||
|
|
||||||
const emptyMain = newTabPanel(mainPane(page));
|
|
||||||
await expect(emptyMain).toBeVisible({ timeout: 15_000 });
|
|
||||||
await capture(page, testInfo, "06-empty-main-pane-launcher");
|
|
||||||
|
|
||||||
const newTabChip = mainPane(page)
|
|
||||||
.locator('[data-testid^="workspace-tab-tab_"]')
|
|
||||||
.filter({ hasText: "New tab" });
|
|
||||||
await newTabChip.hover();
|
|
||||||
await mainPane(page)
|
|
||||||
.locator('[data-testid^="workspace-new-tab-close-"]')
|
|
||||||
.filter({ visible: true })
|
|
||||||
.click();
|
|
||||||
|
|
||||||
// Gone for good, unlike the Side panel beside it, which is still only hidden away.
|
|
||||||
await expect(mainPane(page)).toHaveCount(0, { timeout: 30_000 });
|
|
||||||
await expect(sidePanel(page)).toBeVisible();
|
|
||||||
await expect(draftPane.getByTestId("workspace-new-tab-panel")).toHaveCount(0);
|
|
||||||
await capture(page, testInfo, "07-empty-main-pane-removed");
|
|
||||||
} finally {
|
|
||||||
await workspace.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("closing New cannot remove the last visible pane", async ({ page }, testInfo) => {
|
|
||||||
const workspace = await seedWorkspace({ repoPrefix: "side-panel-final-pane-" });
|
|
||||||
|
|
||||||
try {
|
|
||||||
await gotoWorkspace(page, workspace.workspaceId);
|
|
||||||
await waitForWorkspaceTabsVisible(page);
|
|
||||||
|
|
||||||
// Park a tab in the Side panel, then put the panel away. The workspace still
|
|
||||||
// holds that tab, so emptying Main does not look like an empty workspace and
|
|
||||||
// nothing reseeds a draft into it.
|
|
||||||
await ensureSidePanel(page);
|
|
||||||
await newTabPanel(sidePanel(page))
|
|
||||||
.getByRole("button", { name: "Files", exact: true })
|
|
||||||
.click();
|
|
||||||
await expect(sidePanel(page).getByTestId("workspace-tab-files")).toBeVisible({
|
|
||||||
timeout: 30_000,
|
|
||||||
});
|
|
||||||
await sidePanelToggle(page).click();
|
|
||||||
await expect(sidePanel(page)).toHaveCount(0, { timeout: 15_000 });
|
|
||||||
|
|
||||||
const draftTab = page
|
|
||||||
.locator('[data-testid^="workspace-tab-draft_"]')
|
|
||||||
.filter({ visible: true })
|
|
||||||
.first();
|
|
||||||
await draftTab.hover();
|
|
||||||
await page
|
|
||||||
.locator('[data-testid^="workspace-draft-close-"]')
|
|
||||||
.filter({ visible: true })
|
|
||||||
.first()
|
|
||||||
.click();
|
|
||||||
|
|
||||||
// Main now contains New and is the only pane the user can see. Closing its
|
|
||||||
// tab cannot remove the final visible pane.
|
|
||||||
const lastPane = newTabPanel(mainPane(page));
|
|
||||||
await expect(lastPane).toBeVisible({ timeout: 15_000 });
|
|
||||||
await expect(lastPane.getByRole("button", { name: "Files", exact: true })).toBeVisible();
|
|
||||||
const finalNewTab = mainPane(page)
|
|
||||||
.locator('[data-testid^="workspace-tab-tab_"]')
|
|
||||||
.filter({ hasText: "New tab" });
|
|
||||||
await finalNewTab.hover();
|
|
||||||
await mainPane(page)
|
|
||||||
.locator('[data-testid^="workspace-new-tab-close-"]')
|
|
||||||
.filter({ visible: true })
|
|
||||||
.click();
|
|
||||||
await expect(lastPane).toBeVisible();
|
|
||||||
await capture(page, testInfo, "08-final-visible-pane-has-no-close");
|
|
||||||
} finally {
|
|
||||||
await workspace.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a pane's own menu creates an independent view when one is open elsewhere", async ({
|
|
||||||
page,
|
|
||||||
}, testInfo) => {
|
|
||||||
const workspace = await seedWorkspace({ repoPrefix: "side-panel-claim-" });
|
|
||||||
|
|
||||||
try {
|
|
||||||
await gotoWorkspace(page, workspace.workspaceId);
|
|
||||||
await waitForWorkspaceTabsVisible(page);
|
|
||||||
await ensureSidePanel(page);
|
|
||||||
await newTabPanel(sidePanel(page)).getByRole("button", { name: "Changes" }).click();
|
|
||||||
await expect(sidePanel(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
|
|
||||||
timeout: 30_000,
|
|
||||||
});
|
|
||||||
await capture(page, testInfo, "08-changes-in-side-panel");
|
|
||||||
|
|
||||||
await mainPane(page).getByTestId("workspace-new-tab-button").click();
|
|
||||||
await page
|
|
||||||
.getByTestId("workspace-new-tab-menu-changes")
|
|
||||||
.filter({ visible: true })
|
|
||||||
.first()
|
|
||||||
.click();
|
|
||||||
|
|
||||||
await expect(mainPane(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
|
|
||||||
timeout: 15_000,
|
|
||||||
});
|
|
||||||
await expect(sidePanel(page).getByTestId("workspace-tab-working_diff")).toHaveCount(1);
|
|
||||||
await expect(mainPane(page).getByTestId("workspace-tab-working_diff")).toHaveCount(1);
|
|
||||||
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(2);
|
|
||||||
await capture(page, testInfo, "09-independent-changes-in-both-panes");
|
|
||||||
} finally {
|
|
||||||
await workspace.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -41,46 +41,37 @@ async function expectExternalPage(
|
|||||||
await popup.close();
|
await popup.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
test("opens troubleshooting tools from the sidebar help menu", async ({ page }) => {
|
test("opens troubleshooting, support, and release destinations", async ({ page }) => {
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await expect(page.getByTestId("sidebar-help")).toBeVisible();
|
await expect(page.getByTestId("sidebar-help")).toBeVisible();
|
||||||
|
|
||||||
await openHelpMenu(page);
|
await test.step("opens diagnostics and keyboard shortcuts", async () => {
|
||||||
const triggerBox = await page.getByTestId("sidebar-help").evaluate((element) => {
|
await openHelpMenu(page);
|
||||||
const { y, height } = element.getBoundingClientRect();
|
await expect(page.getByText("Help", { exact: true })).toBeVisible();
|
||||||
return { y, height };
|
await expect(page.getByText("Report an issue", { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByText("What's new", { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByTestId("sidebar-help-version")).toHaveText(APP_VERSION);
|
||||||
|
|
||||||
|
await page.getByTestId("sidebar-help-diagnostics").click();
|
||||||
|
await expectDiagnosticReport(page);
|
||||||
|
await closeSheet(page, "app-diagnostic-sheet");
|
||||||
|
|
||||||
|
await openHelpMenu(page);
|
||||||
|
await page.getByTestId("sidebar-help-shortcuts").click();
|
||||||
|
await expect(page.getByTestId("keyboard-shortcuts-dialog")).toBeVisible();
|
||||||
|
await closeSheet(page, "keyboard-shortcuts-dialog");
|
||||||
});
|
});
|
||||||
const menuBox = await page.getByTestId("sidebar-help-menu").evaluate((element) => {
|
|
||||||
const { y, height } = element.getBoundingClientRect();
|
await test.step("opens support and release pages", async () => {
|
||||||
return { y, height };
|
await openHelpMenu(page);
|
||||||
|
await expectExternalPage(page, "sidebar-help-discord", DISCORD_DESTINATION);
|
||||||
|
|
||||||
|
await openHelpMenu(page);
|
||||||
|
await expectExternalPage(page, "sidebar-help-github", GITHUB_ISSUE_DESTINATION);
|
||||||
|
|
||||||
|
await openHelpMenu(page);
|
||||||
|
await expectExternalPage(page, "sidebar-help-changelog", CHANGELOG_DESTINATION);
|
||||||
});
|
});
|
||||||
expect(menuBox.y + menuBox.height).toBeLessThanOrEqual(triggerBox.y);
|
|
||||||
await expect(page.getByText("Help", { exact: true })).toBeVisible();
|
|
||||||
await expect(page.getByText("Report an issue", { exact: true })).toBeVisible();
|
|
||||||
await expect(page.getByText("What's new", { exact: true })).toBeVisible();
|
|
||||||
await expect(page.getByTestId("sidebar-help-version")).toHaveText(APP_VERSION);
|
|
||||||
|
|
||||||
await page.getByTestId("sidebar-help-diagnostics").click();
|
|
||||||
await expectDiagnosticReport(page);
|
|
||||||
await closeSheet(page, "app-diagnostic-sheet");
|
|
||||||
|
|
||||||
await openHelpMenu(page);
|
|
||||||
await page.getByTestId("sidebar-help-shortcuts").click();
|
|
||||||
await expect(page.getByTestId("keyboard-shortcuts-dialog")).toBeVisible();
|
|
||||||
await closeSheet(page, "keyboard-shortcuts-dialog");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("opens support and release destinations", async ({ page }) => {
|
|
||||||
await gotoAppShell(page);
|
|
||||||
|
|
||||||
await openHelpMenu(page);
|
|
||||||
await expectExternalPage(page, "sidebar-help-discord", DISCORD_DESTINATION);
|
|
||||||
|
|
||||||
await openHelpMenu(page);
|
|
||||||
await expectExternalPage(page, "sidebar-help-github", GITHUB_ISSUE_DESTINATION);
|
|
||||||
|
|
||||||
await openHelpMenu(page);
|
|
||||||
await expectExternalPage(page, "sidebar-help-changelog", CHANGELOG_DESTINATION);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("searches keyboard shortcuts from the sidebar help menu", async ({ page }) => {
|
test("searches keyboard shortcuts from the sidebar help menu", async ({ page }) => {
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
import type { Locator } from "@playwright/test";
|
|
||||||
import { expect, test, type Page } from "../support/fixtures";
|
|
||||||
|
|
||||||
test.use({ viewport: { width: 1600, height: 900 } });
|
|
||||||
|
|
||||||
async function expectBorderHighlight(
|
|
||||||
page: Page,
|
|
||||||
testID: string,
|
|
||||||
hoverTarget: Locator,
|
|
||||||
expectedWidth: string,
|
|
||||||
) {
|
|
||||||
const handle = page.getByTestId(testID);
|
|
||||||
await expect(handle).toBeVisible();
|
|
||||||
await expect(page.getByTestId(`${testID}-highlight`)).toHaveCount(0);
|
|
||||||
|
|
||||||
await hoverTarget.hover();
|
|
||||||
|
|
||||||
const highlight = page.getByTestId(`${testID}-highlight`);
|
|
||||||
await expect(highlight).toBeVisible();
|
|
||||||
await expect(highlight).toHaveCSS("width", expectedWidth);
|
|
||||||
await expect(highlight).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
|
|
||||||
}
|
|
||||||
|
|
||||||
test("sidebar and workspace pane borders highlight on hover", async ({ page, withWorkspace }) => {
|
|
||||||
const workspace = await withWorkspace({ prefix: "sidebar-resize-handle-" });
|
|
||||||
await workspace.navigateTo();
|
|
||||||
|
|
||||||
const sidebarHandle = page.getByTestId("left-sidebar-resize-handle");
|
|
||||||
await expectBorderHighlight(page, "left-sidebar-resize-handle", sidebarHandle, "1px");
|
|
||||||
|
|
||||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
|
||||||
const splitHandle = page.getByTestId("workspace-split-resize-handle");
|
|
||||||
await expectBorderHighlight(
|
|
||||||
page,
|
|
||||||
"workspace-split-resize-handle",
|
|
||||||
splitHandle.getByRole("separator"),
|
|
||||||
"3px",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -146,20 +146,37 @@ async function installPinRpcGate(
|
|||||||
}
|
}
|
||||||
|
|
||||||
test.describe("Pin workspace shortcut", () => {
|
test.describe("Pin workspace shortcut", () => {
|
||||||
test("keeps project creation reachable after pinning its only workspace", async ({ page }) => {
|
test("dispatches pin once and keeps project creation reachable", async ({ page }) => {
|
||||||
const workspace = await seedWorkspace({
|
const workspace = await seedWorkspace({
|
||||||
repoPrefix: "pin-project-creation-",
|
repoPrefix: "pin-project-creation-",
|
||||||
title: "Pinned workspace",
|
title: "Pinned workspace",
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const gate = await installPinRpcGate(page);
|
||||||
|
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await openWorkspace(page, workspace.workspaceId);
|
await openWorkspace(page, workspace.workspaceId);
|
||||||
await page.keyboard.press(PIN_SHORTCUT);
|
|
||||||
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
|
|
||||||
|
|
||||||
await openNewWorkspaceComposer(page, workspace);
|
await test.step("sends one RPC for each pin transition", async () => {
|
||||||
await expectNewWorkspaceProjectSelected(page, workspace.projectDisplayName);
|
await page.keyboard.press(PIN_SHORTCUT);
|
||||||
|
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
|
||||||
|
expect(gate.sentCount()).toBe(1);
|
||||||
|
|
||||||
|
await page.keyboard.press(PIN_SHORTCUT);
|
||||||
|
await expect(pinnedSection(page)).toHaveCount(0, { timeout: 10_000 });
|
||||||
|
expect(gate.sentCount()).toBe(2);
|
||||||
|
await expect(workspaceRow(page, workspace.workspaceId)).toHaveCount(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("keeps the pinned project available to workspace creation", async () => {
|
||||||
|
await page.keyboard.press(PIN_SHORTCUT);
|
||||||
|
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
|
||||||
|
expect(gate.sentCount()).toBe(3);
|
||||||
|
|
||||||
|
await openNewWorkspaceComposer(page, workspace);
|
||||||
|
await expectNewWorkspaceProjectSelected(page, workspace.projectDisplayName);
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
await workspace.cleanup();
|
await workspace.cleanup();
|
||||||
}
|
}
|
||||||
@@ -207,34 +224,6 @@ test.describe("Pin workspace shortcut", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("sends exactly one pin RPC per press when the row is rendered and selected", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-expanded-" });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const gate = await installPinRpcGate(page);
|
|
||||||
|
|
||||||
await gotoAppShell(page);
|
|
||||||
await openWorkspace(page, workspace.workspaceId);
|
|
||||||
|
|
||||||
await page.keyboard.press(PIN_SHORTCUT);
|
|
||||||
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
|
|
||||||
// Counting frames catches a press that produces zero or two RPCs — a misfiring in-flight
|
|
||||||
// guard, or a second dispatch path. It cannot detect a duplicate handler registration:
|
|
||||||
// `keyboardActionDispatcher.dispatch` returns at the first handler that returns true, so a
|
|
||||||
// shadowed second handler is unobservable from outside by design.
|
|
||||||
expect(gate.sentCount()).toBe(1);
|
|
||||||
|
|
||||||
await page.keyboard.press(PIN_SHORTCUT);
|
|
||||||
await expect(pinnedSection(page)).toHaveCount(0, { timeout: 10_000 });
|
|
||||||
expect(gate.sentCount()).toBe(2);
|
|
||||||
await expect(workspaceRow(page, workspace.workspaceId)).toHaveCount(1);
|
|
||||||
} finally {
|
|
||||||
await workspace.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("pins the active workspace while its status group is collapsed", async ({ page }) => {
|
test("pins the active workspace while its status group is collapsed", async ({ page }) => {
|
||||||
const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-status-" });
|
const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-status-" });
|
||||||
|
|
||||||
|
|||||||
@@ -118,19 +118,6 @@ test.describe("Sidebar workspace list", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("project shows workspace under it", async ({ page }) => {
|
|
||||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-workspace-under-project-" });
|
|
||||||
|
|
||||||
try {
|
|
||||||
await gotoAppShell(page);
|
|
||||||
|
|
||||||
await waitForSidebarProject(page, path.basename(workspace.repoPath));
|
|
||||||
await waitForSidebarWorkspace(page, workspace.workspaceId);
|
|
||||||
} finally {
|
|
||||||
await workspace.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("non-git project shows directory name", async ({ page }) => {
|
test("non-git project shows directory name", async ({ page }) => {
|
||||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-directory-", git: false });
|
const workspace = await seedWorkspace({ repoPrefix: "sidebar-directory-", git: false });
|
||||||
|
|
||||||
@@ -319,7 +306,7 @@ test.describe("Half-screen desktop layout", () => {
|
|||||||
await expect(closedIcon).toBeVisible();
|
await expect(closedIcon).toBeVisible();
|
||||||
const closedBounds = await closedIcon.boundingBox();
|
const closedBounds = await closedIcon.boundingBox();
|
||||||
expect(closedBounds).not.toBeNull();
|
expect(closedBounds).not.toBeNull();
|
||||||
expect(closedBounds?.x).toBeCloseTo(12, 0);
|
expect(closedBounds?.x).toBeCloseTo(9, 0);
|
||||||
expect(closedBounds?.y).toBe(openBounds?.y);
|
expect(closedBounds?.y).toBe(openBounds?.y);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -342,16 +329,19 @@ test.describe("Half-screen desktop layout", () => {
|
|||||||
|
|
||||||
await openFilesPanel(page);
|
await openFilesPanel(page);
|
||||||
const explorerToggle = page.getByTestId("workspace-explorer-toggle").first();
|
const explorerToggle = page.getByTestId("workspace-explorer-toggle").first();
|
||||||
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toBeVisible();
|
await expect(
|
||||||
await expect(explorerToggle).toHaveAccessibleName("Close side panel");
|
page.getByTestId("explorer-sidebar-tab-files").filter({ visible: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(explorerToggle).toHaveAccessibleName("Close Explorer sidebar");
|
||||||
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
||||||
await expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(2);
|
await expect(page.getByTestId("explorer-sidebar-tab-rail")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(1);
|
||||||
|
|
||||||
await explorerToggle.click();
|
await explorerToggle.click();
|
||||||
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toHaveCount(
|
await expect(
|
||||||
0,
|
page.getByTestId("explorer-sidebar-tab-files").filter({ visible: true }),
|
||||||
);
|
).toHaveCount(0);
|
||||||
await expect(explorerToggle).toHaveAccessibleName("Open side panel");
|
await expect(explorerToggle).toHaveAccessibleName("Open Explorer sidebar");
|
||||||
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
||||||
} finally {
|
} finally {
|
||||||
await workspace.cleanup();
|
await workspace.cleanup();
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { expect, test } from "../support/fixtures";
|
||||||
|
import { gotoAppShell } from "../support/helpers/app";
|
||||||
|
import { waitForWorkspaceInReplicaCache } from "../support/helpers/replica-cache-storage";
|
||||||
|
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||||
|
import {
|
||||||
|
expectWorkspaceAbsentFromSidebar,
|
||||||
|
selectWorkspaceInSidebar,
|
||||||
|
} from "../support/helpers/sidebar";
|
||||||
|
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||||
|
|
||||||
|
async function archiveWorkspaceOutsideTheApp(workspace: SeededWorkspace): Promise<void> {
|
||||||
|
const result = await workspace.client.archiveWorkspace(workspace.workspaceId);
|
||||||
|
expect(result.error).toBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("Workspace archive cache coherence", () => {
|
||||||
|
test("an archived selected workspace cannot return from the durable cache", async ({ page }) => {
|
||||||
|
const workspace = await seedWorkspace({ repoPrefix: "archive-cache-" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await waitForSidebarHydration(page);
|
||||||
|
await selectWorkspaceInSidebar(page, workspace.workspaceId);
|
||||||
|
await waitForWorkspaceInReplicaCache(page, workspace.workspaceId);
|
||||||
|
|
||||||
|
await archiveWorkspaceOutsideTheApp(workspace);
|
||||||
|
|
||||||
|
await expectWorkspaceAbsentFromSidebar(page, workspace.workspaceId);
|
||||||
|
await expect(page.getByText("Workspace unavailable", { exact: true })).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,35 +11,37 @@ test.describe("Workspace lifecycle", () => {
|
|||||||
// One retry is enough for the daemon to stabilize.
|
// One retry is enough for the daemon to stabilize.
|
||||||
test.describe.configure({ retries: 1 });
|
test.describe.configure({ retries: 1 });
|
||||||
|
|
||||||
test.describe("Main checkout", () => {
|
test("main checkout creates an agent chat and terminal in the project root", async ({
|
||||||
test("creates an agent chat via New Chat", async ({ page, withWorkspace }) => {
|
page,
|
||||||
test.setTimeout(60_000);
|
withWorkspace,
|
||||||
const workspace = await withWorkspace({ prefix: "lifecycle-main-chat-" });
|
}) => {
|
||||||
await workspace.navigateTo();
|
test.setTimeout(60_000);
|
||||||
|
const workspace = await withWorkspace({ prefix: "lifecycle-main-" });
|
||||||
|
await workspace.navigateTo();
|
||||||
|
|
||||||
|
await test.step("creates an agent chat", async () => {
|
||||||
await createAgentChatFromLauncher(page);
|
await createAgentChatFromLauncher(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("creates a terminal with correct CWD", async ({ page, withWorkspace }) => {
|
await test.step("creates a terminal in the project root", async () => {
|
||||||
test.setTimeout(60_000);
|
|
||||||
const workspace = await withWorkspace({ prefix: "lifecycle-main-shell-" });
|
|
||||||
await workspace.navigateTo();
|
|
||||||
await createStandaloneTerminalFromLauncher(page);
|
await createStandaloneTerminalFromLauncher(page);
|
||||||
await expectTerminalCwd(page, workspace.repoPath);
|
await expectTerminalCwd(page, workspace.repoPath);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("Worktree workspace", () => {
|
test("worktree creates an agent chat and terminal in its directory", async ({
|
||||||
test("creates an agent chat via New Chat", async ({ page, withWorkspace }) => {
|
page,
|
||||||
test.setTimeout(90_000);
|
withWorkspace,
|
||||||
const workspace = await withWorkspace({ worktree: true, prefix: "lifecycle-wt-chat-" });
|
}) => {
|
||||||
await workspace.navigateTo();
|
test.setTimeout(90_000);
|
||||||
|
const workspace = await withWorkspace({ worktree: true, prefix: "lifecycle-wt-" });
|
||||||
|
await workspace.navigateTo();
|
||||||
|
|
||||||
|
await test.step("creates an agent chat", async () => {
|
||||||
await createAgentChatFromLauncher(page);
|
await createAgentChatFromLauncher(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("creates a terminal with correct CWD", async ({ page, withWorkspace }) => {
|
await test.step("creates a terminal in the worktree", async () => {
|
||||||
test.setTimeout(90_000);
|
|
||||||
const workspace = await withWorkspace({ worktree: true, prefix: "lifecycle-wt-shell-" });
|
|
||||||
await workspace.navigateTo();
|
|
||||||
await createStandaloneTerminalFromLauncher(page);
|
await createStandaloneTerminalFromLauncher(page);
|
||||||
await expectTerminalCwd(page, workspace.repoPath);
|
await expectTerminalCwd(page, workspace.repoPath);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { execSync, spawn, type ChildProcess } from "node:child_process";
|
import type { ChildProcess } from "node:child_process";
|
||||||
import { once } from "node:events";
|
|
||||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
@@ -23,6 +22,7 @@ import {
|
|||||||
submitNewWorkspaceEmpty,
|
submitNewWorkspaceEmpty,
|
||||||
} from "../support/helpers/new-workspace";
|
} from "../support/helpers/new-workspace";
|
||||||
import { selectSidebarStatusGrouping } from "../support/helpers/sidebar";
|
import { selectSidebarStatusGrouping } from "../support/helpers/sidebar";
|
||||||
|
import { killProcessTree, spawnTsx } from "../support/helpers/spawn-node";
|
||||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||||
import { getVisibleWorkspaceAgentTabIds } from "../support/helpers/workspace-tabs";
|
import { getVisibleWorkspaceAgentTabIds } from "../support/helpers/workspace-tabs";
|
||||||
|
|
||||||
@@ -195,9 +195,11 @@ async function getAvailablePort(): Promise<number> {
|
|||||||
async function waitForServer(port: number, child: ChildProcess): Promise<void> {
|
async function waitForServer(port: number, child: ChildProcess): Promise<void> {
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
let lastConnectionError: unknown = null;
|
let lastConnectionError: unknown = null;
|
||||||
while (Date.now() - startedAt < 20_000) {
|
while (Date.now() - startedAt < 90_000) {
|
||||||
if (child.exitCode !== null) {
|
if (child.exitCode !== null || child.signalCode !== null) {
|
||||||
throw new Error(`Restart test daemon exited before listening (exit ${child.exitCode}).`);
|
throw new Error(
|
||||||
|
`Restart test daemon exited before listening (code ${String(child.exitCode)}, signal ${String(child.signalCode)}).`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
@@ -226,21 +228,6 @@ async function waitForServer(port: number, child: ChildProcess): Promise<void> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function stopProcess(child: ChildProcess): Promise<void> {
|
|
||||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
||||||
child.kill("SIGTERM");
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
if (child.exitCode === null && child.signalCode === null) {
|
|
||||||
child.kill("SIGKILL");
|
|
||||||
}
|
|
||||||
}, 5000);
|
|
||||||
try {
|
|
||||||
await once(child, "exit");
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startRestartDaemon(input: {
|
async function startRestartDaemon(input: {
|
||||||
paseoHome: string;
|
paseoHome: string;
|
||||||
origin: string;
|
origin: string;
|
||||||
@@ -251,8 +238,7 @@ async function startRestartDaemon(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const serverDir = path.resolve(__dirname, "../../../server");
|
const serverDir = path.resolve(__dirname, "../../../server");
|
||||||
const tsxBin = execSync("which tsx").toString().trim();
|
const child = spawnTsx("scripts/supervisor-entrypoint.ts", ["--dev"], {
|
||||||
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
|
|
||||||
cwd: serverDir,
|
cwd: serverDir,
|
||||||
env: withDisabledE2ESpeechEnv({
|
env: withDisabledE2ESpeechEnv({
|
||||||
...process.env,
|
...process.env,
|
||||||
@@ -276,7 +262,7 @@ async function startRestartDaemon(input: {
|
|||||||
try {
|
try {
|
||||||
await waitForServer(port, child);
|
await waitForServer(port, child);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await stopProcess(child);
|
await killProcessTree(child);
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`,
|
`${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`,
|
||||||
{ cause: error },
|
{ cause: error },
|
||||||
@@ -285,7 +271,7 @@ async function startRestartDaemon(input: {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
port,
|
port,
|
||||||
close: () => stopProcess(child),
|
close: () => killProcessTree(child),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,22 +356,6 @@ function parseWorkspaceIdFromPageUrl(page: Page, serverId: string): string | nul
|
|||||||
return decodeWorkspaceIdFromPathSegment(match[1]);
|
return decodeWorkspaceIdFromPathSegment(match[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function expectWorkspaceRowHasOnlyIndicator(
|
|
||||||
page: Page,
|
|
||||||
input: { serverId: string; workspaceId: string; indicator: string },
|
|
||||||
) {
|
|
||||||
const row = page.getByTestId(`sidebar-workspace-row-${input.serverId}:${input.workspaceId}`);
|
|
||||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
|
||||||
for (const indicator of ["attention", "done", "failed", "loading", "needs_input", "running"]) {
|
|
||||||
const locator = row.locator(`[data-testid="workspace-status-indicator-${indicator}"]`);
|
|
||||||
if (indicator === input.indicator) {
|
|
||||||
await expect(locator).toBeVisible({ timeout: 30_000 });
|
|
||||||
} else {
|
|
||||||
await expect(locator).toHaveCount(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function expectWorkspaceRowDoesNotShowIndicator(
|
async function expectWorkspaceRowDoesNotShowIndicator(
|
||||||
page: Page,
|
page: Page,
|
||||||
input: { serverId: string; workspaceId: string; indicator: string },
|
input: { serverId: string; workspaceId: string; indicator: string },
|
||||||
@@ -458,11 +428,6 @@ test.describe("Workspace model restart regressions", () => {
|
|||||||
|
|
||||||
await page.goto(buildHostWorkspaceRoute(serverId, seeded.workspaceA));
|
await page.goto(buildHostWorkspaceRoute(serverId, seeded.workspaceA));
|
||||||
await waitForSidebarHydration(page);
|
await waitForSidebarHydration(page);
|
||||||
await expectWorkspaceRowHasOnlyIndicator(page, {
|
|
||||||
serverId,
|
|
||||||
workspaceId: seeded.workspaceA,
|
|
||||||
indicator: "running",
|
|
||||||
});
|
|
||||||
await expectWorkspaceRowDoesNotShowIndicator(page, {
|
await expectWorkspaceRowDoesNotShowIndicator(page, {
|
||||||
serverId,
|
serverId,
|
||||||
workspaceId: seeded.workspaceB,
|
workspaceId: seeded.workspaceB,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
|
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||||
import type { Locator } from "@playwright/test";
|
import type { Locator, Page } from "@playwright/test";
|
||||||
import { expect, test } from "../support/fixtures";
|
import { expect, test } from "../support/fixtures";
|
||||||
import { createIdleAgent } from "../support/helpers/archive-tab";
|
import { createIdleAgent } from "../support/helpers/archive-tab";
|
||||||
import { expectComposerVisible } from "../support/helpers/composer";
|
import { expectComposerVisible } from "../support/helpers/composer";
|
||||||
@@ -35,10 +35,22 @@ async function expectSameRenderedNode(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function expectNodeConnected(node: Awaited<ReturnType<typeof captureRenderedNode>>) {
|
||||||
|
expect(await node.evaluate((candidate) => candidate.isConnected)).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSettingsShortcut(page: Page) {
|
||||||
|
return page.evaluate(() =>
|
||||||
|
navigator.platform.toLowerCase().includes("mac") ? "Meta+," : "Control+,",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForWorkspaceRoute(page: Page, route: string) {
|
||||||
|
await page.waitForURL((url) => url.pathname === route);
|
||||||
|
}
|
||||||
|
|
||||||
test.describe("Workspace pane mounting", () => {
|
test.describe("Workspace pane mounting", () => {
|
||||||
test("opening the first split pane keeps the existing agent composer mounted", async ({
|
test("workspace navigation keeps the existing agent composer mounted", async ({ page }) => {
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
test.setTimeout(90_000);
|
test.setTimeout(90_000);
|
||||||
const serverId = getServerId();
|
const serverId = getServerId();
|
||||||
|
|
||||||
@@ -65,55 +77,35 @@ test.describe("Workspace pane mounting", () => {
|
|||||||
.first()
|
.first()
|
||||||
.elementHandle();
|
.elementHandle();
|
||||||
expect(originalComposer).not.toBeNull();
|
expect(originalComposer).not.toBeNull();
|
||||||
const emptyPanesBeforeSplit = await page.getByTestId("workspace-new-tab-panel").count();
|
await test.step("opening the first split pane preserves the composer", async () => {
|
||||||
|
const emptyPanesBeforeSplit = await page.getByTestId("workspace-new-tab-panel").count();
|
||||||
|
|
||||||
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||||
await expect(page.getByTestId("workspace-new-tab-panel")).toHaveCount(
|
await expect(page.getByTestId("workspace-new-tab-panel")).toHaveCount(
|
||||||
emptyPanesBeforeSplit + 1,
|
emptyPanesBeforeSplit + 1,
|
||||||
{ timeout: 30_000 },
|
{ timeout: 30_000 },
|
||||||
);
|
);
|
||||||
await expect(page.getByTestId("message-input-root").filter({ visible: true })).toHaveCount(1);
|
await expect(page.getByTestId("message-input-root").filter({ visible: true })).toHaveCount(
|
||||||
|
1,
|
||||||
const originalStillConnected = await originalComposer!.evaluate((node) => node.isConnected);
|
);
|
||||||
expect(originalStillConnected).toBe(true);
|
await expectNodeConnected(originalComposer!);
|
||||||
} finally {
|
|
||||||
await workspace.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("opening Settings hides workspace overlays and keeps the agent composer mounted", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
test.setTimeout(90_000);
|
|
||||||
const serverId = getServerId();
|
|
||||||
const workspace = await seedWorkspace({ repoPrefix: "settings-pane-retention-" });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const agent = await createIdleAgent(workspace.client, {
|
|
||||||
cwd: workspace.repoPath,
|
|
||||||
workspaceId: workspace.workspaceId,
|
|
||||||
title: `settings-pane-retention-${Date.now()}`,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.workspaceId));
|
|
||||||
await waitForWorkspaceTabsVisible(page);
|
|
||||||
const composer = page.getByTestId("message-input-root").filter({ visible: true }).first();
|
const composer = page.getByTestId("message-input-root").filter({ visible: true }).first();
|
||||||
const originalComposer = await captureRenderedNode(composer);
|
await test.step("desktop Settings closes overlays and preserves the composer", async () => {
|
||||||
|
const tab = page.getByTestId(`workspace-tab-agent_${agent.id}`).first();
|
||||||
|
await tab.click({ button: "right" });
|
||||||
|
await page.getByTestId(`workspace-tab-context-agent_${agent.id}-rename`).click();
|
||||||
|
const renameInput = renameModalInput(page, `workspace-tab-rename-modal-agent-${agent.id}`);
|
||||||
|
await expect(renameInput).toBeVisible();
|
||||||
|
|
||||||
const tab = page.getByTestId(`workspace-tab-agent_${agent.id}`).first();
|
const settingsShortcut = await getSettingsShortcut(page);
|
||||||
await tab.click({ button: "right" });
|
await page.keyboard.press(settingsShortcut);
|
||||||
await page.getByTestId(`workspace-tab-context-agent_${agent.id}-rename`).click();
|
await expect(page).toHaveURL(/\/settings\/general$/);
|
||||||
const renameInput = renameModalInput(page, `workspace-tab-rename-modal-agent-${agent.id}`);
|
await expect(renameInput).not.toBeVisible();
|
||||||
await expect(renameInput).toBeVisible();
|
await clickSettingsBackToWorkspace(page);
|
||||||
|
await expectSameRenderedNode(originalComposer!, composer);
|
||||||
const settingsShortcut = await page.evaluate(() =>
|
});
|
||||||
navigator.platform.toLowerCase().includes("mac") ? "Meta+," : "Control+,",
|
|
||||||
);
|
|
||||||
await page.keyboard.press(settingsShortcut);
|
|
||||||
await expect(page).toHaveURL(/\/settings\/general$/);
|
|
||||||
await expect(renameInput).not.toBeVisible();
|
|
||||||
await clickSettingsBackToWorkspace(page);
|
|
||||||
await expectSameRenderedNode(originalComposer, composer);
|
|
||||||
} finally {
|
} finally {
|
||||||
await workspace.cleanup();
|
await workspace.cleanup();
|
||||||
}
|
}
|
||||||
@@ -140,18 +132,17 @@ test.describe("Workspace pane mounting", () => {
|
|||||||
const composer = page.getByTestId("message-input-root").filter({ visible: true }).first();
|
const composer = page.getByTestId("message-input-root").filter({ visible: true }).first();
|
||||||
const originalComposer = await captureRenderedNode(composer);
|
const originalComposer = await captureRenderedNode(composer);
|
||||||
|
|
||||||
await openCompactSettings(page, buildHostWorkspaceRoute(serverId, workspace.workspaceId));
|
const workspaceRoute = buildHostWorkspaceRoute(serverId, workspace.workspaceId);
|
||||||
|
await openCompactSettings(page, workspaceRoute);
|
||||||
await page.goBack();
|
await page.goBack();
|
||||||
await page.waitForURL(
|
await waitForWorkspaceRoute(page, workspaceRoute);
|
||||||
(url) => url.pathname === buildHostWorkspaceRoute(serverId, workspace.workspaceId),
|
|
||||||
);
|
|
||||||
await expectSameRenderedNode(originalComposer, composer);
|
await expectSameRenderedNode(originalComposer, composer);
|
||||||
} finally {
|
} finally {
|
||||||
await workspace.cleanup();
|
await workspace.cleanup();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("switching away from a terminal tab keeps its emulator mounted", async ({ page }) => {
|
test("workspace navigation keeps the terminal emulator mounted", async ({ page }) => {
|
||||||
test.setTimeout(90_000);
|
test.setTimeout(90_000);
|
||||||
const serverId = getServerId();
|
const serverId = getServerId();
|
||||||
const workspace = await seedWorkspace({ repoPrefix: "terminal-pane-retention-" });
|
const workspace = await seedWorkspace({ repoPrefix: "terminal-pane-retention-" });
|
||||||
@@ -170,39 +161,20 @@ test.describe("Workspace pane mounting", () => {
|
|||||||
const terminalSurface = terminalSurfaceLocator(page);
|
const terminalSurface = terminalSurfaceLocator(page);
|
||||||
const originalTerminal = await captureRenderedNode(terminalSurface);
|
const originalTerminal = await captureRenderedNode(terminalSurface);
|
||||||
|
|
||||||
await page.getByTestId(`workspace-tab-agent_${agent.id}`).click();
|
await test.step("switching tabs preserves the terminal", async () => {
|
||||||
await expectComposerVisible(page);
|
await page.getByTestId(`workspace-tab-agent_${agent.id}`).click();
|
||||||
expect(await originalTerminal.evaluate((node) => node.isConnected)).toBe(true);
|
await expectComposerVisible(page);
|
||||||
|
await expectNodeConnected(originalTerminal);
|
||||||
|
|
||||||
await clickFirstTerminalTab(page);
|
await clickFirstTerminalTab(page);
|
||||||
await expectSameRenderedNode(originalTerminal, terminalSurface);
|
await expectSameRenderedNode(originalTerminal, terminalSurface);
|
||||||
} finally {
|
|
||||||
await workspace.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("opening Settings and returning keeps the terminal emulator mounted", async ({ page }) => {
|
|
||||||
test.setTimeout(90_000);
|
|
||||||
const serverId = getServerId();
|
|
||||||
const workspace = await seedWorkspace({ repoPrefix: "settings-terminal-retention-" });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const agent = await createIdleAgent(workspace.client, {
|
|
||||||
cwd: workspace.repoPath,
|
|
||||||
workspaceId: workspace.workspaceId,
|
|
||||||
title: `settings-terminal-retention-${Date.now()}`,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.workspaceId));
|
await test.step("opening Settings preserves the terminal", async () => {
|
||||||
await waitForWorkspaceTabsVisible(page);
|
await openSettings(page);
|
||||||
await clickNewTerminal(page);
|
await clickSettingsBackToWorkspace(page);
|
||||||
await expectTerminalSurfaceVisible(page);
|
await expectSameRenderedNode(originalTerminal, terminalSurface);
|
||||||
const terminalSurface = terminalSurfaceLocator(page);
|
});
|
||||||
const originalTerminal = await captureRenderedNode(terminalSurface);
|
|
||||||
|
|
||||||
await openSettings(page);
|
|
||||||
await clickSettingsBackToWorkspace(page);
|
|
||||||
await expectSameRenderedNode(originalTerminal, terminalSurface);
|
|
||||||
} finally {
|
} finally {
|
||||||
await workspace.cleanup();
|
await workspace.cleanup();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { test, expect } from "../support/fixtures";
|
import { daemonTest, test, expect } from "../support/fixtures";
|
||||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||||
import {
|
import {
|
||||||
closeSetupTab,
|
closeSetupTab,
|
||||||
@@ -117,7 +117,7 @@ test.describe("Workspace setup streaming", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("streams running and completed setup snapshots for a successful setup", async () => {
|
daemonTest("streams running and completed setup snapshots for a successful setup", async () => {
|
||||||
const client = await connectWorkspaceSetupClient();
|
const client = await connectWorkspaceSetupClient();
|
||||||
const repo = await createTempGitRepo("setup-success-", {
|
const repo = await createTempGitRepo("setup-success-", {
|
||||||
paseoConfig: {
|
paseoConfig: {
|
||||||
@@ -204,7 +204,7 @@ test.describe("Workspace setup streaming", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("emits a completed empty snapshot when no setup commands exist", async () => {
|
daemonTest("emits a completed empty snapshot when no setup commands exist", async () => {
|
||||||
const client = await connectWorkspaceSetupClient();
|
const client = await connectWorkspaceSetupClient();
|
||||||
const repo = await createTempGitRepo("setup-none-");
|
const repo = await createTempGitRepo("setup-none-");
|
||||||
|
|
||||||
@@ -283,7 +283,7 @@ test.describe("Workspace setup streaming", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("launches workspace scripts through an explicit daemon request", async () => {
|
daemonTest("launches workspace scripts through an explicit daemon request", async () => {
|
||||||
const client = await connectWorkspaceSetupClient();
|
const client = await connectWorkspaceSetupClient();
|
||||||
const repo = await createTempGitRepo("setup-scripts-", {
|
const repo = await createTempGitRepo("setup-scripts-", {
|
||||||
paseoConfig: {
|
paseoConfig: {
|
||||||
|
|||||||
@@ -39,28 +39,6 @@ test.describe("Workspace archive with worktree backing", () => {
|
|||||||
await tempRepo?.cleanup().catch(() => undefined);
|
await tempRepo?.cleanup().catch(() => undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("archiving the final workspace removes its managed worktree directory", async ({ page }) => {
|
|
||||||
const serverId = getServerId();
|
|
||||||
await openProjectViaDaemon(client, tempRepo.path);
|
|
||||||
const worktree = await createWorktreeViaDaemon(client, {
|
|
||||||
cwd: tempRepo.path,
|
|
||||||
slug: `archive-${Date.now()}`,
|
|
||||||
});
|
|
||||||
createdWorktreeDirectories.add(worktree.workspaceDirectory);
|
|
||||||
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
|
|
||||||
|
|
||||||
await gotoAppShell(page);
|
|
||||||
await waitForSidebarHydration(page);
|
|
||||||
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: worktree.workspaceId });
|
|
||||||
|
|
||||||
await archiveWorkspaceFromSidebar(page, worktree.workspaceId);
|
|
||||||
|
|
||||||
await expectWorkspaceAbsentFromSidebar(page, worktree.workspaceId);
|
|
||||||
await expect
|
|
||||||
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
|
|
||||||
.toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a managed worktree remains until its last workspace is archived", async ({ page }) => {
|
test("a managed worktree remains until its last workspace is archived", async ({ page }) => {
|
||||||
const serverId = getServerId();
|
const serverId = getServerId();
|
||||||
await openProjectViaDaemon(client, tempRepo.path);
|
await openProjectViaDaemon(client, tempRepo.path);
|
||||||
|
|||||||
@@ -32,16 +32,8 @@ const metroTest = base.extend({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const test = metroTest.extend<
|
const daemonTest = metroTest.extend<
|
||||||
{
|
{ projectOwnership: void },
|
||||||
paseoE2ESetup: void;
|
|
||||||
projectOwnership: void;
|
|
||||||
outdatedDaemon: OutdatedDaemon;
|
|
||||||
desktopManagedOutdatedDaemon: OutdatedDaemon;
|
|
||||||
relayConfigOutdatedDaemon: OutdatedDaemon;
|
|
||||||
projectPickerFixture: TrackedProjectPickerFixture;
|
|
||||||
withWorkspace: WithWorkspace;
|
|
||||||
},
|
|
||||||
{ e2eForkProviders: string[]; e2eWorker: void; e2eWorkerClient: SeedDaemonClient }
|
{ e2eForkProviders: string[]; e2eWorker: void; e2eWorkerClient: SeedDaemonClient }
|
||||||
>({
|
>({
|
||||||
e2eForkProviders: [[], { scope: "worker", option: true }],
|
e2eForkProviders: [[], { scope: "worker", option: true }],
|
||||||
@@ -94,6 +86,16 @@ const test = metroTest.extend<
|
|||||||
},
|
},
|
||||||
{ auto: true },
|
{ auto: true },
|
||||||
],
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const test = daemonTest.extend<{
|
||||||
|
paseoE2ESetup: void;
|
||||||
|
outdatedDaemon: OutdatedDaemon;
|
||||||
|
desktopManagedOutdatedDaemon: OutdatedDaemon;
|
||||||
|
relayConfigOutdatedDaemon: OutdatedDaemon;
|
||||||
|
projectPickerFixture: TrackedProjectPickerFixture;
|
||||||
|
withWorkspace: WithWorkspace;
|
||||||
|
}>({
|
||||||
paseoE2ESetup: [
|
paseoE2ESetup: [
|
||||||
async ({ page }, provide, testInfo) => {
|
async ({ page }, provide, testInfo) => {
|
||||||
const daemonPort = getE2EDaemonPort();
|
const daemonPort = getE2EDaemonPort();
|
||||||
@@ -227,4 +229,4 @@ const test = metroTest.extend<
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export { test, metroTest, expect, type Page };
|
export { daemonTest, test, metroTest, expect, type Page };
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { spawn, type ChildProcess } from "node:child_process";
|
import { spawn, type ChildProcess } from "node:child_process";
|
||||||
import { once } from "node:events";
|
import { killProcessTree } from "./helpers/spawn-node";
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import net from "node:net";
|
import net from "node:net";
|
||||||
@@ -148,28 +148,11 @@ export async function warmMetro(port: number): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function stopProcess(child: ChildProcess | null): Promise<void> {
|
|
||||||
if (!child || child.exitCode !== null || child.signalCode !== null) return;
|
|
||||||
child.kill("SIGTERM");
|
|
||||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
||||||
try {
|
|
||||||
await Promise.race([
|
|
||||||
once(child, "exit"),
|
|
||||||
new Promise<void>((resolve) => {
|
|
||||||
timeout = setTimeout(() => {
|
|
||||||
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
|
||||||
resolve();
|
|
||||||
}, 5_000);
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
} finally {
|
|
||||||
if (timeout) clearTimeout(timeout);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function startMetro(port: number, buffer: ReturnType<typeof createLineBuffer>): ChildProcess {
|
function startMetro(port: number, buffer: ReturnType<typeof createLineBuffer>): ChildProcess {
|
||||||
const appDir = path.resolve(__dirname, "../..");
|
const appDir = path.resolve(__dirname, "../..");
|
||||||
const child = spawn("npx", ["expo", "start", "--web", "--port", String(port)], {
|
const expoCli = require.resolve("expo/bin/cli");
|
||||||
|
// Spawns Node directly to bypass Windows .cmd shim execution restrictions without shell: true.
|
||||||
|
const child = spawn(process.execPath, [expoCli, "start", "--web", "--port", String(port)], {
|
||||||
cwd: appDir,
|
cwd: appDir,
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
@@ -222,11 +205,11 @@ export default async function globalSetup() {
|
|||||||
console.log(`[e2e] Metro warmed on port ${metroPort}`);
|
console.log(`[e2e] Metro warmed on port ${metroPort}`);
|
||||||
|
|
||||||
return async () => {
|
return async () => {
|
||||||
await stopProcess(metroProcess);
|
await killProcessTree(metroProcess);
|
||||||
console.log("[e2e] Metro stopped");
|
console.log("[e2e] Metro stopped");
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await stopProcess(metroProcess);
|
await killProcessTree(metroProcess);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
import { expect, type Page } from "@playwright/test";
|
import { expect, type Page } from "@playwright/test";
|
||||||
import { escapeRegex } from "./regex";
|
import { escapeRegex } from "./regex";
|
||||||
import { openChangesPanel as openWorkspaceChangesPanel } from "./workspace-tabs";
|
import { openChangesTreePanel } from "./workspace-tabs";
|
||||||
|
|
||||||
// The branch switcher lives in the Changes tab, not in the workspace header. It renders as a button whose
|
// The branch switcher lives in the Changes tab, not in the workspace header. It renders as a button whose
|
||||||
// accessible name carries the current branch ("Current branch: <name>. Press to
|
// accessible name carries the current branch ("Current branch: <name>. Press to
|
||||||
// switch branch."). Matching on the accessible name keeps these helpers tied to
|
// switch branch."). Matching on the accessible name keeps these helpers tied to
|
||||||
// what a screen reader user hears, and it proves the panel resolved a real
|
// what a screen reader user hears, and it proves the panel resolved a real
|
||||||
// checkout directory from the opaque workspace id. Scoping to the changes header
|
// checkout directory from the opaque workspace id. Scoping to the repository
|
||||||
// keeps the matcher unambiguous even when the header is shared with diff actions.
|
// header keeps the matcher aligned with the row that owns repository identity.
|
||||||
function branchSwitcherTrigger(page: Page, branchName: string) {
|
function branchSwitcherTrigger(page: Page, branchName: string) {
|
||||||
return page
|
return page
|
||||||
.getByTestId("changes-header")
|
.getByTestId("changes-repository-header")
|
||||||
.getByRole("button", { name: new RegExp(`Current branch: ${escapeRegex(branchName)}\\b`) })
|
.getByRole("button", { name: new RegExp(`Current branch: ${escapeRegex(branchName)}\\b`) })
|
||||||
.filter({ visible: true })
|
.filter({ visible: true })
|
||||||
.first();
|
.first();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Opens the fixed pane and lands on the Changes tab, where the switcher and diff live.
|
// Opens the fixed Explorer Changes view, where the switcher lives.
|
||||||
export async function openChangesPanel(page: Page): Promise<void> {
|
export async function openChangesPanel(page: Page): Promise<void> {
|
||||||
await openWorkspaceChangesPanel(page);
|
await openChangesTreePanel(page);
|
||||||
await expect(page.getByTestId("changes-header").filter({ visible: true }).first()).toBeVisible({
|
await expect(
|
||||||
timeout: 30_000,
|
page.getByTestId("changes-repository-header").filter({ visible: true }).first(),
|
||||||
});
|
).toBeVisible({ timeout: 30_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function expectWorkspaceBranch(page: Page, branchName: string): Promise<void> {
|
export async function expectWorkspaceBranch(page: Page, branchName: string): Promise<void> {
|
||||||
|
|||||||
@@ -140,6 +140,15 @@ export async function expectActiveChatOutlinePrompt(page: Page, position: number
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function expectActiveChatOutlinePromptMovedFrom(
|
||||||
|
page: Page,
|
||||||
|
position: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const activePrompt = chatOutlineRail(page).getByRole("tab", { selected: true });
|
||||||
|
await expect(activePrompt).toHaveCount(1);
|
||||||
|
await expect(activePrompt).not.toHaveAccessibleName(new RegExp(`^${position} of `));
|
||||||
|
}
|
||||||
|
|
||||||
export async function expectLiveTurnPromptAboveFoldAndActive(
|
export async function expectLiveTurnPromptAboveFoldAndActive(
|
||||||
page: Page,
|
page: Page,
|
||||||
prompt: string,
|
prompt: string,
|
||||||
|
|||||||
@@ -1,12 +1,27 @@
|
|||||||
import { expect, type Page } from "@playwright/test";
|
import { expect, type Locator, type Page } from "@playwright/test";
|
||||||
import type { SeededWorkspace } from "./seed-client";
|
import type { SeededWorkspace } from "./seed-client";
|
||||||
import { openCommandCenter } from "./command-center";
|
import { openCommandCenter } from "./command-center";
|
||||||
|
import { openAgentRoute, type MockAgentWorkspace } from "./mock-agent";
|
||||||
|
|
||||||
export interface CommandCenterAgentControlChoice {
|
export interface CommandCenterAgentControlChoice {
|
||||||
query: string;
|
query: string;
|
||||||
choice: string;
|
choice: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Focuses a seeded mock agent — which is what makes its model and mode contributions register —
|
||||||
|
* opens the Command Center over it, and hands back the query input. Seeding stays in the test so
|
||||||
|
* the workspace lifecycle is owned by the `try`/`finally` that cleans it up.
|
||||||
|
*/
|
||||||
|
export async function openCommandCenterForAgent(
|
||||||
|
page: Page,
|
||||||
|
workspace: Pick<MockAgentWorkspace, "workspaceId" | "agentId">,
|
||||||
|
): Promise<Locator> {
|
||||||
|
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: workspace.agentId });
|
||||||
|
const panel = await openCommandCenter(page);
|
||||||
|
return panel.getByTestId("command-center-input");
|
||||||
|
}
|
||||||
|
|
||||||
export async function chooseCommandCenterAgentControl(input: {
|
export async function chooseCommandCenterAgentControl(input: {
|
||||||
page: Page;
|
page: Page;
|
||||||
query: string;
|
query: string;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { fork, type ChildProcess } from "node:child_process";
|
import { fork, type ChildProcess } from "node:child_process";
|
||||||
import { once } from "node:events";
|
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { killProcessTree } from "./spawn-node";
|
||||||
|
|
||||||
export interface OutdatedDaemon {
|
export interface OutdatedDaemon {
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
@@ -55,14 +55,10 @@ export async function startOutdatedDaemon(options?: {
|
|||||||
endpoint: ready.endpoint,
|
endpoint: ready.endpoint,
|
||||||
label: options?.desktopManaged === true ? "outdated Desktop host" : "outdated host",
|
label: options?.desktopManaged === true ? "outdated Desktop host" : "outdated host",
|
||||||
serverId: ready.serverId,
|
serverId: ready.serverId,
|
||||||
async close() {
|
close: () => killProcessTree(child),
|
||||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
||||||
child.kill("SIGTERM");
|
|
||||||
await once(child, "exit");
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
child.kill("SIGTERM");
|
await killProcessTree(child);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { execSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
@@ -19,11 +19,19 @@ async function createFakeEditorBin(): Promise<string> {
|
|||||||
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-editor-bin-"));
|
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-editor-bin-"));
|
||||||
let realGhPath = "";
|
let realGhPath = "";
|
||||||
try {
|
try {
|
||||||
realGhPath = execSync("which gh").toString().trim();
|
const locator = process.platform === "win32" ? "where.exe" : "which";
|
||||||
|
const candidates = execFileSync(locator, ["gh"], { encoding: "utf8" })
|
||||||
|
.split(/\r?\n/u)
|
||||||
|
.map((candidate) => candidate.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
realGhPath =
|
||||||
|
candidates.find(
|
||||||
|
(candidate) =>
|
||||||
|
process.platform !== "win32" || !/\.(?:cmd|bat)$/iu.test(path.extname(candidate)),
|
||||||
|
) ?? "";
|
||||||
} catch {
|
} catch {
|
||||||
// The local GitHub fixture remains usable without a system gh binary.
|
// The local GitHub fixture remains usable without a system gh binary.
|
||||||
}
|
}
|
||||||
|
|
||||||
const fakeEditorSource = `#!/usr/bin/env node
|
const fakeEditorSource = `#!/usr/bin/env node
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
@@ -41,6 +49,9 @@ if (recordPath) {
|
|||||||
const editorPath = path.join(binDir, editorCommand);
|
const editorPath = path.join(binDir, editorCommand);
|
||||||
await writeFile(editorPath, fakeEditorSource);
|
await writeFile(editorPath, fakeEditorSource);
|
||||||
await chmod(editorPath, 0o755);
|
await chmod(editorPath, 0o755);
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
await writeFile(`${editorPath}.cmd`, `@node "%~dp0${editorCommand}" %*\r\n`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fakeGhPath = path.join(binDir, "gh");
|
const fakeGhPath = path.join(binDir, "gh");
|
||||||
@@ -106,6 +117,9 @@ process.exit(result.status ?? 1);
|
|||||||
`;
|
`;
|
||||||
await writeFile(fakeGhPath, fakeGhSource);
|
await writeFile(fakeGhPath, fakeGhSource);
|
||||||
await chmod(fakeGhPath, 0o755);
|
await chmod(fakeGhPath, 0o755);
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
await writeFile(`${fakeGhPath}.cmd`, '@node "%~dp0gh" %*\r\n');
|
||||||
|
}
|
||||||
return binDir;
|
return binDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,10 @@
|
|||||||
import { once } from "node:events";
|
import { spawn, execFileSync, type ChildProcess, type SpawnOptions } from "node:child_process";
|
||||||
import {
|
|
||||||
spawn,
|
|
||||||
execFileSync,
|
|
||||||
execSync,
|
|
||||||
type ChildProcess,
|
|
||||||
type SpawnOptions,
|
|
||||||
} from "node:child_process";
|
|
||||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
import net from "node:net";
|
import net from "node:net";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { withDisabledE2ESpeechEnv } from "./speech-env";
|
import { withDisabledE2ESpeechEnv } from "./speech-env";
|
||||||
|
import { killProcessTree, spawnTsx } from "./spawn-node";
|
||||||
|
|
||||||
export interface IsolatedHostDaemon {
|
export interface IsolatedHostDaemon {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
@@ -48,12 +42,14 @@ async function getAvailablePort(): Promise<number> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function waitForServer(port: number, child: ChildProcess): Promise<void> {
|
async function waitForServer(port: number, child: ChildProcess): Promise<void> {
|
||||||
const deadline = Date.now() + 20_000;
|
const deadline = Date.now() + 90_000;
|
||||||
let lastError: unknown = null;
|
let lastError: unknown = null;
|
||||||
|
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
if (child.exitCode !== null) {
|
if (child.exitCode !== null || child.signalCode !== null) {
|
||||||
throw new Error(`Isolated host daemon exited before listening (exit ${child.exitCode})`);
|
throw new Error(
|
||||||
|
`Isolated host daemon exited before listening (code ${String(child.exitCode)}, signal ${String(child.signalCode)})`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
@@ -81,19 +77,6 @@ async function waitForServer(port: number, child: ChildProcess): Promise<void> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function stopProcess(child: ChildProcess): Promise<void> {
|
|
||||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
||||||
child.kill("SIGTERM");
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
|
||||||
}, 5_000);
|
|
||||||
try {
|
|
||||||
await once(child, "exit");
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function startIsolatedHostDaemon(
|
export async function startIsolatedHostDaemon(
|
||||||
serverId: string,
|
serverId: string,
|
||||||
options: IsolatedHostDaemonOptions = {},
|
options: IsolatedHostDaemonOptions = {},
|
||||||
@@ -114,11 +97,17 @@ export async function startIsolatedHostDaemon(
|
|||||||
path.join(publishedPackageRoot, "package.json"),
|
path.join(publishedPackageRoot, "package.json"),
|
||||||
`${JSON.stringify({ private: true })}\n`,
|
`${JSON.stringify({ private: true })}\n`,
|
||||||
);
|
);
|
||||||
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
||||||
try {
|
try {
|
||||||
|
const npmCli = process.env.npm_execpath;
|
||||||
|
if (!npmCli || path.basename(npmCli).toLowerCase() !== "npm-cli.js") {
|
||||||
|
throw new Error(
|
||||||
|
"Published-version E2E requires npm_execpath from npm. Start it through `npm run test:e2e`.",
|
||||||
|
);
|
||||||
|
}
|
||||||
execFileSync(
|
execFileSync(
|
||||||
npmCommand,
|
process.execPath,
|
||||||
[
|
[
|
||||||
|
npmCli,
|
||||||
"install",
|
"install",
|
||||||
"--no-audit",
|
"--no-audit",
|
||||||
"--no-fund",
|
"--no-fund",
|
||||||
@@ -128,6 +117,9 @@ export async function startIsolatedHostDaemon(
|
|||||||
{ cwd: publishedPackageRoot, stdio: "ignore" },
|
{ cwd: publishedPackageRoot, stdio: "ignore" },
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (!options.preserveHome) {
|
||||||
|
await rm(paseoHome, { recursive: true, force: true });
|
||||||
|
}
|
||||||
await rm(publishedPackageRoot, { recursive: true, force: true });
|
await rm(publishedPackageRoot, { recursive: true, force: true });
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -155,7 +147,6 @@ export async function startIsolatedHostDaemon(
|
|||||||
const serverDir = publishedPackageRoot
|
const serverDir = publishedPackageRoot
|
||||||
? path.join(publishedPackageRoot, "node_modules", "@getpaseo", "server")
|
? path.join(publishedPackageRoot, "node_modules", "@getpaseo", "server")
|
||||||
: path.resolve(__dirname, "../../../../server");
|
: path.resolve(__dirname, "../../../../server");
|
||||||
const tsxBin = execSync("which tsx").toString().trim();
|
|
||||||
const spawnDaemon = async (): Promise<ChildProcess> => {
|
const spawnDaemon = async (): Promise<ChildProcess> => {
|
||||||
const spawnOptions: SpawnOptions = {
|
const spawnOptions: SpawnOptions = {
|
||||||
cwd: serverDir,
|
cwd: serverDir,
|
||||||
@@ -175,7 +166,7 @@ export async function startIsolatedHostDaemon(
|
|||||||
};
|
};
|
||||||
const child = publishedPackageRoot
|
const child = publishedPackageRoot
|
||||||
? spawn(process.execPath, ["dist/scripts/supervisor-entrypoint.js"], spawnOptions)
|
? spawn(process.execPath, ["dist/scripts/supervisor-entrypoint.js"], spawnOptions)
|
||||||
: spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], spawnOptions);
|
: spawnTsx("scripts/supervisor-entrypoint.ts", ["--dev"], spawnOptions);
|
||||||
|
|
||||||
let stderr = "";
|
let stderr = "";
|
||||||
child.stderr?.on("data", (chunk: Buffer) => {
|
child.stderr?.on("data", (chunk: Buffer) => {
|
||||||
@@ -187,7 +178,7 @@ export async function startIsolatedHostDaemon(
|
|||||||
await waitForServer(port, child);
|
await waitForServer(port, child);
|
||||||
return child;
|
return child;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await stopProcess(child);
|
await killProcessTree(child);
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`,
|
`${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`,
|
||||||
{ cause: error },
|
{ cause: error },
|
||||||
@@ -216,13 +207,13 @@ export async function startIsolatedHostDaemon(
|
|||||||
getPid: () => child.pid,
|
getPid: () => child.pid,
|
||||||
restart: async () => {
|
restart: async () => {
|
||||||
if (closed) throw new Error(`Cannot restart closed isolated daemon ${serverId}`);
|
if (closed) throw new Error(`Cannot restart closed isolated daemon ${serverId}`);
|
||||||
await stopProcess(child);
|
await killProcessTree(child);
|
||||||
child = await spawnDaemon();
|
child = await spawnDaemon();
|
||||||
},
|
},
|
||||||
close: async () => {
|
close: async () => {
|
||||||
if (closed) return;
|
if (closed) return;
|
||||||
closed = true;
|
closed = true;
|
||||||
await stopProcess(child);
|
await killProcessTree(child);
|
||||||
if (!options.preserveHome) {
|
if (!options.preserveHome) {
|
||||||
await rm(paseoHome, { recursive: true, force: true });
|
await rm(paseoHome, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ export async function sampleTabsDuringTransition(
|
|||||||
function sample() {
|
function sample() {
|
||||||
const tabs = Array.from(
|
const tabs = Array.from(
|
||||||
document.querySelectorAll<HTMLElement>(
|
document.querySelectorAll<HTMLElement>(
|
||||||
'[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])',
|
'[data-testid^="workspace-tab-"][role="button"][aria-selected]',
|
||||||
),
|
),
|
||||||
).filter((element) => element.getClientRects().length > 0);
|
).filter((element) => element.getClientRects().length > 0);
|
||||||
scope.__paseoTabTrackFrames?.push(
|
scope.__paseoTabTrackFrames?.push(
|
||||||
@@ -188,7 +188,10 @@ export async function sampleTabsDuringTransition(
|
|||||||
requestAnimationFrame(sample);
|
requestAnimationFrame(sample);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
requestAnimationFrame(sample);
|
// Establish the known-good pre-action state synchronously. Starting on the
|
||||||
|
// next animation frame lets the action race the first sample, which can
|
||||||
|
// misclassify a not-yet-painted test harness frame as a transition blank.
|
||||||
|
sample();
|
||||||
}, durationMs);
|
}, durationMs);
|
||||||
await action();
|
await action();
|
||||||
await page.waitForTimeout(durationMs + 100);
|
await page.waitForTimeout(durationMs + 100);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { spawn, type ChildProcess } from "node:child_process";
|
import { spawn, type ChildProcess } from "node:child_process";
|
||||||
import { once } from "node:events";
|
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import net from "node:net";
|
import net from "node:net";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { killProcessTree } from "./spawn-node";
|
||||||
|
|
||||||
export interface LocalElixirRelay {
|
export interface LocalElixirRelay {
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
@@ -27,19 +27,6 @@ async function availablePort(): Promise<number> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function stopProcess(child: ChildProcess): Promise<void> {
|
|
||||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
||||||
child.kill("SIGTERM");
|
|
||||||
const force = setTimeout(() => {
|
|
||||||
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
|
||||||
}, 5_000);
|
|
||||||
try {
|
|
||||||
await once(child, "exit");
|
|
||||||
} finally {
|
|
||||||
clearTimeout(force);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitUntilReady(
|
async function waitUntilReady(
|
||||||
port: number,
|
port: number,
|
||||||
child: ChildProcess,
|
child: ChildProcess,
|
||||||
@@ -48,9 +35,9 @@ async function waitUntilReady(
|
|||||||
const deadline = Date.now() + 60_000;
|
const deadline = Date.now() + 60_000;
|
||||||
let lastError: unknown;
|
let lastError: unknown;
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
if (child.exitCode !== null) {
|
if (child.exitCode !== null || child.signalCode !== null) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Elixir relay exited before becoming ready (exit ${child.exitCode}).\n${recentOutput()}`,
|
`Elixir relay exited before becoming ready (code ${String(child.exitCode)}, signal ${String(child.signalCode)}).\n${recentOutput()}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -70,6 +57,12 @@ async function waitUntilReady(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function startLocalElixirRelay(): Promise<LocalElixirRelay> {
|
export async function startLocalElixirRelay(): Promise<LocalElixirRelay> {
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
throw new Error(
|
||||||
|
"The local Elixir relay requires asdf, which is not available on Windows; the relay-deployment Playwright project is POSIX-only.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const relayRoot =
|
const relayRoot =
|
||||||
process.env.PASEO_RELAY_CHECKOUT ?? path.resolve(__dirname, "../../../../../..", "paseo-relay");
|
process.env.PASEO_RELAY_CHECKOUT ?? path.resolve(__dirname, "../../../../../..", "paseo-relay");
|
||||||
if (!existsSync(path.join(relayRoot, "mix.exs"))) {
|
if (!existsSync(path.join(relayRoot, "mix.exs"))) {
|
||||||
@@ -107,14 +100,14 @@ export async function startLocalElixirRelay(): Promise<LocalElixirRelay> {
|
|||||||
try {
|
try {
|
||||||
await waitUntilReady(port, child, () => output.join("\n"));
|
await waitUntilReady(port, child, () => output.join("\n"));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await stopProcess(child);
|
await killProcessTree(child);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const stop = async () => {
|
const stop = async () => {
|
||||||
if (!child) return;
|
if (!child) return;
|
||||||
await stopProcess(child);
|
await killProcessTree(child);
|
||||||
child = null;
|
child = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export async function seedMockAgentWorkspace(
|
|||||||
workspaceId: workspace.workspaceId,
|
workspaceId: workspace.workspaceId,
|
||||||
title: options.title,
|
title: options.title,
|
||||||
modeId: options.modeId ?? "load-test",
|
modeId: options.modeId ?? "load-test",
|
||||||
model: options.model ?? "ten-second-stream",
|
model: options.model ?? "e2e-fast-stream",
|
||||||
initialPrompt: options.initialPrompt,
|
initialPrompt: options.initialPrompt,
|
||||||
featureValues: options.featureValues,
|
featureValues: options.featureValues,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Page } from "@playwright/test";
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
|
||||||
const DATABASE_NAME = "paseo-replica-row-store";
|
const DATABASE_NAME = "paseo-replica-row-store";
|
||||||
const STORE_NAME = "rows";
|
const STORE_NAME = "rows";
|
||||||
@@ -18,7 +18,7 @@ interface ReplicaCacheHostRecord {
|
|||||||
agents: Array<Record<string, unknown>>;
|
agents: Array<Record<string, unknown>>;
|
||||||
workspaces: Array<Record<string, unknown>>;
|
workspaces: Array<Record<string, unknown>>;
|
||||||
projects: Array<Record<string, unknown>>;
|
projects: Array<Record<string, unknown>>;
|
||||||
timeline?: {
|
timelines: Array<{
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
items?: Array<Record<string, unknown>>;
|
items?: Array<Record<string, unknown>>;
|
||||||
range?: {
|
range?: {
|
||||||
@@ -26,7 +26,7 @@ interface ReplicaCacheHostRecord {
|
|||||||
epoch?: string;
|
epoch?: string;
|
||||||
startSeq?: number;
|
startSeq?: number;
|
||||||
} | null;
|
} | null;
|
||||||
} | null;
|
}>;
|
||||||
directorySync?: unknown;
|
directorySync?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ function assembleCache(rows: ReplicaRowRecord[]): ReplicaCacheRecord {
|
|||||||
agents: [],
|
agents: [],
|
||||||
workspaces: [],
|
workspaces: [],
|
||||||
projects: [],
|
projects: [],
|
||||||
timeline: null,
|
timelines: [],
|
||||||
};
|
};
|
||||||
const payload = JSON.parse(row.payload) as Record<string, unknown>;
|
const payload = JSON.parse(row.payload) as Record<string, unknown>;
|
||||||
switch (row.kind) {
|
switch (row.kind) {
|
||||||
@@ -97,7 +97,7 @@ function assembleCache(rows: ReplicaRowRecord[]): ReplicaCacheRecord {
|
|||||||
host.projects.push(payload);
|
host.projects.push(payload);
|
||||||
break;
|
break;
|
||||||
case "timeline":
|
case "timeline":
|
||||||
host.timeline = payload;
|
host.timelines.push(payload);
|
||||||
break;
|
break;
|
||||||
case "checkpoint":
|
case "checkpoint":
|
||||||
host.directorySync = payload;
|
host.directorySync = payload;
|
||||||
@@ -116,6 +116,23 @@ export async function readReplicaCache(page: Page): Promise<ReplicaCacheRecord |
|
|||||||
return rows.length > 0 ? assembleCache(rows) : null;
|
return rows.length > 0 ? assembleCache(rows) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function waitForWorkspaceInReplicaCache(
|
||||||
|
page: Page,
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const cache = await readReplicaCache(page);
|
||||||
|
return cache?.hosts?.some((host) =>
|
||||||
|
host.workspaces.some((workspace) => workspace.id === workspaceId),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
export async function writeReplicaCache(page: Page, value: ReplicaCacheRecord): Promise<void> {
|
export async function writeReplicaCache(page: Page, value: ReplicaCacheRecord): Promise<void> {
|
||||||
const rows: ReplicaRowRecord[] = [];
|
const rows: ReplicaRowRecord[] = [];
|
||||||
for (const host of value.hosts ?? []) {
|
for (const host of value.hosts ?? []) {
|
||||||
@@ -147,12 +164,13 @@ export async function writeReplicaCache(page: Page, value: ReplicaCacheRecord):
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (host.timeline) {
|
for (const timeline of host.timelines) {
|
||||||
|
if (typeof timeline.agentId !== "string") continue;
|
||||||
rows.push({
|
rows.push({
|
||||||
serverId: host.serverId,
|
serverId: host.serverId,
|
||||||
kind: "timeline",
|
kind: "timeline",
|
||||||
id: SINGLETON_ID,
|
id: timeline.agentId,
|
||||||
payload: JSON.stringify(host.timeline),
|
payload: JSON.stringify(timeline),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (host.directorySync !== undefined) {
|
if (host.directorySync !== undefined) {
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export interface SeedDaemonClient {
|
|||||||
workspace: SeedWorkspaceDescriptor | null;
|
workspace: SeedWorkspaceDescriptor | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}>;
|
}>;
|
||||||
|
archiveWorkspace(workspaceId: string): Promise<{ error: string | null }>;
|
||||||
/**
|
/**
|
||||||
* Force the daemon to recompute its git snapshot and diff for a checkout,
|
* Force the daemon to recompute its git snapshot and diff for a checkout,
|
||||||
* mirroring the UI's manual refresh. Tests use this to make an out-of-band
|
* mirroring the UI's manual refresh. Tests use this to make an out-of-band
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { execFile, spawn, type ChildProcess, type SpawnOptions } from "node:child_process";
|
||||||
|
import { once } from "node:events";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs a repo-local TypeScript entrypoint through Node's own tsx loader instead
|
||||||
|
* of the `node_modules/.bin/tsx` shim.
|
||||||
|
*
|
||||||
|
* The shim is a platform trap, not a convenience: on Windows it is `tsx.cmd`,
|
||||||
|
* and Node refuses to spawn `.cmd`/`.bat` without `shell: true` (EINVAL, from
|
||||||
|
* the CVE-2024-27980 mitigation). Opting into `shell: true` to get around that
|
||||||
|
* trades one bug for three — argv is concatenated unescaped (DEP0190), a path
|
||||||
|
* containing spaces splits, and the kill signal lands on `cmd.exe` while the
|
||||||
|
* real child keeps the port. Spawning `process.execPath` has none of those
|
||||||
|
* properties and is byte-identical on POSIX, so there is no platform branch.
|
||||||
|
*/
|
||||||
|
export function spawnTsx(entrypoint: string, args: string[], options: SpawnOptions): ChildProcess {
|
||||||
|
if (entrypoint.startsWith("-")) {
|
||||||
|
throw new Error(`TypeScript entrypoint must be a path, received ${entrypoint}`);
|
||||||
|
}
|
||||||
|
return spawn(process.execPath, ["--import", "tsx", "--", entrypoint, ...args], options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Terminates a child and everything it forked, then waits for the exit.
|
||||||
|
*
|
||||||
|
* `child.kill()` on Windows maps to `TerminateProcess` against the direct child
|
||||||
|
* only. Metro and the daemon supervisor both fork workers, so the signal leaves
|
||||||
|
* grandchildren holding the listening port and the next run fails to bind.
|
||||||
|
* `taskkill /T` walks the tree; POSIX keeps the SIGTERM-then-SIGKILL ladder.
|
||||||
|
*/
|
||||||
|
export async function killProcessTree(child: ChildProcess | null): Promise<void> {
|
||||||
|
if (!child || hasExited(child)) return;
|
||||||
|
const exited = once(child, "exit").then(() => undefined);
|
||||||
|
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
const pid = child.pid;
|
||||||
|
if (pid === undefined) {
|
||||||
|
throw new Error("Cannot terminate a Windows process tree without a PID");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register the exit listener before taskkill to avoid missing a fast exit.
|
||||||
|
// Bound taskkill itself so a stuck system utility cannot hang teardown.
|
||||||
|
const completed = Promise.withResolvers<Error | null>();
|
||||||
|
execFile("taskkill", ["/pid", String(pid), "/T", "/F"], { timeout: 5_000 }, (error) =>
|
||||||
|
completed.resolve(error),
|
||||||
|
);
|
||||||
|
const taskkillError = await completed.promise;
|
||||||
|
if (taskkillError && !hasExited(child)) {
|
||||||
|
try {
|
||||||
|
child.kill("SIGKILL");
|
||||||
|
} catch {
|
||||||
|
// The child can exit between the state check and the direct kill.
|
||||||
|
}
|
||||||
|
await waitForExitOrTimeout(exited, 5_000);
|
||||||
|
throw new Error(`Failed to terminate process tree for PID ${pid}`, { cause: taskkillError });
|
||||||
|
}
|
||||||
|
if (!hasExited(child) && !(await waitForExitOrTimeout(exited, 5_000))) {
|
||||||
|
throw new Error(`Process tree for PID ${pid} did not exit after taskkill`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
child.kill("SIGTERM");
|
||||||
|
if (await waitForExitOrTimeout(exited, 5_000)) return;
|
||||||
|
|
||||||
|
child.kill("SIGKILL");
|
||||||
|
if (!(await waitForExitOrTimeout(exited, 5_000))) {
|
||||||
|
throw new Error(`Process ${String(child.pid)} did not exit after SIGKILL`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasExited(child: ChildProcess): boolean {
|
||||||
|
return child.exitCode !== null || child.signalCode !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForExitOrTimeout(exited: Promise<void>, timeoutMs: number): Promise<boolean> {
|
||||||
|
const timedOut = Promise.withResolvers<boolean>();
|
||||||
|
const timeout = setTimeout(() => timedOut.resolve(false), timeoutMs);
|
||||||
|
try {
|
||||||
|
return await Promise.race([exited.then(() => true), timedOut.promise]);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
import { expect, type Locator, type Page } from "@playwright/test";
|
|
||||||
|
|
||||||
export interface SplitPaneGeometry {
|
|
||||||
containerWidth: number;
|
|
||||||
explorerWidth: number;
|
|
||||||
visibleWidth: number;
|
|
||||||
separatorCount: number;
|
|
||||||
occupiedWidth: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function splitSeparator(page: Page): Locator {
|
|
||||||
return page.getByRole("separator", { name: "" });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function openExplorerSplit(page: Page): Promise<void> {
|
|
||||||
await page.getByRole("button", { name: "Open side panel" }).click();
|
|
||||||
await expect(splitSeparator(page)).toHaveCount(1, { timeout: 30_000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function hideExplorerSplit(page: Page): Promise<void> {
|
|
||||||
await page.getByRole("button", { name: "Close side panel" }).click();
|
|
||||||
await expect(splitSeparator(page)).toHaveCount(0, { timeout: 30_000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resizeExplorerSplit(page: Page, deltaX: number): Promise<void> {
|
|
||||||
const separator = splitSeparator(page);
|
|
||||||
await expect(separator).toHaveCount(1);
|
|
||||||
const box = await separator.boundingBox();
|
|
||||||
expect(box).not.toBeNull();
|
|
||||||
if (!box) throw new Error("Split separator has no bounding box");
|
|
||||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
|
||||||
await page.mouse.down();
|
|
||||||
await page.mouse.move(box.x + box.width / 2 + deltaX, box.y + box.height / 2, { steps: 10 });
|
|
||||||
await page.mouse.up();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function stampExplorerNode(page: Page): Promise<void> {
|
|
||||||
await page
|
|
||||||
.getByTestId("split-group-child")
|
|
||||||
.last()
|
|
||||||
.evaluate((node) => {
|
|
||||||
Reflect.set(window, "__splitPaneExplorerNode", node);
|
|
||||||
node.setAttribute("data-split-pane-identity", "retained");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function expectExplorerNodeRetained(page: Page): Promise<void> {
|
|
||||||
const stampedNode = page.locator('[data-split-pane-identity="retained"]');
|
|
||||||
await expect(stampedNode).toHaveCount(1);
|
|
||||||
expect(
|
|
||||||
await stampedNode.evaluate((node) => Reflect.get(window, "__splitPaneExplorerNode") === node),
|
|
||||||
).toBe(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function measureExplorerSplit(page: Page): Promise<SplitPaneGeometry> {
|
|
||||||
const explorerChild = page.locator('[data-split-pane-identity="retained"]');
|
|
||||||
await expect(explorerChild).toHaveCount(1);
|
|
||||||
|
|
||||||
return explorerChild.evaluate((explorerNode) => {
|
|
||||||
const visibleNode = Array.from(explorerNode.parentElement?.children ?? [])
|
|
||||||
.filter(
|
|
||||||
(candidate) =>
|
|
||||||
candidate !== explorerNode &&
|
|
||||||
(candidate as HTMLElement).dataset.testid === "split-group-child",
|
|
||||||
)
|
|
||||||
.sort(
|
|
||||||
(left, right) => right.getBoundingClientRect().width - left.getBoundingClientRect().width,
|
|
||||||
)[0];
|
|
||||||
if (!(visibleNode instanceof HTMLElement)) throw new Error("Visible split child not found");
|
|
||||||
const visible = visibleNode.getBoundingClientRect();
|
|
||||||
const explorer = explorerNode.getBoundingClientRect();
|
|
||||||
const container = explorerNode.parentElement?.getBoundingClientRect();
|
|
||||||
if (!container) throw new Error("Split child has no container");
|
|
||||||
const separatorCount =
|
|
||||||
explorerNode.parentElement?.querySelectorAll('[role="separator"]').length ?? 0;
|
|
||||||
return {
|
|
||||||
containerWidth: container.width,
|
|
||||||
explorerWidth: explorer.width,
|
|
||||||
visibleWidth: visible.width,
|
|
||||||
separatorCount,
|
|
||||||
occupiedWidth: explorer.width + visible.width,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -293,9 +293,9 @@ export async function reloadAgentTimelineFromPersistedReplica(
|
|||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
const cache = await readReplicaCache(page);
|
const cache = await readReplicaCache(page);
|
||||||
const timeline = cache?.hosts?.find(
|
const timeline = cache?.hosts
|
||||||
(host) => host.timeline?.agentId === agent.agentId,
|
?.flatMap((host) => host.timelines)
|
||||||
)?.timeline;
|
.find((candidate) => candidate.agentId === agent.agentId);
|
||||||
return timeline?.items?.length === 50;
|
return timeline?.items?.length === 50;
|
||||||
})
|
})
|
||||||
.toBe(true);
|
.toBe(true);
|
||||||
@@ -311,7 +311,9 @@ export async function waitForPersistedCanonicalTimelineRange(
|
|||||||
const readRange = async () => {
|
const readRange = async () => {
|
||||||
const cache = await readReplicaCache(page);
|
const cache = await readReplicaCache(page);
|
||||||
if (cache?.version !== 6) return null;
|
if (cache?.version !== 6) return null;
|
||||||
const range = cache.hosts?.find((host) => host.timeline?.agentId === agentId)?.timeline?.range;
|
const range = cache.hosts
|
||||||
|
?.flatMap((host) => host.timelines)
|
||||||
|
.find((timeline) => timeline.agentId === agentId)?.range;
|
||||||
if (
|
if (
|
||||||
typeof range?.epoch !== "string" ||
|
typeof range?.epoch !== "string" ||
|
||||||
typeof range.startSeq !== "number" ||
|
typeof range.startSeq !== "number" ||
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ export async function expectFailedSetupTabSeededInMainPane(
|
|||||||
await expect(page.getByTestId(tabId).filter({ visible: true }).first()).toBeVisible({
|
await expect(page.getByTestId(tabId).filter({ visible: true }).first()).toBeVisible({
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
const panel = await ensureSidePanel(page);
|
const explorer = await ensureExplorerSidebar(page);
|
||||||
await expect(panel.getByTestId(tabId)).toHaveCount(0);
|
await expect(explorer.getByTestId(tabId)).toHaveCount(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function closeSetupTab(page: Page, workspaceId: string): Promise<void> {
|
export async function closeSetupTab(page: Page, workspaceId: string): Promise<void> {
|
||||||
@@ -57,8 +57,8 @@ function visibleTestId(page: Page, testId: string) {
|
|||||||
return page.getByTestId(testId).filter({ visible: true });
|
return page.getByTestId(testId).filter({ visible: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
function sidePanel(page: Page) {
|
function explorerSidebar(page: Page) {
|
||||||
return visibleTestId(page, "workspace-side-panel").first();
|
return visibleTestId(page, "workspace-explorer-sidebar").first();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectWorkspaceTab(tab: Locator): Promise<void> {
|
async function selectWorkspaceTab(tab: Locator): Promise<void> {
|
||||||
@@ -70,53 +70,54 @@ async function selectWorkspaceTab(tab: Locator): Promise<void> {
|
|||||||
await expect(tab).toHaveAttribute("aria-selected", "true");
|
await expect(tab).toHaveAttribute("aria-selected", "true");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reveal the Side panel. It opens empty; the caller decides what goes in it. */
|
/** Reveal the Explorer sidebar without changing its selected view. */
|
||||||
export async function ensureSidePanel(page: Page): Promise<Locator> {
|
export async function ensureExplorerSidebar(page: Page): Promise<Locator> {
|
||||||
const toggle = page.getByTestId("workspace-explorer-toggle").first();
|
const toggle = page.getByTestId("workspace-explorer-toggle").first();
|
||||||
await expect(toggle).toBeVisible({ timeout: 30_000 });
|
await expect(toggle).toBeVisible({ timeout: 30_000 });
|
||||||
const panel = sidePanel(page);
|
const explorer = explorerSidebar(page);
|
||||||
if ((await panel.count()) === 0) {
|
if ((await explorer.count()) === 0) {
|
||||||
await toggle.click();
|
await toggle.click();
|
||||||
}
|
}
|
||||||
await expect(panel).toBeVisible({ timeout: 30_000 });
|
await expect(explorer).toBeVisible({ timeout: 30_000 });
|
||||||
return panel;
|
return explorer;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Reveals the Explorer sidebar and selects one of its fixed navigation views. */
|
||||||
* Reveals the Side panel and brings one of its views up in it. Goes through the
|
async function openExplorerView(
|
||||||
* pane's own `+` menu, which is there whether the pane is empty or already loaded.
|
|
||||||
*/
|
|
||||||
async function openSidePanelView(
|
|
||||||
page: Page,
|
page: Page,
|
||||||
view: { tabTestId: string; launchItemId: string; contentTestId: string; timeout?: number },
|
view: { tabTestId: string; contentTestId: string; timeout?: number },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const panel = await ensureSidePanel(page);
|
const explorer = await ensureExplorerSidebar(page);
|
||||||
const tab = panel.getByTestId(view.tabTestId);
|
const tab = explorer.getByTestId(view.tabTestId);
|
||||||
if ((await tab.count()) === 0) {
|
await tab.click();
|
||||||
await panel.getByTestId("workspace-new-tab-button").click();
|
|
||||||
await page
|
|
||||||
.getByTestId(`workspace-new-tab-menu-${view.launchItemId}`)
|
|
||||||
.filter({ visible: true })
|
|
||||||
.click();
|
|
||||||
}
|
|
||||||
await selectWorkspaceTab(tab);
|
|
||||||
await expect(visibleTestId(page, view.contentTestId).first()).toBeVisible({
|
await expect(visibleTestId(page, view.contentTestId).first()).toBeVisible({
|
||||||
timeout: view.timeout ?? 30_000,
|
timeout: view.timeout ?? 30_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function openChangesTreePanel(page: Page): Promise<void> {
|
||||||
|
await openExplorerView(page, {
|
||||||
|
tabTestId: "explorer-sidebar-tab-changes_tree",
|
||||||
|
contentTestId: "changes-tree-panel",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function openChangesPanel(page: Page): Promise<void> {
|
export async function openChangesPanel(page: Page): Promise<void> {
|
||||||
await openSidePanelView(page, {
|
await openChangesTreePanel(page);
|
||||||
tabTestId: "workspace-tab-working_diff",
|
const changedFile = page
|
||||||
launchItemId: "changes",
|
.locator('[data-testid^="diff-tree-file-"][data-testid$="-toggle"]')
|
||||||
contentTestId: "working-diff-panel",
|
.filter({ visible: true })
|
||||||
|
.first();
|
||||||
|
await expect(changedFile).toBeVisible({ timeout: 30_000 });
|
||||||
|
await changedFile.click();
|
||||||
|
await expect(visibleTestId(page, "working-diff-panel").first()).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openFilesPanel(page: Page): Promise<void> {
|
export async function openFilesPanel(page: Page): Promise<void> {
|
||||||
await openSidePanelView(page, {
|
await openExplorerView(page, {
|
||||||
tabTestId: "workspace-tab-files",
|
tabTestId: "explorer-sidebar-tab-files",
|
||||||
launchItemId: "files",
|
|
||||||
contentTestId: "file-explorer-tree-scroll",
|
contentTestId: "file-explorer-tree-scroll",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -128,12 +129,10 @@ export async function openPullRequestPanel(page: Page): Promise<void> {
|
|||||||
await expect(visibleTestId(page, "pr-pane").first()).toBeVisible({ timeout: 15_000 });
|
await expect(visibleTestId(page, "pr-pane").first()).toBeVisible({ timeout: 15_000 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await openSidePanelView(page, {
|
const trigger = visibleTestId(page, "workspace-new-tab-button").first();
|
||||||
tabTestId: "workspace-tab-pull_request",
|
await trigger.click();
|
||||||
launchItemId: "pull-request",
|
await visibleTestId(page, "workspace-new-tab-menu-pull-request").first().click();
|
||||||
contentTestId: "pr-pane",
|
await expect(visibleTestId(page, "pr-pane").first()).toBeVisible({ timeout: 15_000 });
|
||||||
timeout: 15_000,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function waitForWorkspaceTabsVisible(page: Page): Promise<void> {
|
export async function waitForWorkspaceTabsVisible(page: Page): Promise<void> {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Pod::Spec.new do |s|
|
|||||||
s.version = '0.1.0'
|
s.version = '0.1.0'
|
||||||
s.summary = 'Hardware keyboard shortcuts for Paseo'
|
s.summary = 'Hardware keyboard shortcuts for Paseo'
|
||||||
s.description = 'Hardware keyboard shortcuts for Paseo'
|
s.description = 'Hardware keyboard shortcuts for Paseo'
|
||||||
s.license = 'AGPL-3.0-or-later'
|
s.license = 'Apache-2.0'
|
||||||
s.author = 'Paseo'
|
s.author = 'Paseo'
|
||||||
s.homepage = 'https://paseo.sh'
|
s.homepage = 'https://paseo.sh'
|
||||||
s.platforms = { :ios => '13.4' }
|
s.platforms = { :ios => '13.4' }
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.5.1",
|
"version": "0.7.0-beta.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -31,6 +31,7 @@
|
|||||||
"build:web": "npm --prefix ../.. run build:app-deps && expo export --platform web",
|
"build:web": "npm --prefix ../.. run build:app-deps && expo export --platform web",
|
||||||
"profile:composer-typing": "node ./scripts/profile-composer-typing.mjs",
|
"profile:composer-typing": "node ./scripts/profile-composer-typing.mjs",
|
||||||
"profile:explorer-toggle": "node ./scripts/profile-explorer-toggle.mjs",
|
"profile:explorer-toggle": "node ./scripts/profile-explorer-toggle.mjs",
|
||||||
|
"profile:side-pane": "playwright test --config playwright.profile.config.ts side-pane-performance.spec.ts",
|
||||||
"profile:workspace-switching": "node ./scripts/profile-workspace-switching.mjs",
|
"profile:workspace-switching": "node ./scripts/profile-workspace-switching.mjs",
|
||||||
"profile:workspace-tabs": "node ./scripts/profile-workspace-tabs.mjs",
|
"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",
|
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main",
|
||||||
|
|||||||
@@ -0,0 +1,388 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { expect, type Locator, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||||
|
|
||||||
|
export interface ProfileTarget {
|
||||||
|
serverId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenderSummary {
|
||||||
|
id: string;
|
||||||
|
renders: number;
|
||||||
|
mounts: number;
|
||||||
|
updates: number;
|
||||||
|
actualDurationMs: number;
|
||||||
|
maximumActualDurationMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InteractionMeasurement {
|
||||||
|
name: string;
|
||||||
|
latencyMs: number;
|
||||||
|
commits: number;
|
||||||
|
domMutations: number;
|
||||||
|
addedElements: number;
|
||||||
|
removedElements: number;
|
||||||
|
addedTestIds: string[];
|
||||||
|
removedTestIds: string[];
|
||||||
|
trackedNodes: Array<{
|
||||||
|
selector: string;
|
||||||
|
existedBefore: boolean;
|
||||||
|
connected: boolean;
|
||||||
|
sameNode: boolean;
|
||||||
|
}>;
|
||||||
|
renders: RenderSummary[];
|
||||||
|
reasons: Record<string, Record<string, number>>;
|
||||||
|
heapAfterGcBytes?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BrowserMeasurementState {
|
||||||
|
startedAt: number;
|
||||||
|
selectors: string[];
|
||||||
|
originals: Array<Element | null>;
|
||||||
|
observer?: MutationObserver;
|
||||||
|
domMutations: number;
|
||||||
|
addedElements: number;
|
||||||
|
removedElements: number;
|
||||||
|
addedTestIds: Set<string>;
|
||||||
|
removedTestIds: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__PASEO_SIDE_PANE_MEASUREMENT__?: BrowserMeasurementState;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCliJson<T>(output: string): T {
|
||||||
|
const arrayStart = output.indexOf("[\n");
|
||||||
|
const objectStart = output.indexOf("{\n");
|
||||||
|
const start = arrayStart >= 0 ? arrayStart : objectStart;
|
||||||
|
if (start < 0) throw new Error(`Could not find JSON in CLI output: ${output}`);
|
||||||
|
return JSON.parse(output.slice(start)) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveProfileTarget(): ProfileTarget {
|
||||||
|
const repoRoot = resolve(process.cwd(), "../..");
|
||||||
|
const serverId = (
|
||||||
|
process.env.PASEO_PROFILE_SERVER_ID ??
|
||||||
|
readFileSync(resolve(repoRoot, ".dev/paseo-home/server-id"), "utf8")
|
||||||
|
).trim();
|
||||||
|
const requestedName = process.env.PASEO_PROFILE_WORKSPACE_NAME?.trim() || "Paseo";
|
||||||
|
const requestedWorkspaceId = process.env.PASEO_PROFILE_WORKSPACE_ID?.trim();
|
||||||
|
if (requestedWorkspaceId) return { serverId, workspaceId: requestedWorkspaceId };
|
||||||
|
|
||||||
|
const output = execFileSync("npm", ["run", "cli", "--", "workspace", "ls", "--json"], {
|
||||||
|
cwd: repoRoot,
|
||||||
|
encoding: "utf8",
|
||||||
|
env: { ...process.env, FORCE_COLOR: "0" },
|
||||||
|
});
|
||||||
|
const workspaces =
|
||||||
|
parseCliJson<Array<{ workspaceId: string; name: string; cwd: string }>>(output);
|
||||||
|
const workspace = workspaces.find(
|
||||||
|
(candidate) => candidate.name === requestedName && candidate.cwd === repoRoot,
|
||||||
|
);
|
||||||
|
if (!workspace) {
|
||||||
|
throw new Error(`No Paseo workspace named ${requestedName} points at ${repoRoot}`);
|
||||||
|
}
|
||||||
|
return { serverId, workspaceId: workspace.workspaceId };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openProfileWorkspace(page: Page, target: ProfileTarget): Promise<void> {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
const preserveRenderProfile = (original: History["pushState"]) =>
|
||||||
|
function preserveProfileParam(
|
||||||
|
this: History,
|
||||||
|
state: unknown,
|
||||||
|
unused: string,
|
||||||
|
url?: string | URL | null,
|
||||||
|
) {
|
||||||
|
if (url == null) return Reflect.apply(original, this, [state, unused, url]);
|
||||||
|
const nextUrl = new URL(String(url), location.href);
|
||||||
|
nextUrl.searchParams.set("renderProfile", "1");
|
||||||
|
return Reflect.apply(original, this, [
|
||||||
|
state,
|
||||||
|
unused,
|
||||||
|
`${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`,
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
history.pushState = preserveRenderProfile(history.pushState);
|
||||||
|
history.replaceState = preserveRenderProfile(history.replaceState);
|
||||||
|
});
|
||||||
|
await page.goto("/?renderProfile=1", { waitUntil: "domcontentloaded" });
|
||||||
|
const workspaceRow = page.getByTestId(
|
||||||
|
`sidebar-workspace-row-${target.serverId}:${target.workspaceId}`,
|
||||||
|
);
|
||||||
|
await expect(workspaceRow).toBeVisible({ timeout: 60_000 });
|
||||||
|
await workspaceRow.click();
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("workspace-tabs-row").filter({ visible: true }).first(),
|
||||||
|
).toBeVisible({
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
await expect(page.getByTestId("workspace-explorer-toggle").first()).toBeVisible();
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/workspace/${target.workspaceId}\\?renderProfile=1$`));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enableChangesOpenInSidePane(page: Page): Promise<void> {
|
||||||
|
await page.evaluate((settingsKey) => {
|
||||||
|
const persisted = JSON.parse(localStorage.getItem(settingsKey) ?? "{}") as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
const currentPreferences =
|
||||||
|
typeof persisted.openInSidePane === "object" && persisted.openInSidePane !== null
|
||||||
|
? (persisted.openInSidePane as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
localStorage.setItem(
|
||||||
|
settingsKey,
|
||||||
|
JSON.stringify({
|
||||||
|
...persisted,
|
||||||
|
openInSidePane: { ...currentPreferences, explorerChanges: true },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, APP_SETTINGS_KEY);
|
||||||
|
await page.reload({ waitUntil: "domcontentloaded" });
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("workspace-tabs-row").filter({ visible: true }).first(),
|
||||||
|
).toBeVisible({
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openChangesExplorer(page: Page): Promise<void> {
|
||||||
|
const explorer = page.getByTestId("workspace-explorer-sidebar");
|
||||||
|
if ((await explorer.count()) === 0) {
|
||||||
|
await page.keyboard.press("Meta+e");
|
||||||
|
}
|
||||||
|
await expect(explorer).toBeVisible({ timeout: 30_000 });
|
||||||
|
const changesTab = explorer.getByRole("button", { name: /Working tree diff/i }).first();
|
||||||
|
await expect(changesTab).toBeVisible();
|
||||||
|
if ((await changesTab.getAttribute("aria-selected")) !== "true") {
|
||||||
|
await changesTab.click();
|
||||||
|
}
|
||||||
|
await expect(page.getByTestId("changes-tree-panel")).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(changedFileRows(page).nth(1)).toBeVisible({ timeout: 30_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function changedFileRows(page: Page): Locator {
|
||||||
|
return page
|
||||||
|
.locator('[data-testid^="diff-tree-file-"][data-testid$="-toggle"]')
|
||||||
|
.filter({ visible: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function selectChangedFile(page: Page, index: number): Promise<void> {
|
||||||
|
const row = changedFileRows(page).nth(index);
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
await row.click();
|
||||||
|
await expect(row).toHaveAttribute("aria-selected", "true");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectWorkingDiffVisible(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByTestId("working-diff-panel").filter({ visible: true })).toBeVisible({
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
await expect(page.getByTestId("git-diff-canvas").filter({ visible: true })).toBeVisible({
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeWorkingDiffPane(page: Page): Promise<void> {
|
||||||
|
const tab = page.getByTestId("workspace-tab-working_diff").filter({ visible: true }).first();
|
||||||
|
await expect(tab).toBeVisible();
|
||||||
|
await tab.hover();
|
||||||
|
await page
|
||||||
|
.locator('[data-testid^="workspace-working-diff-close-"]')
|
||||||
|
.filter({ visible: true })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
await expect(page.getByTestId("working-diff-panel").filter({ visible: true })).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function measureHeapAfterGc(page: Page): Promise<number> {
|
||||||
|
await page.requestGC();
|
||||||
|
return page.evaluate(() => {
|
||||||
|
const memory = performance as Performance & {
|
||||||
|
memory?: { usedJSHeapSize: number };
|
||||||
|
};
|
||||||
|
return memory.memory?.usedJSHeapSize ?? 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForReactIdle(page: Page, quietMs = 250): Promise<void> {
|
||||||
|
let previousCount = -1;
|
||||||
|
let unchangedSince = Date.now();
|
||||||
|
const deadline = Date.now() + 15_000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const count = await page.evaluate(() => globalThis.__PASEO_RENDER_PROFILE__?.length ?? 0);
|
||||||
|
if (count !== previousCount) {
|
||||||
|
previousCount = count;
|
||||||
|
unchangedSince = Date.now();
|
||||||
|
} else if (Date.now() - unchangedSince >= quietMs) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(25);
|
||||||
|
}
|
||||||
|
throw new Error("React profiler did not become idle");
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeRenders(
|
||||||
|
samples: Array<{
|
||||||
|
id: string;
|
||||||
|
phase: "mount" | "update" | "nested-update";
|
||||||
|
actualDuration: number;
|
||||||
|
}>,
|
||||||
|
): RenderSummary[] {
|
||||||
|
const summaries = new Map<string, RenderSummary>();
|
||||||
|
for (const sample of samples) {
|
||||||
|
const summary = summaries.get(sample.id) ?? {
|
||||||
|
id: sample.id,
|
||||||
|
renders: 0,
|
||||||
|
mounts: 0,
|
||||||
|
updates: 0,
|
||||||
|
actualDurationMs: 0,
|
||||||
|
maximumActualDurationMs: 0,
|
||||||
|
};
|
||||||
|
summary.renders += 1;
|
||||||
|
summary.mounts += sample.phase === "mount" ? 1 : 0;
|
||||||
|
summary.updates += sample.phase === "mount" ? 0 : 1;
|
||||||
|
summary.actualDurationMs += sample.actualDuration;
|
||||||
|
summary.maximumActualDurationMs = Math.max(
|
||||||
|
summary.maximumActualDurationMs,
|
||||||
|
sample.actualDuration,
|
||||||
|
);
|
||||||
|
summaries.set(sample.id, summary);
|
||||||
|
}
|
||||||
|
for (const summary of summaries.values()) {
|
||||||
|
summary.actualDurationMs = Math.round(summary.actualDurationMs * 100) / 100;
|
||||||
|
summary.maximumActualDurationMs = Math.round(summary.maximumActualDurationMs * 100) / 100;
|
||||||
|
}
|
||||||
|
return [...summaries.values()].sort(
|
||||||
|
(left, right) => right.actualDurationMs - left.actualDurationMs,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function beginMeasurement(page: Page, selectors: string[]): Promise<void> {
|
||||||
|
await waitForReactIdle(page);
|
||||||
|
await page.evaluate((trackedSelectors) => {
|
||||||
|
globalThis.__PASEO_RESET_RENDER_PROFILE__?.();
|
||||||
|
const collectTestIds = (node: Node, output: Set<string>) => {
|
||||||
|
if (!(node instanceof Element)) return;
|
||||||
|
const ownTestId = node.getAttribute("data-testid");
|
||||||
|
if (ownTestId) output.add(ownTestId);
|
||||||
|
for (const element of node.querySelectorAll("[data-testid]")) {
|
||||||
|
const testId = element.getAttribute("data-testid");
|
||||||
|
if (testId) output.add(testId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const state: BrowserMeasurementState = {
|
||||||
|
startedAt: performance.now(),
|
||||||
|
selectors: trackedSelectors,
|
||||||
|
originals: trackedSelectors.map((selector) => document.querySelector(selector)),
|
||||||
|
domMutations: 0,
|
||||||
|
addedElements: 0,
|
||||||
|
removedElements: 0,
|
||||||
|
addedTestIds: new Set(),
|
||||||
|
removedTestIds: new Set(),
|
||||||
|
};
|
||||||
|
state.observer = new MutationObserver((records) => {
|
||||||
|
state.domMutations += records.length;
|
||||||
|
for (const record of records) {
|
||||||
|
for (const node of record.addedNodes) {
|
||||||
|
state.addedElements +=
|
||||||
|
node instanceof Element ? 1 + node.querySelectorAll("*").length : 0;
|
||||||
|
collectTestIds(node, state.addedTestIds);
|
||||||
|
}
|
||||||
|
for (const node of record.removedNodes) {
|
||||||
|
state.removedElements +=
|
||||||
|
node instanceof Element ? 1 + node.querySelectorAll("*").length : 0;
|
||||||
|
collectTestIds(node, state.removedTestIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
state.observer.observe(document.body, { childList: true, subtree: true });
|
||||||
|
window.__PASEO_SIDE_PANE_MEASUREMENT__ = state;
|
||||||
|
}, selectors);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finishMeasurement(page: Page, name: string): Promise<InteractionMeasurement> {
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((resolveFrame) => {
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => resolveFrame()));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await waitForReactIdle(page);
|
||||||
|
const raw = await page.evaluate((measurementName) => {
|
||||||
|
const state = window.__PASEO_SIDE_PANE_MEASUREMENT__;
|
||||||
|
if (!state) throw new Error("Side-pane performance measurement was not started");
|
||||||
|
state.observer?.disconnect();
|
||||||
|
const samples = globalThis.__PASEO_RENDER_PROFILE__ ?? [];
|
||||||
|
return {
|
||||||
|
name: measurementName,
|
||||||
|
latencyMs: Math.round((performance.now() - state.startedAt) * 100) / 100,
|
||||||
|
commits: new Set(samples.map((sample) => sample.commitTime)).size,
|
||||||
|
domMutations: state.domMutations,
|
||||||
|
addedElements: state.addedElements,
|
||||||
|
removedElements: state.removedElements,
|
||||||
|
addedTestIds: [...state.addedTestIds].sort(),
|
||||||
|
removedTestIds: [...state.removedTestIds].sort(),
|
||||||
|
trackedNodes: state.selectors.map((selector, index) => {
|
||||||
|
const original = state.originals[index] ?? null;
|
||||||
|
const current = document.querySelector(selector);
|
||||||
|
return {
|
||||||
|
selector,
|
||||||
|
existedBefore: original !== null,
|
||||||
|
connected: original?.isConnected ?? false,
|
||||||
|
sameNode: original !== null && original === current,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
samples,
|
||||||
|
reasons: globalThis.__PASEO_RENDER_PROFILE_REASONS__ ?? {},
|
||||||
|
};
|
||||||
|
}, name);
|
||||||
|
return {
|
||||||
|
...raw,
|
||||||
|
renders: summarizeRenders(raw.samples),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function measureInteraction(
|
||||||
|
page: Page,
|
||||||
|
input: {
|
||||||
|
name: string;
|
||||||
|
trackedSelectors: string[];
|
||||||
|
interact: () => Promise<void>;
|
||||||
|
expectComplete: () => Promise<void>;
|
||||||
|
},
|
||||||
|
): Promise<InteractionMeasurement> {
|
||||||
|
await beginMeasurement(page, input.trackedSelectors);
|
||||||
|
await input.interact();
|
||||||
|
await input.expectComplete();
|
||||||
|
return finishMeasurement(page, input.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function expectTrackedNodePreserved(
|
||||||
|
measurement: InteractionMeasurement,
|
||||||
|
selector: string,
|
||||||
|
): void {
|
||||||
|
const tracked = measurement.trackedNodes.find((candidate) => candidate.selector === selector);
|
||||||
|
expect.soft(tracked).toEqual({
|
||||||
|
selector,
|
||||||
|
existedBefore: true,
|
||||||
|
connected: true,
|
||||||
|
sameNode: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function expectProfilerDidNotMount(
|
||||||
|
measurement: InteractionMeasurement,
|
||||||
|
idPrefix: string,
|
||||||
|
): void {
|
||||||
|
const summaries = measurement.renders.filter((summary) => summary.id.startsWith(idPrefix));
|
||||||
|
expect.soft(summaries.length).toBeGreaterThan(0);
|
||||||
|
expect.soft(summaries.every((summary) => summary.mounts === 0)).toBe(true);
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import {
|
||||||
|
changedFileRows,
|
||||||
|
closeWorkingDiffPane,
|
||||||
|
enableChangesOpenInSidePane,
|
||||||
|
expectProfilerDidNotMount,
|
||||||
|
expectTrackedNodePreserved,
|
||||||
|
expectWorkingDiffVisible,
|
||||||
|
measureInteraction,
|
||||||
|
measureHeapAfterGc,
|
||||||
|
openChangesExplorer,
|
||||||
|
openProfileWorkspace,
|
||||||
|
resolveProfileTarget,
|
||||||
|
selectChangedFile,
|
||||||
|
type InteractionMeasurement,
|
||||||
|
} from "./helpers/side-pane-performance";
|
||||||
|
|
||||||
|
const MAIN_PANE = '[data-testid="workspace-pane-main"]';
|
||||||
|
const WORKING_DIFF = '[data-testid="working-diff-panel"]';
|
||||||
|
const DIFF_CANVAS = '[data-testid="git-diff-canvas-root"]';
|
||||||
|
const DIFF_TAB = '[data-testid="workspace-tab-working_diff"]';
|
||||||
|
|
||||||
|
test("side-pane diff transitions preserve mounted work and report their real React cost", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
test.setTimeout(180_000);
|
||||||
|
const target = resolveProfileTarget();
|
||||||
|
const reports: InteractionMeasurement[] = [];
|
||||||
|
|
||||||
|
await test.step("open the real main workspace with Changes routed beside it", async () => {
|
||||||
|
await openProfileWorkspace(page, target);
|
||||||
|
await enableChangesOpenInSidePane(page);
|
||||||
|
await openChangesExplorer(page);
|
||||||
|
expect(await changedFileRows(page).count()).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("creating the side pane keeps the main pane mounted", async () => {
|
||||||
|
const report = await measureInteraction(page, {
|
||||||
|
name: "create-side-pane",
|
||||||
|
trackedSelectors: [MAIN_PANE],
|
||||||
|
interact: () => selectChangedFile(page, 0),
|
||||||
|
expectComplete: () => expectWorkingDiffVisible(page),
|
||||||
|
});
|
||||||
|
reports.push(report);
|
||||||
|
await testInfo.attach(report.name, {
|
||||||
|
body: JSON.stringify(report, null, 2),
|
||||||
|
contentType: "application/json",
|
||||||
|
});
|
||||||
|
expectTrackedNodePreserved(report, MAIN_PANE);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("changing the selected diff file updates the existing diff in place", async () => {
|
||||||
|
const report = await measureInteraction(page, {
|
||||||
|
name: "change-diff-focus",
|
||||||
|
trackedSelectors: [MAIN_PANE, WORKING_DIFF, DIFF_CANVAS, DIFF_TAB],
|
||||||
|
interact: () => selectChangedFile(page, 1),
|
||||||
|
expectComplete: () => expectWorkingDiffVisible(page),
|
||||||
|
});
|
||||||
|
reports.push(report);
|
||||||
|
await testInfo.attach(report.name, {
|
||||||
|
body: JSON.stringify(report, null, 2),
|
||||||
|
contentType: "application/json",
|
||||||
|
});
|
||||||
|
expectTrackedNodePreserved(report, MAIN_PANE);
|
||||||
|
expectTrackedNodePreserved(report, WORKING_DIFF);
|
||||||
|
expectTrackedNodePreserved(report, DIFF_CANVAS);
|
||||||
|
expectTrackedNodePreserved(report, DIFF_TAB);
|
||||||
|
expectProfilerDidNotMount(report, "WorkingDiffPanel:");
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("repeated cold reopenings measure retained workspace work", async () => {
|
||||||
|
for (let index = 0; index < 5; index += 1) {
|
||||||
|
await closeWorkingDiffPane(page);
|
||||||
|
const report = await measureInteraction(page, {
|
||||||
|
name: `reopen-side-pane-${index + 1}`,
|
||||||
|
trackedSelectors: [MAIN_PANE],
|
||||||
|
interact: () => selectChangedFile(page, index % 2),
|
||||||
|
expectComplete: () => expectWorkingDiffVisible(page),
|
||||||
|
});
|
||||||
|
report.heapAfterGcBytes = await measureHeapAfterGc(page);
|
||||||
|
reports.push(report);
|
||||||
|
await testInfo.attach(report.name, {
|
||||||
|
body: JSON.stringify(report, null, 2),
|
||||||
|
contentType: "application/json",
|
||||||
|
});
|
||||||
|
expectTrackedNodePreserved(report, MAIN_PANE);
|
||||||
|
expect(
|
||||||
|
report.renders.some(
|
||||||
|
(summary) => summary.id.startsWith("WorkingDiffPanel:") && summary.mounts > 0,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const artifact = {
|
||||||
|
appUrl: testInfo.project.use.baseURL,
|
||||||
|
target,
|
||||||
|
reports,
|
||||||
|
};
|
||||||
|
await testInfo.attach("side-pane-performance", {
|
||||||
|
body: JSON.stringify(artifact, null, 2),
|
||||||
|
contentType: "application/json",
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
`[perf] Side pane: ${JSON.stringify(
|
||||||
|
reports.map((report) => ({
|
||||||
|
name: report.name,
|
||||||
|
latencyMs: report.latencyMs,
|
||||||
|
commits: report.commits,
|
||||||
|
domMutations: report.domMutations,
|
||||||
|
addedElements: report.addedElements,
|
||||||
|
removedElements: report.removedElements,
|
||||||
|
heapAfterGcBytes: report.heapAfterGcBytes,
|
||||||
|
trackedNodes: report.trackedNodes,
|
||||||
|
renders: report.renders.slice(0, 8),
|
||||||
|
})),
|
||||||
|
)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -6,6 +6,12 @@ const baseURL =
|
|||||||
process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
|
process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
|
||||||
const relayDeploymentSpec = "**/relay-deployment-reconnect.real.spec.ts";
|
const relayDeploymentSpec = "**/relay-deployment-reconnect.real.spec.ts";
|
||||||
|
|
||||||
|
function videoMode(): "on" | "on-first-retry" | "retain-on-failure" {
|
||||||
|
if (process.env.E2E_RECORD_VIDEO === "1") return "on";
|
||||||
|
if (process.env.CI) return "on-first-retry";
|
||||||
|
return "retain-on-failure";
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./e2e/browser",
|
testDir: "./e2e/browser",
|
||||||
globalSetup: "./e2e/support/global-setup.ts",
|
globalSetup: "./e2e/support/global-setup.ts",
|
||||||
@@ -21,9 +27,9 @@ export default defineConfig({
|
|||||||
reporter: [["list"]],
|
reporter: [["list"]],
|
||||||
use: {
|
use: {
|
||||||
baseURL,
|
baseURL,
|
||||||
trace: "retain-on-failure",
|
trace: process.env.CI ? "on-first-retry" : "retain-on-failure",
|
||||||
screenshot: "only-on-failure",
|
screenshot: "only-on-failure",
|
||||||
video: process.env.E2E_RECORD_VIDEO === "1" ? "on" : "retain-on-failure",
|
video: videoMode(),
|
||||||
},
|
},
|
||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./perf",
|
||||||
|
timeout: 120_000,
|
||||||
|
expect: { timeout: 30_000 },
|
||||||
|
fullyParallel: false,
|
||||||
|
workers: 1,
|
||||||
|
retries: 0,
|
||||||
|
reporter: [["list"]],
|
||||||
|
use: {
|
||||||
|
...devices["Desktop Chrome"],
|
||||||
|
baseURL: process.env.PASEO_PROFILE_APP_URL ?? "http://127.0.0.1:8081",
|
||||||
|
viewport: { width: 1440, height: 900 },
|
||||||
|
trace: "retain-on-failure",
|
||||||
|
screenshot: "only-on-failure",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -27,7 +27,7 @@ import { useMutation } from "@tanstack/react-query";
|
|||||||
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
||||||
import { Check, ChevronDown, X } from "lucide-react-native";
|
import { Check, ChevronDown, X } from "lucide-react-native";
|
||||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||||
import { openSidePanelView } from "@/workspace-tabs/side-panel";
|
import { openExplorerSidebarView } from "@/workspace-tabs/explorer-sidebar";
|
||||||
import {
|
import {
|
||||||
AssistantMessage,
|
AssistantMessage,
|
||||||
SpeakMessage,
|
SpeakMessage,
|
||||||
@@ -107,6 +107,7 @@ import type { Theme } from "@/styles/theme";
|
|||||||
import { recordRenderProfileReasons } from "@/utils/render-profiler";
|
import { recordRenderProfileReasons } from "@/utils/render-profiler";
|
||||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||||
import { useStreamHistoryWindow } from "./use-stream-history-window";
|
import { useStreamHistoryWindow } from "./use-stream-history-window";
|
||||||
|
import { PluginTimelineItemView } from "@/plugins/timeline";
|
||||||
|
|
||||||
function renderLiveAuxiliaryNode(input: {
|
function renderLiveAuxiliaryNode(input: {
|
||||||
pendingPermissions: ReactNode;
|
pendingPermissions: ReactNode;
|
||||||
@@ -458,7 +459,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
setCurrentPath: false,
|
setCurrentPath: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
openSidePanelView({
|
openExplorerSidebarView({
|
||||||
isCompact: isMobile,
|
isCompact: isMobile,
|
||||||
workspaceKey: buildWorkspaceTabPersistenceKey({
|
workspaceKey: buildWorkspaceTabPersistenceKey({
|
||||||
serverId: resolvedServerId,
|
serverId: resolvedServerId,
|
||||||
@@ -475,7 +476,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleToolCallOpenFile = useStableEvent((filePath: string) => {
|
const handleToolCallOpenFile = useStableEvent((filePath: string) => {
|
||||||
handleInlinePathPress({ raw: filePath, path: filePath }, "side");
|
handleInlinePathPress({ raw: filePath, path: filePath }, "preferred");
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleForkAssistantTurn: AssistantTurnForkHandler = useStableEvent(
|
const handleForkAssistantTurn: AssistantTurnForkHandler = useStableEvent(
|
||||||
@@ -862,11 +863,23 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case "plugin":
|
||||||
|
return (
|
||||||
|
<PluginTimelineItemView agentId={agentId} item={item} serverId={resolvedServerId} />
|
||||||
|
);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[renderUserMessageItem, renderAssistantMessageItem, renderThoughtItem, renderToolCallItem],
|
[
|
||||||
|
agentId,
|
||||||
|
renderUserMessageItem,
|
||||||
|
renderAssistantMessageItem,
|
||||||
|
renderThoughtItem,
|
||||||
|
renderToolCallItem,
|
||||||
|
resolvedServerId,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const bottomTurnFooterHost = streamLayout.auxiliaryTurnFooter;
|
const bottomTurnFooterHost = streamLayout.auxiliaryTurnFooter;
|
||||||
|
|||||||
@@ -36,8 +36,10 @@ import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog"
|
|||||||
import { AppDiagnosticHost } from "@/components/app-diagnostic-host";
|
import { AppDiagnosticHost } from "@/components/app-diagnostic-host";
|
||||||
import { LeftSidebar } from "@/components/left-sidebar";
|
import { LeftSidebar } from "@/components/left-sidebar";
|
||||||
import { WindowSidebarMenuToggle } from "@/components/headers/menu-header";
|
import { WindowSidebarMenuToggle } from "@/components/headers/menu-header";
|
||||||
|
import { DesktopWindowControls } from "@/components/desktop/window-controls";
|
||||||
import { SidebarModelProvider } from "@/components/sidebar/sidebar-model";
|
import { SidebarModelProvider } from "@/components/sidebar/sidebar-model";
|
||||||
import { WorkspacePinShortcutHandler } from "@/components/workspace-pin-shortcut-handler";
|
import { WorkspacePinShortcutHandler } from "@/components/workspace-pin-shortcut-handler";
|
||||||
|
import { WorkspaceRenameHost } from "@/components/workspace-rename-host";
|
||||||
import { CompactExplorerSidebarHost } from "@/components/compact-explorer-sidebar-host";
|
import { CompactExplorerSidebarHost } from "@/components/compact-explorer-sidebar-host";
|
||||||
import { ProviderSettingsHost } from "@/components/provider-settings-host";
|
import { ProviderSettingsHost } from "@/components/provider-settings-host";
|
||||||
import { RootErrorBoundary } from "@/components/root-error-boundary";
|
import { RootErrorBoundary } from "@/components/root-error-boundary";
|
||||||
@@ -76,7 +78,7 @@ import { AgentNavigationListener } from "@/desktop/agent-navigation";
|
|||||||
import { LegacyAgentSkillsMigration } from "@/agent-skills/legacy-migration";
|
import { LegacyAgentSkillsMigration } from "@/agent-skills/legacy-migration";
|
||||||
import { legacyFavoriteProfileMigration } from "@/agent-profiles/migration";
|
import { legacyFavoriteProfileMigration } from "@/agent-profiles/migration";
|
||||||
import { listenToDesktopEvent } from "@/desktop/electron/events";
|
import { listenToDesktopEvent } from "@/desktop/electron/events";
|
||||||
import { updateDesktopWindowControls } from "@/desktop/electron/window";
|
import { updateDesktopWindowChrome } from "@/desktop/electron/window";
|
||||||
import { getDesktopHost } from "@/desktop/host";
|
import { getDesktopHost } from "@/desktop/host";
|
||||||
import { loadDesktopSettings } from "@/desktop/settings/desktop-settings";
|
import { loadDesktopSettings } from "@/desktop/settings/desktop-settings";
|
||||||
import { RosettaCalloutSource } from "@/desktop/updates/rosetta-callout-source";
|
import { RosettaCalloutSource } from "@/desktop/updates/rosetta-callout-source";
|
||||||
@@ -86,6 +88,7 @@ import { useGlobalNewWorkspaceAction } from "@/hooks/use-global-new-workspace-ac
|
|||||||
import { useLatchedBoolean } from "@/hooks/use-latched-boolean";
|
import { useLatchedBoolean } from "@/hooks/use-latched-boolean";
|
||||||
import { useFaviconStatus } from "@/hooks/use-favicon-status";
|
import { useFaviconStatus } from "@/hooks/use-favicon-status";
|
||||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||||
|
import { resolveExplorerSidebarPresentation } from "@/workspace-tabs/explorer-sidebar";
|
||||||
import { KeyboardShiftProvider } from "@/hooks/use-keyboard-shift-style";
|
import { KeyboardShiftProvider } from "@/hooks/use-keyboard-shift-style";
|
||||||
import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-zoom-lock";
|
import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-zoom-lock";
|
||||||
import { useOpenProject } from "@/hooks/use-open-project";
|
import { useOpenProject } from "@/hooks/use-open-project";
|
||||||
@@ -473,6 +476,10 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
|||||||
}, [settings.theme, updateSettings]);
|
}, [settings.theme, updateSettings]);
|
||||||
|
|
||||||
const isCompactLayout = useIsCompactFormFactor();
|
const isCompactLayout = useIsCompactFormFactor();
|
||||||
|
const explorerSidebarPresentation = resolveExplorerSidebarPresentation({
|
||||||
|
isCompact: isCompactLayout,
|
||||||
|
});
|
||||||
|
const usesCompactExplorerHost = explorerSidebarPresentation !== "pane";
|
||||||
useCompactWebViewportZoomLock(isCompactLayout);
|
useCompactWebViewportZoomLock(isCompactLayout);
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const isWorkspaceRoute = parseHostWorkspaceRouteFromPathname(pathname) !== null;
|
const isWorkspaceRoute = parseHostWorkspaceRouteFromPathname(pathname) !== null;
|
||||||
@@ -545,8 +552,11 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
|||||||
{sidebarChrome}
|
{sidebarChrome}
|
||||||
</WindowChromeRegion>
|
</WindowChromeRegion>
|
||||||
) : null}
|
) : null}
|
||||||
{isCompactLayout ? (
|
{usesCompactExplorerHost ? (
|
||||||
<CompactExplorerSidebarHost enabled={chromeEnabled}>
|
<CompactExplorerSidebarHost
|
||||||
|
enabled={chromeEnabled}
|
||||||
|
presentation={explorerSidebarPresentation === "dock" ? "dock" : "overlay"}
|
||||||
|
>
|
||||||
<WindowChromeRegion corners={chromeEnabled ? "both" : appChromeLayout.contentCorners}>
|
<WindowChromeRegion corners={chromeEnabled ? "both" : appChromeLayout.contentCorners}>
|
||||||
<View style={flexStyle}>{children}</View>
|
<View style={flexStyle}>{children}</View>
|
||||||
</WindowChromeRegion>
|
</WindowChromeRegion>
|
||||||
@@ -574,6 +584,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
|||||||
</WindowChromeSafeArea>
|
</WindowChromeSafeArea>
|
||||||
</WindowChromeRegion>
|
</WindowChromeRegion>
|
||||||
) : null}
|
) : null}
|
||||||
|
<DesktopWindowControls />
|
||||||
<FloatingPanelPortalHost />
|
<FloatingPanelPortalHost />
|
||||||
{isCompactLayout ? sidebarChrome : null}
|
{isCompactLayout ? sidebarChrome : null}
|
||||||
<DownloadToast />
|
<DownloadToast />
|
||||||
@@ -585,6 +596,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
|||||||
<CommandCenterWorkspaceActions />
|
<CommandCenterWorkspaceActions />
|
||||||
<PluginCommandCenterActions />
|
<PluginCommandCenterActions />
|
||||||
<WorkspacePinShortcutHandler />
|
<WorkspacePinShortcutHandler />
|
||||||
|
<WorkspaceRenameHost />
|
||||||
<CommandCenter />
|
<CommandCenter />
|
||||||
<AddProjectFlowHost />
|
<AddProjectFlowHost />
|
||||||
<HostChooserModal />
|
<HostChooserModal />
|
||||||
@@ -665,18 +677,16 @@ function DesktopWindowControlsSync() {
|
|||||||
const { isLoading } = useAppSettings();
|
const { isLoading } = useAppSettings();
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const surface0 = theme.colors.surface0;
|
const surface0 = theme.colors.surface0;
|
||||||
const foreground = theme.colors.foreground;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoading || isNative) return;
|
if (isLoading || isNative) return;
|
||||||
void updateDesktopWindowControls({
|
void updateDesktopWindowChrome({
|
||||||
backgroundColor: surface0,
|
backgroundColor: surface0,
|
||||||
foregroundColor: foreground,
|
trafficLightOffsetY: -4,
|
||||||
trafficLightOffsetY: -5,
|
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.warn("[DesktopWindow] Failed to update window controls overlay", error);
|
console.warn("[DesktopWindow] Failed to update window controls overlay", error);
|
||||||
});
|
});
|
||||||
}, [isLoading, surface0, foreground]);
|
}, [isLoading, surface0]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -953,18 +963,9 @@ function RootProviders({ children }: { children: ReactNode }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function recordUserActivity(): void {
|
|
||||||
getHostRuntimeStore().recordUserActivity();
|
|
||||||
}
|
|
||||||
|
|
||||||
function RootAppTree() {
|
function RootAppTree() {
|
||||||
return (
|
return (
|
||||||
<GestureHandlerRootView
|
<GestureHandlerRootView style={flexStyle}>
|
||||||
style={flexStyle}
|
|
||||||
onTouchStart={recordUserActivity}
|
|
||||||
onTouchEnd={recordUserActivity}
|
|
||||||
onTouchCancel={recordUserActivity}
|
|
||||||
>
|
|
||||||
<View style={layoutStyles.surfaceFill}>
|
<View style={layoutStyles.surfaceFill}>
|
||||||
<RootProviders>
|
<RootProviders>
|
||||||
<RuntimeProviders>
|
<RuntimeProviders>
|
||||||
|
|||||||
@@ -38,6 +38,12 @@ function HostAgentReadyRouteContent() {
|
|||||||
}
|
}
|
||||||
return state.sessions[serverId]?.agents?.get(agentId)?.workspaceId ?? null;
|
return state.sessions[serverId]?.agents?.get(agentId)?.workspaceId ?? null;
|
||||||
});
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
if (!serverId || !agentId || agentWorkspaceId) return;
|
||||||
|
void getHostRuntimeStore()
|
||||||
|
.prepareAgentRoute(serverId, agentId)
|
||||||
|
.catch(() => undefined);
|
||||||
|
}, [agentId, agentWorkspaceId, serverId]);
|
||||||
const shouldLookupAgent = Boolean(
|
const shouldLookupAgent = Boolean(
|
||||||
serverId && agentId && client && connectionStatus === "online" && !agentWorkspaceId,
|
serverId && agentId && client && connectionStatus === "online" && !agentWorkspaceId,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ describe("useFileLink", () => {
|
|||||||
lineStart: undefined,
|
lineStart: undefined,
|
||||||
lineEnd: undefined,
|
lineEnd: undefined,
|
||||||
},
|
},
|
||||||
disposition: "side",
|
disposition: "preferred",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult
|
|||||||
});
|
});
|
||||||
|
|
||||||
const onPress = useStableEvent(() => {
|
const onPress = useStableEvent(() => {
|
||||||
open(stableSource, "side");
|
open(stableSource, "preferred");
|
||||||
});
|
});
|
||||||
|
|
||||||
const target = useMemo(() => {
|
const target = useMemo(() => {
|
||||||
|
|||||||
@@ -56,10 +56,13 @@ import { navigateToAgent } from "@/utils/navigate-to-agent";
|
|||||||
import { formatTimeAgo } from "@/utils/time";
|
import { formatTimeAgo } from "@/utils/time";
|
||||||
import { shortenPath } from "@/utils/shorten-path";
|
import { shortenPath } from "@/utils/shorten-path";
|
||||||
import { useCommandCenterContributions } from "./provider";
|
import { useCommandCenterContributions } from "./provider";
|
||||||
|
import { filterAndRankWorkspaces } from "./workspace-search";
|
||||||
import {
|
import {
|
||||||
buildContributionSections,
|
buildContributionSections,
|
||||||
|
filterAndRankBuiltInResults,
|
||||||
joinSubtitleParts,
|
joinSubtitleParts,
|
||||||
moveActiveResultId,
|
moveActiveResultId,
|
||||||
|
PINNED_SECTION_BAND,
|
||||||
preserveActiveResultId,
|
preserveActiveResultId,
|
||||||
projectCommandCenterRows,
|
projectCommandCenterRows,
|
||||||
type CommandCenterAgentResult,
|
type CommandCenterAgentResult,
|
||||||
@@ -67,6 +70,7 @@ import {
|
|||||||
type CommandCenterListRow,
|
type CommandCenterListRow,
|
||||||
type CommandCenterResult,
|
type CommandCenterResult,
|
||||||
type CommandCenterResultSection,
|
type CommandCenterResultSection,
|
||||||
|
type CommandCenterSearchFields,
|
||||||
type CommandCenterWorkspaceResult,
|
type CommandCenterWorkspaceResult,
|
||||||
} from "./results";
|
} from "./results";
|
||||||
import { useWorkspaceFileSearch } from "./workspace-file-search";
|
import { useWorkspaceFileSearch } from "./workspace-file-search";
|
||||||
@@ -88,7 +92,6 @@ const ThemedLoadingSpinner = withUnistyles(LoadingSpinner, (theme) => ({
|
|||||||
}));
|
}));
|
||||||
const COMMAND_CENTER_SNAP_POINTS = ["60%", "90%"];
|
const COMMAND_CENTER_SNAP_POINTS = ["60%", "90%"];
|
||||||
const KEYBOARD_SHOULD_PERSIST_TAPS = "always" as const;
|
const KEYBOARD_SHOULD_PERSIST_TAPS = "always" as const;
|
||||||
const DEFAULT_CATEGORY_RESULT_LIMIT = 5;
|
|
||||||
|
|
||||||
function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number {
|
function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number {
|
||||||
const leftNeedsInput = (left.pendingPermissionCount ?? 0) > 0 ? 1 : 0;
|
const leftNeedsInput = (left.pendingPermissionCount ?? 0) > 0 ? 1 : 0;
|
||||||
@@ -103,41 +106,56 @@ function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number {
|
|||||||
return right.lastActivityAt.getTime() - left.lastActivityAt.getTime();
|
return right.lastActivityAt.getTime() - left.lastActivityAt.getTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
function matchesQuery(searchText: string, query: string): boolean {
|
function compareWorkspacesByTitle(
|
||||||
const normalized = query.trim().toLowerCase();
|
left: CommandCenterWorkspaceResult,
|
||||||
return !normalized || searchText.includes(normalized);
|
right: CommandCenterWorkspaceResult,
|
||||||
|
): number {
|
||||||
|
const titleDelta = left.title.localeCompare(right.title, undefined, {
|
||||||
|
numeric: true,
|
||||||
|
sensitivity: "base",
|
||||||
|
});
|
||||||
|
return titleDelta || left.subtitle.localeCompare(right.subtitle);
|
||||||
}
|
}
|
||||||
|
|
||||||
function limitDefaultCategoryResults<Result>(results: Result[], query: string): Result[] {
|
/** `cwd` is not rendered, so a path match must never outrank a match on text the user can see. */
|
||||||
return query.trim() ? results : results.slice(0, DEFAULT_CATEGORY_RESULT_LIMIT);
|
function agentSearchFields(result: CommandCenterAgentResult): CommandCenterSearchFields {
|
||||||
|
return { visible: [result.title, result.subtitle], hidden: [result.agent.cwd] };
|
||||||
}
|
}
|
||||||
|
|
||||||
function useBuiltInSections(open: boolean, query: string): CommandCenterResultSection[] {
|
/**
|
||||||
|
* Build every pinned row, in its default order. Deliberately not keyed on `query`: none of this
|
||||||
|
* work depends on what was typed, and rebuilding it per keystroke means re-running an Intl
|
||||||
|
* collation sort over every workspace.
|
||||||
|
*
|
||||||
|
* The cost is that `formatTimeAgo` is baked into the agent subtitle here, so relative timestamps
|
||||||
|
* now refresh when agents or projects change rather than on every keystroke.
|
||||||
|
*/
|
||||||
|
function useBuiltInRows(open: boolean): {
|
||||||
|
workspaces: CommandCenterWorkspaceResult[];
|
||||||
|
agents: CommandCenterAgentResult[];
|
||||||
|
} {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { agents } = useAggregatedAgents();
|
const { agents } = useAggregatedAgents({ demand: open });
|
||||||
const { projects } = useProjects({ enabled: open });
|
const { projects } = useProjects({ enabled: open });
|
||||||
const showHost = useHosts().length > 1;
|
const showHost = useHosts().length > 1;
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
if (!open) return [];
|
if (!open) return { workspaces: [], agents: [] };
|
||||||
const allWorkspaces: CommandCenterWorkspaceResult[] = [];
|
const allWorkspaces: CommandCenterWorkspaceResult[] = [];
|
||||||
for (const project of projects) {
|
for (const project of projects) {
|
||||||
for (const host of project.hosts) {
|
for (const host of project.hosts) {
|
||||||
for (const workspace of host.workspaces) {
|
for (const workspace of host.workspaces) {
|
||||||
if (workspace.archivingAt) continue;
|
if (workspace.archivingAt) continue;
|
||||||
const title = workspace.title ?? workspace.name;
|
|
||||||
const subtitle = joinSubtitleParts([
|
|
||||||
showHost ? host.serverName : null,
|
|
||||||
project.projectName,
|
|
||||||
workspace.currentBranch,
|
|
||||||
]);
|
|
||||||
const searchText = `${title} ${subtitle}`.toLowerCase();
|
|
||||||
allWorkspaces.push({
|
allWorkspaces.push({
|
||||||
kind: "workspace",
|
kind: "workspace",
|
||||||
id: `workspace:${host.serverId}:${workspace.id}`,
|
id: `workspace:${host.serverId}:${workspace.id}`,
|
||||||
title,
|
title: workspace.title ?? workspace.name,
|
||||||
subtitle,
|
subtitle: joinSubtitleParts([
|
||||||
searchText,
|
showHost ? host.serverName : null,
|
||||||
|
project.projectName,
|
||||||
|
workspace.currentBranch,
|
||||||
|
]),
|
||||||
|
changeRequestNumber: workspace.changeRequestNumber,
|
||||||
run: () => {
|
run: () => {
|
||||||
clearCommandCenterFocusRestoreElement();
|
clearCommandCenterFocusRestoreElement();
|
||||||
navigateToWorkspace({ serverId: host.serverId, workspaceId: workspace.id });
|
navigateToWorkspace({ serverId: host.serverId, workspaceId: workspace.id });
|
||||||
@@ -146,60 +164,61 @@ function useBuiltInSections(open: boolean, query: string): CommandCenterResultSe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
allWorkspaces.sort((left, right) => {
|
allWorkspaces.sort(compareWorkspacesByTitle);
|
||||||
const titleDelta = left.title.localeCompare(right.title, undefined, {
|
|
||||||
numeric: true,
|
|
||||||
sensitivity: "base",
|
|
||||||
});
|
|
||||||
return titleDelta || left.subtitle.localeCompare(right.subtitle);
|
|
||||||
});
|
|
||||||
const workspaceTitleByKey = new Map(
|
const workspaceTitleByKey = new Map(
|
||||||
allWorkspaces.map((workspace) => [workspace.id.slice("workspace:".length), workspace.title]),
|
allWorkspaces.map((workspace) => [workspace.id.slice("workspace:".length), workspace.title]),
|
||||||
);
|
);
|
||||||
const workspaces = limitDefaultCategoryResults(
|
const agentRows = agents
|
||||||
allWorkspaces.filter((workspace) => matchesQuery(workspace.searchText, query)),
|
.map<CommandCenterAgentResult>((agent) => {
|
||||||
query,
|
const workspaceTitle = agent.workspaceId
|
||||||
);
|
? workspaceTitleByKey.get(`${agent.serverId}:${agent.workspaceId}`)
|
||||||
const agentResults = limitDefaultCategoryResults(
|
: undefined;
|
||||||
agents
|
return {
|
||||||
.map<CommandCenterAgentResult>((agent) => {
|
kind: "agent",
|
||||||
const title = agent.title || t("shell.commandCenter.newAgent");
|
id: `agent:${agent.serverId}:${agent.id}`,
|
||||||
const workspaceTitle = agent.workspaceId
|
agent,
|
||||||
? workspaceTitleByKey.get(`${agent.serverId}:${agent.workspaceId}`)
|
title: agent.title || t("shell.commandCenter.newAgent"),
|
||||||
: undefined;
|
subtitle: joinSubtitleParts([
|
||||||
const location = workspaceTitle ?? shortenPath(agent.cwd);
|
|
||||||
const subtitle = joinSubtitleParts([
|
|
||||||
showHost ? agent.serverLabel : null,
|
showHost ? agent.serverLabel : null,
|
||||||
location,
|
workspaceTitle ?? shortenPath(agent.cwd),
|
||||||
formatTimeAgo(agent.lastActivityAt),
|
formatTimeAgo(agent.lastActivityAt),
|
||||||
]);
|
]),
|
||||||
return {
|
run: () => {
|
||||||
kind: "agent",
|
clearCommandCenterFocusRestoreElement();
|
||||||
id: `agent:${agent.serverId}:${agent.id}`,
|
navigateToAgent({ serverId: agent.serverId, agentId: agent.id });
|
||||||
agent,
|
},
|
||||||
title,
|
};
|
||||||
subtitle,
|
})
|
||||||
searchText: `${title} ${subtitle} ${agent.cwd}`.toLowerCase(),
|
.sort((left, right) => sortAgents(left.agent, right.agent));
|
||||||
run: () => {
|
return { workspaces: allWorkspaces, agents: agentRows };
|
||||||
clearCommandCenterFocusRestoreElement();
|
}, [agents, open, projects, showHost, t]);
|
||||||
navigateToAgent({ serverId: agent.serverId, agentId: agent.id });
|
}
|
||||||
},
|
|
||||||
};
|
function useBuiltInSections(open: boolean, query: string): CommandCenterResultSection[] {
|
||||||
})
|
const { t } = useTranslation();
|
||||||
.filter((agent) => matchesQuery(agent.searchText, query))
|
const rows = useBuiltInRows(open);
|
||||||
.sort((left, right) => sortAgents(left.agent, right.agent)),
|
|
||||||
query,
|
return useMemo(() => {
|
||||||
);
|
if (!open) return [];
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: "workspaces",
|
id: "workspaces",
|
||||||
|
band: PINNED_SECTION_BAND,
|
||||||
rank: 2,
|
rank: 2,
|
||||||
title: t("shell.commandCenter.workspaces"),
|
title: t("shell.commandCenter.workspaces"),
|
||||||
results: workspaces,
|
results: filterAndRankWorkspaces(rows.workspaces, query, compareWorkspacesByTitle),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "agents",
|
||||||
|
band: PINNED_SECTION_BAND,
|
||||||
|
rank: 3,
|
||||||
|
title: t("shell.commandCenter.agents"),
|
||||||
|
results: filterAndRankBuiltInResults(rows.agents, query, agentSearchFields, (left, right) =>
|
||||||
|
sortAgents(left.agent, right.agent),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{ id: "agents", rank: 3, title: t("shell.commandCenter.agents"), results: agentResults },
|
|
||||||
];
|
];
|
||||||
}, [agents, open, projects, query, showHost, t]);
|
}, [open, query, rows, t]);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CommandCenterState {
|
interface CommandCenterState {
|
||||||
@@ -231,7 +250,7 @@ function useCommandCenterState(): CommandCenterState {
|
|||||||
const snapshot = useCommandCenterContributions();
|
const snapshot = useCommandCenterContributions();
|
||||||
const inputRef = useRef<EditingTextInputHandle>(null);
|
const inputRef = useRef<EditingTextInputHandle>(null);
|
||||||
const previousOpenRef = useRef(open);
|
const previousOpenRef = useRef(open);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQueryState] = useState("");
|
||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
const builtInSections = useBuiltInSections(open, query);
|
const builtInSections = useBuiltInSections(open, query);
|
||||||
const {
|
const {
|
||||||
@@ -250,10 +269,20 @@ function useCommandCenterState(): CommandCenterState {
|
|||||||
filePath: entry.path,
|
filePath: entry.path,
|
||||||
title: entry.name,
|
title: entry.name,
|
||||||
subtitle: entry.directory,
|
subtitle: entry.directory,
|
||||||
searchText: entry.path.toLowerCase(),
|
|
||||||
run: () => openFile(entry.path),
|
run: () => openFile(entry.path),
|
||||||
}));
|
}));
|
||||||
return [{ id: "files", rank: 4, title: t("shell.commandCenter.files"), results }];
|
// File rows are matched by the daemon, so they carry no client-side score and cannot be
|
||||||
|
// relevance-compared against the rows that do. Pinning them keeps the section comparator
|
||||||
|
// total: band is decided before any score is read.
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "files",
|
||||||
|
band: PINNED_SECTION_BAND,
|
||||||
|
rank: 4,
|
||||||
|
title: t("shell.commandCenter.files"),
|
||||||
|
results,
|
||||||
|
},
|
||||||
|
];
|
||||||
}, [fileSearchEntries, openFile, t]);
|
}, [fileSearchEntries, openFile, t]);
|
||||||
const contributionSections = useMemo(
|
const contributionSections = useMemo(
|
||||||
() => buildContributionSections(snapshot.contributions, query),
|
() => buildContributionSections(snapshot.contributions, query),
|
||||||
@@ -270,6 +299,14 @@ function useCommandCenterState(): CommandCenterState {
|
|||||||
);
|
);
|
||||||
const resolvedActiveId = preserveActiveResultId(activeId, projection.selectableResults);
|
const resolvedActiveId = preserveActiveResultId(activeId, projection.selectableResults);
|
||||||
|
|
||||||
|
// Editing the query re-ranks everything, so an arrow-key selection made under the previous
|
||||||
|
// query is no longer meaningful. Reset it in the same update as the query rather than in an
|
||||||
|
// effect, so there is no intermediate render holding the stale selection.
|
||||||
|
const setQuery = useCallback((next: string) => {
|
||||||
|
setQueryState(next);
|
||||||
|
setActiveId(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const close = useCallback(() => setOpen(false), [setOpen]);
|
const close = useCallback(() => setOpen(false), [setOpen]);
|
||||||
const select = useCallback(
|
const select = useCallback(
|
||||||
(result: CommandCenterResult) => {
|
(result: CommandCenterResult) => {
|
||||||
@@ -312,7 +349,7 @@ function useCommandCenterState(): CommandCenterState {
|
|||||||
const timer = setTimeout(() => inputRef.current?.focus(), 0);
|
const timer = setTimeout(() => inputRef.current?.focus(), 0);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}
|
}
|
||||||
setQuery("");
|
setQueryState("");
|
||||||
setActiveId(null);
|
setActiveId(null);
|
||||||
if (!wasOpen) return;
|
if (!wasOpen) return;
|
||||||
const element = takeCommandCenterFocusRestoreElement();
|
const element = takeCommandCenterFocusRestoreElement();
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import { describe, expect, it } from "vitest";
|
|||||||
import type { CommandCenterContribution } from "./contributions";
|
import type { CommandCenterContribution } from "./contributions";
|
||||||
import {
|
import {
|
||||||
buildContributionSections,
|
buildContributionSections,
|
||||||
|
filterAndRankBuiltInResults,
|
||||||
joinSubtitleParts,
|
joinSubtitleParts,
|
||||||
moveActiveResultId,
|
moveActiveResultId,
|
||||||
|
PINNED_SECTION_BAND,
|
||||||
preserveActiveResultId,
|
preserveActiveResultId,
|
||||||
projectCommandCenterRows,
|
projectCommandCenterRows,
|
||||||
type CommandCenterWorkspaceResult,
|
type CommandCenterWorkspaceResult,
|
||||||
@@ -29,13 +31,83 @@ function contribution(input: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function workspace(id: string): CommandCenterWorkspaceResult {
|
function action(input: {
|
||||||
|
id: string;
|
||||||
|
group: string;
|
||||||
|
groupRank: number;
|
||||||
|
rank?: number;
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
keywords?: readonly string[];
|
||||||
|
visibility?: "always" | "query";
|
||||||
|
}): CommandCenterContribution {
|
||||||
|
return {
|
||||||
|
id: input.id,
|
||||||
|
group: input.group,
|
||||||
|
groupRank: input.groupRank,
|
||||||
|
rank: input.rank ?? 0,
|
||||||
|
keywords: input.keywords ?? [],
|
||||||
|
visibility: input.visibility ?? "always",
|
||||||
|
run: () => undefined,
|
||||||
|
presentation: {
|
||||||
|
kind: "action",
|
||||||
|
title: input.title,
|
||||||
|
subtitle: input.subtitle,
|
||||||
|
sectionTitle: input.group,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function choice(input: {
|
||||||
|
id: string;
|
||||||
|
group: string;
|
||||||
|
groupRank: number;
|
||||||
|
rank?: number;
|
||||||
|
path: readonly [string, ...string[]];
|
||||||
|
keywords?: readonly string[];
|
||||||
|
}): CommandCenterContribution {
|
||||||
|
return {
|
||||||
|
id: input.id,
|
||||||
|
group: input.group,
|
||||||
|
groupRank: input.groupRank,
|
||||||
|
rank: input.rank ?? 0,
|
||||||
|
keywords: input.keywords ?? [],
|
||||||
|
visibility: "query",
|
||||||
|
run: () => undefined,
|
||||||
|
presentation: { kind: "choice", path: input.path, selected: false },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The real Actions › Schedules contribution, shape-for-shape (root-registration.tsx). */
|
||||||
|
function schedulesAction(): CommandCenterContribution {
|
||||||
|
return action({
|
||||||
|
id: "schedules",
|
||||||
|
group: "actions",
|
||||||
|
groupRank: 0,
|
||||||
|
rank: 4,
|
||||||
|
title: "Schedules",
|
||||||
|
keywords: ["schedules", "scheduled", "automation", "recurring"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The real Mode › Auto mode contribution, shape-for-shape (agent-control-contributions.ts). */
|
||||||
|
function autoModeChoice(): CommandCenterContribution {
|
||||||
|
return choice({
|
||||||
|
id: "mode:auto",
|
||||||
|
group: "modes",
|
||||||
|
groupRank: 1.2,
|
||||||
|
path: ["Mode", "Auto mode"],
|
||||||
|
keywords: ["access", "permission", "approval", "mode"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function workspace(id: string, title = id, subtitle = "host"): CommandCenterWorkspaceResult {
|
||||||
return {
|
return {
|
||||||
kind: "workspace",
|
kind: "workspace",
|
||||||
id,
|
id,
|
||||||
title: id,
|
title,
|
||||||
subtitle: "host",
|
subtitle,
|
||||||
searchText: id,
|
changeRequestNumber: null,
|
||||||
run: () => undefined,
|
run: () => undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -60,7 +132,13 @@ describe("Command Center result projection", () => {
|
|||||||
const sections = buildContributionSections(contributions, "o");
|
const sections = buildContributionSections(contributions, "o");
|
||||||
const projection = projectCommandCenterRows([
|
const projection = projectCommandCenterRows([
|
||||||
...sections,
|
...sections,
|
||||||
{ id: "workspaces", rank: 2, title: "Workspaces", results: [workspace("workspace:1")] },
|
{
|
||||||
|
id: "workspaces",
|
||||||
|
band: PINNED_SECTION_BAND,
|
||||||
|
rank: 2,
|
||||||
|
title: "Workspaces",
|
||||||
|
results: [workspace("workspace:1")],
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
expect(projection.rows.map((row) => row.key)).toEqual([
|
expect(projection.rows.map((row) => row.key)).toEqual([
|
||||||
"section:models",
|
"section:models",
|
||||||
@@ -100,7 +178,13 @@ describe("Command Center result projection", () => {
|
|||||||
it("keeps keyboard selection aligned with rows beyond the render window", () => {
|
it("keeps keyboard selection aligned with rows beyond the render window", () => {
|
||||||
const workspaces = Array.from({ length: 200 }, (_, index) => workspace(`workspace:${index}`));
|
const workspaces = Array.from({ length: 200 }, (_, index) => workspace(`workspace:${index}`));
|
||||||
const projection = projectCommandCenterRows([
|
const projection = projectCommandCenterRows([
|
||||||
{ id: "workspaces", rank: 0, title: "Workspaces", results: workspaces },
|
{
|
||||||
|
id: "workspaces",
|
||||||
|
band: PINNED_SECTION_BAND,
|
||||||
|
rank: 0,
|
||||||
|
title: "Workspaces",
|
||||||
|
results: workspaces,
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let activeId: string | null = workspaces[0].id;
|
let activeId: string | null = workspaces[0].id;
|
||||||
@@ -115,6 +199,320 @@ describe("Command Center result projection", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Command Center relevance ordering", () => {
|
||||||
|
it("ranks a label match above a hidden-keyword match at every prefix", () => {
|
||||||
|
// Reported bug: "Schedules" matched only via its hidden keyword "automation" yet outranked
|
||||||
|
// "Auto mode", so Enter navigated to Schedules instead of switching permission mode.
|
||||||
|
// Every prefix counts, not just the full word — you read the list while you type, and "aut"
|
||||||
|
// is a string-prefix of both "Auto mode" and "automation", so match quality alone ties them.
|
||||||
|
for (const query of ["a", "au", "aut", "auto"]) {
|
||||||
|
const sections = buildContributionSections([schedulesAction(), autoModeChoice()], query);
|
||||||
|
expect(
|
||||||
|
sections.map((section) => section.id),
|
||||||
|
`query ${query}`,
|
||||||
|
).toEqual(["modes", "actions"]);
|
||||||
|
expect(sectionResultIds(sections), `query ${query}`).toEqual(["mode:auto", "schedules"]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers a visible label over an exact hidden keyword", () => {
|
||||||
|
const sections = buildContributionSections(
|
||||||
|
[
|
||||||
|
action({
|
||||||
|
id: "schedules",
|
||||||
|
group: "actions",
|
||||||
|
groupRank: 0,
|
||||||
|
rank: 0,
|
||||||
|
title: "Schedules",
|
||||||
|
keywords: ["automation"],
|
||||||
|
}),
|
||||||
|
action({
|
||||||
|
id: "automation-hub",
|
||||||
|
group: "actions",
|
||||||
|
groupRank: 0,
|
||||||
|
rank: 1,
|
||||||
|
title: "Automation hub",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
"automation",
|
||||||
|
);
|
||||||
|
|
||||||
|
// "automation" is an exact keyword on Schedules and only a prefix of the other label.
|
||||||
|
// The visible match still wins — the deliberate trade-off of ranking by field role first.
|
||||||
|
expect(sectionResultIds(sections)).toEqual(["automation-hub", "schedules"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps registry order when the query is empty", () => {
|
||||||
|
const shuffled = [
|
||||||
|
choice({ id: "mode:auto", group: "modes", groupRank: 1.2, path: ["Mode", "Auto mode"] }),
|
||||||
|
schedulesAction(),
|
||||||
|
action({ id: "new-agent", group: "workspace", groupRank: -1, title: "New agent" }),
|
||||||
|
];
|
||||||
|
const projection = projectCommandCenterRows(buildContributionSections(shuffled, ""));
|
||||||
|
|
||||||
|
// Query-gated choices stay hidden; the rest fall back to groupRank, unchanged by this feature.
|
||||||
|
expect(projection.selectableResults.map((result) => result.id)).toEqual([
|
||||||
|
"new-agent",
|
||||||
|
"schedules",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still ranks Schedules first for 'sched'", () => {
|
||||||
|
const sections = buildContributionSections(
|
||||||
|
[
|
||||||
|
schedulesAction(),
|
||||||
|
choice({
|
||||||
|
id: "mode:auto",
|
||||||
|
group: "modes",
|
||||||
|
groupRank: 1.2,
|
||||||
|
path: ["Mode", "Auto mode"],
|
||||||
|
keywords: ["rescheduled"],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
"sched",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(sectionResultIds(sections)).toEqual(["schedules", "mode:auto"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("orders rows within a section by match quality", () => {
|
||||||
|
const sections = buildContributionSections(
|
||||||
|
[
|
||||||
|
action({ id: "reset", group: "actions", groupRank: 0, rank: 0, title: "Reset device" }),
|
||||||
|
action({ id: "settings", group: "actions", groupRank: 0, rank: 1, title: "Settings" }),
|
||||||
|
],
|
||||||
|
"set",
|
||||||
|
);
|
||||||
|
|
||||||
|
// "Settings" is a prefix match; "Reset device" only matches mid-word.
|
||||||
|
expect(sectionResultIds(sections)).toEqual(["settings", "reset"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("breaks an equal-score tie with registry order, not alphabetically", () => {
|
||||||
|
const sections = buildContributionSections(
|
||||||
|
[
|
||||||
|
action({ id: "z-first", group: "actions", groupRank: 0, rank: 0, title: "Auto one" }),
|
||||||
|
action({ id: "a-second", group: "actions", groupRank: 0, rank: 1, title: "Auto two" }),
|
||||||
|
],
|
||||||
|
"auto",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(sectionResultIds(sections)).toEqual(["z-first", "a-second"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps Workspaces below contributions even on a perfect match", () => {
|
||||||
|
const projection = projectCommandCenterRows([
|
||||||
|
...buildContributionSections([schedulesAction()], "auto"),
|
||||||
|
{
|
||||||
|
id: "workspaces",
|
||||||
|
band: PINNED_SECTION_BAND,
|
||||||
|
rank: 2,
|
||||||
|
title: "Workspaces",
|
||||||
|
results: [workspace("auto")],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(projection.selectableResults.map((result) => result.id)).toEqual(["schedules", "auto"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sorts sections handed to it out of order", () => {
|
||||||
|
const sections = buildContributionSections([schedulesAction(), autoModeChoice()], "auto");
|
||||||
|
const workspaces = {
|
||||||
|
id: "workspaces",
|
||||||
|
band: PINNED_SECTION_BAND,
|
||||||
|
rank: 2,
|
||||||
|
title: "Workspaces",
|
||||||
|
results: [workspace("auto")],
|
||||||
|
};
|
||||||
|
const projection = projectCommandCenterRows([workspaces, ...sections.toReversed()]);
|
||||||
|
|
||||||
|
expect(projection.selectableResults.map((result) => result.id)).toEqual([
|
||||||
|
"mode:auto",
|
||||||
|
"schedules",
|
||||||
|
"auto",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still matches a query spanning two fields", () => {
|
||||||
|
// Each token is scored against each field on its own, so "mode auto" lands one token in
|
||||||
|
// path[0] and one in path[1]. Order is not significant — the reverse matches too.
|
||||||
|
for (const query of ["mode auto", "auto mode"]) {
|
||||||
|
expect(sectionResultIds(buildContributionSections([autoModeChoice()], query)), query).toEqual(
|
||||||
|
["mode:auto"],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Command Center query tokenization", () => {
|
||||||
|
const labelAsDesign = () =>
|
||||||
|
action({
|
||||||
|
id: "label:design",
|
||||||
|
group: "workspace",
|
||||||
|
groupRank: 0,
|
||||||
|
title: "Label as Design",
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches a multi-token query in any order", () => {
|
||||||
|
// The reported bug: "design" found this row and "lab des" found nothing, because the whole
|
||||||
|
// query was tested as one substring.
|
||||||
|
for (const query of ["design", "lab des", "des lab", "Label as Design"]) {
|
||||||
|
expect(sectionResultIds(buildContributionSections([labelAsDesign()], query)), query).toEqual([
|
||||||
|
"label:design",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires each token to be adjacent characters, not a subsequence", () => {
|
||||||
|
// Unlike the composer's slash-command list, where "/pasbab" finds "/paseo-babysit". Dropping
|
||||||
|
// the subsequence tier is what keeps "No matches" meaning no matches: this list preselects
|
||||||
|
// its first row, so a query that should find nothing must not put an action under Enter.
|
||||||
|
for (const query of ["labdes", "lbl", "lasdsgn"]) {
|
||||||
|
expect(sectionResultIds(buildContributionSections([labelAsDesign()], query)), query).toEqual(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects the row when any single token matches nothing", () => {
|
||||||
|
expect(sectionResultIds(buildContributionSections([labelAsDesign()], "lab zzz"))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a repeated token as idempotent", () => {
|
||||||
|
// Each token is scored independently, so both find the same occurrence. Documented, not a bug.
|
||||||
|
expect(sectionResultIds(buildContributionSections([labelAsDesign()], "lab lab"))).toEqual([
|
||||||
|
"label:design",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never drops a row that the old substring filter would have matched", () => {
|
||||||
|
// Tokens hold no whitespace and the old haystack joined fields with a space, so any token
|
||||||
|
// found in that haystack lay entirely within one field. Tokenizing can only widen.
|
||||||
|
const rows = [labelAsDesign(), schedulesAction(), autoModeChoice()];
|
||||||
|
const haystack = (row: CommandCenterContribution): string =>
|
||||||
|
[
|
||||||
|
...(row.presentation.kind === "action"
|
||||||
|
? [row.presentation.title, row.presentation.subtitle ?? ""]
|
||||||
|
: row.presentation.path),
|
||||||
|
...row.keywords,
|
||||||
|
]
|
||||||
|
.join(" ")
|
||||||
|
.toLowerCase();
|
||||||
|
|
||||||
|
for (const query of ["design", "sched", "auto mode", "label as", "recurring", "mode"]) {
|
||||||
|
const substringMatches = rows
|
||||||
|
.filter((row) => haystack(row).includes(query.toLowerCase()))
|
||||||
|
.map((row) => row.id);
|
||||||
|
const tokenMatches = new Set(sectionResultIds(buildContributionSections(rows, query)));
|
||||||
|
for (const id of substringMatches) {
|
||||||
|
expect(tokenMatches.has(id), `${query} -> ${id}`).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches one token against the title and another against the subtitle", () => {
|
||||||
|
const sections = buildContributionSections(
|
||||||
|
[
|
||||||
|
action({
|
||||||
|
id: "open-changes",
|
||||||
|
group: "workspace",
|
||||||
|
groupRank: 0,
|
||||||
|
title: "Open Changes",
|
||||||
|
subtitle: "Review the working tree",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
"changes tree",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(sectionResultIds(sections)).toEqual(["open-changes"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ranks a visible match above one that only a hidden keyword reached", () => {
|
||||||
|
const sections = buildContributionSections(
|
||||||
|
[
|
||||||
|
action({
|
||||||
|
id: "hidden-only",
|
||||||
|
group: "actions",
|
||||||
|
groupRank: 0,
|
||||||
|
rank: 0,
|
||||||
|
title: "Unrelated",
|
||||||
|
keywords: ["label", "design"],
|
||||||
|
}),
|
||||||
|
labelAsDesign(),
|
||||||
|
],
|
||||||
|
"lab des",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(sectionResultIds(sections)).toEqual(["label:design", "hidden-only"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns everything for an empty or whitespace-only query", () => {
|
||||||
|
for (const query of ["", " "]) {
|
||||||
|
expect(
|
||||||
|
sectionResultIds(buildContributionSections([labelAsDesign(), schedulesAction()], query)),
|
||||||
|
JSON.stringify(query),
|
||||||
|
).toEqual(["label:design", "schedules"]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("filterAndRankBuiltInResults", () => {
|
||||||
|
const visibleOnly = (row: CommandCenterWorkspaceResult) => ({
|
||||||
|
visible: [row.title, row.subtitle],
|
||||||
|
hidden: [],
|
||||||
|
});
|
||||||
|
const byTitle = (left: CommandCenterWorkspaceResult, right: CommandCenterWorkspaceResult) =>
|
||||||
|
left.title.localeCompare(right.title);
|
||||||
|
|
||||||
|
it("caps an empty query and preserves the caller's order", () => {
|
||||||
|
const rows = Array.from({ length: 8 }, (_, index) => workspace(`w${index}`));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
filterAndRankBuiltInResults(rows, "", visibleOnly, byTitle).map((row) => row.id),
|
||||||
|
).toEqual(["w0", "w1", "w2", "w3", "w4"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts the best match first even when it sorts last alphabetically", () => {
|
||||||
|
const rows = [
|
||||||
|
workspace("a", "Zebra design system"),
|
||||||
|
workspace("b", "Alpha lab notes"),
|
||||||
|
workspace("c", "Label as Design"),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Alphabetically this is Alpha, Label, Zebra. "lab des" only matches Label as Design.
|
||||||
|
expect(
|
||||||
|
filterAndRankBuiltInResults(rows, "lab des", visibleOnly, byTitle).map((row) => row.id),
|
||||||
|
).toEqual(["c"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ranks a title match above a match that only the hidden field reached", () => {
|
||||||
|
const rows = [workspace("hidden-hit", "Unrelated"), workspace("title-hit", "Deploy")];
|
||||||
|
const fields = (row: CommandCenterWorkspaceResult) =>
|
||||||
|
row.id === "hidden-hit"
|
||||||
|
? { visible: [row.title], hidden: ["deploy"] }
|
||||||
|
: { visible: [row.title], hidden: [] };
|
||||||
|
|
||||||
|
expect(
|
||||||
|
filterAndRankBuiltInResults(rows, "deploy", fields, byTitle).map((row) => row.id),
|
||||||
|
).toEqual(["title-hit", "hidden-hit"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("breaks an equal score with the caller's tiebreak, not with match order", () => {
|
||||||
|
// Both titles are an exact prefix match for "task", so only the tiebreak separates them.
|
||||||
|
const rows = [workspace("second", "Task zebra"), workspace("first", "Task alpha")];
|
||||||
|
|
||||||
|
expect(
|
||||||
|
filterAndRankBuiltInResults(rows, "task", visibleOnly, byTitle).map((row) => row.id),
|
||||||
|
).toEqual(["first", "second"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops a row when one token matches nothing", () => {
|
||||||
|
const rows = [workspace("a", "Label as Design")];
|
||||||
|
|
||||||
|
expect(filterAndRankBuiltInResults(rows, "lab zzz", visibleOnly, byTitle)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("joinSubtitleParts", () => {
|
describe("joinSubtitleParts", () => {
|
||||||
it("joins all present parts with a middle dot", () => {
|
it("joins all present parts with a middle dot", () => {
|
||||||
expect(joinSubtitleParts(["a", "b", "c"])).toBe("a · b · c");
|
expect(joinSubtitleParts(["a", "b", "c"])).toBe("a · b · c");
|
||||||
|
|||||||
@@ -1,12 +1,55 @@
|
|||||||
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||||
|
import {
|
||||||
|
compareMatchScores,
|
||||||
|
type MatchScore,
|
||||||
|
scoreTextFields,
|
||||||
|
} from "@getpaseo/protocol/search/text-match";
|
||||||
import type { CommandCenterContribution } from "./contributions";
|
import type { CommandCenterContribution } from "./contributions";
|
||||||
|
|
||||||
|
/** Sections that reorder by how well they match the query. */
|
||||||
|
export const CONTRIBUTION_SECTION_BAND = 0;
|
||||||
|
/** Sections pinned below every contribution section regardless of match quality. */
|
||||||
|
export const PINNED_SECTION_BAND = 1;
|
||||||
|
|
||||||
|
/** Every query token matched something the user can read on the row. */
|
||||||
|
const VISIBLE_FIELD_RANK = 0;
|
||||||
|
/** At least one token only matched a hidden keyword. */
|
||||||
|
const KEYWORD_FIELD_RANK = 1;
|
||||||
|
|
||||||
|
export interface CommandCenterScore {
|
||||||
|
/** VISIBLE_FIELD_RANK or KEYWORD_FIELD_RANK. Compared before match quality. */
|
||||||
|
fieldRank: number;
|
||||||
|
match: MatchScore;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rank by which fields matched before ranking by how well they matched. A row whose visible
|
||||||
|
* label matches always outranks one that only matches through a hidden keyword, however good
|
||||||
|
* the keyword match is.
|
||||||
|
*
|
||||||
|
* Scoring purely by tier is not enough: "aut" is a string-prefix of both "Auto mode" and
|
||||||
|
* Schedules' hidden keyword "automation", so the tiers tie and the order falls back to group
|
||||||
|
* rank, which puts Schedules first. It only came out right at the full word "auto". Order that
|
||||||
|
* flips mid-word as you type is worse than order that is merely wrong.
|
||||||
|
*
|
||||||
|
* The cost is deliberate: an exact hidden-keyword match now loses to any visible match. No
|
||||||
|
* query over the contributions registered today hits that case — hidden keywords here are
|
||||||
|
* synonyms nothing else displays ("automation", "recurring", "repo", "shell", "hotkeys").
|
||||||
|
*/
|
||||||
|
export function compareCommandCenterScores(
|
||||||
|
left: CommandCenterScore,
|
||||||
|
right: CommandCenterScore,
|
||||||
|
): number {
|
||||||
|
return left.fieldRank - right.fieldRank || compareMatchScores(left.match, right.match);
|
||||||
|
}
|
||||||
|
|
||||||
export interface CommandCenterWorkspaceResult {
|
export interface CommandCenterWorkspaceResult {
|
||||||
kind: "workspace";
|
kind: "workspace";
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
subtitle: string;
|
subtitle: string;
|
||||||
searchText: string;
|
/** PR/MR number backing this workspace, so a number query can match it exactly. */
|
||||||
|
changeRequestNumber: number | null;
|
||||||
run(): void;
|
run(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,7 +59,6 @@ export interface CommandCenterAgentResult {
|
|||||||
agent: AggregatedAgent;
|
agent: AggregatedAgent;
|
||||||
title: string;
|
title: string;
|
||||||
subtitle: string;
|
subtitle: string;
|
||||||
searchText: string;
|
|
||||||
run(): void;
|
run(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,7 +68,6 @@ export interface CommandCenterFileResult {
|
|||||||
filePath: string;
|
filePath: string;
|
||||||
title: string;
|
title: string;
|
||||||
subtitle: string;
|
subtitle: string;
|
||||||
searchText: string;
|
|
||||||
run(): void;
|
run(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,7 +75,8 @@ export interface CommandCenterContributionResult {
|
|||||||
kind: "contribution";
|
kind: "contribution";
|
||||||
id: string;
|
id: string;
|
||||||
contribution: CommandCenterContribution;
|
contribution: CommandCenterContribution;
|
||||||
searchText: string;
|
/** How well this row matches the query. Absent when the query is empty. */
|
||||||
|
score?: CommandCenterScore;
|
||||||
run(): void | Promise<void>;
|
run(): void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,15 +88,21 @@ export type CommandCenterResult =
|
|||||||
|
|
||||||
export interface CommandCenterResultSection {
|
export interface CommandCenterResultSection {
|
||||||
id: string;
|
id: string;
|
||||||
|
/** CONTRIBUTION_SECTION_BAND or PINNED_SECTION_BAND. Compared before anything else. */
|
||||||
|
band: number;
|
||||||
rank: number;
|
rank: number;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
/** Best score among this section's results. Absent when the query is empty. */
|
||||||
|
score?: CommandCenterScore;
|
||||||
results: readonly CommandCenterResult[];
|
results: readonly CommandCenterResult[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MutableCommandCenterResultSection {
|
interface MutableCommandCenterResultSection {
|
||||||
id: string;
|
id: string;
|
||||||
|
band: number;
|
||||||
rank: number;
|
rank: number;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
score?: CommandCenterScore;
|
||||||
results: CommandCenterResult[];
|
results: CommandCenterResult[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,9 +117,41 @@ export interface CommandCenterListProjection {
|
|||||||
offsets: readonly number[];
|
offsets: readonly number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function matchesQuery(searchText: string, query: string): boolean {
|
/**
|
||||||
const normalized = query.trim().toLowerCase();
|
* Fields a row can be matched on, split by whether the user can read them. A row that matches
|
||||||
return !normalized || searchText.includes(normalized);
|
* only through `hidden` still matches, but always ranks below one whose `visible` text did.
|
||||||
|
*/
|
||||||
|
export interface CommandCenterSearchFields {
|
||||||
|
visible: readonly string[];
|
||||||
|
hidden: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const MATCH_OPTIONS = { subsequence: false } as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one definition of "how well did this row match", shared by every scored row kind. Null
|
||||||
|
* means the row is filtered out, so scoring and filtering are the same pass — a separate boolean
|
||||||
|
* predicate would throw this result away and force the token scan a second time.
|
||||||
|
*
|
||||||
|
* Matching is per-token (`scoreTextFields` splits on whitespace and every token must land in
|
||||||
|
* some field), so token order does not matter and `lab des` finds "Label as Design". This can
|
||||||
|
* only ever widen the result set: tokens hold no whitespace, so any token that was found in the
|
||||||
|
* old space-joined haystack lies entirely within one field and is still found here.
|
||||||
|
*
|
||||||
|
* Subsequence matching is off, unlike the composer's slash-command list. Each token has to be a
|
||||||
|
* run of adjacent characters, so `lab des` matches and `labdes` does not. It widens hard enough
|
||||||
|
* that almost any typo finds something, and this list preselects its first row: a query the user
|
||||||
|
* expects to find nothing would instead put an unrelated action under Enter.
|
||||||
|
*/
|
||||||
|
export function scoreSearchFields(
|
||||||
|
query: string,
|
||||||
|
fields: CommandCenterSearchFields,
|
||||||
|
): CommandCenterScore | null {
|
||||||
|
const visible = scoreTextFields(query, [...fields.visible], MATCH_OPTIONS);
|
||||||
|
if (visible) return { fieldRank: VISIBLE_FIELD_RANK, match: visible };
|
||||||
|
if (fields.hidden.length === 0) return null;
|
||||||
|
const match = scoreTextFields(query, [...fields.visible, ...fields.hidden], MATCH_OPTIONS);
|
||||||
|
return match ? { fieldRank: KEYWORD_FIELD_RANK, match } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Join non-empty subtitle parts with " · ", dropping null/undefined/empty. */
|
/** Join non-empty subtitle parts with " · ", dropping null/undefined/empty. */
|
||||||
@@ -79,12 +159,18 @@ export function joinSubtitleParts(parts: readonly (string | null | undefined)[])
|
|||||||
return parts.filter((part): part is string => Boolean(part)).join(" · ");
|
return parts.filter((part): part is string => Boolean(part)).join(" · ");
|
||||||
}
|
}
|
||||||
|
|
||||||
function contributionSearchText(contribution: CommandCenterContribution): string {
|
/**
|
||||||
const presentationText =
|
* Field order carries no meaning: every token is scored against each field on its own, so
|
||||||
|
* ["Mode", "Auto mode"] and its reverse both match "mode auto" and "auto mode".
|
||||||
|
*/
|
||||||
|
function contributionSearchFields(
|
||||||
|
contribution: CommandCenterContribution,
|
||||||
|
): CommandCenterSearchFields {
|
||||||
|
const visible =
|
||||||
contribution.presentation.kind === "action"
|
contribution.presentation.kind === "action"
|
||||||
? [contribution.presentation.title, contribution.presentation.subtitle ?? ""]
|
? [contribution.presentation.title, contribution.presentation.subtitle ?? ""]
|
||||||
: contribution.presentation.path;
|
: [...contribution.presentation.path];
|
||||||
return [...presentationText, ...contribution.keywords].join(" ").toLowerCase();
|
return { visible, hidden: contribution.keywords };
|
||||||
}
|
}
|
||||||
|
|
||||||
function resultHeight(result: CommandCenterResult): number {
|
function resultHeight(result: CommandCenterResult): number {
|
||||||
@@ -96,17 +182,77 @@ function resultHeight(result: CommandCenterResult): number {
|
|||||||
return 36;
|
return 36;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Order two sections. Total and transitive: the mixed case (one score present, one absent)
|
||||||
|
* cannot occur, because scores exist exactly on band-0 sections with a non-empty query, band is
|
||||||
|
* compared first so cross-band pairs never reach the score clause, and within band 0 either
|
||||||
|
* every section has a score or none does.
|
||||||
|
*/
|
||||||
|
function compareResultSections(
|
||||||
|
left: CommandCenterResultSection,
|
||||||
|
right: CommandCenterResultSection,
|
||||||
|
): number {
|
||||||
|
if (left.band !== right.band) return left.band - right.band;
|
||||||
|
if (left.score && right.score) {
|
||||||
|
const delta = compareCommandCenterScores(left.score, right.score);
|
||||||
|
if (delta !== 0) return delta;
|
||||||
|
}
|
||||||
|
return left.rank - right.rank || left.id.localeCompare(right.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resultScore(result: CommandCenterResult): CommandCenterScore | undefined {
|
||||||
|
return result.kind === "contribution" ? result.score : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rows shown when nothing has been typed, per pinned category. */
|
||||||
|
const DEFAULT_CATEGORY_RESULT_LIMIT = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter and rank already-built rows. Rows in, rows out — building them needs translations,
|
||||||
|
* navigation callbacks and the project tree, none of which matching cares about, so that stays
|
||||||
|
* with the caller and this stays testable without mounting React.
|
||||||
|
*
|
||||||
|
* `tiebreak` is the caller's own ordering (alphabetical for workspaces, status and recency for
|
||||||
|
* agents). It decides equal-scoring rows, so an agent awaiting input still outranks an idle one
|
||||||
|
* that matched exactly as well.
|
||||||
|
*/
|
||||||
|
export function filterAndRankBuiltInResults<Row>(
|
||||||
|
rows: readonly Row[],
|
||||||
|
query: string,
|
||||||
|
fieldsOf: (row: Row) => CommandCenterSearchFields,
|
||||||
|
tiebreak: (left: Row, right: Row) => number,
|
||||||
|
): Row[] {
|
||||||
|
if (!query.trim()) return rows.slice(0, DEFAULT_CATEGORY_RESULT_LIMIT);
|
||||||
|
|
||||||
|
const scored: { row: Row; score: CommandCenterScore }[] = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const score = scoreSearchFields(query, fieldsOf(row));
|
||||||
|
if (score) scored.push({ row, score });
|
||||||
|
}
|
||||||
|
scored.sort(
|
||||||
|
(left, right) =>
|
||||||
|
compareCommandCenterScores(left.score, right.score) || tiebreak(left.row, right.row),
|
||||||
|
);
|
||||||
|
return scored.map((entry) => entry.row);
|
||||||
|
}
|
||||||
|
|
||||||
export function buildContributionSections(
|
export function buildContributionSections(
|
||||||
contributions: readonly CommandCenterContribution[],
|
contributions: readonly CommandCenterContribution[],
|
||||||
query: string,
|
query: string,
|
||||||
): CommandCenterResultSection[] {
|
): CommandCenterResultSection[] {
|
||||||
const groups = new Map<string, MutableCommandCenterResultSection>();
|
const groups = new Map<string, MutableCommandCenterResultSection>();
|
||||||
|
const registryIndexById = new Map<string, number>();
|
||||||
const hasQuery = Boolean(query.trim());
|
const hasQuery = Boolean(query.trim());
|
||||||
|
|
||||||
for (const contribution of contributions) {
|
for (const [index, contribution] of contributions.entries()) {
|
||||||
if (contribution.visibility === "query" && !hasQuery) continue;
|
if (contribution.visibility === "query" && !hasQuery) continue;
|
||||||
const searchText = contributionSearchText(contribution);
|
let score: CommandCenterScore | undefined;
|
||||||
if (!matchesQuery(searchText, query)) continue;
|
if (hasQuery) {
|
||||||
|
// The score is the filter: null means no token found a home, so the row is out.
|
||||||
|
const matched = scoreSearchFields(query, contributionSearchFields(contribution));
|
||||||
|
if (!matched) continue;
|
||||||
|
score = matched;
|
||||||
|
}
|
||||||
const existing = groups.get(contribution.group);
|
const existing = groups.get(contribution.group);
|
||||||
const title =
|
const title =
|
||||||
contribution.presentation.kind === "action"
|
contribution.presentation.kind === "action"
|
||||||
@@ -114,23 +260,39 @@ export function buildContributionSections(
|
|||||||
: undefined;
|
: undefined;
|
||||||
const section = existing ?? {
|
const section = existing ?? {
|
||||||
id: contribution.group,
|
id: contribution.group,
|
||||||
|
band: CONTRIBUTION_SECTION_BAND,
|
||||||
rank: contribution.groupRank,
|
rank: contribution.groupRank,
|
||||||
title,
|
title,
|
||||||
results: [],
|
results: [],
|
||||||
};
|
};
|
||||||
|
registryIndexById.set(contribution.id, index);
|
||||||
section.results.push({
|
section.results.push({
|
||||||
kind: "contribution",
|
kind: "contribution",
|
||||||
id: contribution.id,
|
id: contribution.id,
|
||||||
contribution,
|
contribution,
|
||||||
searchText,
|
score,
|
||||||
run: contribution.run,
|
run: contribution.run,
|
||||||
});
|
});
|
||||||
|
if (score && (!section.score || compareCommandCenterScores(score, section.score) < 0)) {
|
||||||
|
section.score = score;
|
||||||
|
}
|
||||||
groups.set(contribution.group, section);
|
groups.set(contribution.group, section);
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...groups.values()].sort(
|
const sections = [...groups.values()];
|
||||||
(left, right) => left.rank - right.rank || left.id.localeCompare(right.id),
|
// With no query there is nothing to rank by, so leave the registry order untouched.
|
||||||
);
|
if (!hasQuery) return sections;
|
||||||
|
|
||||||
|
for (const section of sections) {
|
||||||
|
section.results.sort((left, right) => {
|
||||||
|
const leftScore = resultScore(left);
|
||||||
|
const rightScore = resultScore(right);
|
||||||
|
const delta = leftScore && rightScore ? compareCommandCenterScores(leftScore, rightScore) : 0;
|
||||||
|
if (delta !== 0) return delta;
|
||||||
|
return (registryIndexById.get(left.id) ?? 0) - (registryIndexById.get(right.id) ?? 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return sections.sort(compareResultSections);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function projectCommandCenterRows(
|
export function projectCommandCenterRows(
|
||||||
@@ -138,7 +300,7 @@ export function projectCommandCenterRows(
|
|||||||
): CommandCenterListProjection {
|
): CommandCenterListProjection {
|
||||||
const populated = sections
|
const populated = sections
|
||||||
.filter((section) => section.results.length > 0)
|
.filter((section) => section.results.length > 0)
|
||||||
.sort((left, right) => left.rank - right.rank || left.id.localeCompare(right.id));
|
.sort(compareResultSections);
|
||||||
const rows: CommandCenterListRow[] = [];
|
const rows: CommandCenterListRow[] = [];
|
||||||
const selectableResults: CommandCenterResult[] = [];
|
const selectableResults: CommandCenterResult[] = [];
|
||||||
const rowIndexByResultId = new Map<string, number>();
|
const rowIndexByResultId = new Map<string, number>();
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user