mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
+66
-76
@@ -1,98 +1,88 @@
|
||||
# Changelog
|
||||
|
||||
## 0.5.0-beta.4 - 2026-08-21
|
||||
## 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
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed multiline composers stopping resizing after workspace and agent transitions on iOS and Android ([#3740](https://github.com/getpaseo/paseo/pull/3740))
|
||||
|
||||
## 0.5.0 - 2026-08-23
|
||||
|
||||
### Added
|
||||
|
||||
- Added experimental trusted local plugins ([#3222](https://github.com/getpaseo/paseo/pull/3222), [#3446](https://github.com/getpaseo/paseo/pull/3446), [#3465](https://github.com/getpaseo/paseo/pull/3465))
|
||||
- Added plugin-contributed application themes to Settings → Appearance ([#3602](https://github.com/getpaseo/paseo/pull/3602) by [@ragokan](https://github.com/ragokan))
|
||||
- Added MiniMax Code to the one-click ACP provider catalog ([#3457](https://github.com/getpaseo/paseo/pull/3457) by [@hetaoBackend](https://github.com/hetaoBackend))
|
||||
- Added a user-directed Side panel with independent tabs ([#3287](https://github.com/getpaseo/paseo/pull/3287), [#3605](https://github.com/getpaseo/paseo/pull/3605))
|
||||
- Added browser-style New tabs with an anchored pane-local chooser ([#3715](https://github.com/getpaseo/paseo/pull/3715), [#3735](https://github.com/getpaseo/paseo/pull/3735))
|
||||
- Added active-turn steering for Claude, Codex, and OpenCode ([#3394](https://github.com/getpaseo/paseo/pull/3394), [#3580](https://github.com/getpaseo/paseo/pull/3580) by [@mcowger](https://github.com/mcowger))
|
||||
- Added workspace labels for sidebar organization and filtering ([#3510](https://github.com/getpaseo/paseo/pull/3510) by [@cleiter](https://github.com/cleiter))
|
||||
- Added live workspace change counts above active agent composers ([#3682](https://github.com/getpaseo/paseo/pull/3682))
|
||||
- Added workspace panel, tab, and pane actions to the Command Center ([#3685](https://github.com/getpaseo/paseo/pull/3685))
|
||||
- Added `paseo project` to create, list, rename, and delete projects from the terminal ([#3460](https://github.com/getpaseo/paseo/pull/3460))
|
||||
- Added `paseo reload` to apply runtime-safe `config.json` changes without restarting the daemon ([#3365](https://github.com/getpaseo/paseo/pull/3365))
|
||||
- Added Paseo skill management to Host → Agents, including remote hosts ([#3451](https://github.com/getpaseo/paseo/pull/3451))
|
||||
- Added project filtering to the sidebar display menu ([#3563](https://github.com/getpaseo/paseo/pull/3563) by [@cleiter](https://github.com/cleiter))
|
||||
- Added active-turn steering for OpenCode ([#3580](https://github.com/getpaseo/paseo/pull/3580) by [@mcowger](https://github.com/mcowger))
|
||||
- Added separate Content text sizing for chat, the composer, Markdown, and review prose ([#3637](https://github.com/getpaseo/paseo/pull/3637))
|
||||
- Added live sheets for summarized tool-call groups on compact layouts ([#3619](https://github.com/getpaseo/paseo/pull/3619))
|
||||
- Added optional branch and project names on workspace rows ([#3445](https://github.com/getpaseo/paseo/pull/3445))
|
||||
- Added drag-and-drop reordering for pinned workspaces ([#3341](https://github.com/getpaseo/paseo/pull/3341))
|
||||
- Added one-click agent profile creation from the model chooser ([#3533](https://github.com/getpaseo/paseo/pull/3533))
|
||||
- Added Android Studio to Open in editor ([#3531](https://github.com/getpaseo/paseo/pull/3531), [#3614](https://github.com/getpaseo/paseo/pull/3614) by [@caikovsky](https://github.com/caikovsky))
|
||||
- Added MiniMax Code to the one-click ACP provider catalog ([#3457](https://github.com/getpaseo/paseo/pull/3457) by [@hetaoBackend](https://github.com/hetaoBackend))
|
||||
- Added Nix syntax highlighting ([#3110](https://github.com/getpaseo/paseo/pull/3110) by [@Strainy](https://github.com/Strainy))
|
||||
- Added Svelte syntax highlighting ([#3487](https://github.com/getpaseo/paseo/pull/3487), [#3534](https://github.com/getpaseo/paseo/pull/3534) by [@fiorelorenzo](https://github.com/fiorelorenzo))
|
||||
- Added guided Hub setup that connects a daemon and deploys a compatible starter workflow ([#3651](https://github.com/getpaseo/paseo/pull/3651), [#3657](https://github.com/getpaseo/paseo/pull/3657), [#3677](https://github.com/getpaseo/paseo/pull/3677))
|
||||
|
||||
### Improved
|
||||
|
||||
- Changed the Side panel into a user-directed surface with independent tabs and configurable supporting-tab routing ([#3605](https://github.com/getpaseo/paseo/pull/3605))
|
||||
- Changed Default send in Settings to a dropdown menu ([#3644](https://github.com/getpaseo/paseo/pull/3644))
|
||||
- Reduced Android workspace-switch stalls after using several long chats ([#3610](https://github.com/getpaseo/paseo/pull/3610))
|
||||
- Restored cached projects, workspaces, agents, and timelines immediately while hosts reconnect ([#3259](https://github.com/getpaseo/paseo/pull/3259), [#3329](https://github.com/getpaseo/paseo/pull/3329))
|
||||
- Reduced workspace-switch stalls on desktop and Android with long chats ([#3447](https://github.com/getpaseo/paseo/pull/3447), [#3610](https://github.com/getpaseo/paseo/pull/3610))
|
||||
- Kept composer typing within the frame budget on web and desktop ([#3450](https://github.com/getpaseo/paseo/pull/3450))
|
||||
- Kept large diffs responsive while expanding, scrolling, and commenting ([#3422](https://github.com/getpaseo/paseo/pull/3422))
|
||||
- Kept large read-only source previews responsive with bounded rendering ([#3665](https://github.com/getpaseo/paseo/pull/3665))
|
||||
- Removed complete-transcript disk rewrites from long-running agent timelines ([#3647](https://github.com/getpaseo/paseo/pull/3647))
|
||||
- Added outcome summaries and failure-first grouping to pull request checks ([#3483](https://github.com/getpaseo/paseo/pull/3483))
|
||||
- Moved subagent and task trackers into pills above the composer ([#3482](https://github.com/getpaseo/paseo/pull/3482))
|
||||
- Simplified mobile agent configuration into one options sheet ([#3424](https://github.com/getpaseo/paseo/pull/3424))
|
||||
- Opened summarized tool-call groups in live sheets on compact layouts ([#3619](https://github.com/getpaseo/paseo/pull/3619))
|
||||
- Added separate Content text sizing for chat, the composer, Markdown, and review prose ([#3637](https://github.com/getpaseo/paseo/pull/3637))
|
||||
- Updated Pi usage and context meters during active turns ([#3532](https://github.com/getpaseo/paseo/pull/3532))
|
||||
- Changed Archive finished to archive every finished subagent in the track ([#3368](https://github.com/getpaseo/paseo/pull/3368))
|
||||
- Changed automatic Setup tabs to appear only after workspace setup fails ([#3634](https://github.com/getpaseo/paseo/pull/3634))
|
||||
- Changed Default send in Settings to a dropdown menu ([#3644](https://github.com/getpaseo/paseo/pull/3644))
|
||||
- Added password-equivalent warnings to pairing links in the desktop app and CLI ([#3734](https://github.com/getpaseo/paseo/pull/3734))
|
||||
- Corrected Russian UI translations and terminology ([#3586](https://github.com/getpaseo/paseo/pull/3586) by [@timz](https://github.com/timz))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed modified tab indicators overlapping truncated tab titles
|
||||
- Fixed composer steers remaining unread while Claude or Codex waited on a permission ([#3585](https://github.com/getpaseo/paseo/pull/3585) by [@cleiter](https://github.com/cleiter))
|
||||
- Fixed Changes opening an empty comparison after checkout state changes ([#3636](https://github.com/getpaseo/paseo/pull/3636))
|
||||
- Fixed Pi chats ending in an error state during a successful automatic retry ([#3639](https://github.com/getpaseo/paseo/pull/3639))
|
||||
- Fixed Claude usage appearing unavailable when macOS Keychain contains multiple credential items ([#3597](https://github.com/getpaseo/paseo/pull/3597) by [@t-benoit](https://github.com/t-benoit))
|
||||
- Fixed active Codex agents failing to open when their native thread was archived outside Paseo ([#3334](https://github.com/getpaseo/paseo/pull/3334))
|
||||
- Fixed OpenCode turns failing when plugin-heavy startup takes more than ten seconds ([#3578](https://github.com/getpaseo/paseo/pull/3578) by [@BrianAguilarWasco](https://github.com/BrianAguilarWasco))
|
||||
- Fixed worktree creation rejecting Git-valid branch names containing uppercase letters, underscores, or dots ([#3591](https://github.com/getpaseo/paseo/pull/3591))
|
||||
- Fixed Android Studio showing a generic terminal icon in the desktop open-target menu ([#3614](https://github.com/getpaseo/paseo/pull/3614) by [@caikovsky](https://github.com/caikovsky))
|
||||
|
||||
## 0.5.0-beta.3 - 2026-08-20
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the composer retaining sent text on iOS and Android ([#3564](https://github.com/getpaseo/paseo/pull/3564))
|
||||
- Fixed pull request checkout in fork workflows where the target repository is configured as `upstream` ([#2997](https://github.com/getpaseo/paseo/pull/2997) by [@mcowger](https://github.com/mcowger))
|
||||
|
||||
## 0.5.0-beta.2 - 2026-08-19
|
||||
|
||||
### Added
|
||||
|
||||
- Added Android Studio to Open in editor ([#3531](https://github.com/getpaseo/paseo/pull/3531) by [@caikovsky](https://github.com/caikovsky))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed `npm install @getpaseo/cli@beta` failing with a 404 because the plugin SDK it depends on was never published
|
||||
- Renamed the plugin SDK package to `@getpaseo/plugin`, with `@paseo/plugin` imports still resolving for existing plugins
|
||||
- Fixed the old docked file explorer sidebar staying open next to the explorer pane after upgrading to 0.5.0-beta.1, with no way to dismiss it on Windows and Linux
|
||||
- Fixed new agents, terminals, and browser tabs opening in a background pane instead of the focused one
|
||||
|
||||
## 0.5.0-beta.1 - 2026-08-18
|
||||
|
||||
### Added
|
||||
|
||||
- Add experimental support for plugins ([See docs](/docs/plugins), [#3222](https://github.com/getpaseo/paseo/pull/3222), [#3446](https://github.com/getpaseo/paseo/pull/3446), [#3465](https://github.com/getpaseo/paseo/pull/3465))
|
||||
- Added workspace labels to organize the sidebar ([#3510](https://github.com/getpaseo/paseo/pull/3510) by [@cleiter](https://github.com/cleiter))
|
||||
- Added steering for Codex and Claude — send into a running turn instead of interrupting it ([#3394](https://github.com/getpaseo/paseo/pull/3394))
|
||||
- Reworked the explorer into a persistent pane with its own tabs, so files, changes, and the pull request stay open next to the chat ([#3287](https://github.com/getpaseo/paseo/pull/3287))
|
||||
- Changed Cmd/Ctrl+T to open a menu so you can choose what the new tab opens
|
||||
- Added `paseo project` to create, list, rename, and delete projects from the terminal ([#3460](https://github.com/getpaseo/paseo/pull/3460))
|
||||
- Added `paseo reload` to apply `config.json` changes without restarting the daemon ([#3365](https://github.com/getpaseo/paseo/pull/3365))
|
||||
- Added Paseo skill management to Host → Agents, including remote hosts ([#3451](https://github.com/getpaseo/paseo/pull/3451))
|
||||
- Added optional branch and project names on workspace rows ([#3445](https://github.com/getpaseo/paseo/pull/3445))
|
||||
- Added drag-and-drop reordering for pinned workspaces ([#3341](https://github.com/getpaseo/paseo/pull/3341))
|
||||
- Added Nix and Svelte syntax highlighting ([#3110](https://github.com/getpaseo/paseo/pull/3110) by [@Strainy](https://github.com/Strainy), [#3487](https://github.com/getpaseo/paseo/pull/3487) by [@fiorelorenzo](https://github.com/fiorelorenzo))
|
||||
|
||||
### Improved
|
||||
|
||||
- Improved checks summary to the pull request pane, grouped by status with failures first ([#3483](https://github.com/getpaseo/paseo/pull/3483))
|
||||
- Projects, workspaces, and agents show up instantly when the app opens ([#3259](https://github.com/getpaseo/paseo/pull/3259), [#3329](https://github.com/getpaseo/paseo/pull/3329))
|
||||
- Offline hosts keep showing their projects and workspaces while they reconnect ([#3259](https://github.com/getpaseo/paseo/pull/3259))
|
||||
- Large diffs stay responsive while expanding, scrolling, and commenting ([#3422](https://github.com/getpaseo/paseo/pull/3422))
|
||||
- Switching workspaces with keyboard shortcuts no longer lags ([#3447](https://github.com/getpaseo/paseo/pull/3447))
|
||||
- Typing in the composer no longer lags on web and desktop ([#3450](https://github.com/getpaseo/paseo/pull/3450))
|
||||
- Moved the subagents and tasks trackers into pills above the composer ([#3482](https://github.com/getpaseo/paseo/pull/3482))
|
||||
- Simplified mobile agent configuration into one options sheet ([#3424](https://github.com/getpaseo/paseo/pull/3424))
|
||||
- Archive finished now archives every finished subagent in the track ([#3368](https://github.com/getpaseo/paseo/pull/3368))
|
||||
- The text size setting now scales the whole interface
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed local plugin install on the packaged desktop app: esbuild can spawn from `app.asar.unpacked`, and the plugin subprocess loads `@paseo/plugin/server` instead of the React client barrel
|
||||
- Fixed CJK IME composition being cancelled in text fields and the mobile terminal ([#3517](https://github.com/getpaseo/paseo/pull/3517), [#2811](https://github.com/getpaseo/paseo/pull/2811) by [@northsea4](https://github.com/northsea4), [#3462](https://github.com/getpaseo/paseo/pull/3462) by [@chulmin-dev](https://github.com/chulmin-dev), [#3343](https://github.com/getpaseo/paseo/pull/3343) by [@jimersylee](https://github.com/jimersylee), [#3391](https://github.com/getpaseo/paseo/pull/3391) by [@ZacharyZcR](https://github.com/ZacharyZcR), [@ljy900825-dev](https://github.com/ljy900825-dev))
|
||||
- Fixed OpenCode turns failing when its event stream dropped ([#3395](https://github.com/getpaseo/paseo/pull/3395))
|
||||
- Fixed CJK IME composition being cancelled in text fields and mobile terminals ([#2811](https://github.com/getpaseo/paseo/pull/2811), [#3343](https://github.com/getpaseo/paseo/pull/3343), [#3391](https://github.com/getpaseo/paseo/pull/3391), [#3462](https://github.com/getpaseo/paseo/pull/3462), [#3517](https://github.com/getpaseo/paseo/pull/3517) by [@northsea4](https://github.com/northsea4), [@jimersylee](https://github.com/jimersylee), [@ZacharyZcR](https://github.com/ZacharyZcR), [@chulmin-dev](https://github.com/chulmin-dev))
|
||||
- Fixed OpenCode turns failing when their event stream dropped ([#3395](https://github.com/getpaseo/paseo/pull/3395))
|
||||
- Fixed OpenCode agents timing out during slow or plugin-heavy startup ([#3578](https://github.com/getpaseo/paseo/pull/3578), [#3621](https://github.com/getpaseo/paseo/pull/3621) by [@BrianAguilarWasco](https://github.com/BrianAguilarWasco))
|
||||
- Fixed an unrelated merged pull request archiving a workspace ([#3425](https://github.com/getpaseo/paseo/pull/3425))
|
||||
- Fixed Annotate element and Screenshot element doing nothing on loaded desktop browser pages ([#3187](https://github.com/getpaseo/paseo/pull/3187) by [@dgk-dev](https://github.com/dgk-dev))
|
||||
- Fixed repeated copy and fork footers after heartbeat runs ([#3484](https://github.com/getpaseo/paseo/pull/3484))
|
||||
- Fixed Cursor plan usage on hosts signed in only through `cursor-agent` ([#3486](https://github.com/getpaseo/paseo/pull/3486) by [@Lite-G](https://github.com/Lite-G))
|
||||
- Fixed resumed subagents splitting into separate rows
|
||||
- Fixed a detected pull request revealing the explorer pane and stealing focus, which then made new agents, terminals, and browser tabs open inside the explorer pane instead of the main one
|
||||
- Fixed workspace pull request status being lost on reload
|
||||
- Fixed hidden terminal tabs stopping output in the focused workspace
|
||||
- Fixed mobile composers retaining sent text after submission ([#3564](https://github.com/getpaseo/paseo/pull/3564))
|
||||
- Fixed multiline composers retaining stale heights across workspace and draft lifecycles ([#3681](https://github.com/getpaseo/paseo/pull/3681), [#3740](https://github.com/getpaseo/paseo/pull/3740))
|
||||
- Fixed pull request checkout when the target repository is configured as `upstream` ([#2997](https://github.com/getpaseo/paseo/pull/2997) by [@mcowger](https://github.com/mcowger))
|
||||
- Fixed composer steers remaining unread while Claude or Codex waited on a permission ([#3585](https://github.com/getpaseo/paseo/pull/3585) by [@cleiter](https://github.com/cleiter))
|
||||
- Fixed Changes opening an empty comparison after checkout state changes ([#3636](https://github.com/getpaseo/paseo/pull/3636))
|
||||
- Fixed Pi chats ending in an error state during a successful automatic retry ([#3639](https://github.com/getpaseo/paseo/pull/3639))
|
||||
- Fixed Claude usage appearing unavailable when macOS Keychain contains multiple credential items ([#3597](https://github.com/getpaseo/paseo/pull/3597) by [@t-benoit](https://github.com/t-benoit))
|
||||
- Fixed provider usage checks overwriting Claude or Codex credential files ([#3442](https://github.com/getpaseo/paseo/pull/3442) by [@danberindei](https://github.com/danberindei))
|
||||
- Fixed active Codex agents failing to open when their native thread was archived outside Paseo ([#3334](https://github.com/getpaseo/paseo/pull/3334))
|
||||
- Fixed agents remaining impossible to stop after the provider had already settled their turn ([#3742](https://github.com/getpaseo/paseo/pull/3742))
|
||||
- Fixed worktree creation rejecting Git-valid branch names containing uppercase letters, underscores, or dots ([#3591](https://github.com/getpaseo/paseo/pull/3591))
|
||||
- Fixed project icons showing stale images in New Workspace ([#3600](https://github.com/getpaseo/paseo/pull/3600))
|
||||
- Fixed malformed SVG project icons crashing the iOS app ([#3711](https://github.com/getpaseo/paseo/pull/3711) by [@omercnet](https://github.com/omercnet))
|
||||
- Fixed pinning a project's only workspace hiding its New workspace action ([#3722](https://github.com/getpaseo/paseo/pull/3722) by [@yugui923](https://github.com/yugui923))
|
||||
- Fixed workspace layout changes bypassing timeline retry backoff ([#3736](https://github.com/getpaseo/paseo/pull/3736))
|
||||
- Fixed fresh installs resolving provider SDK versions that were not validated with the release ([#3678](https://github.com/getpaseo/paseo/pull/3678))
|
||||
|
||||
## 0.4.0 - 2026-08-13
|
||||
|
||||
|
||||
+10
-1
@@ -35,7 +35,16 @@ be in flight.
|
||||
|
||||
### Cancellation
|
||||
|
||||
Cancellation changes lifecycle state only after the provider acknowledges the interrupt or emits a terminal turn event. If the interrupt is rejected or times out, the agent remains `running` with its active foreground turn intact. Follow-up actions such as replacement, reload, rewind, and Stop must report that failure instead of accepting work they cannot perform. Synthesizing a local cancellation without provider acknowledgment creates a split-brain session: Paseo accepts a new prompt while the provider still owns the previous foreground turn.
|
||||
Provider interruption is idempotent at the `AgentSession` boundary. It resolves when the prior
|
||||
foreground turn can no longer run, including when the provider reports that it is already idle. It
|
||||
rejects only when the provider may still own the turn. Provider adapters translate native errors
|
||||
into that contract; lifecycle callers do not interpret provider-specific errors.
|
||||
|
||||
After an acknowledged interrupt, the manager settles the captured run even when no terminal event
|
||||
arrives or the run was still waiting for its provider turn id. The captured run token prevents an
|
||||
older cancellation from settling a newer turn. If interruption is rejected or times out, the agent
|
||||
keeps its active foreground turn and replacement, reload, rewind, and Stop report the failure.
|
||||
Accepting new work after an ambiguous interruption would create a split-brain session.
|
||||
|
||||
## Relationships
|
||||
|
||||
|
||||
@@ -502,6 +502,16 @@ For tighter loops, you can rebuild a single workspace:
|
||||
- Changed `packages/server/src/*`, `packages/cli/src/*`, `packages/relay/src/*`, or `packages/highlight/src/*`: `npm run build:server`.
|
||||
- Changed app build dependencies: `npm run build:app-deps`.
|
||||
|
||||
## Dependency patches
|
||||
|
||||
`patches/*.patch` are applied by `scripts/postinstall-patches.mjs` on every install. A patch only
|
||||
runs when its package is actually present, so add the package to that script's `patchedPackages`
|
||||
list when you introduce a new patch — otherwise the file sits in `patches/` and never applies.
|
||||
Regenerate a patch with `npx patch-package <package>` after editing `node_modules/<package>`, and
|
||||
patch every build the consumers use: Metro resolves the `react-native` field of a package
|
||||
(`src/*.ts` for `react-native-svg`), while Node and Vitest resolve `main`/`module`
|
||||
(`lib/commonjs`, `lib/module`). Patching only `lib/` leaves the app bundle unfixed.
|
||||
|
||||
## ACP provider catalog versions
|
||||
|
||||
The in-app ACP provider catalog pins package-runner entries (`npx`, `npm exec`,
|
||||
|
||||
@@ -147,6 +147,10 @@ Kimi Code usage follows the CLI-managed credential file at `KIMI_CODE_HOME` or `
|
||||
|
||||
Cursor usage reads the desktop `state.vscdb` token first, then `cursor-agent`'s `~/.config/cursor/auth.json`. Headless hosts only have the CLI file.
|
||||
|
||||
### Usage fetchers are read-only on credentials
|
||||
|
||||
A fetcher reads the provider's credential file and never writes it. On a 401 or 403 it returns `unavailable` and leaves refresh to the provider's own CLI: redeeming a refresh token in the fetcher invalidates the CLI's copy (refresh tokens are single-use), and rewriting the file through the fetcher's Zod schema drops any field the schema does not model, corrupting the file for the CLI.
|
||||
|
||||
---
|
||||
|
||||
## ACP Provider Checklist
|
||||
|
||||
+5
-1
@@ -83,7 +83,11 @@ Running and successful setup never seed tabs.
|
||||
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.
|
||||
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
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ A failed catch-up or subscription reconcile retries on its own, doubling from 1s
|
||||
A fixed 1s retry turned a persistent daemon-side refusal — a Codex thread that already has an active
|
||||
writer, say — into a request and a log line every second on an idle app. The delay resets on success,
|
||||
reconnect, delivery-mode change, and visibility change, so recovery is still immediate once the
|
||||
condition clears.
|
||||
condition clears. Republishing the same visibility set is a no-op and never bypasses backoff.
|
||||
|
||||
Background retries are silent. The retry the user presses in the sync-error callout is a fallible
|
||||
user action and owns its pending state: `retrying` is a status the sync model publishes, not a React
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-TNwqDZ9gJP613iYmDzVfK5vfye5NBloKN5Jt9t7yhx8=
|
||||
sha256-o8d/nX0FdM6QB7c/RWpo1USsz8UNmwohSqkS7tflayc=
|
||||
|
||||
Generated
+27
-27
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.5.0-beta.4",
|
||||
"version": "0.5.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.5.0-beta.4",
|
||||
"version": "0.5.2",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -36140,7 +36140,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.5.0-beta.4",
|
||||
"version": "0.5.2",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -37192,12 +37192,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.5.0-beta.4",
|
||||
"version": "0.5.2",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.5.0-beta.4",
|
||||
"@getpaseo/protocol": "0.5.0-beta.4",
|
||||
"@getpaseo/server": "0.5.0-beta.4",
|
||||
"@getpaseo/client": "0.5.2",
|
||||
"@getpaseo/protocol": "0.5.2",
|
||||
"@getpaseo/server": "0.5.2",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -37444,10 +37444,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.5.0-beta.4",
|
||||
"version": "0.5.2",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.5.0-beta.4",
|
||||
"@getpaseo/relay": "0.5.0-beta.4",
|
||||
"@getpaseo/protocol": "0.5.2",
|
||||
"@getpaseo/relay": "0.5.2",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37458,7 +37458,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.5.0-beta.4",
|
||||
"version": "0.5.2",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -37701,7 +37701,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.5.0-beta.4",
|
||||
"version": "0.5.2",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -38597,7 +38597,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.5.0-beta.4",
|
||||
"version": "0.5.2",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -38626,12 +38626,12 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.5.0-beta.4",
|
||||
"version": "0.5.2",
|
||||
"dependencies": {
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.5.0-beta.4",
|
||||
"@getpaseo/client": "0.5.2",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.1.0",
|
||||
@@ -38640,14 +38640,14 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.5.0-beta.4",
|
||||
"@getpaseo/client": "0.5.2",
|
||||
"react": "19.1.0",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.5.0-beta.4",
|
||||
"version": "0.5.2",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -38660,7 +38660,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.5.0-beta.4",
|
||||
"version": "0.5.2",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38878,19 +38878,19 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.5.0-beta.4",
|
||||
"version": "0.5.2",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.220",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@babel/parser": "^7.29.8",
|
||||
"@getpaseo/client": "0.5.0-beta.4",
|
||||
"@getpaseo/highlight": "0.5.0-beta.4",
|
||||
"@getpaseo/plugin": "0.5.0-beta.4",
|
||||
"@getpaseo/protocol": "0.5.0-beta.4",
|
||||
"@getpaseo/relay": "0.5.0-beta.4",
|
||||
"@getpaseo/client": "0.5.2",
|
||||
"@getpaseo/highlight": "0.5.2",
|
||||
"@getpaseo/plugin": "0.5.2",
|
||||
"@getpaseo/protocol": "0.5.2",
|
||||
"@getpaseo/relay": "0.5.2",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
"@xterm/headless": "^6.0.0",
|
||||
"ai": "5.0.78",
|
||||
@@ -39940,7 +39940,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.5.0-beta.4",
|
||||
"version": "0.5.2",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.5.0-beta.4",
|
||||
"version": "0.5.2",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -838,25 +838,36 @@ test("desktop Changes toggles a navigation tree beside the expanded diff documen
|
||||
await testInfo.attach("changes-toolbar", { path: screenshot, contentType: "image/png" });
|
||||
});
|
||||
|
||||
test("compact Changes keeps its options control touch-sized", async ({ page }) => {
|
||||
test("compact Changes keeps its actions compact and menu-only", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
await page.setViewportSize({ width: 480, height: 900 });
|
||||
|
||||
const actions = page.getByTestId("changes-actions-menu-trigger").filter({ visible: true });
|
||||
const options = page.getByRole("button", { name: "Diff options" }).filter({ visible: true });
|
||||
const [optionsBox, glyphBox] = await Promise.all([
|
||||
const [actionsBox, optionsBox, glyphBox] = await Promise.all([
|
||||
actions.boundingBox(),
|
||||
options.boundingBox(),
|
||||
options.locator("svg").boundingBox(),
|
||||
]);
|
||||
if (!optionsBox || !glyphBox) {
|
||||
throw new Error("Compact Changes options geometry could not be measured");
|
||||
if (!actionsBox || !optionsBox || !glyphBox) {
|
||||
throw new Error("Compact Changes toolbar geometry could not be measured");
|
||||
}
|
||||
expect(actionsBox.width).toBe(48);
|
||||
expect(actionsBox.height).toBe(28);
|
||||
expect(optionsBox.width).toBe(32);
|
||||
expect(optionsBox.height).toBe(32);
|
||||
expect(glyphBox.width).toBe(18);
|
||||
expect(glyphBox.height).toBe(18);
|
||||
|
||||
await expect(actions).not.toContainText("Commit");
|
||||
await expect(actions.locator("svg")).toHaveCount(2);
|
||||
await actions.click();
|
||||
await expect(page.getByTestId("changes-primary-cta-menu")).toBeVisible();
|
||||
await expect(page.getByTestId("changes-menu-commit")).toContainText("Commit");
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await options.click();
|
||||
const wrapLines = page.getByText("Wrap long lines", { exact: true });
|
||||
await expect(wrapLines).toBeVisible();
|
||||
@@ -996,6 +1007,23 @@ test("canvas diff creates, edits, and deletes an inline review without DOM code
|
||||
await expect(page.locator('[data-testid^="diff-code-row-"]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("autofocusing an inline review keeps the Changes tab focused", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
const changesTab = page.getByTestId("workspace-tab-working_diff").filter({ visible: true });
|
||||
const focusedBackground = await changesTab.evaluate(
|
||||
(element) => getComputedStyle(element).backgroundColor,
|
||||
);
|
||||
|
||||
await startReviewOnFirstChangedLine(page);
|
||||
await expect(page.getByTestId("inline-review-editor-input")).toBeFocused();
|
||||
await expect
|
||||
.poll(() => changesTab.evaluate((element) => getComputedStyle(element).backgroundColor))
|
||||
.toBe(focusedBackground);
|
||||
});
|
||||
|
||||
test("split canvas creates a review on the changed side and keeps it in that column", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -1095,7 +1123,7 @@ test("canvas diff copies a dragged character selection without opening a review"
|
||||
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("the first click after a canvas selection only dismisses the selection", async ({ page }) => {
|
||||
test("clicking the canvas dismisses a selection without opening a review", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithExactSelectionDiff("ABCDEFGHIJ");
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openSelectionWorkspaceChanges(page, workspace);
|
||||
@@ -1105,7 +1133,7 @@ test("the first click after a canvas selection only dismisses the selection", as
|
||||
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
|
||||
|
||||
await clickFirstChangedLine(page);
|
||||
await expect(page.getByTestId("inline-review-editor")).toBeVisible();
|
||||
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("canvas diff replaces a selection with forward and backward drags", async ({
|
||||
@@ -1537,7 +1565,8 @@ async function startReviewOnFirstChangedLine(
|
||||
if (!bodyBounds) throw new Error("Expanded diff body has no bounds");
|
||||
const lineHeight = Math.round(fontSize * 1.5);
|
||||
const columnLeft = side === "right" ? bodyBounds.x + bodyBounds.width / 2 : bodyBounds.x;
|
||||
await page.mouse.click(columnLeft + 20, bodyBounds.y + lineHeight * 1.5);
|
||||
await page.mouse.move(columnLeft + 20, bodyBounds.y + lineHeight * 1.5);
|
||||
await page.getByRole("button", { name: "Add review comment" }).click();
|
||||
await expect(page.getByTestId("inline-review-editor")).toBeVisible();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||
const CHANGES_SHORTCUT = `${process.platform === "darwin" ? "Meta" : "Control"}+Shift+G`;
|
||||
|
||||
function mainPane(page: Page) {
|
||||
return page.getByTestId("workspace-pane-main").filter({ visible: true });
|
||||
}
|
||||
|
||||
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 {
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
|
||||
await page.keyboard.press(CHANGES_SHORTCUT);
|
||||
|
||||
await expect(sidePanel(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(mainPane(page).getByTestId("workspace-tab-working_diff")).toHaveCount(0);
|
||||
} finally {
|
||||
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 {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { openFilesPanel } from "../support/helpers/workspace-tabs";
|
||||
|
||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||
|
||||
function visibleMainPane(page: Page) {
|
||||
return page.getByTestId("workspace-pane-main").filter({ visible: true });
|
||||
}
|
||||
|
||||
async function seedChangedAgent(repoPrefix: string) {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix,
|
||||
title: "Composer diff stat",
|
||||
repo: { withRemote: true },
|
||||
});
|
||||
try {
|
||||
await rm(path.join(workspace.cwd, "remote.git"), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(workspace.cwd, "README.md"),
|
||||
"# Temp Repo\nexport const one = 1;\nexport const two = 2;\n",
|
||||
);
|
||||
await workspace.client.checkoutRefresh(workspace.cwd);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const workspaces = await workspace.client.fetchWorkspaces();
|
||||
return (
|
||||
workspaces.entries.find((entry) => entry.id === workspace.workspaceId)?.diffStat ?? null
|
||||
);
|
||||
})
|
||||
.toEqual({ additions: 2, deletions: 0 });
|
||||
return workspace;
|
||||
} catch (error) {
|
||||
await workspace.cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
test("composer diff stat toggles Changes in the preferred Side panel", async ({ page }) => {
|
||||
const workspace = await seedChangedAgent("composer-diff-stat-side-");
|
||||
|
||||
try {
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentId: workspace.agentId,
|
||||
});
|
||||
|
||||
const pill = page.getByTestId("composer-diff-stat-pill");
|
||||
await expect(pill).toBeVisible({ timeout: 30_000 });
|
||||
await expect(pill).toContainText("+2");
|
||||
await expect(pill).toContainText("-0");
|
||||
await pill.click();
|
||||
|
||||
const sidePanel = page.getByTestId("workspace-side-panel").filter({ visible: true });
|
||||
await expect(sidePanel.getByTestId("workspace-tab-working_diff")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(sidePanel.getByTestId("working-diff-panel")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await test.step("switching from another Side panel view reveals Changes", async () => {
|
||||
await openFilesPanel(page);
|
||||
await expect(sidePanel.getByTestId("file-explorer-tree-scroll")).toBeVisible();
|
||||
|
||||
await pill.click();
|
||||
await expect(sidePanel.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 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 {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("composer diff stat opens the compact explorer instead of a Changes tab", async ({ page }) => {
|
||||
const workspace = await seedChangedAgent("composer-diff-stat-compact-");
|
||||
|
||||
try {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentId: workspace.agentId,
|
||||
});
|
||||
|
||||
await page.getByTestId("composer-diff-stat-pill").click();
|
||||
|
||||
await expect(page.getByTestId("changes-header")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(0);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("composer diff stat opens Changes in the focused pane when Side panel routing is off", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript((settingsKey) => {
|
||||
localStorage.setItem(settingsKey, JSON.stringify({ openSupportingTabsInSidePanel: false }));
|
||||
}, APP_SETTINGS_KEY);
|
||||
const workspace = await seedChangedAgent("composer-diff-stat-tab-");
|
||||
|
||||
try {
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentId: workspace.agentId,
|
||||
});
|
||||
|
||||
await page.getByTestId("composer-diff-stat-pill").click();
|
||||
|
||||
const mainPane = visibleMainPane(page);
|
||||
await expect(mainPane.getByTestId("workspace-tab-working_diff")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(mainPane.getByTestId("working-diff-panel")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("workspace-side-panel")).toHaveCount(0);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
function visibleNewTabPanel(page: Page): Locator {
|
||||
return page.getByTestId("workspace-new-tab-panel").filter({ visible: true });
|
||||
}
|
||||
|
||||
async function expectLauncherSelection(launcher: Locator, name: string): Promise<void> {
|
||||
await expect(launcher.getByRole("button", { name, exact: true })).toBeFocused();
|
||||
}
|
||||
|
||||
async function openSidePanelWithKeyboard(page: Page): Promise<Locator> {
|
||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||
await page.keyboard.press(`${modifier}+E`);
|
||||
const launcher = page
|
||||
.getByTestId("workspace-side-panel")
|
||||
.filter({ visible: true })
|
||||
.getByTestId("workspace-new-tab-panel");
|
||||
await expect(launcher).toBeVisible({ timeout: 30_000 });
|
||||
return launcher;
|
||||
}
|
||||
|
||||
test.describe("New tab keyboard launcher", () => {
|
||||
test("Cmd+E focuses the launcher and Enter opens the selected view", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "empty-pane-side-panel-keyboard-" });
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
|
||||
const launcher = await openSidePanelWithKeyboard(page);
|
||||
await expectLauncherSelection(launcher, "Changes");
|
||||
|
||||
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 {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a new split pane focuses its launcher and supports arrow navigation", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "empty-pane-split-keyboard-" });
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||
|
||||
const launcher = visibleNewTabPanel(page);
|
||||
await expect(launcher).toBeVisible({ timeout: 30_000 });
|
||||
await expectLauncherSelection(launcher, "Agent");
|
||||
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await expectLauncherSelection(launcher, "Terminal");
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await expectLauncherSelection(launcher, "Changes");
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await expectLauncherSelection(launcher, "Files");
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -2,9 +2,8 @@
|
||||
// 1) with the explorer pane focused, a newly appearing agent must auto-open into the
|
||||
// MAIN pane in the background — the explorer pane is a background surface and must
|
||||
// never swallow entity tabs or lose the user's focus, and
|
||||
// 2) splitting a tab out of a pane leaves that pane empty, and the app must stay
|
||||
// interactive in the [agent | empty main | explorer] state (the reporter hit an
|
||||
// app-wide lockup here with no way to close the empty pane).
|
||||
// 2) splitting a tab out of a pane leaves a New tab behind, and the app must stay
|
||||
// interactive in the [agent | New | explorer] state.
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
@@ -53,19 +52,19 @@ async function closeSeededDraftInMainPane(page: Page): Promise<void> {
|
||||
await expect(draftTabChip(page)).toHaveCount(0, { timeout: 15_000 });
|
||||
}
|
||||
|
||||
function emptyPaneChild(page: Page): Locator {
|
||||
function newTabPaneChild(page: Page): Locator {
|
||||
return page
|
||||
.getByTestId("split-group-child")
|
||||
.filter({ has: page.getByTestId("workspace-pane-empty-state") })
|
||||
.filter({ has: page.getByTestId("workspace-new-tab-panel") })
|
||||
.first();
|
||||
}
|
||||
|
||||
async function emptyPaneBox(page: Page) {
|
||||
const paneChild = emptyPaneChild(page);
|
||||
const paneChild = newTabPaneChild(page);
|
||||
await expect(paneChild).toBeVisible({ timeout: 15_000 });
|
||||
const box = await paneChild.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
if (!box) throw new Error("Empty pane has no bounding box");
|
||||
if (!box) throw new Error("New tab pane has no bounding box");
|
||||
return box;
|
||||
}
|
||||
|
||||
@@ -97,7 +96,7 @@ async function dragChipTo(
|
||||
}
|
||||
|
||||
test.describe("explorer pane tab placement", () => {
|
||||
test("agents open in the main pane and empty-pane splits keep the app usable", async ({
|
||||
test("agents open in the main pane and New-tab splits keep the app usable", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const consoleErrors: string[] = [];
|
||||
@@ -171,9 +170,9 @@ test.describe("explorer pane tab placement", () => {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
});
|
||||
// The split must have taken: agent pane + empty main + explorer = 3 tab rows.
|
||||
// The split must have taken: agent pane + New main + explorer = 3 tab rows.
|
||||
await expect(visible(page, "workspace-tabs-row")).toHaveCount(3, { timeout: 10_000 });
|
||||
await expect(page.getByTestId("workspace-pane-empty-state")).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-new-tab-panel")).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("app must stay interactive after the split", async () => {
|
||||
@@ -186,17 +185,12 @@ test.describe("explorer pane tab placement", () => {
|
||||
await chip.click({ position: { x: 12, y: 13 }, timeout: 5_000 });
|
||||
await expect(chip).toHaveAttribute("aria-selected", "true", { timeout: 5_000 });
|
||||
|
||||
// Clicking inside the empty pane (focuses it) must not lock anything. Aim
|
||||
// Clicking inside the New tab pane (focuses it) must not lock anything. Aim
|
||||
// above the vertically centred launcher, which would open a tab instead.
|
||||
const emptyPane = emptyPaneChild(page);
|
||||
const emptyPane = newTabPaneChild(page);
|
||||
await emptyPane.click({ position: { x: 40, y: 120 }, timeout: 5_000 });
|
||||
|
||||
// The empty pane's + menu must open and dismiss cleanly.
|
||||
const emptyPaneTrigger = emptyPane.getByTestId("workspace-new-tab-menu-trigger").first();
|
||||
await emptyPaneTrigger.click({ timeout: 5_000 });
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
// A second drag must work: center-drop the agent tab back into the empty pane.
|
||||
// A second drag must work: center-drop the agent tab back into the New pane.
|
||||
const target = await emptyPaneBox(page);
|
||||
await dragChipTo(page, chip, {
|
||||
x: target.x + target.width / 2,
|
||||
@@ -207,8 +201,8 @@ test.describe("explorer pane tab placement", () => {
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
// The agent chip must have moved into the previously empty pane and stay clickable.
|
||||
await expect(page.getByTestId("workspace-pane-empty-state")).toHaveCount(0, {
|
||||
// The agent chip must have replaced New in that pane and stay clickable.
|
||||
await expect(page.getByTestId("workspace-new-tab-panel")).toHaveCount(0, {
|
||||
timeout: 10_000,
|
||||
});
|
||||
await agentTabChip(page, agentId)
|
||||
|
||||
@@ -71,7 +71,7 @@ test.describe("explorer surface after upgrading from the docked sidebar", () =>
|
||||
const toggle = visible(page, "workspace-explorer-toggle").first();
|
||||
await toggle.click();
|
||||
await expect(sidePanel(page)).toHaveCount(1, { timeout: 15_000 });
|
||||
await expect(sidePanel(page).getByTestId("workspace-pane-empty-state")).toBeVisible();
|
||||
await expect(sidePanel(page).getByTestId("workspace-new-tab-panel")).toBeVisible();
|
||||
await expect(dockedSidebar(page)).toHaveCount(0);
|
||||
|
||||
await toggle.click();
|
||||
@@ -108,7 +108,7 @@ test.describe("explorer surface after upgrading from the docked sidebar", () =>
|
||||
await test.step("open the explorer so both sides are showing", async () => {
|
||||
await toggle.click();
|
||||
await expect(sidePanel(page)).toHaveCount(1, { timeout: 15_000 });
|
||||
await expect(sidePanel(page).getByTestId("workspace-pane-empty-state")).toBeVisible();
|
||||
await expect(sidePanel(page).getByTestId("workspace-new-tab-panel")).toBeVisible();
|
||||
await expect(agentList).toHaveCount(1);
|
||||
});
|
||||
|
||||
@@ -122,7 +122,7 @@ test.describe("explorer surface after upgrading from the docked sidebar", () =>
|
||||
await page.keyboard.press(`${modifier}+.`);
|
||||
await expect(agentList).toHaveCount(1, { timeout: 15_000 });
|
||||
await expect(sidePanel(page)).toHaveCount(1, { timeout: 15_000 });
|
||||
await expect(sidePanel(page).getByTestId("workspace-pane-empty-state")).toBeVisible();
|
||||
await expect(sidePanel(page).getByTestId("workspace-new-tab-panel")).toBeVisible();
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
|
||||
@@ -90,6 +90,87 @@ async function seedAgentWithFileLink(input: LinkedFile) {
|
||||
}
|
||||
|
||||
test.describe("CodeMirror workspace file editing", () => {
|
||||
test("renders a lockfile-sized read-only source with a bounded CodeMirror DOM", async ({
|
||||
page,
|
||||
}) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "file-source-lockfile-",
|
||||
title: "Large source",
|
||||
initialPrompt: "Generate a title and a git branch name. Return JSON only.",
|
||||
});
|
||||
const lockfile = `${'{"packages":['}${Array.from({ length: 42_000 }, (_, index) => `{"name":"package-${index}","version":"1.0.0"}`).join(",")}]}`;
|
||||
await writeFile(path.join(session.cwd, "package-lock.json"), lockfile, "utf8");
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await openWorkspaceFile(page, "package-lock.json");
|
||||
|
||||
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||
await expect(editor(page)).toContainText('"package-0"');
|
||||
await expect.poll(() => page.locator(".cm-line").count()).toBeLessThan(200);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the app interactive around a plain 11 MB source", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "file-source-plain-",
|
||||
title: "Plain large source",
|
||||
initialPrompt: "Generate a title and a git branch name. Return JSON only.",
|
||||
});
|
||||
await writeFile(
|
||||
path.join(session.cwd, "plain.txt"),
|
||||
"plain source\n".repeat(1_050_000),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await openWorkspaceFile(page, "plain.txt");
|
||||
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||
await expect(editor(page)).toContainText("plain source");
|
||||
await expect.poll(() => page.locator(".cm-line").count()).toBeLessThan(200);
|
||||
|
||||
await page.getByTestId(`workspace-tab-agent_${session.agentId}`).first().click();
|
||||
await expect(page.getByTestId("message-input-root")).toBeVisible();
|
||||
await page.getByTestId("workspace-tab-file_plain.txt").first().click();
|
||||
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("refuses a file above the display budget and keeps its tab recoverable", async ({
|
||||
page,
|
||||
}) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "file-source-unsupported-",
|
||||
title: "Unsupported large source",
|
||||
initialPrompt: "Generate a title and a git branch name. Return JSON only.",
|
||||
});
|
||||
await writeFile(
|
||||
path.join(session.cwd, "too-large.txt"),
|
||||
Buffer.alloc(51 * 1024 * 1024),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await openWorkspaceFile(page, "too-large.txt");
|
||||
await expect(page.getByTestId("file-source-too-large")).toContainText(
|
||||
"This file is too large to display",
|
||||
);
|
||||
|
||||
await page.getByTestId(`workspace-tab-agent_${session.agentId}`).first().click();
|
||||
await expect(page.getByTestId("message-input-root")).toBeVisible();
|
||||
await page.getByTestId("workspace-tab-file_too-large.txt").first().click();
|
||||
await expect(page.getByTestId("file-source-too-large")).toBeVisible();
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("opens an assistant file link at its referenced line", async ({ page }) => {
|
||||
const target = "target.ts:42";
|
||||
const session = await seedAgentWithFileLink({
|
||||
@@ -413,10 +494,7 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
.filter({ visible: true })
|
||||
.first()
|
||||
.click({ button: "right" });
|
||||
await page
|
||||
.getByTestId("workspace-tab-context-file_draft.ts-close")
|
||||
.filter({ visible: true })
|
||||
.click();
|
||||
await page.getByRole("menuitem", { name: "Close", exact: true }).click();
|
||||
expect(closePrompt).toContain("Closing it will discard the draft.");
|
||||
|
||||
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||
|
||||
@@ -39,6 +39,12 @@ async function assertHeldFileTransition(
|
||||
finalText: string,
|
||||
gate: Awaited<ReturnType<typeof delayFileReadResponse>>,
|
||||
) {
|
||||
await page.evaluate(() => {
|
||||
const originalChip = document.querySelector('[data-testid="workspace-tab-file_src/a.ts"]');
|
||||
(
|
||||
window as typeof window & { __fileTransitionOriginalChip?: Element | null }
|
||||
).__fileTransitionOriginalChip = originalChip;
|
||||
});
|
||||
await page.evaluate((tabTestId) => {
|
||||
const timeline: Array<{ chip: boolean; loading: boolean; unavailable: boolean }> = [];
|
||||
const sample = () => {
|
||||
@@ -69,6 +75,19 @@ async function assertHeldFileTransition(
|
||||
await expect(page.getByTestId("file-preview-unsupported")).toHaveCount(0);
|
||||
await expect(page.getByText("export const sourceA = true;")).toHaveCount(0);
|
||||
|
||||
const chipIdentity = await page.evaluate((tabTestId) => {
|
||||
const windowWithOriginal = window as typeof window & {
|
||||
__fileTransitionOriginalChip?: Element | null;
|
||||
};
|
||||
const originalChip = windowWithOriginal.__fileTransitionOriginalChip ?? null;
|
||||
const currentChip = document.querySelector(`[data-testid="${tabTestId}"]`);
|
||||
return {
|
||||
originalStillConnected: originalChip?.isConnected ?? false,
|
||||
sameNode: originalChip === currentChip,
|
||||
};
|
||||
}, `workspace-tab-file_${targetPath}`);
|
||||
expect(chipIdentity).toEqual({ originalStillConnected: true, sameNode: true });
|
||||
|
||||
const heldTimeline = await page.evaluate(
|
||||
() =>
|
||||
(
|
||||
|
||||
@@ -9,6 +9,18 @@ import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-cli
|
||||
|
||||
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-",
|
||||
@@ -21,6 +33,21 @@ test.afterAll(async () => {
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import type { Locator } from "@playwright/test";
|
||||
import {
|
||||
gotoWorkspace,
|
||||
assertNewChatTileVisible,
|
||||
assertNewTabMenuTriggerVisible,
|
||||
assertSingleNewTabButton,
|
||||
openNewTabMenuWithShortcut,
|
||||
pressDirectNewTabShortcut,
|
||||
clickNewChat,
|
||||
clickNewTerminal,
|
||||
countTabsOfKind,
|
||||
@@ -45,6 +45,16 @@ const EMPTY_PROMPT_PROFILE: TerminalProfile = {
|
||||
args: ["-c", 'echo prompt-args: "$#"; sleep 10', "profile-name", "{{{prompt}}}"],
|
||||
};
|
||||
|
||||
async function tabTestIds(tabs: Locator): Promise<(string | null)[]> {
|
||||
return tabs.evaluateAll((elements) =>
|
||||
elements.map((element) => element.getAttribute("data-testid")),
|
||||
);
|
||||
}
|
||||
|
||||
function tabIdentityKey(snapshot: Array<{ id: string }>): string {
|
||||
return JSON.stringify(snapshot.map(({ id }) => id));
|
||||
}
|
||||
|
||||
test.beforeAll(async () => {
|
||||
workspace = await seedWorkspace({ repoPrefix: "launcher-e2e-" });
|
||||
const created = await workspace.client.createWorkspace({
|
||||
@@ -66,14 +76,12 @@ test.afterAll(async () => {
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.describe("Tab creation", () => {
|
||||
test("Cmd+T keeps opening the New tab menu after workspace switches", async ({ page }) => {
|
||||
test("Cmd+T keeps creating a New tab after workspace switches", async ({ page }) => {
|
||||
if (!secondWorkspaceId) {
|
||||
throw new Error("Secondary workspace was not created");
|
||||
}
|
||||
|
||||
const serverId = getServerId();
|
||||
const newTabMenuItem = page.getByRole("menuitem", { name: /^Agent/ });
|
||||
|
||||
const switchWorkspaceRow = async (workspaceId: string) => {
|
||||
const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`).first();
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
@@ -92,11 +100,12 @@ test.describe("Tab creation", () => {
|
||||
for (let i = 0; i < 8; i++) {
|
||||
await switchWorkspaceRow(sequence[i % sequence.length]);
|
||||
await pressNewTabShortcut(page);
|
||||
await expect(newTabMenuItem, `New tab menu did not open after switch ${i}`).toBeVisible({
|
||||
await expect(
|
||||
page.getByTestId("workspace-new-tab-panel").filter({ visible: true }),
|
||||
`New tab did not open after switch ${i}`,
|
||||
).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(newTabMenuItem).toBeHidden({ timeout: 5_000 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -104,59 +113,76 @@ test.describe("Tab creation", () => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
const workspaceUrl = page.url();
|
||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||
const newTabCountBefore = await countTabsOfKind(page, "new_tab");
|
||||
|
||||
await page.keyboard.press(`${modifier}+Comma`);
|
||||
await expect(page.getByRole("navigation", { name: "Settings" })).toBeVisible();
|
||||
|
||||
await page.keyboard.press(`${modifier}+t`);
|
||||
await expect(page.getByRole("menuitem", { name: /^Agent/ })).toHaveCount(0);
|
||||
|
||||
await page.goto(workspaceUrl);
|
||||
await assertNewTabMenuTriggerVisible(page);
|
||||
const countBefore = await countTabsOfKind(page, "draft");
|
||||
await pressDirectNewTabShortcut(page, "a");
|
||||
await expect.poll(() => countTabsOfKind(page, "draft")).toBe(countBefore + 1);
|
||||
await expect.poll(() => countTabsOfKind(page, "new_tab")).toBe(newTabCountBefore);
|
||||
});
|
||||
|
||||
test("Cmd+T opens the New tab menu without creating an agent", async ({ page }) => {
|
||||
test("Cmd+T creates a New tab without creating an agent", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
const countBefore = await countTabsOfKind(page, "draft");
|
||||
|
||||
await openNewTabMenuWithShortcut(page);
|
||||
|
||||
await expect.poll(() => countTabsOfKind(page, "draft")).toBe(countBefore);
|
||||
await expect(page.getByRole("button", { name: "New tab", expanded: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("workspace-new-tab-panel").filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("opening two new tabs creates two draft tabs", async ({ page }) => {
|
||||
test("clicking + opens its menu without creating a New tab", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
const countBefore = await countTabsOfKind(page, "new_tab");
|
||||
|
||||
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
|
||||
|
||||
await expect(
|
||||
page.getByTestId("workspace-new-tab-menu").filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
await expect.poll(() => countTabsOfKind(page, "new_tab")).toBe(countBefore);
|
||||
});
|
||||
|
||||
test("opening two New tabs creates two independent tab identities", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
|
||||
const countBefore = await countTabsOfKind(page, "draft");
|
||||
const newTabs = page
|
||||
.locator('[data-testid^="workspace-tab-tab_"]')
|
||||
.filter({ hasText: "New tab" });
|
||||
const countBefore = await newTabs.count();
|
||||
|
||||
await pressDirectNewTabShortcut(page, "a");
|
||||
await expect
|
||||
.poll(() => countTabsOfKind(page, "draft"), { timeout: 15_000 })
|
||||
.toBe(countBefore + 1);
|
||||
const countAfterFirst = await countTabsOfKind(page, "draft");
|
||||
await pressNewTabShortcut(page);
|
||||
await expect(newTabs).toHaveCount(countBefore + 1);
|
||||
const firstIds = await tabTestIds(newTabs);
|
||||
|
||||
await pressDirectNewTabShortcut(page, "a");
|
||||
await expect
|
||||
.poll(() => countTabsOfKind(page, "draft"), { timeout: 15_000 })
|
||||
.toBe(countAfterFirst + 1);
|
||||
await pressNewTabShortcut(page);
|
||||
await expect(newTabs).toHaveCount(countBefore + 2);
|
||||
const ids = await tabTestIds(newTabs);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
expect(ids.filter((id) => !firstIds.includes(id))).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("New tab menu exposes shortcuts and supports arrow navigation", async ({ page }) => {
|
||||
test("New tab exposes shortcuts and supports arrow navigation after refocus", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openNewTabMenuWithShortcut(page);
|
||||
|
||||
const agent = page.getByRole("menuitem", { name: /^Agent/ });
|
||||
const terminal = page.getByRole("menuitem", { name: /^Terminal/ });
|
||||
const changes = page.getByRole("menuitem", { name: /Changes/ });
|
||||
const files = page.getByRole("menuitem", { name: /Files/ });
|
||||
const panel = page.getByTestId("workspace-new-tab-panel").filter({ visible: true });
|
||||
const agent = panel.getByRole("button", { name: /^Agent/ });
|
||||
const terminal = panel.getByRole("button", { name: /^Terminal/ });
|
||||
const changes = panel.getByRole("button", { name: /Changes/ });
|
||||
const files = panel.getByRole("button", { name: /Files/ });
|
||||
const shortcutPrefix = process.platform === "darwin" ? /⇧⌘/ : /Ctrl.*Shift/;
|
||||
await expect(agent).toContainText(new RegExp(`${shortcutPrefix.source}.*A`));
|
||||
await expect(terminal).toContainText(new RegExp(`${shortcutPrefix.source}.*T`));
|
||||
await expect(changes).toContainText(new RegExp(`${shortcutPrefix.source}.*C`));
|
||||
await expect(changes).toContainText(new RegExp(`${shortcutPrefix.source}.*G`));
|
||||
await expect(files).toContainText(new RegExp(`${shortcutPrefix.source}.*E`));
|
||||
await expect(agent).toBeFocused();
|
||||
|
||||
@@ -164,12 +190,10 @@ test.describe("Tab creation", () => {
|
||||
await expect(terminal).toBeFocused();
|
||||
await page.keyboard.press("ArrowUp");
|
||||
await expect(agent).toBeFocused();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("button", { name: "New tab" })).toBeFocused();
|
||||
await expect(page.getByRole("button", { name: "New tab" })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"false",
|
||||
);
|
||||
|
||||
await page.locator("body").click({ position: { x: 1, y: 1 } });
|
||||
await panel.click({ position: { x: 20, y: 20 } });
|
||||
await expect(agent).toBeFocused();
|
||||
});
|
||||
|
||||
test("clicking new agent tab creates a draft tab", async ({ page }) => {
|
||||
@@ -205,8 +229,12 @@ test.describe("Tab creation", () => {
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }).click();
|
||||
await page.getByRole("menuitem", { name: EMPTY_PROMPT_PROFILE.name }).click();
|
||||
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
|
||||
await page
|
||||
.getByTestId("workspace-new-tab-menu")
|
||||
.filter({ visible: true })
|
||||
.getByRole("menuitem", { name: EMPTY_PROMPT_PROFILE.name })
|
||||
.click();
|
||||
|
||||
await expectTerminalOutputContains(page, "prompt-args: 0");
|
||||
} finally {
|
||||
@@ -214,6 +242,20 @@ test.describe("Tab creation", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("terminal profiles are grouped with a settings action", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
|
||||
|
||||
const menu = page.getByTestId("workspace-new-tab-menu").filter({ visible: true });
|
||||
await expect(menu.getByText("Terminal profiles", { exact: true })).toBeVisible();
|
||||
|
||||
const editProfiles = menu.getByTestId("workspace-new-tab-menu-edit-terminal-profiles");
|
||||
await expect(editProfiles).toHaveAccessibleName("Edit profiles");
|
||||
|
||||
await editProfiles.click();
|
||||
await expect(page).toHaveURL(/\/settings\/hosts\/[^/]+\/terminals$/);
|
||||
});
|
||||
|
||||
test("tab bar shows action buttons per pane", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await assertSingleNewTabButton(page);
|
||||
@@ -226,15 +268,13 @@ test.describe("Tab creation", () => {
|
||||
|
||||
const tabRow = page.getByTestId("workspace-tabs-row").filter({ visible: true }).first();
|
||||
const scrollArea = tabRow.getByTestId("workspace-tabs-scroll");
|
||||
const newTabButtonInScroll = await scrollArea
|
||||
.getByTestId("workspace-new-tab-menu-trigger")
|
||||
.count();
|
||||
const newTabButtonInScroll = await scrollArea.getByTestId("workspace-new-tab-button").count();
|
||||
const scrollShades = await tabRow
|
||||
.locator('[data-testid^="workspace-tabs-scroll-shade-"]')
|
||||
.count();
|
||||
|
||||
expect(newTabButtonInScroll === 0 || scrollShades === 0).toBe(true);
|
||||
await expect(tabRow.getByTestId("workspace-new-tab-menu-trigger")).toBeVisible();
|
||||
await expect(tabRow.getByTestId("workspace-new-tab-button")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -333,28 +373,34 @@ test.describe("Terminal title propagation", () => {
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.describe("Tab transitions (no flash)", () => {
|
||||
test("New agent tab transition has no blank intermediate tab state", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
test("New agent tab transition has no blank intermediate tab state", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const isolatedWorkspace = await withWorkspace({ prefix: "launcher-no-flash-" });
|
||||
await isolatedWorkspace.navigateTo();
|
||||
await pressNewTabShortcut(page);
|
||||
await expect(
|
||||
page.getByTestId("workspace-new-tab-panel").filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
|
||||
// Sample tabs at high frequency across the transition
|
||||
const snapshots = await sampleTabsDuringTransition(page, () => clickNewChat(page));
|
||||
// Sample the single New → Agent replacement, not the separate action that
|
||||
// creates the New tab in the first place.
|
||||
const snapshots = await sampleTabsDuringTransition(page, async () => {
|
||||
await page.getByTestId("workspace-new-tab-agent").filter({ visible: true }).first().click();
|
||||
});
|
||||
|
||||
// Every snapshot should have at least one tab — no blank/zero-tab frames
|
||||
for (const snapshot of snapshots) {
|
||||
expect(snapshot.length).toBeGreaterThanOrEqual(1);
|
||||
}
|
||||
|
||||
// Tab count should never spike excessively (no duplicate flash from add-then-remove).
|
||||
// When running in-suite, previous tests may have created tabs on the shared workspace,
|
||||
// so we allow +2 tolerance for accumulated state and React render batching.
|
||||
// Replacement is atomic: the set of identities changes once, while its size stays fixed.
|
||||
const counts = snapshots.map((snapshot) => snapshot.length);
|
||||
const maxCount = Math.max(...counts);
|
||||
const initialCount = counts[0] ?? 0;
|
||||
|
||||
expect(maxCount).toBeLessThanOrEqual(initialCount + 2);
|
||||
expect(new Set(snapshots.map((snapshot) => JSON.stringify(snapshot))).size).toBeLessThanOrEqual(
|
||||
2,
|
||||
);
|
||||
expect(counts.every((count) => count === initialCount)).toBe(true);
|
||||
expect(new Set(snapshots.map(tabIdentityKey)).size).toBeLessThanOrEqual(2);
|
||||
await expectTabTitleFits(page, "New Agent", { min: 96, max: 160 });
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
|
||||
test("a workspace with an existing agent never presents New during startup", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "new-tab-startup-agent-" });
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Existing startup agent",
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
initialPrompt: "ready",
|
||||
});
|
||||
|
||||
try {
|
||||
await page.addInitScript(() => {
|
||||
Reflect.set(globalThis, "__paseoSawVisibleNewTab", false);
|
||||
function recordVisibleNewTab() {
|
||||
const panel = document.querySelector('[data-testid="workspace-new-tab-panel"]');
|
||||
if (panel?.getClientRects().length) {
|
||||
Reflect.set(globalThis, "__paseoSawVisibleNewTab", true);
|
||||
}
|
||||
}
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
new MutationObserver(recordVisibleNewTab).observe(document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
recordVisibleNewTab();
|
||||
});
|
||||
});
|
||||
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${agent.id}`).filter({ visible: true }),
|
||||
).toHaveAttribute("aria-selected", "true", { timeout: 30_000 });
|
||||
await expect(page.getByTestId("workspace-new-tab-panel").filter({ visible: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
expect(await page.evaluate(() => Reflect.get(globalThis, "__paseoSawVisibleNewTab"))).toBe(
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -18,18 +18,20 @@ function mainPane(page: Page): Locator {
|
||||
return page.getByTestId("workspace-pane-main").filter({ visible: true });
|
||||
}
|
||||
|
||||
function emptyState(scope: Locator): Locator {
|
||||
return scope.getByTestId("workspace-pane-empty-state");
|
||||
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();
|
||||
}
|
||||
|
||||
function closePaneButton(page: Page): Locator {
|
||||
return page.getByRole("button", { name: "Close pane" });
|
||||
}
|
||||
|
||||
/** 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();
|
||||
@@ -61,13 +63,20 @@ test.describe("Side panel", () => {
|
||||
await test.step("revealing the Side panel seeds nothing", async () => {
|
||||
await sidePanelToggle(page).click();
|
||||
await expect(sidePanel(page)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(emptyState(sidePanel(page))).toBeVisible();
|
||||
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 emptyState(sidePanel(page)).getByRole("button", { name: "Changes" }).click();
|
||||
await newTabPanel(sidePanel(page)).getByRole("button", { name: "Changes" }).click();
|
||||
await expect(sidePanel(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
@@ -77,10 +86,6 @@ test.describe("Side panel", () => {
|
||||
await capture(page, testInfo, "02-changes-opens-in-side-panel");
|
||||
});
|
||||
|
||||
await test.step("a loaded pane offers no one-tap close", async () => {
|
||||
await expect(closePaneButton(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
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();
|
||||
@@ -93,7 +98,7 @@ test.describe("Side panel", () => {
|
||||
|
||||
await sidePanelToggle(page).click();
|
||||
await expect(sidePanel(page)).toBeVisible({ timeout: 15_000 });
|
||||
await expect(emptyState(sidePanel(page))).toBeVisible();
|
||||
await expect(newTabPanel(sidePanel(page))).toBeVisible();
|
||||
await capture(page, testInfo, "04-side-panel-revealed-empty");
|
||||
});
|
||||
} finally {
|
||||
@@ -123,23 +128,30 @@ test.describe("Side panel", () => {
|
||||
.first();
|
||||
await expect(draftPane).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const emptyMain = emptyState(mainPane(page));
|
||||
const emptyMain = newTabPanel(mainPane(page));
|
||||
await expect(emptyMain).toBeVisible({ timeout: 15_000 });
|
||||
await capture(page, testInfo, "06-empty-main-pane-launcher");
|
||||
|
||||
await emptyMain.getByRole("button", { name: "Close pane" }).click();
|
||||
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-pane-empty-state")).toHaveCount(0);
|
||||
await expect(draftPane.getByTestId("workspace-new-tab-panel")).toHaveCount(0);
|
||||
await capture(page, testInfo, "07-empty-main-pane-removed");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("the last pane on screen offers no Close pane", async ({ page }, testInfo) => {
|
||||
test("closing New cannot remove the last visible pane", async ({ page }, testInfo) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "side-panel-final-pane-" });
|
||||
|
||||
try {
|
||||
@@ -150,7 +162,9 @@ test.describe("Side panel", () => {
|
||||
// holds that tab, so emptying Main does not look like an empty workspace and
|
||||
// nothing reseeds a draft into it.
|
||||
await ensureSidePanel(page);
|
||||
await emptyState(sidePanel(page)).getByRole("button", { name: "Files" }).click();
|
||||
await newTabPanel(sidePanel(page))
|
||||
.getByRole("button", { name: "Files", exact: true })
|
||||
.click();
|
||||
await expect(sidePanel(page).getByTestId("workspace-tab-files")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
@@ -168,13 +182,20 @@ test.describe("Side panel", () => {
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// Main is now empty and the only pane the user can see. Taking it away would
|
||||
// leave nowhere to look, so the launcher appears without a Close pane.
|
||||
const lastPane = emptyState(mainPane(page));
|
||||
// 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" })).toBeVisible();
|
||||
await expect(lastPane.getByRole("button", { name: "Close pane" })).toHaveCount(0);
|
||||
await expect(closePaneButton(page)).toHaveCount(0);
|
||||
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();
|
||||
@@ -190,13 +211,13 @@ test.describe("Side panel", () => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await ensureSidePanel(page);
|
||||
await emptyState(sidePanel(page)).getByRole("button", { name: "Changes" }).click();
|
||||
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-menu-trigger").click();
|
||||
await mainPane(page).getByTestId("workspace-new-tab-button").click();
|
||||
await page
|
||||
.getByTestId("workspace-new-tab-menu-changes")
|
||||
.filter({ visible: true })
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { test, expect, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import {
|
||||
expectNewWorkspaceProjectSelected,
|
||||
openNewWorkspaceComposer,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { selectSidebarStatusGrouping } from "../support/helpers/sidebar";
|
||||
@@ -142,6 +146,25 @@ async function installPinRpcGate(
|
||||
}
|
||||
|
||||
test.describe("Pin workspace shortcut", () => {
|
||||
test("keeps project creation reachable after pinning its only workspace", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "pin-project-creation-",
|
||||
title: "Pinned workspace",
|
||||
});
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await openWorkspace(page, workspace.workspaceId);
|
||||
await page.keyboard.press(PIN_SHORTCUT);
|
||||
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await openNewWorkspaceComposer(page, workspace);
|
||||
await expectNewWorkspaceProjectSelected(page, workspace.projectDisplayName);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("pins the active workspace while its project section is collapsed", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-collapsed-" });
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ async function verifySplitDownResizesPty(page: Page, harness: TerminalE2EHarness
|
||||
beforeXterm.rows,
|
||||
);
|
||||
|
||||
// Split the pane downward. This focuses the new empty pane, so the terminal
|
||||
// Split the pane downward. This focuses the new tab pane, so the terminal
|
||||
// pane is unfocused at the exact moment its container shrinks.
|
||||
await runWorkspaceActionFromCommandCenter(page, "Split pane down");
|
||||
|
||||
|
||||
@@ -59,8 +59,12 @@ async function createTerminalViaMenu(page: Page): Promise<void> {
|
||||
// Workspaces always render a hidden explorer companion pane alongside the
|
||||
// main pane, so an unscoped testid locator matches both; scope to the
|
||||
// visible one.
|
||||
await page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }).click();
|
||||
await page.getByTestId("workspace-new-tab-menu-terminal").click();
|
||||
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
|
||||
await page
|
||||
.getByTestId("workspace-new-tab-menu-terminal")
|
||||
.filter({ visible: true })
|
||||
.first()
|
||||
.click();
|
||||
}
|
||||
|
||||
async function listTerminalIds(harness: TerminalE2EHarness): Promise<string[]> {
|
||||
@@ -128,7 +132,7 @@ test.describe("terminal PTY size claim under lost window focus", () => {
|
||||
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), harness.workspaceId));
|
||||
await expect(
|
||||
page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }),
|
||||
page.getByTestId("workspace-new-tab-button").filter({ visible: true }),
|
||||
).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
@@ -133,7 +133,7 @@ test.describe("Workspace agent title handoff", () => {
|
||||
.toBe(promptTitle);
|
||||
await expect(agentTab).toContainText(promptTitle, { timeout: 15_000 });
|
||||
await agentTab.click({ button: "right" });
|
||||
await expect(page.getByTestId(`workspace-tab-context-agent_${agentId}-rename`)).toBeVisible({
|
||||
await expect(page.getByRole("menuitem", { name: "Rename", exact: true })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
@@ -65,10 +65,10 @@ test.describe("Workspace pane mounting", () => {
|
||||
.first()
|
||||
.elementHandle();
|
||||
expect(originalComposer).not.toBeNull();
|
||||
const emptyPanesBeforeSplit = await page.getByTestId("workspace-pane-empty-state").count();
|
||||
const emptyPanesBeforeSplit = await page.getByTestId("workspace-new-tab-panel").count();
|
||||
|
||||
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||
await expect(page.getByTestId("workspace-pane-empty-state")).toHaveCount(
|
||||
await expect(page.getByTestId("workspace-new-tab-panel")).toHaveCount(
|
||||
emptyPanesBeforeSplit + 1,
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
|
||||
@@ -70,9 +70,9 @@ test.describe("Workspace terminal tab rename", () => {
|
||||
await expect(tab).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await tab.click({ button: "right" });
|
||||
const copyTerminalId = page.getByTestId(
|
||||
`workspace-tab-context-terminal_${terminalId}-copy-terminal-id`,
|
||||
);
|
||||
const copyTerminalId = page.getByRole("menuitem", {
|
||||
name: /^Copy terminal id/,
|
||||
});
|
||||
await expect(copyTerminalId).toBeVisible({ timeout: 10_000 });
|
||||
await copyTerminalId.click();
|
||||
|
||||
@@ -99,10 +99,7 @@ test.describe("Workspace terminal tab rename", () => {
|
||||
await expect(tab).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await tab.click({ button: "right" });
|
||||
await expect(page.getByTestId(`workspace-tab-context-terminal_${terminalId}`)).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
const renameItem = page.getByTestId(`workspace-tab-context-terminal_${terminalId}-rename`);
|
||||
const renameItem = page.getByRole("menuitem", { name: "Rename", exact: true });
|
||||
await expect(renameItem).toBeVisible({ timeout: 10_000 });
|
||||
await renameItem.click();
|
||||
|
||||
|
||||
@@ -275,7 +275,7 @@ async function fillAgentProfileForm(page: Page, draft: AgentProfileDraft): Promi
|
||||
await chooseSelectFieldOption(page, { field: "mode", option: draft.mode });
|
||||
}
|
||||
if (draft.notes !== undefined) {
|
||||
await modal.getByRole("textbox", { name: "Notes for agents", exact: true }).fill(draft.notes);
|
||||
await modal.getByRole("textbox", { name: "When to use", exact: true }).fill(draft.notes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,11 +329,18 @@ export async function expectAgentProfileForm(
|
||||
const modal = editModal(page);
|
||||
await expect(modal).toBeVisible({ timeout: 30_000 });
|
||||
await expect(modal.getByRole("textbox", { name: "Name", exact: true })).toHaveValue(name);
|
||||
const notesHint = modal.getByTestId("agent-profile-notes-field-hint");
|
||||
await expect(notesHint).toHaveText(
|
||||
"Helps agents choose this profile when starting another agent.",
|
||||
);
|
||||
expect(await notesHint.evaluate((element) => getComputedStyle(element).webkitLineClamp)).not.toBe(
|
||||
"1",
|
||||
);
|
||||
for (const [field, value] of Object.entries(expected)) {
|
||||
if (field === "notes") {
|
||||
await expect(
|
||||
modal.getByRole("textbox", { name: "Notes for agents", exact: true }),
|
||||
).toHaveValue(value);
|
||||
await expect(modal.getByRole("textbox", { name: "When to use", exact: true })).toHaveValue(
|
||||
value,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
await expect(
|
||||
|
||||
@@ -62,7 +62,7 @@ export async function getActiveTabTestId(page: Page): Promise<string | null> {
|
||||
|
||||
// ─── Tab actions ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Press Cmd+T (macOS) or Ctrl+T (Linux/Windows) to open the focused pane's New tab menu. */
|
||||
/** Press Cmd+T (macOS) or Ctrl+T (Linux/Windows) to create a New tab in the focused pane. */
|
||||
export async function pressNewTabShortcut(page: Page): Promise<void> {
|
||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||
await page.keyboard.press(`${modifier}+t`);
|
||||
@@ -70,7 +70,7 @@ export async function pressNewTabShortcut(page: Page): Promise<void> {
|
||||
|
||||
export async function openNewTabMenuWithShortcut(page: Page): Promise<void> {
|
||||
await pressNewTabShortcut(page);
|
||||
await expect(page.getByRole("menuitem", { name: /^Agent/ })).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-new-tab-panel").filter({ visible: true })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function pressDirectNewTabShortcut(page: Page, key: string): Promise<void> {
|
||||
@@ -83,30 +83,27 @@ export async function pressDirectNewTabShortcut(page: Page, key: string): Promis
|
||||
/** Assert the inline plus button is visible in the tab bar. */
|
||||
export async function assertNewChatTileVisible(page: Page): Promise<void> {
|
||||
await expect(
|
||||
page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }).first(),
|
||||
page.getByTestId("workspace-new-tab-button").filter({ visible: true }).first(),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
/** Assert the new-tab dropdown trigger is visible in the tab bar. */
|
||||
/** Assert the New tab button is visible in the tab bar. */
|
||||
export async function assertNewTabMenuTriggerVisible(page: Page): Promise<void> {
|
||||
await expect(
|
||||
page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }).first(),
|
||||
page.getByTestId("workspace-new-tab-button").filter({ visible: true }).first(),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
// ─── Tab creation actions ─────────────────────────────────────────────────
|
||||
|
||||
/** Open the new-tab menu and click "New agent" to create a draft/chat tab. */
|
||||
/** Choose Agent from the pane-local `+` menu. */
|
||||
export async function clickNewChat(page: Page): Promise<void> {
|
||||
await createAgentTabFromMenu(page);
|
||||
}
|
||||
|
||||
/** Open the new-tab menu and click "New terminal". */
|
||||
/** Choose Terminal from the pane-local `+` menu. */
|
||||
export async function clickNewTerminal(page: Page): Promise<void> {
|
||||
const trigger = page
|
||||
.getByTestId("workspace-new-tab-menu-trigger")
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
const trigger = page.getByTestId("workspace-new-tab-button").filter({ visible: true }).first();
|
||||
await expect(trigger).toBeVisible({ timeout: 10_000 });
|
||||
await trigger.click();
|
||||
const item = page
|
||||
@@ -137,7 +134,7 @@ export async function waitForTabWithTitle(
|
||||
|
||||
/** Assert the inline plus button is visible in the tab bar. */
|
||||
export async function assertSingleNewTabButton(page: Page): Promise<void> {
|
||||
const buttons = page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true });
|
||||
const buttons = page.getByTestId("workspace-new-tab-button").filter({ visible: true });
|
||||
const count = await buttons.count();
|
||||
expect(count).toBeGreaterThanOrEqual(1);
|
||||
}
|
||||
@@ -224,7 +221,7 @@ export async function expectTabTitleFits(
|
||||
}
|
||||
|
||||
export function terminalSurfaceLocator(page: Page) {
|
||||
return page.locator('[data-testid="terminal-surface"]').first();
|
||||
return page.locator('[data-testid="terminal-surface"]').filter({ visible: true }).first();
|
||||
}
|
||||
|
||||
export async function expectAgentTabActive(page: Page, agentId: string): Promise<void> {
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface MockAgentWorkspace {
|
||||
export interface MockAgentOptions {
|
||||
repoPrefix: string;
|
||||
title: string;
|
||||
repo?: Parameters<typeof seedWorkspace>[0]["repo"];
|
||||
port?: number;
|
||||
initialPrompt?: string;
|
||||
model?: string;
|
||||
@@ -29,7 +30,11 @@ export interface MockAgentOptions {
|
||||
export async function seedMockAgentWorkspace(
|
||||
options: MockAgentOptions,
|
||||
): Promise<MockAgentWorkspace> {
|
||||
const workspace = await seedWorkspace({ repoPrefix: options.repoPrefix, port: options.port });
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: options.repoPrefix,
|
||||
repo: options.repo,
|
||||
port: options.port,
|
||||
});
|
||||
try {
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
|
||||
@@ -113,6 +113,11 @@ export async function enableRelayAndExpectOffer(page: Page): Promise<void> {
|
||||
export async function expectPairingOffer(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("img", { name: "Pairing QR code" })).toBeVisible();
|
||||
await expect(page.getByRole("textbox", { name: "Pairing link" })).toHaveValue(/#offer=/);
|
||||
await expect(
|
||||
page.getByRole("alert").filter({
|
||||
hasText: "Treat this pairing link like a password. Anyone with it can access this daemon.",
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Copy", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Enable relay", exact: true })).toHaveCount(0);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface SeedWorkspaceDescriptor {
|
||||
projectDisplayName: string;
|
||||
projectRootPath: string;
|
||||
workspaceDirectory: string;
|
||||
diffStat: { additions: number; deletions: number } | null;
|
||||
labels?: string[];
|
||||
}
|
||||
|
||||
|
||||
@@ -88,13 +88,16 @@ export async function ensureSidePanel(page: Page): Promise<Locator> {
|
||||
*/
|
||||
async function openSidePanelView(
|
||||
page: Page,
|
||||
view: { tabTestId: string; menuTestId: string; contentTestId: string; timeout?: number },
|
||||
view: { tabTestId: string; launchItemId: string; contentTestId: string; timeout?: number },
|
||||
): Promise<void> {
|
||||
const panel = await ensureSidePanel(page);
|
||||
const tab = panel.getByTestId(view.tabTestId);
|
||||
if ((await tab.count()) === 0) {
|
||||
await panel.getByTestId("workspace-new-tab-menu-trigger").click();
|
||||
await visibleTestId(page, view.menuTestId).first().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({
|
||||
@@ -105,7 +108,7 @@ async function openSidePanelView(
|
||||
export async function openChangesPanel(page: Page): Promise<void> {
|
||||
await openSidePanelView(page, {
|
||||
tabTestId: "workspace-tab-working_diff",
|
||||
menuTestId: "workspace-new-tab-menu-changes",
|
||||
launchItemId: "changes",
|
||||
contentTestId: "working-diff-panel",
|
||||
});
|
||||
}
|
||||
@@ -113,7 +116,7 @@ export async function openChangesPanel(page: Page): Promise<void> {
|
||||
export async function openFilesPanel(page: Page): Promise<void> {
|
||||
await openSidePanelView(page, {
|
||||
tabTestId: "workspace-tab-files",
|
||||
menuTestId: "workspace-new-tab-menu-files",
|
||||
launchItemId: "files",
|
||||
contentTestId: "file-explorer-tree-scroll",
|
||||
});
|
||||
}
|
||||
@@ -127,7 +130,7 @@ export async function openPullRequestPanel(page: Page): Promise<void> {
|
||||
}
|
||||
await openSidePanelView(page, {
|
||||
tabTestId: "workspace-tab-pull_request",
|
||||
menuTestId: "workspace-new-tab-menu-pull-request",
|
||||
launchItemId: "pull-request",
|
||||
contentTestId: "pr-pane",
|
||||
timeout: 15_000,
|
||||
});
|
||||
@@ -137,14 +140,14 @@ export async function waitForWorkspaceTabsVisible(page: Page): Promise<void> {
|
||||
await expect(visibleTestId(page, "workspace-tabs-row").first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(visibleTestId(page, "workspace-new-tab-menu-trigger").first()).toBeVisible({
|
||||
await expect(visibleTestId(page, "workspace-new-tab-button").first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Open the `+` menu in the tab row and pick "New agent". */
|
||||
/** Open the pane-local `+` menu and pick Agent. */
|
||||
export async function createAgentTabFromMenu(page: Page): Promise<void> {
|
||||
const trigger = visibleTestId(page, "workspace-new-tab-menu-trigger").first();
|
||||
const trigger = visibleTestId(page, "workspace-new-tab-button").first();
|
||||
await expect(trigger).toBeVisible({ timeout: 10_000 });
|
||||
await trigger.click();
|
||||
const item = visibleTestId(page, "workspace-new-tab-menu-agent").first();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.5.0-beta.4",
|
||||
"version": "0.5.2",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
BookOpen,
|
||||
Bot,
|
||||
Boxes,
|
||||
Brain,
|
||||
Bug,
|
||||
@@ -27,6 +26,7 @@ import {
|
||||
Server,
|
||||
Shield,
|
||||
Sparkles,
|
||||
Star,
|
||||
Terminal,
|
||||
TestTube,
|
||||
Wrench,
|
||||
@@ -42,7 +42,7 @@ import {
|
||||
} from "./profile-appearance";
|
||||
|
||||
/** Drawn when a profile names no icon, and as the "default" cell in the picker grid. */
|
||||
const ThemedDefaultIcon = withUnistyles(Bot);
|
||||
const ThemedDefaultIcon = withUnistyles(Star);
|
||||
|
||||
/**
|
||||
* `withUnistyles` has to wrap each icon once at module scope, so the registry
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TurnTiming } from "@/timeline/turn-time";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { layoutStream } from "./layout";
|
||||
import type { StreamStrategy } from "./strategy";
|
||||
|
||||
function strategyFor(platform: "web" | "native"): StreamStrategy {
|
||||
const inverted = platform === "native";
|
||||
const neighborStep = inverted ? 1 : -1;
|
||||
const getHistoryBoundaryIndex = (items: StreamItem[]) => {
|
||||
if (items.length === 0) return null;
|
||||
return inverted ? 0 : items.length - 1;
|
||||
};
|
||||
const getLiveHeadBoundaryIndex = (items: StreamItem[]) => {
|
||||
if (items.length === 0) return null;
|
||||
return inverted ? items.length - 1 : 0;
|
||||
};
|
||||
return {
|
||||
orderTail: (items) => (inverted ? [...items].toReversed() : items),
|
||||
orderHead: (items) => (inverted ? [...items].toReversed() : items),
|
||||
getNeighborIndex: (index, relation) =>
|
||||
relation === "above" ? index + neighborStep : index - neighborStep,
|
||||
getNeighborItem: (items, index, relation) => {
|
||||
const neighborIndex = relation === "above" ? index + neighborStep : index - neighborStep;
|
||||
return items[neighborIndex];
|
||||
},
|
||||
getHistoryLiveBoundaryIndex: getHistoryBoundaryIndex,
|
||||
getLiveHeadHistoryBoundaryIndex: getLiveHeadBoundaryIndex,
|
||||
getLatestItemIndex: getHistoryBoundaryIndex,
|
||||
getFrameChildOrder: () => (inverted ? "footer-then-content" : "content-then-footer"),
|
||||
} as StreamStrategy;
|
||||
}
|
||||
|
||||
function userMessage(id: string): Extract<StreamItem, { kind: "user_message" }> {
|
||||
return {
|
||||
kind: "user_message",
|
||||
id,
|
||||
text: id,
|
||||
timestamp: new Date("2026-08-22T10:00:00.000Z"),
|
||||
};
|
||||
}
|
||||
|
||||
function assistantMessage(id: string): Extract<StreamItem, { kind: "assistant_message" }> {
|
||||
return {
|
||||
kind: "assistant_message",
|
||||
id,
|
||||
text: id,
|
||||
timestamp: new Date("2026-08-22T10:00:01.000Z"),
|
||||
};
|
||||
}
|
||||
|
||||
function assistantBlock(
|
||||
id: string,
|
||||
blockIndex: number,
|
||||
): Extract<StreamItem, { kind: "assistant_message" }> {
|
||||
return {
|
||||
...assistantMessage(id),
|
||||
blockGroupId: "response",
|
||||
blockIndex,
|
||||
};
|
||||
}
|
||||
|
||||
function timingFor(assistantId: string): Map<string, TurnTiming> {
|
||||
return new Map([
|
||||
[
|
||||
assistantId,
|
||||
{
|
||||
startedAt: new Date("2026-08-22T10:00:00.000Z"),
|
||||
completedAt: new Date("2026-08-22T10:00:02.000Z"),
|
||||
durationMs: 2_000,
|
||||
},
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
function layoutFor(input: { platform: "web" | "native"; isTurnActive: boolean }) {
|
||||
const strategy = strategyFor(input.platform);
|
||||
const assistant = assistantMessage("assistant");
|
||||
return {
|
||||
assistant,
|
||||
layout: layoutStream({
|
||||
strategy,
|
||||
isTurnActive: input.isTurnActive,
|
||||
history: strategy.orderTail([userMessage("user"), assistant]),
|
||||
liveHead: [],
|
||||
timingByAssistantId: timingFor(assistant.id),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("turn footer spacing", () => {
|
||||
it.each(["web", "native"] as const)(
|
||||
"compacts the last assistant against the completed footer on %s",
|
||||
(platform) => {
|
||||
const { assistant, layout } = layoutFor({ platform, isTurnActive: false });
|
||||
const assistantLayout = layout.history.find((item) => item.item.id === assistant.id);
|
||||
|
||||
expect(layout.auxiliaryTurnFooter?.itemId).toBe(assistant.id);
|
||||
expect(assistantLayout?.assistantSpacing).toBe("compactBottom");
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["web", "native"] as const)(
|
||||
"keeps the same compact edge while the footer is running on %s",
|
||||
(platform) => {
|
||||
const { assistant, layout } = layoutFor({ platform, isTurnActive: true });
|
||||
const assistantLayout = layout.history.find((item) => item.item.id === assistant.id);
|
||||
|
||||
expect(layout.auxiliaryTurnFooter).toBeNull();
|
||||
expect(assistantLayout?.assistantSpacing).toBe("compactBottom");
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["web", false],
|
||||
["web", true],
|
||||
["native", false],
|
||||
["native", true],
|
||||
] as const)(
|
||||
"compacts both edges of the final split paragraph on %s when active=%s",
|
||||
(platform, isTurnActive) => {
|
||||
const strategy = strategyFor(platform);
|
||||
const first = assistantBlock("first", 0);
|
||||
const last = assistantBlock("last", 1);
|
||||
const layout = layoutStream({
|
||||
strategy,
|
||||
isTurnActive,
|
||||
history: strategy.orderTail([userMessage("user"), first]),
|
||||
liveHead: strategy.orderHead([last]),
|
||||
timingByAssistantId: timingFor(last.id),
|
||||
});
|
||||
const firstLayout = layout.history.find((item) => item.item.id === first.id);
|
||||
const lastLayout = layout.liveHead.find((item) => item.item.id === last.id);
|
||||
|
||||
expect(firstLayout?.assistantSpacing).toBe("compactBottom");
|
||||
expect(lastLayout?.assistantSpacing).toBe("compactBoth");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -330,7 +330,7 @@ describe("layoutStream", () => {
|
||||
});
|
||||
|
||||
expect(findLayoutItem(layout, historyBlock.id).assistantSpacing).toBe("compactBottom");
|
||||
expect(findLayoutItem(layout, headBlock.id).assistantSpacing).toBe("compactTop");
|
||||
expect(findLayoutItem(layout, headBlock.id).assistantSpacing).toBe("compactBoth");
|
||||
});
|
||||
|
||||
it.each(["web", "android"] as const)(
|
||||
|
||||
@@ -49,6 +49,7 @@ interface LayoutSegmentInput {
|
||||
items: StreamItem[];
|
||||
timingByAssistantId: Map<string, TurnTiming>;
|
||||
auxiliaryTurnFooter: TurnFooterHost | null;
|
||||
hasAuxiliaryFooter: boolean;
|
||||
frameOrder: StreamFrameChildOrder;
|
||||
boundaryIndex: number | null;
|
||||
boundaryAboveItem: StreamItem | null;
|
||||
@@ -249,11 +250,6 @@ function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] {
|
||||
boundaryIndex: input.boundaryIndex,
|
||||
boundaryItem: input.boundaryBelowItem,
|
||||
});
|
||||
const assistantSpacing = getAssistantBlockSpacing({
|
||||
item,
|
||||
aboveItem,
|
||||
belowItem,
|
||||
});
|
||||
const completedFooter = resolveCompletedFooter({
|
||||
strategy: input.strategy,
|
||||
items: input.items,
|
||||
@@ -265,6 +261,12 @@ function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] {
|
||||
boundaryAboveItems: input.boundaryAboveItems,
|
||||
boundaryAboveIndex: input.boundaryAboveIndex,
|
||||
});
|
||||
const assistantSpacing = getAssistantBlockSpacing({
|
||||
item,
|
||||
aboveItem,
|
||||
belowItem,
|
||||
hasFooterBelow: completedFooter !== null || (input.hasAuxiliaryFooter && belowItem === null),
|
||||
});
|
||||
|
||||
return {
|
||||
item,
|
||||
@@ -294,6 +296,7 @@ const historyLayoutCache = new WeakMap<StreamItem[], Map<string, StreamLayoutIte
|
||||
|
||||
export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
||||
const auxiliaryTurnFooter = resolveAuxiliaryTurnFooter(input);
|
||||
const hasAuxiliaryFooter = input.isTurnActive || auxiliaryTurnFooter !== null;
|
||||
const historyBoundaryIndex = input.strategy.getHistoryLiveBoundaryIndex(input.history);
|
||||
const liveHeadBoundaryIndex = input.strategy.getLiveHeadHistoryBoundaryIndex(input.liveHead);
|
||||
const historyBoundaryItem =
|
||||
@@ -313,6 +316,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
||||
liveHeadBoundaryItem?.kind ?? "null",
|
||||
liveHeadBoundaryItem?.turnId ?? "null",
|
||||
auxiliaryTurnFooter?.itemId ?? "null",
|
||||
hasAuxiliaryFooter ? "footer" : "no-footer",
|
||||
].join(":");
|
||||
let byKey = historyLayoutCache.get(input.history);
|
||||
if (!byKey) {
|
||||
@@ -328,6 +332,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
||||
items: input.history,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
auxiliaryTurnFooter,
|
||||
hasAuxiliaryFooter,
|
||||
frameOrder,
|
||||
boundaryIndex: historyBoundaryIndex,
|
||||
boundaryAboveItem: null,
|
||||
@@ -347,6 +352,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
||||
items: input.liveHead,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
auxiliaryTurnFooter,
|
||||
hasAuxiliaryFooter,
|
||||
frameOrder,
|
||||
boundaryIndex: liveHeadBoundaryIndex,
|
||||
boundaryAboveItem: historyBoundaryItem,
|
||||
|
||||
@@ -74,6 +74,18 @@ describe("getAssistantBlockSpacing", () => {
|
||||
expect(getAssistantBlockSpacing({ item: a, aboveItem: null, belowItem: null })).toBe("default");
|
||||
});
|
||||
|
||||
it("compacts the bottom edge when the turn footer follows the assistant block", () => {
|
||||
const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
|
||||
expect(
|
||||
getAssistantBlockSpacing({
|
||||
item: a,
|
||||
aboveItem: null,
|
||||
belowItem: null,
|
||||
hasFooterBelow: true,
|
||||
}),
|
||||
).toBe("compactBottom");
|
||||
});
|
||||
|
||||
it("returns compactTop when the item above is in the same block group", () => {
|
||||
const above = assistantBlock({ id: "above", blockGroupId: "group-1", blockIndex: 0 });
|
||||
const item = assistantBlock({ id: "item", blockGroupId: "group-1", blockIndex: 1 });
|
||||
|
||||
@@ -17,12 +17,15 @@ export function getAssistantBlockSpacing(params: {
|
||||
item: StreamItem;
|
||||
aboveItem: StreamItem | null | undefined;
|
||||
belowItem: StreamItem | null | undefined;
|
||||
hasFooterBelow?: boolean;
|
||||
}): "default" | "compactTop" | "compactBottom" | "compactBoth" {
|
||||
if (params.item.kind !== "assistant_message") {
|
||||
return "default";
|
||||
}
|
||||
const compactTop = isSameAssistantBlockGroup({ item: params.item, other: params.aboveItem });
|
||||
const compactBottom = isSameAssistantBlockGroup({ item: params.item, other: params.belowItem });
|
||||
const compactBottom =
|
||||
params.hasFooterBelow ||
|
||||
isSameAssistantBlockGroup({ item: params.item, other: params.belowItem });
|
||||
if (compactTop && compactBottom) return "compactBoth";
|
||||
if (compactTop) return "compactTop";
|
||||
if (compactBottom) return "compactBottom";
|
||||
@@ -44,6 +47,9 @@ export function getGapBetweenStreamItems(
|
||||
if (isUserMessageItem(item) && isUserMessageItem(belowItem)) {
|
||||
return SPACING[1];
|
||||
}
|
||||
if (item.kind === "user_message" && belowItem.kind === "assistant_message") {
|
||||
return 0;
|
||||
}
|
||||
if (isToolSequenceItem(item) && isToolSequenceItem(belowItem)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,10 @@ import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar";
|
||||
import { DomOverlayScrollbar } from "@/components/ui/overlay-scrollbar/dom-overlay-scrollbar";
|
||||
import { estimateStreamItemHeight } from "./web-virtualization";
|
||||
import {
|
||||
estimateStreamItemHeight,
|
||||
shouldAdjustScrollForVirtualRowResize,
|
||||
} from "./web-virtualization";
|
||||
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
|
||||
import { createStreamStrategy } from "./strategy";
|
||||
import {
|
||||
@@ -370,14 +373,17 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
overscan: 8,
|
||||
});
|
||||
useEffect(() => {
|
||||
rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (_item, _delta, instance) => {
|
||||
if (historyStartPrependAnchorActiveRef.current) {
|
||||
return false;
|
||||
}
|
||||
rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => {
|
||||
const viewportHeight = instance.scrollRect?.height ?? 0;
|
||||
const scrollOffset = instance.scrollOffset ?? 0;
|
||||
const remainingDistance = instance.getTotalSize() - (scrollOffset + viewportHeight);
|
||||
return remainingDistance > AUTO_SCROLL_BOTTOM_THRESHOLD_PX;
|
||||
return shouldAdjustScrollForVirtualRowResize({
|
||||
isHistoryStartPrependActive: historyStartPrependAnchorActiveRef.current,
|
||||
rowStart: item.start,
|
||||
scrollOffset,
|
||||
remainingDistanceFromBottom: remainingDistance,
|
||||
bottomThreshold: AUTO_SCROLL_BOTTOM_THRESHOLD_PX,
|
||||
});
|
||||
};
|
||||
return () => {
|
||||
rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = undefined;
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("react-native", () => ({
|
||||
Platform: {
|
||||
OS: "web",
|
||||
select: (options: { web?: unknown; default?: unknown }) => options.web ?? options.default,
|
||||
},
|
||||
View: ({ children, testID }: { children?: React.ReactNode; testID?: string }) => (
|
||||
<div data-testid={testID}>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("react-native-unistyles", () => ({
|
||||
StyleSheet: {
|
||||
create: (factory: unknown) =>
|
||||
typeof factory === "function"
|
||||
? (factory as (theme: Record<string, unknown>) => unknown)({
|
||||
spacing: { 1: 4, 2: 8, 3: 12, 4: 16, 6: 24 },
|
||||
colors: {
|
||||
foregroundMuted: "#aaa",
|
||||
palette: { amber: { 500: "#f0b429", 700: "#b7791f" } },
|
||||
},
|
||||
})
|
||||
: factory,
|
||||
},
|
||||
useUnistyles: () => ({ rt: { breakpoint: "md" } }),
|
||||
withUnistyles: <T,>(component: T) => component,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/message", () => ({
|
||||
AssistantTurnFooter: () => null,
|
||||
LiveElapsed: () => <span data-testid="running-turn-timestamp" />,
|
||||
STREAM_METADATA_FONT_SIZE: 11,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/assistant-fork-menu", () => ({
|
||||
AssistantForkMenu: () => <button data-testid="running-turn-fork" type="button" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/synced-loader", () => ({
|
||||
SyncedLoader: () => <span data-testid="running-turn-loader" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/retained-panel", () => ({
|
||||
useRetainedPanelActive: () => true,
|
||||
}));
|
||||
|
||||
import { TurnFooter } from "./turn-footer";
|
||||
|
||||
const unusedRunningTurnStrategy = null as unknown as React.ComponentProps<
|
||||
typeof TurnFooter
|
||||
>["strategy"];
|
||||
|
||||
describe("TurnFooter", () => {
|
||||
let root: Root | null = null;
|
||||
let container: HTMLDivElement | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => root?.unmount());
|
||||
}
|
||||
root = null;
|
||||
container?.remove();
|
||||
container = null;
|
||||
});
|
||||
|
||||
it("places the running-turn fork between the loader and timestamp", () => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root?.render(
|
||||
<TurnFooter
|
||||
isRunning
|
||||
inFlightTurnStartedAt={new Date("2026-08-01T10:00:00.000Z")}
|
||||
host={null}
|
||||
strategy={unusedRunningTurnStrategy}
|
||||
supportsTimelineCursor
|
||||
onForkInFlightTurn={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const footer = container.querySelector('[data-testid="turn-working-indicator"]');
|
||||
const controls = Array.from(footer?.querySelectorAll("[data-testid]") ?? []).map((node) =>
|
||||
node.getAttribute("data-testid"),
|
||||
);
|
||||
|
||||
expect(controls).toEqual([
|
||||
"running-turn-loader",
|
||||
"running-turn-fork",
|
||||
"running-turn-timestamp",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -23,7 +23,7 @@ import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
|
||||
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
|
||||
const workingIndicatorColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
export const TURN_FOOTER_BOTTOM_SPACING = SPACING[6];
|
||||
export const TURN_FOOTER_BOTTOM_SPACING = SPACING[8];
|
||||
|
||||
export type TurnContentStrategy = StreamStrategy;
|
||||
export type AssistantTurnForkHandler = (input: {
|
||||
@@ -220,7 +220,7 @@ const stylesheet = StyleSheet.create((theme) => ({
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
},
|
||||
turnFooterRow: {
|
||||
marginTop: theme.spacing[4],
|
||||
marginTop: theme.spacing[2] + 5,
|
||||
},
|
||||
turnFooterSlot: {
|
||||
flexDirection: "row",
|
||||
|
||||
@@ -260,6 +260,8 @@ export interface AgentStreamViewProps {
|
||||
isAuthoritativeHistoryReady?: boolean;
|
||||
/** Tail space required by a transparent overlay rendered at the bottom edge. */
|
||||
bottomOverlayTailClearance?: number;
|
||||
/** Bottom offset required for controls floating above that overlay. */
|
||||
bottomOverlayControlClearance?: number;
|
||||
toast?: ToastApi | null;
|
||||
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
||||
readOnly?: boolean;
|
||||
@@ -295,6 +297,10 @@ function useRetainedValue<T>(value: T, active: boolean): T {
|
||||
const EMPTY_PENDING_MESSAGE_SUBMISSIONS: readonly PendingMessageSubmission[] = [];
|
||||
const GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT = 200;
|
||||
|
||||
function resolveBottomOverlayControlOffset(clearance: number | undefined): number {
|
||||
return Math.max(16, clearance ?? 0);
|
||||
}
|
||||
|
||||
const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamViewProps>(
|
||||
function AgentStreamView(
|
||||
{
|
||||
@@ -309,6 +315,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
routeBottomAnchorRequest = null,
|
||||
isAuthoritativeHistoryReady = true,
|
||||
bottomOverlayTailClearance = 0,
|
||||
bottomOverlayControlClearance,
|
||||
toast,
|
||||
onOpenWorkspaceFile,
|
||||
readOnly = false,
|
||||
@@ -934,6 +941,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
}, [baseRenderModel, pendingPermissionsNode, turnFooterNode]);
|
||||
|
||||
const emptyStateStyle = useMemo(() => [stylesheet.emptyState, stylesheet.contentWrapper], []);
|
||||
const scrollToBottomContainerStyle = useMemo(
|
||||
() => [
|
||||
stylesheet.scrollToBottomContainer,
|
||||
{ bottom: resolveBottomOverlayControlOffset(bottomOverlayControlClearance) },
|
||||
],
|
||||
[bottomOverlayControlClearance],
|
||||
);
|
||||
const listEmptyComponent = useMemo(
|
||||
() =>
|
||||
renderListEmptyComponent({
|
||||
@@ -1065,7 +1079,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
onJumpToPrompt={chatOutline.jumpToPrompt}
|
||||
/>
|
||||
{(!isNearBottom || isTimelineDetached) && (
|
||||
<View style={stylesheet.scrollToBottomContainer} pointerEvents="box-none">
|
||||
<View style={scrollToBottomContainerStyle} pointerEvents="box-none">
|
||||
<Animated.View entering={scrollIndicatorFadeIn} exiting={scrollIndicatorFadeOut}>
|
||||
<Pressable
|
||||
style={stylesheet.scrollToBottomButton}
|
||||
@@ -1574,7 +1588,6 @@ const stylesheet = StyleSheet.create((theme) => ({
|
||||
},
|
||||
scrollToBottomContainer: {
|
||||
position: "absolute",
|
||||
bottom: 16,
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: "center",
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
findMountedWindowStart,
|
||||
getWebMountedRecentStreamItems,
|
||||
getWebPartialVirtualizationThreshold,
|
||||
shouldAdjustScrollForVirtualRowResize,
|
||||
splitWebVirtualizedHistory,
|
||||
type IndexedStreamItem,
|
||||
} from "./web-virtualization";
|
||||
@@ -168,6 +169,55 @@ describe("estimateStreamItemHeight", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("virtual row resize anchoring", () => {
|
||||
it("does not move the viewport when a visible row expands while detached", () => {
|
||||
expect(
|
||||
shouldAdjustScrollForVirtualRowResize({
|
||||
isHistoryStartPrependActive: false,
|
||||
rowStart: 1200,
|
||||
scrollOffset: 1000,
|
||||
remainingDistanceFromBottom: 5000,
|
||||
bottomThreshold: 64,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the reading position when a row above the viewport changes size", () => {
|
||||
expect(
|
||||
shouldAdjustScrollForVirtualRowResize({
|
||||
isHistoryStartPrependActive: false,
|
||||
rowStart: 800,
|
||||
scrollOffset: 1000,
|
||||
remainingDistanceFromBottom: 5000,
|
||||
bottomThreshold: 64,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves history prepend and bottom following to their existing anchors", () => {
|
||||
const baseInput = {
|
||||
rowStart: 800,
|
||||
scrollOffset: 1000,
|
||||
remainingDistanceFromBottom: 5000,
|
||||
bottomThreshold: 64,
|
||||
};
|
||||
|
||||
expect(
|
||||
shouldAdjustScrollForVirtualRowResize({
|
||||
...baseInput,
|
||||
isHistoryStartPrependActive: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldAdjustScrollForVirtualRowResize({
|
||||
...baseInput,
|
||||
isHistoryStartPrependActive: false,
|
||||
remainingDistanceFromBottom: 64,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("web virtualization test overrides", () => {
|
||||
it("uses defaults unless explicit positive integer overrides are present", () => {
|
||||
const globalWithOverrides = globalThis as typeof globalThis & {
|
||||
|
||||
@@ -10,6 +10,21 @@ export const DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 100;
|
||||
export const DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS = DEFAULT_MOUNTED_RECENT_STREAM_ITEMS;
|
||||
const COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE = 40;
|
||||
|
||||
export function shouldAdjustScrollForVirtualRowResize(input: {
|
||||
isHistoryStartPrependActive: boolean;
|
||||
rowStart: number;
|
||||
scrollOffset: number;
|
||||
remainingDistanceFromBottom: number;
|
||||
bottomThreshold: number;
|
||||
}): boolean {
|
||||
if (input.isHistoryStartPrependActive) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
input.remainingDistanceFromBottom > input.bottomThreshold && input.rowStart < input.scrollOffset
|
||||
);
|
||||
}
|
||||
|
||||
type BottomAnchorE2ETestGlobals = typeof globalThis & {
|
||||
__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: unknown;
|
||||
};
|
||||
|
||||
@@ -953,9 +953,18 @@ function RootProviders({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
function recordUserActivity(): void {
|
||||
getHostRuntimeStore().recordUserActivity();
|
||||
}
|
||||
|
||||
function RootAppTree() {
|
||||
return (
|
||||
<GestureHandlerRootView style={flexStyle}>
|
||||
<GestureHandlerRootView
|
||||
style={flexStyle}
|
||||
onTouchStart={recordUserActivity}
|
||||
onTouchEnd={recordUserActivity}
|
||||
onTouchCancel={recordUserActivity}
|
||||
>
|
||||
<View style={layoutStyles.surfaceFill}>
|
||||
<RootProviders>
|
||||
<RuntimeProviders>
|
||||
|
||||
@@ -36,10 +36,41 @@ function source(gitActions: GitActions): {
|
||||
newBrowser: "New browser",
|
||||
splitRight: "Split pane right",
|
||||
splitDown: "Split pane down",
|
||||
changes: "Changes",
|
||||
files: "Files",
|
||||
pullRequest: "Pull request",
|
||||
openPanel: (name, placement) => `Open ${name} ${placement}`,
|
||||
previousTab: "Previous tab",
|
||||
nextTab: "Next tab",
|
||||
closeCurrentTab: "Close current tab",
|
||||
renameTab: "Rename",
|
||||
reloadAgent: "Reload agent",
|
||||
copyResumeCommand: "Copy resume command",
|
||||
copyAgentId: "Copy agent id",
|
||||
copyTerminalId: "Copy terminal id",
|
||||
copyFilePath: "Copy file path",
|
||||
closeTabsLeft: "Close tabs left",
|
||||
closeTabsRight: "Close tabs right",
|
||||
closeOtherTabs: "Close other tabs",
|
||||
focusPaneLeft: "Focus pane left",
|
||||
focusPaneRight: "Focus pane right",
|
||||
focusPaneUp: "Focus pane up",
|
||||
focusPaneDown: "Focus pane down",
|
||||
moveTabLeft: "Move tab left",
|
||||
moveTabRight: "Move tab right",
|
||||
moveTabUp: "Move tab up",
|
||||
moveTabDown: "Move tab down",
|
||||
closePane: "Close pane",
|
||||
togglePaneMaximization: "Toggle pane maximization",
|
||||
toggleFocusMode: "Toggle focus mode",
|
||||
toggleSidePanel: "Toggle side panel",
|
||||
},
|
||||
icons: {},
|
||||
shortcuts: {},
|
||||
capabilities: { canSplitPanes: true, canOpenBrowserTabs: true },
|
||||
capabilities: { canSplitPanes: true, canOpenBrowserTabs: true, isGit: false },
|
||||
activeTabKind: null,
|
||||
activeTabIndex: -1,
|
||||
activeTabCount: 0,
|
||||
dispatch: (action) => dispatched.push(action),
|
||||
runGitAction: (action) => runGitActions.push(action),
|
||||
},
|
||||
@@ -88,23 +119,43 @@ describe("workspace command center contributions", () => {
|
||||
|
||||
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
|
||||
|
||||
expect(contributions.map(({ id, rank, visibility }) => ({ id, rank, visibility }))).toEqual([
|
||||
{ id: "tab:new-agent", rank: 0, visibility: "always" },
|
||||
{ id: "git:commit", rank: 1, visibility: "always" },
|
||||
{ id: "tab:new-terminal", rank: 2, visibility: "query" },
|
||||
{ id: "tab:new-browser", rank: 3, visibility: "query" },
|
||||
{ id: "pane:split-right", rank: 4, visibility: "query" },
|
||||
{ id: "pane:split-down", rank: 5, visibility: "query" },
|
||||
expect(
|
||||
contributions
|
||||
.filter((item) =>
|
||||
[
|
||||
"tab:new-agent",
|
||||
"git:commit",
|
||||
"tab:new-terminal",
|
||||
"tab:new-browser",
|
||||
"pane:split-right",
|
||||
"pane:split-down",
|
||||
].includes(item.id),
|
||||
)
|
||||
.map(({ id, visibility }) => ({ id, visibility })),
|
||||
).toEqual([
|
||||
{ id: "tab:new-agent", visibility: "always" },
|
||||
{ id: "git:commit", visibility: "always" },
|
||||
{ id: "tab:new-terminal", visibility: "query" },
|
||||
{ id: "tab:new-browser", visibility: "query" },
|
||||
{ id: "pane:split-right", visibility: "query" },
|
||||
{ id: "pane:split-down", visibility: "query" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits browser and split actions when their existing capabilities are unavailable", () => {
|
||||
const fixture = source({ primary: null, secondary: [], menu: [] });
|
||||
fixture.value.capabilities = { canSplitPanes: false, canOpenBrowserTabs: false };
|
||||
fixture.value.capabilities = {
|
||||
canSplitPanes: false,
|
||||
canOpenBrowserTabs: false,
|
||||
isGit: false,
|
||||
};
|
||||
|
||||
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
|
||||
|
||||
expect(contributions.map((item) => item.id)).toEqual(["tab:new-agent", "tab:new-terminal"]);
|
||||
expect(contributions.some((item) => item.id === "tab:new-agent")).toBe(true);
|
||||
expect(contributions.some((item) => item.id === "tab:new-terminal")).toBe(true);
|
||||
expect(contributions.some((item) => item.id === "tab:new-browser")).toBe(false);
|
||||
expect(contributions.some((item) => item.id.startsWith("pane:"))).toBe(false);
|
||||
});
|
||||
|
||||
it("dispatches every tab and pane command to the workspace scope", () => {
|
||||
@@ -113,13 +164,8 @@ describe("workspace command center contributions", () => {
|
||||
|
||||
for (const contribution of contributions) contribution.run();
|
||||
|
||||
expect(fixture.dispatched).toEqual([
|
||||
{ id: "workspace.agent.new", scope: "workspace" },
|
||||
{ id: "workspace.terminal.new", scope: "workspace" },
|
||||
{ id: "workspace.browser.new", scope: "workspace" },
|
||||
{ id: "workspace.pane.split.right", scope: "workspace" },
|
||||
{ id: "workspace.pane.split.down", scope: "workspace" },
|
||||
]);
|
||||
expect(fixture.dispatched.length).toBeGreaterThan(5);
|
||||
expect(fixture.dispatched.every((action) => action.scope === "workspace")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps workspace creation commands available outside Git", () => {
|
||||
@@ -127,13 +173,11 @@ describe("workspace command center contributions", () => {
|
||||
|
||||
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
|
||||
|
||||
expect(contributions.map((item) => item.id)).toEqual([
|
||||
"tab:new-agent",
|
||||
"tab:new-terminal",
|
||||
"tab:new-browser",
|
||||
"pane:split-right",
|
||||
"pane:split-down",
|
||||
]);
|
||||
expect(contributions.some((item) => item.id === "tab:new-agent")).toBe(true);
|
||||
expect(contributions.some((item) => item.id === "tab:new-terminal")).toBe(true);
|
||||
expect(contributions.some((item) => item.id === "tab:new-browser")).toBe(true);
|
||||
expect(contributions.some((item) => item.id === "pane:split-right")).toBe(true);
|
||||
expect(contributions.some((item) => item.id === "pane:split-down")).toBe(true);
|
||||
expect(contributions.some((item) => item.id.startsWith("git:"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { GitAction, GitActions } from "@/git/policy";
|
||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import type {
|
||||
WorkspacePanelPlacement,
|
||||
WorkspacePanelTarget,
|
||||
} from "@/keyboard/keyboard-action-dispatcher";
|
||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import type { CommandCenterContribution, CommandCenterIcon } from "./contributions";
|
||||
|
||||
@@ -10,6 +15,34 @@ export interface WorkspaceCommandCenterLabels {
|
||||
newBrowser: string;
|
||||
splitRight: string;
|
||||
splitDown: string;
|
||||
changes: string;
|
||||
files: string;
|
||||
pullRequest: string;
|
||||
openPanel(name: string, placement: WorkspacePanelPlacement): string;
|
||||
previousTab: string;
|
||||
nextTab: string;
|
||||
closeCurrentTab: string;
|
||||
renameTab: string;
|
||||
reloadAgent: string;
|
||||
copyResumeCommand: string;
|
||||
copyAgentId: string;
|
||||
copyTerminalId: string;
|
||||
copyFilePath: string;
|
||||
closeTabsLeft: string;
|
||||
closeTabsRight: string;
|
||||
closeOtherTabs: string;
|
||||
focusPaneLeft: string;
|
||||
focusPaneRight: string;
|
||||
focusPaneUp: string;
|
||||
focusPaneDown: string;
|
||||
moveTabLeft: string;
|
||||
moveTabRight: string;
|
||||
moveTabUp: string;
|
||||
moveTabDown: string;
|
||||
closePane: string;
|
||||
togglePaneMaximization: string;
|
||||
toggleFocusMode: string;
|
||||
toggleSidePanel: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceCommandCenterIcons {
|
||||
@@ -18,6 +51,20 @@ export interface WorkspaceCommandCenterIcons {
|
||||
newBrowser?: CommandCenterIcon;
|
||||
splitRight?: CommandCenterIcon;
|
||||
splitDown?: CommandCenterIcon;
|
||||
changes?: CommandCenterIcon;
|
||||
files?: CommandCenterIcon;
|
||||
pullRequest?: CommandCenterIcon;
|
||||
previousTab?: CommandCenterIcon;
|
||||
nextTab?: CommandCenterIcon;
|
||||
close?: CommandCenterIcon;
|
||||
rename?: CommandCenterIcon;
|
||||
reload?: CommandCenterIcon;
|
||||
copy?: CommandCenterIcon;
|
||||
focusPane?: CommandCenterIcon;
|
||||
moveTab?: CommandCenterIcon;
|
||||
maximize?: CommandCenterIcon;
|
||||
focusMode?: CommandCenterIcon;
|
||||
sidePanel?: CommandCenterIcon;
|
||||
git?(action: GitAction): CommandCenterIcon | undefined;
|
||||
}
|
||||
|
||||
@@ -27,6 +74,13 @@ export interface WorkspaceCommandCenterShortcuts {
|
||||
splitRight?: ShortcutKey[][];
|
||||
splitDown?: ShortcutKey[][];
|
||||
archiveWorkspace?: ShortcutKey[][];
|
||||
previousTab?: ShortcutKey[][];
|
||||
nextTab?: ShortcutKey[][];
|
||||
closeCurrentTab?: ShortcutKey[][];
|
||||
closePane?: ShortcutKey[][];
|
||||
togglePaneMaximization?: ShortcutKey[][];
|
||||
toggleFocusMode?: ShortcutKey[][];
|
||||
toggleSidePanel?: ShortcutKey[][];
|
||||
}
|
||||
|
||||
export interface WorkspaceCommandCenterSource {
|
||||
@@ -37,7 +91,11 @@ export interface WorkspaceCommandCenterSource {
|
||||
capabilities: {
|
||||
canSplitPanes: boolean;
|
||||
canOpenBrowserTabs: boolean;
|
||||
isGit: boolean;
|
||||
};
|
||||
activeTabKind: WorkspaceTabTarget["kind"] | null;
|
||||
activeTabIndex: number;
|
||||
activeTabCount: number;
|
||||
dispatch(action: KeyboardActionDefinition): void;
|
||||
runGitAction(action: GitAction): void;
|
||||
}
|
||||
@@ -96,7 +154,322 @@ function buildWorkspaceAction(input: {
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWorkspaceCommandCenterContributions(
|
||||
type QueryActionInput = Omit<Parameters<typeof buildWorkspaceAction>[0], "source" | "visibility">;
|
||||
|
||||
function buildQueryAction(
|
||||
source: WorkspaceCommandCenterSource,
|
||||
input: QueryActionInput,
|
||||
): CommandCenterContribution {
|
||||
return buildWorkspaceAction({ ...input, source, visibility: "query" });
|
||||
}
|
||||
|
||||
function buildPanelContributions(
|
||||
source: WorkspaceCommandCenterSource,
|
||||
): CommandCenterContribution[] {
|
||||
const contributions: CommandCenterContribution[] = [];
|
||||
const panelTargets: Array<{
|
||||
target: WorkspacePanelTarget;
|
||||
name: string;
|
||||
icon?: CommandCenterIcon;
|
||||
}> = [
|
||||
{ target: "changes", name: source.labels.changes, icon: source.icons.changes },
|
||||
{ target: "files", name: source.labels.files, icon: source.icons.files },
|
||||
{
|
||||
target: "pull-request",
|
||||
name: source.labels.pullRequest,
|
||||
icon: source.icons.pullRequest,
|
||||
},
|
||||
];
|
||||
for (const [index, panel] of panelTargets.entries()) {
|
||||
if (panel.target !== "files" && !source.capabilities.isGit) continue;
|
||||
contributions.push(
|
||||
buildQueryAction(source, {
|
||||
id: `tab:open:${panel.target}`,
|
||||
rank: 4 + index,
|
||||
title: source.labels.openPanel(panel.name, "supporting"),
|
||||
keywords: ["open", panel.target, "tab", "supporting"],
|
||||
icon: panel.icon,
|
||||
action: {
|
||||
id: "workspace.tab.open",
|
||||
scope: "workspace",
|
||||
target: panel.target,
|
||||
placement: "supporting",
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (!source.capabilities.canSplitPanes) continue;
|
||||
for (const [placementIndex, placement] of (["side-panel", "focused-pane"] as const).entries()) {
|
||||
contributions.push(
|
||||
buildQueryAction(source, {
|
||||
id: `tab:open:${panel.target}:${placement}`,
|
||||
rank: 10 + index * 2 + placementIndex,
|
||||
title: source.labels.openPanel(panel.name, placement),
|
||||
keywords: ["open", panel.target, "tab", placement, "pane"],
|
||||
icon: panel.icon,
|
||||
action: {
|
||||
id: "workspace.tab.open",
|
||||
scope: "workspace",
|
||||
target: panel.target,
|
||||
placement,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
return contributions;
|
||||
}
|
||||
|
||||
function buildActiveTabContributions(
|
||||
source: WorkspaceCommandCenterSource,
|
||||
): CommandCenterContribution[] {
|
||||
if (source.activeTabCount === 0) return [];
|
||||
const contributions: CommandCenterContribution[] = [
|
||||
buildQueryAction(source, {
|
||||
id: "tab:previous",
|
||||
rank: 30,
|
||||
title: source.labels.previousTab,
|
||||
keywords: ["tab", "previous", "switch", "navigate"],
|
||||
icon: source.icons.previousTab,
|
||||
shortcutKeys: source.shortcuts.previousTab,
|
||||
action: { id: "workspace.tab.navigate-relative", scope: "workspace", delta: -1 },
|
||||
}),
|
||||
buildQueryAction(source, {
|
||||
id: "tab:next",
|
||||
rank: 31,
|
||||
title: source.labels.nextTab,
|
||||
keywords: ["tab", "next", "switch", "navigate"],
|
||||
icon: source.icons.nextTab,
|
||||
shortcutKeys: source.shortcuts.nextTab,
|
||||
action: { id: "workspace.tab.navigate-relative", scope: "workspace", delta: 1 },
|
||||
}),
|
||||
buildQueryAction(source, {
|
||||
id: "tab:close-current",
|
||||
rank: 32,
|
||||
title: source.labels.closeCurrentTab,
|
||||
keywords: ["tab", "close", "current"],
|
||||
icon: source.icons.close,
|
||||
shortcutKeys: source.shortcuts.closeCurrentTab,
|
||||
action: { id: "workspace.tab.close-current", scope: "workspace" },
|
||||
}),
|
||||
];
|
||||
if (source.activeTabKind === "agent" || source.activeTabKind === "terminal") {
|
||||
contributions.push(
|
||||
buildQueryAction(source, {
|
||||
id: "tab:rename-current",
|
||||
rank: 33,
|
||||
title: source.labels.renameTab,
|
||||
keywords: ["tab", "rename", "current"],
|
||||
icon: source.icons.rename,
|
||||
action: { id: "workspace.tab.rename-current", scope: "workspace" },
|
||||
}),
|
||||
buildQueryAction(source, {
|
||||
id: "tab:copy-id",
|
||||
rank: 34,
|
||||
title:
|
||||
source.activeTabKind === "agent"
|
||||
? source.labels.copyAgentId
|
||||
: source.labels.copyTerminalId,
|
||||
keywords: ["tab", "copy", "id"],
|
||||
icon: source.icons.copy,
|
||||
action: { id: "workspace.tab.copy-id", scope: "workspace" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (source.activeTabKind === "agent") {
|
||||
contributions.push(
|
||||
buildQueryAction(source, {
|
||||
id: "tab:reload-current",
|
||||
rank: 35,
|
||||
title: source.labels.reloadAgent,
|
||||
keywords: ["tab", "agent", "reload"],
|
||||
icon: source.icons.reload,
|
||||
action: { id: "workspace.tab.reload-current", scope: "workspace" },
|
||||
}),
|
||||
buildQueryAction(source, {
|
||||
id: "tab:copy-resume-command",
|
||||
rank: 36,
|
||||
title: source.labels.copyResumeCommand,
|
||||
keywords: ["tab", "agent", "copy", "resume", "command"],
|
||||
icon: source.icons.copy,
|
||||
action: { id: "workspace.tab.copy-resume-command", scope: "workspace" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (source.activeTabKind === "file") {
|
||||
contributions.push(
|
||||
buildQueryAction(source, {
|
||||
id: "tab:copy-file-path",
|
||||
rank: 37,
|
||||
title: source.labels.copyFilePath,
|
||||
keywords: ["tab", "file", "copy", "path"],
|
||||
icon: source.icons.copy,
|
||||
action: { id: "workspace.tab.copy-file-path", scope: "workspace" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (source.activeTabIndex > 0) {
|
||||
contributions.push(
|
||||
buildQueryAction(source, {
|
||||
id: "tab:close-left",
|
||||
rank: 38,
|
||||
title: source.labels.closeTabsLeft,
|
||||
keywords: ["tab", "close", "left"],
|
||||
icon: source.icons.close,
|
||||
action: { id: "workspace.tab.close-left", scope: "workspace" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (source.activeTabIndex >= 0 && source.activeTabIndex < source.activeTabCount - 1) {
|
||||
contributions.push(
|
||||
buildQueryAction(source, {
|
||||
id: "tab:close-right",
|
||||
rank: 39,
|
||||
title: source.labels.closeTabsRight,
|
||||
keywords: ["tab", "close", "right"],
|
||||
icon: source.icons.close,
|
||||
action: { id: "workspace.tab.close-right", scope: "workspace" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (source.activeTabCount > 1) {
|
||||
contributions.push(
|
||||
buildQueryAction(source, {
|
||||
id: "tab:close-others",
|
||||
rank: 40,
|
||||
title: source.labels.closeOtherTabs,
|
||||
keywords: ["tab", "close", "others"],
|
||||
icon: source.icons.close,
|
||||
action: { id: "workspace.tab.close-others", scope: "workspace" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
return contributions;
|
||||
}
|
||||
|
||||
function buildPaneContributions(source: WorkspaceCommandCenterSource): CommandCenterContribution[] {
|
||||
const paneActions: Array<{
|
||||
id: string;
|
||||
rank: number;
|
||||
title: string;
|
||||
keywords: string[];
|
||||
action: KeyboardActionDefinition;
|
||||
icon?: CommandCenterIcon;
|
||||
shortcutKeys?: ShortcutKey[][];
|
||||
}> = [
|
||||
{
|
||||
id: "pane:split-right",
|
||||
rank: 50,
|
||||
title: source.labels.splitRight,
|
||||
keywords: ["split", "pane", "vertical"],
|
||||
icon: source.icons.splitRight,
|
||||
shortcutKeys: source.shortcuts.splitRight,
|
||||
action: { id: "workspace.pane.split.right", scope: "workspace" },
|
||||
},
|
||||
{
|
||||
id: "pane:split-down",
|
||||
rank: 51,
|
||||
title: source.labels.splitDown,
|
||||
keywords: ["split", "pane", "horizontal"],
|
||||
icon: source.icons.splitDown,
|
||||
shortcutKeys: source.shortcuts.splitDown,
|
||||
action: { id: "workspace.pane.split.down", scope: "workspace" },
|
||||
},
|
||||
{
|
||||
id: "pane:focus-left",
|
||||
rank: 52,
|
||||
title: source.labels.focusPaneLeft,
|
||||
keywords: ["pane", "focus", "left"],
|
||||
icon: source.icons.focusPane,
|
||||
action: { id: "workspace.pane.focus.left", scope: "workspace" },
|
||||
},
|
||||
{
|
||||
id: "pane:focus-right",
|
||||
rank: 53,
|
||||
title: source.labels.focusPaneRight,
|
||||
keywords: ["pane", "focus", "right"],
|
||||
icon: source.icons.focusPane,
|
||||
action: { id: "workspace.pane.focus.right", scope: "workspace" },
|
||||
},
|
||||
{
|
||||
id: "pane:focus-up",
|
||||
rank: 54,
|
||||
title: source.labels.focusPaneUp,
|
||||
keywords: ["pane", "focus", "up"],
|
||||
icon: source.icons.focusPane,
|
||||
action: { id: "workspace.pane.focus.up", scope: "workspace" },
|
||||
},
|
||||
{
|
||||
id: "pane:focus-down",
|
||||
rank: 55,
|
||||
title: source.labels.focusPaneDown,
|
||||
keywords: ["pane", "focus", "down"],
|
||||
icon: source.icons.focusPane,
|
||||
action: { id: "workspace.pane.focus.down", scope: "workspace" },
|
||||
},
|
||||
{
|
||||
id: "pane:move-tab-left",
|
||||
rank: 56,
|
||||
title: source.labels.moveTabLeft,
|
||||
keywords: ["pane", "tab", "move", "left"],
|
||||
icon: source.icons.moveTab,
|
||||
action: { id: "workspace.pane.move-tab.left", scope: "workspace" },
|
||||
},
|
||||
{
|
||||
id: "pane:move-tab-right",
|
||||
rank: 57,
|
||||
title: source.labels.moveTabRight,
|
||||
keywords: ["pane", "tab", "move", "right"],
|
||||
icon: source.icons.moveTab,
|
||||
action: { id: "workspace.pane.move-tab.right", scope: "workspace" },
|
||||
},
|
||||
{
|
||||
id: "pane:move-tab-up",
|
||||
rank: 58,
|
||||
title: source.labels.moveTabUp,
|
||||
keywords: ["pane", "tab", "move", "up"],
|
||||
icon: source.icons.moveTab,
|
||||
action: { id: "workspace.pane.move-tab.up", scope: "workspace" },
|
||||
},
|
||||
{
|
||||
id: "pane:move-tab-down",
|
||||
rank: 59,
|
||||
title: source.labels.moveTabDown,
|
||||
keywords: ["pane", "tab", "move", "down"],
|
||||
icon: source.icons.moveTab,
|
||||
action: { id: "workspace.pane.move-tab.down", scope: "workspace" },
|
||||
},
|
||||
{
|
||||
id: "pane:close",
|
||||
rank: 60,
|
||||
title: source.labels.closePane,
|
||||
keywords: ["pane", "close"],
|
||||
icon: source.icons.close,
|
||||
shortcutKeys: source.shortcuts.closePane,
|
||||
action: { id: "workspace.pane.close", scope: "workspace" },
|
||||
},
|
||||
{
|
||||
id: "pane:maximize-toggle",
|
||||
rank: 61,
|
||||
title: source.labels.togglePaneMaximization,
|
||||
keywords: ["pane", "maximize", "restore", "toggle"],
|
||||
icon: source.icons.maximize,
|
||||
shortcutKeys: source.shortcuts.togglePaneMaximization,
|
||||
action: { id: "workspace.explorer.maximize.toggle", scope: "workspace" },
|
||||
},
|
||||
{
|
||||
id: "pane:focus-mode-toggle",
|
||||
rank: 62,
|
||||
title: source.labels.toggleFocusMode,
|
||||
keywords: ["pane", "focus", "mode", "toggle"],
|
||||
icon: source.icons.focusMode,
|
||||
shortcutKeys: source.shortcuts.toggleFocusMode,
|
||||
action: { id: "workspace.focus.toggle", scope: "workspace" },
|
||||
},
|
||||
];
|
||||
return paneActions.map((action) => buildQueryAction(source, action));
|
||||
}
|
||||
|
||||
function buildCreationContributions(
|
||||
source: WorkspaceCommandCenterSource,
|
||||
): CommandCenterContribution[] {
|
||||
const contributions: CommandCenterContribution[] = [
|
||||
@@ -115,8 +488,7 @@ export function buildWorkspaceCommandCenterContributions(
|
||||
const primary = source.gitActions.primary;
|
||||
if (primary) contributions.push(buildGitContribution(source, primary, 1, "always"));
|
||||
contributions.push(
|
||||
buildWorkspaceAction({
|
||||
source,
|
||||
buildQueryAction(source, {
|
||||
id: "tab:new-terminal",
|
||||
rank: 2,
|
||||
title: source.labels.newTerminal,
|
||||
@@ -124,52 +496,45 @@ export function buildWorkspaceCommandCenterContributions(
|
||||
icon: source.icons.newTerminal,
|
||||
shortcutKeys: source.shortcuts.newTerminal,
|
||||
action: { id: "workspace.terminal.new", scope: "workspace" },
|
||||
visibility: "query",
|
||||
}),
|
||||
);
|
||||
if (source.capabilities.canOpenBrowserTabs) {
|
||||
contributions.push(
|
||||
buildWorkspaceAction({
|
||||
source,
|
||||
buildQueryAction(source, {
|
||||
id: "tab:new-browser",
|
||||
rank: 3,
|
||||
title: source.labels.newBrowser,
|
||||
keywords: ["browser", "web", "preview"],
|
||||
icon: source.icons.newBrowser,
|
||||
action: { id: "workspace.browser.new", scope: "workspace" },
|
||||
visibility: "query",
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (source.capabilities.canSplitPanes) {
|
||||
contributions.push(
|
||||
buildWorkspaceAction({
|
||||
source,
|
||||
id: "pane:split-right",
|
||||
rank: 4,
|
||||
title: source.labels.splitRight,
|
||||
keywords: ["split", "pane", "vertical"],
|
||||
icon: source.icons.splitRight,
|
||||
shortcutKeys: source.shortcuts.splitRight,
|
||||
action: { id: "workspace.pane.split.right", scope: "workspace" },
|
||||
visibility: "query",
|
||||
}),
|
||||
buildWorkspaceAction({
|
||||
source,
|
||||
id: "pane:split-down",
|
||||
rank: 5,
|
||||
title: source.labels.splitDown,
|
||||
keywords: ["split", "pane", "horizontal"],
|
||||
icon: source.icons.splitDown,
|
||||
shortcutKeys: source.shortcuts.splitDown,
|
||||
action: { id: "workspace.pane.split.down", scope: "workspace" },
|
||||
visibility: "query",
|
||||
}),
|
||||
);
|
||||
}
|
||||
return contributions;
|
||||
}
|
||||
|
||||
export function buildWorkspaceCommandCenterContributions(
|
||||
source: WorkspaceCommandCenterSource,
|
||||
): CommandCenterContribution[] {
|
||||
const contributions = [
|
||||
...buildCreationContributions(source),
|
||||
...buildPanelContributions(source),
|
||||
...buildActiveTabContributions(source),
|
||||
...(source.capabilities.canSplitPanes ? buildPaneContributions(source) : []),
|
||||
buildQueryAction(source, {
|
||||
id: "side-panel:toggle",
|
||||
rank: 70,
|
||||
title: source.labels.toggleSidePanel,
|
||||
keywords: ["side", "panel", "toggle", "show", "hide"],
|
||||
icon: source.icons.sidePanel,
|
||||
shortcutKeys: source.shortcuts.toggleSidePanel,
|
||||
action: { id: "sidebar.toggle.right", scope: "workspace" },
|
||||
}),
|
||||
];
|
||||
const primary = source.gitActions.primary;
|
||||
for (const [index, action] of source.gitActions.secondary.entries()) {
|
||||
if (action.id === primary?.id) continue;
|
||||
contributions.push(buildGitContribution(source, action, 10 + index, "query"));
|
||||
contributions.push(buildGitContribution(source, action, 100 + index, "query"));
|
||||
}
|
||||
return contributions;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,41 @@
|
||||
import { useMemo, type ReactElement } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Columns2, Globe, Rows2, SquarePen, SquareTerminal } from "lucide-react-native";
|
||||
import {
|
||||
ArrowDownToLine,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Columns2,
|
||||
Copy,
|
||||
Files,
|
||||
Focus,
|
||||
GitCompareArrows,
|
||||
GitPullRequest,
|
||||
Globe,
|
||||
Maximize2,
|
||||
Move,
|
||||
PanelRight,
|
||||
Pencil,
|
||||
RotateCw,
|
||||
Rows2,
|
||||
SquarePen,
|
||||
SquareTerminal,
|
||||
X,
|
||||
} from "lucide-react-native";
|
||||
import { getIsElectron } from "@/constants/platform";
|
||||
import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { GIT_ACTION_ICONS } from "@/git/action-icons";
|
||||
import { useGitActionRunner, useGitActions } from "@/git/use-actions";
|
||||
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
||||
import {
|
||||
resolveShortcutKeysForAction,
|
||||
type ShortcutOverrides,
|
||||
} from "@/keyboard/keyboard-shortcuts";
|
||||
import { type ShortcutOverrides } from "@/keyboard/keyboard-shortcuts";
|
||||
import { useKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher-context";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
|
||||
import {
|
||||
collectAllTabs,
|
||||
findPaneById,
|
||||
useWorkspaceLayoutStore,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import { getCommandCenterIcon } from "./icon";
|
||||
@@ -22,6 +45,7 @@ import {
|
||||
buildWorkspaceCommandCenterContributions,
|
||||
type WorkspaceCommandCenterShortcuts,
|
||||
} from "./workspace-contributions";
|
||||
import { resolveWorkspaceCommandCenterShortcuts } from "./workspace-shortcuts";
|
||||
|
||||
const WORKSPACE_COMMAND_CENTER_ICONS = {
|
||||
newAgent: getCommandCenterIcon(SquarePen),
|
||||
@@ -29,8 +53,28 @@ const WORKSPACE_COMMAND_CENTER_ICONS = {
|
||||
newBrowser: getCommandCenterIcon(Globe),
|
||||
splitRight: getCommandCenterIcon(Columns2),
|
||||
splitDown: getCommandCenterIcon(Rows2),
|
||||
changes: getCommandCenterIcon(GitCompareArrows),
|
||||
files: getCommandCenterIcon(Files),
|
||||
pullRequest: getCommandCenterIcon(GitPullRequest),
|
||||
previousTab: getCommandCenterIcon(ArrowLeft),
|
||||
nextTab: getCommandCenterIcon(ArrowRight),
|
||||
close: getCommandCenterIcon(X),
|
||||
rename: getCommandCenterIcon(Pencil),
|
||||
reload: getCommandCenterIcon(RotateCw),
|
||||
copy: getCommandCenterIcon(Copy),
|
||||
focusPane: getCommandCenterIcon(Focus),
|
||||
moveTab: getCommandCenterIcon(Move),
|
||||
maximize: getCommandCenterIcon(Maximize2),
|
||||
focusMode: getCommandCenterIcon(ArrowDownToLine),
|
||||
sidePanel: getCommandCenterIcon(PanelRight),
|
||||
};
|
||||
|
||||
const OPEN_PANEL_LABEL_KEYS = {
|
||||
supporting: "shell.commandCenter.open",
|
||||
"side-panel": "shell.commandCenter.openInSidePanel",
|
||||
"focused-pane": "shell.commandCenter.openInFocusedPane",
|
||||
} as const;
|
||||
|
||||
function staticIcon(element: ReactElement | undefined): CommandCenterIcon | undefined {
|
||||
if (!element) return undefined;
|
||||
function StaticIcon() {
|
||||
@@ -41,17 +85,7 @@ function staticIcon(element: ReactElement | undefined): CommandCenterIcon | unde
|
||||
|
||||
function resolveWorkspaceShortcuts(overrides: ShortcutOverrides): WorkspaceCommandCenterShortcuts {
|
||||
const platform = { isMac: getShortcutOs() === "mac", isDesktop: getIsElectron() };
|
||||
return {
|
||||
newAgent: resolveShortcutKeysForAction("workspace-tab-new", overrides, platform) ?? undefined,
|
||||
newTerminal:
|
||||
resolveShortcutKeysForAction("workspace-terminal-new", overrides, platform) ?? undefined,
|
||||
splitRight:
|
||||
resolveShortcutKeysForAction("workspace-pane-split-right", overrides, platform) ?? undefined,
|
||||
splitDown:
|
||||
resolveShortcutKeysForAction("workspace-pane-split-down", overrides, platform) ?? undefined,
|
||||
archiveWorkspace:
|
||||
resolveShortcutKeysForAction("archive-workspace", overrides, platform) ?? undefined,
|
||||
};
|
||||
return resolveWorkspaceCommandCenterShortcuts({ overrides, platform });
|
||||
}
|
||||
|
||||
export function useWorkspaceCommandCenterActions(): void {
|
||||
@@ -60,10 +94,22 @@ export function useWorkspaceCommandCenterActions(): void {
|
||||
const selection = useActiveWorkspaceSelection();
|
||||
const serverId = selection?.serverId ?? null;
|
||||
const workspaceId = selection?.workspaceId ?? null;
|
||||
const workspaceKey =
|
||||
serverId && workspaceId ? buildWorkspaceTabPersistenceKey({ serverId, workspaceId }) : null;
|
||||
const layout = useWorkspaceLayoutStore((state) =>
|
||||
workspaceKey ? (state.layoutByWorkspace[workspaceKey] ?? null) : null,
|
||||
);
|
||||
const focusedPane = layout ? findPaneById(layout.root, layout.focusedPaneId) : null;
|
||||
const focusedTabs = layout
|
||||
? collectAllTabs(layout.root).filter((tab) => focusedPane?.tabIds.includes(tab.tabId))
|
||||
: [];
|
||||
const activeTabIndex = focusedTabs.findIndex((tab) => tab.tabId === focusedPane?.focusedTabId);
|
||||
const activeTabKind =
|
||||
activeTabIndex >= 0 ? (focusedTabs[activeTabIndex]?.target.kind ?? null) : null;
|
||||
const cwd = useWorkspaceDirectory(serverId, workspaceId);
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const { overrides } = useKeyboardShortcutOverrides();
|
||||
const { gitActions } = useGitActions({
|
||||
const { gitActions, isGit } = useGitActions({
|
||||
serverId: serverId ?? "",
|
||||
cwd: cwd ?? "",
|
||||
icons: GIT_ACTION_ICONS,
|
||||
@@ -81,6 +127,34 @@ export function useWorkspaceCommandCenterActions(): void {
|
||||
newBrowser: t("workspace.tabs.actions.newBrowser"),
|
||||
splitRight: t("workspace.tabs.actions.splitRight"),
|
||||
splitDown: t("workspace.tabs.actions.splitDown"),
|
||||
changes: t("workspace.tabs.actions.changes"),
|
||||
files: t("workspace.tabs.actions.files"),
|
||||
pullRequest: t("workspace.tabs.actions.pullRequest"),
|
||||
openPanel: (name, placement) => t(OPEN_PANEL_LABEL_KEYS[placement], { name }),
|
||||
previousTab: t("settings.shortcuts.help.previousTab"),
|
||||
nextTab: t("settings.shortcuts.help.nextTab"),
|
||||
closeCurrentTab: t("settings.shortcuts.help.closeCurrentTab"),
|
||||
renameTab: t("workspace.tabs.menu.rename"),
|
||||
reloadAgent: t("workspace.tabs.menu.reloadAgent"),
|
||||
copyResumeCommand: t("workspace.tabs.menu.copyResumeCommand"),
|
||||
copyAgentId: t("workspace.tabs.menu.copyAgentId"),
|
||||
copyTerminalId: t("workspace.tabs.menu.copyTerminalId"),
|
||||
copyFilePath: t("workspace.tabs.menu.copyFilePath"),
|
||||
closeTabsLeft: t("workspace.tabs.menu.closeLeft"),
|
||||
closeTabsRight: t("workspace.tabs.menu.closeRight"),
|
||||
closeOtherTabs: t("workspace.tabs.menu.closeOthers"),
|
||||
focusPaneLeft: t("settings.shortcuts.help.focusPaneLeft"),
|
||||
focusPaneRight: t("settings.shortcuts.help.focusPaneRight"),
|
||||
focusPaneUp: t("settings.shortcuts.help.focusPaneUp"),
|
||||
focusPaneDown: t("settings.shortcuts.help.focusPaneDown"),
|
||||
moveTabLeft: t("settings.shortcuts.help.moveTabLeft"),
|
||||
moveTabRight: t("settings.shortcuts.help.moveTabRight"),
|
||||
moveTabUp: t("settings.shortcuts.help.moveTabUp"),
|
||||
moveTabDown: t("settings.shortcuts.help.moveTabDown"),
|
||||
closePane: t("settings.shortcuts.help.closePane"),
|
||||
togglePaneMaximization: t("settings.shortcuts.help.toggleExplorerPaneMaximization"),
|
||||
toggleFocusMode: t("settings.shortcuts.help.toggleFocusMode"),
|
||||
toggleSidePanel: t("workspace.tabs.sidePanel.toggle"),
|
||||
},
|
||||
icons: {
|
||||
...WORKSPACE_COMMAND_CENTER_ICONS,
|
||||
@@ -90,14 +164,29 @@ export function useWorkspaceCommandCenterActions(): void {
|
||||
capabilities: {
|
||||
canSplitPanes: supportsDesktopPaneSplits() && !isCompact,
|
||||
canOpenBrowserTabs: getIsElectron(),
|
||||
isGit,
|
||||
},
|
||||
activeTabKind,
|
||||
activeTabIndex,
|
||||
activeTabCount: focusedTabs.length,
|
||||
dispatch: (action) => {
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
keyboardActionDispatcher.dispatch(action);
|
||||
},
|
||||
runGitAction,
|
||||
}),
|
||||
[gitActions, isCompact, keyboardActionDispatcher, overrides, runGitAction, t],
|
||||
[
|
||||
activeTabIndex,
|
||||
activeTabKind,
|
||||
focusedTabs.length,
|
||||
gitActions,
|
||||
isCompact,
|
||||
isGit,
|
||||
keyboardActionDispatcher,
|
||||
overrides,
|
||||
runGitAction,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
useCommandCenterActions({
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveWorkspaceCommandCenterShortcuts } from "./workspace-shortcuts";
|
||||
|
||||
describe("resolveWorkspaceCommandCenterShortcuts", () => {
|
||||
it("assigns the New agent command its own shortcut", () => {
|
||||
expect(
|
||||
resolveWorkspaceCommandCenterShortcuts({
|
||||
overrides: {},
|
||||
platform: { isMac: true, isDesktop: true },
|
||||
}).newAgent,
|
||||
).toEqual([["mod", "shift", "A"]]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
resolveShortcutKeysForAction,
|
||||
type ShortcutOverrides,
|
||||
} from "@/keyboard/keyboard-shortcuts";
|
||||
import type { WorkspaceCommandCenterShortcuts } from "./workspace-contributions";
|
||||
|
||||
interface ResolveWorkspaceCommandCenterShortcutsInput {
|
||||
overrides: ShortcutOverrides;
|
||||
platform: { isMac: boolean; isDesktop: boolean };
|
||||
}
|
||||
|
||||
export function resolveWorkspaceCommandCenterShortcuts({
|
||||
overrides,
|
||||
platform,
|
||||
}: ResolveWorkspaceCommandCenterShortcutsInput): WorkspaceCommandCenterShortcuts {
|
||||
return {
|
||||
newAgent:
|
||||
resolveShortcutKeysForAction("workspace-tab-target-agent", overrides, platform) ?? undefined,
|
||||
newTerminal:
|
||||
resolveShortcutKeysForAction("workspace-terminal-new", overrides, platform) ?? undefined,
|
||||
splitRight:
|
||||
resolveShortcutKeysForAction("workspace-pane-split-right", overrides, platform) ?? undefined,
|
||||
splitDown:
|
||||
resolveShortcutKeysForAction("workspace-pane-split-down", overrides, platform) ?? undefined,
|
||||
archiveWorkspace:
|
||||
resolveShortcutKeysForAction("archive-workspace", overrides, platform) ?? undefined,
|
||||
previousTab:
|
||||
resolveShortcutKeysForAction("workspace-tab-prev", overrides, platform) ?? undefined,
|
||||
nextTab: resolveShortcutKeysForAction("workspace-tab-next", overrides, platform) ?? undefined,
|
||||
closeCurrentTab:
|
||||
resolveShortcutKeysForAction("workspace-tab-close-current", overrides, platform) ?? undefined,
|
||||
closePane:
|
||||
resolveShortcutKeysForAction("workspace-pane-close", overrides, platform) ?? undefined,
|
||||
togglePaneMaximization:
|
||||
resolveShortcutKeysForAction("workspace-explorer-maximize", overrides, platform) ?? undefined,
|
||||
toggleSidePanel:
|
||||
resolveShortcutKeysForAction("toggle-right-sidebar", overrides, platform) ?? undefined,
|
||||
};
|
||||
}
|
||||
@@ -1662,7 +1662,6 @@ function getErrorRecoveryPath(state: AgentFileExplorerState | undefined): string
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
},
|
||||
desktopSplit: {
|
||||
flex: 1,
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { BottomSheetFlatList } from "@gorhom/bottom-sheet";
|
||||
import { BottomSheetFlatList, BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
@@ -1455,7 +1455,19 @@ function ModelBrowserContent({
|
||||
return scrolling === "independent" ? (
|
||||
<IndependentProviderList>{allProvidersContent}</IndependentProviderList>
|
||||
) : (
|
||||
allProvidersContent
|
||||
<BottomSheetScrollView
|
||||
style={styles.virtualizedModelList}
|
||||
contentContainerStyle={[
|
||||
styles.virtualizedModelListContent,
|
||||
styles.virtualizedProviderListContent,
|
||||
]}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
keyboardDismissMode="on-drag"
|
||||
showsVerticalScrollIndicator={false}
|
||||
testID="compact-provider-list"
|
||||
>
|
||||
{allProvidersContent}
|
||||
</BottomSheetScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -69,7 +69,6 @@ import {
|
||||
WorkspaceDesktopTabsRow,
|
||||
type WorkspaceDesktopTabRowItem,
|
||||
} from "@/screens/workspace/workspace-desktop-tabs-row";
|
||||
import type { TerminalProfile } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
WorkspaceTabPresentationResolver,
|
||||
WorkspaceTabIcon,
|
||||
@@ -110,10 +109,7 @@ interface SplitContainerProps {
|
||||
onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||
onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||
onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||
onCreateDraftTab: (input: { paneId?: string }) => void;
|
||||
onCreateTerminalTab: (input: { paneId?: string; profile?: TerminalProfile }) => void;
|
||||
onCreateBrowserTab: (input: { paneId?: string }) => void;
|
||||
showCreateBrowserTab?: boolean;
|
||||
onCreateNewTab: (input: { paneId?: string }) => void;
|
||||
buildPaneContentModel: (input: {
|
||||
paneId: string;
|
||||
tab: WorkspaceTabDescriptor;
|
||||
@@ -129,14 +125,8 @@ interface SplitContainerProps {
|
||||
position: "left" | "right" | "top" | "bottom";
|
||||
}) => void;
|
||||
onMoveTabToPane: (tabId: string, toPaneId: string) => void;
|
||||
onClosePane: (paneId: string) => void;
|
||||
onResizeSplit: (groupId: string, sizes: number[]) => void;
|
||||
onReorderTabsInPane: (paneId: string, tabIds: string[]) => void;
|
||||
renderPaneEmptyState?: (input: {
|
||||
paneId: string;
|
||||
paneIsolated: boolean;
|
||||
onClosePane: () => void;
|
||||
}) => ReactNode;
|
||||
focusModeEnabled?: boolean;
|
||||
onExitFocusMode: () => void;
|
||||
}
|
||||
@@ -182,7 +172,6 @@ interface SplitNodeViewProps extends Omit<SplitContainerProps, "layout" | "onMov
|
||||
sidePanelPaneId: string | null;
|
||||
maximizedPaneId: string | null;
|
||||
onTogglePaneMaximized: (paneId: string) => void;
|
||||
paneIsolated: boolean;
|
||||
activeDragTabId: string | null;
|
||||
showDropZones: boolean;
|
||||
dropPreview: SplitDropZoneHover | null;
|
||||
@@ -402,19 +391,14 @@ export function SplitContainer({
|
||||
onCloseTabsToLeft,
|
||||
onCloseTabsToRight,
|
||||
onCloseOtherTabs,
|
||||
onCreateDraftTab,
|
||||
onCreateTerminalTab,
|
||||
onCreateBrowserTab,
|
||||
showCreateBrowserTab,
|
||||
onCreateNewTab,
|
||||
buildPaneContentModel,
|
||||
onFocusPane,
|
||||
onSplitPane,
|
||||
onSplitPaneEmpty,
|
||||
onMoveTabToPane,
|
||||
onClosePane,
|
||||
onResizeSplit,
|
||||
onReorderTabsInPane,
|
||||
renderPaneEmptyState = () => null,
|
||||
focusModeEnabled,
|
||||
onExitFocusMode,
|
||||
}: SplitContainerProps) {
|
||||
@@ -502,12 +486,6 @@ export function SplitContainer({
|
||||
[focusModeEnabled, layout.focusedPaneId, layout.root, maximizedPaneId],
|
||||
);
|
||||
const renderRoot = useMemo(() => wrapRootPaneForStableMount(splitRoot.root), [splitRoot.root]);
|
||||
// Isolating a pane (focus mode, maximize) hides its siblings without removing
|
||||
// them, so the one pane on screen is not really the last one and closing it is not
|
||||
// what anyone means. That is all this container knows; whether the layout itself
|
||||
// allows the dismissal is `canDismissPaneInLayout`, which the caller reads.
|
||||
const paneIsolated = focusModeEnabled || Boolean(maximizedPaneId);
|
||||
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
const data = asWorkspaceTabDragData(event.active.data.current);
|
||||
if (!data) {
|
||||
@@ -687,19 +665,13 @@ export function SplitContainer({
|
||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||
onCloseTabsToRight={onCloseTabsToRight}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
onCreateDraftTab={onCreateDraftTab}
|
||||
onCreateTerminalTab={onCreateTerminalTab}
|
||||
onCreateBrowserTab={onCreateBrowserTab}
|
||||
showCreateBrowserTab={showCreateBrowserTab}
|
||||
onCreateNewTab={onCreateNewTab}
|
||||
buildPaneContentModel={buildPaneContentModel}
|
||||
onFocusPane={onFocusPane}
|
||||
onSplitPane={onSplitPane}
|
||||
onSplitPaneEmpty={onSplitPaneEmpty}
|
||||
paneIsolated={paneIsolated}
|
||||
onClosePane={onClosePane}
|
||||
onResizeSplit={onResizeSplit}
|
||||
onReorderTabsInPane={onReorderTabsInPane}
|
||||
renderPaneEmptyState={renderPaneEmptyState}
|
||||
activeDragTabId={activeDragTabId}
|
||||
showDropZones={activeDragTabId !== null}
|
||||
dropPreview={dropPreview}
|
||||
@@ -898,19 +870,13 @@ function SplitNodeView({
|
||||
onCloseTabsToLeft,
|
||||
onCloseTabsToRight,
|
||||
onCloseOtherTabs,
|
||||
onCreateDraftTab,
|
||||
onCreateTerminalTab,
|
||||
onCreateBrowserTab,
|
||||
showCreateBrowserTab,
|
||||
onCreateNewTab,
|
||||
buildPaneContentModel,
|
||||
onFocusPane,
|
||||
onSplitPane,
|
||||
onSplitPaneEmpty,
|
||||
paneIsolated,
|
||||
onClosePane,
|
||||
onResizeSplit,
|
||||
onReorderTabsInPane,
|
||||
renderPaneEmptyState,
|
||||
activeDragTabId,
|
||||
showDropZones,
|
||||
dropPreview,
|
||||
@@ -979,18 +945,12 @@ function SplitNodeView({
|
||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||
onCloseTabsToRight={onCloseTabsToRight}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
onCreateDraftTab={onCreateDraftTab}
|
||||
onCreateTerminalTab={onCreateTerminalTab}
|
||||
onCreateBrowserTab={onCreateBrowserTab}
|
||||
showCreateBrowserTab={showCreateBrowserTab}
|
||||
onCreateNewTab={onCreateNewTab}
|
||||
buildPaneContentModel={buildPaneContentModel}
|
||||
onFocusPane={onFocusPane}
|
||||
onSplitPane={onSplitPane}
|
||||
onSplitPaneEmpty={onSplitPaneEmpty}
|
||||
paneIsolated={paneIsolated}
|
||||
onClosePane={onClosePane}
|
||||
onReorderTabsInPane={onReorderTabsInPane}
|
||||
renderPaneEmptyState={renderPaneEmptyState}
|
||||
activeDragTabId={activeDragTabId}
|
||||
showDropZones={showDropZones}
|
||||
dropPreview={dropPreview}
|
||||
@@ -1033,19 +993,13 @@ function SplitNodeView({
|
||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||
onCloseTabsToRight={onCloseTabsToRight}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
onCreateDraftTab={onCreateDraftTab}
|
||||
onCreateTerminalTab={onCreateTerminalTab}
|
||||
onCreateBrowserTab={onCreateBrowserTab}
|
||||
showCreateBrowserTab={showCreateBrowserTab}
|
||||
onCreateNewTab={onCreateNewTab}
|
||||
buildPaneContentModel={buildPaneContentModel}
|
||||
onFocusPane={onFocusPane}
|
||||
onSplitPane={onSplitPane}
|
||||
onSplitPaneEmpty={onSplitPaneEmpty}
|
||||
paneIsolated={paneIsolated}
|
||||
onClosePane={onClosePane}
|
||||
onResizeSplit={onResizeSplit}
|
||||
onReorderTabsInPane={onReorderTabsInPane}
|
||||
renderPaneEmptyState={renderPaneEmptyState}
|
||||
activeDragTabId={activeDragTabId}
|
||||
showDropZones={showDropZones}
|
||||
dropPreview={dropPreview}
|
||||
@@ -1095,18 +1049,12 @@ function SplitPaneView({
|
||||
onCloseTabsToLeft,
|
||||
onCloseTabsToRight,
|
||||
onCloseOtherTabs,
|
||||
onCreateDraftTab,
|
||||
onCreateTerminalTab,
|
||||
onCreateBrowserTab,
|
||||
showCreateBrowserTab,
|
||||
onCreateNewTab,
|
||||
buildPaneContentModel,
|
||||
onFocusPane,
|
||||
onSplitPane: _onSplitPane,
|
||||
onSplitPaneEmpty: _onSplitPaneEmpty,
|
||||
paneIsolated,
|
||||
onClosePane,
|
||||
onReorderTabsInPane,
|
||||
renderPaneEmptyState,
|
||||
activeDragTabId,
|
||||
showDropZones,
|
||||
dropPreview,
|
||||
@@ -1218,7 +1166,6 @@ function SplitPaneView({
|
||||
() => onTogglePaneMaximized(paneId),
|
||||
[onTogglePaneMaximized, paneId],
|
||||
);
|
||||
const handleClosePane = useCallback(() => onClosePane(paneId), [onClosePane, paneId]);
|
||||
return (
|
||||
<RenderProfile id={`SplitPaneView:${pane.id}`}>
|
||||
<View
|
||||
@@ -1247,10 +1194,7 @@ function SplitPaneView({
|
||||
onCloseTabsToLeft={handleCloseTabsToLeft}
|
||||
onCloseTabsToRight={handleCloseTabsToRight}
|
||||
onCloseOtherTabs={handleCloseOtherTabs}
|
||||
onCreateDraftTab={onCreateDraftTab}
|
||||
onCreateTerminalTab={onCreateTerminalTab}
|
||||
onCreateBrowserTab={onCreateBrowserTab}
|
||||
showCreateBrowserTab={showCreateBrowserTab}
|
||||
onCreateNewTab={onCreateNewTab}
|
||||
onReorderTabs={handleReorderTabs}
|
||||
externalDndContext
|
||||
activeDragTabId={activeDragTabId}
|
||||
@@ -1266,31 +1210,25 @@ function SplitPaneView({
|
||||
</WindowChromeSafeArea>
|
||||
|
||||
<View style={styles.paneContent}>
|
||||
{mountedPaneTabIds.length > 0
|
||||
? mountedPaneTabIds.map((tabId) => {
|
||||
const tabDescriptor = tabDescriptorMap.get(tabId);
|
||||
if (!tabDescriptor) {
|
||||
return null;
|
||||
}
|
||||
{mountedPaneTabIds.map((tabId) => {
|
||||
const tabDescriptor = tabDescriptorMap.get(tabId);
|
||||
if (!tabDescriptor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<MountedTabSlot
|
||||
key={tabId}
|
||||
tabDescriptor={tabDescriptor}
|
||||
isVisible={tabId === activeTabDescriptor?.tabId}
|
||||
isWorkspaceFocused={isWorkspaceFocused}
|
||||
isPaneFocused={isFocused && tabId === activeTabDescriptor?.tabId}
|
||||
paneId={pane.id}
|
||||
onFocusPane={stableOnFocusPane}
|
||||
buildPaneContentModel={buildPaneContentModel}
|
||||
/>
|
||||
);
|
||||
})
|
||||
: (renderPaneEmptyState?.({
|
||||
paneId,
|
||||
paneIsolated,
|
||||
onClosePane: handleClosePane,
|
||||
}) ?? null)}
|
||||
return (
|
||||
<MountedTabSlot
|
||||
key={tabId}
|
||||
tabDescriptor={tabDescriptor}
|
||||
isVisible={tabId === activeTabDescriptor?.tabId}
|
||||
isWorkspaceFocused={isWorkspaceFocused}
|
||||
isPaneFocused={isFocused && tabId === activeTabDescriptor?.tabId}
|
||||
paneId={pane.id}
|
||||
onFocusPane={stableOnFocusPane}
|
||||
buildPaneContentModel={buildPaneContentModel}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<SplitDropZone paneId={pane.id} active={showDropZones} preview={dropPreview} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const BUILDS = {
|
||||
// Metro resolves src; Node consumers resolve one of the compiled builds.
|
||||
src: "react-native-svg/src/lib/extract/extractTransform.ts",
|
||||
esm: "react-native-svg/lib/module/lib/extract/extractTransform",
|
||||
cjs: "react-native-svg/lib/commonjs/lib/extract/extractTransform.js",
|
||||
} as const;
|
||||
|
||||
const CSS_MODULE = "react-native-svg/src/css/index.tsx";
|
||||
const XML_MODULE = "react-native-svg/src/xml.tsx";
|
||||
|
||||
type Extractor = (props: { transform?: unknown }) => unknown;
|
||||
type XmlMiddleware = (root: unknown) => unknown;
|
||||
interface ParsedRoot {
|
||||
props: Record<string, unknown>;
|
||||
}
|
||||
type XmlParser = (source: string, middleware?: XmlMiddleware) => ParsedRoot | null;
|
||||
|
||||
async function loadBuild(extractSpecifier: string) {
|
||||
const [cssModule, extractModule, xmlModule] = (await Promise.all([
|
||||
import(/* @vite-ignore */ CSS_MODULE),
|
||||
import(/* @vite-ignore */ extractSpecifier),
|
||||
import(/* @vite-ignore */ XML_MODULE),
|
||||
])) as [
|
||||
{ inlineStyles?: XmlMiddleware; default?: { inlineStyles?: XmlMiddleware } },
|
||||
{
|
||||
extractTransformSvgView?: Extractor;
|
||||
default?: { extractTransformSvgView?: Extractor };
|
||||
},
|
||||
{ parse?: XmlParser; default?: { parse?: XmlParser } },
|
||||
];
|
||||
const inlineStyles = cssModule.inlineStyles ?? cssModule.default?.inlineStyles;
|
||||
const extract =
|
||||
extractModule.extractTransformSvgView ?? extractModule.default?.extractTransformSvgView;
|
||||
const parseXml = xmlModule.parse ?? xmlModule.default?.parse;
|
||||
if (!inlineStyles || !extract || !parseXml)
|
||||
throw new Error(`could not load react-native-svg build`);
|
||||
|
||||
return (xml: string) => {
|
||||
const root = parseXml(xml, inlineStyles);
|
||||
if (!root) throw new Error(`could not parse root SVG`);
|
||||
const props = { ...root.props };
|
||||
const style = props.style;
|
||||
if (style && typeof style === "object" && "transform" in style) {
|
||||
props.transform = style.transform;
|
||||
}
|
||||
return extract(props);
|
||||
};
|
||||
}
|
||||
|
||||
describe.each(Object.entries(BUILDS))("root SVG transforms (%s build)", (_name, specifier) => {
|
||||
it("still converts a real SVG transform string", async () => {
|
||||
const extractFromXml = await loadBuild(specifier);
|
||||
|
||||
expect(extractFromXml(`<svg transform="rotate(-90)"></svg>`)).toEqual([{ rotate: "-90deg" }]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an inline CSS keyword", `<svg style="transform:none"></svg>`],
|
||||
["an embedded CSS rule", `<svg><style>svg { transform: none; }</style></svg>`],
|
||||
["a CSS angle unit", `<svg transform="rotate(-90deg)"></svg>`],
|
||||
["a CSS length unit", `<svg transform="translate(10px, 0)"></svg>`],
|
||||
])("drops %s from parsed root SVG props", async (_label, xml) => {
|
||||
const extractFromXml = await loadBuild(specifier);
|
||||
|
||||
expect(() => extractFromXml(xml)).not.toThrow();
|
||||
expect(extractFromXml(xml)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -86,23 +86,28 @@ describe("control geometry", () => {
|
||||
expect(geometry.formTextInputMd.lineHeight).toBe(20);
|
||||
});
|
||||
|
||||
it("derives field padding from line height without changing the control height", () => {
|
||||
it("derives field padding from content and border without changing the control height", () => {
|
||||
const geometry = createControlGeometry(theme);
|
||||
const borderWidth = theme.borderWidth[1];
|
||||
|
||||
expect(geometry.fieldControlSm.minHeight).toBe(32);
|
||||
expect(geometry.fieldControlSm.paddingVertical).toBe(6);
|
||||
expect(geometry.fieldTextSm.lineHeight + geometry.fieldControlSm.paddingVertical * 2).toBe(
|
||||
geometry.fieldControlSm.minHeight,
|
||||
);
|
||||
expect(geometry.fieldControlSm.paddingVertical).toBe(5);
|
||||
expect(
|
||||
geometry.fieldTextSm.lineHeight +
|
||||
geometry.fieldControlSm.paddingVertical * 2 +
|
||||
borderWidth * 2,
|
||||
).toBe(geometry.fieldControlSm.minHeight);
|
||||
|
||||
expect(geometry.fieldControlMd.minHeight).toBe(44);
|
||||
expect(geometry.fieldControlMd.paddingVertical).toBe(12);
|
||||
expect(geometry.fieldTextMd.lineHeight + geometry.fieldControlMd.paddingVertical * 2).toBe(
|
||||
geometry.fieldControlMd.minHeight,
|
||||
);
|
||||
expect(geometry.fieldControlMd.paddingVertical).toBe(11);
|
||||
expect(
|
||||
geometry.fieldTextMd.lineHeight +
|
||||
geometry.fieldControlMd.paddingVertical * 2 +
|
||||
borderWidth * 2,
|
||||
).toBe(geometry.fieldControlMd.minHeight);
|
||||
|
||||
expect(geometry.formTextInputSm.paddingVertical).toBe(6);
|
||||
expect(geometry.formTextInputMd.paddingVertical).toBe(12);
|
||||
expect(geometry.formTextInputSm.paddingVertical).toBe(5);
|
||||
expect(geometry.formTextInputMd.paddingVertical).toBe(11);
|
||||
});
|
||||
|
||||
it("keeps segmented controls ghost with button-radius segments in a button-sized track", () => {
|
||||
|
||||
@@ -78,8 +78,12 @@ function fieldLineHeight(fontSize: number): number {
|
||||
return Math.round(fontSize * FIELD_TEXT_LINE_HEIGHT_RATIO);
|
||||
}
|
||||
|
||||
function fieldVerticalPadding(controlHeight: number, lineHeight: number): number {
|
||||
return (controlHeight - lineHeight) / 2;
|
||||
function fieldVerticalPadding(
|
||||
controlHeight: number,
|
||||
lineHeight: number,
|
||||
borderWidth: number,
|
||||
): number {
|
||||
return (controlHeight - lineHeight - borderWidth * 2) / 2;
|
||||
}
|
||||
|
||||
export function getControlInteractionPhase(
|
||||
@@ -111,18 +115,27 @@ export function resolveControlInteractionStyles(
|
||||
}
|
||||
|
||||
export function createControlGeometry(theme: Theme) {
|
||||
const controlBorderWidth = theme.borderWidth[1];
|
||||
const fieldTextSmLineHeight = fieldLineHeight(theme.fontSize.base);
|
||||
const fieldTextMdLineHeight = fieldLineHeight(theme.fontSize.base);
|
||||
const fieldControlSm = {
|
||||
minHeight: CONTROL_HEIGHTS.compact,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: fieldVerticalPadding(CONTROL_HEIGHTS.compact, fieldTextSmLineHeight),
|
||||
paddingVertical: fieldVerticalPadding(
|
||||
CONTROL_HEIGHTS.compact,
|
||||
fieldTextSmLineHeight,
|
||||
controlBorderWidth,
|
||||
),
|
||||
borderRadius: theme.borderRadius.md,
|
||||
};
|
||||
const fieldControlMd = {
|
||||
minHeight: CONTROL_HEIGHTS.field,
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: fieldVerticalPadding(CONTROL_HEIGHTS.field, fieldTextMdLineHeight),
|
||||
paddingVertical: fieldVerticalPadding(
|
||||
CONTROL_HEIGHTS.field,
|
||||
fieldTextMdLineHeight,
|
||||
controlBorderWidth,
|
||||
),
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
};
|
||||
const fieldTextSm = {
|
||||
@@ -182,7 +195,7 @@ export function createControlGeometry(theme: Theme) {
|
||||
fieldTextSm,
|
||||
fieldTextMd,
|
||||
controlRest: {
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderWidth: controlBorderWidth,
|
||||
borderColor: "transparent",
|
||||
outlineWidth: 0,
|
||||
outlineColor: "transparent",
|
||||
|
||||
@@ -45,7 +45,7 @@ export function Field({ label, children, hint, error, testID }: FieldProps) {
|
||||
}
|
||||
if (hint) {
|
||||
return (
|
||||
<Text numberOfLines={1} style={styles.hintText} testID={hintTestID}>
|
||||
<Text style={styles.hintText} testID={hintTestID}>
|
||||
{hint}
|
||||
</Text>
|
||||
);
|
||||
|
||||
@@ -70,7 +70,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
badgeBackground: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.surface4,
|
||||
backgroundColor: theme.colors.surface3,
|
||||
opacity: theme.opacity[50],
|
||||
},
|
||||
sequence: {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { seedSessionWorkspaces } from "@/test/seed-session";
|
||||
import { useWorkspaceHasDiffStat } from "./workspace-diff-stat";
|
||||
|
||||
const SERVER_ID = "diff-stat-pill";
|
||||
const WORKSPACE_ID = "workspace";
|
||||
|
||||
const workspace: WorkspaceDescriptor = {
|
||||
id: WORKSPACE_ID,
|
||||
projectId: "project",
|
||||
projectDisplayName: "Project",
|
||||
projectRootPath: "/repo",
|
||||
workspaceDirectory: "/repo",
|
||||
projectKind: "git",
|
||||
workspaceKind: "local_checkout",
|
||||
name: "main",
|
||||
status: "done",
|
||||
statusEnteredAt: null,
|
||||
archivingAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
};
|
||||
|
||||
function setDiffStat(diffStat: WorkspaceDescriptor["diffStat"]): void {
|
||||
useSessionStore.getState().setWorkspaces(SERVER_ID, (workspaces) => {
|
||||
const next = new Map(workspaces);
|
||||
next.set(WORKSPACE_ID, { ...workspace, diffStat });
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
describe("useWorkspaceHasDiffStat", () => {
|
||||
beforeEach(() => {
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
|
||||
seedSessionWorkspaces(SERVER_ID, new Map([[WORKSPACE_ID, workspace]]));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useSessionStore.getState().clearSession(SERVER_ID);
|
||||
});
|
||||
|
||||
it("does not re-render its owner when only the visible counts change", () => {
|
||||
let renderCount = 0;
|
||||
const { result } = renderHook(() => {
|
||||
renderCount += 1;
|
||||
return useWorkspaceHasDiffStat(SERVER_ID, WORKSPACE_ID);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
expect(renderCount).toBe(1);
|
||||
|
||||
act(() => setDiffStat({ additions: 2, deletions: 0 }));
|
||||
expect(result.current).toBe(true);
|
||||
expect(renderCount).toBe(2);
|
||||
|
||||
act(() => setDiffStat({ additions: 5, deletions: 3 }));
|
||||
expect(result.current).toBe(true);
|
||||
expect(renderCount).toBe(2);
|
||||
|
||||
act(() => setDiffStat(null));
|
||||
expect(result.current).toBe(false);
|
||||
expect(renderCount).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { memo, useCallback, useMemo, useState, type ReactElement } from "react";
|
||||
import { Pressable } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DiffStat } from "@/components/diff-stat";
|
||||
import { composerPillStyles } from "@/composer/pill-styles";
|
||||
import { useVisibleWorkspaceDiffStat } from "@/composer/workspace-diff-stat";
|
||||
|
||||
interface ComposerDiffStatPillProps {
|
||||
additions: number;
|
||||
deletions: number;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
export function ComposerDiffStatPill({ additions, deletions, onPress }: ComposerDiffStatPillProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const handleHoverIn = useCallback(() => setIsHovered(true), []);
|
||||
const handleHoverOut = useCallback(() => setIsHovered(false), []);
|
||||
const bodyStyle = useMemo(
|
||||
() => [composerPillStyles.body, isHovered && composerPillStyles.bodyActive],
|
||||
[isHovered],
|
||||
);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
testID="composer-diff-stat-pill"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("workspace.git.diff.openChangesTab")}
|
||||
onPress={onPress}
|
||||
onHoverIn={handleHoverIn}
|
||||
onHoverOut={handleHoverOut}
|
||||
style={bodyStyle}
|
||||
>
|
||||
<DiffStat additions={additions} deletions={deletions} />
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
export const WorkspaceDiffStatPill = memo(function WorkspaceDiffStatPill({
|
||||
serverId,
|
||||
workspaceId,
|
||||
onPress,
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
onPress: () => void;
|
||||
}): ReactElement | null {
|
||||
const diffStat = useVisibleWorkspaceDiffStat(serverId, workspaceId);
|
||||
if (!diffStat) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<ComposerDiffStatPill
|
||||
additions={diffStat.additions}
|
||||
deletions={diffStat.deletions}
|
||||
onPress={onPress}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./height-mirror.native";
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { RefObject } from "react";
|
||||
|
||||
interface Args {
|
||||
value: string;
|
||||
textareaRef: RefObject<unknown>;
|
||||
minHeight: number;
|
||||
maxHeight: number;
|
||||
onHeight: (height: number) => void;
|
||||
}
|
||||
|
||||
export function useComposerHeightMirror(_args: Args): (_value: string) => void {
|
||||
// No-op on native: onContentSizeChange drives height natively.
|
||||
return () => undefined;
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
|
||||
import type { RefObject } from "react";
|
||||
|
||||
interface Args {
|
||||
value: string;
|
||||
textareaRef: RefObject<HTMLElement | null>;
|
||||
minHeight: number;
|
||||
maxHeight: number;
|
||||
onHeight: (height: number) => void;
|
||||
}
|
||||
|
||||
const COPIED_STYLES = [
|
||||
"fontFamily",
|
||||
"fontSize",
|
||||
"fontWeight",
|
||||
"fontStyle",
|
||||
"fontVariant",
|
||||
"lineHeight",
|
||||
"letterSpacing",
|
||||
"wordSpacing",
|
||||
"textTransform",
|
||||
"textIndent",
|
||||
"whiteSpace",
|
||||
"wordWrap",
|
||||
"overflowWrap",
|
||||
"wordBreak",
|
||||
"tabSize",
|
||||
"paddingTop",
|
||||
"paddingRight",
|
||||
"paddingBottom",
|
||||
"paddingLeft",
|
||||
] as const;
|
||||
|
||||
export function useComposerHeightMirror({
|
||||
value,
|
||||
textareaRef,
|
||||
minHeight,
|
||||
maxHeight,
|
||||
onHeight,
|
||||
}: Args): (value: string) => void {
|
||||
const paramsRef = useRef({ value, minHeight, maxHeight, onHeight });
|
||||
paramsRef.current = { value, minHeight, maxHeight, onHeight };
|
||||
|
||||
const mirrorRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined") return;
|
||||
const mirror = document.createElement("textarea");
|
||||
mirror.setAttribute("aria-hidden", "true");
|
||||
mirror.setAttribute("tabindex", "-1");
|
||||
mirror.readOnly = true;
|
||||
mirror.rows = 1;
|
||||
const style = mirror.style;
|
||||
style.position = "absolute";
|
||||
style.top = "0";
|
||||
style.left = "0";
|
||||
style.visibility = "hidden";
|
||||
style.pointerEvents = "none";
|
||||
style.overflow = "hidden";
|
||||
style.border = "0";
|
||||
style.margin = "0";
|
||||
style.resize = "none";
|
||||
style.zIndex = "-1";
|
||||
style.boxSizing = "border-box";
|
||||
document.body.appendChild(mirror);
|
||||
mirrorRef.current = mirror;
|
||||
return () => {
|
||||
mirror.remove();
|
||||
mirrorRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const measure = useCallback(
|
||||
(text: string) => {
|
||||
const mirror = mirrorRef.current;
|
||||
const source = textareaRef.current;
|
||||
if (!mirror || !source || typeof window === "undefined") return;
|
||||
if (!(source instanceof HTMLElement)) return;
|
||||
|
||||
const cs = window.getComputedStyle(source);
|
||||
const ms = mirror.style;
|
||||
for (const prop of COPIED_STYLES) {
|
||||
ms[prop] = cs[prop];
|
||||
}
|
||||
ms.width = `${source.clientWidth}px`;
|
||||
|
||||
const {
|
||||
minHeight: currentMinHeight,
|
||||
maxHeight: currentMaxHeight,
|
||||
onHeight: currentOnHeight,
|
||||
} = paramsRef.current;
|
||||
// Trailing newline is collapsed by textarea measurement — pad with a space.
|
||||
mirror.value = text.endsWith("\n") ? `${text} ` : text;
|
||||
|
||||
const next = Math.max(currentMinHeight, Math.min(currentMaxHeight, mirror.scrollHeight));
|
||||
currentOnHeight(next);
|
||||
},
|
||||
[textareaRef],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
measure(value);
|
||||
}, [maxHeight, minHeight, value, measure]);
|
||||
|
||||
useEffect(() => {
|
||||
const source = textareaRef.current;
|
||||
if (!source || !(source instanceof HTMLElement)) return;
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
let previousWidth = source.clientWidth;
|
||||
const observer = new ResizeObserver(() => {
|
||||
const nextWidth = source.clientWidth;
|
||||
if (Math.abs(nextWidth - previousWidth) < 1) return;
|
||||
previousWidth = nextWidth;
|
||||
measure(paramsRef.current.value);
|
||||
});
|
||||
observer.observe(source);
|
||||
return () => observer.disconnect();
|
||||
}, [textareaRef, measure]);
|
||||
|
||||
return measure;
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./height.native";
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useMemo } from "react";
|
||||
import type { RefObject } from "react";
|
||||
import type { ComposerHeightResult } from "./height.types";
|
||||
|
||||
interface ComposerHeightArgs {
|
||||
value: string;
|
||||
textareaRef: RefObject<unknown>;
|
||||
minHeight: number;
|
||||
maxHeight: number;
|
||||
}
|
||||
|
||||
export function useComposerHeight({
|
||||
minHeight,
|
||||
maxHeight,
|
||||
}: ComposerHeightArgs): ComposerHeightResult {
|
||||
const style = useMemo(() => ({ minHeight, maxHeight }), [maxHeight, minHeight]);
|
||||
return { mode: "intrinsic", style, scrollEnabled: true };
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { TextStyle } from "react-native";
|
||||
|
||||
export type ComposerHeightResult =
|
||||
| {
|
||||
mode: "intrinsic";
|
||||
style: TextStyle;
|
||||
scrollEnabled: true;
|
||||
}
|
||||
| {
|
||||
mode: "measured";
|
||||
style: TextStyle;
|
||||
scrollEnabled: boolean;
|
||||
onTextChange: (previousText: string, nextText: string) => void;
|
||||
reset: () => void;
|
||||
};
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import type { RefObject } from "react";
|
||||
import type { ComposerHeightResult } from "./height.types";
|
||||
|
||||
interface ComposerHeightArgs {
|
||||
value: string;
|
||||
textareaRef: RefObject<HTMLElement | null>;
|
||||
minHeight: number;
|
||||
maxHeight: number;
|
||||
}
|
||||
|
||||
const COPIED_STYLES = [
|
||||
"fontFamily",
|
||||
"fontSize",
|
||||
"fontWeight",
|
||||
"fontStyle",
|
||||
"fontVariant",
|
||||
"lineHeight",
|
||||
"letterSpacing",
|
||||
"wordSpacing",
|
||||
"textTransform",
|
||||
"textIndent",
|
||||
"whiteSpace",
|
||||
"wordWrap",
|
||||
"overflowWrap",
|
||||
"wordBreak",
|
||||
"tabSize",
|
||||
"paddingTop",
|
||||
"paddingRight",
|
||||
"paddingBottom",
|
||||
"paddingLeft",
|
||||
] as const;
|
||||
|
||||
export function useComposerHeight({
|
||||
value,
|
||||
textareaRef,
|
||||
minHeight,
|
||||
maxHeight,
|
||||
}: ComposerHeightArgs): ComposerHeightResult {
|
||||
const [height, setHeight] = useState(minHeight);
|
||||
const heightRef = useRef(minHeight);
|
||||
const paramsRef = useRef({ value, minHeight, maxHeight });
|
||||
paramsRef.current = { value, minHeight, maxHeight };
|
||||
const mirrorRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
const setBoundedHeight = useCallback((nextHeight: number) => {
|
||||
const { minHeight: currentMin, maxHeight: currentMax } = paramsRef.current;
|
||||
const bounded = Math.max(currentMin, Math.min(currentMax, nextHeight));
|
||||
if (Math.abs(heightRef.current - bounded) < 1) return;
|
||||
heightRef.current = bounded;
|
||||
setHeight(bounded);
|
||||
}, []);
|
||||
|
||||
const measure = useCallback(
|
||||
(text: string) => {
|
||||
const mirror = mirrorRef.current;
|
||||
const source = textareaRef.current;
|
||||
if (!mirror || !source || typeof window === "undefined") return;
|
||||
|
||||
const computedStyle = window.getComputedStyle(source);
|
||||
for (const property of COPIED_STYLES) {
|
||||
mirror.style[property] = computedStyle[property];
|
||||
}
|
||||
mirror.style.width = `${source.clientWidth}px`;
|
||||
mirror.value = text.endsWith("\n") ? `${text} ` : text;
|
||||
setBoundedHeight(mirror.scrollHeight);
|
||||
},
|
||||
[setBoundedHeight, textareaRef],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined") return;
|
||||
const mirror = document.createElement("textarea");
|
||||
mirror.setAttribute("aria-hidden", "true");
|
||||
mirror.setAttribute("tabindex", "-1");
|
||||
mirror.readOnly = true;
|
||||
mirror.rows = 1;
|
||||
Object.assign(mirror.style, {
|
||||
position: "absolute",
|
||||
top: "0",
|
||||
left: "0",
|
||||
visibility: "hidden",
|
||||
pointerEvents: "none",
|
||||
overflow: "hidden",
|
||||
border: "0",
|
||||
margin: "0",
|
||||
resize: "none",
|
||||
zIndex: "-1",
|
||||
boxSizing: "border-box",
|
||||
});
|
||||
document.body.appendChild(mirror);
|
||||
mirrorRef.current = mirror;
|
||||
measure(paramsRef.current.value);
|
||||
return () => {
|
||||
mirror.remove();
|
||||
mirrorRef.current = null;
|
||||
};
|
||||
}, [measure]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
measure(value);
|
||||
}, [maxHeight, minHeight, value, measure]);
|
||||
|
||||
useEffect(() => {
|
||||
const source = textareaRef.current;
|
||||
if (!source || typeof ResizeObserver === "undefined") return;
|
||||
let previousWidth = source.clientWidth;
|
||||
const observer = new ResizeObserver(() => {
|
||||
const nextWidth = source.clientWidth;
|
||||
if (Math.abs(nextWidth - previousWidth) < 1) return;
|
||||
previousWidth = nextWidth;
|
||||
measure(paramsRef.current.value);
|
||||
});
|
||||
observer.observe(source);
|
||||
return () => observer.disconnect();
|
||||
}, [measure, textareaRef]);
|
||||
|
||||
const onTextChange = useCallback(
|
||||
(_previousText: string, nextText: string) => measure(nextText),
|
||||
[measure],
|
||||
);
|
||||
const reset = useCallback(() => setBoundedHeight(minHeight), [minHeight, setBoundedHeight]);
|
||||
const style = useMemo(() => ({ height, minHeight, maxHeight }), [height, maxHeight, minHeight]);
|
||||
|
||||
return {
|
||||
mode: "measured",
|
||||
style,
|
||||
scrollEnabled: height >= maxHeight,
|
||||
onTextChange,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
@@ -4,9 +4,9 @@ import {
|
||||
Text,
|
||||
useWindowDimensions,
|
||||
NativeSyntheticEvent,
|
||||
TextInputContentSizeChangeEventData,
|
||||
TextInputKeyPressEventData,
|
||||
TextInputSelectionChangeEventData,
|
||||
type LayoutChangeEvent,
|
||||
} from "react-native";
|
||||
import {
|
||||
useState,
|
||||
@@ -55,7 +55,7 @@ import { isWeb } from "@/constants/platform";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useComposerKeyboardScope } from "@/composer/keyboard-scope";
|
||||
import { RenderProfile } from "@/utils/render-profiler";
|
||||
import { useComposerHeightMirror } from "./height-mirror";
|
||||
import { useComposerHeight } from "./height";
|
||||
import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode";
|
||||
import type { NativePastedFile } from "@/composer/native-pasted-image";
|
||||
import {
|
||||
@@ -639,7 +639,6 @@ interface ComposerTextSurfaceProps {
|
||||
editable: boolean;
|
||||
scrollEnabled: boolean;
|
||||
autoFocus: boolean;
|
||||
onContentSizeChange: (event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>) => void;
|
||||
onKeyPress: ((event: WebTextInputKeyPressEvent) => void) | undefined;
|
||||
onSelectionChange: (event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => void;
|
||||
onPasteImages: ((files: readonly NativePastedFile[]) => void) | undefined;
|
||||
@@ -678,7 +677,6 @@ function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElemen
|
||||
style={props.textInputStyle}
|
||||
multiline
|
||||
scrollEnabled={props.scrollEnabled}
|
||||
onContentSizeChange={props.onContentSizeChange}
|
||||
editable={props.editable}
|
||||
onKeyPress={props.onKeyPress}
|
||||
onSelectionChange={props.onSelectionChange}
|
||||
@@ -990,20 +988,6 @@ function resolveMaxInputHeight(windowHeight: number): number {
|
||||
return Math.max(DEFAULT_MAX_INPUT_HEIGHT, Math.floor(windowHeight * MAX_INPUT_VIEWPORT_RATIO));
|
||||
}
|
||||
|
||||
function computeTextInputHeightStyle(inputHeight: number, maxInputHeight: number) {
|
||||
if (isWeb) {
|
||||
return {
|
||||
height: inputHeight,
|
||||
minHeight: MIN_INPUT_HEIGHT,
|
||||
maxHeight: maxInputHeight,
|
||||
};
|
||||
}
|
||||
return {
|
||||
minHeight: MIN_INPUT_HEIGHT,
|
||||
maxHeight: maxInputHeight,
|
||||
};
|
||||
}
|
||||
|
||||
function isTextAreaLike(v: unknown): v is TextAreaHandle {
|
||||
return typeof v === "object" && v !== null && "scrollHeight" in v;
|
||||
}
|
||||
@@ -1213,7 +1197,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
const voiceMuteToggleKeys = useShortcutKeys("voice-mute-toggle");
|
||||
const dictationToggleKeys = useShortcutKeys("dictation-toggle");
|
||||
const focusInputKeys = useShortcutKeys("focus-message-input");
|
||||
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT);
|
||||
const [isInputFocused, setIsInputFocused] = useState(false);
|
||||
// Web text is DOM-owned between deferred draft publications. The action button only needs
|
||||
// this boundary, so publish empty/non-empty transitions without rerendering for every key.
|
||||
@@ -1227,6 +1210,22 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
const valueRef = useRef(value);
|
||||
const selectionRef = useRef({ start: value.length, end: value.length });
|
||||
const appliedTextReplacementKeyRef = useRef(textReplacementKey);
|
||||
const webTextareaRef = useRef<HTMLElement | null>(null);
|
||||
const composerHeight = useComposerHeight({
|
||||
value,
|
||||
textareaRef: webTextareaRef,
|
||||
minHeight: MIN_INPUT_HEIGHT,
|
||||
maxHeight: maxInputHeight,
|
||||
});
|
||||
const { style: composerHeightStyle, scrollEnabled: isComposerScrollEnabled } = composerHeight;
|
||||
const measuredComposerHeight = composerHeight.mode === "measured" ? composerHeight : undefined;
|
||||
const updateComposerHeightForText = measuredComposerHeight?.onTextChange;
|
||||
const resetComposerHeight = measuredComposerHeight?.reset;
|
||||
|
||||
const handleComposerLayout = useCallback(
|
||||
(event: LayoutChangeEvent) => onHeightChange?.(event.nativeEvent.layout.height),
|
||||
[onHeightChange],
|
||||
);
|
||||
|
||||
const updateLiveTextPresence = useCallback((text: string) => {
|
||||
const nextHasLiveText = text.trim().length > 0;
|
||||
@@ -1237,13 +1236,14 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
|
||||
const replaceText = useCallback(
|
||||
(nextText: string, selection?: { start: number; end: number }) => {
|
||||
updateComposerHeightForText?.(valueRef.current, nextText);
|
||||
valueRef.current = nextText;
|
||||
updateLiveTextPresence(nextText);
|
||||
selectionRef.current = selection ?? { start: nextText.length, end: nextText.length };
|
||||
textInputRef.current?.replaceText(nextText, selection);
|
||||
onChangeText(nextText);
|
||||
},
|
||||
[onChangeText, updateLiveTextPresence],
|
||||
[onChangeText, updateComposerHeightForText, updateLiveTextPresence],
|
||||
);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
@@ -1273,7 +1273,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
}),
|
||||
getNativeElement: () => (isWeb ? getTextInputNativeElement(textInputRef.current) : null),
|
||||
}));
|
||||
const inputHeightRef = useRef(MIN_INPUT_HEIGHT);
|
||||
const sendAfterTranscriptRef = useRef(false);
|
||||
const serverInfo = useSessionStore(
|
||||
useCallback(
|
||||
@@ -1290,10 +1289,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
useEffect(() => {
|
||||
if (appliedTextReplacementKeyRef.current === textReplacementKey) return;
|
||||
appliedTextReplacementKeyRef.current = textReplacementKey;
|
||||
updateComposerHeightForText?.(valueRef.current, value);
|
||||
valueRef.current = value;
|
||||
updateLiveTextPresence(value);
|
||||
textInputRef.current?.replaceText(value);
|
||||
}, [textReplacementKey, updateLiveTextPresence, value]);
|
||||
}, [textReplacementKey, updateComposerHeightForText, updateLiveTextPresence, value]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -1490,10 +1490,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
]);
|
||||
|
||||
const minimizeInputHeight = useCallback(() => {
|
||||
inputHeightRef.current = MIN_INPUT_HEIGHT;
|
||||
setInputHeight(MIN_INPUT_HEIGHT);
|
||||
onHeightChange?.(MIN_INPUT_HEIGHT);
|
||||
}, [onHeightChange]);
|
||||
resetComposerHeight?.();
|
||||
}, [resetComposerHeight]);
|
||||
|
||||
const handleSendMessage = useCallback(() => {
|
||||
const liveValue = textInputRef.current?.getText() ?? valueRef.current;
|
||||
@@ -1561,8 +1559,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
[],
|
||||
);
|
||||
|
||||
const webTextareaRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (isWeb) {
|
||||
webTextareaRef.current = getWebTextArea() as HTMLElement | null;
|
||||
@@ -1578,37 +1574,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
onAddImages,
|
||||
});
|
||||
|
||||
const setBoundedInputHeight = useCallback(
|
||||
(nextHeight: number) => {
|
||||
const bounded = Math.max(MIN_INPUT_HEIGHT, Math.min(maxInputHeight, nextHeight));
|
||||
if (Math.abs(inputHeightRef.current - bounded) < 1) return;
|
||||
inputHeightRef.current = bounded;
|
||||
setInputHeight(bounded);
|
||||
onHeightChange?.(bounded);
|
||||
},
|
||||
[maxInputHeight, onHeightChange],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setBoundedInputHeight(inputHeightRef.current);
|
||||
}, [setBoundedInputHeight]);
|
||||
|
||||
const measureComposerHeight = useComposerHeightMirror({
|
||||
value,
|
||||
textareaRef: webTextareaRef,
|
||||
minHeight: MIN_INPUT_HEIGHT,
|
||||
maxHeight: maxInputHeight,
|
||||
onHeight: setBoundedInputHeight,
|
||||
});
|
||||
|
||||
const handleContentSizeChange = useCallback(
|
||||
(event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>) => {
|
||||
if (isWeb) return;
|
||||
setBoundedInputHeight(event.nativeEvent.contentSize.height);
|
||||
},
|
||||
[setBoundedInputHeight],
|
||||
);
|
||||
|
||||
const handleSelectionChange = useCallback(
|
||||
(event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => {
|
||||
const start = event.nativeEvent.selection?.start ?? 0;
|
||||
@@ -1695,12 +1660,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(nextValue: string) => {
|
||||
updateComposerHeightForText?.(valueRef.current, nextValue);
|
||||
valueRef.current = nextValue;
|
||||
measureComposerHeight(nextValue);
|
||||
updateLiveTextPresence(nextValue);
|
||||
onChangeText(nextValue);
|
||||
},
|
||||
[measureComposerHeight, onChangeText, updateLiveTextPresence],
|
||||
[onChangeText, updateComposerHeightForText, updateLiveTextPresence],
|
||||
);
|
||||
|
||||
const handleInputFocus = useCallback(() => {
|
||||
@@ -1761,12 +1726,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
// `fontFamily` here is silently dropped while every other property lands.
|
||||
// An inline style outranks both classes. See docs/unistyles.md.
|
||||
const textInputStyle = useMemo(
|
||||
() => [
|
||||
styles.textInput,
|
||||
mode.isMonospace && styles.textInputMonospace,
|
||||
computeTextInputHeightStyle(inputHeight, maxInputHeight),
|
||||
],
|
||||
[inputHeight, maxInputHeight, mode.isMonospace],
|
||||
() => [styles.textInput, mode.isMonospace && styles.textInputMonospace, composerHeightStyle],
|
||||
[composerHeightStyle, mode.isMonospace],
|
||||
);
|
||||
// Static content has no textarea to mirror, so it grows with its own text
|
||||
// instead of the measured input height.
|
||||
@@ -1811,7 +1772,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
);
|
||||
|
||||
return (
|
||||
<View ref={rootRef} style={styles.container} testID="message-input-root">
|
||||
<View
|
||||
ref={rootRef}
|
||||
style={styles.container}
|
||||
testID="message-input-root"
|
||||
onLayout={handleComposerLayout}
|
||||
>
|
||||
<MessageInputAutoFocus
|
||||
enabled={autoFocus}
|
||||
autoFocusKey={autoFocusKey}
|
||||
@@ -1838,9 +1804,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
onFocus={handleInputFocus}
|
||||
onBlur={handleInputBlur}
|
||||
editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled}
|
||||
scrollEnabled={isWeb ? inputHeight >= maxInputHeight : true}
|
||||
scrollEnabled={isComposerScrollEnabled}
|
||||
autoFocus={false}
|
||||
onContentSizeChange={handleContentSizeChange}
|
||||
onKeyPress={shouldHandleWebKeyPress ? handleDesktopKeyPress : undefined}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onPasteImages={onPasteImages}
|
||||
|
||||
@@ -12,6 +12,12 @@ export function resolveComposerPillClearance(isCompact: boolean): number {
|
||||
}
|
||||
|
||||
export function resolveComposerTrackTailClearance(isCompact: boolean): number {
|
||||
const composerClearance = resolveComposerPillClearance(isCompact);
|
||||
const transcriptClearance = isCompact ? SPACING[6] : 20;
|
||||
return transcriptClearance + COMPOSER_PILL_MIN_HEIGHT + composerClearance;
|
||||
}
|
||||
|
||||
export function resolveComposerTrackControlClearance(isCompact: boolean): number {
|
||||
const clearance = resolveComposerPillClearance(isCompact);
|
||||
return clearance + COMPOSER_PILL_MIN_HEIGHT + clearance;
|
||||
}
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("react-native", () => ({
|
||||
Platform: {
|
||||
OS: "web",
|
||||
select: (options: { web?: unknown; default?: unknown }) => options.web ?? options.default,
|
||||
},
|
||||
Pressable: "button",
|
||||
Text: "span",
|
||||
// The status ring reaches for plain React Native styles rather than Unistyles ones — see the
|
||||
// note on `rotatorStyles` in status-ring/frame.tsx.
|
||||
StyleSheet: { create: <T,>(styles: T): T => styles },
|
||||
View: ({
|
||||
children,
|
||||
style,
|
||||
onLayout,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
style?: unknown;
|
||||
onLayout?: unknown;
|
||||
}) => (
|
||||
<div data-style={JSON.stringify(style)} data-has-layout-handler={Boolean(onLayout)}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("react-native-unistyles", () => ({
|
||||
StyleSheet: {
|
||||
create: (factory: (theme: Record<string, unknown>) => unknown) =>
|
||||
factory({
|
||||
spacing: { 1: 4, 2: 8, 3: 12, 4: 16, 6: 24 },
|
||||
borderRadius: { md: 6, lg: 8, xl: 12, "2xl": 16, full: 9999 },
|
||||
borderWidth: { 1: 1 },
|
||||
fontSize: { sm: 12 },
|
||||
colors: {
|
||||
statusDotWarning: "#1",
|
||||
statusDotDanger: "#2",
|
||||
statusDotRunning: "#3",
|
||||
statusDotSuccess: "#4",
|
||||
border: "#5",
|
||||
surface2: "#6",
|
||||
borderAccent: "#7",
|
||||
surface1: "#8",
|
||||
foregroundMuted: "#9",
|
||||
foreground: "#a",
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/menu", () => ({
|
||||
MenuRoot: ({ children }: { children?: React.ReactNode }) => children,
|
||||
MenuSurface: ({ children }: { children?: React.ReactNode }) => children,
|
||||
MenuTrigger: ({ children }: { children?: React.ReactNode }) => children,
|
||||
useMenuContext: () => ({ open: false }),
|
||||
}));
|
||||
|
||||
import { ComposerTrackBar } from "./tracks";
|
||||
import {
|
||||
COMPOSER_PILL_CLEARANCE,
|
||||
composerPillStyles,
|
||||
resolveComposerPillClearance,
|
||||
resolveComposerTrackTailClearance,
|
||||
} from "./pill-styles";
|
||||
|
||||
describe("ComposerTrackBar", () => {
|
||||
let root: Root | null = null;
|
||||
let container: HTMLDivElement | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("React", React);
|
||||
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root) act(() => root?.unmount());
|
||||
container?.remove();
|
||||
root = null;
|
||||
container = null;
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("floats over content without painting a background band", () => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
act(() => root?.render(<ComposerTrackBar>track</ComposerTrackBar>));
|
||||
|
||||
const style = JSON.parse(container.firstElementChild?.getAttribute("data-style") ?? "{}");
|
||||
expect(style).toMatchObject({
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: { xs: 8, md: 10 },
|
||||
});
|
||||
expect(style).not.toHaveProperty("backgroundColor");
|
||||
expect(container.firstElementChild?.getAttribute("data-has-layout-handler")).toBe("false");
|
||||
|
||||
const trackStyle = JSON.parse(
|
||||
container.firstElementChild?.firstElementChild?.getAttribute("data-style") ?? "{}",
|
||||
);
|
||||
expect(trackStyle).toMatchObject({ flexDirection: "row" });
|
||||
expect(trackStyle).not.toHaveProperty("flexWrap");
|
||||
});
|
||||
|
||||
it("matches the composer's corner tangent without radius clamping", () => {
|
||||
expect(composerPillStyles.body).toMatchObject({
|
||||
minHeight: 32,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 16,
|
||||
});
|
||||
expect(composerPillStyles.label).toMatchObject({ fontSize: 12 });
|
||||
expect(resolveComposerPillClearance(true)).toBe(8);
|
||||
expect(resolveComposerPillClearance(false)).toBe(10);
|
||||
expect(resolveComposerTrackTailClearance(true)).toBe(48);
|
||||
expect(resolveComposerTrackTailClearance(false)).toBe(52);
|
||||
expect(COMPOSER_PILL_CLEARANCE).toEqual({ compact: 8, wide: 10 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||
|
||||
type DiffStat = NonNullable<WorkspaceDescriptor["diffStat"]>;
|
||||
|
||||
export function useVisibleWorkspaceDiffStat(
|
||||
serverId: string,
|
||||
workspaceId: string,
|
||||
): DiffStat | null {
|
||||
const diffStat = useWorkspaceFields(serverId, workspaceId, (workspace) => workspace.diffStat);
|
||||
return hasDiffStatChanges(diffStat) ? diffStat : null;
|
||||
}
|
||||
|
||||
export function useWorkspaceHasDiffStat(serverId: string, workspaceId: string): boolean {
|
||||
return (
|
||||
useWorkspaceFields(serverId, workspaceId, (workspace) =>
|
||||
hasDiffStatChanges(workspace.diffStat),
|
||||
) ?? false
|
||||
);
|
||||
}
|
||||
|
||||
function hasDiffStatChanges(diffStat: WorkspaceDescriptor["diffStat"]): diffStat is DiffStat {
|
||||
return Boolean(diffStat && (diffStat.additions > 0 || diffStat.deletions > 0));
|
||||
}
|
||||
@@ -408,7 +408,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
);
|
||||
|
||||
// Client activity tracking (heartbeat, push token registration)
|
||||
useClientActivity({ client, focusedAgentId, focusedTerminalId, onAppResumed: handleAppResumed });
|
||||
useClientActivity({
|
||||
client,
|
||||
focusedAgentId,
|
||||
focusedTerminalId,
|
||||
onUserActivity: () => getHostRuntimeStore().recordUserActivity(),
|
||||
onAppResumed: handleAppResumed,
|
||||
});
|
||||
useEffect(() => startPushNotifications({ client, serverId }), [client, serverId]);
|
||||
|
||||
const notifyAgentAttention = useCallback(
|
||||
|
||||
@@ -262,6 +262,7 @@ function PairingOffer(props: PairDeviceBodyProps & { offer: { url: string } }) {
|
||||
{props.copied ? t("pairing.device.copied") : t("pairing.device.copy")}
|
||||
</Button>
|
||||
</View>
|
||||
<Alert variant="warning" description={t("pairing.device.securityWarning")} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
||||
import { LiveFileModel, type LiveFileSession } from "./model";
|
||||
import { SOURCE_PRESENTATION_BUDGETS } from "../source/presentation";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
export function useLiveFile(input: {
|
||||
client: DaemonClient | null;
|
||||
@@ -18,7 +20,12 @@ export function useLiveFile(input: {
|
||||
return client.subscribeFile(target, onVersion);
|
||||
},
|
||||
read(target) {
|
||||
return client.readFile(target.cwd, target.path);
|
||||
return client.readFile(
|
||||
target.cwd,
|
||||
target.path,
|
||||
undefined,
|
||||
SOURCE_PRESENTATION_BUDGETS[isWeb ? "web" : "native"].plain,
|
||||
);
|
||||
},
|
||||
};
|
||||
}, [input.client]);
|
||||
|
||||
@@ -14,11 +14,6 @@ import { StyleSheet, UnistylesRuntime, withUnistyles } from "react-native-unisty
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
|
||||
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
|
||||
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
import { lineNumberGutterWidth } from "@/components/code-insets";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import { filePreviewRenderKind } from "@/components/file-pane-render-mode";
|
||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||
import { getFileNameFromPath } from "@/attachments/utils";
|
||||
@@ -38,6 +33,7 @@ import { FileMarkdownPreview } from "./markdown-preview";
|
||||
import { FileEditorModel, getFileConflictCallout, type FileConflictCallout } from "./editor/model";
|
||||
import { createFileObservationSource } from "./editor/observation-source";
|
||||
import { FileEditorView } from "./editor/view";
|
||||
import { FileSourceView } from "./source/view";
|
||||
import type { FileConflictAlertState } from "./conflict-alert";
|
||||
import type { LiveFileModel } from "./live-file/model";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
@@ -49,13 +45,6 @@ const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
|
||||
interface CodeLineProps {
|
||||
tokens: HighlightToken[];
|
||||
lineNumber: number;
|
||||
gutterWidth: number;
|
||||
highlighted: boolean;
|
||||
}
|
||||
|
||||
interface FilePreviewBodyProps {
|
||||
preview: ExplorerFile | null;
|
||||
mode?: "preview" | "source";
|
||||
@@ -76,11 +65,6 @@ function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
interface FileLineSelection {
|
||||
lineStart: number;
|
||||
lineEnd: number;
|
||||
}
|
||||
|
||||
function formatFileSize({ size }: { size: number }): string {
|
||||
if (size < 1024) {
|
||||
return `${size} B`;
|
||||
@@ -91,101 +75,66 @@ function formatFileSize({ size }: { size: number }): string {
|
||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function clampLineSelection(input: {
|
||||
lineStart?: number;
|
||||
lineEnd?: number;
|
||||
lineCount: number;
|
||||
}): FileLineSelection | null {
|
||||
if (!input.lineStart || input.lineStart <= 0 || input.lineCount <= 0) {
|
||||
return null;
|
||||
}
|
||||
const lineStart = Math.min(Math.floor(input.lineStart), input.lineCount);
|
||||
const rawLineEnd =
|
||||
input.lineEnd && input.lineEnd >= input.lineStart ? input.lineEnd : input.lineStart;
|
||||
const lineEnd = Math.min(Math.floor(rawLineEnd), input.lineCount);
|
||||
return { lineStart, lineEnd: Math.max(lineStart, lineEnd) };
|
||||
}
|
||||
|
||||
const CodeLine = React.memo(function CodeLine({
|
||||
tokens,
|
||||
lineNumber,
|
||||
gutterWidth,
|
||||
highlighted,
|
||||
}: CodeLineProps) {
|
||||
const gutterStyle = useMemo(
|
||||
() => [codeLineStyles.gutter, inlineUnistylesStyle({ width: gutterWidth })],
|
||||
[gutterWidth],
|
||||
);
|
||||
const lineStyle = useMemo(
|
||||
() => [codeLineStyles.line, highlighted && codeLineStyles.highlightedLine],
|
||||
[highlighted],
|
||||
);
|
||||
const keyedTokens = useMemo(
|
||||
() => tokens.map((token, index) => ({ key: `${index}-${token.text}`, token })),
|
||||
[tokens],
|
||||
function ReadonlySource({
|
||||
preview,
|
||||
filename,
|
||||
location,
|
||||
navigationRevision,
|
||||
}: {
|
||||
preview: ExplorerFile;
|
||||
filename: string;
|
||||
location: WorkspaceFileLocation;
|
||||
navigationRevision: number;
|
||||
}) {
|
||||
const theme = UnistylesRuntime.getTheme();
|
||||
const { t } = useTranslation();
|
||||
const visualTheme = useMemo(
|
||||
() => ({
|
||||
colorScheme: theme.colorScheme,
|
||||
background: theme.colors.surface0,
|
||||
foreground: theme.colors.foreground,
|
||||
cursor: theme.colors.terminal.cursor,
|
||||
foregroundMuted: theme.colors.foregroundMuted,
|
||||
border: theme.colors.border,
|
||||
selection: theme.colors.terminal.selectionBackground,
|
||||
monoFont: theme.fontFamily.mono,
|
||||
codeFontSize: theme.fontSize.code,
|
||||
syntax: theme.colors.syntax,
|
||||
}),
|
||||
[theme],
|
||||
);
|
||||
return (
|
||||
<View style={lineStyle}>
|
||||
<View style={gutterStyle}>
|
||||
<Text numberOfLines={1} style={codeLineStyles.gutterText}>
|
||||
{String(lineNumber)}
|
||||
</Text>
|
||||
</View>
|
||||
<Text selectable style={codeLineStyles.lineText}>
|
||||
{keyedTokens.map(({ key, token }) => (
|
||||
<CodeLineToken key={key} token={token} />
|
||||
))}
|
||||
</Text>
|
||||
<FileSourceView
|
||||
content={preview.content ?? ""}
|
||||
filename={filename}
|
||||
location={location}
|
||||
navigationRevision={navigationRevision}
|
||||
size={preview.size}
|
||||
theme={visualTheme}
|
||||
tooLargeMessage={t("panels.file.tooLargeToDisplay")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TooLargeSource({ size }: { size?: number }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View style={styles.centerState} testID="file-source-too-large">
|
||||
<Text style={styles.emptyText}>{t("panels.file.tooLargeToDisplay")}</Text>
|
||||
{size ? <Text style={styles.binaryMetaText}>{formatFileSize({ size })}</Text> : null}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
interface CodeLineTokenProps {
|
||||
token: HighlightToken;
|
||||
}
|
||||
|
||||
function CodeLineToken({ token }: CodeLineTokenProps) {
|
||||
return <Text style={syntaxTokenStyleFor(token.style)}>{token.text}</Text>;
|
||||
}
|
||||
|
||||
const codeLineStyles = StyleSheet.create((theme) => ({
|
||||
line: {
|
||||
flexDirection: "row",
|
||||
},
|
||||
highlightedLine: {
|
||||
backgroundColor: theme.colors.accentBorder,
|
||||
},
|
||||
gutter: {
|
||||
alignItems: "flex-end",
|
||||
paddingRight: theme.spacing[3],
|
||||
flexShrink: 0,
|
||||
},
|
||||
gutterText: {
|
||||
color: theme.colors.foreground,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
lineHeight: theme.fontSize.code * 1.45,
|
||||
opacity: 0.4,
|
||||
userSelect: "none",
|
||||
},
|
||||
lineText: {
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
lineHeight: theme.fontSize.code * 1.45,
|
||||
flex: 1,
|
||||
},
|
||||
}));
|
||||
|
||||
function FilePreviewBody({
|
||||
preview,
|
||||
mode,
|
||||
isLoading,
|
||||
isMobile,
|
||||
isMobile: _isMobile,
|
||||
location,
|
||||
navigationRevision,
|
||||
imagePreviewUri,
|
||||
}: FilePreviewBodyProps) {
|
||||
const theme = UnistylesRuntime.getTheme();
|
||||
const { t } = useTranslation();
|
||||
const filePath = location.path;
|
||||
// A line target means the caller wants to land on that line, so fall back to
|
||||
@@ -197,48 +146,11 @@ function FilePreviewBody({
|
||||
|
||||
const previewScrollRef = useRef<RNScrollView>(null);
|
||||
|
||||
const highlightedLines = useMemo(() => {
|
||||
if (!preview || preview.kind !== "text" || renderKind) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return highlightCode(preview.content ?? "", filePath);
|
||||
}, [renderKind, preview, filePath]);
|
||||
|
||||
const gutterWidth = useMemo(() => {
|
||||
if (!highlightedLines) return 0;
|
||||
return lineNumberGutterWidth(highlightedLines.length, theme.fontSize.code);
|
||||
}, [highlightedLines, theme.fontSize.code]);
|
||||
const lineHeight = theme.fontSize.code * 1.45;
|
||||
const lineSelection = useMemo(() => {
|
||||
if (!highlightedLines) {
|
||||
return null;
|
||||
}
|
||||
return clampLineSelection({
|
||||
lineStart: location.lineStart,
|
||||
lineEnd: location.lineEnd,
|
||||
lineCount: highlightedLines.length,
|
||||
});
|
||||
}, [highlightedLines, location.lineEnd, location.lineStart]);
|
||||
|
||||
const imageSource = useMemo(
|
||||
() => (imagePreviewUri ? { uri: imagePreviewUri } : null),
|
||||
[imagePreviewUri],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lineSelection) {
|
||||
return;
|
||||
}
|
||||
const timeout = setTimeout(() => {
|
||||
previewScrollRef.current?.scrollTo({
|
||||
y: Math.max(0, (lineSelection.lineStart - 1) * lineHeight),
|
||||
animated: false,
|
||||
});
|
||||
}, 0);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [lineHeight, lineSelection, navigationRevision]);
|
||||
|
||||
if (isLoading && !preview) {
|
||||
return (
|
||||
<View style={styles.centerState} testID="file-preview-loading">
|
||||
@@ -280,51 +192,13 @@ function FilePreviewBody({
|
||||
);
|
||||
}
|
||||
|
||||
const lines = highlightedLines ?? [[{ text: preview.content ?? "", style: null }]];
|
||||
const keyedLines = lines.map((tokens, index) => ({
|
||||
key: `line-${index}`,
|
||||
tokens,
|
||||
lineNumber: index + 1,
|
||||
}));
|
||||
const codeLines = (
|
||||
<View dataSet={CODE_SURFACE_DATASET}>
|
||||
{keyedLines.map(({ key, tokens, lineNumber }) => (
|
||||
<CodeLine
|
||||
key={key}
|
||||
tokens={tokens}
|
||||
lineNumber={lineNumber}
|
||||
gutterWidth={gutterWidth}
|
||||
highlighted={
|
||||
Boolean(lineSelection) &&
|
||||
lineNumber >= (lineSelection?.lineStart ?? 0) &&
|
||||
lineNumber <= (lineSelection?.lineEnd ?? 0)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.previewScrollContainer}>
|
||||
<RNScrollView
|
||||
ref={previewScrollRef}
|
||||
style={styles.previewContent}
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
{isMobile ? (
|
||||
<View style={styles.previewCodeScrollContent}>{codeLines}</View>
|
||||
) : (
|
||||
<RNScrollView
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
contentContainerStyle={styles.previewCodeScrollContent}
|
||||
>
|
||||
{codeLines}
|
||||
</RNScrollView>
|
||||
)}
|
||||
</RNScrollView>
|
||||
</View>
|
||||
<ReadonlySource
|
||||
preview={preview}
|
||||
filename={filePath}
|
||||
location={location}
|
||||
navigationRevision={navigationRevision}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -557,6 +431,13 @@ function FilePanePresentation({
|
||||
}
|
||||
|
||||
if (errorMessage) {
|
||||
if (errorMessage === "File is too large to display") {
|
||||
return (
|
||||
<View style={styles.container} testID="workspace-file-pane">
|
||||
<TooLargeSource />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View style={styles.container} testID="workspace-file-pane">
|
||||
<View style={styles.centerState}>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SOURCE_PRESENTATION_BUDGETS, selectSourcePresentation } from "./presentation";
|
||||
|
||||
describe("selectSourcePresentation", () => {
|
||||
for (const platform of ["web", "native"] as const) {
|
||||
const budgets = SOURCE_PRESENTATION_BUDGETS[platform];
|
||||
it(`keeps ${platform} threshold edges predictable`, () => {
|
||||
expect(selectSourcePresentation({ platform, size: budgets.highlighted })).toBe("highlighted");
|
||||
expect(selectSourcePresentation({ platform, size: budgets.highlighted + 1 })).toBe("plain");
|
||||
expect(selectSourcePresentation({ platform, size: budgets.plain })).toBe("plain");
|
||||
expect(selectSourcePresentation({ platform, size: budgets.plain + 1 })).toBe("unsupported");
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
export const SOURCE_PRESENTATION_BUDGETS = {
|
||||
web: { highlighted: 10 * 1024 * 1024, plain: 50 * 1024 * 1024 },
|
||||
native: { highlighted: 1024 * 1024, plain: 10 * 1024 * 1024 },
|
||||
} as const;
|
||||
|
||||
export type SourcePresentation = "highlighted" | "plain" | "unsupported";
|
||||
export type SourcePlatform = keyof typeof SOURCE_PRESENTATION_BUDGETS;
|
||||
|
||||
export function selectSourcePresentation(input: {
|
||||
size: number;
|
||||
platform: SourcePlatform;
|
||||
}): SourcePresentation {
|
||||
const budgets = SOURCE_PRESENTATION_BUDGETS[input.platform];
|
||||
if (input.size <= budgets.highlighted) return "highlighted";
|
||||
if (input.size <= budgets.plain) return "plain";
|
||||
return "unsupported";
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { FlatList, Text, View, type ListRenderItem } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
|
||||
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
||||
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||
import type { EditorVisualTheme } from "../editor/extensions.web";
|
||||
import { selectSourcePresentation } from "./presentation";
|
||||
|
||||
interface FileSourceViewProps {
|
||||
content: string;
|
||||
filename: string;
|
||||
location: WorkspaceFileLocation;
|
||||
navigationRevision: number;
|
||||
size: number;
|
||||
theme: EditorVisualTheme;
|
||||
tooLargeMessage: string;
|
||||
}
|
||||
|
||||
interface SourceLine {
|
||||
number: number;
|
||||
tokens: HighlightToken[];
|
||||
}
|
||||
|
||||
export function FileSourceView({
|
||||
content,
|
||||
filename,
|
||||
location,
|
||||
navigationRevision,
|
||||
size,
|
||||
tooLargeMessage,
|
||||
}: FileSourceViewProps) {
|
||||
const presentation = selectSourcePresentation({ size, platform: "native" });
|
||||
if (presentation === "unsupported") {
|
||||
return (
|
||||
<View style={styles.unsupported} testID="file-source-too-large">
|
||||
<Text style={styles.unsupportedText}>{tooLargeMessage}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<VirtualizedSource
|
||||
content={content}
|
||||
filename={filename}
|
||||
location={location}
|
||||
navigationRevision={navigationRevision}
|
||||
presentation={presentation}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function VirtualizedSource({
|
||||
content,
|
||||
filename,
|
||||
location,
|
||||
navigationRevision,
|
||||
presentation,
|
||||
}: Omit<FileSourceViewProps, "size" | "theme" | "tooLargeMessage"> & {
|
||||
presentation: "highlighted" | "plain";
|
||||
}) {
|
||||
const listRef = useRef<FlatList<SourceLine>>(null);
|
||||
const lines = useMemo(() => {
|
||||
if (presentation === "highlighted")
|
||||
return highlightCode(content, filename).map((tokens, index) => ({
|
||||
number: index + 1,
|
||||
tokens,
|
||||
}));
|
||||
return content
|
||||
.split("\n")
|
||||
.map((text, index) => ({ number: index + 1, tokens: [{ text, style: null }] }));
|
||||
}, [content, filename, presentation]);
|
||||
useEffect(() => {
|
||||
if (!location.lineStart) return;
|
||||
listRef.current?.scrollToIndex({
|
||||
index: Math.min(location.lineStart - 1, lines.length - 1),
|
||||
animated: false,
|
||||
viewPosition: 0.5,
|
||||
});
|
||||
}, [lines.length, location.lineStart, navigationRevision]);
|
||||
return (
|
||||
<FlatList
|
||||
ref={listRef}
|
||||
data={lines}
|
||||
keyExtractor={sourceLineKey}
|
||||
initialNumToRender={24}
|
||||
windowSize={9}
|
||||
getItemLayout={sourceLineLayout}
|
||||
renderItem={renderSourceLine}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceLineView({ line }: { line: SourceLine }) {
|
||||
return (
|
||||
<View style={styles.line}>
|
||||
<Text style={styles.gutter}>{line.number}</Text>
|
||||
<Text selectable style={styles.text}>
|
||||
{line.tokens.map((token) => (
|
||||
<Text key={`${token.style}:${token.text}`} style={syntaxTokenStyleFor(token.style)}>
|
||||
{token.text}
|
||||
</Text>
|
||||
))}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function sourceLineKey(line: SourceLine): string {
|
||||
return String(line.number);
|
||||
}
|
||||
|
||||
function sourceLineLayout(_data: ArrayLike<SourceLine> | null | undefined, index: number) {
|
||||
return { length: 20, offset: index * 20, index };
|
||||
}
|
||||
|
||||
const renderSourceLine: ListRenderItem<SourceLine> = ({ item }) => <SourceLineView line={item} />;
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
unsupported: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: theme.spacing[4],
|
||||
},
|
||||
unsupportedText: { color: theme.colors.foregroundMuted, textAlign: "center" },
|
||||
line: { flexDirection: "row", minHeight: theme.fontSize.code * 1.45 },
|
||||
gutter: {
|
||||
width: 56,
|
||||
paddingRight: theme.spacing[3],
|
||||
textAlign: "right",
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
},
|
||||
text: {
|
||||
flex: 1,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
lineHeight: theme.fontSize.code * 1.45,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1 @@
|
||||
export { FileSourceView } from "./view.native";
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Compartment, EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { getLanguageForFile } from "@getpaseo/highlight";
|
||||
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||
import type { EditorVisualTheme } from "../editor/extensions.web";
|
||||
import { editorTheme } from "../editor/extensions.web";
|
||||
import { selectSourcePresentation, type SourcePresentation } from "./presentation";
|
||||
|
||||
interface FileSourceViewProps {
|
||||
content: string;
|
||||
filename: string;
|
||||
location: WorkspaceFileLocation;
|
||||
navigationRevision: number;
|
||||
size: number;
|
||||
theme: EditorVisualTheme;
|
||||
tooLargeMessage: string;
|
||||
}
|
||||
|
||||
const languageCompartment = new Compartment();
|
||||
const themeCompartment = new Compartment();
|
||||
|
||||
export function FileSourceView({
|
||||
content,
|
||||
filename,
|
||||
location,
|
||||
navigationRevision,
|
||||
size,
|
||||
theme,
|
||||
tooLargeMessage,
|
||||
}: FileSourceViewProps) {
|
||||
const presentation = selectSourcePresentation({ size, platform: "web" });
|
||||
if (presentation === "unsupported") {
|
||||
return (
|
||||
<div data-testid="file-source-too-large" style={UNSUPPORTED_STYLE}>
|
||||
{tooLargeMessage}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ReadonlyCodeMirror
|
||||
content={content}
|
||||
filename={filename}
|
||||
location={location}
|
||||
navigationRevision={navigationRevision}
|
||||
presentation={presentation}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ReadonlyCodeMirror({
|
||||
content,
|
||||
filename,
|
||||
location,
|
||||
navigationRevision,
|
||||
presentation,
|
||||
theme,
|
||||
}: Omit<FileSourceViewProps, "size" | "tooLargeMessage"> & {
|
||||
presentation: Exclude<SourcePresentation, "unsupported">;
|
||||
}) {
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
const initial = useRef({ content, filename, presentation, theme });
|
||||
|
||||
useEffect(() => {
|
||||
if (!hostRef.current) return;
|
||||
const values = initial.current;
|
||||
const view = new EditorView({
|
||||
parent: hostRef.current,
|
||||
state: EditorState.create({
|
||||
doc: values.content,
|
||||
extensions: [
|
||||
EditorState.readOnly.of(true),
|
||||
EditorView.editable.of(false),
|
||||
languageCompartment.of(
|
||||
languageFor({ filename: values.filename, presentation: values.presentation }),
|
||||
),
|
||||
themeCompartment.of(editorTheme(values.theme)),
|
||||
],
|
||||
}),
|
||||
});
|
||||
viewRef.current = view;
|
||||
return () => {
|
||||
view.destroy();
|
||||
viewRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view || view.state.doc.toString() === content) return;
|
||||
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: content } });
|
||||
}, [content]);
|
||||
|
||||
useEffect(() => {
|
||||
viewRef.current?.dispatch({
|
||||
effects: [
|
||||
languageCompartment.reconfigure(languageFor({ filename, presentation })),
|
||||
themeCompartment.reconfigure(editorTheme(theme)),
|
||||
],
|
||||
});
|
||||
}, [filename, presentation, theme]);
|
||||
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view || !location.lineStart) return;
|
||||
const line = Math.min(location.lineStart, view.state.doc.lines);
|
||||
const from = view.state.doc.line(line).from;
|
||||
view.dispatch({ effects: EditorView.scrollIntoView(from, { y: "center" }) });
|
||||
}, [location.lineStart, navigationRevision]);
|
||||
|
||||
return <div ref={hostRef} data-testid="file-source-editor" style={HOST_STYLE} />;
|
||||
}
|
||||
|
||||
function languageFor(input: {
|
||||
filename: string;
|
||||
presentation: Exclude<SourcePresentation, "unsupported">;
|
||||
}) {
|
||||
return input.presentation === "highlighted"
|
||||
? (getLanguageForFile(input.filename)?.extension ?? [])
|
||||
: [];
|
||||
}
|
||||
|
||||
const HOST_STYLE = { flex: 1, minHeight: 0, overflow: "hidden" } as const;
|
||||
const UNSUPPORTED_STYLE = {
|
||||
alignItems: "center",
|
||||
display: "flex",
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
} as const;
|
||||
@@ -2,7 +2,7 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { View, Text, Pressable, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { ChevronDown, MoreVertical } from "lucide-react-native";
|
||||
import { ChevronDown, GitBranch, MoreVertical } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -22,6 +22,7 @@ import { buttonControlHeight } from "@/components/ui/control-geometry";
|
||||
interface GitActionsSplitButtonProps {
|
||||
gitActions: GitActions;
|
||||
hideLabels?: boolean;
|
||||
menuOnly?: boolean;
|
||||
}
|
||||
|
||||
interface GitActionMenuItemProps {
|
||||
@@ -74,7 +75,11 @@ function GitActionMenuItem({
|
||||
);
|
||||
}
|
||||
|
||||
export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSplitButtonProps) {
|
||||
export function GitActionsSplitButton({
|
||||
gitActions,
|
||||
hideLabels,
|
||||
menuOnly = false,
|
||||
}: GitActionsSplitButtonProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const runGitAction = useGitActionRunner();
|
||||
@@ -115,6 +120,62 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli
|
||||
[theme.colors.surface2],
|
||||
);
|
||||
|
||||
const menuOnlyTriggerStyle = useCallback(
|
||||
({ hovered, pressed, open }: { hovered: boolean; pressed: boolean; open: boolean }) => [
|
||||
styles.menuOnlyTrigger,
|
||||
(hovered || pressed || open) &&
|
||||
inlineUnistylesStyle({ backgroundColor: theme.colors.surface2 }),
|
||||
],
|
||||
[theme.colors.surface2],
|
||||
);
|
||||
|
||||
const menuOnlyActions = useMemo(
|
||||
() => [
|
||||
...(gitActions.primary ? [gitActions.primary] : []),
|
||||
...gitActions.secondary,
|
||||
...gitActions.menu,
|
||||
],
|
||||
[gitActions.menu, gitActions.primary, gitActions.secondary],
|
||||
);
|
||||
|
||||
if (menuOnly) {
|
||||
if (menuOnlyActions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
testID="changes-actions-menu-trigger"
|
||||
style={menuOnlyTriggerStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("workspace.header.actions.workspaceActions")}
|
||||
>
|
||||
<GitBranch size={16} color={theme.colors.foregroundMuted} />
|
||||
<ChevronDown size={12} color={theme.colors.foregroundMuted} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" testID="changes-primary-cta-menu">
|
||||
{menuOnlyActions.map((action, index) => (
|
||||
<GitActionMenuItem
|
||||
key={action.id}
|
||||
action={action}
|
||||
onSelect={runGitAction}
|
||||
archiveShortcutKeys={archiveShortcutKeys}
|
||||
needsSeparator={action.startsGroup}
|
||||
showSeparator={index > 0}
|
||||
closeOnSelect={
|
||||
action.status === "idle" &&
|
||||
action.id === "pr" &&
|
||||
action.label === action.pendingLabel &&
|
||||
action.label === action.successLabel
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
{gitActions.primary ? (
|
||||
@@ -224,6 +285,17 @@ const styles = StyleSheet.create((theme) => ({
|
||||
justifyContent: "center",
|
||||
position: "relative",
|
||||
},
|
||||
menuOnlyTrigger: {
|
||||
width: 48,
|
||||
height: buttonControlHeight.xs,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.borderAccent,
|
||||
},
|
||||
splitButtonPrimaryDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
|
||||
@@ -495,7 +495,14 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
schedulePaint();
|
||||
return;
|
||||
}
|
||||
if (drag && !moved && hit?.kind === "cell" && hit.target && reviewActions) {
|
||||
if (
|
||||
event.pointerType === "touch" &&
|
||||
drag &&
|
||||
!moved &&
|
||||
hit?.kind === "cell" &&
|
||||
hit.target &&
|
||||
reviewActions
|
||||
) {
|
||||
selectionRef.current = null;
|
||||
reviewActions.onStartComment(hit.target);
|
||||
schedulePaint();
|
||||
|
||||
@@ -415,7 +415,7 @@ function ChangesToolbar(props: ChangesToolbarProps) {
|
||||
onToggle={onToggleDesktopTree}
|
||||
/>
|
||||
) : null}
|
||||
{isMobile ? <GitActionsSplitButton gitActions={gitActions} /> : null}
|
||||
{isMobile ? <GitActionsSplitButton gitActions={gitActions} menuOnly /> : null}
|
||||
<ChangesOptionsMenu {...props} />
|
||||
</View>
|
||||
</PaneContentToolbar>
|
||||
|
||||
@@ -68,12 +68,26 @@ function buildTracker(
|
||||
initialFocusedTerminalId: overrides.initialFocusedTerminalId ?? null,
|
||||
initialAppVisible: overrides.initialAppVisible ?? true,
|
||||
now: clock.now,
|
||||
onUserActivity: overrides.onUserActivity ?? (() => undefined),
|
||||
onAppResumed: overrides.onAppResumed,
|
||||
});
|
||||
return { tracker, client, clock };
|
||||
}
|
||||
|
||||
describe("client activity tracker", () => {
|
||||
it("notifies local consumers about user activity", () => {
|
||||
let notifications = 0;
|
||||
const { tracker } = buildTracker({
|
||||
onUserActivity: () => {
|
||||
notifications += 1;
|
||||
},
|
||||
});
|
||||
|
||||
tracker.recordUserActivity();
|
||||
|
||||
expect(notifications).toBe(1);
|
||||
});
|
||||
|
||||
it("includes the latest user-activity time in the next heartbeat", () => {
|
||||
const { tracker, client, clock } = buildTracker();
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface ClientActivityTrackerInput {
|
||||
initialFocusedTerminalId: string | null;
|
||||
initialAppVisible: boolean;
|
||||
now: () => number;
|
||||
onUserActivity: () => void;
|
||||
onAppResumed?: (awayMs: number) => void;
|
||||
}
|
||||
|
||||
@@ -39,7 +40,7 @@ export interface ClientActivityTracker {
|
||||
export function createClientActivityTracker(
|
||||
input: ClientActivityTrackerInput,
|
||||
): ClientActivityTracker {
|
||||
const { client, deviceType, now, onAppResumed } = input;
|
||||
const { client, deviceType, now, onUserActivity, onAppResumed } = input;
|
||||
let lastActivityAtMs = now();
|
||||
let appVisible = input.initialAppVisible;
|
||||
let appVisibilityChangedAtMs = now();
|
||||
@@ -62,6 +63,7 @@ export function createClientActivityTracker(
|
||||
|
||||
function recordUserActivity(): void {
|
||||
lastActivityAtMs = now();
|
||||
onUserActivity();
|
||||
}
|
||||
|
||||
function maybeSendImmediateHeartbeat(): void {
|
||||
|
||||
@@ -15,6 +15,7 @@ interface ClientActivityOptions {
|
||||
client: DaemonClient;
|
||||
focusedAgentId: string | null;
|
||||
focusedTerminalId: string | null;
|
||||
onUserActivity: () => void;
|
||||
onAppResumed?: (awayMs: number) => void;
|
||||
}
|
||||
|
||||
@@ -28,6 +29,7 @@ export function useClientActivity({
|
||||
client,
|
||||
focusedAgentId,
|
||||
focusedTerminalId,
|
||||
onUserActivity,
|
||||
onAppResumed,
|
||||
}: ClientActivityOptions): void {
|
||||
const onAppResumedRef = useRef(onAppResumed);
|
||||
@@ -42,6 +44,7 @@ export function useClientActivity({
|
||||
initialFocusedTerminalId: focusedTerminalId,
|
||||
initialAppVisible: AppState.currentState === "active",
|
||||
now: () => Date.now(),
|
||||
onUserActivity,
|
||||
onAppResumed: (awayMs) => onAppResumedRef.current?.(awayMs),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@ export const LEGACY_SETTINGS_KEY = "@paseo:settings";
|
||||
|
||||
/**
|
||||
* The applied-migration marker deliberately sits outside the settings blob:
|
||||
* `StoredAppSettingsSchema` is a strict object and `readValidatedJson` clears the value when
|
||||
* parsing fails, so a new field there would wipe every setting for anyone who downgrades to a
|
||||
* client that predates it. Keeping the marker as a list of ids also means the next migration is
|
||||
* a new entry rather than a schema change.
|
||||
* App settings preserve fields newer builds understand, but this marker remains separate so
|
||||
* applying a migration never becomes an app-settings field that older builds must interpret.
|
||||
* Keeping the marker as a list of ids also means the next migration is a new entry rather than
|
||||
* a schema change.
|
||||
*/
|
||||
export const SETTINGS_MIGRATIONS_KEY = "@paseo:settings-migrations";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import { readValidatedJson } from "@/storage/validated-storage";
|
||||
import { APP_SETTINGS_KEY, SETTINGS_MIGRATIONS_KEY } from "./keys";
|
||||
import type { AppSettings, KeyValueStorage } from "./storage";
|
||||
import type { AppSettings, KeyValueStorage, PersistedAppSettings } from "./storage";
|
||||
|
||||
const AppliedMigrationsSchema = z.strictObject({ applied: z.array(z.string()) });
|
||||
|
||||
@@ -22,9 +22,14 @@ const STEER_DEFAULT_MIGRATION = "steer-default";
|
||||
export async function migrateAppSettings(
|
||||
settings: AppSettings,
|
||||
storage: KeyValueStorage,
|
||||
stored?: PersistedAppSettings,
|
||||
): Promise<AppSettings> {
|
||||
const stored = await readValidatedJson(storage, SETTINGS_MIGRATIONS_KEY, AppliedMigrationsSchema);
|
||||
const applied = new Set(stored?.applied ?? []);
|
||||
const migrationMarker = await readValidatedJson(
|
||||
storage,
|
||||
SETTINGS_MIGRATIONS_KEY,
|
||||
AppliedMigrationsSchema,
|
||||
);
|
||||
const applied = new Set(migrationMarker?.applied ?? []);
|
||||
if (applied.has(STEER_DEFAULT_MIGRATION)) {
|
||||
return settings;
|
||||
}
|
||||
@@ -32,7 +37,15 @@ export async function migrateAppSettings(
|
||||
const migrated: AppSettings =
|
||||
settings.sendBehavior === "interrupt" ? { ...settings, sendBehavior: "steer" } : settings;
|
||||
if (migrated !== settings) {
|
||||
await storage.setItem(APP_SETTINGS_KEY, JSON.stringify(migrated));
|
||||
const storedSidebarRowItems = stored?.sidebarRowItems ?? {};
|
||||
await storage.setItem(
|
||||
APP_SETTINGS_KEY,
|
||||
JSON.stringify({
|
||||
...stored,
|
||||
...migrated,
|
||||
sidebarRowItems: { ...storedSidebarRowItems, ...migrated.sidebarRowItems },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
applied.add(STEER_DEFAULT_MIGRATION);
|
||||
|
||||
@@ -50,6 +50,28 @@ describe("loadAppSettingsFromStorage", () => {
|
||||
});
|
||||
expect((await loadAppSettingsFromStorage(deps)).sendBehavior).toBe("steer");
|
||||
});
|
||||
|
||||
it("keeps valid settings when another build wrote unknown fields or enum values", async () => {
|
||||
const stored = {
|
||||
theme: "dark",
|
||||
contentFontSize: 16,
|
||||
sendBehavior: "future-mode",
|
||||
futureSetting: { enabled: true },
|
||||
sidebarRowItems: { host: false, futureRowItem: true },
|
||||
};
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[APP_SETTINGS_KEY]: JSON.stringify(stored),
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await loadAppSettingsFromStorage(deps);
|
||||
|
||||
expect(result.theme).toBe("dark");
|
||||
expect(result.sendBehavior).toBe(DEFAULT_CLIENT_SETTINGS.sendBehavior);
|
||||
expect(result.sidebarRowItems.host).toBe(false);
|
||||
expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null")).toEqual(stored);
|
||||
});
|
||||
it("migrates a stored interrupt to steer and persists it", async () => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
@@ -63,6 +85,32 @@ describe("loadAppSettingsFromStorage", () => {
|
||||
expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "{}").sendBehavior).toBe(
|
||||
"steer",
|
||||
);
|
||||
expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "{}")).not.toHaveProperty(
|
||||
"needsWrite",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps an explicit services choice over the legacy scripts fallback", async () => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({
|
||||
contentFontSize: 16,
|
||||
sidebarRowItems: { scripts: false, services: true },
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
expect((await loadAppSettingsFromStorage(deps)).sidebarRowItems.services).toBe(true);
|
||||
|
||||
await saveAppSettings({
|
||||
queryClient: new QueryClient(),
|
||||
updates: {
|
||||
sidebarRowItems: { ...DEFAULT_SIDEBAR_ROW_ITEMS, services: true },
|
||||
},
|
||||
deps,
|
||||
});
|
||||
|
||||
expect((await loadAppSettingsFromStorage(deps)).sidebarRowItems.services).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps an interrupt the user picked after the migration ran", async () => {
|
||||
@@ -108,8 +156,8 @@ describe("loadAppSettingsFromStorage", () => {
|
||||
|
||||
expect(result).toEqual(DEFAULT_CLIENT_SETTINGS);
|
||||
expect(DEFAULT_CLIENT_SETTINGS.language).toBe("system");
|
||||
expect(deps.storage.entries.get(APP_SETTINGS_KEY)).toBe(
|
||||
JSON.stringify(DEFAULT_CLIENT_SETTINGS),
|
||||
expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null")).toEqual(
|
||||
DEFAULT_CLIENT_SETTINGS,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -235,7 +283,11 @@ describe("loadAppSettingsFromStorage", () => {
|
||||
theme: "dark",
|
||||
contentFontSize: DEFAULT_UI_BASE_FONT_SIZE,
|
||||
});
|
||||
expect(deps.storage.entries.get(APP_SETTINGS_KEY)).toBe(JSON.stringify(result));
|
||||
expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null")).toEqual({
|
||||
manageBuiltInDaemon: false,
|
||||
releaseChannel: "beta",
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the legacy key's explicit interface size as content size", async () => {
|
||||
@@ -400,6 +452,36 @@ describe("loadSettingsFromStorage", () => {
|
||||
});
|
||||
|
||||
describe("saveAppSettings", () => {
|
||||
it("round-trips fields written by a newer build", async () => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({
|
||||
theme: "dark",
|
||||
contentFontSize: 16,
|
||||
sendBehavior: "future-mode",
|
||||
futureSetting: { enabled: true },
|
||||
sidebarRowItems: { host: false, futureRowItem: true },
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
await loadAppSettingsFromStorage(deps);
|
||||
await saveAppSettings({
|
||||
queryClient: new QueryClient(),
|
||||
updates: { theme: "light" },
|
||||
deps,
|
||||
});
|
||||
|
||||
expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null")).toMatchObject({
|
||||
theme: "light",
|
||||
futureSetting: { enabled: true },
|
||||
sidebarRowItems: {
|
||||
host: false,
|
||||
futureRowItem: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("saves terminal scrollback through app settings persistence", async () => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
@@ -609,7 +691,7 @@ describe("appearance settings", () => {
|
||||
|
||||
expect(result.uiBaseFontSize).toBe(baseSize);
|
||||
expect(persisted).toMatchObject({ uiBaseFontSize: baseSize });
|
||||
expect(persisted).not.toHaveProperty("uiFontSize");
|
||||
expect(persisted.uiFontSize).toBe(legacySize);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -623,6 +705,20 @@ describe("appearance settings", () => {
|
||||
expect((await loadAppSettingsFromStorage(deps)).uiBaseFontSize).toBe(16);
|
||||
});
|
||||
|
||||
it("falls back to a valid legacy interface scale when the explicit base size is invalid", async () => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({ uiBaseFontSize: "abc", uiFontSize: 17 }),
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await loadAppSettingsFromStorage(deps);
|
||||
const persisted = JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null");
|
||||
|
||||
expect(result.uiBaseFontSize).toBe(15);
|
||||
expect(persisted).toMatchObject({ uiBaseFontSize: 15, uiFontSize: 17 });
|
||||
});
|
||||
|
||||
it("clamps the UI base font size into range and rejects non-numeric values", async () => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
|
||||
@@ -2,16 +2,14 @@ import { isSyntaxThemeId, type SyntaxThemeId } from "@getpaseo/highlight";
|
||||
import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import type { DesktopSettings } from "@/desktop/settings/desktop-settings";
|
||||
import { parseAppLanguage, type AppLanguage } from "@/i18n/locales";
|
||||
import type { AppLanguage } from "@/i18n/locales";
|
||||
import {
|
||||
DEFAULT_SIDEBAR_CHECKS_DISPLAY,
|
||||
parseSidebarChecksDisplay,
|
||||
type SidebarChecksDisplay,
|
||||
} from "@/components/sidebar/display-preferences/checks-display";
|
||||
import {
|
||||
DEFAULT_SIDEBAR_ROW_ITEMS,
|
||||
isChecksHiddenByLegacyRowItem,
|
||||
parseSidebarRowItems,
|
||||
type SidebarRowItems,
|
||||
} from "@/components/sidebar/display-preferences/row-items";
|
||||
import { isNative } from "@/constants/platform";
|
||||
@@ -22,7 +20,6 @@ import {
|
||||
type ThemePreference,
|
||||
} from "@/styles/theme";
|
||||
import { z } from "zod";
|
||||
import { readValidatedJson } from "@/storage/validated-storage";
|
||||
import { APP_SETTINGS_KEY, LEGACY_SETTINGS_KEY } from "./keys";
|
||||
import { migrateAppSettings } from "./migrations";
|
||||
|
||||
@@ -41,17 +38,8 @@ const ThemePreferenceSchema = z.enum([
|
||||
...THEME_OPTIONS.map((option) => option.name),
|
||||
PLUGIN_THEME_PREFERENCE,
|
||||
]);
|
||||
const VALID_THEMES = new Set<string>(ThemePreferenceSchema.options);
|
||||
/** Where the theme picker lands when the persisted preference cannot be honoured. */
|
||||
export const DEFAULT_THEME_PREFERENCE = "auto" satisfies ThemePreference;
|
||||
const VALID_SERVICE_URL_BEHAVIORS = new Set<ServiceUrlBehavior>(["ask", "in-app", "external"]);
|
||||
const VALID_WORKSPACE_TITLE_SOURCES = new Set<WorkspaceTitleSource>(["title", "branch"]);
|
||||
const VALID_SIDEBAR_WORKSPACE_TRAILINGS = new Set<SidebarWorkspaceTrailing>([
|
||||
"diff",
|
||||
"timestamp",
|
||||
"none",
|
||||
]);
|
||||
const VALID_TOOL_CALL_DETAIL_LEVELS = new Set<ToolCallDetailLevel>(["overview", "detailed"]);
|
||||
export const DEFAULT_TERMINAL_SCROLLBACK_LINES = 10_000;
|
||||
export const MIN_TERMINAL_SCROLLBACK_LINES = 0;
|
||||
export const MAX_TERMINAL_SCROLLBACK_LINES = 1_000_000;
|
||||
@@ -106,58 +94,6 @@ export interface Settings extends AppSettings {
|
||||
releaseChannel: ReleaseChannel;
|
||||
}
|
||||
|
||||
// Strict, so every item in SIDEBAR_ROW_ITEMS needs a key here the day it is added: the whole
|
||||
// settings write is one validation, and one unknown key silently loses every other toggle in it.
|
||||
// `checks` and `scripts` are gone from the item list and stay here for the COMPAT reads in
|
||||
// row-items.ts.
|
||||
const SidebarRowItemsSchema = z.strictObject({
|
||||
branch: z.boolean().optional(),
|
||||
project: z.boolean().optional(),
|
||||
host: z.boolean().optional(),
|
||||
changeRequest: z.boolean().optional(),
|
||||
services: z.boolean().optional(),
|
||||
labels: z.boolean().optional(),
|
||||
checks: z.boolean().optional(),
|
||||
scripts: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const StoredAppSettingsSchema = z.strictObject({
|
||||
theme: ThemePreferenceSchema.optional(),
|
||||
pluginThemeId: z.string().nullish(),
|
||||
language: z
|
||||
.enum(["system", "ar", "en", "es", "fr", "ja", "ko", "pt-BR", "ru", "zh-CN"])
|
||||
.optional(),
|
||||
sendBehavior: z.enum(["interrupt", "steer", "queue"]).optional(),
|
||||
serviceUrlBehavior: z.enum(["ask", "in-app", "external"]).optional(),
|
||||
terminalScrollbackLines: z.union([z.number(), z.string()]).optional(),
|
||||
useLegacyTerminalRenderer: z.boolean().optional(),
|
||||
uiFontFamily: z.string().optional(),
|
||||
monoFontFamily: z.string().optional(),
|
||||
uiBaseFontSize: z.union([z.number(), z.string()]).optional(),
|
||||
contentFontSize: z.union([z.number(), z.string()]).optional(),
|
||||
// COMPAT(uiFontSizeScale): replaced by the literal base size in v0.4, remove after 2027-08-17.
|
||||
uiFontSize: z.union([z.number(), z.string()]).optional(),
|
||||
codeFontSize: z.union([z.number(), z.string()]).optional(),
|
||||
syntaxTheme: z.string().refine(isSyntaxThemeId).optional(),
|
||||
workspaceTitleSource: z.enum(["title", "branch"]).optional(),
|
||||
sidebarWorkspaceTrailing: z.enum(["diff", "timestamp", "none"]).optional(),
|
||||
sidebarRowItems: SidebarRowItemsSchema.optional(),
|
||||
sidebarChecksDisplay: z.enum(["iconAndText", "icon", "none"]).optional(),
|
||||
autoExpandReasoning: z.boolean().optional(),
|
||||
toolCallDetailLevel: z.enum(["overview", "detailed"]).optional(),
|
||||
compactToolCalls: z.boolean().optional(),
|
||||
chatOutlineEnabled: z.boolean().optional(),
|
||||
vimKeybindings: z.boolean().optional(),
|
||||
openSupportingTabsInSidePanel: z.boolean().optional(),
|
||||
// COMPAT(rendererDesktopSettings): these fields used to share this renderer-owned key.
|
||||
manageBuiltInDaemon: z.boolean().optional(),
|
||||
releaseChannel: z.enum(["stable", "beta"]).optional(),
|
||||
});
|
||||
|
||||
const LegacyRendererSettingsSchema = StoredAppSettingsSchema;
|
||||
|
||||
type StoredAppSettings = z.infer<typeof StoredAppSettingsSchema>;
|
||||
|
||||
export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
|
||||
theme: DEFAULT_THEME_PREFERENCE,
|
||||
pluginThemeId: null,
|
||||
@@ -189,6 +125,131 @@ export const DEFAULT_APP_SETTINGS: Settings = {
|
||||
releaseChannel: "stable",
|
||||
};
|
||||
|
||||
function clampedNumber(min: number, max: number) {
|
||||
return z
|
||||
.unknown()
|
||||
.transform((value) => parseClampedFontSize(value, { min, max }))
|
||||
.pipe(z.number());
|
||||
}
|
||||
|
||||
function sanitizedFontFamily() {
|
||||
return z.unknown().transform(sanitizeFontFamily).pipe(z.string());
|
||||
}
|
||||
|
||||
const SidebarRowItemsSchema = z
|
||||
.looseObject({
|
||||
branch: z.boolean().catch(DEFAULT_SIDEBAR_ROW_ITEMS.branch),
|
||||
project: z.boolean().catch(DEFAULT_SIDEBAR_ROW_ITEMS.project),
|
||||
host: z.boolean().catch(DEFAULT_SIDEBAR_ROW_ITEMS.host),
|
||||
changeRequest: z.boolean().catch(DEFAULT_SIDEBAR_ROW_ITEMS.changeRequest),
|
||||
services: z.boolean().optional().catch(undefined),
|
||||
labels: z.boolean().catch(DEFAULT_SIDEBAR_ROW_ITEMS.labels),
|
||||
// COMPAT(sidebarRowItemsChecks): migrated in v0.3.0, remove after 2027-08-05.
|
||||
checks: z.boolean().optional().catch(undefined),
|
||||
// COMPAT(sidebarRowItemsScripts): migrated in v0.3.0, remove after 2027-08-05.
|
||||
scripts: z.boolean().optional().catch(undefined),
|
||||
})
|
||||
.catch(DEFAULT_SIDEBAR_ROW_ITEMS);
|
||||
|
||||
type StoredAppSettingsFallback = AppSettings & {
|
||||
uiFontSize?: number;
|
||||
compactToolCalls?: boolean;
|
||||
manageBuiltInDaemon?: boolean;
|
||||
releaseChannel?: ReleaseChannel;
|
||||
needsWrite: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_STORED_APP_SETTINGS = {
|
||||
...DEFAULT_CLIENT_SETTINGS,
|
||||
needsWrite: false,
|
||||
} satisfies StoredAppSettingsFallback;
|
||||
|
||||
const StoredAppSettingsSchema = z
|
||||
.looseObject({
|
||||
theme: ThemePreferenceSchema.catch(DEFAULT_THEME_PREFERENCE),
|
||||
pluginThemeId: z.string().nullable().catch(null),
|
||||
language: z
|
||||
.enum(["system", "ar", "en", "es", "fr", "ja", "ko", "pt-BR", "ru", "zh-CN"])
|
||||
.catch("system"),
|
||||
sendBehavior: z.enum(["interrupt", "steer", "queue"]).catch("steer"),
|
||||
serviceUrlBehavior: z.enum(["ask", "in-app", "external"]).catch("ask"),
|
||||
terminalScrollbackLines: clampedNumber(
|
||||
MIN_TERMINAL_SCROLLBACK_LINES,
|
||||
MAX_TERMINAL_SCROLLBACK_LINES,
|
||||
).catch(DEFAULT_TERMINAL_SCROLLBACK_LINES),
|
||||
useLegacyTerminalRenderer: z.boolean().catch(false),
|
||||
uiFontFamily: sanitizedFontFamily().catch(""),
|
||||
monoFontFamily: sanitizedFontFamily().catch(""),
|
||||
uiBaseFontSize: clampedNumber(MIN_UI_BASE_FONT_SIZE, MAX_UI_BASE_FONT_SIZE)
|
||||
.optional()
|
||||
.catch(undefined),
|
||||
contentFontSize: clampedNumber(MIN_CONTENT_FONT_SIZE, MAX_CONTENT_FONT_SIZE)
|
||||
.optional()
|
||||
.catch(DEFAULT_CONTENT_FONT_SIZE),
|
||||
// COMPAT(uiFontSizeScale): replaced by the literal base size in v0.4, remove after 2027-08-17.
|
||||
uiFontSize: clampedNumber(11, 24).optional().catch(undefined),
|
||||
codeFontSize: clampedNumber(MIN_CODE_FONT_SIZE, MAX_CODE_FONT_SIZE).catch(
|
||||
DEFAULT_CODE_FONT_SIZE,
|
||||
),
|
||||
syntaxTheme: z.string().refine(isSyntaxThemeId).catch("one"),
|
||||
workspaceTitleSource: z.enum(["title", "branch"]).catch("title"),
|
||||
sidebarWorkspaceTrailing: z.enum(["diff", "timestamp", "none"]).catch("diff"),
|
||||
sidebarRowItems: SidebarRowItemsSchema,
|
||||
sidebarChecksDisplay: z
|
||||
.enum(["iconAndText", "icon", "none"])
|
||||
.optional()
|
||||
.catch(DEFAULT_SIDEBAR_CHECKS_DISPLAY),
|
||||
autoExpandReasoning: z.boolean().catch(false),
|
||||
toolCallDetailLevel: z
|
||||
.enum(["overview", "detailed"])
|
||||
.or(z.literal("concise").transform(() => "overview" as const))
|
||||
.optional()
|
||||
.catch("detailed"),
|
||||
// COMPAT(compactToolCalls): migrated in v0.1.105, remove after 2027-01-12.
|
||||
compactToolCalls: z.boolean().optional().catch(undefined),
|
||||
chatOutlineEnabled: z.boolean().catch(true),
|
||||
vimKeybindings: z.boolean().catch(false),
|
||||
openSupportingTabsInSidePanel: z.boolean().catch(true),
|
||||
// COMPAT(rendererDesktopSettings): these fields used to share this renderer-owned key.
|
||||
manageBuiltInDaemon: z.boolean().optional().catch(undefined),
|
||||
releaseChannel: z.enum(["stable", "beta"]).optional().catch(undefined),
|
||||
})
|
||||
.transform((stored) => {
|
||||
const needsWrite =
|
||||
(stored.uiBaseFontSize === undefined && stored.uiFontSize !== undefined) ||
|
||||
stored.contentFontSize === undefined;
|
||||
const uiBaseFontSize =
|
||||
stored.uiBaseFontSize ??
|
||||
(stored.uiFontSize === undefined
|
||||
? DEFAULT_UI_BASE_FONT_SIZE
|
||||
: Math.round((FONT_SIZE.base * stored.uiFontSize) / 16));
|
||||
const sidebarChecksDisplay =
|
||||
stored.sidebarChecksDisplay ??
|
||||
(isChecksHiddenByLegacyRowItem(stored.sidebarRowItems)
|
||||
? "none"
|
||||
: DEFAULT_SIDEBAR_CHECKS_DISPLAY);
|
||||
const toolCallDetailLevel =
|
||||
stored.toolCallDetailLevel ?? (stored.compactToolCalls ? "overview" : "detailed");
|
||||
return {
|
||||
...stored,
|
||||
uiBaseFontSize,
|
||||
contentFontSize: stored.contentFontSize ?? uiBaseFontSize,
|
||||
sidebarChecksDisplay,
|
||||
sidebarRowItems: {
|
||||
...stored.sidebarRowItems,
|
||||
services:
|
||||
stored.sidebarRowItems.services ??
|
||||
(stored.sidebarRowItems.scripts === false ? false : DEFAULT_SIDEBAR_ROW_ITEMS.services),
|
||||
},
|
||||
toolCallDetailLevel,
|
||||
needsWrite,
|
||||
};
|
||||
})
|
||||
.catch(DEFAULT_STORED_APP_SETTINGS);
|
||||
|
||||
type StoredAppSettings = z.output<typeof StoredAppSettingsSchema>;
|
||||
export type PersistedAppSettings = Omit<StoredAppSettings, "needsWrite">;
|
||||
|
||||
export interface KeyValueStorage {
|
||||
getItem(key: string): Promise<string | null>;
|
||||
setItem(key: string, value: string): Promise<void>;
|
||||
@@ -220,16 +281,22 @@ export async function saveAppSettings(input: {
|
||||
const current = normalizeAppSettings(storedCurrent);
|
||||
const next = { ...current, ...input.updates };
|
||||
input.queryClient.setQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY, next);
|
||||
await input.deps.storage.setItem(APP_SETTINGS_KEY, JSON.stringify(next));
|
||||
await writeAppSettings(
|
||||
input.deps.storage,
|
||||
(await readSettingsObject(input.deps.storage, APP_SETTINGS_KEY)) ??
|
||||
StoredAppSettingsSchema.parse({}),
|
||||
next,
|
||||
);
|
||||
}
|
||||
|
||||
export async function loadAppSettingsFromStorage(deps: SettingsDeps): Promise<AppSettings> {
|
||||
try {
|
||||
const read = await readAppSettings(deps);
|
||||
if (read.needsWrite) {
|
||||
await deps.storage.setItem(APP_SETTINGS_KEY, JSON.stringify(read.settings));
|
||||
await writeAppSettings(deps.storage, read.stored, read.settings);
|
||||
}
|
||||
return await migrateAppSettings(read.settings, deps.storage);
|
||||
const { needsWrite: _needsWrite, ...stored } = read.stored;
|
||||
return await migrateAppSettings(read.settings, deps.storage, stored);
|
||||
} catch (error) {
|
||||
console.error("[AppSettings] Failed to load settings:", error);
|
||||
throw error;
|
||||
@@ -242,23 +309,18 @@ export async function loadAppSettingsFromStorage(deps: SettingsDeps): Promise<Ap
|
||||
*/
|
||||
async function readAppSettings(
|
||||
deps: SettingsDeps,
|
||||
): Promise<{ settings: AppSettings; needsWrite: boolean }> {
|
||||
const stored = await readValidatedJson(deps.storage, APP_SETTINGS_KEY, StoredAppSettingsSchema);
|
||||
): Promise<{ settings: AppSettings; needsWrite: boolean; stored: StoredAppSettings }> {
|
||||
const stored = await readSettingsObject(deps.storage, APP_SETTINGS_KEY);
|
||||
if (stored) {
|
||||
return {
|
||||
settings: normalizeAppSettings(stored),
|
||||
// COMPAT(uiFontSizeScale): persist the converted base size, remove after 2027-08-17.
|
||||
needsWrite:
|
||||
(stored.uiBaseFontSize === undefined && stored.uiFontSize !== undefined) ||
|
||||
stored.contentFontSize === undefined,
|
||||
needsWrite: stored.needsWrite,
|
||||
stored,
|
||||
};
|
||||
}
|
||||
|
||||
const legacyStored = await readValidatedJson(
|
||||
deps.storage,
|
||||
LEGACY_SETTINGS_KEY,
|
||||
LegacyRendererSettingsSchema,
|
||||
);
|
||||
const legacyStored = await readSettingsObject(deps.storage, LEGACY_SETTINGS_KEY);
|
||||
if (legacyStored) {
|
||||
return {
|
||||
settings: {
|
||||
@@ -266,10 +328,12 @@ async function readAppSettings(
|
||||
...pickAppSettingsFromLegacy(legacyStored),
|
||||
} satisfies AppSettings,
|
||||
needsWrite: true,
|
||||
stored: legacyStored,
|
||||
};
|
||||
}
|
||||
|
||||
return { settings: DEFAULT_CLIENT_SETTINGS, needsWrite: true };
|
||||
const defaultStored = StoredAppSettingsSchema.parse({});
|
||||
return { settings: DEFAULT_CLIENT_SETTINGS, needsWrite: true, stored: defaultStored };
|
||||
}
|
||||
|
||||
export async function loadSettingsFromStorage(deps: SettingsDeps): Promise<Settings> {
|
||||
@@ -299,189 +363,27 @@ export async function loadSettingsFromStorage(deps: SettingsDeps): Promise<Setti
|
||||
}
|
||||
|
||||
export function normalizeAppSettings(value: unknown): AppSettings {
|
||||
const result = StoredAppSettingsSchema.safeParse(value);
|
||||
const {
|
||||
needsWrite: _needsWrite,
|
||||
manageBuiltInDaemon: _manageBuiltInDaemon,
|
||||
releaseChannel: _releaseChannel,
|
||||
compactToolCalls: _compactToolCalls,
|
||||
uiFontSize: _uiFontSize,
|
||||
...settings
|
||||
} = StoredAppSettingsSchema.parse(value);
|
||||
return settings;
|
||||
}
|
||||
|
||||
function pickAppSettingsFromLegacy(legacy: StoredAppSettings): AppSettings {
|
||||
const settings = normalizeAppSettings(legacy);
|
||||
return {
|
||||
...DEFAULT_CLIENT_SETTINGS,
|
||||
...pickAppSettings(result.success ? result.data : {}),
|
||||
...settings,
|
||||
// The legacy key rendered content on the interface ramp. Freeze that
|
||||
// rendered value into the new independent preference during migration.
|
||||
contentFontSize: legacy.uiBaseFontSize,
|
||||
};
|
||||
}
|
||||
|
||||
function parseToolCallDetailLevel(stored: StoredAppSettings): ToolCallDetailLevel | null {
|
||||
if (stored.toolCallDetailLevel !== undefined) {
|
||||
if (
|
||||
typeof stored.toolCallDetailLevel === "string" &&
|
||||
VALID_TOOL_CALL_DETAIL_LEVELS.has(stored.toolCallDetailLevel)
|
||||
) {
|
||||
return stored.toolCallDetailLevel;
|
||||
}
|
||||
// COMPAT(toolCallDetailLevelConcise): removed in v0.1.107; legacy "concise" values
|
||||
// deliberately follow the unknown-value fallback. Remove after 2027-01-14.
|
||||
return "overview";
|
||||
}
|
||||
if (typeof stored.compactToolCalls === "boolean") {
|
||||
// COMPAT(compactToolCalls): migrated in v0.1.105, remove after 2027-01-12.
|
||||
return stored.compactToolCalls ? "overview" : "detailed";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseStoredSidebarChecksDisplay(stored: StoredAppSettings): SidebarChecksDisplay | null {
|
||||
const display = parseSidebarChecksDisplay(stored.sidebarChecksDisplay);
|
||||
if (display !== null) {
|
||||
return display;
|
||||
}
|
||||
// COMPAT(sidebarRowItemsChecks): migrated in v0.3.0, remove after 2027-08-05.
|
||||
return isChecksHiddenByLegacyRowItem(stored.sidebarRowItems) ? "none" : null;
|
||||
}
|
||||
|
||||
function pickBooleanAppSettings(stored: StoredAppSettings): Partial<AppSettings> {
|
||||
const result: Partial<AppSettings> = {};
|
||||
if (typeof stored.useLegacyTerminalRenderer === "boolean") {
|
||||
result.useLegacyTerminalRenderer = stored.useLegacyTerminalRenderer;
|
||||
}
|
||||
if (typeof stored.vimKeybindings === "boolean") {
|
||||
result.vimKeybindings = stored.vimKeybindings;
|
||||
}
|
||||
if (typeof stored.chatOutlineEnabled === "boolean") {
|
||||
result.chatOutlineEnabled = stored.chatOutlineEnabled;
|
||||
}
|
||||
if (typeof stored.openSupportingTabsInSidePanel === "boolean") {
|
||||
result.openSupportingTabsInSidePanel = stored.openSupportingTabsInSidePanel;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* The settings whose stored value only has to be a member of a fixed set. Grouped like the
|
||||
* boolean settings are: the numeric and font settings need real parsing and clamping, these
|
||||
* need a membership check and nothing else.
|
||||
*/
|
||||
function pickEnumAppSettings(stored: StoredAppSettings): Partial<AppSettings> {
|
||||
const result: Partial<AppSettings> = {};
|
||||
if (typeof stored.theme === "string" && VALID_THEMES.has(stored.theme)) {
|
||||
result.theme = stored.theme;
|
||||
}
|
||||
if (
|
||||
stored.sendBehavior === "interrupt" ||
|
||||
stored.sendBehavior === "steer" ||
|
||||
stored.sendBehavior === "queue"
|
||||
) {
|
||||
result.sendBehavior = stored.sendBehavior;
|
||||
}
|
||||
if (
|
||||
typeof stored.serviceUrlBehavior === "string" &&
|
||||
VALID_SERVICE_URL_BEHAVIORS.has(stored.serviceUrlBehavior)
|
||||
) {
|
||||
result.serviceUrlBehavior = stored.serviceUrlBehavior;
|
||||
}
|
||||
if (typeof stored.syntaxTheme === "string" && isSyntaxThemeId(stored.syntaxTheme)) {
|
||||
result.syntaxTheme = stored.syntaxTheme;
|
||||
}
|
||||
if (
|
||||
typeof stored.workspaceTitleSource === "string" &&
|
||||
VALID_WORKSPACE_TITLE_SOURCES.has(stored.workspaceTitleSource)
|
||||
) {
|
||||
result.workspaceTitleSource = stored.workspaceTitleSource;
|
||||
}
|
||||
if (
|
||||
typeof stored.sidebarWorkspaceTrailing === "string" &&
|
||||
VALID_SIDEBAR_WORKSPACE_TRAILINGS.has(stored.sidebarWorkspaceTrailing)
|
||||
) {
|
||||
result.sidebarWorkspaceTrailing = stored.sidebarWorkspaceTrailing;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function pickAppSettings(stored: StoredAppSettings): Partial<AppSettings> {
|
||||
const result: Partial<AppSettings> = {};
|
||||
Object.assign(result, pickEnumAppSettings(stored));
|
||||
if (typeof stored.pluginThemeId === "string") {
|
||||
result.pluginThemeId = stored.pluginThemeId;
|
||||
}
|
||||
if (stored.sidebarRowItems !== undefined) {
|
||||
result.sidebarRowItems = parseSidebarRowItems(stored.sidebarRowItems);
|
||||
}
|
||||
const sidebarChecksDisplay = parseStoredSidebarChecksDisplay(stored);
|
||||
if (sidebarChecksDisplay !== null) {
|
||||
result.sidebarChecksDisplay = sidebarChecksDisplay;
|
||||
}
|
||||
const language = parseAppLanguage(stored.language);
|
||||
if (language !== null) {
|
||||
result.language = language;
|
||||
}
|
||||
const terminalScrollbackLines = parseTerminalScrollbackLines(stored.terminalScrollbackLines);
|
||||
if (terminalScrollbackLines !== null) {
|
||||
result.terminalScrollbackLines = terminalScrollbackLines;
|
||||
}
|
||||
const uiFontFamily = sanitizeFontFamily(stored.uiFontFamily);
|
||||
if (uiFontFamily !== null) {
|
||||
result.uiFontFamily = uiFontFamily;
|
||||
}
|
||||
const monoFontFamily = sanitizeFontFamily(stored.monoFontFamily);
|
||||
if (monoFontFamily !== null) {
|
||||
result.monoFontFamily = monoFontFamily;
|
||||
}
|
||||
const uiBaseFontSize = parseClampedFontSize(stored.uiBaseFontSize, {
|
||||
min: MIN_UI_BASE_FONT_SIZE,
|
||||
max: MAX_UI_BASE_FONT_SIZE,
|
||||
});
|
||||
if (uiBaseFontSize !== null) {
|
||||
result.uiBaseFontSize = uiBaseFontSize;
|
||||
} else {
|
||||
const legacyUiFontSize = parseClampedFontSize(stored.uiFontSize, {
|
||||
min: 11,
|
||||
max: 24,
|
||||
});
|
||||
if (legacyUiFontSize !== null) {
|
||||
result.uiBaseFontSize = Math.round((FONT_SIZE.base * legacyUiFontSize) / 16);
|
||||
}
|
||||
}
|
||||
const contentFontSize = parseClampedFontSize(stored.contentFontSize, {
|
||||
min: MIN_CONTENT_FONT_SIZE,
|
||||
max: MAX_CONTENT_FONT_SIZE,
|
||||
});
|
||||
if (contentFontSize !== null) {
|
||||
result.contentFontSize = contentFontSize;
|
||||
} else if (stored.contentFontSize === undefined) {
|
||||
// Existing content followed the interface ramp. Preserve that rendered size
|
||||
// once, then persist the independent setting during the read migration.
|
||||
result.contentFontSize = result.uiBaseFontSize ?? DEFAULT_UI_BASE_FONT_SIZE;
|
||||
}
|
||||
const codeFontSize = parseClampedFontSize(stored.codeFontSize, {
|
||||
min: MIN_CODE_FONT_SIZE,
|
||||
max: MAX_CODE_FONT_SIZE,
|
||||
});
|
||||
if (codeFontSize !== null) {
|
||||
result.codeFontSize = codeFontSize;
|
||||
}
|
||||
Object.assign(result, pickBooleanAppSettings(stored));
|
||||
if (typeof stored.autoExpandReasoning === "boolean") {
|
||||
result.autoExpandReasoning = stored.autoExpandReasoning;
|
||||
}
|
||||
const toolCallDetailLevel = parseToolCallDetailLevel(stored);
|
||||
if (toolCallDetailLevel !== null) {
|
||||
result.toolCallDetailLevel = toolCallDetailLevel;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function pickAppSettingsFromLegacy(
|
||||
legacy: z.infer<typeof LegacyRendererSettingsSchema>,
|
||||
): Partial<AppSettings> {
|
||||
const result: Partial<AppSettings> = {};
|
||||
if (legacy.theme === "dark" || legacy.theme === "light" || legacy.theme === "auto") {
|
||||
result.theme = legacy.theme;
|
||||
}
|
||||
const legacyInterfaceSize = pickAppSettings(legacy).uiBaseFontSize;
|
||||
if (legacyInterfaceSize !== undefined) {
|
||||
result.uiBaseFontSize = legacyInterfaceSize;
|
||||
}
|
||||
// The legacy key rendered content on the interface ramp. Freeze that
|
||||
// rendered value into the new independent preference during migration.
|
||||
result.contentFontSize = legacyInterfaceSize ?? DEFAULT_UI_BASE_FONT_SIZE;
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseTerminalScrollbackLines(value: unknown): number | null {
|
||||
let numericValue = NaN;
|
||||
if (typeof value === "number") {
|
||||
@@ -548,10 +450,10 @@ async function loadLegacyDesktopSettingsFromStorage(storage: KeyValueStorage): P
|
||||
releaseChannel?: ReleaseChannel;
|
||||
} = {};
|
||||
|
||||
if (typeof stored.manageBuiltInDaemon === "boolean") {
|
||||
if (stored.manageBuiltInDaemon !== undefined) {
|
||||
result.manageBuiltInDaemon = stored.manageBuiltInDaemon;
|
||||
}
|
||||
if (stored.releaseChannel === "stable" || stored.releaseChannel === "beta") {
|
||||
if (stored.releaseChannel !== undefined) {
|
||||
result.releaseChannel = stored.releaseChannel;
|
||||
}
|
||||
|
||||
@@ -560,11 +462,48 @@ async function loadLegacyDesktopSettingsFromStorage(storage: KeyValueStorage): P
|
||||
|
||||
async function loadRendererSettingsPayload(
|
||||
storage: KeyValueStorage,
|
||||
): Promise<z.infer<typeof LegacyRendererSettingsSchema> | null> {
|
||||
const current = await readValidatedJson(storage, APP_SETTINGS_KEY, LegacyRendererSettingsSchema);
|
||||
): Promise<StoredAppSettings | null> {
|
||||
const current = await readSettingsObject(storage, APP_SETTINGS_KEY);
|
||||
if (current) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return readValidatedJson(storage, LEGACY_SETTINGS_KEY, LegacyRendererSettingsSchema);
|
||||
return readSettingsObject(storage, LEGACY_SETTINGS_KEY);
|
||||
}
|
||||
|
||||
async function readSettingsObject(
|
||||
storage: KeyValueStorage,
|
||||
key: string,
|
||||
): Promise<StoredAppSettings | null> {
|
||||
const raw = await storage.getItem(key);
|
||||
if (raw === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let decoded: unknown;
|
||||
try {
|
||||
decoded = JSON.parse(raw);
|
||||
} catch {
|
||||
console.warn(`[AppSettings] Removing corrupt ${key}: invalid JSON.`);
|
||||
await storage.removeItem(key);
|
||||
return null;
|
||||
}
|
||||
return StoredAppSettingsSchema.parse(decoded);
|
||||
}
|
||||
|
||||
async function writeAppSettings(
|
||||
storage: KeyValueStorage,
|
||||
stored: StoredAppSettings,
|
||||
settings: AppSettings,
|
||||
): Promise<void> {
|
||||
const { needsWrite: _needsWrite, ...persistedStored } = stored;
|
||||
const storedSidebarRowItems = persistedStored.sidebarRowItems;
|
||||
await storage.setItem(
|
||||
APP_SETTINGS_KEY,
|
||||
JSON.stringify({
|
||||
...persistedStored,
|
||||
...settings,
|
||||
sidebarRowItems: { ...storedSidebarRowItems, ...settings.sidebarRowItems },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ function project(projectKey: string, workspaces: SidebarWorkspacePlacement[]): S
|
||||
}
|
||||
|
||||
describe("splitPinnedSidebarGroups", () => {
|
||||
it("drops the empty shell when every chat of a project is pinned", () => {
|
||||
it("keeps the project shell reachable when every chat is pinned", () => {
|
||||
const only = placement("w1");
|
||||
const projects = [project("p1", [only])];
|
||||
const result = splitPinnedSidebarGroups({
|
||||
@@ -42,7 +42,7 @@ describe("splitPinnedSidebarGroups", () => {
|
||||
pinnedWorkspaceOrder: [],
|
||||
});
|
||||
expect(result.pinnedChats).toHaveLength(1);
|
||||
expect(result.unpinnedProjects).toHaveLength(0);
|
||||
expect(result.unpinnedProjects).toEqual([{ ...projects[0], workspaces: [] }]);
|
||||
});
|
||||
|
||||
it("keeps a genuinely empty project so its new-workspace row stays reachable", () => {
|
||||
|
||||
@@ -121,12 +121,6 @@ export function splitPinnedSidebarGroups(input: {
|
||||
remainingWorkspaces.push(workspace);
|
||||
}
|
||||
}
|
||||
// Every chat got hoisted into the Pinned section: drop the empty shell instead of
|
||||
// leaving a duplicate project header below. A genuinely empty project (no chats to
|
||||
// begin with) is kept so its "new workspace" row stays reachable.
|
||||
if (remainingWorkspaces.length === 0 && project.workspaces.length > 0) {
|
||||
continue;
|
||||
}
|
||||
unpinnedProjects.push(
|
||||
remainingWorkspaces.length === project.workspaces.length
|
||||
? project
|
||||
|
||||
@@ -64,6 +64,9 @@ export const ar: TranslationResources = {
|
||||
workspaces: "مساحات العمل",
|
||||
agents: "الوكلاء",
|
||||
newAgent: "وكيل جديد",
|
||||
open: "فتح {{name}}",
|
||||
openInSidePanel: "فتح {{name}} في اللوحة الجانبية",
|
||||
openInFocusedPane: "فتح {{name}} في الجزء النشط",
|
||||
addProject: "إضافة مشروع",
|
||||
home: "بيت",
|
||||
groupByProject: "تجميع حسب المشروع",
|
||||
@@ -602,7 +605,7 @@ export const ar: TranslationResources = {
|
||||
files: "الملفات",
|
||||
pullRequest: "طلب السحب",
|
||||
terminalProfilesMenu: "Terminal profiles",
|
||||
editTerminalProfiles: "Edit profiles…",
|
||||
editTerminalProfiles: "Edit profiles",
|
||||
},
|
||||
sidePanel: {
|
||||
open: "افتح اللوحة الجانبية",
|
||||
@@ -1627,6 +1630,8 @@ export const ar: TranslationResources = {
|
||||
updateRequired: "حدّث المضيف لتمكين التتابع من Paseo Desktop.",
|
||||
unavailable: "عرض الاقتران غير متاح.",
|
||||
hint: "قم بمسح رمز QR هذا باستخدام Paseo على هاتفك، أو انسخ الرابط أدناه.",
|
||||
securityWarning:
|
||||
"تعامل مع رابط الاقتران هذا ككلمة مرور. يمكن لأي شخص يملكه الوصول إلى هذا البرنامج الخفي.",
|
||||
qrUnavailable: "رمز QR غير متاح.",
|
||||
qrAccessibility: "رمز QR للإقران",
|
||||
retry: "أعد المحاولة",
|
||||
@@ -1700,6 +1705,7 @@ export const ar: TranslationResources = {
|
||||
loading: "جارٍ تحميل الملف...",
|
||||
noPreview: "لا تتوفر معاينة",
|
||||
binaryPreviewUnavailable: "المعاينة الثنائية غير متاحة",
|
||||
tooLargeToDisplay: "هذا الملف كبير جدًا بحيث لا يمكن عرضه",
|
||||
failedToLoad: "فشل تحميل الملف",
|
||||
failedToLoadPreview: "فشل تحميل معاينة الملف",
|
||||
editor: {
|
||||
@@ -1728,6 +1734,7 @@ export const ar: TranslationResources = {
|
||||
label: "الملفات",
|
||||
subtitle: "ملفات مساحة العمل",
|
||||
tooltip: "تصفح ملفات مساحة العمل",
|
||||
chooseFile: "اختر ملفًا",
|
||||
},
|
||||
pullRequest: {
|
||||
label: "طلب السحب",
|
||||
@@ -2342,9 +2349,9 @@ export const ar: TranslationResources = {
|
||||
featuresLabel: "السمات",
|
||||
featureCount: "{{count}} سمة",
|
||||
featureCountOne: "{{count}} سمة",
|
||||
notesLabel: "ملاحظات للوكلاء",
|
||||
notesLabel: "متى يُستخدم",
|
||||
notesPlaceholder: "استخدمه لعمل الواجهة — المكونات والتخطيط ورموز التصميم.",
|
||||
notesHint: 'تُعاد بواسطة أداة MCP "list_profiles". اكتبها كتعليمة لوكيل آخر.',
|
||||
notesHint: "يساعد الوكلاء على اختيار ملف التعريف هذا عند بدء وكيل آخر.",
|
||||
save: "حفظ",
|
||||
saving: "جارٍ الحفظ...",
|
||||
remove: "إزالة",
|
||||
|
||||
@@ -61,6 +61,9 @@ export const en = {
|
||||
workspaces: "Workspaces",
|
||||
agents: "Agents",
|
||||
newAgent: "New agent",
|
||||
open: "Open {{name}}",
|
||||
openInSidePanel: "Open {{name}} in Side panel",
|
||||
openInFocusedPane: "Open {{name}} in focused pane",
|
||||
addProject: "Add project",
|
||||
home: "Home",
|
||||
groupByProject: "Group by project",
|
||||
@@ -600,7 +603,7 @@ export const en = {
|
||||
files: "Files",
|
||||
pullRequest: "Pull request",
|
||||
terminalProfilesMenu: "Terminal profiles",
|
||||
editTerminalProfiles: "Edit profiles…",
|
||||
editTerminalProfiles: "Edit profiles",
|
||||
},
|
||||
sidePanel: {
|
||||
open: "Open side panel",
|
||||
@@ -1637,6 +1640,8 @@ export const en = {
|
||||
updateRequired: "Update the host to enable relay from Paseo Desktop.",
|
||||
unavailable: "Pairing offer unavailable.",
|
||||
hint: "Scan this QR code with Paseo on your phone, or copy the link below.",
|
||||
securityWarning:
|
||||
"Treat this pairing link like a password. Anyone with it can access this daemon.",
|
||||
qrUnavailable: "QR code unavailable.",
|
||||
qrAccessibility: "Pairing QR code",
|
||||
retry: "Retry",
|
||||
@@ -1710,6 +1715,7 @@ export const en = {
|
||||
loading: "Loading file...",
|
||||
noPreview: "No preview available",
|
||||
binaryPreviewUnavailable: "Binary preview unavailable",
|
||||
tooLargeToDisplay: "This file is too large to display",
|
||||
failedToLoad: "Failed to load file",
|
||||
failedToLoadPreview: "Failed to load file preview",
|
||||
editor: {
|
||||
@@ -1738,6 +1744,7 @@ export const en = {
|
||||
label: "Files",
|
||||
subtitle: "Workspace files",
|
||||
tooltip: "Browse workspace files",
|
||||
chooseFile: "Choose a file",
|
||||
},
|
||||
pullRequest: {
|
||||
label: "Pull request",
|
||||
@@ -2409,10 +2416,9 @@ export const en = {
|
||||
featuresLabel: "Features",
|
||||
featureCount: "{{count}} features",
|
||||
featureCountOne: "{{count}} feature",
|
||||
notesLabel: "Notes for agents",
|
||||
notesLabel: "When to use",
|
||||
notesPlaceholder: "Use for UI work — components, layout and design tokens.",
|
||||
notesHint:
|
||||
"Returned by the list_profiles MCP tool. Write it as an instruction to another agent.",
|
||||
notesHint: "Helps agents choose this profile when starting another agent.",
|
||||
save: "Save",
|
||||
saving: "Saving...",
|
||||
remove: "Remove",
|
||||
|
||||
@@ -64,6 +64,9 @@ export const es: TranslationResources = {
|
||||
workspaces: "Espacios de trabajo",
|
||||
agents: "Agentes",
|
||||
newAgent: "Nuevo agente",
|
||||
open: "Abrir {{name}}",
|
||||
openInSidePanel: "Abrir {{name}} en el panel lateral",
|
||||
openInFocusedPane: "Abrir {{name}} en el panel enfocado",
|
||||
addProject: "Agregar proyecto",
|
||||
home: "Hogar",
|
||||
groupByProject: "Agrupar por proyecto",
|
||||
@@ -607,7 +610,7 @@ export const es: TranslationResources = {
|
||||
files: "Archivos",
|
||||
pullRequest: "Solicitud de extracción",
|
||||
terminalProfilesMenu: "Terminal profiles",
|
||||
editTerminalProfiles: "Edit profiles…",
|
||||
editTerminalProfiles: "Edit profiles",
|
||||
},
|
||||
sidePanel: {
|
||||
open: "Abrir panel lateral",
|
||||
@@ -1673,6 +1676,8 @@ export const es: TranslationResources = {
|
||||
updateRequired: "Actualice el host para habilitar el relé desde Paseo Desktop.",
|
||||
unavailable: "Oferta de maridaje no disponible.",
|
||||
hint: "Escanee este códigoQRconPaseoen su teléfono o copie el enlace a continuación.",
|
||||
securityWarning:
|
||||
"Trata este enlace de emparejamiento como una contraseña. Cualquiera que lo tenga puede acceder a este daemon.",
|
||||
qrUnavailable: "CódigoQRno disponible.",
|
||||
qrAccessibility: "Código QR de emparejamiento",
|
||||
retry: "Rever",
|
||||
@@ -1746,6 +1751,7 @@ export const es: TranslationResources = {
|
||||
loading: "Cargando archivo...",
|
||||
noPreview: "No hay vista previa disponible",
|
||||
binaryPreviewUnavailable: "Vista previa binaria no disponible",
|
||||
tooLargeToDisplay: "Este archivo es demasiado grande para mostrarlo",
|
||||
failedToLoad: "No se pudo cargar el archivo",
|
||||
failedToLoadPreview: "No se pudo cargar la vista previa del archivo",
|
||||
editor: {
|
||||
@@ -1774,6 +1780,7 @@ export const es: TranslationResources = {
|
||||
label: "Archivos",
|
||||
subtitle: "Archivos del espacio de trabajo",
|
||||
tooltip: "Explorar archivos del espacio de trabajo",
|
||||
chooseFile: "Elige un archivo",
|
||||
},
|
||||
pullRequest: {
|
||||
label: "Solicitud de extracción",
|
||||
@@ -2396,10 +2403,9 @@ export const es: TranslationResources = {
|
||||
featuresLabel: "Características",
|
||||
featureCount: "{{count}} características",
|
||||
featureCountOne: "{{count}} característica",
|
||||
notesLabel: "Notas para los agentes",
|
||||
notesLabel: "Cuándo usarlo",
|
||||
notesPlaceholder: "Úselo para trabajo de UI: componentes, diseño y tokens de diseño.",
|
||||
notesHint:
|
||||
'Devuelto por la herramienta MCP "list_profiles". Escríbalo como una instrucción para otro agente.',
|
||||
notesHint: "Ayuda a los agentes a elegir este perfil al iniciar otro agente.",
|
||||
save: "Guardar",
|
||||
saving: "Guardando...",
|
||||
remove: "Eliminar",
|
||||
|
||||
@@ -65,6 +65,9 @@ export const fr: TranslationResources = {
|
||||
workspaces: "Espaces de travail",
|
||||
agents: "Agents",
|
||||
newAgent: "Nouvel agent",
|
||||
open: "Ouvrir {{name}}",
|
||||
openInSidePanel: "Ouvrir {{name}} dans le panneau latéral",
|
||||
openInFocusedPane: "Ouvrir {{name}} dans le volet actif",
|
||||
addProject: "Ajouter un projet",
|
||||
home: "Maison",
|
||||
groupByProject: "Grouper par projet",
|
||||
@@ -607,7 +610,7 @@ export const fr: TranslationResources = {
|
||||
files: "Fichiers",
|
||||
pullRequest: "Demande de fusion",
|
||||
terminalProfilesMenu: "Terminal profiles",
|
||||
editTerminalProfiles: "Edit profiles…",
|
||||
editTerminalProfiles: "Edit profiles",
|
||||
},
|
||||
sidePanel: {
|
||||
open: "Ouvrir le panneau latéral",
|
||||
@@ -1677,6 +1680,8 @@ export const fr: TranslationResources = {
|
||||
updateRequired: "Mettez à jour l’hôte pour activer le relais depuis Paseo Desktop.",
|
||||
unavailable: "Offre de jumelage indisponible.",
|
||||
hint: "Scannez ce codeQRavecPaseosur votre téléphone ou copiez le lien ci-dessous.",
|
||||
securityWarning:
|
||||
"Traitez ce lien d’association comme un mot de passe. Toute personne qui le possède peut accéder à ce daemon.",
|
||||
qrUnavailable: "CodeQRindisponible.",
|
||||
qrAccessibility: "Code QR de couplage",
|
||||
retry: "Réessayer",
|
||||
@@ -1750,6 +1755,7 @@ export const fr: TranslationResources = {
|
||||
loading: "Chargement du fichier...",
|
||||
noPreview: "Aucun aperçu disponible",
|
||||
binaryPreviewUnavailable: "Aperçu binaire indisponible",
|
||||
tooLargeToDisplay: "Ce fichier est trop volumineux pour être affiché",
|
||||
failedToLoad: "Échec du chargement du fichier",
|
||||
failedToLoadPreview: "Échec du chargement de l'aperçu du fichier",
|
||||
editor: {
|
||||
@@ -1778,6 +1784,7 @@ export const fr: TranslationResources = {
|
||||
label: "Fichiers",
|
||||
subtitle: "Fichiers de l’espace de travail",
|
||||
tooltip: "Parcourir les fichiers de l’espace de travail",
|
||||
chooseFile: "Choisissez un fichier",
|
||||
},
|
||||
pullRequest: {
|
||||
label: "Demande de fusion",
|
||||
@@ -2403,11 +2410,10 @@ export const fr: TranslationResources = {
|
||||
featuresLabel: "Caractéristiques",
|
||||
featureCount: "{{count}} caractéristiques",
|
||||
featureCountOne: "{{count}} caractéristique",
|
||||
notesLabel: "Notes pour les agents",
|
||||
notesLabel: "Quand l’utiliser",
|
||||
notesPlaceholder:
|
||||
"À utiliser pour le travail sur l'UI — composants, mise en page et tokens de design.",
|
||||
notesHint:
|
||||
"Renvoyé par l'outil MCP « list_profiles ». Rédigez-le comme une instruction destinée à un autre agent.",
|
||||
notesHint: "Aide les agents à choisir ce profil lorsqu’ils démarrent un autre agent.",
|
||||
save: "Enregistrer",
|
||||
saving: "Enregistrement...",
|
||||
remove: "Supprimer",
|
||||
|
||||
@@ -64,6 +64,9 @@ export const ja: TranslationResources = {
|
||||
workspaces: "ワークスペース",
|
||||
agents: "エージェント",
|
||||
newAgent: "新しいエージェント",
|
||||
open: "{{name}}を開く",
|
||||
openInSidePanel: "{{name}}をサイドパネルで開く",
|
||||
openInFocusedPane: "{{name}}をフォーカス中のペインで開く",
|
||||
addProject: "プロジェクトを追加",
|
||||
home: "ホーム",
|
||||
groupByProject: "プロジェクトでグループ化",
|
||||
@@ -607,7 +610,7 @@ export const ja: TranslationResources = {
|
||||
files: "ファイル",
|
||||
pullRequest: "プルリクエスト",
|
||||
terminalProfilesMenu: "ターミナルプロファイル",
|
||||
editTerminalProfiles: "プロファイルを編集…",
|
||||
editTerminalProfiles: "プロファイルを編集",
|
||||
},
|
||||
sidePanel: {
|
||||
open: "サイドパネルを開く",
|
||||
@@ -1644,6 +1647,8 @@ export const ja: TranslationResources = {
|
||||
updateRequired: "Paseo Desktop からリレーを有効にするにはホストを更新してください。",
|
||||
unavailable: "ペアリングオファーが利用できません。",
|
||||
hint: "スマートフォンのPaseoでこのQRコードをスキャンするか、以下のリンクをコピーしてください。",
|
||||
securityWarning:
|
||||
"このペアリングリンクはパスワードと同様に扱ってください。リンクを知っている人は誰でもこのデーモンにアクセスできます。",
|
||||
qrUnavailable: "QRコードが利用できません。",
|
||||
qrAccessibility: "ペアリング QR コード",
|
||||
retry: "再試行",
|
||||
@@ -1717,6 +1722,7 @@ export const ja: TranslationResources = {
|
||||
loading: "ファイルを読み込み中...",
|
||||
noPreview: "プレビューが利用できません",
|
||||
binaryPreviewUnavailable: "バイナリプレビューが利用できません",
|
||||
tooLargeToDisplay: "このファイルは大きすぎて表示できません",
|
||||
failedToLoad: "ファイルの読み込みに失敗しました",
|
||||
failedToLoadPreview: "ファイルプレビューの読み込みに失敗しました",
|
||||
editor: {
|
||||
@@ -1745,6 +1751,7 @@ export const ja: TranslationResources = {
|
||||
label: "ファイル",
|
||||
subtitle: "ワークスペースのファイル",
|
||||
tooltip: "ワークスペースのファイルを参照",
|
||||
chooseFile: "ファイルを選択",
|
||||
},
|
||||
pullRequest: {
|
||||
label: "プルリクエスト",
|
||||
@@ -2365,10 +2372,9 @@ export const ja: TranslationResources = {
|
||||
featuresLabel: "機能",
|
||||
featureCount: "{{count}}個の機能",
|
||||
featureCountOne: "{{count}}個の機能",
|
||||
notesLabel: "エージェントへのメモ",
|
||||
notesLabel: "使用する場面",
|
||||
notesPlaceholder: "UI作業に使用 — コンポーネント、レイアウト、デザイントークンなど。",
|
||||
notesHint:
|
||||
"list_profiles MCPツールによって返されます。他のエージェントへの指示として記述してください。",
|
||||
notesHint: "別のエージェントを起動するとき、このプロファイルを選ぶために使用されます。",
|
||||
save: "保存",
|
||||
saving: "保存中...",
|
||||
remove: "削除",
|
||||
|
||||
@@ -64,6 +64,9 @@ export const ko: TranslationResources = {
|
||||
workspaces: "워크스페이스",
|
||||
agents: "에이전트",
|
||||
newAgent: "새 에이전트",
|
||||
open: "{{name}} 열기",
|
||||
openInSidePanel: "사이드 패널에서 {{name}} 열기",
|
||||
openInFocusedPane: "포커스된 창에서 {{name}} 열기",
|
||||
addProject: "프로젝트 추가",
|
||||
home: "홈",
|
||||
groupByProject: "프로젝트별 그룹화",
|
||||
@@ -604,7 +607,7 @@ export const ko: TranslationResources = {
|
||||
files: "파일",
|
||||
pullRequest: "풀 리퀘스트",
|
||||
terminalProfilesMenu: "터미널 프로필",
|
||||
editTerminalProfiles: "프로필 편집…",
|
||||
editTerminalProfiles: "프로필 편집",
|
||||
},
|
||||
sidePanel: {
|
||||
open: "사이드 패널 열기",
|
||||
@@ -1637,6 +1640,8 @@ export const ko: TranslationResources = {
|
||||
updateRequired: "Paseo 데스크톱에서 릴레이를 활성화하려면 호스트를 업데이트하세요.",
|
||||
unavailable: "페어링 정보를 사용할 수 없습니다.",
|
||||
hint: "휴대폰의 Paseo로 이 QR 코드를 스캔하거나 아래 링크를 복사하세요.",
|
||||
securityWarning:
|
||||
"이 페어링 링크는 비밀번호처럼 취급하세요. 링크를 가진 사람은 누구나 이 데몬에 접근할 수 있습니다.",
|
||||
qrUnavailable: "QR 코드를 사용할 수 없습니다.",
|
||||
qrAccessibility: "페어링 QR 코드",
|
||||
retry: "다시 시도",
|
||||
@@ -1710,6 +1715,7 @@ export const ko: TranslationResources = {
|
||||
loading: "파일 불러오는 중...",
|
||||
noPreview: "사용 가능한 미리보기가 없습니다",
|
||||
binaryPreviewUnavailable: "바이너리 미리보기를 사용할 수 없습니다",
|
||||
tooLargeToDisplay: "이 파일은 너무 커서 표시할 수 없습니다",
|
||||
failedToLoad: "파일을 불러오지 못했습니다",
|
||||
failedToLoadPreview: "파일 미리보기를 불러오지 못했습니다",
|
||||
editor: {
|
||||
@@ -1738,6 +1744,7 @@ export const ko: TranslationResources = {
|
||||
label: "파일",
|
||||
subtitle: "워크스페이스 파일",
|
||||
tooltip: "워크스페이스 파일 탐색",
|
||||
chooseFile: "파일 선택",
|
||||
},
|
||||
pullRequest: {
|
||||
label: "풀 리퀘스트",
|
||||
@@ -2354,10 +2361,9 @@ export const ko: TranslationResources = {
|
||||
featuresLabel: "기능",
|
||||
featureCount: "기능 {{count}}개",
|
||||
featureCountOne: "기능 {{count}}개",
|
||||
notesLabel: "에이전트를 위한 메모",
|
||||
notesLabel: "사용 시점",
|
||||
notesPlaceholder: "UI 작업에 사용 — 컴포넌트, 레이아웃, 디자인 토큰.",
|
||||
notesHint:
|
||||
"list_profiles MCP 도구가 반환합니다. 다른 에이전트에게 전달할 지침으로 작성하세요.",
|
||||
notesHint: "에이전트가 다른 에이전트를 시작할 때 이 프로필을 선택하는 데 사용됩니다.",
|
||||
save: "저장",
|
||||
saving: "저장하는 중...",
|
||||
remove: "제거",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user