mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 17:04:42 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c17bd1e614 | ||
|
|
f898537233 | ||
|
|
3ed57e9dd7 | ||
|
|
e9f897a66a | ||
|
|
b0a8a5db6d | ||
|
|
412550c36f | ||
|
|
e6064bb374 | ||
|
|
59be8975a4 | ||
|
|
cdd7800fed | ||
|
|
902d43da7c | ||
|
|
92c8c55f31 | ||
|
|
b920243c74 | ||
|
|
9667ec5576 | ||
|
|
72955cef60 | ||
|
|
8dfdb479ab | ||
|
|
5d1a2c10f8 | ||
|
|
a91bb4d406 | ||
|
|
15997a06d0 | ||
|
|
2d5b8eb1fd | ||
|
|
ac775a2904 | ||
|
|
0e7f452c2f | ||
|
|
cab5eedfe3 |
@@ -1,5 +1,28 @@
|
||||
# Changelog
|
||||
|
||||
## 0.15.2 - 2026-11-05
|
||||
|
||||
### Added
|
||||
|
||||
- Added a first-run setup on Paseo Desktop and web that walks new users through theme, providers, projects, agent orchestration and the mobile app, with Settings → General → Show setup again to reopen it ([#8363](https://github.com/getpaseo/paseo/pull/8363))
|
||||
- Added project suggestions from the folders where you ran Claude Code, Codex, OpenCode or Pi, shown in the setup Projects step ([#8363](https://github.com/getpaseo/paseo/pull/8363))
|
||||
|
||||
### Improved
|
||||
|
||||
- Changed the theme picker to a grid of tiles with a preview of each theme ([#8363](https://github.com/getpaseo/paseo/pull/8363))
|
||||
- Replaced the "Loading…" text in Providers settings with skeleton rows ([#8363](https://github.com/getpaseo/paseo/pull/8363))
|
||||
- Changed controls to two sizes: default controls are 32px tall on desktop and 44px on phones, and compact controls are 28px ([#8363](https://github.com/getpaseo/paseo/pull/8363))
|
||||
- Changed first launch to go from the loading screen straight into setup instead of showing the empty home first ([#8824](https://github.com/getpaseo/paseo/pull/8824))
|
||||
- Changed dialogs on desktop and web to fade in over about 160ms ([#8824](https://github.com/getpaseo/paseo/pull/8824))
|
||||
- Changed modal and sheet backgrounds to match the screen background ([#8363](https://github.com/getpaseo/paseo/pull/8363))
|
||||
- Changed documentation links in Settings sections to a `Label ↗` link beside the section title ([#8363](https://github.com/getpaseo/paseo/pull/8363))
|
||||
|
||||
## 0.15.1 - 2026-11-05
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the daemon failing to start when the terminal worker cannot read its own process identity, which left Paseo Desktop without a daemon on Windows machines where Windows PowerShell 5.1 cannot run `Get-CimInstance` ([#8810](https://github.com/getpaseo/paseo/pull/8810))
|
||||
|
||||
## 0.15.0 - 2026-11-03
|
||||
|
||||
Daemon restarts now keep terminal programs running. Interrupted agents can resume in their existing conversations when you turn on agent recovery.
|
||||
|
||||
@@ -1,5 +1,28 @@
|
||||
# Changelog
|
||||
|
||||
## 0.15.2 - 2026-11-05
|
||||
|
||||
### 新增
|
||||
|
||||
- 在 Paseo 桌面版和网页版新增首次运行引导,依次带新用户设置主题、提供商、项目、Agent 编排和移动端应用,并可通过“设置 → 通用 → Show setup again”重新打开 ([#8363](https://github.com/getpaseo/paseo/pull/8363))
|
||||
- 在引导的“项目”步骤中,根据你运行过 Claude Code、Codex、OpenCode 或 Pi 的文件夹推荐项目 ([#8363](https://github.com/getpaseo/paseo/pull/8363))
|
||||
|
||||
### 改进
|
||||
|
||||
- 将主题选择器改为带各主题预览的图块网格 ([#8363](https://github.com/getpaseo/paseo/pull/8363))
|
||||
- 将“提供商”设置中的“Loading…”文字替换为骨架占位行 ([#8363](https://github.com/getpaseo/paseo/pull/8363))
|
||||
- 将控件统一为两种尺寸:默认控件在桌面端高 32px、在手机上高 44px,紧凑控件高 28px ([#8363](https://github.com/getpaseo/paseo/pull/8363))
|
||||
- 将首次启动改为从加载界面直接进入引导,不再先显示空白的主页 ([#8824](https://github.com/getpaseo/paseo/pull/8824))
|
||||
- 将桌面端和网页版的对话框改为约 160 毫秒的淡入 ([#8824](https://github.com/getpaseo/paseo/pull/8824))
|
||||
- 将弹窗和面板的背景改为与屏幕背景一致 ([#8363](https://github.com/getpaseo/paseo/pull/8363))
|
||||
- 将“设置”各分区的文档链接改为分区标题旁的 `Label ↗` 链接 ([#8363](https://github.com/getpaseo/paseo/pull/8363))
|
||||
|
||||
## 0.15.1 - 2026-11-05
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复终端工作进程无法读取自身进程标识时 daemon 无法启动的问题;此前在 Windows PowerShell 5.1 无法运行 `Get-CimInstance` 的 Windows 机器上,Paseo 桌面版因此没有可用的 daemon ([#8810](https://github.com/getpaseo/paseo/pull/8810))
|
||||
|
||||
## 0.15.0 - 2026-11-03
|
||||
|
||||
daemon 重启后,终端中的程序继续运行。开启 Agent 恢复后,被中断的 Agent 会在原有会话中恢复工作。
|
||||
|
||||
+10
-3
@@ -305,9 +305,9 @@ Workspace status is an aggregate activity signal computed **per `workspaceId`**.
|
||||
|
||||
Running provider-native subagents contribute `running` to the workspace owned by their parent agent. Their completed, failed, and canceled states stay in the parent's subagents track.
|
||||
|
||||
A finished workspace can be marked unread after it has been reviewed. The daemon restores
|
||||
`finished` attention on its newest eligible workspace-root agent without sending a new completion
|
||||
notification. Opening the workspace clears that attention through the normal focus flow.
|
||||
A finished or failed workspace can be marked unread after it has been reviewed. The daemon restores
|
||||
attention on its newest eligible workspace-root agent, `error` for an agent whose lifecycle is
|
||||
`error` and `finished` otherwise, without sending a new notification. Opening the workspace clears that attention through the normal focus flow.
|
||||
|
||||
Attention is set by the agent finishing or failing and cleared by the client's
|
||||
`workspace.clear_attention`, which fires when the user reads the chat. Loading an agent's runtime is
|
||||
@@ -316,6 +316,13 @@ untouched. Forging either makes a background resume look like the user read a wo
|
||||
agent worked in it just now, which rewrites the sidebar timestamp permanently — persisted
|
||||
`updatedAt` is what workspace `statusEnteredAt` is re-derived from on the next daemon start.
|
||||
|
||||
The `failed` bucket follows unread `error` attention, not lifecycle status. An agent stays `error`
|
||||
until its next turn starts, so reading the chat dismisses the red dot the same way it dismisses the
|
||||
green one. For the same reason every new failure raises `error` attention, including a failed reload
|
||||
that leaves the lifecycle at `error`, and replaces unread `finished` attention; otherwise the failed
|
||||
agent would show the green dot or none. Subagents track marks are the exception: they follow the
|
||||
child's lifecycle, because the track is where a failed child stays visible.
|
||||
|
||||
## The subagents track
|
||||
|
||||
The track is a pill at the foot of an agent's pane (`packages/app/src/subagents/track.tsx`): a count you can read at a glance, and a panel behind it — a popover on wide screens, a sheet on compact ones — holding the rows. It floats over the transcript rather than sitting in a band above the composer, so the timeline scrolls underneath it; `packages/app/src/panels/agent-tracks.tsx` owns that placement, and the pill frame is shared with the task list in `packages/app/src/composer/tracks.tsx`.
|
||||
|
||||
+5
-1
@@ -26,6 +26,10 @@ checkout root. They intentionally differ for an exact subproject inside a worktr
|
||||
restore, branch auto-name, and descriptor flows consume those persisted facts rather than
|
||||
rediscovering ownership from a directory that may already be gone. Reconciliation may refresh
|
||||
mutable placement facts, but never changes `projectId`, `cwd`, `displayName`, or `baseBranch`.
|
||||
An explicit base selection is stored in the checkout's Git-directory Paseo metadata and
|
||||
mirrored to `baseBranch` in every workspace record sharing that checkout. The record keeps
|
||||
recovery possible after the checkout metadata is deleted. A newly opened workspace inherits
|
||||
the checkout's selected base; separate worktrees keep separate bases.
|
||||
Workspace archive runs lifecycle teardown from the exact `cwd` but removes only the backing
|
||||
`worktreeRoot` after its last active reference disappears. Worktree recovery recreates that backing
|
||||
checkout from `mainRepoRoot`, then restores the relative path from `worktreeRoot` to `cwd`.
|
||||
@@ -470,7 +474,7 @@ Array of workspace records. A workspace is a specific working directory within a
|
||||
| `title` | `string \| null` | User-set name override layered over `displayName`. Null means "use `displayName`". |
|
||||
| `branch` | `string \| null` | The current Git branch for git-backed workspaces. Separate from `displayName`/`title`; a background branch refresh never rewrites the name. |
|
||||
| `worktreeRoot` | `string \| null` | Backing checkout/worktree root. May differ from `cwd` for exact subprojects and remains persisted after the worktree is deleted so restore can reproduce the placement. |
|
||||
| `baseBranch` | `string \| null` | Comparison base retained across archive and restore. Branch-off creation stores the resolved ref; legacy and PR-checkout records hold a bare name. Null means no recorded base. |
|
||||
| `baseBranch` | `string \| null` | Current base retained across archive and restore. Explicit selections and branch-off creation store exact refs; older records may store branch names. Null means no recorded base. |
|
||||
| `isPaseoOwnedWorktree` | `boolean` | Whether Paseo owns and may remove/recreate the backing `worktreeRoot` |
|
||||
| `mainRepoRoot` | `string \| null` | Main repository root for worktree checkouts, independent of both exact `cwd` and backing `worktreeRoot` |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
|
||||
@@ -169,6 +169,8 @@ Electron window controls are top-corner obstructions, not a compact-layout condi
|
||||
|
||||
Windows and Linux controls are fixed window chrome, outside scrolling header content. A tab rail that reaches them ends at their obstruction and shows the shared overflow fade. On macOS, the Explorer toggle occupies a fixed top-right window slot so opening and closing Explorer does not move the pointer target.
|
||||
|
||||
Each window edge has one safe-area owner. `WindowLayoutProvider` publishes the bottom inset that every surface reads through `useSafeAreaInsets()`: on iOS 27.1 and later it is UIKit's bottom-bar clearance, which refines the rectangular home-indicator inset to the screen's corners, and elsewhere the system inset. Surfaces apply that value once and never stack their own spacing under it; `WindowLayoutSurface` owns the horizontal insets the same way.
|
||||
|
||||
A new list+detail feature copies the settings shell. A new workspace-shaped feature copies the workspace shell. Inventing a third shape happens in design review, not in a PR.
|
||||
|
||||
---
|
||||
|
||||
+10
-1
@@ -594,7 +594,16 @@ rerun passes only by landing elsewhere.
|
||||
To confirm, fetch the URL from several runners and compare `content-length`, the `cf-ray` colo
|
||||
suffix, and the number of `attestations` entries (#7730 has the probe). To fix, remove or
|
||||
replace that version in the lockfile. If the dependent’s range cannot be changed, pin a clean
|
||||
version in root `overrides` and regenerate the lockfile. Report the URL to npm so they purge it.
|
||||
version in root `overrides`. Report the URL to npm so they purge it.
|
||||
|
||||
A lockfile regeneration is itself how new bad versions enter: the pkijs fix re-resolved the
|
||||
electron-builder tree and pulled in `semver` 5.7.3 and 6.3.2, published two days earlier, and
|
||||
5.7.3 then failed the same way. After editing `overrides`, run `npm install --package-lock-only`
|
||||
and check that the changed `resolved` lines in `package-lock.json` are only the ones you meant.
|
||||
|
||||
A per-parent override such as `"tiny-async-pool": { "semver": "5.7.2" }` applies to that
|
||||
parent's whole subtree, so it can also downgrade a grandchild that needs a different major. Key
|
||||
the child by range (`{ "semver@^5": "5.7.2" }`) so only matching edges are replaced.
|
||||
|
||||
## ACP provider catalog versions
|
||||
|
||||
|
||||
@@ -172,6 +172,11 @@ slide under the header on open and out from under it on close. Do not swap the
|
||||
dock to keyboard padding at rest. Changing its height creates either a blank
|
||||
band or a paused transition when the stream list updates its viewport.
|
||||
|
||||
The docked composer rests at the larger of the safe-area bottom and 16pt. With
|
||||
the keyboard open it keeps 16pt above the keyboard through the translate view's
|
||||
`keyboardGap`, which rises further than the keyboard's safe-area overlap. Do not
|
||||
grow the composer's bottom padding with the keyboard instead.
|
||||
|
||||
Do not animate a layout prop through the keyboard transition. On Android
|
||||
Fabric, each animated layout update clones the shadow tree and runs Yoga over
|
||||
the dock subtree. This measured about 10 ms per frame on the emulator and the
|
||||
|
||||
+39
-10
@@ -9,6 +9,10 @@ Compact layouts have three mutually exclusive destinations:
|
||||
They are one interaction, not two independent drawers. The implementation lives in
|
||||
`packages/app/src/mobile-panels/`.
|
||||
|
||||
Wide layouts reuse `agent-list` for the navigation overlay. When the left dock is collapsed or
|
||||
yields its width, a touch swipe reveals the same sidebar over the workspace at the resolved sidebar
|
||||
width. The workspace does not move, and the saved dock preference does not change.
|
||||
|
||||
## Ownership
|
||||
|
||||
React/Zustand owns the durable intent:
|
||||
@@ -23,6 +27,12 @@ interface MobilePanelSelection {
|
||||
Every semantic target change increments `revision`. Repeating the current target is idempotent.
|
||||
Compact panel selection is not persisted; a cold start begins at `agent`.
|
||||
|
||||
Only an overlay sidebar holds `agent-list`, and only a compact layout holds `file-explorer`. App
|
||||
chrome publishes the layout (compact or wide, and the left sidebar presentation `overlay`, `dock`, or
|
||||
`hidden`) to `panel-store`, which drops any target the layout does not present. A stale
|
||||
`agent-list` would reopen the overlay on the next collapse; a stale `file-explorer` after widening
|
||||
would block the navigation swipe, which begins only from `agent`.
|
||||
|
||||
The UI worklet owns transient motion:
|
||||
|
||||
- one normalized position (`-1` left, `0` center, `1` right)
|
||||
@@ -32,8 +42,10 @@ The UI worklet owns transient motion:
|
||||
|
||||
React publishes the active panel only when the canonical target and the UI-thread position agree at
|
||||
the final anchor. Retained content never observes gesture previews, progress, or an unsettled target.
|
||||
The gesture hosts stay mounted. The Gesture Handler web patch keeps initially disabled hosts
|
||||
from capturing paragraph pointers; that capture confines browser selection to its starting paragraph.
|
||||
The gesture hosts stay mounted. A pointer capture on a paragraph confines browser selection to that
|
||||
paragraph, so the Gesture Handler web patch keeps it off: initially disabled hosts register no
|
||||
pointer listeners, and an enabled host captures only a pointer its handler still tracks after
|
||||
pointer-down. The touch-only navigation pans fail a mouse at pointer-down and never capture it.
|
||||
Native worklets own overlay opacity and pointer events while
|
||||
React owns settled panel activity and accessibility. Keep hidden native overlays laid out: collapsing
|
||||
their scroll ranges makes Android spring retained offsets back to zero during touch cancellation or
|
||||
@@ -43,8 +55,11 @@ appearance keys; see [Unistyles appearance boundaries](unistyles.md#runtime-them
|
||||
|
||||
## Why one position
|
||||
|
||||
Both transforms and both backdrop opacities are derived from the same normalized position. Window
|
||||
width is only a projection input. Rotation changes the projection, not the panel state.
|
||||
Both transforms and both backdrop opacities are derived from the same normalized position. Panel
|
||||
width is only a projection input: the window width on the right, and on the left the width
|
||||
`MobilePanelsProvider` resolves (the full window on compact layouts, the saved sidebar width
|
||||
clamped to the viewport on wide ones). The dock, the overlay, and their gestures read that one
|
||||
value. Rotation changes the projection, not the panel state.
|
||||
|
||||
This makes these invalid states unrepresentable:
|
||||
|
||||
@@ -62,7 +77,9 @@ A gesture captures the current revision when it becomes active. Per-frame update
|
||||
while that revision still owns the gesture.
|
||||
|
||||
When a React command arrives during a drag, its newer revision clears gesture ownership and starts
|
||||
motion toward the new target. The older gesture's remaining updates and finish callback are ignored.
|
||||
motion toward its target, even an unchanged one: docking mid-swipe keeps `agent` and still returns
|
||||
the dragged position to its anchor. The older gesture's remaining updates and finish callback are
|
||||
ignored.
|
||||
Canceled gestures return to the latest canonical target. A successful gesture starts its finishing
|
||||
motion immediately; the matching React command adopts that motion instead of restarting it. Position
|
||||
settlement and command acceptance may arrive in either order. Activity changes after both agree on
|
||||
@@ -80,6 +97,12 @@ definition, no longer eligible to begin.
|
||||
|
||||
- Callers request semantic targets through `panel-store`; they never write shared values.
|
||||
- Gesture behavior comes from the four explicit hooks in `mobile-panels/gestures.ts`.
|
||||
- Navigation gestures accept touch pointers only. Mouse and pen fail at touch-down, so dragging a
|
||||
mouse to select text on a hybrid laptop never moves a panel. Browsers without a touch screen
|
||||
(`hooks/pointer-capabilities`) never enable them.
|
||||
- Phone web opens a drawer only from the 32px edge it slides in from, so interior swipes stay with
|
||||
terminals, code blocks, and diff scrollers. Native drawers and the wide overlay open from anywhere.
|
||||
Horizontal-scroll ownership and the open-gesture blocker still take precedence.
|
||||
- A focused interactive surface may block panel-opening gestures through
|
||||
`useBlockMobilePanelOpenGestures`. Register only while the conflicting interaction is active and
|
||||
unregister when the surface is hidden or unfocused. The blocker never disables gestures that close
|
||||
@@ -87,11 +110,17 @@ definition, no longer eligible to begin.
|
||||
- Keep `SidebarModelProvider` outside `MobileGestureWrapper`. The provider shares sidebar derivation
|
||||
across consumers, while Gesture Handler requires the wrapper's direct child to be a native `View`
|
||||
so its injected `collapsable={false}` reaches Android/Fabric.
|
||||
- Mobile sidebars render through `MobilePanelOverlay`; do not duplicate overlay lifecycle or motion
|
||||
styles in sidebar components.
|
||||
- The desktop left sidebar is retained too. App chrome owns separate mounted and visible decisions:
|
||||
closing it or yielding its width marks it inactive and applies `display: none` without conditionally
|
||||
removing the sidebar tree.
|
||||
- Sidebars render through `MobilePanelOverlay`; do not duplicate overlay lifecycle or motion styles
|
||||
in sidebar components. The wide left sidebar keeps the same content and gesture hosts when switching
|
||||
between dock and overlay. App chrome owns mounting and presentation; settled panel activity owns
|
||||
overlay work. A hidden dock is inactive with `display: none`; the sidebar tree stays mounted. Keep
|
||||
the native overlay laid out while hidden, as on compact layouts. Give the switching host no
|
||||
Reanimated layout props: a width keyed on the presentation reaches Android after React commits the
|
||||
switch, and a lost update left the workspace at the dock width. The dock sizes to its content and
|
||||
the overlay host fills the window.
|
||||
- The overlay's backdrop covers the whole window; safe-area padding applies to the sidebar and
|
||||
workspace content only. It closes by swiping back, tapping the backdrop, or picking a destination:
|
||||
every sidebar destination closes the overlay before it navigates.
|
||||
- Animated panel nodes use React Native static styles plus inline theme values. Do not attach
|
||||
Unistyles-generated styles to those nodes; Unistyles and Reanimated patching the same Fabric node
|
||||
has caused native crashes.
|
||||
|
||||
+9
-6
@@ -281,23 +281,26 @@ Drafts do not appear in GitHub's releases feed. Updater clients continue to see
|
||||
|
||||
The `rollout_hours` input on `desktop-release.yml` is **only read on `workflow_dispatch`** — tag-push runs always default to 36. To get any other rollout duration on a fresh release, use the post-publish flip below.
|
||||
|
||||
### Instant-admit release (rollout_hours=0 from publish)
|
||||
### Instant-admit release (rollout_hours=0)
|
||||
|
||||
For a fresh release that should admit everyone immediately (low-risk change, doc-only, hotfix, or just a release you want out fast), cut the release normally and queue the rollout flip immediately after:
|
||||
`npm run release:patch` and `npm run release:minor` tag-push, and tag-push runs always stamp 36 hours. To admit everyone immediately (hotfix, doc-only change, a release you want out fast), cut the release normally, wait for it to publish, then flip the manifests:
|
||||
|
||||
```bash
|
||||
# 1. Cut and publish (default 36h ramp from tag push).
|
||||
# 1. Cut and publish (36h ramp from tag push).
|
||||
npm run release:patch
|
||||
|
||||
# 2. Immediately queue the flip — runs as soon as finalize-rollout completes.
|
||||
# 2. Wait for Desktop Release to publish the GitHub Release.
|
||||
gh run watch <desktop-release-run-id> --exit-status
|
||||
|
||||
# 3. Flip the published manifests to instant admit.
|
||||
gh workflow run desktop-rollout.yml \
|
||||
-f tag=v0.1.64 \
|
||||
-f rollout_hours=0
|
||||
```
|
||||
|
||||
**Why this is gap-free:** `desktop-release.yml`'s `finalize-rollout` job and `desktop-rollout.yml` share the concurrency group `desktop-rollout-<tag>`. Dispatching `desktop-rollout.yml` while the tag-push pipeline is still running queues it safely behind `finalize-rollout`. The first public manifests already carry `rolloutHours=36`, then `desktop-rollout.yml` flips them to `rolloutHours=0` shortly afterward. The renderer polls every 30 minutes, so active stable users pick up the new manifest on their next check.
|
||||
Dispatch only after `finalize-rollout` has published the release. Until then the release is a draft, and a draft has no tag: `desktop-rollout.yml` looks the release up by tag and fails with `release not found`. The concurrency group `desktop-rollout-<tag>` that the two workflows share orders them only once both are queued; it does not hold an early dispatch until the draft is published.
|
||||
|
||||
Run the dispatch right after `release:patch` or `release:minor` returns. Don't wait for the tag-push CI to finish.
|
||||
The first public manifests carry `rolloutHours=36` for the minutes between publish and the flip. The renderer polls every 30 minutes, so active stable users pick up the new manifest on their next check.
|
||||
|
||||
### Adjusting an already-published release
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
Fixed
|
||||
- Fixed the daemon failing to start when the terminal worker cannot read its own process identity, which left Paseo Desktop without a daemon on Windows machines where Windows PowerShell 5.1 cannot run Get-CimInstance
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,4 @@
|
||||
Fixed
|
||||
- Fixed the daemon failing to start when the terminal worker cannot read its own process identity, which left Paseo Desktop without a daemon on Windows machines where Windows PowerShell 5.1 cannot run Get-CimInstance
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,4 @@
|
||||
Fixed
|
||||
- Fixed the daemon failing to start when the terminal worker cannot read its own process identity, which left Paseo Desktop without a daemon on Windows machines where Windows PowerShell 5.1 cannot run Get-CimInstance
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,4 @@
|
||||
Fixed
|
||||
- Fixed the daemon failing to start when the terminal worker cannot read its own process identity, which left Paseo Desktop without a daemon on Windows machines where Windows PowerShell 5.1 cannot run Get-CimInstance
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,8 @@
|
||||
Added
|
||||
- Added a first-run setup on Paseo Desktop and web that walks new users through theme, providers, projects, agent orchestration and the mobile app, with Settings → General → Show setup again to reopen it
|
||||
- Added project suggestions from the folders where you ran Claude Code, Codex, OpenCode or Pi, shown in the setup Projects step
|
||||
|
||||
Improved
|
||||
- Changed the theme picker to a grid of tiles with a preview of each theme
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,8 @@
|
||||
Added
|
||||
- Added a first-run setup on Paseo Desktop and web that walks new users through theme, providers, projects, agent orchestration and the mobile app, with Settings → General → Show setup again to reopen it
|
||||
- Added project suggestions from the folders where you ran Claude Code, Codex, OpenCode or Pi, shown in the setup Projects step
|
||||
|
||||
Improved
|
||||
- Changed the theme picker to a grid of tiles with a preview of each theme
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,8 @@
|
||||
Added
|
||||
- Added a first-run setup on Paseo Desktop and web that walks new users through theme, providers, projects, agent orchestration and the mobile app, with Settings → General → Show setup again to reopen it
|
||||
- Added project suggestions from the folders where you ran Claude Code, Codex, OpenCode or Pi, shown in the setup Projects step
|
||||
|
||||
Improved
|
||||
- Changed the theme picker to a grid of tiles with a preview of each theme
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,8 @@
|
||||
Added
|
||||
- Added a first-run setup on Paseo Desktop and web that walks new users through theme, providers, projects, agent orchestration and the mobile app, with Settings → General → Show setup again to reopen it
|
||||
- Added project suggestions from the folders where you ran Claude Code, Codex, OpenCode or Pi, shown in the setup Projects step
|
||||
|
||||
Improved
|
||||
- Changed the theme picker to a grid of tiles with a preview of each theme
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-sLaLHD1u1eJRdGdflcwWBGxnnhRQpB1T74BlaWcmxbU=
|
||||
sha256-wO0vJVhh1urkt4q9NiQ0RSXUj++WWuHTneWSPelU+JQ=
|
||||
|
||||
Generated
+34
-34
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.15.0",
|
||||
"version": "0.15.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.15.0",
|
||||
"version": "0.15.2",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
@@ -15088,9 +15088,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/app-builder-lib/node_modules/@electron/get/node_modules/semver": {
|
||||
"version": "6.3.2",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.2.tgz",
|
||||
"integrity": "sha512-GYrCADwpu6yV5OAvchkQ27Z5wwMxWAEMg5NNWjyHh/5DboFFqbdbvYNIvZouQpxXyeMPoTOh0vdPYKlf0ZswdQ==",
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
@@ -35100,9 +35100,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-async-pool/node_modules/semver": {
|
||||
"version": "5.7.3",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.3.tgz",
|
||||
"integrity": "sha512-kM18BPOqTi0+TB68703/GJRpAML5Me+IDQsR1J26YB3EFKKNEyHIVZuWDX7+WKQB3tHwDwgk24BfhdpWUbytvQ==",
|
||||
"version": "5.7.2",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
|
||||
"integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
@@ -37468,7 +37468,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.15.0",
|
||||
"version": "0.15.2",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -39637,13 +39637,13 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.15.0",
|
||||
"version": "0.15.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.15.0",
|
||||
"@getpaseo/protocol": "0.15.0",
|
||||
"@getpaseo/server": "0.15.0",
|
||||
"@getpaseo/client": "0.15.2",
|
||||
"@getpaseo/protocol": "0.15.2",
|
||||
"@getpaseo/server": "0.15.2",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -39890,11 +39890,11 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.15.0",
|
||||
"version": "0.15.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.15.0",
|
||||
"@getpaseo/relay": "0.15.0",
|
||||
"@getpaseo/protocol": "0.15.2",
|
||||
"@getpaseo/relay": "0.15.2",
|
||||
"ws": "^8.20.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -39906,7 +39906,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.15.0",
|
||||
"version": "0.15.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -40149,7 +40149,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.15.0",
|
||||
"version": "0.15.2",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -40195,7 +40195,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.15.0",
|
||||
"version": "0.15.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -40225,7 +40225,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.15.0",
|
||||
"version": "0.15.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^1.4.0",
|
||||
@@ -40233,8 +40233,8 @@
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.15.0",
|
||||
"@getpaseo/protocol": "0.15.0",
|
||||
"@getpaseo/client": "0.15.2",
|
||||
"@getpaseo/protocol": "0.15.2",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.2.3",
|
||||
@@ -40244,8 +40244,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.15.0",
|
||||
"@getpaseo/protocol": "0.15.0",
|
||||
"@getpaseo/client": "0.15.2",
|
||||
"@getpaseo/protocol": "0.15.2",
|
||||
"react": ">=19.1.0 <20",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -40267,7 +40267,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.15.0",
|
||||
"version": "0.15.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"semver": "^7.8.5",
|
||||
@@ -40295,7 +40295,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.15.0",
|
||||
"version": "0.15.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "^2.4.0",
|
||||
@@ -40517,17 +40517,17 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.15.0",
|
||||
"version": "0.15.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.15.0",
|
||||
"@getpaseo/highlight": "0.15.0",
|
||||
"@getpaseo/plugin": "0.15.0",
|
||||
"@getpaseo/protocol": "0.15.0",
|
||||
"@getpaseo/relay": "0.15.0",
|
||||
"@getpaseo/client": "0.15.2",
|
||||
"@getpaseo/highlight": "0.15.2",
|
||||
"@getpaseo/plugin": "0.15.2",
|
||||
"@getpaseo/protocol": "0.15.2",
|
||||
"@getpaseo/relay": "0.15.2",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -41474,7 +41474,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.15.0",
|
||||
"version": "0.15.2",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
@@ -43084,7 +43084,7 @@
|
||||
},
|
||||
"plugins": {
|
||||
"name": "@getpaseo/builtin-plugins",
|
||||
"version": "0.15.0",
|
||||
"version": "0.15.2",
|
||||
"dependencies": {
|
||||
"@getpaseo/plugin": "*",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
+8
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.15.0",
|
||||
"version": "0.15.2",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
@@ -149,6 +149,9 @@
|
||||
"overrides": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/view": "6.43.6",
|
||||
"@electron/get@^3.0.0": {
|
||||
"semver@^6": "6.3.1"
|
||||
},
|
||||
"expo": "~57.0.30",
|
||||
"expo-router": "~57.0.28",
|
||||
"lightningcss": "1.30.1",
|
||||
@@ -158,6 +161,9 @@
|
||||
"react-native": "0.86.3",
|
||||
"react-native-gesture-handler": "~2.32.0",
|
||||
"react-native-reanimated": "4.5.5",
|
||||
"react-native-worklets": "0.10.4"
|
||||
"react-native-worklets": "0.10.4",
|
||||
"tiny-async-pool": {
|
||||
"semver@^5": "5.7.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -723,3 +723,46 @@ test("copying a selection across an assistant image keeps its Markdown source an
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test.describe("on a touch-capable desktop browser", () => {
|
||||
// A touch screen registers the navigation pan's pointer listeners over the whole workspace.
|
||||
// The pan accepts touch only, and a mouse drag must still extend the browser's selection.
|
||||
test.use({ hasTouch: true });
|
||||
|
||||
test("a mouse drag selects across assistant paragraphs", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "assistant-selection-drag-",
|
||||
title: "Assistant selection drag",
|
||||
initialPrompt: "Render three paragraphs.",
|
||||
featureValues: {
|
||||
mockAssistantResponse: "First paragraph.\n\nSecond paragraph.\n\nThird paragraph.",
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await agent.client.waitForAgentUpsert(
|
||||
agent.agentId,
|
||||
(snapshot) => snapshot.status === "idle",
|
||||
30_000,
|
||||
);
|
||||
await openAgentRoute(page, agent);
|
||||
const blocks = assistantMessageBlocks(page);
|
||||
const first = await blocks.getByText("First paragraph.").boundingBox();
|
||||
const third = await blocks.getByText("Third paragraph.").boundingBox();
|
||||
if (!first || !third) {
|
||||
throw new Error("Expected rendered paragraphs");
|
||||
}
|
||||
|
||||
await page.mouse.move(first.x + 1, first.y + first.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(third.x + third.width - 1, third.y + third.height / 2, { steps: 20 });
|
||||
await page.mouse.up();
|
||||
|
||||
const selected = await page.evaluate(() => window.getSelection()?.toString() ?? "");
|
||||
expect(selected).toContain("First paragraph.");
|
||||
expect(selected).toContain("Third paragraph.");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -323,6 +323,110 @@ test("All changes combines committed and pending edits and preserves an unfinish
|
||||
});
|
||||
});
|
||||
|
||||
test("Changes selects exact local and remote bases and retains the selection after reload", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const workspace = await createWorktreeWithCommittedDiff();
|
||||
execFileSync("git", ["branch", "release"], { cwd: workspace.repoPath });
|
||||
execFileSync("git", ["update-ref", "refs/remotes/upstream/release", "main"], {
|
||||
cwd: workspace.repoPath,
|
||||
});
|
||||
const head = execFileSync("git", ["rev-parse", "HEAD"], { cwd: workspace.repoPath }).toString();
|
||||
await openWorkspaceChangesSurface(page, workspace);
|
||||
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
|
||||
const picker = tree.getByTestId("workspace-base-branch-trigger");
|
||||
await expect(picker).toBeVisible();
|
||||
await picker.click();
|
||||
await expect(page.getByRole("button", { name: "release", exact: true })).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath("base-picker.png") });
|
||||
await page.getByRole("button", { name: "release", exact: true }).click();
|
||||
await expect(picker).toContainText("release");
|
||||
await expect(tree.getByText("No changes to display", { exact: true })).toBeVisible();
|
||||
await picker.click();
|
||||
await page.getByRole("button", { name: "upstream/release", exact: true }).click();
|
||||
await expect(picker).toContainText("upstream/release");
|
||||
await expect(tree.getByText("committed-only.ts", { exact: true })).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath("base-selected.png") });
|
||||
await page.reload();
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expect(picker).toContainText("upstream/release");
|
||||
expect(execFileSync("git", ["rev-parse", "HEAD"], { cwd: workspace.repoPath }).toString()).toBe(
|
||||
head,
|
||||
);
|
||||
});
|
||||
|
||||
test("Changes lets a compact workspace change its base in a sheet", async ({ page }, testInfo) => {
|
||||
const workspace = await createWorktreeWithCommittedDiff();
|
||||
execFileSync("git", ["branch", "release"], { cwd: workspace.repoPath });
|
||||
await openCompactChanges(page, workspace);
|
||||
const picker = page.getByRole("button", { name: "Change base" }).filter({ visible: true });
|
||||
await picker.click();
|
||||
await expect(page.getByText("Change base", { exact: true })).toBeVisible();
|
||||
const release = page.getByRole("button", { name: "release", exact: true });
|
||||
await expect(release).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath("compact-base-picker.png") });
|
||||
await release.click();
|
||||
await expect(picker).toContainText("release");
|
||||
await page.screenshot({ path: testInfo.outputPath("compact-base-selected.png") });
|
||||
});
|
||||
|
||||
test("Changes shows a failed base selection and lets the user retry", async ({ page }) => {
|
||||
const workspace = await createWorktreeWithCommittedDiff();
|
||||
execFileSync("git", ["branch", "release"], { cwd: workspace.repoPath });
|
||||
await openWorkspaceChangesSurface(page, workspace);
|
||||
const picker = page
|
||||
.getByTestId("workspace-base-branch-trigger")
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
await picker.click();
|
||||
const option = page.getByRole("button", { name: "release", exact: true });
|
||||
await expect(option).toBeVisible();
|
||||
execFileSync("git", ["branch", "-D", "release"], { cwd: workspace.repoPath });
|
||||
await option.click();
|
||||
await expect(page.getByTestId("workspace-base-branch-error")).toContainText(
|
||||
"Base branch not found",
|
||||
);
|
||||
await expect(picker).toContainText("main");
|
||||
execFileSync("git", ["branch", "release"], { cwd: workspace.repoPath });
|
||||
await option.click();
|
||||
await expect(page.getByTestId("workspace-base-branch-error")).toHaveCount(0);
|
||||
await expect(picker).toContainText("release");
|
||||
});
|
||||
|
||||
test("Changes shows the base as text on a host without the base-branch capability", async ({
|
||||
page,
|
||||
}) => {
|
||||
let baseRequests = 0;
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
|
||||
const server = browser.connectToServer();
|
||||
browser.onMessage((frame) => {
|
||||
if (typeof frame === "string") {
|
||||
const envelope = JSON.parse(frame) as { message?: { type?: string } };
|
||||
if (envelope.message?.type === "workspace.base_branch.set.request") baseRequests += 1;
|
||||
}
|
||||
server.send(frame);
|
||||
});
|
||||
server.onMessage((frame) => {
|
||||
if (typeof frame === "string") {
|
||||
const envelope = JSON.parse(frame) as {
|
||||
message?: { payload?: { status?: string; features?: Record<string, unknown> } };
|
||||
};
|
||||
const payload = envelope.message?.payload;
|
||||
if (payload?.status === "server_info" && payload.features) {
|
||||
delete payload.features.workspaceBaseBranch;
|
||||
browser.send(JSON.stringify(envelope));
|
||||
return;
|
||||
}
|
||||
}
|
||||
browser.send(frame);
|
||||
});
|
||||
});
|
||||
const workspace = await createWorktreeWithCommittedDiff();
|
||||
await openWorkspaceChangesSurface(page, workspace);
|
||||
await expect(page.getByTestId("workspace-base-branch-trigger")).toHaveCount(0);
|
||||
expect(baseRequests).toBe(0);
|
||||
});
|
||||
|
||||
test("Changes keeps a manual comparison when checkout dirtiness changes", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
@@ -1657,6 +1761,28 @@ async function expectRenderedAddedLine(page: Page, contents: string): Promise<vo
|
||||
}).toPass({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
async function createWorktreeWithCommittedDiff(): Promise<DirtyWorkspace> {
|
||||
const repo = await createTempGitRepo("changes-base-worktree-", {
|
||||
files: [{ path: "tracked.ts", content: "export const tracked = 1;\n" }],
|
||||
});
|
||||
const client = await connectSeedClient();
|
||||
cleanupTasks.push({
|
||||
run: async () => {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
},
|
||||
});
|
||||
const created = await client.createWorkspace({
|
||||
source: { kind: "worktree", cwd: repo.path, worktreeSlug: "base-picker", baseBranch: "main" },
|
||||
});
|
||||
if (!created.workspace) throw new Error(created.error ?? "Failed to create worktree");
|
||||
const cwd = created.workspace.workspaceDirectory;
|
||||
await writeFile(path.join(cwd, "committed-only.ts"), "export const committed = true;\n");
|
||||
execFileSync("git", ["add", "committed-only.ts"], { cwd });
|
||||
execFileSync("git", ["commit", "-m", "Add committed-only file"], { cwd });
|
||||
return { id: created.workspace.id, repoPath: cwd };
|
||||
}
|
||||
|
||||
async function createWorkspaceWithCommittedDiff(): Promise<DirtyWorkspace> {
|
||||
const repo = await createTempGitRepo("changes-committed-", {
|
||||
files: [{ path: "tracked.ts", content: "export const tracked = 1;\n" }],
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { openNewWorkspaceComposer } from "../support/helpers/new-workspace";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
|
||||
async function box(locator: Locator) {
|
||||
await expect(locator).toBeVisible({ timeout: 30_000 });
|
||||
const rect = await locator.boundingBox();
|
||||
expect(rect).not.toBeNull();
|
||||
return { top: rect!.y, bottom: rect!.y + rect!.height };
|
||||
}
|
||||
|
||||
test.describe("Composer bottom spacing", () => {
|
||||
test("the chat composer rests one content spacing above the window edge", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-bottom-spacing-",
|
||||
title: "Bottom spacing",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
|
||||
const surface = await box(
|
||||
page.getByTestId("message-input-root").filter({ visible: true }).first(),
|
||||
);
|
||||
const viewportHeight = await page.evaluate(() => window.innerHeight);
|
||||
expect(viewportHeight - surface.bottom).toBe(16);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("the wide new workspace form keeps one content spacing above Import session", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "composer-bottom-spacing-form-" });
|
||||
try {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: workspace.projectKey,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
});
|
||||
const composer = await box(page.locator('[data-testid="message-input-root"]:visible'));
|
||||
const importButton = await box(page.getByTestId("new-workspace-import-session"));
|
||||
expect(importButton.top - composer.bottom).toBe(16);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -78,7 +78,8 @@ test("composer diff stat reveals Changes, then opens the diff in the configured
|
||||
const workspace = await seedChangedAgent("composer-diff-stat-side-");
|
||||
|
||||
try {
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
// Keep both workspace rails visible alongside app navigation and Explorer.
|
||||
await page.setViewportSize({ width: 1900, height: 900 });
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentId: workspace.agentId,
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
expectMobileAgentSidebarVisible,
|
||||
expectMobileExplorerHidden,
|
||||
expectMobileExplorerVisible,
|
||||
swipeOpenMobileAgentSidebar,
|
||||
openMobileAgentSidebarWithKeyboard,
|
||||
swipeOpenMobileExplorer,
|
||||
} from "../support/helpers/sidebar";
|
||||
import { readScrollMetrics } from "../support/helpers/agent-bottom-anchor";
|
||||
@@ -249,7 +249,7 @@ test.describe("touch screen wide enough for the desktop layout", () => {
|
||||
const COMPACT_PANELS = [
|
||||
{
|
||||
name: "agent list",
|
||||
open: swipeOpenMobileAgentSidebar,
|
||||
open: openMobileAgentSidebarWithKeyboard,
|
||||
expectOpen: expectMobileAgentSidebarVisible,
|
||||
close: closeMobileAgentSidebar,
|
||||
expectClosed: expectMobileAgentSidebarHidden,
|
||||
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
closeMobileAgentSidebar,
|
||||
expectMobileAgentSidebarHidden,
|
||||
expectMobileAgentSidebarVisible,
|
||||
swipeOpenMobileAgentSidebar,
|
||||
openMobileAgentSidebarWithKeyboard,
|
||||
} from "../support/helpers/sidebar";
|
||||
|
||||
async function composerHeight(page: Page): Promise<number> {
|
||||
@@ -541,8 +541,8 @@ test("the compact sidebar over a fullscreen editor leaves the exit action alone"
|
||||
await writeCompactDraft(page, draft);
|
||||
await enterComposerFullscreen(page);
|
||||
await expectWorkspaceHeaderHidden(page);
|
||||
// Fullscreen hides the header's menu button; the edge swipe still opens the list over it.
|
||||
await swipeOpenMobileAgentSidebar(page);
|
||||
// Fullscreen hides the header's menu button; the keyboard shortcut opens the list over it.
|
||||
await openMobileAgentSidebarWithKeyboard(page);
|
||||
await expectMobileAgentSidebarVisible(page);
|
||||
// The list covers the pane, so the editor's exit action is not armed.
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { installLocalDesktopHost } from "../support/helpers/local-desktop-host";
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
|
||||
import {
|
||||
@@ -9,6 +11,7 @@ import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
test.describe("Direct terminal shortcut pane placement", () => {
|
||||
test.use({ viewport: { width: 1600, height: 900 } });
|
||||
test("opens a terminal in the focused pane", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "direct-terminal-shortcut-pane-" });
|
||||
|
||||
@@ -47,3 +50,88 @@ test.describe("Direct terminal shortcut pane placement", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function prepareUnfocusedCompactPane(page: Page, workspaceId: string) {
|
||||
await page.setViewportSize({ width: 1600, height: 900 });
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await clickNewChat(page);
|
||||
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||
const draftPane = page
|
||||
.locator('[data-testid^="workspace-pane-"]')
|
||||
.filter({ has: page.getByTestId("message-input-root") });
|
||||
const emptyPane = page
|
||||
.locator('[data-testid^="workspace-pane-"]')
|
||||
.filter({ has: page.getByTestId("workspace-new-tab-panel") });
|
||||
const draftPaneId = await draftPane.getAttribute("data-testid");
|
||||
const emptyPaneId = await emptyPane.getAttribute("data-testid");
|
||||
expect(draftPaneId).not.toBeNull();
|
||||
expect(emptyPaneId).not.toBeNull();
|
||||
await page.setViewportSize({ width: 760, height: 900 });
|
||||
return {
|
||||
originalPane: page.getByTestId(draftPaneId!),
|
||||
otherPane: page.getByTestId(emptyPaneId!),
|
||||
};
|
||||
}
|
||||
|
||||
async function launchTerminalFromCompactPane(page: Page, pane: Locator) {
|
||||
await pane.getByTestId("workspace-tab-switcher-trigger").click();
|
||||
await page.getByTestId("workspace-tab-switcher-new-terminal").click();
|
||||
await expect(pane.getByTestId("terminal-surface")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function maximizeAndRestoreCompactPane(page: Page, pane: Locator) {
|
||||
await pane.getByTestId("workspace-header-menu-trigger").click();
|
||||
await expect(page.getByRole("menuitem", { name: "Split pane right", exact: true })).toBeVisible();
|
||||
await page.getByRole("menuitem", { name: "Maximize pane", exact: true }).click();
|
||||
await pane.getByTestId("workspace-header-menu-trigger").click();
|
||||
await page.getByRole("menuitem", { name: "Restore pane", exact: true }).click();
|
||||
}
|
||||
|
||||
test("compact pane launch stays in its owning pane and pane controls remain reachable", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "compact-pane-launch-" });
|
||||
try {
|
||||
const { originalPane, otherPane } = await prepareUnfocusedCompactPane(
|
||||
page,
|
||||
workspace.workspaceId,
|
||||
);
|
||||
await launchTerminalFromCompactPane(page, originalPane);
|
||||
await expect(otherPane.getByTestId("workspace-new-tab-panel")).toBeVisible();
|
||||
await maximizeAndRestoreCompactPane(page, originalPane);
|
||||
await expect(otherPane).toBeVisible();
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function expectPaneHasWindowDragSurface(pane: Locator) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
pane.evaluate((element) =>
|
||||
Array.from(element.querySelectorAll<HTMLElement>("div")).some(
|
||||
(node) => node.style.getPropertyValue("-webkit-app-region") === "drag",
|
||||
),
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
test("compact desktop panes retain window dragging with both sidebars closed", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "compact-pane-window-drag-" });
|
||||
try {
|
||||
await installLocalDesktopHost(page, workspace.repoPath);
|
||||
const { originalPane, otherPane } = await prepareUnfocusedCompactPane(
|
||||
page,
|
||||
workspace.workspaceId,
|
||||
);
|
||||
await runWorkspaceActionFromCommandCenter(page, "Toggle left sidebar");
|
||||
await expect(originalPane.getByTestId("workspace-tab-switcher-trigger")).toBeVisible();
|
||||
await expect(otherPane.getByTestId("workspace-tab-switcher-trigger")).toBeVisible();
|
||||
await expectPaneHasWindowDragSurface(originalPane);
|
||||
await expectPaneHasWindowDragSurface(otherPane);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -56,9 +56,9 @@ test.describe("New tab keyboard launcher", () => {
|
||||
await expectLauncherSelection(launcher, "Diff");
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expect(
|
||||
page.getByTestId("workspace-tab-working_diff").filter({ visible: true }),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("working-diff-panel").filter({ visible: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
|
||||
@@ -105,6 +105,9 @@ function collectConsoleErrors(page: Page): string[] {
|
||||
return errors;
|
||||
}
|
||||
|
||||
// These drag and tab-menu journeys need full rails beside the docked Explorer.
|
||||
test.use({ viewport: { width: 1900, height: 900 } });
|
||||
|
||||
test.describe("explorer pane tab placement", () => {
|
||||
test("agents open in the main pane and New-tab splits keep the app usable", async ({
|
||||
page,
|
||||
|
||||
@@ -435,7 +435,8 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
});
|
||||
|
||||
try {
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
// Keep both tab rails available alongside the docked Explorer.
|
||||
await page.setViewportSize({ width: 1900, height: 900 });
|
||||
await openAgentRoute(page, session);
|
||||
|
||||
await openWorkspaceFile(page, "target.ts");
|
||||
|
||||
@@ -12,11 +12,15 @@ import {
|
||||
openAppAsNewUser,
|
||||
openRelayPairingFromSetup,
|
||||
closeRelayPairing,
|
||||
emptyHome,
|
||||
loadingScreen,
|
||||
openTailscaleDocsFromSetup,
|
||||
paseoToolsSwitch,
|
||||
pickTheme,
|
||||
projectSuggestion,
|
||||
projectSuggestionRow,
|
||||
readFirstLaunch,
|
||||
recordFirstLaunch,
|
||||
setupModal,
|
||||
setupPrimaryButton,
|
||||
showSetupAgainFromSettings,
|
||||
@@ -99,6 +103,139 @@ test("a new user is walked through setup once", async ({ page }, testInfo) => {
|
||||
}
|
||||
});
|
||||
|
||||
test("a new user goes from the loading screen straight into setup", async ({ page }) => {
|
||||
const host = await startSetupHost("first-launch");
|
||||
|
||||
try {
|
||||
await recordFirstLaunch(page);
|
||||
await openAppAsNewUser(page, host);
|
||||
await expectSetupStep(page, "Welcome to Paseo");
|
||||
|
||||
const launch = await readFirstLaunch(page);
|
||||
expect(launch.appBeforeSetup).toBe(false);
|
||||
expect(launch.setupOpacityOnArrival).toBeLessThan(1);
|
||||
await expect(setupModal(page)).toHaveCSS("opacity", "1");
|
||||
} finally {
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("with reduced motion, setup appears without fading in", async ({ page }) => {
|
||||
const host = await startSetupHost("reduced-motion");
|
||||
|
||||
try {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await recordFirstLaunch(page);
|
||||
await openAppAsNewUser(page, host);
|
||||
await expectSetupStep(page, "Welcome to Paseo");
|
||||
|
||||
expect((await readFirstLaunch(page)).setupOpacityOnArrival).toBe(1);
|
||||
} finally {
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("changing the theme keeps the setup window in place on every step", async ({ page }) => {
|
||||
const host = await startSetupHost("theme-change");
|
||||
|
||||
try {
|
||||
await openAppAsNewUser(page, host);
|
||||
await expectSetupStep(page, "Welcome to Paseo");
|
||||
const setupWindow = await setupModal(page).elementHandle();
|
||||
|
||||
for (const title of [
|
||||
"Welcome to Paseo",
|
||||
"Your agents",
|
||||
"Add your projects",
|
||||
"Let agents use Paseo",
|
||||
"Take your agents with you",
|
||||
]) {
|
||||
if (title !== "Welcome to Paseo") await continueSetup(page);
|
||||
await expectSetupStep(page, title);
|
||||
const heading = setupModal(page).getByRole("heading", { name: title });
|
||||
const headingNode = await heading.elementHandle();
|
||||
const lightColor = await heading.evaluate((node) => getComputedStyle(node).color);
|
||||
await page.emulateMedia({ colorScheme: "dark" });
|
||||
await expect(heading).not.toHaveCSS("color", lightColor);
|
||||
await page.emulateMedia({ colorScheme: "light" });
|
||||
await expect(heading).toHaveCSS("color", lightColor);
|
||||
expect(await setupWindow?.evaluate((node) => node.isConnected), title).toBe(true);
|
||||
expect(await headingNode?.evaluate((node) => node.isConnected), title).toBe(true);
|
||||
}
|
||||
|
||||
await goToSetupStep(page, "Appearance");
|
||||
await pickTheme(page, "Midnight");
|
||||
await pickTheme(page, "Light");
|
||||
expect(await setupWindow?.evaluate((node) => node.isConnected)).toBe(true);
|
||||
} finally {
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("the loading screen stays up while the setup host loads its workspaces", async ({ page }) => {
|
||||
const host = await startSetupHost("slow-workspaces");
|
||||
const gate = await installDaemonWebSocketGate(page, { port: host.port });
|
||||
|
||||
try {
|
||||
await recordFirstLaunch(page);
|
||||
gate.holdNextClientRequest("fetch_workspaces_request");
|
||||
await openAppAsNewUser(page, host);
|
||||
await gate.waitForHeldClientRequest();
|
||||
await expect(loadingScreen(page)).toBeVisible();
|
||||
await expect(emptyHome(page)).toHaveCount(0);
|
||||
|
||||
gate.releaseHeldClientRequest();
|
||||
await expectSetupStep(page, "Welcome to Paseo");
|
||||
expect((await readFirstLaunch(page)).appBeforeSetup).toBe(false);
|
||||
} finally {
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a setup host that never loads its workspaces still lands on the home", async ({ page }) => {
|
||||
const host = await startSetupHost("stuck-workspaces");
|
||||
const gate = await installDaemonWebSocketGate(page, { port: host.port });
|
||||
|
||||
try {
|
||||
gate.holdNextClientRequest("fetch_workspaces_request");
|
||||
await openAppAsNewUser(page, host);
|
||||
await gate.waitForHeldClientRequest();
|
||||
await expect(emptyHome(page)).toBeVisible({ timeout: 15_000 });
|
||||
await expect(setupModal(page)).toHaveCount(0);
|
||||
|
||||
// Setup still opens once the host answers.
|
||||
gate.releaseHeldClientRequest();
|
||||
await expectSetupStep(page, "Welcome to Paseo");
|
||||
} finally {
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a setup host that drops while loading still lands on the home", async ({ page }) => {
|
||||
const host = await startSetupHost("dropped-workspaces");
|
||||
const gate = await installDaemonWebSocketGate(page, { port: host.port });
|
||||
|
||||
try {
|
||||
gate.holdNextClientRequest("fetch_workspaces_request");
|
||||
await openAppAsNewUser(page, host);
|
||||
await gate.waitForHeldClientRequest();
|
||||
await gate.drop();
|
||||
await expect(emptyHome(page)).toBeVisible({ timeout: 15_000 });
|
||||
await expect(setupModal(page)).toHaveCount(0);
|
||||
} finally {
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("an unreachable setup host lands on the home without setup", async ({ page }) => {
|
||||
const host = await startSetupHost("unreachable");
|
||||
await host.close();
|
||||
|
||||
await openAppAsNewUser(page, host);
|
||||
await expect(emptyHome(page)).toBeVisible({ timeout: 15_000 });
|
||||
await expect(setupModal(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("setup suggests the repos the user already ran agents in", async ({ page }, testInfo) => {
|
||||
const repo = await createTempGitRepo("onboarding-history-");
|
||||
const repoName = path.basename(repo.path);
|
||||
|
||||
@@ -97,6 +97,7 @@ test("fixed footer line keeps its five icons, Help and Settings at the end", asy
|
||||
await setFooterItemVisible(page, "usage", true);
|
||||
await leaveSettings(page);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openCompactSidebar(page);
|
||||
await expectCompactSidebarOpen(page);
|
||||
await expectFooterSeparator(page, true);
|
||||
await expectFooterIconRow(page);
|
||||
|
||||
@@ -652,6 +652,9 @@ test.describe("Half-screen desktop layout", () => {
|
||||
.locator('[data-testid^="workspace-pane-"]')
|
||||
.getByTestId("workspace-tabs-row")
|
||||
.filter({ visible: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByTestId("workspace-tab-switcher-trigger").filter({ visible: true }),
|
||||
).toHaveCount(1);
|
||||
|
||||
await explorerToggle.click();
|
||||
|
||||
@@ -38,8 +38,12 @@ async function openParentInRightPane(page: Page, parentId: string, workspaceId:
|
||||
await expect(left).toBeVisible();
|
||||
await expect(right).toBeVisible();
|
||||
await expect(right).not.toHaveAttribute("data-testid", "workspace-pane-main");
|
||||
// A later split can replace the parent pane's rail with compact chrome.
|
||||
// Keep the pane identity instead of finding it again through that rail.
|
||||
const rightPaneId = await right.getAttribute("data-testid");
|
||||
expect(rightPaneId).not.toBeNull();
|
||||
await left.locator('[data-testid^="workspace-tab-draft_"]').click();
|
||||
return { left, right };
|
||||
return { left, right: page.getByTestId(rightPaneId!) };
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
|
||||
@@ -414,6 +414,8 @@ test("searches retained output without sending Find input to the shell", async (
|
||||
test("keeps terminal Find owned by retained tabs and the focused split pane", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
// This journey selects tabs from the full rails in both split panes.
|
||||
await page.setViewportSize({ width: 1600, height: 900 });
|
||||
const first = await harness.createTerminal({
|
||||
name: "First search",
|
||||
command: "bash",
|
||||
@@ -627,36 +629,3 @@ async function dragTerminalWithFinger(
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Diagnostic-only: application modes emitted before the first attachment.
|
||||
// The existing live-mode tests above are the control for this failure.
|
||||
test.describe("mouse mode enabled before terminal attachment", () => {
|
||||
test.use({ isMobile: true, hasTouch: true, viewport: { width: 390, height: 844 } });
|
||||
|
||||
test("preserves SGR wheel reports when opening a running terminal", async ({ page }) => {
|
||||
await openPreAttachedMouseCapture(page);
|
||||
await expectFingerNavigationInput(page, "mouse");
|
||||
});
|
||||
});
|
||||
|
||||
async function openPreAttachedMouseCapture(page: Page) {
|
||||
await harness.createTerminal({
|
||||
name: "Mouse mode before attachment",
|
||||
command: process.execPath,
|
||||
args: [
|
||||
"-e",
|
||||
`
|
||||
process.stdin.setRawMode(true);
|
||||
process.stdin.on("data", chunk => {
|
||||
process.stdout.write("\\r\\nTOUCH_BYTES:" + chunk.toString("hex") + ":END\\r\\n");
|
||||
});
|
||||
process.stdout.write("\\x1b[?1000h\\x1b[?1006hTOUCH_INPUT_READY\\r\\n");
|
||||
`,
|
||||
],
|
||||
});
|
||||
await openPhoneTerminal(page);
|
||||
await expect.poll(() => getTerminalBufferText(page)).toContain("TOUCH_INPUT_READY");
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.__paseoTerminal!.modes.mouseTrackingMode))
|
||||
.toBe("vt200");
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import path from "node:path";
|
||||
import {
|
||||
holdOlderHistoryPages,
|
||||
scrollTimelineRowIntoView,
|
||||
userScrollsTimelineToHistoryStart,
|
||||
} from "../support/helpers/timeline-pagination";
|
||||
import { getActiveTabTestId } from "../support/helpers/launcher";
|
||||
@@ -529,6 +530,7 @@ async function expectThinkingPresentation(page: Page, autoExpandReasoning: boole
|
||||
.last();
|
||||
await expect(group).toContainText(/read 1 file/i);
|
||||
await expect(group.getByRole("button").first()).not.toContainText(/thought/i);
|
||||
await scrollTimelineRowIntoView(page, group);
|
||||
await group.getByRole("button").first().click();
|
||||
// Expand thinking is off: the group opens its rows, but the thought row stays closed
|
||||
// until the reader opens it.
|
||||
|
||||
@@ -357,6 +357,8 @@ test.describe("Viewed agent timelines", () => {
|
||||
});
|
||||
|
||||
test("two visible split chats both stay current", async ({ page }) => {
|
||||
// Keep both split panes wide enough for the tab-rail interactions below.
|
||||
await page.setViewportSize({ width: 1600, height: 900 });
|
||||
const scenario = await seedViewedTimelineScenario();
|
||||
try {
|
||||
await enableMoveTabShortcut(page);
|
||||
|
||||
@@ -101,3 +101,20 @@ test("focus mode only applies to the active workspace screen", async ({ page, wi
|
||||
await expect(exitFocusMode).toHaveCount(0);
|
||||
await expect(settingsButton).toBeVisible();
|
||||
});
|
||||
|
||||
async function exitFocusModeFromCompactHeader(page: Page) {
|
||||
await page.setViewportSize({ width: 400, height: 844 });
|
||||
await page.getByTestId("workspace-header-menu-trigger").click();
|
||||
await page.getByRole("menuitem", { name: "Exit focus mode", exact: true }).click();
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
}
|
||||
|
||||
test("compact workspace menu keeps focus mode exit reachable", async ({ page, withWorkspace }) => {
|
||||
const workspace = await withWorkspace({ prefix: "compact-focus-exit-" });
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await workspace.navigateTo();
|
||||
await enterFocusMode(page);
|
||||
await exitFocusModeFromCompactHeader(page);
|
||||
await expect(exitFocusModeButton(page)).toHaveCount(0);
|
||||
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -52,6 +52,8 @@ async function waitForWorkspaceRoute(page: Page, route: string) {
|
||||
test.describe("Workspace pane mounting", () => {
|
||||
test("workspace navigation keeps the existing agent composer mounted", async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
// Keep both split panes wide enough for the tab-rail interactions below.
|
||||
await page.setViewportSize({ width: 1600, height: 900 });
|
||||
const serverId = getServerId();
|
||||
|
||||
const workspace = await seedWorkspace({ repoPrefix: "pane-remount-" });
|
||||
|
||||
@@ -34,6 +34,54 @@ export async function openAppAsNewUser(
|
||||
await page.goto("/");
|
||||
}
|
||||
|
||||
interface FirstLaunchRecord {
|
||||
appBeforeSetup: boolean;
|
||||
setupOpacityOnArrival: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Watches the page from its first paint: whether the app's sidebar or home showed before setup,
|
||||
* and how opaque setup was when it arrived. Call before `openAppAsNewUser`.
|
||||
*/
|
||||
export async function recordFirstLaunch(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
const record: FirstLaunchRecord = { appBeforeSetup: false, setupOpacityOnArrival: null };
|
||||
Object.assign(window, { __firstLaunch: record });
|
||||
const observer = new MutationObserver(() => {
|
||||
const setup = document.querySelector('[data-testid="onboarding-modal"] [role="dialog"]');
|
||||
if (setup) {
|
||||
record.setupOpacityOnArrival = Number(getComputedStyle(setup).opacity);
|
||||
observer.disconnect();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
document.querySelector(
|
||||
'[data-testid="open-project-submit"], [data-testid="sidebar-settings"]',
|
||||
)
|
||||
) {
|
||||
record.appBeforeSetup = true;
|
||||
}
|
||||
});
|
||||
observer.observe(document, { childList: true, subtree: true });
|
||||
});
|
||||
}
|
||||
|
||||
export async function readFirstLaunch(page: Page): Promise<FirstLaunchRecord> {
|
||||
return page.evaluate(
|
||||
() => (window as unknown as { __firstLaunch: FirstLaunchRecord }).__firstLaunch,
|
||||
);
|
||||
}
|
||||
|
||||
/** The loading screen the app shows while it starts. */
|
||||
export function loadingScreen(page: Page): Locator {
|
||||
return page.getByTestId("startup-splash");
|
||||
}
|
||||
|
||||
/** The empty home a host with no projects lands on. */
|
||||
export function emptyHome(page: Page): Locator {
|
||||
return page.getByTestId("open-project-submit");
|
||||
}
|
||||
|
||||
export function setupModal(page: Page): Locator {
|
||||
return page.getByTestId("onboarding-modal").getByRole("dialog");
|
||||
}
|
||||
|
||||
@@ -81,7 +81,13 @@ export async function scrollWorkspaceList(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
export async function expectWorkspaceHoverCardOpen(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("workspace-hover-card")).toBeVisible();
|
||||
const cards = page.getByTestId("workspace-hover-card");
|
||||
// Keyboard navigation can leave the previous card closing while the next opens.
|
||||
// Retry both checks together: another card may mount between count and visibility.
|
||||
await expect(async () => {
|
||||
expect(await cards.count()).toBe(1);
|
||||
expect(await cards.isVisible()).toBe(true);
|
||||
}).toPass({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
export async function expectWorkspaceHoverCardStaysOpen(page: Page): Promise<void> {
|
||||
@@ -261,16 +267,9 @@ export async function expectMobileAgentSidebarHidden(page: Page): Promise<void>
|
||||
await expect(page.getByTestId("sidebar-sessions")).not.toBeInViewport({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
/** Drag from the left edge: the only way to open the list while fullscreen hides the header. */
|
||||
export async function swipeOpenMobileAgentSidebar(page: Page): Promise<void> {
|
||||
const viewport = page.viewportSize();
|
||||
if (!viewport) throw new Error("viewport size is required");
|
||||
const y = Math.round(viewport.height / 2);
|
||||
await page.mouse.move(8, y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(40, y, { steps: 4 });
|
||||
await page.mouse.move(Math.round(viewport.width * 0.7), y, { steps: 12 });
|
||||
await page.mouse.up();
|
||||
/** Opens navigation without moving focus to the header, including over a fullscreen editor. */
|
||||
export async function openMobileAgentSidebarWithKeyboard(page: Page): Promise<void> {
|
||||
await page.keyboard.press("ControlOrMeta+b");
|
||||
}
|
||||
|
||||
/** Drag from the right edge to open the compact Explorer over the agent. */
|
||||
|
||||
@@ -477,18 +477,29 @@ export async function expectStableHistoryStartGutter(page: Page): Promise<void>
|
||||
.toBe(32);
|
||||
}
|
||||
|
||||
export async function scrollTimelinePromptIntoView(page: Page, prompt: string): Promise<void> {
|
||||
/**
|
||||
* Wheels the timeline until `row` is centered, as a reader does. Playwright's own
|
||||
* scroll-into-view is programmatic, which the timeline does not treat as the reader leaving
|
||||
* the bottom: it snaps back to the bottom and can move the row away mid-click.
|
||||
*/
|
||||
export async function scrollTimelineRowIntoView(page: Page, row: Locator): Promise<void> {
|
||||
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
const row = timeline.getByTestId("user-message").filter({ hasText: prompt });
|
||||
const [viewport, target] = await Promise.all([timeline.boundingBox(), row.boundingBox()]);
|
||||
if (!viewport || !target) {
|
||||
throw new Error(`Expected a rendered timeline prompt for ${prompt}`);
|
||||
throw new Error("Expected a rendered timeline row");
|
||||
}
|
||||
// Programmatic scrolling does not express the user's intent to stop following output.
|
||||
await timeline.hover();
|
||||
await page.mouse.wheel(0, target.y - viewport.y - (viewport.height - target.height) / 2);
|
||||
await expect(row).toBeInViewport({ ratio: 1 });
|
||||
await waitForTimelineGeometryToSettle(page);
|
||||
}
|
||||
|
||||
export async function scrollTimelinePromptIntoView(page: Page, prompt: string): Promise<void> {
|
||||
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
await scrollTimelineRowIntoView(
|
||||
page,
|
||||
timeline.getByTestId("user-message").filter({ hasText: prompt }),
|
||||
);
|
||||
await expect
|
||||
.poll(async () => (await readTimelineViewport(page)).scrollTop)
|
||||
.toBeGreaterThan(HISTORY_START_THRESHOLD_PX);
|
||||
|
||||
@@ -17,6 +17,7 @@ final class WindowLayoutView: ExpoView {
|
||||
private var divisions: [CGRect] = []
|
||||
private var barGuide: UILayoutGuide?
|
||||
private var barEdge = "none"
|
||||
private var bottomBarGuide: UILayoutGuide?
|
||||
private var measuredBounds = CGRect.zero
|
||||
|
||||
required init(appContext: AppContext? = nil) {
|
||||
@@ -36,6 +37,7 @@ final class WindowLayoutView: ExpoView {
|
||||
override func didMoveToWindow() {
|
||||
super.didMoveToWindow()
|
||||
previousPayload = nil
|
||||
bottomBarGuide = nil
|
||||
setNeedsUpdateConstraints()
|
||||
scheduleUpdate()
|
||||
}
|
||||
@@ -66,6 +68,11 @@ final class WindowLayoutView: ExpoView {
|
||||
case .trailing: edge = "trailing"
|
||||
default: edge = "none"
|
||||
}
|
||||
if bottomBarGuide == nil {
|
||||
// Where a bottom bar rests: above the home indicator, refined to the
|
||||
// screen's corners rather than the rectangular safe area.
|
||||
bottomBarGuide = layoutGuide(for: .bar(onEdge: UIRectEdge.bottom, extent: 44))
|
||||
}
|
||||
if edge != barEdge {
|
||||
barEdge = edge
|
||||
barGuide = edge == "none" ? nil : layoutGuide(
|
||||
@@ -93,10 +100,17 @@ final class WindowLayoutView: ExpoView {
|
||||
let bar = barGuide?.layoutFrame
|
||||
var barPayload: Any = NSNull()
|
||||
if let bar, bar.width > 0, bar.height > 0 { barPayload = rect(bar) }
|
||||
// Before UIKit resolves the guide its frame has no height: publish null (the
|
||||
// system inset applies) rather than a false zero clearance.
|
||||
var bottomClearance: Any = NSNull()
|
||||
if let bottomBar = bottomBarGuide?.layoutFrame, bottomBar.height > 0 {
|
||||
bottomClearance = max(0, bounds.maxY - bottomBar.maxY)
|
||||
}
|
||||
let payload: [String: Any] = [
|
||||
"bounds": rect(bounds),
|
||||
"verticalBarEdge": barEdge,
|
||||
"verticalBar": barPayload,
|
||||
"bottomClearance": bottomClearance,
|
||||
"divisions": divisions.map(rect)
|
||||
]
|
||||
guard !NSDictionary(dictionary: payload).isEqual(to: previousPayload ?? [:]) else { return }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.15.0",
|
||||
"version": "0.15.2",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
@@ -45,7 +46,7 @@ import { DownloadToast } from "@/components/download-toast";
|
||||
import { QuittingOverlay } from "@/components/quitting-overlay";
|
||||
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
||||
import { ChangelogHost } from "@/changelog";
|
||||
import { OnboardingHost } from "@/onboarding";
|
||||
import { OnboardingHost, useIsSetupDecisionPending } from "@/onboarding";
|
||||
import { AppDiagnosticHost } from "@/components/app-diagnostic-host";
|
||||
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
|
||||
import { LeftSidebar } from "@/components/left-sidebar";
|
||||
@@ -68,6 +69,7 @@ import {
|
||||
resolveDesktopAppContentMinimum,
|
||||
resolveDesktopSidebarVisibility,
|
||||
resolveSidebarActivity,
|
||||
resolveLeftSidebarPresentation,
|
||||
} from "@/components/desktop-sidebar-layout";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { HorizontalScrollProvider } from "@/contexts/horizontal-scroll-context";
|
||||
@@ -121,7 +123,7 @@ import {
|
||||
useHosts,
|
||||
} from "@/runtime/host-runtime";
|
||||
import { getDaemonStartService } from "@/runtime/daemon-start-service";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { usePanelStore, type LeftSidebarPresentation } from "@/stores/panel-store";
|
||||
import { flushDraftPersistStorage } from "@/stores/draft-store";
|
||||
import { getNextThemePreference, ICON_STROKE_WIDTH } from "@/styles/theme";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
@@ -149,6 +151,7 @@ export interface HostRuntimeBootstrapState {
|
||||
splashError: string | null;
|
||||
retry: () => void;
|
||||
hasGivenUpWaitingForHost: boolean;
|
||||
isWaitingForSetup: boolean;
|
||||
storeReady: boolean;
|
||||
startupBlocker: StartupBlocker;
|
||||
}
|
||||
@@ -157,6 +160,7 @@ const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
|
||||
splashError: null,
|
||||
retry: () => {},
|
||||
hasGivenUpWaitingForHost: false,
|
||||
isWaitingForSetup: false,
|
||||
storeReady: false,
|
||||
startupBlocker: { kind: "none" },
|
||||
});
|
||||
@@ -253,6 +257,9 @@ function useDaemonStartIsRunning(): boolean {
|
||||
}
|
||||
|
||||
const STARTUP_GIVE_UP_TIMEOUT_MS = 5_000;
|
||||
// How long the splash waits on top of host startup to learn whether setup opens. A slow host
|
||||
// lands on the home instead, and setup opens over it once it is decided.
|
||||
const STARTUP_SETUP_WAIT_TIMEOUT_MS = 3_000;
|
||||
|
||||
async function shouldStartBuiltInDaemon(): Promise<boolean> {
|
||||
if (!shouldUseDesktopDaemon()) {
|
||||
@@ -314,6 +321,23 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
};
|
||||
}, [shouldRunGiveUpTimer]);
|
||||
|
||||
const isSetupDecisionPending = useIsSetupDecisionPending();
|
||||
const [hasGivenUpWaitingForSetup, setHasGivenUpWaitingForSetup] = useState(false);
|
||||
const isWaitingForSetup = isSetupDecisionPending && !hasGivenUpWaitingForSetup;
|
||||
const shouldRunSetupWaitTimer = isWaitingForSetup && startupBlocker.kind === "none";
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldRunSetupWaitTimer) {
|
||||
return;
|
||||
}
|
||||
const handle = setTimeout(() => {
|
||||
setHasGivenUpWaitingForSetup(true);
|
||||
}, STARTUP_SETUP_WAIT_TIMEOUT_MS);
|
||||
return () => {
|
||||
clearTimeout(handle);
|
||||
};
|
||||
}, [shouldRunSetupWaitTimer]);
|
||||
|
||||
const retry = useCallback(() => {
|
||||
const daemonStartService = getDaemonStartService({ store: getHostRuntimeStore() });
|
||||
void daemonStartService.startIfEnabled({ shouldStart: shouldStartBuiltInDaemon });
|
||||
@@ -324,8 +348,15 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
const storeReady = resolveStartupNavigationReady({ startupBlocker });
|
||||
|
||||
const state = useMemo<HostRuntimeBootstrapState>(
|
||||
() => ({ splashError, retry, hasGivenUpWaitingForHost, storeReady, startupBlocker }),
|
||||
[splashError, retry, hasGivenUpWaitingForHost, storeReady, startupBlocker],
|
||||
() => ({
|
||||
splashError,
|
||||
retry,
|
||||
hasGivenUpWaitingForHost,
|
||||
isWaitingForSetup,
|
||||
storeReady,
|
||||
startupBlocker,
|
||||
}),
|
||||
[splashError, retry, hasGivenUpWaitingForHost, isWaitingForSetup, storeReady, startupBlocker],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -441,6 +472,17 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
viewportWidth,
|
||||
}),
|
||||
});
|
||||
const sidebarPresentation = resolveLeftSidebarPresentation({
|
||||
chromeEnabled,
|
||||
isCompactLayout,
|
||||
isFocusModeEnabled: isWorkspaceFocusModeEnabled,
|
||||
desktopSidebarVisible,
|
||||
});
|
||||
const applyPanelLayout = usePanelStore((state) => state.applyPanelLayout);
|
||||
// Publishes the layout to the store, which owns the panel targets each layout allows.
|
||||
useLayoutEffect(() => {
|
||||
applyPanelLayout({ isCompact: isCompactLayout, leftSidebar: sidebarPresentation });
|
||||
}, [applyPanelLayout, isCompactLayout, sidebarPresentation]);
|
||||
const hasTopLeftWindowControls = useHasWindowChromeObstruction("top-left");
|
||||
const appChromeLayout = resolveDesktopAppChromeLayout({
|
||||
desktopSidebarRendered: desktopSidebarVisible,
|
||||
@@ -450,7 +492,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
const sidebarChrome = (
|
||||
<SidebarChrome
|
||||
mounted={isCompactLayout ? chromeEnabled : desktopSidebarMounted}
|
||||
visible={isCompactLayout ? chromeEnabled : desktopSidebarVisible}
|
||||
presentation={sidebarPresentation}
|
||||
keyboardShortcutsEnabled={keyboardShortcutsEnabled}
|
||||
/>
|
||||
);
|
||||
@@ -516,7 +558,6 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
<WorkspacePinShortcutHandler />
|
||||
<WorkspaceRenameHost />
|
||||
<CommandCenter />
|
||||
<OnboardingHost />
|
||||
<AddProjectFlowHost />
|
||||
<HostChooserModal />
|
||||
<HostConfirmationSheet />
|
||||
@@ -527,11 +568,15 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
<ChangelogHost />
|
||||
<QuittingOverlay />
|
||||
</AppearanceStyleBoundary>
|
||||
{/* Setup changes the theme while it is open, so it stays out of the remount above. Its
|
||||
colors are CSS variables and themed props, which update in place, and setup changes no
|
||||
font size. */}
|
||||
<OnboardingHost />
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<AppContainerLifetime compactGesturesEnabled={chromeEnabled && isCompactLayout}>
|
||||
<AppContainerLifetime sidebarGesturesEnabled={sidebarPresentation === "overlay"}>
|
||||
<SheetHostScope enabled={isCompactLayout}>{surface}</SheetHostScope>
|
||||
</AppContainerLifetime>
|
||||
);
|
||||
@@ -539,15 +584,15 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
|
||||
function AppContainerLifetime({
|
||||
children,
|
||||
compactGesturesEnabled,
|
||||
sidebarGesturesEnabled,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
compactGesturesEnabled: boolean;
|
||||
sidebarGesturesEnabled: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ComposerEditingSessionsProvider>
|
||||
<CommandCenterProvider>
|
||||
<MobileGestureWrapper chromeEnabled={compactGesturesEnabled}>
|
||||
<MobileGestureWrapper chromeEnabled={sidebarGesturesEnabled}>
|
||||
{children}
|
||||
</MobileGestureWrapper>
|
||||
</CommandCenterProvider>
|
||||
@@ -557,23 +602,26 @@ function AppContainerLifetime({
|
||||
|
||||
function SidebarChrome({
|
||||
mounted,
|
||||
visible,
|
||||
presentation,
|
||||
keyboardShortcutsEnabled,
|
||||
}: {
|
||||
mounted: boolean;
|
||||
visible: boolean;
|
||||
presentation: LeftSidebarPresentation;
|
||||
keyboardShortcutsEnabled: boolean;
|
||||
}) {
|
||||
const isCompactLayout = useIsCompactFormFactor();
|
||||
const isMobileActive = useIsMobilePanelActive("agent-list");
|
||||
const active = resolveSidebarActivity(visible, isCompactLayout, isMobileActive);
|
||||
const overlay = presentation === "overlay";
|
||||
const active = resolveSidebarActivity(presentation !== "hidden", overlay, isMobileActive);
|
||||
const hardwareKeyboardPresent = useHardwareKeyboardPresent();
|
||||
return (
|
||||
<SidebarModelProvider
|
||||
active={active}
|
||||
keyboardNavigationActive={keyboardShortcutsEnabled && hardwareKeyboardPresent}
|
||||
>
|
||||
{mounted ? <LeftSidebar active={active} /> : null}
|
||||
{mounted ? (
|
||||
<LeftSidebar active={active} desktopOverlay={overlay && !isCompactLayout} />
|
||||
) : null}
|
||||
<WorkspaceShortcutTargetsSubscriber enabled={keyboardShortcutsEnabled} />
|
||||
</SidebarModelProvider>
|
||||
);
|
||||
@@ -586,8 +634,8 @@ function MobileGestureWrapper({
|
||||
children: ReactNode;
|
||||
chromeEnabled: boolean;
|
||||
}) {
|
||||
const openGesture = useOpenAgentListGesture(chromeEnabled);
|
||||
const isCompactLayout = useIsCompactFormFactor();
|
||||
const openGesture = useOpenAgentListGesture(chromeEnabled);
|
||||
|
||||
return (
|
||||
<GestureDetector gesture={openGesture} touchAction={MOBILE_WEB_GESTURE_TOUCH_ACTION}>
|
||||
|
||||
@@ -45,11 +45,19 @@ export default function Index() {
|
||||
}),
|
||||
isWorkspaceSelectionLoaded,
|
||||
hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost,
|
||||
isWaitingForSetup: bootstrapState.isWaitingForSetup,
|
||||
});
|
||||
|
||||
const splash = <StartupSplashScreen bootstrapState={isDesktop ? bootstrapState : undefined} />;
|
||||
if (startupRoute.kind === "redirect") {
|
||||
return <Redirect href={startupRoute.href} />;
|
||||
// The loading screen stays up until the target route paints; a redirect alone renders nothing.
|
||||
return (
|
||||
<>
|
||||
{splash}
|
||||
<Redirect href={startupRoute.href} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <StartupSplashScreen bootstrapState={isDesktop ? bootstrapState : undefined} />;
|
||||
return splash;
|
||||
}
|
||||
|
||||
@@ -232,6 +232,28 @@ const styles = StyleSheet.create((theme) => ({
|
||||
}));
|
||||
|
||||
const WEB_EXIT_DURATION_MS = 160;
|
||||
const WEB_ENTER_DURATION_MS = 160;
|
||||
const WEB_ENTER_ANIMATION_NAME = "paseo-modal-enter";
|
||||
|
||||
// The desktop card fades in; the dim lands at once, so the screen behind is never seen undimmed
|
||||
// while the dialog arrives. A CSS animation runs from the card's first paint, so a dialog that opens
|
||||
// on its own, like first-run setup, never pops in. Closing fades the whole overlay. With reduced
|
||||
// motion the keyframes are never defined, so the card appears at once.
|
||||
function ensureWebEnterKeyframes() {
|
||||
if (!isWeb || typeof document === "undefined") return;
|
||||
if (document.getElementById(WEB_ENTER_ANIMATION_NAME)) return;
|
||||
const style = document.createElement("style");
|
||||
style.id = WEB_ENTER_ANIMATION_NAME;
|
||||
style.textContent = `@media (prefers-reduced-motion: no-preference) { @keyframes ${WEB_ENTER_ANIMATION_NAME} { from { opacity: 0; } to { opacity: 1; } } }`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
ensureWebEnterKeyframes();
|
||||
// The CSS animation props are not part of ViewStyle without the generated expo-env.d.ts.
|
||||
const WEB_ENTER_STYLE = {
|
||||
animationName: WEB_ENTER_ANIMATION_NAME,
|
||||
animationDuration: `${WEB_ENTER_DURATION_MS}ms`,
|
||||
animationTimingFunction: "ease-out",
|
||||
} as unknown as ViewStyle;
|
||||
|
||||
/** The compact sheet's surface: Gorhom's `backgroundComponent` for every bottom sheet. */
|
||||
export function SheetBackground({ style }: BottomSheetBackgroundProps) {
|
||||
@@ -662,6 +684,7 @@ export function AdaptiveModalSheet({
|
||||
styles.desktopCard,
|
||||
desktopHeight != null && { height: desktopHeight },
|
||||
desktopMaxWidth != null && { maxWidth: desktopMaxWidth },
|
||||
isWeb && WEB_ENTER_STYLE,
|
||||
],
|
||||
[desktopMaxWidth, desktopHeight],
|
||||
);
|
||||
|
||||
@@ -9,9 +9,11 @@ import {
|
||||
canDesktopAppSidebarShare,
|
||||
resolveDesktopAppChromeLayout,
|
||||
resolveDesktopAppContentMinimum,
|
||||
resolveDesktopSidebarInsetLeft,
|
||||
resolveDesktopSidebarVisibility,
|
||||
resolveSidebarActivity,
|
||||
resolveDesktopSidebarWidth,
|
||||
resolveLeftSidebarPresentation,
|
||||
} from "@/components/desktop-sidebar-layout";
|
||||
|
||||
describe("desktop sidebar layout", () => {
|
||||
@@ -104,6 +106,40 @@ describe("desktop sidebar layout", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("offers an overlay only after wide navigation yields its dock", () => {
|
||||
const input = {
|
||||
chromeEnabled: true,
|
||||
isCompactLayout: false,
|
||||
isFocusModeEnabled: false,
|
||||
};
|
||||
expect(resolveLeftSidebarPresentation({ ...input, desktopSidebarVisible: true })).toBe("dock");
|
||||
expect(resolveLeftSidebarPresentation({ ...input, desktopSidebarVisible: false })).toBe(
|
||||
"overlay",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not reveal navigation over a focused workspace or suppressed chrome", () => {
|
||||
const input = {
|
||||
chromeEnabled: true,
|
||||
isCompactLayout: false,
|
||||
isFocusModeEnabled: false,
|
||||
desktopSidebarVisible: false,
|
||||
};
|
||||
expect(resolveLeftSidebarPresentation({ ...input, isFocusModeEnabled: true })).toBe("hidden");
|
||||
expect(resolveLeftSidebarPresentation({ ...input, chromeEnabled: false })).toBe("hidden");
|
||||
});
|
||||
|
||||
it("keeps phone navigation available independently of saved wide focus mode", () => {
|
||||
expect(
|
||||
resolveLeftSidebarPresentation({
|
||||
chromeEnabled: true,
|
||||
isCompactLayout: true,
|
||||
isFocusModeEnabled: true,
|
||||
desktopSidebarVisible: false,
|
||||
}),
|
||||
).toBe("overlay");
|
||||
});
|
||||
|
||||
it("keeps the sidebar toggle window-owned beside left window controls", () => {
|
||||
expect(
|
||||
resolveDesktopAppChromeLayout({
|
||||
@@ -207,3 +243,8 @@ it("reserves room for Explorer and the workspace before showing app navigation",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("insets an overlay sidebar's content by the left safe area and leaves the dock to app chrome", () => {
|
||||
expect(resolveDesktopSidebarInsetLeft({ overlay: true, safeAreaLeft: 20 })).toBe(20);
|
||||
expect(resolveDesktopSidebarInsetLeft({ overlay: false, safeAreaLeft: 20 })).toBe(0);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { EXPLORER_WORKSPACE_MIN_WIDTH } from "@/components/explorer-sidebar-layout";
|
||||
import { SETTINGS_DESKTOP_SPLIT_MIN_WIDTH } from "@/constants/layout";
|
||||
import { MAX_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH } from "@/stores/panel-store";
|
||||
import {
|
||||
MAX_SIDEBAR_WIDTH,
|
||||
MIN_SIDEBAR_WIDTH,
|
||||
type LeftSidebarPresentation,
|
||||
} from "@/stores/panel-store";
|
||||
import type { DesktopDivisionLayout } from "@/window-layout";
|
||||
|
||||
const MIN_DESKTOP_CENTER_WIDTH = 400;
|
||||
@@ -21,6 +25,33 @@ export function resolveDesktopSidebarVisibility(input: {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact layouts always present navigation as a drawer overlay. Wide layouts dock it when it is
|
||||
* visible and fall back to an overlay when the dock is collapsed or yields its width.
|
||||
*/
|
||||
export function resolveLeftSidebarPresentation(input: {
|
||||
chromeEnabled: boolean;
|
||||
isCompactLayout: boolean;
|
||||
isFocusModeEnabled: boolean;
|
||||
desktopSidebarVisible: boolean;
|
||||
}): LeftSidebarPresentation {
|
||||
if (!input.chromeEnabled) return "hidden";
|
||||
if (input.isCompactLayout) return "overlay";
|
||||
if (input.isFocusModeEnabled) return "hidden";
|
||||
return input.desktopSidebarVisible ? "dock" : "overlay";
|
||||
}
|
||||
|
||||
/**
|
||||
* The overlay sidebar starts at the window's left edge, over the workspace's safe-area padding, so
|
||||
* its content takes the left inset itself. The dock sits inside app chrome, which already pads it.
|
||||
*/
|
||||
export function resolveDesktopSidebarInsetLeft(input: {
|
||||
overlay: boolean;
|
||||
safeAreaLeft: number;
|
||||
}): number {
|
||||
return input.overlay ? input.safeAreaLeft : 0;
|
||||
}
|
||||
|
||||
export function resolveDesktopAppChromeLayout(input: {
|
||||
desktopSidebarRendered: boolean;
|
||||
hasTopLeftWindowControls: boolean;
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
beginExplorerSidebarResize,
|
||||
finishExplorerSidebarResize,
|
||||
reconcileExplorerSidebarResize,
|
||||
resolveExplorerSidebarDockSizes,
|
||||
resolveExplorerSidebarFrameWidth,
|
||||
resolveExplorerSidebarWidth,
|
||||
type ExplorerSidebarResize,
|
||||
} from "@/components/explorer-sidebar-layout";
|
||||
|
||||
describe("Explorer sidebar layout", () => {
|
||||
@@ -19,4 +24,196 @@ describe("Explorer sidebar layout", () => {
|
||||
expect(resolveExplorerSidebarWidth({ requestedWidth: 900, containerWidth: 1200 })).toBe(840);
|
||||
expect(resolveExplorerSidebarWidth({ requestedWidth: 600, containerWidth: 750 })).toBe(390);
|
||||
});
|
||||
|
||||
describe("native resize ownership", () => {
|
||||
const allocation = {
|
||||
width: 320,
|
||||
containerWidth: 1200,
|
||||
overlay: false,
|
||||
resizeEnabled: true,
|
||||
};
|
||||
const dragging: ExplorerSidebarResize = {
|
||||
sourceWidths: [allocation.width],
|
||||
restoreWidth: allocation.width,
|
||||
containerWidth: allocation.containerWidth,
|
||||
pendingWidth: null,
|
||||
};
|
||||
|
||||
it.each([false, true])(
|
||||
"keeps a second drag through the previous release acknowledgement (released=%s)",
|
||||
(releaseSecond) => {
|
||||
const first = finishExplorerSidebarResize(dragging, 480, true);
|
||||
const second = beginExplorerSidebarResize(first, allocation);
|
||||
const current = releaseSecond ? finishExplorerSidebarResize(second, 600, true) : second;
|
||||
const acknowledged = reconcileExplorerSidebarResize(current, { ...allocation, width: 480 });
|
||||
expect(
|
||||
resolveExplorerSidebarFrameWidth({
|
||||
...allocation,
|
||||
width: 480,
|
||||
resize: acknowledged,
|
||||
resizeWidth: 600,
|
||||
}),
|
||||
).toBe(600);
|
||||
const released = finishExplorerSidebarResize(acknowledged, 600, true);
|
||||
expect(released?.pendingWidth).toBe(600);
|
||||
expect(reconcileExplorerSidebarResize(released, { ...allocation, width: 600 })).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps a return to the minimum while an earlier wider release is acknowledged", () => {
|
||||
const initial = { ...allocation, width: 240 };
|
||||
const wider = finishExplorerSidebarResize(
|
||||
beginExplorerSidebarResize(null, initial),
|
||||
540,
|
||||
true,
|
||||
);
|
||||
const returned = finishExplorerSidebarResize(
|
||||
beginExplorerSidebarResize(wider, initial),
|
||||
240,
|
||||
true,
|
||||
);
|
||||
const awaiting = reconcileExplorerSidebarResize(returned, initial);
|
||||
const intermediate = reconcileExplorerSidebarResize(awaiting, { ...initial, width: 540 });
|
||||
expect(
|
||||
resolveExplorerSidebarFrameWidth({
|
||||
...initial,
|
||||
width: 540,
|
||||
resize: intermediate,
|
||||
resizeWidth: 240,
|
||||
}),
|
||||
).toBe(240);
|
||||
expect(reconcileExplorerSidebarResize(intermediate, initial)).toBeNull();
|
||||
});
|
||||
|
||||
it("cancels back to the latest release when earlier widths repeat", () => {
|
||||
const first = finishExplorerSidebarResize(dragging, 480, true);
|
||||
const second = finishExplorerSidebarResize(
|
||||
beginExplorerSidebarResize(first, allocation),
|
||||
600,
|
||||
true,
|
||||
);
|
||||
const third = finishExplorerSidebarResize(
|
||||
beginExplorerSidebarResize(second, allocation),
|
||||
480,
|
||||
true,
|
||||
);
|
||||
const fourth = beginExplorerSidebarResize(third, allocation);
|
||||
const canceled = finishExplorerSidebarResize(fourth, 700, false);
|
||||
expect(
|
||||
resolveExplorerSidebarFrameWidth({ ...allocation, resize: canceled, resizeWidth: 700 }),
|
||||
).toBe(480);
|
||||
});
|
||||
|
||||
it("cancels a newer gesture back to its last released width", () => {
|
||||
const first = finishExplorerSidebarResize(dragging, 480, true);
|
||||
const second = beginExplorerSidebarResize(first, allocation);
|
||||
const canceled = finishExplorerSidebarResize(second, 600, false);
|
||||
expect(
|
||||
resolveExplorerSidebarFrameWidth({ ...allocation, resize: canceled, resizeWidth: 600 }),
|
||||
).toBe(480);
|
||||
expect(reconcileExplorerSidebarResize(canceled, { ...allocation, width: 480 })).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the drag width after release while the JS commit still has the old prop", () => {
|
||||
expect(
|
||||
resolveExplorerSidebarFrameWidth({ ...allocation, resize: dragging, resizeWidth: 480 }),
|
||||
).toBe(480);
|
||||
|
||||
const released = finishExplorerSidebarResize(dragging, 480, true);
|
||||
expect(reconcileExplorerSidebarResize(released, allocation)).toBe(released);
|
||||
expect(
|
||||
resolveExplorerSidebarFrameWidth({ ...allocation, resize: released, resizeWidth: 480 }),
|
||||
).toBe(480);
|
||||
expect(
|
||||
resolveExplorerSidebarFrameWidth({ ...allocation, resize: released, resizeWidth: 320 }),
|
||||
).toBe(480);
|
||||
});
|
||||
|
||||
it("acknowledges the committed width and immediately follows subsequent props", () => {
|
||||
const released = finishExplorerSidebarResize(dragging, 480, true);
|
||||
const acknowledgedAllocation = { ...allocation, width: 480 };
|
||||
expect(
|
||||
resolveExplorerSidebarFrameWidth({
|
||||
...acknowledgedAllocation,
|
||||
resize: released,
|
||||
resizeWidth: 480,
|
||||
}),
|
||||
).toBe(480);
|
||||
const acknowledged = reconcileExplorerSidebarResize(released, acknowledgedAllocation);
|
||||
expect(acknowledged).toBeNull();
|
||||
expect(
|
||||
resolveExplorerSidebarFrameWidth({
|
||||
...allocation,
|
||||
width: 360,
|
||||
resize: acknowledged,
|
||||
resizeWidth: 480,
|
||||
}),
|
||||
).toBe(360);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "a new width prop", next: { ...allocation, width: 360 }, expected: 360 },
|
||||
{
|
||||
name: "a window allocation with an unchanged width prop",
|
||||
next: { ...allocation, containerWidth: 900 },
|
||||
expected: 320,
|
||||
},
|
||||
{
|
||||
name: "a window allocation that clamps the width prop",
|
||||
next: { ...allocation, width: 540, containerWidth: 900 },
|
||||
expected: 540,
|
||||
},
|
||||
{ name: "overlay presentation", next: { ...allocation, overlay: true }, expected: 1200 },
|
||||
{ name: "an inactive dock", next: { ...allocation, resizeEnabled: false }, expected: 320 },
|
||||
])(
|
||||
"yields synchronously to $name and does not resurrect the old handoff",
|
||||
({ next, expected }) => {
|
||||
const released = finishExplorerSidebarResize(dragging, 600, true);
|
||||
expect(
|
||||
resolveExplorerSidebarFrameWidth({ ...next, resize: released, resizeWidth: 600 }),
|
||||
).toBe(expected);
|
||||
const interrupted = reconcileExplorerSidebarResize(released, next);
|
||||
expect(interrupted).toBeNull();
|
||||
expect(
|
||||
resolveExplorerSidebarFrameWidth({
|
||||
...allocation,
|
||||
resize: interrupted,
|
||||
resizeWidth: 600,
|
||||
}),
|
||||
).toBe(320);
|
||||
},
|
||||
);
|
||||
|
||||
it("restores canonical intent when a drag is canceled", () => {
|
||||
const canceled = finishExplorerSidebarResize(dragging, 480, false);
|
||||
expect(canceled).toBeNull();
|
||||
expect(
|
||||
resolveExplorerSidebarFrameWidth({ ...allocation, resize: canceled, resizeWidth: 480 }),
|
||||
).toBe(320);
|
||||
});
|
||||
|
||||
it("cannot finish an interrupted drag over a newer allocation", () => {
|
||||
const next = { ...allocation, width: 360, containerWidth: 1000 };
|
||||
const interrupted = reconcileExplorerSidebarResize(dragging, next);
|
||||
const released = finishExplorerSidebarResize(interrupted, 480, true);
|
||||
expect(released).toBeNull();
|
||||
expect(
|
||||
resolveExplorerSidebarFrameWidth({ ...next, resize: released, resizeWidth: 480 }),
|
||||
).toBe(360);
|
||||
});
|
||||
|
||||
it("settles a release at the already-committed width without retaining ownership", () => {
|
||||
const released = finishExplorerSidebarResize(dragging, allocation.width, true);
|
||||
expect(reconcileExplorerSidebarResize(released, allocation)).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps an earlier released handoff when a later touch fails before activation", () => {
|
||||
const released = finishExplorerSidebarResize(dragging, 480, true);
|
||||
const finalized = finishExplorerSidebarResize(released, 480, false);
|
||||
expect(finalized).toBe(released);
|
||||
expect(
|
||||
resolveExplorerSidebarFrameWidth({ ...allocation, resize: finalized, resizeWidth: 480 }),
|
||||
).toBe(480);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,85 @@ export function resolveExplorerSidebarWidth(input: {
|
||||
return Math.max(MIN_EXPLORER_SIDEBAR_WIDTH, Math.min(maximumVisibleWidth, requestedWidth));
|
||||
}
|
||||
|
||||
// A release keeps its UI width until React acknowledges it or replaces its allocation.
|
||||
// Null pendingWidth means the gesture still owns the live resizeWidth instead.
|
||||
export interface ExplorerSidebarResize {
|
||||
sourceWidths: number[];
|
||||
restoreWidth: number;
|
||||
containerWidth: number;
|
||||
pendingWidth: number | null;
|
||||
}
|
||||
|
||||
interface ExplorerSidebarAllocation {
|
||||
width: number;
|
||||
containerWidth: number;
|
||||
overlay: boolean;
|
||||
resizeEnabled: boolean;
|
||||
}
|
||||
|
||||
export function reconcileExplorerSidebarResize(
|
||||
resize: ExplorerSidebarResize | null,
|
||||
allocation: ExplorerSidebarAllocation,
|
||||
): ExplorerSidebarResize | null {
|
||||
"worklet";
|
||||
if (
|
||||
!resize ||
|
||||
allocation.overlay ||
|
||||
!allocation.resizeEnabled ||
|
||||
allocation.containerWidth !== resize.containerWidth ||
|
||||
!resize.sourceWidths.includes(allocation.width) ||
|
||||
(allocation.width === resize.pendingWidth &&
|
||||
resize.sourceWidths.indexOf(allocation.width) === resize.sourceWidths.length - 1)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const acknowledgedIndex = resize.sourceWidths.indexOf(allocation.width);
|
||||
return acknowledgedIndex > 0
|
||||
? { ...resize, sourceWidths: resize.sourceWidths.slice(acknowledgedIndex) }
|
||||
: resize;
|
||||
}
|
||||
|
||||
export function beginExplorerSidebarResize(
|
||||
previous: ExplorerSidebarResize | null,
|
||||
allocation: ExplorerSidebarAllocation,
|
||||
): ExplorerSidebarResize {
|
||||
"worklet";
|
||||
const current = reconcileExplorerSidebarResize(previous, allocation);
|
||||
// Released widths remain expected allocations while a newer gesture owns the frame.
|
||||
const sourceWidths = current ? [...current.sourceWidths] : [allocation.width];
|
||||
if (current && current.pendingWidth !== null && !sourceWidths.includes(current.pendingWidth))
|
||||
sourceWidths.push(current.pendingWidth);
|
||||
return {
|
||||
sourceWidths,
|
||||
restoreWidth: current ? (current.pendingWidth ?? current.restoreWidth) : allocation.width,
|
||||
containerWidth: allocation.containerWidth,
|
||||
pendingWidth: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function finishExplorerSidebarResize(
|
||||
resize: ExplorerSidebarResize | null,
|
||||
width: number,
|
||||
success: boolean,
|
||||
): ExplorerSidebarResize | null {
|
||||
"worklet";
|
||||
if (!resize || resize.pendingWidth !== null) return resize;
|
||||
if (success) return { ...resize, pendingWidth: width };
|
||||
return resize.sourceWidths.length > 1 ? { ...resize, pendingWidth: resize.restoreWidth } : null;
|
||||
}
|
||||
|
||||
export function resolveExplorerSidebarFrameWidth(
|
||||
allocation: ExplorerSidebarAllocation & {
|
||||
resize: ExplorerSidebarResize | null;
|
||||
resizeWidth: number;
|
||||
},
|
||||
): number {
|
||||
"worklet";
|
||||
if (allocation.overlay) return allocation.containerWidth;
|
||||
const resize = reconcileExplorerSidebarResize(allocation.resize, allocation);
|
||||
return resize ? (resize.pendingWidth ?? allocation.resizeWidth) : allocation.width;
|
||||
}
|
||||
|
||||
export function resolveExplorerSidebarDockSizes(input: {
|
||||
requestedWidth?: number;
|
||||
containerWidth: number;
|
||||
|
||||
@@ -25,7 +25,10 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import { SidebarCloseButton } from "@/components/headers/sidebar-close-button";
|
||||
import { resolveDesktopSidebarWidth } from "@/components/desktop-sidebar-layout";
|
||||
import {
|
||||
resolveDesktopSidebarInsetLeft,
|
||||
resolveDesktopSidebarWidth,
|
||||
} from "@/components/desktop-sidebar-layout";
|
||||
import { useWindowLayout, resolveDesktopDivisionLayout } from "@/window-layout";
|
||||
import {
|
||||
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
|
||||
@@ -65,6 +68,7 @@ import { useSidebarNavItems } from "@/sidebar-nav/use-sidebar-nav-items";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window";
|
||||
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
|
||||
import { useLeftPanelWidth } from "@/mobile-panels/provider";
|
||||
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
||||
import { buildSettingsRoute } from "@/utils/host-routes";
|
||||
import { openHostOverview } from "@/navigation/settings-navigation";
|
||||
@@ -121,9 +125,16 @@ interface MobileSidebarProps extends SidebarSharedProps {
|
||||
interface DesktopSidebarProps extends SidebarSharedProps {
|
||||
insetsTop: number;
|
||||
active: boolean;
|
||||
overlay: boolean;
|
||||
}
|
||||
|
||||
export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boolean }) {
|
||||
export const LeftSidebar = memo(function LeftSidebar({
|
||||
active,
|
||||
desktopOverlay,
|
||||
}: {
|
||||
active: boolean;
|
||||
desktopOverlay: boolean;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
@@ -164,27 +175,20 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
|
||||
const openProjectPicker = useOpenAddProject();
|
||||
const { open: openImportSession, sheet: importSessionSheet } = useImportSession();
|
||||
|
||||
const handleOpenProjectMobile = useCallback(() => {
|
||||
// Each destination closes the navigation overlay first. Closing is idempotent, so a docked
|
||||
// sidebar runs the same handler.
|
||||
const handleOpenProject = useCallback(() => {
|
||||
void openProjectPicker();
|
||||
}, [openProjectPicker]);
|
||||
|
||||
const handleOpenProjectDesktop = useCallback(() => {
|
||||
void openProjectPicker();
|
||||
}, [openProjectPicker]);
|
||||
|
||||
const handleSettingsMobile = useCallback(() => {
|
||||
const handleSettings = useCallback(() => {
|
||||
showMobileAgent();
|
||||
router.push(buildSettingsRoute());
|
||||
}, [showMobileAgent]);
|
||||
|
||||
const handleSettingsDesktop = useCallback(() => {
|
||||
router.push(buildSettingsRoute());
|
||||
}, []);
|
||||
const handleAddHost = useAddHost();
|
||||
|
||||
const handleAddHostMobile = useAddHost();
|
||||
const handleAddHostDesktop = handleAddHostMobile;
|
||||
|
||||
const handleOpenHostSettingsMobile = useCallback(
|
||||
const handleOpenHostSettings = useCallback(
|
||||
(serverId: string) => {
|
||||
showMobileAgent();
|
||||
openHostOverview(serverId);
|
||||
@@ -192,14 +196,6 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
|
||||
[showMobileAgent],
|
||||
);
|
||||
|
||||
const handleOpenHostSettingsDesktop = useCallback((serverId: string) => {
|
||||
openHostOverview(serverId);
|
||||
}, []);
|
||||
|
||||
const handleImportSessionMobile = useCallback(() => {
|
||||
openImportSession();
|
||||
}, [openImportSession]);
|
||||
|
||||
const labels = useMemo(
|
||||
(): SidebarLabels => ({
|
||||
addProject: t("sidebar.actions.addProject"),
|
||||
@@ -232,41 +228,35 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
|
||||
labels,
|
||||
};
|
||||
|
||||
if (isCompactLayout) {
|
||||
return (
|
||||
<>
|
||||
<RetainedPanelActivity active={active}>
|
||||
<MobileSidebar
|
||||
{...sharedProps}
|
||||
active={active}
|
||||
insetsTop={insets.top}
|
||||
insetsBottom={insets.bottom}
|
||||
closeSidebar={showMobileAgent}
|
||||
handleOpenProject={handleOpenProjectMobile}
|
||||
handleImportSession={handleImportSessionMobile}
|
||||
handleSettings={handleSettingsMobile}
|
||||
handleAddHost={handleAddHostMobile}
|
||||
handleOpenHostSettings={handleOpenHostSettingsMobile}
|
||||
/>
|
||||
</RetainedPanelActivity>
|
||||
{importSessionSheet}
|
||||
</>
|
||||
);
|
||||
}
|
||||
const handlers = {
|
||||
handleOpenProject,
|
||||
handleImportSession: openImportSession,
|
||||
handleSettings,
|
||||
handleAddHost,
|
||||
handleOpenHostSettings,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<RetainedPanelActivity active={active}>
|
||||
<DesktopSidebar
|
||||
{...sharedProps}
|
||||
insetsTop={insets.top}
|
||||
active={active}
|
||||
handleOpenProject={handleOpenProjectDesktop}
|
||||
handleImportSession={openImportSession}
|
||||
handleSettings={handleSettingsDesktop}
|
||||
handleAddHost={handleAddHostDesktop}
|
||||
handleOpenHostSettings={handleOpenHostSettingsDesktop}
|
||||
/>
|
||||
{isCompactLayout ? (
|
||||
<MobileSidebar
|
||||
{...sharedProps}
|
||||
{...handlers}
|
||||
active={active}
|
||||
insetsTop={insets.top}
|
||||
insetsBottom={insets.bottom}
|
||||
closeSidebar={showMobileAgent}
|
||||
/>
|
||||
) : (
|
||||
<DesktopSidebar
|
||||
{...sharedProps}
|
||||
{...handlers}
|
||||
insetsTop={insets.top}
|
||||
active={active}
|
||||
overlay={desktopOverlay}
|
||||
/>
|
||||
)}
|
||||
</RetainedPanelActivity>
|
||||
{importSessionSheet}
|
||||
</>
|
||||
@@ -663,10 +653,10 @@ function DesktopSidebar({
|
||||
handleOpenHostSettings,
|
||||
insetsTop,
|
||||
active,
|
||||
overlay,
|
||||
}: DesktopSidebarProps) {
|
||||
const ownsTopLeft = useOwnsWindowChromeCorner("top-left");
|
||||
const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0);
|
||||
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
||||
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
|
||||
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
||||
const insets = useSafeAreaInsets();
|
||||
@@ -678,14 +668,12 @@ function DesktopSidebar({
|
||||
leftInset: insets.left,
|
||||
rightInset: insets.right,
|
||||
});
|
||||
const visibleSidebarWidth =
|
||||
divisionLayout?.sidebarWidth ??
|
||||
resolveDesktopSidebarWidth({
|
||||
requestedWidth: sidebarWidth,
|
||||
viewportWidth,
|
||||
});
|
||||
const visibleSidebarWidth = useLeftPanelWidth();
|
||||
|
||||
const startWidthRef = useRef(visibleSidebarWidth);
|
||||
const closeSidebar = usePanelStore((state) => state.showMobileAgent);
|
||||
const { gesture: closeGesture, gestureRef: closeGestureRef } = useCloseAgentListGesture(overlay);
|
||||
|
||||
const startWidth = useSharedValue(visibleSidebarWidth);
|
||||
const resizeWidth = useSharedValue(visibleSidebarWidth);
|
||||
const [resizePressed, setResizePressed] = useState(false);
|
||||
const showResizeGrip = useCallback(() => setResizePressed(true), []);
|
||||
@@ -709,12 +697,12 @@ function DesktopSidebar({
|
||||
.activeOffsetX([-SIDEBAR_RESIZE_ACTIVATION_OFFSET, SIDEBAR_RESIZE_ACTIVATION_OFFSET])
|
||||
.failOffsetY([-SIDEBAR_RESIZE_FAIL_OFFSET, SIDEBAR_RESIZE_FAIL_OFFSET])
|
||||
.onStart((event) => {
|
||||
startWidthRef.current = visibleSidebarWidth - event.translationX;
|
||||
startWidth.value = visibleSidebarWidth - event.translationX;
|
||||
resizeWidth.value = visibleSidebarWidth;
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
// Dragging right (positive translationX) increases width
|
||||
const newWidth = startWidthRef.current + event.translationX;
|
||||
const newWidth = startWidth.value + event.translationX;
|
||||
resizeWidth.value = resolveDesktopSidebarWidth({
|
||||
requestedWidth: newWidth,
|
||||
viewportWidth,
|
||||
@@ -732,6 +720,7 @@ function DesktopSidebar({
|
||||
resizeWidth,
|
||||
setSidebarWidth,
|
||||
showResizeGrip,
|
||||
startWidth,
|
||||
viewportWidth,
|
||||
visibleSidebarWidth,
|
||||
],
|
||||
@@ -740,101 +729,110 @@ function DesktopSidebar({
|
||||
const resizeAnimatedStyle = useAnimatedStyle(() => ({
|
||||
width: divisionLayout?.sidebarWidth ?? resizeWidth.value,
|
||||
}));
|
||||
|
||||
// The panel host gets no width of its own: the dock sizes to this view and the overlay fills the
|
||||
// window. A Reanimated width on the host reached Android after React had switched presentation,
|
||||
// and a lost update left the workspace at the dock width after the dock collapsed.
|
||||
const desktopSidebarStyle = useMemo(
|
||||
() => [
|
||||
staticStyles.desktopSidebar,
|
||||
!active && staticStyles.desktopSidebarHidden,
|
||||
resizeAnimatedStyle,
|
||||
],
|
||||
[active, resizeAnimatedStyle],
|
||||
() => [staticStyles.desktopSidebar, resizeAnimatedStyle],
|
||||
[resizeAnimatedStyle],
|
||||
);
|
||||
const desktopSidebarBorderStyle = useMemo(
|
||||
() => [
|
||||
styles.desktopSidebarBorder(divisionLayout !== null),
|
||||
{ flex: 1, paddingTop: insetsTop, paddingBottom: insets.bottom },
|
||||
{
|
||||
flex: 1,
|
||||
paddingTop: insetsTop,
|
||||
paddingBottom: insets.bottom,
|
||||
paddingLeft: resolveDesktopSidebarInsetLeft({ overlay, safeAreaLeft: insets.left }),
|
||||
},
|
||||
],
|
||||
[insetsTop, insets.bottom, divisionLayout],
|
||||
[insetsTop, insets.bottom, insets.left, overlay, divisionLayout],
|
||||
);
|
||||
const sidebarHeaderGroupStyle = useMemo(
|
||||
() => [styles.sidebarHeaderGroup, ownsTopLeft && styles.sidebarHeaderGroupBelowChrome],
|
||||
[ownsTopLeft],
|
||||
);
|
||||
return (
|
||||
<Animated.View
|
||||
accessibilityElementsHidden={!active}
|
||||
importantForAccessibility={active ? "auto" : "no-hide-descendants"}
|
||||
pointerEvents={active ? "auto" : "none"}
|
||||
style={desktopSidebarStyle}
|
||||
<MobilePanelOverlay
|
||||
panel="agent-list"
|
||||
presentation={overlay ? "overlay" : "dock"}
|
||||
dockVisible={active}
|
||||
closeGesture={closeGesture}
|
||||
>
|
||||
<View style={desktopSidebarBorderStyle}>
|
||||
<View style={styles.sidebarDragArea}>
|
||||
{ownsTopLeft || DEV_BUILD_LABEL ? (
|
||||
<View style={styles.desktopChromeRow}>
|
||||
<Animated.View style={desktopSidebarStyle}>
|
||||
<View style={desktopSidebarBorderStyle}>
|
||||
<View style={styles.sidebarDragArea}>
|
||||
{ownsTopLeft || DEV_BUILD_LABEL ? (
|
||||
<View style={styles.desktopChromeRow}>
|
||||
<TitlebarDragRegion />
|
||||
{DEV_BUILD_LABEL ? (
|
||||
<View
|
||||
pointerEvents="none"
|
||||
style={styles.devBuildBadge}
|
||||
testID="dev-build-label"
|
||||
accessibilityLabel={`Development build: ${DEV_BUILD_LABEL}`}
|
||||
>
|
||||
<GitBranch size={12} color={theme.colors.accentForeground} />
|
||||
<Text numberOfLines={1} ellipsizeMode="tail" style={styles.devBuildBadgeText}>
|
||||
{DEV_BUILD_LABEL}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
) : (
|
||||
<TitlebarDragRegion />
|
||||
{DEV_BUILD_LABEL ? (
|
||||
<View
|
||||
pointerEvents="none"
|
||||
style={styles.devBuildBadge}
|
||||
testID="dev-build-label"
|
||||
accessibilityLabel={`Development build: ${DEV_BUILD_LABEL}`}
|
||||
>
|
||||
<GitBranch size={12} color={theme.colors.accentForeground} />
|
||||
<Text numberOfLines={1} ellipsizeMode="tail" style={styles.devBuildBadgeText}>
|
||||
{DEV_BUILD_LABEL}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
)}
|
||||
<SidebarNavRows style={sidebarHeaderGroupStyle} onBeforeNavigate={closeSidebar} />
|
||||
</View>
|
||||
|
||||
{isInitialLoad && !hasActiveHostFilter ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
<TitlebarDragRegion />
|
||||
<SidebarWorkspaceList
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
groupMode={groupMode}
|
||||
workspaceGroups={workspaceGroups}
|
||||
projectIconTargets={projectIconTargets}
|
||||
pinnedGroups={pinnedGroups}
|
||||
projects={projects}
|
||||
hasProjectsBeforeFilter={hasProjectsBeforeFilter}
|
||||
hasActiveProjectFilter={hasActiveProjectFilter}
|
||||
workspaceEntriesByKey={workspaceEntriesByKey}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
onAddProject={handleOpenProject}
|
||||
onImportSession={handleImportSession}
|
||||
onWorkspacePress={closeSidebar}
|
||||
parentGestureRef={closeGestureRef}
|
||||
dragGestureHostActive={active}
|
||||
listHeaderComponent={workspacesSectionHeaderElement}
|
||||
/>
|
||||
)}
|
||||
<SidebarNavRows style={sidebarHeaderGroupStyle} />
|
||||
|
||||
<SidebarCalloutSlot />
|
||||
|
||||
<SidebarFooter
|
||||
handleOpenProject={handleOpenProject}
|
||||
handleSettings={handleSettings}
|
||||
labels={labels}
|
||||
handleAddHost={handleAddHost}
|
||||
handleOpenHostSettings={handleOpenHostSettings}
|
||||
onBeforeNavigate={closeSidebar}
|
||||
/>
|
||||
|
||||
{!overlay && divisionLayout === null ? (
|
||||
<SidebarResizeHandle
|
||||
edge="right"
|
||||
gesture={resizeGesture}
|
||||
pressed={resizePressed}
|
||||
testID="left-sidebar-resize-handle"
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{isInitialLoad && !hasActiveHostFilter ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
<SidebarWorkspaceList
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
groupMode={groupMode}
|
||||
workspaceGroups={workspaceGroups}
|
||||
projectIconTargets={projectIconTargets}
|
||||
pinnedGroups={pinnedGroups}
|
||||
projects={projects}
|
||||
hasProjectsBeforeFilter={hasProjectsBeforeFilter}
|
||||
hasActiveProjectFilter={hasActiveProjectFilter}
|
||||
workspaceEntriesByKey={workspaceEntriesByKey}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
onAddProject={handleOpenProject}
|
||||
onImportSession={handleImportSession}
|
||||
listHeaderComponent={workspacesSectionHeaderElement}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SidebarCalloutSlot />
|
||||
|
||||
<SidebarFooter
|
||||
handleOpenProject={handleOpenProject}
|
||||
handleSettings={handleSettings}
|
||||
labels={labels}
|
||||
handleAddHost={handleAddHost}
|
||||
handleOpenHostSettings={handleOpenHostSettings}
|
||||
/>
|
||||
|
||||
{divisionLayout === null ? (
|
||||
<SidebarResizeHandle
|
||||
edge="right"
|
||||
gesture={resizeGesture}
|
||||
pressed={resizePressed}
|
||||
testID="left-sidebar-resize-handle"
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</MobilePanelOverlay>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -865,9 +863,7 @@ const workspacesSectionHeaderElement = <WorkspacesSectionHeader />;
|
||||
const staticStyles = RNStyleSheet.create({
|
||||
desktopSidebar: {
|
||||
position: "relative" as const,
|
||||
},
|
||||
desktopSidebarHidden: {
|
||||
display: "none",
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { View, type PointerEvent as RNPointerEvent } from "react-native";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { startResizeHandleDrag, type ResizeHandleDrag } from "@/components/resize-handle-drag";
|
||||
import { useHasFinePointer } from "@/hooks/use-fine-pointer";
|
||||
import { usePointerCapabilities } from "@/hooks/pointer-capabilities";
|
||||
import {
|
||||
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
|
||||
SIDEBAR_RESIZE_FAIL_OFFSET,
|
||||
@@ -47,7 +47,7 @@ export function ResizeHandle({
|
||||
onResizeSplit,
|
||||
}: ResizeHandleProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const finePointer = useHasFinePointer();
|
||||
const { finePointer } = usePointerCapabilities();
|
||||
const pointerStatesRef = useRef(new Map<number, PointerState>());
|
||||
const touchStateRef = useRef<PointerState | null>(null);
|
||||
const cursorBeforeDragRef = useRef<string | null>(null);
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
type SidebarResizeEdge,
|
||||
} from "@/components/sidebar-resize-handle-layout";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useHasFinePointer } from "@/hooks/use-fine-pointer";
|
||||
import { usePointerCapabilities } from "@/hooks/pointer-capabilities";
|
||||
|
||||
interface SidebarResizeHandleProps {
|
||||
edge: SidebarResizeEdge;
|
||||
@@ -29,7 +29,7 @@ function edgeOffsetStyle(edge: SidebarResizeEdge, edgeOffset: number) {
|
||||
}
|
||||
|
||||
export function SidebarResizeHandle({ edge, gesture, pressed, testID }: SidebarResizeHandleProps) {
|
||||
const finePointer = useHasFinePointer();
|
||||
const { finePointer } = usePointerCapabilities();
|
||||
|
||||
if (finePointer) {
|
||||
return <PointerResizeHandle edge={edge} gesture={gesture} pressed={pressed} testID={testID} />;
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
AdaptiveWorkspaceChrome,
|
||||
type WorkspaceCompactHeaderActions,
|
||||
} from "@/screens/workspace/adaptive-workspace-chrome";
|
||||
import { useWorkspaceTabDrag } from "./workspace-tab-drag-context";
|
||||
import {
|
||||
Fragment,
|
||||
@@ -32,6 +36,7 @@ import {
|
||||
WindowChromeSafeArea,
|
||||
useWindowChromeCorners,
|
||||
removeWindowChromeCorner,
|
||||
resolveChildWindowChromeCorners,
|
||||
type WindowChromeCorners,
|
||||
} from "@/utils/desktop-window";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
@@ -42,6 +47,7 @@ import type { WorkspacePaneContentModel } from "@/screens/workspace/workspace-pa
|
||||
import { WorkspacePanelHost } from "@/screens/workspace/workspace-panel-host";
|
||||
import {
|
||||
WorkspaceDesktopTabsRow,
|
||||
useWorkspaceNewTabKeyboardAction,
|
||||
type WorkspaceDesktopTabRowItem,
|
||||
} from "@/screens/workspace/workspace-desktop-tabs-row";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
@@ -61,6 +67,11 @@ import { isNative } from "@/constants/platform";
|
||||
interface SplitContainerProps {
|
||||
layout: WorkspaceLayout;
|
||||
renderMainHeader?: () => ReactNode;
|
||||
renderCompactHeader: (
|
||||
tabs: WorkspaceTabDescriptor[],
|
||||
activeTabKey: string,
|
||||
actions: WorkspaceCompactHeaderActions,
|
||||
) => ReactNode;
|
||||
workspaceKey: string;
|
||||
normalizedServerId: string;
|
||||
normalizedWorkspaceId: string;
|
||||
@@ -145,7 +156,6 @@ const EMPTY_SPLIT_SIZES: number[] = [];
|
||||
|
||||
export function SplitContainer({
|
||||
layout,
|
||||
renderMainHeader,
|
||||
workspaceKey,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
@@ -174,9 +184,11 @@ export function SplitContainer({
|
||||
onReorderTabsInPane,
|
||||
focusModeEnabled,
|
||||
onExitFocusMode,
|
||||
renderMainHeader,
|
||||
renderCompactHeader,
|
||||
}: SplitContainerProps) {
|
||||
const inheritedWindowChromeCorners = useWindowChromeCorners();
|
||||
const windowChromeCorners = focusModeEnabled ? inheritedWindowChromeCorners : "none";
|
||||
const windowChromeCorners = inheritedWindowChromeCorners;
|
||||
const { activeDragTabId, dropPreview, tabDropPreview } = useWorkspaceTabDrag();
|
||||
const [maximizedPane, setMaximizedPane] = useState<{
|
||||
workspaceKey: string;
|
||||
@@ -237,7 +249,6 @@ export function SplitContainer({
|
||||
<View style={styles.workspaceShell}>
|
||||
<WindowChromeRegion corners={mainColumnWindowChromeCorners}>
|
||||
<View style={mainColumnStyle}>
|
||||
{renderMainHeader?.()}
|
||||
{splitRoot.usesFallbackStrip && <WindowChromeSafeArea placement="below" />}
|
||||
{renderRoot ? (
|
||||
<SplitNodeView
|
||||
@@ -279,6 +290,8 @@ export function SplitContainer({
|
||||
onTogglePaneMaximized={handleTogglePaneMaximized}
|
||||
focusModeEnabled={focusModeEnabled}
|
||||
onExitFocusMode={onExitFocusMode}
|
||||
renderMainHeader={renderMainHeader}
|
||||
renderCompactHeader={renderCompactHeader}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
@@ -410,6 +423,8 @@ function SplitNodeView({
|
||||
onTogglePaneMaximized,
|
||||
focusModeEnabled,
|
||||
onExitFocusMode,
|
||||
renderMainHeader,
|
||||
renderCompactHeader,
|
||||
}: SplitNodeViewProps) {
|
||||
const [groupContainerSize, setGroupContainerSize] = useState(0);
|
||||
const groupId = node.kind === "group" ? node.group.id : null;
|
||||
@@ -496,6 +511,8 @@ function SplitNodeView({
|
||||
onTogglePaneMaximized={onTogglePaneMaximized}
|
||||
focusModeEnabled={focusModeEnabled}
|
||||
onExitFocusMode={onExitFocusMode}
|
||||
renderMainHeader={renderMainHeader}
|
||||
renderCompactHeader={renderCompactHeader}
|
||||
/>
|
||||
</WindowChromeRegion>
|
||||
</RetainedPanel>
|
||||
@@ -544,12 +561,25 @@ function SplitNodeView({
|
||||
showDropZones={showDropZones}
|
||||
dropPreview={dropPreview}
|
||||
tabDropPreview={tabDropPreview}
|
||||
windowChromeCorners={windowChromeCorners}
|
||||
windowChromeCorners={resolveChildWindowChromeCorners({
|
||||
corners: windowChromeCorners,
|
||||
direction: node.group.direction,
|
||||
isFirst:
|
||||
node.group.children.findIndex(
|
||||
(candidate) => !isSplitNodeHiddenForPresentation(candidate, maximizedPaneId),
|
||||
) === index,
|
||||
isLast:
|
||||
node.group.children.findLastIndex(
|
||||
(candidate) => !isSplitNodeHiddenForPresentation(candidate, maximizedPaneId),
|
||||
) === index,
|
||||
})}
|
||||
maximizedPaneId={maximizedPaneId}
|
||||
workspaceHasMultiplePanes={workspaceHasMultiplePanes}
|
||||
onTogglePaneMaximized={onTogglePaneMaximized}
|
||||
focusModeEnabled={focusModeEnabled}
|
||||
onExitFocusMode={onExitFocusMode}
|
||||
renderMainHeader={renderMainHeader}
|
||||
renderCompactHeader={renderCompactHeader}
|
||||
/>
|
||||
</SplitGroupChild>
|
||||
{index < node.group.children.length - 1 &&
|
||||
@@ -608,6 +638,8 @@ const SplitPaneView = memo(function SplitPaneView({
|
||||
onTogglePaneMaximized,
|
||||
focusModeEnabled,
|
||||
onExitFocusMode,
|
||||
renderMainHeader,
|
||||
renderCompactHeader,
|
||||
}: SplitPaneViewProps) {
|
||||
recordRenderProfileReasons(`SplitPaneView:${pane.id}`, ["render"]);
|
||||
const paneRef = useRef<View | null>(null);
|
||||
@@ -701,6 +733,51 @@ const SplitPaneView = memo(function SplitPaneView({
|
||||
() => onTogglePaneMaximized(paneId),
|
||||
[onTogglePaneMaximized, paneId],
|
||||
);
|
||||
const compactHeaderActions = useMemo(
|
||||
() => ({
|
||||
pane: {
|
||||
id: paneId,
|
||||
splitRight: focusModeEnabled ? undefined : handleSplitRight,
|
||||
splitDown: focusModeEnabled ? undefined : handleSplitDown,
|
||||
toggleMaximized:
|
||||
workspaceHasMultiplePanes && !focusModeEnabled ? handleTogglePaneMaximized : undefined,
|
||||
maximized: paneId === maximizedPaneId,
|
||||
},
|
||||
closeAbove: handleCloseTabsToLeft,
|
||||
closeBelow: handleCloseTabsToRight,
|
||||
closeOthers: handleCloseOtherTabs,
|
||||
}),
|
||||
[
|
||||
paneId,
|
||||
focusModeEnabled,
|
||||
handleSplitRight,
|
||||
handleSplitDown,
|
||||
workspaceHasMultiplePanes,
|
||||
handleTogglePaneMaximized,
|
||||
maximizedPaneId,
|
||||
handleCloseTabsToLeft,
|
||||
handleCloseTabsToRight,
|
||||
handleCloseOtherTabs,
|
||||
],
|
||||
);
|
||||
const createNewTab = useCallback(() => onCreateNewTab({ paneId }), [onCreateNewTab, paneId]);
|
||||
useWorkspaceNewTabKeyboardAction({
|
||||
handlerName: "workspace-chrome-new-tab",
|
||||
ownsKeyboardShortcuts: isFocused && isWorkspaceFocused,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
paneId,
|
||||
createNewTab,
|
||||
});
|
||||
const compactHeader = useMemo(
|
||||
() => (
|
||||
<WindowChromeSafeArea placement="inline">
|
||||
<TitlebarDragRegion />
|
||||
{renderCompactHeader(paneTabs, activeTabDescriptor?.key ?? "", compactHeaderActions)}
|
||||
</WindowChromeSafeArea>
|
||||
),
|
||||
[renderCompactHeader, paneTabs, activeTabDescriptor?.key, compactHeaderActions],
|
||||
);
|
||||
return (
|
||||
<RenderProfile id={`SplitPaneView:${pane.id}`}>
|
||||
<View
|
||||
@@ -709,43 +786,49 @@ const SplitPaneView = memo(function SplitPaneView({
|
||||
style={styles.pane}
|
||||
testID={`workspace-pane-${pane.id}`}
|
||||
>
|
||||
<WindowChromeSafeArea placement="inline" style={styles.paneTabs}>
|
||||
<TitlebarDragRegion />
|
||||
<WorkspaceDesktopTabsRow
|
||||
paneId={pane.id}
|
||||
isFocused={isFocused && isWorkspaceFocused}
|
||||
tabs={desktopTabRowItems}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
onNavigateTab={onNavigateTab}
|
||||
onCloseTab={onCloseTab}
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onCopyTerminalId={onCopyTerminalId}
|
||||
onCopyFilePath={onCopyFilePath}
|
||||
onReloadAgent={onReloadAgent}
|
||||
onRenameTab={onRenameTab}
|
||||
onCloseTabsToLeft={handleCloseTabsToLeft}
|
||||
onCloseTabsToRight={handleCloseTabsToRight}
|
||||
onCloseOtherTabs={handleCloseOtherTabs}
|
||||
onCreateNewTab={onCreateNewTab}
|
||||
onReorderTabs={handleReorderTabs}
|
||||
externalDndContext
|
||||
activeDragTabId={activeDragTabId}
|
||||
tabDropPreviewIndex={
|
||||
tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null
|
||||
}
|
||||
showPaneSplitActions={!focusModeEnabled}
|
||||
showPaneMaximizeAction={workspaceHasMultiplePanes && !focusModeEnabled}
|
||||
paneMaximized={paneId === maximizedPaneId}
|
||||
onTogglePaneMaximized={handleTogglePaneMaximized}
|
||||
onSplitRight={handleSplitRight}
|
||||
onSplitDown={handleSplitDown}
|
||||
focusModeEnabled={Boolean(focusModeEnabled)}
|
||||
onExitFocusMode={onExitFocusMode}
|
||||
/>
|
||||
</WindowChromeSafeArea>
|
||||
<AdaptiveWorkspaceChrome compactHeader={compactHeader}>
|
||||
{renderMainHeader?.()}
|
||||
<WindowChromeRegion corners={focusModeEnabled ? "both" : "none"}>
|
||||
<WindowChromeSafeArea placement="inline" style={styles.paneTabs}>
|
||||
<TitlebarDragRegion />
|
||||
<WorkspaceDesktopTabsRow
|
||||
paneId={pane.id}
|
||||
isFocused={isFocused && isWorkspaceFocused}
|
||||
tabs={desktopTabRowItems}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
onNavigateTab={onNavigateTab}
|
||||
onCloseTab={onCloseTab}
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onCopyTerminalId={onCopyTerminalId}
|
||||
onCopyFilePath={onCopyFilePath}
|
||||
onReloadAgent={onReloadAgent}
|
||||
onRenameTab={onRenameTab}
|
||||
onCloseTabsToLeft={handleCloseTabsToLeft}
|
||||
onCloseTabsToRight={handleCloseTabsToRight}
|
||||
onCloseOtherTabs={handleCloseOtherTabs}
|
||||
onCreateNewTab={onCreateNewTab}
|
||||
onReorderTabs={handleReorderTabs}
|
||||
ownsKeyboardShortcuts={false}
|
||||
externalDndContext
|
||||
activeDragTabId={activeDragTabId}
|
||||
tabDropPreviewIndex={
|
||||
tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null
|
||||
}
|
||||
showPaneSplitActions={!focusModeEnabled}
|
||||
showPaneMaximizeAction={workspaceHasMultiplePanes && !focusModeEnabled}
|
||||
paneMaximized={paneId === maximizedPaneId}
|
||||
onTogglePaneMaximized={handleTogglePaneMaximized}
|
||||
onSplitRight={handleSplitRight}
|
||||
onSplitDown={handleSplitDown}
|
||||
focusModeEnabled={Boolean(focusModeEnabled)}
|
||||
onExitFocusMode={onExitFocusMode}
|
||||
/>
|
||||
</WindowChromeSafeArea>
|
||||
</WindowChromeRegion>
|
||||
</AdaptiveWorkspaceChrome>
|
||||
|
||||
<View style={styles.paneContent}>
|
||||
<WorkspacePanelHost
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { hasTouchScreen } from "@/hooks/use-fine-pointer";
|
||||
import { hasTouchScreen } from "@/hooks/pointer-capabilities";
|
||||
|
||||
// The sizing gate, apart from the control sizes so that low-level modules such as
|
||||
// `constants/layout` can size by it without loading the theme. Import it through
|
||||
|
||||
@@ -436,7 +436,7 @@ export function WorkspaceSetupDialog() {
|
||||
desktopMaxWidth={640}
|
||||
>
|
||||
<FileDropZone style={styles.section}>
|
||||
<KeyboardTranslateView>
|
||||
<KeyboardTranslateView style={styles.composerSlot}>
|
||||
<Composer
|
||||
agentId={`workspace-setup:${serverId}:${sourceDirectory}`}
|
||||
serverId={serverId}
|
||||
@@ -487,6 +487,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.destructive,
|
||||
lineHeight: 20,
|
||||
},
|
||||
composerSlot: {
|
||||
paddingBottom: theme.spacing[4],
|
||||
},
|
||||
composerInputWrapper: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
|
||||
@@ -19,15 +19,18 @@ import Animated, {
|
||||
import { useKeyboardShift } from "@/keyboard/shift";
|
||||
import {
|
||||
resolveCenteredClearance,
|
||||
resolveComposerBottomPlacement,
|
||||
resolveComposerSlotCapacity,
|
||||
updateComposerCapacity,
|
||||
type ComposerCapacity,
|
||||
} from "./internal/capacity";
|
||||
import { SPACING } from "@/styles/theme";
|
||||
|
||||
const ViewportSizing = createContext<SharedValue<ComposerCapacity | undefined> | null>(null);
|
||||
|
||||
interface ComposerViewportProps extends ViewProps {
|
||||
bottomInset?: number;
|
||||
keyboardGap?: number;
|
||||
centered?: boolean;
|
||||
}
|
||||
|
||||
@@ -36,6 +39,7 @@ function ComposerViewport({
|
||||
children,
|
||||
onLayout,
|
||||
bottomInset = 0,
|
||||
keyboardGap = 0,
|
||||
centered = false,
|
||||
...props
|
||||
}: ComposerViewportProps) {
|
||||
@@ -48,6 +52,7 @@ function ComposerViewport({
|
||||
bottomInset,
|
||||
safeAreaBottom: safeAreaBottom.value,
|
||||
keyboardShift: layoutShift.value,
|
||||
keyboardGap,
|
||||
centered,
|
||||
}),
|
||||
(geometry) => {
|
||||
@@ -129,6 +134,10 @@ export function ComposerDock({
|
||||
const insets = useSafeAreaInsets();
|
||||
const contentMaxWidth = resolveContentMaxWidth(useAppSettings().settings);
|
||||
const centeredClearance = useCenteredClearance(insets.bottom);
|
||||
const placement = resolveComposerBottomPlacement({
|
||||
safeAreaBottom: insets.bottom,
|
||||
contentSpacing: SPACING[4],
|
||||
});
|
||||
// Preserve the existing centered form's visual balance on tablets.
|
||||
const bottomInset = centered && !fullscreen ? HEADER_INNER_HEIGHT + 24 : 0;
|
||||
const fullscreenSurface = [
|
||||
@@ -176,7 +185,9 @@ export function ComposerDock({
|
||||
style={fullscreen ? dockStyles.surface : dockStyles.centeredComposer}
|
||||
fullscreen={fullscreen}
|
||||
>
|
||||
<View style={fullscreen ? fullscreenSurface : dockStyles.composer}>{composer}</View>
|
||||
<View style={fullscreen ? fullscreenSurface : dockStyles.centeredComposerSurface}>
|
||||
{composer}
|
||||
</View>
|
||||
</ComposerViewportContent>
|
||||
</ComposerViewportContent>
|
||||
</KeyboardTranslateView>
|
||||
@@ -185,8 +196,12 @@ export function ComposerDock({
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ComposerViewport style={dockStyles.viewport}>
|
||||
<KeyboardTranslateView style={dockStyles.surface} enabled={!fullscreen}>
|
||||
<ComposerViewport style={dockStyles.viewport} keyboardGap={placement.keyboardGap}>
|
||||
<KeyboardTranslateView
|
||||
style={dockStyles.surface}
|
||||
enabled={!fullscreen}
|
||||
keyboardGap={placement.keyboardGap}
|
||||
>
|
||||
<View
|
||||
testID="composer-dock-content"
|
||||
collapsable={false}
|
||||
@@ -208,7 +223,7 @@ export function ComposerDock({
|
||||
style={
|
||||
fullscreen
|
||||
? fullscreenSurface
|
||||
: [dockStyles.composer, { paddingBottom: insets.bottom }]
|
||||
: [dockStyles.composer, { paddingBottom: placement.restingPadding }]
|
||||
}
|
||||
>
|
||||
{composer}
|
||||
@@ -232,6 +247,7 @@ const dockStyles = StyleSheet.create({
|
||||
centered: { flexShrink: 1, width: "100%" },
|
||||
// Reserve the composer's own capped height before the setup scroll view shrinks.
|
||||
centeredComposer: { width: "100%", flexShrink: 0 },
|
||||
centeredComposerSurface: { width: "100%", flexShrink: 1 },
|
||||
setup: { flexGrow: 0, flexShrink: 1, minHeight: 0 },
|
||||
hiddenSetup: { display: "none" },
|
||||
});
|
||||
|
||||
@@ -72,7 +72,7 @@ export function ComposerDock({
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
surface: { flex: 1, overflow: "hidden" },
|
||||
content: { flex: 1, justifyContent: "flex-end" },
|
||||
composer: { width: "100%", flexShrink: 1 },
|
||||
composer: { width: "100%", flexShrink: 1, paddingBottom: theme.spacing[4] },
|
||||
// Fixed, not absolute: a touch pan that starts inside an overflow-hidden ancestor
|
||||
// never reaches the zoomed visual viewport, while a fixed box chains to the viewport
|
||||
// directly. React Native Web renders `fixed`; React Native's style type omits it.
|
||||
|
||||
@@ -1,18 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveCenteredClearance,
|
||||
resolveComposerBottomPlacement,
|
||||
resolveComposerSlotCapacity,
|
||||
resolveInlineComposerCapacity,
|
||||
resolveComposerCapacity,
|
||||
updateComposerCapacity,
|
||||
} from "./capacity";
|
||||
|
||||
describe("composer bottom placement", () => {
|
||||
const keyboardHeight = 264;
|
||||
/** Keyboard top minus composer bottom, both measured up from the window bottom. */
|
||||
function keyboardGapAbove(safeAreaBottom: number): number {
|
||||
const placement = resolveComposerBottomPlacement({ safeAreaBottom, contentSpacing: 16 });
|
||||
const shift = keyboardHeight - safeAreaBottom;
|
||||
const lift = shift + Math.min(placement.keyboardGap, shift);
|
||||
return placement.restingPadding + lift - keyboardHeight;
|
||||
}
|
||||
|
||||
it("rests at the bottom clearance without stacking a second gutter", () => {
|
||||
expect(resolveComposerBottomPlacement({ safeAreaBottom: 34, contentSpacing: 16 })).toEqual({
|
||||
restingPadding: 34,
|
||||
keyboardGap: 16,
|
||||
});
|
||||
expect(
|
||||
resolveComposerBottomPlacement({ safeAreaBottom: 24, contentSpacing: 16 }).restingPadding,
|
||||
).toBe(24);
|
||||
});
|
||||
|
||||
it.each([0, 20, 34])("never rests closer to the edge than the content spacing (%s)", (inset) => {
|
||||
expect(
|
||||
resolveComposerBottomPlacement({ safeAreaBottom: inset, contentSpacing: 16 }).restingPadding,
|
||||
).toBe(Math.max(inset, 16));
|
||||
});
|
||||
|
||||
it("keeps a measured zero clearance at the content spacing", () => {
|
||||
expect(resolveComposerBottomPlacement({ safeAreaBottom: 0, contentSpacing: 16 })).toEqual({
|
||||
restingPadding: 16,
|
||||
keyboardGap: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([0, 20, 24, 34])("keeps the content spacing above the keyboard (%s)", (inset) => {
|
||||
expect(keyboardGapAbove(inset)).toBe(16);
|
||||
});
|
||||
|
||||
it("reserves the gap's extra rise out of the docked composer's capacity", () => {
|
||||
const viewport = { height: 582, bottomInset: 0, safeAreaBottom: 34, centered: false };
|
||||
const closed = resolveComposerCapacity({ ...viewport, keyboardShift: 0, keyboardGap: 16 });
|
||||
const open = resolveComposerCapacity({ ...viewport, keyboardShift: 230, keyboardGap: 16 });
|
||||
expect(closed).toBe(577);
|
||||
expect(open).toBe(331);
|
||||
});
|
||||
});
|
||||
|
||||
describe("composer viewport", () => {
|
||||
it("caps the complete compact composer at sixty percent of keyboard-reserved space", () => {
|
||||
const open = updateComposerCapacity(undefined, {
|
||||
height: 582,
|
||||
bottomInset: 24,
|
||||
safeAreaBottom: 0,
|
||||
keyboardGap: 0,
|
||||
centered: false,
|
||||
keyboardShift: 308,
|
||||
});
|
||||
@@ -25,6 +73,7 @@ describe("composer viewport", () => {
|
||||
height: 582,
|
||||
bottomInset: 24,
|
||||
safeAreaBottom: 0,
|
||||
keyboardGap: 0,
|
||||
centered: false,
|
||||
keyboardShift: 308,
|
||||
});
|
||||
@@ -35,7 +84,13 @@ describe("composer viewport", () => {
|
||||
);
|
||||
});
|
||||
it("preserves the editing capacity when the keyboard closes", () => {
|
||||
const viewport = { height: 582, bottomInset: 24, safeAreaBottom: 0, centered: false };
|
||||
const viewport = {
|
||||
height: 582,
|
||||
bottomInset: 24,
|
||||
safeAreaBottom: 0,
|
||||
keyboardGap: 0,
|
||||
centered: false,
|
||||
};
|
||||
const open = updateComposerCapacity(undefined, { ...viewport, keyboardShift: 308 });
|
||||
const closed = updateComposerCapacity(open, { ...viewport, keyboardShift: 0 });
|
||||
expect(open.capacity).toBe(245);
|
||||
@@ -48,6 +103,7 @@ describe("composer viewport", () => {
|
||||
height: 582,
|
||||
bottomInset: 24,
|
||||
safeAreaBottom: 0,
|
||||
keyboardGap: 0,
|
||||
centered: false,
|
||||
keyboardShift: 308,
|
||||
});
|
||||
@@ -56,6 +112,7 @@ describe("composer viewport", () => {
|
||||
height: 650,
|
||||
bottomInset: 24,
|
||||
safeAreaBottom: 0,
|
||||
keyboardGap: 0,
|
||||
centered: false,
|
||||
keyboardShift: 0,
|
||||
}).capacity,
|
||||
@@ -65,6 +122,7 @@ describe("composer viewport", () => {
|
||||
height: 0,
|
||||
bottomInset: 24,
|
||||
safeAreaBottom: 0,
|
||||
keyboardGap: 0,
|
||||
centered: false,
|
||||
keyboardShift: 0,
|
||||
}),
|
||||
@@ -76,6 +134,7 @@ describe("composer viewport", () => {
|
||||
bottomInset: 24,
|
||||
safeAreaBottom: 0,
|
||||
keyboardShift: 308,
|
||||
keyboardGap: 0,
|
||||
centered: false,
|
||||
});
|
||||
expect(height).toBe(245);
|
||||
@@ -91,6 +150,7 @@ describe("composer viewport", () => {
|
||||
bottomInset,
|
||||
safeAreaBottom: 0,
|
||||
keyboardShift,
|
||||
keyboardGap: 0,
|
||||
centered: true,
|
||||
});
|
||||
expect(height).toBe(695);
|
||||
@@ -110,6 +170,7 @@ describe("composer viewport", () => {
|
||||
bottomInset: 72,
|
||||
safeAreaBottom: 0,
|
||||
keyboardShift: 313,
|
||||
keyboardGap: 0,
|
||||
centered: true,
|
||||
}),
|
||||
).toBe(407);
|
||||
@@ -122,6 +183,7 @@ describe("composer viewport", () => {
|
||||
bottomInset: 60,
|
||||
safeAreaBottom: 32,
|
||||
keyboardShift: 288,
|
||||
keyboardGap: 0,
|
||||
centered: true,
|
||||
};
|
||||
const height = resolveComposerCapacity(geometry);
|
||||
@@ -145,6 +207,7 @@ describe("composer viewport", () => {
|
||||
bottomInset: 24,
|
||||
safeAreaBottom: 0,
|
||||
keyboardShift: 0,
|
||||
keyboardGap: 0,
|
||||
centered: false,
|
||||
}),
|
||||
).toBe(553);
|
||||
@@ -154,6 +217,7 @@ describe("composer viewport", () => {
|
||||
bottomInset: 0,
|
||||
safeAreaBottom: 0,
|
||||
keyboardShift: 200,
|
||||
keyboardGap: 0,
|
||||
centered: false,
|
||||
}),
|
||||
).toBe(95);
|
||||
|
||||
@@ -4,6 +4,8 @@ interface ComposerGeometry {
|
||||
/** The keyboard also covers this space below its shift. */
|
||||
safeAreaBottom: number;
|
||||
keyboardShift: number;
|
||||
/** A docked composer rises this much beyond the keyboard shift; see the placement. */
|
||||
keyboardGap: number;
|
||||
centered: boolean;
|
||||
}
|
||||
|
||||
@@ -24,10 +26,36 @@ export function resolveComposerCapacity(input: ComposerGeometry): number {
|
||||
input.height - input.bottomInset,
|
||||
input.height - input.safeAreaBottom - input.keyboardShift,
|
||||
)
|
||||
: input.height - input.bottomInset - input.keyboardShift;
|
||||
: input.height -
|
||||
input.bottomInset -
|
||||
input.keyboardShift -
|
||||
Math.min(input.keyboardGap, input.keyboardShift);
|
||||
return Math.max(0, available - 5);
|
||||
}
|
||||
|
||||
export interface ComposerBottomPlacement {
|
||||
/** The composer's distance from the window bottom with the keyboard closed. */
|
||||
restingPadding: number;
|
||||
/** How much further than the keyboard's safe-area overlap the composer rises. */
|
||||
keyboardGap: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* One bottom distance for the docked composer: the safe-area bottom, never closer to
|
||||
* the edge than the content spacing. With the keyboard open it keeps the content
|
||||
* spacing above the keyboard.
|
||||
*/
|
||||
export function resolveComposerBottomPlacement(input: {
|
||||
safeAreaBottom: number;
|
||||
contentSpacing: number;
|
||||
}): ComposerBottomPlacement {
|
||||
const restingPadding = Math.max(input.safeAreaBottom, input.contentSpacing);
|
||||
return {
|
||||
restingPadding,
|
||||
keyboardGap: input.safeAreaBottom + input.contentSpacing - restingPadding,
|
||||
};
|
||||
}
|
||||
|
||||
/** Space below a centered form that the keyboard fills before the form moves. */
|
||||
export function resolveCenteredClearance(input: {
|
||||
viewportHeight: number;
|
||||
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
TOOLBAR_CONTROL_HEIGHT,
|
||||
} from "@/components/ui/control-geometry";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useHasFinePointer } from "@/hooks/use-fine-pointer";
|
||||
import { usePointerCapabilities } from "@/hooks/pointer-capabilities";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import {
|
||||
ArrowUp,
|
||||
@@ -1312,7 +1312,7 @@ function ComposerContentImpl({
|
||||
const isCompactLayout = resolveCompactLayout(isCompactLayoutOverride, isCompactFormFactor);
|
||||
const buttonIconSize = iconSizeFor("md")[controlSizingForLayout(isCompactFormFactor)];
|
||||
const isDesktopWebBreakpoint = resolveIsDesktopWebBreakpoint(isCompactFormFactor);
|
||||
const hasFinePointer = useHasFinePointer();
|
||||
const { finePointer: hasFinePointer } = usePointerCapabilities();
|
||||
const isDesktopLayout = resolveIsDesktopWebBreakpoint(isCompactLayout);
|
||||
const messagePlaceholder = resolveMessagePlaceholder(inputMode, isDesktopLayout, t, placeholder);
|
||||
const hasText = useSyncExternalStore(
|
||||
@@ -2568,7 +2568,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
width: "100%",
|
||||
overflow: "visible",
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingBottom: theme.spacing[4],
|
||||
},
|
||||
inputAreaLocked: {
|
||||
opacity: 0.6,
|
||||
@@ -2578,7 +2577,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
minHeight: 0,
|
||||
alignItems: "stretch",
|
||||
paddingHorizontal: 0,
|
||||
paddingBottom: 0,
|
||||
},
|
||||
fullscreenFill: {
|
||||
flex: 1,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import {
|
||||
View,
|
||||
Platform,
|
||||
Text,
|
||||
useWindowDimensions,
|
||||
NativeSyntheticEvent,
|
||||
@@ -36,7 +37,7 @@ import {
|
||||
import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useDictation } from "@/hooks/use-dictation";
|
||||
import { hasFinePointer } from "@/hooks/use-fine-pointer";
|
||||
import { hasFinePointer } from "@/hooks/pointer-capabilities";
|
||||
import { DictationOverlay } from "@/components/dictation-controls";
|
||||
import { RealtimeVoiceOverlay } from "@/components/realtime-voice-overlay";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
@@ -1936,7 +1937,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
const inputWrapperCombinedStyle = useMemo(
|
||||
() => [
|
||||
styles.inputWrapper,
|
||||
isCompact && !isFullscreen && styles.inputWrapperCompact,
|
||||
Platform.OS !== "ios" && isCompact && !isFullscreen && styles.inputWrapperCompact,
|
||||
readOnly && styles.inputWrapperReadOnly,
|
||||
inputWrapperStyle,
|
||||
{ opacity: surfacePresentation.input.opacity },
|
||||
@@ -2230,7 +2231,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
return (
|
||||
<View
|
||||
ref={rootRef}
|
||||
style={isFullscreen ? styles.fullscreenContainer : styles.container}
|
||||
style={
|
||||
isFullscreen
|
||||
? styles.fullscreenContainer
|
||||
: [styles.container, Platform.OS === "ios" && isCompact && styles.inputWrapperCompact]
|
||||
}
|
||||
testID="message-input-root"
|
||||
onLayout={handleComposerLayout}
|
||||
>
|
||||
@@ -2276,6 +2281,7 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
position: "relative",
|
||||
},
|
||||
inputWrapper: {
|
||||
...(Platform.OS === "ios" ? { overflow: "hidden" as const } : {}),
|
||||
flexShrink: 1,
|
||||
flexDirection: "column",
|
||||
gap: theme.spacing[3],
|
||||
|
||||
@@ -25,7 +25,7 @@ export async function fetchCheckoutStatus({
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
}): Promise<CheckoutStatusPayload> {
|
||||
const payload = await client.getCheckoutStatus(cwd);
|
||||
const payload = normalizeCheckoutBaseRef(await client.getCheckoutStatus(cwd));
|
||||
return payload;
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ export function applyCheckoutStatusUpdateFromEvent({
|
||||
serverId: string;
|
||||
message: CheckoutStatusUpdate;
|
||||
}): void {
|
||||
const { payload } = message;
|
||||
const payload = normalizeCheckoutBaseRef(message.payload);
|
||||
const prStatus = payload.prStatus
|
||||
? normalizeCheckoutPrStatusPayload(payload.prStatus)
|
||||
: undefined;
|
||||
@@ -88,6 +88,13 @@ export function applyCheckoutStatusUpdateFromEvent({
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCheckoutBaseRef<T extends CheckoutStatusPayload>(payload: T): T {
|
||||
// COMPAT(workspaceBaseBranch): hosts <= v0.11.2 only send the display ref; remove after 2027-10-14.
|
||||
return !payload.isGit || payload.baseRefExact === undefined
|
||||
? payload
|
||||
: { ...payload, baseRef: payload.baseRefExact };
|
||||
}
|
||||
|
||||
// requestId changes on every emission and carries no PR state.
|
||||
function prStatusWithoutVolatileFields(
|
||||
prStatus: CheckoutPrStatusPayload,
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { Theme } from "@/styles/theme";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import {
|
||||
AlignJustify,
|
||||
ArrowRight,
|
||||
ChevronDown,
|
||||
Columns2,
|
||||
ExternalLink,
|
||||
@@ -61,6 +62,7 @@ import { GitActionsSplitButton } from "@/git/actions-split-button";
|
||||
import type { GitActions } from "@/git/policy";
|
||||
import { BranchSwitcher } from "@/components/branch-switcher";
|
||||
import { useGitActions } from "@/git/use-actions";
|
||||
import { WorkspaceBaseBranchPicker } from "@/git/workspace-base-branch-picker";
|
||||
import { GIT_ACTION_ICONS } from "@/git/action-icons";
|
||||
import { buildForgeSignInCommand, getForgePresentation, type Forge } from "@/git/forge";
|
||||
import { parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
|
||||
@@ -201,6 +203,7 @@ const foregroundMutedIconColorMapping = (theme: Theme) => ({ color: theme.colors
|
||||
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const ThemedAlignJustify = withUnistyles(AlignJustify);
|
||||
const ThemedArrowRight = withUnistyles(ArrowRight);
|
||||
const ThemedColumns2 = withUnistyles(Columns2);
|
||||
const ThemedPilcrow = withUnistyles(Pilcrow);
|
||||
const ThemedWrapText = withUnistyles(WrapText);
|
||||
@@ -470,6 +473,8 @@ interface ChangesPullRequestLinkModel extends Pick<PrHint, "forge" | "number" |
|
||||
}
|
||||
|
||||
interface ChangesRepositoryToolbarModel {
|
||||
baseRef: string | null;
|
||||
isWorktree: boolean;
|
||||
branchName: string | null;
|
||||
cwd: string;
|
||||
gitActions: GitActions | null;
|
||||
@@ -496,6 +501,8 @@ interface ChangesHeaderProps {
|
||||
}
|
||||
|
||||
interface BuildChangesHeaderModelInput {
|
||||
baseRef: string | null;
|
||||
isWorktree: boolean;
|
||||
branchName: string | null;
|
||||
committedDescription?: string;
|
||||
compact: boolean;
|
||||
@@ -519,6 +526,8 @@ function buildChangesHeaderModel(input: BuildChangesHeaderModelInput): {
|
||||
} {
|
||||
return {
|
||||
repository: {
|
||||
baseRef: input.baseRef,
|
||||
isWorktree: input.isWorktree,
|
||||
branchName: input.branchName,
|
||||
cwd: input.cwd,
|
||||
gitActions: input.compact ? input.gitActions : null,
|
||||
@@ -629,6 +638,10 @@ function ChangesToolbarTrailing({ children }: { children: ReactNode }) {
|
||||
return <ToolbarControls style={styles.changesToolbarControls}>{children}</ToolbarControls>;
|
||||
}
|
||||
|
||||
function isManagedWorktree(status: { isPaseoOwnedWorktree?: boolean } | null | undefined): boolean {
|
||||
return status?.isPaseoOwnedWorktree === true;
|
||||
}
|
||||
|
||||
function ChangesRepositoryToolbar({
|
||||
compact,
|
||||
model,
|
||||
@@ -654,6 +667,18 @@ function ChangesRepositoryToolbar({
|
||||
isGitCheckout
|
||||
testID="changes-branch-switcher"
|
||||
/>
|
||||
{model.baseRef ? (
|
||||
<>
|
||||
<ThemedArrowRight size={14} uniProps={foregroundMutedIconColorMapping} />
|
||||
<WorkspaceBaseBranchPicker
|
||||
serverId={model.serverId}
|
||||
workspaceId={model.workspaceId}
|
||||
cwd={model.cwd}
|
||||
baseRef={model.baseRef}
|
||||
isWorktree={model.isWorktree}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</ChangesToolbarLeading>
|
||||
<ChangesToolbarTrailing>
|
||||
{model.pullRequest ? (
|
||||
@@ -1904,6 +1929,8 @@ export function ChangesSurface({
|
||||
const changesHeaderModel = useMemo(
|
||||
() =>
|
||||
buildChangesHeaderModel({
|
||||
baseRef: baseRef ?? null,
|
||||
isWorktree: isManagedWorktree(status),
|
||||
branchName: currentBranchName,
|
||||
committedDescription: committedDiffDescription,
|
||||
compact: isMobile,
|
||||
@@ -1921,6 +1948,8 @@ export function ChangesSurface({
|
||||
workspaceId,
|
||||
}),
|
||||
[
|
||||
baseRef,
|
||||
status,
|
||||
committedDiffDescription,
|
||||
currentBranchName,
|
||||
cwd,
|
||||
|
||||
@@ -2,7 +2,13 @@ import { afterEach, describe, expect, it } from "vitest";
|
||||
import { CheckoutPrStatusSchema } from "@getpaseo/protocol/messages";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
|
||||
import { actionsWithoutPrimary, buildGitActions, type BuildGitActionsInput } from "./policy";
|
||||
import {
|
||||
actionsWithoutPrimary,
|
||||
buildGitActions,
|
||||
isCurrentBranchBase,
|
||||
canMergeIntoLocalBase,
|
||||
type BuildGitActionsInput,
|
||||
} from "./policy";
|
||||
import { deriveMergeCapability, type ForgeSpecificStatusFacts } from "./merge-capability";
|
||||
|
||||
type GithubMergeFactsFixture = ForgeSpecificStatusFacts & {
|
||||
@@ -72,6 +78,7 @@ function createInput(
|
||||
hasRemote: false,
|
||||
isPaseoOwnedWorktree: false,
|
||||
isOnBaseBranch: true,
|
||||
canMergeLocally: true,
|
||||
hasUncommittedChanges: false,
|
||||
baseRefAvailable: true,
|
||||
baseRefLabel: "main",
|
||||
@@ -208,6 +215,81 @@ describe("git-actions-policy", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("distinguishes exact local and remote base refs and retains legacy names", () => {
|
||||
expect(isCurrentBranchBase("release", "refs/heads/release")).toBe(true);
|
||||
expect(isCurrentBranchBase("release", "refs/remotes/origin/release")).toBe(false);
|
||||
expect(isCurrentBranchBase("release", "release")).toBe(true);
|
||||
expect(isCurrentBranchBase("release", "origin/release")).toBe(true);
|
||||
expect(isCurrentBranchBase(null, "refs/heads/release")).toBe(false);
|
||||
expect(canMergeIntoLocalBase("release", "refs/heads/release")).toBe(false);
|
||||
expect(canMergeIntoLocalBase("feature", "refs/remotes/origin/release")).toBe(true);
|
||||
expect(canMergeIntoLocalBase("release", "refs/remotes/upstream/release")).toBe(false);
|
||||
});
|
||||
|
||||
it("offers local merge when the selected local base starts with origin slash", () => {
|
||||
const actions = buildGitActions(
|
||||
createInput({
|
||||
isPaseoOwnedWorktree: true,
|
||||
isOnBaseBranch: isCurrentBranchBase("release", "refs/heads/origin/release"),
|
||||
canMergeLocally: canMergeIntoLocalBase("release", "refs/heads/origin/release"),
|
||||
baseRefLabel: "origin/release",
|
||||
aheadCount: 1,
|
||||
shipDefault: "merge",
|
||||
}),
|
||||
);
|
||||
expect(actions.primary?.id).toBe("merge-branch");
|
||||
});
|
||||
|
||||
it("offers update but not local merge for a non-origin remote base", () => {
|
||||
const actions = buildGitActions(
|
||||
createInput({
|
||||
isPaseoOwnedWorktree: true,
|
||||
isOnBaseBranch: false,
|
||||
canMergeLocally: canMergeIntoLocalBase("feature", "refs/remotes/upstream/release"),
|
||||
baseRefLabel: "upstream/release",
|
||||
aheadCount: 1,
|
||||
behindBaseCount: 1,
|
||||
aheadOfOrigin: null,
|
||||
behindOfOrigin: null,
|
||||
shipDefault: "merge",
|
||||
}),
|
||||
);
|
||||
expect(actions.primary?.id).toBe("merge-from-base");
|
||||
expect(actionsWithoutPrimary(actions).map((action) => action.id)).not.toContain("merge-branch");
|
||||
});
|
||||
|
||||
it("does not offer merging into the current local branch when the remote base shares its name", () => {
|
||||
const actions = buildGitActions(
|
||||
createInput({
|
||||
isPaseoOwnedWorktree: true,
|
||||
isOnBaseBranch: false,
|
||||
canMergeLocally: canMergeIntoLocalBase("release", "refs/remotes/origin/release"),
|
||||
baseRefLabel: "release",
|
||||
aheadCount: 1,
|
||||
behindBaseCount: 1,
|
||||
aheadOfOrigin: null,
|
||||
behindOfOrigin: null,
|
||||
shipDefault: "merge",
|
||||
}),
|
||||
);
|
||||
expect(actions.primary?.id).toBe("merge-from-base");
|
||||
expect(actionsWithoutPrimary(actions).map((action) => action.id)).not.toContain("merge-branch");
|
||||
});
|
||||
|
||||
it("offers update from the same-named remote base when it is ahead", () => {
|
||||
const actions = buildGitActions(
|
||||
createInput({
|
||||
isPaseoOwnedWorktree: true,
|
||||
isOnBaseBranch: isCurrentBranchBase("release", "refs/remotes/origin/release"),
|
||||
baseRefLabel: "release",
|
||||
behindBaseCount: 1,
|
||||
aheadOfOrigin: null,
|
||||
behindOfOrigin: null,
|
||||
}),
|
||||
);
|
||||
expect(actions.primary?.id).toBe("merge-from-base");
|
||||
});
|
||||
|
||||
it("drops a promoted merge action from the remaining menu actions", () => {
|
||||
const actions = buildGitActions(
|
||||
createInput({
|
||||
|
||||
@@ -50,6 +50,29 @@ interface GitActionRuntimeState {
|
||||
handler: () => void;
|
||||
}
|
||||
|
||||
export function isCurrentBranchBase(
|
||||
currentBranch: string | null | undefined,
|
||||
baseRef: string | null | undefined,
|
||||
): boolean {
|
||||
if (!currentBranch || !baseRef) return false;
|
||||
if (baseRef.startsWith("refs/heads/")) return baseRef === `refs/heads/${currentBranch}`;
|
||||
if (baseRef.startsWith("refs/")) return false;
|
||||
// Older hosts send a display name, which cannot distinguish a local ref from its remote.
|
||||
return currentBranch === baseRef.replace(/^origin\//, "");
|
||||
}
|
||||
|
||||
export function canMergeIntoLocalBase(
|
||||
currentBranch: string | null | undefined,
|
||||
baseRef: string | null | undefined,
|
||||
): boolean {
|
||||
if (!currentBranch || !baseRef) return false;
|
||||
if (baseRef.startsWith("refs/remotes/") && !baseRef.startsWith("refs/remotes/origin/")) {
|
||||
return false;
|
||||
}
|
||||
const localTarget = baseRef.replace(/^(?:refs\/heads\/|refs\/remotes\/origin\/|origin\/)/, "");
|
||||
return currentBranch !== localTarget;
|
||||
}
|
||||
|
||||
export interface BuildGitActionsInput {
|
||||
isGit: boolean;
|
||||
githubFeaturesEnabled: boolean;
|
||||
@@ -68,6 +91,7 @@ export interface BuildGitActionsInput {
|
||||
hasRemote: boolean;
|
||||
isPaseoOwnedWorktree: boolean;
|
||||
isOnBaseBranch: boolean;
|
||||
canMergeLocally: boolean;
|
||||
hasUncommittedChanges: boolean;
|
||||
baseRefAvailable: boolean;
|
||||
baseRefLabel: string;
|
||||
@@ -349,7 +373,7 @@ function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
|
||||
if (input.shipDefault === "pr" && canUsePullRequestActionAsShipDefault(input)) {
|
||||
return "pr";
|
||||
}
|
||||
if (!input.isOnBaseBranch && input.aheadCount > 0) {
|
||||
if (!input.isOnBaseBranch && input.canMergeLocally && input.aheadCount > 0) {
|
||||
return "merge-branch";
|
||||
}
|
||||
if (!input.isOnBaseBranch && canMergeFromBase(input)) {
|
||||
@@ -380,7 +404,7 @@ function getPullRequestActionIds(filter: {
|
||||
function getFeatureActionIds(input: BuildGitActionsInput): GitActionId[] {
|
||||
return [
|
||||
"merge-from-base",
|
||||
"merge-branch",
|
||||
...(input.canMergeLocally ? (["merge-branch"] as const) : []),
|
||||
...getPullRequestActionIds({ roles: ["status", "direct", "auto"], input }),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import { type CheckoutStatusPayload, useCheckoutStatusQuery } from "@/git/use-st
|
||||
import { type CheckoutPrStatusPayload, useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
|
||||
import {
|
||||
buildGitActions,
|
||||
isCurrentBranchBase,
|
||||
canMergeIntoLocalBase,
|
||||
narrowPullRequestState,
|
||||
type BuildGitActionsInput,
|
||||
type GitAction,
|
||||
@@ -116,7 +118,7 @@ interface DeriveGitActionsStateArgs {
|
||||
hasUncommittedChanges: boolean;
|
||||
postShipArchiveSuggested: boolean;
|
||||
isStatusLoading: boolean;
|
||||
baseRefLabel: string;
|
||||
baseRef: string | undefined;
|
||||
}
|
||||
|
||||
interface DerivedGitActionsState {
|
||||
@@ -129,6 +131,7 @@ interface DerivedGitActionsState {
|
||||
hasRemote: boolean;
|
||||
isPaseoOwnedWorktree: boolean;
|
||||
isOnBaseBranch: boolean;
|
||||
canMergeLocally: boolean;
|
||||
shouldPromoteArchive: boolean;
|
||||
}
|
||||
|
||||
@@ -167,7 +170,7 @@ function deriveGitActionsState(args: DeriveGitActionsStateArgs): DerivedGitActio
|
||||
hasUncommittedChanges,
|
||||
postShipArchiveSuggested,
|
||||
isStatusLoading,
|
||||
baseRefLabel,
|
||||
baseRef,
|
||||
} = args;
|
||||
const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading;
|
||||
const isPaseoOwnedWorktree = gitStatus?.isPaseoOwnedWorktree ?? false;
|
||||
@@ -178,7 +181,8 @@ function deriveGitActionsState(args: DeriveGitActionsStateArgs): DerivedGitActio
|
||||
hasPullRequest: Boolean(prStatus?.url),
|
||||
hasRemote: gitStatus?.hasRemote ?? false,
|
||||
isPaseoOwnedWorktree,
|
||||
isOnBaseBranch: gitStatus?.currentBranch === baseRefLabel,
|
||||
isOnBaseBranch: isCurrentBranchBase(gitStatus?.currentBranch, baseRef),
|
||||
canMergeLocally: canMergeIntoLocalBase(gitStatus?.currentBranch, baseRef),
|
||||
shouldPromoteArchive: computeShouldPromoteArchive({
|
||||
hasUncommittedChanges,
|
||||
postShipArchiveSuggested,
|
||||
@@ -653,7 +657,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
hasUncommittedChanges,
|
||||
postShipArchiveSuggested,
|
||||
isStatusLoading,
|
||||
baseRefLabel,
|
||||
baseRef,
|
||||
});
|
||||
const {
|
||||
actionsDisabled,
|
||||
@@ -665,6 +669,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
hasRemote,
|
||||
isPaseoOwnedWorktree,
|
||||
isOnBaseBranch,
|
||||
canMergeLocally,
|
||||
shouldPromoteArchive,
|
||||
} = derived;
|
||||
|
||||
@@ -695,6 +700,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
hasRemote,
|
||||
isPaseoOwnedWorktree,
|
||||
isOnBaseBranch,
|
||||
canMergeLocally,
|
||||
hasUncommittedChanges,
|
||||
baseRefAvailable: Boolean(baseRef),
|
||||
baseRefLabel,
|
||||
@@ -812,6 +818,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
behindBaseCount,
|
||||
isPaseoOwnedWorktree,
|
||||
isOnBaseBranch,
|
||||
canMergeLocally,
|
||||
githubFeaturesEnabled,
|
||||
forge,
|
||||
githubAutoMergeActionsEnabled,
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { GitBranch } from "lucide-react-native";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useFetchQuery } from "@/data/query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { Combobox, ComboboxItem, type ComboboxProps } from "@/components/ui/combobox";
|
||||
import { ToolbarLabelSelectTrigger } from "@/components/ui/toolbar-label-trigger";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import { invalidateCheckoutGitQueriesForClient } from "./query-keys";
|
||||
|
||||
interface Props {
|
||||
serverId: string;
|
||||
workspaceId?: string | null;
|
||||
cwd: string;
|
||||
baseRef: string;
|
||||
isWorktree: boolean;
|
||||
}
|
||||
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const ThemedGitBranch = withUnistyles(GitBranch);
|
||||
const mutedColor = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
function refLabel(ref: string): string {
|
||||
return ref.replace(/^refs\/(heads|remotes)\//, "");
|
||||
}
|
||||
|
||||
export function WorkspaceBaseBranchPicker({
|
||||
serverId,
|
||||
workspaceId,
|
||||
cwd,
|
||||
baseRef,
|
||||
isWorktree,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const connected = useHostRuntimeIsConnected(serverId);
|
||||
// COMPAT(workspaceBaseBranch): added after v0.11.2, remove gate after 2027-10-14.
|
||||
const supported = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.serverInfo?.features?.workspaceBaseBranch === true,
|
||||
);
|
||||
const queryClient = useQueryClient();
|
||||
const anchorRef = useRef<View>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const query = useDebouncedValue(search, 200);
|
||||
const branches = useFetchQuery({
|
||||
queryKey: ["workspaceBaseBranches", serverId, cwd, query],
|
||||
queryFn: async () => {
|
||||
if (!client) throw new Error(t("common.errors.daemonClientUnavailable"));
|
||||
const result = await client.getBranchSuggestions({
|
||||
cwd,
|
||||
refMode: "exact",
|
||||
query,
|
||||
limit: 200,
|
||||
});
|
||||
if (result.error) throw new Error(result.error);
|
||||
return result.branches;
|
||||
},
|
||||
enabled: open && supported && connected && Boolean(client),
|
||||
retry: false,
|
||||
dataShape: "value",
|
||||
staleTimeMs: 0,
|
||||
});
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (ref: string) => {
|
||||
if (!client || !workspaceId) throw new Error(t("common.errors.daemonClientUnavailable"));
|
||||
return client.setWorkspaceBaseBranch({ workspaceId, baseRef: ref });
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setOpen(false);
|
||||
await invalidateCheckoutGitQueriesForClient(queryClient, { serverId, cwd });
|
||||
},
|
||||
});
|
||||
const options = useMemo(
|
||||
() => (branches.data ?? []).map((ref) => ({ id: ref, label: refLabel(ref) })),
|
||||
[branches.data],
|
||||
);
|
||||
const error = mutation.error ?? branches.error;
|
||||
const title = t("workspace.git.diff.baseBranch.title");
|
||||
const label = refLabel(baseRef).replace(/^origin\//, "");
|
||||
|
||||
const { reset, mutate, isPending } = mutation;
|
||||
const { refetch, isError } = branches;
|
||||
const handleOpen = useCallback(() => {
|
||||
reset();
|
||||
setSearch("");
|
||||
setOpen(true);
|
||||
}, [reset]);
|
||||
const handleSelect = useCallback(
|
||||
(ref: string) => {
|
||||
if (!isPending) mutate(ref);
|
||||
},
|
||||
[isPending, mutate],
|
||||
);
|
||||
const handleRetry = useCallback(() => {
|
||||
void refetch();
|
||||
}, [refetch]);
|
||||
const branchIcon = useMemo(() => <ThemedGitBranch size={14} uniProps={mutedColor} />, []);
|
||||
const renderOption = useCallback<NonNullable<ComboboxProps["renderOption"]>>(
|
||||
({ option, selected, active, onPress }) => (
|
||||
<ComboboxItem
|
||||
label={option.label}
|
||||
leadingSlot={branchIcon}
|
||||
selected={selected}
|
||||
active={active}
|
||||
onPress={onPress}
|
||||
disabled={isPending}
|
||||
/>
|
||||
),
|
||||
[isPending, branchIcon],
|
||||
);
|
||||
const footer = useMemo(
|
||||
() => (
|
||||
<View style={styles.footer}>
|
||||
{isPending ? <ThemedLoadingSpinner size="small" uniProps={mutedColor} /> : null}
|
||||
{error ? (
|
||||
<Text accessibilityRole="alert" testID="workspace-base-branch-error" style={styles.error}>
|
||||
{error.message}
|
||||
</Text>
|
||||
) : null}
|
||||
{isError ? (
|
||||
<Button variant="outline" onPress={handleRetry}>
|
||||
{t("common.actions.retry")}
|
||||
</Button>
|
||||
) : null}
|
||||
</View>
|
||||
),
|
||||
[t, isPending, error, isError, handleRetry],
|
||||
);
|
||||
|
||||
if (!supported || !isWorktree || !workspaceId) return <Text style={styles.text}>{label}</Text>;
|
||||
|
||||
return (
|
||||
<View ref={anchorRef} collapsable={false} style={styles.anchor}>
|
||||
<ToolbarLabelSelectTrigger
|
||||
testID="workspace-base-branch-trigger"
|
||||
label={label}
|
||||
open={open}
|
||||
disabled={!connected || mutation.isPending}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={title}
|
||||
onPress={handleOpen}
|
||||
/>
|
||||
<Combobox
|
||||
options={options}
|
||||
value={baseRef}
|
||||
onSelect={handleSelect}
|
||||
keepOpenOnSelect
|
||||
searchable
|
||||
onSearchQueryChange={setSearch}
|
||||
searchPlaceholder={t("branchSwitcher.searchPlaceholder")}
|
||||
emptyText={branches.isFetching ? t("common.states.loading") : t("branchSwitcher.empty")}
|
||||
title={title}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
anchorRef={anchorRef}
|
||||
desktopPlacement="bottom-start"
|
||||
desktopPreventInitialFlash
|
||||
desktopMinWidth={300}
|
||||
renderOption={renderOption}
|
||||
footer={error || isPending ? footer : undefined}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
anchor: { flexShrink: 1, minWidth: 0 },
|
||||
footer: { padding: theme.spacing[3], gap: theme.spacing[2] },
|
||||
text: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm },
|
||||
error: { color: theme.colors.destructive, fontSize: theme.fontSize.sm },
|
||||
}));
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createPointerCapabilitiesSource } from "./pointer-capabilities";
|
||||
|
||||
class FakeMediaQueryList {
|
||||
private readonly listeners = new Set<() => void>();
|
||||
|
||||
constructor(
|
||||
readonly media: string,
|
||||
public matches: boolean,
|
||||
) {}
|
||||
|
||||
addEventListener(_type: "change", listener: () => void) {
|
||||
this.listeners.add(listener);
|
||||
}
|
||||
|
||||
removeEventListener(_type: "change", listener: () => void) {
|
||||
this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
setMatches(matches: boolean) {
|
||||
this.matches = matches;
|
||||
for (const listener of this.listeners) listener();
|
||||
}
|
||||
}
|
||||
|
||||
function createFakeDisplay(initial: { finePointer: boolean; touchScreen: boolean }) {
|
||||
const queries = {
|
||||
finePointer: new FakeMediaQueryList("(hover: hover) and (pointer: fine)", initial.finePointer),
|
||||
touchScreen: new FakeMediaQueryList("(any-pointer: coarse)", initial.touchScreen),
|
||||
};
|
||||
const matchMedia = (query: string) => {
|
||||
const match = Object.values(queries).find((list) => list.media === query);
|
||||
if (!match) throw new Error(`Unexpected media query ${query}`);
|
||||
return match as unknown as MediaQueryList;
|
||||
};
|
||||
return { queries, matchMedia };
|
||||
}
|
||||
|
||||
describe("pointer capabilities", () => {
|
||||
it("reports a hybrid touch laptop as both a fine pointer and a touch screen", () => {
|
||||
const display = createFakeDisplay({ finePointer: true, touchScreen: true });
|
||||
const source = createPointerCapabilitiesSource(display.matchMedia);
|
||||
|
||||
expect(source.getSnapshot()).toEqual({ finePointer: true, touchScreen: true });
|
||||
});
|
||||
|
||||
it("notifies and updates the touch answer when a touch screen appears or goes away", () => {
|
||||
const display = createFakeDisplay({ finePointer: true, touchScreen: false });
|
||||
const source = createPointerCapabilitiesSource(display.matchMedia);
|
||||
const seen: boolean[] = [];
|
||||
const unsubscribe = source.subscribe(() => seen.push(source.getSnapshot().touchScreen));
|
||||
|
||||
display.queries.touchScreen.setMatches(true);
|
||||
display.queries.touchScreen.setMatches(false);
|
||||
unsubscribe();
|
||||
display.queries.touchScreen.setMatches(true);
|
||||
|
||||
expect(seen).toEqual([true, false]);
|
||||
});
|
||||
|
||||
it("keeps one snapshot until an answer changes", () => {
|
||||
const display = createFakeDisplay({ finePointer: false, touchScreen: true });
|
||||
const source = createPointerCapabilitiesSource(display.matchMedia);
|
||||
const first = source.getSnapshot();
|
||||
|
||||
expect(source.getSnapshot()).toBe(first);
|
||||
display.queries.finePointer.setMatches(true);
|
||||
expect(source.getSnapshot()).toEqual({ finePointer: true, touchScreen: true });
|
||||
});
|
||||
|
||||
it("reports neither capability without media queries", () => {
|
||||
expect(createPointerCapabilitiesSource(null).getSnapshot()).toEqual({
|
||||
finePointer: false,
|
||||
touchScreen: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
// A touch tablet running the desktop layout is still `isWeb === false || true`
|
||||
// depending on the build, so neither platform gate answers "can this user
|
||||
// hover and point precisely" or "can this user touch the screen". Only the
|
||||
// media queries do, and the same queries notify when the answer changes
|
||||
// (detachable keyboards, tablet mode, devtools touch emulation).
|
||||
const FINE_POINTER_QUERY = "(hover: hover) and (pointer: fine)";
|
||||
const TOUCH_SCREEN_QUERY = "(any-pointer: coarse)";
|
||||
|
||||
export interface PointerCapabilities {
|
||||
/** A mouse-like pointer drives the UI. */
|
||||
finePointer: boolean;
|
||||
/** A touch screen is available, possibly next to a mouse on hybrid devices. */
|
||||
touchScreen: boolean;
|
||||
}
|
||||
|
||||
type MatchMedia = (query: string) => MediaQueryList;
|
||||
|
||||
export interface PointerCapabilitiesSource {
|
||||
getSnapshot: () => PointerCapabilities;
|
||||
subscribe: (onChange: () => void) => () => void;
|
||||
}
|
||||
|
||||
export function createPointerCapabilitiesSource(
|
||||
matchMedia: MatchMedia | null,
|
||||
): PointerCapabilitiesSource {
|
||||
const finePointerQuery = matchMedia?.(FINE_POINTER_QUERY) ?? null;
|
||||
const touchScreenQuery = matchMedia?.(TOUCH_SCREEN_QUERY) ?? null;
|
||||
let snapshot: PointerCapabilities = { finePointer: false, touchScreen: false };
|
||||
|
||||
return {
|
||||
getSnapshot() {
|
||||
const finePointer = finePointerQuery?.matches ?? false;
|
||||
const touchScreen = touchScreenQuery?.matches ?? false;
|
||||
if (snapshot.finePointer !== finePointer || snapshot.touchScreen !== touchScreen) {
|
||||
snapshot = { finePointer, touchScreen };
|
||||
}
|
||||
return snapshot;
|
||||
},
|
||||
subscribe(onChange) {
|
||||
finePointerQuery?.addEventListener("change", onChange);
|
||||
touchScreenQuery?.addEventListener("change", onChange);
|
||||
return () => {
|
||||
finePointerQuery?.removeEventListener("change", onChange);
|
||||
touchScreenQuery?.removeEventListener("change", onChange);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function browserMatchMedia(): MatchMedia | null {
|
||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return null;
|
||||
return (query) => window.matchMedia(query);
|
||||
}
|
||||
|
||||
// Native builds run on iOS and Android touch screens. A paired mouse or pen is
|
||||
// filtered per gesture event by its pointer type, not by this capability.
|
||||
const NATIVE_POINTER_CAPABILITIES: PointerCapabilities = { finePointer: false, touchScreen: true };
|
||||
const nativeSource: PointerCapabilitiesSource = {
|
||||
getSnapshot: () => NATIVE_POINTER_CAPABILITIES,
|
||||
subscribe: () => () => {},
|
||||
};
|
||||
|
||||
const source = isWeb ? createPointerCapabilitiesSource(browserMatchMedia()) : nativeSource;
|
||||
const SERVER_SNAPSHOT: PointerCapabilities = { finePointer: false, touchScreen: false };
|
||||
|
||||
function getServerSnapshot(): PointerCapabilities {
|
||||
return SERVER_SNAPSHOT;
|
||||
}
|
||||
|
||||
export function usePointerCapabilities(): PointerCapabilities {
|
||||
return useSyncExternalStore(source.subscribe, source.getSnapshot, getServerSnapshot);
|
||||
}
|
||||
|
||||
/** True when a mouse-like pointer drives the UI, read at call time. */
|
||||
export function hasFinePointer(): boolean {
|
||||
return source.getSnapshot().finePointer;
|
||||
}
|
||||
|
||||
/** Touch sizing remains enabled on hybrid browsers that report touch points. */
|
||||
export function hasTouchScreen(): boolean {
|
||||
return (
|
||||
source.getSnapshot().touchScreen ||
|
||||
(isWeb && typeof window !== "undefined" && window.navigator.maxTouchPoints > 0)
|
||||
);
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
// A touch tablet running the desktop layout is still `isWeb === false || true`
|
||||
// depending on the build, so neither platform gate answers "can this user
|
||||
// hover and point precisely". Only the media query does.
|
||||
const FINE_POINTER_QUERY = "(hover: hover) and (pointer: fine)";
|
||||
|
||||
function getFinePointerQueryList(): MediaQueryList | null {
|
||||
if (!isWeb || typeof window === "undefined" || typeof window.matchMedia !== "function") {
|
||||
return null;
|
||||
}
|
||||
return window.matchMedia(FINE_POINTER_QUERY);
|
||||
}
|
||||
|
||||
/** True when a mouse-like pointer drives the UI. Always false on native. */
|
||||
export function hasFinePointer(): boolean {
|
||||
return getFinePointerQueryList()?.matches ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the device has a touch screen, even while a mouse or keyboard is attached to it:
|
||||
* the primary pointer is coarse, or the screen reports touch points. Always true on native.
|
||||
*/
|
||||
export function hasTouchScreen(): boolean {
|
||||
if (!isWeb) return true;
|
||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
|
||||
return window.matchMedia("(pointer: coarse)").matches || window.navigator.maxTouchPoints > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive `hasFinePointer()` — re-renders when the user switches between a
|
||||
* mouse and touch (detachable keyboards, tablet mode).
|
||||
*/
|
||||
export function useHasFinePointer(): boolean {
|
||||
const [finePointer, setFinePointer] = useState(hasFinePointer);
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQuery = getFinePointerQueryList();
|
||||
if (!mediaQuery) return;
|
||||
const handleChange = () => setFinePointer(mediaQuery.matches);
|
||||
handleChange();
|
||||
mediaQuery.addEventListener?.("change", handleChange);
|
||||
return () => {
|
||||
mediaQuery.removeEventListener?.("change", handleChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return finePointer;
|
||||
}
|
||||
@@ -930,6 +930,9 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
},
|
||||
diff: {
|
||||
baseBranch: {
|
||||
title: "الفرع الأساسي",
|
||||
},
|
||||
openChangesTab: "فتح علامة تبويب التغييرات",
|
||||
openDiffTab: "فتح علامة تبويب الفرق",
|
||||
closeChangesTab: "إغلاق علامة تبويب التغييرات",
|
||||
|
||||
@@ -938,6 +938,9 @@ export const en = {
|
||||
},
|
||||
},
|
||||
diff: {
|
||||
baseBranch: {
|
||||
title: "Change base",
|
||||
},
|
||||
openChangesTab: "Open Changes tab",
|
||||
openDiffTab: "Open Diff tab",
|
||||
closeChangesTab: "Close Changes tab",
|
||||
|
||||
@@ -963,6 +963,9 @@ export const es: TranslationResources = {
|
||||
},
|
||||
},
|
||||
diff: {
|
||||
baseBranch: {
|
||||
title: "Rama base",
|
||||
},
|
||||
openChangesTab: "Abrir la pestaña Cambios",
|
||||
openDiffTab: "Abrir la pestaña Diferencia",
|
||||
closeChangesTab: "Cerrar la pestaña Cambios",
|
||||
|
||||
@@ -956,6 +956,9 @@ export const fr: TranslationResources = {
|
||||
},
|
||||
},
|
||||
diff: {
|
||||
baseBranch: {
|
||||
title: "Branche de base",
|
||||
},
|
||||
openChangesTab: "Ouvrir l’onglet Modifications",
|
||||
openDiffTab: "Ouvrir l’onglet Diff",
|
||||
closeChangesTab: "Fermer l’onglet Modifications",
|
||||
|
||||
@@ -943,6 +943,9 @@ export const ja: TranslationResources = {
|
||||
},
|
||||
},
|
||||
diff: {
|
||||
baseBranch: {
|
||||
title: "ベースブランチ",
|
||||
},
|
||||
openChangesTab: "変更タブを開く",
|
||||
openDiffTab: "差分タブを開く",
|
||||
closeChangesTab: "変更タブを閉じる",
|
||||
|
||||
@@ -937,6 +937,9 @@ export const ko: TranslationResources = {
|
||||
},
|
||||
},
|
||||
diff: {
|
||||
baseBranch: {
|
||||
title: "기준 브랜치",
|
||||
},
|
||||
openChangesTab: "변경사항 탭 열기",
|
||||
openDiffTab: "Diff 탭 열기",
|
||||
closeChangesTab: "변경사항 탭 닫기",
|
||||
|
||||
@@ -953,6 +953,9 @@ export const ptBR: TranslationResources = {
|
||||
},
|
||||
},
|
||||
diff: {
|
||||
baseBranch: {
|
||||
title: "Branch base",
|
||||
},
|
||||
openChangesTab: "Abrir a aba Alterações",
|
||||
openDiffTab: "Abrir a aba Diff",
|
||||
closeChangesTab: "Fechar a aba Alterações",
|
||||
|
||||
@@ -947,6 +947,9 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
},
|
||||
diff: {
|
||||
baseBranch: {
|
||||
title: "Базовая ветка",
|
||||
},
|
||||
openChangesTab: "Открыть вкладку «Изменения»",
|
||||
openDiffTab: "Открыть вкладку «Дифф»",
|
||||
closeChangesTab: "Закрыть вкладку «Изменения»",
|
||||
|
||||
@@ -922,6 +922,9 @@ export const zhCN: TranslationResources = {
|
||||
},
|
||||
},
|
||||
diff: {
|
||||
baseBranch: {
|
||||
title: "基准分支",
|
||||
},
|
||||
openChangesTab: "打开“更改”标签页",
|
||||
openDiffTab: "打开 Diff 标签页",
|
||||
closeChangesTab: "关闭“更改”标签页",
|
||||
|
||||
@@ -17,6 +17,7 @@ export const KeyboardShiftContext = createContext<KeyboardShiftContextValue | nu
|
||||
export interface KeyboardTranslation {
|
||||
enabled: boolean;
|
||||
bottomClearance: number;
|
||||
keyboardGap: number;
|
||||
}
|
||||
|
||||
export interface WindowRect {
|
||||
@@ -38,7 +39,7 @@ export interface KeyboardTranslationScope {
|
||||
|
||||
/** Views outside a KeyboardTranslateView stay put. */
|
||||
export const KeyboardTranslationContext = createContext<KeyboardTranslationScope>({
|
||||
translation: { enabled: false, bottomClearance: 0 },
|
||||
translation: { enabled: false, bottomClearance: 0, keyboardGap: 0 },
|
||||
measureAtRest: measureInWindow,
|
||||
});
|
||||
|
||||
|
||||
@@ -29,6 +29,21 @@ describe("keyboard layout reservation", () => {
|
||||
});
|
||||
|
||||
describe("resolveKeyboardShift", () => {
|
||||
it.each([0, -1])(
|
||||
"ignores a rotation report with height %s and progress one",
|
||||
(rawKeyboardHeight) => {
|
||||
expect(
|
||||
resolveKeyboardShift({
|
||||
rawKeyboardHeight,
|
||||
keyboardProgress: 1,
|
||||
bottomInset: 24,
|
||||
isIos: true,
|
||||
iosMinHeight: 120,
|
||||
}),
|
||||
).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps the existing open-keyboard offset behavior", () => {
|
||||
expect(
|
||||
resolveKeyboardShift({
|
||||
@@ -68,16 +83,46 @@ describe("resolveKeyboardShift", () => {
|
||||
|
||||
describe("resolveKeyboardLift", () => {
|
||||
it("lifts a docked view by the whole shift", () => {
|
||||
expect(resolveKeyboardLift({ shift: 302, enabled: true, bottomClearance: 0 })).toBe(302);
|
||||
expect(
|
||||
resolveKeyboardLift({ shift: 302, enabled: true, bottomClearance: 0, keyboardGap: 0 }),
|
||||
).toBe(302);
|
||||
});
|
||||
|
||||
it("lets the keyboard fill a centered form's clearance before lifting it", () => {
|
||||
expect(resolveKeyboardLift({ shift: 403, enabled: true, bottomClearance: 267 })).toBe(136);
|
||||
expect(resolveKeyboardLift({ shift: 403, enabled: true, bottomClearance: 455 })).toBe(0);
|
||||
expect(
|
||||
resolveKeyboardLift({ shift: 403, enabled: true, bottomClearance: 267, keyboardGap: 0 }),
|
||||
).toBe(136);
|
||||
expect(
|
||||
resolveKeyboardLift({ shift: 403, enabled: true, bottomClearance: 455, keyboardGap: 0 }),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("rises a docked composer's gap beyond the keyboard overlap, never at rest", () => {
|
||||
const docked = { enabled: true, bottomClearance: 0, keyboardGap: 16 };
|
||||
expect(resolveKeyboardLift({ ...docked, shift: 0 })).toBe(0);
|
||||
expect(resolveKeyboardLift({ ...docked, shift: 8 })).toBe(16);
|
||||
expect(resolveKeyboardLift({ ...docked, shift: 276 })).toBe(292);
|
||||
});
|
||||
|
||||
it("measures the shift from the window's bottom clearance, not the rectangular inset", () => {
|
||||
// iPhone Duo outer screen: the bar guide rests 24pt up where safeAreaInsets reports 34.
|
||||
const shift = resolveKeyboardShift({
|
||||
rawKeyboardHeight: 300,
|
||||
keyboardProgress: 1,
|
||||
bottomInset: 24,
|
||||
isIos: true,
|
||||
iosMinHeight: 120,
|
||||
});
|
||||
expect(shift).toBe(276);
|
||||
const lift = resolveKeyboardLift({ shift, enabled: true, bottomClearance: 0, keyboardGap: 16 });
|
||||
const restingComposerBottom = 24;
|
||||
expect(restingComposerBottom + lift - 300).toBe(16);
|
||||
});
|
||||
|
||||
it("keeps a disabled view in place", () => {
|
||||
expect(resolveKeyboardLift({ shift: 302, enabled: false, bottomClearance: 0 })).toBe(0);
|
||||
expect(
|
||||
resolveKeyboardLift({ shift: 302, enabled: false, bottomClearance: 0, keyboardGap: 0 }),
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -37,9 +37,14 @@ export function resolveKeyboardLift(input: {
|
||||
shift: number;
|
||||
enabled: boolean;
|
||||
bottomClearance: number;
|
||||
keyboardGap: number;
|
||||
}): number {
|
||||
"worklet";
|
||||
return input.enabled ? Math.max(0, input.shift - input.bottomClearance) : 0;
|
||||
if (!input.enabled) return 0;
|
||||
// The gap opens over the shift's first points, so the view never rises at rest.
|
||||
return (
|
||||
Math.max(0, input.shift - input.bottomClearance) + Math.min(input.keyboardGap, input.shift)
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldReconcileHiddenKeyboardEnd(input: {
|
||||
|
||||
@@ -10,18 +10,27 @@ interface KeyboardTranslateViewProps extends ViewProps {
|
||||
enabled?: boolean;
|
||||
/** Space below the view's resting position that the keyboard may fill before the view moves. */
|
||||
bottomClearance?: number;
|
||||
/**
|
||||
* Distance the view rises beyond the keyboard's overlap of the safe area, so content
|
||||
* resting on the safe-area boundary keeps this gap above the open keyboard.
|
||||
*/
|
||||
keyboardGap?: number;
|
||||
}
|
||||
|
||||
export function KeyboardTranslateView({
|
||||
children,
|
||||
enabled = true,
|
||||
bottomClearance = 0,
|
||||
keyboardGap = 0,
|
||||
style,
|
||||
...props
|
||||
}: KeyboardTranslateViewProps) {
|
||||
const { shift } = useKeyboardShift();
|
||||
const frameRef = useRef<View>(null);
|
||||
const translation = useMemo(() => ({ enabled, bottomClearance }), [bottomClearance, enabled]);
|
||||
const translation = useMemo(
|
||||
() => ({ enabled, bottomClearance, keyboardGap }),
|
||||
[bottomClearance, enabled, keyboardGap],
|
||||
);
|
||||
const scope = useKeyboardTranslationScope(frameRef, translation);
|
||||
const keyboardStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateY: -resolveKeyboardLift({ shift: shift.value, ...translation }) }],
|
||||
|
||||
@@ -10,30 +10,40 @@ interface KeyboardTranslateViewProps extends ViewProps {
|
||||
enabled?: boolean;
|
||||
/** Space below the view's resting position that the keyboard may fill before the view moves. */
|
||||
bottomClearance?: number;
|
||||
/**
|
||||
* Distance the view rises beyond the keyboard's overlap of the safe area, so content
|
||||
* resting on the safe-area boundary keeps this gap above the open keyboard.
|
||||
*/
|
||||
keyboardGap?: number;
|
||||
}
|
||||
|
||||
export function KeyboardTranslateView({
|
||||
children,
|
||||
enabled = true,
|
||||
bottomClearance = 0,
|
||||
keyboardGap = 0,
|
||||
style,
|
||||
...props
|
||||
}: KeyboardTranslateViewProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { height, progress } = useKeyboardAnimation();
|
||||
const translateY = useMemo(
|
||||
() =>
|
||||
Animated.add(
|
||||
Animated.add(height, Animated.multiply(progress, insets.bottom)),
|
||||
bottomClearance,
|
||||
).interpolate({
|
||||
inputRange: [-1, 0],
|
||||
outputRange: [-1, 0],
|
||||
extrapolateLeft: "extend",
|
||||
extrapolateRight: "clamp",
|
||||
}),
|
||||
[bottomClearance, height, insets.bottom, progress],
|
||||
);
|
||||
// Mirrors resolveKeyboardLift on the native driver: `overlap` is the negated shift.
|
||||
const translateY = useMemo(() => {
|
||||
const overlap = Animated.add(height, Animated.multiply(progress, insets.bottom));
|
||||
const lift = Animated.add(overlap, bottomClearance).interpolate({
|
||||
inputRange: [-1, 0],
|
||||
outputRange: [-1, 0],
|
||||
extrapolateLeft: "extend",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
if (!(keyboardGap > 0)) return lift;
|
||||
const gap = overlap.interpolate({
|
||||
inputRange: [-keyboardGap, 0],
|
||||
outputRange: [-keyboardGap, 0],
|
||||
extrapolate: "clamp",
|
||||
});
|
||||
return Animated.add(lift, gap);
|
||||
}, [bottomClearance, height, insets.bottom, keyboardGap, progress]);
|
||||
// Fullscreen resizes this view while disabling translation. iOS Fabric can
|
||||
// reapply its previous transform during that layout commit. Keep the graph
|
||||
// attached and apply the mode on the native animation frame; a static zero or
|
||||
@@ -57,7 +67,10 @@ export function KeyboardTranslateView({
|
||||
);
|
||||
|
||||
const frameRef = useRef<View>(null);
|
||||
const translation = useMemo(() => ({ enabled, bottomClearance }), [bottomClearance, enabled]);
|
||||
const translation = useMemo(
|
||||
() => ({ enabled, bottomClearance, keyboardGap }),
|
||||
[bottomClearance, enabled, keyboardGap],
|
||||
);
|
||||
const scope = useKeyboardTranslationScope(frameRef, translation);
|
||||
|
||||
return (
|
||||
|
||||
@@ -5,12 +5,14 @@ interface KeyboardTranslateViewProps extends ViewProps {
|
||||
children: ReactNode;
|
||||
enabled?: boolean;
|
||||
bottomClearance?: number;
|
||||
keyboardGap?: number;
|
||||
}
|
||||
|
||||
export function KeyboardTranslateView({
|
||||
children,
|
||||
enabled: _enabled,
|
||||
bottomClearance: _bottomClearance,
|
||||
keyboardGap: _keyboardGap,
|
||||
...props
|
||||
}: KeyboardTranslateViewProps) {
|
||||
return <View {...props}>{children}</View>;
|
||||
|
||||
@@ -3,19 +3,36 @@ import { describe, expect, it } from "vitest";
|
||||
import { resolveMobilePanelGestureIntent } from "./gesture-intent";
|
||||
|
||||
describe("mobile panel gesture intent", () => {
|
||||
it("rejects a pending swipe when presentation changes before activation", () => {
|
||||
expect(
|
||||
resolveMobilePanelGestureIntent({
|
||||
enabled: false,
|
||||
deltaX: 40,
|
||||
deltaY: 2,
|
||||
direction: 1,
|
||||
openGesturesBlocked: false,
|
||||
openEdge: null,
|
||||
}),
|
||||
).toBe("fail");
|
||||
});
|
||||
|
||||
it("blocks both panel-opening directions while the active surface owns horizontal dragging", () => {
|
||||
expect([
|
||||
resolveMobilePanelGestureIntent({
|
||||
enabled: true,
|
||||
deltaX: 40,
|
||||
deltaY: 2,
|
||||
direction: 1,
|
||||
openGesturesBlocked: true,
|
||||
openEdge: null,
|
||||
}),
|
||||
resolveMobilePanelGestureIntent({
|
||||
enabled: true,
|
||||
deltaX: -40,
|
||||
deltaY: 2,
|
||||
direction: -1,
|
||||
openGesturesBlocked: true,
|
||||
openEdge: null,
|
||||
}),
|
||||
]).toEqual(["fail", "fail"]);
|
||||
});
|
||||
@@ -23,17 +40,68 @@ describe("mobile panel gesture intent", () => {
|
||||
it("keeps ordinary horizontal panel gestures available when unblocked", () => {
|
||||
expect([
|
||||
resolveMobilePanelGestureIntent({
|
||||
enabled: true,
|
||||
deltaX: 40,
|
||||
deltaY: 2,
|
||||
direction: 1,
|
||||
openGesturesBlocked: false,
|
||||
openEdge: null,
|
||||
}),
|
||||
resolveMobilePanelGestureIntent({
|
||||
enabled: true,
|
||||
deltaX: -40,
|
||||
deltaY: 2,
|
||||
direction: -1,
|
||||
openGesturesBlocked: false,
|
||||
openEdge: null,
|
||||
}),
|
||||
]).toEqual(["activate", "activate"]);
|
||||
});
|
||||
|
||||
it("opens phone web drawers only from the edge they slide in from", () => {
|
||||
const swipe = { deltaY: 2, openGesturesBlocked: false };
|
||||
expect([
|
||||
resolveMobilePanelGestureIntent({
|
||||
enabled: true,
|
||||
...swipe,
|
||||
deltaX: 40,
|
||||
direction: 1,
|
||||
openEdge: { startX: 200, windowWidth: 400 },
|
||||
}),
|
||||
resolveMobilePanelGestureIntent({
|
||||
enabled: true,
|
||||
...swipe,
|
||||
deltaX: 40,
|
||||
direction: 1,
|
||||
openEdge: { startX: 12, windowWidth: 400 },
|
||||
}),
|
||||
resolveMobilePanelGestureIntent({
|
||||
enabled: true,
|
||||
...swipe,
|
||||
deltaX: -40,
|
||||
direction: -1,
|
||||
openEdge: { startX: 200, windowWidth: 400 },
|
||||
}),
|
||||
resolveMobilePanelGestureIntent({
|
||||
enabled: true,
|
||||
...swipe,
|
||||
deltaX: -40,
|
||||
direction: -1,
|
||||
openEdge: { startX: 388, windowWidth: 400 },
|
||||
}),
|
||||
]).toEqual(["fail", "activate", "fail", "activate"]);
|
||||
});
|
||||
|
||||
it("opens from anywhere when the layout does not require an edge start", () => {
|
||||
expect(
|
||||
resolveMobilePanelGestureIntent({
|
||||
enabled: true,
|
||||
deltaX: 40,
|
||||
deltaY: 2,
|
||||
direction: 1,
|
||||
openGesturesBlocked: false,
|
||||
openEdge: null,
|
||||
}),
|
||||
).toBe("activate");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,39 @@
|
||||
export type MobilePanelGestureDirection = -1 | 1;
|
||||
export type MobilePanelGestureIntent = "activate" | "fail" | "wait";
|
||||
|
||||
/** How far from the window edge an edge-only open gesture may start. */
|
||||
export const MOBILE_PANEL_OPEN_EDGE_WIDTH = 32;
|
||||
|
||||
/**
|
||||
* Where an edge-only open gesture started. Phone web opens drawers only from the edge they slide in
|
||||
* from: interior horizontal scrollers (terminals, code blocks, diffs) keep their swipes there.
|
||||
*/
|
||||
export interface MobilePanelOpenEdge {
|
||||
startX: number;
|
||||
windowWidth: number;
|
||||
}
|
||||
|
||||
function startsAtOpenEdge(edge: MobilePanelOpenEdge, direction: MobilePanelGestureDirection) {
|
||||
"worklet";
|
||||
return direction === 1
|
||||
? edge.startX <= MOBILE_PANEL_OPEN_EDGE_WIDTH
|
||||
: edge.startX >= edge.windowWidth - MOBILE_PANEL_OPEN_EDGE_WIDTH;
|
||||
}
|
||||
|
||||
export function resolveMobilePanelGestureIntent(input: {
|
||||
enabled: boolean;
|
||||
deltaX: number;
|
||||
deltaY: number;
|
||||
direction: MobilePanelGestureDirection;
|
||||
openGesturesBlocked: boolean;
|
||||
/** Required start edge for opening gestures, or `null` when they may start anywhere. */
|
||||
openEdge: MobilePanelOpenEdge | null;
|
||||
}): MobilePanelGestureIntent {
|
||||
"worklet";
|
||||
if (input.openGesturesBlocked) {
|
||||
if (!input.enabled || input.openGesturesBlocked) {
|
||||
return "fail";
|
||||
}
|
||||
if (input.openEdge && !startsAtOpenEdge(input.openEdge, input.direction)) {
|
||||
return "fail";
|
||||
}
|
||||
const directedDelta = input.deltaX * input.direction;
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { createContext, useContext, useRef, type RefObject, useCallback, useMemo } from "react";
|
||||
import { Gesture, type GestureType } from "react-native-gesture-handler";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
import { Gesture, PointerType, type GestureType } from "react-native-gesture-handler";
|
||||
import { useDerivedValue, useSharedValue } from "react-native-reanimated";
|
||||
import { scheduleOnRN } from "react-native-worklets";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context";
|
||||
import { usePointerCapabilities } from "@/hooks/pointer-capabilities";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { canBeginMobilePanelGesture, isMobilePanelGestureCurrent } from "./model";
|
||||
import { useMobilePanelsRuntime } from "./provider";
|
||||
import { resolveMobilePanelGestureIntent } from "./gesture-intent";
|
||||
|
||||
const MOBILE_WEB_EDGE_SWIPE_WIDTH = 32;
|
||||
|
||||
function isCurrentSelection(startedRevision: number): boolean {
|
||||
return usePanelStore.getState().mobilePanel.revision === startedRevision;
|
||||
}
|
||||
@@ -23,6 +23,15 @@ function useGestureState() {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gates a sidebar pan on the UI thread. The sidebar flips between the dock and the overlay at
|
||||
* runtime, and on Android flipping the handler's `.enabled()` config from false back to true left
|
||||
* it inert until the app reloaded (RNGH 2.32), so `.enabled()` only carries the device capability.
|
||||
*/
|
||||
function useGestureGate(enabled: boolean) {
|
||||
return useDerivedValue(() => enabled, [enabled]);
|
||||
}
|
||||
|
||||
function useRevisionCommit(action: () => void) {
|
||||
return useCallback(
|
||||
(revision: number) => {
|
||||
@@ -39,24 +48,35 @@ export function useOpenAgentListGesture(enabled: boolean) {
|
||||
beginGesture,
|
||||
finishGesture,
|
||||
leftOpenGestureRef,
|
||||
leftPanelWidth: width,
|
||||
motionState,
|
||||
openGesturesBlocked,
|
||||
position,
|
||||
updateGesture,
|
||||
windowWidth,
|
||||
} = useMobilePanelsRuntime();
|
||||
const { touchScreen: hasTouchScreen } = usePointerCapabilities();
|
||||
// Phone web keeps interior swipes for the page's horizontal scrollers; the wide overlay opens
|
||||
// from anywhere in the workspace.
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const openFromEdgeOnly = isWeb && isCompact;
|
||||
const horizontalScroll = useHorizontalScrollOptional();
|
||||
const { startedRevision, touchStartX, touchStartY } = useGestureState();
|
||||
const showMobileAgentList = usePanelStore((state) => state.showMobileAgentList);
|
||||
const commit = useRevisionCommit(showMobileAgentList);
|
||||
const gate = useGestureGate(enabled);
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
Gesture.Pan()
|
||||
.withRef(leftOpenGestureRef)
|
||||
.enabled(enabled)
|
||||
.enabled(hasTouchScreen)
|
||||
.manualActivation(true)
|
||||
.onTouchesDown((event) => {
|
||||
.onTouchesDown((event, stateManager) => {
|
||||
if (!gate.value || event.pointerType !== PointerType.TOUCH) {
|
||||
stateManager.fail();
|
||||
return;
|
||||
}
|
||||
const touch = event.changedTouches[0];
|
||||
if (touch) {
|
||||
touchStartX.value = touch.absoluteX;
|
||||
@@ -76,15 +96,16 @@ export function useOpenAgentListGesture(enabled: boolean) {
|
||||
}
|
||||
|
||||
const panIntent = resolveMobilePanelGestureIntent({
|
||||
enabled: gate.value,
|
||||
deltaX,
|
||||
deltaY,
|
||||
direction: 1,
|
||||
openGesturesBlocked: openGesturesBlocked.value,
|
||||
openEdge: openFromEdgeOnly ? { startX: touchStartX.value, windowWidth } : null,
|
||||
});
|
||||
if (
|
||||
!canBeginMobilePanelGesture(motionState.value, "agent", position.value) ||
|
||||
horizontalScroll?.isAnyScrolledRight.value ||
|
||||
(isWeb && touchStartX.value > MOBILE_WEB_EDGE_SWIPE_WIDTH) ||
|
||||
panIntent === "fail"
|
||||
) {
|
||||
stateManager.fail();
|
||||
@@ -98,10 +119,10 @@ export function useOpenAgentListGesture(enabled: boolean) {
|
||||
startedRevision.value = beginGesture({ origin: "agent" });
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
updateGesture(startedRevision.value, -event.translationX / windowWidth);
|
||||
updateGesture(startedRevision.value, -event.translationX / width);
|
||||
})
|
||||
.onEnd((event, success) => {
|
||||
const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500;
|
||||
const shouldOpen = event.translationX > width / 3 || event.velocityX > 500;
|
||||
const result = finishGesture({
|
||||
startedRevision: startedRevision.value,
|
||||
target: shouldOpen ? "agent-list" : "agent",
|
||||
@@ -113,43 +134,53 @@ export function useOpenAgentListGesture(enabled: boolean) {
|
||||
}),
|
||||
[
|
||||
commit,
|
||||
enabled,
|
||||
beginGesture,
|
||||
gate,
|
||||
finishGesture,
|
||||
hasTouchScreen,
|
||||
horizontalScroll?.isAnyScrolledRight,
|
||||
leftOpenGestureRef,
|
||||
motionState,
|
||||
openFromEdgeOnly,
|
||||
openGesturesBlocked,
|
||||
position,
|
||||
startedRevision,
|
||||
touchStartX,
|
||||
touchStartY,
|
||||
updateGesture,
|
||||
width,
|
||||
windowWidth,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export function useCloseAgentListGesture() {
|
||||
export function useCloseAgentListGesture(enabled = true) {
|
||||
const {
|
||||
beginGesture,
|
||||
finishGesture,
|
||||
leftCloseGestureRef,
|
||||
leftPanelWidth: width,
|
||||
motionState,
|
||||
position,
|
||||
updateGesture,
|
||||
windowWidth,
|
||||
} = useMobilePanelsRuntime();
|
||||
const { touchScreen: hasTouchScreen } = usePointerCapabilities();
|
||||
const { startedRevision, touchStartX, touchStartY } = useGestureState();
|
||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||
const commit = useRevisionCommit(showMobileAgent);
|
||||
const gate = useGestureGate(enabled);
|
||||
|
||||
const gesture = useMemo(
|
||||
() =>
|
||||
Gesture.Pan()
|
||||
.withRef(leftCloseGestureRef)
|
||||
.enabled(hasTouchScreen)
|
||||
.manualActivation(true)
|
||||
.onTouchesDown((event) => {
|
||||
.onTouchesDown((event, stateManager) => {
|
||||
if (!gate.value || event.pointerType !== PointerType.TOUCH) {
|
||||
stateManager.fail();
|
||||
return;
|
||||
}
|
||||
const touch = event.changedTouches[0];
|
||||
if (touch) {
|
||||
touchStartX.value = touch.absoluteX;
|
||||
@@ -169,10 +200,12 @@ export function useCloseAgentListGesture() {
|
||||
}
|
||||
|
||||
const panIntent = resolveMobilePanelGestureIntent({
|
||||
enabled: gate.value,
|
||||
deltaX,
|
||||
deltaY,
|
||||
direction: -1,
|
||||
openGesturesBlocked: false,
|
||||
openEdge: null,
|
||||
});
|
||||
if (
|
||||
!canBeginMobilePanelGesture(motionState.value, "agent-list", position.value) ||
|
||||
@@ -191,10 +224,10 @@ export function useCloseAgentListGesture() {
|
||||
});
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
updateGesture(startedRevision.value, -1 - event.translationX / windowWidth);
|
||||
updateGesture(startedRevision.value, -1 - event.translationX / width);
|
||||
})
|
||||
.onEnd((event, success) => {
|
||||
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500;
|
||||
const shouldClose = event.translationX < -width / 3 || event.velocityX < -500;
|
||||
const result = finishGesture({
|
||||
startedRevision: startedRevision.value,
|
||||
target: shouldClose ? "agent" : "agent-list",
|
||||
@@ -207,6 +240,8 @@ export function useCloseAgentListGesture() {
|
||||
[
|
||||
beginGesture,
|
||||
commit,
|
||||
gate,
|
||||
hasTouchScreen,
|
||||
finishGesture,
|
||||
leftCloseGestureRef,
|
||||
motionState,
|
||||
@@ -215,7 +250,7 @@ export function useCloseAgentListGesture() {
|
||||
touchStartX,
|
||||
touchStartY,
|
||||
updateGesture,
|
||||
windowWidth,
|
||||
width,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -269,14 +304,15 @@ export function useOpenFileExplorerGesture({ enabled, onOpen }: OpenFileExplorer
|
||||
}
|
||||
|
||||
const panIntent = resolveMobilePanelGestureIntent({
|
||||
enabled: enabled,
|
||||
deltaX,
|
||||
deltaY,
|
||||
direction: -1,
|
||||
openGesturesBlocked: openGesturesBlocked.value,
|
||||
openEdge: isWeb ? { startX: touchStartX.value, windowWidth } : null,
|
||||
});
|
||||
if (
|
||||
!canBeginMobilePanelGesture(motionState.value, "agent", position.value) ||
|
||||
(isWeb && touchStartX.value < windowWidth - MOBILE_WEB_EDGE_SWIPE_WIDTH) ||
|
||||
panIntent === "fail"
|
||||
) {
|
||||
stateManager.fail();
|
||||
@@ -363,10 +399,12 @@ export function useCloseFileExplorerGesture(enabled = true) {
|
||||
}
|
||||
|
||||
const panIntent = resolveMobilePanelGestureIntent({
|
||||
enabled: enabled,
|
||||
deltaX,
|
||||
deltaY,
|
||||
direction: 1,
|
||||
openGesturesBlocked: false,
|
||||
openEdge: null,
|
||||
});
|
||||
if (
|
||||
!canBeginMobilePanelGesture(motionState.value, "file-explorer", position.value) ||
|
||||
|
||||
@@ -17,6 +17,7 @@ class MobilePanelsScenario {
|
||||
private startedRevision = -1;
|
||||
private state: MobilePanelMotionState;
|
||||
readonly commits: MobilePanelCommit[] = [];
|
||||
readonly animations: MobilePanelView[] = [];
|
||||
|
||||
constructor(target: MobilePanelView = "agent") {
|
||||
this.state = createMobilePanelMotionState({ target, revision: this.nextRevision });
|
||||
@@ -74,6 +75,9 @@ class MobilePanelsScenario {
|
||||
if (transition.commit) {
|
||||
this.commits.push(transition.commit);
|
||||
}
|
||||
if (transition.animationTarget) {
|
||||
this.animations.push(transition.animationTarget);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,6 +241,29 @@ describe("mobile panel ownership", () => {
|
||||
expect(panels.commits).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns an interrupted drag to the anchor when the command keeps its target", () => {
|
||||
const panels = new MobilePanelsScenario();
|
||||
|
||||
panels.beginGesture("agent").command("agent").finishGesture("agent-list");
|
||||
|
||||
expect(panels.animations).toEqual(["agent"]);
|
||||
expect(panels.snapshot()).toEqual({
|
||||
target: "agent",
|
||||
motionTarget: "agent",
|
||||
settledTarget: "agent",
|
||||
revision: 1,
|
||||
});
|
||||
expect(panels.commits).toEqual([]);
|
||||
});
|
||||
|
||||
it("lets the command for a completed drag adopt the drag's finishing motion", () => {
|
||||
const panels = new MobilePanelsScenario();
|
||||
|
||||
panels.beginGesture("agent").finishGesture("agent-list").command("agent-list");
|
||||
|
||||
expect(panels.animations).toEqual(["agent-list"]);
|
||||
});
|
||||
|
||||
it("keeps the latest rapid command when position reaches a stale anchor", () => {
|
||||
const panels = new MobilePanelsScenario();
|
||||
|
||||
|
||||
@@ -78,7 +78,9 @@ export function transitionMobilePanel(
|
||||
if (event.selection.revision <= state.revision) {
|
||||
return { state };
|
||||
}
|
||||
const continuesCurrentMotion = state.motionTarget === event.selection.target;
|
||||
// An interrupted drag left the position between anchors, so it moves back even when the
|
||||
// target is unchanged. Without a drag, the command adopts motion already heading there.
|
||||
const continuesCurrentMotion = !state.gesture && state.motionTarget === event.selection.target;
|
||||
return {
|
||||
animationTarget: continuesCurrentMotion ? undefined : event.selection.target,
|
||||
state: {
|
||||
|
||||
@@ -29,26 +29,27 @@ export function MobilePanelOverlay({
|
||||
dockVisible = false,
|
||||
frameStyle,
|
||||
}: MobilePanelOverlayProps) {
|
||||
const { position, windowWidth } = useMobilePanelsRuntime();
|
||||
const { leftPanelWidth, position, windowWidth } = useMobilePanelsRuntime();
|
||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||
const overlayOpen = useIsMobilePanelActive(panel);
|
||||
const docked = presentation === "dock";
|
||||
const isOpen = docked ? dockVisible : overlayOpen;
|
||||
const hostId = useId();
|
||||
const isLeft = panel === "agent-list";
|
||||
const width = isLeft ? leftPanelWidth : windowWidth;
|
||||
|
||||
const sidebarAnimatedStyle = useAnimatedStyle(() => {
|
||||
if (docked) return { transform: [{ translateX: 0 }] };
|
||||
const frame = getMobilePanelFrame(position.value, windowWidth);
|
||||
const frame = getMobilePanelFrame(position.value, width);
|
||||
return {
|
||||
transform: [{ translateX: isLeft ? frame.leftTranslateX : frame.rightTranslateX }],
|
||||
};
|
||||
}, [docked, isLeft, windowWidth]);
|
||||
}, [docked, isLeft, width]);
|
||||
|
||||
const backdropAnimatedStyle = useAnimatedStyle(() => {
|
||||
const frame = getMobilePanelFrame(position.value, windowWidth);
|
||||
const frame = getMobilePanelFrame(position.value, width);
|
||||
return { opacity: isLeft ? frame.leftBackdropOpacity : frame.rightBackdropOpacity };
|
||||
}, [isLeft, windowWidth]);
|
||||
}, [isLeft, width]);
|
||||
|
||||
const overlayAnimatedStyle = useAnimatedStyle(() => {
|
||||
if (docked) return { opacity: 1, display: "flex" as const, pointerEvents: "box-none" as const };
|
||||
@@ -73,11 +74,11 @@ export function MobilePanelOverlay({
|
||||
() => [
|
||||
docked ? styles.dockedPanel : styles.panel,
|
||||
!docked && positionedPanelStyle,
|
||||
!docked && { width: windowWidth },
|
||||
!docked && { width },
|
||||
panelStyle,
|
||||
sidebarAnimatedStyle,
|
||||
],
|
||||
[docked, panelStyle, positionedPanelStyle, sidebarAnimatedStyle, windowWidth],
|
||||
[docked, panelStyle, positionedPanelStyle, sidebarAnimatedStyle, width],
|
||||
);
|
||||
let overlayPointerEvents: "auto" | "box-none" | "none";
|
||||
if (!isWeb) {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { useWindowLayout, resolveDesktopDivisionLayout } from "@/window-layout";
|
||||
import { subscribeMobilePanelSelection } from "./selection";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
@@ -20,7 +23,10 @@ import {
|
||||
withTiming,
|
||||
type SharedValue,
|
||||
} from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { scheduleOnRN, scheduleOnUI } from "react-native-worklets";
|
||||
import { resolveDesktopSidebarWidth } from "@/components/desktop-sidebar-layout";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import {
|
||||
usePanelStore,
|
||||
@@ -45,6 +51,7 @@ interface MobilePanelsRuntime {
|
||||
beginGesture: (input: BeginGestureInput) => number;
|
||||
finishGesture: (input: FinishGestureInput) => MobilePanelCommit | null;
|
||||
leftCloseGestureRef: RefObject<GestureType | undefined>;
|
||||
leftPanelWidth: number;
|
||||
leftOpenGestureRef: RefObject<GestureType | undefined>;
|
||||
motionState: SharedValue<MobilePanelMotionState>;
|
||||
openGesturesBlocked: SharedValue<boolean>;
|
||||
@@ -68,8 +75,36 @@ interface FinishGestureInput {
|
||||
const MobilePanelsContext = createContext<MobilePanelsRuntime | null>(null);
|
||||
const MobilePanelActiveContext = createContext<MobilePanelView>("agent");
|
||||
|
||||
/**
|
||||
* The left sidebar's width: the full window for the compact drawer, the saved sidebar width
|
||||
* clamped to the safe viewport on wide layouts. The wide dock, its overlay, and the gestures that
|
||||
* drag it all read this one value.
|
||||
*/
|
||||
function useResolvedLeftPanelWidth(windowWidth: number): number {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const insets = useSafeAreaInsets();
|
||||
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
||||
const { height: windowHeight } = useWindowDimensions();
|
||||
const divisionLayout = resolveDesktopDivisionLayout({
|
||||
geometry: useWindowLayout(),
|
||||
windowWidth,
|
||||
windowHeight,
|
||||
leftInset: insets.left,
|
||||
rightInset: insets.right,
|
||||
});
|
||||
if (isCompact) return windowWidth;
|
||||
return (
|
||||
divisionLayout?.sidebarWidth ??
|
||||
resolveDesktopSidebarWidth({
|
||||
requestedWidth: sidebarWidth,
|
||||
viewportWidth: windowWidth - insets.left - insets.right,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function MobilePanelsProvider({ children }: { children: ReactNode }) {
|
||||
const { width: windowWidth } = useWindowDimensions();
|
||||
const leftPanelWidth = useResolvedLeftPanelWidth(windowWidth);
|
||||
const initialSelection = useRef(usePanelStore.getState().mobilePanel).current;
|
||||
const position = useSharedValue(getMobilePanelAnchor(initialSelection.target));
|
||||
const motionState = useSharedValue(createMobilePanelMotionState(initialSelection));
|
||||
@@ -152,11 +187,7 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return usePanelStore.subscribe((state, previousState) => {
|
||||
const selection = state.mobilePanel;
|
||||
if (selection === previousState.mobilePanel) {
|
||||
return;
|
||||
}
|
||||
return subscribeMobilePanelSelection((selection) => {
|
||||
scheduleOnUI(applySelection, selection);
|
||||
});
|
||||
}, [applySelection]);
|
||||
@@ -217,6 +248,7 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) {
|
||||
finishGesture,
|
||||
leftCloseGestureRef,
|
||||
leftOpenGestureRef,
|
||||
leftPanelWidth,
|
||||
motionState,
|
||||
openGesturesBlocked,
|
||||
position,
|
||||
@@ -228,6 +260,7 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) {
|
||||
[
|
||||
beginGesture,
|
||||
finishGesture,
|
||||
leftPanelWidth,
|
||||
motionState,
|
||||
openGesturesBlocked,
|
||||
position,
|
||||
@@ -255,6 +288,11 @@ export function useMobilePanelsRuntime(): MobilePanelsRuntime {
|
||||
return context;
|
||||
}
|
||||
|
||||
/** The resolved left sidebar width shared by the dock, the overlay, and their gestures. */
|
||||
export function useLeftPanelWidth(): number {
|
||||
return useMobilePanelsRuntime().leftPanelWidth;
|
||||
}
|
||||
|
||||
export function useIsMobilePanelActive(panel: MobilePanelView): boolean {
|
||||
return useContext(MobilePanelActiveContext) === panel;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { usePanelStore, type MobilePanelSelection } from "@/stores/panel-store";
|
||||
|
||||
/** Observe panel commands, including layout commands published before subscription. */
|
||||
export function subscribeMobilePanelSelection(
|
||||
onSelection: (selection: MobilePanelSelection) => void,
|
||||
) {
|
||||
const unsubscribe = usePanelStore.subscribe((state, previousState) => {
|
||||
if (state.mobilePanel !== previousState.mobilePanel) {
|
||||
onSelection(state.mobilePanel);
|
||||
}
|
||||
});
|
||||
onSelection(usePanelStore.getState().mobilePanel);
|
||||
return unsubscribe;
|
||||
}
|
||||
@@ -177,6 +177,7 @@ describe("resolveStartupRoute", () => {
|
||||
workspaceSelectionStatus: "unknown" as const,
|
||||
isWorkspaceSelectionLoaded: true,
|
||||
hasGivenUpWaitingForHost: false,
|
||||
isWaitingForSetup: false,
|
||||
};
|
||||
const baseHostInput = {
|
||||
route: { kind: "host" as const, serverId: "server-saved" },
|
||||
@@ -278,6 +279,28 @@ describe("resolveStartupRoute", () => {
|
||||
).toEqual({ kind: "redirect", href: "/h/srv-desktop" });
|
||||
});
|
||||
|
||||
it("holds the splash over the empty home while setup may still open", () => {
|
||||
expect(
|
||||
resolveStartupRoute({
|
||||
...baseIndexInput,
|
||||
hosts: [{ serverId: "srv-desktop" }],
|
||||
anyOnlineHostServerId: "srv-desktop",
|
||||
isWaitingForSetup: true,
|
||||
}),
|
||||
).toEqual({ kind: "splash" });
|
||||
});
|
||||
|
||||
it("restores a saved workspace without waiting for setup", () => {
|
||||
expect(
|
||||
resolveStartupRoute({
|
||||
...baseIndexInput,
|
||||
hosts: [{ serverId: "server-1" }],
|
||||
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
|
||||
isWaitingForSetup: true,
|
||||
}),
|
||||
).toEqual({ kind: "redirect", href: "/h/server-1" });
|
||||
});
|
||||
|
||||
it("keeps a known connecting host in app-owned routing instead of showing welcome", () => {
|
||||
expect(
|
||||
resolveStartupRoute({
|
||||
|
||||
@@ -117,6 +117,8 @@ export interface ResolveIndexStartupRouteInput extends ResolveStartupRouteBaseIn
|
||||
workspaceSelectionStatus: WorkspaceSelectionStatus;
|
||||
isWorkspaceSelectionLoaded: boolean;
|
||||
hasGivenUpWaitingForHost: boolean;
|
||||
/** Setup may still open over the empty home; see `useIsSetupDecisionPending`. */
|
||||
isWaitingForSetup: boolean;
|
||||
}
|
||||
|
||||
export interface ResolveHostStartupRouteInput extends ResolveStartupRouteBaseInput {
|
||||
@@ -198,6 +200,12 @@ function resolveReadyIndexStartupRoute(input: ResolveIndexStartupRouteInput): St
|
||||
};
|
||||
}
|
||||
|
||||
// Without a workspace to restore, startup lands on the empty home. A new user's setup opens
|
||||
// over it, so hold the splash until that is known instead of flashing the home first.
|
||||
if (input.isWaitingForSetup) {
|
||||
return { kind: "splash" };
|
||||
}
|
||||
|
||||
if (input.anyOnlineHostServerId) {
|
||||
return { kind: "redirect", href: buildHostRootRoute(input.anyOnlineHostServerId) };
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
* First-run setup: a one-time modal that walks a new user through the surfaces Settings
|
||||
* already has — appearance, providers, projects, Paseo tools and phone pairing.
|
||||
*
|
||||
* The app shell mounts the host; Settings reopens it. Whether it opens, which steps it has,
|
||||
* and how completion is remembered stay inside; import from `@/onboarding` only.
|
||||
* The app shell mounts the host, startup waits for its decision, and Settings reopens it. Whether
|
||||
* it opens, which steps it has, and how completion is remembered stay inside; import from
|
||||
* `@/onboarding` only.
|
||||
*/
|
||||
export { OnboardingHost } from "./onboarding-host";
|
||||
export { useOpenOnboarding } from "./use-open-onboarding";
|
||||
export { useIsSetupDecisionPending } from "./use-setup-decision-pending";
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { useHostRuntimeConnectionStatus } from "@/runtime/host-runtime";
|
||||
import { getOnboardingModel } from "./internal/onboarding-instance";
|
||||
import { useSetupHostServerId } from "./internal/use-setup-host";
|
||||
|
||||
/**
|
||||
* True while this launch may still open setup: it has not been decided, and the setup host is
|
||||
* connecting, or connected and loading its workspaces. Startup keeps its loading screen up
|
||||
* meanwhile, so a new user lands on setup rather than on the empty home first. False on phones,
|
||||
* where setup never opens, when there is no setup host, and while the host is unreachable.
|
||||
*/
|
||||
export const useIsSetupDecisionPending: () => boolean = isNative
|
||||
? () => false
|
||||
: useIsSetupDecisionPendingOnDesktop;
|
||||
|
||||
function useIsSetupDecisionPendingOnDesktop(): boolean {
|
||||
const model = getOnboardingModel();
|
||||
const state = useSyncExternalStore(model.subscribe, model.getState, model.getState);
|
||||
const serverId = useSetupHostServerId();
|
||||
const connectionStatus = useHostRuntimeConnectionStatus(serverId ?? "");
|
||||
if (state.status !== "deciding" || !serverId) return false;
|
||||
return connectionStatus !== "offline" && connectionStatus !== "error";
|
||||
}
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { PendingPermission } from "@/types/shared";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import { deriveSubagentStatusBucket } from "@/subagents";
|
||||
import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy";
|
||||
import type { TurnPresentation } from "@/timeline/turn-liveness";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
@@ -107,10 +107,7 @@ function useProviderSubagentDescriptor(
|
||||
titleState: descriptor ? "ready" : "loading",
|
||||
icon: useProviderIcon(provider, context.serverId),
|
||||
statusBucket: descriptor
|
||||
? deriveSidebarStateBucket({
|
||||
status: providerSubagentLifecycleStatus(descriptor.status),
|
||||
requiresAttention: descriptor.status === "failed",
|
||||
})
|
||||
? deriveSubagentStatusBucket(providerSubagentLifecycleStatus(descriptor.status))
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user