mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
113 Commits
conductor-
...
v0.2.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b589599a8f | ||
|
|
9821729c75 | ||
|
|
7e599db6fe | ||
|
|
b3bfec027f | ||
|
|
6610dbdffe | ||
|
|
a27a1a07dc | ||
|
|
dcaed8008b | ||
|
|
36f38245ca | ||
|
|
782b341b1a | ||
|
|
7ef3376b62 | ||
|
|
d98c5e77f7 | ||
|
|
fbbdbdc571 | ||
|
|
7e97ab4a9c | ||
|
|
0cb9ecf44b | ||
|
|
afcd972dd0 | ||
|
|
48b14d27a5 | ||
|
|
609f81bc11 | ||
|
|
779a56ed36 | ||
|
|
7bd4afe848 | ||
|
|
fc10c79e26 | ||
|
|
08c522c986 | ||
|
|
4b5551d61c | ||
|
|
b02acb882c | ||
|
|
13bce05630 | ||
|
|
17c12e2e1a | ||
|
|
c469ac124a | ||
|
|
09cfdecbbf | ||
|
|
1c95f8c37e | ||
|
|
12612f6646 | ||
|
|
a290f74705 | ||
|
|
b73592ccac | ||
|
|
8a8f2baf80 | ||
|
|
a3438f96f8 | ||
|
|
eb83e2bb45 | ||
|
|
2bffd6e71e | ||
|
|
1ffa2f821a | ||
|
|
fd0deea1c1 | ||
|
|
e8fb9bda6c | ||
|
|
5e47cff58e | ||
|
|
8e063f0dfc | ||
|
|
8cf70d10bf | ||
|
|
31c8dc3f05 | ||
|
|
d1f19a5cdd | ||
|
|
8a1243e8d3 | ||
|
|
dd8a111c30 | ||
|
|
780c6513f1 | ||
|
|
8b54d35818 | ||
|
|
e699f07a17 | ||
|
|
30b871e8d2 | ||
|
|
894fa8516e | ||
|
|
35f5171477 | ||
|
|
5dfe50ca74 | ||
|
|
de69b2a2af | ||
|
|
10da5ca169 | ||
|
|
512c9b31a8 | ||
|
|
76a5edb020 | ||
|
|
9952615c33 | ||
|
|
bd937850b3 | ||
|
|
42ee5a5949 | ||
|
|
68993b7ab3 | ||
|
|
246c07fba5 | ||
|
|
0afcc96370 | ||
|
|
3a8d08ba91 | ||
|
|
f22a8ea8e0 | ||
|
|
89a022e853 | ||
|
|
14b25d4266 | ||
|
|
6cddc657cd | ||
|
|
14acb97662 | ||
|
|
4a4556f499 | ||
|
|
21d9bdc644 | ||
|
|
97cfcf8306 | ||
|
|
9603397aed | ||
|
|
309672c8e5 | ||
|
|
2ed1f4f353 | ||
|
|
cb6d2f1459 | ||
|
|
cf9dfcf947 | ||
|
|
7d10791bad | ||
|
|
2cc1886a6b | ||
|
|
d27c7ad590 | ||
|
|
a4d11cda2b | ||
|
|
c049e86fee | ||
|
|
5c93ac4aa5 | ||
|
|
9ca790df6a | ||
|
|
ee431bb340 | ||
|
|
0707092131 | ||
|
|
b2139b1400 | ||
|
|
a8fb40e689 | ||
|
|
25cdda9492 | ||
|
|
d6dc309408 | ||
|
|
6755341fbb | ||
|
|
2cb1b041dd | ||
|
|
5605b2aa94 | ||
|
|
fd4e13735c | ||
|
|
6acc82e9d3 | ||
|
|
d0456b1943 | ||
|
|
8aa55db1e8 | ||
|
|
afeb4d18f8 | ||
|
|
f214261ae5 | ||
|
|
aa6384babd | ||
|
|
ddb6d97bf0 | ||
|
|
187561e433 | ||
|
|
045de763f7 | ||
|
|
1962816c1e | ||
|
|
8c92c7d423 | ||
|
|
345d8e5240 | ||
|
|
4bda2dfea9 | ||
|
|
9292f58896 | ||
|
|
2ead7e7719 | ||
|
|
b3b1283d3b | ||
|
|
e1bda8e498 | ||
|
|
07d988488e | ||
|
|
0c68b26a8b | ||
|
|
3d86c738ff |
18
.mise.toml
18
.mise.toml
@@ -1,10 +1,10 @@
|
||||
[env]
|
||||
ANDROID_HOME = "{{env.HOME}}/.local/share/mise/installs/android-sdk/21.0"
|
||||
_.path = [
|
||||
"{{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",
|
||||
]
|
||||
[vars]
|
||||
android_sdk_version = '{{ read_file(path=".tool-versions") | split(pat="android-sdk") | last | trim | split(pat="\n") | first | trim }}'
|
||||
|
||||
[tools]
|
||||
java = "21"
|
||||
[env]
|
||||
ANDROID_HOME = "{{ env.HOME }}/.local/share/mise/installs/android-sdk/{{ vars.android_sdk_version }}"
|
||||
_.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",
|
||||
]
|
||||
|
||||
62
CHANGELOG.md
62
CHANGELOG.md
@@ -1,18 +1,48 @@
|
||||
# Changelog
|
||||
|
||||
## 0.2.0-beta.1 - 2026-07-17
|
||||
## 0.2.2 - 2026-07-25
|
||||
|
||||
### Fixed
|
||||
|
||||
- Claude 5 models now use the correct context windows.
|
||||
|
||||
## 0.2.1 - 2026-07-24
|
||||
|
||||
### Added
|
||||
|
||||
- Claude Opus 5 is available
|
||||
|
||||
## 0.2.0 - 2026-07-24
|
||||
|
||||
### 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))
|
||||
- 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))
|
||||
- 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))
|
||||
- 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
|
||||
|
||||
- ACP provider catalog updated to the latest registry versions
|
||||
- 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))
|
||||
- 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))
|
||||
@@ -20,12 +50,34 @@
|
||||
|
||||
### 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))
|
||||
- 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))
|
||||
- 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
|
||||
|
||||
@@ -80,6 +80,20 @@ 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:
|
||||
|
||||
@@ -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 sets `ANDROID_HOME` and puts `cmdline-tools/21.0/bin`, `platform-tools`, and `emulator` on `PATH`). 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 derives `ANDROID_HOME` and the command-line tool paths from the `android-sdk` entry). 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 the version in `.tool-versions` and in all four paths in `.mise.toml`.
|
||||
> **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.
|
||||
|
||||
`mise install` only lays down the command-line tools. Install the rest and create an emulator. On Apple Silicon:
|
||||
|
||||
@@ -120,6 +120,16 @@ 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.
|
||||
|
||||
@@ -190,6 +190,10 @@ 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: {
|
||||
|
||||
@@ -64,6 +64,8 @@ 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`.
|
||||
|
||||
---
|
||||
|
||||
@@ -278,6 +278,15 @@ 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.
|
||||
@@ -374,13 +383,14 @@ 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 --isolation 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 --new-workspace 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
|
||||
```
|
||||
@@ -391,6 +401,11 @@ 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:
|
||||
|
||||
@@ -73,6 +73,20 @@ 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.
|
||||
|
||||
@@ -57,7 +57,7 @@ Android touches sailed straight through to the chat scroll view behind it.
|
||||
|
||||
Two escape hatches in the codebase:
|
||||
|
||||
- **`Modal`** (combobox, tooltip on native) — opens a new Android window, so
|
||||
- **`Modal`** (combobox, dropdown menu and 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,7 +70,13 @@ 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.
|
||||
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.
|
||||
|
||||
## Gotcha 2 — Portal breaks lifecycle and coordinate-system inheritance
|
||||
|
||||
@@ -123,6 +129,13 @@ 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.
|
||||
|
||||
@@ -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 passed to Pi with `--append-system-prompt`, so Pi keeps its default coding prompt while receiving Paseo's additional instructions.
|
||||
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.
|
||||
|
||||
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,6 +44,8 @@ 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.
|
||||
|
||||
@@ -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>))
|
||||
```
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
If config installation fails, daemon startup and terminal spawn continue without terminal activity hooks.
|
||||
|
||||
@@ -32,6 +32,11 @@ 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)
|
||||
|
||||
|
||||
@@ -51,17 +51,27 @@ 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 short grace period so brief tab, pane, route, and app switches do not repeatedly
|
||||
subscribed for a 30-second 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, a retained timeline stays covered when revisited until authoritative
|
||||
catch-up completes; cached partial output is never presented as current history.
|
||||
After grace has expired, revisiting a retained timeline displays its cached state immediately and
|
||||
authoritative catch-up advances it to the current tail.
|
||||
- Legacy daemons keep globally streaming agent timelines. Visibility still triggers the existing
|
||||
authoritative catch-up, but the app does not issue selective-subscription RPCs.
|
||||
|
||||
@@ -77,11 +87,14 @@ 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 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.
|
||||
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.
|
||||
|
||||
## Relevant code
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
sha256-MjAAaU+zU3m5fdE9rQrr4aA3eTDZztIXJQVXKbj73rY=
|
||||
sha256-t7FqvPj7U5YidGH9OZ1kFnD2dPTYUyjcxBlzAJU7Mak=
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
nodejs_22,
|
||||
python3,
|
||||
makeWrapper,
|
||||
autoPatchelfHook,
|
||||
# node-pty needs libuv headers on Linux
|
||||
libuv,
|
||||
# Exposed so downstream flakes that follow a different nixpkgs revision
|
||||
@@ -59,10 +60,13 @@ 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
|
||||
@@ -71,10 +75,9 @@ buildNpmPackage rec {
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
|
||||
# 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.
|
||||
# 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.
|
||||
npm rebuild node-pty
|
||||
|
||||
# Build all server packages in dependency order (defined in package.json)
|
||||
@@ -89,7 +92,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-worker-process.js) plus an explicit list of non-JS
|
||||
# forked terminal/speech worker processes) 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.
|
||||
|
||||
191
package-lock.json
generated
191
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.2.0-beta.1",
|
||||
"version": "0.2.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.2.0-beta.1",
|
||||
"version": "0.2.2",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -2531,10 +2531,35 @@
|
||||
"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.3",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz",
|
||||
"integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
|
||||
"version": "6.12.4",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz",
|
||||
"integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
@@ -2554,22 +2579,33 @@
|
||||
"@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.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz",
|
||||
"integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
|
||||
"version": "6.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz",
|
||||
"integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@marijn/find-cluster-break": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/view": {
|
||||
"version": "6.43.0",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.0.tgz",
|
||||
"integrity": "sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==",
|
||||
"version": "6.43.6",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.6.tgz",
|
||||
"integrity": "sha512-EVunGSYN1wz1p75WY1s3Xg7t3i8Yol0kGZGizNdX9BUFgMFILYVe8/u6EVpo7Ff5PwbZuILb4QAq7IZoKzIEQA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/state": "^6.7.0",
|
||||
"crelt": "^1.0.6",
|
||||
"style-mod": "^4.1.0",
|
||||
"w3c-keyname": "^2.2.4"
|
||||
@@ -11055,6 +11091,19 @@
|
||||
"@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",
|
||||
@@ -35162,8 +35211,13 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.2.0-beta.1",
|
||||
"version": "0.2.2",
|
||||
"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",
|
||||
@@ -35178,6 +35232,7 @@
|
||||
"@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",
|
||||
@@ -36181,12 +36236,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.2.0-beta.1",
|
||||
"version": "0.2.2",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.2.0-beta.1",
|
||||
"@getpaseo/protocol": "0.2.0-beta.1",
|
||||
"@getpaseo/server": "0.2.0-beta.1",
|
||||
"@getpaseo/client": "0.2.2",
|
||||
"@getpaseo/protocol": "0.2.2",
|
||||
"@getpaseo/server": "0.2.2",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -36432,10 +36487,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.2.0-beta.1",
|
||||
"version": "0.2.2",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.2.0-beta.1",
|
||||
"@getpaseo/relay": "0.2.0-beta.1",
|
||||
"@getpaseo/protocol": "0.2.2",
|
||||
"@getpaseo/relay": "0.2.2",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -36446,7 +36501,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.2.0-beta.1",
|
||||
"version": "0.2.2",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -36689,7 +36744,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.2.0-beta.1",
|
||||
"version": "0.2.2",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -37585,9 +37640,9 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.2.0-beta.1",
|
||||
"version": "0.2.2",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.12.3",
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/cpp": "^1.1.5",
|
||||
@@ -37817,7 +37872,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.2.0-beta.1",
|
||||
"version": "0.2.2",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -37830,7 +37885,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.2.0-beta.1",
|
||||
"version": "0.2.2",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38048,15 +38103,15 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.2.0-beta.1",
|
||||
"version": "0.2.2",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.214",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@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",
|
||||
"@getpaseo/client": "0.2.2",
|
||||
"@getpaseo/highlight": "0.2.2",
|
||||
"@getpaseo/protocol": "0.2.2",
|
||||
"@getpaseo/relay": "0.2.2",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -38100,22 +38155,22 @@
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk": {
|
||||
"version": "0.3.214",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.214.tgz",
|
||||
"integrity": "sha512-wt5ImwhU+p259Zt4K/Q9v5xVi6ruxYO5+KFICyJxnjs/QFEClAeSRqhcXx1J8jgGfatPW1faw09hkm66680UjA==",
|
||||
"version": "0.3.220",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.220.tgz",
|
||||
"integrity": "sha512-glc7SdwPkOkLw8oxwLo9PKTdLJGqW/PIR4urWXFoRtX9YllwozsEVc5Tc1+EvLSkfrsxPJqQWqOgpjUOQXf1oA==",
|
||||
"license": "SEE LICENSE IN README.md",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.214",
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.214",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.214",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.214",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.214",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.214",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.214",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.214"
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.220",
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.220",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.220",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.220",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.220",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.220",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.220",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.220"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@anthropic-ai/sdk": ">=0.93.0",
|
||||
@@ -38124,9 +38179,9 @@
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": {
|
||||
"version": "0.3.214",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.214.tgz",
|
||||
"integrity": "sha512-vAAOeVtlXs3p7MpFVfvfu9ja32eaKtB+MDZnPxCbdzxJk5nofCHlNsHa1UJwXHOsP9lOnFYNIccYztsZ4DNaJA==",
|
||||
"version": "0.3.220",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.220.tgz",
|
||||
"integrity": "sha512-7VxlbEosK7DODiOnsjoVd0DSJzbnaPrM2jelMHI0y8zx1UnLS3WC6EFUXbvy74F2sXqEznh2tzn7EKWInaRN6Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -38137,9 +38192,9 @@
|
||||
]
|
||||
},
|
||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": {
|
||||
"version": "0.3.214",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.214.tgz",
|
||||
"integrity": "sha512-NTbV8U2yucxCWqEiDC7L0MUehNmd8x3Op8OGzLZRhMF3xmQBy2DxpXiNZgSwwKWNDC3HdgKmJM5ZBbT7XrF/0g==",
|
||||
"version": "0.3.220",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.220.tgz",
|
||||
"integrity": "sha512-X9RwDsSmbF6ultKZroaip+DL8WRgC64gHbrAwrRlAFSPNZV7zmJyP2ur8rW7KrxqmtuehdMMkw8+SAC/6hD2PA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -38150,9 +38205,9 @@
|
||||
]
|
||||
},
|
||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": {
|
||||
"version": "0.3.214",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.214.tgz",
|
||||
"integrity": "sha512-KBCf+BlusG0ZcgvpjjHwv1kh+6WiR8vJbPjpR2udwkrtcQgKLN+24l+FeaQEzO2TL+ExCmM1KC4tNPvnPpy+tw==",
|
||||
"version": "0.3.220",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.220.tgz",
|
||||
"integrity": "sha512-WkROPwWskqhKR9XgnmseHQ6rLi9zM9qt57IWoToIjL/eXOqDWipp7JXZ1L5ud+LrA42dunHPZfBwD/vXZ+A7LA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -38163,9 +38218,9 @@
|
||||
]
|
||||
},
|
||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": {
|
||||
"version": "0.3.214",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.214.tgz",
|
||||
"integrity": "sha512-i06wQRsmevE7spY1ryYfs+NP+xdZ1FwAyTjDaF0k/xG+cgtzZYghTFUemdYF3GVwgWgpcava9GiCFDT3DoHI6w==",
|
||||
"version": "0.3.220",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.220.tgz",
|
||||
"integrity": "sha512-OHoZOZ8Cf2TBr6oXIXPwyvUxj9jrq2w8E4poA8dMpacXszcPSPiCQCMuuOh4aWJzfeJE1+TtWxhKMVb2csXyZQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -38176,9 +38231,9 @@
|
||||
]
|
||||
},
|
||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": {
|
||||
"version": "0.3.214",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.214.tgz",
|
||||
"integrity": "sha512-vqadSceJkBKHaTUszxI35uTuECGWtsHWK/mOwIJ4b9DUtYYySz6EJEk4gHg4ccutisJ/oRVCpFXHIKFb+osrKw==",
|
||||
"version": "0.3.220",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.220.tgz",
|
||||
"integrity": "sha512-tkTJFnpR9VifvWX2fmkCAPkT6+8Wk/gVu8B5jsVekKZPiZoWRHmMXO30BnZn+f0TZhgYP+82PSX3S8crH1kn+w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -38189,9 +38244,9 @@
|
||||
]
|
||||
},
|
||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": {
|
||||
"version": "0.3.214",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.214.tgz",
|
||||
"integrity": "sha512-948RstHDhs0E69h+2dKYWIAL1kcwEme4zvtgNUwP8XpKXzWOhrTKmd6MAokHn25zwfuSx5d+HE0XHfFH6R4hLg==",
|
||||
"version": "0.3.220",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.220.tgz",
|
||||
"integrity": "sha512-K+FWj+LcGhC1Z7wqeWoLxm1iemcba5xKpLLFVwYm4V6HyMx3ruYd/2r2TiQtjT+JWeNFWIys0ScHiItR6vWAiA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -38202,9 +38257,9 @@
|
||||
]
|
||||
},
|
||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": {
|
||||
"version": "0.3.214",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.214.tgz",
|
||||
"integrity": "sha512-CEKlPFCPv+ee79utQMEDSsgeo2f27ulhNHpjrKIt1jXz5G04J7lAWN/QwvPDv9XaLBkWpyfqZWiPXlRcwuaO/w==",
|
||||
"version": "0.3.220",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.220.tgz",
|
||||
"integrity": "sha512-rIwgq0UwQExWl6KrHUyC4w5KwpL9l6nd95aUTx6RitexaAuEw//xtfTVLnuE4hDDQZFkzEwpdKc3nxDWoGcUbA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -38215,9 +38270,9 @@
|
||||
]
|
||||
},
|
||||
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": {
|
||||
"version": "0.3.214",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.214.tgz",
|
||||
"integrity": "sha512-cJMJfFoR9IWBZWnTtt9PnEm89hGOsZjoDNjOCEOF3+x+g/ANIXAjMJTcaQYv2HKh6MylWZ0AkxBASI+twkukaQ==",
|
||||
"version": "0.3.220",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.220.tgz",
|
||||
"integrity": "sha512-MuOuXhbr66HlGaWXD2f3w0k2PsvmnbkwcUZ0dAe2poFLdl72GC2dapwwOBefxm9QmoNqk9+jmv/dSKGOVWyvLw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -38593,7 +38648,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.2.0-beta.1",
|
||||
"version": "0.2.2",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.2.0-beta.1",
|
||||
"version": "0.2.2",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
@@ -132,6 +132,8 @@
|
||||
"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",
|
||||
|
||||
49
packages/app/e2e/add-changed-file-to-chat.spec.ts
Normal file
49
packages/app/e2e/add-changed-file-to-chat.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
115
packages/app/e2e/archived-codex-agent.real.spec.ts
Normal file
115
packages/app/e2e/archived-codex-agent.real.spec.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
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,4 +123,16 @@ 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,25 @@ 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,
|
||||
@@ -23,31 +42,26 @@ 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 });
|
||||
await expect(panel.getByTestId("commit-diff-layout-unified")).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
const layoutToggle = panel.getByTestId("commit-diff-toggle-layout");
|
||||
await expect(layoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
|
||||
await expect(panel.getByTestId("diff-code-row-0")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await panel.getByTestId("commit-diff-layout-split").click();
|
||||
await expect(panel.getByTestId("commit-diff-layout-split")).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await layoutToggle.click();
|
||||
await expect(layoutToggle).toHaveAccessibleName("Switch to unified diff");
|
||||
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-layout-split")).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
await expect(panel.getByTestId("commit-diff-toggle-layout")).toHaveAccessibleName(
|
||||
"Switch to unified diff",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { unlink, 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,6 +10,11 @@ import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
||||
|
||||
interface DirtyWorkspace {
|
||||
id: string;
|
||||
repoPath: string;
|
||||
}
|
||||
|
||||
interface WorkspaceFixtureOptions {
|
||||
includeDeletedFile?: boolean;
|
||||
}
|
||||
|
||||
interface CleanupTask {
|
||||
@@ -218,6 +223,99 @@ 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);
|
||||
@@ -243,6 +341,11 @@ 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);
|
||||
|
||||
@@ -250,6 +353,53 @@ 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,
|
||||
}) => {
|
||||
@@ -399,10 +549,14 @@ async function readVisibleDiffRowGeometry(page: Page): Promise<{
|
||||
});
|
||||
}
|
||||
|
||||
async function createWorkspaceWithMountedTabDiff(): Promise<DirtyWorkspace> {
|
||||
const repo = await createTempGitRepo("diff-row-alignment-", {
|
||||
files: [{ path: "src/use-mounted-tab-set.ts", content: BEFORE }],
|
||||
});
|
||||
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 });
|
||||
const client = await connectSeedClient();
|
||||
cleanupTasks.push({
|
||||
run: async () => {
|
||||
@@ -412,13 +566,16 @@ async function createWorkspaceWithMountedTabDiff(): Promise<DirtyWorkspace> {
|
||||
});
|
||||
|
||||
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 };
|
||||
return { id: createdWorkspace.workspace.id, repoPath: repo.path };
|
||||
}
|
||||
|
||||
async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
|
||||
@@ -436,6 +593,21 @@ 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
packages/app/e2e/file-editing.spec.ts
Normal file
356
packages/app/e2e/file-editing.spec.ts
Normal file
@@ -0,0 +1,356 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -328,6 +328,12 @@ 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");
|
||||
@@ -349,6 +355,70 @@ 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,8 @@ 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
|
||||
@@ -101,6 +103,7 @@ declare global {
|
||||
__capturedDialogCall: ConfirmDialogCall | undefined;
|
||||
__capturedDialogOpenCalls: Array<Record<string, unknown> | undefined>;
|
||||
__recordDesktopEditorOpen?: (input: DesktopEditorOpenRecord) => Promise<void>;
|
||||
__desktopDaemonStartRequested?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +132,7 @@ 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 {
|
||||
@@ -145,6 +149,7 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
||||
}
|
||||
|
||||
function startDesktopDaemon() {
|
||||
window.__desktopDaemonStartRequested = true;
|
||||
if (cfg.hangDaemonStart) {
|
||||
return new Promise(() => undefined);
|
||||
}
|
||||
@@ -156,6 +161,13 @@ 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>;
|
||||
@@ -212,6 +224,7 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
||||
}
|
||||
|
||||
if (command === "get_desktop_settings") {
|
||||
await waitForDesktopSettingsResponse();
|
||||
return {
|
||||
releaseChannel: "stable",
|
||||
daemon: { manageBuiltInDaemon: manageDaemon, keepRunningAfterQuit: true },
|
||||
@@ -277,6 +290,17 @@ 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,5 +1,5 @@
|
||||
import { execFileSync, execSync } from "node:child_process";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
export function hasGithubAuth(): boolean {
|
||||
@@ -59,6 +59,12 @@ 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,
|
||||
@@ -281,3 +287,57 @@ 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,4 +1,4 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { expect, type BrowserContext, 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,6 +290,13 @@ 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 });
|
||||
@@ -351,6 +358,18 @@ 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: {
|
||||
|
||||
@@ -144,7 +144,7 @@ export interface SeedDaemonClient {
|
||||
} | null;
|
||||
fetchAgentHistory(options?: {
|
||||
page?: { limit: number };
|
||||
}): Promise<{ entries: Array<{ id: string }> }>;
|
||||
}): Promise<{ entries: Array<{ agent: { id: string } }> }>;
|
||||
subscribeTerminal(
|
||||
terminalId: string,
|
||||
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
|
||||
|
||||
@@ -22,6 +22,7 @@ interface SavedSettingsHostInput {
|
||||
const SECTION_LABELS = {
|
||||
general: "General",
|
||||
appearance: "Appearance",
|
||||
editor: "Editor",
|
||||
shortcuts: "Shortcuts",
|
||||
integrations: "Integrations",
|
||||
permissions: "Permissions",
|
||||
|
||||
@@ -7,6 +7,10 @@ interface SessionMessage {
|
||||
payload?: unknown;
|
||||
}
|
||||
|
||||
interface TimelineSubscriptionWaitOptions {
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
function readSessionMessage(message: WebSocketMessage): SessionMessage | null {
|
||||
if (typeof message !== "string") return null;
|
||||
try {
|
||||
@@ -36,72 +40,16 @@ export function observeTimelineSubscriptions(page: Page) {
|
||||
});
|
||||
|
||||
return {
|
||||
async waitForSubscribedAgents(agentIds: string[]): Promise<void> {
|
||||
async waitForSubscribedAgents(
|
||||
agentIds: string[],
|
||||
options: TimelineSubscriptionWaitOptions = {},
|
||||
): Promise<void> {
|
||||
const expected = [...new Set(agentIds)].sort();
|
||||
await expect
|
||||
.poll(() => acknowledgedAgentIds?.slice().sort() ?? null, { timeout: 15_000 })
|
||||
.poll(() => acknowledgedAgentIds?.slice().sort() ?? null, {
|
||||
timeout: options.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]);
|
||||
});
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,11 +17,14 @@ import {
|
||||
expectPickerOpen,
|
||||
expectPickerSelected,
|
||||
expectStartingRefPickerTriggerPr,
|
||||
fillNewWorkspaceDraft,
|
||||
openGlobalNewWorkspaceComposer,
|
||||
openBranchPicker,
|
||||
openNewWorkspaceComposer,
|
||||
openProjectViaDaemon,
|
||||
openStartingRefPicker,
|
||||
pasteGithubPrUrl,
|
||||
searchAndSelectBranchInPicker,
|
||||
selectBranchInPicker,
|
||||
selectGitHubPrInPicker,
|
||||
selectPickerOptionByKeyboard,
|
||||
@@ -30,9 +33,11 @@ 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";
|
||||
@@ -191,6 +196,7 @@ 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 });
|
||||
|
||||
@@ -212,6 +218,13 @@ 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();
|
||||
@@ -807,6 +820,100 @@ 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,6 +219,7 @@ 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
packages/app/e2e/tool-call-shimmer.spec.ts
Normal file
238
packages/app/e2e/tool-call-shimmer.spec.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
@@ -3,10 +3,7 @@ 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 {
|
||||
observeLastAssistantFrames,
|
||||
observeTimelineSubscriptions,
|
||||
} from "./helpers/timeline-delivery";
|
||||
import { observeTimelineSubscriptions } from "./helpers/timeline-delivery";
|
||||
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
||||
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
|
||||
import {
|
||||
@@ -75,47 +72,40 @@ async function commitMessage(scenario: ViewedTimelineScenario, agentId: string,
|
||||
}
|
||||
|
||||
test.describe("Viewed agent timelines", () => {
|
||||
test("a focused turn streams live and resumes its hidden remainder atomically", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("an unsubscribed hidden chat catches up when shown", async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
const subscriptions = observeTimelineSubscriptions(page);
|
||||
const scenario = await seedViewedTimelineScenario();
|
||||
try {
|
||||
await openAgent(page, scenario, scenario.firstAgentId);
|
||||
await subscriptions.waitForSubscribedAgents([scenario.firstAgentId]);
|
||||
|
||||
await scenario.client.sendAgentMessage(
|
||||
scenario.firstAgentId,
|
||||
"Stream while focused, then finish while hidden.",
|
||||
);
|
||||
await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible();
|
||||
await expect(
|
||||
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 subscriptions.waitForSubscribedAgents([scenario.secondAgentId], { timeout: 45_000 });
|
||||
await commitMessage(
|
||||
scenario,
|
||||
scenario.firstAgentId,
|
||||
"Committed after the first chat unsubscribed.",
|
||||
);
|
||||
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 }),
|
||||
).toBeVisible();
|
||||
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 catches up when shown", async ({ page }) => {
|
||||
test("a hidden retained chat stays current during unsubscribe grace", async ({ page }) => {
|
||||
test.setTimeout(60_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.firstAgentId, scenario.secondAgentId]);
|
||||
await commitMessage(
|
||||
scenario,
|
||||
scenario.firstAgentId,
|
||||
@@ -128,6 +118,7 @@ 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,5 +1,6 @@
|
||||
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";
|
||||
@@ -43,6 +44,49 @@ 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,4 +1,8 @@
|
||||
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import {
|
||||
buildHostAgentDetailRoute,
|
||||
buildHostWorkspaceOpenRoute,
|
||||
buildHostWorkspaceRoute,
|
||||
} from "@/utils/host-routes";
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||
import {
|
||||
@@ -31,11 +35,53 @@ import {
|
||||
} from "./helpers/workspace-ui";
|
||||
import { clickSettingsBackToWorkspace } from "./helpers/settings";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { injectDesktopBridge } from "./helpers/desktop-updates";
|
||||
import { injectDesktopBridge, waitForDesktopDaemonStartRequest } 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,
|
||||
@@ -117,6 +163,43 @@ 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,
|
||||
@@ -239,7 +322,9 @@ test.describe("Workspace navigation regression", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("refresh keeps the user on the same workspace route", async ({ page }) => {
|
||||
test("refresh keeps one continuous splash before restoring the workspace route", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
const daemonGate = await installDaemonWebSocketGate(page);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "workspace-refresh-route-" });
|
||||
@@ -254,6 +339,7 @@ test.describe("Workspace navigation regression", () => {
|
||||
serverId,
|
||||
manageBuiltInDaemon: true,
|
||||
hangDaemonStart: true,
|
||||
desktopSettingsDelayMs: 250,
|
||||
daemonListen: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||
});
|
||||
await openWorkspaceThroughApp(page, { serverId, workspace });
|
||||
@@ -261,14 +347,16 @@ 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 expect(page.getByTestId("startup-splash")).toBeVisible({ timeout: 30_000 });
|
||||
await waitForDesktopDaemonStartRequest(page);
|
||||
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();
|
||||
@@ -374,12 +462,13 @@ test.describe("Workspace navigation regression", () => {
|
||||
await expectWorkspaceDeckEntryCount(page, 2);
|
||||
|
||||
await page.evaluate(
|
||||
({ agentId, serverId: targetServerId }) => {
|
||||
({ agentId, serverId: targetServerId, workspaceId }) => {
|
||||
globalThis.dispatchEvent(
|
||||
new CustomEvent("paseo:web-notification-click", {
|
||||
detail: {
|
||||
data: {
|
||||
serverId: targetServerId,
|
||||
workspaceId,
|
||||
agentId,
|
||||
reason: "finished",
|
||||
},
|
||||
@@ -388,7 +477,7 @@ test.describe("Workspace navigation regression", () => {
|
||||
}),
|
||||
);
|
||||
},
|
||||
{ agentId: secondAgent.id, serverId },
|
||||
{ agentId: secondAgent.id, serverId, workspaceId: secondWorkspace.workspaceId },
|
||||
);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), {
|
||||
|
||||
@@ -44,7 +44,10 @@ test.describe("Worktree restore", () => {
|
||||
tempRepo = await createTempGitRepo("wt-restore-");
|
||||
});
|
||||
|
||||
async function createArchivedMissingWorktree(prefix: string) {
|
||||
async function createArchivedMissingWorktree(
|
||||
prefix: string,
|
||||
options: { agentCount?: number; keepAgentsArchived?: boolean } = {},
|
||||
) {
|
||||
const project = await openProjectViaDaemon(worktreeClient, tempRepo.path);
|
||||
createdProjectIds.add(project.projectKey);
|
||||
const worktree = await createWorktreeViaDaemon(worktreeClient, {
|
||||
@@ -53,11 +56,19 @@ test.describe("Worktree restore", () => {
|
||||
});
|
||||
createdProjectIds.add(worktree.projectKey);
|
||||
createdWorktreeDirectories.add(worktree.workspaceDirectory);
|
||||
const agent = await createIdleAgent(client, {
|
||||
cwd: worktree.workspaceDirectory,
|
||||
workspaceId: worktree.workspaceId,
|
||||
title: `${prefix}-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
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");
|
||||
}
|
||||
|
||||
await archiveWorkspaceFromDaemon(worktreeClient, worktree.workspaceDirectory);
|
||||
await expect
|
||||
@@ -67,11 +78,21 @@ 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.
|
||||
await client.refreshAgent(agent.id).catch(() => undefined);
|
||||
await expect.poll(() => fetchAgentArchivedAt(client, agent.id), { timeout: 30_000 }).toBeNull();
|
||||
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();
|
||||
}
|
||||
expect(existsSync(worktree.workspaceDirectory)).toBe(false);
|
||||
|
||||
return { agent, worktree };
|
||||
return { agent, agents, worktree };
|
||||
}
|
||||
|
||||
async function openArchivedWorkspaceFromHistory(page: Page, prefix: string) {
|
||||
@@ -241,6 +262,54 @@ 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`;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.2.0-beta.1",
|
||||
"version": "0.2.2",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -32,6 +32,11 @@
|
||||
"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",
|
||||
@@ -46,6 +51,7 @@
|
||||
"@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",
|
||||
|
||||
@@ -11,6 +11,51 @@ 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",
|
||||
@@ -65,19 +110,7 @@ function withFdroidAutolinking(config) {
|
||||
});
|
||||
|
||||
return withAppBuildGradle(config, (modConfig) => {
|
||||
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 }`,
|
||||
);
|
||||
modConfig.modResults.contents = configureFdroidAppBuildGradle(modConfig.modResults.contents);
|
||||
return modConfig;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
__private__,
|
||||
deriveBottomAnchorBlockedReason,
|
||||
@@ -126,10 +126,15 @@ function createDriverHarness(input?: {
|
||||
measurementState,
|
||||
nearBottom: input?.isNearBottom ?? true,
|
||||
};
|
||||
const scrollToBottom = vi.fn(() => {
|
||||
const scrollAttempts: boolean[] = [];
|
||||
let scrollToBottomBehavior = () => {
|
||||
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,
|
||||
@@ -150,7 +155,10 @@ function createDriverHarness(input?: {
|
||||
context,
|
||||
driver,
|
||||
scheduler,
|
||||
scrollToBottom,
|
||||
scrollAttempts,
|
||||
setScrollToBottomBehavior(next: () => void) {
|
||||
scrollToBottomBehavior = next;
|
||||
},
|
||||
modeChanges,
|
||||
};
|
||||
}
|
||||
@@ -210,7 +218,7 @@ describe("bottom anchor controller driver", () => {
|
||||
});
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.scrollToBottom).not.toHaveBeenCalled();
|
||||
expect(harness.scrollAttempts).toHaveLength(0);
|
||||
expect(harness.driver.getSnapshot()).toMatchObject({
|
||||
mode: "sticky-bottom",
|
||||
blockedReason: "waiting_for_history_readiness",
|
||||
@@ -229,7 +237,7 @@ describe("bottom anchor controller driver", () => {
|
||||
harness.driver.reevaluate();
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
|
||||
expect(harness.scrollAttempts).toHaveLength(1);
|
||||
expect(harness.driver.getSnapshot()).toMatchObject({
|
||||
blockedReason: null,
|
||||
pendingRequest: null,
|
||||
@@ -237,6 +245,97 @@ 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();
|
||||
|
||||
@@ -254,7 +353,74 @@ describe("bottom anchor controller driver", () => {
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.driver.getSnapshot().mode).toBe("detached");
|
||||
expect(harness.scrollToBottom).not.toHaveBeenCalled();
|
||||
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);
|
||||
});
|
||||
|
||||
it("switches back to sticky-bottom for explicit jump-to-bottom", () => {
|
||||
@@ -271,7 +437,7 @@ describe("bottom anchor controller driver", () => {
|
||||
|
||||
expect(harness.modeChanges).toContain("detached");
|
||||
expect(harness.modeChanges).toContain("sticky-bottom");
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
|
||||
expect(harness.scrollAttempts).toHaveLength(1);
|
||||
expect(harness.driver.getSnapshot().mode).toBe("sticky-bottom");
|
||||
});
|
||||
|
||||
@@ -292,7 +458,7 @@ describe("bottom anchor controller driver", () => {
|
||||
});
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(2);
|
||||
expect(harness.scrollAttempts).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps a pending request blocked when stale container measurements arrive", () => {
|
||||
@@ -313,7 +479,7 @@ describe("bottom anchor controller driver", () => {
|
||||
});
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.scrollToBottom).not.toHaveBeenCalled();
|
||||
expect(harness.scrollAttempts).toHaveLength(0);
|
||||
expect(harness.driver.getSnapshot()).toMatchObject({
|
||||
blockedReason: "waiting_for_measurable_viewport",
|
||||
pendingRequest: {
|
||||
@@ -326,7 +492,7 @@ describe("bottom anchor controller driver", () => {
|
||||
harness.driver.reevaluate();
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
|
||||
expect(harness.scrollAttempts).toHaveLength(1);
|
||||
expect(harness.driver.getSnapshot().pendingRequest).toBeNull();
|
||||
});
|
||||
|
||||
@@ -338,7 +504,7 @@ describe("bottom anchor controller driver", () => {
|
||||
},
|
||||
isNearBottom: false,
|
||||
});
|
||||
harness.scrollToBottom.mockImplementation(() => {
|
||||
harness.setScrollToBottomBehavior(() => {
|
||||
harness.context.measurementState.offsetY = 0;
|
||||
});
|
||||
|
||||
@@ -348,16 +514,16 @@ describe("bottom anchor controller driver", () => {
|
||||
});
|
||||
|
||||
harness.scheduler.flushFrame();
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
|
||||
expect(harness.scrollAttempts).toHaveLength(1);
|
||||
|
||||
harness.scheduler.flushFrame();
|
||||
harness.scheduler.flushFrame();
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
|
||||
expect(harness.scrollAttempts).toHaveLength(1);
|
||||
|
||||
harness.context.nearBottom = true;
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
|
||||
expect(harness.scrollAttempts).toHaveLength(1);
|
||||
expect(harness.driver.getSnapshot().pendingRequest).toBeNull();
|
||||
});
|
||||
|
||||
@@ -375,7 +541,7 @@ describe("bottom anchor controller driver", () => {
|
||||
isNearBottom: false,
|
||||
});
|
||||
|
||||
harness.scrollToBottom.mockImplementation(() => {
|
||||
harness.setScrollToBottomBehavior(() => {
|
||||
harness.context.measurementState.offsetY = 13476;
|
||||
});
|
||||
|
||||
@@ -386,7 +552,7 @@ describe("bottom anchor controller driver", () => {
|
||||
});
|
||||
|
||||
harness.scheduler.flushFrame();
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
|
||||
expect(harness.scrollAttempts).toHaveLength(1);
|
||||
|
||||
harness.context.measurementState.contentHeight = 14804;
|
||||
harness.context.nearBottom = false;
|
||||
@@ -409,7 +575,7 @@ describe("bottom anchor controller driver", () => {
|
||||
|
||||
harness.scheduler.flushFrame();
|
||||
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(2);
|
||||
expect(harness.scrollAttempts).toHaveLength(2);
|
||||
expect(harness.driver.getSnapshot()).toMatchObject({
|
||||
blockedReason: "waiting_for_post_layout_verification",
|
||||
pendingRequest: {
|
||||
@@ -435,7 +601,7 @@ describe("bottom anchor controller driver", () => {
|
||||
isNearBottom: false,
|
||||
});
|
||||
|
||||
harness.scrollToBottom.mockImplementation(() => {
|
||||
harness.setScrollToBottomBehavior(() => {
|
||||
harness.context.measurementState.offsetY = Math.max(
|
||||
0,
|
||||
harness.context.measurementState.contentHeight -
|
||||
@@ -471,7 +637,7 @@ describe("bottom anchor controller driver", () => {
|
||||
harness.scheduler.flushFrame();
|
||||
harness.scheduler.flushFrame();
|
||||
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(2);
|
||||
expect(harness.scrollAttempts).toHaveLength(2);
|
||||
expect(harness.driver.getSnapshot().pendingRequest).toMatchObject({
|
||||
reason: "resume",
|
||||
});
|
||||
@@ -480,7 +646,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.scrollToBottom.mockImplementation(() => {
|
||||
harness.setScrollToBottomBehavior(() => {
|
||||
harness.context.measurementState.offsetY = 720;
|
||||
});
|
||||
|
||||
@@ -492,7 +658,7 @@ describe("bottom anchor controller driver", () => {
|
||||
});
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(4);
|
||||
expect(harness.scrollAttempts).toHaveLength(4);
|
||||
expect(harness.driver.getSnapshot()).toMatchObject({
|
||||
mode: "sticky-bottom",
|
||||
pendingRequest: null,
|
||||
@@ -523,7 +689,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.scrollToBottom.mockImplementation(() => {
|
||||
harness.setScrollToBottomBehavior(() => {
|
||||
harness.context.measurementState.offsetY = 900;
|
||||
});
|
||||
|
||||
@@ -533,7 +699,7 @@ describe("bottom anchor controller driver", () => {
|
||||
});
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(4);
|
||||
expect(harness.scrollAttempts).toHaveLength(4);
|
||||
expect(harness.driver.getSnapshot()).toMatchObject({
|
||||
mode: "sticky-bottom",
|
||||
pendingRequest: null,
|
||||
@@ -577,7 +743,7 @@ describe("bottom anchor controller driver", () => {
|
||||
contentMeasuredForKey: null,
|
||||
}),
|
||||
});
|
||||
harness.scrollToBottom.mockImplementation(() => {
|
||||
harness.setScrollToBottomBehavior(() => {
|
||||
harness.context.measurementState.offsetY = 0;
|
||||
});
|
||||
|
||||
@@ -588,7 +754,7 @@ describe("bottom anchor controller driver", () => {
|
||||
contentHeight: 1348,
|
||||
});
|
||||
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
|
||||
expect(harness.scrollAttempts).toHaveLength(1);
|
||||
expect(harness.driver.getSnapshot()).toMatchObject({
|
||||
mode: "sticky-bottom",
|
||||
pendingVerification: {
|
||||
@@ -625,14 +791,14 @@ describe("bottom anchor controller driver", () => {
|
||||
contentMeasuredForKey: "native-virtualized",
|
||||
}),
|
||||
});
|
||||
harness.scrollToBottom.mockImplementation(() => {
|
||||
harness.setScrollToBottomBehavior(() => {
|
||||
harness.context.measurementState.offsetY = 0;
|
||||
harness.context.nearBottom = true;
|
||||
});
|
||||
|
||||
harness.driver.prepareForStickyContentChange();
|
||||
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
|
||||
expect(harness.scrollAttempts).toHaveLength(1);
|
||||
expect(harness.driver.getSnapshot()).toMatchObject({
|
||||
mode: "sticky-bottom",
|
||||
pendingVerification: {
|
||||
|
||||
@@ -68,6 +68,8 @@ 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;
|
||||
@@ -243,6 +245,7 @@ function createBottomAnchorControllerDriver(
|
||||
let lastRouteRequestKey: string | null = null;
|
||||
let stickyMeasurementRevision = 0;
|
||||
let lastVerifiedStickyMeasurementRevision = 0;
|
||||
let isUserScrollActive = false;
|
||||
|
||||
const setBlockedReason = (nextBlockedReason: BottomAnchorBlockedReason | null) => {
|
||||
if (blockedReason === nextBlockedReason) {
|
||||
@@ -411,10 +414,14 @@ 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;
|
||||
}
|
||||
@@ -481,6 +488,7 @@ function createBottomAnchorControllerDriver(
|
||||
cancelPendingAttempt();
|
||||
stickyMeasurementRevision = 0;
|
||||
lastVerifiedStickyMeasurementRevision = 0;
|
||||
isUserScrollActive = false;
|
||||
mode = "sticky-bottom";
|
||||
input.onModeChange("sticky-bottom");
|
||||
},
|
||||
@@ -497,6 +505,38 @@ 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;
|
||||
@@ -511,6 +551,9 @@ function createBottomAnchorControllerDriver(
|
||||
) {
|
||||
markStickyMeasurementChanged();
|
||||
}
|
||||
if (isUserScrollActive) {
|
||||
return;
|
||||
}
|
||||
const shouldRestick = __private__.shouldRestickOnViewportChange({
|
||||
mode,
|
||||
previousViewportWidth: params.previousViewportWidth,
|
||||
@@ -529,6 +572,9 @@ function createBottomAnchorControllerDriver(
|
||||
if (params.previousContentHeight !== params.contentHeight) {
|
||||
markStickyMeasurementChanged();
|
||||
}
|
||||
if (isUserScrollActive) {
|
||||
return;
|
||||
}
|
||||
const shouldRestick = __private__.shouldRestickOnContentChange({
|
||||
mode,
|
||||
previousContentHeight: params.previousContentHeight,
|
||||
@@ -558,6 +604,9 @@ function createBottomAnchorControllerDriver(
|
||||
return;
|
||||
}
|
||||
markStickyMeasurementChanged();
|
||||
if (isUserScrollActive) {
|
||||
return;
|
||||
}
|
||||
if (!pendingRequest) {
|
||||
pendingVerification = { requestId: null, retries: 0 };
|
||||
if (attemptHandle) {
|
||||
@@ -571,6 +620,9 @@ function createBottomAnchorControllerDriver(
|
||||
},
|
||||
handleScrollNearBottomChange(params) {
|
||||
const { nextIsNearBottom, scrollDelta } = params;
|
||||
if (isUserScrollActive) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
nextIsNearBottom &&
|
||||
mode === "sticky-bottom" &&
|
||||
@@ -737,6 +789,12 @@ 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,6 +11,7 @@ import {
|
||||
FlatList,
|
||||
ActivityIndicator,
|
||||
Keyboard,
|
||||
Platform,
|
||||
View,
|
||||
type LayoutChangeEvent,
|
||||
type ListRenderItemInfo,
|
||||
@@ -88,6 +89,8 @@ 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);
|
||||
@@ -128,6 +131,13 @@ 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);
|
||||
@@ -189,6 +199,12 @@ 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 = {
|
||||
@@ -201,6 +217,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
contentMeasuredForKey: null,
|
||||
};
|
||||
scrollOffsetYRef.current = 0;
|
||||
isUserScrollActiveRef.current = false;
|
||||
clearPendingUserScrollEnd();
|
||||
clearNativeViewportSettling();
|
||||
setIsNativeViewportSettling(false);
|
||||
historyStartReadyRef.current = false;
|
||||
@@ -209,8 +227,9 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
});
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
clearPendingUserScrollEnd();
|
||||
};
|
||||
}, [agentId, clearNativeViewportSettling]);
|
||||
}, [agentId, clearNativeViewportSettling, clearPendingUserScrollEnd]);
|
||||
|
||||
useEffect(() => {
|
||||
const keyboardEvents = [
|
||||
@@ -259,6 +278,19 @@ 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;
|
||||
@@ -273,13 +305,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
contentMeasuredForKey: "native-virtualized",
|
||||
};
|
||||
|
||||
const nearBottom = isNearBottomForStreamRenderStrategy({
|
||||
strategy,
|
||||
offsetY: contentOffset.y,
|
||||
threshold: 32,
|
||||
contentHeight: streamViewportMetricsRef.current.contentHeight,
|
||||
viewportHeight: streamViewportMetricsRef.current.viewportHeight,
|
||||
});
|
||||
const nearBottom = isScrollEventNearBottom(event);
|
||||
onNearBottomChange(nearBottom);
|
||||
|
||||
const distanceFromOldestEdge =
|
||||
@@ -294,7 +320,11 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
onNearHistoryStart();
|
||||
}
|
||||
|
||||
if (programmaticScrollEventBudgetRef.current > 0 && contentOffset.y <= 8) {
|
||||
if (
|
||||
!isUserScrollActiveRef.current &&
|
||||
programmaticScrollEventBudgetRef.current > 0 &&
|
||||
contentOffset.y <= 8
|
||||
) {
|
||||
programmaticScrollEventBudgetRef.current -= 1;
|
||||
} else {
|
||||
programmaticScrollEventBudgetRef.current = 0;
|
||||
@@ -305,6 +335,37 @@ 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;
|
||||
@@ -412,9 +473,13 @@ 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={DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION}
|
||||
maintainVisibleContentPosition={maintainVisibleContentPosition}
|
||||
initialNumToRender={40}
|
||||
maxToRenderPerBatch={40}
|
||||
updateCellsBatchingPeriod={0}
|
||||
|
||||
@@ -199,6 +199,89 @@ 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,6 +22,7 @@ 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;
|
||||
@@ -88,7 +89,8 @@ function getScrollContainerDistanceFromBottom(
|
||||
function isScrollContainerOverscrolledPastBottom(
|
||||
scrollContainer: Pick<HTMLElement, "scrollTop" | "clientHeight" | "scrollHeight">,
|
||||
): boolean {
|
||||
return getScrollContainerDistanceFromBottom(scrollContainer) < 0;
|
||||
// Browser zoom can leave scrollTop fractional while the height metrics remain integer-valued.
|
||||
return getScrollContainerDistanceFromBottom(scrollContainer) < -BOTTOM_OVERSCROLL_TOLERANCE_PX;
|
||||
}
|
||||
|
||||
function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: boolean }) {
|
||||
|
||||
@@ -99,7 +99,7 @@ import {
|
||||
useWorkspaceAttachmentsStore,
|
||||
} from "@/attachments/workspace-attachments-store";
|
||||
import type { WorkspaceComposerAttachment } from "@/attachments/types";
|
||||
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
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 = context.status === "running";
|
||||
const showRunningTurnFooter = baseRenderModel.turnTiming.isActive;
|
||||
const pendingPermissionsNode = useMemo(
|
||||
() =>
|
||||
renderPendingPermissionsNode({
|
||||
|
||||
@@ -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,9 +147,10 @@ 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 && agentId) {
|
||||
navigateToAgent({ serverId, agentId, pin: true });
|
||||
if (serverId && workspaceId && agentId) {
|
||||
navigateToAgent({ serverId, workspaceId, agentId, pin: true });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -337,7 +338,6 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
store,
|
||||
daemonStartService,
|
||||
shouldStartDaemon: shouldStartBuiltInDaemon,
|
||||
onGateError: (message) => daemonStartService.recordError(message),
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -376,11 +376,7 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const retry = useCallback(() => {
|
||||
const daemonStartService = getDaemonStartService({ store: getHostRuntimeStore() });
|
||||
startDaemonIfGateAllows({
|
||||
daemonStartService,
|
||||
shouldStartDaemon: shouldStartBuiltInDaemon,
|
||||
onGateError: (message) => daemonStartService.recordError(message),
|
||||
});
|
||||
void daemonStartService.startIfEnabled({ shouldStart: shouldStartBuiltInDaemon });
|
||||
}, []);
|
||||
|
||||
const splashError =
|
||||
@@ -914,6 +910,7 @@ function AppShell() {
|
||||
<MobilePanelsProvider>
|
||||
<HorizontalScrollProvider>
|
||||
<OpenProjectListener />
|
||||
<AgentNavigationListener />
|
||||
<AppWithSidebar>
|
||||
<WorkspaceRouteNavigationBridge />
|
||||
<RootStack />
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useCallback, useEffect, useMemo, 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 { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
|
||||
import { getHostRuntimeStore, useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
|
||||
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
|
||||
export default function HostAgentReadyRoute() {
|
||||
@@ -21,97 +24,128 @@ function HostAgentReadyRouteContent() {
|
||||
serverId?: string;
|
||||
agentId?: string;
|
||||
}>();
|
||||
const redirectedRef = useRef(false);
|
||||
const handledNavigationRef = useRef<string | null>(null);
|
||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||
const agentId = typeof params.agentId === "string" ? params.agentId : "";
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
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 agentWorkspaceId = useSessionStore((state) => {
|
||||
if (!serverId || !agentId) {
|
||||
return null;
|
||||
}
|
||||
return state.sessions[serverId]?.agents?.get(agentId)?.workspaceId ?? null;
|
||||
});
|
||||
const hasHydratedWorkspaces = useSessionStore((state) =>
|
||||
serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false,
|
||||
const shouldLookupAgent = Boolean(
|
||||
serverId && agentId && client && connectionStatus === "online" && !agentWorkspaceId,
|
||||
);
|
||||
const resolvedWorkspaceId = normalizeWorkspaceOpaqueId(agentWorkspaceId);
|
||||
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,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (redirectedRef.current) {
|
||||
return;
|
||||
}
|
||||
if (!serverId || !agentId) {
|
||||
redirectedRef.current = true;
|
||||
router.replace("/" as Href);
|
||||
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 (!navigationKey || handledNavigationRef.current === navigationKey) {
|
||||
return;
|
||||
}
|
||||
handledNavigationRef.current = navigationKey;
|
||||
|
||||
if (resolvedWorkspaceId) {
|
||||
redirectedRef.current = true;
|
||||
navigateToAgent({
|
||||
serverId,
|
||||
agentId,
|
||||
});
|
||||
if (resolution.kind === "resolved") {
|
||||
navigateToAgent({ serverId, agentId, workspaceId: resolution.workspaceId });
|
||||
return;
|
||||
}
|
||||
}, [agentId, resolvedWorkspaceId, router, serverId]);
|
||||
router.replace(resolution.kind === "invalid" ? ("/" as Href) : buildHostRootRoute(serverId));
|
||||
}, [agentId, resolution, router, serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (redirectedRef.current) {
|
||||
const handleRetry = useCallback(() => {
|
||||
if (resolution.kind === "lookupError") {
|
||||
void lookupQuery.refetch();
|
||||
return;
|
||||
}
|
||||
if (!serverId || !agentId) {
|
||||
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();
|
||||
return;
|
||||
}
|
||||
if (agentWorkspaceId && !hasHydratedWorkspaces) {
|
||||
return;
|
||||
}
|
||||
if (!client || !isConnected) {
|
||||
redirectedRef.current = true;
|
||||
router.replace(buildHostRootRoute(serverId));
|
||||
}
|
||||
}, [agentWorkspaceId, agentId, client, hasHydratedWorkspaces, isConnected, router, serverId]);
|
||||
router.replace(serverId ? buildHostRootRoute(serverId) : ("/" as Href));
|
||||
}, [router, serverId]);
|
||||
|
||||
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]);
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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 "@/stores/workspace-tabs-store";
|
||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
||||
import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store";
|
||||
import {
|
||||
@@ -110,7 +110,9 @@ function HostWorkspaceRouteContent() {
|
||||
const openValue = getParamValue(globalParams.open);
|
||||
const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId);
|
||||
const workspaceExists = useWorkspaceExists(serverId, workspaceId);
|
||||
const isAgentOpenIntent = parseWorkspaceOpenIntent(openValue)?.kind === "agent";
|
||||
const openIntent = useMemo(() => parseWorkspaceOpenIntent(openValue), [openValue]);
|
||||
const recoveryAgentId = openIntent?.kind === "agent" ? openIntent.agentId : null;
|
||||
const isAgentOpenIntent = recoveryAgentId !== null;
|
||||
const isOpenIntentWaitingForWorkspace = Boolean(
|
||||
isAgentOpenIntent && (!hasHydratedWorkspaces || !workspaceExists),
|
||||
);
|
||||
@@ -147,7 +149,6 @@ function HostWorkspaceRouteContent() {
|
||||
}
|
||||
consumedIntentRef.current = consumptionKey;
|
||||
|
||||
const openIntent = parseWorkspaceOpenIntent(openValue);
|
||||
if (openIntent) {
|
||||
prepareWorkspaceTab({
|
||||
serverId,
|
||||
@@ -171,6 +172,7 @@ function HostWorkspaceRouteContent() {
|
||||
hasHydratedWorkspaceLayoutStore,
|
||||
isOpenIntentWaitingForWorkspace,
|
||||
navigation,
|
||||
openIntent,
|
||||
openValue,
|
||||
rootNavigationState?.key,
|
||||
serverId,
|
||||
@@ -185,10 +187,16 @@ function HostWorkspaceRouteContent() {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <WorkspaceDeck recoveryRequested={isAgentOpenIntent} />;
|
||||
return <WorkspaceDeck recoveryRequested={isAgentOpenIntent} recoveryAgentId={recoveryAgentId} />;
|
||||
}
|
||||
|
||||
function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) {
|
||||
function WorkspaceDeck({
|
||||
recoveryRequested,
|
||||
recoveryAgentId,
|
||||
}: {
|
||||
recoveryRequested: boolean;
|
||||
recoveryAgentId: string | null;
|
||||
}) {
|
||||
const activeSelection = useActiveWorkspaceSelection();
|
||||
const [mountedSelections, setMountedSelections] = useState<ActiveWorkspaceSelection[]>(() =>
|
||||
activeSelection ? [activeSelection] : [],
|
||||
@@ -234,6 +242,7 @@ function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) {
|
||||
selection={selection}
|
||||
activeSelection={activeSelection}
|
||||
recoveryRequested={recoveryRequested}
|
||||
recoveryAgentId={recoveryAgentId}
|
||||
onUnmountInactive={unmountWorkspaceSelection}
|
||||
/>
|
||||
);
|
||||
@@ -246,11 +255,13 @@ 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);
|
||||
@@ -282,6 +293,7 @@ function WorkspaceDeckEntry({
|
||||
workspaceId={selection.workspaceId}
|
||||
isRouteFocused={isActive}
|
||||
recoveryRequested={isActive && recoveryRequested}
|
||||
recoveryAgentId={isActive ? recoveryAgentId : null}
|
||||
/>
|
||||
</RetainedPanel>
|
||||
);
|
||||
|
||||
@@ -92,9 +92,20 @@ 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
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { WorkspaceFileSelection } from "./types";
|
||||
|
||||
export interface WorkspaceFileDragSourceInput {
|
||||
enabled: boolean;
|
||||
disabled?: boolean;
|
||||
serverId?: string;
|
||||
workspaceId: string | null | undefined;
|
||||
path: string;
|
||||
selection?: WorkspaceFileSelection;
|
||||
}
|
||||
62
packages/app/src/attachments/workspace-file-drag.test.ts
Normal file
62
packages/app/src/attachments/workspace-file-drag.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
55
packages/app/src/attachments/workspace-file-drag.ts
Normal file
55
packages/app/src/attachments/workspace-file-drag.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
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;
|
||||
}
|
||||
71
packages/app/src/attachments/workspace-file.test.ts
Normal file
71
packages/app/src/attachments/workspace-file.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
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)],
|
||||
});
|
||||
});
|
||||
});
|
||||
106
packages/app/src/attachments/workspace-file.ts
Normal file
106
packages/app/src/attachments/workspace-file.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
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,10 +4,8 @@ import { createJSONStorage, type StateStorage } from "zustand/middleware";
|
||||
import { mountBrowserAutomationHandler } from "./handler";
|
||||
import type { DesktopHostBridge } from "@/desktop/host";
|
||||
import { useBrowserStore } from "@/stores/browser-store";
|
||||
import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
useWorkspaceLayoutStore,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
|
||||
type BrowserAutomationExecuteRequest = Extract<
|
||||
SessionOutboundMessage,
|
||||
|
||||
@@ -6,11 +6,8 @@ import {
|
||||
resizeResidentBrowserWebview,
|
||||
} from "@/components/browser-webview-resident";
|
||||
import { createWorkspaceBrowser, getBrowserRecord, useBrowserStore } from "@/stores/browser-store";
|
||||
import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
collectAllTabs,
|
||||
useWorkspaceLayoutStore,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
import { collectAllTabs, useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
|
||||
type BrowserAutomationExecuteRequest = Extract<
|
||||
SessionOutboundMessage,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import type { WorkspaceDraftTabSetup } from "@/stores/workspace-tabs-store";
|
||||
import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model";
|
||||
|
||||
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 { TextInputProps } from "react-native";
|
||||
import type { StyleProp, TextInputProps, ViewStyle } 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,9 +11,10 @@ import {
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetScrollView,
|
||||
BottomSheetTextInput,
|
||||
useBottomSheetInternal,
|
||||
type BottomSheetBackgroundProps,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import Animated from "react-native-reanimated";
|
||||
import Animated, { useAnimatedStyle } from "react-native-reanimated";
|
||||
import { ArrowLeft, Search, X } from "lucide-react-native";
|
||||
import {
|
||||
IsolatedBottomSheetModal,
|
||||
@@ -203,6 +204,14 @@ 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,
|
||||
@@ -248,6 +257,39 @@ 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;
|
||||
@@ -452,12 +494,16 @@ 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;
|
||||
}
|
||||
|
||||
export function AdaptiveModalSheet({
|
||||
@@ -467,11 +513,14 @@ export function AdaptiveModalSheet({
|
||||
onDismiss,
|
||||
children,
|
||||
footer,
|
||||
footerContainerStyle,
|
||||
snapPoints,
|
||||
testID,
|
||||
desktopMaxWidth,
|
||||
scrollable = true,
|
||||
presentation,
|
||||
contentContainerStyle,
|
||||
sizeContentToCurrentSnapPoint = false,
|
||||
}: AdaptiveModalSheetProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
@@ -490,31 +539,37 @@ 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,
|
||||
compactSafeAreaPadding.contentPaddingBottom != null
|
||||
? { paddingBottom: compactSafeAreaPadding.contentPaddingBottom }
|
||||
: null,
|
||||
[
|
||||
contentContainerStyle,
|
||||
compactSafeAreaPadding.contentPaddingBottom != null
|
||||
? { paddingBottom: compactSafeAreaPadding.contentPaddingBottom }
|
||||
: null,
|
||||
],
|
||||
],
|
||||
[compactSafeAreaPadding.contentPaddingBottom],
|
||||
[compactSafeAreaPadding.contentPaddingBottom, contentContainerStyle],
|
||||
);
|
||||
const bottomSheetStaticContentStyle = useMemo(
|
||||
() => [
|
||||
styles.bottomSheetStaticContent,
|
||||
contentContainerStyle,
|
||||
compactSafeAreaPadding.contentPaddingBottom != null
|
||||
? { paddingBottom: compactSafeAreaPadding.contentPaddingBottom }
|
||||
: null,
|
||||
],
|
||||
[compactSafeAreaPadding.contentPaddingBottom],
|
||||
[compactSafeAreaPadding.contentPaddingBottom, contentContainerStyle],
|
||||
);
|
||||
const footerStyle = useMemo(
|
||||
() => [
|
||||
styles.footer,
|
||||
footerContainerStyle,
|
||||
compactSafeAreaPadding.footerPaddingBottom != null
|
||||
? { paddingBottom: compactSafeAreaPadding.footerPaddingBottom }
|
||||
: null,
|
||||
],
|
||||
[compactSafeAreaPadding.footerPaddingBottom],
|
||||
[compactSafeAreaPadding.footerPaddingBottom, footerContainerStyle],
|
||||
);
|
||||
const handleIndicatorStyle = useMemo(
|
||||
() => ({ backgroundColor: theme.colors.palette.zinc[600] }),
|
||||
@@ -599,6 +654,25 @@ export function AdaptiveModalSheet({
|
||||
}, [visible, isMobile, notifyNativeModalDismiss]);
|
||||
|
||||
if (isMobile) {
|
||||
const sheetContent = (
|
||||
<>
|
||||
<SheetHeaderView header={header} onClose={onClose} testID={testID} />
|
||||
{scrollable ? (
|
||||
<BottomSheetScrollView
|
||||
style={sizeContentToCurrentSnapPoint ? styles.bottomSheetVisibleScroll : undefined}
|
||||
contentContainerStyle={bottomSheetContentStyle}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{children}
|
||||
</BottomSheetScrollView>
|
||||
) : (
|
||||
<View style={bottomSheetStaticContentStyle}>{children}</View>
|
||||
)}
|
||||
{footer ? <View style={footerStyle}>{footer}</View> : null}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<IsolatedBottomSheetModal
|
||||
ref={sheetRef}
|
||||
@@ -616,19 +690,11 @@ export function AdaptiveModalSheet({
|
||||
accessible={false}
|
||||
presentation={presentation}
|
||||
>
|
||||
<SheetHeaderView header={header} onClose={onClose} testID={testID} />
|
||||
{scrollable ? (
|
||||
<BottomSheetScrollView
|
||||
contentContainerStyle={bottomSheetContentStyle}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{children}
|
||||
</BottomSheetScrollView>
|
||||
{sizeContentToCurrentSnapPoint ? (
|
||||
<BottomSheetVisibleContent>{sheetContent}</BottomSheetVisibleContent>
|
||||
) : (
|
||||
<View style={bottomSheetStaticContentStyle}>{children}</View>
|
||||
sheetContent
|
||||
)}
|
||||
{footer ? <View style={footerStyle}>{footer}</View> : null}
|
||||
</IsolatedBottomSheetModal>
|
||||
);
|
||||
}
|
||||
@@ -640,7 +706,7 @@ export function AdaptiveModalSheet({
|
||||
<View style={styles.desktopScrollContainer}>
|
||||
<ScrollView
|
||||
style={styles.desktopScroll}
|
||||
contentContainerStyle={styles.desktopContent}
|
||||
contentContainerStyle={[styles.desktopContent, contentContainerStyle]}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
@@ -648,7 +714,7 @@ export function AdaptiveModalSheet({
|
||||
</ScrollView>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.desktopStaticContent}>{children}</View>
|
||||
<View style={[styles.desktopStaticContent, contentContainerStyle]}>{children}</View>
|
||||
)}
|
||||
{footer ? <View style={footerStyle}>{footer}</View> : null}
|
||||
</>
|
||||
|
||||
@@ -394,6 +394,7 @@ export function AgentList({
|
||||
serverId,
|
||||
agentId,
|
||||
workspaceId: agent.workspaceId,
|
||||
pin: true,
|
||||
});
|
||||
},
|
||||
[isActionSheetVisible, onAgentSelect],
|
||||
|
||||
@@ -9,6 +9,7 @@ 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;
|
||||
@@ -21,6 +22,7 @@ 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" });
|
||||
|
||||
@@ -32,10 +34,11 @@ 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) {
|
||||
console.error("[ArchivedAgentCallout] Failed to unarchive agent:", error);
|
||||
setUnarchiveError(toErrorMessage(error));
|
||||
setIsUnarchiving(false);
|
||||
}
|
||||
}, [client, isConnected, isUnarchiving, agentId]);
|
||||
@@ -44,16 +47,23 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
|
||||
<Animated.View style={containerStyle}>
|
||||
<View style={styles.inputAreaContainer}>
|
||||
<View style={styles.inputAreaContent}>
|
||||
<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 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>
|
||||
</View>
|
||||
</View>
|
||||
@@ -97,8 +107,16 @@ 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 "@/stores/workspace-layout-store";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
|
||||
|
||||
@@ -18,17 +18,16 @@ 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 {
|
||||
@@ -74,7 +73,7 @@ function getMeterColors(
|
||||
return { progress: theme.colors.foregroundMuted, track };
|
||||
}
|
||||
|
||||
function getMeterGeometry(showPercentage: boolean) {
|
||||
function getMeterGeometry(showPercentage: boolean, glyphSize?: number) {
|
||||
if (showPercentage) {
|
||||
return {
|
||||
svgSize: COMPACT_SVG_SIZE,
|
||||
@@ -85,12 +84,14 @@ function getMeterGeometry(showPercentage: boolean) {
|
||||
containerStyle: styles.containerWithLabel,
|
||||
};
|
||||
}
|
||||
const resolvedSize = glyphSize ?? SVG_SIZE;
|
||||
const resolvedStrokeWidth = glyphSize ? 2 : STROKE_WIDTH;
|
||||
return {
|
||||
svgSize: SVG_SIZE,
|
||||
center: CENTER,
|
||||
radius: RADIUS,
|
||||
strokeWidth: STROKE_WIDTH,
|
||||
circumference: CIRCUMFERENCE,
|
||||
svgSize: resolvedSize,
|
||||
center: resolvedSize / 2,
|
||||
radius: (resolvedSize - resolvedStrokeWidth) / 2,
|
||||
strokeWidth: resolvedStrokeWidth,
|
||||
circumference: Math.PI * (resolvedSize - resolvedStrokeWidth),
|
||||
containerStyle: styles.container,
|
||||
};
|
||||
}
|
||||
@@ -103,6 +104,7 @@ export function ContextWindowMeter({
|
||||
serverId,
|
||||
provider,
|
||||
pending = false,
|
||||
glyphSize,
|
||||
}: ContextWindowMeterProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
@@ -123,7 +125,7 @@ export function ContextWindowMeter({
|
||||
[refreshProviderUsage],
|
||||
);
|
||||
|
||||
const geometry = getMeterGeometry(showPercentage);
|
||||
const geometry = getMeterGeometry(showPercentage, glyphSize);
|
||||
|
||||
// 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
|
||||
|
||||
@@ -4,6 +4,7 @@ import { StyleSheet } from "react-native-unistyles";
|
||||
interface DiffStatProps {
|
||||
additions: number;
|
||||
deletions: number;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
const compactFormatter = new Intl.NumberFormat("en-US", {
|
||||
@@ -15,9 +16,9 @@ export function formatDiffCount(value: number): string {
|
||||
return compactFormatter.format(value).toLowerCase();
|
||||
}
|
||||
|
||||
export function DiffStat({ additions, deletions }: DiffStatProps) {
|
||||
export function DiffStat({ additions, deletions, testID }: DiffStatProps) {
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<View style={styles.row} testID={testID}>
|
||||
<Text style={styles.additions}>+{formatDiffCount(additions)}</Text>
|
||||
<Text style={styles.deletions}>-{formatDiffCount(deletions)}</Text>
|
||||
</View>
|
||||
|
||||
@@ -36,6 +36,11 @@ 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 {}
|
||||
|
||||
@@ -386,15 +391,16 @@ function ExplorerSidebarContent({
|
||||
{/* Content based on active tab */}
|
||||
<View style={styles.contentArea} testID="explorer-content-area">
|
||||
{resolvedTab === "changes" && (
|
||||
<GitDiffPane
|
||||
<ChangedFilesPane
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
cwd={workspaceRoot}
|
||||
enabled={isOpen}
|
||||
workspaceRoot={workspaceRoot}
|
||||
isOpen={isOpen}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
)}
|
||||
{resolvedTab === "files" && (
|
||||
<FileExplorerPane
|
||||
<FilesPane
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
workspaceRoot={workspaceRoot}
|
||||
@@ -415,6 +421,83 @@ 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;
|
||||
|
||||
185
packages/app/src/components/file-actions-menu.tsx
Normal file
185
packages/app/src/components/file-actions-menu.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
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,4 +1,5 @@
|
||||
import type { ImageAttachment } from "@/composer/types";
|
||||
import type { WorkspaceFileDragPayload } from "@/attachments/workspace-file-drag";
|
||||
|
||||
export interface DroppedFileItem {
|
||||
kind: "web-file";
|
||||
@@ -18,4 +19,5 @@ export type DroppedItem = DroppedFileItem | DroppedPathItem;
|
||||
export interface FileDropSink {
|
||||
onFiles: (images: ImageAttachment[]) => void;
|
||||
onGenericFiles?: (items: DroppedItem[]) => void;
|
||||
onWorkspaceFile?: (payload: WorkspaceFileDragPayload) => void;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,10 @@ 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[] }
|
||||
@@ -185,7 +189,11 @@ export function useDropListeners({
|
||||
if (disabledRef.current) return;
|
||||
|
||||
dragCounter.current++;
|
||||
if (e.dataTransfer?.types.includes("Files")) {
|
||||
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) {
|
||||
isDragging.value = true;
|
||||
}
|
||||
}
|
||||
@@ -197,7 +205,11 @@ 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 canAccept = !disabledRef.current && !suppressed.value && hasSink.value;
|
||||
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;
|
||||
e.dataTransfer.dropEffect = canAccept ? "copy" : "none";
|
||||
}
|
||||
|
||||
@@ -225,6 +237,14 @@ 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",
|
||||
|
||||
@@ -15,36 +15,26 @@ import {
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { SvgXml } from "react-native-svg";
|
||||
import { ChevronDown, Eye, EyeOff, RotateCw } from "lucide-react-native";
|
||||
import { MaterialFileIcon } from "@/components/material-file-icon";
|
||||
import {
|
||||
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";
|
||||
TreeChevron,
|
||||
TreeIndentGuides,
|
||||
treeRowPaddingLeft,
|
||||
WORKSPACE_FILE_ROW_VERTICAL_PADDING,
|
||||
} 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 { useDownloadStore } from "@/stores/download-store";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { FileActionsMenu } from "@/components/file-actions-menu";
|
||||
import { useFileDownload } from "@/hooks/use-file-download";
|
||||
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" },
|
||||
@@ -63,6 +53,8 @@ function formatFileSize({ size }: { size: number }): string {
|
||||
}
|
||||
|
||||
interface TreeRowItemProps {
|
||||
serverId: string;
|
||||
workspaceId?: string | null;
|
||||
entry: ExplorerEntry;
|
||||
depth: number;
|
||||
isExpanded: boolean;
|
||||
@@ -71,21 +63,8 @@ interface TreeRowItemProps {
|
||||
onEntryPress: (entry: ExplorerEntry) => void;
|
||||
onCopyPath: (path: string) => void;
|
||||
onDownloadEntry: (entry: ExplorerEntry) => void;
|
||||
}
|
||||
|
||||
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,
|
||||
];
|
||||
onAddToChat?: (path: string) => void;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
function sortTriggerStyle({
|
||||
@@ -104,6 +83,8 @@ function treeRowKeyExtractor(row: TreeRow) {
|
||||
}
|
||||
|
||||
function TreeRowItem({
|
||||
serverId,
|
||||
workspaceId,
|
||||
entry,
|
||||
depth,
|
||||
isExpanded,
|
||||
@@ -112,10 +93,17 @@ 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);
|
||||
@@ -124,10 +112,10 @@ function TreeRowItem({
|
||||
const pressableStyle = useCallback(
|
||||
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.entryRow,
|
||||
{ paddingLeft: theme.spacing[2] + depth * TREE_INDENT_PER_LEVEL },
|
||||
{ paddingLeft: treeRowPaddingLeft(depth) },
|
||||
(Boolean(hovered) || pressed || isSelected) && styles.entryRowActive,
|
||||
],
|
||||
[depth, isSelected, theme.spacing],
|
||||
[depth, isSelected],
|
||||
);
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
@@ -138,23 +126,42 @@ function TreeRowItem({
|
||||
onDownloadEntry(entry);
|
||||
}, [onDownloadEntry, entry]);
|
||||
|
||||
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],
|
||||
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],
|
||||
);
|
||||
|
||||
return (
|
||||
<Pressable onPress={handlePress} style={pressableStyle}>
|
||||
<Pressable onPress={handlePress} style={pressableStyle} testID={testID}>
|
||||
<TreeIndentGuides depth={depth} />
|
||||
<View style={styles.entryInfo}>
|
||||
<View ref={dragSourceRef} style={styles.entryInfo}>
|
||||
<View style={styles.entryIcon}>
|
||||
{(() => {
|
||||
if (!isDirectory) {
|
||||
return <SvgXml xml={getFileIconSvg(entry.name)} width={16} height={16} />;
|
||||
return <MaterialFileIcon fileName={entry.name} size={16} />;
|
||||
}
|
||||
if (loading) return <ActivityIndicator size="small" />;
|
||||
return <TreeChevron expanded={isExpanded} />;
|
||||
@@ -164,40 +171,15 @@ function TreeRowItem({
|
||||
{entry.name}
|
||||
</Text>
|
||||
</View>
|
||||
<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>
|
||||
<FileActionsMenu
|
||||
fileKind={entry.kind}
|
||||
onCopyPath={handleCopy}
|
||||
onDownload={handleDownload}
|
||||
onAddToChat={onAddToChat ? handleAddToChat : undefined}
|
||||
header={metaHeader}
|
||||
accessibilityLabel={t("workspace.fileActions.moreActions")}
|
||||
testIDPrefix={testID}
|
||||
/>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
@@ -207,6 +189,7 @@ interface FileExplorerPaneProps {
|
||||
workspaceId?: string | null;
|
||||
workspaceRoot: string;
|
||||
onOpenFile?: (filePath: string) => void;
|
||||
onAddToChat?: (path: string) => void;
|
||||
}
|
||||
|
||||
interface TreeRow {
|
||||
@@ -219,14 +202,10 @@ 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(
|
||||
() =>
|
||||
@@ -236,10 +215,6 @@ 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]
|
||||
@@ -247,12 +222,16 @@ export function FileExplorerPane({
|
||||
: undefined,
|
||||
);
|
||||
|
||||
const { requestDirectoryListing, requestFileDownloadToken, selectExplorerEntry } =
|
||||
useFileExplorerActions({
|
||||
serverId,
|
||||
workspaceId,
|
||||
workspaceRoot: normalizedWorkspaceRoot,
|
||||
});
|
||||
const { requestDirectoryListing, selectExplorerEntry } = useFileExplorerActions({
|
||||
serverId,
|
||||
workspaceId,
|
||||
workspaceRoot: normalizedWorkspaceRoot,
|
||||
});
|
||||
const downloadFile = useFileDownload({
|
||||
serverId,
|
||||
workspaceId,
|
||||
workspaceRoot: normalizedWorkspaceRoot,
|
||||
});
|
||||
const sortOption = usePanelStore((state) => state.explorerSortOption);
|
||||
const showHiddenFiles = usePanelStore((state) => state.explorerShowHiddenFiles);
|
||||
const setSortOption = usePanelStore((state) => state.setExplorerSortOption);
|
||||
@@ -347,18 +326,14 @@ export function FileExplorerPane({
|
||||
[normalizedWorkspaceRoot],
|
||||
);
|
||||
|
||||
const startDownload = useDownloadStore((state) => state.startDownload);
|
||||
const handleDownloadEntry = useCallback(
|
||||
(entry: ExplorerEntry) =>
|
||||
downloadExplorerEntry({
|
||||
entry,
|
||||
workspaceScopeId,
|
||||
serverId,
|
||||
daemonProfile,
|
||||
startDownload,
|
||||
requestFileDownloadToken,
|
||||
}),
|
||||
[daemonProfile, requestFileDownloadToken, serverId, startDownload, workspaceScopeId],
|
||||
(entry: ExplorerEntry) => {
|
||||
if (entry.kind !== "file") {
|
||||
return;
|
||||
}
|
||||
downloadFile({ fileName: entry.name, path: entry.path });
|
||||
},
|
||||
[downloadFile],
|
||||
);
|
||||
|
||||
const handleSortCycle = useCallback(() => {
|
||||
@@ -420,6 +395,8 @@ export function FileExplorerPane({
|
||||
const renderTreeRow = useCallback(
|
||||
(info: ListRenderItemInfo<TreeRow>) => (
|
||||
<TreeRowDispatcher
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
info={info}
|
||||
expandedPaths={expandedPaths}
|
||||
selectedEntryPath={selectedEntryPath}
|
||||
@@ -427,6 +404,7 @@ export function FileExplorerPane({
|
||||
onEntryPress={handleEntryPress}
|
||||
onCopyPath={handleCopyPath}
|
||||
onDownloadEntry={handleDownloadEntry}
|
||||
onAddToChat={onAddToChat}
|
||||
/>
|
||||
),
|
||||
[
|
||||
@@ -436,6 +414,9 @@ export function FileExplorerPane({
|
||||
handleDownloadEntry,
|
||||
isDirectoryLoading,
|
||||
selectedEntryPath,
|
||||
onAddToChat,
|
||||
serverId,
|
||||
workspaceId,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -577,8 +558,14 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
|
||||
return (
|
||||
<View style={[styles.treePane, styles.treePaneFill]}>
|
||||
<View style={styles.paneHeader} testID="files-pane-header">
|
||||
<Pressable onPress={handleSortCycle} style={sortTriggerStyleProp}>
|
||||
<Text style={styles.sortTriggerText}>{currentSortLabel}</Text>
|
||||
<Pressable
|
||||
onPress={handleSortCycle}
|
||||
style={sortTriggerStyleProp}
|
||||
testID="files-sort-trigger"
|
||||
>
|
||||
<Text style={styles.sortTriggerText} testID="files-sort-label">
|
||||
{currentSortLabel}
|
||||
</Text>
|
||||
<ChevronDown size={12} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
<View style={styles.headerActions}>
|
||||
@@ -589,6 +576,7 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={hiddenFilesToggleAccessibilityLabel}
|
||||
accessibilityState={hiddenFilesToggleAccessibilityState}
|
||||
testID="files-hidden-toggle"
|
||||
>
|
||||
{showHiddenFiles ? (
|
||||
<Eye size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
@@ -607,6 +595,7 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
|
||||
? t("workspace.fileExplorer.actions.refreshing")
|
||||
: t("workspace.fileExplorer.actions.refresh")
|
||||
}
|
||||
testID="files-refresh"
|
||||
>
|
||||
<View style={styles.refreshIcon}>
|
||||
{isRefreshFetching ? (
|
||||
@@ -779,39 +768,6 @@ 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,
|
||||
@@ -851,6 +807,8 @@ function toggleDirectory({
|
||||
}
|
||||
|
||||
function TreeRowDispatcher({
|
||||
serverId,
|
||||
workspaceId,
|
||||
info,
|
||||
expandedPaths,
|
||||
selectedEntryPath,
|
||||
@@ -858,7 +816,10 @@ function TreeRowDispatcher({
|
||||
onEntryPress,
|
||||
onCopyPath,
|
||||
onDownloadEntry,
|
||||
onAddToChat,
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceId?: string | null;
|
||||
info: ListRenderItemInfo<TreeRow>;
|
||||
expandedPaths: Set<string>;
|
||||
selectedEntryPath: string | null;
|
||||
@@ -866,6 +827,7 @@ 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;
|
||||
@@ -876,6 +838,8 @@ function TreeRowDispatcher({
|
||||
|
||||
return (
|
||||
<TreeRowItem
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
entry={entry}
|
||||
depth={depth}
|
||||
isExpanded={isExpanded}
|
||||
@@ -884,6 +848,8 @@ function TreeRowDispatcher({
|
||||
onEntryPress={onEntryPress}
|
||||
onCopyPath={onCopyPath}
|
||||
onDownloadEntry={onDownloadEntry}
|
||||
onAddToChat={onAddToChat}
|
||||
testID={`file-explorer-row-${info.index}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1062,7 +1028,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
minHeight: 0,
|
||||
},
|
||||
entriesContent: {
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingTop: theme.spacing[2],
|
||||
paddingBottom: theme.spacing[4],
|
||||
},
|
||||
@@ -1112,9 +1077,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingVertical: 2,
|
||||
paddingRight: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
paddingVertical: WORKSPACE_FILE_ROW_VERTICAL_PADDING,
|
||||
paddingRight: theme.spacing[3],
|
||||
},
|
||||
entryRowActive: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
@@ -1134,16 +1098,6 @@ 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],
|
||||
},
|
||||
|
||||
@@ -7,7 +7,7 @@ interface OmpIconProps {
|
||||
|
||||
export function OmpIcon({ size = 16, color = "currentColor" }: OmpIconProps) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 64 64" fill={color}>
|
||||
<Svg width={size} height={size} viewBox="4 4 56 56" 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="0 0 800 800" fill={color}>
|
||||
<Svg width={size} height={size} viewBox="100 100 600 600" 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}
|
||||
|
||||
@@ -341,6 +341,64 @@ 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,
|
||||
@@ -494,6 +552,12 @@ function SidebarFooter({
|
||||
|
||||
return (
|
||||
<View style={styles.sidebarFooter}>
|
||||
<FooterAddProjectButton
|
||||
onPress={handleOpenProject}
|
||||
label={labels.addProject}
|
||||
shortcutKeys={newAgentKeys}
|
||||
theme={theme}
|
||||
/>
|
||||
<View style={styles.footerIconRow}>
|
||||
<SidebarHostPicker
|
||||
theme={theme}
|
||||
@@ -501,14 +565,6 @@ 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"
|
||||
@@ -924,9 +980,6 @@ 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,
|
||||
@@ -1008,8 +1061,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
sidebarFooter: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-start",
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[3],
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.colors.border,
|
||||
@@ -1020,6 +1073,30 @@ 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,8 +569,26 @@ export function createSharedMarkdownRules(): RenderRules {
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
hardbreak: (node: ASTNode) => <MarkdownTextSpan key={node.key}>{"\n"}</MarkdownTextSpan>,
|
||||
softbreak: (node: ASTNode) => <MarkdownTextSpan key={node.key}>{"\n"}</MarkdownTextSpan>,
|
||||
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>
|
||||
),
|
||||
code_block: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
|
||||
15
packages/app/src/components/material-file-icon.tsx
Normal file
15
packages/app/src/components/material-file-icon.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
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;
|
||||
}
|
||||
@@ -1689,10 +1689,29 @@ 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; 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>,
|
||||
// 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>
|
||||
),
|
||||
code_block: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
@@ -2638,7 +2657,9 @@ function computeShimmerMetrics(input: {
|
||||
Math.min(120, input.labelRowWidth > 0 ? input.labelRowWidth * 0.28 : 0),
|
||||
);
|
||||
const isWebShimmer = input.isLoading && isWeb;
|
||||
const shouldMeasureWebShimmer = isWebShimmer;
|
||||
// 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 shouldMeasureNativeShimmer = input.isLoading && isNative;
|
||||
const isNativeShimmer =
|
||||
shouldMeasureNativeShimmer && input.labelRowWidth > 0 && input.labelRowHeight > 0;
|
||||
|
||||
49
packages/app/src/components/model-browser-view.test.ts
Normal file
49
packages/app/src/components/model-browser-view.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
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" });
|
||||
});
|
||||
});
|
||||
40
packages/app/src/components/model-browser-view.ts
Normal file
40
packages/app/src/components/model-browser-view.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
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" };
|
||||
}
|
||||
1169
packages/app/src/components/model-browser.tsx
Normal file
1169
packages/app/src/components/model-browser.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -94,6 +94,8 @@ 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,
|
||||
@@ -1632,6 +1634,12 @@ function ProjectBlock({
|
||||
supportsPinningByServerId: ReadonlyMap<string, boolean>;
|
||||
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
||||
}) {
|
||||
const {
|
||||
visibleItems: visibleWorkspaces,
|
||||
expanded: workspacesExpanded,
|
||||
canToggle: canToggleWorkspaces,
|
||||
toggleExpanded: toggleWorkspacesExpanded,
|
||||
} = useLimitedSidebarGroup(project.workspaces);
|
||||
const rowModel = useMemo(
|
||||
() =>
|
||||
buildSidebarProjectRowModel({
|
||||
@@ -1784,19 +1792,28 @@ function ProjectBlock({
|
||||
if (!collapsed) {
|
||||
if (project.workspaces.length > 0) {
|
||||
projectChildren = (
|
||||
<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}
|
||||
/>
|
||||
<>
|
||||
<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}
|
||||
</>
|
||||
);
|
||||
} else if (rowModel.trailingAction.kind === "new_workspace") {
|
||||
projectChildren = (
|
||||
@@ -2073,6 +2090,12 @@ 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) => {
|
||||
@@ -2303,7 +2326,6 @@ 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}
|
||||
@@ -2331,7 +2353,18 @@ function ProjectModeList({
|
||||
{pinnedChats.length > 0 ? (
|
||||
<View style={styles.pinnedSection} testID="sidebar-pinned-section">
|
||||
<PinnedSectionHeader collapsed={pinnedCollapsed} onToggle={togglePinnedCollapsed} />
|
||||
{pinnedCollapsed ? null : pinnedChats.map(renderPinnedChat)}
|
||||
{pinnedCollapsed ? null : (
|
||||
<>
|
||||
{visiblePinnedChats.map(renderPinnedChat)}
|
||||
{canTogglePinnedChats ? (
|
||||
<SidebarGroupToggleRow
|
||||
expanded={pinnedChatsExpanded}
|
||||
onPress={togglePinnedChatsExpanded}
|
||||
testID="sidebar-pinned-show-more"
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
) : null}
|
||||
{unpinnedProjects.length > 0 || hasActiveHostFilter ? listHeaderComponent : null}
|
||||
@@ -2399,7 +2432,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
listContent: {
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingTop: 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,
|
||||
paddingBottom: theme.spacing[4],
|
||||
},
|
||||
projectListContainer: {
|
||||
|
||||
@@ -47,6 +47,7 @@ export function PinnedSectionHeader({
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
header: {
|
||||
minHeight: 36,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
alignSelf: "flex-start",
|
||||
|
||||
106
packages/app/src/components/sidebar/sidebar-group-toggle-row.tsx
Normal file
106
packages/app/src/components/sidebar/sidebar-group-toggle-row.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
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,6 +42,8 @@ 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
|
||||
@@ -93,6 +95,12 @@ 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 = (
|
||||
@@ -100,9 +108,9 @@ export function SidebarStatusWorkspaceList({
|
||||
{pinnedWorkspaces.length > 0 ? (
|
||||
<View style={styles.pinnedSection} testID="sidebar-pinned-section">
|
||||
<PinnedSectionHeader collapsed={pinnedCollapsed} onToggle={togglePinnedCollapsed} />
|
||||
{pinnedCollapsed
|
||||
? null
|
||||
: pinnedWorkspaces.map((workspace) => (
|
||||
{pinnedCollapsed ? null : (
|
||||
<>
|
||||
{visiblePinnedWorkspaces.map((workspace) => (
|
||||
<StatusWorkspaceRow
|
||||
key={workspace.workspaceKey}
|
||||
workspace={workspace}
|
||||
@@ -116,10 +124,18 @@ 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}
|
||||
@@ -189,38 +205,92 @@ function StatusGroupList({
|
||||
return (
|
||||
<>
|
||||
{groups.map((group) => (
|
||||
<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>
|
||||
<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}`}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Status mode breaks the project grouping, so the row needs the project name to stay
|
||||
// legible; the host is appended after a middle dot once labels are active.
|
||||
function buildStatusRowSubtitle({
|
||||
@@ -723,7 +793,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
listContent: {
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingTop: theme.spacing[2],
|
||||
// Keep status mode's Pinned/Workspaces boundary identical to project mode.
|
||||
paddingTop: 2,
|
||||
paddingBottom: theme.spacing[4],
|
||||
},
|
||||
pinnedSection: {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
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,6 +55,7 @@ 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,
|
||||
@@ -75,7 +76,7 @@ import {
|
||||
type SplitPane,
|
||||
type WorkspaceLayout,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
|
||||
import type { WorkspaceTab } from "@/workspace-tabs/model";
|
||||
import { RenderProfile } from "@/utils/render-profiler";
|
||||
import { workspaceTabTargetsEqual } from "@/workspace-tabs/identity";
|
||||
import { isNative } from "@/constants/platform";
|
||||
@@ -934,11 +935,17 @@ 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,6 +11,7 @@ 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. */
|
||||
|
||||
@@ -24,6 +24,7 @@ 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 {
|
||||
@@ -100,6 +101,8 @@ 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;
|
||||
@@ -934,6 +937,7 @@ interface MobileBodyProps {
|
||||
searchable: boolean;
|
||||
hasChildren: boolean;
|
||||
mobileChildrenScrollEnabled: boolean;
|
||||
mobileChildrenContentContainerStyle: StyleProp<ViewStyle>;
|
||||
presentation?: "push" | "replace";
|
||||
searchResetKey: number;
|
||||
searchPlaceholder: string;
|
||||
@@ -947,6 +951,7 @@ interface MobileBodyProps {
|
||||
handleSelect: (id: string) => void;
|
||||
renderOption: RenderOptionFn | undefined;
|
||||
children: ReactNode;
|
||||
safeAreaBottom: number;
|
||||
}
|
||||
|
||||
function MobileComboboxBody(props: MobileBodyProps): ReactElement {
|
||||
@@ -966,6 +971,10 @@ 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
|
||||
@@ -996,40 +1005,46 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement {
|
||||
keyboardBlurBehavior="none"
|
||||
presentation={props.presentation}
|
||||
>
|
||||
{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}
|
||||
<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>
|
||||
</IsolatedBottomSheetModal>
|
||||
);
|
||||
}
|
||||
@@ -1222,6 +1237,7 @@ export function Combobox({
|
||||
title,
|
||||
header,
|
||||
mobileChildrenScrollEnabled = true,
|
||||
mobileChildrenContentContainerStyle,
|
||||
presentation,
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -1241,6 +1257,7 @@ 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);
|
||||
@@ -1515,6 +1532,7 @@ export function Combobox({
|
||||
searchable={searchable}
|
||||
hasChildren={hasChildren}
|
||||
mobileChildrenScrollEnabled={mobileChildrenScrollEnabled}
|
||||
mobileChildrenContentContainerStyle={mobileChildrenContentContainerStyle}
|
||||
presentation={presentation}
|
||||
searchResetKey={searchResetKey}
|
||||
searchPlaceholder={effectiveSearchPlaceholder}
|
||||
@@ -1527,6 +1545,7 @@ export function Combobox({
|
||||
emptyText={resolvedEmptyText}
|
||||
handleSelect={handleSelect}
|
||||
renderOption={renderOption}
|
||||
safeAreaBottom={safeAreaInsets.bottom}
|
||||
>
|
||||
{children}
|
||||
</MobileComboboxBody>
|
||||
@@ -1569,6 +1588,14 @@ export function Combobox({
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
mobileSheetFrame: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
mobileSheetBody: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
searchInputContainer: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -10,6 +10,7 @@ const theme = {
|
||||
md: 6,
|
||||
lg: 8,
|
||||
xl: 12,
|
||||
full: 9999,
|
||||
},
|
||||
borderWidth: {
|
||||
1: 1,
|
||||
@@ -102,12 +103,41 @@ describe("control geometry", () => {
|
||||
expect(geometry.formTextInputMd.paddingVertical).toBe(11);
|
||||
});
|
||||
|
||||
it("subtracts segmented control inset from the nested segment radius", () => {
|
||||
it("keeps segmented controls ghost with fully rounded segments in a button-sized track", () => {
|
||||
const geometry = createControlGeometry(theme);
|
||||
|
||||
expect(geometry.segmentedContainerSm.borderRadius).toBe(6);
|
||||
expect(geometry.segmentedSegmentSm.borderRadius).toBe(4);
|
||||
expect(geometry.segmentedContainerMd.borderRadius).toBe(8);
|
||||
expect(geometry.segmentedSegmentMd.borderRadius).toBe(5);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = "sm" | "md";
|
||||
export type SegmentedControlSize = "xs" | "sm" | "md";
|
||||
export type ControlInteractionPhase = "rest" | "hover" | "active";
|
||||
|
||||
export interface ControlInteractionState {
|
||||
@@ -22,8 +22,10 @@ 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;
|
||||
@@ -35,6 +37,7 @@ 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,
|
||||
};
|
||||
@@ -47,6 +50,7 @@ export const buttonIconSize: Record<ButtonControlSize, number> = {
|
||||
};
|
||||
|
||||
export const segmentedIconSize: Record<SegmentedControlSize, number> = {
|
||||
xs: ICON_SIZE.xs,
|
||||
sm: ICON_SIZE.sm,
|
||||
md: ICON_SIZE.md,
|
||||
};
|
||||
@@ -58,10 +62,6 @@ 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,8 +121,6 @@ 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,
|
||||
@@ -130,7 +128,7 @@ export function createControlGeometry(theme: Theme) {
|
||||
|
||||
return {
|
||||
buttonXs: {
|
||||
minHeight: controlHeights.compact,
|
||||
minHeight: controlHeights.tight,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
@@ -194,31 +192,41 @@ export function createControlGeometry(theme: Theme) {
|
||||
opacity: theme.opacity[50],
|
||||
},
|
||||
switchControl,
|
||||
segmentedContainerXs: {
|
||||
minHeight: controlHeights.tight,
|
||||
padding: 0,
|
||||
},
|
||||
segmentedContainerSm: {
|
||||
minHeight: controlHeights.compact,
|
||||
padding: SEGMENTED_COMPACT_INSET,
|
||||
borderRadius: segmentedContainerSmRadius,
|
||||
padding: 0,
|
||||
},
|
||||
segmentedContainerMd: {
|
||||
minHeight: controlHeights.field,
|
||||
padding: SEGMENTED_FIELD_INSET,
|
||||
borderRadius: segmentedContainerMdRadius,
|
||||
padding: 0,
|
||||
},
|
||||
segmentedSegmentXs: {
|
||||
minHeight: controlHeights.tight - SEGMENTED_TIGHT_INSET * 2,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.full,
|
||||
},
|
||||
segmentedSegmentSm: {
|
||||
minHeight: controlHeights.compact - SEGMENTED_COMPACT_INSET * 2,
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
borderRadius: nestedRadius(segmentedContainerSmRadius, SEGMENTED_COMPACT_INSET),
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.full,
|
||||
},
|
||||
segmentedSegmentMd: {
|
||||
minHeight: controlHeights.field - SEGMENTED_FIELD_INSET * 2,
|
||||
paddingHorizontal: theme.spacing[6],
|
||||
borderRadius: nestedRadius(segmentedContainerMdRadius, SEGMENTED_FIELD_INSET),
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
borderRadius: theme.borderRadius.full,
|
||||
},
|
||||
segmentedLabelXs: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
segmentedLabelSm: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
segmentedLabelMd: {
|
||||
fontSize: theme.fontSize.base,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
@@ -33,6 +34,7 @@ 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";
|
||||
@@ -216,12 +218,16 @@ function renderDropdownSurface(input: {
|
||||
style={surfaceStyle}
|
||||
frameStyle={frameStyle}
|
||||
entering={contentEntering}
|
||||
exiting={contentExiting.withCallback((finished) => {
|
||||
"worklet";
|
||||
if (finished) {
|
||||
runOnJS(onExited)();
|
||||
}
|
||||
})}
|
||||
exiting={
|
||||
isWeb
|
||||
? undefined
|
||||
: contentExiting.withCallback((finished) => {
|
||||
"worklet";
|
||||
if (finished) {
|
||||
runOnJS(onExited)();
|
||||
}
|
||||
})
|
||||
}
|
||||
>
|
||||
{body}
|
||||
</FloatingSurface>
|
||||
@@ -496,6 +502,17 @@ 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) {
|
||||
@@ -613,6 +630,34 @@ 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}
|
||||
@@ -622,27 +667,7 @@ export function DropdownMenuContent({
|
||||
onDismiss={flushPendingSelect}
|
||||
onRequestClose={handleClose}
|
||||
>
|
||||
<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>
|
||||
{overlay}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -878,6 +903,11 @@ 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.foreground });
|
||||
const selectedIconMapping = (theme: Theme) => ({ iconColor: theme.colors.surface0 });
|
||||
const mutedIconMapping = (theme: Theme) => ({ iconColor: theme.colors.foregroundMuted });
|
||||
|
||||
export function SegmentedControl<T extends string>({
|
||||
@@ -53,9 +53,14 @@ export function SegmentedControl<T extends string>({
|
||||
style,
|
||||
testID,
|
||||
}: SegmentedControlProps<T>) {
|
||||
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 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 iconSize = segmentedIconSize[size];
|
||||
|
||||
const containerStyle = useMemo(
|
||||
@@ -161,9 +166,12 @@ const styles = StyleSheet.create((theme) => {
|
||||
return {
|
||||
container: {
|
||||
flexDirection: "row",
|
||||
alignItems: "stretch",
|
||||
backgroundColor: theme.colors.surface2,
|
||||
gap: 2,
|
||||
alignItems: "center",
|
||||
backgroundColor: "transparent",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
containerXs: {
|
||||
...geometry.segmentedContainerXs,
|
||||
},
|
||||
containerSm: {
|
||||
...geometry.segmentedContainerSm,
|
||||
@@ -178,6 +186,9 @@ const styles = StyleSheet.create((theme) => {
|
||||
flexShrink: 0,
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
segmentXs: {
|
||||
...geometry.segmentedSegmentXs,
|
||||
},
|
||||
segmentSm: {
|
||||
...geometry.segmentedSegmentSm,
|
||||
},
|
||||
@@ -185,18 +196,13 @@ const styles = StyleSheet.create((theme) => {
|
||||
...geometry.segmentedSegmentMd,
|
||||
},
|
||||
segmentSelected: {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 2,
|
||||
elevation: 1,
|
||||
backgroundColor: theme.colors.foreground,
|
||||
},
|
||||
segmentHover: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
segmentPressed: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
segmentDisabled: {
|
||||
opacity: theme.opacity[50],
|
||||
@@ -209,6 +215,9 @@ const styles = StyleSheet.create((theme) => {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
labelXs: {
|
||||
...geometry.segmentedLabelXs,
|
||||
},
|
||||
labelSm: {
|
||||
...geometry.segmentedLabelSm,
|
||||
},
|
||||
@@ -216,7 +225,7 @@ const styles = StyleSheet.create((theme) => {
|
||||
...geometry.segmentedLabelMd,
|
||||
},
|
||||
labelSelected: {
|
||||
color: theme.colors.foreground,
|
||||
color: theme.colors.surface0,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type PropsWithChildren,
|
||||
type ReactElement,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
Dimensions,
|
||||
Platform,
|
||||
@@ -23,13 +24,12 @@ 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,7 +446,6 @@ 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);
|
||||
@@ -516,23 +515,22 @@ 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 (
|
||||
<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>
|
||||
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(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -570,7 +568,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
zIndex: 1000,
|
||||
zIndex: OVERLAY_Z.tooltip,
|
||||
},
|
||||
content: {
|
||||
paddingVertical: theme.spacing[1],
|
||||
|
||||
@@ -6,11 +6,9 @@ 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";
|
||||
@@ -382,7 +380,6 @@ 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(
|
||||
() =>
|
||||
@@ -395,14 +392,6 @@ export function WorkspaceSetupDialog() {
|
||||
[composerState, pendingAction],
|
||||
);
|
||||
|
||||
const composerFooter = useMemo(
|
||||
() =>
|
||||
isCompact && agentControlsWithDisabled ? (
|
||||
<DraftAgentModeControl placement="footer" {...agentControlsWithDisabled} />
|
||||
) : undefined,
|
||||
[isCompact, agentControlsWithDisabled],
|
||||
);
|
||||
|
||||
const subtitleContent = useMemo(
|
||||
() => (
|
||||
<View style={styles.subtitleRow}>
|
||||
@@ -457,7 +446,6 @@ export function WorkspaceSetupDialog() {
|
||||
commandDraftConfig={composerState?.commandDraftConfig}
|
||||
agentControls={agentControlsWithDisabled}
|
||||
inputWrapperStyle={styles.composerInputWrapper}
|
||||
footer={composerFooter}
|
||||
/>
|
||||
</FileDropZone>
|
||||
|
||||
|
||||
@@ -333,6 +333,26 @@ describe("pickAndPersistImages", () => {
|
||||
});
|
||||
|
||||
describe("dispatchComposerAgentMessage", () => {
|
||||
it("removes the optimistic prompt when the host rejects it", async () => {
|
||||
const rejection = new Error("Host rejected prompt");
|
||||
const client = createFakeSendClient({ rejection });
|
||||
const stream = createFakeStream();
|
||||
|
||||
await expect(
|
||||
dispatchComposerAgentMessage({
|
||||
client,
|
||||
agentId: "agent",
|
||||
text: "rejected prompt",
|
||||
attachments: [],
|
||||
encodeImages: passthroughEncodeImages,
|
||||
stream,
|
||||
}),
|
||||
).rejects.toBe(rejection);
|
||||
|
||||
expect(stream.head.get("agent")).toBeUndefined();
|
||||
expect(stream.tail.get("agent") ?? []).toEqual([]);
|
||||
});
|
||||
|
||||
it("sends text + image data + structured attachments and appends user_message to the tail when head is empty", async () => {
|
||||
const client = createFakeSendClient();
|
||||
const stream = createFakeStream();
|
||||
|
||||
@@ -186,40 +186,50 @@ export async function dispatchComposerAgentMessage(
|
||||
images: wirePayload.images,
|
||||
attachments: wirePayload.attachments,
|
||||
});
|
||||
appendUserMessageToStream(input.agentId, userMessage, input.stream);
|
||||
const imagesData = await input.encodeImages(wirePayload.images);
|
||||
await input.client.sendAgentMessage(input.agentId, input.text, {
|
||||
messageId,
|
||||
images: imagesData ?? [],
|
||||
attachments: wirePayload.attachments,
|
||||
});
|
||||
const rollbackOptimisticMessage = appendUserMessageToStream(
|
||||
input.agentId,
|
||||
userMessage,
|
||||
input.stream,
|
||||
);
|
||||
try {
|
||||
const imagesData = await input.encodeImages(wirePayload.images);
|
||||
await input.client.sendAgentMessage(input.agentId, input.text, {
|
||||
messageId,
|
||||
images: imagesData ?? [],
|
||||
attachments: wirePayload.attachments,
|
||||
});
|
||||
} catch (error) {
|
||||
rollbackOptimisticMessage();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function appendUserMessageToStream(
|
||||
agentId: string,
|
||||
userMessage: UserMessageItem,
|
||||
stream: AgentStreamWriter,
|
||||
): void {
|
||||
): () => void {
|
||||
const result = appendOptimisticUserMessageToStream({
|
||||
tail: stream.getTail(agentId) ?? [],
|
||||
head: stream.getHead(agentId) ?? [],
|
||||
message: userMessage,
|
||||
placement: "active-head",
|
||||
});
|
||||
if (result.changedHead) {
|
||||
stream.setHead((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, result.head);
|
||||
return next;
|
||||
const write = result.changedHead ? stream.setHead : stream.setTail;
|
||||
const items = result.changedHead ? result.head : result.tail;
|
||||
write((prev) => new Map(prev).set(agentId, items));
|
||||
|
||||
return () => {
|
||||
write((prev) => {
|
||||
const current = prev.get(agentId);
|
||||
if (!current) return prev;
|
||||
const nextItems = current.filter(
|
||||
(item) => item.id !== userMessage.id || item.kind !== "user_message" || !item.optimistic,
|
||||
);
|
||||
if (nextItems.length === current.length) return prev;
|
||||
return new Map(prev).set(agentId, nextItems);
|
||||
});
|
||||
}
|
||||
if (result.changedTail) {
|
||||
stream.setTail((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, result.tail);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export interface QueueComposerMessageInput {
|
||||
@@ -338,7 +348,7 @@ export function openComposerAttachment(input: OpenComposerAttachmentInput): void
|
||||
input.setLightboxMetadata(input.attachment.metadata);
|
||||
return;
|
||||
}
|
||||
if (input.attachment.kind === "file") {
|
||||
if (input.attachment.kind === "file" || input.attachment.kind === "workspace_file") {
|
||||
return;
|
||||
}
|
||||
if (isWorkspaceAttachment(input.attachment)) {
|
||||
|
||||
173
packages/app/src/composer/agent-controls/control.tsx
Normal file
173
packages/app/src/composer/agent-controls/control.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import { forwardRef, useCallback, type ComponentType } from "react";
|
||||
import { Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
|
||||
import { useComposerControlLayout } from "@/composer/agent-controls/layout-context";
|
||||
import { ComposerToolbarGlyph } from "@/composer/agent-controls/glyph";
|
||||
|
||||
export interface AgentControlIconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export type AgentControlIcon = ComponentType<AgentControlIconProps>;
|
||||
|
||||
interface AgentControlTriggerProps {
|
||||
icon: AgentControlIcon;
|
||||
iconColor?: string;
|
||||
surface: "toolbar" | "sheet";
|
||||
label: string;
|
||||
value?: string;
|
||||
showToolbarLabel?: boolean;
|
||||
showCaret?: boolean;
|
||||
open?: boolean;
|
||||
disabled?: boolean;
|
||||
onPress: () => void;
|
||||
accessibilityLabel: string;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
export const AgentControlTrigger = forwardRef<View, AgentControlTriggerProps>(
|
||||
function AgentControlTrigger(
|
||||
{
|
||||
icon: Icon,
|
||||
iconColor,
|
||||
surface,
|
||||
label,
|
||||
value,
|
||||
showToolbarLabel = true,
|
||||
showCaret = false,
|
||||
open = false,
|
||||
disabled = false,
|
||||
onPress,
|
||||
accessibilityLabel,
|
||||
testID,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const { glyphSize } = useComposerControlLayout();
|
||||
const isSheet = surface === "sheet";
|
||||
const resolvedGlyphSize = isSheet ? 16 : glyphSize;
|
||||
const resolvedIconColor = iconColor ?? styles.iconColor.color;
|
||||
const showValue = isSheet || showToolbarLabel;
|
||||
const triggerStyle = useCallback(
|
||||
({ pressed, hovered }: PressableStateCallbackType) => [
|
||||
isSheet ? styles.sheetRow : styles.toolbarControl,
|
||||
!isSheet && !showToolbarLabel && styles.toolbarIconOnly,
|
||||
hovered && (isSheet ? styles.sheetRowInteractive : styles.hovered),
|
||||
(pressed || open) && (isSheet ? styles.sheetRowInteractive : styles.pressed),
|
||||
disabled && styles.disabled,
|
||||
],
|
||||
[disabled, isSheet, open, showToolbarLabel],
|
||||
);
|
||||
|
||||
return (
|
||||
<ComboboxTrigger
|
||||
ref={ref}
|
||||
collapsable={false}
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
style={triggerStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
testID={testID}
|
||||
chevron={showCaret ? undefined : null}
|
||||
>
|
||||
{isSheet ? (
|
||||
<View style={styles.sheetGlyph}>
|
||||
<Icon size={resolvedGlyphSize} color={resolvedIconColor} />
|
||||
</View>
|
||||
) : (
|
||||
<ComposerToolbarGlyph size={resolvedGlyphSize}>
|
||||
<Icon size={resolvedGlyphSize} color={resolvedIconColor} />
|
||||
</ComposerToolbarGlyph>
|
||||
)}
|
||||
{isSheet ? (
|
||||
<Text style={styles.sheetLabel} numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
) : null}
|
||||
{showValue ? (
|
||||
<Text style={isSheet ? styles.sheetValue : styles.toolbarValue} numberOfLines={1}>
|
||||
{value ?? label}
|
||||
</Text>
|
||||
) : null}
|
||||
</ComboboxTrigger>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
toolbarControl: {
|
||||
height: 28,
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius["2xl"],
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
toolbarIconOnly: {
|
||||
width: 28,
|
||||
flexShrink: 0,
|
||||
paddingHorizontal: 0,
|
||||
justifyContent: "center",
|
||||
},
|
||||
toolbarValue: {
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
sheetRow: {
|
||||
minHeight: 44,
|
||||
minWidth: 0,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
marginHorizontal: -theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
borderRadius: theme.borderRadius["2xl"],
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
sheetRowInteractive: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
sheetGlyph: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
flexShrink: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
sheetLabel: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
sheetValue: {
|
||||
maxWidth: "45%",
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
hovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
pressed: {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
disabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
iconColor: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
}));
|
||||
32
packages/app/src/composer/agent-controls/glyph.tsx
Normal file
32
packages/app/src/composer/agent-controls/glyph.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { StyleSheet, View } from "react-native";
|
||||
|
||||
export function ComposerToolbarGlyph({ children, size }: { children: ReactNode; size: number }) {
|
||||
return (
|
||||
<View
|
||||
style={size >= 20 ? styles.native : styles.web}
|
||||
accessibilityElementsHidden
|
||||
importantForAccessibility="no-hide-descendants"
|
||||
pointerEvents="none"
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
web: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
flexShrink: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
native: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
flexShrink: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
37
packages/app/src/composer/agent-controls/layout-context.tsx
Normal file
37
packages/app/src/composer/agent-controls/layout-context.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
import type { ComposerControlPresentation } from "@/composer/agent-controls/layout";
|
||||
|
||||
interface ComposerControlLayoutValue {
|
||||
glyphSize: number;
|
||||
presentation: ComposerControlPresentation;
|
||||
}
|
||||
|
||||
const DEFAULT_LAYOUT: ComposerControlLayoutValue = {
|
||||
glyphSize: 16,
|
||||
presentation: {
|
||||
showCarets: true,
|
||||
showThinkingLabel: true,
|
||||
showModeLabel: true,
|
||||
aggregateFeatures: false,
|
||||
},
|
||||
};
|
||||
|
||||
const ComposerControlLayoutContext = createContext(DEFAULT_LAYOUT);
|
||||
|
||||
export function ComposerControlLayoutProvider({
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
value: ComposerControlLayoutValue;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<ComposerControlLayoutContext.Provider value={value}>
|
||||
{children}
|
||||
</ComposerControlLayoutContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useComposerControlLayout(): ComposerControlLayoutValue {
|
||||
return useContext(ComposerControlLayoutContext);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user