mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 08:54:42 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df67f21403 | ||
|
|
1c35da2bdb | ||
|
|
a1cd62fbc9 | ||
|
|
78669b1f5b |
@@ -1,28 +1,5 @@
|
||||
# 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,28 +1,5 @@
|
||||
# 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 会在原有会话中恢复工作。
|
||||
|
||||
+3
-10
@@ -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 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.
|
||||
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.
|
||||
|
||||
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,13 +316,6 @@ 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`.
|
||||
|
||||
+1
-5
@@ -26,10 +26,6 @@ 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`.
|
||||
@@ -474,7 +470,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` | 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. |
|
||||
| `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. |
|
||||
| `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,8 +169,6 @@ 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.
|
||||
|
||||
---
|
||||
|
||||
+1
-10
@@ -594,16 +594,7 @@ 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`. 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.
|
||||
version in root `overrides` and regenerate the lockfile. Report the URL to npm so they purge it.
|
||||
|
||||
## ACP provider catalog versions
|
||||
|
||||
|
||||
@@ -172,11 +172,6 @@ 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
|
||||
|
||||
+10
-39
@@ -9,10 +9,6 @@ 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:
|
||||
@@ -27,12 +23,6 @@ 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)
|
||||
@@ -42,10 +32,8 @@ 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. 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.
|
||||
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.
|
||||
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
|
||||
@@ -55,11 +43,8 @@ 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. 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.
|
||||
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.
|
||||
|
||||
This makes these invalid states unrepresentable:
|
||||
|
||||
@@ -77,9 +62,7 @@ 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 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.
|
||||
motion toward the new target. 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
|
||||
@@ -97,12 +80,6 @@ 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
|
||||
@@ -110,17 +87,11 @@ 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
+6
-9
@@ -281,26 +281,23 @@ 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)
|
||||
### Instant-admit release (rollout_hours=0 from publish)
|
||||
|
||||
`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:
|
||||
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:
|
||||
|
||||
```bash
|
||||
# 1. Cut and publish (36h ramp from tag push).
|
||||
# 1. Cut and publish (default 36h ramp from tag push).
|
||||
npm run release:patch
|
||||
|
||||
# 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.
|
||||
# 2. Immediately queue the flip — runs as soon as finalize-rollout completes.
|
||||
gh workflow run desktop-rollout.yml \
|
||||
-f tag=v0.1.64 \
|
||||
-f rollout_hours=0
|
||||
```
|
||||
|
||||
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.
|
||||
**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.
|
||||
|
||||
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.
|
||||
Run the dispatch right after `release:patch` or `release:minor` returns. Don't wait for the tag-push CI to finish.
|
||||
|
||||
### Adjusting an already-published release
|
||||
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
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
|
||||
@@ -1,4 +0,0 @@
|
||||
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
|
||||
@@ -1,4 +0,0 @@
|
||||
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
|
||||
@@ -1,4 +0,0 @@
|
||||
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
|
||||
@@ -1,8 +0,0 @@
|
||||
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,8 +0,0 @@
|
||||
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,8 +0,0 @@
|
||||
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,8 +0,0 @@
|
||||
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-wO0vJVhh1urkt4q9NiQ0RSXUj++WWuHTneWSPelU+JQ=
|
||||
sha256-sLaLHD1u1eJRdGdflcwWBGxnnhRQpB1T74BlaWcmxbU=
|
||||
|
||||
Generated
+34
-34
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.15.2",
|
||||
"version": "0.15.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.15.2",
|
||||
"version": "0.15.0",
|
||||
"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.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
||||
"version": "6.3.2",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.2.tgz",
|
||||
"integrity": "sha512-GYrCADwpu6yV5OAvchkQ27Z5wwMxWAEMg5NNWjyHh/5DboFFqbdbvYNIvZouQpxXyeMPoTOh0vdPYKlf0ZswdQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
@@ -35100,9 +35100,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-async-pool/node_modules/semver": {
|
||||
"version": "5.7.2",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
|
||||
"integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
|
||||
"version": "5.7.3",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.3.tgz",
|
||||
"integrity": "sha512-kM18BPOqTi0+TB68703/GJRpAML5Me+IDQsR1J26YB3EFKKNEyHIVZuWDX7+WKQB3tHwDwgk24BfhdpWUbytvQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
@@ -37468,7 +37468,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.15.2",
|
||||
"version": "0.15.0",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -39637,13 +39637,13 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.15.2",
|
||||
"version": "0.15.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.15.2",
|
||||
"@getpaseo/protocol": "0.15.2",
|
||||
"@getpaseo/server": "0.15.2",
|
||||
"@getpaseo/client": "0.15.0",
|
||||
"@getpaseo/protocol": "0.15.0",
|
||||
"@getpaseo/server": "0.15.0",
|
||||
"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.2",
|
||||
"version": "0.15.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.15.2",
|
||||
"@getpaseo/relay": "0.15.2",
|
||||
"@getpaseo/protocol": "0.15.0",
|
||||
"@getpaseo/relay": "0.15.0",
|
||||
"ws": "^8.20.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -39906,7 +39906,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.15.2",
|
||||
"version": "0.15.0",
|
||||
"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.2",
|
||||
"version": "0.15.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -40195,7 +40195,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.15.2",
|
||||
"version": "0.15.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -40225,7 +40225,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.15.2",
|
||||
"version": "0.15.0",
|
||||
"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.2",
|
||||
"@getpaseo/protocol": "0.15.2",
|
||||
"@getpaseo/client": "0.15.0",
|
||||
"@getpaseo/protocol": "0.15.0",
|
||||
"@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.2",
|
||||
"@getpaseo/protocol": "0.15.2",
|
||||
"@getpaseo/client": "0.15.0",
|
||||
"@getpaseo/protocol": "0.15.0",
|
||||
"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.2",
|
||||
"version": "0.15.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"semver": "^7.8.5",
|
||||
@@ -40295,7 +40295,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.15.2",
|
||||
"version": "0.15.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "^2.4.0",
|
||||
@@ -40517,17 +40517,17 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.15.2",
|
||||
"version": "0.15.0",
|
||||
"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.2",
|
||||
"@getpaseo/highlight": "0.15.2",
|
||||
"@getpaseo/plugin": "0.15.2",
|
||||
"@getpaseo/protocol": "0.15.2",
|
||||
"@getpaseo/relay": "0.15.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",
|
||||
"@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.2",
|
||||
"version": "0.15.0",
|
||||
"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.2",
|
||||
"version": "0.15.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/plugin": "*",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
+2
-8
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.15.2",
|
||||
"version": "0.15.0",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
@@ -149,9 +149,6 @@
|
||||
"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",
|
||||
@@ -161,9 +158,6 @@
|
||||
"react-native": "0.86.3",
|
||||
"react-native-gesture-handler": "~2.32.0",
|
||||
"react-native-reanimated": "4.5.5",
|
||||
"react-native-worklets": "0.10.4",
|
||||
"tiny-async-pool": {
|
||||
"semver@^5": "5.7.2"
|
||||
}
|
||||
"react-native-worklets": "0.10.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -723,46 +723,3 @@ 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,11 +8,7 @@ import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { connectSeedClient } from "../support/helpers/seed-client";
|
||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||
import {
|
||||
openChangesPanel,
|
||||
openChangesTreePanel,
|
||||
waitForWorkspaceTabsVisible,
|
||||
} from "../support/helpers/workspace-tabs";
|
||||
import { openChangesPanel, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
interface DirtyWorkspace {
|
||||
id: string;
|
||||
@@ -266,126 +262,6 @@ test.afterEach(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("Changes without a comparison base explains how to view pending changes", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithManyTinyDiffs(2, "dev");
|
||||
await openWorkspaceChangesTree(page, workspace);
|
||||
await expect(
|
||||
page.getByText("Comparison base is unavailable. Select Uncommitted to view pending changes."),
|
||||
).toBeVisible();
|
||||
await selectChangesComparison(page, "Uncommitted");
|
||||
await expect(changesTree(page).getByText("file-0000.ts", { exact: true })).toBeVisible();
|
||||
});
|
||||
async function openWorkspaceChangesTree(page: Page, workspace: DirtyWorkspace): Promise<void> {
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await openChangesTreePanel(page);
|
||||
}
|
||||
|
||||
test("streamed Changes shows the tree before completion and applies a one-file refresh", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
const workspace = await createLargeStreamedWorkspace();
|
||||
await addOversizedSibling(workspace);
|
||||
const stream = await holdDiffCompletion(page);
|
||||
await openWorkspaceChangesSurface(page, workspace, 90_000);
|
||||
await expectAnnouncedTreeAndLoader(page);
|
||||
await page.screenshot({ path: testInfo.outputPath("tree-loading.png") });
|
||||
stream.finish();
|
||||
await expect.poll(() => stream.completed, { timeout: 60_000 }).toBeGreaterThan(0);
|
||||
await expect(page.getByRole("progressbar", { name: "Loading..." })).toHaveCount(0);
|
||||
const connections = stream.connections;
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("src/file-0000.ts, +90, -1");
|
||||
await addOneLineToFirstFile(workspace);
|
||||
await expect.poll(() => stream.changedBodies.at(-1), { timeout: 30_000 }).toBe(1);
|
||||
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("src/file-0000.ts, +91, -1");
|
||||
await expect(page.getByRole("progressbar", { name: "Loading..." })).toHaveCount(0);
|
||||
expect(stream.connections).toBe(connections);
|
||||
await page.screenshot({ path: testInfo.outputPath("refreshed.png") });
|
||||
});
|
||||
|
||||
async function createLargeStreamedWorkspace(): Promise<DirtyWorkspace> {
|
||||
const workspace = await createWorkspaceWithManyTinyDiffs(252);
|
||||
await Promise.all(
|
||||
Array.from({ length: 252 }, (_, file) =>
|
||||
writeFile(
|
||||
path.join(workspace.repoPath, `src/file-${String(file).padStart(4, "0")}.ts`),
|
||||
Array.from(
|
||||
{ length: 90 },
|
||||
(_unused, line) =>
|
||||
`export const fn${line} = (param: string) => ({ key: param, value: ${file} });`,
|
||||
).join("\n") + "\n",
|
||||
),
|
||||
),
|
||||
);
|
||||
return workspace;
|
||||
}
|
||||
async function addOversizedSibling(workspace: DirtyWorkspace): Promise<void> {
|
||||
await writeFile(path.join(workspace.repoPath, "zz-too-large.txt"), "x".repeat(1024 * 1024 + 1));
|
||||
}
|
||||
async function addOneLineToFirstFile(workspace: DirtyWorkspace): Promise<void> {
|
||||
const file = path.join(workspace.repoPath, "src/file-0000.ts");
|
||||
await writeFile(file, (await readFile(file, "utf8")) + "export const refreshed = true;\n");
|
||||
}
|
||||
async function holdDiffCompletion(page: Page) {
|
||||
const { decodeDiffFrame, DiffOpcode } = await import("@getpaseo/protocol/binary-frames/index");
|
||||
let holding = true;
|
||||
let connections = 0;
|
||||
let completed = 0;
|
||||
const completions: Array<() => void> = [];
|
||||
const changedBodies: number[] = [];
|
||||
const bodyCounts = new Map<number, number>();
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (browserSocket) => {
|
||||
connections++;
|
||||
const serverSocket = browserSocket.connectToServer();
|
||||
browserSocket.onMessage((message) => serverSocket.send(message));
|
||||
serverSocket.onMessage((message) => {
|
||||
if (
|
||||
typeof message !== "string" &&
|
||||
message[0] >= DiffOpcode.Begin &&
|
||||
message[0] <= DiffOpcode.End
|
||||
) {
|
||||
const frame = decodeDiffFrame(message);
|
||||
if (frame.opcode === DiffOpcode.Begin) bodyCounts.set(frame.slot, 0);
|
||||
else if (frame.opcode === DiffOpcode.Bodies)
|
||||
bodyCounts.set(frame.slot, (bodyCounts.get(frame.slot) ?? 0) + frame.files.length);
|
||||
else {
|
||||
completed++;
|
||||
changedBodies.push(bodyCounts.get(frame.slot) ?? 0);
|
||||
}
|
||||
}
|
||||
if (holding && typeof message !== "string" && message[0] === DiffOpcode.End) {
|
||||
completions.push(() => browserSocket.send(message));
|
||||
return;
|
||||
}
|
||||
browserSocket.send(message);
|
||||
});
|
||||
});
|
||||
return {
|
||||
changedBodies,
|
||||
get connections() {
|
||||
return connections;
|
||||
},
|
||||
get completed() {
|
||||
return completed;
|
||||
},
|
||||
finish: () => {
|
||||
holding = false;
|
||||
completions.splice(0).forEach((send) => send());
|
||||
},
|
||||
};
|
||||
}
|
||||
async function expectWorkingDiffLoaded(page: Page, timeout: number): Promise<void> {
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
await expect(panel.getByTestId("diff-file-0")).toBeAttached({ timeout });
|
||||
}
|
||||
async function expectAnnouncedTreeAndLoader(page: Page): Promise<void> {
|
||||
await expect(changesTree(page).first()).toBeVisible();
|
||||
await expect(page.getByRole("progressbar", { name: "Loading..." }).first()).toBeVisible();
|
||||
}
|
||||
|
||||
test("Changes opens All changes for a clean checkout", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithCommittedDiff();
|
||||
|
||||
@@ -447,110 +323,6 @@ 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);
|
||||
@@ -614,35 +386,6 @@ test("an empty Changes comparison links to the populated comparison", async ({ p
|
||||
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("committed-only.ts, +1, -0");
|
||||
});
|
||||
|
||||
test("legacy diff hosts keep All, Committed and Uncommitted comparisons", async ({ page }) => {
|
||||
await useLegacyDiffHost(page);
|
||||
const workspace = await createWorkspaceWithScrollableComparisons();
|
||||
await openWorkspaceChangesSurface(page, workspace);
|
||||
await expect(changesTree(page).getByText("committed", { exact: true })).toBeVisible();
|
||||
await expect(changesTree(page).getByText("uncommitted", { exact: true })).toBeVisible();
|
||||
await selectChangesComparison(page, "Committed");
|
||||
await expectWorkingComparisonFiles(page, "committed");
|
||||
await selectChangesComparison(page, "Uncommitted");
|
||||
await expectWorkingComparisonFiles(page, "uncommitted");
|
||||
});
|
||||
async function useLegacyDiffHost(page: Page): Promise<void> {
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (browserSocket) => {
|
||||
const serverSocket = browserSocket.connectToServer();
|
||||
browserSocket.onMessage((message) => serverSocket.send(message));
|
||||
serverSocket.onMessage((message) => {
|
||||
if (typeof message === "string") {
|
||||
const envelope = JSON.parse(message);
|
||||
if (envelope.message?.payload?.status === "server_info") {
|
||||
delete envelope.message.payload.features.diffSubscription;
|
||||
browserSocket.send(JSON.stringify(envelope));
|
||||
return;
|
||||
}
|
||||
}
|
||||
browserSocket.send(message);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("Changes comparison controls the working diff and tree selection focuses its file", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -1455,13 +1198,11 @@ test("canvas diff uses the overlay scrollbar and its thumb controls vertical scr
|
||||
test("canvas headers keep a many-file diff bounded while scrolling end to end", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(300_000);
|
||||
test.setTimeout(180_000);
|
||||
const workspace = await createWorkspaceWithManyTinyDiffs(2_000);
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChangesSurface(page, workspace, 90_000);
|
||||
|
||||
// Measure document bounds after delivery, independently of the cold Git read.
|
||||
await expectWorkingDiffLoaded(page, 240_000);
|
||||
const root = page.getByTestId("git-diff-canvas-root");
|
||||
const scroller = page.getByTestId("git-diff-scroll");
|
||||
await expect(root.locator('[data-testid^="git-diff-sticky-header-"]')).toHaveCount(2);
|
||||
@@ -1916,28 +1657,6 @@ 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" }],
|
||||
@@ -2040,16 +1759,12 @@ async function createWorkspaceWithTwoSelectionDiffs(): Promise<DirtyWorkspace> {
|
||||
return { id: created.workspace.id, repoPath: repo.path };
|
||||
}
|
||||
|
||||
async function createWorkspaceWithManyTinyDiffs(
|
||||
fileCount: number,
|
||||
branch = "main",
|
||||
): Promise<DirtyWorkspace> {
|
||||
async function createWorkspaceWithManyTinyDiffs(fileCount: number): Promise<DirtyWorkspace> {
|
||||
const files = Array.from({ length: fileCount }, (_, index) => ({
|
||||
path: `src/file-${String(index).padStart(4, "0")}.ts`,
|
||||
content: "export const value = 0;\n",
|
||||
}));
|
||||
const repo = await createTempGitRepo("changes-canvas-many-files-", { files });
|
||||
if (branch !== "main") execFileSync("git", ["branch", "-m", branch], { cwd: repo.path });
|
||||
await Promise.all(
|
||||
files.map((file, index) =>
|
||||
writeFile(path.join(repo.path, file.path), `export const value = ${index + 1};\n`),
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
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,8 +78,7 @@ test("composer diff stat reveals Changes, then opens the diff in the configured
|
||||
const workspace = await seedChangedAgent("composer-diff-stat-side-");
|
||||
|
||||
try {
|
||||
// Keep both workspace rails visible alongside app navigation and Explorer.
|
||||
await page.setViewportSize({ width: 1900, height: 900 });
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentId: workspace.agentId,
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
expectMobileAgentSidebarVisible,
|
||||
expectMobileExplorerHidden,
|
||||
expectMobileExplorerVisible,
|
||||
openMobileAgentSidebarWithKeyboard,
|
||||
swipeOpenMobileAgentSidebar,
|
||||
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: openMobileAgentSidebarWithKeyboard,
|
||||
open: swipeOpenMobileAgentSidebar,
|
||||
expectOpen: expectMobileAgentSidebarVisible,
|
||||
close: closeMobileAgentSidebar,
|
||||
expectClosed: expectMobileAgentSidebarHidden,
|
||||
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
closeMobileAgentSidebar,
|
||||
expectMobileAgentSidebarHidden,
|
||||
expectMobileAgentSidebarVisible,
|
||||
openMobileAgentSidebarWithKeyboard,
|
||||
swipeOpenMobileAgentSidebar,
|
||||
} 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 keyboard shortcut opens the list over it.
|
||||
await openMobileAgentSidebarWithKeyboard(page);
|
||||
// Fullscreen hides the header's menu button; the edge swipe still opens the list over it.
|
||||
await swipeOpenMobileAgentSidebar(page);
|
||||
await expectMobileAgentSidebarVisible(page);
|
||||
// The list covers the pane, so the editor's exit action is not armed.
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
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 {
|
||||
@@ -11,7 +9,6 @@ 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-" });
|
||||
|
||||
@@ -50,88 +47,3 @@ 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("working-diff-panel").filter({ visible: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(
|
||||
page.getByTestId("workspace-tab-working_diff").filter({ visible: true }),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
|
||||
@@ -105,9 +105,6 @@ 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,
|
||||
|
||||
@@ -79,12 +79,6 @@ async function previewExplorerResize(page: Page, distance: number) {
|
||||
return { ...measurement, width: after };
|
||||
}
|
||||
|
||||
async function expectCleanChangesLoaded(page: Page): Promise<void> {
|
||||
await expect(
|
||||
page.getByTestId("changes-tree-panel").getByText("No changes to display", { exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectExplorerWidthSurvivesReload(page: Page, width: number): Promise<void> {
|
||||
await page.reload();
|
||||
const explorer = page.getByTestId("workspace-explorer-sidebar");
|
||||
@@ -106,7 +100,6 @@ test("Explorer drag previews without rerendering panes or rewriting the shared s
|
||||
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||
await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible();
|
||||
await expectCleanChangesLoaded(page);
|
||||
// Verify that direct pane renders are being counted before resetting them.
|
||||
expect(
|
||||
await page.evaluate(() =>
|
||||
|
||||
@@ -435,8 +435,7 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
});
|
||||
|
||||
try {
|
||||
// Keep both tab rails available alongside the docked Explorer.
|
||||
await page.setViewportSize({ width: 1900, height: 900 });
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await openAgentRoute(page, session);
|
||||
|
||||
await openWorkspaceFile(page, "target.ts");
|
||||
|
||||
@@ -12,15 +12,11 @@ import {
|
||||
openAppAsNewUser,
|
||||
openRelayPairingFromSetup,
|
||||
closeRelayPairing,
|
||||
emptyHome,
|
||||
loadingScreen,
|
||||
openTailscaleDocsFromSetup,
|
||||
paseoToolsSwitch,
|
||||
pickTheme,
|
||||
projectSuggestion,
|
||||
projectSuggestionRow,
|
||||
readFirstLaunch,
|
||||
recordFirstLaunch,
|
||||
setupModal,
|
||||
setupPrimaryButton,
|
||||
showSetupAgainFromSettings,
|
||||
@@ -103,139 +99,6 @@ 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,7 +97,6 @@ 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,9 +652,6 @@ 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,12 +38,8 @@ 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: page.getByTestId(rightPaneId!) };
|
||||
return { left, right };
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
|
||||
@@ -414,8 +414,6 @@ 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",
|
||||
@@ -629,3 +627,36 @@ 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,7 +2,6 @@ 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";
|
||||
@@ -530,7 +529,6 @@ 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,8 +357,6 @@ 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,20 +101,3 @@ 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,8 +52,6 @@ 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,54 +34,6 @@ 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,13 +81,7 @@ export async function scrollWorkspaceList(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
export async function expectWorkspaceHoverCardOpen(page: Page): Promise<void> {
|
||||
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 });
|
||||
await expect(page.getByTestId("workspace-hover-card")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectWorkspaceHoverCardStaysOpen(page: Page): Promise<void> {
|
||||
@@ -267,9 +261,16 @@ export async function expectMobileAgentSidebarHidden(page: Page): Promise<void>
|
||||
await expect(page.getByTestId("sidebar-sessions")).not.toBeInViewport({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
/** 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 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();
|
||||
}
|
||||
|
||||
/** Drag from the right edge to open the compact Explorer over the agent. */
|
||||
|
||||
@@ -477,29 +477,18 @@ export async function expectStableHistoryStartGutter(page: Page): Promise<void>
|
||||
.toBe(32);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
export async function scrollTimelinePromptIntoView(page: Page, prompt: string): 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 row");
|
||||
throw new Error(`Expected a rendered timeline prompt for ${prompt}`);
|
||||
}
|
||||
// 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,7 +17,6 @@ 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) {
|
||||
@@ -37,7 +36,6 @@ final class WindowLayoutView: ExpoView {
|
||||
override func didMoveToWindow() {
|
||||
super.didMoveToWindow()
|
||||
previousPayload = nil
|
||||
bottomBarGuide = nil
|
||||
setNeedsUpdateConstraints()
|
||||
scheduleUpdate()
|
||||
}
|
||||
@@ -68,11 +66,6 @@ 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(
|
||||
@@ -100,17 +93,10 @@ 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.2",
|
||||
"version": "0.15.0",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
@@ -46,7 +45,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, useIsSetupDecisionPending } from "@/onboarding";
|
||||
import { OnboardingHost } from "@/onboarding";
|
||||
import { AppDiagnosticHost } from "@/components/app-diagnostic-host";
|
||||
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
|
||||
import { LeftSidebar } from "@/components/left-sidebar";
|
||||
@@ -69,7 +68,6 @@ import {
|
||||
resolveDesktopAppContentMinimum,
|
||||
resolveDesktopSidebarVisibility,
|
||||
resolveSidebarActivity,
|
||||
resolveLeftSidebarPresentation,
|
||||
} from "@/components/desktop-sidebar-layout";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { HorizontalScrollProvider } from "@/contexts/horizontal-scroll-context";
|
||||
@@ -123,7 +121,7 @@ import {
|
||||
useHosts,
|
||||
} from "@/runtime/host-runtime";
|
||||
import { getDaemonStartService } from "@/runtime/daemon-start-service";
|
||||
import { usePanelStore, type LeftSidebarPresentation } from "@/stores/panel-store";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { flushDraftPersistStorage } from "@/stores/draft-store";
|
||||
import { getNextThemePreference, ICON_STROKE_WIDTH } from "@/styles/theme";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
@@ -151,7 +149,6 @@ export interface HostRuntimeBootstrapState {
|
||||
splashError: string | null;
|
||||
retry: () => void;
|
||||
hasGivenUpWaitingForHost: boolean;
|
||||
isWaitingForSetup: boolean;
|
||||
storeReady: boolean;
|
||||
startupBlocker: StartupBlocker;
|
||||
}
|
||||
@@ -160,7 +157,6 @@ const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
|
||||
splashError: null,
|
||||
retry: () => {},
|
||||
hasGivenUpWaitingForHost: false,
|
||||
isWaitingForSetup: false,
|
||||
storeReady: false,
|
||||
startupBlocker: { kind: "none" },
|
||||
});
|
||||
@@ -257,9 +253,6 @@ 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()) {
|
||||
@@ -321,23 +314,6 @@ 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 });
|
||||
@@ -348,15 +324,8 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
const storeReady = resolveStartupNavigationReady({ startupBlocker });
|
||||
|
||||
const state = useMemo<HostRuntimeBootstrapState>(
|
||||
() => ({
|
||||
splashError,
|
||||
retry,
|
||||
hasGivenUpWaitingForHost,
|
||||
isWaitingForSetup,
|
||||
storeReady,
|
||||
startupBlocker,
|
||||
}),
|
||||
[splashError, retry, hasGivenUpWaitingForHost, isWaitingForSetup, storeReady, startupBlocker],
|
||||
() => ({ splashError, retry, hasGivenUpWaitingForHost, storeReady, startupBlocker }),
|
||||
[splashError, retry, hasGivenUpWaitingForHost, storeReady, startupBlocker],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -472,17 +441,6 @@ 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,
|
||||
@@ -492,7 +450,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
const sidebarChrome = (
|
||||
<SidebarChrome
|
||||
mounted={isCompactLayout ? chromeEnabled : desktopSidebarMounted}
|
||||
presentation={sidebarPresentation}
|
||||
visible={isCompactLayout ? chromeEnabled : desktopSidebarVisible}
|
||||
keyboardShortcutsEnabled={keyboardShortcutsEnabled}
|
||||
/>
|
||||
);
|
||||
@@ -558,6 +516,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
<WorkspacePinShortcutHandler />
|
||||
<WorkspaceRenameHost />
|
||||
<CommandCenter />
|
||||
<OnboardingHost />
|
||||
<AddProjectFlowHost />
|
||||
<HostChooserModal />
|
||||
<HostConfirmationSheet />
|
||||
@@ -568,15 +527,11 @@ 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 sidebarGesturesEnabled={sidebarPresentation === "overlay"}>
|
||||
<AppContainerLifetime compactGesturesEnabled={chromeEnabled && isCompactLayout}>
|
||||
<SheetHostScope enabled={isCompactLayout}>{surface}</SheetHostScope>
|
||||
</AppContainerLifetime>
|
||||
);
|
||||
@@ -584,15 +539,15 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
|
||||
function AppContainerLifetime({
|
||||
children,
|
||||
sidebarGesturesEnabled,
|
||||
compactGesturesEnabled,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
sidebarGesturesEnabled: boolean;
|
||||
compactGesturesEnabled: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ComposerEditingSessionsProvider>
|
||||
<CommandCenterProvider>
|
||||
<MobileGestureWrapper chromeEnabled={sidebarGesturesEnabled}>
|
||||
<MobileGestureWrapper chromeEnabled={compactGesturesEnabled}>
|
||||
{children}
|
||||
</MobileGestureWrapper>
|
||||
</CommandCenterProvider>
|
||||
@@ -602,26 +557,23 @@ function AppContainerLifetime({
|
||||
|
||||
function SidebarChrome({
|
||||
mounted,
|
||||
presentation,
|
||||
visible,
|
||||
keyboardShortcutsEnabled,
|
||||
}: {
|
||||
mounted: boolean;
|
||||
presentation: LeftSidebarPresentation;
|
||||
visible: boolean;
|
||||
keyboardShortcutsEnabled: boolean;
|
||||
}) {
|
||||
const isCompactLayout = useIsCompactFormFactor();
|
||||
const isMobileActive = useIsMobilePanelActive("agent-list");
|
||||
const overlay = presentation === "overlay";
|
||||
const active = resolveSidebarActivity(presentation !== "hidden", overlay, isMobileActive);
|
||||
const active = resolveSidebarActivity(visible, isCompactLayout, isMobileActive);
|
||||
const hardwareKeyboardPresent = useHardwareKeyboardPresent();
|
||||
return (
|
||||
<SidebarModelProvider
|
||||
active={active}
|
||||
keyboardNavigationActive={keyboardShortcutsEnabled && hardwareKeyboardPresent}
|
||||
>
|
||||
{mounted ? (
|
||||
<LeftSidebar active={active} desktopOverlay={overlay && !isCompactLayout} />
|
||||
) : null}
|
||||
{mounted ? <LeftSidebar active={active} /> : null}
|
||||
<WorkspaceShortcutTargetsSubscriber enabled={keyboardShortcutsEnabled} />
|
||||
</SidebarModelProvider>
|
||||
);
|
||||
@@ -634,8 +586,8 @@ function MobileGestureWrapper({
|
||||
children: ReactNode;
|
||||
chromeEnabled: boolean;
|
||||
}) {
|
||||
const isCompactLayout = useIsCompactFormFactor();
|
||||
const openGesture = useOpenAgentListGesture(chromeEnabled);
|
||||
const isCompactLayout = useIsCompactFormFactor();
|
||||
|
||||
return (
|
||||
<GestureDetector gesture={openGesture} touchAction={MOBILE_WEB_GESTURE_TOUCH_ACTION}>
|
||||
|
||||
@@ -45,19 +45,11 @@ export default function Index() {
|
||||
}),
|
||||
isWorkspaceSelectionLoaded,
|
||||
hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost,
|
||||
isWaitingForSetup: bootstrapState.isWaitingForSetup,
|
||||
});
|
||||
|
||||
const splash = <StartupSplashScreen bootstrapState={isDesktop ? bootstrapState : undefined} />;
|
||||
if (startupRoute.kind === "redirect") {
|
||||
// The loading screen stays up until the target route paints; a redirect alone renders nothing.
|
||||
return (
|
||||
<>
|
||||
{splash}
|
||||
<Redirect href={startupRoute.href} />
|
||||
</>
|
||||
);
|
||||
return <Redirect href={startupRoute.href} />;
|
||||
}
|
||||
|
||||
return splash;
|
||||
return <StartupSplashScreen bootstrapState={isDesktop ? bootstrapState : undefined} />;
|
||||
}
|
||||
|
||||
@@ -232,28 +232,6 @@ 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) {
|
||||
@@ -684,7 +662,6 @@ export function AdaptiveModalSheet({
|
||||
styles.desktopCard,
|
||||
desktopHeight != null && { height: desktopHeight },
|
||||
desktopMaxWidth != null && { maxWidth: desktopMaxWidth },
|
||||
isWeb && WEB_ENTER_STYLE,
|
||||
],
|
||||
[desktopMaxWidth, desktopHeight],
|
||||
);
|
||||
|
||||
@@ -9,11 +9,9 @@ import {
|
||||
canDesktopAppSidebarShare,
|
||||
resolveDesktopAppChromeLayout,
|
||||
resolveDesktopAppContentMinimum,
|
||||
resolveDesktopSidebarInsetLeft,
|
||||
resolveDesktopSidebarVisibility,
|
||||
resolveSidebarActivity,
|
||||
resolveDesktopSidebarWidth,
|
||||
resolveLeftSidebarPresentation,
|
||||
} from "@/components/desktop-sidebar-layout";
|
||||
|
||||
describe("desktop sidebar layout", () => {
|
||||
@@ -106,40 +104,6 @@ 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({
|
||||
@@ -243,8 +207,3 @@ 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,10 +1,6 @@
|
||||
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,
|
||||
type LeftSidebarPresentation,
|
||||
} from "@/stores/panel-store";
|
||||
import { MAX_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH } from "@/stores/panel-store";
|
||||
import type { DesktopDivisionLayout } from "@/window-layout";
|
||||
|
||||
const MIN_DESKTOP_CENTER_WIDTH = 400;
|
||||
@@ -25,33 +21,6 @@ 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,12 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
beginExplorerSidebarResize,
|
||||
finishExplorerSidebarResize,
|
||||
reconcileExplorerSidebarResize,
|
||||
resolveExplorerSidebarDockSizes,
|
||||
resolveExplorerSidebarFrameWidth,
|
||||
resolveExplorerSidebarWidth,
|
||||
type ExplorerSidebarResize,
|
||||
} from "@/components/explorer-sidebar-layout";
|
||||
|
||||
describe("Explorer sidebar layout", () => {
|
||||
@@ -24,196 +19,4 @@ 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,85 +16,6 @@ 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,10 +25,7 @@ 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 {
|
||||
resolveDesktopSidebarInsetLeft,
|
||||
resolveDesktopSidebarWidth,
|
||||
} from "@/components/desktop-sidebar-layout";
|
||||
import { resolveDesktopSidebarWidth } from "@/components/desktop-sidebar-layout";
|
||||
import { useWindowLayout, resolveDesktopDivisionLayout } from "@/window-layout";
|
||||
import {
|
||||
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
|
||||
@@ -68,7 +65,6 @@ 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";
|
||||
@@ -125,16 +121,9 @@ interface MobileSidebarProps extends SidebarSharedProps {
|
||||
interface DesktopSidebarProps extends SidebarSharedProps {
|
||||
insetsTop: number;
|
||||
active: boolean;
|
||||
overlay: boolean;
|
||||
}
|
||||
|
||||
export const LeftSidebar = memo(function LeftSidebar({
|
||||
active,
|
||||
desktopOverlay,
|
||||
}: {
|
||||
active: boolean;
|
||||
desktopOverlay: boolean;
|
||||
}) {
|
||||
export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boolean }) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
@@ -175,20 +164,27 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
const openProjectPicker = useOpenAddProject();
|
||||
const { open: openImportSession, sheet: importSessionSheet } = useImportSession();
|
||||
|
||||
// Each destination closes the navigation overlay first. Closing is idempotent, so a docked
|
||||
// sidebar runs the same handler.
|
||||
const handleOpenProject = useCallback(() => {
|
||||
const handleOpenProjectMobile = useCallback(() => {
|
||||
void openProjectPicker();
|
||||
}, [openProjectPicker]);
|
||||
|
||||
const handleSettings = useCallback(() => {
|
||||
const handleOpenProjectDesktop = useCallback(() => {
|
||||
void openProjectPicker();
|
||||
}, [openProjectPicker]);
|
||||
|
||||
const handleSettingsMobile = useCallback(() => {
|
||||
showMobileAgent();
|
||||
router.push(buildSettingsRoute());
|
||||
}, [showMobileAgent]);
|
||||
|
||||
const handleAddHost = useAddHost();
|
||||
const handleSettingsDesktop = useCallback(() => {
|
||||
router.push(buildSettingsRoute());
|
||||
}, []);
|
||||
|
||||
const handleOpenHostSettings = useCallback(
|
||||
const handleAddHostMobile = useAddHost();
|
||||
const handleAddHostDesktop = handleAddHostMobile;
|
||||
|
||||
const handleOpenHostSettingsMobile = useCallback(
|
||||
(serverId: string) => {
|
||||
showMobileAgent();
|
||||
openHostOverview(serverId);
|
||||
@@ -196,6 +192,14 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
[showMobileAgent],
|
||||
);
|
||||
|
||||
const handleOpenHostSettingsDesktop = useCallback((serverId: string) => {
|
||||
openHostOverview(serverId);
|
||||
}, []);
|
||||
|
||||
const handleImportSessionMobile = useCallback(() => {
|
||||
openImportSession();
|
||||
}, [openImportSession]);
|
||||
|
||||
const labels = useMemo(
|
||||
(): SidebarLabels => ({
|
||||
addProject: t("sidebar.actions.addProject"),
|
||||
@@ -228,35 +232,41 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
labels,
|
||||
};
|
||||
|
||||
const handlers = {
|
||||
handleOpenProject,
|
||||
handleImportSession: openImportSession,
|
||||
handleSettings,
|
||||
handleAddHost,
|
||||
handleOpenHostSettings,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<RetainedPanelActivity active={active}>
|
||||
{isCompactLayout ? (
|
||||
if (isCompactLayout) {
|
||||
return (
|
||||
<>
|
||||
<RetainedPanelActivity active={active}>
|
||||
<MobileSidebar
|
||||
{...sharedProps}
|
||||
{...handlers}
|
||||
active={active}
|
||||
insetsTop={insets.top}
|
||||
insetsBottom={insets.bottom}
|
||||
closeSidebar={showMobileAgent}
|
||||
handleOpenProject={handleOpenProjectMobile}
|
||||
handleImportSession={handleImportSessionMobile}
|
||||
handleSettings={handleSettingsMobile}
|
||||
handleAddHost={handleAddHostMobile}
|
||||
handleOpenHostSettings={handleOpenHostSettingsMobile}
|
||||
/>
|
||||
) : (
|
||||
<DesktopSidebar
|
||||
{...sharedProps}
|
||||
{...handlers}
|
||||
insetsTop={insets.top}
|
||||
active={active}
|
||||
overlay={desktopOverlay}
|
||||
/>
|
||||
)}
|
||||
</RetainedPanelActivity>
|
||||
{importSessionSheet}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<RetainedPanelActivity active={active}>
|
||||
<DesktopSidebar
|
||||
{...sharedProps}
|
||||
insetsTop={insets.top}
|
||||
active={active}
|
||||
handleOpenProject={handleOpenProjectDesktop}
|
||||
handleImportSession={openImportSession}
|
||||
handleSettings={handleSettingsDesktop}
|
||||
handleAddHost={handleAddHostDesktop}
|
||||
handleOpenHostSettings={handleOpenHostSettingsDesktop}
|
||||
/>
|
||||
</RetainedPanelActivity>
|
||||
{importSessionSheet}
|
||||
</>
|
||||
@@ -653,10 +663,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();
|
||||
@@ -668,12 +678,14 @@ function DesktopSidebar({
|
||||
leftInset: insets.left,
|
||||
rightInset: insets.right,
|
||||
});
|
||||
const visibleSidebarWidth = useLeftPanelWidth();
|
||||
const visibleSidebarWidth =
|
||||
divisionLayout?.sidebarWidth ??
|
||||
resolveDesktopSidebarWidth({
|
||||
requestedWidth: sidebarWidth,
|
||||
viewportWidth,
|
||||
});
|
||||
|
||||
const closeSidebar = usePanelStore((state) => state.showMobileAgent);
|
||||
const { gesture: closeGesture, gestureRef: closeGestureRef } = useCloseAgentListGesture(overlay);
|
||||
|
||||
const startWidth = useSharedValue(visibleSidebarWidth);
|
||||
const startWidthRef = useRef(visibleSidebarWidth);
|
||||
const resizeWidth = useSharedValue(visibleSidebarWidth);
|
||||
const [resizePressed, setResizePressed] = useState(false);
|
||||
const showResizeGrip = useCallback(() => setResizePressed(true), []);
|
||||
@@ -697,12 +709,12 @@ function DesktopSidebar({
|
||||
.activeOffsetX([-SIDEBAR_RESIZE_ACTIVATION_OFFSET, SIDEBAR_RESIZE_ACTIVATION_OFFSET])
|
||||
.failOffsetY([-SIDEBAR_RESIZE_FAIL_OFFSET, SIDEBAR_RESIZE_FAIL_OFFSET])
|
||||
.onStart((event) => {
|
||||
startWidth.value = visibleSidebarWidth - event.translationX;
|
||||
startWidthRef.current = visibleSidebarWidth - event.translationX;
|
||||
resizeWidth.value = visibleSidebarWidth;
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
// Dragging right (positive translationX) increases width
|
||||
const newWidth = startWidth.value + event.translationX;
|
||||
const newWidth = startWidthRef.current + event.translationX;
|
||||
resizeWidth.value = resolveDesktopSidebarWidth({
|
||||
requestedWidth: newWidth,
|
||||
viewportWidth,
|
||||
@@ -720,7 +732,6 @@ function DesktopSidebar({
|
||||
resizeWidth,
|
||||
setSidebarWidth,
|
||||
showResizeGrip,
|
||||
startWidth,
|
||||
viewportWidth,
|
||||
visibleSidebarWidth,
|
||||
],
|
||||
@@ -729,110 +740,101 @@ 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, resizeAnimatedStyle],
|
||||
[resizeAnimatedStyle],
|
||||
() => [
|
||||
staticStyles.desktopSidebar,
|
||||
!active && staticStyles.desktopSidebarHidden,
|
||||
resizeAnimatedStyle,
|
||||
],
|
||||
[active, resizeAnimatedStyle],
|
||||
);
|
||||
const desktopSidebarBorderStyle = useMemo(
|
||||
() => [
|
||||
styles.desktopSidebarBorder(divisionLayout !== null),
|
||||
{
|
||||
flex: 1,
|
||||
paddingTop: insetsTop,
|
||||
paddingBottom: insets.bottom,
|
||||
paddingLeft: resolveDesktopSidebarInsetLeft({ overlay, safeAreaLeft: insets.left }),
|
||||
},
|
||||
{ flex: 1, paddingTop: insetsTop, paddingBottom: insets.bottom },
|
||||
],
|
||||
[insetsTop, insets.bottom, insets.left, overlay, divisionLayout],
|
||||
[insetsTop, insets.bottom, divisionLayout],
|
||||
);
|
||||
const sidebarHeaderGroupStyle = useMemo(
|
||||
() => [styles.sidebarHeaderGroup, ownsTopLeft && styles.sidebarHeaderGroupBelowChrome],
|
||||
[ownsTopLeft],
|
||||
);
|
||||
return (
|
||||
<MobilePanelOverlay
|
||||
panel="agent-list"
|
||||
presentation={overlay ? "overlay" : "dock"}
|
||||
dockVisible={active}
|
||||
closeGesture={closeGesture}
|
||||
<Animated.View
|
||||
accessibilityElementsHidden={!active}
|
||||
importantForAccessibility={active ? "auto" : "no-hide-descendants"}
|
||||
pointerEvents={active ? "auto" : "none"}
|
||||
style={desktopSidebarStyle}
|
||||
>
|
||||
<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>
|
||||
) : (
|
||||
<View style={desktopSidebarBorderStyle}>
|
||||
<View style={styles.sidebarDragArea}>
|
||||
{ownsTopLeft || DEV_BUILD_LABEL ? (
|
||||
<View style={styles.desktopChromeRow}>
|
||||
<TitlebarDragRegion />
|
||||
)}
|
||||
<SidebarNavRows style={sidebarHeaderGroupStyle} onBeforeNavigate={closeSidebar} />
|
||||
</View>
|
||||
|
||||
{isInitialLoad && !hasActiveHostFilter ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
{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>
|
||||
) : (
|
||||
<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}
|
||||
/>
|
||||
<TitlebarDragRegion />
|
||||
)}
|
||||
|
||||
<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}
|
||||
<SidebarNavRows style={sidebarHeaderGroupStyle} />
|
||||
</View>
|
||||
</Animated.View>
|
||||
</MobilePanelOverlay>
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -863,7 +865,9 @@ const workspacesSectionHeaderElement = <WorkspacesSectionHeader />;
|
||||
const staticStyles = RNStyleSheet.create({
|
||||
desktopSidebar: {
|
||||
position: "relative" as const,
|
||||
flex: 1,
|
||||
},
|
||||
desktopSidebarHidden: {
|
||||
display: "none",
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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 { usePointerCapabilities } from "@/hooks/pointer-capabilities";
|
||||
import { useHasFinePointer } from "@/hooks/use-fine-pointer";
|
||||
import {
|
||||
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
|
||||
SIDEBAR_RESIZE_FAIL_OFFSET,
|
||||
@@ -47,7 +47,7 @@ export function ResizeHandle({
|
||||
onResizeSplit,
|
||||
}: ResizeHandleProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { finePointer } = usePointerCapabilities();
|
||||
const finePointer = useHasFinePointer();
|
||||
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 { usePointerCapabilities } from "@/hooks/pointer-capabilities";
|
||||
import { useHasFinePointer } from "@/hooks/use-fine-pointer";
|
||||
|
||||
interface SidebarResizeHandleProps {
|
||||
edge: SidebarResizeEdge;
|
||||
@@ -29,7 +29,7 @@ function edgeOffsetStyle(edge: SidebarResizeEdge, edgeOffset: number) {
|
||||
}
|
||||
|
||||
export function SidebarResizeHandle({ edge, gesture, pressed, testID }: SidebarResizeHandleProps) {
|
||||
const { finePointer } = usePointerCapabilities();
|
||||
const finePointer = useHasFinePointer();
|
||||
|
||||
if (finePointer) {
|
||||
return <PointerResizeHandle edge={edge} gesture={gesture} pressed={pressed} testID={testID} />;
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import {
|
||||
AdaptiveWorkspaceChrome,
|
||||
type WorkspaceCompactHeaderActions,
|
||||
} from "@/screens/workspace/adaptive-workspace-chrome";
|
||||
import { useWorkspaceTabDrag } from "./workspace-tab-drag-context";
|
||||
import {
|
||||
Fragment,
|
||||
@@ -36,7 +32,6 @@ import {
|
||||
WindowChromeSafeArea,
|
||||
useWindowChromeCorners,
|
||||
removeWindowChromeCorner,
|
||||
resolveChildWindowChromeCorners,
|
||||
type WindowChromeCorners,
|
||||
} from "@/utils/desktop-window";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
@@ -47,7 +42,6 @@ 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";
|
||||
@@ -67,11 +61,6 @@ 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;
|
||||
@@ -156,6 +145,7 @@ const EMPTY_SPLIT_SIZES: number[] = [];
|
||||
|
||||
export function SplitContainer({
|
||||
layout,
|
||||
renderMainHeader,
|
||||
workspaceKey,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
@@ -184,11 +174,9 @@ export function SplitContainer({
|
||||
onReorderTabsInPane,
|
||||
focusModeEnabled,
|
||||
onExitFocusMode,
|
||||
renderMainHeader,
|
||||
renderCompactHeader,
|
||||
}: SplitContainerProps) {
|
||||
const inheritedWindowChromeCorners = useWindowChromeCorners();
|
||||
const windowChromeCorners = inheritedWindowChromeCorners;
|
||||
const windowChromeCorners = focusModeEnabled ? inheritedWindowChromeCorners : "none";
|
||||
const { activeDragTabId, dropPreview, tabDropPreview } = useWorkspaceTabDrag();
|
||||
const [maximizedPane, setMaximizedPane] = useState<{
|
||||
workspaceKey: string;
|
||||
@@ -249,6 +237,7 @@ export function SplitContainer({
|
||||
<View style={styles.workspaceShell}>
|
||||
<WindowChromeRegion corners={mainColumnWindowChromeCorners}>
|
||||
<View style={mainColumnStyle}>
|
||||
{renderMainHeader?.()}
|
||||
{splitRoot.usesFallbackStrip && <WindowChromeSafeArea placement="below" />}
|
||||
{renderRoot ? (
|
||||
<SplitNodeView
|
||||
@@ -290,8 +279,6 @@ export function SplitContainer({
|
||||
onTogglePaneMaximized={handleTogglePaneMaximized}
|
||||
focusModeEnabled={focusModeEnabled}
|
||||
onExitFocusMode={onExitFocusMode}
|
||||
renderMainHeader={renderMainHeader}
|
||||
renderCompactHeader={renderCompactHeader}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
@@ -423,8 +410,6 @@ function SplitNodeView({
|
||||
onTogglePaneMaximized,
|
||||
focusModeEnabled,
|
||||
onExitFocusMode,
|
||||
renderMainHeader,
|
||||
renderCompactHeader,
|
||||
}: SplitNodeViewProps) {
|
||||
const [groupContainerSize, setGroupContainerSize] = useState(0);
|
||||
const groupId = node.kind === "group" ? node.group.id : null;
|
||||
@@ -511,8 +496,6 @@ function SplitNodeView({
|
||||
onTogglePaneMaximized={onTogglePaneMaximized}
|
||||
focusModeEnabled={focusModeEnabled}
|
||||
onExitFocusMode={onExitFocusMode}
|
||||
renderMainHeader={renderMainHeader}
|
||||
renderCompactHeader={renderCompactHeader}
|
||||
/>
|
||||
</WindowChromeRegion>
|
||||
</RetainedPanel>
|
||||
@@ -561,25 +544,12 @@ function SplitNodeView({
|
||||
showDropZones={showDropZones}
|
||||
dropPreview={dropPreview}
|
||||
tabDropPreview={tabDropPreview}
|
||||
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,
|
||||
})}
|
||||
windowChromeCorners={windowChromeCorners}
|
||||
maximizedPaneId={maximizedPaneId}
|
||||
workspaceHasMultiplePanes={workspaceHasMultiplePanes}
|
||||
onTogglePaneMaximized={onTogglePaneMaximized}
|
||||
focusModeEnabled={focusModeEnabled}
|
||||
onExitFocusMode={onExitFocusMode}
|
||||
renderMainHeader={renderMainHeader}
|
||||
renderCompactHeader={renderCompactHeader}
|
||||
/>
|
||||
</SplitGroupChild>
|
||||
{index < node.group.children.length - 1 &&
|
||||
@@ -638,8 +608,6 @@ const SplitPaneView = memo(function SplitPaneView({
|
||||
onTogglePaneMaximized,
|
||||
focusModeEnabled,
|
||||
onExitFocusMode,
|
||||
renderMainHeader,
|
||||
renderCompactHeader,
|
||||
}: SplitPaneViewProps) {
|
||||
recordRenderProfileReasons(`SplitPaneView:${pane.id}`, ["render"]);
|
||||
const paneRef = useRef<View | null>(null);
|
||||
@@ -733,51 +701,6 @@ 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
|
||||
@@ -786,49 +709,43 @@ const SplitPaneView = memo(function SplitPaneView({
|
||||
style={styles.pane}
|
||||
testID={`workspace-pane-${pane.id}`}
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<View style={styles.paneContent}>
|
||||
<WorkspacePanelHost
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { hasTouchScreen } from "@/hooks/pointer-capabilities";
|
||||
import { hasTouchScreen } from "@/hooks/use-fine-pointer";
|
||||
|
||||
// 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 style={styles.composerSlot}>
|
||||
<KeyboardTranslateView>
|
||||
<Composer
|
||||
agentId={`workspace-setup:${serverId}:${sourceDirectory}`}
|
||||
serverId={serverId}
|
||||
@@ -487,9 +487,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.destructive,
|
||||
lineHeight: 20,
|
||||
},
|
||||
composerSlot: {
|
||||
paddingBottom: theme.spacing[4],
|
||||
},
|
||||
composerInputWrapper: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
|
||||
@@ -19,18 +19,15 @@ 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;
|
||||
}
|
||||
|
||||
@@ -39,7 +36,6 @@ function ComposerViewport({
|
||||
children,
|
||||
onLayout,
|
||||
bottomInset = 0,
|
||||
keyboardGap = 0,
|
||||
centered = false,
|
||||
...props
|
||||
}: ComposerViewportProps) {
|
||||
@@ -52,7 +48,6 @@ function ComposerViewport({
|
||||
bottomInset,
|
||||
safeAreaBottom: safeAreaBottom.value,
|
||||
keyboardShift: layoutShift.value,
|
||||
keyboardGap,
|
||||
centered,
|
||||
}),
|
||||
(geometry) => {
|
||||
@@ -134,10 +129,6 @@ 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 = [
|
||||
@@ -185,9 +176,7 @@ export function ComposerDock({
|
||||
style={fullscreen ? dockStyles.surface : dockStyles.centeredComposer}
|
||||
fullscreen={fullscreen}
|
||||
>
|
||||
<View style={fullscreen ? fullscreenSurface : dockStyles.centeredComposerSurface}>
|
||||
{composer}
|
||||
</View>
|
||||
<View style={fullscreen ? fullscreenSurface : dockStyles.composer}>{composer}</View>
|
||||
</ComposerViewportContent>
|
||||
</ComposerViewportContent>
|
||||
</KeyboardTranslateView>
|
||||
@@ -196,12 +185,8 @@ export function ComposerDock({
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ComposerViewport style={dockStyles.viewport} keyboardGap={placement.keyboardGap}>
|
||||
<KeyboardTranslateView
|
||||
style={dockStyles.surface}
|
||||
enabled={!fullscreen}
|
||||
keyboardGap={placement.keyboardGap}
|
||||
>
|
||||
<ComposerViewport style={dockStyles.viewport}>
|
||||
<KeyboardTranslateView style={dockStyles.surface} enabled={!fullscreen}>
|
||||
<View
|
||||
testID="composer-dock-content"
|
||||
collapsable={false}
|
||||
@@ -223,7 +208,7 @@ export function ComposerDock({
|
||||
style={
|
||||
fullscreen
|
||||
? fullscreenSurface
|
||||
: [dockStyles.composer, { paddingBottom: placement.restingPadding }]
|
||||
: [dockStyles.composer, { paddingBottom: insets.bottom }]
|
||||
}
|
||||
>
|
||||
{composer}
|
||||
@@ -247,7 +232,6 @@ 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, paddingBottom: theme.spacing[4] },
|
||||
composer: { width: "100%", flexShrink: 1 },
|
||||
// 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,66 +1,18 @@
|
||||
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,
|
||||
});
|
||||
@@ -73,7 +25,6 @@ describe("composer viewport", () => {
|
||||
height: 582,
|
||||
bottomInset: 24,
|
||||
safeAreaBottom: 0,
|
||||
keyboardGap: 0,
|
||||
centered: false,
|
||||
keyboardShift: 308,
|
||||
});
|
||||
@@ -84,13 +35,7 @@ describe("composer viewport", () => {
|
||||
);
|
||||
});
|
||||
it("preserves the editing capacity when the keyboard closes", () => {
|
||||
const viewport = {
|
||||
height: 582,
|
||||
bottomInset: 24,
|
||||
safeAreaBottom: 0,
|
||||
keyboardGap: 0,
|
||||
centered: false,
|
||||
};
|
||||
const viewport = { height: 582, bottomInset: 24, safeAreaBottom: 0, centered: false };
|
||||
const open = updateComposerCapacity(undefined, { ...viewport, keyboardShift: 308 });
|
||||
const closed = updateComposerCapacity(open, { ...viewport, keyboardShift: 0 });
|
||||
expect(open.capacity).toBe(245);
|
||||
@@ -103,7 +48,6 @@ describe("composer viewport", () => {
|
||||
height: 582,
|
||||
bottomInset: 24,
|
||||
safeAreaBottom: 0,
|
||||
keyboardGap: 0,
|
||||
centered: false,
|
||||
keyboardShift: 308,
|
||||
});
|
||||
@@ -112,7 +56,6 @@ describe("composer viewport", () => {
|
||||
height: 650,
|
||||
bottomInset: 24,
|
||||
safeAreaBottom: 0,
|
||||
keyboardGap: 0,
|
||||
centered: false,
|
||||
keyboardShift: 0,
|
||||
}).capacity,
|
||||
@@ -122,7 +65,6 @@ describe("composer viewport", () => {
|
||||
height: 0,
|
||||
bottomInset: 24,
|
||||
safeAreaBottom: 0,
|
||||
keyboardGap: 0,
|
||||
centered: false,
|
||||
keyboardShift: 0,
|
||||
}),
|
||||
@@ -134,7 +76,6 @@ describe("composer viewport", () => {
|
||||
bottomInset: 24,
|
||||
safeAreaBottom: 0,
|
||||
keyboardShift: 308,
|
||||
keyboardGap: 0,
|
||||
centered: false,
|
||||
});
|
||||
expect(height).toBe(245);
|
||||
@@ -150,7 +91,6 @@ describe("composer viewport", () => {
|
||||
bottomInset,
|
||||
safeAreaBottom: 0,
|
||||
keyboardShift,
|
||||
keyboardGap: 0,
|
||||
centered: true,
|
||||
});
|
||||
expect(height).toBe(695);
|
||||
@@ -170,7 +110,6 @@ describe("composer viewport", () => {
|
||||
bottomInset: 72,
|
||||
safeAreaBottom: 0,
|
||||
keyboardShift: 313,
|
||||
keyboardGap: 0,
|
||||
centered: true,
|
||||
}),
|
||||
).toBe(407);
|
||||
@@ -183,7 +122,6 @@ describe("composer viewport", () => {
|
||||
bottomInset: 60,
|
||||
safeAreaBottom: 32,
|
||||
keyboardShift: 288,
|
||||
keyboardGap: 0,
|
||||
centered: true,
|
||||
};
|
||||
const height = resolveComposerCapacity(geometry);
|
||||
@@ -207,7 +145,6 @@ describe("composer viewport", () => {
|
||||
bottomInset: 24,
|
||||
safeAreaBottom: 0,
|
||||
keyboardShift: 0,
|
||||
keyboardGap: 0,
|
||||
centered: false,
|
||||
}),
|
||||
).toBe(553);
|
||||
@@ -217,7 +154,6 @@ describe("composer viewport", () => {
|
||||
bottomInset: 0,
|
||||
safeAreaBottom: 0,
|
||||
keyboardShift: 200,
|
||||
keyboardGap: 0,
|
||||
centered: false,
|
||||
}),
|
||||
).toBe(95);
|
||||
|
||||
@@ -4,8 +4,6 @@ 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;
|
||||
}
|
||||
|
||||
@@ -26,36 +24,10 @@ export function resolveComposerCapacity(input: ComposerGeometry): number {
|
||||
input.height - input.bottomInset,
|
||||
input.height - input.safeAreaBottom - input.keyboardShift,
|
||||
)
|
||||
: input.height -
|
||||
input.bottomInset -
|
||||
input.keyboardShift -
|
||||
Math.min(input.keyboardGap, input.keyboardShift);
|
||||
: input.height - input.bottomInset - 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 { usePointerCapabilities } from "@/hooks/pointer-capabilities";
|
||||
import { useHasFinePointer } from "@/hooks/use-fine-pointer";
|
||||
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 { finePointer: hasFinePointer } = usePointerCapabilities();
|
||||
const hasFinePointer = useHasFinePointer();
|
||||
const isDesktopLayout = resolveIsDesktopWebBreakpoint(isCompactLayout);
|
||||
const messagePlaceholder = resolveMessagePlaceholder(inputMode, isDesktopLayout, t, placeholder);
|
||||
const hasText = useSyncExternalStore(
|
||||
@@ -2568,6 +2568,7 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
width: "100%",
|
||||
overflow: "visible",
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingBottom: theme.spacing[4],
|
||||
},
|
||||
inputAreaLocked: {
|
||||
opacity: 0.6,
|
||||
@@ -2577,6 +2578,7 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
minHeight: 0,
|
||||
alignItems: "stretch",
|
||||
paddingHorizontal: 0,
|
||||
paddingBottom: 0,
|
||||
},
|
||||
fullscreenFill: {
|
||||
flex: 1,
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import {
|
||||
View,
|
||||
Platform,
|
||||
Text,
|
||||
useWindowDimensions,
|
||||
NativeSyntheticEvent,
|
||||
@@ -37,7 +36,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/pointer-capabilities";
|
||||
import { hasFinePointer } from "@/hooks/use-fine-pointer";
|
||||
import { DictationOverlay } from "@/components/dictation-controls";
|
||||
import { RealtimeVoiceOverlay } from "@/components/realtime-voice-overlay";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
@@ -1937,7 +1936,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
const inputWrapperCombinedStyle = useMemo(
|
||||
() => [
|
||||
styles.inputWrapper,
|
||||
Platform.OS !== "ios" && isCompact && !isFullscreen && styles.inputWrapperCompact,
|
||||
isCompact && !isFullscreen && styles.inputWrapperCompact,
|
||||
readOnly && styles.inputWrapperReadOnly,
|
||||
inputWrapperStyle,
|
||||
{ opacity: surfacePresentation.input.opacity },
|
||||
@@ -2231,11 +2230,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
return (
|
||||
<View
|
||||
ref={rootRef}
|
||||
style={
|
||||
isFullscreen
|
||||
? styles.fullscreenContainer
|
||||
: [styles.container, Platform.OS === "ios" && isCompact && styles.inputWrapperCompact]
|
||||
}
|
||||
style={isFullscreen ? styles.fullscreenContainer : styles.container}
|
||||
testID="message-input-root"
|
||||
onLayout={handleComposerLayout}
|
||||
>
|
||||
@@ -2281,7 +2276,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
position: "relative",
|
||||
},
|
||||
inputWrapper: {
|
||||
...(Platform.OS === "ios" ? { overflow: "hidden" as const } : {}),
|
||||
flexShrink: 1,
|
||||
flexDirection: "column",
|
||||
gap: theme.spacing[3],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { DiffState, OwnedSubscription, SubscriptionObserver } from "@getpaseo/client";
|
||||
import type { OwnedSubscription, SubscriptionObserver } from "@getpaseo/client";
|
||||
import { QueryClient, QueryObserver, skipToken } from "@tanstack/react-query";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { MutableDaemonConfig, SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
@@ -144,21 +144,10 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {}
|
||||
["status", "providers_snapshot_update"],
|
||||
() => {},
|
||||
),
|
||||
observeDiff(input) {
|
||||
const cwd = input.cwd;
|
||||
let mode: "uncommitted" | "base" | "all" = "uncommitted";
|
||||
if (input.head.kind === "rev") mode = "base";
|
||||
else if (input.mergeBase) mode = "all";
|
||||
const compare = {
|
||||
mode,
|
||||
...(input.base.kind === "rev" && input.base.rev !== "HEAD"
|
||||
? { baseRef: input.base.rev }
|
||||
: {}),
|
||||
ignoreWhitespace: input.ignoreWhitespace,
|
||||
};
|
||||
observeCheckoutDiff(cwd, compare) {
|
||||
const subscriptionId = `server-diff-${subscribeCheckoutDiffCalls.length + 1}`;
|
||||
subscribeCheckoutDiffCalls.push({ cwd, compare, subscriptionId });
|
||||
const handle = observe<SubscribeCheckoutDiffResponseMessage["payload"]>(
|
||||
const handle = observe(
|
||||
{ subscriptionId, cwd, files: [], error: null, requestId: "subscribe-checkout-diff" },
|
||||
["checkout_diff_update", "subscribe_checkout_diff_response"],
|
||||
() => unsubscribeCheckoutDiffCalls.push(subscriptionId),
|
||||
@@ -166,43 +155,9 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {}
|
||||
"subscriptionId" in message.payload &&
|
||||
message.payload.subscriptionId === subscriptionId,
|
||||
);
|
||||
const modern: OwnedSubscription<DiffState> = {
|
||||
...handle,
|
||||
ready: Promise.resolve({
|
||||
phase: "ready",
|
||||
revision: 1,
|
||||
resolved: { baseSha: "", headSha: "", mergeBaseSha: "" },
|
||||
files: [],
|
||||
diffTooLarge: false,
|
||||
subscriptionId,
|
||||
}),
|
||||
subscribe: (observer) =>
|
||||
handle.subscribe({
|
||||
snapshot: (payload) =>
|
||||
observer.snapshot({
|
||||
phase: "ready",
|
||||
revision: 1,
|
||||
resolved: { baseSha: "", headSha: "", mergeBaseSha: "" },
|
||||
files: payload.files,
|
||||
diffTooLarge: payload.diffTooLarge === true,
|
||||
subscriptionId,
|
||||
}),
|
||||
update: (message) => {
|
||||
if (message.type === "checkout_diff_update")
|
||||
observer.snapshot({
|
||||
phase: "ready",
|
||||
revision: 2,
|
||||
resolved: { baseSha: "", headSha: "", mergeBaseSha: "" },
|
||||
files: message.payload.files,
|
||||
diffTooLarge: message.payload.diffTooLarge === true,
|
||||
subscriptionId,
|
||||
});
|
||||
},
|
||||
}),
|
||||
};
|
||||
if (!config.rejectCheckoutDiffSubscribe) return modern;
|
||||
if (!config.rejectCheckoutDiffSubscribe) return handle;
|
||||
return {
|
||||
...modern,
|
||||
...handle,
|
||||
subscribe: (observer) => {
|
||||
queueMicrotask(() => observer.error?.(new Error("subscribe failed")));
|
||||
return () => {};
|
||||
@@ -373,7 +328,7 @@ describe("server data push router", () => {
|
||||
const first = publish(files, "first");
|
||||
const same = publish(structuredClone(files), "reopen");
|
||||
expect(same.files).toBe(first.files);
|
||||
expect(same.requestId).toBe("diff:1:ready");
|
||||
expect(same.requestId).toBe("reopen");
|
||||
const changed = structuredClone(files);
|
||||
changed[0]!.hunks[0]!.lines[0]!.tokens![0]!.style = "variable";
|
||||
const next = publish(changed, "edit");
|
||||
@@ -475,7 +430,7 @@ describe("server data push router", () => {
|
||||
files: [],
|
||||
error: null,
|
||||
diffTooLarge: true,
|
||||
requestId: "diff:1:ready",
|
||||
requestId: "diff-1",
|
||||
});
|
||||
|
||||
fake.emit({
|
||||
@@ -494,7 +449,7 @@ describe("server data push router", () => {
|
||||
files: [],
|
||||
error: null,
|
||||
diffTooLarge: true,
|
||||
requestId: "diff:2:ready",
|
||||
requestId: `subscription:${serverSubscriptionId}`,
|
||||
});
|
||||
|
||||
unsubscribeObserver();
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { checkoutDiffSnapshot, type CheckoutDiffSnapshot } from "@/git/diff-snapshot";
|
||||
import type { DiffState } from "@getpaseo/client";
|
||||
import type { OwnedSubscription } from "@getpaseo/client";
|
||||
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
||||
import type { Query, QueryCacheNotifyEvent, QueryClient, QueryKey } from "@tanstack/react-query";
|
||||
@@ -25,9 +23,14 @@ type ProvidersSnapshotUpdateMessage = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "providers_snapshot_update" }
|
||||
>;
|
||||
type SubscribeCheckoutDiffResponseMessage = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "subscribe_checkout_diff_response" }
|
||||
>;
|
||||
type StatusMessage = Extract<SessionOutboundMessage, { type: "status" }>;
|
||||
type TerminalsChangedMessage = Extract<SessionOutboundMessage, { type: "terminals_changed" }>;
|
||||
type CheckoutDiffCachePayload = CheckoutDiffSnapshot;
|
||||
type CheckoutDiffResponsePayload = SubscribeCheckoutDiffResponseMessage["payload"];
|
||||
type CheckoutDiffCachePayload = Omit<CheckoutDiffResponsePayload, "subscriptionId">;
|
||||
type ListTerminalsPayload = ListTerminalsResponse["payload"];
|
||||
|
||||
interface CheckoutDiffCompare {
|
||||
@@ -46,7 +49,7 @@ interface CheckoutDiffRoute {
|
||||
}
|
||||
|
||||
interface CheckoutDiffRegistration extends CheckoutDiffRoute {
|
||||
subscription: OwnedSubscription<DiffState>;
|
||||
subscription: OwnedSubscription<CheckoutDiffResponsePayload>;
|
||||
}
|
||||
|
||||
interface WorkspaceTerminalsRoute {
|
||||
@@ -72,7 +75,7 @@ export type ProvidersSnapshotUpdate = ProvidersSnapshotUpdateMessage;
|
||||
interface ServerDataPushClient {
|
||||
getProvidersSnapshot: import("@getpaseo/client/internal/daemon-client").DaemonClient["getProvidersSnapshot"];
|
||||
observeEvents: import("@getpaseo/client/internal/daemon-client").DaemonClient["observeEvents"];
|
||||
observeDiff: import("@getpaseo/client/internal/daemon-client").DaemonClient["observeDiff"];
|
||||
observeCheckoutDiff: import("@getpaseo/client/internal/daemon-client").DaemonClient["observeCheckoutDiff"];
|
||||
observeTerminals: import("@getpaseo/client/internal/daemon-client").DaemonClient["observeTerminals"];
|
||||
}
|
||||
|
||||
@@ -336,34 +339,34 @@ function reconcileCheckoutDiffSubscriptions(input: {
|
||||
}
|
||||
for (const [key, desired] of input.desired) {
|
||||
if (input.active.has(key)) continue;
|
||||
const { mode, baseRef, ignoreWhitespace } = desired.compare;
|
||||
const revision = mode === "uncommitted" ? "HEAD" : baseRef;
|
||||
if (!revision) continue;
|
||||
const subscription = input.client.observeDiff({
|
||||
cwd: desired.cwd,
|
||||
base: { kind: "rev", rev: revision },
|
||||
head: mode === "base" ? { kind: "rev", rev: "HEAD" } : { kind: "worktree" },
|
||||
mergeBase: mode !== "uncommitted",
|
||||
ignoreWhitespace,
|
||||
});
|
||||
const subscription = input.client.observeCheckoutDiff(desired.cwd, desired.compare);
|
||||
const registration = { ...desired, subscription };
|
||||
input.active.set(key, registration);
|
||||
const apply = (
|
||||
payload: Omit<CheckoutDiffResponsePayload, "requestId"> & { requestId?: string },
|
||||
) => {
|
||||
if (input.active.get(key) !== registration) return;
|
||||
const { subscriptionId, ...snapshot } = payload;
|
||||
setCheckoutDiffPayload({
|
||||
activeCheckoutDiffSubscriptions: input.active,
|
||||
queryClient: input.queryClient,
|
||||
serverId: input.serverId,
|
||||
subscriptionId: key,
|
||||
payload: {
|
||||
...snapshot,
|
||||
files: orderCheckoutDiffFiles(payload.files),
|
||||
requestId: payload.requestId ?? `subscription:${subscriptionId}`,
|
||||
},
|
||||
});
|
||||
};
|
||||
subscription.subscribe({
|
||||
snapshot: (state) => {
|
||||
if (input.active.get(key) !== registration) return;
|
||||
const snapshot = checkoutDiffSnapshot(desired.cwd, state);
|
||||
setCheckoutDiffPayload({
|
||||
activeCheckoutDiffSubscriptions: input.active,
|
||||
queryClient: input.queryClient,
|
||||
serverId: input.serverId,
|
||||
subscriptionId: key,
|
||||
payload: { ...snapshot, files: orderCheckoutDiffFiles(snapshot.files) },
|
||||
});
|
||||
snapshot: apply,
|
||||
update: (message) => {
|
||||
if (message.type === "checkout_diff_update") apply(message.payload);
|
||||
},
|
||||
update: () => {},
|
||||
error: (error) => {
|
||||
if (input.active.get(key) === registration) input.active.delete(key);
|
||||
console.error("[server-data] observeDiff failed", {
|
||||
console.error("[server-data] observeCheckoutDiff failed", {
|
||||
serverId: input.serverId,
|
||||
cwd: desired.cwd,
|
||||
error,
|
||||
|
||||
@@ -13,10 +13,10 @@ import {
|
||||
type DiffTreeRow,
|
||||
} from "@/git/diff-tree";
|
||||
import { FileHeader } from "@/git/file-header";
|
||||
import type { DiffFileHeader } from "@/git/diff-snapshot";
|
||||
import type { ParsedDiffFile } from "@/git/use-diff-query";
|
||||
|
||||
export interface ChangedFilesTreeProps {
|
||||
files: DiffFileHeader[];
|
||||
files: ParsedDiffFile[];
|
||||
mode: WorkingDiffMode;
|
||||
onSelectFile: (path: string) => void;
|
||||
collapsedFolderPaths: string[];
|
||||
|
||||
@@ -25,7 +25,7 @@ export async function fetchCheckoutStatus({
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
}): Promise<CheckoutStatusPayload> {
|
||||
const payload = normalizeCheckoutBaseRef(await client.getCheckoutStatus(cwd));
|
||||
const payload = await client.getCheckoutStatus(cwd);
|
||||
return payload;
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ export function applyCheckoutStatusUpdateFromEvent({
|
||||
serverId: string;
|
||||
message: CheckoutStatusUpdate;
|
||||
}): void {
|
||||
const payload = normalizeCheckoutBaseRef(message.payload);
|
||||
const { payload } = message;
|
||||
const prStatus = payload.prStatus
|
||||
? normalizeCheckoutPrStatusPayload(payload.prStatus)
|
||||
: undefined;
|
||||
@@ -88,13 +88,6 @@ 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,
|
||||
|
||||
@@ -13,6 +13,6 @@ export function orderCheckoutDiffFiles(files: ParsedDiffFile[]): ParsedDiffFile[
|
||||
// chains, so it is skipped; the collapsed set is empty because the flat list
|
||||
// always carries every file, whatever the rail has collapsed.
|
||||
return flattenDiffTree(buildDiffTree(files), new Set()).flatMap((row) =>
|
||||
row.kind === "file" ? [files[row.fileIndex]] : [],
|
||||
row.kind === "file" ? [row.file] : [],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { DiffFileHeader } from "@/git/diff-snapshot";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { floatingActionsClearance } from "@/components/ui/floating-action-button";
|
||||
import { useState, useCallback, useMemo, type ReactElement, type ReactNode } from "react";
|
||||
@@ -20,7 +19,6 @@ import type { Theme } from "@/styles/theme";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import {
|
||||
AlignJustify,
|
||||
ArrowRight,
|
||||
ChevronDown,
|
||||
Columns2,
|
||||
ExternalLink,
|
||||
@@ -63,7 +61,6 @@ 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";
|
||||
@@ -204,7 +201,6 @@ 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);
|
||||
@@ -474,8 +470,6 @@ interface ChangesPullRequestLinkModel extends Pick<PrHint, "forge" | "number" |
|
||||
}
|
||||
|
||||
interface ChangesRepositoryToolbarModel {
|
||||
baseRef: string | null;
|
||||
isWorktree: boolean;
|
||||
branchName: string | null;
|
||||
cwd: string;
|
||||
gitActions: GitActions | null;
|
||||
@@ -502,8 +496,6 @@ interface ChangesHeaderProps {
|
||||
}
|
||||
|
||||
interface BuildChangesHeaderModelInput {
|
||||
baseRef: string | null;
|
||||
isWorktree: boolean;
|
||||
branchName: string | null;
|
||||
committedDescription?: string;
|
||||
compact: boolean;
|
||||
@@ -527,8 +519,6 @@ function buildChangesHeaderModel(input: BuildChangesHeaderModelInput): {
|
||||
} {
|
||||
return {
|
||||
repository: {
|
||||
baseRef: input.baseRef,
|
||||
isWorktree: input.isWorktree,
|
||||
branchName: input.branchName,
|
||||
cwd: input.cwd,
|
||||
gitActions: input.compact ? input.gitActions : null,
|
||||
@@ -639,10 +629,6 @@ 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,
|
||||
@@ -668,18 +654,6 @@ 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 ? (
|
||||
@@ -1096,7 +1070,6 @@ interface DiffBodyContentProps {
|
||||
emptyMessage: string;
|
||||
emptyAction: ChangesEmptyAction | null;
|
||||
children: ReactElement;
|
||||
loadingLabel: string;
|
||||
checkingRepositoryLabel: string;
|
||||
notRepositoryLabel: string;
|
||||
}
|
||||
@@ -1113,16 +1086,11 @@ function DiffBodyContent({
|
||||
emptyAction,
|
||||
children,
|
||||
checkingRepositoryLabel,
|
||||
loadingLabel,
|
||||
notRepositoryLabel,
|
||||
}: DiffBodyContentProps) {
|
||||
if (isStatusLoading) {
|
||||
return (
|
||||
<View
|
||||
style={styles.loadingContainer}
|
||||
accessibilityRole="progressbar"
|
||||
accessibilityLabel={loadingLabel}
|
||||
>
|
||||
<View style={styles.loadingContainer}>
|
||||
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedIconColorMapping} />
|
||||
<Text style={styles.loadingText}>{checkingRepositoryLabel}</Text>
|
||||
</View>
|
||||
@@ -1144,16 +1112,12 @@ function DiffBodyContent({
|
||||
}
|
||||
if (isDiffLoading) {
|
||||
return (
|
||||
<View
|
||||
style={styles.loadingContainer}
|
||||
accessibilityRole="progressbar"
|
||||
accessibilityLabel={loadingLabel}
|
||||
>
|
||||
<View style={styles.loadingContainer}>
|
||||
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedIconColorMapping} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (diffTooLarge && !hasChanges) {
|
||||
if (diffTooLarge) {
|
||||
return <DiffTooLargeState />;
|
||||
}
|
||||
if (diffErrorMessage) {
|
||||
@@ -1332,7 +1296,7 @@ function ChangesTreeRail({
|
||||
}: {
|
||||
shown: boolean;
|
||||
children: ReactElement;
|
||||
files: DiffFileHeader[];
|
||||
files: ParsedDiffFile[];
|
||||
mode: WorkingDiffMode;
|
||||
onSelectFile: (path: string) => void;
|
||||
treeWidth?: number;
|
||||
@@ -1372,7 +1336,7 @@ function ChangesBody({
|
||||
children: ReactElement;
|
||||
desktopTreeVisible: boolean;
|
||||
isMobile: boolean;
|
||||
files: DiffFileHeader[];
|
||||
files: ParsedDiffFile[];
|
||||
mode: WorkingDiffMode;
|
||||
onSelectFile: (path: string) => void;
|
||||
treeWidth?: number;
|
||||
@@ -1597,7 +1561,6 @@ export function ChangesSurface({
|
||||
selectBase: handleSelectBase,
|
||||
selectAll: handleSelectAll,
|
||||
files,
|
||||
treeFiles,
|
||||
diffPayloadError,
|
||||
diffTooLarge,
|
||||
isDiffLoading,
|
||||
@@ -1847,7 +1810,6 @@ export function ChangesSurface({
|
||||
hasChanges={hasChanges}
|
||||
emptyMessage={emptyMessage}
|
||||
emptyAction={emptyAction}
|
||||
loadingLabel={t("common.loading")}
|
||||
checkingRepositoryLabel={t("workspace.git.diff.checkingRepository")}
|
||||
notRepositoryLabel={t("workspace.git.diff.notRepository")}
|
||||
>
|
||||
@@ -1878,7 +1840,7 @@ export function ChangesSurface({
|
||||
presentation={presentation}
|
||||
desktopTreeVisible={desktopTreeVisible}
|
||||
isMobile={isMobile}
|
||||
files={treeFiles}
|
||||
files={files}
|
||||
mode={workingMode}
|
||||
onSelectFile={handleSelectTreeFile}
|
||||
treeWidth={instanceState.treeWidth}
|
||||
@@ -1942,8 +1904,6 @@ export function ChangesSurface({
|
||||
const changesHeaderModel = useMemo(
|
||||
() =>
|
||||
buildChangesHeaderModel({
|
||||
baseRef: baseRef ?? null,
|
||||
isWorktree: isManagedWorktree(status),
|
||||
branchName: currentBranchName,
|
||||
committedDescription: committedDiffDescription,
|
||||
compact: isMobile,
|
||||
@@ -1961,8 +1921,6 @@ export function ChangesSurface({
|
||||
workspaceId,
|
||||
}),
|
||||
[
|
||||
baseRef,
|
||||
status,
|
||||
committedDiffDescription,
|
||||
currentBranchName,
|
||||
cwd,
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { replaceEqualDeep } from "@tanstack/react-query";
|
||||
import type { CheckoutDiffSnapshot } from "./diff-snapshot";
|
||||
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
|
||||
import type { ParsedDiffFile, SubscribeCheckoutDiffResponse } from "@getpaseo/protocol/messages";
|
||||
|
||||
type DiffPayload = CheckoutDiffSnapshot;
|
||||
type DiffPayload = Omit<SubscribeCheckoutDiffResponse["payload"], "subscriptionId">;
|
||||
|
||||
/** File identity owns text-model reuse. Never reconstruct the hunk/token graph. */
|
||||
export function retainDiffFiles(
|
||||
@@ -31,7 +30,6 @@ export function shareCheckoutDiff(previous: unknown, incoming: unknown): DiffPay
|
||||
error === old.error &&
|
||||
old.cwd === next.cwd &&
|
||||
old.requestId === next.requestId &&
|
||||
old.entries === next.entries &&
|
||||
old.diffTooLarge === next.diffTooLarge
|
||||
? old
|
||||
: { ...next, files, error };
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import type { DiffState, DiffEntry } from "@getpaseo/client";
|
||||
import type {
|
||||
DiffError,
|
||||
ParsedDiffFile,
|
||||
SubscribeCheckoutDiffRequest,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
export type DiffFileHeader = Omit<ParsedDiffFile, "hunks">;
|
||||
export interface CheckoutDiffSnapshot {
|
||||
requestId: string;
|
||||
cwd: string;
|
||||
files: ParsedDiffFile[];
|
||||
entries?: DiffEntry[];
|
||||
error: DiffError | null;
|
||||
diffTooLarge?: boolean;
|
||||
}
|
||||
export function checkoutDiffSnapshot(cwd: string, state: DiffState): CheckoutDiffSnapshot {
|
||||
const common = { cwd, requestId: `diff:${state.revision}:${state.phase}` };
|
||||
if (state.phase === "loading")
|
||||
return { ...common, files: [], entries: state.entries, error: null };
|
||||
if (state.phase === "error") return { ...common, files: [], error: state.error };
|
||||
return { ...common, files: state.files, error: null, diffTooLarge: state.diffTooLarge };
|
||||
}
|
||||
export function diffEntryHeaders(entries: DiffEntry[]): DiffFileHeader[] {
|
||||
return entries.map((entry) => ({
|
||||
path: entry.path,
|
||||
...(entry.oldPath === null ? {} : { oldPath: entry.oldPath }),
|
||||
isNew: entry.change === "added",
|
||||
isDeleted: entry.change === "deleted",
|
||||
additions: entry.additions,
|
||||
deletions: entry.deletions,
|
||||
status: entry.content === "binary" ? "binary" : "ok",
|
||||
}));
|
||||
}
|
||||
export interface CheckoutDiffResult {
|
||||
files: ParsedDiffFile[];
|
||||
entries: DiffEntry[] | null;
|
||||
payloadError: CheckoutDiffSnapshot["error"];
|
||||
diffTooLarge: boolean;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
isError: boolean;
|
||||
error: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The result derives from the payload alone. Until a payload arrives there is no
|
||||
* diff to describe, so the boundary reports loading rather than an empty diff —
|
||||
* whether the query is idle (an inactive retained panel), in flight, or the host
|
||||
* is disconnected. Reporting `files: []` as settled made callers render "+0 -0"
|
||||
* and "No changes" for a diff nobody has fetched yet.
|
||||
*/
|
||||
export function deriveCheckoutDiffResult(payload: CheckoutDiffSnapshot | null): CheckoutDiffResult {
|
||||
const payloadError = payload?.error ?? null;
|
||||
return {
|
||||
files: payload?.files ?? [],
|
||||
entries: payload?.entries ?? null,
|
||||
payloadError,
|
||||
diffTooLarge: payload?.diffTooLarge === true,
|
||||
isLoading: payload === null || payload.entries !== undefined,
|
||||
isFetching: false,
|
||||
isError: Boolean(payloadError),
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function checkoutDiffComparisonError(
|
||||
compare: Pick<SubscribeCheckoutDiffRequest["compare"], "mode" | "baseRef">,
|
||||
): DiffError | null {
|
||||
if (compare.mode === "uncommitted" || compare.baseRef?.trim()) return null;
|
||||
return {
|
||||
code: "INVALID_COMPARE",
|
||||
message: "Comparison base is unavailable. Select Uncommitted to view pending changes.",
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { DiffFileHeader } from "@/git/diff-snapshot";
|
||||
import type { ParsedDiffFile } from "@/git/use-diff-query";
|
||||
|
||||
// Builds a directory hierarchy from the `DiffFileHeader[]` the Changes view
|
||||
// Builds a directory hierarchy from the `ParsedDiffFile[]` the Changes view
|
||||
// renders. The tree renders on every form factor, consistent with the Files
|
||||
// explorer.
|
||||
//
|
||||
@@ -17,7 +17,7 @@ import type { DiffFileHeader } from "@/git/diff-snapshot";
|
||||
|
||||
export interface DiffTreeFileNode {
|
||||
kind: "file";
|
||||
file: DiffFileHeader;
|
||||
file: ParsedDiffFile;
|
||||
fileIndex: number;
|
||||
/** basename, e.g. "diff-pane.tsx" */
|
||||
name: string;
|
||||
@@ -47,7 +47,7 @@ export interface DiffTreeFolderRow {
|
||||
|
||||
export interface DiffTreeFileRow {
|
||||
kind: "file";
|
||||
file: DiffFileHeader;
|
||||
file: ParsedDiffFile;
|
||||
fileIndex: number;
|
||||
depth: number;
|
||||
}
|
||||
@@ -73,7 +73,7 @@ function sortTree(node: DiffTreeDirNode): void {
|
||||
}
|
||||
|
||||
/** Build the (uncompressed) directory tree. Returns the virtual root (dirPath ""). */
|
||||
export function buildDiffTree(files: DiffFileHeader[]): DiffTreeDirNode {
|
||||
export function buildDiffTree(files: ParsedDiffFile[]): DiffTreeDirNode {
|
||||
const root: DiffTreeDirNode = { kind: "dir", dirPath: "", name: "", children: [] };
|
||||
const dirByPath = new Map<string, DiffTreeDirNode>([["", root]]);
|
||||
|
||||
|
||||
@@ -28,11 +28,11 @@ import {
|
||||
fileNameForPath,
|
||||
formatDiffCount,
|
||||
} from "@/git/file-header-presentation";
|
||||
import type { DiffFileHeader } from "@/git/diff-snapshot";
|
||||
import type { ParsedDiffFile } from "@/git/use-diff-query";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
|
||||
export interface FileHeaderProps {
|
||||
file: DiffFileHeader;
|
||||
file: ParsedDiffFile;
|
||||
workspaceFileDragScope?: { serverId: string; workspaceId: string };
|
||||
bodyVisible: boolean;
|
||||
showsBodyState?: boolean;
|
||||
@@ -73,7 +73,7 @@ function expandedAriaValue(showsBodyState: boolean, bodyVisible: boolean): boole
|
||||
return showsBodyState ? bodyVisible : undefined;
|
||||
}
|
||||
|
||||
function fileHeaderAccessibilityLabel(canvasRendered: boolean, file: DiffFileHeader) {
|
||||
function fileHeaderAccessibilityLabel(canvasRendered: boolean, file: ParsedDiffFile) {
|
||||
if (!canvasRendered) return undefined;
|
||||
return `${file.path}, +${formatDiffCount(file.additions)}, -${formatDiffCount(file.deletions)}`;
|
||||
}
|
||||
@@ -130,7 +130,7 @@ function fileHeaderNameStyle(showsBodyState: boolean, isHovered: boolean) {
|
||||
];
|
||||
}
|
||||
|
||||
function fileChange(file: DiffFileHeader): "added" | "deleted" | "modified" {
|
||||
function fileChange(file: ParsedDiffFile): "added" | "deleted" | "modified" {
|
||||
return diffFileChangeKind(file);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,13 +2,7 @@ import { afterEach, describe, expect, it } from "vitest";
|
||||
import { CheckoutPrStatusSchema } from "@getpaseo/protocol/messages";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
|
||||
import {
|
||||
actionsWithoutPrimary,
|
||||
buildGitActions,
|
||||
isCurrentBranchBase,
|
||||
canMergeIntoLocalBase,
|
||||
type BuildGitActionsInput,
|
||||
} from "./policy";
|
||||
import { actionsWithoutPrimary, buildGitActions, type BuildGitActionsInput } from "./policy";
|
||||
import { deriveMergeCapability, type ForgeSpecificStatusFacts } from "./merge-capability";
|
||||
|
||||
type GithubMergeFactsFixture = ForgeSpecificStatusFacts & {
|
||||
@@ -78,7 +72,6 @@ function createInput(
|
||||
hasRemote: false,
|
||||
isPaseoOwnedWorktree: false,
|
||||
isOnBaseBranch: true,
|
||||
canMergeLocally: true,
|
||||
hasUncommittedChanges: false,
|
||||
baseRefAvailable: true,
|
||||
baseRefLabel: "main",
|
||||
@@ -215,81 +208,6 @@ 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,29 +50,6 @@ 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;
|
||||
@@ -91,7 +68,6 @@ export interface BuildGitActionsInput {
|
||||
hasRemote: boolean;
|
||||
isPaseoOwnedWorktree: boolean;
|
||||
isOnBaseBranch: boolean;
|
||||
canMergeLocally: boolean;
|
||||
hasUncommittedChanges: boolean;
|
||||
baseRefAvailable: boolean;
|
||||
baseRefLabel: string;
|
||||
@@ -373,7 +349,7 @@ function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
|
||||
if (input.shipDefault === "pr" && canUsePullRequestActionAsShipDefault(input)) {
|
||||
return "pr";
|
||||
}
|
||||
if (!input.isOnBaseBranch && input.canMergeLocally && input.aheadCount > 0) {
|
||||
if (!input.isOnBaseBranch && input.aheadCount > 0) {
|
||||
return "merge-branch";
|
||||
}
|
||||
if (!input.isOnBaseBranch && canMergeFromBase(input)) {
|
||||
@@ -404,7 +380,7 @@ function getPullRequestActionIds(filter: {
|
||||
function getFeatureActionIds(input: BuildGitActionsInput): GitActionId[] {
|
||||
return [
|
||||
"merge-from-base",
|
||||
...(input.canMergeLocally ? (["merge-branch"] as const) : []),
|
||||
"merge-branch",
|
||||
...getPullRequestActionIds({ roles: ["status", "direct", "auto"], input }),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -12,8 +12,6 @@ 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,
|
||||
@@ -118,7 +116,7 @@ interface DeriveGitActionsStateArgs {
|
||||
hasUncommittedChanges: boolean;
|
||||
postShipArchiveSuggested: boolean;
|
||||
isStatusLoading: boolean;
|
||||
baseRef: string | undefined;
|
||||
baseRefLabel: string;
|
||||
}
|
||||
|
||||
interface DerivedGitActionsState {
|
||||
@@ -131,7 +129,6 @@ interface DerivedGitActionsState {
|
||||
hasRemote: boolean;
|
||||
isPaseoOwnedWorktree: boolean;
|
||||
isOnBaseBranch: boolean;
|
||||
canMergeLocally: boolean;
|
||||
shouldPromoteArchive: boolean;
|
||||
}
|
||||
|
||||
@@ -170,7 +167,7 @@ function deriveGitActionsState(args: DeriveGitActionsStateArgs): DerivedGitActio
|
||||
hasUncommittedChanges,
|
||||
postShipArchiveSuggested,
|
||||
isStatusLoading,
|
||||
baseRef,
|
||||
baseRefLabel,
|
||||
} = args;
|
||||
const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading;
|
||||
const isPaseoOwnedWorktree = gitStatus?.isPaseoOwnedWorktree ?? false;
|
||||
@@ -181,8 +178,7 @@ function deriveGitActionsState(args: DeriveGitActionsStateArgs): DerivedGitActio
|
||||
hasPullRequest: Boolean(prStatus?.url),
|
||||
hasRemote: gitStatus?.hasRemote ?? false,
|
||||
isPaseoOwnedWorktree,
|
||||
isOnBaseBranch: isCurrentBranchBase(gitStatus?.currentBranch, baseRef),
|
||||
canMergeLocally: canMergeIntoLocalBase(gitStatus?.currentBranch, baseRef),
|
||||
isOnBaseBranch: gitStatus?.currentBranch === baseRefLabel,
|
||||
shouldPromoteArchive: computeShouldPromoteArchive({
|
||||
hasUncommittedChanges,
|
||||
postShipArchiveSuggested,
|
||||
@@ -657,7 +653,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
hasUncommittedChanges,
|
||||
postShipArchiveSuggested,
|
||||
isStatusLoading,
|
||||
baseRef,
|
||||
baseRefLabel,
|
||||
});
|
||||
const {
|
||||
actionsDisabled,
|
||||
@@ -669,7 +665,6 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
hasRemote,
|
||||
isPaseoOwnedWorktree,
|
||||
isOnBaseBranch,
|
||||
canMergeLocally,
|
||||
shouldPromoteArchive,
|
||||
} = derived;
|
||||
|
||||
@@ -700,7 +695,6 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
hasRemote,
|
||||
isPaseoOwnedWorktree,
|
||||
isOnBaseBranch,
|
||||
canMergeLocally,
|
||||
hasUncommittedChanges,
|
||||
baseRefAvailable: Boolean(baseRef),
|
||||
baseRefLabel,
|
||||
@@ -818,7 +812,6 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
behindBaseCount,
|
||||
isPaseoOwnedWorktree,
|
||||
isOnBaseBranch,
|
||||
canMergeLocally,
|
||||
githubFeaturesEnabled,
|
||||
forge,
|
||||
githubAutoMergeActionsEnabled,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
|
||||
import { deriveCheckoutDiffResult } from "./diff-snapshot";
|
||||
import { deriveCheckoutDiffResult } from "./use-diff-query";
|
||||
|
||||
function createParsedDiffFile(path: string): ParsedDiffFile {
|
||||
return {
|
||||
@@ -59,7 +59,6 @@ describe("deriveCheckoutDiffResult", () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
files: [],
|
||||
entries: null,
|
||||
payloadError: error,
|
||||
diffTooLarge: true,
|
||||
isLoading: false,
|
||||
@@ -69,27 +68,3 @@ describe("deriveCheckoutDiffResult", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the announced tree while file bodies are still loading", () => {
|
||||
const entries = [
|
||||
{
|
||||
path: "src/app.ts",
|
||||
oldPath: null,
|
||||
change: "modified",
|
||||
content: "text",
|
||||
additions: 3,
|
||||
deletions: 1,
|
||||
},
|
||||
] as const;
|
||||
const result = deriveCheckoutDiffResult({ ...createPayload([]), entries: [...entries] });
|
||||
expect(result.isLoading).toBe(true);
|
||||
expect(result.entries).toEqual(entries);
|
||||
expect(result.files).toEqual([]);
|
||||
});
|
||||
|
||||
it("settles a comparison without a base and leaves Uncommitted available", async () => {
|
||||
const { checkoutDiffComparisonError } = await import("./diff-snapshot");
|
||||
expect(checkoutDiffComparisonError({ mode: "all" })).toMatchObject({ code: "INVALID_COMPARE" });
|
||||
expect(checkoutDiffComparisonError({ mode: "base" })).toMatchObject({ code: "INVALID_COMPARE" });
|
||||
expect(checkoutDiffComparisonError({ mode: "uncommitted" })).toBeNull();
|
||||
});
|
||||
|
||||
@@ -5,13 +5,7 @@ import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { useReplicaQuery } from "@/data/query";
|
||||
import { checkoutDiffPushRoute } from "@/data/push-router";
|
||||
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import {
|
||||
checkoutDiffComparisonError,
|
||||
deriveCheckoutDiffResult,
|
||||
type CheckoutDiffSnapshot,
|
||||
} from "./diff-snapshot";
|
||||
export { deriveCheckoutDiffResult } from "./diff-snapshot";
|
||||
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
|
||||
import type { ParsedDiffFile, SubscribeCheckoutDiffResponse } from "@getpaseo/protocol/messages";
|
||||
import { checkoutDiffQueryKey } from "@/git/query-keys";
|
||||
|
||||
interface UseCheckoutDiffQueryOptions {
|
||||
@@ -24,7 +18,7 @@ interface UseCheckoutDiffQueryOptions {
|
||||
queryScope?: string;
|
||||
}
|
||||
|
||||
type CheckoutDiffQueryPayload = CheckoutDiffSnapshot;
|
||||
type CheckoutDiffQueryPayload = Omit<SubscribeCheckoutDiffResponse["payload"], "subscriptionId">;
|
||||
|
||||
// Re-export the canonical protocol type so all consumers share one definition.
|
||||
export type { ParsedDiffFile };
|
||||
@@ -83,11 +77,7 @@ export function useCheckoutDiffQuery({
|
||||
return normalizedScope ? [...comparisonKey, "scope", normalizedScope] : comparisonKey;
|
||||
}, [serverId, cwd, compareMode, compareBaseRef, compareIgnoreWhitespace, queryScope]);
|
||||
const subscriptionId = useMemo(() => `checkoutDiff:${JSON.stringify(queryKey)}`, [queryKey]);
|
||||
const comparisonError =
|
||||
queryEnabled && isConnected ? checkoutDiffComparisonError(normalizedCompare) : null;
|
||||
const routeEnabled = Boolean(
|
||||
queryEnabled && isConnected && cwd && !requiresUpdate && !comparisonError,
|
||||
);
|
||||
const routeEnabled = Boolean(queryEnabled && isConnected && cwd && !requiresUpdate);
|
||||
|
||||
const query = useReplicaQuery<CheckoutDiffQueryPayload>({
|
||||
queryKey,
|
||||
@@ -107,13 +97,46 @@ export function useCheckoutDiffQuery({
|
||||
}),
|
||||
});
|
||||
|
||||
const boundaryError =
|
||||
requiresUpdate && isConnected
|
||||
? { code: "UNKNOWN" as const, message: "Update the host to view All changes" }
|
||||
: comparisonError;
|
||||
return deriveCheckoutDiffResult(
|
||||
boundaryError
|
||||
? { requestId: "unavailable", cwd, files: [], error: boundaryError }
|
||||
requiresUpdate && isConnected
|
||||
? {
|
||||
requestId: "unsupported",
|
||||
cwd,
|
||||
files: [],
|
||||
error: { code: "UNKNOWN", message: "Update the host to view All changes" },
|
||||
}
|
||||
: (query.data ?? null),
|
||||
);
|
||||
}
|
||||
|
||||
export interface CheckoutDiffResult {
|
||||
files: ParsedDiffFile[];
|
||||
payloadError: CheckoutDiffQueryPayload["error"];
|
||||
diffTooLarge: boolean;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
isError: boolean;
|
||||
error: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The result derives from the payload alone. Until a payload arrives there is no
|
||||
* diff to describe, so the boundary reports loading rather than an empty diff —
|
||||
* whether the query is idle (an inactive retained panel), in flight, or the host
|
||||
* is disconnected. Reporting `files: []` as settled made callers render "+0 -0"
|
||||
* and "No changes" for a diff nobody has fetched yet.
|
||||
*/
|
||||
export function deriveCheckoutDiffResult(
|
||||
payload: CheckoutDiffQueryPayload | null,
|
||||
): CheckoutDiffResult {
|
||||
const payloadError = payload?.error ?? null;
|
||||
return {
|
||||
files: payload?.files ?? [],
|
||||
payloadError,
|
||||
diffTooLarge: payload?.diffTooLarge === true,
|
||||
isLoading: payload === null,
|
||||
isFetching: false,
|
||||
isError: Boolean(payloadError),
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,8 +9,6 @@ import {
|
||||
useReviewAttachmentSnapshot,
|
||||
useReviewFeedback,
|
||||
} from "@/review";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { diffEntryHeaders } from "./diff-snapshot";
|
||||
import { useCheckoutDiffQuery } from "@/git/use-diff-query";
|
||||
import { useCheckoutStatusQuery } from "@/git/use-status-query";
|
||||
import { useWorkingDiffComparison } from "@/git/working-diff-comparison";
|
||||
@@ -46,11 +44,7 @@ export function useWorkingDiff({
|
||||
const statusErrorMessage =
|
||||
status?.error?.message ??
|
||||
(isStatusError && statusError instanceof Error ? statusError.message : null);
|
||||
const modernDiff = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.serverInfo?.features?.diffSubscription === true,
|
||||
);
|
||||
// COMPAT(diffSubscription): added in v0.15.0, remove after 2027-05-06.
|
||||
const baseRef = (modernDiff ? gitStatus?.comparisonBaseRef : gitStatus?.baseRef) ?? undefined;
|
||||
const baseRef = gitStatus?.baseRef ?? undefined;
|
||||
const currentBranchName =
|
||||
gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD" ? gitStatus.currentBranch : null;
|
||||
|
||||
@@ -65,7 +59,6 @@ export function useWorkingDiff({
|
||||
|
||||
const {
|
||||
files,
|
||||
entries,
|
||||
payloadError: diffPayloadError,
|
||||
diffTooLarge,
|
||||
isLoading: isDiffLoading,
|
||||
@@ -78,7 +71,6 @@ export function useWorkingDiff({
|
||||
enabled: enabled && isGit,
|
||||
queryScope,
|
||||
});
|
||||
const treeFiles = useMemo(() => (entries ? diffEntryHeaders(entries) : files), [entries, files]);
|
||||
const reviewDraftKey = useMemo(
|
||||
() =>
|
||||
buildReviewDraftKey({
|
||||
@@ -120,7 +112,6 @@ export function useWorkingDiff({
|
||||
selectBase,
|
||||
selectAll,
|
||||
files,
|
||||
treeFiles,
|
||||
diffPayloadError,
|
||||
diffTooLarge,
|
||||
isDiffLoading,
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
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 },
|
||||
}));
|
||||
@@ -1,77 +0,0 @@
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,88 +0,0 @@
|
||||
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)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
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,9 +930,6 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
},
|
||||
diff: {
|
||||
baseBranch: {
|
||||
title: "الفرع الأساسي",
|
||||
},
|
||||
openChangesTab: "فتح علامة تبويب التغييرات",
|
||||
openDiffTab: "فتح علامة تبويب الفرق",
|
||||
closeChangesTab: "إغلاق علامة تبويب التغييرات",
|
||||
|
||||
@@ -938,9 +938,6 @@ export const en = {
|
||||
},
|
||||
},
|
||||
diff: {
|
||||
baseBranch: {
|
||||
title: "Change base",
|
||||
},
|
||||
openChangesTab: "Open Changes tab",
|
||||
openDiffTab: "Open Diff tab",
|
||||
closeChangesTab: "Close Changes tab",
|
||||
|
||||
@@ -963,9 +963,6 @@ 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,9 +956,6 @@ 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,9 +943,6 @@ export const ja: TranslationResources = {
|
||||
},
|
||||
},
|
||||
diff: {
|
||||
baseBranch: {
|
||||
title: "ベースブランチ",
|
||||
},
|
||||
openChangesTab: "変更タブを開く",
|
||||
openDiffTab: "差分タブを開く",
|
||||
closeChangesTab: "変更タブを閉じる",
|
||||
|
||||
@@ -937,9 +937,6 @@ export const ko: TranslationResources = {
|
||||
},
|
||||
},
|
||||
diff: {
|
||||
baseBranch: {
|
||||
title: "기준 브랜치",
|
||||
},
|
||||
openChangesTab: "변경사항 탭 열기",
|
||||
openDiffTab: "Diff 탭 열기",
|
||||
closeChangesTab: "변경사항 탭 닫기",
|
||||
|
||||
@@ -953,9 +953,6 @@ 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,9 +947,6 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
},
|
||||
diff: {
|
||||
baseBranch: {
|
||||
title: "Базовая ветка",
|
||||
},
|
||||
openChangesTab: "Открыть вкладку «Изменения»",
|
||||
openDiffTab: "Открыть вкладку «Дифф»",
|
||||
closeChangesTab: "Закрыть вкладку «Изменения»",
|
||||
|
||||
@@ -922,9 +922,6 @@ export const zhCN: TranslationResources = {
|
||||
},
|
||||
},
|
||||
diff: {
|
||||
baseBranch: {
|
||||
title: "基准分支",
|
||||
},
|
||||
openChangesTab: "打开“更改”标签页",
|
||||
openDiffTab: "打开 Diff 标签页",
|
||||
closeChangesTab: "关闭“更改”标签页",
|
||||
|
||||
@@ -17,7 +17,6 @@ export const KeyboardShiftContext = createContext<KeyboardShiftContextValue | nu
|
||||
export interface KeyboardTranslation {
|
||||
enabled: boolean;
|
||||
bottomClearance: number;
|
||||
keyboardGap: number;
|
||||
}
|
||||
|
||||
export interface WindowRect {
|
||||
@@ -39,7 +38,7 @@ export interface KeyboardTranslationScope {
|
||||
|
||||
/** Views outside a KeyboardTranslateView stay put. */
|
||||
export const KeyboardTranslationContext = createContext<KeyboardTranslationScope>({
|
||||
translation: { enabled: false, bottomClearance: 0, keyboardGap: 0 },
|
||||
translation: { enabled: false, bottomClearance: 0 },
|
||||
measureAtRest: measureInWindow,
|
||||
});
|
||||
|
||||
|
||||
@@ -29,21 +29,6 @@ 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({
|
||||
@@ -83,46 +68,16 @@ describe("resolveKeyboardShift", () => {
|
||||
|
||||
describe("resolveKeyboardLift", () => {
|
||||
it("lifts a docked view by the whole shift", () => {
|
||||
expect(
|
||||
resolveKeyboardLift({ shift: 302, enabled: true, bottomClearance: 0, keyboardGap: 0 }),
|
||||
).toBe(302);
|
||||
expect(resolveKeyboardLift({ shift: 302, enabled: true, bottomClearance: 0 })).toBe(302);
|
||||
});
|
||||
|
||||
it("lets the keyboard fill a centered form's clearance before lifting it", () => {
|
||||
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);
|
||||
expect(resolveKeyboardLift({ shift: 403, enabled: true, bottomClearance: 267 })).toBe(136);
|
||||
expect(resolveKeyboardLift({ shift: 403, enabled: true, bottomClearance: 455 })).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps a disabled view in place", () => {
|
||||
expect(
|
||||
resolveKeyboardLift({ shift: 302, enabled: false, bottomClearance: 0, keyboardGap: 0 }),
|
||||
).toBe(0);
|
||||
expect(resolveKeyboardLift({ shift: 302, enabled: false, bottomClearance: 0 })).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -37,14 +37,9 @@ export function resolveKeyboardLift(input: {
|
||||
shift: number;
|
||||
enabled: boolean;
|
||||
bottomClearance: number;
|
||||
keyboardGap: number;
|
||||
}): number {
|
||||
"worklet";
|
||||
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)
|
||||
);
|
||||
return input.enabled ? Math.max(0, input.shift - input.bottomClearance) : 0;
|
||||
}
|
||||
|
||||
export function shouldReconcileHiddenKeyboardEnd(input: {
|
||||
|
||||
@@ -10,27 +10,18 @@ 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, keyboardGap }),
|
||||
[bottomClearance, enabled, keyboardGap],
|
||||
);
|
||||
const translation = useMemo(() => ({ enabled, bottomClearance }), [bottomClearance, enabled]);
|
||||
const scope = useKeyboardTranslationScope(frameRef, translation);
|
||||
const keyboardStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateY: -resolveKeyboardLift({ shift: shift.value, ...translation }) }],
|
||||
|
||||
@@ -10,40 +10,30 @@ 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();
|
||||
// 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]);
|
||||
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],
|
||||
);
|
||||
// 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
|
||||
@@ -67,10 +57,7 @@ export function KeyboardTranslateView({
|
||||
);
|
||||
|
||||
const frameRef = useRef<View>(null);
|
||||
const translation = useMemo(
|
||||
() => ({ enabled, bottomClearance, keyboardGap }),
|
||||
[bottomClearance, enabled, keyboardGap],
|
||||
);
|
||||
const translation = useMemo(() => ({ enabled, bottomClearance }), [bottomClearance, enabled]);
|
||||
const scope = useKeyboardTranslationScope(frameRef, translation);
|
||||
|
||||
return (
|
||||
|
||||
@@ -5,14 +5,12 @@ 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,36 +3,19 @@ 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"]);
|
||||
});
|
||||
@@ -40,68 +23,17 @@ 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");
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user