Compare commits

...
34 Commits
Author SHA1 Message Date
Mohamed Boudra b8a31034ca chore(release): cut 0.5.0 2026-08-23 15:42:11 +02:00
Mohamed Boudra 68275f1e0f docs(changelog): prepare 0.5.0 stable release 2026-08-23 15:38:25 +02:00
Mohamed Boudra 3979371901 fix(server): recover already-settled interrupts (#3742)
* fix(server): recover already-settled interrupts

Provider state can drift from Paseo’s tracked run. Treat an authoritative already-idle interrupt as settled and retire the captured run token so retrying can start new work without weakening ambiguous-cancellation protection.

* fix(codex): fence interruption during turn startup

Reserve foreground-start ownership before asynchronous provider setup. An interrupt can now cancel preflight before turn/start or wait for an issued start to become interruptible, preventing acknowledged cancellation from leaving unmanaged work.
2026-08-23 14:59:36 +02:00
Dan Berindei 71e375c2bc fix(server): stop quota fetcher from writing provider credentials (#3442) 2026-08-23 14:24:18 +02:00
Mohamed Boudra 35cde075ae fix(app): restore composer height lifecycle (#3740)
Native Fabric can omit the initial content-size callback and retain stale size after deletion. Keep the common composer surface free of platform branches while native and web adapters own their measurement mechanisms.
2026-08-23 13:57:17 +02:00
Mohamed Boudra 2124115c8e Open the tab chooser beside the pointer (#3735)
* feat(app): open the tab chooser beside the pointer

Keep keyboard-created and empty-pane New tabs as persistent launcher surfaces, while giving pointer users an anchored chooser at the pane-local plus button. Both presentations resolve the same launch catalogue so future ordering and customization cannot drift.

* test(app): follow the anchored tab chooser

The browser helpers still searched inside the pane for persistent-launcher controls after clicking the portalled menu trigger. Exercise the menu through its own roles and stable catalogue IDs.
2026-08-23 13:08:30 +02:00
Mohamed Boudra 4094eeb566 fix(app): preserve timeline retry backoff (#3736)
Equivalent visibility publications were acting as implicit retry commands, so unrelated tab and layout changes could restart failed timeline work. Keep visibility identity stable and reserve immediate retries for explicit user action.
2026-08-23 13:04:14 +02:00
Mohamed Boudra 8d92e1cc17 fix(pairing): warn that pairing links grant daemon access (#3734)
A pairing offer remains valid across daemon restarts, so remove the incorrect rotation guidance and warn users where the durable credential is displayed.
2026-08-23 12:32:21 +02:00
5123b23f41 fix(app): stop a string SVG transform from crashing the app (#3711)
* fix(app): stop a string SVG transform from crashing the app

A root <Svg> is a real React Native view, so react-native-svg rewrites a
*string* `transform` into RN style syntax in `extractTransformSvgView`,
using a PEG parser whose grammar only accepts SVG transform functions.
Upstream lets that parser's SyntaxError escape, so any string it does not
understand takes the whole app down during render:

  SyntaxError: Expected transform functions but "n" found.
  "found": "n", "location": { "start": { "offset": 0 } }

`none` reproduces that message character for character; so does any other
CSS value (`rotate(-90deg)`, `translate(10px, 0)`). Every sibling
extractor in that file already catches the same error and degrades, so
the asymmetry is the bug: a string that is merely ignored on a <Path>
crashes on an <Svg>.

We feed that path untrusted input. The daemon scans a project for
favicon.svg / icon.svg / logo.svg (packages/server/src/utils/project-icon.ts)
and ships it to the client, where ProjectIconImage hands it to SvgCss.
Running the library's own XML parser over `<svg style="transform:none">`
yields exactly `transform: "none"` on the root, which is why the crash
starts once a host connects and the project icons render, with no
terminal involved.

Note this is *not* reachable from an RN-style array transform: the
extractor returns arrays untouched and only parses strings, so
`style={{ transform: [{ rotate: "-90deg" }] }}` (the context window meter
and the checks ring) was never a factor.

Patch the three builds a consumer can resolve - Metro takes the package's
`react-native` field (src/*.ts), Node and Vitest take main/module - and
register react-native-svg with the postinstall patch runner so the patch
actually applies. The new test pins all three.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EDwfsMWoSFfPm2cFXe5noQ

* test(app): cover parsed root SVG transforms

Exercise the dependency patch with root props produced by the library's XML and CSS parsers, including embedded style rules that can arrive through project icons. Keep dependency-patch documentation focused on the reusable install and resolution constraints.

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-23 11:05:25 +02:00
c3594b2079 fix(app): keep pinned project actions reachable (#3722)
* fix(app): keep pinned project actions reachable

Preserve an empty project shell after its final workspace is hoisted into the pinned section so project-scoped workspace creation remains available.

Co-authored-by: Agent Op <agentop718@gmail.com>

* test(app): verify pinned project workspace context

---------

Co-authored-by: Agent Op <agentop718@gmail.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-23 10:44:23 +02:00
Mohamed Boudra dc9fb9aa1b fix(app): make compact git actions menu-only
Keep compact toolbars narrow and require users to choose potentially consequential Git and workspace actions from the menu.
2026-08-23 09:39:42 +02:00
paseo-ai[bot] 7c430777bf fix: update lockfile signatures and Nix hash [skip ci] 2026-08-22 21:45:00 +00:00
Mohamed Boudra acad9f6881 chore(release): cut 0.5.0-beta.5 2026-08-22 23:35:40 +02:00
Mohamed Boudra fc214b20f1 test(app): align new tab launcher expectations 2026-08-22 23:32:05 +02:00
Mohamed Boudra c998b24c5f docs: add 0.5.0-beta.5 changelog 2026-08-22 23:25:10 +02:00
Mohamed Boudra 28df3f259a fix(app): align terminal profiles header 2026-08-22 22:34:05 +02:00
Mohamed Boudra cc2012be7c fix(app): prioritize side panel launcher actions
Lead with supporting workspace tools when the launcher is in the Side panel, while ordinary panes continue to lead with agent creation.
2026-08-22 22:24:35 +02:00
Mohamed Boudra b139046a3e fix(app): show the New agent shortcut
The command center resolved New agent through the New tab binding, so it advertised Cmd+T despite creating an agent. Resolve the target-agent binding instead and cover the mapping.
2026-08-22 22:17:13 +02:00
Mohamed Boudra 482bbb2910 fix(app): order new tab launcher actions by workflow 2026-08-22 22:16:48 +02:00
Mohamed Boudra 4a5a8d469c fix(app): keep compact model options scrollable
The compact model sheet had no sheet-aware scroll owner when profiles filled its viewport, leaving the controls below them unreachable. Keep the root provider view inside Gorhom's scroll container so native sheet gestures work on both platforms.
2026-08-22 22:06:27 +02:00
Mohamed Boudra c60fa098ac Give every pane a browser-style New tab (#3715)
* feat(app): replace empty panes with New tabs

Give every retained pane an independently identified New tab, replace it in place when a destination is chosen, and preserve split close and move invariants. Keep existing workspace startup and compact explorer routing explicit, with focused unit and browser regression coverage.

* test(app): count content tabs in subagent integration

* fix(app): preserve workspace shortcut ownership
2026-08-22 21:30:15 +02:00
Mohamed Boudra 33cf6cf337 perf(app): defer replica persistence until user inactivity
Whole-replica capture and serialization blocked the JS thread during navigation and streaming. Defer that work until the user has been inactive while allowing long-running streams to persist without becoming the idle signal.
2026-08-22 20:57:13 +02:00
Mohamed Boudra 65fbe38dd3 Restore compact and stable chat timelines (#3716)
Restore compact message and footer spacing, reserve independent composer-pill clearance for transcript content and floating controls, and keep visible web tool-call expansion from shifting the reading position.
2026-08-22 20:50:20 +02:00
Mohamed Boudra a69dca2907 Expand workspace panel actions and keyboard navigation (#3685)
* fix(app): keep file navigation in place

Retargeting a File tab allocated a new tab identity, causing the tab track to unmount and remount during navigation. Preserve the identity for File-to-File changes while retaining new instances for cross-kind replacements.

* feat(app): expand workspace panel actions

Keep the Command Center aligned with workspace tab and pane primitives. Add explicit panel placement, focused open behavior, keyboard navigation for empty panes, and a Changes pill that toggles the visible Side panel without closing its tab.
2026-08-22 14:15:23 +02:00
Mohamed Boudra 3029b5991a Surface workspace changes from agent panes (#3682)
* feat(app): refine workspace pane actions

Prioritize Changes when available, keep Files useful before a selection, and reserve desktop diff clicks for selection so comments require the explicit affordance. Move the Changes shortcut to Shift+G to avoid consuming a likely copy variant.

* feat(app): improve access to workspace changes
2026-08-22 12:20:21 +02:00
Mohamed Boudra b1455fb4b3 docs(hub): document zero-friction onboarding (#3657)
Lead with the browser-to-CLI onboarding journey, document the generated starter bundle, and align guided setup with available Hub connections and daemon-reported runtime defaults.
2026-08-22 12:15:58 +02:00
Mohamed Boudra b4e0ff7685 fix(app): restore native composer auto-growth (#3681)
Native multiline inputs were always scrollable, which kept iOS content-size measurement pinned to one line. Keep native sizing intrinsic below the cap and enable internal scrolling only after the composer reaches it.
2026-08-22 11:36:22 +02:00
Mohamed Boudra 4c6b79dc44 feat(app): refine agent profile creation (#3680)
Keep profiles as the richer primitive while restoring the familiar star cue and clearer guidance for agent selection. Account for borders in shared field geometry so controls preserve their declared heights across form consumers.
2026-08-22 11:16:28 +02:00
Mohamed Boudra a687c42f33 Keep released agent SDK versions reproducible (#3678)
Pin the validated provider and protocol SDK dependency pair so fresh installs cannot silently float after a Paseo release. Refresh the Nix npm dependency hash for the resulting lockfile.
2026-08-22 11:10:20 +02:00
Mohamed Boudra 7beff9cb0b fix(hub): make guided setup choices actionable (#3677)
Show a concise login success state, scope starter choices to available Hub connections, wait for daemon capabilities, and avoid partial starter bundles when setup cannot proceed.
2026-08-22 10:37:19 +02:00
Mohamed Boudra c763b80824 fix(files): keep large source previews responsive (#3665)
Large read-only files bypassed the editor's virtualization and eagerly mounted every highlighted token, which could stall the renderer for seconds and consume gigabytes of memory. Route source previews through bounded platform renderers and reject reads above their display budgets before accumulation.
2026-08-22 10:13:31 +02:00
Mohamed Boudra 8905ad4160 fix(relay): validate handshake keys (#3669) 2026-08-22 02:32:13 +02:00
BrianAguilarWascoandMohamed Boudra 60a5344cc6 fix(server): stop short start gates from undercutting provider startup budgets (#3621)
* fix(server): stop short start gates from undercutting provider startup budgets

Agent creation still failed on contended cold starts after #3578, because two
shorter gates on the same path fire before the event-stream readiness budget can
apply. A traced creation reached session.create twelve seconds in, past the 10s
session.create gate, and the agent was then killed by a bare "timeout" at fifteen
seconds from AGENT_RUN_START_TIMEOUT_MS while the SSE connect was still running.

A run-start bound of 15s wraps a path that is deliberately allowed 30s, so the
outer gate always wins and the inner budget is unreachable.

Give session.create the shared OPENCODE_SERVER_STARTUP_TIMEOUT_MS budget, since
creating the first session for a directory is part of OpenCode coming up, and
derive its error message from the constant instead of hardcoding "10s". Raise
AGENT_RUN_START_TIMEOUT_MS to 60s with a comment recording that it must stay
above the slowest provider startup budget; it stays a literal because that module
is provider-agnostic.

Related to #3571.

* test(server): drive the run start budget through a real AgentManager

The run start boundary tests replaced AgentManager.waitForAgentRunStart with a
bare timer, so they never exercised the production run-state transition or the
agent_state subscription the real wait depends on. That path could regress while
the tests stayed green.

Build the scenario on a real AgentManager with a provider session that holds its
turn start open for a configurable span, so pendingRun.started, the lifecycle
change to "running", and the agent_state notification are what the wait actually
observes. Budgets stay independent literals and the settled-before/after
assertions are unchanged.

* fix(server): describe agent run start timeouts

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-22 02:06:16 +02:00
Mohamed Boudra 8f0c35e924 feat(hub): guide starter setup (#3651)
* feat(hub): guide setup with provider IDs

* fix(hub): preserve login setup state

* feat(hub): select starter agent runtime

Use the connected daemon's provider snapshot so generated Hub bundles only
contain complete provider, model, and mode combinations.

* fix(hub): stage starter runtime choices

Keep providers with valid modes selectable when the daemon has no default mode, so users explicitly choose the complete runtime.

* fix(hub): scope runtime choices to workspace

Provider capabilities can vary by repository, so request the snapshot for the bundle directory that guided setup will deploy.
2026-08-21 23:11:55 +02:00
202 changed files with 7792 additions and 2879 deletions
+53 -76
View File
@@ -1,98 +1,75 @@
# Changelog # Changelog
## 0.5.0-beta.4 - 2026-08-21 ## 0.5.0 - 2026-08-23
### Added ### 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 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 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 optional branch and project names on workspace rows ([#3445](https://github.com/getpaseo/paseo/pull/3445))
- Added separate Content text sizing for chat, the composer, Markdown, and review prose ([#3637](https://github.com/getpaseo/paseo/pull/3637)) - Added drag-and-drop reordering for pinned workspaces ([#3341](https://github.com/getpaseo/paseo/pull/3341))
- Added live sheets for summarized tool-call groups on compact layouts ([#3619](https://github.com/getpaseo/paseo/pull/3619)) - 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 ### 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)) - 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))
- Changed Default send in Settings to a dropdown menu ([#3644](https://github.com/getpaseo/paseo/pull/3644)) - 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))
- Reduced Android workspace-switch stalls after using several long chats ([#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 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)) - Corrected Russian UI translations and terminology ([#3586](https://github.com/getpaseo/paseo/pull/3586) by [@timz](https://github.com/timz))
### Fixed ### Fixed
- Fixed modified tab indicators overlapping truncated tab titles - 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 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 OpenCode turns failing when their event stream dropped ([#3395](https://github.com/getpaseo/paseo/pull/3395))
- Fixed Changes opening an empty comparison after checkout state changes ([#3636](https://github.com/getpaseo/paseo/pull/3636)) - 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 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 an unrelated merged pull request archiving a workspace ([#3425](https://github.com/getpaseo/paseo/pull/3425)) - 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 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 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 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 mobile composers retaining sent text after submission ([#3564](https://github.com/getpaseo/paseo/pull/3564))
- 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 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 workspace pull request status being lost on reload - 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 hidden terminal tabs stopping output in the focused workspace - 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 ## 0.4.0 - 2026-08-13
+10 -1
View File
@@ -35,7 +35,16 @@ be in flight.
### Cancellation ### 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 ## Relationships
+10
View File
@@ -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 `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`. - 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 ## ACP provider catalog versions
The in-app ACP provider catalog pins package-runner entries (`npx`, `npm exec`, The in-app ACP provider catalog pins package-runner entries (`npx`, `npm exec`,
+4
View File
@@ -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. 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 ## ACP Provider Checklist
+5 -1
View File
@@ -83,7 +83,11 @@ Running and successful setup never seed tabs.
persisted in client app settings as `openSupportingTabsInSidePanel`. Off means new persisted in client app settings as `openSupportingTabsInSidePanel`. Off means new
supporting tabs land in the focused pane instead. Mobile ignores it. Explicit actions 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 — 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 ## The name
+1 -1
View File
@@ -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 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, 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 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 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 user action and owns its pending state: `retrying` is a status the sync model publishes, not a React
+1 -1
View File
@@ -1 +1 @@
sha256-TNwqDZ9gJP613iYmDzVfK5vfye5NBloKN5Jt9t7yhx8= sha256-umufh5lfrLUsnvRLzOzw+BYaE/DgH/7x6uGnDwdM4Gk=
+27 -27
View File
@@ -1,12 +1,12 @@
{ {
"name": "paseo", "name": "paseo",
"version": "0.5.0-beta.4", "version": "0.5.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "paseo", "name": "paseo",
"version": "0.5.0-beta.4", "version": "0.5.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"workspaces": [ "workspaces": [
@@ -36140,7 +36140,7 @@
}, },
"packages/app": { "packages/app": {
"name": "@getpaseo/app", "name": "@getpaseo/app",
"version": "0.5.0-beta.4", "version": "0.5.0",
"dependencies": { "dependencies": {
"@codemirror/commands": "6.10.4", "@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4", "@codemirror/language": "6.12.4",
@@ -37192,12 +37192,12 @@
}, },
"packages/cli": { "packages/cli": {
"name": "@getpaseo/cli", "name": "@getpaseo/cli",
"version": "0.5.0-beta.4", "version": "0.5.0",
"dependencies": { "dependencies": {
"@clack/prompts": "^1.0.0", "@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.5.0-beta.4", "@getpaseo/client": "0.5.0",
"@getpaseo/protocol": "0.5.0-beta.4", "@getpaseo/protocol": "0.5.0",
"@getpaseo/server": "0.5.0-beta.4", "@getpaseo/server": "0.5.0",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"commander": "^12.0.0", "commander": "^12.0.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
@@ -37444,10 +37444,10 @@
}, },
"packages/client": { "packages/client": {
"name": "@getpaseo/client", "name": "@getpaseo/client",
"version": "0.5.0-beta.4", "version": "0.5.0",
"dependencies": { "dependencies": {
"@getpaseo/protocol": "0.5.0-beta.4", "@getpaseo/protocol": "0.5.0",
"@getpaseo/relay": "0.5.0-beta.4", "@getpaseo/relay": "0.5.0",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
@@ -37458,7 +37458,7 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@getpaseo/desktop", "name": "@getpaseo/desktop",
"version": "0.5.0-beta.4", "version": "0.5.0",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"@getpaseo/cli": "*", "@getpaseo/cli": "*",
@@ -37701,7 +37701,7 @@
}, },
"packages/expo-two-way-audio": { "packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio", "name": "@getpaseo/expo-two-way-audio",
"version": "0.5.0-beta.4", "version": "0.5.0",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
@@ -38597,7 +38597,7 @@
}, },
"packages/highlight": { "packages/highlight": {
"name": "@getpaseo/highlight", "name": "@getpaseo/highlight",
"version": "0.5.0-beta.4", "version": "0.5.0",
"dependencies": { "dependencies": {
"@codemirror/language": "6.12.4", "@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3", "@codemirror/legacy-modes": "^6.5.3",
@@ -38626,12 +38626,12 @@
}, },
"packages/plugin": { "packages/plugin": {
"name": "@getpaseo/plugin", "name": "@getpaseo/plugin",
"version": "0.5.0-beta.4", "version": "0.5.0",
"dependencies": { "dependencies": {
"use-sync-external-store": "^1.6.0" "use-sync-external-store": "^1.6.0"
}, },
"devDependencies": { "devDependencies": {
"@getpaseo/client": "0.5.0-beta.4", "@getpaseo/client": "0.5.0",
"@types/node": "^20.9.0", "@types/node": "^20.9.0",
"@types/react": "~19.2.0", "@types/react": "~19.2.0",
"react": "19.1.0", "react": "19.1.0",
@@ -38640,14 +38640,14 @@
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"peerDependencies": { "peerDependencies": {
"@getpaseo/client": "0.5.0-beta.4", "@getpaseo/client": "0.5.0",
"react": "19.1.0", "react": "19.1.0",
"zod": "^4.4.3" "zod": "^4.4.3"
} }
}, },
"packages/protocol": { "packages/protocol": {
"name": "@getpaseo/protocol", "name": "@getpaseo/protocol",
"version": "0.5.0-beta.4", "version": "0.5.0",
"dependencies": { "dependencies": {
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
@@ -38660,7 +38660,7 @@
}, },
"packages/relay": { "packages/relay": {
"name": "@getpaseo/relay", "name": "@getpaseo/relay",
"version": "0.5.0-beta.4", "version": "0.5.0",
"dependencies": { "dependencies": {
"base64-js": "^1.5.1", "base64-js": "^1.5.1",
"tweetnacl": "^1.0.3", "tweetnacl": "^1.0.3",
@@ -38878,19 +38878,19 @@
}, },
"packages/server": { "packages/server": {
"name": "@getpaseo/server", "name": "@getpaseo/server",
"version": "0.5.0-beta.4", "version": "0.5.0",
"dependencies": { "dependencies": {
"@agentclientprotocol/sdk": "^0.17.1", "@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.220", "@anthropic-ai/claude-agent-sdk": "0.3.220",
"@anthropic-ai/sdk": "^0.104.2", "@anthropic-ai/sdk": "^0.104.2",
"@babel/parser": "^7.29.8", "@babel/parser": "^7.29.8",
"@getpaseo/client": "0.5.0-beta.4", "@getpaseo/client": "0.5.0",
"@getpaseo/highlight": "0.5.0-beta.4", "@getpaseo/highlight": "0.5.0",
"@getpaseo/plugin": "0.5.0-beta.4", "@getpaseo/plugin": "0.5.0",
"@getpaseo/protocol": "0.5.0-beta.4", "@getpaseo/protocol": "0.5.0",
"@getpaseo/relay": "0.5.0-beta.4", "@getpaseo/relay": "0.5.0",
"@isaacs/ttlcache": "^2.1.4", "@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1", "@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46", "@opencode-ai/sdk": "1.14.46",
"@xterm/headless": "^6.0.0", "@xterm/headless": "^6.0.0",
"ai": "5.0.78", "ai": "5.0.78",
@@ -39940,7 +39940,7 @@
}, },
"packages/website": { "packages/website": {
"name": "@getpaseo/website", "name": "@getpaseo/website",
"version": "0.5.0-beta.4", "version": "0.5.0",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "^1.29.1", "@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1", "@cloudflare/workers-types": "^4.20260317.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "paseo", "name": "paseo",
"version": "0.5.0-beta.4", "version": "0.5.0",
"private": true, "private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents", "description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [ "keywords": [
+36 -7
View File
@@ -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" }); 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(); const workspace = await createWorkspaceWithMountedTabDiff();
await useUnwrappedDiffLines(page); await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace); await openWorkspaceChanges(page, workspace);
await page.setViewportSize({ width: 480, height: 900 }); await page.setViewportSize({ width: 480, height: 900 });
const actions = page.getByTestId("changes-actions-menu-trigger").filter({ visible: true });
const options = page.getByRole("button", { name: "Diff options" }).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.boundingBox(),
options.locator("svg").boundingBox(), options.locator("svg").boundingBox(),
]); ]);
if (!optionsBox || !glyphBox) { if (!actionsBox || !optionsBox || !glyphBox) {
throw new Error("Compact Changes options geometry could not be measured"); 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.width).toBe(32);
expect(optionsBox.height).toBe(32); expect(optionsBox.height).toBe(32);
expect(glyphBox.width).toBe(18); expect(glyphBox.width).toBe(18);
expect(glyphBox.height).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(); await options.click();
const wrapLines = page.getByText("Wrap long lines", { exact: true }); const wrapLines = page.getByText("Wrap long lines", { exact: true });
await expect(wrapLines).toBeVisible(); 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); 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 ({ test("split canvas creates a review on the changed side and keeps it in that column", async ({
page, 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); 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"); const workspace = await createWorkspaceWithExactSelectionDiff("ABCDEFGHIJ");
await useUnwrappedDiffLines(page); await useUnwrappedDiffLines(page);
await openSelectionWorkspaceChanges(page, workspace); 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 expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
await clickFirstChangedLine(page); 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 ({ 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"); if (!bodyBounds) throw new Error("Expanded diff body has no bounds");
const lineHeight = Math.round(fontSize * 1.5); const lineHeight = Math.round(fontSize * 1.5);
const columnLeft = side === "right" ? bodyBounds.x + bodyBounds.width / 2 : bodyBounds.x; 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(); 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 // 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 // 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 // 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 // 2) splitting a tab out of a pane leaves a New tab behind, and the app must stay
// interactive in the [agent | empty main | explorer] state (the reporter hit an // interactive in the [agent | New | explorer] state.
// app-wide lockup here with no way to close the empty pane).
import { expect, type Locator, type Page } from "@playwright/test"; import { expect, type Locator, type Page } from "@playwright/test";
import { test } from "../support/fixtures"; import { test } from "../support/fixtures";
import { gotoWorkspace } from "../support/helpers/launcher"; import { gotoWorkspace } from "../support/helpers/launcher";
@@ -53,19 +52,19 @@ async function closeSeededDraftInMainPane(page: Page): Promise<void> {
await expect(draftTabChip(page)).toHaveCount(0, { timeout: 15_000 }); await expect(draftTabChip(page)).toHaveCount(0, { timeout: 15_000 });
} }
function emptyPaneChild(page: Page): Locator { function newTabPaneChild(page: Page): Locator {
return page return page
.getByTestId("split-group-child") .getByTestId("split-group-child")
.filter({ has: page.getByTestId("workspace-pane-empty-state") }) .filter({ has: page.getByTestId("workspace-new-tab-panel") })
.first(); .first();
} }
async function emptyPaneBox(page: Page) { async function emptyPaneBox(page: Page) {
const paneChild = emptyPaneChild(page); const paneChild = newTabPaneChild(page);
await expect(paneChild).toBeVisible({ timeout: 15_000 }); await expect(paneChild).toBeVisible({ timeout: 15_000 });
const box = await paneChild.boundingBox(); const box = await paneChild.boundingBox();
expect(box).not.toBeNull(); 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; return box;
} }
@@ -97,7 +96,7 @@ async function dragChipTo(
} }
test.describe("explorer pane tab placement", () => { 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, page,
}, testInfo) => { }, testInfo) => {
const consoleErrors: string[] = []; const consoleErrors: string[] = [];
@@ -171,9 +170,9 @@ test.describe("explorer pane tab placement", () => {
body: await page.screenshot(), body: await page.screenshot(),
contentType: "image/png", contentType: "image/png",
}); });
// The split must have taken: agent pane + 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(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 () => { 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 chip.click({ position: { x: 12, y: 13 }, timeout: 5_000 });
await expect(chip).toHaveAttribute("aria-selected", "true", { 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. // 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 }); await emptyPane.click({ position: { x: 40, y: 120 }, timeout: 5_000 });
// The empty pane's + menu must open and dismiss cleanly. // A second drag must work: center-drop the agent tab back into the New pane.
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.
const target = await emptyPaneBox(page); const target = await emptyPaneBox(page);
await dragChipTo(page, chip, { await dragChipTo(page, chip, {
x: target.x + target.width / 2, x: target.x + target.width / 2,
@@ -207,8 +201,8 @@ test.describe("explorer pane tab placement", () => {
contentType: "image/png", contentType: "image/png",
}); });
// The agent chip must have moved into the previously empty pane and stay clickable. // The agent chip must have replaced New in that pane and stay clickable.
await expect(page.getByTestId("workspace-pane-empty-state")).toHaveCount(0, { await expect(page.getByTestId("workspace-new-tab-panel")).toHaveCount(0, {
timeout: 10_000, timeout: 10_000,
}); });
await agentTabChip(page, agentId) 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(); const toggle = visible(page, "workspace-explorer-toggle").first();
await toggle.click(); await toggle.click();
await expect(sidePanel(page)).toHaveCount(1, { timeout: 15_000 }); await expect(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 expect(dockedSidebar(page)).toHaveCount(0);
await toggle.click(); 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 test.step("open the explorer so both sides are showing", async () => {
await toggle.click(); await toggle.click();
await expect(sidePanel(page)).toHaveCount(1, { timeout: 15_000 }); await expect(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); 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 page.keyboard.press(`${modifier}+.`);
await expect(agentList).toHaveCount(1, { timeout: 15_000 }); await expect(agentList).toHaveCount(1, { timeout: 15_000 });
await expect(sidePanel(page)).toHaveCount(1, { timeout: 15_000 }); await expect(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 { } finally {
await workspace.cleanup(); await workspace.cleanup();
+82 -4
View File
@@ -90,6 +90,87 @@ async function seedAgentWithFileLink(input: LinkedFile) {
} }
test.describe("CodeMirror workspace file editing", () => { 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 }) => { test("opens an assistant file link at its referenced line", async ({ page }) => {
const target = "target.ts:42"; const target = "target.ts:42";
const session = await seedAgentWithFileLink({ const session = await seedAgentWithFileLink({
@@ -413,10 +494,7 @@ test.describe("CodeMirror workspace file editing", () => {
.filter({ visible: true }) .filter({ visible: true })
.first() .first()
.click({ button: "right" }); .click({ button: "right" });
await page await page.getByRole("menuitem", { name: "Close", exact: true }).click();
.getByTestId("workspace-tab-context-file_draft.ts-close")
.filter({ visible: true })
.click();
expect(closePrompt).toContain("Closing it will discard the draft."); expect(closePrompt).toContain("Closing it will discard the draft.");
await expect(page.getByTestId("file-source-editor")).toBeVisible(); await expect(page.getByTestId("file-source-editor")).toBeVisible();
@@ -39,6 +39,12 @@ async function assertHeldFileTransition(
finalText: string, finalText: string,
gate: Awaited<ReturnType<typeof delayFileReadResponse>>, 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) => { await page.evaluate((tabTestId) => {
const timeline: Array<{ chip: boolean; loading: boolean; unavailable: boolean }> = []; const timeline: Array<{ chip: boolean; loading: boolean; unavailable: boolean }> = [];
const sample = () => { const sample = () => {
@@ -69,6 +75,19 @@ async function assertHeldFileTransition(
await expect(page.getByTestId("file-preview-unsupported")).toHaveCount(0); await expect(page.getByTestId("file-preview-unsupported")).toHaveCount(0);
await expect(page.getByText("export const sourceA = true;")).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( const heldTimeline = await page.evaluate(
() => () =>
( (
@@ -9,6 +9,18 @@ import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-cli
let workspace: SeededWorkspace; 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 () => { test.beforeAll(async () => {
workspace = await seedWorkspace({ workspace = await seedWorkspace({
repoPrefix: "file-tree-rail-toggle-", repoPrefix: "file-tree-rail-toggle-",
@@ -21,6 +33,21 @@ test.afterAll(async () => {
}); });
test.describe("File panel tree rail", () => { 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) => { test("the file toolbar toggle closes and reopens the tree", async ({ page }, testInfo) => {
await gotoWorkspace(page, workspace.workspaceId); await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page); await openFileExplorer(page);
+100 -54
View File
@@ -1,11 +1,11 @@
import { test, expect } from "../support/fixtures"; import { test, expect } from "../support/fixtures";
import type { Locator } from "@playwright/test";
import { import {
gotoWorkspace, gotoWorkspace,
assertNewChatTileVisible, assertNewChatTileVisible,
assertNewTabMenuTriggerVisible, assertNewTabMenuTriggerVisible,
assertSingleNewTabButton, assertSingleNewTabButton,
openNewTabMenuWithShortcut, openNewTabMenuWithShortcut,
pressDirectNewTabShortcut,
clickNewChat, clickNewChat,
clickNewTerminal, clickNewTerminal,
countTabsOfKind, countTabsOfKind,
@@ -45,6 +45,16 @@ const EMPTY_PROMPT_PROFILE: TerminalProfile = {
args: ["-c", 'echo prompt-args: "$#"; sleep 10', "profile-name", "{{{prompt}}}"], 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 () => { test.beforeAll(async () => {
workspace = await seedWorkspace({ repoPrefix: "launcher-e2e-" }); workspace = await seedWorkspace({ repoPrefix: "launcher-e2e-" });
const created = await workspace.client.createWorkspace({ const created = await workspace.client.createWorkspace({
@@ -66,14 +76,12 @@ test.afterAll(async () => {
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
test.describe("Tab creation", () => { 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) { if (!secondWorkspaceId) {
throw new Error("Secondary workspace was not created"); throw new Error("Secondary workspace was not created");
} }
const serverId = getServerId(); const serverId = getServerId();
const newTabMenuItem = page.getByRole("menuitem", { name: /^Agent/ });
const switchWorkspaceRow = async (workspaceId: string) => { const switchWorkspaceRow = async (workspaceId: string) => {
const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`).first(); const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`).first();
await expect(row).toBeVisible({ timeout: 30_000 }); await expect(row).toBeVisible({ timeout: 30_000 });
@@ -92,11 +100,12 @@ test.describe("Tab creation", () => {
for (let i = 0; i < 8; i++) { for (let i = 0; i < 8; i++) {
await switchWorkspaceRow(sequence[i % sequence.length]); await switchWorkspaceRow(sequence[i % sequence.length]);
await pressNewTabShortcut(page); 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, 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); await gotoWorkspace(page, workspace.workspaceId);
const workspaceUrl = page.url(); const workspaceUrl = page.url();
const modifier = process.platform === "darwin" ? "Meta" : "Control"; const modifier = process.platform === "darwin" ? "Meta" : "Control";
const newTabCountBefore = await countTabsOfKind(page, "new_tab");
await page.keyboard.press(`${modifier}+Comma`); await page.keyboard.press(`${modifier}+Comma`);
await expect(page.getByRole("navigation", { name: "Settings" })).toBeVisible(); await expect(page.getByRole("navigation", { name: "Settings" })).toBeVisible();
await page.keyboard.press(`${modifier}+t`); await page.keyboard.press(`${modifier}+t`);
await expect(page.getByRole("menuitem", { name: /^Agent/ })).toHaveCount(0);
await page.goto(workspaceUrl); await page.goto(workspaceUrl);
await assertNewTabMenuTriggerVisible(page); await assertNewTabMenuTriggerVisible(page);
const countBefore = await countTabsOfKind(page, "draft"); await expect.poll(() => countTabsOfKind(page, "new_tab")).toBe(newTabCountBefore);
await pressDirectNewTabShortcut(page, "a");
await expect.poll(() => countTabsOfKind(page, "draft")).toBe(countBefore + 1);
}); });
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); await gotoWorkspace(page, workspace.workspaceId);
const countBefore = await countTabsOfKind(page, "draft"); const countBefore = await countTabsOfKind(page, "draft");
await openNewTabMenuWithShortcut(page); await openNewTabMenuWithShortcut(page);
await expect.poll(() => countTabsOfKind(page, "draft")).toBe(countBefore); 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); 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 pressNewTabShortcut(page);
await expect await expect(newTabs).toHaveCount(countBefore + 1);
.poll(() => countTabsOfKind(page, "draft"), { timeout: 15_000 }) const firstIds = await tabTestIds(newTabs);
.toBe(countBefore + 1);
const countAfterFirst = await countTabsOfKind(page, "draft");
await pressDirectNewTabShortcut(page, "a"); await pressNewTabShortcut(page);
await expect await expect(newTabs).toHaveCount(countBefore + 2);
.poll(() => countTabsOfKind(page, "draft"), { timeout: 15_000 }) const ids = await tabTestIds(newTabs);
.toBe(countAfterFirst + 1); 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 gotoWorkspace(page, workspace.workspaceId);
await openNewTabMenuWithShortcut(page); await openNewTabMenuWithShortcut(page);
const agent = page.getByRole("menuitem", { name: /^Agent/ }); const panel = page.getByTestId("workspace-new-tab-panel").filter({ visible: true });
const terminal = page.getByRole("menuitem", { name: /^Terminal/ }); const agent = panel.getByRole("button", { name: /^Agent/ });
const changes = page.getByRole("menuitem", { name: /Changes/ }); const terminal = panel.getByRole("button", { name: /^Terminal/ });
const files = page.getByRole("menuitem", { name: /Files/ }); const changes = panel.getByRole("button", { name: /Changes/ });
const files = panel.getByRole("button", { name: /Files/ });
const shortcutPrefix = process.platform === "darwin" ? /⇧⌘/ : /Ctrl.*Shift/; const shortcutPrefix = process.platform === "darwin" ? /⇧⌘/ : /Ctrl.*Shift/;
await expect(agent).toContainText(new RegExp(`${shortcutPrefix.source}.*A`)); await expect(agent).toContainText(new RegExp(`${shortcutPrefix.source}.*A`));
await expect(terminal).toContainText(new RegExp(`${shortcutPrefix.source}.*T`)); await expect(terminal).toContainText(new RegExp(`${shortcutPrefix.source}.*T`));
await expect(changes).toContainText(new RegExp(`${shortcutPrefix.source}.*C`)); await expect(changes).toContainText(new RegExp(`${shortcutPrefix.source}.*G`));
await expect(files).toContainText(new RegExp(`${shortcutPrefix.source}.*E`)); await expect(files).toContainText(new RegExp(`${shortcutPrefix.source}.*E`));
await expect(agent).toBeFocused(); await expect(agent).toBeFocused();
@@ -164,12 +190,10 @@ test.describe("Tab creation", () => {
await expect(terminal).toBeFocused(); await expect(terminal).toBeFocused();
await page.keyboard.press("ArrowUp"); await page.keyboard.press("ArrowUp");
await expect(agent).toBeFocused(); await expect(agent).toBeFocused();
await page.keyboard.press("Escape");
await expect(page.getByRole("button", { name: "New tab" })).toBeFocused(); await page.locator("body").click({ position: { x: 1, y: 1 } });
await expect(page.getByRole("button", { name: "New tab" })).toHaveAttribute( await panel.click({ position: { x: 20, y: 20 } });
"aria-expanded", await expect(agent).toBeFocused();
"false",
);
}); });
test("clicking new agent tab creates a draft tab", async ({ page }) => { test("clicking new agent tab creates a draft tab", async ({ page }) => {
@@ -205,8 +229,12 @@ test.describe("Tab creation", () => {
try { try {
await gotoWorkspace(page, workspace.workspaceId); await gotoWorkspace(page, workspace.workspaceId);
await page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }).click(); await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
await page.getByRole("menuitem", { name: EMPTY_PROMPT_PROFILE.name }).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"); await expectTerminalOutputContains(page, "prompt-args: 0");
} finally { } 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 }) => { test("tab bar shows action buttons per pane", async ({ page }) => {
await gotoWorkspace(page, workspace.workspaceId); await gotoWorkspace(page, workspace.workspaceId);
await assertSingleNewTabButton(page); await assertSingleNewTabButton(page);
@@ -226,15 +268,13 @@ test.describe("Tab creation", () => {
const tabRow = page.getByTestId("workspace-tabs-row").filter({ visible: true }).first(); const tabRow = page.getByTestId("workspace-tabs-row").filter({ visible: true }).first();
const scrollArea = tabRow.getByTestId("workspace-tabs-scroll"); const scrollArea = tabRow.getByTestId("workspace-tabs-scroll");
const newTabButtonInScroll = await scrollArea const newTabButtonInScroll = await scrollArea.getByTestId("workspace-new-tab-button").count();
.getByTestId("workspace-new-tab-menu-trigger")
.count();
const scrollShades = await tabRow const scrollShades = await tabRow
.locator('[data-testid^="workspace-tabs-scroll-shade-"]') .locator('[data-testid^="workspace-tabs-scroll-shade-"]')
.count(); .count();
expect(newTabButtonInScroll === 0 || scrollShades === 0).toBe(true); 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.describe("Tab transitions (no flash)", () => {
test("New agent tab transition has no blank intermediate tab state", async ({ page }) => { test("New agent tab transition has no blank intermediate tab state", async ({
await gotoWorkspace(page, workspace.workspaceId); 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 // Sample the single New → Agent replacement, not the separate action that
const snapshots = await sampleTabsDuringTransition(page, () => clickNewChat(page)); // 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 // Every snapshot should have at least one tab — no blank/zero-tab frames
for (const snapshot of snapshots) { for (const snapshot of snapshots) {
expect(snapshot.length).toBeGreaterThanOrEqual(1); expect(snapshot.length).toBeGreaterThanOrEqual(1);
} }
// Tab count should never spike excessively (no duplicate flash from add-then-remove). // Replacement is atomic: the set of identities changes once, while its size stays fixed.
// 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.
const counts = snapshots.map((snapshot) => snapshot.length); const counts = snapshots.map((snapshot) => snapshot.length);
const maxCount = Math.max(...counts);
const initialCount = counts[0] ?? 0; const initialCount = counts[0] ?? 0;
expect(maxCount).toBeLessThanOrEqual(initialCount + 2); expect(counts.every((count) => count === initialCount)).toBe(true);
expect(new Set(snapshots.map((snapshot) => JSON.stringify(snapshot))).size).toBeLessThanOrEqual( expect(new Set(snapshots.map(tabIdentityKey)).size).toBeLessThanOrEqual(2);
2,
);
await expectTabTitleFits(page, "New Agent", { min: 96, max: 160 }); 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();
}
});
+47 -26
View File
@@ -18,18 +18,20 @@ function mainPane(page: Page): Locator {
return page.getByTestId("workspace-pane-main").filter({ visible: true }); return page.getByTestId("workspace-pane-main").filter({ visible: true });
} }
function emptyState(scope: Locator): Locator { function newTabPanel(scope: Locator): Locator {
return scope.getByTestId("workspace-pane-empty-state"); 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 { function sidePanelToggle(page: Page): Locator {
return page.getByTestId("workspace-explorer-toggle").first(); 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. */ /** 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> { async function splitTabOutOfMainPane(page: Page, chip: Locator): Promise<void> {
const paneBox = await mainPane(page).boundingBox(); 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 test.step("revealing the Side panel seeds nothing", async () => {
await sidePanelToggle(page).click(); await sidePanelToggle(page).click();
await expect(sidePanel(page)).toBeVisible({ timeout: 30_000 }); 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 expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(0);
await capture(page, testInfo, "01-side-panel-reveals-empty"); await capture(page, testInfo, "01-side-panel-reveals-empty");
}); });
await test.step("Changes opens in the Side panel that offered it", async () => { 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({ await expect(sidePanel(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
timeout: 30_000, timeout: 30_000,
}); });
@@ -77,10 +86,6 @@ test.describe("Side panel", () => {
await capture(page, testInfo, "02-changes-opens-in-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 () => { 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"); const changesTab = sidePanel(page).getByTestId("workspace-tab-working_diff");
await changesTab.hover(); await changesTab.hover();
@@ -93,7 +98,7 @@ test.describe("Side panel", () => {
await sidePanelToggle(page).click(); await sidePanelToggle(page).click();
await expect(sidePanel(page)).toBeVisible({ timeout: 15_000 }); 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"); await capture(page, testInfo, "04-side-panel-revealed-empty");
}); });
} finally { } finally {
@@ -123,23 +128,30 @@ test.describe("Side panel", () => {
.first(); .first();
await expect(draftPane).toBeVisible({ timeout: 15_000 }); 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 expect(emptyMain).toBeVisible({ timeout: 15_000 });
await capture(page, testInfo, "06-empty-main-pane-launcher"); 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. // 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(mainPane(page)).toHaveCount(0, { timeout: 30_000 });
await expect(sidePanel(page)).toBeVisible(); 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"); await capture(page, testInfo, "07-empty-main-pane-removed");
} finally { } finally {
await workspace.cleanup(); 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-" }); const workspace = await seedWorkspace({ repoPrefix: "side-panel-final-pane-" });
try { try {
@@ -150,7 +162,9 @@ test.describe("Side panel", () => {
// holds that tab, so emptying Main does not look like an empty workspace and // holds that tab, so emptying Main does not look like an empty workspace and
// nothing reseeds a draft into it. // nothing reseeds a draft into it.
await ensureSidePanel(page); 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({ await expect(sidePanel(page).getByTestId("workspace-tab-files")).toBeVisible({
timeout: 30_000, timeout: 30_000,
}); });
@@ -168,13 +182,20 @@ test.describe("Side panel", () => {
.first() .first()
.click(); .click();
// Main is now empty and the only pane the user can see. Taking it away would // Main now contains New and is the only pane the user can see. Closing its
// leave nowhere to look, so the launcher appears without a Close pane. // tab cannot remove the final visible pane.
const lastPane = emptyState(mainPane(page)); const lastPane = newTabPanel(mainPane(page));
await expect(lastPane).toBeVisible({ timeout: 15_000 }); await expect(lastPane).toBeVisible({ timeout: 15_000 });
await expect(lastPane.getByRole("button", { name: "Files" })).toBeVisible(); await expect(lastPane.getByRole("button", { name: "Files", exact: true })).toBeVisible();
await expect(lastPane.getByRole("button", { name: "Close pane" })).toHaveCount(0); const finalNewTab = mainPane(page)
await expect(closePaneButton(page)).toHaveCount(0); .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"); await capture(page, testInfo, "08-final-visible-pane-has-no-close");
} finally { } finally {
await workspace.cleanup(); await workspace.cleanup();
@@ -190,13 +211,13 @@ test.describe("Side panel", () => {
await gotoWorkspace(page, workspace.workspaceId); await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page); await waitForWorkspaceTabsVisible(page);
await ensureSidePanel(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({ await expect(sidePanel(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
timeout: 30_000, timeout: 30_000,
}); });
await capture(page, testInfo, "08-changes-in-side-panel"); 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 await page
.getByTestId("workspace-new-tab-menu-changes") .getByTestId("workspace-new-tab-menu-changes")
.filter({ visible: true }) .filter({ visible: true })
@@ -1,6 +1,10 @@
import { test, expect, type Page } from "../support/fixtures"; import { test, expect, type Page } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app"; import { gotoAppShell } from "../support/helpers/app";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; 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 { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id"; import { getServerId } from "../support/helpers/server-id";
import { selectSidebarStatusGrouping } from "../support/helpers/sidebar"; import { selectSidebarStatusGrouping } from "../support/helpers/sidebar";
@@ -142,6 +146,25 @@ async function installPinRpcGate(
} }
test.describe("Pin workspace shortcut", () => { test.describe("Pin workspace shortcut", () => {
test("keeps project creation reachable after pinning its only workspace", async ({ page }) => {
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 }) => { test("pins the active workspace while its project section is collapsed", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-collapsed-" }); const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-collapsed-" });
@@ -81,7 +81,7 @@ async function verifySplitDownResizesPty(page: Page, harness: TerminalE2EHarness
beforeXterm.rows, 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. // pane is unfocused at the exact moment its container shrinks.
await runWorkspaceActionFromCommandCenter(page, "Split pane down"); 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 // Workspaces always render a hidden explorer companion pane alongside the
// main pane, so an unscoped testid locator matches both; scope to the // main pane, so an unscoped testid locator matches both; scope to the
// visible one. // visible one.
await page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }).click(); await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
await page.getByTestId("workspace-new-tab-menu-terminal").click(); await page
.getByTestId("workspace-new-tab-menu-terminal")
.filter({ visible: true })
.first()
.click();
} }
async function listTerminalIds(harness: TerminalE2EHarness): Promise<string[]> { 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 page.goto(buildHostWorkspaceRoute(getServerId(), harness.workspaceId));
await expect( await expect(
page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }), page.getByTestId("workspace-new-tab-button").filter({ visible: true }),
).toBeVisible({ ).toBeVisible({
timeout: 30_000, timeout: 30_000,
}); });
@@ -133,7 +133,7 @@ test.describe("Workspace agent title handoff", () => {
.toBe(promptTitle); .toBe(promptTitle);
await expect(agentTab).toContainText(promptTitle, { timeout: 15_000 }); await expect(agentTab).toContainText(promptTitle, { timeout: 15_000 });
await agentTab.click({ button: "right" }); 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, timeout: 10_000,
}); });
await page.keyboard.press("Escape"); await page.keyboard.press("Escape");
@@ -65,10 +65,10 @@ test.describe("Workspace pane mounting", () => {
.first() .first()
.elementHandle(); .elementHandle();
expect(originalComposer).not.toBeNull(); 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 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, emptyPanesBeforeSplit + 1,
{ timeout: 30_000 }, { timeout: 30_000 },
); );
@@ -70,9 +70,9 @@ test.describe("Workspace terminal tab rename", () => {
await expect(tab).toBeVisible({ timeout: 15_000 }); await expect(tab).toBeVisible({ timeout: 15_000 });
await tab.click({ button: "right" }); await tab.click({ button: "right" });
const copyTerminalId = page.getByTestId( const copyTerminalId = page.getByRole("menuitem", {
`workspace-tab-context-terminal_${terminalId}-copy-terminal-id`, name: /^Copy terminal id/,
); });
await expect(copyTerminalId).toBeVisible({ timeout: 10_000 }); await expect(copyTerminalId).toBeVisible({ timeout: 10_000 });
await copyTerminalId.click(); await copyTerminalId.click();
@@ -99,10 +99,7 @@ test.describe("Workspace terminal tab rename", () => {
await expect(tab).toBeVisible({ timeout: 15_000 }); await expect(tab).toBeVisible({ timeout: 15_000 });
await tab.click({ button: "right" }); await tab.click({ button: "right" });
await expect(page.getByTestId(`workspace-tab-context-terminal_${terminalId}`)).toBeVisible({ const renameItem = page.getByRole("menuitem", { name: "Rename", exact: true });
timeout: 10_000,
});
const renameItem = page.getByTestId(`workspace-tab-context-terminal_${terminalId}-rename`);
await expect(renameItem).toBeVisible({ timeout: 10_000 }); await expect(renameItem).toBeVisible({ timeout: 10_000 });
await renameItem.click(); await renameItem.click();
@@ -275,7 +275,7 @@ async function fillAgentProfileForm(page: Page, draft: AgentProfileDraft): Promi
await chooseSelectFieldOption(page, { field: "mode", option: draft.mode }); await chooseSelectFieldOption(page, { field: "mode", option: draft.mode });
} }
if (draft.notes !== undefined) { 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); const modal = editModal(page);
await expect(modal).toBeVisible({ timeout: 30_000 }); await expect(modal).toBeVisible({ timeout: 30_000 });
await expect(modal.getByRole("textbox", { name: "Name", exact: true })).toHaveValue(name); 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)) { for (const [field, value] of Object.entries(expected)) {
if (field === "notes") { if (field === "notes") {
await expect( await expect(modal.getByRole("textbox", { name: "When to use", exact: true })).toHaveValue(
modal.getByRole("textbox", { name: "Notes for agents", exact: true }), value,
).toHaveValue(value); );
continue; continue;
} }
await expect( await expect(
+10 -13
View File
@@ -62,7 +62,7 @@ export async function getActiveTabTestId(page: Page): Promise<string | null> {
// ─── Tab actions ─────────────────────────────────────────────────────────── // ─── 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> { export async function pressNewTabShortcut(page: Page): Promise<void> {
const modifier = process.platform === "darwin" ? "Meta" : "Control"; const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.keyboard.press(`${modifier}+t`); 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> { export async function openNewTabMenuWithShortcut(page: Page): Promise<void> {
await pressNewTabShortcut(page); 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> { 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. */ /** Assert the inline plus button is visible in the tab bar. */
export async function assertNewChatTileVisible(page: Page): Promise<void> { export async function assertNewChatTileVisible(page: Page): Promise<void> {
await expect( await expect(
page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }).first(), page.getByTestId("workspace-new-tab-button").filter({ visible: true }).first(),
).toBeVisible(); ).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> { export async function assertNewTabMenuTriggerVisible(page: Page): Promise<void> {
await expect( await expect(
page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }).first(), page.getByTestId("workspace-new-tab-button").filter({ visible: true }).first(),
).toBeVisible(); ).toBeVisible();
} }
// ─── Tab creation actions ───────────────────────────────────────────────── // ─── 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> { export async function clickNewChat(page: Page): Promise<void> {
await createAgentTabFromMenu(page); 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> { export async function clickNewTerminal(page: Page): Promise<void> {
const trigger = page const trigger = page.getByTestId("workspace-new-tab-button").filter({ visible: true }).first();
.getByTestId("workspace-new-tab-menu-trigger")
.filter({ visible: true })
.first();
await expect(trigger).toBeVisible({ timeout: 10_000 }); await expect(trigger).toBeVisible({ timeout: 10_000 });
await trigger.click(); await trigger.click();
const item = page const item = page
@@ -137,7 +134,7 @@ export async function waitForTabWithTitle(
/** Assert the inline plus button is visible in the tab bar. */ /** Assert the inline plus button is visible in the tab bar. */
export async function assertSingleNewTabButton(page: Page): Promise<void> { 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(); const count = await buttons.count();
expect(count).toBeGreaterThanOrEqual(1); expect(count).toBeGreaterThanOrEqual(1);
} }
@@ -224,7 +221,7 @@ export async function expectTabTitleFits(
} }
export function terminalSurfaceLocator(page: Page) { 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> { export async function expectAgentTabActive(page: Page, agentId: string): Promise<void> {
@@ -14,6 +14,7 @@ export interface MockAgentWorkspace {
export interface MockAgentOptions { export interface MockAgentOptions {
repoPrefix: string; repoPrefix: string;
title: string; title: string;
repo?: Parameters<typeof seedWorkspace>[0]["repo"];
port?: number; port?: number;
initialPrompt?: string; initialPrompt?: string;
model?: string; model?: string;
@@ -29,7 +30,11 @@ export interface MockAgentOptions {
export async function seedMockAgentWorkspace( export async function seedMockAgentWorkspace(
options: MockAgentOptions, options: MockAgentOptions,
): Promise<MockAgentWorkspace> { ): 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 { try {
const agent = await workspace.client.createAgent({ const agent = await workspace.client.createAgent({
provider: "mock", provider: "mock",
@@ -113,6 +113,11 @@ export async function enableRelayAndExpectOffer(page: Page): Promise<void> {
export async function expectPairingOffer(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("img", { name: "Pairing QR code" })).toBeVisible();
await expect(page.getByRole("textbox", { name: "Pairing link" })).toHaveValue(/#offer=/); 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: "Copy", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Enable relay", exact: true })).toHaveCount(0); await expect(page.getByRole("button", { name: "Enable relay", exact: true })).toHaveCount(0);
} }
@@ -12,6 +12,7 @@ export interface SeedWorkspaceDescriptor {
projectDisplayName: string; projectDisplayName: string;
projectRootPath: string; projectRootPath: string;
workspaceDirectory: string; workspaceDirectory: string;
diffStat: { additions: number; deletions: number } | null;
labels?: string[]; labels?: string[];
} }
@@ -88,13 +88,16 @@ export async function ensureSidePanel(page: Page): Promise<Locator> {
*/ */
async function openSidePanelView( async function openSidePanelView(
page: Page, page: Page,
view: { tabTestId: string; menuTestId: string; contentTestId: string; timeout?: number }, view: { tabTestId: string; launchItemId: string; contentTestId: string; timeout?: number },
): Promise<void> { ): Promise<void> {
const panel = await ensureSidePanel(page); const panel = await ensureSidePanel(page);
const tab = panel.getByTestId(view.tabTestId); const tab = panel.getByTestId(view.tabTestId);
if ((await tab.count()) === 0) { if ((await tab.count()) === 0) {
await panel.getByTestId("workspace-new-tab-menu-trigger").click(); await panel.getByTestId("workspace-new-tab-button").click();
await visibleTestId(page, view.menuTestId).first().click(); await page
.getByTestId(`workspace-new-tab-menu-${view.launchItemId}`)
.filter({ visible: true })
.click();
} }
await selectWorkspaceTab(tab); await selectWorkspaceTab(tab);
await expect(visibleTestId(page, view.contentTestId).first()).toBeVisible({ await expect(visibleTestId(page, view.contentTestId).first()).toBeVisible({
@@ -105,7 +108,7 @@ async function openSidePanelView(
export async function openChangesPanel(page: Page): Promise<void> { export async function openChangesPanel(page: Page): Promise<void> {
await openSidePanelView(page, { await openSidePanelView(page, {
tabTestId: "workspace-tab-working_diff", tabTestId: "workspace-tab-working_diff",
menuTestId: "workspace-new-tab-menu-changes", launchItemId: "changes",
contentTestId: "working-diff-panel", contentTestId: "working-diff-panel",
}); });
} }
@@ -113,7 +116,7 @@ export async function openChangesPanel(page: Page): Promise<void> {
export async function openFilesPanel(page: Page): Promise<void> { export async function openFilesPanel(page: Page): Promise<void> {
await openSidePanelView(page, { await openSidePanelView(page, {
tabTestId: "workspace-tab-files", tabTestId: "workspace-tab-files",
menuTestId: "workspace-new-tab-menu-files", launchItemId: "files",
contentTestId: "file-explorer-tree-scroll", contentTestId: "file-explorer-tree-scroll",
}); });
} }
@@ -127,7 +130,7 @@ export async function openPullRequestPanel(page: Page): Promise<void> {
} }
await openSidePanelView(page, { await openSidePanelView(page, {
tabTestId: "workspace-tab-pull_request", tabTestId: "workspace-tab-pull_request",
menuTestId: "workspace-new-tab-menu-pull-request", launchItemId: "pull-request",
contentTestId: "pr-pane", contentTestId: "pr-pane",
timeout: 15_000, timeout: 15_000,
}); });
@@ -137,14 +140,14 @@ export async function waitForWorkspaceTabsVisible(page: Page): Promise<void> {
await expect(visibleTestId(page, "workspace-tabs-row").first()).toBeVisible({ await expect(visibleTestId(page, "workspace-tabs-row").first()).toBeVisible({
timeout: 30_000, 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, 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> { 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 expect(trigger).toBeVisible({ timeout: 10_000 });
await trigger.click(); await trigger.click();
const item = visibleTestId(page, "workspace-new-tab-menu-agent").first(); const item = visibleTestId(page, "workspace-new-tab-menu-agent").first();
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/app", "name": "@getpaseo/app",
"version": "0.5.0-beta.4", "version": "0.5.0",
"private": true, "private": true,
"main": "index.ts", "main": "index.ts",
"scripts": { "scripts": {
@@ -1,7 +1,6 @@
import { withUnistyles } from "react-native-unistyles"; import { withUnistyles } from "react-native-unistyles";
import { import {
BookOpen, BookOpen,
Bot,
Boxes, Boxes,
Brain, Brain,
Bug, Bug,
@@ -27,6 +26,7 @@ import {
Server, Server,
Shield, Shield,
Sparkles, Sparkles,
Star,
Terminal, Terminal,
TestTube, TestTube,
Wrench, Wrench,
@@ -42,7 +42,7 @@ import {
} from "./profile-appearance"; } from "./profile-appearance";
/** Drawn when a profile names no icon, and as the "default" cell in the picker grid. */ /** 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 * `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");
},
);
});
+1 -1
View File
@@ -330,7 +330,7 @@ describe("layoutStream", () => {
}); });
expect(findLayoutItem(layout, historyBlock.id).assistantSpacing).toBe("compactBottom"); 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)( it.each(["web", "android"] as const)(
+11 -5
View File
@@ -49,6 +49,7 @@ interface LayoutSegmentInput {
items: StreamItem[]; items: StreamItem[];
timingByAssistantId: Map<string, TurnTiming>; timingByAssistantId: Map<string, TurnTiming>;
auxiliaryTurnFooter: TurnFooterHost | null; auxiliaryTurnFooter: TurnFooterHost | null;
hasAuxiliaryFooter: boolean;
frameOrder: StreamFrameChildOrder; frameOrder: StreamFrameChildOrder;
boundaryIndex: number | null; boundaryIndex: number | null;
boundaryAboveItem: StreamItem | null; boundaryAboveItem: StreamItem | null;
@@ -249,11 +250,6 @@ function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] {
boundaryIndex: input.boundaryIndex, boundaryIndex: input.boundaryIndex,
boundaryItem: input.boundaryBelowItem, boundaryItem: input.boundaryBelowItem,
}); });
const assistantSpacing = getAssistantBlockSpacing({
item,
aboveItem,
belowItem,
});
const completedFooter = resolveCompletedFooter({ const completedFooter = resolveCompletedFooter({
strategy: input.strategy, strategy: input.strategy,
items: input.items, items: input.items,
@@ -265,6 +261,12 @@ function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] {
boundaryAboveItems: input.boundaryAboveItems, boundaryAboveItems: input.boundaryAboveItems,
boundaryAboveIndex: input.boundaryAboveIndex, boundaryAboveIndex: input.boundaryAboveIndex,
}); });
const assistantSpacing = getAssistantBlockSpacing({
item,
aboveItem,
belowItem,
hasFooterBelow: completedFooter !== null || (input.hasAuxiliaryFooter && belowItem === null),
});
return { return {
item, item,
@@ -294,6 +296,7 @@ const historyLayoutCache = new WeakMap<StreamItem[], Map<string, StreamLayoutIte
export function layoutStream(input: StreamLayoutInput): StreamLayout { export function layoutStream(input: StreamLayoutInput): StreamLayout {
const auxiliaryTurnFooter = resolveAuxiliaryTurnFooter(input); const auxiliaryTurnFooter = resolveAuxiliaryTurnFooter(input);
const hasAuxiliaryFooter = input.isTurnActive || auxiliaryTurnFooter !== null;
const historyBoundaryIndex = input.strategy.getHistoryLiveBoundaryIndex(input.history); const historyBoundaryIndex = input.strategy.getHistoryLiveBoundaryIndex(input.history);
const liveHeadBoundaryIndex = input.strategy.getLiveHeadHistoryBoundaryIndex(input.liveHead); const liveHeadBoundaryIndex = input.strategy.getLiveHeadHistoryBoundaryIndex(input.liveHead);
const historyBoundaryItem = const historyBoundaryItem =
@@ -313,6 +316,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
liveHeadBoundaryItem?.kind ?? "null", liveHeadBoundaryItem?.kind ?? "null",
liveHeadBoundaryItem?.turnId ?? "null", liveHeadBoundaryItem?.turnId ?? "null",
auxiliaryTurnFooter?.itemId ?? "null", auxiliaryTurnFooter?.itemId ?? "null",
hasAuxiliaryFooter ? "footer" : "no-footer",
].join(":"); ].join(":");
let byKey = historyLayoutCache.get(input.history); let byKey = historyLayoutCache.get(input.history);
if (!byKey) { if (!byKey) {
@@ -328,6 +332,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
items: input.history, items: input.history,
timingByAssistantId: input.timingByAssistantId, timingByAssistantId: input.timingByAssistantId,
auxiliaryTurnFooter, auxiliaryTurnFooter,
hasAuxiliaryFooter,
frameOrder, frameOrder,
boundaryIndex: historyBoundaryIndex, boundaryIndex: historyBoundaryIndex,
boundaryAboveItem: null, boundaryAboveItem: null,
@@ -347,6 +352,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
items: input.liveHead, items: input.liveHead,
timingByAssistantId: input.timingByAssistantId, timingByAssistantId: input.timingByAssistantId,
auxiliaryTurnFooter, auxiliaryTurnFooter,
hasAuxiliaryFooter,
frameOrder, frameOrder,
boundaryIndex: liveHeadBoundaryIndex, boundaryIndex: liveHeadBoundaryIndex,
boundaryAboveItem: historyBoundaryItem, boundaryAboveItem: historyBoundaryItem,
@@ -74,6 +74,18 @@ describe("getAssistantBlockSpacing", () => {
expect(getAssistantBlockSpacing({ item: a, aboveItem: null, belowItem: null })).toBe("default"); 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", () => { it("returns compactTop when the item above is in the same block group", () => {
const above = assistantBlock({ id: "above", blockGroupId: "group-1", blockIndex: 0 }); const above = assistantBlock({ id: "above", blockGroupId: "group-1", blockIndex: 0 });
const item = assistantBlock({ id: "item", blockGroupId: "group-1", blockIndex: 1 }); const item = assistantBlock({ id: "item", blockGroupId: "group-1", blockIndex: 1 });
+7 -1
View File
@@ -17,12 +17,15 @@ export function getAssistantBlockSpacing(params: {
item: StreamItem; item: StreamItem;
aboveItem: StreamItem | null | undefined; aboveItem: StreamItem | null | undefined;
belowItem: StreamItem | null | undefined; belowItem: StreamItem | null | undefined;
hasFooterBelow?: boolean;
}): "default" | "compactTop" | "compactBottom" | "compactBoth" { }): "default" | "compactTop" | "compactBottom" | "compactBoth" {
if (params.item.kind !== "assistant_message") { if (params.item.kind !== "assistant_message") {
return "default"; return "default";
} }
const compactTop = isSameAssistantBlockGroup({ item: params.item, other: params.aboveItem }); 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 && compactBottom) return "compactBoth";
if (compactTop) return "compactTop"; if (compactTop) return "compactTop";
if (compactBottom) return "compactBottom"; if (compactBottom) return "compactBottom";
@@ -44,6 +47,9 @@ export function getGapBetweenStreamItems(
if (isUserMessageItem(item) && isUserMessageItem(belowItem)) { if (isUserMessageItem(item) && isUserMessageItem(belowItem)) {
return SPACING[1]; return SPACING[1];
} }
if (item.kind === "user_message" && belowItem.kind === "assistant_message") {
return 0;
}
if (isToolSequenceItem(item) && isToolSequenceItem(belowItem)) { if (isToolSequenceItem(item) && isToolSequenceItem(belowItem)) {
return 0; return 0;
} }
+12 -6
View File
@@ -15,7 +15,10 @@ import { useStableEvent } from "@/hooks/use-stable-event";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar"; import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar";
import { DomOverlayScrollbar } from "@/components/ui/overlay-scrollbar/dom-overlay-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 type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
import { createStreamStrategy } from "./strategy"; import { createStreamStrategy } from "./strategy";
import { import {
@@ -370,14 +373,17 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
overscan: 8, overscan: 8,
}); });
useEffect(() => { useEffect(() => {
rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (_item, _delta, instance) => { rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => {
if (historyStartPrependAnchorActiveRef.current) {
return false;
}
const viewportHeight = instance.scrollRect?.height ?? 0; const viewportHeight = instance.scrollRect?.height ?? 0;
const scrollOffset = instance.scrollOffset ?? 0; const scrollOffset = instance.scrollOffset ?? 0;
const remainingDistance = instance.getTotalSize() - (scrollOffset + viewportHeight); 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 () => { return () => {
rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = undefined; 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 ThemedSyncedLoader = withUnistyles(SyncedLoader);
const workingIndicatorColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); 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 TurnContentStrategy = StreamStrategy;
export type AssistantTurnForkHandler = (input: { export type AssistantTurnForkHandler = (input: {
@@ -220,7 +220,7 @@ const stylesheet = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[2], paddingHorizontal: theme.spacing[2],
}, },
turnFooterRow: { turnFooterRow: {
marginTop: theme.spacing[4], marginTop: theme.spacing[2] + 5,
}, },
turnFooterSlot: { turnFooterSlot: {
flexDirection: "row", flexDirection: "row",
+15 -2
View File
@@ -260,6 +260,8 @@ export interface AgentStreamViewProps {
isAuthoritativeHistoryReady?: boolean; isAuthoritativeHistoryReady?: boolean;
/** Tail space required by a transparent overlay rendered at the bottom edge. */ /** Tail space required by a transparent overlay rendered at the bottom edge. */
bottomOverlayTailClearance?: number; bottomOverlayTailClearance?: number;
/** Bottom offset required for controls floating above that overlay. */
bottomOverlayControlClearance?: number;
toast?: ToastApi | null; toast?: ToastApi | null;
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void; onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
readOnly?: boolean; readOnly?: boolean;
@@ -295,6 +297,10 @@ function useRetainedValue<T>(value: T, active: boolean): T {
const EMPTY_PENDING_MESSAGE_SUBMISSIONS: readonly PendingMessageSubmission[] = []; const EMPTY_PENDING_MESSAGE_SUBMISSIONS: readonly PendingMessageSubmission[] = [];
const GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT = 200; 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>( const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamViewProps>(
function AgentStreamView( function AgentStreamView(
{ {
@@ -309,6 +315,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
routeBottomAnchorRequest = null, routeBottomAnchorRequest = null,
isAuthoritativeHistoryReady = true, isAuthoritativeHistoryReady = true,
bottomOverlayTailClearance = 0, bottomOverlayTailClearance = 0,
bottomOverlayControlClearance,
toast, toast,
onOpenWorkspaceFile, onOpenWorkspaceFile,
readOnly = false, readOnly = false,
@@ -934,6 +941,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
}, [baseRenderModel, pendingPermissionsNode, turnFooterNode]); }, [baseRenderModel, pendingPermissionsNode, turnFooterNode]);
const emptyStateStyle = useMemo(() => [stylesheet.emptyState, stylesheet.contentWrapper], []); const emptyStateStyle = useMemo(() => [stylesheet.emptyState, stylesheet.contentWrapper], []);
const scrollToBottomContainerStyle = useMemo(
() => [
stylesheet.scrollToBottomContainer,
{ bottom: resolveBottomOverlayControlOffset(bottomOverlayControlClearance) },
],
[bottomOverlayControlClearance],
);
const listEmptyComponent = useMemo( const listEmptyComponent = useMemo(
() => () =>
renderListEmptyComponent({ renderListEmptyComponent({
@@ -1065,7 +1079,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
onJumpToPrompt={chatOutline.jumpToPrompt} onJumpToPrompt={chatOutline.jumpToPrompt}
/> />
{(!isNearBottom || isTimelineDetached) && ( {(!isNearBottom || isTimelineDetached) && (
<View style={stylesheet.scrollToBottomContainer} pointerEvents="box-none"> <View style={scrollToBottomContainerStyle} pointerEvents="box-none">
<Animated.View entering={scrollIndicatorFadeIn} exiting={scrollIndicatorFadeOut}> <Animated.View entering={scrollIndicatorFadeIn} exiting={scrollIndicatorFadeOut}>
<Pressable <Pressable
style={stylesheet.scrollToBottomButton} style={stylesheet.scrollToBottomButton}
@@ -1574,7 +1588,6 @@ const stylesheet = StyleSheet.create((theme) => ({
}, },
scrollToBottomContainer: { scrollToBottomContainer: {
position: "absolute", position: "absolute",
bottom: 16,
left: 0, left: 0,
right: 0, right: 0,
alignItems: "center", alignItems: "center",
@@ -11,6 +11,7 @@ import {
findMountedWindowStart, findMountedWindowStart,
getWebMountedRecentStreamItems, getWebMountedRecentStreamItems,
getWebPartialVirtualizationThreshold, getWebPartialVirtualizationThreshold,
shouldAdjustScrollForVirtualRowResize,
splitWebVirtualizedHistory, splitWebVirtualizedHistory,
type IndexedStreamItem, type IndexedStreamItem,
} from "./web-virtualization"; } 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", () => { describe("web virtualization test overrides", () => {
it("uses defaults unless explicit positive integer overrides are present", () => { it("uses defaults unless explicit positive integer overrides are present", () => {
const globalWithOverrides = globalThis as typeof globalThis & { 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; export const DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS = DEFAULT_MOUNTED_RECENT_STREAM_ITEMS;
const COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE = 40; 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 & { type BottomAnchorE2ETestGlobals = typeof globalThis & {
__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: unknown; __PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: unknown;
}; };
+10 -1
View File
@@ -953,9 +953,18 @@ function RootProviders({ children }: { children: ReactNode }) {
); );
} }
function recordUserActivity(): void {
getHostRuntimeStore().recordUserActivity();
}
function RootAppTree() { function RootAppTree() {
return ( return (
<GestureHandlerRootView style={flexStyle}> <GestureHandlerRootView
style={flexStyle}
onTouchStart={recordUserActivity}
onTouchEnd={recordUserActivity}
onTouchCancel={recordUserActivity}
>
<View style={layoutStyles.surfaceFill}> <View style={layoutStyles.surfaceFill}>
<RootProviders> <RootProviders>
<RuntimeProviders> <RuntimeProviders>
@@ -36,10 +36,41 @@ function source(gitActions: GitActions): {
newBrowser: "New browser", newBrowser: "New browser",
splitRight: "Split pane right", splitRight: "Split pane right",
splitDown: "Split pane down", 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: {}, icons: {},
shortcuts: {}, shortcuts: {},
capabilities: { canSplitPanes: true, canOpenBrowserTabs: true }, capabilities: { canSplitPanes: true, canOpenBrowserTabs: true, isGit: false },
activeTabKind: null,
activeTabIndex: -1,
activeTabCount: 0,
dispatch: (action) => dispatched.push(action), dispatch: (action) => dispatched.push(action),
runGitAction: (action) => runGitActions.push(action), runGitAction: (action) => runGitActions.push(action),
}, },
@@ -88,23 +119,43 @@ describe("workspace command center contributions", () => {
const contributions = buildWorkspaceCommandCenterContributions(fixture.value); const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
expect(contributions.map(({ id, rank, visibility }) => ({ id, rank, visibility }))).toEqual([ expect(
{ id: "tab:new-agent", rank: 0, visibility: "always" }, contributions
{ id: "git:commit", rank: 1, visibility: "always" }, .filter((item) =>
{ id: "tab:new-terminal", rank: 2, visibility: "query" }, [
{ id: "tab:new-browser", rank: 3, visibility: "query" }, "tab:new-agent",
{ id: "pane:split-right", rank: 4, visibility: "query" }, "git:commit",
{ id: "pane:split-down", rank: 5, visibility: "query" }, "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", () => { it("omits browser and split actions when their existing capabilities are unavailable", () => {
const fixture = source({ primary: null, secondary: [], menu: [] }); 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); 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", () => { 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(); for (const contribution of contributions) contribution.run();
expect(fixture.dispatched).toEqual([ expect(fixture.dispatched.length).toBeGreaterThan(5);
{ id: "workspace.agent.new", scope: "workspace" }, expect(fixture.dispatched.every((action) => action.scope === "workspace")).toBe(true);
{ 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" },
]);
}); });
it("keeps workspace creation commands available outside Git", () => { it("keeps workspace creation commands available outside Git", () => {
@@ -127,13 +173,11 @@ describe("workspace command center contributions", () => {
const contributions = buildWorkspaceCommandCenterContributions(fixture.value); const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
expect(contributions.map((item) => item.id)).toEqual([ expect(contributions.some((item) => item.id === "tab:new-agent")).toBe(true);
"tab:new-agent", expect(contributions.some((item) => item.id === "tab:new-terminal")).toBe(true);
"tab:new-terminal", expect(contributions.some((item) => item.id === "tab:new-browser")).toBe(true);
"tab:new-browser", expect(contributions.some((item) => item.id === "pane:split-right")).toBe(true);
"pane:split-right", expect(contributions.some((item) => item.id === "pane:split-down")).toBe(true);
"pane:split-down",
]);
expect(contributions.some((item) => item.id.startsWith("git:"))).toBe(false); expect(contributions.some((item) => item.id.startsWith("git:"))).toBe(false);
}); });
}); });
@@ -1,5 +1,10 @@
import type { GitAction, GitActions } from "@/git/policy"; import type { GitAction, GitActions } from "@/git/policy";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; 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 { ShortcutKey } from "@/utils/format-shortcut";
import type { CommandCenterContribution, CommandCenterIcon } from "./contributions"; import type { CommandCenterContribution, CommandCenterIcon } from "./contributions";
@@ -10,6 +15,34 @@ export interface WorkspaceCommandCenterLabels {
newBrowser: string; newBrowser: string;
splitRight: string; splitRight: string;
splitDown: 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 { export interface WorkspaceCommandCenterIcons {
@@ -18,6 +51,20 @@ export interface WorkspaceCommandCenterIcons {
newBrowser?: CommandCenterIcon; newBrowser?: CommandCenterIcon;
splitRight?: CommandCenterIcon; splitRight?: CommandCenterIcon;
splitDown?: 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; git?(action: GitAction): CommandCenterIcon | undefined;
} }
@@ -27,6 +74,13 @@ export interface WorkspaceCommandCenterShortcuts {
splitRight?: ShortcutKey[][]; splitRight?: ShortcutKey[][];
splitDown?: ShortcutKey[][]; splitDown?: ShortcutKey[][];
archiveWorkspace?: ShortcutKey[][]; archiveWorkspace?: ShortcutKey[][];
previousTab?: ShortcutKey[][];
nextTab?: ShortcutKey[][];
closeCurrentTab?: ShortcutKey[][];
closePane?: ShortcutKey[][];
togglePaneMaximization?: ShortcutKey[][];
toggleFocusMode?: ShortcutKey[][];
toggleSidePanel?: ShortcutKey[][];
} }
export interface WorkspaceCommandCenterSource { export interface WorkspaceCommandCenterSource {
@@ -37,7 +91,11 @@ export interface WorkspaceCommandCenterSource {
capabilities: { capabilities: {
canSplitPanes: boolean; canSplitPanes: boolean;
canOpenBrowserTabs: boolean; canOpenBrowserTabs: boolean;
isGit: boolean;
}; };
activeTabKind: WorkspaceTabTarget["kind"] | null;
activeTabIndex: number;
activeTabCount: number;
dispatch(action: KeyboardActionDefinition): void; dispatch(action: KeyboardActionDefinition): void;
runGitAction(action: GitAction): 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, source: WorkspaceCommandCenterSource,
): CommandCenterContribution[] { ): CommandCenterContribution[] {
const contributions: CommandCenterContribution[] = [ const contributions: CommandCenterContribution[] = [
@@ -115,8 +488,7 @@ export function buildWorkspaceCommandCenterContributions(
const primary = source.gitActions.primary; const primary = source.gitActions.primary;
if (primary) contributions.push(buildGitContribution(source, primary, 1, "always")); if (primary) contributions.push(buildGitContribution(source, primary, 1, "always"));
contributions.push( contributions.push(
buildWorkspaceAction({ buildQueryAction(source, {
source,
id: "tab:new-terminal", id: "tab:new-terminal",
rank: 2, rank: 2,
title: source.labels.newTerminal, title: source.labels.newTerminal,
@@ -124,52 +496,45 @@ export function buildWorkspaceCommandCenterContributions(
icon: source.icons.newTerminal, icon: source.icons.newTerminal,
shortcutKeys: source.shortcuts.newTerminal, shortcutKeys: source.shortcuts.newTerminal,
action: { id: "workspace.terminal.new", scope: "workspace" }, action: { id: "workspace.terminal.new", scope: "workspace" },
visibility: "query",
}), }),
); );
if (source.capabilities.canOpenBrowserTabs) { if (source.capabilities.canOpenBrowserTabs) {
contributions.push( contributions.push(
buildWorkspaceAction({ buildQueryAction(source, {
source,
id: "tab:new-browser", id: "tab:new-browser",
rank: 3, rank: 3,
title: source.labels.newBrowser, title: source.labels.newBrowser,
keywords: ["browser", "web", "preview"], keywords: ["browser", "web", "preview"],
icon: source.icons.newBrowser, icon: source.icons.newBrowser,
action: { id: "workspace.browser.new", scope: "workspace" }, action: { id: "workspace.browser.new", scope: "workspace" },
visibility: "query",
}), }),
); );
} }
if (source.capabilities.canSplitPanes) { return contributions;
contributions.push( }
buildWorkspaceAction({
source, export function buildWorkspaceCommandCenterContributions(
id: "pane:split-right", source: WorkspaceCommandCenterSource,
rank: 4, ): CommandCenterContribution[] {
title: source.labels.splitRight, const contributions = [
keywords: ["split", "pane", "vertical"], ...buildCreationContributions(source),
icon: source.icons.splitRight, ...buildPanelContributions(source),
shortcutKeys: source.shortcuts.splitRight, ...buildActiveTabContributions(source),
action: { id: "workspace.pane.split.right", scope: "workspace" }, ...(source.capabilities.canSplitPanes ? buildPaneContributions(source) : []),
visibility: "query", buildQueryAction(source, {
}), id: "side-panel:toggle",
buildWorkspaceAction({ rank: 70,
source, title: source.labels.toggleSidePanel,
id: "pane:split-down", keywords: ["side", "panel", "toggle", "show", "hide"],
rank: 5, icon: source.icons.sidePanel,
title: source.labels.splitDown, shortcutKeys: source.shortcuts.toggleSidePanel,
keywords: ["split", "pane", "horizontal"], action: { id: "sidebar.toggle.right", scope: "workspace" },
icon: source.icons.splitDown, }),
shortcutKeys: source.shortcuts.splitDown, ];
action: { id: "workspace.pane.split.down", scope: "workspace" }, const primary = source.gitActions.primary;
visibility: "query",
}),
);
}
for (const [index, action] of source.gitActions.secondary.entries()) { for (const [index, action] of source.gitActions.secondary.entries()) {
if (action.id === primary?.id) continue; if (action.id === primary?.id) continue;
contributions.push(buildGitContribution(source, action, 10 + index, "query")); contributions.push(buildGitContribution(source, action, 100 + index, "query"));
} }
return contributions; return contributions;
} }
@@ -1,18 +1,41 @@
import { useMemo, type ReactElement } from "react"; import { useMemo, type ReactElement } from "react";
import { useTranslation } from "react-i18next"; 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 { getIsElectron } from "@/constants/platform";
import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout"; import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout";
import { GIT_ACTION_ICONS } from "@/git/action-icons"; import { GIT_ACTION_ICONS } from "@/git/action-icons";
import { useGitActionRunner, useGitActions } from "@/git/use-actions"; import { useGitActionRunner, useGitActions } from "@/git/use-actions";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides"; import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { import { type ShortcutOverrides } from "@/keyboard/keyboard-shortcuts";
resolveShortcutKeysForAction,
type ShortcutOverrides,
} from "@/keyboard/keyboard-shortcuts";
import { useKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher-context"; import { useKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher-context";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useWorkspaceDirectory } from "@/stores/session-store-hooks"; 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 { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
import { getShortcutOs } from "@/utils/shortcut-platform"; import { getShortcutOs } from "@/utils/shortcut-platform";
import { getCommandCenterIcon } from "./icon"; import { getCommandCenterIcon } from "./icon";
@@ -22,6 +45,7 @@ import {
buildWorkspaceCommandCenterContributions, buildWorkspaceCommandCenterContributions,
type WorkspaceCommandCenterShortcuts, type WorkspaceCommandCenterShortcuts,
} from "./workspace-contributions"; } from "./workspace-contributions";
import { resolveWorkspaceCommandCenterShortcuts } from "./workspace-shortcuts";
const WORKSPACE_COMMAND_CENTER_ICONS = { const WORKSPACE_COMMAND_CENTER_ICONS = {
newAgent: getCommandCenterIcon(SquarePen), newAgent: getCommandCenterIcon(SquarePen),
@@ -29,8 +53,28 @@ const WORKSPACE_COMMAND_CENTER_ICONS = {
newBrowser: getCommandCenterIcon(Globe), newBrowser: getCommandCenterIcon(Globe),
splitRight: getCommandCenterIcon(Columns2), splitRight: getCommandCenterIcon(Columns2),
splitDown: getCommandCenterIcon(Rows2), 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 { function staticIcon(element: ReactElement | undefined): CommandCenterIcon | undefined {
if (!element) return undefined; if (!element) return undefined;
function StaticIcon() { function StaticIcon() {
@@ -41,17 +85,7 @@ function staticIcon(element: ReactElement | undefined): CommandCenterIcon | unde
function resolveWorkspaceShortcuts(overrides: ShortcutOverrides): WorkspaceCommandCenterShortcuts { function resolveWorkspaceShortcuts(overrides: ShortcutOverrides): WorkspaceCommandCenterShortcuts {
const platform = { isMac: getShortcutOs() === "mac", isDesktop: getIsElectron() }; const platform = { isMac: getShortcutOs() === "mac", isDesktop: getIsElectron() };
return { return resolveWorkspaceCommandCenterShortcuts({ overrides, platform });
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,
};
} }
export function useWorkspaceCommandCenterActions(): void { export function useWorkspaceCommandCenterActions(): void {
@@ -60,10 +94,22 @@ export function useWorkspaceCommandCenterActions(): void {
const selection = useActiveWorkspaceSelection(); const selection = useActiveWorkspaceSelection();
const serverId = selection?.serverId ?? null; const serverId = selection?.serverId ?? null;
const workspaceId = selection?.workspaceId ?? 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 cwd = useWorkspaceDirectory(serverId, workspaceId);
const isCompact = useIsCompactFormFactor(); const isCompact = useIsCompactFormFactor();
const { overrides } = useKeyboardShortcutOverrides(); const { overrides } = useKeyboardShortcutOverrides();
const { gitActions } = useGitActions({ const { gitActions, isGit } = useGitActions({
serverId: serverId ?? "", serverId: serverId ?? "",
cwd: cwd ?? "", cwd: cwd ?? "",
icons: GIT_ACTION_ICONS, icons: GIT_ACTION_ICONS,
@@ -81,6 +127,34 @@ export function useWorkspaceCommandCenterActions(): void {
newBrowser: t("workspace.tabs.actions.newBrowser"), newBrowser: t("workspace.tabs.actions.newBrowser"),
splitRight: t("workspace.tabs.actions.splitRight"), splitRight: t("workspace.tabs.actions.splitRight"),
splitDown: t("workspace.tabs.actions.splitDown"), 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: { icons: {
...WORKSPACE_COMMAND_CENTER_ICONS, ...WORKSPACE_COMMAND_CENTER_ICONS,
@@ -90,14 +164,29 @@ export function useWorkspaceCommandCenterActions(): void {
capabilities: { capabilities: {
canSplitPanes: supportsDesktopPaneSplits() && !isCompact, canSplitPanes: supportsDesktopPaneSplits() && !isCompact,
canOpenBrowserTabs: getIsElectron(), canOpenBrowserTabs: getIsElectron(),
isGit,
}, },
activeTabKind,
activeTabIndex,
activeTabCount: focusedTabs.length,
dispatch: (action) => { dispatch: (action) => {
clearCommandCenterFocusRestoreElement(); clearCommandCenterFocusRestoreElement();
keyboardActionDispatcher.dispatch(action); keyboardActionDispatcher.dispatch(action);
}, },
runGitAction, runGitAction,
}), }),
[gitActions, isCompact, keyboardActionDispatcher, overrides, runGitAction, t], [
activeTabIndex,
activeTabKind,
focusedTabs.length,
gitActions,
isCompact,
isGit,
keyboardActionDispatcher,
overrides,
runGitAction,
t,
],
); );
useCommandCenterActions({ 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) => ({ const styles = StyleSheet.create((theme) => ({
container: { container: {
flex: 1, flex: 1,
backgroundColor: theme.colors.surfaceSidebar,
}, },
desktopSplit: { desktopSplit: {
flex: 1, flex: 1,
+14 -2
View File
@@ -13,7 +13,7 @@ import {
type StyleProp, type StyleProp,
type ViewStyle, type ViewStyle,
} from "react-native"; } 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 { Gesture, GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { import {
@@ -1455,7 +1455,19 @@ function ModelBrowserContent({
return scrolling === "independent" ? ( return scrolling === "independent" ? (
<IndependentProviderList>{allProvidersContent}</IndependentProviderList> <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>
); );
} }
+26 -88
View File
@@ -69,7 +69,6 @@ import {
WorkspaceDesktopTabsRow, WorkspaceDesktopTabsRow,
type WorkspaceDesktopTabRowItem, type WorkspaceDesktopTabRowItem,
} from "@/screens/workspace/workspace-desktop-tabs-row"; } from "@/screens/workspace/workspace-desktop-tabs-row";
import type { TerminalProfile } from "@getpaseo/protocol/messages";
import { import {
WorkspaceTabPresentationResolver, WorkspaceTabPresentationResolver,
WorkspaceTabIcon, WorkspaceTabIcon,
@@ -110,10 +109,7 @@ interface SplitContainerProps {
onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void; onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void; onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void; onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCreateDraftTab: (input: { paneId?: string }) => void; onCreateNewTab: (input: { paneId?: string }) => void;
onCreateTerminalTab: (input: { paneId?: string; profile?: TerminalProfile }) => void;
onCreateBrowserTab: (input: { paneId?: string }) => void;
showCreateBrowserTab?: boolean;
buildPaneContentModel: (input: { buildPaneContentModel: (input: {
paneId: string; paneId: string;
tab: WorkspaceTabDescriptor; tab: WorkspaceTabDescriptor;
@@ -129,14 +125,8 @@ interface SplitContainerProps {
position: "left" | "right" | "top" | "bottom"; position: "left" | "right" | "top" | "bottom";
}) => void; }) => void;
onMoveTabToPane: (tabId: string, toPaneId: string) => void; onMoveTabToPane: (tabId: string, toPaneId: string) => void;
onClosePane: (paneId: string) => void;
onResizeSplit: (groupId: string, sizes: number[]) => void; onResizeSplit: (groupId: string, sizes: number[]) => void;
onReorderTabsInPane: (paneId: string, tabIds: string[]) => void; onReorderTabsInPane: (paneId: string, tabIds: string[]) => void;
renderPaneEmptyState?: (input: {
paneId: string;
paneIsolated: boolean;
onClosePane: () => void;
}) => ReactNode;
focusModeEnabled?: boolean; focusModeEnabled?: boolean;
onExitFocusMode: () => void; onExitFocusMode: () => void;
} }
@@ -182,7 +172,6 @@ interface SplitNodeViewProps extends Omit<SplitContainerProps, "layout" | "onMov
sidePanelPaneId: string | null; sidePanelPaneId: string | null;
maximizedPaneId: string | null; maximizedPaneId: string | null;
onTogglePaneMaximized: (paneId: string) => void; onTogglePaneMaximized: (paneId: string) => void;
paneIsolated: boolean;
activeDragTabId: string | null; activeDragTabId: string | null;
showDropZones: boolean; showDropZones: boolean;
dropPreview: SplitDropZoneHover | null; dropPreview: SplitDropZoneHover | null;
@@ -402,19 +391,14 @@ export function SplitContainer({
onCloseTabsToLeft, onCloseTabsToLeft,
onCloseTabsToRight, onCloseTabsToRight,
onCloseOtherTabs, onCloseOtherTabs,
onCreateDraftTab, onCreateNewTab,
onCreateTerminalTab,
onCreateBrowserTab,
showCreateBrowserTab,
buildPaneContentModel, buildPaneContentModel,
onFocusPane, onFocusPane,
onSplitPane, onSplitPane,
onSplitPaneEmpty, onSplitPaneEmpty,
onMoveTabToPane, onMoveTabToPane,
onClosePane,
onResizeSplit, onResizeSplit,
onReorderTabsInPane, onReorderTabsInPane,
renderPaneEmptyState = () => null,
focusModeEnabled, focusModeEnabled,
onExitFocusMode, onExitFocusMode,
}: SplitContainerProps) { }: SplitContainerProps) {
@@ -502,12 +486,6 @@ export function SplitContainer({
[focusModeEnabled, layout.focusedPaneId, layout.root, maximizedPaneId], [focusModeEnabled, layout.focusedPaneId, layout.root, maximizedPaneId],
); );
const renderRoot = useMemo(() => wrapRootPaneForStableMount(splitRoot.root), [splitRoot.root]); 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 handleDragStart = useCallback((event: DragStartEvent) => {
const data = asWorkspaceTabDragData(event.active.data.current); const data = asWorkspaceTabDragData(event.active.data.current);
if (!data) { if (!data) {
@@ -687,19 +665,13 @@ export function SplitContainer({
onCloseTabsToLeft={onCloseTabsToLeft} onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight} onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs} onCloseOtherTabs={onCloseOtherTabs}
onCreateDraftTab={onCreateDraftTab} onCreateNewTab={onCreateNewTab}
onCreateTerminalTab={onCreateTerminalTab}
onCreateBrowserTab={onCreateBrowserTab}
showCreateBrowserTab={showCreateBrowserTab}
buildPaneContentModel={buildPaneContentModel} buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane} onFocusPane={onFocusPane}
onSplitPane={onSplitPane} onSplitPane={onSplitPane}
onSplitPaneEmpty={onSplitPaneEmpty} onSplitPaneEmpty={onSplitPaneEmpty}
paneIsolated={paneIsolated}
onClosePane={onClosePane}
onResizeSplit={onResizeSplit} onResizeSplit={onResizeSplit}
onReorderTabsInPane={onReorderTabsInPane} onReorderTabsInPane={onReorderTabsInPane}
renderPaneEmptyState={renderPaneEmptyState}
activeDragTabId={activeDragTabId} activeDragTabId={activeDragTabId}
showDropZones={activeDragTabId !== null} showDropZones={activeDragTabId !== null}
dropPreview={dropPreview} dropPreview={dropPreview}
@@ -898,19 +870,13 @@ function SplitNodeView({
onCloseTabsToLeft, onCloseTabsToLeft,
onCloseTabsToRight, onCloseTabsToRight,
onCloseOtherTabs, onCloseOtherTabs,
onCreateDraftTab, onCreateNewTab,
onCreateTerminalTab,
onCreateBrowserTab,
showCreateBrowserTab,
buildPaneContentModel, buildPaneContentModel,
onFocusPane, onFocusPane,
onSplitPane, onSplitPane,
onSplitPaneEmpty, onSplitPaneEmpty,
paneIsolated,
onClosePane,
onResizeSplit, onResizeSplit,
onReorderTabsInPane, onReorderTabsInPane,
renderPaneEmptyState,
activeDragTabId, activeDragTabId,
showDropZones, showDropZones,
dropPreview, dropPreview,
@@ -979,18 +945,12 @@ function SplitNodeView({
onCloseTabsToLeft={onCloseTabsToLeft} onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight} onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs} onCloseOtherTabs={onCloseOtherTabs}
onCreateDraftTab={onCreateDraftTab} onCreateNewTab={onCreateNewTab}
onCreateTerminalTab={onCreateTerminalTab}
onCreateBrowserTab={onCreateBrowserTab}
showCreateBrowserTab={showCreateBrowserTab}
buildPaneContentModel={buildPaneContentModel} buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane} onFocusPane={onFocusPane}
onSplitPane={onSplitPane} onSplitPane={onSplitPane}
onSplitPaneEmpty={onSplitPaneEmpty} onSplitPaneEmpty={onSplitPaneEmpty}
paneIsolated={paneIsolated}
onClosePane={onClosePane}
onReorderTabsInPane={onReorderTabsInPane} onReorderTabsInPane={onReorderTabsInPane}
renderPaneEmptyState={renderPaneEmptyState}
activeDragTabId={activeDragTabId} activeDragTabId={activeDragTabId}
showDropZones={showDropZones} showDropZones={showDropZones}
dropPreview={dropPreview} dropPreview={dropPreview}
@@ -1033,19 +993,13 @@ function SplitNodeView({
onCloseTabsToLeft={onCloseTabsToLeft} onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight} onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs} onCloseOtherTabs={onCloseOtherTabs}
onCreateDraftTab={onCreateDraftTab} onCreateNewTab={onCreateNewTab}
onCreateTerminalTab={onCreateTerminalTab}
onCreateBrowserTab={onCreateBrowserTab}
showCreateBrowserTab={showCreateBrowserTab}
buildPaneContentModel={buildPaneContentModel} buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane} onFocusPane={onFocusPane}
onSplitPane={onSplitPane} onSplitPane={onSplitPane}
onSplitPaneEmpty={onSplitPaneEmpty} onSplitPaneEmpty={onSplitPaneEmpty}
paneIsolated={paneIsolated}
onClosePane={onClosePane}
onResizeSplit={onResizeSplit} onResizeSplit={onResizeSplit}
onReorderTabsInPane={onReorderTabsInPane} onReorderTabsInPane={onReorderTabsInPane}
renderPaneEmptyState={renderPaneEmptyState}
activeDragTabId={activeDragTabId} activeDragTabId={activeDragTabId}
showDropZones={showDropZones} showDropZones={showDropZones}
dropPreview={dropPreview} dropPreview={dropPreview}
@@ -1095,18 +1049,12 @@ function SplitPaneView({
onCloseTabsToLeft, onCloseTabsToLeft,
onCloseTabsToRight, onCloseTabsToRight,
onCloseOtherTabs, onCloseOtherTabs,
onCreateDraftTab, onCreateNewTab,
onCreateTerminalTab,
onCreateBrowserTab,
showCreateBrowserTab,
buildPaneContentModel, buildPaneContentModel,
onFocusPane, onFocusPane,
onSplitPane: _onSplitPane, onSplitPane: _onSplitPane,
onSplitPaneEmpty: _onSplitPaneEmpty, onSplitPaneEmpty: _onSplitPaneEmpty,
paneIsolated,
onClosePane,
onReorderTabsInPane, onReorderTabsInPane,
renderPaneEmptyState,
activeDragTabId, activeDragTabId,
showDropZones, showDropZones,
dropPreview, dropPreview,
@@ -1218,7 +1166,6 @@ function SplitPaneView({
() => onTogglePaneMaximized(paneId), () => onTogglePaneMaximized(paneId),
[onTogglePaneMaximized, paneId], [onTogglePaneMaximized, paneId],
); );
const handleClosePane = useCallback(() => onClosePane(paneId), [onClosePane, paneId]);
return ( return (
<RenderProfile id={`SplitPaneView:${pane.id}`}> <RenderProfile id={`SplitPaneView:${pane.id}`}>
<View <View
@@ -1247,10 +1194,7 @@ function SplitPaneView({
onCloseTabsToLeft={handleCloseTabsToLeft} onCloseTabsToLeft={handleCloseTabsToLeft}
onCloseTabsToRight={handleCloseTabsToRight} onCloseTabsToRight={handleCloseTabsToRight}
onCloseOtherTabs={handleCloseOtherTabs} onCloseOtherTabs={handleCloseOtherTabs}
onCreateDraftTab={onCreateDraftTab} onCreateNewTab={onCreateNewTab}
onCreateTerminalTab={onCreateTerminalTab}
onCreateBrowserTab={onCreateBrowserTab}
showCreateBrowserTab={showCreateBrowserTab}
onReorderTabs={handleReorderTabs} onReorderTabs={handleReorderTabs}
externalDndContext externalDndContext
activeDragTabId={activeDragTabId} activeDragTabId={activeDragTabId}
@@ -1266,31 +1210,25 @@ function SplitPaneView({
</WindowChromeSafeArea> </WindowChromeSafeArea>
<View style={styles.paneContent}> <View style={styles.paneContent}>
{mountedPaneTabIds.length > 0 {mountedPaneTabIds.map((tabId) => {
? mountedPaneTabIds.map((tabId) => { const tabDescriptor = tabDescriptorMap.get(tabId);
const tabDescriptor = tabDescriptorMap.get(tabId); if (!tabDescriptor) {
if (!tabDescriptor) { return null;
return null; }
}
return ( return (
<MountedTabSlot <MountedTabSlot
key={tabId} key={tabId}
tabDescriptor={tabDescriptor} tabDescriptor={tabDescriptor}
isVisible={tabId === activeTabDescriptor?.tabId} isVisible={tabId === activeTabDescriptor?.tabId}
isWorkspaceFocused={isWorkspaceFocused} isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isFocused && tabId === activeTabDescriptor?.tabId} isPaneFocused={isFocused && tabId === activeTabDescriptor?.tabId}
paneId={pane.id} paneId={pane.id}
onFocusPane={stableOnFocusPane} onFocusPane={stableOnFocusPane}
buildPaneContentModel={buildPaneContentModel} buildPaneContentModel={buildPaneContentModel}
/> />
); );
}) })}
: (renderPaneEmptyState?.({
paneId,
paneIsolated,
onClosePane: handleClosePane,
}) ?? null)}
<SplitDropZone paneId={pane.id} active={showDropZones} preview={dropPreview} /> <SplitDropZone paneId={pane.id} active={showDropZones} preview={dropPreview} />
</View> </View>
</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); 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 geometry = createControlGeometry(theme);
const borderWidth = theme.borderWidth[1];
expect(geometry.fieldControlSm.minHeight).toBe(32); expect(geometry.fieldControlSm.minHeight).toBe(32);
expect(geometry.fieldControlSm.paddingVertical).toBe(6); expect(geometry.fieldControlSm.paddingVertical).toBe(5);
expect(geometry.fieldTextSm.lineHeight + geometry.fieldControlSm.paddingVertical * 2).toBe( expect(
geometry.fieldControlSm.minHeight, geometry.fieldTextSm.lineHeight +
); geometry.fieldControlSm.paddingVertical * 2 +
borderWidth * 2,
).toBe(geometry.fieldControlSm.minHeight);
expect(geometry.fieldControlMd.minHeight).toBe(44); expect(geometry.fieldControlMd.minHeight).toBe(44);
expect(geometry.fieldControlMd.paddingVertical).toBe(12); expect(geometry.fieldControlMd.paddingVertical).toBe(11);
expect(geometry.fieldTextMd.lineHeight + geometry.fieldControlMd.paddingVertical * 2).toBe( expect(
geometry.fieldControlMd.minHeight, geometry.fieldTextMd.lineHeight +
); geometry.fieldControlMd.paddingVertical * 2 +
borderWidth * 2,
).toBe(geometry.fieldControlMd.minHeight);
expect(geometry.formTextInputSm.paddingVertical).toBe(6); expect(geometry.formTextInputSm.paddingVertical).toBe(5);
expect(geometry.formTextInputMd.paddingVertical).toBe(12); expect(geometry.formTextInputMd.paddingVertical).toBe(11);
}); });
it("keeps segmented controls ghost with button-radius segments in a button-sized track", () => { 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); return Math.round(fontSize * FIELD_TEXT_LINE_HEIGHT_RATIO);
} }
function fieldVerticalPadding(controlHeight: number, lineHeight: number): number { function fieldVerticalPadding(
return (controlHeight - lineHeight) / 2; controlHeight: number,
lineHeight: number,
borderWidth: number,
): number {
return (controlHeight - lineHeight - borderWidth * 2) / 2;
} }
export function getControlInteractionPhase( export function getControlInteractionPhase(
@@ -111,18 +115,27 @@ export function resolveControlInteractionStyles(
} }
export function createControlGeometry(theme: Theme) { export function createControlGeometry(theme: Theme) {
const controlBorderWidth = theme.borderWidth[1];
const fieldTextSmLineHeight = fieldLineHeight(theme.fontSize.base); const fieldTextSmLineHeight = fieldLineHeight(theme.fontSize.base);
const fieldTextMdLineHeight = fieldLineHeight(theme.fontSize.base); const fieldTextMdLineHeight = fieldLineHeight(theme.fontSize.base);
const fieldControlSm = { const fieldControlSm = {
minHeight: CONTROL_HEIGHTS.compact, minHeight: CONTROL_HEIGHTS.compact,
paddingHorizontal: theme.spacing[3], paddingHorizontal: theme.spacing[3],
paddingVertical: fieldVerticalPadding(CONTROL_HEIGHTS.compact, fieldTextSmLineHeight), paddingVertical: fieldVerticalPadding(
CONTROL_HEIGHTS.compact,
fieldTextSmLineHeight,
controlBorderWidth,
),
borderRadius: theme.borderRadius.md, borderRadius: theme.borderRadius.md,
}; };
const fieldControlMd = { const fieldControlMd = {
minHeight: CONTROL_HEIGHTS.field, minHeight: CONTROL_HEIGHTS.field,
paddingHorizontal: theme.spacing[4], paddingHorizontal: theme.spacing[4],
paddingVertical: fieldVerticalPadding(CONTROL_HEIGHTS.field, fieldTextMdLineHeight), paddingVertical: fieldVerticalPadding(
CONTROL_HEIGHTS.field,
fieldTextMdLineHeight,
controlBorderWidth,
),
borderRadius: theme.borderRadius.lg, borderRadius: theme.borderRadius.lg,
}; };
const fieldTextSm = { const fieldTextSm = {
@@ -182,7 +195,7 @@ export function createControlGeometry(theme: Theme) {
fieldTextSm, fieldTextSm,
fieldTextMd, fieldTextMd,
controlRest: { controlRest: {
borderWidth: theme.borderWidth[1], borderWidth: controlBorderWidth,
borderColor: "transparent", borderColor: "transparent",
outlineWidth: 0, outlineWidth: 0,
outlineColor: "transparent", outlineColor: "transparent",
@@ -45,7 +45,7 @@ export function Field({ label, children, hint, error, testID }: FieldProps) {
} }
if (hint) { if (hint) {
return ( return (
<Text numberOfLines={1} style={styles.hintText} testID={hintTestID}> <Text style={styles.hintText} testID={hintTestID}>
{hint} {hint}
</Text> </Text>
); );
+1 -1
View File
@@ -70,7 +70,7 @@ const styles = StyleSheet.create((theme) => ({
badgeBackground: { badgeBackground: {
...StyleSheet.absoluteFillObject, ...StyleSheet.absoluteFillObject,
borderRadius: theme.borderRadius.md, borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface4, backgroundColor: theme.colors.surface3,
opacity: theme.opacity[50], opacity: theme.opacity[50],
}, },
sequence: { 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
View File
@@ -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;
}
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import { reduceNativeComposerHeight } from "./height-state";
const bounds = { minHeight: 30, maxHeight: 240 };
describe("native composer height", () => {
it("grows a fresh composer from the content size exposed during caret scrolling", () => {
expect(
reduceNativeComposerHeight(30, bounds, {
type: "content-measured",
height: 131,
}),
).toBe(131);
});
it("remeasures a restored multiline draft before applying its intrinsic height", () => {
const invalidatedHeight = reduceNativeComposerHeight(30, bounds, { type: "remeasure" });
expect(invalidatedHeight).toBeGreaterThan(30);
expect(
reduceNativeComposerHeight(invalidatedHeight, bounds, {
type: "content-measured",
height: 173,
}),
).toBe(173);
});
it("shrinks a grown composer after deletion and the resulting native measurement", () => {
const invalidatedHeight = reduceNativeComposerHeight(240, bounds, {
type: "text-changed",
previousText: "one\ntwo\nthree",
nextText: "one",
});
expect(invalidatedHeight).toBeLessThan(240);
expect(
reduceNativeComposerHeight(invalidatedHeight, bounds, {
type: "content-measured",
height: 50,
}),
).toBe(50);
});
it("does not disturb height while text grows", () => {
expect(
reduceNativeComposerHeight(120, bounds, {
type: "text-changed",
previousText: "one",
nextText: "one two",
}),
).toBe(120);
});
it("clamps measurements and resets to the current bounds", () => {
expect(
reduceNativeComposerHeight(30, bounds, {
type: "content-measured",
height: 999,
}),
).toBe(240);
expect(reduceNativeComposerHeight(180, bounds, { type: "reset" })).toBe(30);
});
});
@@ -0,0 +1,41 @@
export interface ComposerHeightBounds {
minHeight: number;
maxHeight: number;
}
export type NativeComposerHeightEvent =
| { type: "content-measured"; height: number }
| { type: "text-changed"; previousText: string; nextText: string }
| { type: "remeasure" }
| { type: "reset" };
export function clampComposerHeight(height: number, bounds: ComposerHeightBounds): number {
return Math.max(bounds.minHeight, Math.min(bounds.maxHeight, height));
}
export function reduceNativeComposerHeight(
currentHeight: number,
bounds: ComposerHeightBounds,
event: NativeComposerHeightEvent,
): number {
if (event.type === "reset") return bounds.minHeight;
if (event.type === "content-measured") {
if (!Number.isFinite(event.height) || event.height <= 0) return currentHeight;
return clampComposerHeight(event.height, bounds);
}
if (event.type === "remeasure") {
const direction = currentHeight <= bounds.minHeight ? 1 : -1;
return clampComposerHeight(currentHeight + direction, bounds);
}
if (event.nextText.length >= event.previousText.length || currentHeight <= bounds.minHeight) {
return currentHeight;
}
// Fabric can retain the old UITextView content size after deletion while the text still fits
// inside its frame. Changing that frame invalidates layout; the following content-size event
// supplies the real smaller height.
return reduceNativeComposerHeight(currentHeight, bounds, { type: "remeasure" });
}
+1
View File
@@ -0,0 +1 @@
export * from "./height.native";
@@ -0,0 +1,100 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { RefObject } from "react";
import type {
NativeSyntheticEvent,
TextInputContentSizeChangeEventData,
TextInputScrollEventData,
TextStyle,
} from "react-native";
import { reduceNativeComposerHeight, type NativeComposerHeightEvent } from "./height-state";
interface ComposerHeightArgs {
value: string;
textareaRef: RefObject<unknown>;
minHeight: number;
maxHeight: number;
onHeightChange?: (height: number) => void;
}
interface ComposerHeightResult {
style: TextStyle;
scrollEnabled: boolean;
onTextChange: (previousText: string, nextText: string) => void;
onContentSizeChange: (event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>) => void;
onScroll: (event: NativeSyntheticEvent<TextInputScrollEventData>) => void;
reset: () => void;
}
export function useComposerHeight({
value,
minHeight,
maxHeight,
onHeightChange,
}: ComposerHeightArgs): ComposerHeightResult {
const [height, setHeight] = useState(minHeight);
const heightRef = useRef(minHeight);
const didRemeasureInitialValueRef = useRef(false);
const onHeightChangeRef = useRef(onHeightChange);
onHeightChangeRef.current = onHeightChange;
const apply = useCallback(
(event: NativeComposerHeightEvent) => {
const nextHeight = reduceNativeComposerHeight(
heightRef.current,
{ minHeight, maxHeight },
event,
);
if (Math.abs(nextHeight - heightRef.current) < 1) return;
heightRef.current = nextHeight;
setHeight(nextHeight);
onHeightChangeRef.current?.(nextHeight);
},
[maxHeight, minHeight],
);
useEffect(() => {
apply({ type: "content-measured", height: heightRef.current });
}, [apply]);
useEffect(() => {
if (didRemeasureInitialValueRef.current || value.length === 0) return;
didRemeasureInitialValueRef.current = true;
apply({ type: "remeasure" });
}, [apply, value]);
const onTextChange = useCallback(
(previousText: string, nextText: string) => {
apply({ type: "text-changed", previousText, nextText });
},
[apply],
);
const onContentSizeChange = useCallback(
(event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>) => {
apply({ type: "content-measured", height: event.nativeEvent.contentSize.height });
},
[apply],
);
const onScroll = useCallback(
(event: NativeSyntheticEvent<TextInputScrollEventData>) => {
// iOS Fabric can omit the first content-size callback when a fixed-height input overflows.
// The caret-scroll event still carries the actual size and bootstraps the same state.
const nativeEvent = event.nativeEvent as TextInputScrollEventData & {
contentSize?: { width: number; height: number };
};
const contentHeight = nativeEvent.contentSize?.height;
if (contentHeight !== undefined) {
apply({ type: "content-measured", height: contentHeight });
}
},
[apply],
);
const reset = useCallback(() => apply({ type: "reset" }), [apply]);
const style = useMemo(() => ({ height, minHeight, maxHeight }), [height, maxHeight, minHeight]);
// A non-scrolling UITextView reports its frame as contentSize. Scrolling stays enabled so
// caret overflow can expose the intrinsic size through onScroll.
return { style, scrollEnabled: true, onTextChange, onContentSizeChange, onScroll, reset };
}
@@ -0,0 +1,166 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { RefObject } from "react";
import type {
NativeSyntheticEvent,
TextInputContentSizeChangeEventData,
TextInputScrollEventData,
TextStyle,
} from "react-native";
import { clampComposerHeight } from "./height-state";
interface ComposerHeightArgs {
value: string;
textareaRef: RefObject<HTMLElement | null>;
minHeight: number;
maxHeight: number;
onHeightChange?: (height: number) => void;
}
interface ComposerHeightResult {
style: TextStyle;
scrollEnabled: boolean;
onTextChange: (previousText: string, nextText: string) => void;
onContentSizeChange: (event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>) => void;
onScroll: (event: NativeSyntheticEvent<TextInputScrollEventData>) => void;
reset: () => 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 useComposerHeight({
value,
textareaRef,
minHeight,
maxHeight,
onHeightChange,
}: ComposerHeightArgs): ComposerHeightResult {
const [height, setHeight] = useState(minHeight);
const heightRef = useRef(minHeight);
const paramsRef = useRef({ value, minHeight, maxHeight, onHeightChange });
paramsRef.current = { value, minHeight, maxHeight, onHeightChange };
const mirrorRef = useRef<HTMLTextAreaElement | null>(null);
const setBoundedHeight = useCallback((nextHeight: number) => {
const {
minHeight: currentMin,
maxHeight: currentMax,
onHeightChange: onChange,
} = paramsRef.current;
const bounded = clampComposerHeight(nextHeight, {
minHeight: currentMin,
maxHeight: currentMax,
});
if (Math.abs(heightRef.current - bounded) < 1) return;
heightRef.current = bounded;
setHeight(bounded);
onChange?.(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 onContentSizeChange = useCallback(
(_event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>) => undefined,
[],
);
const onScroll = useCallback(
(_event: NativeSyntheticEvent<TextInputScrollEventData>) => undefined,
[],
);
const style = useMemo(() => ({ height, minHeight, maxHeight }), [height, maxHeight, minHeight]);
return {
style,
scrollEnabled: height >= maxHeight,
onTextChange,
onContentSizeChange,
onScroll,
reset,
};
}
+32 -66
View File
@@ -6,6 +6,7 @@ import {
NativeSyntheticEvent, NativeSyntheticEvent,
TextInputContentSizeChangeEventData, TextInputContentSizeChangeEventData,
TextInputKeyPressEventData, TextInputKeyPressEventData,
TextInputScrollEventData,
TextInputSelectionChangeEventData, TextInputSelectionChangeEventData,
} from "react-native"; } from "react-native";
import { import {
@@ -55,7 +56,7 @@ import { isWeb } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { useComposerKeyboardScope } from "@/composer/keyboard-scope"; import { useComposerKeyboardScope } from "@/composer/keyboard-scope";
import { RenderProfile } from "@/utils/render-profiler"; import { RenderProfile } from "@/utils/render-profiler";
import { useComposerHeightMirror } from "./height-mirror"; import { useComposerHeight } from "./height";
import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode"; import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode";
import type { NativePastedFile } from "@/composer/native-pasted-image"; import type { NativePastedFile } from "@/composer/native-pasted-image";
import { import {
@@ -640,6 +641,7 @@ interface ComposerTextSurfaceProps {
scrollEnabled: boolean; scrollEnabled: boolean;
autoFocus: boolean; autoFocus: boolean;
onContentSizeChange: (event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>) => void; onContentSizeChange: (event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>) => void;
onScroll: (event: NativeSyntheticEvent<TextInputScrollEventData>) => void;
onKeyPress: ((event: WebTextInputKeyPressEvent) => void) | undefined; onKeyPress: ((event: WebTextInputKeyPressEvent) => void) | undefined;
onSelectionChange: (event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => void; onSelectionChange: (event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => void;
onPasteImages: ((files: readonly NativePastedFile[]) => void) | undefined; onPasteImages: ((files: readonly NativePastedFile[]) => void) | undefined;
@@ -679,6 +681,7 @@ function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElemen
multiline multiline
scrollEnabled={props.scrollEnabled} scrollEnabled={props.scrollEnabled}
onContentSizeChange={props.onContentSizeChange} onContentSizeChange={props.onContentSizeChange}
onScroll={props.onScroll}
editable={props.editable} editable={props.editable}
onKeyPress={props.onKeyPress} onKeyPress={props.onKeyPress}
onSelectionChange={props.onSelectionChange} onSelectionChange={props.onSelectionChange}
@@ -990,20 +993,6 @@ function resolveMaxInputHeight(windowHeight: number): number {
return Math.max(DEFAULT_MAX_INPUT_HEIGHT, Math.floor(windowHeight * MAX_INPUT_VIEWPORT_RATIO)); 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 { function isTextAreaLike(v: unknown): v is TextAreaHandle {
return typeof v === "object" && v !== null && "scrollHeight" in v; return typeof v === "object" && v !== null && "scrollHeight" in v;
} }
@@ -1213,7 +1202,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const voiceMuteToggleKeys = useShortcutKeys("voice-mute-toggle"); const voiceMuteToggleKeys = useShortcutKeys("voice-mute-toggle");
const dictationToggleKeys = useShortcutKeys("dictation-toggle"); const dictationToggleKeys = useShortcutKeys("dictation-toggle");
const focusInputKeys = useShortcutKeys("focus-message-input"); const focusInputKeys = useShortcutKeys("focus-message-input");
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT);
const [isInputFocused, setIsInputFocused] = useState(false); const [isInputFocused, setIsInputFocused] = useState(false);
// Web text is DOM-owned between deferred draft publications. The action button only needs // 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. // this boundary, so publish empty/non-empty transitions without rerendering for every key.
@@ -1227,6 +1215,21 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const valueRef = useRef(value); const valueRef = useRef(value);
const selectionRef = useRef({ start: value.length, end: value.length }); const selectionRef = useRef({ start: value.length, end: value.length });
const appliedTextReplacementKeyRef = useRef(textReplacementKey); const appliedTextReplacementKeyRef = useRef(textReplacementKey);
const webTextareaRef = useRef<HTMLElement | null>(null);
const {
style: composerHeightStyle,
scrollEnabled: isComposerScrollEnabled,
onTextChange: updateComposerHeightForText,
onContentSizeChange: handleComposerContentSizeChange,
onScroll: handleComposerScroll,
reset: resetComposerHeight,
} = useComposerHeight({
value,
textareaRef: webTextareaRef,
minHeight: MIN_INPUT_HEIGHT,
maxHeight: maxInputHeight,
onHeightChange,
});
const updateLiveTextPresence = useCallback((text: string) => { const updateLiveTextPresence = useCallback((text: string) => {
const nextHasLiveText = text.trim().length > 0; const nextHasLiveText = text.trim().length > 0;
@@ -1237,13 +1240,14 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const replaceText = useCallback( const replaceText = useCallback(
(nextText: string, selection?: { start: number; end: number }) => { (nextText: string, selection?: { start: number; end: number }) => {
updateComposerHeightForText(valueRef.current, nextText);
valueRef.current = nextText; valueRef.current = nextText;
updateLiveTextPresence(nextText); updateLiveTextPresence(nextText);
selectionRef.current = selection ?? { start: nextText.length, end: nextText.length }; selectionRef.current = selection ?? { start: nextText.length, end: nextText.length };
textInputRef.current?.replaceText(nextText, selection); textInputRef.current?.replaceText(nextText, selection);
onChangeText(nextText); onChangeText(nextText);
}, },
[onChangeText, updateLiveTextPresence], [onChangeText, updateComposerHeightForText, updateLiveTextPresence],
); );
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
@@ -1273,7 +1277,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
}), }),
getNativeElement: () => (isWeb ? getTextInputNativeElement(textInputRef.current) : null), getNativeElement: () => (isWeb ? getTextInputNativeElement(textInputRef.current) : null),
})); }));
const inputHeightRef = useRef(MIN_INPUT_HEIGHT);
const sendAfterTranscriptRef = useRef(false); const sendAfterTranscriptRef = useRef(false);
const serverInfo = useSessionStore( const serverInfo = useSessionStore(
useCallback( useCallback(
@@ -1290,10 +1293,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
useEffect(() => { useEffect(() => {
if (appliedTextReplacementKeyRef.current === textReplacementKey) return; if (appliedTextReplacementKeyRef.current === textReplacementKey) return;
appliedTextReplacementKeyRef.current = textReplacementKey; appliedTextReplacementKeyRef.current = textReplacementKey;
updateComposerHeightForText(valueRef.current, value);
valueRef.current = value; valueRef.current = value;
updateLiveTextPresence(value); updateLiveTextPresence(value);
textInputRef.current?.replaceText(value); textInputRef.current?.replaceText(value);
}, [textReplacementKey, updateLiveTextPresence, value]); }, [textReplacementKey, updateComposerHeightForText, updateLiveTextPresence, value]);
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -1490,10 +1494,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
]); ]);
const minimizeInputHeight = useCallback(() => { const minimizeInputHeight = useCallback(() => {
inputHeightRef.current = MIN_INPUT_HEIGHT; resetComposerHeight();
setInputHeight(MIN_INPUT_HEIGHT); }, [resetComposerHeight]);
onHeightChange?.(MIN_INPUT_HEIGHT);
}, [onHeightChange]);
const handleSendMessage = useCallback(() => { const handleSendMessage = useCallback(() => {
const liveValue = textInputRef.current?.getText() ?? valueRef.current; const liveValue = textInputRef.current?.getText() ?? valueRef.current;
@@ -1561,8 +1563,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
[], [],
); );
const webTextareaRef = useRef<HTMLElement | null>(null);
useLayoutEffect(() => { useLayoutEffect(() => {
if (isWeb) { if (isWeb) {
webTextareaRef.current = getWebTextArea() as HTMLElement | null; webTextareaRef.current = getWebTextArea() as HTMLElement | null;
@@ -1578,37 +1578,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
onAddImages, 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( const handleSelectionChange = useCallback(
(event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => { (event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => {
const start = event.nativeEvent.selection?.start ?? 0; const start = event.nativeEvent.selection?.start ?? 0;
@@ -1695,12 +1664,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const handleInputChange = useCallback( const handleInputChange = useCallback(
(nextValue: string) => { (nextValue: string) => {
updateComposerHeightForText(valueRef.current, nextValue);
valueRef.current = nextValue; valueRef.current = nextValue;
measureComposerHeight(nextValue);
updateLiveTextPresence(nextValue); updateLiveTextPresence(nextValue);
onChangeText(nextValue); onChangeText(nextValue);
}, },
[measureComposerHeight, onChangeText, updateLiveTextPresence], [onChangeText, updateComposerHeightForText, updateLiveTextPresence],
); );
const handleInputFocus = useCallback(() => { const handleInputFocus = useCallback(() => {
@@ -1761,12 +1730,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
// `fontFamily` here is silently dropped while every other property lands. // `fontFamily` here is silently dropped while every other property lands.
// An inline style outranks both classes. See docs/unistyles.md. // An inline style outranks both classes. See docs/unistyles.md.
const textInputStyle = useMemo( const textInputStyle = useMemo(
() => [ () => [styles.textInput, mode.isMonospace && styles.textInputMonospace, composerHeightStyle],
styles.textInput, [composerHeightStyle, mode.isMonospace],
mode.isMonospace && styles.textInputMonospace,
computeTextInputHeightStyle(inputHeight, maxInputHeight),
],
[inputHeight, maxInputHeight, mode.isMonospace],
); );
// Static content has no textarea to mirror, so it grows with its own text // Static content has no textarea to mirror, so it grows with its own text
// instead of the measured input height. // instead of the measured input height.
@@ -1838,9 +1803,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
onFocus={handleInputFocus} onFocus={handleInputFocus}
onBlur={handleInputBlur} onBlur={handleInputBlur}
editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled} editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled}
scrollEnabled={isWeb ? inputHeight >= maxInputHeight : true} scrollEnabled={isComposerScrollEnabled}
autoFocus={false} autoFocus={false}
onContentSizeChange={handleContentSizeChange} onContentSizeChange={handleComposerContentSizeChange}
onScroll={handleComposerScroll}
onKeyPress={shouldHandleWebKeyPress ? handleDesktopKeyPress : undefined} onKeyPress={shouldHandleWebKeyPress ? handleDesktopKeyPress : undefined}
onSelectionChange={handleSelectionChange} onSelectionChange={handleSelectionChange}
onPasteImages={onPasteImages} onPasteImages={onPasteImages}
+6
View File
@@ -12,6 +12,12 @@ export function resolveComposerPillClearance(isCompact: boolean): number {
} }
export function resolveComposerTrackTailClearance(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); const clearance = resolveComposerPillClearance(isCompact);
return clearance + COMPOSER_PILL_MIN_HEIGHT + clearance; return clearance + COMPOSER_PILL_MIN_HEIGHT + clearance;
} }
-131
View File
@@ -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) // 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]); useEffect(() => startPushNotifications({ client, serverId }), [client, serverId]);
const notifyAgentAttention = useCallback( const notifyAgentAttention = useCallback(
@@ -262,6 +262,7 @@ function PairingOffer(props: PairDeviceBodyProps & { offer: { url: string } }) {
{props.copied ? t("pairing.device.copied") : t("pairing.device.copy")} {props.copied ? t("pairing.device.copied") : t("pairing.device.copy")}
</Button> </Button>
</View> </View>
<Alert variant="warning" description={t("pairing.device.securityWarning")} />
</View> </View>
); );
} }
+8 -1
View File
@@ -1,6 +1,8 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { useEffect, useMemo, useState, useSyncExternalStore } from "react"; import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { LiveFileModel, type LiveFileSession } from "./model"; import { LiveFileModel, type LiveFileSession } from "./model";
import { SOURCE_PRESENTATION_BUDGETS } from "../source/presentation";
import { isWeb } from "@/constants/platform";
export function useLiveFile(input: { export function useLiveFile(input: {
client: DaemonClient | null; client: DaemonClient | null;
@@ -18,7 +20,12 @@ export function useLiveFile(input: {
return client.subscribeFile(target, onVersion); return client.subscribeFile(target, onVersion);
}, },
read(target) { 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]); }, [input.client]);
+60 -179
View File
@@ -14,11 +14,6 @@ import { StyleSheet, UnistylesRuntime, withUnistyles } from "react-native-unisty
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { useSessionStore, type ExplorerFile } from "@/stores/session-store"; 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 { filePreviewRenderKind } from "@/components/file-pane-render-mode";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url"; import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { getFileNameFromPath } from "@/attachments/utils"; import { getFileNameFromPath } from "@/attachments/utils";
@@ -38,6 +33,7 @@ import { FileMarkdownPreview } from "./markdown-preview";
import { FileEditorModel, getFileConflictCallout, type FileConflictCallout } from "./editor/model"; import { FileEditorModel, getFileConflictCallout, type FileConflictCallout } from "./editor/model";
import { createFileObservationSource } from "./editor/observation-source"; import { createFileObservationSource } from "./editor/observation-source";
import { FileEditorView } from "./editor/view"; import { FileEditorView } from "./editor/view";
import { FileSourceView } from "./source/view";
import type { FileConflictAlertState } from "./conflict-alert"; import type { FileConflictAlertState } from "./conflict-alert";
import type { LiveFileModel } from "./live-file/model"; import type { LiveFileModel } from "./live-file/model";
import { confirmDialog } from "@/utils/confirm-dialog"; import { confirmDialog } from "@/utils/confirm-dialog";
@@ -49,13 +45,6 @@ const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
}); });
interface CodeLineProps {
tokens: HighlightToken[];
lineNumber: number;
gutterWidth: number;
highlighted: boolean;
}
interface FilePreviewBodyProps { interface FilePreviewBodyProps {
preview: ExplorerFile | null; preview: ExplorerFile | null;
mode?: "preview" | "source"; mode?: "preview" | "source";
@@ -76,11 +65,6 @@ function trimNonEmpty(value: string | null | undefined): string | null {
return trimmed.length > 0 ? trimmed : null; return trimmed.length > 0 ? trimmed : null;
} }
interface FileLineSelection {
lineStart: number;
lineEnd: number;
}
function formatFileSize({ size }: { size: number }): string { function formatFileSize({ size }: { size: number }): string {
if (size < 1024) { if (size < 1024) {
return `${size} B`; return `${size} B`;
@@ -91,101 +75,66 @@ function formatFileSize({ size }: { size: number }): string {
return `${(size / (1024 * 1024)).toFixed(1)} MB`; return `${(size / (1024 * 1024)).toFixed(1)} MB`;
} }
function clampLineSelection(input: { function ReadonlySource({
lineStart?: number; preview,
lineEnd?: number; filename,
lineCount: number; location,
}): FileLineSelection | null { navigationRevision,
if (!input.lineStart || input.lineStart <= 0 || input.lineCount <= 0) { }: {
return null; preview: ExplorerFile;
} filename: string;
const lineStart = Math.min(Math.floor(input.lineStart), input.lineCount); location: WorkspaceFileLocation;
const rawLineEnd = navigationRevision: number;
input.lineEnd && input.lineEnd >= input.lineStart ? input.lineEnd : input.lineStart; }) {
const lineEnd = Math.min(Math.floor(rawLineEnd), input.lineCount); const theme = UnistylesRuntime.getTheme();
return { lineStart, lineEnd: Math.max(lineStart, lineEnd) }; const { t } = useTranslation();
} const visualTheme = useMemo(
() => ({
const CodeLine = React.memo(function CodeLine({ colorScheme: theme.colorScheme,
tokens, background: theme.colors.surface0,
lineNumber, foreground: theme.colors.foreground,
gutterWidth, cursor: theme.colors.terminal.cursor,
highlighted, foregroundMuted: theme.colors.foregroundMuted,
}: CodeLineProps) { border: theme.colors.border,
const gutterStyle = useMemo( selection: theme.colors.terminal.selectionBackground,
() => [codeLineStyles.gutter, inlineUnistylesStyle({ width: gutterWidth })], monoFont: theme.fontFamily.mono,
[gutterWidth], codeFontSize: theme.fontSize.code,
); syntax: theme.colors.syntax,
const lineStyle = useMemo( }),
() => [codeLineStyles.line, highlighted && codeLineStyles.highlightedLine], [theme],
[highlighted],
);
const keyedTokens = useMemo(
() => tokens.map((token, index) => ({ key: `${index}-${token.text}`, token })),
[tokens],
); );
return ( return (
<View style={lineStyle}> <FileSourceView
<View style={gutterStyle}> content={preview.content ?? ""}
<Text numberOfLines={1} style={codeLineStyles.gutterText}> filename={filename}
{String(lineNumber)} location={location}
</Text> navigationRevision={navigationRevision}
</View> size={preview.size}
<Text selectable style={codeLineStyles.lineText}> theme={visualTheme}
{keyedTokens.map(({ key, token }) => ( tooLargeMessage={t("panels.file.tooLargeToDisplay")}
<CodeLineToken key={key} token={token} /> />
))} );
</Text> }
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> </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({ function FilePreviewBody({
preview, preview,
mode, mode,
isLoading, isLoading,
isMobile, isMobile: _isMobile,
location, location,
navigationRevision, navigationRevision,
imagePreviewUri, imagePreviewUri,
}: FilePreviewBodyProps) { }: FilePreviewBodyProps) {
const theme = UnistylesRuntime.getTheme();
const { t } = useTranslation(); const { t } = useTranslation();
const filePath = location.path; const filePath = location.path;
// A line target means the caller wants to land on that line, so fall back to // 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 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( const imageSource = useMemo(
() => (imagePreviewUri ? { uri: imagePreviewUri } : null), () => (imagePreviewUri ? { uri: imagePreviewUri } : null),
[imagePreviewUri], [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) { if (isLoading && !preview) {
return ( return (
<View style={styles.centerState} testID="file-preview-loading"> <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 ( return (
<View style={styles.previewScrollContainer}> <ReadonlySource
<RNScrollView preview={preview}
ref={previewScrollRef} filename={filePath}
style={styles.previewContent} location={location}
showsVerticalScrollIndicator navigationRevision={navigationRevision}
> />
{isMobile ? (
<View style={styles.previewCodeScrollContent}>{codeLines}</View>
) : (
<RNScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
contentContainerStyle={styles.previewCodeScrollContent}
>
{codeLines}
</RNScrollView>
)}
</RNScrollView>
</View>
); );
} }
@@ -557,6 +431,13 @@ function FilePanePresentation({
} }
if (errorMessage) { if (errorMessage) {
if (errorMessage === "File is too large to display") {
return (
<View style={styles.container} testID="workspace-file-pane">
<TooLargeSource />
</View>
);
}
return ( return (
<View style={styles.container} testID="workspace-file-pane"> <View style={styles.container} testID="workspace-file-pane">
<View style={styles.centerState}> <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;
+74 -2
View File
@@ -2,7 +2,7 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { View, Text, Pressable, type PressableStateCallbackType } from "react-native"; import { View, Text, Pressable, type PressableStateCallbackType } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; 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 { useTranslation } from "react-i18next";
import { import {
DropdownMenu, DropdownMenu,
@@ -22,6 +22,7 @@ import { buttonControlHeight } from "@/components/ui/control-geometry";
interface GitActionsSplitButtonProps { interface GitActionsSplitButtonProps {
gitActions: GitActions; gitActions: GitActions;
hideLabels?: boolean; hideLabels?: boolean;
menuOnly?: boolean;
} }
interface GitActionMenuItemProps { 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 { theme } = useUnistyles();
const { t } = useTranslation(); const { t } = useTranslation();
const runGitAction = useGitActionRunner(); const runGitAction = useGitActionRunner();
@@ -115,6 +120,62 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli
[theme.colors.surface2], [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 ( return (
<View style={styles.row}> <View style={styles.row}>
{gitActions.primary ? ( {gitActions.primary ? (
@@ -224,6 +285,17 @@ const styles = StyleSheet.create((theme) => ({
justifyContent: "center", justifyContent: "center",
position: "relative", 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: { splitButtonPrimaryDisabled: {
opacity: 0.6, opacity: 0.6,
}, },
@@ -495,7 +495,14 @@ export function DiffSurface(props: DiffSurfaceProps) {
schedulePaint(); schedulePaint();
return; 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; selectionRef.current = null;
reviewActions.onStartComment(hit.target); reviewActions.onStartComment(hit.target);
schedulePaint(); schedulePaint();
+1 -1
View File
@@ -415,7 +415,7 @@ function ChangesToolbar(props: ChangesToolbarProps) {
onToggle={onToggleDesktopTree} onToggle={onToggleDesktopTree}
/> />
) : null} ) : null}
{isMobile ? <GitActionsSplitButton gitActions={gitActions} /> : null} {isMobile ? <GitActionsSplitButton gitActions={gitActions} menuOnly /> : null}
<ChangesOptionsMenu {...props} /> <ChangesOptionsMenu {...props} />
</View> </View>
</PaneContentToolbar> </PaneContentToolbar>
@@ -68,12 +68,26 @@ function buildTracker(
initialFocusedTerminalId: overrides.initialFocusedTerminalId ?? null, initialFocusedTerminalId: overrides.initialFocusedTerminalId ?? null,
initialAppVisible: overrides.initialAppVisible ?? true, initialAppVisible: overrides.initialAppVisible ?? true,
now: clock.now, now: clock.now,
onUserActivity: overrides.onUserActivity ?? (() => undefined),
onAppResumed: overrides.onAppResumed, onAppResumed: overrides.onAppResumed,
}); });
return { tracker, client, clock }; return { tracker, client, clock };
} }
describe("client activity tracker", () => { 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", () => { it("includes the latest user-activity time in the next heartbeat", () => {
const { tracker, client, clock } = buildTracker(); const { tracker, client, clock } = buildTracker();
@@ -23,6 +23,7 @@ export interface ClientActivityTrackerInput {
initialFocusedTerminalId: string | null; initialFocusedTerminalId: string | null;
initialAppVisible: boolean; initialAppVisible: boolean;
now: () => number; now: () => number;
onUserActivity: () => void;
onAppResumed?: (awayMs: number) => void; onAppResumed?: (awayMs: number) => void;
} }
@@ -39,7 +40,7 @@ export interface ClientActivityTracker {
export function createClientActivityTracker( export function createClientActivityTracker(
input: ClientActivityTrackerInput, input: ClientActivityTrackerInput,
): ClientActivityTracker { ): ClientActivityTracker {
const { client, deviceType, now, onAppResumed } = input; const { client, deviceType, now, onUserActivity, onAppResumed } = input;
let lastActivityAtMs = now(); let lastActivityAtMs = now();
let appVisible = input.initialAppVisible; let appVisible = input.initialAppVisible;
let appVisibilityChangedAtMs = now(); let appVisibilityChangedAtMs = now();
@@ -62,6 +63,7 @@ export function createClientActivityTracker(
function recordUserActivity(): void { function recordUserActivity(): void {
lastActivityAtMs = now(); lastActivityAtMs = now();
onUserActivity();
} }
function maybeSendImmediateHeartbeat(): void { function maybeSendImmediateHeartbeat(): void {
@@ -15,6 +15,7 @@ interface ClientActivityOptions {
client: DaemonClient; client: DaemonClient;
focusedAgentId: string | null; focusedAgentId: string | null;
focusedTerminalId: string | null; focusedTerminalId: string | null;
onUserActivity: () => void;
onAppResumed?: (awayMs: number) => void; onAppResumed?: (awayMs: number) => void;
} }
@@ -28,6 +29,7 @@ export function useClientActivity({
client, client,
focusedAgentId, focusedAgentId,
focusedTerminalId, focusedTerminalId,
onUserActivity,
onAppResumed, onAppResumed,
}: ClientActivityOptions): void { }: ClientActivityOptions): void {
const onAppResumedRef = useRef(onAppResumed); const onAppResumedRef = useRef(onAppResumed);
@@ -42,6 +44,7 @@ export function useClientActivity({
initialFocusedTerminalId: focusedTerminalId, initialFocusedTerminalId: focusedTerminalId,
initialAppVisible: AppState.currentState === "active", initialAppVisible: AppState.currentState === "active",
now: () => Date.now(), now: () => Date.now(),
onUserActivity,
onAppResumed: (awayMs) => onAppResumedRef.current?.(awayMs), onAppResumed: (awayMs) => onAppResumedRef.current?.(awayMs),
}); });
} }
@@ -30,7 +30,7 @@ function project(projectKey: string, workspaces: SidebarWorkspacePlacement[]): S
} }
describe("splitPinnedSidebarGroups", () => { 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 only = placement("w1");
const projects = [project("p1", [only])]; const projects = [project("p1", [only])];
const result = splitPinnedSidebarGroups({ const result = splitPinnedSidebarGroups({
@@ -42,7 +42,7 @@ describe("splitPinnedSidebarGroups", () => {
pinnedWorkspaceOrder: [], pinnedWorkspaceOrder: [],
}); });
expect(result.pinnedChats).toHaveLength(1); 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", () => { it("keeps a genuinely empty project so its new-workspace row stays reachable", () => {
@@ -121,12 +121,6 @@ export function splitPinnedSidebarGroups(input: {
remainingWorkspaces.push(workspace); 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( unpinnedProjects.push(
remainingWorkspaces.length === project.workspaces.length remainingWorkspaces.length === project.workspaces.length
? project ? project
+10 -3
View File
@@ -64,6 +64,9 @@ export const ar: TranslationResources = {
workspaces: "مساحات العمل", workspaces: "مساحات العمل",
agents: "الوكلاء", agents: "الوكلاء",
newAgent: "وكيل جديد", newAgent: "وكيل جديد",
open: "فتح {{name}}",
openInSidePanel: "فتح {{name}} في اللوحة الجانبية",
openInFocusedPane: "فتح {{name}} في الجزء النشط",
addProject: "إضافة مشروع", addProject: "إضافة مشروع",
home: "بيت", home: "بيت",
groupByProject: "تجميع حسب المشروع", groupByProject: "تجميع حسب المشروع",
@@ -602,7 +605,7 @@ export const ar: TranslationResources = {
files: "الملفات", files: "الملفات",
pullRequest: "طلب السحب", pullRequest: "طلب السحب",
terminalProfilesMenu: "Terminal profiles", terminalProfilesMenu: "Terminal profiles",
editTerminalProfiles: "Edit profiles…", editTerminalProfiles: "Edit profiles",
}, },
sidePanel: { sidePanel: {
open: "افتح اللوحة الجانبية", open: "افتح اللوحة الجانبية",
@@ -1627,6 +1630,8 @@ export const ar: TranslationResources = {
updateRequired: "حدّث المضيف لتمكين التتابع من Paseo Desktop.", updateRequired: "حدّث المضيف لتمكين التتابع من Paseo Desktop.",
unavailable: "عرض الاقتران غير متاح.", unavailable: "عرض الاقتران غير متاح.",
hint: "قم بمسح رمز QR هذا باستخدام Paseo على هاتفك، أو انسخ الرابط أدناه.", hint: "قم بمسح رمز QR هذا باستخدام Paseo على هاتفك، أو انسخ الرابط أدناه.",
securityWarning:
"تعامل مع رابط الاقتران هذا ككلمة مرور. يمكن لأي شخص يملكه الوصول إلى هذا البرنامج الخفي.",
qrUnavailable: "رمز QR غير متاح.", qrUnavailable: "رمز QR غير متاح.",
qrAccessibility: "رمز QR للإقران", qrAccessibility: "رمز QR للإقران",
retry: "أعد المحاولة", retry: "أعد المحاولة",
@@ -1700,6 +1705,7 @@ export const ar: TranslationResources = {
loading: "جارٍ تحميل الملف...", loading: "جارٍ تحميل الملف...",
noPreview: "لا تتوفر معاينة", noPreview: "لا تتوفر معاينة",
binaryPreviewUnavailable: "المعاينة الثنائية غير متاحة", binaryPreviewUnavailable: "المعاينة الثنائية غير متاحة",
tooLargeToDisplay: "هذا الملف كبير جدًا بحيث لا يمكن عرضه",
failedToLoad: "فشل تحميل الملف", failedToLoad: "فشل تحميل الملف",
failedToLoadPreview: "فشل تحميل معاينة الملف", failedToLoadPreview: "فشل تحميل معاينة الملف",
editor: { editor: {
@@ -1728,6 +1734,7 @@ export const ar: TranslationResources = {
label: "الملفات", label: "الملفات",
subtitle: "ملفات مساحة العمل", subtitle: "ملفات مساحة العمل",
tooltip: "تصفح ملفات مساحة العمل", tooltip: "تصفح ملفات مساحة العمل",
chooseFile: "اختر ملفًا",
}, },
pullRequest: { pullRequest: {
label: "طلب السحب", label: "طلب السحب",
@@ -2342,9 +2349,9 @@ export const ar: TranslationResources = {
featuresLabel: "السمات", featuresLabel: "السمات",
featureCount: "{{count}} سمة", featureCount: "{{count}} سمة",
featureCountOne: "{{count}} سمة", featureCountOne: "{{count}} سمة",
notesLabel: "ملاحظات للوكلاء", notesLabel: "متى يُستخدم",
notesPlaceholder: "استخدمه لعمل الواجهة — المكونات والتخطيط ورموز التصميم.", notesPlaceholder: "استخدمه لعمل الواجهة — المكونات والتخطيط ورموز التصميم.",
notesHint: 'تُعاد بواسطة أداة MCP "list_profiles". اكتبها كتعليمة لوكيل آخر.', notesHint: "يساعد الوكلاء على اختيار ملف التعريف هذا عند بدء وكيل آخر.",
save: "حفظ", save: "حفظ",
saving: "جارٍ الحفظ...", saving: "جارٍ الحفظ...",
remove: "إزالة", remove: "إزالة",
+10 -4
View File
@@ -61,6 +61,9 @@ export const en = {
workspaces: "Workspaces", workspaces: "Workspaces",
agents: "Agents", agents: "Agents",
newAgent: "New agent", newAgent: "New agent",
open: "Open {{name}}",
openInSidePanel: "Open {{name}} in Side panel",
openInFocusedPane: "Open {{name}} in focused pane",
addProject: "Add project", addProject: "Add project",
home: "Home", home: "Home",
groupByProject: "Group by project", groupByProject: "Group by project",
@@ -600,7 +603,7 @@ export const en = {
files: "Files", files: "Files",
pullRequest: "Pull request", pullRequest: "Pull request",
terminalProfilesMenu: "Terminal profiles", terminalProfilesMenu: "Terminal profiles",
editTerminalProfiles: "Edit profiles…", editTerminalProfiles: "Edit profiles",
}, },
sidePanel: { sidePanel: {
open: "Open side panel", open: "Open side panel",
@@ -1637,6 +1640,8 @@ export const en = {
updateRequired: "Update the host to enable relay from Paseo Desktop.", updateRequired: "Update the host to enable relay from Paseo Desktop.",
unavailable: "Pairing offer unavailable.", unavailable: "Pairing offer unavailable.",
hint: "Scan this QR code with Paseo on your phone, or copy the link below.", 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.", qrUnavailable: "QR code unavailable.",
qrAccessibility: "Pairing QR code", qrAccessibility: "Pairing QR code",
retry: "Retry", retry: "Retry",
@@ -1710,6 +1715,7 @@ export const en = {
loading: "Loading file...", loading: "Loading file...",
noPreview: "No preview available", noPreview: "No preview available",
binaryPreviewUnavailable: "Binary preview unavailable", binaryPreviewUnavailable: "Binary preview unavailable",
tooLargeToDisplay: "This file is too large to display",
failedToLoad: "Failed to load file", failedToLoad: "Failed to load file",
failedToLoadPreview: "Failed to load file preview", failedToLoadPreview: "Failed to load file preview",
editor: { editor: {
@@ -1738,6 +1744,7 @@ export const en = {
label: "Files", label: "Files",
subtitle: "Workspace files", subtitle: "Workspace files",
tooltip: "Browse workspace files", tooltip: "Browse workspace files",
chooseFile: "Choose a file",
}, },
pullRequest: { pullRequest: {
label: "Pull request", label: "Pull request",
@@ -2409,10 +2416,9 @@ export const en = {
featuresLabel: "Features", featuresLabel: "Features",
featureCount: "{{count}} features", featureCount: "{{count}} features",
featureCountOne: "{{count}} feature", featureCountOne: "{{count}} feature",
notesLabel: "Notes for agents", notesLabel: "When to use",
notesPlaceholder: "Use for UI work — components, layout and design tokens.", notesPlaceholder: "Use for UI work — components, layout and design tokens.",
notesHint: notesHint: "Helps agents choose this profile when starting another agent.",
"Returned by the list_profiles MCP tool. Write it as an instruction to another agent.",
save: "Save", save: "Save",
saving: "Saving...", saving: "Saving...",
remove: "Remove", remove: "Remove",
+10 -4
View File
@@ -64,6 +64,9 @@ export const es: TranslationResources = {
workspaces: "Espacios de trabajo", workspaces: "Espacios de trabajo",
agents: "Agentes", agents: "Agentes",
newAgent: "Nuevo agente", newAgent: "Nuevo agente",
open: "Abrir {{name}}",
openInSidePanel: "Abrir {{name}} en el panel lateral",
openInFocusedPane: "Abrir {{name}} en el panel enfocado",
addProject: "Agregar proyecto", addProject: "Agregar proyecto",
home: "Hogar", home: "Hogar",
groupByProject: "Agrupar por proyecto", groupByProject: "Agrupar por proyecto",
@@ -607,7 +610,7 @@ export const es: TranslationResources = {
files: "Archivos", files: "Archivos",
pullRequest: "Solicitud de extracción", pullRequest: "Solicitud de extracción",
terminalProfilesMenu: "Terminal profiles", terminalProfilesMenu: "Terminal profiles",
editTerminalProfiles: "Edit profiles…", editTerminalProfiles: "Edit profiles",
}, },
sidePanel: { sidePanel: {
open: "Abrir panel lateral", open: "Abrir panel lateral",
@@ -1673,6 +1676,8 @@ export const es: TranslationResources = {
updateRequired: "Actualice el host para habilitar el relé desde Paseo Desktop.", updateRequired: "Actualice el host para habilitar el relé desde Paseo Desktop.",
unavailable: "Oferta de maridaje no disponible.", unavailable: "Oferta de maridaje no disponible.",
hint: "Escanee este códigoQRconPaseoen su teléfono o copie el enlace a continuación.", 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.", qrUnavailable: "CódigoQRno disponible.",
qrAccessibility: "Código QR de emparejamiento", qrAccessibility: "Código QR de emparejamiento",
retry: "Rever", retry: "Rever",
@@ -1746,6 +1751,7 @@ export const es: TranslationResources = {
loading: "Cargando archivo...", loading: "Cargando archivo...",
noPreview: "No hay vista previa disponible", noPreview: "No hay vista previa disponible",
binaryPreviewUnavailable: "Vista previa binaria no disponible", binaryPreviewUnavailable: "Vista previa binaria no disponible",
tooLargeToDisplay: "Este archivo es demasiado grande para mostrarlo",
failedToLoad: "No se pudo cargar el archivo", failedToLoad: "No se pudo cargar el archivo",
failedToLoadPreview: "No se pudo cargar la vista previa del archivo", failedToLoadPreview: "No se pudo cargar la vista previa del archivo",
editor: { editor: {
@@ -1774,6 +1780,7 @@ export const es: TranslationResources = {
label: "Archivos", label: "Archivos",
subtitle: "Archivos del espacio de trabajo", subtitle: "Archivos del espacio de trabajo",
tooltip: "Explorar archivos del espacio de trabajo", tooltip: "Explorar archivos del espacio de trabajo",
chooseFile: "Elige un archivo",
}, },
pullRequest: { pullRequest: {
label: "Solicitud de extracción", label: "Solicitud de extracción",
@@ -2396,10 +2403,9 @@ export const es: TranslationResources = {
featuresLabel: "Características", featuresLabel: "Características",
featureCount: "{{count}} características", featureCount: "{{count}} características",
featureCountOne: "{{count}} característica", 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.", notesPlaceholder: "Úselo para trabajo de UI: componentes, diseño y tokens de diseño.",
notesHint: notesHint: "Ayuda a los agentes a elegir este perfil al iniciar otro agente.",
'Devuelto por la herramienta MCP "list_profiles". Escríbalo como una instrucción para otro agente.',
save: "Guardar", save: "Guardar",
saving: "Guardando...", saving: "Guardando...",
remove: "Eliminar", remove: "Eliminar",
+10 -4
View File
@@ -65,6 +65,9 @@ export const fr: TranslationResources = {
workspaces: "Espaces de travail", workspaces: "Espaces de travail",
agents: "Agents", agents: "Agents",
newAgent: "Nouvel agent", 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", addProject: "Ajouter un projet",
home: "Maison", home: "Maison",
groupByProject: "Grouper par projet", groupByProject: "Grouper par projet",
@@ -607,7 +610,7 @@ export const fr: TranslationResources = {
files: "Fichiers", files: "Fichiers",
pullRequest: "Demande de fusion", pullRequest: "Demande de fusion",
terminalProfilesMenu: "Terminal profiles", terminalProfilesMenu: "Terminal profiles",
editTerminalProfiles: "Edit profiles…", editTerminalProfiles: "Edit profiles",
}, },
sidePanel: { sidePanel: {
open: "Ouvrir le panneau latéral", 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.", updateRequired: "Mettez à jour l’hôte pour activer le relais depuis Paseo Desktop.",
unavailable: "Offre de jumelage indisponible.", unavailable: "Offre de jumelage indisponible.",
hint: "Scannez ce codeQRavecPaseosur votre téléphone ou copiez le lien ci-dessous.", 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.", qrUnavailable: "CodeQRindisponible.",
qrAccessibility: "Code QR de couplage", qrAccessibility: "Code QR de couplage",
retry: "Réessayer", retry: "Réessayer",
@@ -1750,6 +1755,7 @@ export const fr: TranslationResources = {
loading: "Chargement du fichier...", loading: "Chargement du fichier...",
noPreview: "Aucun aperçu disponible", noPreview: "Aucun aperçu disponible",
binaryPreviewUnavailable: "Aperçu binaire indisponible", binaryPreviewUnavailable: "Aperçu binaire indisponible",
tooLargeToDisplay: "Ce fichier est trop volumineux pour être affiché",
failedToLoad: "Échec du chargement du fichier", failedToLoad: "Échec du chargement du fichier",
failedToLoadPreview: "Échec du chargement de l'aperçu du fichier", failedToLoadPreview: "Échec du chargement de l'aperçu du fichier",
editor: { editor: {
@@ -1778,6 +1784,7 @@ export const fr: TranslationResources = {
label: "Fichiers", label: "Fichiers",
subtitle: "Fichiers de l’espace de travail", subtitle: "Fichiers de l’espace de travail",
tooltip: "Parcourir les fichiers de l’espace de travail", tooltip: "Parcourir les fichiers de l’espace de travail",
chooseFile: "Choisissez un fichier",
}, },
pullRequest: { pullRequest: {
label: "Demande de fusion", label: "Demande de fusion",
@@ -2403,11 +2410,10 @@ export const fr: TranslationResources = {
featuresLabel: "Caractéristiques", featuresLabel: "Caractéristiques",
featureCount: "{{count}} caractéristiques", featureCount: "{{count}} caractéristiques",
featureCountOne: "{{count}} caractéristique", featureCountOne: "{{count}} caractéristique",
notesLabel: "Notes pour les agents", notesLabel: "Quand l’utiliser",
notesPlaceholder: notesPlaceholder:
"À utiliser pour le travail sur l'UI — composants, mise en page et tokens de design.", "À utiliser pour le travail sur l'UI — composants, mise en page et tokens de design.",
notesHint: notesHint: "Aide les agents à choisir ce profil lorsqu’ils démarrent un autre agent.",
"Renvoyé par l'outil MCP « list_profiles ». Rédigez-le comme une instruction destinée à un autre agent.",
save: "Enregistrer", save: "Enregistrer",
saving: "Enregistrement...", saving: "Enregistrement...",
remove: "Supprimer", remove: "Supprimer",
+10 -4
View File
@@ -64,6 +64,9 @@ export const ja: TranslationResources = {
workspaces: "ワークスペース", workspaces: "ワークスペース",
agents: "エージェント", agents: "エージェント",
newAgent: "新しいエージェント", newAgent: "新しいエージェント",
open: "{{name}}を開く",
openInSidePanel: "{{name}}をサイドパネルで開く",
openInFocusedPane: "{{name}}をフォーカス中のペインで開く",
addProject: "プロジェクトを追加", addProject: "プロジェクトを追加",
home: "ホーム", home: "ホーム",
groupByProject: "プロジェクトでグループ化", groupByProject: "プロジェクトでグループ化",
@@ -607,7 +610,7 @@ export const ja: TranslationResources = {
files: "ファイル", files: "ファイル",
pullRequest: "プルリクエスト", pullRequest: "プルリクエスト",
terminalProfilesMenu: "ターミナルプロファイル", terminalProfilesMenu: "ターミナルプロファイル",
editTerminalProfiles: "プロファイルを編集…", editTerminalProfiles: "プロファイルを編集",
}, },
sidePanel: { sidePanel: {
open: "サイドパネルを開く", open: "サイドパネルを開く",
@@ -1644,6 +1647,8 @@ export const ja: TranslationResources = {
updateRequired: "Paseo Desktop からリレーを有効にするにはホストを更新してください。", updateRequired: "Paseo Desktop からリレーを有効にするにはホストを更新してください。",
unavailable: "ペアリングオファーが利用できません。", unavailable: "ペアリングオファーが利用できません。",
hint: "スマートフォンのPaseoでこのQRコードをスキャンするか、以下のリンクをコピーしてください。", hint: "スマートフォンのPaseoでこのQRコードをスキャンするか、以下のリンクをコピーしてください。",
securityWarning:
"このペアリングリンクはパスワードと同様に扱ってください。リンクを知っている人は誰でもこのデーモンにアクセスできます。",
qrUnavailable: "QRコードが利用できません。", qrUnavailable: "QRコードが利用できません。",
qrAccessibility: "ペアリング QR コード", qrAccessibility: "ペアリング QR コード",
retry: "再試行", retry: "再試行",
@@ -1717,6 +1722,7 @@ export const ja: TranslationResources = {
loading: "ファイルを読み込み中...", loading: "ファイルを読み込み中...",
noPreview: "プレビューが利用できません", noPreview: "プレビューが利用できません",
binaryPreviewUnavailable: "バイナリプレビューが利用できません", binaryPreviewUnavailable: "バイナリプレビューが利用できません",
tooLargeToDisplay: "このファイルは大きすぎて表示できません",
failedToLoad: "ファイルの読み込みに失敗しました", failedToLoad: "ファイルの読み込みに失敗しました",
failedToLoadPreview: "ファイルプレビューの読み込みに失敗しました", failedToLoadPreview: "ファイルプレビューの読み込みに失敗しました",
editor: { editor: {
@@ -1745,6 +1751,7 @@ export const ja: TranslationResources = {
label: "ファイル", label: "ファイル",
subtitle: "ワークスペースのファイル", subtitle: "ワークスペースのファイル",
tooltip: "ワークスペースのファイルを参照", tooltip: "ワークスペースのファイルを参照",
chooseFile: "ファイルを選択",
}, },
pullRequest: { pullRequest: {
label: "プルリクエスト", label: "プルリクエスト",
@@ -2365,10 +2372,9 @@ export const ja: TranslationResources = {
featuresLabel: "機能", featuresLabel: "機能",
featureCount: "{{count}}個の機能", featureCount: "{{count}}個の機能",
featureCountOne: "{{count}}個の機能", featureCountOne: "{{count}}個の機能",
notesLabel: "エージェントへのメモ", notesLabel: "使用する場面",
notesPlaceholder: "UI作業に使用 — コンポーネント、レイアウト、デザイントークンなど。", notesPlaceholder: "UI作業に使用 — コンポーネント、レイアウト、デザイントークンなど。",
notesHint: notesHint: "別のエージェントを起動するとき、このプロファイルを選ぶために使用されます。",
"list_profiles MCPツールによって返されます。他のエージェントへの指示として記述してください。",
save: "保存", save: "保存",
saving: "保存中...", saving: "保存中...",
remove: "削除", remove: "削除",
+10 -4
View File
@@ -64,6 +64,9 @@ export const ko: TranslationResources = {
workspaces: "워크스페이스", workspaces: "워크스페이스",
agents: "에이전트", agents: "에이전트",
newAgent: "새 에이전트", newAgent: "새 에이전트",
open: "{{name}} 열기",
openInSidePanel: "사이드 패널에서 {{name}} 열기",
openInFocusedPane: "포커스된 창에서 {{name}} 열기",
addProject: "프로젝트 추가", addProject: "프로젝트 추가",
home: "홈", home: "홈",
groupByProject: "프로젝트별 그룹화", groupByProject: "프로젝트별 그룹화",
@@ -604,7 +607,7 @@ export const ko: TranslationResources = {
files: "파일", files: "파일",
pullRequest: "풀 리퀘스트", pullRequest: "풀 리퀘스트",
terminalProfilesMenu: "터미널 프로필", terminalProfilesMenu: "터미널 프로필",
editTerminalProfiles: "프로필 편집…", editTerminalProfiles: "프로필 편집",
}, },
sidePanel: { sidePanel: {
open: "사이드 패널 열기", open: "사이드 패널 열기",
@@ -1637,6 +1640,8 @@ export const ko: TranslationResources = {
updateRequired: "Paseo 데스크톱에서 릴레이를 활성화하려면 호스트를 업데이트하세요.", updateRequired: "Paseo 데스크톱에서 릴레이를 활성화하려면 호스트를 업데이트하세요.",
unavailable: "페어링 정보를 사용할 수 없습니다.", unavailable: "페어링 정보를 사용할 수 없습니다.",
hint: "휴대폰의 Paseo로 이 QR 코드를 스캔하거나 아래 링크를 복사하세요.", hint: "휴대폰의 Paseo로 이 QR 코드를 스캔하거나 아래 링크를 복사하세요.",
securityWarning:
"이 페어링 링크는 비밀번호처럼 취급하세요. 링크를 가진 사람은 누구나 이 데몬에 접근할 수 있습니다.",
qrUnavailable: "QR 코드를 사용할 수 없습니다.", qrUnavailable: "QR 코드를 사용할 수 없습니다.",
qrAccessibility: "페어링 QR 코드", qrAccessibility: "페어링 QR 코드",
retry: "다시 시도", retry: "다시 시도",
@@ -1710,6 +1715,7 @@ export const ko: TranslationResources = {
loading: "파일 불러오는 중...", loading: "파일 불러오는 중...",
noPreview: "사용 가능한 미리보기가 없습니다", noPreview: "사용 가능한 미리보기가 없습니다",
binaryPreviewUnavailable: "바이너리 미리보기를 사용할 수 없습니다", binaryPreviewUnavailable: "바이너리 미리보기를 사용할 수 없습니다",
tooLargeToDisplay: "이 파일은 너무 커서 표시할 수 없습니다",
failedToLoad: "파일을 불러오지 못했습니다", failedToLoad: "파일을 불러오지 못했습니다",
failedToLoadPreview: "파일 미리보기를 불러오지 못했습니다", failedToLoadPreview: "파일 미리보기를 불러오지 못했습니다",
editor: { editor: {
@@ -1738,6 +1744,7 @@ export const ko: TranslationResources = {
label: "파일", label: "파일",
subtitle: "워크스페이스 파일", subtitle: "워크스페이스 파일",
tooltip: "워크스페이스 파일 탐색", tooltip: "워크스페이스 파일 탐색",
chooseFile: "파일 선택",
}, },
pullRequest: { pullRequest: {
label: "풀 리퀘스트", label: "풀 리퀘스트",
@@ -2354,10 +2361,9 @@ export const ko: TranslationResources = {
featuresLabel: "기능", featuresLabel: "기능",
featureCount: "기능 {{count}}개", featureCount: "기능 {{count}}개",
featureCountOne: "기능 {{count}}개", featureCountOne: "기능 {{count}}개",
notesLabel: "에이전트를 위한 메모", notesLabel: "사용 시점",
notesPlaceholder: "UI 작업에 사용 — 컴포넌트, 레이아웃, 디자인 토큰.", notesPlaceholder: "UI 작업에 사용 — 컴포넌트, 레이아웃, 디자인 토큰.",
notesHint: notesHint: "에이전트가 다른 에이전트를 시작할 때 이 프로필을 선택하는 데 사용됩니다.",
"list_profiles MCP 도구가 반환합니다. 다른 에이전트에게 전달할 지침으로 작성하세요.",
save: "저장", save: "저장",
saving: "저장하는 중...", saving: "저장하는 중...",
remove: "제거", remove: "제거",
+10 -4
View File
@@ -64,6 +64,9 @@ export const ptBR: TranslationResources = {
workspaces: "Espaços de trabalho", workspaces: "Espaços de trabalho",
agents: "Agentes", agents: "Agentes",
newAgent: "Novo agente", newAgent: "Novo agente",
open: "Abrir {{name}}",
openInSidePanel: "Abrir {{name}} no painel lateral",
openInFocusedPane: "Abrir {{name}} no painel em foco",
addProject: "Adicionar projeto", addProject: "Adicionar projeto",
home: "Início", home: "Início",
groupByProject: "Agrupar por projeto", groupByProject: "Agrupar por projeto",
@@ -606,7 +609,7 @@ export const ptBR: TranslationResources = {
files: "Arquivos", files: "Arquivos",
pullRequest: "Pull request", pullRequest: "Pull request",
terminalProfilesMenu: "Perfis de terminal", terminalProfilesMenu: "Perfis de terminal",
editTerminalProfiles: "Editar perfis…", editTerminalProfiles: "Editar perfis",
}, },
sidePanel: { sidePanel: {
open: "Abrir painel lateral", open: "Abrir painel lateral",
@@ -1659,6 +1662,8 @@ export const ptBR: TranslationResources = {
updateRequired: "Atualize o host para ativar o relay pelo Paseo Desktop.", updateRequired: "Atualize o host para ativar o relay pelo Paseo Desktop.",
unavailable: "Oferta de pareamento indisponível.", unavailable: "Oferta de pareamento indisponível.",
hint: "Escaneie este QR code com o Paseo no seu celular ou copie o link abaixo.", hint: "Escaneie este QR code com o Paseo no seu celular ou copie o link abaixo.",
securityWarning:
"Trate este link de pareamento como uma senha. Qualquer pessoa com o link pode acessar este daemon.",
qrUnavailable: "QR code indisponível.", qrUnavailable: "QR code indisponível.",
qrAccessibility: "Código QR de pareamento", qrAccessibility: "Código QR de pareamento",
retry: "Tentar novamente", retry: "Tentar novamente",
@@ -1732,6 +1737,7 @@ export const ptBR: TranslationResources = {
loading: "Carregando arquivo...", loading: "Carregando arquivo...",
noPreview: "Nenhuma prévia disponível", noPreview: "Nenhuma prévia disponível",
binaryPreviewUnavailable: "Prévia binária indisponível", binaryPreviewUnavailable: "Prévia binária indisponível",
tooLargeToDisplay: "Este arquivo é grande demais para exibir",
failedToLoad: "Falha ao carregar arquivo", failedToLoad: "Falha ao carregar arquivo",
failedToLoadPreview: "Falha ao carregar prévia do arquivo", failedToLoadPreview: "Falha ao carregar prévia do arquivo",
editor: { editor: {
@@ -1760,6 +1766,7 @@ export const ptBR: TranslationResources = {
label: "Arquivos", label: "Arquivos",
subtitle: "Arquivos do espaço de trabalho", subtitle: "Arquivos do espaço de trabalho",
tooltip: "Explorar arquivos do espaço de trabalho", tooltip: "Explorar arquivos do espaço de trabalho",
chooseFile: "Escolha um arquivo",
}, },
pullRequest: { pullRequest: {
label: "Pull request", label: "Pull request",
@@ -2381,10 +2388,9 @@ export const ptBR: TranslationResources = {
featuresLabel: "Recursos", featuresLabel: "Recursos",
featureCount: "{{count}} recursos", featureCount: "{{count}} recursos",
featureCountOne: "{{count}} recurso", featureCountOne: "{{count}} recurso",
notesLabel: "Notas para os agentes", notesLabel: "Quando usar",
notesPlaceholder: "Use para trabalho de UI — componentes, layout e tokens de design.", notesPlaceholder: "Use para trabalho de UI — componentes, layout e tokens de design.",
notesHint: notesHint: "Ajuda os agentes a escolher este perfil ao iniciar outro agente.",
'Retornado pela ferramenta MCP "list_profiles". Escreva como uma instrução para outro agente.',
save: "Salvar", save: "Salvar",
saving: "Salvando...", saving: "Salvando...",
remove: "Remover", remove: "Remover",
+10 -4
View File
@@ -64,6 +64,9 @@ export const ru: TranslationResources = {
workspaces: "Рабочие пространства", workspaces: "Рабочие пространства",
agents: "Агенты", agents: "Агенты",
newAgent: "Новый агент", newAgent: "Новый агент",
open: "Открыть {{name}}",
openInSidePanel: "Открыть {{name}} в боковой панели",
openInFocusedPane: "Открыть {{name}} в активной области",
addProject: "Добавить проект", addProject: "Добавить проект",
home: "Главная", home: "Главная",
groupByProject: "Группировать по проекту", groupByProject: "Группировать по проекту",
@@ -607,7 +610,7 @@ export const ru: TranslationResources = {
files: "Файлы", files: "Файлы",
pullRequest: "PR", pullRequest: "PR",
terminalProfilesMenu: "Профили терминала", terminalProfilesMenu: "Профили терминала",
editTerminalProfiles: "Изменить профили…", editTerminalProfiles: "Изменить профили",
}, },
sidePanel: { sidePanel: {
open: "Открыть боковую панель", open: "Открыть боковую панель",
@@ -1657,6 +1660,8 @@ export const ru: TranslationResources = {
updateRequired: "Обновите хост, чтобы включить ретранслятор из Paseo Desktop.", updateRequired: "Обновите хост, чтобы включить ретранслятор из Paseo Desktop.",
unavailable: "Данные для сопряжения недоступны.", unavailable: "Данные для сопряжения недоступны.",
hint: "Отсканируйте этот QR-код с помощью Paseo на телефоне или скопируйте ссылку ниже.", hint: "Отсканируйте этот QR-код с помощью Paseo на телефоне или скопируйте ссылку ниже.",
securityWarning:
"Обращайтесь с этой ссылкой для сопряжения как с паролем. Любой, у кого она есть, может получить доступ к этому демону.",
qrUnavailable: "QR-код недоступен.", qrUnavailable: "QR-код недоступен.",
qrAccessibility: "QR-код сопряжения", qrAccessibility: "QR-код сопряжения",
retry: "Повторить попытку", retry: "Повторить попытку",
@@ -1730,6 +1735,7 @@ export const ru: TranslationResources = {
loading: "Загрузка файла...", loading: "Загрузка файла...",
noPreview: "Предварительный просмотр недоступен", noPreview: "Предварительный просмотр недоступен",
binaryPreviewUnavailable: "Предварительный просмотр двоичного файла недоступен.", binaryPreviewUnavailable: "Предварительный просмотр двоичного файла недоступен.",
tooLargeToDisplay: "Этот файл слишком велик для отображения",
failedToLoad: "Не удалось загрузить файл", failedToLoad: "Не удалось загрузить файл",
failedToLoadPreview: "Не удалось загрузить предварительный просмотр файла.", failedToLoadPreview: "Не удалось загрузить предварительный просмотр файла.",
editor: { editor: {
@@ -1759,6 +1765,7 @@ export const ru: TranslationResources = {
label: "Файлы", label: "Файлы",
subtitle: "Файлы рабочего пространства", subtitle: "Файлы рабочего пространства",
tooltip: "Просмотр файлов рабочего пространства", tooltip: "Просмотр файлов рабочего пространства",
chooseFile: "Выберите файл",
}, },
pullRequest: { pullRequest: {
label: "PR", label: "PR",
@@ -2386,10 +2393,9 @@ export const ru: TranslationResources = {
featuresLabel: "Функции", featuresLabel: "Функции",
featureCount: "Функции: {{count}}", featureCount: "Функции: {{count}}",
featureCountOne: "{{count}} функция", featureCountOne: "{{count}} функция",
notesLabel: "Заметки для агентов", notesLabel: "Когда использовать",
notesPlaceholder: "Используйте для работы над UI — компоненты, макет и токены дизайна.", notesPlaceholder: "Используйте для работы над UI — компоненты, макет и токены дизайна.",
notesHint: notesHint: "Помогает агентам выбрать этот профиль при запуске другого агента.",
"MCP-инструмент «list_profiles» возвращает эти заметки. Сформулируйте их как инструкцию для другого агента.",
save: "Сохранить", save: "Сохранить",
saving: "Сохранение...", saving: "Сохранение...",
remove: "Удалить", remove: "Удалить",
+9 -3
View File
@@ -64,6 +64,9 @@ export const zhCN: TranslationResources = {
workspaces: "工作区", workspaces: "工作区",
agents: "Agents", agents: "Agents",
newAgent: "新建 Agent", newAgent: "新建 Agent",
open: "打开{{name}}",
openInSidePanel: "在侧边面板中打开{{name}}",
openInFocusedPane: "在当前窗格中打开{{name}}",
addProject: "添加 project", addProject: "添加 project",
home: "首页", home: "首页",
groupByProject: "按项目分组", groupByProject: "按项目分组",
@@ -602,7 +605,7 @@ export const zhCN: TranslationResources = {
files: "文件", files: "文件",
pullRequest: "拉取请求", pullRequest: "拉取请求",
terminalProfilesMenu: "Terminal profiles", terminalProfilesMenu: "Terminal profiles",
editTerminalProfiles: "Edit profiles…", editTerminalProfiles: "Edit profiles",
}, },
sidePanel: { sidePanel: {
open: "打开侧边面板", open: "打开侧边面板",
@@ -1607,6 +1610,7 @@ export const zhCN: TranslationResources = {
updateRequired: "请更新主机,以便从 Paseo Desktop 启用中继。", updateRequired: "请更新主机,以便从 Paseo Desktop 启用中继。",
unavailable: "配对 offer 不可用。", unavailable: "配对 offer 不可用。",
hint: "用手机上的 Paseo 扫描此二维码,或复制下方链接。", hint: "用手机上的 Paseo 扫描此二维码,或复制下方链接。",
securityWarning: "请像保管密码一样保管此配对链接。任何获得此链接的人都可以访问此守护进程。",
qrUnavailable: "二维码不可用。", qrUnavailable: "二维码不可用。",
qrAccessibility: "配对二维码", qrAccessibility: "配对二维码",
retry: "重试", retry: "重试",
@@ -1680,6 +1684,7 @@ export const zhCN: TranslationResources = {
loading: "正在加载文件...", loading: "正在加载文件...",
noPreview: "没有可用预览", noPreview: "没有可用预览",
binaryPreviewUnavailable: "二进制预览不可用", binaryPreviewUnavailable: "二进制预览不可用",
tooLargeToDisplay: "此文件过大,无法显示",
failedToLoad: "加载文件失败", failedToLoad: "加载文件失败",
failedToLoadPreview: "加载文件预览失败", failedToLoadPreview: "加载文件预览失败",
editor: { editor: {
@@ -1708,6 +1713,7 @@ export const zhCN: TranslationResources = {
label: "文件", label: "文件",
subtitle: "工作区文件", subtitle: "工作区文件",
tooltip: "浏览工作区文件", tooltip: "浏览工作区文件",
chooseFile: "选择文件",
}, },
pullRequest: { pullRequest: {
label: "拉取请求", label: "拉取请求",
@@ -2316,9 +2322,9 @@ export const zhCN: TranslationResources = {
featuresLabel: "Features", featuresLabel: "Features",
featureCount: "{{count}} 个 Feature", featureCount: "{{count}} 个 Feature",
featureCountOne: "{{count}} 个 Feature", featureCountOne: "{{count}} 个 Feature",
notesLabel: "给 Agent 的说明", notesLabel: "适用场景",
notesPlaceholder: "用于 UI 工作 — 组件、布局和设计 token。", notesPlaceholder: "用于 UI 工作 — 组件、布局和设计 token。",
notesHint: "由 list_profiles MCP 工具返回。请将其写成给另一个 Agent 的指令。", notesHint: "帮助 Agent 在启动另一个 Agent 时选择此配置。",
save: "保存", save: "保存",
saving: "保存中...", saving: "保存中...",
remove: "移除", remove: "移除",

Some files were not shown because too many files have changed in this diff Show More