Compare commits

..
Author SHA1 Message Date
Mohamed Boudra a2e8a44848 fix(desktop): recognize managed import targets
Desktop ownership is explicit application state. Avoid inferring it from host addresses or launching a second status probe, while retaining validation when an import starts.
2026-07-23 20:36:22 +02:00
Mohamed Boudra 81bffbd01a fix(import): harden the desktop import flow 2026-07-20 15:16:03 +02:00
Mohamed Boudra 9519c5ba99 refactor(import): load the import engine as a dependency 2026-07-20 15:05:57 +02:00
Mohamed Boudra 194822b939 fix(server): authorize active workspace config 2026-07-19 07:28:45 +02:00
Mohamed Boudra 2505075beb fix(migrate): reject nested command substitutions 2026-07-19 06:30:11 +02:00
Mohamed Boudra 0c3c61be94 fix(migrate): refresh managed checkout config 2026-07-19 06:09:20 +02:00
Mohamed Boudra 7046b118e9 fix(migrate): preserve adopted workspace config 2026-07-19 05:39:59 +02:00
Mohamed Boudra 3fed2f2cd9 fix(migrate): validate source refs and expansions 2026-07-19 05:02:59 +02:00
Mohamed Boudra 234f0d9b75 test(migrate): cover deleted checkout recreation 2026-07-19 04:28:05 +02:00
Mohamed Boudra 548e15c177 fix(migrate): preserve direct port arithmetic 2026-07-19 03:53:07 +02:00
Mohamed Boudra 6240b2d17c test(server): tolerate delayed Windows handle release 2026-07-19 03:39:32 +02:00
Mohamed Boudra 4e99e4c18c fix(server): release workspace watchers on shutdown 2026-07-19 03:06:48 +02:00
Mohamed Boudra 2f07c5918f test(migrate): compare checkout identities portably 2026-07-19 02:47:23 +02:00
Mohamed Boudra 3e6061b169 fix(client): normalize checkout branch refs 2026-07-19 02:27:46 +02:00
Mohamed Boudra 4ed944db2f fix(migrate): validate live source state 2026-07-19 02:12:29 +02:00
Mohamed Boudra af244d0f02 fix(migrate): share service command semantics 2026-07-19 02:01:39 +02:00
Mohamed Boudra f2124f8cf9 fix(migrate): normalize source references 2026-07-19 01:50:04 +02:00
Mohamed Boudra 152cdcabad fix(migrate): preserve Windows shell semantics 2026-07-19 01:39:15 +02:00
Mohamed Boudra 44da3cdf05 fix(migrate): preserve consistent source state 2026-07-19 01:33:48 +02:00
Mohamed Boudra a8c6c2f4bc fix(migrate): gate automatic source discovery 2026-07-19 01:21:02 +02:00
Mohamed Boudra faccbaae59 fix(migrate): validate workspace connection targets 2026-07-19 01:10:18 +02:00
Mohamed Boudra e61c06706c fix(migrate): preserve source command semantics 2026-07-19 01:03:00 +02:00
Mohamed Boudra c54ddfd07f fix(migrate): preserve checkout and shell semantics 2026-07-19 00:48:41 +02:00
Mohamed Boudra ddaaaa27be fix(desktop): flush migration output before completion 2026-07-19 00:29:50 +02:00
Mohamed Boudra fbd02437dc Merge main into project migration branch 2026-07-19 00:28:33 +02:00
Mohamed Boudra b447a650a7 feat(desktop): migrate existing projects and worktrees 2026-07-19 00:22:17 +02:00
Mohamed Boudra 4d2acfbeb5 fix(server): reject imported port offsets 2026-07-17 18:28:10 +02:00
Mohamed Boudra d6bc201f46 fix(app): refresh import availability previews 2026-07-17 18:10:55 +02:00
Mohamed Boudra 5e75e463ec fix(settings): expose remaining import errors 2026-07-17 17:47:16 +02:00
Mohamed Boudra 35d4ae201a fix(settings): tighten conductor service imports 2026-07-17 17:21:23 +02:00
Mohamed Boudra b93d326fb2 fix(settings): preserve conductor migration context 2026-07-17 17:00:30 +02:00
Mohamed Boudra 9505e43925 fix(settings): report remaining import gaps 2026-07-17 16:42:07 +02:00
Mohamed Boudra 2f0e3451de fix(settings): wait for import preview before setup callout 2026-07-17 16:22:10 +02:00
Mohamed Boudra 28cac5859a fix(settings): keep project imports current and complete 2026-07-17 15:56:04 +02:00
Mohamed Boudra 59d9a25a0d fix(settings): handle remaining import edge cases 2026-07-17 15:32:27 +02:00
Mohamed Boudra 60462337b6 fix(settings): close project import edge cases 2026-07-17 15:11:04 +02:00
Mohamed Boudra f44ef807ef fix(settings): harden conductor imports 2026-07-17 14:48:54 +02:00
Mohamed Boudra f030ddefb1 refactor(settings): make project imports pluggable 2026-07-17 14:42:02 +02:00
Mohamed Boudra f7a6548b68 fix(app): refresh stale config import apply 2026-07-17 11:33:56 +02:00
Mohamed Boudra ab9c9b96a7 fix(server): normalize conductor import paths 2026-07-17 11:31:52 +02:00
Mohamed Boudra 8fe11e3bb0 feat(settings): import existing project setup 2026-07-17 11:15:10 +02:00
472 changed files with 9920 additions and 24270 deletions
+35
View File
@@ -200,6 +200,41 @@ jobs:
if-no-files-found: ignore
retention-days: 7
import-electron:
runs-on: macos-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- name: Install dependencies with retry
run: node scripts/npm-retry.mjs ci
- name: Build app and server dependencies
run: |
npm run build:app-deps
npm run build:server
- name: Build Desktop main
run: npm run build:main --workspace=@getpaseo/desktop
- name: Run product import behavior
run: npm run test:import-electron --workspace=@getpaseo/desktop
- name: Upload import behavior diagnostics
if: failure()
uses: actions/upload-artifact@v4
with:
name: import-electron-macos
path: |
packages/app/test-results/
packages/app/playwright-report/
retention-days: 7
app-tests:
runs-on: ubuntu-latest
env:
+7 -7
View File
@@ -1,10 +1,10 @@
[vars]
android_sdk_version = '{{ read_file(path=".tool-versions") | split(pat="android-sdk") | last | trim | split(pat="\n") | first | trim }}'
[env]
ANDROID_HOME = "{{ env.HOME }}/.local/share/mise/installs/android-sdk/{{ vars.android_sdk_version }}"
ANDROID_HOME = "{{env.HOME}}/.local/share/mise/installs/android-sdk/21.0"
_.path = [
"{{ env.HOME }}/.local/share/mise/installs/android-sdk/{{ vars.android_sdk_version }}/cmdline-tools/{{ vars.android_sdk_version }}/bin",
"{{ env.HOME }}/.local/share/mise/installs/android-sdk/{{ vars.android_sdk_version }}/platform-tools",
"{{ env.HOME }}/.local/share/mise/installs/android-sdk/{{ vars.android_sdk_version }}/emulator",
"{{env.HOME}}/.local/share/mise/installs/android-sdk/21.0/cmdline-tools/21.0/bin",
"{{env.HOME}}/.local/share/mise/installs/android-sdk/21.0/platform-tools",
"{{env.HOME}}/.local/share/mise/installs/android-sdk/21.0/emulator",
]
[tools]
java = "21"
+5 -51
View File
@@ -1,42 +1,18 @@
# Changelog
## 0.2.1 - 2026-07-24
### Added
- Claude Opus 5 is available
## 0.2.0 - 2026-07-24
## 0.2.0-beta.1 - 2026-07-17
### Added
- Work with pull requests and merge requests from GitLab, Gitea, Forgejo, and Codeberg ([#1913](https://github.com/getpaseo/paseo/pull/1913) by [@nllptrx](https://github.com/nllptrx))
- Edit files directly in the web and desktop apps ([#2270](https://github.com/getpaseo/paseo/pull/2270), [#2309](https://github.com/getpaseo/paseo/pull/2309), [#2277](https://github.com/getpaseo/paseo/pull/2277), [#2382](https://github.com/getpaseo/paseo/pull/2382) by [@dwyanewang](https://github.com/dwyanewang))
- Oh My Pi (OMP) as a native agent provider ([#2067](https://github.com/getpaseo/paseo/pull/2067) by [@ebg1223](https://github.com/ebg1223))
- Open the complete Changes view as a workspace tab ([#2298](https://github.com/getpaseo/paseo/pull/2298) by [@nikuscs](https://github.com/nikuscs))
- Add files to chat directly from Files and Changes ([#2275](https://github.com/getpaseo/paseo/pull/2275) by [@nikuscs](https://github.com/nikuscs))
- Browse workspace commit history and open individual commit diffs from Changes ([#1534](https://github.com/getpaseo/paseo/pull/1534), [#2146](https://github.com/getpaseo/paseo/pull/2146), [#2312](https://github.com/getpaseo/paseo/pull/2312) by [@adradr](https://github.com/adradr))
- Switch models from the Command Center for active agents and new drafts ([#2147](https://github.com/getpaseo/paseo/pull/2147) by [@kedrzu](https://github.com/kedrzu))
- Open existing agents from Paseo links or the CLI ([#2324](https://github.com/getpaseo/paseo/pull/2324))
- Configure workspace service ports with a fixed range or external allocator ([#2165](https://github.com/getpaseo/paseo/pull/2165) by [@mcowger](https://github.com/mcowger))
- Search keyboard shortcuts by action, note, or key combination ([#2160](https://github.com/getpaseo/paseo/pull/2160))
- Turn thinking off for supported Claude models ([#2257](https://github.com/getpaseo/paseo/pull/2257))
- Allow Pi's Max thinking level ([#2267](https://github.com/getpaseo/paseo/pull/2267) by [@ByteTrue](https://github.com/ByteTrue))
- Use Oh My Pi as a native agent provider ([#2067](https://github.com/getpaseo/paseo/pull/2067) by [@ebg1223](https://github.com/ebg1223))
- Browse branch commit history and open individual commit diffs from Changes ([#1534](https://github.com/getpaseo/paseo/pull/1534), [#2146](https://github.com/getpaseo/paseo/pull/2146) by [@adradr](https://github.com/adradr))
- Open workspace files in more installed editors and file managers ([#2119](https://github.com/getpaseo/paseo/pull/2119))
- Remove individual custom providers from Settings ([#1951](https://github.com/getpaseo/paseo/pull/1951))
### Improved
- Improved model selection on mobile ([#2361](https://github.com/getpaseo/paseo/pull/2361))
- Selector popovers stay readable on iPad ([#2360](https://github.com/getpaseo/paseo/pull/2360) by [@yzim](https://github.com/yzim))
- Projects, workspaces and chat syncing is more efficient ([#2028](https://github.com/getpaseo/paseo/pull/2028), [#2185](https://github.com/getpaseo/paseo/pull/2185), [#2196](https://github.com/getpaseo/paseo/pull/2196), [#2206](https://github.com/getpaseo/paseo/pull/2206), [#2259](https://github.com/getpaseo/paseo/pull/2259), [#2263](https://github.com/getpaseo/paseo/pull/2263))
- CLI and MCP tools manage workspaces, agents, and schedules more consistently ([#2186](https://github.com/getpaseo/paseo/pull/2186))
- Pasted PR/MR links in the composer become auto-selected as a checkout option ([#2290](https://github.com/getpaseo/paseo/pull/2290))
- Make project creation more explicit ([#2098](https://github.com/getpaseo/paseo/pull/2098), [#2187](https://github.com/getpaseo/paseo/pull/2187))
- Idle agents release processes automatically and resume when needed ([#2203](https://github.com/getpaseo/paseo/pull/2203), [#2209](https://github.com/getpaseo/paseo/pull/2209))
- New Claude and Codex agents default to safer automatic approval modes when supported ([#2213](https://github.com/getpaseo/paseo/pull/2213))
- Permission and thinking changes made during a turn now show when they take effect ([#2201](https://github.com/getpaseo/paseo/pull/2201))
- Usage bars now warn as provider limits approach ([#2322](https://github.com/getpaseo/paseo/pull/2322) by [@cleiter](https://github.com/cleiter))
- ACP provider catalog updated to the latest registry versions
- Workspace focus mode stays confined to the active workspace with a visible exit control ([#2151](https://github.com/getpaseo/paseo/pull/2151))
- Desktop installs the newest available update instead of a cached older release ([#2149](https://github.com/getpaseo/paseo/pull/2149))
- Remote daemon update failures now show specific recovery steps ([#2120](https://github.com/getpaseo/paseo/pull/2120))
@@ -44,34 +20,12 @@
### Fixed
- Terminal pairing QR codes remain scannable in narrow terminals ([#2381](https://github.com/getpaseo/paseo/pull/2381))
- Workspace creation stays responsive even with many active or archived workspaces ([#2355](https://github.com/getpaseo/paseo/pull/2355), [#2379](https://github.com/getpaseo/paseo/pull/2379))
- Failed agent starts no longer leave provider processes running ([#2348](https://github.com/getpaseo/paseo/pull/2348) by [@dwyanewang](https://github.com/dwyanewang))
- Completed OpenCode turns stay idle when late metadata updates arrive ([#2336](https://github.com/getpaseo/paseo/pull/2336) by [@mcowger](https://github.com/mcowger))
- ACP image prompts no longer appear twice ([#2363](https://github.com/getpaseo/paseo/pull/2363))
- Web chats stay pinned to the latest message at non-default browser zoom ([#2368](https://github.com/getpaseo/paseo/pull/2368))
- Grouped tool-call loading animations display correctly ([#2369](https://github.com/getpaseo/paseo/pull/2369))
- Notifications now open the correct workspace and agent ([#2331](https://github.com/getpaseo/paseo/pull/2331))
- Archived agents can be restored directly from History ([#2316](https://github.com/getpaseo/paseo/pull/2316))
- CLI agent runs stay in the current workspace unless a new workspace is requested ([#2315](https://github.com/getpaseo/paseo/pull/2315))
- Reused branches no longer attach an unrelated merged or closed pull request ([#2172](https://github.com/getpaseo/paseo/pull/2172) by [@nllptrx](https://github.com/nllptrx))
- Pi compaction waits for long summaries instead of reporting a false timeout ([#2181](https://github.com/getpaseo/paseo/pull/2181) by [@jasonhnd](https://github.com/jasonhnd))
- Pi chats keep new messages aligned with the correct history after an idle agent resumes ([#2313](https://github.com/getpaseo/paseo/pull/2313))
- OpenCode follow-ups triggered by completed background work now remain visible ([#2258](https://github.com/getpaseo/paseo/pull/2258))
- Codex no longer shows the parent agent as a phantom subagent ([#2214](https://github.com/getpaseo/paseo/pull/2214))
- Oh My Pi background notices appear as task notifications instead of raw system text ([#2218](https://github.com/getpaseo/paseo/pull/2218) by [@ebg1223](https://github.com/ebg1223))
- Local dictation now works in Nix-packaged installations ([#1587](https://github.com/getpaseo/paseo/pull/1587) by [@yhori991](https://github.com/yhori991))
- The composer remains visible after submitting dictated text and returning to the app ([#2194](https://github.com/getpaseo/paseo/pull/2194))
- Desktop's dictation shortcut remains responsive after finishing a recording ([#2268](https://github.com/getpaseo/paseo/pull/2268))
- Projects can be renamed before their first workspace ([#2252](https://github.com/getpaseo/paseo/pull/2252) by [@albertodeago](https://github.com/albertodeago))
- Settings keep showing a connected remote host when the local daemon is stopped ([#1749](https://github.com/getpaseo/paseo/pull/1749) by [@dwyanewang](https://github.com/dwyanewang))
- Pinned workspaces no longer disappear briefly when reopening the compact sidebar ([#2210](https://github.com/getpaseo/paseo/pull/2210))
- Terminal panes no longer remain at 80x24 after focus or visibility changes ([#2059](https://github.com/getpaseo/paseo/pull/2059), [#2154](https://github.com/getpaseo/paseo/pull/2154) by [@cleiter](https://github.com/cleiter))
- Sign-in popups in the desktop browser now complete successfully ([#2137](https://github.com/getpaseo/paseo/pull/2137))
- Browser typing and shortcuts no longer submit the active Paseo prompt ([#1982](https://github.com/getpaseo/paseo/pull/1982))
- Agent browser tabs remain controllable after switching workspaces ([#2156](https://github.com/getpaseo/paseo/pull/2156))
- Archived workspaces now show the correct Unarchive or Restore action ([#2002](https://github.com/getpaseo/paseo/pull/2002))
- Archived sessions can be reimported into the current workspace ([#2123](https://github.com/getpaseo/paseo/pull/2123), [#2265](https://github.com/getpaseo/paseo/pull/2265) by [@nikuscs](https://github.com/nikuscs))
- Archived sessions can be reimported into the current workspace ([#2123](https://github.com/getpaseo/paseo/pull/2123))
- Browser shortcuts no longer appear where browser tabs are unavailable ([#2116](https://github.com/getpaseo/paseo/pull/2116) by [@jasonhnd](https://github.com/jasonhnd))
## 0.1.110 - 2026-07-16
-14
View File
@@ -80,20 +80,6 @@ archived workspace. History navigation must not infer workspace lifecycle from `
or mutate either lifecycle. The workspace route asks the daemon for authoritative recovery state;
only the route's explicit Unarchive or Restore action changes the archived workspace.
History navigation preserves the selected agent as an explicit recovery target. If both that agent
and its workspace are archived, the workspace recovery action restores the workspace and unarchives
the selected agent as one user action. Other archived agents in the restored workspace remain
recoverable from History. Opening one pins its tab and renders the archived-agent callout. Authoritative
timeline catch-up may load provider history with a runtime-only `history` resume purpose, which must
leave both Paseo's `archivedAt` and the provider's native archive state unchanged. **Unarchive** remains
the only transition back to an interactive runtime: it runs the provider's native unarchive hook
(including Codex `thread/unarchive`) before the normal agent resume and timeline hydration flow.
Provider session connection owns every process it spawns until the session is registered with
`AgentManager`. If initialization, persisted-session resume, or initial history hydration fails,
`connect()` must dispose that process before rethrowing; the manager cannot clean up a session it never
received.
## Tabs vs archive
These are two distinct concepts that used to be conflated:
+2 -12
View File
@@ -27,13 +27,13 @@ The formula reserves three digits each for minor and patch. If either reaches `1
## Prerequisites (local dev)
Local Android builds run on macOS (or Linux) and need the Android toolchain, pinned in `.tool-versions` (`java 21`, `android-sdk 21.0`) and wired up by `.mise.toml` (which derives `ANDROID_HOME` and the command-line tool paths from the `android-sdk` entry). With [mise](https://mise.jdx.dev):
Local Android builds run on macOS (or Linux) and need the Android toolchain, pinned in `.tool-versions` (`java 21`, `android-sdk 21.0`) and wired up by `.mise.toml` (which sets `ANDROID_HOME` and puts `cmdline-tools/21.0/bin`, `platform-tools`, and `emulator` on `PATH`). With [mise](https://mise.jdx.dev):
```bash
mise install # java 21 + android-sdk 21.0 command-line tools
```
> **Pin a real `android-sdk` version, not `latest`.** The mise `android-sdk` plugin's `latest` resolved to the ancient `1.0` bundle, whose `sdkmanager` (3.6.0) predates the `emulator` package and fails with `Failed to find package emulator`. `21.0` ships a current `sdkmanager`. If you bump it, update only the version in `.tool-versions`; `.mise.toml` derives its paths from that tool entry.
> **Pin a real `android-sdk` version, not `latest`.** The mise `android-sdk` plugin's `latest` resolved to the ancient `1.0` bundle, whose `sdkmanager` (3.6.0) predates the `emulator` package and fails with `Failed to find package emulator`. `21.0` ships a current `sdkmanager`. If you bump it, update the version in `.tool-versions` and in all four paths in `.mise.toml`.
`mise install` only lays down the command-line tools. Install the rest and create an emulator. On Apple Silicon:
@@ -120,16 +120,6 @@ PASEO_FDROID_BUILD=1 ./gradlew assembleRelease --no-daemon --max-workers=1 -Dorg
The flag must be present for both prebuild and Gradle because Gradle starts Metro for the release bundle. Keep the source build serial and daemon-free as shown above: compiling every Expo module can exhaust memory when Gradle workers run in parallel. The profile enables source-built Expo modules, excludes the proprietary camera, Firebase notification, and Expo development-client native modules, disables EAS updates and Gradle dependency metadata, and substitutes JavaScript stubs for camera and notifications. The resulting app supports direct and pasted-link pairing but not QR scanning or push notifications.
For a single-ABI APK, pass React Native's architecture property to Gradle:
```bash
PASEO_FDROID_BUILD=1 ./gradlew assembleRelease \
-PreactNativeArchitectures=arm64-v8a \
--no-daemon --max-workers=1 -Dorg.gradle.parallel=false
```
Supported values are `armeabi-v7a`, `arm64-v8a`, `x86`, and `x86_64`. The F-Droid profile filters native libraries to that ABI and changes the APK version code to `baseVersionCode * 10 + abiSuffix`, where the suffixes are ordered `1` through `4` in that same sequence. F-Droid metadata should use four build blocks with `VercodeOperation` entries `10 * %c + 1` through `10 * %c + 4` and pass the matching `reactNativeArchitectures` value in each build command. Builds without a single architecture keep the base version code.
Keep the excluded npm packages installed. Normal builds use them, while the F-Droid profile removes only their Android native modules and config plugins. Paseo always applies `expo-gradle-jvmargs` with `-Xmx4096m` and `-XX:MaxMetaspaceSize=1024m` so local Expo prebuilds have enough Gradle heap whether they use precompiled AARs or source-built Expo modules.
The EAS `production-apk` profile uses the large Android resource class. Release builds compile the native ABIs and run Hermes bundling in the same Gradle invocation; the default worker can exhaust its remaining memory and kill Hermes with exit code 137 even when Gradle's own heap is correctly sized.
+13 -1
View File
@@ -91,7 +91,19 @@ it does not depend on the server.
Owns the low-level daemon WebSocket driver plus the higher-level `PaseoClient`
facade. App and CLI may import the low-level driver from
`@getpaseo/client/internal/daemon-client` during migration, while new SDK-shaped
code imports from `@getpaseo/client`.
code imports from `@getpaseo/client`. Node automation uses
`@getpaseo/client/node`, which owns daemon discovery, authentication, transport
selection, and the capability-gated host automation facade shared by the CLI and
published import tools.
### External project import
The private [`getpaseo/import`](https://github.com/getpaseo/import) repository publishes
`@getpaseo/import`. It owns source discovery, parsing, mapping, fixtures, the CLI, and the
programmatic import engine. Paseo Desktop pins an exact package version and calls its library
entrypoint from Electron main, passing the public Node host-automation facade and forwarding typed
progress events through a narrow IPC bridge. Paseo does not contain source databases, parsers, or
import-package release machinery.
### `packages/app` — Mobile + web client (Expo)
-4
View File
@@ -190,10 +190,6 @@ Single file, validated with `PersistedConfigSchema`.
},
worktrees?: {
root?: string // optional root for new worktrees; defaults to $PASEO_HOME/worktrees
servicePorts?: { // optional dynamic service port allocation policy
range?: string // inclusive range, e.g. "3000-4000"
portScript?: string // executable that receives service/workspace context and prints one TCP port
}
},
providers: {
openai: {
-2
View File
@@ -64,8 +64,6 @@ The button is `<Button>` (`packages/app/src/components/ui/button.tsx`). It has f
Sizes: `xs` for ultra-tight inline triggers. `sm` for any button sitting in a row. `md` is the page default. `lg` is reserved for large standalone CTAs.
Sizes are a shared contract across control kinds, defined once in `control-geometry.ts`: `xs` = 28px tall with `fontSize.xs` labels, `sm` = 32px with `fontSize.sm`, `md`/`lg` = 44px with `fontSize.sm`. `<SegmentedControl>` (`packages/app/src/components/ui/segmented-control.tsx`) takes the same `xs`/`sm`/`md` sizes — a segmented control next to a `<Button>` of the same size always matches in height, label size, and horizontal padding. Thin chrome such as the file toolbar uses `xs`; settings rows use `sm`. Never shrink a control's font or padding locally to fit a context — if the context needs a smaller control, the size tier is missing or the wrong one is in use.
A `<Pressable>` wrapping a `<Text>` is a sixth variant. It is wrong. `<Button>` accepts `style`, `textStyle`, `leftIcon`, `disabled`, `size`, and `variant`.
---
+1 -16
View File
@@ -278,15 +278,6 @@ Service proxy hostnames use the double-dash shape: `web--feature-auth--project.l
}
```
Service ports use OS ephemeral allocation by default. Set `worktrees.servicePorts` in
`$PASEO_HOME/config.json`, or replace it for one project with `worktree.servicePorts` in
`paseo.json`. The block accepts an inclusive `range` such as `"3000-4000"` or a `portScript`
executable. Since `portScript` is executed directly without a shell, it must point to a real executable (e.g., a binary or a script with a proper shebang like `#!/bin/sh`) rather than an inline shell command or shell pipeline. For inline shell commands or pipelines, wrap them in a small script. `portScript` runs in the workspace directory with four arguments: service name,
workspace ID, branch name, and worktree path. A missing branch is passed as an empty string. The same
values are available as `PASEO_SCRIPTNAME`, `PASEO_WORKSPACE_ID`, `PASEO_BRANCH_NAME`, and
`PASEO_WORKTREE_PATH`. The script must print one valid TCP port. Paseo trusts the external allocator,
so the port may already be bound. `portScript` takes precedence when both values are present.
## Bundled daemon web UI
> The user-facing guide for this feature (enabling it, reverse proxy, TLS, tunnels, security) lives at [public-docs/web-ui.md](../public-docs/web-ui.md). This section is the contributor/build reference: how the artifact is produced, bundled, and excluded from desktop packaging.
@@ -383,14 +374,13 @@ install.
Use `npm run cli` to run the in-repo CLI from source (`npx tsx packages/cli/src/index.ts`). The script wraps the CLI with `scripts/dev-home.sh`, so it automatically uses this checkout's `.dev/paseo-home` and dev daemon endpoint unless you pass an explicit override. The globally installed `paseo` binary on macOS is a symlink into the installed Paseo desktop app, not this checkout — use it to drive the desktop's built-in daemon, but use `npm run cli` when you want to talk to the CLI you are editing.
Canonical automation uses `paseo workspace create/ls/archive`, `paseo heartbeat create/update/delete`, and the full `paseo schedule` group. MCP heartbeat automation is intentionally smaller: create and delete only. Detach remains an explicit user lifecycle action rather than an agent tool. `paseo run --new-workspace local|worktree` composes workspace creation with agent creation. The old `paseo worktree` and `paseo run --worktree` forms are hidden compatibility aliases.
Canonical automation uses `paseo workspace create/ls/archive`, `paseo heartbeat create/update/delete`, and the full `paseo schedule` group. MCP heartbeat automation is intentionally smaller: create and delete only. Detach remains an explicit user lifecycle action rather than an agent tool. `paseo run --isolation local|worktree` composes workspace creation with agent creation. The old `paseo worktree` and `paseo run --worktree` forms are hidden compatibility aliases.
```bash
npm run cli -- ls -a -g # List all agents globally
npm run cli -- ls -a -g --json # Same, as JSON
npm run cli -- inspect <id> # Show detailed agent info
npm run cli -- logs <id> # View agent timeline
npm run cli -- agent open <id> # Focus an existing agent in Paseo Desktop
npm run cli -- daemon status # Check daemon status
npm run cli -- clone owner/repo --dir ~/workspace # Clone GitHub repo and register project
```
@@ -401,11 +391,6 @@ Use `--host <host:port>` to point the CLI at a different daemon:
npm run cli -- --host localhost:7777 ls -a
```
Desktop integrations can focus an existing agent without creating one or
sending a message. Use `paseo://h/<server-id>/agent/<agent-id>`, or run
`paseo agent open <agent-id>`. The CLI reads the local daemon's server ID by
default; pass `--server <server-id>` when targeting another server.
## Agent state
Agent data lives at:
-14
View File
@@ -73,20 +73,6 @@ only use local param fallback during cold mount (`/` or empty pathname), or a
hidden workspace can overwrite the remembered workspace before Settings or
History returns.
## Agent Targets
Notifications and agent URLs enter the router with different authoritative
targets.
- Notifications carry `serverId`, `workspaceId`, and `agentId`. Route them
directly to the workspace with the agent open intent.
- Agent URLs carry only `serverId` and `agentId`. Route them through
`/h/[serverId]/agent/[agentId]`; that route waits for the named host, resolves
the agent's workspace from the host, and then opens the agent there.
Both paths converge on `navigateToAgent()`. Do not make notification routing
guess a workspace, and do not add a workspace to the stable agent URL format.
## Params
Required dynamic params belong to the matched route.
+2 -15
View File
@@ -57,7 +57,7 @@ Android touches sailed straight through to the chat scroll view behind it.
Two escape hatches in the codebase:
- **`Modal`** (combobox, dropdown menu and tooltip on native) — opens a new Android window, so
- **`Modal`** (combobox, tooltip on native) — opens a new Android window, so
hit-testing starts fresh in that window. Side effect: a Modal opening on
Android can detach the IME from an underlying TextInput. Fine for combobox
(it has its own input) and tooltip (no input). **Not** fine for autocomplete
@@ -70,13 +70,7 @@ Two escape hatches in the codebase:
overlays can use the current `FloatingPanelPortalHost` so sliding sidebars
cover them.
Choose Modal vs Portal by whether the underlying input can lose its keyboard.
On web, dropdown menus render into the shared `overlay-root`, not React Native
Web's `<Modal>`/`<dialog>`. A browser top-layer dialog always paints above
ordinary portals regardless of `z-index`, which would hide app toasts and
tooltips behind the menu. The shared overlay scale keeps menus below toasts and
lets tooltip portals paint above both.
Choose Modal vs Portal by whether the underlying input can lose its keyboard.
## Gotcha 2 — Portal breaks lifecycle and coordinate-system inheritance
@@ -129,13 +123,6 @@ lockstep, no re-measurement needed. Do not call
can briefly report a stale nonzero height with closed progress, and the shared
provider is where that is normalized.
The provider also reconciles iOS from the controller's native `onEnd` event.
The controller's stock iOS shared values update at move start and during an
interactive move, but not at the terminal event, so JS contention can otherwise
leave the last height/progress pair stuck in either the open or closed state.
Keep that terminal reconciliation on the UI thread; a later focus or blur must
not be required to repair the offset.
Re-measure on `Keyboard.addListener('keyboardDidShow'|'keyboardDidHide')` only
to refresh the snapshot if the keyboard was mid-transition when the popover
opened.
+1 -3
View File
@@ -24,7 +24,7 @@ Paseo tools are not implemented as MCP tools internally. They live in a shared t
Pi is a process-backed provider. Paseo requires the user to have the `pi` binary installed and talks to it through `pi --mode rpc`; the server package does not embed Pi's SDK/runtime packages.
Paseo's per-agent and daemon-wide system prompts are appended by its generated Pi integration extension. Paseo deliberately does not pass `--append-system-prompt`, because that flag replaces Pi's automatic `APPEND_SYSTEM.md` discovery instead of composing with it.
Paseo's per-agent and daemon-wide system prompts are passed to Pi with `--append-system-prompt`, so Pi keeps its default coding prompt while receiving Paseo's additional instructions.
Pi model records expose input capabilities through `model.input`. Only send raw RPC `images` when the current model explicitly includes `"image"` in that list. Text-only Pi/OMP models reject image content and persist the rejected image in JSONL history, so image prompts for those models must be materialized to a local file and passed as a text path hint instead.
@@ -44,8 +44,6 @@ OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prom
Every provider adapter owns its canonical user-message timeline rows. When a foreground prompt is accepted, the adapter must emit exactly one `user_message` timeline item for that submitted prompt, using the same message ID it gives to or receives from the provider runtime. Optimistic client messages are UI-only and provider transcript echoes are optional; neither is allowed to be the only source of truth. If the provider later echoes the same submitted user message, dedupe it only within the active turn. Prefer provider-visible message IDs, but ACP runtimes may omit that ID or replace it with a provider-owned one; in that case suppress only echo chunks whose accumulated text is a prefix of the active submitted prompt. Do not perform global transcript text dedupe.
Submitted user-message rows preserve both identities: `messageId` is the provider-visible ID and the optional `clientMessageId` is the Paseo ID from `AgentRunOptions`. Attach `clientMessageId` only to the canonical row for that foreground submission; provider history and externally initiated user rows do not have a Paseo client ID.
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.fetchCatalog`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. `fetchCatalog` is the single discovery API for models and modes — provider implementations may use one process, separate upstream calls, or static data internally, but callers outside the provider do not get separate runtime model/mode probes. Draft feature and command listing must use the explicit draft model only; if no model is selected yet, return no metadata instead of resolving a default model through catalog discovery.
Provider session import has its own contract. The picker calls `listImportableSessions` and receives rows only: provider handle, cwd, title, prompt previews, and last activity. Import calls `importSession({ providerHandleId, cwd })` for the selected row and must not call listing again. The provider returns the resumed session, storage config, persistence handle, and hydrated timeline for that one native session; `AgentManager.importProviderSession` seeds the daemon timeline and publishes the Paseo agent only after it is ready.
+1 -1
View File
@@ -107,6 +107,6 @@ Codex also receives the Windows equivalent:
if defined PASEO_TERMINAL_ID (if defined PASEO_HOOK_CLI ("%PASEO_HOOK_CLI%" hooks codex <event>) else (paseo hooks codex <event>))
```
The daemon resolves the current CLI through `PASEO_CLI` when its launcher supplies one, or through the npm package shim for standalone installs. Terminal setup exposes that resolved executable to hooks as `PASEO_HOOK_CLI`; desktop and other daemon launchers do not know about the hook-specific variable. The generated command falls back to bare `paseo` if the hook env is missing and no-ops outside Paseo terminals because the `PASEO_TERMINAL_ID` gate remains first. Paseo also prepends the resolved CLI directory to each terminal `PATH` as a secondary fallback. All other behavior lives in `paseo hooks`: read the env, map the event, POST activity, and no-op/fail-open when anything is missing or unavailable.
Paseo injects `PASEO_HOOK_CLI` so Codex's hook shell cannot pick up a stale global `paseo` before the current one. The command still falls back to bare `paseo` if the env is missing, and it still no-ops outside Paseo terminals because the `PASEO_TERMINAL_ID` gate remains first. Paseo also prepends the CLI binary directory to each terminal `PATH` as a secondary fallback. All other behavior lives in `paseo hooks`: read the env, map the event, POST activity, and no-op/fail-open when anything is missing or unavailable.
If config installation fails, daemon startup and terminal spawn continue without terminal activity hooks.
-5
View File
@@ -32,11 +32,6 @@ Terminal frames share the daemon main event loop with all agent traffic. The `ev
- **Browser perf specs (user-perceived path):** gated behind `PASEO_TERMINAL_PERF_E2E=1` —
`packages/app/e2e/terminal-performance.spec.ts` and `packages/app/e2e/terminal-keystroke-stress.spec.ts` (per-stage keydown→xterm-commit breakdown under mock-agent load). Healthy: keydown→commit p50 ~18ms under 600-key burst.
- **Production:** grep `daemon.log` for `ws_runtime_metrics` and read `eventLoopDelay` + `bufferedAmount`.
- **Git pressure:** the same log line includes `git.commands` (limiter occupancy, queue age,
queue wait, execution time, failures, timeouts, and top operations),
`git.workspaceService` (daemon-global Git observer ownership), and per-session workspace Git
subscription totals under `runtime`. Queue wait and execution time are separate because the Git
command timeout begins only after a command acquires a limiter slot.
## Known remaining contention (follow-up candidates)
+9
View File
@@ -156,6 +156,15 @@ Vitest picks up tests by suffix. The suffix tells the runner which category it b
App-level Playwright browser E2E lives in `packages/app/e2e/*.spec.ts` and runs via `npm run test:e2e --workspace=@getpaseo/app` (separate from Vitest E2E). App Playwright specs that hit real providers use `*.real.spec.ts` and run through `npm run test:e2e:real --workspace=@getpaseo/app`; the default app E2E project ignores that suffix so CI does not need provider credentials.
Desktop import coverage starts from the real product Integrations row and crosses the real Electron
main process, compiled sandboxed preload, production import IPC, the installed import library, and a
real Desktop-managed daemon. It covers confirmation, streamed output, success, failure, and
ineligible-host UI:
```bash
npm run test:import-electron --workspace=@getpaseo/desktop
```
Live provider smoke tests belong in `*.real.e2e.test.ts`, not `*.test.ts`, even when guarded by environment variables. Default unit suites must use deterministic provider adapters/fakes so missing credits, auth outages, and upstream model drift do not block normal CI.
Codex MultiAgentV2 real tests use local Codex authentication rather than the OpenRouter-compatible test provider. OpenRouter does not accept Codex collaboration-history items on the parent follow-up request, so it cannot verify a complete native sub-agent turn.
+8 -21
View File
@@ -51,27 +51,17 @@ When a client resumes with a known cursor, it catches up after that cursor to co
When a client resumes without a cursor, it fetches the latest tail page.
## Client replica lifetime
The host runtime owns each session replica for as long as the host remains registered. React
providers attach message handlers and UI integrations to that replica, but mounting or unmounting a
provider must not create or clear it. A provider can remount during Fast Refresh or ordinary UI
recomposition while the runtime still owns the same directory snapshot and timeline cursors.
Removing the host from the registry is the destructive boundary: it stops the runtime and clears the
session and host-scoped setup state together.
## Selective and legacy delivery
The app chooses one delivery policy from `server_info.features.selectiveAgentTimeline`:
- Selective daemons receive the union of agents visible in every pane. Additions subscribe and
catch up immediately. Every visibility-driven removal, including app backgrounding, stays
subscribed for a 30-second grace period so brief tab, pane, route, and app switches do not repeatedly
subscribed for a short grace period so brief tab, pane, route, and app switches do not repeatedly
unsubscribe and catch up. Losing window keyboard focus does not make a selected pane invisible.
Disconnecting and disposal clear pending grace because the subscription itself no longer exists.
After grace has expired, revisiting a retained timeline displays its cached state immediately and
authoritative catch-up advances it to the current tail.
After grace has expired, a retained timeline stays covered when revisited until authoritative
catch-up completes; cached partial output is never presented as current history.
- Legacy daemons keep globally streaming agent timelines. Visibility still triggers the existing
authoritative catch-up, but the app does not issue selective-subscription RPCs.
@@ -87,14 +77,11 @@ its completion advances `seqEnd`, followed by a merged assistant message. The ap
remaining page through the existing stream reducer. It must not append full projected text to a
live prefix.
Optimistic user prompts occupy stable timeline slots. Catch-up never extracts, delays, or reinserts
them. A canonical user row replaces its matching slot in place; an unmatched prompt stays exactly
where the user submitted it. Other canonical rows are applied after the already-present timeline
instead of relocating visible user messages around newly fetched history.
Canonical submitted user rows carry the provider's `messageId` and Paseo's optional
`clientMessageId`. Clients reconcile optimistic prompts by `clientMessageId`. Content matching is
limited to the dated compatibility path for daemon timelines created before that field existed.
Optimistic user prompts are presentation state rather than canonical history. Incremental catch-up
temporarily separates them, applies canonical entries, lets canonical user rows reconcile through
the existing optimistic-message rules, then restores any unmatched prompts after the caught-up
history. This keeps late history before a newly submitted prompt without duplicating an
acknowledged prompt.
## Relevant code
+1 -1
View File
@@ -1 +1 @@
sha256-t7FqvPj7U5YidGH9OZ1kFnD2dPTYUyjcxBlzAJU7Mak=
sha256-MjAAaU+zU3m5fdE9rQrr4aA3eTDZztIXJQVXKbj73rY=
+5 -8
View File
@@ -5,7 +5,6 @@
nodejs_22,
python3,
makeWrapper,
autoPatchelfHook,
# node-pty needs libuv headers on Linux
libuv,
# Exposed so downstream flakes that follow a different nixpkgs revision
@@ -60,13 +59,10 @@ buildNpmPackage rec {
nativeBuildInputs = [
python3 # for node-gyp (node-pty compilation)
makeWrapper
] ++ lib.optionals stdenv.hostPlatform.isLinux [
autoPatchelfHook
];
buildInputs = lib.optionals stdenv.hostPlatform.isLinux [
libuv
stdenv.cc.cc.lib # libstdc++ for sherpa-onnx prebuilt binaries
];
# Don't use the default npm build hook — we need a custom build sequence
@@ -75,9 +71,10 @@ buildNpmPackage rec {
buildPhase = ''
runHook preBuild
# Rebuild only node-pty (native addon for terminal emulation). The sherpa
# speech runtime ships prebuilt platform packages and is copied into the
# daemon closure by scripts/trace-daemon.mjs.
# Rebuild only node-pty (native addon for terminal emulation).
# Speech-related native modules (sherpa-onnx, onnxruntime-node) are
# intentionally left unbuilt — they're lazily loaded and gracefully
# degrade when unavailable.
npm rebuild node-pty
# Build all server packages in dependency order (defined in package.json)
@@ -92,7 +89,7 @@ buildNpmPackage rec {
# Compute the daemon's runtime closure by static module-graph tracing
# (@vercel/nft from supervisor-entrypoint.js, cli/dist/index.js, and the
# forked terminal/speech worker processes) plus an explicit list of non-JS
# forked terminal-worker-process.js) plus an explicit list of non-JS
# assets read at runtime. The trace script is the single source of
# truth for what the daemon needs at $out — auditable in plain JS, no
# npm hoisting / .bin / workspace-symlink footguns.
+62 -88
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.2.1",
"version": "0.2.0-beta.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.2.1",
"version": "0.2.0-beta.1",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -2531,35 +2531,10 @@
"optional": true,
"peer": true
},
"node_modules/@codemirror/autocomplete": {
"version": "6.20.3",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
"integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.10.4",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.4.tgz",
"integrity": "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.7.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.12.4",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz",
"integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
"version": "6.12.3",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz",
"integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
@@ -2579,33 +2554,22 @@
"@codemirror/language": "^6.0.0"
}
},
"node_modules/@codemirror/search": {
"version": "6.7.1",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.1.tgz",
"integrity": "sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.37.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/state": {
"version": "6.7.1",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz",
"integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==",
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz",
"integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.43.6",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.6.tgz",
"integrity": "sha512-EVunGSYN1wz1p75WY1s3Xg7t3i8Yol0kGZGizNdX9BUFgMFILYVe8/u6EVpo7Ff5PwbZuILb4QAq7IZoKzIEQA==",
"version": "6.43.0",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.0.tgz",
"integrity": "sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.7.0",
"@codemirror/state": "^6.6.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
@@ -11091,19 +11055,6 @@
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@replit/codemirror-vim": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/@replit/codemirror-vim/-/codemirror-vim-6.3.0.tgz",
"integrity": "sha512-aTx931ULAMuJx6xLf7KQDOL7CxD+Sa05FktTDrtLaSy53uj01ll3Zf17JdKsriER248oS55GBzg0CfCTjEneAQ==",
"license": "MIT",
"peerDependencies": {
"@codemirror/commands": "6.x.x",
"@codemirror/language": "6.x.x",
"@codemirror/search": "6.x.x",
"@codemirror/state": "6.x.x",
"@codemirror/view": "6.x.x"
}
},
"node_modules/@rollup/pluginutils": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz",
@@ -31775,7 +31726,6 @@
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.0.tgz",
"integrity": "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">= 18"
@@ -31940,6 +31890,12 @@
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"license": "BSD-3-Clause"
},
"node_modules/sql.js": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.1.tgz",
"integrity": "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==",
"license": "MIT"
},
"node_modules/srvx": {
"version": "0.11.15",
"resolved": "https://registry.npmjs.org/srvx/-/srvx-0.11.15.tgz",
@@ -35211,13 +35167,8 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.2.1",
"version": "0.2.0-beta.1",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
"@codemirror/search": "6.7.1",
"@codemirror/state": "6.7.1",
"@codemirror/view": "6.43.6",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
@@ -35232,7 +35183,6 @@
"@react-native-masked-view/masked-view": "^0.3.2",
"@react-native/normalize-colors": "^0.81.5",
"@react-navigation/native": "^7.1.8",
"@replit/codemirror-vim": "6.3.0",
"@tanstack/react-query": "^5.90.11",
"@tanstack/react-virtual": "^3.13.21",
"@xterm/addon-clipboard": "^0.3.0-beta.213",
@@ -36236,12 +36186,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.2.1",
"version": "0.2.0-beta.1",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.2.1",
"@getpaseo/protocol": "0.2.1",
"@getpaseo/server": "0.2.1",
"@getpaseo/client": "0.2.0-beta.1",
"@getpaseo/protocol": "0.2.0-beta.1",
"@getpaseo/server": "0.2.0-beta.1",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -36487,24 +36437,27 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.2.1",
"version": "0.2.0-beta.1",
"dependencies": {
"@getpaseo/protocol": "0.2.1",
"@getpaseo/relay": "0.2.1",
"@getpaseo/protocol": "0.2.0-beta.1",
"@getpaseo/relay": "0.2.0-beta.1",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/node": "^20.9.0",
"@types/ws": "^8.5.14",
"typescript": "^5.2.2",
"vitest": "^4.1.6"
}
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.2.1",
"version": "0.2.0-beta.1",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
"@getpaseo/import": "0.1.0-beta.2",
"@getpaseo/server": "*",
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2",
@@ -36512,6 +36465,7 @@
"zod": "^4.4.3"
},
"devDependencies": {
"@playwright/test": "^1.56.1",
"@types/node": "24.6.0",
"@types/ws": "^8.5.14",
"electron": "41.2.0",
@@ -36522,6 +36476,26 @@
"wait-on": "8.0.5"
}
},
"packages/desktop/node_modules/@getpaseo/import": {
"version": "0.1.0-beta.2",
"resolved": "https://registry.npmjs.org/@getpaseo/import/-/import-0.1.0-beta.2.tgz",
"integrity": "sha512-1t+kw6gUQpaz30BLWIwnm252DpdyMANDGq7sRmb0fRz4Eetsz/QAefTRiYUkS/eKVvfDd3avH9HCIU/DzAXCgQ==",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/protocol": "0.2.0-beta.1",
"smol-toml": "^1.6.0",
"sql.js": "^1.14.1"
},
"bin": {
"paseo-import": "dist/bin.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@getpaseo/client": ">=0.2.0-beta.2"
}
},
"packages/desktop/node_modules/@types/node": {
"version": "24.6.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.6.0.tgz",
@@ -36744,7 +36718,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.2.1",
"version": "0.2.0-beta.1",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -37640,9 +37614,9 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.2.1",
"version": "0.2.0-beta.1",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/language": "^6.12.3",
"@codemirror/legacy-modes": "^6.5.3",
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
@@ -37872,7 +37846,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.2.1",
"version": "0.2.0-beta.1",
"dependencies": {
"zod": "^4.4.3"
},
@@ -37885,7 +37859,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.2.1",
"version": "0.2.0-beta.1",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38103,15 +38077,15 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.2.1",
"version": "0.2.0-beta.1",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.214",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.2.1",
"@getpaseo/highlight": "0.2.1",
"@getpaseo/protocol": "0.2.1",
"@getpaseo/relay": "0.2.1",
"@getpaseo/client": "0.2.0-beta.1",
"@getpaseo/highlight": "0.2.0-beta.1",
"@getpaseo/protocol": "0.2.0-beta.1",
"@getpaseo/relay": "0.2.0-beta.1",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -38648,7 +38622,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.2.1",
"version": "0.2.0-beta.1",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
+1 -3
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.2.1",
"version": "0.2.0-beta.1",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -132,8 +132,6 @@
"ws": "^8.20.0"
},
"overrides": {
"@codemirror/language": "6.12.4",
"@codemirror/view": "6.43.6",
"lightningcss": "1.30.1",
"react": "19.1.0",
"react-dom": "19.1.0",
@@ -1,49 +0,0 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { test, expect, type Page } from "./fixtures";
import { seedMockAgentWorkspace, openAgentRoute } from "./helpers/mock-agent";
function visibleComposer(page: Page) {
return page.locator("textarea[data-composer-input]").filter({ visible: true }).first();
}
test("adds a changed file to the focused chat without replacing its composer draft", async ({
page,
}) => {
const workspace = await seedMockAgentWorkspace({
repoPrefix: "add-file-to-chat-",
title: "Target chat",
});
const relativePath = "src/changed file.ts";
try {
await mkdir(path.join(workspace.cwd, "src"), { recursive: true });
await writeFile(path.join(workspace.cwd, relativePath), "export const changed = true;\n");
await workspace.client.checkoutRefresh(workspace.cwd);
await page.setViewportSize({ width: 1400, height: 900 });
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
});
const agentComposer = visibleComposer(page);
await expect(agentComposer).toBeEditable({ timeout: 30_000 });
await agentComposer.fill("Preserve this thought");
await page.getByRole("button", { name: "Open explorer" }).click();
await page.getByTestId("explorer-tab-changes").click();
const changedFile = page.getByText("changed file.ts", { exact: true }).first();
await expect(changedFile).toBeVisible({ timeout: 30_000 });
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
await page.getByTestId("diff-file-0-add-to-chat").click();
const attachment = page.getByTestId("composer-workspace-file-attachment-pill");
await expect(attachment).toContainText("changed file.ts");
await expect(attachment).toContainText(relativePath);
await expect(agentComposer).toHaveValue("Preserve this thought");
await expect(agentComposer).toBeFocused();
} finally {
await workspace.cleanup();
}
});
@@ -1,115 +0,0 @@
import { mkdtempSync, realpathSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { test, expect } from "./fixtures";
import { openSessions } from "./helpers/archive-tab";
import {
assertChatTranscript,
cleanupRewindFlow,
launchAgent,
sendMessage,
type AgentHandle,
} from "./helpers/rewind-flow";
import type { SeedDaemonClient } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
interface TimelineClient extends SeedDaemonClient {
fetchAgentTimeline(
agentId: string,
options: { direction: "tail"; projection: "projected"; limit: number },
): Promise<unknown>;
}
const INITIAL_PROMPT = "Reply with exactly CODEX_ARCHIVE_TIMELINE_SENTINEL and nothing else.";
const INITIAL_REPLY = "CODEX_ARCHIVE_TIMELINE_SENTINEL";
const FOLLOW_UP_PROMPT = "Reply with exactly CODEX_UNARCHIVED_SENTINEL and nothing else.";
const FOLLOW_UP_REPLY = "CODEX_UNARCHIVED_SENTINEL";
async function historyContainsAgent(client: SeedDaemonClient, agentId: string): Promise<boolean> {
const history = await client.fetchAgentHistory({ page: { limit: 200 } });
return history.entries.some((entry) => entry.agent.id === agentId);
}
test.describe("archived Codex agent recovery", () => {
test.setTimeout(600_000);
test("cold-opens without provider history, then unarchives and restores the conversation", async ({
page,
}) => {
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-archived-codex-")));
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({
page,
provider: "codex",
cwd,
mode: "full-access",
});
await sendMessage(handle, INITIAL_PROMPT);
await assertChatTranscript(handle, [
{ role: "user", text: INITIAL_PROMPT },
{ role: "assistant", text: INITIAL_REPLY },
]);
await handle.client.archiveAgent(handle.agentId);
await expect
.poll(
async () =>
(await handle?.client.fetchAgent({ agentId: handle.agentId }))?.agent.archivedAt ??
null,
{ timeout: 30_000 },
)
.not.toBeNull();
const timelineClient = handle.client as TimelineClient;
await expect(
timelineClient.fetchAgentTimeline(handle.agentId, {
direction: "tail",
projection: "projected",
limit: 100,
}),
).rejects.toThrow(/archiv/i);
await expect
.poll(async () => (handle ? historyContainsAgent(handle.client, handle.agentId) : false), {
timeout: 30_000,
})
.toBe(true);
await page.reload();
await waitForSidebarHydration(page);
await openSessions(page);
await page.getByTestId(`agent-row-${getServerId()}-${handle.agentId}`).click();
await expect(
page.getByTestId(`workspace-tab-agent_${handle.agentId}`).filter({ visible: true }).first(),
).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("This agent is archived", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(page.getByTestId("agent-load-error")).toHaveCount(0);
await expect(page.getByTestId("agent-timeline-sync-error")).toHaveCount(0);
await expect(page.getByTestId("user-message")).toHaveCount(0);
await page.getByRole("button", { name: "Unarchive" }).click();
await expect(page.getByRole("button", { name: "Unarchive" })).toHaveCount(0, {
timeout: 60_000,
});
await assertChatTranscript(handle, [
{ role: "user", text: INITIAL_PROMPT },
{ role: "assistant", text: INITIAL_REPLY },
]);
await sendMessage(handle, FOLLOW_UP_PROMPT);
await assertChatTranscript(handle, [
{ role: "user", text: INITIAL_PROMPT },
{ role: "assistant", text: INITIAL_REPLY },
{ role: "user", text: FOLLOW_UP_PROMPT },
{ role: "assistant", text: FOLLOW_UP_REPLY },
]);
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
});
@@ -123,16 +123,4 @@ test.describe("mobile bottom sheet reopen", () => {
await openAndCloseModelSelectorTwice(page);
});
});
test("model selector closes after model selection", async ({ page }) => {
await withMobileMockAgent(page, async () => {
await openModelSelector(page);
const sheet = page.getByLabel("Bottom Sheet", { exact: true });
await sheet.getByText("Ten second stream", { exact: true }).click();
await expect(sheet).not.toBeVisible({ timeout: 10_000 });
await expect(page.getByRole("button", { name: /Ten second stream/ })).toBeVisible();
});
});
});
+12 -26
View File
@@ -5,25 +5,6 @@ import { test, expect } from "./fixtures";
const COMMIT_SUBJECT = "Show commit timestamps";
test("commit history explains when the workspace has no commits ahead of its base", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace({ prefix: "commit-history-empty-workspace-" });
execFileSync("git", ["checkout", "-b", "feature"], { cwd: workspace.repoPath, stdio: "ignore" });
await workspace.navigateTo();
await page.getByRole("button", { name: "Open explorer" }).click();
const commitsSection = page.getByRole("button", { name: /Commits/i });
await expect(commitsSection).toBeVisible({ timeout: 30_000 });
await commitsSection.click();
await expect(page.getByTestId("commits-section-no-workspace-commits")).toHaveText(
"No commits ahead of main yet",
{ timeout: 30_000 },
);
});
test("commit history shows dates and shares diff layout preferences", async ({
page,
withWorkspace,
@@ -42,26 +23,31 @@ test("commit history shows dates and shares diff layout preferences", async ({
hasText: COMMIT_SUBJECT,
});
await expect(commitRow).toContainText(COMMIT_SUBJECT, { timeout: 30_000 });
await expect(page.locator('[data-testid^="commit-row-"]')).toHaveCount(1);
await expect(commitRow).toContainText("Jan 15");
await commitRow.click();
const panel = page.getByTestId("commit-diff-panel").filter({ visible: true });
await expect(panel.getByTestId("commit-diff-toolbar")).toBeVisible({ timeout: 30_000 });
const layoutToggle = panel.getByTestId("commit-diff-toggle-layout");
await expect(layoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
await expect(panel.getByTestId("commit-diff-layout-unified")).toHaveAttribute(
"aria-selected",
"true",
);
await expect(panel.getByTestId("diff-code-row-0")).toBeVisible({ timeout: 30_000 });
await layoutToggle.click();
await expect(layoutToggle).toHaveAccessibleName("Switch to unified diff");
await panel.getByTestId("commit-diff-layout-split").click();
await expect(panel.getByTestId("commit-diff-layout-split")).toHaveAttribute(
"aria-selected",
"true",
);
await expect(panel.getByTestId("diff-code-row-0")).toHaveCount(0);
await expect(panel.getByTestId("diff-file-0-body")).toBeVisible();
await page.getByTestId(/^workspace-commit-diff-close-/).click();
await expect(panel).toHaveCount(0);
await commitRow.click();
await expect(panel.getByTestId("commit-diff-toggle-layout")).toHaveAccessibleName(
"Switch to unified diff",
await expect(panel.getByTestId("commit-diff-layout-split")).toHaveAttribute(
"aria-selected",
"true",
{ timeout: 30_000 },
);
@@ -0,0 +1,174 @@
import { execFileSync } from "node:child_process";
import {
cpSync,
mkdirSync,
mkdtempSync,
readFileSync,
realpathSync,
rmSync,
writeFileSync,
} from "node:fs";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import {
_electron as electron,
expect,
test,
type ElectronApplication,
type Page,
} from "@playwright/test";
test.skip(process.env.E2E_DESKTOP_RUNTIME !== "1", "requires the real Electron product runtime");
test.setTimeout(180_000);
const repoRoot = path.resolve(__dirname, "../../..");
const nodeRequire = createRequire(path.join(repoRoot, "packages", "desktop", "package.json"));
const importPackageRoot = path.resolve(path.dirname(nodeRequire.resolve("@getpaseo/import")), "..");
let installation: Awaited<ReturnType<typeof createConductorInstallation>>;
let electronApp: ElectronApplication;
test.beforeAll(async () => {
installation = await createConductorInstallation();
});
test.afterAll(async () => {
await electronApp?.close();
rmSync(installation.root, { recursive: true, force: true });
});
test("imports from the product Integrations row and renders success, failure, and eligibility", async () => {
electronApp = await launchProduct();
const page = await electronApp.firstWindow();
await openIntegrations(page);
await expect(page.getByRole("button", { name: "Enable built-in daemon" })).toHaveCount(0);
await openImport(page);
await expect(
page.getByText("Paseo will register valid repositories", { exact: false }),
).toBeVisible();
await page.getByTestId("import-confirm").click();
await expect(page.getByTestId("import-result")).toHaveText("Import complete.");
await expect(page.getByTestId("import-output")).toContainText(
`Registered project ${installation.repo}.`,
);
await expect(page.getByTestId("import-output")).toContainText("Import summary:");
await page.getByTestId("import-done").click();
writeFileSync(installation.databasePath, "not a sqlite database");
await openImport(page);
await page.getByTestId("import-confirm").click();
await expect(page.getByTestId("import-result")).toHaveText("Import failed.");
await expect(page.getByTestId("import-output")).toContainText("ERROR:");
await page.getByTestId("import-done").click();
await stopImportHost();
await page.getByRole("button", { name: "General", exact: true }).click();
await page.getByRole("button", { name: "Integrations", exact: true }).click();
const row = page.getByTestId("conductor-import-row");
await expect(row).toContainText("Start the Desktop-managed host before importing.");
await expect(row.getByRole("button", { name: "Import", exact: true })).toBeDisabled();
});
async function launchProduct(): Promise<ElectronApplication> {
const metroPort = requiredEnvironment("E2E_METRO_PORT");
return electron.launch({
args: [path.join(repoRoot, "packages", "desktop", "dist", "main.js")],
env: {
...process.env,
EXPO_DEV_URL: `http://127.0.0.1:${metroPort}`,
HOME: installation.home,
PASEO_HOME: requiredEnvironment("E2E_PASEO_HOME"),
PASEO_DISABLE_SINGLE_INSTANCE_LOCK: "1",
PASEO_TEST_APP_NAME: "Paseo Import Behavior",
},
});
}
async function openIntegrations(page: Page): Promise<void> {
const settings = page.getByRole("button", { name: "Settings", exact: true });
await expect(settings).toBeVisible({ timeout: 90_000 });
await settings.click();
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
await page.getByRole("button", { name: "Integrations", exact: true }).click();
await expect(page.getByTestId("conductor-import-row")).toBeVisible();
}
async function openImport(page: Page): Promise<void> {
const button = page
.getByTestId("conductor-import-row")
.getByRole("button", { name: "Import", exact: true });
await expect(button).toBeEnabled();
await button.click();
await expect(page.getByTestId("import-sheet")).toBeVisible();
}
async function createConductorInstallation(): Promise<{
root: string;
home: string;
repo: string;
databasePath: string;
}> {
const root = realpathSync(mkdtempSync(path.join(os.tmpdir(), "paseo-product-import-")));
const home = path.join(root, "home");
const repo = path.join(root, "repo");
mkdirSync(home, { recursive: true });
execFileSync("git", ["init", "-b", "main", repo]);
execFileSync("git", ["config", "user.email", "fixture@example.com"], { cwd: repo });
execFileSync("git", ["config", "user.name", "Fixture"], { cwd: repo });
writeFileSync(path.join(repo, "README.md"), "fixture\n");
execFileSync("git", ["add", "."], { cwd: repo });
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "fixture"], {
cwd: repo,
});
const databaseDirectory = path.join(home, "Library", "Application Support", "com.conductor.app");
mkdirSync(databaseDirectory, { recursive: true });
const databasePath = path.join(databaseDirectory, "conductor.db");
cpSync(path.join(importPackageRoot, "fixtures", "conductor", "conductor.db"), databasePath);
execFileSync(
process.execPath,
[
"-e",
`const fs = require("node:fs");
const initSqlJs = require("sql.js");
(async () => {
const SQL = await initSqlJs({ locateFile: () => require.resolve("sql.js/dist/sql-wasm.wasm") });
const databasePath = process.argv[1];
const repo = process.argv[2];
const database = new SQL.Database(fs.readFileSync(databasePath));
database.run("UPDATE repos SET root_path = ? WHERE id = 'repo-current'", [repo]);
database.run("UPDATE repos SET is_hidden = 1 WHERE id <> 'repo-current'");
database.run("DELETE FROM workspaces");
fs.writeFileSync(databasePath, database.export());
database.close();
})().catch((error) => { console.error(error); process.exitCode = 1; });`,
databasePath,
repo,
],
{ cwd: repoRoot },
);
return { root, home, repo: realpathSync(repo), databasePath };
}
async function stopImportHost(): Promise<void> {
const pidFile = path.join(requiredEnvironment("E2E_PASEO_HOME"), "paseo.pid");
const pid = (JSON.parse(readFileSync(pidFile, "utf8")) as { pid: number }).pid;
process.kill(pid, "SIGTERM");
const deadline = Date.now() + 10_000;
while (Date.now() < deadline) {
try {
process.kill(pid, 0);
await new Promise((resolve) => setTimeout(resolve, 100));
} catch {
return;
}
}
throw new Error(`Import host ${pid} did not stop.`);
}
function requiredEnvironment(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`${name} is required.`);
return value;
}
+6 -178
View File
@@ -1,4 +1,4 @@
import { unlink, writeFile } from "node:fs/promises";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { type Page } from "@playwright/test";
import { buildHostWorkspaceRoute, buildSettingsSectionRoute } from "../src/utils/host-routes";
@@ -10,11 +10,6 @@ import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
interface DirtyWorkspace {
id: string;
repoPath: string;
}
interface WorkspaceFixtureOptions {
includeDeletedFile?: boolean;
}
interface CleanupTask {
@@ -223,99 +218,6 @@ test("changes diff keeps code rows aligned with the gutter", async ({ page }) =>
});
});
test("changes file actions open from the kebab and right-click", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true });
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await expect(page.getByTestId("diff-file-1")).toContainText("zz-deleted.ts");
await page.getByTestId("diff-file-1-actions").click();
await expect(page.getByText("Copy path")).toBeVisible();
await expect(page.getByTestId("diff-file-1-open-file")).toHaveCount(0);
await page.keyboard.press("Escape");
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
await expect(page.getByTestId("diff-file-0-open-file")).toBeVisible();
await page.getByTestId("diff-file-0-open-file").click();
await expect(page.getByTestId("workspace-file-pane")).toBeVisible();
await expect(page.getByTestId("workspace-tab-file_src/use-mounted-tab-set.ts")).toBeVisible();
});
test("Changes switches between inline and full-tab navigation", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true });
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
const changesTabToggle = page.getByTestId("changes-open-tab");
await expect(changesTabToggle).toHaveAccessibleName("Open Changes tab");
await changesTabToggle.click();
await expect(changesTabToggle).toHaveAccessibleName("Close Changes tab");
const visiblePanel = page.getByTestId("working-diff-panel").filter({ visible: true });
await expect(visiblePanel).toBeVisible();
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible();
await expect(visiblePanel).toContainText("zz-deleted.ts");
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
await expect(page.getByTestId("workspace-file-pane")).toHaveCount(0);
await visiblePanel.getByTestId("diff-file-0-toggle").click();
await expect(visiblePanel.getByTestId("diff-file-0-body")).toHaveCount(0);
await visiblePanel.getByTestId("diff-file-0-toggle").click();
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
const workingDiffLayoutToggle = visiblePanel.getByTestId("working-diff-toggle-layout");
await expect(workingDiffLayoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
await workingDiffLayoutToggle.click();
await expect(workingDiffLayoutToggle).toHaveAccessibleName("Switch to unified diff");
await visiblePanel.getByTestId("working-diff-options-menu").click();
await expect(page.getByTestId("working-diff-toggle-whitespace")).toContainText("Hide whitespace");
await expect(page.getByTestId("working-diff-toggle-wrap-lines")).toContainText("Wrap long lines");
await expect(page.getByTestId("working-diff-refresh")).toContainText("Refresh");
await page.getByTestId("working-diff-toggle-wrap-lines").click();
await visiblePanel.getByTestId("working-diff-options-menu").click();
await expect(page.getByTestId("working-diff-toggle-wrap-lines")).toContainText(
"Scroll long lines",
);
await page.keyboard.press("Escape");
await visiblePanel.getByTestId("working-diff-toggle-expand-all").click();
await expect(visiblePanel.getByTestId(/^diff-file-\d+-body$/)).toHaveCount(0);
await visiblePanel.getByTestId("working-diff-toggle-expand-all").click();
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
await page.getByTestId("explorer-content-area").getByTestId("diff-file-0-toggle").click();
await expect(
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
).toHaveCount(0);
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE);
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toHaveCount(0, {
timeout: 30_000,
});
await expect(visiblePanel).toContainText("zz-deleted.ts");
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), AFTER);
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(page.getByTestId("explorer-content-area").getByTestId("diff-file-1")).toContainText(
"zz-deleted.ts",
);
await page.getByTestId("explorer-content-area").getByTestId("diff-file-1-toggle").click();
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
await expect(visiblePanel.getByText("zz-deleted.ts", { exact: true })).toBeVisible();
await expect(visiblePanel).toContainText("Deleted");
await changesTabToggle.click();
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(0);
await expect(
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
).toBeVisible();
await page.getByTestId("explorer-content-area").getByTestId("diff-file-0-toggle").click();
await expect(
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
).toHaveCount(0);
});
test("changes diff switches between flat and tree file lists", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useUnwrappedDiffLines(page);
@@ -341,11 +243,6 @@ test("changes diff switches between flat and tree file lists", async ({ page })
await expect(page.getByTestId("diff-folder-src")).toBeVisible();
await expect(page.getByTestId("diff-file-0")).toBeVisible();
await page.getByRole("button", { name: "Collapse all" }).click();
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
await page.getByRole("button", { name: "Expand all" }).click();
await expect(page.getByTestId("diff-file-0-body")).toBeVisible();
await page.getByTestId("diff-folder-src-toggle").click();
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
@@ -353,53 +250,6 @@ test("changes diff switches between flat and tree file lists", async ({ page })
await expectFlatFileList(page);
});
test("workspace file panes keep their controls on shared alignment rails", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await openWorkspaceChanges(page, workspace);
await page.getByTestId("changes-toggle-view-mode").click();
await expect(page.getByTestId("diff-folder-src")).toBeVisible();
const changesRightRail = await Promise.all([
readSvgRight(page, "explorer-close"),
readSvgRight(page, "changes-options-menu"),
readSvgRight(page, "diff-file-0-actions"),
]);
expectAligned(changesRightRail);
const [folderStat, fileStat] = await Promise.all([
page.getByTestId("diff-folder-src-stat").boundingBox(),
page.getByTestId("diff-file-0-stat").boundingBox(),
]);
expect(folderStat).not.toBeNull();
expect(fileStat).not.toBeNull();
expect(folderStat!.x + folderStat!.width).toBeCloseTo(fileStat!.x + fileStat!.width, 0);
await page.getByTestId("explorer-tab-files").click();
await expect(page.getByTestId("file-explorer-row-0")).toBeVisible();
const filesRightRail = await Promise.all([
readSvgRight(page, "explorer-close"),
readSvgRight(page, "files-refresh"),
readSvgRight(page, "file-explorer-row-0-actions"),
]);
expectAligned(filesRightRail);
const [sortLabel, firstRowIcon, treeBounds, rowBounds] = await Promise.all([
page.getByTestId("files-sort-label").boundingBox(),
page.getByTestId("file-explorer-row-0").locator("svg").first().boundingBox(),
page.getByTestId("file-explorer-tree-scroll").boundingBox(),
page.getByTestId("file-explorer-row-0").boundingBox(),
]);
expect(sortLabel).not.toBeNull();
expect(firstRowIcon).not.toBeNull();
expect(treeBounds).not.toBeNull();
expect(rowBounds).not.toBeNull();
expect(sortLabel!.x).toBeCloseTo(firstRowIcon!.x, 0);
expect(rowBounds!.x).toBeCloseTo(treeBounds!.x, 0);
expect(rowBounds!.x + rowBounds!.width).toBeCloseTo(treeBounds!.x + treeBounds!.width, 0);
});
test("changes diff keeps unwrapped gutter and code rows aligned after code size changes", async ({
page,
}) => {
@@ -549,14 +399,10 @@ async function readVisibleDiffRowGeometry(page: Page): Promise<{
});
}
async function createWorkspaceWithMountedTabDiff(
options: WorkspaceFixtureOptions = {},
): Promise<DirtyWorkspace> {
const files = [{ path: "src/use-mounted-tab-set.ts", content: BEFORE }];
if (options.includeDeletedFile) {
files.push({ path: "src/zz-deleted.ts", content: "export const deleted = true;\n" });
}
const repo = await createTempGitRepo("diff-row-alignment-", { files });
async function createWorkspaceWithMountedTabDiff(): Promise<DirtyWorkspace> {
const repo = await createTempGitRepo("diff-row-alignment-", {
files: [{ path: "src/use-mounted-tab-set.ts", content: BEFORE }],
});
const client = await connectSeedClient();
cleanupTasks.push({
run: async () => {
@@ -566,16 +412,13 @@ async function createWorkspaceWithMountedTabDiff(
});
await writeFile(path.join(repo.path, "src/use-mounted-tab-set.ts"), AFTER);
if (options.includeDeletedFile) {
await unlink(path.join(repo.path, "src/zz-deleted.ts"));
}
const createdWorkspace = await client.createWorkspace({
source: { kind: "directory", path: repo.path },
});
if (!createdWorkspace.workspace) {
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${repo.path}`);
}
return { id: createdWorkspace.workspace.id, repoPath: repo.path };
return { id: createdWorkspace.workspace.id };
}
async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
@@ -593,21 +436,6 @@ async function openChangesInVisibleExplorer(page: Page): Promise<void> {
await expect(page.getByText("use-mounted-tab-set.ts")).toBeVisible({ timeout: 30_000 });
}
async function readSvgRight(page: Page, testID: string): Promise<number> {
const box = await page.getByTestId(testID).locator("svg").first().boundingBox();
if (!box) {
throw new Error(`Could not measure ${testID}`);
}
return box.x + box.width;
}
function expectAligned(values: number[]): void {
const [first, ...rest] = values;
for (const value of rest) {
expect(value).toBeCloseTo(first, 0);
}
}
async function expectExpandedMountedTabDiff(page: Page): Promise<void> {
await expect(page.getByTestId("diff-file-0-body")).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("function createInitialMountedTabIds")).toBeVisible({
-356
View File
@@ -1,356 +0,0 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "./fixtures";
import { openFileExplorer, openFileFromExplorer, expectFileTabOpen } from "./helpers/file-explorer";
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
const RED_PIXEL = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII=",
"base64",
);
const BLUE_PIXEL = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"base64",
);
function editor(page: Page) {
return page.getByTestId("file-source-editor").filter({ visible: true }).locator(".cm-content");
}
async function replaceEditorText(page: Page, content: string): Promise<void> {
const contentElement = editor(page);
await contentElement.click();
await contentElement.press("Control+A");
await contentElement.type(content);
}
async function openWorkspaceFile(page: Page, filename: string): Promise<void> {
const tree = page.getByTestId("file-explorer-tree-scroll");
if (!(await tree.isVisible())) await openFileExplorer(page);
await openFileFromExplorer(page, filename);
await expectFileTabOpen(page, filename);
}
async function seedAgentWithFileLink(target: string) {
const session = await seedMockAgentWorkspace({
repoPrefix: "file-editing-chat-link-",
title: "Chat file link e2e",
initialPrompt: [
"Generate a title and a git branch name for a coding agent from the user prompt and attachments.",
"Return JSON only with fields 'title' and 'branch'.",
"",
"<user-prompt>",
`Open \`${target}\` now`,
"</user-prompt>",
].join("\n"),
});
await writeFile(
path.join(session.cwd, "target.ts"),
Array.from({ length: 80 }, (_, index) => `export const line${index + 1} = ${index + 1};`).join(
"\n",
),
"utf8",
);
return session;
}
test.describe("CodeMirror workspace file editing", () => {
test("opens an assistant file link at its referenced line", async ({ page }) => {
const target = "target.ts:42";
const session = await seedAgentWithFileLink(target);
try {
await openAgentRoute(page, session);
const fileLink = page.getByText(target, { exact: true });
await expect(fileLink).toBeVisible({ timeout: 15_000 });
await fileLink.click();
await expectFileTabOpen(page, "target.ts");
await expect(page.getByTestId("file-source-editor")).toBeVisible();
await expect(page.getByLabel("Line 42, column 1")).toBeVisible();
await expect(
page.getByTestId("file-source-editor").locator(".cm-line", { hasText: "line42 = 42" }),
).toBeVisible();
const sourceEditor = editor(page);
await sourceEditor.click();
await sourceEditor.press("Control+Home");
await expect(page.getByLabel(/^Line 1, column \d+$/)).toBeVisible();
await page
.getByTestId(`workspace-tab-agent_${session.agentId}`)
.filter({ visible: true })
.click();
await expect(fileLink).toBeVisible();
await fileLink.click();
await expect(page.getByLabel("Line 42, column 1")).toBeVisible();
await expect(
page.getByTestId("file-source-editor").locator(".cm-line", { hasText: "line42 = 42" }),
).toBeVisible();
} finally {
await session.cleanup();
}
});
test("shows the full file path and keeps editor controls stable", async ({
page,
withWorkspace,
}) => {
await page.emulateMedia({ colorScheme: "dark" });
const workspace = await withWorkspace({ prefix: "file-editing-visuals-" });
const relativePath = "src/deep/visuals.md";
const sourcePath = path.join(workspace.repoPath, relativePath);
await mkdir(path.dirname(sourcePath), { recursive: true });
await writeFile(
sourcePath,
[...Array.from({ length: 11 }, (_, index) => `line ${index + 1}`), "abcdefghijklmnop"].join(
"\n",
),
"utf8",
);
await workspace.navigateTo();
await openFileExplorer(page);
await page.getByTestId("file-explorer-tree-scroll").getByText("src", { exact: true }).click();
await page.getByTestId("file-explorer-tree-scroll").getByText("deep", { exact: true }).click();
await openFileFromExplorer(page, "visuals.md");
await expectFileTabOpen(page, relativePath);
const fileTab = page.getByTestId(`workspace-tab-file_${relativePath}`).first();
await fileTab.hover();
await expect(page.getByTestId(`workspace-tab-tooltip-file_${relativePath}`)).toHaveText(
relativePath,
);
await expect(page.getByTestId("file-panel-bar")).not.toContainText("visuals.md");
const modeControl = page.getByTestId("file-markdown-mode");
await expect(modeControl).toBeVisible();
await page.getByTestId("file-mode-source").click();
const editorHost = page.getByTestId("file-source-editor");
const content = editor(page);
await expect(editorHost).toHaveAttribute("data-pmono", "");
await expect(content).toHaveCSS("font-family", /SFMono-Regular/);
await content.click();
const cursor = editorHost.locator(".cm-cursor-primary");
await expect(cursor).toBeVisible();
await expect(cursor).toHaveCSS("border-left-color", "rgb(250, 250, 250)");
const initialModeBox = await modeControl.boundingBox();
expect(initialModeBox).not.toBeNull();
const initialModeX = initialModeBox!.x;
await content.press("Control+End");
await expect(page.getByLabel(/Line 12, column \d+/)).toBeVisible();
const movedModeBox = await modeControl.boundingBox();
expect(movedModeBox).not.toBeNull();
expect(movedModeBox!.x).toBe(initialModeX);
await content.press("Control+a");
const selection = editorHost.locator(".cm-selectionBackground").first();
await expect(selection).toBeVisible();
await expect(selection).toHaveCSS("background-color", "rgba(255, 255, 255, 0.2)");
});
test("applies the interface font to portaled tooltips", async ({ page, withWorkspace }) => {
await page.addInitScript(() => {
localStorage.setItem("@paseo:app-settings", JSON.stringify({ uiFontFamily: "monospace" }));
});
const workspace = await withWorkspace({ prefix: "file-tooltip-font-" });
const relativePath = "tooltip-font.txt";
await writeFile(path.join(workspace.repoPath, relativePath), "tooltip font\n", "utf8");
await workspace.navigateTo();
await openFileExplorer(page);
await openFileFromExplorer(page, relativePath);
await expectFileTabOpen(page, relativePath);
await page.getByTestId(`workspace-tab-file_${relativePath}`).first().hover();
await expect(
page
.getByTestId(`workspace-tab-tooltip-file_${relativePath}`)
.getByText(relativePath, { exact: true }),
).toHaveCSS("font-family", "monospace");
});
test("autosaves, saves immediately, resolves conflicts, and restores live updates after reconnect", async ({
page,
withWorkspace,
}) => {
test.setTimeout(120_000);
const gate = await installDaemonWebSocketGate(page);
const workspace = await withWorkspace({ prefix: "file-editing-source-" });
const sourcePath = path.join(workspace.repoPath, "source.ts");
await writeFile(sourcePath, "const initial = 1;\n", "utf8");
await Promise.all(
["one.ts", "two.ts", "three.ts", "four.ts"].map((fileName) =>
writeFile(path.join(workspace.repoPath, fileName), `// ${fileName}\n`, "utf8"),
),
);
await workspace.navigateTo();
await openWorkspaceFile(page, "source.ts");
await expect(page.getByTestId("file-source-editor")).toBeVisible();
await expect(page.getByLabel(/File size/)).toBeVisible();
await expect(page.getByLabel(/lines/)).toBeVisible();
await replaceEditorText(page, "const autosaved = 2;\n");
await expect(page.getByTestId("workspace-tab-modified-file_source.ts")).toBeVisible();
await expect(page.getByLabel("Editor status dirty")).toBeVisible();
await expect(page.getByLabel("Editor status clean")).toBeVisible({ timeout: 5_000 });
await expect(page.getByTestId("workspace-tab-modified-file_source.ts")).not.toBeVisible();
await expect.poll(() => readFile(sourcePath, "utf8")).toBe("const autosaved = 2;\n");
await replaceEditorText(page, "const immediate = 3;\n");
await editor(page).press("Control+s");
await expect.poll(() => readFile(sourcePath, "utf8")).toBe("const immediate = 3;\n");
await writeFile(sourcePath, "const external = 4;\nconst line = 2;\n", "utf8");
await expect(editor(page)).toContainText("const external = 4;");
await expect(page.getByLabel("3 lines")).toBeVisible();
await replaceEditorText(page, "const localWins = 5;\n");
await writeFile(sourcePath, "const diskLoses = 6;\n", "utf8");
await expect(page.getByTestId("file-conflict-alert")).toBeVisible();
for (const fileName of ["one.ts", "two.ts", "three.ts", "four.ts"]) {
await openWorkspaceFile(page, fileName);
}
await page.getByTestId("workspace-tab-file_source.ts").filter({ visible: true }).click();
await expect(editor(page)).toContainText("const localWins = 5;");
await expect(page.getByTestId("file-conflict-alert")).toBeVisible();
await page.getByRole("button", { name: "Overwrite", exact: true }).click();
await expect.poll(() => readFile(sourcePath, "utf8")).toBe("const localWins = 5;\n");
await replaceEditorText(page, "const discarded = 7;\n");
await writeFile(sourcePath, "const diskWins = 8;\n", "utf8");
await expect(page.getByTestId("file-conflict-alert")).toBeVisible();
page.once("dialog", (dialog) => dialog.accept());
await page.getByRole("button", { name: "Reload", exact: true }).click();
await expect(editor(page)).toContainText("const diskWins = 8;");
const subscriptionCount = gate.getClientRequestCount("fs.file.subscribe.request");
await gate.drop();
gate.restore();
await expect
.poll(() => gate.getClientRequestCount("fs.file.subscribe.request"), { timeout: 30_000 })
.toBeGreaterThan(subscriptionCount);
await writeFile(sourcePath, "const afterReconnect = 9;\n", "utf8");
await expect(editor(page)).toContainText("const afterReconnect = 9;");
});
test("preserves a UTF-8 BOM and uses the first line separator after saving", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace({ prefix: "file-editing-encoding-" });
const sourcePath = path.join(workspace.repoPath, "windows.ts");
await writeFile(
sourcePath,
Buffer.from("\uFEFFconst initial = true;\r\nconst mixed = true;\n", "utf8"),
);
await workspace.navigateTo();
await openWorkspaceFile(page, "windows.ts");
await replaceEditorText(page, "const saved = true;\nconst normalized = true;\n");
await editor(page).press("Control+s");
const expected = Buffer.from(
"\uFEFFconst saved = true;\r\nconst normalized = true;\r\n",
"utf8",
).toString("hex");
await expect.poll(async () => (await readFile(sourcePath)).toString("hex")).toBe(expected);
});
test("warns before closing a panel with an unsaved draft", async ({ page, withWorkspace }) => {
const workspace = await withWorkspace({ prefix: "file-editing-draft-" });
const sourcePath = path.join(workspace.repoPath, "draft.ts");
await writeFile(sourcePath, "const initial = 1;\n", "utf8");
await workspace.navigateTo();
await openWorkspaceFile(page, "draft.ts");
await replaceEditorText(page, "const local = 2;\n");
await writeFile(sourcePath, "const external = 3;\n", "utf8");
await expect(page.getByTestId("file-conflict-alert")).toBeVisible();
await expect(page.getByTestId("workspace-tab-modified-file_draft.ts")).toBeVisible();
let closePrompt = "";
page.once("dialog", async (dialog) => {
closePrompt = dialog.message();
await dialog.dismiss();
});
await page
.getByTestId("workspace-tab-file_draft.ts")
.filter({ visible: true })
.first()
.click({ button: "right" });
await page
.getByTestId("workspace-tab-context-file_draft.ts-close")
.filter({ visible: true })
.click();
expect(closePrompt).toContain("Closing it will discard the draft.");
await expect(page.getByTestId("file-source-editor")).toBeVisible();
await expect(page.getByTestId("workspace-tab-modified-file_draft.ts")).toBeVisible();
});
test("refreshes Markdown and images while preserving Preview and Source behavior", async ({
page,
withWorkspace,
}) => {
test.setTimeout(90_000);
const workspace = await withWorkspace({ prefix: "file-editing-preview-" });
const markdownPath = path.join(workspace.repoPath, "notes.md");
const imagePath = path.join(workspace.repoPath, "pixel.png");
await writeFile(markdownPath, "# First heading\n", "utf8");
await writeFile(imagePath, RED_PIXEL);
await workspace.navigateTo();
await openWorkspaceFile(page, "notes.md");
await expect(page.getByText("First heading", { exact: true })).toBeVisible();
await expect(page.getByTestId("file-markdown-mode")).toBeVisible();
await writeFile(markdownPath, "# Updated heading\n", "utf8");
await expect(page.getByText("Updated heading", { exact: true })).toBeVisible();
await page.getByTestId("file-mode-source").click();
await expect(page.getByTestId("file-source-editor")).toBeVisible();
await replaceEditorText(page, "# Saved from source\n");
await expect.poll(() => readFile(markdownPath, "utf8")).toBe("# Saved from source\n");
await page.getByTestId("file-mode-preview").click();
await expect(page.getByText("Saved from source", { exact: true })).toBeVisible();
await openWorkspaceFile(page, "pixel.png");
const image = page.getByTestId("workspace-file-pane").locator("img");
await expect(image).toBeVisible();
const initialSource = await image.getAttribute("src");
await writeFile(imagePath, BLUE_PIXEL);
await expect.poll(() => image.getAttribute("src")).not.toBe(initialSource);
});
test("persists Vim keybindings and reports Vim mode with cursor position", async ({
page,
withWorkspace,
}) => {
test.setTimeout(90_000);
const workspace = await withWorkspace({ prefix: "file-editing-vim-" });
await writeFile(path.join(workspace.repoPath, "vim.ts"), "const vim = true;\n", "utf8");
await page.goto("/settings/editor");
const toggle = page.getByRole("switch", { name: "Vim keybindings" });
await expect(toggle).toBeVisible();
await toggle.click();
await expect(toggle).toBeChecked();
await page.reload();
await expect(page.getByRole("switch", { name: "Vim keybindings" })).toBeChecked();
await workspace.navigateTo();
await openWorkspaceFile(page, "vim.ts");
await expect(page.getByLabel("Vim mode NORMAL")).toBeVisible();
await expect(page.getByLabel("Line 1, column 1")).toBeVisible();
await editor(page).click();
await editor(page).press("i");
await expect(page.getByLabel("Vim mode INSERT")).toBeVisible();
await editor(page).press("Escape");
await expect(page.getByLabel("Vim mode NORMAL")).toBeVisible();
});
});
+3 -71
View File
@@ -328,12 +328,6 @@ interface PairingDaemonClient {
async function createFakeEditorBin(): Promise<string> {
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-editor-bin-"));
let realGhPath = "";
try {
realGhPath = execSync("which gh").toString().trim();
} catch {
// The local PR fixture below remains usable without a system gh binary.
}
const fakeEditorSource = `#!/usr/bin/env node
const fs = require("fs");
@@ -355,70 +349,6 @@ if (recordPath) {
await chmod(editorPath, 0o755);
}
const fakeGhPath = path.join(binDir, "gh");
const fakeGhSource = `#!/usr/bin/env node
const { spawnSync } = require("child_process");
const args = process.argv.slice(2);
const fixtureRemote = "https://github.com/paseo-e2e/local-fixture.git";
const origin = spawnSync("git", ["config", "--get", "remote.origin.url"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"]
}).stdout?.trim();
if (origin === fixtureRemote) {
const command = args.slice(0, 2).join(" ");
if (command === "auth status") process.exit(0);
if (command === "repo view") {
process.stdout.write(JSON.stringify({ owner: { login: "paseo-e2e" }, name: "local-fixture", parent: null }));
process.exit(0);
}
if (command === "issue list") {
process.stdout.write("[]");
process.exit(0);
}
if (command === "pr list" || command === "pr view") {
const pr = {
number: 1,
title: "Use pasted PR as start ref",
url: "https://github.com/paseo-e2e/local-fixture/pull/1",
state: "OPEN",
body: null,
labels: [],
baseRefName: "main",
headRefName: "pr-branch-1",
updatedAt: "2026-01-01T00:00:00Z"
};
process.stdout.write(JSON.stringify(command === "pr list" ? [pr] : pr));
process.exit(0);
}
if (command === "api graphql" && args.some((arg) => arg.includes("PullRequestCheckoutTarget"))) {
process.stdout.write(JSON.stringify({
data: { repository: { pullRequest: {
number: 1,
baseRefName: "main",
headRefName: "pr-branch-1",
isCrossRepository: false,
headRepositoryOwner: { login: "paseo-e2e" },
headRepository: {
sshUrl: "git@github.com:paseo-e2e/local-fixture.git",
url: fixtureRemote
}
} } }
}));
process.exit(0);
}
process.stderr.write("Unsupported local GitHub fixture command: " + args.join(" ") + "\\n");
process.exit(1);
}
const realGhPath = ${JSON.stringify(realGhPath)};
if (!realGhPath) process.exit(127);
const result = spawnSync(realGhPath, args, { stdio: "inherit" });
process.exit(result.status ?? 1);
`;
await writeFile(fakeGhPath, fakeGhSource);
await chmod(fakeGhPath, 0o755);
return binDir;
}
@@ -779,11 +709,13 @@ function startDaemon(args: DaemonSpawnArgs): ChildProcess {
PASEO_HOME: args.paseoHome,
PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath,
PASEO_SERVER_ID: "srv_e2e_test_daemon",
PASEO_LISTEN: `0.0.0.0:${args.port}`,
PASEO_LISTEN:
process.env.E2E_DESKTOP_RUNTIME === "1" ? `127.0.0.1:${args.port}` : `0.0.0.0:${args.port}`,
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
PASEO_NODE_ENV: "development",
NODE_ENV: "development",
...(process.env.E2E_DESKTOP_RUNTIME === "1" ? { PASEO_DESKTOP_MANAGED: "1" } : {}),
});
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
@@ -65,8 +65,6 @@ export interface DesktopBridgeConfig {
daemonListen?: string;
/** Keep start_desktop_daemon pending to hold the desktop startup blocker open. */
hangDaemonStart?: boolean;
/** Delay the desktop settings IPC response to exercise startup ordering. */
desktopSettingsDelayMs?: number;
/**
* Controls what dialog.ask returns when the daemon management confirm dialog
* fires. True = confirm (proceed with the action), false = cancel. Defaults to
@@ -103,7 +101,6 @@ declare global {
__capturedDialogCall: ConfirmDialogCall | undefined;
__capturedDialogOpenCalls: Array<Record<string, unknown> | undefined>;
__recordDesktopEditorOpen?: (input: DesktopEditorOpenRecord) => Promise<void>;
__desktopDaemonStartRequested?: boolean;
}
}
@@ -132,7 +129,6 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
let daemonRunning = true;
let currentPid: number | null = cfg.daemonPid ?? null;
let startCount = 0;
window.__desktopDaemonStartRequested = false;
function buildDaemonStatus() {
return {
@@ -149,7 +145,6 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
}
function startDesktopDaemon() {
window.__desktopDaemonStartRequested = true;
if (cfg.hangDaemonStart) {
return new Promise(() => undefined);
}
@@ -161,13 +156,6 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
return buildDaemonStatus();
}
async function waitForDesktopSettingsResponse() {
const delayMs = cfg.desktopSettingsDelayMs ?? 0;
if (delayMs > 0) {
await new Promise<void>((resolve) => setTimeout(resolve, delayMs));
}
}
const desktopBridge: {
platform: string;
invoke: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
@@ -224,7 +212,6 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
}
if (command === "get_desktop_settings") {
await waitForDesktopSettingsResponse();
return {
releaseChannel: "stable",
daemon: { manageBuiltInDaemon: manageDaemon, keepRunningAfterQuit: true },
@@ -290,17 +277,6 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
}, config);
}
export async function waitForDesktopDaemonStartRequest(page: Page): Promise<void> {
await page.waitForFunction(() => window.__desktopDaemonStartRequested === true);
// Give the startup state two paints to expose any app → splash regression.
await page.evaluate(
() =>
new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
),
);
}
export async function waitForDirectoryDialog(
page: Page,
): Promise<Record<string, unknown> | undefined> {
+1 -61
View File
@@ -1,5 +1,5 @@
import { execFileSync, execSync } from "node:child_process";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import path from "node:path";
export function hasGithubAuth(): boolean {
@@ -59,12 +59,6 @@ export interface GhDefaultBranchClone {
cleanup(): Promise<void>;
}
export interface LocalGhPrFixture {
pr: GhPrFixture;
mainCheckout: GhDefaultBranchClone;
cleanup(): Promise<void>;
}
function gh(args: string[], opts?: { cwd?: string }): string {
return execFileSync("gh", args, {
cwd: opts?.cwd,
@@ -287,57 +281,3 @@ export async function cloneGithubRepoDefaultBranchOnly(
},
};
}
export async function createLocalGithubPrFixture(): Promise<LocalGhPrFixture> {
const fixtureRoot = await mkdtemp(path.join("/tmp", "paseo-e2e-local-github-pr-"));
const basePath = path.join(fixtureRoot, "base");
const remotePath = path.join(fixtureRoot, "remote.git");
const checkoutPath = path.join(fixtureRoot, "main-only");
const githubUrl = "https://github.com/paseo-e2e/local-fixture.git";
await mkdir(basePath);
git(["init", "-b", "main"], basePath);
git(["config", "user.email", "e2e@paseo.test"], basePath);
git(["config", "user.name", "Paseo E2E"], basePath);
git(["config", "commit.gpgsign", "false"], basePath);
await writeFile(path.join(basePath, "README.md"), "# Local GitHub fixture\n");
git(["add", "README.md"], basePath);
git(["commit", "-m", "Initial commit"], basePath);
git(["checkout", "-b", "pr-branch-1"], basePath);
await writeFile(path.join(basePath, "pr-1.txt"), "PR 1\n");
git(["add", "pr-1.txt"], basePath);
git(["commit", "-m", "Add PR 1"], basePath);
git(["checkout", "main"], basePath);
execFileSync("git", ["clone", "--quiet", "--bare", basePath, remotePath], {
stdio: ["ignore", "pipe", "pipe"],
});
git(["update-ref", "refs/pull/1/head", "refs/heads/pr-branch-1"], remotePath);
execFileSync(
"git",
["clone", "--quiet", "--single-branch", "--branch", "main", remotePath, checkoutPath],
{ stdio: ["ignore", "pipe", "pipe"] },
);
git(["remote", "set-url", "origin", githubUrl], checkoutPath);
git(["config", `url.${remotePath}.insteadOf`, githubUrl], checkoutPath);
git(["config", "user.email", "e2e@paseo.test"], checkoutPath);
git(["config", "user.name", "Paseo E2E"], checkoutPath);
git(["config", "commit.gpgsign", "false"], checkoutPath);
return {
pr: {
number: 1,
title: "Use pasted PR as start ref",
url: "https://github.com/paseo-e2e/local-fixture/pull/1",
branch: "pr-branch-1",
localPath: basePath,
},
mainCheckout: {
path: checkoutPath,
cleanup: async () => {},
},
cleanup: async () => {
await rm(fixtureRoot, { recursive: true, force: true });
},
};
}
+1 -20
View File
@@ -1,4 +1,4 @@
import { expect, type BrowserContext, type Page } from "@playwright/test";
import { expect, type Page } from "@playwright/test";
import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client";
import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
import { connectDaemonClient } from "./daemon-client-loader";
@@ -290,13 +290,6 @@ export async function selectBranchInPicker(page: Page, name: string): Promise<vo
await branchRow.click();
}
export async function searchAndSelectBranchInPicker(page: Page, name: string): Promise<void> {
const searchInput = page.getByPlaceholder("Search branches and PRs");
await expect(searchInput).toBeVisible({ timeout: 30_000 });
await searchInput.fill(name);
await selectBranchInPicker(page, name);
}
export async function selectGitHubPrInPicker(page: Page, number: number): Promise<void> {
const prRow = page.getByTestId(`new-workspace-ref-picker-pr-${number}`);
await expect(prRow).toBeVisible({ timeout: 30_000 });
@@ -358,18 +351,6 @@ export async function expectComposerGithubAttachmentPill(
await expect(pills.first()).toContainText(input.title);
}
export async function pasteGithubPrUrl(
page: Page,
context: BrowserContext,
url: string,
): Promise<void> {
const composer = page.getByRole("textbox", { name: "Message agent..." });
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await page.evaluate((value) => navigator.clipboard.writeText(value), url);
await composer.focus();
await page.keyboard.press("Control+V");
}
export async function assertNewWorkspaceSidebarAndHeader(
page: Page,
input: {
+1 -1
View File
@@ -144,7 +144,7 @@ export interface SeedDaemonClient {
} | null;
fetchAgentHistory(options?: {
page?: { limit: number };
}): Promise<{ entries: Array<{ agent: { id: string } }> }>;
}): Promise<{ entries: Array<{ id: string }> }>;
subscribeTerminal(
terminalId: string,
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
-1
View File
@@ -22,7 +22,6 @@ interface SavedSettingsHostInput {
const SECTION_LABELS = {
general: "General",
appearance: "Appearance",
editor: "Editor",
shortcuts: "Shortcuts",
integrations: "Integrations",
permissions: "Permissions",
+63 -11
View File
@@ -7,10 +7,6 @@ interface SessionMessage {
payload?: unknown;
}
interface TimelineSubscriptionWaitOptions {
timeout?: number;
}
function readSessionMessage(message: WebSocketMessage): SessionMessage | null {
if (typeof message !== "string") return null;
try {
@@ -40,16 +36,72 @@ export function observeTimelineSubscriptions(page: Page) {
});
return {
async waitForSubscribedAgents(
agentIds: string[],
options: TimelineSubscriptionWaitOptions = {},
): Promise<void> {
async waitForSubscribedAgents(agentIds: string[]): Promise<void> {
const expected = [...new Set(agentIds)].sort();
await expect
.poll(() => acknowledgedAgentIds?.slice().sort() ?? null, {
timeout: options.timeout ?? 15_000,
})
.poll(() => acknowledgedAgentIds?.slice().sort() ?? null, { timeout: 15_000 })
.toEqual(expected);
},
};
}
interface AssistantFrameState {
active: boolean;
lastText: string | null;
snapshots: string[];
}
export async function observeLastAssistantFrames(page: Page) {
await page.evaluate(() => {
const state: AssistantFrameState = {
active: true,
lastText: null,
snapshots: [],
};
const sample = () => {
const hasPaintedHistoryOverlay = Array.from(
document.querySelectorAll('[data-testid="agent-history-overlay"]'),
).some((element) => {
const rect = element.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
});
if (!hasPaintedHistoryOverlay) {
const messages = Array.from(
document.querySelectorAll('[data-testid="assistant-message"]'),
).filter((element) => {
const rect = element.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
});
const text = messages.at(-1)?.textContent?.trim() ?? "";
if (text && text !== state.lastText) {
state.lastText = text;
state.snapshots.push(text);
}
}
if (state.active) requestAnimationFrame(sample);
};
Object.assign(window, { __assistantFrameState: state });
requestAnimationFrame(sample);
});
return {
async stop(): Promise<string[]> {
return page.evaluate(
() =>
new Promise<string[]>((resolve) => {
requestAnimationFrame(() => {
const state = (
window as typeof window & { __assistantFrameState?: AssistantFrameState }
).__assistantFrameState;
if (!state) {
resolve([]);
return;
}
state.active = false;
resolve([...state.snapshots]);
});
}),
);
},
};
}
-107
View File
@@ -17,14 +17,11 @@ import {
expectPickerOpen,
expectPickerSelected,
expectStartingRefPickerTriggerPr,
fillNewWorkspaceDraft,
openGlobalNewWorkspaceComposer,
openBranchPicker,
openNewWorkspaceComposer,
openProjectViaDaemon,
openStartingRefPicker,
pasteGithubPrUrl,
searchAndSelectBranchInPicker,
selectBranchInPicker,
selectGitHubPrInPicker,
selectPickerOptionByKeyboard,
@@ -33,11 +30,9 @@ import {
} from "./helpers/new-workspace";
import { createTempGitRepo, readWorktreeBranchInfo } from "./helpers/workspace";
import {
createLocalGithubPrFixture,
cloneGithubRepoDefaultBranchOnly,
createTempGithubRepo,
hasGithubAuth,
type LocalGhPrFixture,
} from "./helpers/github-fixtures";
import { getServerId } from "./helpers/server-id";
import { getE2EDaemonPort } from "./helpers/daemon-port";
@@ -196,7 +191,6 @@ test.describe("New workspace flow", () => {
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
const localWorkspaceIds = new Set<string>();
const createdWorktreeDirectories = new Set<string>();
const localGithubFixtures = new Set<LocalGhPrFixture>();
test.describe.configure({ timeout: 240_000 });
@@ -218,13 +212,6 @@ test.describe("New workspace flow", () => {
await client?.close().catch(() => undefined);
});
test.afterAll(async () => {
for (const fixture of localGithubFixtures) {
await fixture.cleanup();
}
localGithubFixtures.clear();
});
test("adds a project from the selected empty host", async ({ page }) => {
const repo = await createTempGitRepo("new-workspace-project-picker-");
const primaryServerId = getServerId();
@@ -820,100 +807,6 @@ test.describe("New workspace flow", () => {
}
});
test("pasted GitHub PR replaces a selected branch and creates its worktree", async ({
page,
context,
}) => {
const fixture = await createLocalGithubPrFixture();
localGithubFixtures.add(fixture);
const { pr, mainCheckout } = fixture;
const openedProject = await openProjectViaDaemon(client, mainCheckout.path);
localWorkspaceIds.add(openedProject.workspaceId);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await selectWorkspaceIsolation(page, "worktree");
await openStartingRefPicker(page);
await selectBranchInPicker(page, "main");
await pasteGithubPrUrl(page, context, pr.url);
await expect(page.getByTestId("workspace-create-submit")).toBeDisabled();
await expectComposerGithubAttachmentPill(page, {
number: pr.number,
title: pr.title,
});
await expectStartingRefPickerTriggerPr(page, {
number: pr.number,
title: pr.title,
headRef: pr.branch,
});
await submitNewWorkspaceWithoutPrompt(page);
const worktree = await assertNewWorkspaceSidebarAndHeader(page, {
serverId: getServerId(),
client,
previousWorkspaceId: openedProject.workspaceId,
projectDisplayName: openedProject.projectDisplayName,
});
createdWorktreeDirectories.add(worktree.workspaceDirectory);
const branchInfo = await readWorktreeBranchInfo({
worktreePath: worktree.workspaceDirectory,
});
expect(branchInfo.currentBranch).toBe(pr.branch);
expect(existsSync(path.join(worktree.workspaceDirectory, "pr-1.txt"))).toBe(true);
});
test("branches remain searchable after a pasted PR and determine the created worktree", async ({
page,
context,
}) => {
const fixture = await createLocalGithubPrFixture();
localGithubFixtures.add(fixture);
const { pr, mainCheckout } = fixture;
const openedProject = await openProjectViaDaemon(client, mainCheckout.path);
localWorkspaceIds.add(openedProject.workspaceId);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await selectWorkspaceIsolation(page, "worktree");
await pasteGithubPrUrl(page, context, pr.url);
await expectStartingRefPickerTriggerPr(page, {
number: pr.number,
title: pr.title,
headRef: pr.branch,
});
await openStartingRefPicker(page);
await searchAndSelectBranchInPicker(page, "main");
await expectPickerSelected(page, "main");
await fillNewWorkspaceDraft(page, `${pr.url}\nKeep this checkout on main`);
await expectPickerSelected(page, "main");
await submitNewWorkspaceWithoutPrompt(page);
const worktree = await assertNewWorkspaceSidebarAndHeader(page, {
serverId: getServerId(),
client,
previousWorkspaceId: openedProject.workspaceId,
projectDisplayName: openedProject.projectDisplayName,
});
createdWorktreeDirectories.add(worktree.workspaceDirectory);
expect(existsSync(path.join(worktree.workspaceDirectory, "pr-1.txt"))).toBe(false);
});
test("selected GitHub PR creates the worktree from the PR head even when the head branch is not fetched", async ({
page,
}) => {
@@ -219,7 +219,6 @@ test.describe("Half-screen desktop layout", () => {
}
await gotoAppShell(page);
await page.getByTestId(`sidebar-project-show-more-${workspace.projectId}`).click();
await waitForSidebarWorkspace(page, lastWorkspaceId);
const sidebarScroll = page.getByTestId("sidebar-project-workspace-list-scroll");
-238
View File
@@ -1,238 +0,0 @@
import type { Locator, Page } from "@playwright/test";
import { test, expect } from "./fixtures";
import { daemonWsRoutePattern } from "./helpers/daemon-port";
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
type WebSocketMessage = string | Buffer;
interface ShimmerEvidence {
animationDuration: string;
endPx: number;
label: string;
renderedWidth: number;
startPx: number;
}
function parseSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
const raw = typeof message === "string" ? message : message.toString("utf8");
try {
const envelope = JSON.parse(raw) as { type?: unknown; message?: unknown };
return envelope.type === "session" && envelope.message && typeof envelope.message === "object"
? (envelope.message as Record<string, unknown>)
: null;
} catch {
return null;
}
}
function getToolCallStatus(
message: WebSocketMessage,
agentId: string,
): { callId: string; status: string } | null {
const sessionMessage = parseSessionMessage(message);
if (sessionMessage?.type !== "agent_stream") {
return null;
}
const payload = sessionMessage.payload as Record<string, unknown> | undefined;
if (payload?.agentId !== agentId) {
return null;
}
const event = payload.event as Record<string, unknown> | undefined;
const item = event?.item as Record<string, unknown> | undefined;
return event?.type === "timeline" &&
item?.type === "tool_call" &&
typeof item.callId === "string" &&
typeof item.status === "string"
? { callId: item.callId, status: item.status }
: null;
}
function replaceToolCallStatus(message: WebSocketMessage, status: string): string {
const raw = typeof message === "string" ? message : message.toString("utf8");
const envelope = JSON.parse(raw) as {
message?: { payload?: { event?: { item?: { status?: string } } } };
};
const item = envelope.message?.payload?.event?.item;
if (!item) {
throw new Error("Expected a tool-call session message");
}
item.status = status;
return JSON.stringify(envelope);
}
async function gateSecondToolCall(page: Page, agentId: string) {
let firstCallId: string | null = null;
let secondCallId: string | null = null;
let secondRunningMessage: WebSocketMessage | null = null;
let releaseSecondRequested = false;
let pauseServerMessages = false;
let secondRunningForwarded = false;
let forwardSecondRunning: (() => void) | null = null;
let resolveFirstCompleted!: () => void;
let resolveSecondRunning!: () => void;
const firstCompleted = new Promise<void>((resolve) => {
resolveFirstCompleted = resolve;
});
const secondRunning = new Promise<void>((resolve) => {
resolveSecondRunning = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
forwardSecondRunning = () => {
if (!secondRunningMessage || secondRunningForwarded) {
return;
}
ws.send(secondRunningMessage);
secondRunningForwarded = true;
};
ws.onMessage((message) => server.send(message));
server.onMessage((message) => {
if (pauseServerMessages) {
return;
}
const toolCall = getToolCallStatus(message, agentId);
if (!toolCall) {
ws.send(message);
return;
}
if (!firstCallId) {
firstCallId = toolCall.callId;
}
if (toolCall.callId === firstCallId) {
if (toolCall.status === "running" || toolCall.status === "executing") {
return;
}
if (toolCall.status === "completed") {
// The mock tool completes inside the daemon's coalescing window, so the
// browser naturally receives its authoritative completed state first.
ws.send(message);
resolveFirstCompleted();
return;
}
}
secondCallId ??= toolCall.callId;
if (
toolCall.callId === secondCallId &&
(toolCall.status === "running" || toolCall.status === "executing")
) {
secondRunningMessage = message;
pauseServerMessages = true;
resolveSecondRunning();
if (releaseSecondRequested) {
forwardSecondRunning?.();
}
return;
}
if (toolCall.callId === secondCallId && toolCall.status === "completed") {
// Keep the second real tool call inspectably active. Production providers
// send this same status shape while their work remains in flight.
secondRunningMessage = replaceToolCallStatus(message, "running");
pauseServerMessages = true;
resolveSecondRunning();
if (releaseSecondRequested) {
forwardSecondRunning?.();
}
return;
}
ws.send(message);
});
});
return {
waitForFirstCompleted: () => firstCompleted,
waitForSecondRunning: () => secondRunning,
releaseSecondRunning() {
releaseSecondRequested = true;
if (secondRunningMessage) {
forwardSecondRunning?.();
}
},
};
}
async function readShimmerEvidence(locator: Locator): Promise<ShimmerEvidence> {
return locator.evaluate((root) => {
const shimmer = Array.from(root.querySelectorAll<HTMLElement>("*")).find((element) =>
getComputedStyle(element).animationName.includes("paseo-toolcall-shimmer"),
);
if (!shimmer) {
throw new Error("Expected a running shimmer inside the badge");
}
const style = getComputedStyle(shimmer);
return {
animationDuration: style.animationDuration,
endPx: Number.parseFloat(style.getPropertyValue("--paseo-shimmer-end")),
label: shimmer.textContent ?? "",
renderedWidth: shimmer.getBoundingClientRect().width,
startPx: Number.parseFloat(style.getPropertyValue("--paseo-shimmer-start")),
};
});
}
test("measures an overview heading that becomes loading after its idle mount", async ({
page,
}, testInfo) => {
test.setTimeout(120_000);
await page.addInitScript(() => {
localStorage.setItem(
"@paseo:app-settings",
JSON.stringify({ toolCallDetailLevel: "overview" }),
);
});
const agent = await seedMockAgentWorkspace({
repoPrefix: "tool-call-shimmer-",
title: "Tool-call shimmer",
model: "ten-second-stream",
});
try {
const gate = await gateSecondToolCall(page, agent.agentId);
await openAgentRoute(page, {
workspaceId: agent.workspaceId,
agentId: agent.agentId,
});
await agent.client.sendAgentMessage(agent.agentId, "Prove the overview shimmer lifecycle.");
await gate.waitForFirstCompleted();
const group = page.getByTestId("tool-call-group");
await expect(group).toBeVisible();
const idleGroupHandle = await group.elementHandle();
if (!idleGroupHandle) {
throw new Error("Expected the idle tool-call group to be mounted");
}
await expect(group.locator('[style*="paseo-toolcall-shimmer"]')).toHaveCount(0);
await gate.waitForSecondRunning();
gate.releaseSecondRunning();
await expect(group.locator('[style*="paseo-toolcall-shimmer"]')).not.toHaveCount(0);
const sameGroupNode = await group.evaluate(
(node, previous) => node === previous,
idleGroupHandle,
);
await group.click();
const runningChild = page.getByTestId("tool-call-badge").last();
await expect(runningChild).toBeVisible();
const header = await readShimmerEvidence(group);
const child = await readShimmerEvidence(runningChild);
const evidence = { sameGroupNode, header, child };
await testInfo.attach("tool-call-shimmer-evidence", {
body: JSON.stringify(evidence, null, 2),
contentType: "application/json",
});
expect(sameGroupNode).toBe(true);
expect(child.endPx).toBeGreaterThan(0);
expect(
header.endPx,
`The retained header rendered ${header.renderedWidth}px wide but its shimmer still ends at ${header.endPx}px`,
).toBeGreaterThan(0);
} finally {
await agent.cleanup();
}
});
+26 -17
View File
@@ -3,7 +3,10 @@ import { buildHostAgentDetailRoute } from "@/utils/host-routes";
import { test } from "./fixtures";
import { seedWorkspace, type SeedDaemonClient } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { observeTimelineSubscriptions } from "./helpers/timeline-delivery";
import {
observeLastAssistantFrames,
observeTimelineSubscriptions,
} from "./helpers/timeline-delivery";
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
import {
@@ -72,40 +75,47 @@ async function commitMessage(scenario: ViewedTimelineScenario, agentId: string,
}
test.describe("Viewed agent timelines", () => {
test("an unsubscribed hidden chat catches up when shown", async ({ page }) => {
test("a focused turn streams live and resumes its hidden remainder atomically", async ({
page,
}) => {
test.setTimeout(90_000);
const subscriptions = observeTimelineSubscriptions(page);
const scenario = await seedViewedTimelineScenario();
try {
await openAgent(page, scenario, scenario.firstAgentId);
await selectAgent(page, "Second viewed chat");
await subscriptions.waitForSubscribedAgents([scenario.secondAgentId], { timeout: 45_000 });
await commitMessage(
scenario,
await subscriptions.waitForSubscribedAgents([scenario.firstAgentId]);
await scenario.client.sendAgentMessage(
scenario.firstAgentId,
"Committed after the first chat unsubscribed.",
"Stream while focused, then finish while hidden.",
);
await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible();
await expect(
page.getByText("Committed after the first chat unsubscribed.", { exact: true }),
).toHaveCount(0);
await selectAgent(page, "First viewed chat");
await expect(
page.getByText("Committed after the first chat unsubscribed.", { exact: true }),
page.getByTestId("assistant-message").filter({ hasText: "Cycle 1" }).first(),
).toBeVisible();
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toHaveCount(0);
await selectAgent(page, "Second viewed chat");
await subscriptions.waitForSubscribedAgents([scenario.secondAgentId]);
const finish = await scenario.client.waitForFinish(scenario.firstAgentId, 30_000);
expect(finish.status).toBe("idle");
const assistantFrames = await observeLastAssistantFrames(page);
await selectAgent(page, "First viewed chat");
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toBeVisible();
const snapshots = await assistantFrames.stop();
expect(snapshots[0]).toContain("(end of synthetic stream)");
} finally {
await scenario.cleanup();
}
});
test("a hidden retained chat stays current during unsubscribe grace", async ({ page }) => {
test.setTimeout(60_000);
const subscriptions = observeTimelineSubscriptions(page);
test("a hidden retained chat catches up when shown", async ({ page }) => {
const scenario = await seedViewedTimelineScenario();
try {
await openAgent(page, scenario, scenario.firstAgentId);
await selectAgent(page, "Second viewed chat");
await subscriptions.waitForSubscribedAgents([scenario.firstAgentId, scenario.secondAgentId]);
await commitMessage(
scenario,
scenario.firstAgentId,
@@ -118,7 +128,6 @@ test.describe("Viewed agent timelines", () => {
await expect(
page.getByText("Committed while the first chat is hidden.", { exact: true }),
).toBeVisible();
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toBeVisible();
} finally {
await scenario.cleanup();
}
@@ -1,6 +1,5 @@
import { test, expect } from "./fixtures";
import { expectComposerVisible, submitMessage } from "./helpers/composer";
import { delayCreatedAgentInitialTailResponse } from "./helpers/agent-timeline-gate";
import { delayBrowserAgentCreatedStatus } from "./helpers/new-workspace";
import { seedWorkspace, type SeedDaemonClient } from "./helpers/seed-client";
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
@@ -44,49 +43,6 @@ async function fetchActiveAgentTitle(
}
test.describe("Workspace agent title handoff", () => {
test("does not cover the agent pane while the optimistic create becomes authoritative", async ({
page,
}) => {
test.setTimeout(120_000);
await page.setViewportSize({ width: 1440, height: 900 });
const timelineGate = await delayCreatedAgentInitialTailResponse(page);
const workspace = await seedWorkspace({ repoPrefix: "workspace-create-handoff-flash-" });
try {
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.workspaceId));
await waitForWorkspaceTabsVisible(page);
await page.getByTestId("workspace-new-agent-tab-inline").click();
await expectComposerVisible(page);
const prompt = "Keep the optimistic agent pane visible during handoff";
await submitMessage(page, prompt);
const agentId = await timelineGate.waitForCreatedAgent();
await timelineGate.waitForDelayedResponse();
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`).first()).toBeVisible({
timeout: 15_000,
});
await expect(page.getByText(prompt, { exact: true }).first()).toBeVisible();
await expect(page.getByTestId("agent-history-overlay")).toHaveCount(0);
const overlayAppeared = page
.getByTestId("agent-history-overlay")
.waitFor({ state: "attached", timeout: 2_000 })
.then(
() => true,
() => false,
);
timelineGate.release();
await timelineGate.waitForForwardedResponse();
expect(await overlayAppeared).toBe(false);
} finally {
timelineGate.release();
await workspace.cleanup();
}
});
test("shows the prompt tab title and replaces it when the daemon title updates", async ({
page,
}) => {
@@ -1,8 +1,4 @@
import {
buildHostAgentDetailRoute,
buildHostWorkspaceOpenRoute,
buildHostWorkspaceRoute,
} from "@/utils/host-routes";
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
import { expect, test, type Page } from "./fixtures";
import { gotoAppShell, openSettings } from "./helpers/app";
import {
@@ -35,53 +31,11 @@ import {
} from "./helpers/workspace-ui";
import { clickSettingsBackToWorkspace } from "./helpers/settings";
import { getServerId } from "./helpers/server-id";
import { injectDesktopBridge, waitForDesktopDaemonStartRequest } from "./helpers/desktop-updates";
import { injectDesktopBridge } from "./helpers/desktop-updates";
import { expectAppRoute } from "./helpers/route-assertions";
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
import { addOfflineHostAndReload } from "./helpers/hosts";
const LOADING_WORKSPACE_TEXT_PATTERN = /Loading workspace/i;
type StartupPresentation = "splash" | "app";
declare global {
interface Window {
__paseoStartupPresentationTrace?: StartupPresentation[];
}
}
async function observeStartupPresentation(page: Page): Promise<void> {
await page.addInitScript(() => {
const trace: StartupPresentation[] = [];
window.__paseoStartupPresentationTrace = trace;
document.addEventListener("DOMContentLoaded", () => {
const recordPresentation = () => {
let presentation: StartupPresentation | null = null;
if (document.querySelector('[data-testid="startup-splash"]')) {
presentation = "splash";
} else if (
document.querySelector(
'[data-testid="workspace-header-title"], [data-testid="sidebar-settings"]',
)
) {
presentation = "app";
}
if (presentation && trace.at(-1) !== presentation) {
trace.push(presentation);
}
};
new MutationObserver(recordPresentation).observe(document.documentElement, {
childList: true,
subtree: true,
});
recordPresentation();
});
});
}
async function getStartupPresentation(page: Page): Promise<StartupPresentation[]> {
return page.evaluate(() => window.__paseoStartupPresentationTrace?.slice() ?? []);
}
async function expectNoLoadingWorkspacePane(
page: Page,
@@ -163,43 +117,6 @@ async function expectWorkspaceLocation(
test.describe("Workspace navigation regression", () => {
test.describe.configure({ timeout: 240_000 });
test("opens a notification's workspace on a different offline host", async ({ page }) => {
const target = {
serverId: "notification-offline-host",
workspaceId: "notification-workspace",
agentId: "notification-agent",
};
await gotoAppShell(page);
await addOfflineHostAndReload(page, {
serverId: target.serverId,
label: "Notification Host",
});
await expect(
page.getByTestId("sidebar-settings").filter({ visible: true }).first(),
).toBeVisible({
timeout: 30_000,
});
await page.evaluate((data) => {
globalThis.dispatchEvent(
new CustomEvent("paseo:web-notification-click", {
detail: { data: { ...data, reason: "finished" } },
cancelable: true,
}),
);
}, target);
await expectAppRoute(
page,
buildHostWorkspaceOpenRoute(target.serverId, target.workspaceId, `agent:${target.agentId}`),
{ timeout: 30_000 },
);
await expect(page.getByText("Connecting", { exact: true })).toBeVisible();
await expect(page.getByText("Notification Host", { exact: true })).toBeVisible();
await expect(page.getByText("Add a project", { exact: true })).toHaveCount(0);
});
test("keeps one replacement draft after returning from settings and closing the last tab", async ({
page,
withWorkspace,
@@ -322,9 +239,7 @@ test.describe("Workspace navigation regression", () => {
}
});
test("refresh keeps one continuous splash before restoring the workspace route", async ({
page,
}) => {
test("refresh keeps the user on the same workspace route", async ({ page }) => {
const serverId = getServerId();
const daemonGate = await installDaemonWebSocketGate(page);
const workspace = await seedWorkspace({ repoPrefix: "workspace-refresh-route-" });
@@ -339,7 +254,6 @@ test.describe("Workspace navigation regression", () => {
serverId,
manageBuiltInDaemon: true,
hangDaemonStart: true,
desktopSettingsDelayMs: 250,
daemonListen: `127.0.0.1:${getE2EDaemonPort()}`,
});
await openWorkspaceThroughApp(page, { serverId, workspace });
@@ -347,16 +261,14 @@ test.describe("Workspace navigation regression", () => {
await expectWorkspaceTabVisible(page, agent.id);
await expectWorkspaceLocation(page, { serverId, workspace });
await observeStartupPresentation(page);
await daemonGate.drop();
await page.reload();
await waitForDesktopDaemonStartRequest(page);
await expect(page.getByTestId("startup-splash")).toBeVisible({ timeout: 30_000 });
daemonGate.restore();
await waitForSidebarHydration(page);
await expectWorkspaceLocation(page, { serverId, workspace });
await waitForWorkspaceTabsVisible(page);
expect(await getStartupPresentation(page)).toEqual(["splash", "app"]);
} finally {
daemonGate.restore();
await workspace.cleanup();
@@ -462,13 +374,12 @@ test.describe("Workspace navigation regression", () => {
await expectWorkspaceDeckEntryCount(page, 2);
await page.evaluate(
({ agentId, serverId: targetServerId, workspaceId }) => {
({ agentId, serverId: targetServerId }) => {
globalThis.dispatchEvent(
new CustomEvent("paseo:web-notification-click", {
detail: {
data: {
serverId: targetServerId,
workspaceId,
agentId,
reason: "finished",
},
@@ -477,7 +388,7 @@ test.describe("Workspace navigation regression", () => {
}),
);
},
{ agentId: secondAgent.id, serverId, workspaceId: secondWorkspace.workspaceId },
{ agentId: secondAgent.id, serverId },
);
await waitForWorkspaceTabsVisible(page);
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), {
+9 -78
View File
@@ -44,10 +44,7 @@ test.describe("Worktree restore", () => {
tempRepo = await createTempGitRepo("wt-restore-");
});
async function createArchivedMissingWorktree(
prefix: string,
options: { agentCount?: number; keepAgentsArchived?: boolean } = {},
) {
async function createArchivedMissingWorktree(prefix: string) {
const project = await openProjectViaDaemon(worktreeClient, tempRepo.path);
createdProjectIds.add(project.projectKey);
const worktree = await createWorktreeViaDaemon(worktreeClient, {
@@ -56,19 +53,11 @@ test.describe("Worktree restore", () => {
});
createdProjectIds.add(worktree.projectKey);
createdWorktreeDirectories.add(worktree.workspaceDirectory);
const agents = await Promise.all(
Array.from({ length: options.agentCount ?? 1 }, () =>
createIdleAgent(client, {
cwd: worktree.workspaceDirectory,
workspaceId: worktree.workspaceId,
title: `${prefix}-${randomUUID().slice(0, 8)}`,
}),
),
);
const agent = agents[0];
if (!agent) {
throw new Error("Expected at least one archived-worktree agent");
}
const agent = await createIdleAgent(client, {
cwd: worktree.workspaceDirectory,
workspaceId: worktree.workspaceId,
title: `${prefix}-${randomUUID().slice(0, 8)}`,
});
await archiveWorkspaceFromDaemon(worktreeClient, worktree.workspaceDirectory);
await expect
@@ -78,21 +67,11 @@ test.describe("Worktree restore", () => {
// Match the remote cloud-race record: workspace archived and absent, while
// the surviving closed agent record is not agent-archived. Refresh now owns
// only agent lifecycle, so its expected cwd failure cannot recover the workspace.
if (options.keepAgentsArchived) {
for (const archivedAgent of agents) {
await expect
.poll(() => fetchAgentArchivedAt(client, archivedAgent.id), { timeout: 30_000 })
.not.toBeNull();
}
} else {
await client.refreshAgent(agent.id).catch(() => undefined);
await expect
.poll(() => fetchAgentArchivedAt(client, agent.id), { timeout: 30_000 })
.toBeNull();
}
await client.refreshAgent(agent.id).catch(() => undefined);
await expect.poll(() => fetchAgentArchivedAt(client, agent.id), { timeout: 30_000 }).toBeNull();
expect(existsSync(worktree.workspaceDirectory)).toBe(false);
return { agent, agents, worktree };
return { agent, worktree };
}
async function openArchivedWorkspaceFromHistory(page: Page, prefix: string) {
@@ -262,54 +241,6 @@ test.describe("Worktree restore", () => {
).toHaveText(switchedBranch, { timeout: 30_000 });
});
test("recovers the selected agent with its workspace and later rescues another archived agent", async ({
page,
}) => {
const { agents, worktree } = await createArchivedMissingWorktree("restore-agents", {
agentCount: 2,
keepAgentsArchived: true,
});
const [firstAgent, secondAgent] = agents;
if (!firstAgent || !secondAgent) {
throw new Error("Expected two archived agents");
}
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openSessions(page);
await page.getByTestId(`agent-row-${getServerId()}-${firstAgent.id}`).click();
await expect(page.getByText("Workspace archived", { exact: true })).toBeVisible({
timeout: 30_000,
});
await page.getByTestId("workspace-recovery-action").click();
await expect
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
.toBe(true);
await expect(
page.getByTestId(`workspace-tab-agent_${firstAgent.id}`).filter({ visible: true }).first(),
).toBeVisible({ timeout: 30_000 });
await expect
.poll(() => fetchAgentArchivedAt(client, firstAgent.id), { timeout: 30_000 })
.toBeNull();
await expect(page.getByRole("button", { name: "Unarchive" })).toHaveCount(0, {
timeout: 30_000,
});
await openSessions(page);
await page.getByTestId(`agent-row-${getServerId()}-${secondAgent.id}`).click();
await expect(
page.getByTestId(`workspace-tab-agent_${secondAgent.id}`).filter({ visible: true }).first(),
).toBeVisible({ timeout: 30_000 });
await expect(page.getByRole("button", { name: "Unarchive" })).toBeVisible({
timeout: 30_000,
});
await page.getByRole("button", { name: "Unarchive" }).click();
await expect
.poll(() => fetchAgentArchivedAt(client, secondAgent.id), { timeout: 30_000 })
.toBeNull();
});
test("restore failure stays visible and permits a successful retry", async ({ page }) => {
const { agent, worktree } = await openArchivedWorkspaceFromHistory(page, "restore-retry");
const displacedProjectPath = `${tempRepo.path}-temporarily-unavailable`;
+2 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.2.1",
"version": "0.2.0-beta.1",
"private": true,
"main": "index.ts",
"scripts": {
@@ -23,6 +23,7 @@
"test:browser": "vitest run --project browser",
"test:e2e": "playwright test --project='Desktop Chrome'",
"test:e2e:desktop": "cross-env E2E_DESKTOP_RUNTIME=1 playwright test --project='Desktop Chrome' e2e/project-picker-desktop.spec.ts",
"test:e2e:import-electron": "cross-env E2E_DESKTOP_RUNTIME=1 playwright test --project='Desktop Chrome' e2e/conductor-import.electron.spec.ts",
"test:e2e:real": "cross-env E2E_FORK_PASEO_HOME_FROM=../../.dev/paseo-home playwright test --project=real-provider",
"test:e2e:ui": "playwright test --ui",
"build": "npm run build:web",
@@ -32,11 +33,6 @@
"build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs"
},
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
"@codemirror/search": "6.7.1",
"@codemirror/state": "6.7.1",
"@codemirror/view": "6.43.6",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
@@ -51,7 +47,6 @@
"@react-native-masked-view/masked-view": "^0.3.2",
"@react-native/normalize-colors": "^0.81.5",
"@react-navigation/native": "^7.1.8",
"@replit/codemirror-vim": "6.3.0",
"@tanstack/react-query": "^5.90.11",
"@tanstack/react-virtual": "^3.13.21",
"@xterm/addon-clipboard": "^0.3.0-beta.213",
+4 -1
View File
@@ -27,7 +27,10 @@ export default defineConfig({
projects: [
{
name: "Desktop Chrome",
testIgnore: ["**/*.real.spec.ts"],
testIgnore:
process.env.E2E_DESKTOP_RUNTIME === "1"
? ["**/*.real.spec.ts"]
: ["**/*.real.spec.ts", "**/*.electron.spec.ts"],
use: { ...devices["Desktop Chrome"] },
},
{
+13 -46
View File
@@ -11,51 +11,6 @@ const EXCLUDED_ANDROID_MODULES = [
"expo-dev-menu-interface",
];
const FDROID_ABI_VERSION_CODE_BLOCK = `// Paseo F-Droid single-ABI version codes
def paseoAbiVersionCodes = [
"armeabi-v7a": 1,
"arm64-v8a": 2,
"x86": 3,
"x86_64": 4,
]
def paseoArchitectures = (findProperty("reactNativeArchitectures") ?: "")
.toString()
.split(",")
.collect { it.trim() }
.findAll { !it.isEmpty() }
if (paseoArchitectures.size() == 1) {
def paseoAbi = paseoArchitectures[0]
def paseoAbiVersionCode = paseoAbiVersionCodes[paseoAbi]
if (paseoAbiVersionCode == null) {
throw new GradleException("Unsupported Paseo Android ABI: " + paseoAbi)
}
android.defaultConfig.versionCode = android.defaultConfig.versionCode * 10 + paseoAbiVersionCode
}
`;
function configureFdroidAppBuildGradle(contents) {
let configuredContents = contents;
if (!configuredContents.includes("dependenciesInfo {")) {
const androidBlock = "android {";
if (!configuredContents.includes(androidBlock)) {
throw new Error("Could not disable F-Droid dependency metadata in app/build.gradle");
}
configuredContents = configuredContents.replace(
androidBlock,
`${androidBlock}\n dependenciesInfo {\n includeInApk = false\n includeInBundle = false\n }`,
);
}
if (!configuredContents.includes("// Paseo F-Droid single-ABI version codes")) {
configuredContents = `${configuredContents.trimEnd()}\n\n${FDROID_ABI_VERSION_CODE_BLOCK}`;
}
return configuredContents;
}
function withFdroidAutolinking(config) {
config = withDangerousMod(config, [
"android",
@@ -110,7 +65,19 @@ function withFdroidAutolinking(config) {
});
return withAppBuildGradle(config, (modConfig) => {
modConfig.modResults.contents = configureFdroidAppBuildGradle(modConfig.modResults.contents);
if (modConfig.modResults.contents.includes("dependenciesInfo {")) {
return modConfig;
}
const androidBlock = "android {";
if (!modConfig.modResults.contents.includes(androidBlock)) {
throw new Error("Could not disable F-Droid dependency metadata in app/build.gradle");
}
modConfig.modResults.contents = modConfig.modResults.contents.replace(
androidBlock,
`${androidBlock}\n dependenciesInfo {\n includeInApk = false\n includeInBundle = false\n }`,
);
return modConfig;
});
}
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import {
__private__,
deriveBottomAnchorBlockedReason,
@@ -126,15 +126,10 @@ function createDriverHarness(input?: {
measurementState,
nearBottom: input?.isNearBottom ?? true,
};
const scrollAttempts: boolean[] = [];
let scrollToBottomBehavior = () => {
const scrollToBottom = vi.fn(() => {
context.nearBottom = true;
context.measurementState.offsetY = 720;
};
const scrollToBottom = (animated: boolean) => {
scrollAttempts.push(animated);
scrollToBottomBehavior();
};
});
const modeChanges: BottomAnchorMode[] = [];
const driver = __private__.createBottomAnchorControllerDriver({
getAgentId: () => context.agentId,
@@ -155,10 +150,7 @@ function createDriverHarness(input?: {
context,
driver,
scheduler,
scrollAttempts,
setScrollToBottomBehavior(next: () => void) {
scrollToBottomBehavior = next;
},
scrollToBottom,
modeChanges,
};
}
@@ -218,7 +210,7 @@ describe("bottom anchor controller driver", () => {
});
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(0);
expect(harness.scrollToBottom).not.toHaveBeenCalled();
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
blockedReason: "waiting_for_history_readiness",
@@ -237,7 +229,7 @@ describe("bottom anchor controller driver", () => {
harness.driver.reevaluate();
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(1);
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
expect(harness.driver.getSnapshot()).toMatchObject({
blockedReason: null,
pendingRequest: null,
@@ -245,97 +237,6 @@ describe("bottom anchor controller driver", () => {
});
});
it("preserves a blocked route anchor while a user scroll ends at the bottom", () => {
const harness = createDriverHarness({ authoritativeReady: false });
harness.driver.applyRouteRequest({
agentId: "agent-1",
reason: "initial-entry",
requestKey: "route:agent-1:initial-entry",
});
harness.scheduler.flushAll();
harness.driver.beginUserScroll();
harness.context.authoritativeReady = true;
harness.driver.notifyAuthoritativeHistoryMaybeChanged();
harness.driver.reevaluate();
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(0);
harness.driver.endUserScroll({ isNearBottom: true });
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(1);
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
blockedReason: null,
pendingRequest: null,
pendingVerification: null,
});
});
it("preserves a blocked route anchor when layout moves after drag release", () => {
const harness = createDriverHarness({ authoritativeReady: false });
harness.driver.applyRouteRequest({
agentId: "agent-1",
reason: "resume",
requestKey: "route:agent-1:resume",
});
harness.scheduler.flushAll();
harness.driver.beginUserScroll();
harness.context.nearBottom = false;
harness.driver.handleScrollNearBottomChange({
nextIsNearBottom: false,
scrollDelta: 0,
});
harness.context.authoritativeReady = true;
harness.driver.notifyAuthoritativeHistoryMaybeChanged();
harness.driver.endUserScroll({ isNearBottom: true });
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(1);
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
blockedReason: null,
pendingRequest: null,
pendingVerification: null,
});
});
it("lets a user scroll away supersede a blocked route anchor", () => {
const harness = createDriverHarness({ authoritativeReady: false });
harness.driver.applyRouteRequest({
agentId: "agent-1",
reason: "resume",
requestKey: "route:agent-1:resume",
});
harness.scheduler.flushAll();
harness.driver.beginUserScroll();
harness.context.nearBottom = false;
harness.driver.handleScrollNearBottomChange({
nextIsNearBottom: false,
scrollDelta: 48,
});
harness.driver.endUserScroll({ isNearBottom: false });
harness.context.authoritativeReady = true;
harness.driver.notifyAuthoritativeHistoryMaybeChanged();
harness.driver.reevaluate();
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(0);
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "detached",
blockedReason: null,
pendingRequest: null,
pendingVerification: null,
});
});
it("suppresses sticky maintenance while detached", () => {
const harness = createDriverHarness();
@@ -353,74 +254,7 @@ describe("bottom anchor controller driver", () => {
harness.scheduler.flushAll();
expect(harness.driver.getSnapshot().mode).toBe("detached");
expect(harness.scrollAttempts).toHaveLength(0);
});
it("pauses sticky maintenance while a user scroll owns the viewport", () => {
const harness = createDriverHarness({
transportBehavior: {
verificationDelayFrames: 2,
verificationRetryMode: "recheck",
},
});
harness.driver.prepareForStickyContentChange();
harness.driver.beginUserScroll();
harness.driver.handleContentSizeChange({
previousContentHeight: 1200,
contentHeight: 1400,
});
harness.context.nearBottom = false;
harness.driver.handleScrollNearBottomChange({
nextIsNearBottom: false,
scrollDelta: 1,
});
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(1);
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
pendingRequest: null,
pendingVerification: null,
});
harness.driver.endUserScroll({ isNearBottom: false });
expect(harness.driver.getSnapshot().mode).toBe("detached");
});
it("restores sticky maintenance when a user scroll returns to the bottom", () => {
const harness = createDriverHarness({
transportBehavior: {
verificationDelayFrames: 2,
verificationRetryMode: "recheck",
},
});
harness.driver.beginUserScroll();
harness.context.nearBottom = false;
harness.driver.handleScrollNearBottomChange({
nextIsNearBottom: false,
scrollDelta: 48,
});
harness.driver.handleContentSizeChange({
previousContentHeight: 1200,
contentHeight: 1400,
});
harness.context.nearBottom = true;
harness.driver.handleScrollNearBottomChange({
nextIsNearBottom: true,
scrollDelta: -48,
});
harness.driver.endUserScroll({ isNearBottom: true });
harness.scheduler.flushAll();
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
pendingRequest: null,
pendingVerification: null,
});
expect(harness.scrollAttempts).toHaveLength(1);
expect(harness.scrollToBottom).not.toHaveBeenCalled();
});
it("switches back to sticky-bottom for explicit jump-to-bottom", () => {
@@ -437,7 +271,7 @@ describe("bottom anchor controller driver", () => {
expect(harness.modeChanges).toContain("detached");
expect(harness.modeChanges).toContain("sticky-bottom");
expect(harness.scrollAttempts).toHaveLength(1);
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
expect(harness.driver.getSnapshot().mode).toBe("sticky-bottom");
});
@@ -458,7 +292,7 @@ describe("bottom anchor controller driver", () => {
});
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(2);
expect(harness.scrollToBottom).toHaveBeenCalledTimes(2);
});
it("keeps a pending request blocked when stale container measurements arrive", () => {
@@ -479,7 +313,7 @@ describe("bottom anchor controller driver", () => {
});
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(0);
expect(harness.scrollToBottom).not.toHaveBeenCalled();
expect(harness.driver.getSnapshot()).toMatchObject({
blockedReason: "waiting_for_measurable_viewport",
pendingRequest: {
@@ -492,7 +326,7 @@ describe("bottom anchor controller driver", () => {
harness.driver.reevaluate();
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(1);
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
expect(harness.driver.getSnapshot().pendingRequest).toBeNull();
});
@@ -504,7 +338,7 @@ describe("bottom anchor controller driver", () => {
},
isNearBottom: false,
});
harness.setScrollToBottomBehavior(() => {
harness.scrollToBottom.mockImplementation(() => {
harness.context.measurementState.offsetY = 0;
});
@@ -514,16 +348,16 @@ describe("bottom anchor controller driver", () => {
});
harness.scheduler.flushFrame();
expect(harness.scrollAttempts).toHaveLength(1);
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
harness.scheduler.flushFrame();
harness.scheduler.flushFrame();
expect(harness.scrollAttempts).toHaveLength(1);
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
harness.context.nearBottom = true;
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(1);
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
expect(harness.driver.getSnapshot().pendingRequest).toBeNull();
});
@@ -541,7 +375,7 @@ describe("bottom anchor controller driver", () => {
isNearBottom: false,
});
harness.setScrollToBottomBehavior(() => {
harness.scrollToBottom.mockImplementation(() => {
harness.context.measurementState.offsetY = 13476;
});
@@ -552,7 +386,7 @@ describe("bottom anchor controller driver", () => {
});
harness.scheduler.flushFrame();
expect(harness.scrollAttempts).toHaveLength(1);
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
harness.context.measurementState.contentHeight = 14804;
harness.context.nearBottom = false;
@@ -575,7 +409,7 @@ describe("bottom anchor controller driver", () => {
harness.scheduler.flushFrame();
expect(harness.scrollAttempts).toHaveLength(2);
expect(harness.scrollToBottom).toHaveBeenCalledTimes(2);
expect(harness.driver.getSnapshot()).toMatchObject({
blockedReason: "waiting_for_post_layout_verification",
pendingRequest: {
@@ -601,7 +435,7 @@ describe("bottom anchor controller driver", () => {
isNearBottom: false,
});
harness.setScrollToBottomBehavior(() => {
harness.scrollToBottom.mockImplementation(() => {
harness.context.measurementState.offsetY = Math.max(
0,
harness.context.measurementState.contentHeight -
@@ -637,7 +471,7 @@ describe("bottom anchor controller driver", () => {
harness.scheduler.flushFrame();
harness.scheduler.flushFrame();
expect(harness.scrollAttempts).toHaveLength(2);
expect(harness.scrollToBottom).toHaveBeenCalledTimes(2);
expect(harness.driver.getSnapshot().pendingRequest).toMatchObject({
reason: "resume",
});
@@ -646,7 +480,7 @@ describe("bottom anchor controller driver", () => {
it("keeps sticky-bottom during viewport growth until bottom is re-verified", () => {
const harness = createDriverHarness();
harness.context.nearBottom = false;
harness.setScrollToBottomBehavior(() => {
harness.scrollToBottom.mockImplementation(() => {
harness.context.measurementState.offsetY = 720;
});
@@ -658,7 +492,7 @@ describe("bottom anchor controller driver", () => {
});
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(4);
expect(harness.scrollToBottom).toHaveBeenCalledTimes(4);
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
pendingRequest: null,
@@ -689,7 +523,7 @@ describe("bottom anchor controller driver", () => {
it("keeps sticky-bottom during streaming growth until bottom is re-verified", () => {
const harness = createDriverHarness();
harness.context.nearBottom = false;
harness.setScrollToBottomBehavior(() => {
harness.scrollToBottom.mockImplementation(() => {
harness.context.measurementState.offsetY = 900;
});
@@ -699,7 +533,7 @@ describe("bottom anchor controller driver", () => {
});
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(4);
expect(harness.scrollToBottom).toHaveBeenCalledTimes(4);
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
pendingRequest: null,
@@ -743,7 +577,7 @@ describe("bottom anchor controller driver", () => {
contentMeasuredForKey: null,
}),
});
harness.setScrollToBottomBehavior(() => {
harness.scrollToBottom.mockImplementation(() => {
harness.context.measurementState.offsetY = 0;
});
@@ -754,7 +588,7 @@ describe("bottom anchor controller driver", () => {
contentHeight: 1348,
});
expect(harness.scrollAttempts).toHaveLength(1);
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
pendingVerification: {
@@ -791,14 +625,14 @@ describe("bottom anchor controller driver", () => {
contentMeasuredForKey: "native-virtualized",
}),
});
harness.setScrollToBottomBehavior(() => {
harness.scrollToBottom.mockImplementation(() => {
harness.context.measurementState.offsetY = 0;
harness.context.nearBottom = true;
});
harness.driver.prepareForStickyContentChange();
expect(harness.scrollAttempts).toHaveLength(1);
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
pendingVerification: {
@@ -68,8 +68,6 @@ interface BottomAnchorControllerDriver {
resetForAgent: () => void;
applyRouteRequest: (request: BottomAnchorRouteRequest | null) => void;
requestLocalAnchor: (request: BottomAnchorLocalRequest) => void;
beginUserScroll: () => void;
endUserScroll: (params: { isNearBottom: boolean }) => void;
detachByUser: () => void;
handleViewportMetricsChange: (params: {
previousViewportWidth: number;
@@ -245,7 +243,6 @@ function createBottomAnchorControllerDriver(
let lastRouteRequestKey: string | null = null;
let stickyMeasurementRevision = 0;
let lastVerifiedStickyMeasurementRevision = 0;
let isUserScrollActive = false;
const setBlockedReason = (nextBlockedReason: BottomAnchorBlockedReason | null) => {
if (blockedReason === nextBlockedReason) {
@@ -414,14 +411,10 @@ function createBottomAnchorControllerDriver(
| "viewport_change"
| "content_size_change"
| "scroll_near_bottom_change"
| "user_scroll_end"
| "history_readiness_change"
| "manual_reevaluate"
| "retry_scroll",
) => {
if (isUserScrollActive) {
return;
}
if (attemptHandle) {
return;
}
@@ -488,7 +481,6 @@ function createBottomAnchorControllerDriver(
cancelPendingAttempt();
stickyMeasurementRevision = 0;
lastVerifiedStickyMeasurementRevision = 0;
isUserScrollActive = false;
mode = "sticky-bottom";
input.onModeChange("sticky-bottom");
},
@@ -505,38 +497,6 @@ function createBottomAnchorControllerDriver(
requestLocalAnchor(request) {
createRequest(request);
},
beginUserScroll() {
isUserScrollActive = true;
cancelPendingAttempt();
},
endUserScroll(params) {
isUserScrollActive = false;
if (params.isNearBottom) {
if (mode === "detached") {
setModeInternal("sticky-bottom");
pendingVerification = { requestId: null, retries: 0 };
evaluate(false, "user_scroll_end");
return;
}
if (pendingRequest) {
evaluate(false, "user_scroll_end");
return;
}
if (
!input.isNearBottom() ||
stickyMeasurementRevision !== lastVerifiedStickyMeasurementRevision
) {
pendingVerification = { requestId: null, retries: 0 };
evaluate(false, "user_scroll_end");
return;
}
markStickyMeasurementVerified();
return;
}
if (mode === "sticky-bottom") {
this.detachByUser();
}
},
detachByUser() {
if (mode === "detached") {
return;
@@ -551,9 +511,6 @@ function createBottomAnchorControllerDriver(
) {
markStickyMeasurementChanged();
}
if (isUserScrollActive) {
return;
}
const shouldRestick = __private__.shouldRestickOnViewportChange({
mode,
previousViewportWidth: params.previousViewportWidth,
@@ -572,9 +529,6 @@ function createBottomAnchorControllerDriver(
if (params.previousContentHeight !== params.contentHeight) {
markStickyMeasurementChanged();
}
if (isUserScrollActive) {
return;
}
const shouldRestick = __private__.shouldRestickOnContentChange({
mode,
previousContentHeight: params.previousContentHeight,
@@ -604,9 +558,6 @@ function createBottomAnchorControllerDriver(
return;
}
markStickyMeasurementChanged();
if (isUserScrollActive) {
return;
}
if (!pendingRequest) {
pendingVerification = { requestId: null, retries: 0 };
if (attemptHandle) {
@@ -620,9 +571,6 @@ function createBottomAnchorControllerDriver(
},
handleScrollNearBottomChange(params) {
const { nextIsNearBottom, scrollDelta } = params;
if (isUserScrollActive) {
return;
}
if (
nextIsNearBottom &&
mode === "sticky-bottom" &&
@@ -789,12 +737,6 @@ export function useBottomAnchorController(input: {
requestLocalAnchor(request: BottomAnchorLocalRequest) {
driverRef.current?.requestLocalAnchor(request);
},
beginUserScroll() {
driverRef.current?.beginUserScroll();
},
endUserScroll(params: { isNearBottom: boolean }) {
driverRef.current?.endUserScroll(params);
},
detachByUser() {
driverRef.current?.detachByUser();
},
@@ -11,7 +11,6 @@ import {
FlatList,
ActivityIndicator,
Keyboard,
Platform,
View,
type LayoutChangeEvent,
type ListRenderItemInfo,
@@ -89,8 +88,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
contentMeasuredForKey: null as string | null,
});
const scrollOffsetYRef = useRef(0);
const isUserScrollActiveRef = useRef(false);
const userScrollEndFrameIdRef = useRef<number | null>(null);
const programmaticScrollEventBudgetRef = useRef(0);
const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false);
const nativeViewportSettlingFrameIdRef = useRef<number | null>(null);
@@ -131,13 +128,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
}
}, []);
const clearPendingUserScrollEnd = useCallback(() => {
if (userScrollEndFrameIdRef.current !== null) {
cancelAnimationFrame(userScrollEndFrameIdRef.current);
userScrollEndFrameIdRef.current = null;
}
}, []);
const markNativeViewportSettling = useCallback(() => {
clearNativeViewportSettling();
setIsNativeViewportSettling(true);
@@ -199,12 +189,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
},
scrollToBottom,
});
// Android's maintainVisibleContentPosition ignores the list inversion transform and
// fights the controller's offset-zero correction while the live header grows.
const maintainVisibleContentPosition =
Platform.OS === "android" && bottomAnchorController.mode === "sticky-bottom"
? undefined
: DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION;
useEffect(() => {
streamViewportMetricsRef.current = {
@@ -217,8 +201,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
contentMeasuredForKey: null,
};
scrollOffsetYRef.current = 0;
isUserScrollActiveRef.current = false;
clearPendingUserScrollEnd();
clearNativeViewportSettling();
setIsNativeViewportSettling(false);
historyStartReadyRef.current = false;
@@ -227,9 +209,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
});
return () => {
cancelAnimationFrame(frame);
clearPendingUserScrollEnd();
};
}, [agentId, clearNativeViewportSettling, clearPendingUserScrollEnd]);
}, [agentId, clearNativeViewportSettling]);
useEffect(() => {
const keyboardEvents = [
@@ -278,19 +259,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
};
}, [agentId, bottomAnchorController, markNativeViewportSettling, viewportRef]);
const isScrollEventNearBottom = useStableEvent(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
return isNearBottomForStreamRenderStrategy({
strategy,
offsetY: contentOffset.y,
threshold: 32,
contentHeight: contentSize.height,
viewportHeight: layoutMeasurement.height,
});
},
);
const handleScroll = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
const previousOffsetY = scrollOffsetYRef.current;
@@ -305,7 +273,13 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
contentMeasuredForKey: "native-virtualized",
};
const nearBottom = isScrollEventNearBottom(event);
const nearBottom = isNearBottomForStreamRenderStrategy({
strategy,
offsetY: contentOffset.y,
threshold: 32,
contentHeight: streamViewportMetricsRef.current.contentHeight,
viewportHeight: streamViewportMetricsRef.current.viewportHeight,
});
onNearBottomChange(nearBottom);
const distanceFromOldestEdge =
@@ -320,11 +294,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
onNearHistoryStart();
}
if (
!isUserScrollActiveRef.current &&
programmaticScrollEventBudgetRef.current > 0 &&
contentOffset.y <= 8
) {
if (programmaticScrollEventBudgetRef.current > 0 && contentOffset.y <= 8) {
programmaticScrollEventBudgetRef.current -= 1;
} else {
programmaticScrollEventBudgetRef.current = 0;
@@ -335,37 +305,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
}
});
const handleScrollBeginDrag = useStableEvent(() => {
clearPendingUserScrollEnd();
isUserScrollActiveRef.current = true;
bottomAnchorController.beginUserScroll();
});
// Defer drag end so momentum can take ownership, but capture the terminal
// gesture position now because layout may move the viewport in the meantime.
const handleScrollEndDrag = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
const isNearBottom = isScrollEventNearBottom(event);
clearPendingUserScrollEnd();
userScrollEndFrameIdRef.current = requestAnimationFrame(() => {
userScrollEndFrameIdRef.current = null;
isUserScrollActiveRef.current = false;
bottomAnchorController.endUserScroll({ isNearBottom });
});
});
const handleMomentumScrollBegin = useStableEvent(() => {
clearPendingUserScrollEnd();
});
const handleMomentumScrollEnd = useStableEvent(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
const isNearBottom = isScrollEventNearBottom(event);
clearPendingUserScrollEnd();
isUserScrollActiveRef.current = false;
bottomAnchorController.endUserScroll({ isNearBottom });
},
);
const handleListLayout = useStableEvent((event: LayoutChangeEvent) => {
const previousViewportWidth = streamViewportMetricsRef.current.viewportWidth;
const previousViewportHeight = streamViewportMetricsRef.current.viewportHeight;
@@ -473,13 +412,9 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
style={listStyle}
onLayout={handleListLayout}
onScroll={handleScroll}
onScrollBeginDrag={handleScrollBeginDrag}
onScrollEndDrag={handleScrollEndDrag}
onMomentumScrollBegin={handleMomentumScrollBegin}
onMomentumScrollEnd={handleMomentumScrollEnd}
scrollEventThrottle={16}
onContentSizeChange={handleContentSizeChange}
maintainVisibleContentPosition={maintainVisibleContentPosition}
maintainVisibleContentPosition={DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION}
initialNumToRender={40}
maxToRenderPerBatch={40}
updateCellsBatchingPeriod={0}
@@ -199,89 +199,6 @@ describe("createWebStreamStrategy", () => {
expect(renderLiveHeadRow).toHaveBeenCalledTimes(2);
});
it("keeps bottom anchoring through subpixel browser rounding", () => {
const scrollTo = vi.fn();
HTMLElement.prototype.scrollTo = scrollTo;
const strategy = createWebStreamStrategy({ isMobileBreakpoint: false });
const viewportRef = React.createRef<StreamViewportHandle>();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root?.render(
strategy.render({
agentId: "agent",
segments: {
historyVirtualized: [],
historyMounted: [userMessage(1)],
liveHead: [],
},
boundary: {
hasVirtualizedHistory: false,
hasMountedHistory: true,
hasLiveHead: false,
},
renderers: createRenderers(vi.fn()),
listEmptyComponent: null,
viewportRef,
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: vi.fn(),
onNearHistoryStart: vi.fn(),
isLoadingOlderHistory: false,
hasOlderHistory: false,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
}),
);
});
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
if (!(scrollContainer instanceof HTMLElement)) {
throw new Error("Expected agent chat scroll container");
}
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 766 });
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 5725 });
Object.defineProperty(scrollContainer, "scrollTop", {
configurable: true,
value: 4959.1708984375,
});
scrollTo.mockClear();
act(() => {
viewportRef.current?.scrollToBottom("message-sent");
});
expect(scrollTo).toHaveBeenCalledWith({ top: 5725, behavior: "auto" });
scrollTo.mockClear();
Object.defineProperty(scrollContainer, "scrollTop", {
configurable: true,
value: 4960.5,
});
act(() => {
viewportRef.current?.scrollToBottom("message-sent");
});
expect(scrollTo).toHaveBeenCalledWith({ top: 5725, behavior: "auto" });
scrollTo.mockClear();
Object.defineProperty(scrollContainer, "scrollTop", {
configurable: true,
value: 4967,
});
act(() => {
viewportRef.current?.scrollToBottom("message-sent");
});
expect(scrollTo).not.toHaveBeenCalled();
});
it("fires near-history-start when the user scrolls near the top", async () => {
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
const viewportRef = React.createRef<StreamViewportHandle>();
@@ -22,7 +22,6 @@ type ScrollBehaviorLike = "auto" | "smooth";
const WEB_BOTTOM_SETTLE_TIMEOUT_MS = 200;
const USER_SCROLL_DELTA_EPSILON = 1;
const BOTTOM_OVERSCROLL_TOLERANCE_PX = 2;
const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64;
const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1;
const HISTORY_START_THRESHOLD_PX = 96;
@@ -89,8 +88,7 @@ function getScrollContainerDistanceFromBottom(
function isScrollContainerOverscrolledPastBottom(
scrollContainer: Pick<HTMLElement, "scrollTop" | "clientHeight" | "scrollHeight">,
): boolean {
// Browser zoom can leave scrollTop fractional while the height metrics remain integer-valued.
return getScrollContainerDistanceFromBottom(scrollContainer) < -BOTTOM_OVERSCROLL_TOLERANCE_PX;
return getScrollContainerDistanceFromBottom(scrollContainer) < 0;
}
function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: boolean }) {
+2 -2
View File
@@ -99,7 +99,7 @@ import {
useWorkspaceAttachmentsStore,
} from "@/attachments/workspace-attachments-store";
import type { WorkspaceComposerAttachment } from "@/attachments/types";
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/workspace-tabs/model";
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { toErrorMessage } from "@/utils/error-messages";
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
@@ -876,7 +876,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[pendingPermissions, agentId],
);
const showRunningTurnFooter = baseRenderModel.turnTiming.isActive;
const showRunningTurnFooter = context.status === "running";
const pendingPermissionsNode = useMemo(
() =>
renderPendingPermissionsNode({
+9 -6
View File
@@ -60,13 +60,13 @@ import {
resolveStartupBlocker,
resolveStartupNavigationReady,
shouldRunStartupGiveUpTimer,
startDaemonIfGateAllows,
startHostRuntimeBootstrap,
type StartupBlocker,
} from "@/navigation/host-runtime-bootstrap";
import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation";
import { ThemedStack } from "@/navigation/themed-stack";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { AgentNavigationListener } from "@/desktop/agent-navigation";
import { listenToDesktopEvent } from "@/desktop/electron/events";
import { updateDesktopWindowControls } from "@/desktop/electron/window";
import { getDesktopHost } from "@/desktop/host";
@@ -147,10 +147,9 @@ function PushNotificationRouter() {
const openNotification = useStableEvent((data: Record<string, unknown> | undefined) => {
const target = resolveNotificationTarget(data);
const serverId = target.serverId;
const workspaceId = target.workspaceId;
const agentId = target.agentId;
if (serverId && workspaceId && agentId) {
navigateToAgent({ serverId, workspaceId, agentId, pin: true });
if (serverId && agentId) {
navigateToAgent({ serverId, agentId, pin: true });
return;
}
@@ -338,6 +337,7 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
store,
daemonStartService,
shouldStartDaemon: shouldStartBuiltInDaemon,
onGateError: (message) => daemonStartService.recordError(message),
});
}, []);
@@ -376,7 +376,11 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
const retry = useCallback(() => {
const daemonStartService = getDaemonStartService({ store: getHostRuntimeStore() });
void daemonStartService.startIfEnabled({ shouldStart: shouldStartBuiltInDaemon });
startDaemonIfGateAllows({
daemonStartService,
shouldStartDaemon: shouldStartBuiltInDaemon,
onGateError: (message) => daemonStartService.recordError(message),
});
}, []);
const splashError =
@@ -910,7 +914,6 @@ function AppShell() {
<MobilePanelsProvider>
<HorizontalScrollProvider>
<OpenProjectListener />
<AgentNavigationListener />
<AppWithSidebar>
<WorkspaceRouteNavigationBridge />
<RootStack />
@@ -1,13 +1,10 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import { useEffect, useRef } from "react";
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { useFetchQuery } from "@/data/query";
import { resolveAgentRoute, type AgentRouteLookup } from "@/navigation/agent-route-resolution";
import { AgentRouteResolutionView } from "@/navigation/agent-route-resolution-view";
import { useSessionStore } from "@/stores/session-store";
import { getHostRuntimeStore, useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
import { toErrorMessage } from "@/utils/error-messages";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { buildHostRootRoute } from "@/utils/host-routes";
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
import { navigateToAgent } from "@/utils/navigate-to-agent";
export default function HostAgentReadyRoute() {
@@ -24,128 +21,97 @@ function HostAgentReadyRouteContent() {
serverId?: string;
agentId?: string;
}>();
const handledNavigationRef = useRef<string | null>(null);
const redirectedRef = useRef(false);
const serverId = typeof params.serverId === "string" ? params.serverId : "";
const agentId = typeof params.agentId === "string" ? params.agentId : "";
const hosts = useHosts();
const runtimeSnapshot = useHostRuntimeSnapshot(serverId);
const client = runtimeSnapshot?.client ?? null;
const connectionStatus = runtimeSnapshot?.connectionStatus ?? "connecting";
const hostName = hosts.find((host) => host.serverId === serverId)?.label ?? serverId;
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const agentWorkspaceId = useSessionStore((state) => {
if (!serverId || !agentId) {
return null;
}
return state.sessions[serverId]?.agents?.get(agentId)?.workspaceId ?? null;
});
const shouldLookupAgent = Boolean(
serverId && agentId && client && connectionStatus === "online" && !agentWorkspaceId,
const hasHydratedWorkspaces = useSessionStore((state) =>
serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false,
);
const lookupQuery = useFetchQuery({
queryKey: ["agentRouteResolution", serverId, agentId, runtimeSnapshot?.clientGeneration ?? 0],
queryFn: async () => {
if (!client) {
throw new Error("Target host client is unavailable");
}
const result = await client.fetchAgent({ agentId });
return result?.agent?.workspaceId ?? null;
},
enabled: shouldLookupAgent,
retry: false,
dataShape: "value",
staleTimeMs: 0,
});
const lookup = useMemo<AgentRouteLookup>(() => {
if (!shouldLookupAgent) {
return { kind: "idle" };
}
if (lookupQuery.isFetching) {
return { kind: "fetching" };
}
if (lookupQuery.isError) {
return { kind: "failed", error: toErrorMessage(lookupQuery.error) };
}
if (lookupQuery.isSuccess) {
return { kind: "found", workspaceId: lookupQuery.data };
}
return { kind: "fetching" };
}, [
lookupQuery.data,
lookupQuery.error,
lookupQuery.isError,
lookupQuery.isFetching,
lookupQuery.isSuccess,
shouldLookupAgent,
]);
const resolution = resolveAgentRoute({
serverId,
agentId,
cachedWorkspaceId: agentWorkspaceId,
connectionStatus,
lookup,
});
const resolvedWorkspaceId = normalizeWorkspaceOpaqueId(agentWorkspaceId);
useEffect(() => {
let navigationKey: string | null = null;
if (resolution.kind === "invalid") {
navigationKey = "invalid";
} else if (resolution.kind === "resolved") {
navigationKey = `workspace:${resolution.workspaceId}`;
} else if (resolution.kind === "notFound") {
navigationKey = "not-found";
if (redirectedRef.current) {
return;
}
if (!navigationKey || handledNavigationRef.current === navigationKey) {
if (!serverId || !agentId) {
redirectedRef.current = true;
router.replace("/" as Href);
return;
}
handledNavigationRef.current = navigationKey;
if (resolution.kind === "resolved") {
navigateToAgent({ serverId, agentId, workspaceId: resolution.workspaceId });
return;
if (resolvedWorkspaceId) {
redirectedRef.current = true;
navigateToAgent({
serverId,
agentId,
});
}
router.replace(resolution.kind === "invalid" ? ("/" as Href) : buildHostRootRoute(serverId));
}, [agentId, resolution, router, serverId]);
}, [agentId, resolvedWorkspaceId, router, serverId]);
const handleRetry = useCallback(() => {
if (resolution.kind === "lookupError") {
void lookupQuery.refetch();
useEffect(() => {
if (redirectedRef.current) {
return;
}
if (serverId) {
void getHostRuntimeStore().runProbeCycleNow(serverId);
}
}, [lookupQuery, resolution.kind, serverId]);
const handleManageHost = useCallback(() => {
if (serverId) {
router.push(buildSettingsHostRoute(serverId));
}
}, [router, serverId]);
const handleBack = useCallback(() => {
if (router.canGoBack()) {
router.back();
if (!serverId || !agentId) {
return;
}
router.replace(serverId ? buildHostRootRoute(serverId) : ("/" as Href));
}, [router, serverId]);
if (agentWorkspaceId && !hasHydratedWorkspaces) {
return;
}
if (!client || !isConnected) {
redirectedRef.current = true;
router.replace(buildHostRootRoute(serverId));
}
}, [agentWorkspaceId, agentId, client, hasHydratedWorkspaces, isConnected, router, serverId]);
if (
resolution.kind === "waitingForHost" ||
resolution.kind === "fetchingAgent" ||
resolution.kind === "lookupError"
) {
// Agent URLs intentionally omit workspaceId. Keep this route mounted while the target host
// reconnects, then resolve the workspace from the authoritative agent record.
return (
<AgentRouteResolutionView
resolution={resolution}
hostName={hostName}
lastHostError={runtimeSnapshot?.lastError ?? null}
onRetry={handleRetry}
onManageHost={handleManageHost}
onBack={handleBack}
/>
);
}
useEffect(() => {
if (redirectedRef.current) {
return;
}
if (!serverId || !agentId || !client || !isConnected) {
return;
}
let cancelled = false;
void client
.fetchAgent({ agentId })
.then((result) => {
if (cancelled || redirectedRef.current) {
return;
}
const workspaceId = normalizeWorkspaceOpaqueId(result?.agent?.workspaceId);
redirectedRef.current = true;
if (workspaceId) {
navigateToAgent({
serverId,
agentId,
workspaceId,
});
return;
}
router.replace(buildHostRootRoute(serverId));
return;
})
.catch(() => {
if (cancelled || redirectedRef.current) {
return;
}
redirectedRef.current = true;
router.replace(buildHostRootRoute(serverId));
});
return () => {
cancelled = true;
};
}, [agentId, client, isConnected, router, serverId]);
return null;
}
@@ -9,7 +9,7 @@ import {
useActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
import { useHasHydratedWorkspaces, useWorkspaceExists } from "@/stores/session-store-hooks";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store";
import {
@@ -110,9 +110,7 @@ function HostWorkspaceRouteContent() {
const openValue = getParamValue(globalParams.open);
const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId);
const workspaceExists = useWorkspaceExists(serverId, workspaceId);
const openIntent = useMemo(() => parseWorkspaceOpenIntent(openValue), [openValue]);
const recoveryAgentId = openIntent?.kind === "agent" ? openIntent.agentId : null;
const isAgentOpenIntent = recoveryAgentId !== null;
const isAgentOpenIntent = parseWorkspaceOpenIntent(openValue)?.kind === "agent";
const isOpenIntentWaitingForWorkspace = Boolean(
isAgentOpenIntent && (!hasHydratedWorkspaces || !workspaceExists),
);
@@ -149,6 +147,7 @@ function HostWorkspaceRouteContent() {
}
consumedIntentRef.current = consumptionKey;
const openIntent = parseWorkspaceOpenIntent(openValue);
if (openIntent) {
prepareWorkspaceTab({
serverId,
@@ -172,7 +171,6 @@ function HostWorkspaceRouteContent() {
hasHydratedWorkspaceLayoutStore,
isOpenIntentWaitingForWorkspace,
navigation,
openIntent,
openValue,
rootNavigationState?.key,
serverId,
@@ -187,16 +185,10 @@ function HostWorkspaceRouteContent() {
return null;
}
return <WorkspaceDeck recoveryRequested={isAgentOpenIntent} recoveryAgentId={recoveryAgentId} />;
return <WorkspaceDeck recoveryRequested={isAgentOpenIntent} />;
}
function WorkspaceDeck({
recoveryRequested,
recoveryAgentId,
}: {
recoveryRequested: boolean;
recoveryAgentId: string | null;
}) {
function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) {
const activeSelection = useActiveWorkspaceSelection();
const [mountedSelections, setMountedSelections] = useState<ActiveWorkspaceSelection[]>(() =>
activeSelection ? [activeSelection] : [],
@@ -242,7 +234,6 @@ function WorkspaceDeck({
selection={selection}
activeSelection={activeSelection}
recoveryRequested={recoveryRequested}
recoveryAgentId={recoveryAgentId}
onUnmountInactive={unmountWorkspaceSelection}
/>
);
@@ -255,13 +246,11 @@ function WorkspaceDeckEntry({
selection,
activeSelection,
recoveryRequested,
recoveryAgentId,
onUnmountInactive,
}: {
selection: ActiveWorkspaceSelection;
activeSelection: ActiveWorkspaceSelection;
recoveryRequested: boolean;
recoveryAgentId: string | null;
onUnmountInactive: (selection: ActiveWorkspaceSelection) => void;
}) {
const isActive = areWorkspaceSelectionsEqual(selection, activeSelection);
@@ -293,7 +282,6 @@ function WorkspaceDeckEntry({
workspaceId={selection.workspaceId}
isRouteFocused={isActive}
recoveryRequested={isActive && recoveryRequested}
recoveryAgentId={isActive ? recoveryAgentId : null}
/>
</RetainedPanel>
);
-11
View File
@@ -92,20 +92,9 @@ export interface ChatHistoryContextAttachment {
export const NEW_WORKSPACE_PICKER_ATTACHMENT_OWNER = "new-workspace-picker";
export type WorkspaceFileSelection =
| { kind: "whole_file" }
| { kind: "line_range"; startLine: number; endLine: number };
export interface WorkspaceFileComposerAttachment {
kind: "workspace_file";
path: string;
selection: WorkspaceFileSelection;
}
export type UserComposerAttachment =
| { kind: "image"; metadata: AttachmentMetadata }
| { kind: "file"; attachment: UploadedFileAttachment }
| WorkspaceFileComposerAttachment
| { kind: "forge_issue"; item: ForgeSearchItem }
| { kind: "forge_change_request"; item: ForgeSearchItem }
// COMPAT(githubAttachmentKinds): added in v0.1.106, remove after 2026-12-28 once daemon floor >= v0.1.106
@@ -1,9 +0,0 @@
import type { RefCallback } from "react";
import type { View } from "react-native";
import type { WorkspaceFileDragSourceInput } from "./workspace-file-drag-source.types";
export function useWorkspaceFileDragSource(
_input: WorkspaceFileDragSourceInput,
): RefCallback<View> | undefined {
return undefined;
}
@@ -1,54 +0,0 @@
import { useCallback, useEffect, useState, type RefCallback } from "react";
import type { View } from "react-native";
import { createWorkspaceFileAttachment } from "./workspace-file";
import { serializeWorkspaceFileDragPayload, WORKSPACE_FILE_DRAG_MIME } from "./workspace-file-drag";
import type { WorkspaceFileDragSourceInput } from "./workspace-file-drag-source.types";
export function useWorkspaceFileDragSource({
enabled,
disabled = false,
serverId,
workspaceId,
path,
selection,
}: WorkspaceFileDragSourceInput): RefCallback<View> {
const [element, setElement] = useState<HTMLElement | null>(null);
const dragSourceRef = useCallback((node: View | null) => {
setElement(node as unknown as HTMLElement | null);
}, []);
useEffect(() => {
if (!element || !enabled || disabled || !serverId || !workspaceId) {
if (element) {
element.draggable = false;
}
return;
}
const sourceServerId = serverId;
const sourceWorkspaceId = workspaceId;
element.draggable = true;
function handleDragStart(event: DragEvent) {
if (!event.dataTransfer) {
return;
}
event.dataTransfer.effectAllowed = "copy";
event.dataTransfer.setData(
WORKSPACE_FILE_DRAG_MIME,
serializeWorkspaceFileDragPayload({
version: 1,
serverId: sourceServerId,
workspaceId: sourceWorkspaceId,
attachment: createWorkspaceFileAttachment({ path, selection }),
}),
);
}
element.addEventListener("dragstart", handleDragStart);
return () => {
element.draggable = false;
element.removeEventListener("dragstart", handleDragStart);
};
}, [disabled, element, enabled, path, selection, serverId, workspaceId]);
return dragSourceRef;
}
@@ -1,10 +0,0 @@
import type { WorkspaceFileSelection } from "./types";
export interface WorkspaceFileDragSourceInput {
enabled: boolean;
disabled?: boolean;
serverId?: string;
workspaceId: string | null | undefined;
path: string;
selection?: WorkspaceFileSelection;
}
@@ -1,62 +0,0 @@
import { describe, expect, it } from "vitest";
import { createWorkspaceFileAttachment } from "./workspace-file";
import {
parseWorkspaceFileDragPayload,
resolveWorkspaceFileDrop,
serializeWorkspaceFileDragPayload,
type WorkspaceFileDragPayload,
} from "./workspace-file-drag";
function payload(): WorkspaceFileDragPayload {
return {
version: 1,
serverId: "server-1",
workspaceId: "workspace-1",
attachment: createWorkspaceFileAttachment({
path: "src/app.ts",
selection: { kind: "line_range", startLine: 12, endLine: 24 },
}),
};
}
describe("workspace file drag payload", () => {
it("round-trips workspace identity and future line selections", () => {
expect(parseWorkspaceFileDragPayload(serializeWorkspaceFileDragPayload(payload()))).toEqual(
payload(),
);
});
it("rejects malformed and invalid payloads", () => {
expect(parseWorkspaceFileDragPayload("not json")).toBeNull();
expect(
parseWorkspaceFileDragPayload(
JSON.stringify({
...payload(),
attachment: {
kind: "workspace_file",
path: "src/app.ts",
selection: { kind: "line_range", startLine: 24, endLine: 12 },
},
}),
),
).toBeNull();
});
it("accepts drops only within the originating server and workspace", () => {
const dragged = payload();
expect(
resolveWorkspaceFileDrop({
payload: dragged,
serverId: "server-1",
workspaceId: "workspace-1",
}),
).toEqual(dragged.attachment);
expect(
resolveWorkspaceFileDrop({
payload: dragged,
serverId: "server-1",
workspaceId: "workspace-2",
}),
).toBeNull();
});
});
@@ -1,55 +0,0 @@
import type { WorkspaceFileComposerAttachment } from "./types";
import { isWorkspaceFileComposerAttachment } from "./workspace-file";
export const WORKSPACE_FILE_DRAG_MIME = "application/x-paseo-workspace-file+json";
export interface WorkspaceFileDragPayload {
version: 1;
serverId: string;
workspaceId: string;
attachment: WorkspaceFileComposerAttachment;
}
export function serializeWorkspaceFileDragPayload(payload: WorkspaceFileDragPayload): string {
return JSON.stringify(payload);
}
export function parseWorkspaceFileDragPayload(serialized: string): WorkspaceFileDragPayload | null {
let value: unknown;
try {
value = JSON.parse(serialized);
} catch {
return null;
}
if (!value || typeof value !== "object") {
return null;
}
const record = value as Record<string, unknown>;
if (
record.version !== 1 ||
typeof record.serverId !== "string" ||
record.serverId.length === 0 ||
typeof record.workspaceId !== "string" ||
record.workspaceId.length === 0 ||
!isWorkspaceFileComposerAttachment(record.attachment)
) {
return null;
}
return {
version: 1,
serverId: record.serverId,
workspaceId: record.workspaceId,
attachment: record.attachment,
};
}
export function resolveWorkspaceFileDrop(input: {
payload: WorkspaceFileDragPayload;
serverId: string;
workspaceId: string;
}): WorkspaceFileComposerAttachment | null {
return input.payload.serverId === input.serverId &&
input.payload.workspaceId === input.workspaceId
? input.payload.attachment
: null;
}
@@ -1,71 +0,0 @@
import { describe, expect, it } from "vitest";
import type { UserComposerAttachment } from "@/attachments/types";
import {
appendWorkspaceFileAttachment,
createWorkspaceFileAttachment,
getWorkspaceFileAttachmentKey,
getWorkspaceFileAttachmentSubtitle,
workspaceFileAttachmentToAgentAttachment,
} from "./workspace-file";
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
describe("workspace file attachments", () => {
it("models whole files and line ranges as distinct selections", () => {
const wholeFile = createWorkspaceFileAttachment({ path: "src/app.ts" });
const lineRange = createWorkspaceFileAttachment({
path: "src/app.ts",
selection: { kind: "line_range", startLine: 12, endLine: 24 },
});
expect(wholeFile).toEqual({
kind: "workspace_file",
path: "src/app.ts",
selection: { kind: "whole_file" },
});
expect(getWorkspaceFileAttachmentKey(wholeFile)).not.toBe(
getWorkspaceFileAttachmentKey(lineRange),
);
expect(getWorkspaceFileAttachmentSubtitle(wholeFile)).toBe("src/app.ts");
expect(getWorkspaceFileAttachmentSubtitle(lineRange)).toBe("src/app.ts · 12-24");
});
it("deduplicates only identical paths and selections", () => {
const image = {
kind: "image" as const,
metadata: {
id: "image-1",
mimeType: "image/png",
storageType: "web-indexeddb" as const,
storageKey: "image-1",
createdAt: 1,
},
};
const wholeFile = createWorkspaceFileAttachment({ path: "src/app.ts" });
const range = createWorkspaceFileAttachment({
path: "src/app.ts",
selection: { kind: "line_range", startLine: 1, endLine: 5 },
});
const current: UserComposerAttachment[] = [image, wholeFile];
expect(appendWorkspaceFileAttachment(current, wholeFile)).toBe(current);
expect(appendWorkspaceFileAttachment(current, range)).toEqual([image, wholeFile, range]);
});
it("submits a path reference without uploading or inserting prompt text", () => {
const attachment = createWorkspaceFileAttachment({
path: "src/app.ts",
selection: { kind: "line_range", startLine: 12, endLine: 24 },
});
expect(workspaceFileAttachmentToAgentAttachment(attachment)).toEqual({
type: "text",
mimeType: "text/plain",
title: "app.ts",
text: "Workspace file: src/app.ts\nLines: 12-24",
});
expect(splitComposerAttachmentsForSubmit([attachment])).toEqual({
images: [],
attachments: [workspaceFileAttachmentToAgentAttachment(attachment)],
});
});
});
@@ -1,106 +0,0 @@
import type { AgentAttachment } from "@getpaseo/protocol/messages";
import type {
UserComposerAttachment,
WorkspaceFileComposerAttachment,
WorkspaceFileSelection,
} from "./types";
interface CreateWorkspaceFileAttachmentInput {
path: string;
selection?: WorkspaceFileSelection;
}
function normalizePath(path: string): string {
return path.trim().replace(/^\.\//, "");
}
export function isWorkspaceFileComposerAttachment(
value: unknown,
): value is WorkspaceFileComposerAttachment {
if (!value || typeof value !== "object") {
return false;
}
const record = value as Record<string, unknown>;
if (
record.kind !== "workspace_file" ||
typeof record.path !== "string" ||
record.path.trim().length === 0
) {
return false;
}
const selection = record.selection;
if (!selection || typeof selection !== "object") {
return false;
}
const { kind, startLine, endLine } = selection as Record<string, unknown>;
if (kind === "whole_file") {
return true;
}
return (
kind === "line_range" &&
typeof startLine === "number" &&
Number.isInteger(startLine) &&
typeof endLine === "number" &&
Number.isInteger(endLine) &&
startLine > 0 &&
endLine >= startLine
);
}
export function createWorkspaceFileAttachment({
path,
selection = { kind: "whole_file" },
}: CreateWorkspaceFileAttachmentInput): WorkspaceFileComposerAttachment {
return {
kind: "workspace_file",
path: normalizePath(path),
selection,
};
}
export function getWorkspaceFileAttachmentKey(attachment: WorkspaceFileComposerAttachment): string {
const selection = attachment.selection;
const selectionKey =
selection.kind === "whole_file"
? selection.kind
: `${selection.kind}:${selection.startLine}-${selection.endLine}`;
return `${normalizePath(attachment.path)}:${selectionKey}`;
}
export function appendWorkspaceFileAttachment(
current: UserComposerAttachment[],
attachment: WorkspaceFileComposerAttachment,
): UserComposerAttachment[] {
const attachmentKey = getWorkspaceFileAttachmentKey(attachment);
const alreadyAttached = current.some(
(candidate) =>
candidate.kind === "workspace_file" &&
getWorkspaceFileAttachmentKey(candidate) === attachmentKey,
);
return alreadyAttached ? current : [...current, attachment];
}
export function workspaceFileAttachmentToAgentAttachment(
attachment: WorkspaceFileComposerAttachment,
): Extract<AgentAttachment, { type: "text" }> {
const fileName = attachment.path.split("/").pop() ?? attachment.path;
const lines =
attachment.selection.kind === "line_range"
? `\nLines: ${attachment.selection.startLine}-${attachment.selection.endLine}`
: "";
return {
type: "text",
mimeType: "text/plain",
title: fileName,
text: `Workspace file: ${attachment.path}${lines}`,
};
}
export function getWorkspaceFileAttachmentSubtitle(
attachment: WorkspaceFileComposerAttachment,
): string {
if (attachment.selection.kind === "whole_file") {
return attachment.path;
}
return `${attachment.path} · ${attachment.selection.startLine}-${attachment.selection.endLine}`;
}
@@ -4,8 +4,10 @@ import { createJSONStorage, type StateStorage } from "zustand/middleware";
import { mountBrowserAutomationHandler } from "./handler";
import type { DesktopHostBridge } from "@/desktop/host";
import { useBrowserStore } from "@/stores/browser-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import {
buildWorkspaceTabPersistenceKey,
useWorkspaceLayoutStore,
} from "@/stores/workspace-layout-store";
type BrowserAutomationExecuteRequest = Extract<
SessionOutboundMessage,
@@ -6,8 +6,11 @@ import {
resizeResidentBrowserWebview,
} from "@/components/browser-webview-resident";
import { createWorkspaceBrowser, getBrowserRecord, useBrowserStore } from "@/stores/browser-store";
import { collectAllTabs, useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import {
buildWorkspaceTabPersistenceKey,
collectAllTabs,
useWorkspaceLayoutStore,
} from "@/stores/workspace-layout-store";
type BrowserAutomationExecuteRequest = Extract<
SessionOutboundMessage,
@@ -1,5 +1,5 @@
import type { Agent } from "@/stores/session-store";
import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model";
import type { WorkspaceDraftTabSetup } from "@/stores/workspace-tabs-store";
export type ClientSlashCommandKind = "archive-agent" | "replace-agent-with-draft";
export type ClientSlashCommandExecution = "immediate" | "insert";
@@ -3,7 +3,7 @@ import type { ReactNode, Ref } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { Modal, Platform, Pressable, ScrollView, Text, TextInput, View } from "react-native";
import type { StyleProp, TextInputProps, ViewStyle } from "react-native";
import type { TextInputProps } from "react-native";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root";
@@ -11,10 +11,9 @@ import {
BottomSheetBackdrop,
BottomSheetScrollView,
BottomSheetTextInput,
useBottomSheetInternal,
type BottomSheetBackgroundProps,
} from "@gorhom/bottom-sheet";
import Animated, { useAnimatedStyle } from "react-native-reanimated";
import Animated from "react-native-reanimated";
import { ArrowLeft, Search, X } from "lucide-react-native";
import {
IsolatedBottomSheetModal,
@@ -204,14 +203,6 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[4],
minHeight: 0,
},
bottomSheetVisibleContent: {
minHeight: 0,
overflow: "hidden",
},
bottomSheetVisibleScroll: {
flex: 1,
minHeight: 0,
},
desktopStaticContent: {
flexShrink: 1,
minHeight: 0,
@@ -257,39 +248,6 @@ function SheetBackground({ style }: BottomSheetBackgroundProps) {
return <Animated.View pointerEvents="none" style={combinedStyle} />;
}
function BottomSheetVisibleContent({ children }: { children: ReactNode }) {
const { animatedDetentsState, animatedKeyboardState, animatedLayoutState, animatedPosition } =
useBottomSheetInternal();
const visibleContentStyle = useAnimatedStyle(() => {
const { containerHeight, handleHeight } = animatedLayoutState.get();
if (containerHeight < 0 || handleHeight < 0) {
return { height: 0 };
}
const initialDetentPosition = animatedDetentsState.get().detents?.[0];
const contentPosition =
initialDetentPosition == null
? animatedPosition.get()
: Math.min(animatedPosition.get(), initialDetentPosition);
return {
height: Math.max(
0,
containerHeight -
contentPosition -
handleHeight -
animatedKeyboardState.get().heightWithinContainer,
),
};
}, [animatedDetentsState, animatedKeyboardState, animatedLayoutState, animatedPosition]);
return (
<Animated.View style={[styles.bottomSheetVisibleContent, visibleContentStyle]}>
{children}
</Animated.View>
);
}
export type AdaptiveTextInputProps = TextInputProps & {
initialValue?: string;
resetKey?: string | number;
@@ -494,16 +452,14 @@ export interface AdaptiveModalSheetProps {
children: ReactNode;
/** Sticky footer rendered below the scrollable content. */
footer?: ReactNode;
footerContainerStyle?: StyleProp<ViewStyle>;
snapPoints?: string[];
testID?: string;
/** Override the max width of the desktop card. */
desktopMaxWidth?: number;
scrollable?: boolean;
presentation?: "push" | "replace";
contentContainerStyle?: StyleProp<ViewStyle>;
/** Size compact sheet content to the live snap height instead of its largest snap point. */
sizeContentToCurrentSnapPoint?: boolean;
/** Whether gestures may dismiss the compact bottom sheet. */
dismissible?: boolean;
}
export function AdaptiveModalSheet({
@@ -513,14 +469,12 @@ export function AdaptiveModalSheet({
onDismiss,
children,
footer,
footerContainerStyle,
snapPoints,
testID,
desktopMaxWidth,
scrollable = true,
presentation,
contentContainerStyle,
sizeContentToCurrentSnapPoint = false,
dismissible = true,
}: AdaptiveModalSheetProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
@@ -539,37 +493,31 @@ export function AdaptiveModalSheet({
[footer, insets.bottom, isMobile, theme.spacing],
);
const bottomSheetContentStyle = useMemo(
// Gorhom spreads this outer array into StyleSheet.compose, which accepts two arguments on web.
() => [
styles.bottomSheetContent,
[
contentContainerStyle,
compactSafeAreaPadding.contentPaddingBottom != null
? { paddingBottom: compactSafeAreaPadding.contentPaddingBottom }
: null,
],
],
[compactSafeAreaPadding.contentPaddingBottom, contentContainerStyle],
);
const bottomSheetStaticContentStyle = useMemo(
() => [
styles.bottomSheetStaticContent,
contentContainerStyle,
compactSafeAreaPadding.contentPaddingBottom != null
? { paddingBottom: compactSafeAreaPadding.contentPaddingBottom }
: null,
],
[compactSafeAreaPadding.contentPaddingBottom, contentContainerStyle],
[compactSafeAreaPadding.contentPaddingBottom],
);
const bottomSheetStaticContentStyle = useMemo(
() => [
styles.bottomSheetStaticContent,
compactSafeAreaPadding.contentPaddingBottom != null
? { paddingBottom: compactSafeAreaPadding.contentPaddingBottom }
: null,
],
[compactSafeAreaPadding.contentPaddingBottom],
);
const footerStyle = useMemo(
() => [
styles.footer,
footerContainerStyle,
compactSafeAreaPadding.footerPaddingBottom != null
? { paddingBottom: compactSafeAreaPadding.footerPaddingBottom }
: null,
],
[compactSafeAreaPadding.footerPaddingBottom, footerContainerStyle],
[compactSafeAreaPadding.footerPaddingBottom],
);
const handleIndicatorStyle = useMemo(
() => ({ backgroundColor: theme.colors.palette.zinc[600] }),
@@ -654,12 +602,26 @@ export function AdaptiveModalSheet({
}, [visible, isMobile, notifyNativeModalDismiss]);
if (isMobile) {
const sheetContent = (
<>
return (
<IsolatedBottomSheetModal
ref={sheetRef}
snapPoints={resolvedSnapPoints}
index={0}
enableDynamicSizing={false}
onChange={handleSheetChange}
onDismiss={handleDismiss}
backdropComponent={renderBackdrop}
enablePanDownToClose={dismissible}
backgroundComponent={SheetBackground}
handleIndicatorStyle={handleIndicatorStyle}
keyboardBehavior="extend"
keyboardBlurBehavior="restore"
accessible={false}
presentation={presentation}
>
<SheetHeaderView header={header} onClose={onClose} testID={testID} />
{scrollable ? (
<BottomSheetScrollView
style={sizeContentToCurrentSnapPoint ? styles.bottomSheetVisibleScroll : undefined}
contentContainerStyle={bottomSheetContentStyle}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
@@ -670,31 +632,6 @@ export function AdaptiveModalSheet({
<View style={bottomSheetStaticContentStyle}>{children}</View>
)}
{footer ? <View style={footerStyle}>{footer}</View> : null}
</>
);
return (
<IsolatedBottomSheetModal
ref={sheetRef}
snapPoints={resolvedSnapPoints}
index={0}
enableDynamicSizing={false}
onChange={handleSheetChange}
onDismiss={handleDismiss}
backdropComponent={renderBackdrop}
enablePanDownToClose
backgroundComponent={SheetBackground}
handleIndicatorStyle={handleIndicatorStyle}
keyboardBehavior="extend"
keyboardBlurBehavior="restore"
accessible={false}
presentation={presentation}
>
{sizeContentToCurrentSnapPoint ? (
<BottomSheetVisibleContent>{sheetContent}</BottomSheetVisibleContent>
) : (
sheetContent
)}
</IsolatedBottomSheetModal>
);
}
@@ -706,7 +643,7 @@ export function AdaptiveModalSheet({
<View style={styles.desktopScrollContainer}>
<ScrollView
style={styles.desktopScroll}
contentContainerStyle={[styles.desktopContent, contentContainerStyle]}
contentContainerStyle={styles.desktopContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator
>
@@ -714,7 +651,7 @@ export function AdaptiveModalSheet({
</ScrollView>
</View>
) : (
<View style={[styles.desktopStaticContent, contentContainerStyle]}>{children}</View>
<View style={styles.desktopStaticContent}>{children}</View>
)}
{footer ? <View style={footerStyle}>{footer}</View> : null}
</>
@@ -394,7 +394,6 @@ export function AgentList({
serverId,
agentId,
workspaceId: agent.workspaceId,
pin: true,
});
},
[isActionSheetVisible, onAgentSelect],
@@ -9,7 +9,6 @@ import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { Button } from "@/components/ui/button";
import type { Theme } from "@/styles/theme";
import { toErrorMessage } from "@/utils/error-messages";
interface ArchivedAgentCalloutProps {
serverId: string;
@@ -22,7 +21,6 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const [isUnarchiving, setIsUnarchiving] = useState(false);
const [unarchiveError, setUnarchiveError] = useState<string | null>(null);
const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({ mode: "translate" });
@@ -34,11 +32,10 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
const handleUnarchive = useCallback(async () => {
if (!client || !isConnected || isUnarchiving) return;
setIsUnarchiving(true);
setUnarchiveError(null);
try {
await client.refreshAgent(agentId);
} catch (error) {
setUnarchiveError(toErrorMessage(error));
console.error("[ArchivedAgentCallout] Failed to unarchive agent:", error);
setIsUnarchiving(false);
}
}, [client, isConnected, isUnarchiving, agentId]);
@@ -47,23 +44,16 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
<Animated.View style={containerStyle}>
<View style={styles.inputAreaContainer}>
<View style={styles.inputAreaContent}>
<View style={styles.calloutStack}>
<View style={styles.callout}>
<Text style={styles.calloutText}>{t("agentPanel.archived.callout")}</Text>
<Button
size="sm"
variant="secondary"
onPress={handleUnarchive}
disabled={!isConnected || isUnarchiving}
>
{t("agentPanel.archived.unarchive")}
</Button>
</View>
{unarchiveError ? (
<Text style={styles.errorText} testID="agent-unarchive-error">
{unarchiveError}
</Text>
) : null}
<View style={styles.callout}>
<Text style={styles.calloutText}>{t("agentPanel.archived.callout")}</Text>
<Button
size="sm"
variant="secondary"
onPress={handleUnarchive}
disabled={!isConnected || isUnarchiving}
>
{t("agentPanel.archived.unarchive")}
</Button>
</View>
</View>
</View>
@@ -107,16 +97,8 @@ const styles = StyleSheet.create((theme: Theme) => ({
md: theme.spacing[6],
},
},
calloutStack: {
gap: theme.spacing[2],
},
calloutText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
},
errorText: {
color: theme.colors.statusDanger,
fontSize: theme.fontSize.sm,
textAlign: "center",
},
})) as unknown as Record<string, object>;
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,4 @@
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-layout-store";
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import type { WorkspaceDescriptor } from "@/stores/session-store";
@@ -18,16 +18,17 @@ interface ContextWindowMeterProps {
provider?: string | null;
/** Reserve the meter footprint and show a loading ring while usage is pending. */
pending?: boolean;
/** Optional glyph envelope for icon-toolbar alignment. */
glyphSize?: number;
}
const SVG_SIZE = 14;
const COMPACT_SVG_SIZE = 12;
const CENTER = SVG_SIZE / 2;
const COMPACT_CENTER = COMPACT_SVG_SIZE / 2;
const RADIUS = 6;
const COMPACT_RADIUS = 5;
const STROKE_WIDTH = 2;
const COMPACT_STROKE_WIDTH = 1.75;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
const COMPACT_CIRCUMFERENCE = 2 * Math.PI * COMPACT_RADIUS;
function isValidMaxTokens(value: number): boolean {
@@ -73,7 +74,7 @@ function getMeterColors(
return { progress: theme.colors.foregroundMuted, track };
}
function getMeterGeometry(showPercentage: boolean, glyphSize?: number) {
function getMeterGeometry(showPercentage: boolean) {
if (showPercentage) {
return {
svgSize: COMPACT_SVG_SIZE,
@@ -84,14 +85,12 @@ function getMeterGeometry(showPercentage: boolean, glyphSize?: number) {
containerStyle: styles.containerWithLabel,
};
}
const resolvedSize = glyphSize ?? SVG_SIZE;
const resolvedStrokeWidth = glyphSize ? 2 : STROKE_WIDTH;
return {
svgSize: resolvedSize,
center: resolvedSize / 2,
radius: (resolvedSize - resolvedStrokeWidth) / 2,
strokeWidth: resolvedStrokeWidth,
circumference: Math.PI * (resolvedSize - resolvedStrokeWidth),
svgSize: SVG_SIZE,
center: CENTER,
radius: RADIUS,
strokeWidth: STROKE_WIDTH,
circumference: CIRCUMFERENCE,
containerStyle: styles.container,
};
}
@@ -104,7 +103,6 @@ export function ContextWindowMeter({
serverId,
provider,
pending = false,
glyphSize,
}: ContextWindowMeterProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
@@ -125,7 +123,7 @@ export function ContextWindowMeter({
[refreshProviderUsage],
);
const geometry = getMeterGeometry(showPercentage, glyphSize);
const geometry = getMeterGeometry(showPercentage);
// No usage yet: reserve the footprint with a track-only ring while a session is
// active so the real ring fades in without shifting siblings. Render nothing when
+2 -3
View File
@@ -4,7 +4,6 @@ import { StyleSheet } from "react-native-unistyles";
interface DiffStatProps {
additions: number;
deletions: number;
testID?: string;
}
const compactFormatter = new Intl.NumberFormat("en-US", {
@@ -16,9 +15,9 @@ export function formatDiffCount(value: number): string {
return compactFormatter.format(value).toLowerCase();
}
export function DiffStat({ additions, deletions, testID }: DiffStatProps) {
export function DiffStat({ additions, deletions }: DiffStatProps) {
return (
<View style={styles.row} testID={testID}>
<View style={styles.row}>
<Text style={styles.additions}>+{formatDiffCount(additions)}</Text>
<Text style={styles.deletions}>-{formatDiffCount(deletions)}</Text>
</View>
@@ -36,11 +36,6 @@ import { RetainedPanelActivity } from "@/components/retained-panel";
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
import { buildWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
import { resolveDesktopExplorerWidth } from "@/components/desktop-sidebar-layout";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { resolveFocusedChatTarget } from "@/composer/focused-chat-target";
import { createWorkspaceFileAttachment } from "@/attachments/workspace-file";
import { useDraftStore } from "@/stores/draft-store";
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
@@ -391,16 +386,15 @@ function ExplorerSidebarContent({
{/* Content based on active tab */}
<View style={styles.contentArea} testID="explorer-content-area">
{resolvedTab === "changes" && (
<ChangedFilesPane
<GitDiffPane
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
isOpen={isOpen}
onOpenFile={onOpenFile}
cwd={workspaceRoot}
enabled={isOpen}
/>
)}
{resolvedTab === "files" && (
<FilesPane
<FileExplorerPane
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
@@ -421,83 +415,6 @@ function ExplorerSidebarContent({
);
}
/**
* Shared add-to-chat state for the changes/files panes: both expose an "add file
* to chat" action that attaches the file to the focused chat's composer.
* Available only when a workspace with a focused chat is available.
*/
function useAddFileToChat({
serverId,
workspaceId,
}: Pick<SidebarContentProps, "serverId" | "workspaceId">) {
const workspaceKey = workspaceId
? buildWorkspaceTabPersistenceKey({ serverId, workspaceId })
: null;
const layout = useWorkspaceLayoutStore((state) =>
workspaceKey ? state.layoutByWorkspace[workspaceKey] : undefined,
);
const focusTab = useWorkspaceLayoutStore((state) => state.focusTab);
const focusedChat = useMemo(
() => resolveFocusedChatTarget({ serverId, layout }),
[serverId, layout],
);
const addFile = useCallback(
(filePath: string) => {
if (!focusedChat || !workspaceKey) {
return;
}
void useDraftStore.getState().attachWorkspaceFile({
draftKey: focusedChat.draftKey,
attachment: createWorkspaceFileAttachment({ path: filePath }),
});
focusTab(workspaceKey, focusedChat.tabId);
},
[focusTab, focusedChat, workspaceKey],
);
return { addFile, canAddToChat: focusedChat !== null };
}
function ChangedFilesPane({
serverId,
workspaceId,
workspaceRoot,
isOpen,
onOpenFile,
}: Pick<
SidebarContentProps,
"serverId" | "workspaceId" | "workspaceRoot" | "isOpen" | "onOpenFile"
>) {
const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId });
return (
<GitDiffPane
serverId={serverId}
workspaceId={workspaceId}
cwd={workspaceRoot}
enabled={isOpen}
onOpenFile={onOpenFile}
onAddToChat={canAddToChat ? addFile : undefined}
/>
);
}
function FilesPane({
serverId,
workspaceId,
workspaceRoot,
onOpenFile,
}: Pick<SidebarContentProps, "serverId" | "workspaceId" | "workspaceRoot" | "onOpenFile">) {
const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId });
return (
<FileExplorerPane
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
onOpenFile={onOpenFile}
onAddToChat={canAddToChat ? addFile : undefined}
/>
);
}
interface PrTabContentProps {
serverId: string;
cwd: string;
@@ -1,185 +0,0 @@
import { useMemo, type ReactElement, type ReactNode } from "react";
import { type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
Copy,
Download,
FileText,
MessageSquarePlus,
MoreVertical,
type LucideIcon,
} from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { ICON_SIZE, SPACING, type Theme } from "@/styles/theme";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const ThemedMoreVertical = withUnistyles(MoreVertical);
/** Width occupied by a file action trigger, including its visual padding. */
export const FILE_ACTIONS_MENU_WIDTH = ICON_SIZE.sm + 2 * SPACING[1];
interface FileAction {
key: string;
label: string;
icon: LucideIcon;
onSelect: () => void;
testID?: string;
}
interface FileActionsMenuProps {
fileKind: "file" | "directory";
fileExists?: boolean;
onOpenFile?: () => void;
onCopyPath?: () => void;
onDownload?: () => void;
onAddToChat?: () => void;
/** Optional metadata block rendered above the actions (e.g. size/modified). */
header?: ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
hitSlop?: number;
accessibilityLabel: string;
testIDPrefix?: string;
}
// The menu lives inside pressable rows (diff header, explorer entry); stop the
// press so opening it doesn't also trigger the row.
function stopTriggerPropagation(event: { stopPropagation?: () => void }) {
event.stopPropagation?.();
}
function triggerStyle({
hovered,
pressed,
open,
}: PressableStateCallbackType & { hovered?: boolean; open?: boolean }) {
return [styles.trigger, (Boolean(hovered) || pressed || Boolean(open)) && styles.triggerActive];
}
/**
* Shared kebab (⋮) menu for per-file actions. Used by the file explorer tree and
* git diff pane so both surfaces share action availability, ordering, and chrome.
*/
export function FileActionsMenu({
fileKind,
fileExists = true,
onOpenFile,
onCopyPath,
onDownload,
onAddToChat,
header,
open,
onOpenChange,
hitSlop = 12,
accessibilityLabel,
testIDPrefix,
}: FileActionsMenuProps): ReactElement | null {
const { t } = useTranslation();
const actions = useMemo<FileAction[]>(() => {
const availableFile = fileKind === "file" && fileExists;
const next: FileAction[] = [];
if (availableFile && onOpenFile) {
next.push({
key: "open-file",
label: t("workspace.fileActions.openFile"),
icon: FileText,
onSelect: onOpenFile,
testID: testIDPrefix ? `${testIDPrefix}-open-file` : undefined,
});
}
if (onCopyPath) {
next.push({
key: "copy-path",
label: t("workspace.fileActions.copyPath"),
icon: Copy,
onSelect: onCopyPath,
});
}
if (availableFile && onDownload) {
next.push({
key: "download",
label: t("workspace.fileActions.download"),
icon: Download,
onSelect: onDownload,
});
}
if (availableFile && onAddToChat) {
next.push({
key: "add-to-chat",
label: t("workspace.fileActions.addToChat"),
icon: MessageSquarePlus,
onSelect: onAddToChat,
testID: testIDPrefix ? `${testIDPrefix}-add-to-chat` : undefined,
});
}
return next;
}, [fileExists, fileKind, onAddToChat, onCopyPath, onDownload, onOpenFile, t, testIDPrefix]);
if (actions.length === 0) {
return null;
}
return (
<DropdownMenu open={open} onOpenChange={onOpenChange}>
<DropdownMenuTrigger
hitSlop={hitSlop}
onPressIn={stopTriggerPropagation}
style={triggerStyle}
accessibilityLabel={accessibilityLabel}
testID={testIDPrefix ? `${testIDPrefix}-actions` : undefined}
>
<ThemedMoreVertical size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={220}>
{header ? (
<>
{header}
<DropdownMenuSeparator />
</>
) : null}
{actions.map((action) => (
<FileActionMenuItem key={action.key} action={action} />
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
function FileActionMenuItem({ action }: { action: FileAction }): ReactElement {
const Icon = action.icon;
const ThemedIcon = useMemo(() => withUnistyles(Icon), [Icon]);
const leading = useMemo(
() => <ThemedIcon size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />,
[ThemedIcon],
);
return (
<DropdownMenuItem leading={leading} onSelect={action.onSelect} testID={action.testID}>
{action.label}
</DropdownMenuItem>
);
}
const styles = StyleSheet.create((theme) => ({
trigger: {
// The hover box comes from padding, but an equal negative vertical margin
// cancels its height contribution so the trigger overlaps the row's natural
// line height instead of growing it. The comfortable tap target is `hitSlop`,
// never padding.
padding: theme.spacing[1],
width: FILE_ACTIONS_MENU_WIDTH,
marginVertical: -theme.spacing[1],
borderRadius: theme.borderRadius.md,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
triggerActive: {
backgroundColor: theme.colors.surface2,
},
}));
@@ -1,5 +1,4 @@
import type { ImageAttachment } from "@/composer/types";
import type { WorkspaceFileDragPayload } from "@/attachments/workspace-file-drag";
export interface DroppedFileItem {
kind: "web-file";
@@ -19,5 +18,4 @@ export type DroppedItem = DroppedFileItem | DroppedPathItem;
export interface FileDropSink {
onFiles: (images: ImageAttachment[]) => void;
onGenericFiles?: (items: DroppedItem[]) => void;
onWorkspaceFile?: (payload: WorkspaceFileDragPayload) => void;
}
@@ -11,10 +11,6 @@ import {
} from "@/attachments/file-types";
import { isWeb } from "@/constants/platform";
import type { DroppedItem, DroppedPathItem, FileDropSink } from "./types";
import {
parseWorkspaceFileDragPayload,
WORKSPACE_FILE_DRAG_MIME,
} from "@/attachments/workspace-file-drag";
type DesktopDragDropPayload =
| { type: "enter"; paths: string[] }
@@ -189,11 +185,7 @@ export function useDropListeners({
if (disabledRef.current) return;
dragCounter.current++;
if (suppressed.value || !hasSink.value) return;
const types = new Set(e.dataTransfer?.types ?? []);
const acceptsWorkspaceFile =
types.has(WORKSPACE_FILE_DRAG_MIME) && Boolean(getSink()?.onWorkspaceFile);
if (types.has("Files") || acceptsWorkspaceFile) {
if (e.dataTransfer?.types.includes("Files")) {
isDragging.value = true;
}
}
@@ -205,11 +197,7 @@ export function useDropListeners({
if (!e.dataTransfer) return;
// Only advertise "copy" when the drop would actually be accepted, so the cursor doesn't
// promise a drop that the handler then discards (suppressed/archived/no consumer mounted).
const types = new Set(e.dataTransfer.types);
const acceptsWorkspaceFile =
types.has(WORKSPACE_FILE_DRAG_MIME) && Boolean(getSink()?.onWorkspaceFile);
const acceptsDrop = types.has("Files") || acceptsWorkspaceFile;
const canAccept = acceptsDrop && !disabledRef.current && !suppressed.value && hasSink.value;
const canAccept = !disabledRef.current && !suppressed.value && hasSink.value;
e.dataTransfer.dropEffect = canAccept ? "copy" : "none";
}
@@ -237,14 +225,6 @@ export function useDropListeners({
const sink = getSink();
if (!sink) return;
const serializedWorkspaceFile = e.dataTransfer?.getData(WORKSPACE_FILE_DRAG_MIME);
if (serializedWorkspaceFile && sink.onWorkspaceFile) {
const payload = parseWorkspaceFileDragPayload(serializedWorkspaceFile);
if (payload) {
sink.onWorkspaceFile(payload);
}
}
const files = Array.from(e.dataTransfer?.files ?? []);
const genericItems: DroppedItem[] = files.map((file) => ({
kind: "web-file",
+157 -111
View File
@@ -15,26 +15,36 @@ import {
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import * as Clipboard from "expo-clipboard";
import { ChevronDown, Eye, EyeOff, RotateCw } from "lucide-react-native";
import { MaterialFileIcon } from "@/components/material-file-icon";
import { SvgXml } from "react-native-svg";
import {
TreeChevron,
TreeIndentGuides,
treeRowPaddingLeft,
WORKSPACE_FILE_ROW_VERTICAL_PADDING,
} from "@/components/tree-primitives";
ChevronDown,
Copy,
Download,
Eye,
EyeOff,
MoreVertical,
RotateCw,
} from "lucide-react-native";
import { getFileIconSvg } from "@/components/material-file-icons";
import { TreeChevron, TreeIndentGuides, TREE_INDENT_PER_LEVEL } from "@/components/tree-primitives";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
import { useHosts } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { FileActionsMenu } from "@/components/file-actions-menu";
import { useFileDownload } from "@/hooks/use-file-download";
import { useDownloadStore } from "@/stores/download-store";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
import { usePanelStore, type SortOption } from "@/stores/panel-store";
import { formatTimeAgo } from "@/utils/time";
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
import { filterVisibleExplorerEntries, isHiddenExplorerPath } from "@/file-explorer/visibility";
import { useWorkspaceFileDragSource } from "@/attachments/use-workspace-file-drag-source";
const SORT_OPTIONS: { value: SortOption }[] = [
{ value: "name" },
@@ -53,8 +63,6 @@ function formatFileSize({ size }: { size: number }): string {
}
interface TreeRowItemProps {
serverId: string;
workspaceId?: string | null;
entry: ExplorerEntry;
depth: number;
isExpanded: boolean;
@@ -63,8 +71,21 @@ interface TreeRowItemProps {
onEntryPress: (entry: ExplorerEntry) => void;
onCopyPath: (path: string) => void;
onDownloadEntry: (entry: ExplorerEntry) => void;
onAddToChat?: (path: string) => void;
testID?: string;
}
function stopPressInPropagation(event: { stopPropagation?: () => void }) {
event.stopPropagation?.();
}
function menuButtonStyle({
hovered,
pressed,
open,
}: PressableStateCallbackType & { hovered?: boolean; open?: boolean }) {
return [
styles.menuButton,
(Boolean(hovered) || pressed || Boolean(open)) && styles.menuButtonActive,
];
}
function sortTriggerStyle({
@@ -83,8 +104,6 @@ function treeRowKeyExtractor(row: TreeRow) {
}
function TreeRowItem({
serverId,
workspaceId,
entry,
depth,
isExpanded,
@@ -93,17 +112,10 @@ function TreeRowItem({
onEntryPress,
onCopyPath,
onDownloadEntry,
onAddToChat,
testID,
}: TreeRowItemProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const isDirectory = entry.kind === "directory";
const dragSourceRef = useWorkspaceFileDragSource({
enabled: !isDirectory,
serverId,
workspaceId,
path: entry.path,
});
const handlePress = useCallback(() => {
onEntryPress(entry);
@@ -112,10 +124,10 @@ function TreeRowItem({
const pressableStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.entryRow,
{ paddingLeft: treeRowPaddingLeft(depth) },
{ paddingLeft: theme.spacing[2] + depth * TREE_INDENT_PER_LEVEL },
(Boolean(hovered) || pressed || isSelected) && styles.entryRowActive,
],
[depth, isSelected],
[depth, isSelected, theme.spacing],
);
const handleCopy = useCallback(() => {
@@ -126,42 +138,23 @@ function TreeRowItem({
onDownloadEntry(entry);
}, [onDownloadEntry, entry]);
const handleAddToChat = useCallback(() => {
onAddToChat?.(entry.path);
}, [onAddToChat, entry.path]);
const metaHeader = useMemo(
() => (
<View style={styles.contextMetaBlock}>
<View style={styles.contextMetaRow}>
<Text style={styles.contextMetaLabel} numberOfLines={1}>
{t("workspace.fileExplorer.context.size")}
</Text>
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
{formatFileSize({ size: entry.size })}
</Text>
</View>
<View style={styles.contextMetaRow}>
<Text style={styles.contextMetaLabel} numberOfLines={1}>
{t("workspace.fileExplorer.context.modified")}
</Text>
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
{formatTimeAgo(new Date(entry.modifiedAt))}
</Text>
</View>
</View>
),
[entry.modifiedAt, entry.size, t],
const copyLeading = useMemo(
() => <Copy size={14} color={theme.colors.foregroundMuted} />,
[theme.colors.foregroundMuted],
);
const downloadLeading = useMemo(
() => <Download size={14} color={theme.colors.foregroundMuted} />,
[theme.colors.foregroundMuted],
);
return (
<Pressable onPress={handlePress} style={pressableStyle} testID={testID}>
<Pressable onPress={handlePress} style={pressableStyle}>
<TreeIndentGuides depth={depth} />
<View ref={dragSourceRef} style={styles.entryInfo}>
<View style={styles.entryInfo}>
<View style={styles.entryIcon}>
{(() => {
if (!isDirectory) {
return <MaterialFileIcon fileName={entry.name} size={16} />;
return <SvgXml xml={getFileIconSvg(entry.name)} width={16} height={16} />;
}
if (loading) return <ActivityIndicator size="small" />;
return <TreeChevron expanded={isExpanded} />;
@@ -171,15 +164,40 @@ function TreeRowItem({
{entry.name}
</Text>
</View>
<FileActionsMenu
fileKind={entry.kind}
onCopyPath={handleCopy}
onDownload={handleDownload}
onAddToChat={onAddToChat ? handleAddToChat : undefined}
header={metaHeader}
accessibilityLabel={t("workspace.fileActions.moreActions")}
testIDPrefix={testID}
/>
<DropdownMenu>
<DropdownMenuTrigger hitSlop={8} onPressIn={stopPressInPropagation} style={menuButtonStyle}>
<MoreVertical size={16} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={220}>
<View style={styles.contextMetaBlock}>
<View style={styles.contextMetaRow}>
<Text style={styles.contextMetaLabel} numberOfLines={1}>
{t("workspace.fileExplorer.context.size")}
</Text>
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
{formatFileSize({ size: entry.size })}
</Text>
</View>
<View style={styles.contextMetaRow}>
<Text style={styles.contextMetaLabel} numberOfLines={1}>
{t("workspace.fileExplorer.context.modified")}
</Text>
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
{formatTimeAgo(new Date(entry.modifiedAt))}
</Text>
</View>
</View>
<DropdownMenuSeparator />
<DropdownMenuItem leading={copyLeading} onSelect={handleCopy}>
{t("workspace.fileExplorer.context.copyPath")}
</DropdownMenuItem>
{entry.kind === "file" ? (
<DropdownMenuItem leading={downloadLeading} onSelect={handleDownload}>
{t("workspace.fileExplorer.context.download")}
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</Pressable>
);
}
@@ -189,7 +207,6 @@ interface FileExplorerPaneProps {
workspaceId?: string | null;
workspaceRoot: string;
onOpenFile?: (filePath: string) => void;
onAddToChat?: (path: string) => void;
}
interface TreeRow {
@@ -202,10 +219,14 @@ export function FileExplorerPane({
workspaceId,
workspaceRoot,
onOpenFile,
onAddToChat,
}: FileExplorerPaneProps) {
const { t } = useTranslation();
const daemons = useHosts();
const daemonProfile = useMemo(
() => daemons.find((daemon) => daemon.serverId === serverId),
[daemons, serverId],
);
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
const workspaceStateKey = useMemo(
() =>
@@ -215,6 +236,10 @@ export function FileExplorerPane({
}),
[normalizedWorkspaceRoot, workspaceId],
);
const workspaceScopeId = useMemo(
() => workspaceId?.trim() || normalizedWorkspaceRoot,
[normalizedWorkspaceRoot, workspaceId],
);
const hasWorkspaceScope = Boolean(workspaceStateKey && normalizedWorkspaceRoot);
const explorerState = useSessionStore((state) =>
workspaceStateKey && state.sessions[serverId]
@@ -222,16 +247,12 @@ export function FileExplorerPane({
: undefined,
);
const { requestDirectoryListing, selectExplorerEntry } = useFileExplorerActions({
serverId,
workspaceId,
workspaceRoot: normalizedWorkspaceRoot,
});
const downloadFile = useFileDownload({
serverId,
workspaceId,
workspaceRoot: normalizedWorkspaceRoot,
});
const { requestDirectoryListing, requestFileDownloadToken, selectExplorerEntry } =
useFileExplorerActions({
serverId,
workspaceId,
workspaceRoot: normalizedWorkspaceRoot,
});
const sortOption = usePanelStore((state) => state.explorerSortOption);
const showHiddenFiles = usePanelStore((state) => state.explorerShowHiddenFiles);
const setSortOption = usePanelStore((state) => state.setExplorerSortOption);
@@ -326,14 +347,18 @@ export function FileExplorerPane({
[normalizedWorkspaceRoot],
);
const startDownload = useDownloadStore((state) => state.startDownload);
const handleDownloadEntry = useCallback(
(entry: ExplorerEntry) => {
if (entry.kind !== "file") {
return;
}
downloadFile({ fileName: entry.name, path: entry.path });
},
[downloadFile],
(entry: ExplorerEntry) =>
downloadExplorerEntry({
entry,
workspaceScopeId,
serverId,
daemonProfile,
startDownload,
requestFileDownloadToken,
}),
[daemonProfile, requestFileDownloadToken, serverId, startDownload, workspaceScopeId],
);
const handleSortCycle = useCallback(() => {
@@ -395,8 +420,6 @@ export function FileExplorerPane({
const renderTreeRow = useCallback(
(info: ListRenderItemInfo<TreeRow>) => (
<TreeRowDispatcher
serverId={serverId}
workspaceId={workspaceId}
info={info}
expandedPaths={expandedPaths}
selectedEntryPath={selectedEntryPath}
@@ -404,7 +427,6 @@ export function FileExplorerPane({
onEntryPress={handleEntryPress}
onCopyPath={handleCopyPath}
onDownloadEntry={handleDownloadEntry}
onAddToChat={onAddToChat}
/>
),
[
@@ -414,9 +436,6 @@ export function FileExplorerPane({
handleDownloadEntry,
isDirectoryLoading,
selectedEntryPath,
onAddToChat,
serverId,
workspaceId,
],
);
@@ -558,14 +577,8 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
return (
<View style={[styles.treePane, styles.treePaneFill]}>
<View style={styles.paneHeader} testID="files-pane-header">
<Pressable
onPress={handleSortCycle}
style={sortTriggerStyleProp}
testID="files-sort-trigger"
>
<Text style={styles.sortTriggerText} testID="files-sort-label">
{currentSortLabel}
</Text>
<Pressable onPress={handleSortCycle} style={sortTriggerStyleProp}>
<Text style={styles.sortTriggerText}>{currentSortLabel}</Text>
<ChevronDown size={12} color={theme.colors.foregroundMuted} />
</Pressable>
<View style={styles.headerActions}>
@@ -576,7 +589,6 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
accessibilityRole="button"
accessibilityLabel={hiddenFilesToggleAccessibilityLabel}
accessibilityState={hiddenFilesToggleAccessibilityState}
testID="files-hidden-toggle"
>
{showHiddenFiles ? (
<Eye size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
@@ -595,7 +607,6 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
? t("workspace.fileExplorer.actions.refreshing")
: t("workspace.fileExplorer.actions.refresh")
}
testID="files-refresh"
>
<View style={styles.refreshIcon}>
{isRefreshFetching ? (
@@ -768,6 +779,39 @@ function resolveTreeRows({
});
}
type StartDownloadFn = ReturnType<typeof useDownloadStore.getState>["startDownload"];
type StartDownloadParams = Parameters<StartDownloadFn>[0];
function downloadExplorerEntry({
entry,
workspaceScopeId,
serverId,
daemonProfile,
startDownload,
requestFileDownloadToken,
}: {
entry: ExplorerEntry;
workspaceScopeId: string | undefined;
serverId: string;
daemonProfile: StartDownloadParams["daemonProfile"];
startDownload: StartDownloadFn;
requestFileDownloadToken: (
targetPath: string,
) => ReturnType<StartDownloadParams["requestFileDownloadToken"]>;
}): void {
if (!workspaceScopeId || entry.kind !== "file") {
return;
}
startDownload({
serverId,
scopeId: workspaceScopeId,
fileName: entry.name,
path: entry.path,
daemonProfile,
requestFileDownloadToken: (targetPath) => requestFileDownloadToken(targetPath),
});
}
function toggleDirectory({
entry,
workspaceStateKey,
@@ -807,8 +851,6 @@ function toggleDirectory({
}
function TreeRowDispatcher({
serverId,
workspaceId,
info,
expandedPaths,
selectedEntryPath,
@@ -816,10 +858,7 @@ function TreeRowDispatcher({
onEntryPress,
onCopyPath,
onDownloadEntry,
onAddToChat,
}: {
serverId: string;
workspaceId?: string | null;
info: ListRenderItemInfo<TreeRow>;
expandedPaths: Set<string>;
selectedEntryPath: string | null;
@@ -827,7 +866,6 @@ function TreeRowDispatcher({
onEntryPress: (entry: ExplorerEntry) => void;
onCopyPath: (path: string) => void | Promise<void>;
onDownloadEntry: (entry: ExplorerEntry) => void;
onAddToChat?: (path: string) => void;
}) {
const entry = info.item.entry;
const depth = info.item.depth;
@@ -838,8 +876,6 @@ function TreeRowDispatcher({
return (
<TreeRowItem
serverId={serverId}
workspaceId={workspaceId}
entry={entry}
depth={depth}
isExpanded={isExpanded}
@@ -848,8 +884,6 @@ function TreeRowDispatcher({
onEntryPress={onEntryPress}
onCopyPath={onCopyPath}
onDownloadEntry={onDownloadEntry}
onAddToChat={onAddToChat}
testID={`file-explorer-row-${info.index}`}
/>
);
}
@@ -1028,6 +1062,7 @@ const styles = StyleSheet.create((theme) => ({
minHeight: 0,
},
entriesContent: {
paddingHorizontal: theme.spacing[2],
paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[4],
},
@@ -1077,8 +1112,9 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingVertical: WORKSPACE_FILE_ROW_VERTICAL_PADDING,
paddingRight: theme.spacing[3],
paddingVertical: 2,
paddingRight: theme.spacing[2],
borderRadius: theme.borderRadius.md,
},
entryRowActive: {
backgroundColor: theme.colors.surfaceSidebarHover,
@@ -1098,6 +1134,16 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
menuButton: {
width: 30,
height: 30,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
},
menuButtonActive: {
backgroundColor: theme.colors.surface2,
},
contextMetaBlock: {
paddingVertical: theme.spacing[1],
},
@@ -1,13 +1,6 @@
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import type { DaemonClient, FileReadResult } from "@getpaseo/client/internal/daemon-client";
import type { FileVersion } from "@getpaseo/protocol/messages";
import React, { useEffect, useMemo, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
import {
ActivityIndicator,
Image as RNImage,
@@ -15,7 +8,7 @@ import {
Text,
View,
} from "react-native";
import { StyleSheet, UnistylesRuntime } from "react-native-unistyles";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { MarkdownRenderer } from "@/components/markdown/renderer";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -36,14 +29,6 @@ import type { WorkspaceFileLocation } from "@/workspace/file-open";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { useAppActivelyVisible } from "@/hooks/use-app-visible";
import { isFileQueryEnabled } from "@/components/file-pane-enabled";
import { isWeb } from "@/constants/platform";
import { useAppSettings } from "@/hooks/use-settings";
import { useLiveFile } from "./live-file";
import { FilePanelBar } from "./bar";
import { FileEditorModel, type FileEditorFile } from "./editor/model";
import { FileEditorView } from "./editor/view";
import { confirmDialog } from "@/utils/confirm-dialog";
import { usePublishPanelInstanceAttributes } from "@/panels/panel-instance-attributes";
interface CodeLineProps {
tokens: HighlightToken[];
@@ -57,12 +42,9 @@ interface FilePreviewBodyProps {
isLoading: boolean;
isMobile: boolean;
location: WorkspaceFileLocation;
navigationRevision: number;
imagePreviewUri: string | null;
}
type TextExplorerFile = ExplorerFile & { kind: "text" };
function trimNonEmpty(value: string | null | undefined): string | null {
if (typeof value !== "string") {
return null;
@@ -208,10 +190,9 @@ function FilePreviewBody({
isLoading,
isMobile,
location,
navigationRevision,
imagePreviewUri,
}: FilePreviewBodyProps) {
const theme = UnistylesRuntime.getTheme();
const { theme } = useUnistyles();
const { t } = useTranslation();
const filePath = location.path;
const isMarkdownFile =
@@ -259,7 +240,7 @@ function FilePreviewBody({
});
}, 0);
return () => clearTimeout(timeout);
}, [lineHeight, lineSelection, navigationRevision]);
}, [lineHeight, lineSelection]);
if (isLoading && !preview) {
return (
@@ -382,27 +363,15 @@ export function FilePane({
serverId,
workspaceRoot,
location,
navigationRevision,
}: {
serverId: string;
workspaceRoot: string;
location: WorkspaceFileLocation;
navigationRevision: number;
}) {
const { t } = useTranslation();
const isMobile = useIsCompactFormFactor();
const [markdownMode, setMarkdownMode] = useState<"preview" | "source">("preview");
const [resolvedPreview, setResolvedPreview] = useState<{
key: string | null;
file: ExplorerFile | null;
imageAttachment: AttachmentMetadata | null;
}>({ key: null, file: null, imageAttachment: null });
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
// COMPAT(workspaceFileEditing): added in v0.2.0, remove after 2027-01-18 once daemon floor >= v0.2.0.
const supportsEditing = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.workspaceFileEditing === true,
);
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
const normalizedFilePath = useMemo(() => trimNonEmpty(location.path), [location.path]);
const readTarget = useMemo(
@@ -421,365 +390,61 @@ export function FilePane({
// from another window after an external edit. The gate lives in isFileQueryEnabled.
const isActive = useRetainedPanelActive();
const isAppVisible = useAppActivelyVisible();
const enabled = isFileQueryEnabled({
hasReadTarget: Boolean(client && readTarget),
isTabActive: isActive,
isAppVisible,
const query = useQuery({
queryKey: ["workspaceFile", serverId, readTarget?.cwd ?? null, readTarget?.path ?? null],
enabled: isFileQueryEnabled({
hasReadTarget: Boolean(client && readTarget),
isTabActive: isActive,
isAppVisible,
}),
queryFn: async () => {
if (!client || !readTarget) {
return {
file: null as ExplorerFile | null,
error: t("workspace.terminal.hostDisconnected"),
};
}
try {
const file = await client.readFile(readTarget.cwd, readTarget.path);
const preview = await createFilePanePreview(file);
return {
file: preview.file,
imageAttachment: preview.imageAttachment,
error: null,
};
} catch (error) {
return {
file: null,
imageAttachment: null,
error: error instanceof Error ? error.message : t("panels.file.failedToLoad"),
};
}
},
staleTime: 5_000,
refetchOnMount: true,
});
const { query, version } = useLiveFile({
client,
serverId,
cwd: readTarget?.cwd ?? null,
path: readTarget?.path ?? null,
enabled,
liveUpdates: supportsEditing,
});
useEffect(() => {
let active = true;
const key = readTarget ? `${readTarget.cwd}:${readTarget.path}` : null;
void (async () => {
const nextPreview = await createFilePanePreview(query.data ?? null);
if (active) setResolvedPreview({ key, ...nextPreview });
})();
return () => {
active = false;
};
}, [query.data, readTarget]);
useEffect(() => setMarkdownMode("preview"), [readTarget?.path]);
const previewKey = readTarget ? `${readTarget.cwd}:${readTarget.path}` : null;
const preview = resolvedPreview.key === previewKey ? resolvedPreview.file : null;
const imagePreviewUri = useAttachmentPreviewUrl(
resolvedPreview.key === previewKey ? resolvedPreview.imageAttachment : null,
);
const isMarkdown = isMarkdownPreview(preview, location.path);
const editable = isEditableTextFile({
preview,
supportsEditing,
});
const canToggleMarkdownMode = isMarkdown && editable;
const lineCount =
preview?.kind === "text" ? (preview.content ?? "").split("\n").length : undefined;
const errorMessage = getFileErrorMessage(query.error, t("panels.file.failedToLoad"));
return (
<FilePanePresentation
serverId={serverId}
client={client}
readTarget={readTarget}
preview={preview}
version={version}
filename={getFileNameFromPath(location.path) ?? location.path}
markdownMode={canToggleMarkdownMode ? markdownMode : undefined}
onMarkdownModeChange={canToggleMarkdownMode ? setMarkdownMode : undefined}
lineCount={lineCount}
editable={editable}
disconnectedMessage={t("workspace.terminal.hostDisconnected")}
errorMessage={errorMessage}
isLoading={query.isFetching}
isMobile={isMobile}
location={location}
navigationRevision={navigationRevision}
imagePreviewUri={imagePreviewUri}
/>
);
}
function isMarkdownPreview(preview: ExplorerFile | null, path: string): boolean {
return preview?.kind === "text" && isRenderedMarkdownFile(path);
}
function getFileErrorMessage(error: unknown, fallback: string): string | null {
if (!error) return null;
return error instanceof Error ? error.message : fallback;
}
function isEditableTextFile(input: {
preview: ExplorerFile | null;
supportsEditing: boolean;
}): boolean {
return Boolean(
isWeb &&
input.supportsEditing &&
input.preview?.kind === "text" &&
input.preview.size <= 1024 * 1024,
);
}
function FilePanePresentation({
serverId,
client,
readTarget,
preview,
version,
filename,
markdownMode,
onMarkdownModeChange,
lineCount,
editable,
disconnectedMessage,
errorMessage,
isLoading,
isMobile,
location,
navigationRevision,
imagePreviewUri,
}: {
serverId: string;
client: DaemonClient | null;
readTarget: { cwd: string; path: string } | null;
preview: ExplorerFile | null;
version: FileVersion | null;
filename: string;
markdownMode?: "preview" | "source";
onMarkdownModeChange?: (mode: "preview" | "source") => void;
lineCount?: number;
editable: boolean;
disconnectedMessage: string;
errorMessage: string | null;
isLoading: boolean;
isMobile: boolean;
location: WorkspaceFileLocation;
navigationRevision: number;
imagePreviewUri: string | null;
}) {
if (!client && readTarget) {
return (
<View style={styles.container} testID="workspace-file-pane">
<View style={styles.centerState}>
<Text style={styles.errorText}>{disconnectedMessage}</Text>
</View>
</View>
);
}
if (editable && client && readTarget && preview?.kind === "text") {
return (
<EditableFilePane
key={`${serverId}:${readTarget.cwd}:${readTarget.path}`}
client={client}
cwd={readTarget.cwd}
path={readTarget.path}
preview={preview as TextExplorerFile}
version={version}
filename={filename}
mode={markdownMode}
onModeChange={onMarkdownModeChange}
isLoading={isLoading}
isMobile={isMobile}
location={location}
navigationRevision={navigationRevision}
/>
);
}
const imagePreviewUri = useAttachmentPreviewUrl(query.data?.imageAttachment ?? null);
return (
<View style={styles.container} testID="workspace-file-pane">
{preview ? (
<FilePanelBar
size={preview.size}
lineCount={lineCount}
mode={markdownMode}
onModeChange={onMarkdownModeChange}
/>
) : null}
{errorMessage ? (
{query.data?.error ? (
<View style={styles.centerState}>
<Text style={styles.errorText}>{errorMessage}</Text>
<Text style={styles.errorText}>{query.data.error}</Text>
</View>
) : null}
<FilePreviewBody
preview={preview}
isLoading={isLoading}
preview={query.data?.file ?? null}
isLoading={query.isFetching}
isMobile={isMobile}
location={location}
navigationRevision={navigationRevision}
imagePreviewUri={imagePreviewUri}
/>
</View>
);
}
function EditableFilePane({
client,
cwd,
path,
preview,
version,
filename,
mode,
onModeChange,
isLoading,
isMobile,
location,
navigationRevision,
}: {
client: DaemonClient;
cwd: string;
path: string;
preview: TextExplorerFile;
version: FileVersion | null;
filename: string;
mode?: "preview" | "source";
onModeChange?: (mode: "preview" | "source") => void;
isLoading: boolean;
isMobile: boolean;
location: WorkspaceFileLocation;
navigationRevision: number;
}) {
const { settings } = useAppSettings();
const { t } = useTranslation();
const [cursor, setCursor] = useState({ line: 1, column: 1 });
const [vimMode, setVimMode] = useState<string | null>(settings.vimKeybindings ? "NORMAL" : null);
const session = useMemo(
() => ({
async read(): Promise<FileEditorFile> {
const file = await client.readFile(cwd, path);
const decodedFile = explorerFileFromReadResult(file);
if (decodedFile.kind !== "text" || decodedFile.content === undefined) {
throw new Error("File is no longer text.");
}
return {
content: decodedFile.content,
hasBom: decodedFile.hasBom,
version: {
status: "ready",
cwd,
path,
size: file.size,
modifiedAt: file.modifiedAt,
revision: file.revision,
},
};
},
write(input: { content: string; expectedModifiedAt: string; expectedRevision?: string }) {
return client.writeFile({ cwd, path, ...input });
},
}),
[client, cwd, path],
);
const [model] = useState(
() =>
new FileEditorModel({
file: {
content: preview.content ?? "",
hasBom: preview.hasBom,
version: {
status: "ready",
cwd,
path,
size: preview.size,
modifiedAt: preview.modifiedAt,
},
},
session,
}),
);
const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
const suspendPendingSave = useCallback(() => model.suspendAutosave(), [model]);
usePublishPanelInstanceAttributes({ modified: snapshot.modified, suspendPendingSave });
const theme = UnistylesRuntime.getTheme();
const visualTheme = useMemo(
() => ({
colorScheme: theme.colorScheme,
background: theme.colors.surface0,
foreground: theme.colors.foreground,
cursor: theme.colors.terminal.cursor,
foregroundMuted: theme.colors.foregroundMuted,
border: theme.colors.border,
selection: theme.colors.terminal.selectionBackground,
monoFont: theme.fontFamily.mono,
codeFontSize: theme.fontSize.code,
syntax: theme.colors.syntax,
}),
[
theme.colors.border,
theme.colors.foreground,
theme.colors.foregroundMuted,
theme.colors.surface0,
theme.colors.syntax,
theme.colors.terminal.cursor,
theme.colors.terminal.selectionBackground,
theme.colorScheme,
theme.fontFamily.mono,
theme.fontSize.code,
],
);
useEffect(() => () => model.dispose(), [model]);
useEffect(() => {
if (version) model.receiveFileVersion(version);
}, [model, version]);
const handleReload = useCallback(() => {
void (async () => {
const confirmed = await confirmDialog({
title: t("panels.file.editor.reloadTitle"),
message: t("panels.file.editor.reloadMessage"),
confirmLabel: t("panels.file.editor.reload"),
destructive: true,
});
if (confirmed) void model.reload();
})();
}, [model, t]);
const handleOverwrite = useCallback(() => void model.overwrite(), [model]);
const handleVimModeChange = useCallback((nextMode: string | null) => setVimMode(nextMode), []);
const renderedPreview = useMemo<ExplorerFile>(
() => ({
...preview,
content: snapshot.content,
size: snapshot.version.status === "ready" ? snapshot.version.size : preview.size,
modifiedAt:
snapshot.version.status === "ready" ? snapshot.version.modifiedAt : preview.modifiedAt,
}),
[preview, snapshot.content, snapshot.version],
);
const showSource = mode !== "preview";
return (
<View style={styles.container} testID="workspace-file-pane">
<FilePanelBar
size={
snapshot.observedVersion.status === "ready" ? snapshot.observedVersion.size : preview.size
}
lineCount={snapshot.content.split("\n").length}
editorStatus={snapshot.status}
cursor={showSource ? cursor : undefined}
vimMode={showSource ? vimMode : null}
conflictUnavailable={snapshot.observedVersion.status !== "ready"}
onOverwrite={handleOverwrite}
onReload={handleReload}
mode={mode}
onModeChange={onModeChange}
/>
{showSource ? (
<FileEditorView
model={model}
filename={filename}
location={location}
navigationRevision={navigationRevision}
vimEnabled={settings.vimKeybindings}
theme={visualTheme}
onCursorChange={setCursor}
onVimModeChange={handleVimModeChange}
/>
) : (
<FilePreviewBody
preview={renderedPreview}
isLoading={isLoading}
isMobile={isMobile}
location={location}
navigationRevision={navigationRevision}
imagePreviewUri={null}
/>
)}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
@@ -7,7 +7,7 @@ interface OmpIconProps {
export function OmpIcon({ size = 16, color = "currentColor" }: OmpIconProps) {
return (
<Svg width={size} height={size} viewBox="4 4 56 56" fill={color}>
<Svg width={size} height={size} viewBox="0 0 64 64" fill={color}>
<Path d="M10 14h44v9H43v33h-9V23h-9v22h-9V23H10z" />
</Svg>
);
@@ -7,7 +7,7 @@ interface PiIconProps {
export function PiIcon({ size = 16, color = "currentColor" }: PiIconProps) {
return (
<Svg width={size} height={size} viewBox="100 100 600 600" fill={color}>
<Svg width={size} height={size} viewBox="0 0 800 800" fill={color}>
<Path
d="M165.29 165.29 H517.36 V400 H400 V517.36 H282.65 V634.72 H165.29 Z M282.65 282.65 V400 H400 V282.65 Z"
fill={color}
+13 -90
View File
@@ -341,64 +341,6 @@ function FooterIconButton({
);
}
function footerAddProjectButtonStyle({
hovered,
}: PressableStateCallbackType & { hovered?: boolean }) {
return [styles.footerAddProjectButton, Boolean(hovered) && styles.footerAddProjectButtonHovered];
}
function FooterAddProjectButton({
onPress,
label,
shortcutKeys,
theme,
}: {
onPress: () => void;
label: string;
shortcutKeys: ReturnType<typeof useShortcutKeys>;
theme: SidebarTheme;
}) {
return (
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
style={footerAddProjectButtonStyle}
testID="sidebar-add-project"
nativeID="sidebar-add-project"
accessible
accessibilityLabel={label}
accessibilityRole="button"
onPress={onPress}
>
{({ hovered }) => {
const isHovered = Boolean(hovered);
return (
<>
<FolderPlus
size={theme.iconSize.sm}
color={isHovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text
numberOfLines={1}
style={[
styles.footerAddProjectLabel,
isHovered && styles.footerAddProjectLabelHovered,
]}
>
{label}
</Text>
</>
);
}}
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<IconTooltipContent label={label} shortcutKeys={shortcutKeys} />
</TooltipContent>
</Tooltip>
);
}
function SidebarHostPicker({
theme,
label,
@@ -552,12 +494,6 @@ function SidebarFooter({
return (
<View style={styles.sidebarFooter}>
<FooterAddProjectButton
onPress={handleOpenProject}
label={labels.addProject}
shortcutKeys={newAgentKeys}
theme={theme}
/>
<View style={styles.footerIconRow}>
<SidebarHostPicker
theme={theme}
@@ -565,6 +501,14 @@ function SidebarFooter({
onAddHost={handleAddHost}
onOpenHostSettings={handleOpenHostSettings}
/>
<FooterIconButton
onPress={handleOpenProject}
testID="sidebar-add-project"
label={labels.addProject}
icon={FolderPlus}
shortcutKeys={newAgentKeys}
theme={theme}
/>
<FooterIconButton
onPress={handleHome}
testID="sidebar-home"
@@ -980,6 +924,9 @@ const styles = StyleSheet.create((theme) => ({
sidebarHeaderGroup: {
paddingTop: theme.spacing[2],
gap: 2,
// Distance from History's bottom edge to the divider. WorkspacesSectionHeader
// uses a slightly smaller paddingTop to balance the action buttons' centering
// offset so the divider reads as visually centered between the two.
paddingBottom: theme.spacing[1.5],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
@@ -1061,8 +1008,8 @@ const styles = StyleSheet.create((theme) => ({
sidebarFooter: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[2],
justifyContent: "flex-start",
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderTopWidth: 1,
borderTopColor: theme.colors.border,
@@ -1073,30 +1020,6 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[2],
flexShrink: 0,
},
footerAddProjectButton: {
minWidth: 0,
minHeight: 32,
flex: 1,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingVertical: theme.spacing[1.5],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
},
footerAddProjectButtonHovered: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
footerAddProjectLabel: {
minWidth: 0,
flexShrink: 1,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted,
},
footerAddProjectLabelHovered: {
color: theme.colors.foreground,
},
footerIconButton: {
width: 28,
height: 28,
@@ -569,26 +569,8 @@ export function createSharedMarkdownRules(): RenderRules {
{children}
</MarkdownInheritedText>
),
hardbreak: (
node: ASTNode,
_children: ReactNode[],
_parent: ASTNode[],
styles: MarkdownStyles,
) => (
<MarkdownTextSpan key={node.key} style={styles.hardbreak}>
{"\n"}
</MarkdownTextSpan>
),
softbreak: (
node: ASTNode,
_children: ReactNode[],
_parent: ASTNode[],
styles: MarkdownStyles,
) => (
<MarkdownTextSpan key={node.key} style={styles.softbreak}>
{"\n"}
</MarkdownTextSpan>
),
hardbreak: (node: ASTNode) => <MarkdownTextSpan key={node.key}>{"\n"}</MarkdownTextSpan>,
softbreak: (node: ASTNode) => <MarkdownTextSpan key={node.key}>{"\n"}</MarkdownTextSpan>,
code_block: (
node: ASTNode,
_children: ReactNode[],
@@ -1,15 +0,0 @@
import type { ComponentType } from "react";
import { SvgXml } from "react-native-svg";
import { getFileIconSvg } from "@/components/material-file-icons";
import type { PanelIconProps } from "@/panels/panel-registry";
export function MaterialFileIcon({ fileName, size }: { fileName: string; size: number }) {
return <SvgXml xml={getFileIconSvg(fileName)} width={size} height={size} />;
}
export function createMaterialFileIcon(fileName: string): ComponentType<PanelIconProps> {
function BoundMaterialFileIcon({ size }: PanelIconProps) {
return <MaterialFileIcon fileName={fileName} size={size} />;
}
return BoundMaterialFileIcon;
}
+5 -26
View File
@@ -1689,29 +1689,10 @@ export const AssistantMessage = memo(function AssistantMessage({
// plain <Text> is not hoisted into a UITextViewChild and is dropped (same
// root cause as strong/em/s) — so on iOS a hard line break vanished, and
// a softbreak between words jammed them together ("one\ntwo" -> "onetwo").
// Emit the break through MarkdownTextSpan so it composes on iOS. Keep
// the resolved break styles: hardbreak is a full-width flex-row child on
// Android, and dropping that width joins the surrounding text spans.
hardbreak: (
node: ASTNode,
_children: ReactNode[],
_parent: ASTNode[],
styles: MarkdownStyles,
) => (
<MarkdownTextSpan key={node.key} style={styles.hardbreak}>
{"\n"}
</MarkdownTextSpan>
),
softbreak: (
node: ASTNode,
_children: ReactNode[],
_parent: ASTNode[],
styles: MarkdownStyles,
) => (
<MarkdownTextSpan key={node.key} style={styles.softbreak}>
{"\n"}
</MarkdownTextSpan>
),
// Emit the break through MarkdownTextSpan so it composes on iOS; web and
// Android keep the same "\n" they rendered before.
hardbreak: (node: ASTNode) => <MarkdownTextSpan key={node.key}>{"\n"}</MarkdownTextSpan>,
softbreak: (node: ASTNode) => <MarkdownTextSpan key={node.key}>{"\n"}</MarkdownTextSpan>,
code_block: (
node: ASTNode,
_children: ReactNode[],
@@ -2657,9 +2638,7 @@ function computeShimmerMetrics(input: {
Math.min(120, input.labelRowWidth > 0 ? input.labelRowWidth * 0.28 : 0),
);
const isWebShimmer = input.isLoading && isWeb;
// React Native Web only observes a node when onLayout exists at mount. Keep
// measuring while idle so a retained badge has dimensions when it starts loading.
const shouldMeasureWebShimmer = isWeb;
const shouldMeasureWebShimmer = isWebShimmer;
const shouldMeasureNativeShimmer = input.isLoading && isNative;
const isNativeShimmer =
shouldMeasureNativeShimmer && input.labelRowWidth > 0 && input.labelRowHeight > 0;
@@ -1,49 +0,0 @@
import { describe, expect, it } from "vitest";
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
import { resolveInitialModelBrowserView } from "./model-browser-view";
function provider(id: string, label: string): ProviderSelectorProvider {
return {
id,
label,
modelSelection: { kind: "models", rows: [] },
};
}
describe("model browser initial view", () => {
const codex = provider("codex", "Codex");
const pi = provider("pi", "Pi");
it("opens a sole provider directly", () => {
expect(
resolveInitialModelBrowserView({
providers: [pi],
selectedProvider: "",
selectedModel: "",
favoriteKeys: new Set(),
}),
).toEqual({ kind: "provider", providerId: "pi", providerLabel: "Pi" });
});
it("opens the selected provider when its model is not a favorite", () => {
expect(
resolveInitialModelBrowserView({
providers: [codex, pi],
selectedProvider: "pi",
selectedModel: "pi-pro",
favoriteKeys: new Set(),
}),
).toEqual({ kind: "provider", providerId: "pi", providerLabel: "Pi" });
});
it("opens the provider overview when the selected model is a favorite", () => {
expect(
resolveInitialModelBrowserView({
providers: [codex, pi],
selectedProvider: "pi",
selectedModel: "pi-pro",
favoriteKeys: new Set(["pi:pi-pro"]),
}),
).toEqual({ kind: "all" });
});
});
@@ -1,40 +0,0 @@
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
export type ModelBrowserView =
| { kind: "all" }
| { kind: "provider"; providerId: string; providerLabel: string };
export function resolveInitialModelBrowserView({
providers,
selectedProvider,
selectedModel,
favoriteKeys,
}: {
providers: ProviderSelectorProvider[];
selectedProvider: string;
selectedModel: string;
favoriteKeys: Set<string>;
}): ModelBrowserView {
const singleProvider = providers.length === 1 ? providers[0] : undefined;
if (singleProvider) {
return {
kind: "provider",
providerId: singleProvider.id,
providerLabel: singleProvider.label,
};
}
const selectedFavoriteKey = `${selectedProvider}:${selectedModel}`;
const shouldOpenSelectedProvider =
selectedProvider.length > 0 &&
selectedModel.length > 0 &&
!favoriteKeys.has(selectedFavoriteKey);
if (shouldOpenSelectedProvider) {
const provider = providers.find((entry) => entry.id === selectedProvider);
if (provider) {
return { kind: "provider", providerId: provider.id, providerLabel: provider.label };
}
}
return { kind: "all" };
}
File diff suppressed because it is too large Load Diff
@@ -94,8 +94,6 @@ import type { StatusGroup } from "@/hooks/sidebar-status-view-model";
import { SidebarWorkspaceMenu } from "@/components/sidebar/sidebar-workspace-menu";
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
import { PinnedSectionHeader } from "@/components/sidebar/pinned-section-header";
import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row";
import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group";
import {
SidebarWorkspaceRowFrame,
SidebarWorkspaceRowContent,
@@ -1634,12 +1632,6 @@ function ProjectBlock({
supportsPinningByServerId: ReadonlyMap<string, boolean>;
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
}) {
const {
visibleItems: visibleWorkspaces,
expanded: workspacesExpanded,
canToggle: canToggleWorkspaces,
toggleExpanded: toggleWorkspacesExpanded,
} = useLimitedSidebarGroup(project.workspaces);
const rowModel = useMemo(
() =>
buildSidebarProjectRowModel({
@@ -1792,28 +1784,19 @@ function ProjectBlock({
if (!collapsed) {
if (project.workspaces.length > 0) {
projectChildren = (
<>
<DraggableList
testID={`sidebar-workspace-list-${project.projectKey}`}
data={visibleWorkspaces}
keyExtractor={workspaceKeyExtractor}
renderItem={renderWorkspace}
onDragEnd={handleWorkspaceDragEnd}
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
scrollEnabled={false}
useDragHandle
nestable={useNestable}
simultaneousGestureRef={parentGestureRef}
containerStyle={styles.workspaceListContainer}
/>
{canToggleWorkspaces ? (
<SidebarGroupToggleRow
expanded={workspacesExpanded}
onPress={toggleWorkspacesExpanded}
testID={`sidebar-project-show-more-${project.projectKey}`}
/>
) : null}
</>
<DraggableList
testID={`sidebar-workspace-list-${project.projectKey}`}
data={project.workspaces}
keyExtractor={workspaceKeyExtractor}
renderItem={renderWorkspace}
onDragEnd={handleWorkspaceDragEnd}
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
scrollEnabled={false}
useDragHandle
nestable={useNestable}
simultaneousGestureRef={parentGestureRef}
containerStyle={styles.workspaceListContainer}
/>
);
} else if (rowModel.trailingAction.kind === "new_workspace") {
projectChildren = (
@@ -2090,12 +2073,6 @@ function ProjectModeList({
const selectionEnabled = isWorkspaceRoute;
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const { pinnedChats, unpinnedProjects } = pinnedGroups;
const {
visibleItems: visiblePinnedChats,
expanded: pinnedChatsExpanded,
canToggle: canTogglePinnedChats,
toggleExpanded: togglePinnedChatsExpanded,
} = useLimitedSidebarGroup(pinnedChats);
const projectIconTargets = useMemo(
() =>
projects.flatMap((project) => {
@@ -2326,6 +2303,7 @@ function ProjectModeList({
canCopyBranchName={workspace.projectKind === "git"}
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
onToggleWorkspacePin={onToggleWorkspacePin}
reserveIdleStatusIndicatorSpace={false}
isCreating={creatingWorkspaceIds.has(workspace.workspaceId)}
selectionEnabled={selectionEnabled}
activeWorkspaceSelection={activeWorkspaceSelection}
@@ -2353,18 +2331,7 @@ function ProjectModeList({
{pinnedChats.length > 0 ? (
<View style={styles.pinnedSection} testID="sidebar-pinned-section">
<PinnedSectionHeader collapsed={pinnedCollapsed} onToggle={togglePinnedCollapsed} />
{pinnedCollapsed ? null : (
<>
{visiblePinnedChats.map(renderPinnedChat)}
{canTogglePinnedChats ? (
<SidebarGroupToggleRow
expanded={pinnedChatsExpanded}
onPress={togglePinnedChatsExpanded}
testID="sidebar-pinned-show-more"
/>
) : null}
</>
)}
{pinnedCollapsed ? null : pinnedChats.map(renderPinnedChat)}
</View>
) : null}
{unpinnedProjects.length > 0 || hasActiveHostFilter ? listHeaderComponent : null}
@@ -2432,9 +2399,7 @@ const styles = StyleSheet.create((theme) => ({
},
listContent: {
paddingHorizontal: theme.spacing[2],
// Optical inset: aligns the visible Pinned/Workspaces glyph edge with the
// Schedules icon across the divider; their layout boxes have different insets.
paddingTop: 2,
paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[4],
},
projectListContainer: {
@@ -47,7 +47,6 @@ export function PinnedSectionHeader({
const styles = StyleSheet.create((theme) => ({
header: {
minHeight: 36,
flexDirection: "row",
alignItems: "center",
alignSelf: "flex-start",
@@ -1,106 +0,0 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { ChevronDown, ChevronUp } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { isWeb } from "@/constants/platform";
import type { Theme } from "@/styles/theme";
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
const foregroundColorMapping = (theme: Theme) => ({
color: theme.colors.foreground,
});
const ThemedChevronDown = withUnistyles(ChevronDown);
const ThemedChevronUp = withUnistyles(ChevronUp);
export function SidebarGroupToggleRow({
expanded,
onPress,
testID,
}: {
expanded: boolean;
onPress: () => void;
testID: string;
}) {
const { t } = useTranslation();
const label = t(
expanded ? "sidebar.workspace.actions.showLess" : "sidebar.workspace.actions.showMore",
);
const rowStyle = useCallback(
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.row,
(Boolean(hovered) || pressed) && styles.rowHovered,
],
[],
);
return (
<Pressable
accessibilityRole={isWeb ? undefined : "button"}
accessibilityLabel={label}
onPress={onPress}
style={rowStyle}
testID={testID}
>
{({ hovered, pressed }) => (
<>
<View style={styles.iconSlot}>
{expanded ? (
<ThemedChevronUp
size={14}
uniProps={hovered || pressed ? foregroundColorMapping : foregroundMutedColorMapping}
/>
) : (
<ThemedChevronDown
size={14}
uniProps={hovered || pressed ? foregroundColorMapping : foregroundMutedColorMapping}
/>
)}
</View>
<Text style={hovered || pressed ? styles.textHovered : styles.text} numberOfLines={1}>
{label}
</Text>
</>
)}
</Pressable>
);
}
const styles = StyleSheet.create((theme) => ({
row: {
minHeight: 32,
marginLeft: theme.spacing[6],
marginRight: theme.spacing[1],
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.md,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
userSelect: "none",
},
rowHovered: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
iconSlot: {
width: theme.iconSize.sm,
height: theme.iconSize.sm,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
text: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
minWidth: 0,
flexShrink: 1,
},
textHovered: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
minWidth: 0,
flexShrink: 1,
},
}));
@@ -42,8 +42,6 @@ import {
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
import { SidebarWorkspaceMenu } from "@/components/sidebar/sidebar-workspace-menu";
import { PinnedSectionHeader } from "@/components/sidebar/pinned-section-header";
import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row";
import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group";
import type { ToggleSidebarWorkspacePin } from "@/hooks/use-sidebar-workspace-pin";
// Themed icon wrappers
@@ -95,12 +93,6 @@ export function SidebarStatusWorkspaceList({
const togglePinnedCollapsed = useSidebarCollapsedSectionsStore(
(state) => state.togglePinnedCollapsed,
);
const {
visibleItems: visiblePinnedWorkspaces,
expanded: pinnedWorkspacesExpanded,
canToggle: canTogglePinnedWorkspaces,
toggleExpanded: togglePinnedWorkspacesExpanded,
} = useLimitedSidebarGroup(pinnedWorkspaces);
const statusShortcutIndex = showShortcutBadges ? shortcutIndexByWorkspaceKey : new Map();
const content = (
@@ -108,9 +100,9 @@ export function SidebarStatusWorkspaceList({
{pinnedWorkspaces.length > 0 ? (
<View style={styles.pinnedSection} testID="sidebar-pinned-section">
<PinnedSectionHeader collapsed={pinnedCollapsed} onToggle={togglePinnedCollapsed} />
{pinnedCollapsed ? null : (
<>
{visiblePinnedWorkspaces.map((workspace) => (
{pinnedCollapsed
? null
: pinnedWorkspaces.map((workspace) => (
<StatusWorkspaceRow
key={workspace.workspaceKey}
workspace={workspace}
@@ -124,18 +116,10 @@ export function SidebarStatusWorkspaceList({
showShortcutBadge={showShortcutBadges}
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
onToggleWorkspacePin={onToggleWorkspacePin}
reserveIdleStatusIndicatorSpace={false}
onWorkspacePress={onWorkspacePress}
/>
))}
{canTogglePinnedWorkspaces ? (
<SidebarGroupToggleRow
expanded={pinnedWorkspacesExpanded}
onPress={togglePinnedWorkspacesExpanded}
testID="sidebar-pinned-show-more"
/>
) : null}
</>
)}
</View>
) : null}
{listHeaderComponent}
@@ -205,89 +189,35 @@ function StatusGroupList({
return (
<>
{groups.map((group) => (
<StatusGroupRows
key={group.bucket}
group={group}
collapsed={collapsedStatusGroupKeys.has(group.bucket)}
projectNamesByKey={projectNamesByKey}
shortcutIndex={shortcutIndex}
showShortcutBadges={showShortcutBadges}
onWorkspacePress={onWorkspacePress}
hostLabelByServerId={hostLabelByServerId}
showHostLabels={showHostLabels}
supportsPinningByServerId={supportsPinningByServerId}
onToggleWorkspacePin={onToggleWorkspacePin}
/>
))}
</>
);
}
function StatusGroupRows({
group,
collapsed,
projectNamesByKey,
shortcutIndex,
showShortcutBadges,
onWorkspacePress,
hostLabelByServerId,
showHostLabels,
supportsPinningByServerId,
onToggleWorkspacePin,
}: {
group: StatusGroup;
collapsed: boolean;
projectNamesByKey: Map<string, string>;
shortcutIndex: Map<string, number>;
showShortcutBadges: boolean;
onWorkspacePress?: () => void;
hostLabelByServerId: ReadonlyMap<string, string>;
showHostLabels: boolean;
supportsPinningByServerId: ReadonlyMap<string, boolean>;
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
}) {
const {
visibleItems: visibleWorkspaces,
expanded: workspacesExpanded,
canToggle: canToggleWorkspaces,
toggleExpanded: toggleWorkspacesExpanded,
} = useLimitedSidebarGroup(group.rows);
return (
<View style={styles.statusGroupBlock}>
<StatusGroupHeader group={group} collapsed={collapsed} />
{!collapsed ? (
<View
style={styles.statusWorkspaceListContainer}
testID={`sidebar-status-group-rows-${group.bucket}`}
>
{visibleWorkspaces.map((workspace) => (
<StatusWorkspaceRow
key={workspace.workspaceKey}
workspace={workspace}
subtitle={buildStatusRowSubtitle({
projectName: projectNamesByKey.get(workspace.projectKey) ?? "",
hostLabel: showHostLabels
? (hostLabelByServerId.get(workspace.serverId) ?? workspace.serverId)
: null,
})}
shortcutNumber={shortcutIndex.get(workspace.workspaceKey) ?? null}
showShortcutBadge={showShortcutBadges}
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
onToggleWorkspacePin={onToggleWorkspacePin}
onWorkspacePress={onWorkspacePress}
/>
))}
{canToggleWorkspaces ? (
<SidebarGroupToggleRow
expanded={workspacesExpanded}
onPress={toggleWorkspacesExpanded}
testID={`sidebar-status-show-more-${group.bucket}`}
/>
<View key={group.bucket} style={styles.statusGroupBlock}>
<StatusGroupHeader group={group} collapsed={collapsedStatusGroupKeys.has(group.bucket)} />
{!collapsedStatusGroupKeys.has(group.bucket) ? (
<View
style={styles.statusWorkspaceListContainer}
testID={`sidebar-status-group-rows-${group.bucket}`}
>
{group.rows.map((workspace) => (
<StatusWorkspaceRow
key={workspace.workspaceKey}
workspace={workspace}
subtitle={buildStatusRowSubtitle({
projectName: projectNamesByKey.get(workspace.projectKey) ?? "",
hostLabel: showHostLabels
? (hostLabelByServerId.get(workspace.serverId) ?? workspace.serverId)
: null,
})}
shortcutNumber={shortcutIndex.get(workspace.workspaceKey) ?? null}
showShortcutBadge={showShortcutBadges}
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
onToggleWorkspacePin={onToggleWorkspacePin}
onWorkspacePress={onWorkspacePress}
/>
))}
</View>
) : null}
</View>
) : null}
</View>
))}
</>
);
}
@@ -793,8 +723,7 @@ const styles = StyleSheet.create((theme) => ({
},
listContent: {
paddingHorizontal: theme.spacing[2],
// Keep status mode's Pinned/Workspaces boundary identical to project mode.
paddingTop: 2,
paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[4],
},
pinnedSection: {
@@ -1,15 +0,0 @@
import { useCallback, useMemo, useState } from "react";
const INITIAL_VISIBLE_ITEMS = 20;
export function useLimitedSidebarGroup<T>(items: readonly T[]) {
const [expanded, setExpanded] = useState(false);
const visibleItems = useMemo(
() => (expanded ? items.slice() : items.slice(0, INITIAL_VISIBLE_ITEMS)),
[expanded, items],
);
const canToggle = items.length > INITIAL_VISIBLE_ITEMS;
const toggleExpanded = useCallback(() => setExpanded((current) => !current), []);
return { visibleItems, expanded, canToggle, toggleExpanded };
}
@@ -55,7 +55,6 @@ import {
getWorkspacePaneDescriptors,
} from "@/screens/workspace/workspace-pane-state";
import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set";
import { useModifiedPanelTabIds } from "@/panels/panel-instance-attributes";
import {
WorkspacePaneContent,
type WorkspacePaneContentModel,
@@ -76,7 +75,7 @@ import {
type SplitPane,
type WorkspaceLayout,
} from "@/stores/workspace-layout-store";
import type { WorkspaceTab } from "@/workspace-tabs/model";
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
import { RenderProfile } from "@/utils/render-profiler";
import { workspaceTabTargetsEqual } from "@/workspace-tabs/identity";
import { isNative } from "@/constants/platform";
@@ -935,17 +934,11 @@ function SplitPaneView({
);
const paneTabs = useMemo(() => paneState.tabs.map((tab) => tab.descriptor), [paneState.tabs]);
const paneTabIds = useMemo(() => paneTabs.map((tab) => tab.tabId), [paneTabs]);
const modifiedPaneTabIds = useModifiedPanelTabIds({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
tabIds: paneTabIds,
});
const tabDescriptorMap = useStableTabDescriptorMap(paneTabs);
const activeTabDescriptor = paneState.activeTab?.descriptor ?? null;
const { mountedTabIds } = useMountedTabSet({
activeTabId: activeTabDescriptor?.tabId ?? null,
allTabIds: paneTabIds,
retainedTabIds: modifiedPaneTabIds,
cap: 3,
});
const mountedPaneTabIds = useMemo(
@@ -11,7 +11,6 @@ import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
// indentation, guide lines, and chevron. Keep those here so the two trees can't
// drift apart.
export const TREE_INDENT_PER_LEVEL = 16;
export const WORKSPACE_FILE_ROW_VERTICAL_PADDING = SPACING[1.5];
/** Left padding for a tree row at `depth`. Shared by folder rows and file headers
* in the Changes tree so their indentation can't drift apart. */
+34 -61
View File
@@ -24,7 +24,6 @@ import {
type ViewStyle,
} from "react-native";
import { useTranslation } from "react-i18next";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import {
@@ -101,8 +100,6 @@ export interface ComboboxProps {
*/
header?: SheetHeader;
mobileChildrenScrollEnabled?: boolean;
/** Overrides the mobile scroll container spacing for custom child content. */
mobileChildrenContentContainerStyle?: StyleProp<ViewStyle>;
presentation?: "push" | "replace";
open?: boolean;
onOpenChange?: (open: boolean) => void;
@@ -937,7 +934,6 @@ interface MobileBodyProps {
searchable: boolean;
hasChildren: boolean;
mobileChildrenScrollEnabled: boolean;
mobileChildrenContentContainerStyle: StyleProp<ViewStyle>;
presentation?: "push" | "replace";
searchResetKey: number;
searchPlaceholder: string;
@@ -951,7 +947,6 @@ interface MobileBodyProps {
handleSelect: (id: string) => void;
renderOption: RenderOptionFn | undefined;
children: ReactNode;
safeAreaBottom: number;
}
function MobileComboboxBody(props: MobileBodyProps): ReactElement {
@@ -971,10 +966,6 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement {
() => [styles.comboboxTitle, { color: props.titleColor }],
[props.titleColor],
);
const frameStyle = useMemo(
() => [styles.mobileSheetFrame, { paddingBottom: props.safeAreaBottom }],
[props.safeAreaBottom],
);
const body = props.hasChildren ? (
props.children
@@ -1005,46 +996,40 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement {
keyboardBlurBehavior="none"
presentation={props.presentation}
>
<View style={frameStyle}>
{props.header ? (
<SheetHeaderView header={props.header} onClose={props.onClose} />
) : (
<>
<View style={styles.bottomSheetHeader}>
<Text key={props.titleColor} style={comboboxTitleStyle}>
{props.title}
</Text>
</View>
{props.stickyHeader}
{!props.hasChildren && props.searchable ? (
<SearchInput
placeholder={props.searchPlaceholder}
onChangeText={props.setSearchQueryWithCallback}
onSubmitEditing={props.handleSubmitSearch}
autoFocus={false}
useBottomSheetInput
resetKey={props.searchResetKey}
/>
) : null}
</>
)}
{props.hasChildren && !props.mobileChildrenScrollEnabled ? (
body
) : (
<BottomSheetScrollView
style={styles.mobileSheetBody}
contentContainerStyle={[
styles.comboboxScrollContent,
props.mobileChildrenContentContainerStyle,
]}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{body}
</BottomSheetScrollView>
)}
{props.footer ? <View style={styles.footer}>{props.footer}</View> : null}
</View>
{props.header ? (
<SheetHeaderView header={props.header} onClose={props.onClose} />
) : (
<>
<View style={styles.bottomSheetHeader}>
<Text key={props.titleColor} style={comboboxTitleStyle}>
{props.title}
</Text>
</View>
{props.stickyHeader}
{!props.hasChildren && props.searchable ? (
<SearchInput
placeholder={props.searchPlaceholder}
onChangeText={props.setSearchQueryWithCallback}
onSubmitEditing={props.handleSubmitSearch}
autoFocus={false}
useBottomSheetInput
resetKey={props.searchResetKey}
/>
) : null}
</>
)}
{props.hasChildren && !props.mobileChildrenScrollEnabled ? (
body
) : (
<BottomSheetScrollView
contentContainerStyle={styles.comboboxScrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{body}
</BottomSheetScrollView>
)}
{props.footer ? <View style={styles.footer}>{props.footer}</View> : null}
</IsolatedBottomSheetModal>
);
}
@@ -1237,7 +1222,6 @@ export function Combobox({
title,
header,
mobileChildrenScrollEnabled = true,
mobileChildrenContentContainerStyle,
presentation,
open,
onOpenChange,
@@ -1257,7 +1241,6 @@ export function Combobox({
const resolvedEmptyText = emptyText ?? t("common.empty.noOptionsMatchSearch");
const resolvedTitle = title ?? t("common.actions.select");
const isMobile = useIsCompactFormFactor();
const safeAreaInsets = useSafeAreaInsets();
const titleColor = theme.colors.foreground;
const effectiveOptionsPosition = resolveEffectiveOptionsPosition(isMobile, optionsPosition);
const isDesktopAboveSearch = resolveIsDesktopAboveSearch(isMobile, effectiveOptionsPosition);
@@ -1532,7 +1515,6 @@ export function Combobox({
searchable={searchable}
hasChildren={hasChildren}
mobileChildrenScrollEnabled={mobileChildrenScrollEnabled}
mobileChildrenContentContainerStyle={mobileChildrenContentContainerStyle}
presentation={presentation}
searchResetKey={searchResetKey}
searchPlaceholder={effectiveSearchPlaceholder}
@@ -1545,7 +1527,6 @@ export function Combobox({
emptyText={resolvedEmptyText}
handleSelect={handleSelect}
renderOption={renderOption}
safeAreaBottom={safeAreaInsets.bottom}
>
{children}
</MobileComboboxBody>
@@ -1588,14 +1569,6 @@ export function Combobox({
}
const styles = StyleSheet.create((theme) => ({
mobileSheetFrame: {
flex: 1,
minHeight: 0,
},
mobileSheetBody: {
flex: 1,
minHeight: 0,
},
searchInputContainer: {
flexDirection: "row",
alignItems: "center",
@@ -10,7 +10,6 @@ const theme = {
md: 6,
lg: 8,
xl: 12,
full: 9999,
},
borderWidth: {
1: 1,
@@ -103,41 +102,12 @@ describe("control geometry", () => {
expect(geometry.formTextInputMd.paddingVertical).toBe(11);
});
it("keeps segmented controls ghost with fully rounded segments in a button-sized track", () => {
it("subtracts segmented control inset from the nested segment radius", () => {
const geometry = createControlGeometry(theme);
expect(geometry.segmentedContainerXs.padding).toBe(0);
expect(geometry.segmentedContainerSm.padding).toBe(0);
expect(geometry.segmentedContainerMd.padding).toBe(0);
expect(geometry.segmentedSegmentXs.borderRadius).toBe(9999);
expect(geometry.segmentedSegmentSm.borderRadius).toBe(9999);
expect(geometry.segmentedSegmentMd.borderRadius).toBe(9999);
expect(geometry.segmentedContainerXs.minHeight).toBe(geometry.buttonXs.minHeight);
expect(geometry.segmentedContainerSm.minHeight).toBe(geometry.buttonSm.minHeight);
expect(geometry.segmentedContainerMd.minHeight).toBe(geometry.buttonMd.minHeight);
expect(geometry.segmentedSegmentXs.minHeight).toBe(24);
expect(geometry.segmentedSegmentSm.minHeight).toBe(28);
expect(geometry.segmentedSegmentMd.minHeight).toBe(38);
});
it("keeps one size contract across buttons and segmented controls", () => {
const geometry = createControlGeometry(theme);
// xs is a genuinely smaller tier, not sm with a different font.
expect(geometry.buttonXs.minHeight).toBe(28);
expect(geometry.buttonSm.minHeight).toBe(32);
expect(geometry.buttonMd.minHeight).toBe(44);
// Same size name means the same label size on every control kind.
expect(geometry.segmentedLabelXs.fontSize).toBe(12);
expect(geometry.segmentedLabelXs.fontSize).toBe(geometry.buttonTextXs.fontSize);
expect(geometry.segmentedLabelSm.fontSize).toBe(14);
expect(geometry.segmentedLabelSm.fontSize).toBe(geometry.buttonText.fontSize);
expect(geometry.segmentedLabelMd.fontSize).toBe(geometry.buttonText.fontSize);
// Same size name means the same horizontal padding on every control kind.
expect(geometry.segmentedSegmentXs.paddingHorizontal).toBe(geometry.buttonXs.paddingHorizontal);
expect(geometry.segmentedSegmentSm.paddingHorizontal).toBe(geometry.buttonSm.paddingHorizontal);
expect(geometry.segmentedSegmentMd.paddingHorizontal).toBe(geometry.buttonMd.paddingHorizontal);
expect(geometry.segmentedContainerSm.borderRadius).toBe(6);
expect(geometry.segmentedSegmentSm.borderRadius).toBe(4);
expect(geometry.segmentedContainerMd.borderRadius).toBe(8);
expect(geometry.segmentedSegmentMd.borderRadius).toBe(5);
});
});
@@ -3,7 +3,7 @@ import { ICON_SIZE, type Theme } from "@/styles/theme";
export type ButtonControlSize = "xs" | "sm" | "md" | "lg";
export type FieldControlSize = "sm" | "md";
export type SegmentedControlSize = "xs" | "sm" | "md";
export type SegmentedControlSize = "sm" | "md";
export type ControlInteractionPhase = "rest" | "hover" | "active";
export interface ControlInteractionState {
@@ -22,10 +22,8 @@ export interface ControlInteractionStyleMap {
controlDisabled?: StyleProp<ViewStyle>;
}
const TIGHT_CONTROL_HEIGHT = 28;
const COMPACT_CONTROL_HEIGHT = 32;
const FIELD_CONTROL_HEIGHT = 44;
const SEGMENTED_TIGHT_INSET = 2;
const SEGMENTED_COMPACT_INSET = 2;
const SEGMENTED_FIELD_INSET = 3;
const SWITCH_TRACK_WIDTH = 34;
@@ -37,7 +35,6 @@ const CONTROL_CENTER_JUSTIFY_CONTENT = "center";
const FIELD_TEXT_LINE_HEIGHT_RATIO = 1.4;
const controlHeights = {
tight: TIGHT_CONTROL_HEIGHT,
compact: COMPACT_CONTROL_HEIGHT,
field: FIELD_CONTROL_HEIGHT,
};
@@ -50,7 +47,6 @@ export const buttonIconSize: Record<ButtonControlSize, number> = {
};
export const segmentedIconSize: Record<SegmentedControlSize, number> = {
xs: ICON_SIZE.xs,
sm: ICON_SIZE.sm,
md: ICON_SIZE.md,
};
@@ -62,6 +58,10 @@ export const switchGeometry = {
thumbTravel: SWITCH_TRACK_WIDTH - SWITCH_THUMB_SIZE - (SWITCH_TRACK_HEIGHT - SWITCH_THUMB_SIZE),
};
function nestedRadius(containerRadius: number, inset: number): number {
return Math.max(0, containerRadius - inset);
}
function fieldLineHeight(fontSize: number): number {
return Math.round(fontSize * FIELD_TEXT_LINE_HEIGHT_RATIO);
}
@@ -121,6 +121,8 @@ export function createControlGeometry(theme: Theme) {
fontSize: theme.fontSize.base,
lineHeight: fieldTextMdLineHeight,
};
const segmentedContainerSmRadius = theme.borderRadius.md;
const segmentedContainerMdRadius = theme.borderRadius.lg;
const switchControl = {
minHeight: controlHeights.compact,
justifyContent: CONTROL_CENTER_JUSTIFY_CONTENT,
@@ -128,7 +130,7 @@ export function createControlGeometry(theme: Theme) {
return {
buttonXs: {
minHeight: controlHeights.tight,
minHeight: controlHeights.compact,
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
},
@@ -192,41 +194,31 @@ export function createControlGeometry(theme: Theme) {
opacity: theme.opacity[50],
},
switchControl,
segmentedContainerXs: {
minHeight: controlHeights.tight,
padding: 0,
},
segmentedContainerSm: {
minHeight: controlHeights.compact,
padding: 0,
padding: SEGMENTED_COMPACT_INSET,
borderRadius: segmentedContainerSmRadius,
},
segmentedContainerMd: {
minHeight: controlHeights.field,
padding: 0,
},
segmentedSegmentXs: {
minHeight: controlHeights.tight - SEGMENTED_TIGHT_INSET * 2,
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.full,
padding: SEGMENTED_FIELD_INSET,
borderRadius: segmentedContainerMdRadius,
},
segmentedSegmentSm: {
minHeight: controlHeights.compact - SEGMENTED_COMPACT_INSET * 2,
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.full,
paddingHorizontal: theme.spacing[4],
borderRadius: nestedRadius(segmentedContainerSmRadius, SEGMENTED_COMPACT_INSET),
},
segmentedSegmentMd: {
minHeight: controlHeights.field - SEGMENTED_FIELD_INSET * 2,
paddingHorizontal: theme.spacing[4],
borderRadius: theme.borderRadius.full,
},
segmentedLabelXs: {
fontSize: theme.fontSize.xs,
paddingHorizontal: theme.spacing[6],
borderRadius: nestedRadius(segmentedContainerMdRadius, SEGMENTED_FIELD_INSET),
},
segmentedLabelSm: {
fontSize: theme.fontSize.sm,
},
segmentedLabelMd: {
fontSize: theme.fontSize.sm,
fontSize: theme.fontSize.base,
},
};
}
@@ -11,7 +11,6 @@ import {
type ReactElement,
type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
@@ -34,7 +33,6 @@ import { FloatingScrollView, FloatingSurface } from "@/components/ui/floating";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { isWeb } from "@/constants/platform";
import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss";
import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root";
// Action status for menu items with loading/success feedback
export type ActionStatus = "idle" | "pending" | "success";
@@ -218,16 +216,12 @@ function renderDropdownSurface(input: {
style={surfaceStyle}
frameStyle={frameStyle}
entering={contentEntering}
exiting={
isWeb
? undefined
: contentExiting.withCallback((finished) => {
"worklet";
if (finished) {
runOnJS(onExited)();
}
})
}
exiting={contentExiting.withCallback((finished) => {
"worklet";
if (finished) {
runOnJS(onExited)();
}
})}
>
{body}
</FloatingSurface>
@@ -502,17 +496,6 @@ export function DropdownMenuContent({
setOpen(false);
}, [setOpen]);
useEffect(() => {
if (!isWeb || !modalVisible || typeof window === "undefined") return undefined;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
event.stopPropagation();
handleClose();
};
window.addEventListener("keydown", handleKeyDown, true);
return () => window.removeEventListener("keydown", handleKeyDown, true);
}, [handleClose, modalVisible]);
// Measure trigger when opening
useEffect(() => {
if (!open || !triggerRef.current) {
@@ -630,34 +613,6 @@ export function DropdownMenuContent({
</View>
);
const overlay = (
<View style={[styles.overlay, isWeb ? styles.overlayWeb : null]}>
<Pressable
accessibilityRole="button"
accessibilityLabel={t("menu.backdrop")}
style={styles.backdrop}
onPress={handleClose}
testID={testID ? `${testID}-backdrop` : undefined}
/>
{!closing
? renderDropdownSurface({
frameStyle,
testID,
surfaceStyle,
scrollable,
scrollViewportStyle,
content,
surfaceNativeID,
onExited: () => setModalVisible(false),
})
: null}
</View>
);
if (isWeb && typeof document !== "undefined") {
return createPortal(overlay, getOverlayRoot());
}
return (
<Modal
visible={modalVisible}
@@ -667,7 +622,27 @@ export function DropdownMenuContent({
onDismiss={flushPendingSelect}
onRequestClose={handleClose}
>
{overlay}
<View style={styles.overlay}>
<Pressable
accessibilityRole="button"
accessibilityLabel={t("menu.backdrop")}
style={styles.backdrop}
onPress={handleClose}
testID={testID ? `${testID}-backdrop` : undefined}
/>
{!closing
? renderDropdownSurface({
frameStyle,
testID,
surfaceStyle,
scrollable,
scrollViewportStyle,
content,
surfaceNativeID,
onExited: () => setModalVisible(false),
})
: null}
</View>
</Modal>
);
}
@@ -903,11 +878,6 @@ const styles = StyleSheet.create((theme) => ({
overlay: {
flex: 1,
},
overlayWeb: {
...StyleSheet.absoluteFillObject,
zIndex: OVERLAY_Z.modal,
pointerEvents: "auto" as const,
},
backdrop: {
position: "absolute",
top: 0,
@@ -41,7 +41,7 @@ function SegmentIcon({ icon, iconSize, iconColor }: SegmentIconProps) {
const ThemedSegmentIcon = withUnistyles(SegmentIcon);
const selectedIconMapping = (theme: Theme) => ({ iconColor: theme.colors.surface0 });
const selectedIconMapping = (theme: Theme) => ({ iconColor: theme.colors.foreground });
const mutedIconMapping = (theme: Theme) => ({ iconColor: theme.colors.foregroundMuted });
export function SegmentedControl<T extends string>({
@@ -53,14 +53,9 @@ export function SegmentedControl<T extends string>({
style,
testID,
}: SegmentedControlProps<T>) {
const sizeStyles = {
xs: { container: styles.containerXs, segment: styles.segmentXs, label: styles.labelXs },
sm: { container: styles.containerSm, segment: styles.segmentSm, label: styles.labelSm },
md: { container: styles.containerMd, segment: styles.segmentMd, label: styles.labelMd },
}[size];
const containerSizeStyle = sizeStyles.container;
const segmentSizeStyle = sizeStyles.segment;
const labelSizeStyle = sizeStyles.label;
const containerSizeStyle = size === "sm" ? styles.containerSm : styles.containerMd;
const segmentSizeStyle = size === "sm" ? styles.segmentSm : styles.segmentMd;
const labelSizeStyle = size === "sm" ? styles.labelSm : styles.labelMd;
const iconSize = segmentedIconSize[size];
const containerStyle = useMemo(
@@ -166,12 +161,9 @@ const styles = StyleSheet.create((theme) => {
return {
container: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "transparent",
gap: theme.spacing[1],
},
containerXs: {
...geometry.segmentedContainerXs,
alignItems: "stretch",
backgroundColor: theme.colors.surface2,
gap: 2,
},
containerSm: {
...geometry.segmentedContainerSm,
@@ -186,9 +178,6 @@ const styles = StyleSheet.create((theme) => {
flexShrink: 0,
gap: theme.spacing[1],
},
segmentXs: {
...geometry.segmentedSegmentXs,
},
segmentSm: {
...geometry.segmentedSegmentSm,
},
@@ -196,13 +185,18 @@ const styles = StyleSheet.create((theme) => {
...geometry.segmentedSegmentMd,
},
segmentSelected: {
backgroundColor: theme.colors.foreground,
backgroundColor: theme.colors.surface0,
shadowColor: "#000",
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.08,
shadowRadius: 2,
elevation: 1,
},
segmentHover: {
backgroundColor: theme.colors.surface2,
backgroundColor: theme.colors.surface1,
},
segmentPressed: {
backgroundColor: theme.colors.surface3,
backgroundColor: theme.colors.surface1,
},
segmentDisabled: {
opacity: theme.opacity[50],
@@ -215,9 +209,6 @@ const styles = StyleSheet.create((theme) => {
color: theme.colors.foregroundMuted,
fontWeight: theme.fontWeight.normal,
},
labelXs: {
...geometry.segmentedLabelXs,
},
labelSm: {
...geometry.segmentedLabelSm,
},
@@ -225,7 +216,7 @@ const styles = StyleSheet.create((theme) => {
...geometry.segmentedLabelMd,
},
labelSelected: {
color: theme.colors.surface0,
color: theme.colors.foreground,
},
};
});
+21 -19
View File
@@ -12,7 +12,6 @@ import {
type PropsWithChildren,
type ReactElement,
} from "react";
import { createPortal } from "react-dom";
import {
Dimensions,
Platform,
@@ -24,12 +23,13 @@ import {
type StyleProp,
type ViewStyle,
} from "react-native";
import { Portal } from "@gorhom/portal";
import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet";
import { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { FloatingSurface } from "@/components/ui/floating";
import { isWeb } from "@/constants/platform";
import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root";
type Side = "top" | "bottom" | "left" | "right";
type Align = "start" | "center" | "end";
@@ -446,6 +446,7 @@ export function TooltipContent({
maxWidth?: number;
}>): ReactElement | null {
const ctx = useTooltipContext("TooltipContent");
const bottomSheetInternal = useBottomSheetModalInternal(true);
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
@@ -515,22 +516,23 @@ export function TooltipContent({
// steal focus / disrupt hover). Rendering via Portal + position:fixed keeps the
// exact same positioning math as DropdownMenu, without hover feedback loops.
if (isWeb) {
return createPortal(
<View pointerEvents="none" style={styles.portalOverlay}>
<FloatingSurface
pointerEvents="none"
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
collapsable={false}
testID={testID}
onLayout={handleLayout}
style={contentStyle}
frameStyle={frameStyle}
>
{children}
</FloatingSurface>
</View>,
getOverlayRoot(),
return (
<Portal hostName={bottomSheetInternal?.hostName}>
<View pointerEvents="none" style={styles.portalOverlay}>
<FloatingSurface
pointerEvents="none"
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
collapsable={false}
testID={testID}
onLayout={handleLayout}
style={contentStyle}
frameStyle={frameStyle}
>
{children}
</FloatingSurface>
</View>
</Portal>
);
}
@@ -568,7 +570,7 @@ const styles = StyleSheet.create((theme) => ({
right: 0,
bottom: 0,
left: 0,
zIndex: OVERLAY_Z.tooltip,
zIndex: 1000,
},
content: {
paddingVertical: theme.spacing[1],
@@ -6,9 +6,11 @@ import { createNameId } from "mnemonic-id";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
import { Composer } from "@/composer";
import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control";
import { useToast } from "@/contexts/toast-context";
import { useAgentInputDraft } from "@/composer/draft/input-draft";
import { useProjectIconQuery } from "@/hooks/use-project-icon-query";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
@@ -380,6 +382,7 @@ export function WorkspaceSetupDialog() {
const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(workspaceTitle);
const placeholderInitial = placeholderLabel.charAt(0).toUpperCase();
const isCompact = useIsCompactFormFactor();
const iconSource = useMemo(() => (iconDataUri ? { uri: iconDataUri } : null), [iconDataUri]);
const agentControlsWithDisabled = useMemo(
() =>
@@ -392,6 +395,14 @@ export function WorkspaceSetupDialog() {
[composerState, pendingAction],
);
const composerFooter = useMemo(
() =>
isCompact && agentControlsWithDisabled ? (
<DraftAgentModeControl placement="footer" {...agentControlsWithDisabled} />
) : undefined,
[isCompact, agentControlsWithDisabled],
);
const subtitleContent = useMemo(
() => (
<View style={styles.subtitleRow}>
@@ -446,6 +457,7 @@ export function WorkspaceSetupDialog() {
commandDraftConfig={composerState?.commandDraftConfig}
agentControls={agentControlsWithDisabled}
inputWrapperStyle={styles.composerInputWrapper}
footer={composerFooter}
/>
</FileDropZone>

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