mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
32 Commits
fix/androi
...
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
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",
|
||||
]
|
||||
|
||||
110
CHANGELOG.md
110
CHANGELOG.md
@@ -1,78 +1,36 @@
|
||||
# Changelog
|
||||
|
||||
## 0.2.0-beta.3 - 2026-07-22
|
||||
|
||||
### Added
|
||||
|
||||
- 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))
|
||||
- Open existing agents from Paseo links or the CLI ([#2324](https://github.com/getpaseo/paseo/pull/2324))
|
||||
- Use Oh My Pi's Write Approval mode to allow reads while requiring approval for changes ([#2228](https://github.com/getpaseo/paseo/pull/2228) by [@theslava](https://github.com/theslava))
|
||||
|
||||
### Improved
|
||||
|
||||
- Usage bars now warn as provider limits approach ([#2322](https://github.com/getpaseo/paseo/pull/2322) by [@cleiter](https://github.com/cleiter))
|
||||
- Oh My Pi advisor results now retain their structure and severity ([#2219](https://github.com/getpaseo/paseo/pull/2219) by [@ebg1223](https://github.com/ebg1223))
|
||||
|
||||
### Fixed
|
||||
|
||||
- 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))
|
||||
- Oh My Pi slash commands now include commands from every supported source ([#2175](https://github.com/getpaseo/paseo/pull/2175) by [@bendavid](https://github.com/bendavid))
|
||||
- Oh My Pi chats no longer stay stuck as running after delayed or incomplete completion events ([#2261](https://github.com/getpaseo/paseo/pull/2261), [#2282](https://github.com/getpaseo/paseo/pull/2282) by [@isac322](https://github.com/isac322))
|
||||
|
||||
## 0.2.0-beta.2 - 2026-07-22
|
||||
|
||||
### Added
|
||||
|
||||
- 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))
|
||||
- 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))
|
||||
- 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))
|
||||
- Use Pi's Max thinking level ([#2267](https://github.com/getpaseo/paseo/pull/2267) by [@ByteTrue](https://github.com/ByteTrue))
|
||||
|
||||
### Improved
|
||||
|
||||
- Improved parity of CLI and MCP tools for workspace, agent and schedule management ([#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))
|
||||
- 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))
|
||||
- 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))
|
||||
- Oh My Pi now supports Max thinking in imported and new sessions ([#2191](https://github.com/getpaseo/paseo/pull/2191) by [@mvanhorn](https://github.com/mvanhorn))
|
||||
- Commit history now includes recent pushed and base-branch commits ([#2312](https://github.com/getpaseo/paseo/pull/2312))
|
||||
- Permission and thinking changes made during a turn now show when they take effect ([#2201](https://github.com/getpaseo/paseo/pull/2201))
|
||||
|
||||
### Fixed
|
||||
|
||||
- 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))
|
||||
- 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))
|
||||
- Session imports find older matches from the current workspace ([#2265](https://github.com/getpaseo/paseo/pull/2265) by [@nikuscs](https://github.com/nikuscs))
|
||||
|
||||
## 0.2.0-beta.1 - 2026-07-17
|
||||
## 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))
|
||||
@@ -80,12 +38,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
|
||||
|
||||
@@ -83,9 +83,16 @@ only the route's explicit Unarchive or Restore action changes the archived works
|
||||
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 before any
|
||||
provider timeline is loaded; **Unarchive** runs the provider's native unarchive hook (including Codex
|
||||
`thread/unarchive`) before the normal agent resume and timeline hydration flow.
|
||||
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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -129,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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
sha256-x6jiaw7zkCuBRlddLHD0tXQxolXwr7tzIQBfWu5xnUU=
|
||||
sha256-t7FqvPj7U5YidGH9OZ1kFnD2dPTYUyjcxBlzAJU7Mak=
|
||||
|
||||
42
package-lock.json
generated
42
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.2.0-beta.3",
|
||||
"version": "0.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.2.0-beta.3",
|
||||
"version": "0.2.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -35211,7 +35211,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.2.0-beta.3",
|
||||
"version": "0.2.0",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -36236,12 +36236,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.2.0-beta.3",
|
||||
"version": "0.2.0",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.2.0-beta.3",
|
||||
"@getpaseo/protocol": "0.2.0-beta.3",
|
||||
"@getpaseo/server": "0.2.0-beta.3",
|
||||
"@getpaseo/client": "0.2.0",
|
||||
"@getpaseo/protocol": "0.2.0",
|
||||
"@getpaseo/server": "0.2.0",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -36487,10 +36487,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.2.0-beta.3",
|
||||
"version": "0.2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.2.0-beta.3",
|
||||
"@getpaseo/relay": "0.2.0-beta.3",
|
||||
"@getpaseo/protocol": "0.2.0",
|
||||
"@getpaseo/relay": "0.2.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -36501,7 +36501,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.2.0-beta.3",
|
||||
"version": "0.2.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -36744,7 +36744,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.2.0-beta.3",
|
||||
"version": "0.2.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -37640,7 +37640,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.2.0-beta.3",
|
||||
"version": "0.2.0",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -37872,7 +37872,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.2.0-beta.3",
|
||||
"version": "0.2.0",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -37885,7 +37885,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.2.0-beta.3",
|
||||
"version": "0.2.0",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38103,15 +38103,15 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.2.0-beta.3",
|
||||
"version": "0.2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.214",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.2.0-beta.3",
|
||||
"@getpaseo/highlight": "0.2.0-beta.3",
|
||||
"@getpaseo/protocol": "0.2.0-beta.3",
|
||||
"@getpaseo/relay": "0.2.0-beta.3",
|
||||
"@getpaseo/client": "0.2.0",
|
||||
"@getpaseo/highlight": "0.2.0",
|
||||
"@getpaseo/protocol": "0.2.0",
|
||||
"@getpaseo/relay": "0.2.0",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -38648,7 +38648,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.2.0-beta.3",
|
||||
"version": "0.2.0",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.2.0-beta.3",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -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,6 +42,7 @@ 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();
|
||||
|
||||
|
||||
@@ -239,6 +239,29 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
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");
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.2.0-beta.3",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -89,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);
|
||||
@@ -129,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);
|
||||
@@ -208,6 +217,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
contentMeasuredForKey: null,
|
||||
};
|
||||
scrollOffsetYRef.current = 0;
|
||||
isUserScrollActiveRef.current = false;
|
||||
clearPendingUserScrollEnd();
|
||||
clearNativeViewportSettling();
|
||||
setIsNativeViewportSettling(false);
|
||||
historyStartReadyRef.current = false;
|
||||
@@ -216,8 +227,9 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
});
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
clearPendingUserScrollEnd();
|
||||
};
|
||||
}, [agentId, clearNativeViewportSettling]);
|
||||
}, [agentId, clearNativeViewportSettling, clearPendingUserScrollEnd]);
|
||||
|
||||
useEffect(() => {
|
||||
const keyboardEvents = [
|
||||
@@ -266,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;
|
||||
@@ -280,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 =
|
||||
@@ -301,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;
|
||||
@@ -312,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;
|
||||
@@ -419,6 +473,10 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
style={listStyle}
|
||||
onLayout={handleListLayout}
|
||||
onScroll={handleScroll}
|
||||
onScrollBeginDrag={handleScrollBeginDrag}
|
||||
onScrollEndDrag={handleScrollEndDrag}
|
||||
onMomentumScrollBegin={handleMomentumScrollBegin}
|
||||
onMomentumScrollEnd={handleMomentumScrollEnd}
|
||||
scrollEventThrottle={16}
|
||||
onContentSizeChange={handleContentSizeChange}
|
||||
maintainVisibleContentPosition={maintainVisibleContentPosition}
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -2657,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
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
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);
|
||||
}
|
||||
168
packages/app/src/composer/agent-controls/layout.test.ts
Normal file
168
packages/app/src/composer/agent-controls/layout.test.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
COMPOSER_TOOLBAR_GEOMETRY,
|
||||
resolveComposerControlDensity,
|
||||
resolveComposerControlPresentation,
|
||||
resolveComposerToolbarGlyphSize,
|
||||
} from "./layout";
|
||||
|
||||
describe("composer control layout", () => {
|
||||
it("removes labels in priority order as the toolbar narrows", () => {
|
||||
expect(resolveComposerControlPresentation("full")).toEqual({
|
||||
showCarets: true,
|
||||
showThinkingLabel: true,
|
||||
showModeLabel: true,
|
||||
aggregateFeatures: false,
|
||||
});
|
||||
expect(resolveComposerControlPresentation("condensed")).toEqual({
|
||||
showCarets: false,
|
||||
showThinkingLabel: false,
|
||||
showModeLabel: true,
|
||||
aggregateFeatures: true,
|
||||
});
|
||||
expect(resolveComposerControlPresentation("tight")).toEqual({
|
||||
showCarets: false,
|
||||
showThinkingLabel: false,
|
||||
showModeLabel: false,
|
||||
aggregateFeatures: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses local available width and hysteresis to avoid density churn", () => {
|
||||
const controls = {
|
||||
hasModel: true,
|
||||
hasThinking: true,
|
||||
hasMode: true,
|
||||
features: [{ type: "toggle" as const }],
|
||||
fontScale: 1,
|
||||
};
|
||||
|
||||
expect(
|
||||
resolveComposerControlDensity({
|
||||
availableWidth: 420,
|
||||
currentDensity: "full",
|
||||
controls,
|
||||
}),
|
||||
).toBe("full");
|
||||
expect(
|
||||
resolveComposerControlDensity({
|
||||
availableWidth: 380,
|
||||
currentDensity: "full",
|
||||
controls,
|
||||
}),
|
||||
).toBe("condensed");
|
||||
expect(
|
||||
resolveComposerControlDensity({
|
||||
availableWidth: 290,
|
||||
currentDensity: "condensed",
|
||||
controls,
|
||||
}),
|
||||
).toBe("condensed");
|
||||
expect(
|
||||
resolveComposerControlDensity({
|
||||
availableWidth: 280,
|
||||
currentDensity: "condensed",
|
||||
controls,
|
||||
}),
|
||||
).toBe("tight");
|
||||
expect(
|
||||
resolveComposerControlDensity({
|
||||
availableWidth: 300,
|
||||
currentDensity: "tight",
|
||||
controls,
|
||||
}),
|
||||
).toBe("tight");
|
||||
expect(
|
||||
resolveComposerControlDensity({
|
||||
availableWidth: 312,
|
||||
currentDensity: "tight",
|
||||
controls,
|
||||
}),
|
||||
).toBe("condensed");
|
||||
});
|
||||
|
||||
it("budgets extra features and larger text before restoring full labels", () => {
|
||||
const base = {
|
||||
availableWidth: 430,
|
||||
currentDensity: "condensed" as const,
|
||||
};
|
||||
|
||||
expect(
|
||||
resolveComposerControlDensity({
|
||||
...base,
|
||||
controls: {
|
||||
hasModel: true,
|
||||
hasThinking: true,
|
||||
hasMode: true,
|
||||
features: [{ type: "toggle" }],
|
||||
fontScale: 1,
|
||||
},
|
||||
}),
|
||||
).toBe("full");
|
||||
expect(
|
||||
resolveComposerControlDensity({
|
||||
...base,
|
||||
controls: {
|
||||
hasModel: true,
|
||||
hasThinking: true,
|
||||
hasMode: true,
|
||||
features: [{ type: "toggle" }, { type: "select", label: "Tools" }],
|
||||
fontScale: 1,
|
||||
},
|
||||
}),
|
||||
).toBe("condensed");
|
||||
expect(
|
||||
resolveComposerControlDensity({
|
||||
...base,
|
||||
controls: {
|
||||
hasModel: true,
|
||||
hasThinking: true,
|
||||
hasMode: true,
|
||||
features: [{ type: "toggle" }],
|
||||
fontScale: 1.25,
|
||||
},
|
||||
}),
|
||||
).toBe("condensed");
|
||||
});
|
||||
|
||||
it("condenses before a labeled feature would overflow", () => {
|
||||
const base = {
|
||||
availableWidth: 430,
|
||||
currentDensity: "full" as const,
|
||||
controls: {
|
||||
hasModel: true,
|
||||
hasThinking: true,
|
||||
hasMode: true,
|
||||
fontScale: 1,
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
resolveComposerControlDensity({
|
||||
...base,
|
||||
controls: { ...base.controls, features: [{ type: "toggle" }] },
|
||||
}),
|
||||
).toBe("full");
|
||||
expect(
|
||||
resolveComposerControlDensity({
|
||||
...base,
|
||||
controls: {
|
||||
...base.controls,
|
||||
features: [{ type: "select", label: "A much longer localized feature label" }],
|
||||
},
|
||||
}),
|
||||
).toBe("condensed");
|
||||
});
|
||||
|
||||
it("gives every toolbar control one shell and one platform glyph envelope", () => {
|
||||
expect(COMPOSER_TOOLBAR_GEOMETRY).toEqual({
|
||||
controlSize: 28,
|
||||
controlGap: 4,
|
||||
iconLabelGap: 4,
|
||||
labelPadding: 8,
|
||||
caretSize: 14,
|
||||
});
|
||||
expect(resolveComposerToolbarGlyphSize("web")).toBe(16);
|
||||
expect(resolveComposerToolbarGlyphSize("native")).toBe(20);
|
||||
});
|
||||
});
|
||||
134
packages/app/src/composer/agent-controls/layout.ts
Normal file
134
packages/app/src/composer/agent-controls/layout.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
export type ComposerControlDensity = "full" | "condensed" | "tight";
|
||||
|
||||
export interface ComposerControlPresence {
|
||||
hasModel: boolean;
|
||||
hasThinking: boolean;
|
||||
hasMode: boolean;
|
||||
features: readonly ComposerFeatureControlPresence[];
|
||||
fontScale: number;
|
||||
}
|
||||
|
||||
export type ComposerFeatureControlPresence = { type: "toggle" } | { type: "select"; label: string };
|
||||
|
||||
export interface ComposerControlPresentation {
|
||||
showCarets: boolean;
|
||||
showThinkingLabel: boolean;
|
||||
showModeLabel: boolean;
|
||||
aggregateFeatures: boolean;
|
||||
}
|
||||
|
||||
export const COMPOSER_TOOLBAR_GEOMETRY = {
|
||||
controlSize: 28,
|
||||
controlGap: 4,
|
||||
iconLabelGap: 4,
|
||||
labelPadding: 8,
|
||||
caretSize: 14,
|
||||
} as const;
|
||||
|
||||
const DENSITY_HYSTERESIS = 12;
|
||||
|
||||
function normalizedFontScale(fontScale: number): number {
|
||||
return Number.isFinite(fontScale) ? Math.max(1, fontScale) : 1;
|
||||
}
|
||||
|
||||
function sumControlWidths(widths: number[]): number {
|
||||
if (widths.length === 0) return 0;
|
||||
return (
|
||||
widths.reduce((total, width) => total + width, 0) +
|
||||
(widths.length - 1) * COMPOSER_TOOLBAR_GEOMETRY.controlGap
|
||||
);
|
||||
}
|
||||
|
||||
function estimateLabelWidth(label: string, fontScale: number): number {
|
||||
return Array.from(label).length * 7 * fontScale;
|
||||
}
|
||||
|
||||
function resolveFeatureControlWidth(
|
||||
feature: ComposerFeatureControlPresence,
|
||||
fontScale: number,
|
||||
): number {
|
||||
if (feature.type === "toggle") return COMPOSER_TOOLBAR_GEOMETRY.controlSize;
|
||||
return (
|
||||
COMPOSER_TOOLBAR_GEOMETRY.controlSize +
|
||||
COMPOSER_TOOLBAR_GEOMETRY.iconLabelGap +
|
||||
COMPOSER_TOOLBAR_GEOMETRY.labelPadding * 2 +
|
||||
estimateLabelWidth(feature.label, fontScale)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveCondensedFloor(controls: ComposerControlPresence): number {
|
||||
const fontScale = normalizedFontScale(controls.fontScale);
|
||||
const widths: number[] = [];
|
||||
if (controls.hasModel) widths.push(36 + 60 * fontScale);
|
||||
if (controls.hasThinking) widths.push(COMPOSER_TOOLBAR_GEOMETRY.controlSize);
|
||||
if (controls.hasMode) widths.push(36 + 96 * fontScale);
|
||||
if (controls.features.length > 0) widths.push(COMPOSER_TOOLBAR_GEOMETRY.controlSize);
|
||||
return sumControlWidths(widths);
|
||||
}
|
||||
|
||||
function resolveFullFloor(controls: ComposerControlPresence): number {
|
||||
const fontScale = normalizedFontScale(controls.fontScale);
|
||||
const widths: number[] = [];
|
||||
if (controls.hasModel) widths.push(50 + 70 * fontScale);
|
||||
if (controls.hasThinking) widths.push(54 + 48 * fontScale);
|
||||
if (controls.hasMode) widths.push(54 + 96 * fontScale);
|
||||
for (const feature of controls.features) {
|
||||
widths.push(resolveFeatureControlWidth(feature, fontScale));
|
||||
}
|
||||
return sumControlWidths(widths);
|
||||
}
|
||||
|
||||
export function resolveComposerControlDensity(input: {
|
||||
availableWidth: number;
|
||||
currentDensity: ComposerControlDensity;
|
||||
controls: ComposerControlPresence;
|
||||
}): ComposerControlDensity {
|
||||
const fullFloor = resolveFullFloor(input.controls);
|
||||
const condensedFloor = resolveCondensedFloor(input.controls);
|
||||
|
||||
if (input.currentDensity === "full") {
|
||||
if (input.availableWidth >= fullFloor - DENSITY_HYSTERESIS) return "full";
|
||||
return input.availableWidth >= condensedFloor ? "condensed" : "tight";
|
||||
}
|
||||
|
||||
if (input.currentDensity === "condensed") {
|
||||
if (input.availableWidth >= fullFloor + DENSITY_HYSTERESIS) return "full";
|
||||
if (input.availableWidth < condensedFloor - DENSITY_HYSTERESIS) return "tight";
|
||||
return "condensed";
|
||||
}
|
||||
|
||||
if (input.availableWidth >= fullFloor + DENSITY_HYSTERESIS) return "full";
|
||||
if (input.availableWidth >= condensedFloor + DENSITY_HYSTERESIS) return "condensed";
|
||||
return "tight";
|
||||
}
|
||||
|
||||
export function resolveComposerControlPresentation(
|
||||
density: ComposerControlDensity,
|
||||
): ComposerControlPresentation {
|
||||
if (density === "full") {
|
||||
return {
|
||||
showCarets: true,
|
||||
showThinkingLabel: true,
|
||||
showModeLabel: true,
|
||||
aggregateFeatures: false,
|
||||
};
|
||||
}
|
||||
if (density === "condensed") {
|
||||
return {
|
||||
showCarets: false,
|
||||
showThinkingLabel: false,
|
||||
showModeLabel: true,
|
||||
aggregateFeatures: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
showCarets: false,
|
||||
showThinkingLabel: false,
|
||||
showModeLabel: false,
|
||||
aggregateFeatures: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveComposerToolbarGlyphSize(platform: "web" | "native"): number {
|
||||
return platform === "native" ? 20 : 16;
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
@@ -8,7 +7,7 @@ import {
|
||||
type ReactElement,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
@@ -22,7 +21,6 @@ import {
|
||||
ShieldPlus,
|
||||
ShieldQuestionMark,
|
||||
} from "lucide-react-native";
|
||||
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
|
||||
import { type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
@@ -32,7 +30,6 @@ import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { mergeProviderPreferences, useFormPreferences } from "@/hooks/use-form-preferences";
|
||||
import { resolveProviderDefinition } from "@/utils/provider-definitions";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { showProviderNoticeToast } from "@/utils/provider-notice-toast";
|
||||
import { formatAgentModeLabel, getAgentControlHintKey } from "@/composer/agent-controls/utils";
|
||||
@@ -41,15 +38,11 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import { resolveNextAgentModeId } from "@/composer/agent-controls/mode";
|
||||
import { useComposerKeyboardScope } from "@/composer/keyboard-scope";
|
||||
import type { AgentMode, AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import { useComposerControlLayout } from "@/composer/agent-controls/layout-context";
|
||||
import { AgentControlTrigger } from "@/composer/agent-controls/control";
|
||||
import type { AgentMode } from "@getpaseo/protocol/agent-types";
|
||||
import { getModeVisuals, type AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
|
||||
|
||||
export type AgentModeControlPlacement = "toolbar" | "footer";
|
||||
|
||||
function shouldRenderForPlacement(placement: AgentModeControlPlacement, isCompact: boolean) {
|
||||
return placement === "footer" ? isCompact : !isCompact;
|
||||
}
|
||||
|
||||
interface ModeIconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
@@ -102,7 +95,7 @@ function ModeComboboxOption({
|
||||
);
|
||||
}
|
||||
|
||||
interface AgentModeControlViewProps {
|
||||
export interface AgentModeControlValue {
|
||||
provider: string;
|
||||
providerDefinitions: AgentProviderDefinition[];
|
||||
modeOptions: AgentMode[];
|
||||
@@ -115,20 +108,24 @@ function normalizeSearchQuery(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function AgentModeControlView({
|
||||
export function AgentModeControl({
|
||||
provider,
|
||||
providerDefinitions,
|
||||
modeOptions,
|
||||
selectedModeId,
|
||||
onSelectMode,
|
||||
disabled = false,
|
||||
}: AgentModeControlViewProps) {
|
||||
surface = "toolbar",
|
||||
onClose,
|
||||
}: AgentModeControlValue & { surface?: "toolbar" | "sheet"; onClose?: () => void }) {
|
||||
const { theme } = useUnistyles();
|
||||
const { presentation } = useComposerControlLayout();
|
||||
const { t } = useTranslation();
|
||||
const { isActiveComposer } = useComposerKeyboardScope();
|
||||
const cycleShortcutKeys = useShortcutKeys("cycle-agent-mode");
|
||||
const anchorRef = useRef<View>(null);
|
||||
const keyboardHandlerIdRef = useRef(`mode-control:${Math.random().toString(36).slice(2)}`);
|
||||
const openRef = useRef(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
@@ -140,7 +137,7 @@ function AgentModeControlView({
|
||||
const visuals = selectedMode
|
||||
? getModeVisuals(provider, selectedMode.id, providerDefinitions)
|
||||
: undefined;
|
||||
const Icon = visuals?.icon ? MODE_ICONS[visuals.icon] : undefined;
|
||||
const Icon = visuals?.icon ? (MODE_ICONS[visuals.icon] ?? Bot) : Bot;
|
||||
const iconColor = theme.colors.foregroundMuted;
|
||||
const selectedModeLabel = selectedMode ? formatAgentModeLabel(selectedMode) : "";
|
||||
|
||||
@@ -154,10 +151,18 @@ function AgentModeControlView({
|
||||
return allOptions.filter((o) => o.label.toLowerCase().includes(q));
|
||||
}, [allOptions, searchQuery]);
|
||||
|
||||
const handleOpenChange = useCallback((next: boolean) => {
|
||||
setOpen(next);
|
||||
if (!next) setSearchQuery("");
|
||||
}, []);
|
||||
const handleOpenChange = useCallback(
|
||||
(next: boolean) => {
|
||||
const wasOpen = openRef.current;
|
||||
openRef.current = next;
|
||||
setOpen(next);
|
||||
if (!next) {
|
||||
setSearchQuery("");
|
||||
if (wasOpen) onClose?.();
|
||||
}
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
const handlePress = useCallback(() => handleOpenChange(!open), [handleOpenChange, open]);
|
||||
const handleSelect = useCallback(
|
||||
@@ -208,18 +213,6 @@ function AgentModeControlView({
|
||||
[provider, providerDefinitions, theme.colors.foreground],
|
||||
);
|
||||
|
||||
const pressableStyle = useCallback(
|
||||
({ pressed, hovered }: PressableStateCallbackType) => [
|
||||
styles.chip,
|
||||
hovered && styles.chipHovered,
|
||||
(pressed || open) && styles.chipPressed,
|
||||
disabled && styles.chipDisabled,
|
||||
],
|
||||
[open, disabled],
|
||||
);
|
||||
|
||||
const labelStyle = styles.chipLabel;
|
||||
|
||||
const sheetHeader = useMemo<SheetHeader>(
|
||||
() => ({
|
||||
title: t("agentControls.mode.title"),
|
||||
@@ -238,21 +231,23 @@ function AgentModeControlView({
|
||||
<>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
<ComboboxTrigger
|
||||
<AgentControlTrigger
|
||||
ref={anchorRef}
|
||||
collapsable={false}
|
||||
icon={Icon}
|
||||
iconColor={iconColor}
|
||||
surface={surface}
|
||||
label={t("agentControls.mode.title")}
|
||||
value={selectedModeLabel}
|
||||
showToolbarLabel={presentation.showModeLabel}
|
||||
showCaret={surface === "toolbar" && presentation.showCarets}
|
||||
open={open}
|
||||
disabled={disabled}
|
||||
onPress={handlePress}
|
||||
style={pressableStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("agentControls.mode.selectWithValue", {
|
||||
value: selectedModeLabel,
|
||||
})}
|
||||
testID="mode-control"
|
||||
>
|
||||
{Icon ? <Icon size={theme.iconSize.md} color={iconColor} /> : null}
|
||||
<Text style={labelStyle}>{selectedModeLabel}</Text>
|
||||
</ComboboxTrigger>
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<View style={styles.tooltipRow}>
|
||||
@@ -269,6 +264,7 @@ function AgentModeControlView({
|
||||
onOpenChange={handleOpenChange}
|
||||
anchorRef={anchorRef}
|
||||
desktopPlacement="top-start"
|
||||
desktopMinWidth={260}
|
||||
header={sheetHeader}
|
||||
renderOption={renderOption}
|
||||
/>
|
||||
@@ -282,21 +278,10 @@ function compareAvailableModes(a: AgentMode[], b: AgentMode[]): boolean {
|
||||
return a === b || JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
|
||||
interface AgentModeControlProps {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
placement: AgentModeControlPlacement;
|
||||
isCompactLayout?: boolean;
|
||||
}
|
||||
|
||||
export const AgentModeControl = memo(function AgentModeControl({
|
||||
serverId,
|
||||
agentId,
|
||||
placement,
|
||||
isCompactLayout,
|
||||
}: AgentModeControlProps) {
|
||||
const isCompactFormFactor = useIsCompactFormFactor();
|
||||
const isCompact = isCompactLayout ?? isCompactFormFactor;
|
||||
export function useLiveAgentModeControl(
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
): AgentModeControlValue | null {
|
||||
const slice = useSessionStore(
|
||||
useShallow((state) => {
|
||||
const agent = state.sessions[serverId]?.agents?.get(agentId);
|
||||
@@ -349,82 +334,20 @@ export const AgentModeControl = memo(function AgentModeControl({
|
||||
[agentId, client, slice?.provider, toast, updatePreferences],
|
||||
);
|
||||
|
||||
if (!slice || availableModes.length === 0) return null;
|
||||
if (!shouldRenderForPlacement(placement, isCompact)) return null;
|
||||
|
||||
return (
|
||||
<AgentModeControlView
|
||||
provider={slice.provider}
|
||||
providerDefinitions={providerDefinitions}
|
||||
modeOptions={availableModes}
|
||||
selectedModeId={slice.currentModeId}
|
||||
onSelectMode={handleSelectMode}
|
||||
disabled={!client}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export interface DraftAgentModeControlProps {
|
||||
selectedProvider: AgentProvider | null;
|
||||
providerDefinitions: AgentProviderDefinition[];
|
||||
modeOptions: AgentMode[];
|
||||
selectedMode: string;
|
||||
onSelectMode: (modeId: string) => void;
|
||||
disabled?: boolean;
|
||||
placement: AgentModeControlPlacement;
|
||||
isCompactLayout?: boolean;
|
||||
}
|
||||
|
||||
export function DraftAgentModeControl({
|
||||
selectedProvider,
|
||||
providerDefinitions,
|
||||
modeOptions,
|
||||
selectedMode,
|
||||
onSelectMode,
|
||||
disabled,
|
||||
placement,
|
||||
isCompactLayout,
|
||||
}: DraftAgentModeControlProps) {
|
||||
const isCompactFormFactor = useIsCompactFormFactor();
|
||||
const isCompact = isCompactLayout ?? isCompactFormFactor;
|
||||
if (!selectedProvider || modeOptions.length === 0) return null;
|
||||
if (!shouldRenderForPlacement(placement, isCompact)) return null;
|
||||
return (
|
||||
<AgentModeControlView
|
||||
provider={selectedProvider}
|
||||
providerDefinitions={providerDefinitions}
|
||||
modeOptions={modeOptions}
|
||||
selectedModeId={selectedMode}
|
||||
onSelectMode={onSelectMode}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
return useMemo(() => {
|
||||
if (!slice || availableModes.length === 0) return null;
|
||||
return {
|
||||
provider: slice.provider,
|
||||
providerDefinitions,
|
||||
modeOptions: availableModes,
|
||||
selectedModeId: slice.currentModeId,
|
||||
onSelectMode: handleSelectMode,
|
||||
disabled: !client,
|
||||
};
|
||||
}, [availableModes, client, handleSelectMode, providerDefinitions, slice]);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
chip: {
|
||||
height: 28,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: "transparent",
|
||||
gap: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius["2xl"],
|
||||
},
|
||||
chipHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
chipPressed: {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
chipDisabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
chipLabel: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
tooltipRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
275
packages/app/src/composer/agent-controls/model-sheet.tsx
Normal file
275
packages/app/src/composer/agent-controls/model-sheet.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
import { useCallback, useMemo, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Keyboard, ScrollView, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { ModelBrowser, useModelBrowser } from "@/components/model-browser";
|
||||
import { ComposerToolbarGlyph } from "@/composer/agent-controls/glyph";
|
||||
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
|
||||
const SNAP_POINTS = ["80%", "90%"];
|
||||
const MODEL_LIST_TOP_INSET = 4;
|
||||
const MODEL_ROW_STRIDE = 44;
|
||||
const MODEL_VIEWPORT_VISIBLE_ROWS = 4.5;
|
||||
const FIXED_MODEL_VIEWPORT_HEIGHT =
|
||||
MODEL_LIST_TOP_INSET + MODEL_ROW_STRIDE * MODEL_VIEWPORT_VISIBLE_ROWS;
|
||||
|
||||
interface CompactModelSheetProps {
|
||||
providers: ProviderSelectorProvider[];
|
||||
selectedProvider: string;
|
||||
selectedModel: string;
|
||||
onSelect: (provider: string, modelId: string) => void;
|
||||
isLoading: boolean;
|
||||
favoriteKeys: Set<string>;
|
||||
onToggleFavorite?: (provider: string, modelId: string) => void;
|
||||
onOpen?: () => void;
|
||||
onClose?: () => void;
|
||||
onRetryProvider?: (provider: AgentProvider) => void;
|
||||
isRetryingProvider?: boolean;
|
||||
disabled?: boolean;
|
||||
serverId?: string | null;
|
||||
glyphSize: number;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function shortModelLabel(label: string): string {
|
||||
const separatorIndex = label.lastIndexOf("/");
|
||||
return separatorIndex === -1 ? label : label.slice(separatorIndex + 1);
|
||||
}
|
||||
|
||||
export function CompactModelSheet({
|
||||
providers,
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
onSelect,
|
||||
isLoading,
|
||||
favoriteKeys,
|
||||
onToggleFavorite,
|
||||
onOpen,
|
||||
onClose,
|
||||
onRetryProvider,
|
||||
isRetryingProvider = false,
|
||||
disabled = false,
|
||||
serverId = null,
|
||||
glyphSize,
|
||||
children,
|
||||
}: CompactModelSheetProps) {
|
||||
const { t } = useTranslation();
|
||||
const usesBottomSheet = useIsCompactFormFactor();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const browser = useModelBrowser({
|
||||
providers,
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
isLoading,
|
||||
favoriteKeys,
|
||||
serverId,
|
||||
});
|
||||
const { prepareToOpen, reset } = browser;
|
||||
const ProviderIcon =
|
||||
selectedProvider.trim().length > 0 ? getProviderIcon(selectedProvider) : null;
|
||||
const compactFooter = useMemo(
|
||||
() =>
|
||||
usesBottomSheet ? (
|
||||
<View style={styles.compactFooter} testID="agent-controls-settings-list">
|
||||
<View style={styles.modelViewportDivider} />
|
||||
<View style={[styles.controlsContent, styles.compactControlsContent]}>{children}</View>
|
||||
</View>
|
||||
) : undefined,
|
||||
[children, usesBottomSheet],
|
||||
);
|
||||
|
||||
const open = useCallback(() => {
|
||||
Keyboard.dismiss();
|
||||
prepareToOpen();
|
||||
setIsOpen(true);
|
||||
onOpen?.();
|
||||
}, [onOpen, prepareToOpen]);
|
||||
|
||||
const close = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
reset();
|
||||
onClose?.();
|
||||
}, [onClose, reset]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(provider: string, modelId: string) => {
|
||||
onSelect(provider, modelId);
|
||||
close();
|
||||
},
|
||||
[close, onSelect],
|
||||
);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
if (isOpen) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
open();
|
||||
}, [close, isOpen, open]);
|
||||
|
||||
const triggerStyle = useCallback(
|
||||
({ hovered, pressed }: PressableStateCallbackType) => [
|
||||
styles.trigger,
|
||||
hovered && styles.triggerHovered,
|
||||
(pressed || isOpen) && styles.triggerPressed,
|
||||
disabled && styles.triggerDisabled,
|
||||
],
|
||||
[disabled, isOpen],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ComboboxTrigger
|
||||
collapsable={false}
|
||||
disabled={disabled}
|
||||
onPress={toggle}
|
||||
style={triggerStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("modelSelector.selectedModel", {
|
||||
model: browser.selectedModelLabel,
|
||||
})}
|
||||
testID="combined-model-selector"
|
||||
chevron={null}
|
||||
>
|
||||
{ProviderIcon ? (
|
||||
<ComposerToolbarGlyph size={glyphSize}>
|
||||
<ProviderIcon size={glyphSize} color={styles.providerIcon.color} />
|
||||
</ComposerToolbarGlyph>
|
||||
) : null}
|
||||
<Text style={styles.triggerText} numberOfLines={1}>
|
||||
{shortModelLabel(browser.triggerLabel)}
|
||||
</Text>
|
||||
</ComboboxTrigger>
|
||||
|
||||
<AdaptiveModalSheet
|
||||
header={browser.header}
|
||||
visible={isOpen}
|
||||
onClose={close}
|
||||
snapPoints={SNAP_POINTS}
|
||||
scrollable={false}
|
||||
sizeContentToCurrentSnapPoint={usesBottomSheet}
|
||||
footer={compactFooter}
|
||||
footerContainerStyle={usesBottomSheet ? styles.compactFooterContainer : undefined}
|
||||
contentContainerStyle={styles.sheetBody}
|
||||
testID="agent-controls-model-sheet"
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.modelViewport,
|
||||
usesBottomSheet ? styles.flexibleModelViewport : styles.fixedModelViewport,
|
||||
]}
|
||||
testID="agent-controls-model-viewport"
|
||||
>
|
||||
<ModelBrowser
|
||||
state={browser}
|
||||
onSelect={handleSelect}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onRetryProvider={onRetryProvider}
|
||||
isRetryingProvider={isRetryingProvider}
|
||||
scrolling="independent"
|
||||
/>
|
||||
</View>
|
||||
{!usesBottomSheet ? (
|
||||
<>
|
||||
<View style={styles.modelViewportDivider} />
|
||||
<ScrollView
|
||||
style={styles.controlsScroll}
|
||||
contentContainerStyle={styles.controlsContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
testID="agent-controls-settings-list"
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
</>
|
||||
) : null}
|
||||
</AdaptiveModalSheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
trigger: {
|
||||
height: 28,
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius["2xl"],
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
triggerHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
triggerPressed: {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
triggerDisabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
triggerText: {
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
providerIcon: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
sheetBody: {
|
||||
paddingHorizontal: 0,
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
gap: 0,
|
||||
},
|
||||
modelViewport: {
|
||||
overflow: "hidden",
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
},
|
||||
flexibleModelViewport: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
fixedModelViewport: {
|
||||
height: FIXED_MODEL_VIEWPORT_HEIGHT,
|
||||
minHeight: FIXED_MODEL_VIEWPORT_HEIGHT,
|
||||
},
|
||||
modelViewportDivider: {
|
||||
height: 1,
|
||||
flexShrink: 0,
|
||||
backgroundColor: theme.colors.border,
|
||||
},
|
||||
controlsScroll: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
compactFooterContainer: {
|
||||
flexDirection: "column",
|
||||
alignItems: "stretch",
|
||||
justifyContent: "flex-start",
|
||||
gap: 0,
|
||||
paddingHorizontal: 0,
|
||||
paddingTop: 0,
|
||||
borderTopWidth: 0,
|
||||
},
|
||||
compactFooter: {
|
||||
minWidth: 0,
|
||||
},
|
||||
compactControlsContent: {
|
||||
paddingBottom: 0,
|
||||
},
|
||||
controlsContent: {
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingTop: theme.spacing[3],
|
||||
paddingBottom: theme.spacing[3],
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
}));
|
||||
@@ -45,6 +45,7 @@ interface ComposerWorkspaceAttachmentBinding {
|
||||
buildOutgoingAttachments: (normalAttachments: UserComposerAttachment[]) => ComposerAttachment[];
|
||||
removeAttachment: (input: RemoveWorkspaceAttachmentInput) => boolean;
|
||||
openAttachment: (input: OpenWorkspaceAttachmentInput) => boolean;
|
||||
beginSubmit: (attachments: readonly ComposerAttachment[]) => void;
|
||||
clearSentAttachments: (attachments: readonly ComposerAttachment[]) => void;
|
||||
completeSubmit: (input: CompleteSubmitInput) => void;
|
||||
resetSuppression: () => void;
|
||||
@@ -196,12 +197,22 @@ function useWorkspaceAttachmentBinding({
|
||||
setSuppressedKeys([]);
|
||||
}, []);
|
||||
|
||||
const beginSubmit = useCallback((attachments: readonly ComposerAttachment[]) => {
|
||||
const keys = attachments.filter(isWorkspaceAttachment).map(getAttachmentKey);
|
||||
if (keys.length === 0) return;
|
||||
setSuppressedKeys((current) => {
|
||||
const next = new Set(current);
|
||||
for (const key of keys) next.add(key);
|
||||
return next.size === current.length ? current : Array.from(next);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const completeSubmit = useCallback(
|
||||
({ result, outgoingAttachments }: CompleteSubmitInput) => {
|
||||
if (result === "submitted") {
|
||||
clearSentAttachments(outgoingAttachments);
|
||||
}
|
||||
if (result === "queued" || result === "submitted") {
|
||||
if (result === "queued" || result === "submitted" || result === "failed") {
|
||||
resetSuppression();
|
||||
}
|
||||
},
|
||||
@@ -213,6 +224,7 @@ function useWorkspaceAttachmentBinding({
|
||||
buildOutgoingAttachments,
|
||||
removeAttachment,
|
||||
openAttachment,
|
||||
beginSubmit,
|
||||
clearSentAttachments,
|
||||
completeSubmit,
|
||||
resetSuppression,
|
||||
|
||||
@@ -19,6 +19,7 @@ export function ComposerImportPill({ onPress, disabled = false }: ComposerImport
|
||||
const handleHoverIn = useCallback(() => setIsHovered(true), []);
|
||||
const handleHoverOut = useCallback(() => setIsHovered(false), []);
|
||||
const bodyStyle = useMemo(() => [styles.body, isHovered && styles.bodyHovered], [isHovered]);
|
||||
const labelStyle = useMemo(() => [styles.label, isHovered && styles.labelHovered], [isHovered]);
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<Pressable
|
||||
@@ -32,7 +33,7 @@ export function ComposerImportPill({ onPress, disabled = false }: ComposerImport
|
||||
style={bodyStyle}
|
||||
>
|
||||
<ThemedImportIcon size={14} uniProps={iconColorMapping} />
|
||||
<Text style={styles.label} numberOfLines={1}>
|
||||
<Text style={labelStyle} numberOfLines={1}>
|
||||
{t("importSession.title")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
@@ -50,7 +51,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.borderAccent,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
@@ -59,7 +60,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
label: {
|
||||
color: theme.colors.foreground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
labelHovered: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -9,7 +9,6 @@ import { useContainerWidthBelow } from "@/hooks/use-container-width";
|
||||
import invariant from "tiny-invariant";
|
||||
import { Composer } from "@/composer";
|
||||
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
|
||||
import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control";
|
||||
import { ComposerImportPill } from "@/composer/draft/import-pill";
|
||||
import { AgentStreamView } from "@/agent-stream/view";
|
||||
import { composerWorkspaceAttachment } from "@/composer/attachments/workspace";
|
||||
@@ -609,57 +608,6 @@ export function WorkspaceDraftAgentTab({
|
||||
focusInputRef.current = focus;
|
||||
}, []);
|
||||
|
||||
const handleProviderSelectWithFocus = useCallback(
|
||||
(provider: Parameters<typeof composerState.setProviderFromUser>[0]) => {
|
||||
composerState.setProviderFromUser(provider);
|
||||
focusInputRef.current?.();
|
||||
},
|
||||
[composerState],
|
||||
);
|
||||
|
||||
const handleModeSelectWithFocus = useCallback(
|
||||
(modeId: string) => {
|
||||
composerState.setModeFromUser(modeId);
|
||||
focusInputRef.current?.();
|
||||
},
|
||||
[composerState],
|
||||
);
|
||||
|
||||
const handleModelSelectWithFocus = useCallback(
|
||||
(modelId: string) => {
|
||||
composerState.setModelFromUser(modelId);
|
||||
focusInputRef.current?.();
|
||||
},
|
||||
[composerState],
|
||||
);
|
||||
|
||||
const handleProviderAndModelSelectWithFocus = useCallback(
|
||||
(
|
||||
provider: Parameters<typeof composerState.setProviderAndModelFromUser>[0],
|
||||
modelId: string,
|
||||
) => {
|
||||
composerState.setProviderAndModelFromUser(provider, modelId);
|
||||
focusInputRef.current?.();
|
||||
},
|
||||
[composerState],
|
||||
);
|
||||
|
||||
const handleThinkingOptionSelectWithFocus = useCallback(
|
||||
(optionId: string) => {
|
||||
composerState.setThinkingOptionFromUser(optionId);
|
||||
focusInputRef.current?.();
|
||||
},
|
||||
[composerState],
|
||||
);
|
||||
|
||||
const handleSetFeatureWithFocus = useCallback(
|
||||
(featureId: string, value: unknown) => {
|
||||
composerState.agentControls.onSetFeature?.(featureId, value);
|
||||
focusInputRef.current?.();
|
||||
},
|
||||
[composerState],
|
||||
);
|
||||
|
||||
const { style: composerKeyboardStyle } = useKeyboardShiftStyle({
|
||||
mode: "translate",
|
||||
});
|
||||
@@ -680,39 +628,11 @@ export function WorkspaceDraftAgentTab({
|
||||
const composerAgentControls = useMemo(
|
||||
() => ({
|
||||
...composerState.agentControls,
|
||||
onSelectProvider: handleProviderSelectWithFocus,
|
||||
onSelectMode: handleModeSelectWithFocus,
|
||||
onSelectModel: handleModelSelectWithFocus,
|
||||
onSelectProviderAndModel: handleProviderAndModelSelectWithFocus,
|
||||
onSelectThinkingOption: handleThinkingOptionSelectWithFocus,
|
||||
onSetFeature: handleSetFeatureWithFocus,
|
||||
onDropdownClose: handleDropdownCloseFocus,
|
||||
disabled: isSubmitting,
|
||||
}),
|
||||
[
|
||||
composerState.agentControls,
|
||||
handleProviderSelectWithFocus,
|
||||
handleModeSelectWithFocus,
|
||||
handleModelSelectWithFocus,
|
||||
handleProviderAndModelSelectWithFocus,
|
||||
handleThinkingOptionSelectWithFocus,
|
||||
handleSetFeatureWithFocus,
|
||||
handleDropdownCloseFocus,
|
||||
isSubmitting,
|
||||
],
|
||||
[composerState.agentControls, handleDropdownCloseFocus, isSubmitting],
|
||||
);
|
||||
const composerFooter = useMemo(
|
||||
() =>
|
||||
isCompactComposerLayout ? (
|
||||
<DraftAgentModeControl
|
||||
placement="footer"
|
||||
{...composerAgentControls}
|
||||
isCompactLayout={isCompactComposerLayout}
|
||||
/>
|
||||
) : undefined,
|
||||
[isCompactComposerLayout, composerAgentControls],
|
||||
);
|
||||
|
||||
return (
|
||||
<FileDropZone style={styles.container}>
|
||||
<View style={styles.contentContainer}>
|
||||
@@ -770,7 +690,6 @@ export function WorkspaceDraftAgentTab({
|
||||
onFocusInput={handleFocusInputCallback}
|
||||
commandDraftConfig={composerState.commandDraftConfig}
|
||||
agentControls={composerAgentControls}
|
||||
footer={composerFooter}
|
||||
isCompactLayout={isCompactComposerLayout}
|
||||
/>
|
||||
</ReanimatedAnimated.View>
|
||||
|
||||
@@ -232,6 +232,7 @@ function renderContextWindowMeter(
|
||||
serverId: string,
|
||||
provider: string | null,
|
||||
pending: boolean,
|
||||
glyphSize: number,
|
||||
): ReactElement | null {
|
||||
const hasData = contextWindowMaxTokens !== null && contextWindowUsedTokens !== null;
|
||||
if (!hasData && !pending) {
|
||||
@@ -246,21 +247,16 @@ function renderContextWindowMeter(
|
||||
serverId={serverId}
|
||||
provider={provider}
|
||||
pending={pending}
|
||||
glyphSize={glyphSize}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function resolveContextWindowPlacement(
|
||||
meter: ReactElement | null,
|
||||
isMobile: boolean,
|
||||
): { beforeVoiceContent: ReactNode; footerInlineContent: ReactNode } {
|
||||
if (isMobile) {
|
||||
return { beforeVoiceContent: null, footerInlineContent: meter };
|
||||
}
|
||||
return {
|
||||
beforeVoiceContent: <View style={styles.contextWindowMeterSlot}>{meter}</View>,
|
||||
footerInlineContent: null,
|
||||
};
|
||||
reserveSlot: boolean,
|
||||
): ReactNode {
|
||||
return reserveSlot ? <View style={styles.contextWindowMeterSlot}>{meter}</View> : null;
|
||||
}
|
||||
|
||||
interface RenderLeftContentArgs {
|
||||
@@ -302,23 +298,6 @@ interface RenderAttachmentTrayArgs {
|
||||
};
|
||||
}
|
||||
|
||||
function renderComposerFooter(
|
||||
footer: ReactNode,
|
||||
footerInlineContent: ReactNode,
|
||||
): ReactElement | null {
|
||||
if (!footer && !footerInlineContent) return null;
|
||||
return (
|
||||
<View style={styles.footer}>
|
||||
<View style={styles.footerContent}>
|
||||
<View style={styles.footerLeft}>
|
||||
{footer}
|
||||
{footerInlineContent}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function renderAttachmentTray(args: RenderAttachmentTrayArgs): ReactElement | null {
|
||||
const {
|
||||
selectedAttachments,
|
||||
@@ -861,8 +840,6 @@ interface ComposerProps {
|
||||
agentControls?: DraftAgentControlsProps;
|
||||
/** Extra styles merged onto the message input wrapper (e.g. elevated background). */
|
||||
inputWrapperStyle?: import("react-native").ViewStyle;
|
||||
/** Rendered below the input, inside the keyboard-shifted container. */
|
||||
footer?: ReactNode;
|
||||
/** When true, a parent wrapper owns the keyboard shift, so the composer skips its own. */
|
||||
externalKeyboardShift?: boolean;
|
||||
/** Optional panel/container layout breakpoint. Defaults to the screen breakpoint. */
|
||||
@@ -1072,7 +1049,6 @@ export function Composer({
|
||||
onAttentionPromptSend,
|
||||
agentControls,
|
||||
inputWrapperStyle,
|
||||
footer,
|
||||
externalKeyboardShift,
|
||||
isCompactLayout: isCompactLayoutOverride,
|
||||
}: ComposerProps) {
|
||||
@@ -1119,6 +1095,7 @@ export function Composer({
|
||||
buildOutgoingAttachments,
|
||||
removeAttachment,
|
||||
openAttachment,
|
||||
beginSubmit,
|
||||
clearSentAttachments,
|
||||
completeSubmit,
|
||||
resetSuppression,
|
||||
@@ -1396,6 +1373,9 @@ export function Composer({
|
||||
queueMessage(queuedText, queuedAttachments);
|
||||
},
|
||||
submitMessage: async ({ message: submitText, attachments: submitAttachments }) => {
|
||||
if (submitBehavior !== "preserve-and-lock") {
|
||||
beginSubmit(submitAttachments);
|
||||
}
|
||||
await submitMessage(submitText, submitAttachments);
|
||||
},
|
||||
clearDraft,
|
||||
@@ -1417,6 +1397,7 @@ export function Composer({
|
||||
},
|
||||
[
|
||||
allowEmptySubmit,
|
||||
beginSubmit,
|
||||
clearDraft,
|
||||
completeSubmit,
|
||||
hasExternalContent,
|
||||
@@ -1800,6 +1781,7 @@ export function Composer({
|
||||
|
||||
const contextWindowPending =
|
||||
agentState.status === "initializing" || agentState.status === "running";
|
||||
const contextWindowMeterGlyphSize = isCompactLayout ? ICON_SIZE.md : buttonIconSize;
|
||||
|
||||
const contextWindowMeter = useMemo(
|
||||
() =>
|
||||
@@ -1807,24 +1789,25 @@ export function Composer({
|
||||
contextWindowMaxTokens,
|
||||
contextWindowUsedTokens,
|
||||
agentState.totalCostUsd,
|
||||
isCompactLayout,
|
||||
false,
|
||||
serverId,
|
||||
agentState.provider,
|
||||
contextWindowPending,
|
||||
contextWindowMeterGlyphSize,
|
||||
),
|
||||
[
|
||||
contextWindowMaxTokens,
|
||||
contextWindowUsedTokens,
|
||||
agentState.totalCostUsd,
|
||||
isCompactLayout,
|
||||
serverId,
|
||||
agentState.provider,
|
||||
contextWindowPending,
|
||||
contextWindowMeterGlyphSize,
|
||||
],
|
||||
);
|
||||
const { beforeVoiceContent, footerInlineContent } = useMemo(
|
||||
() => resolveContextWindowPlacement(contextWindowMeter, isCompactLayout),
|
||||
[contextWindowMeter, isCompactLayout],
|
||||
const beforeVoiceContent = useMemo(
|
||||
() => resolveContextWindowPlacement(contextWindowMeter, hasAgent),
|
||||
[contextWindowMeter, hasAgent],
|
||||
);
|
||||
|
||||
const hasGithubAttachment = useMemo(
|
||||
@@ -2155,7 +2138,6 @@ export function Composer({
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
{renderComposerFooter(footer, footerInlineContent)}
|
||||
</Animated.View>
|
||||
</ComposerKeyboardScopeProvider>
|
||||
);
|
||||
@@ -2191,50 +2173,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
maxWidth: MAX_CONTENT_WIDTH,
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
footer: {
|
||||
width: "100%",
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
// Negative margin pulls the footer up against the input area's paddingBottom.
|
||||
// On mobile, leave a 3px gap (no token sits below spacing[1]); desktop keeps more.
|
||||
marginTop: {
|
||||
xs: -(theme.spacing[4] - 3),
|
||||
md: -theme.spacing[3],
|
||||
},
|
||||
alignItems: "center",
|
||||
paddingBottom: {
|
||||
xs: 0,
|
||||
md: theme.spacing[2],
|
||||
},
|
||||
},
|
||||
footerContent: {
|
||||
width: "100%",
|
||||
maxWidth: MAX_CONTENT_WIDTH,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
// On mobile, the negative margins below cancel each glyph's internal padding
|
||||
// to reach the composer border; this inset adds a small visual gap from it.
|
||||
paddingLeft: {
|
||||
xs: 5,
|
||||
md: 10,
|
||||
},
|
||||
paddingRight: {
|
||||
xs: 5,
|
||||
md: 10,
|
||||
},
|
||||
},
|
||||
footerLeft: {
|
||||
flexShrink: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
// On mobile, cancel the leading glyph's internal padding (chip paddingHorizontal)
|
||||
// so its icon aligns to the composer border before the footer inset is applied.
|
||||
marginLeft: {
|
||||
xs: -theme.spacing[2],
|
||||
md: 0,
|
||||
},
|
||||
},
|
||||
messageInputContainer: {
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
@@ -2257,6 +2195,7 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
contextWindowMeterSlot: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
flexShrink: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
|
||||
@@ -485,11 +485,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
serverId,
|
||||
bumpHistorySyncGeneration,
|
||||
refreshDirectories: () => getHostRuntimeStore().refreshDirectories(serverId),
|
||||
}).catch((error) => {
|
||||
console.error("[SessionProvider] resume revalidation failed", {
|
||||
serverId,
|
||||
error: toErrorMessage(error),
|
||||
});
|
||||
});
|
||||
},
|
||||
[bumpHistorySyncGeneration, serverId],
|
||||
|
||||
@@ -32,4 +32,21 @@ describe("session resume revalidation", () => {
|
||||
expect(revalidated).toBe(false);
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it("defers stale resume revalidation while the host is disconnected", async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
const revalidated = await revalidateSessionAfterResume({
|
||||
awayMs: SESSION_STALE_AFTER_MS,
|
||||
serverId: "server",
|
||||
bumpHistorySyncGeneration: (serverId) => calls.push(`history:${serverId}`),
|
||||
refreshDirectories: async () => {
|
||||
calls.push("directories");
|
||||
throw new Error("Host server is not connected");
|
||||
},
|
||||
});
|
||||
|
||||
expect(revalidated).toBe(false);
|
||||
expect(calls).toEqual(["history:server", "directories"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,11 @@ export async function revalidateSessionAfterResume(input: {
|
||||
return false;
|
||||
}
|
||||
|
||||
input.bumpHistorySyncGeneration(input.serverId);
|
||||
await input.refreshDirectories();
|
||||
return true;
|
||||
try {
|
||||
input.bumpHistorySyncGeneration(input.serverId);
|
||||
await input.refreshDirectories();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,10 +18,10 @@ const CATALOG_DATA = [
|
||||
title: "Agoragentic",
|
||||
description:
|
||||
"Agent marketplace with 174+ AI capabilities. Browse, invoke, and pay for agent services settled in USDC on Base L2.",
|
||||
version: "1.3.3",
|
||||
version: "1.3.6",
|
||||
iconId: "agoragentic-acp",
|
||||
installLink: "https://agoragentic.com",
|
||||
command: ["npx", "-y", "agoragentic-mcp@1.3.3", "--acp"],
|
||||
command: ["npx", "-y", "agoragentic-mcp@1.3.6", "--acp"],
|
||||
},
|
||||
{
|
||||
id: "amp-acp",
|
||||
@@ -140,10 +140,10 @@ const CATALOG_DATA = [
|
||||
id: "dimcode",
|
||||
title: "DimCode",
|
||||
description: "A coding agent that puts leading models at your command.",
|
||||
version: "0.2.35",
|
||||
version: "0.2.36",
|
||||
iconId: "dimcode",
|
||||
installLink: "https://dimcode.dev/docs/acp.html",
|
||||
command: ["npx", "-y", "dimcode@0.2.35", "acp"],
|
||||
command: ["npx", "-y", "dimcode@0.2.36", "acp"],
|
||||
},
|
||||
{
|
||||
id: "dirac",
|
||||
@@ -159,10 +159,10 @@ const CATALOG_DATA = [
|
||||
id: "factory-droid",
|
||||
title: "Factory Droid",
|
||||
description: "Factory Droid - AI coding agent powered by Factory AI",
|
||||
version: "0.177.0",
|
||||
version: "0.179.0",
|
||||
iconId: "factory-droid",
|
||||
installLink: "https://factory.ai/product/cli",
|
||||
command: ["npx", "-y", "droid@0.177.0", "exec", "--output-format", "acp-daemon"],
|
||||
command: ["npx", "-y", "droid@0.179.0", "exec", "--output-format", "acp-daemon"],
|
||||
env: {
|
||||
DROID_DISABLE_AUTO_UPDATE: "true",
|
||||
FACTORY_DROID_AUTO_UPDATE_ENABLED: "false",
|
||||
@@ -173,19 +173,19 @@ const CATALOG_DATA = [
|
||||
id: "fast-agent",
|
||||
title: "fast-agent",
|
||||
description: "Code and build agents with comprehensive multi-provider support",
|
||||
version: "0.9.21",
|
||||
version: "0.9.22",
|
||||
iconId: "fast-agent",
|
||||
installLink: "https://fast-agent.ai/acp/",
|
||||
command: ["uvx", "--from", "fast-agent-acp==0.9.21", "fast-agent-acp", "-x"],
|
||||
command: ["uvx", "--from", "fast-agent-acp==0.9.22", "fast-agent-acp", "-x"],
|
||||
},
|
||||
{
|
||||
id: "gemini",
|
||||
title: "Gemini CLI",
|
||||
description: "Google's official CLI for Gemini",
|
||||
version: "0.51.0",
|
||||
version: "0.52.0",
|
||||
iconId: "gemini",
|
||||
installLink: "https://geminicli.com",
|
||||
command: ["npx", "-y", "@google/gemini-cli@0.51.0", "--acp"],
|
||||
command: ["npx", "-y", "@google/gemini-cli@0.52.0", "--acp"],
|
||||
},
|
||||
{
|
||||
id: "glm-acp-agent",
|
||||
@@ -302,10 +302,10 @@ const CATALOG_DATA = [
|
||||
id: "qoder",
|
||||
title: "Qoder CLI",
|
||||
description: "AI coding assistant with agentic capabilities",
|
||||
version: "1.1.3",
|
||||
version: "1.1.4",
|
||||
iconId: "qoder",
|
||||
installLink: "https://qoder.com",
|
||||
command: ["npx", "-y", "@qoder-ai/qodercli@1.1.3", "--acp"],
|
||||
command: ["npx", "-y", "@qoder-ai/qodercli@1.1.4", "--acp"],
|
||||
},
|
||||
{
|
||||
id: "qwen-code",
|
||||
|
||||
@@ -4,7 +4,6 @@ export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: Infinity,
|
||||
gcTime: Infinity,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
|
||||
34
packages/app/src/file-explorer/read-result.test.ts
Normal file
34
packages/app/src/file-explorer/read-result.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { explorerFileFromReadResult } from "./read-result";
|
||||
|
||||
function textRead(bytes: Uint8Array): FileReadResult {
|
||||
return {
|
||||
bytes,
|
||||
mime: "text/plain",
|
||||
size: bytes.byteLength,
|
||||
path: "notes.txt",
|
||||
kind: "text",
|
||||
modifiedAt: "2026-07-21T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
describe("explorerFileFromReadResult", () => {
|
||||
it("records and hides a leading UTF-8 BOM", () => {
|
||||
const file = explorerFileFromReadResult(
|
||||
textRead(new Uint8Array([0xef, 0xbb, 0xbf, 0x68, 0x69])),
|
||||
);
|
||||
|
||||
expect(file).toMatchObject({ content: "hi", hasBom: true });
|
||||
});
|
||||
|
||||
it("does not mark BOM-free text or non-leading U+FEFF as BOM files", () => {
|
||||
const plain = explorerFileFromReadResult(textRead(new TextEncoder().encode("hi")));
|
||||
const embedded = explorerFileFromReadResult(
|
||||
textRead(new Uint8Array([0x68, 0x69, 0xef, 0xbb, 0xbf])),
|
||||
);
|
||||
|
||||
expect(plain.hasBom).toBe(false);
|
||||
expect(embedded.hasBom).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -8,8 +8,13 @@ export function explorerFileFromReadResult(file: FileReadResult): ExplorerFile {
|
||||
kind: file.kind,
|
||||
encoding: isText ? "utf-8" : "none",
|
||||
content: isText ? new TextDecoder().decode(file.bytes) : undefined,
|
||||
hasBom: isText && hasUtf8Bom(file.bytes),
|
||||
mimeType: file.mime,
|
||||
size: file.size,
|
||||
modifiedAt: file.modifiedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function hasUtf8Bom(bytes: Uint8Array): boolean {
|
||||
return bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf;
|
||||
}
|
||||
|
||||
@@ -77,8 +77,17 @@ function ready(
|
||||
return { status: "ready", cwd: "/workspace", path: "file.ts", size, modifiedAt };
|
||||
}
|
||||
|
||||
function makeModel() {
|
||||
const file = { content: "one", version: ready() as Extract<FileVersion, { status: "ready" }> };
|
||||
interface MakeModelInput {
|
||||
content?: string;
|
||||
hasBom?: boolean;
|
||||
}
|
||||
|
||||
function makeModel(input: MakeModelInput = {}) {
|
||||
const file = {
|
||||
content: input.content ?? "one",
|
||||
hasBom: input.hasBom ?? false,
|
||||
version: ready() as Extract<FileVersion, { status: "ready" }>,
|
||||
};
|
||||
const session = new FileSession(file);
|
||||
const clock = new TestClock();
|
||||
return { model: new FileEditorModel({ file, session, clock }), session, clock };
|
||||
@@ -142,10 +151,55 @@ describe("FileEditorModel", () => {
|
||||
expect(model.getSnapshot().status).toBe("clean");
|
||||
});
|
||||
|
||||
test("keeps CRLF content in file form", async () => {
|
||||
const { model, session } = makeModel({ content: "one\r\ntwo\r\n" });
|
||||
|
||||
expect(model.getSnapshot()).toMatchObject({
|
||||
content: "one\r\ntwo\r\n",
|
||||
lineSeparator: "\r\n",
|
||||
});
|
||||
model.edit("one\r\ntwo\r\nthree\r\n");
|
||||
await model.save();
|
||||
|
||||
expect(session.writes).toEqual([
|
||||
{
|
||||
content: "one\r\ntwo\r\nthree\r\n",
|
||||
expectedModifiedAt: "2026-07-18T00:00:00.000Z",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("restores a UTF-8 BOM before writing a CRLF file", async () => {
|
||||
const { model, session } = makeModel({ content: "one\r\n", hasBom: true });
|
||||
|
||||
model.edit("saved\r\n");
|
||||
await model.save();
|
||||
model.edit("saved again\r\n");
|
||||
await model.save();
|
||||
|
||||
expect(session.writes).toEqual([
|
||||
{
|
||||
content: "\uFEFFsaved\r\n",
|
||||
expectedModifiedAt: "2026-07-18T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
content: "\uFEFFsaved again\r\n",
|
||||
expectedModifiedAt: "2026-07-18T00:00:01.000Z",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("uses the first line separator when a file mixes styles", () => {
|
||||
const { model } = makeModel({ content: "one\r\ntwo\nthree\r" });
|
||||
|
||||
expect(model.getSnapshot().lineSeparator).toBe("\r\n");
|
||||
});
|
||||
|
||||
test("reloads a clean editor when the disk version changes", async () => {
|
||||
const { model, session } = makeModel();
|
||||
session.file = {
|
||||
content: "external",
|
||||
hasBom: false,
|
||||
version: ready("2026-07-18T00:00:02.000Z", 8) as Extract<FileVersion, { status: "ready" }>,
|
||||
};
|
||||
|
||||
@@ -155,6 +209,26 @@ describe("FileEditorModel", () => {
|
||||
expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "external" });
|
||||
});
|
||||
|
||||
test("adopts the format from a clean remote refresh", async () => {
|
||||
const { model, session } = makeModel({ content: "local\r\n", hasBom: true });
|
||||
session.file = {
|
||||
content: "remote\n",
|
||||
hasBom: false,
|
||||
version: ready("2026-07-18T00:00:02.000Z", 7) as Extract<FileVersion, { status: "ready" }>,
|
||||
};
|
||||
|
||||
model.receiveFileVersion(session.file.version);
|
||||
await Promise.resolve();
|
||||
expect(model.getSnapshot().lineSeparator).toBe("\n");
|
||||
model.edit("saved\n");
|
||||
await model.save();
|
||||
|
||||
expect(session.writes.at(-1)).toEqual({
|
||||
content: "saved\n",
|
||||
expectedModifiedAt: "2026-07-18T00:00:02.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
test("coalesces consecutive clean disk updates onto the latest reload", async () => {
|
||||
const { model, session } = makeModel();
|
||||
const reads: Array<(file: FileEditorFile) => void> = [];
|
||||
@@ -164,9 +238,9 @@ describe("FileEditorModel", () => {
|
||||
|
||||
model.receiveFileVersion(firstVersion);
|
||||
model.receiveFileVersion(latestVersion);
|
||||
reads[0]?.({ content: "first", version: firstVersion });
|
||||
reads[0]?.({ content: "first", hasBom: false, version: firstVersion });
|
||||
await Promise.resolve();
|
||||
reads[1]?.({ content: "latest", version: latestVersion });
|
||||
reads[1]?.({ content: "latest", hasBom: false, version: latestVersion });
|
||||
await Promise.resolve();
|
||||
|
||||
expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "latest" });
|
||||
@@ -186,6 +260,21 @@ describe("FileEditorModel", () => {
|
||||
expect(model.getSnapshot().status).toBe("clean");
|
||||
});
|
||||
|
||||
test("keeps the local CRLF and BOM when overwriting a conflict", async () => {
|
||||
const { model, session } = makeModel({ content: "one\r\n", hasBom: true });
|
||||
model.edit("local\r\n");
|
||||
model.receiveFileVersion(ready("2026-07-18T00:00:02.000Z", 4));
|
||||
|
||||
await model.overwrite();
|
||||
|
||||
expect(session.writes).toEqual([
|
||||
{
|
||||
content: "\uFEFFlocal\r\n",
|
||||
expectedModifiedAt: "2026-07-18T00:00:02.000Z",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("reload discards a conflicted local buffer for the disk contents", async () => {
|
||||
const { model, session } = makeModel();
|
||||
model.edit("local");
|
||||
@@ -193,7 +282,7 @@ describe("FileEditorModel", () => {
|
||||
FileVersion,
|
||||
{ status: "ready" }
|
||||
>;
|
||||
session.file = { content: "disk", version: diskVersion };
|
||||
session.file = { content: "disk", hasBom: false, version: diskVersion };
|
||||
model.receiveFileVersion(diskVersion);
|
||||
|
||||
await model.reload();
|
||||
@@ -201,6 +290,26 @@ describe("FileEditorModel", () => {
|
||||
expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "disk" });
|
||||
});
|
||||
|
||||
test("adopts the remote format when reloading a conflict", async () => {
|
||||
const { model, session } = makeModel({ content: "one\r\n", hasBom: true });
|
||||
model.edit("local\r\n");
|
||||
const diskVersion = ready("2026-07-18T00:00:02.000Z", 5) as Extract<
|
||||
FileVersion,
|
||||
{ status: "ready" }
|
||||
>;
|
||||
session.file = { content: "disk\n", hasBom: false, version: diskVersion };
|
||||
model.receiveFileVersion(diskVersion);
|
||||
|
||||
await model.reload();
|
||||
model.edit("saved\n");
|
||||
await model.save();
|
||||
|
||||
expect(session.writes.at(-1)).toEqual({
|
||||
content: "saved\n",
|
||||
expectedModifiedAt: "2026-07-18T00:00:02.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
test("reports failed saves without losing the local buffer", async () => {
|
||||
const { model, session } = makeModel();
|
||||
session.nextWrite = new Error("disk full");
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { FileVersion, FileWriteResult } from "@getpaseo/protocol/messages";
|
||||
|
||||
export type FileEditorStatus = "loading" | "clean" | "dirty" | "saving" | "conflict" | "error";
|
||||
export type FileLineSeparator = "\n" | "\r\n" | "\r";
|
||||
|
||||
export interface FileEditorSnapshot {
|
||||
status: FileEditorStatus;
|
||||
content: string;
|
||||
lineSeparator: FileLineSeparator;
|
||||
modified: boolean;
|
||||
version: FileVersion;
|
||||
observedVersion: FileVersion;
|
||||
@@ -13,6 +15,7 @@ export interface FileEditorSnapshot {
|
||||
|
||||
export interface FileEditorFile {
|
||||
content: string;
|
||||
hasBom: boolean;
|
||||
version: Extract<FileVersion, { status: "ready" }>;
|
||||
}
|
||||
|
||||
@@ -49,6 +52,7 @@ export class FileEditorModel {
|
||||
private disposed = false;
|
||||
private observedWhileSaving: FileVersion | null = null;
|
||||
private persistedContent: string;
|
||||
private hasBom: boolean;
|
||||
|
||||
constructor(input: {
|
||||
file: FileEditorFile;
|
||||
@@ -58,9 +62,11 @@ export class FileEditorModel {
|
||||
this.session = input.session;
|
||||
this.clock = input.clock ?? systemClock;
|
||||
this.persistedContent = input.file.content;
|
||||
this.hasBom = input.file.hasBom;
|
||||
this.snapshot = {
|
||||
status: "clean",
|
||||
content: input.file.content,
|
||||
lineSeparator: detectLineSeparator(input.file.content),
|
||||
modified: false,
|
||||
version: input.file.version,
|
||||
observedVersion: input.file.version,
|
||||
@@ -167,10 +173,11 @@ export class FileEditorModel {
|
||||
const content = this.snapshot.content;
|
||||
this.observedWhileSaving = null;
|
||||
this.setSnapshot({ ...this.snapshot, status: "saving", error: null });
|
||||
const serializedContent = this.hasBom ? `\uFEFF${content}` : content;
|
||||
let result: FileWriteResult;
|
||||
try {
|
||||
result = await this.session.write({
|
||||
content,
|
||||
content: serializedContent,
|
||||
expectedModifiedAt: expectedVersion.modifiedAt,
|
||||
expectedRevision: expectedVersion.revision,
|
||||
});
|
||||
@@ -241,9 +248,11 @@ export class FileEditorModel {
|
||||
return;
|
||||
}
|
||||
this.persistedContent = file.content;
|
||||
this.hasBom = file.hasBom;
|
||||
this.setSnapshot({
|
||||
status: "clean",
|
||||
content: file.content,
|
||||
lineSeparator: detectLineSeparator(file.content),
|
||||
modified: false,
|
||||
version: file.version,
|
||||
observedVersion: file.version,
|
||||
@@ -290,6 +299,15 @@ export class FileEditorModel {
|
||||
}
|
||||
}
|
||||
|
||||
function detectLineSeparator(content: string): FileLineSeparator {
|
||||
for (let index = 0; index < content.length; index += 1) {
|
||||
const character = content.charCodeAt(index);
|
||||
if (character === 10) return "\n";
|
||||
if (character === 13) return content.charCodeAt(index + 1) === 10 ? "\r\n" : "\r";
|
||||
}
|
||||
return "\n";
|
||||
}
|
||||
|
||||
function sameVersion(left: FileVersion, right: FileVersion): boolean {
|
||||
if (left.status !== right.status || left.cwd !== right.cwd || left.path !== right.path)
|
||||
return false;
|
||||
|
||||
@@ -56,7 +56,8 @@ export function FileEditorView({
|
||||
update.docChanged &&
|
||||
!update.transactions.some((tr) => tr.annotation(remoteUpdate))
|
||||
) {
|
||||
values.model.edit(update.state.doc.toString());
|
||||
const { lineSeparator } = values.model.getSnapshot();
|
||||
values.model.edit(update.state.doc.sliceString(0, undefined, lineSeparator));
|
||||
}
|
||||
if (update.selectionSet || update.docChanged) {
|
||||
const head = update.state.selection.main.head;
|
||||
@@ -77,10 +78,12 @@ export function FileEditorView({
|
||||
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view || view.state.doc.toString() === snapshot.content) return;
|
||||
const head = Math.min(view.state.selection.main.head, snapshot.content.length);
|
||||
if (!view) return;
|
||||
const document = view.state.toText(snapshot.content);
|
||||
if (view.state.doc.eq(document)) return;
|
||||
const head = Math.min(view.state.selection.main.head, document.length);
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: snapshot.content },
|
||||
changes: { from: 0, to: view.state.doc.length, insert: document },
|
||||
selection: { anchor: head },
|
||||
annotations: [remoteUpdate.of(true), Transaction.addToHistory.of(false)],
|
||||
});
|
||||
|
||||
@@ -459,6 +459,7 @@ export function FilePane({
|
||||
preview,
|
||||
supportsEditing,
|
||||
});
|
||||
const canToggleMarkdownMode = isMarkdown && editable;
|
||||
const lineCount =
|
||||
preview?.kind === "text" ? (preview.content ?? "").split("\n").length : undefined;
|
||||
const errorMessage = getFileErrorMessage(query.error, t("panels.file.failedToLoad"));
|
||||
@@ -471,8 +472,8 @@ export function FilePane({
|
||||
preview={preview}
|
||||
version={version}
|
||||
filename={getFileNameFromPath(location.path) ?? location.path}
|
||||
markdownMode={isMarkdown ? markdownMode : undefined}
|
||||
onMarkdownModeChange={isMarkdown ? setMarkdownMode : undefined}
|
||||
markdownMode={canToggleMarkdownMode ? markdownMode : undefined}
|
||||
onMarkdownModeChange={canToggleMarkdownMode ? setMarkdownMode : undefined}
|
||||
lineCount={lineCount}
|
||||
editable={editable}
|
||||
disconnectedMessage={t("workspace.terminal.hostDisconnected")}
|
||||
@@ -637,9 +638,13 @@ function EditableFilePane({
|
||||
() => ({
|
||||
async read(): Promise<FileEditorFile> {
|
||||
const file = await client.readFile(cwd, path);
|
||||
if (file.kind !== "text") throw new Error("File is no longer text.");
|
||||
const decodedFile = explorerFileFromReadResult(file);
|
||||
if (decodedFile.kind !== "text" || decodedFile.content === undefined) {
|
||||
throw new Error("File is no longer text.");
|
||||
}
|
||||
return {
|
||||
content: new TextDecoder().decode(file.bytes),
|
||||
content: decodedFile.content,
|
||||
hasBom: decodedFile.hasBom,
|
||||
version: {
|
||||
status: "ready",
|
||||
cwd,
|
||||
@@ -661,6 +666,7 @@ function EditableFilePane({
|
||||
new FileEditorModel({
|
||||
file: {
|
||||
content: preview.content ?? "",
|
||||
hasBom: preview.hasBom,
|
||||
version: {
|
||||
status: "ready",
|
||||
cwd,
|
||||
|
||||
79
packages/app/src/git/commits-section/commit-graph-node.tsx
Normal file
79
packages/app/src/git/commits-section/commit-graph-node.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { ClassifiedCheckoutCommit } from "@/git/use-commits-query";
|
||||
|
||||
interface CommitGraphNodeProps {
|
||||
commit: ClassifiedCheckoutCommit;
|
||||
isFirst: boolean;
|
||||
isLast: boolean;
|
||||
}
|
||||
|
||||
export function CommitGraphNode({ commit, isFirst, isLast }: CommitGraphNodeProps) {
|
||||
const isOnBase = commit.isOnBase;
|
||||
const railColor = isOnBase ? styles.railBase : styles.railWorkspace;
|
||||
const markerColor = isOnBase ? styles.markerBase : styles.markerWorkspace;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{isFirst && isLast ? null : (
|
||||
<View
|
||||
style={[styles.rail, railColor, isFirst && styles.railFirst, isLast && styles.railLast]}
|
||||
/>
|
||||
)}
|
||||
<View
|
||||
testID={commit.isOnRemote ? "commit-dot-remote" : "commit-dot-local"}
|
||||
style={[styles.marker, markerColor, !commit.isOnRemote && styles.markerRing]}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const MARKER_SIZE = 8;
|
||||
const RAIL_WIDTH = 2;
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
width: MARKER_SIZE,
|
||||
alignSelf: "stretch",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
position: "relative",
|
||||
flexShrink: 0,
|
||||
},
|
||||
rail: {
|
||||
position: "absolute",
|
||||
top: -theme.spacing[1] - 1,
|
||||
bottom: -theme.spacing[1] - 1,
|
||||
width: RAIL_WIDTH,
|
||||
},
|
||||
railFirst: {
|
||||
top: "50%",
|
||||
},
|
||||
railLast: {
|
||||
bottom: "50%",
|
||||
},
|
||||
railBase: {
|
||||
backgroundColor: theme.colors.foregroundMuted,
|
||||
},
|
||||
railWorkspace: {
|
||||
backgroundColor: theme.colors.accent,
|
||||
},
|
||||
marker: {
|
||||
width: MARKER_SIZE,
|
||||
height: MARKER_SIZE,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
borderWidth: theme.borderWidth[2],
|
||||
zIndex: 1,
|
||||
},
|
||||
markerBase: {
|
||||
backgroundColor: theme.colors.foregroundMuted,
|
||||
borderColor: theme.colors.foregroundMuted,
|
||||
},
|
||||
markerWorkspace: {
|
||||
backgroundColor: theme.colors.accent,
|
||||
borderColor: theme.colors.accent,
|
||||
},
|
||||
markerRing: {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
}));
|
||||
@@ -1,18 +1,34 @@
|
||||
import { memo, useCallback } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { CheckoutCommit } from "@getpaseo/protocol/messages";
|
||||
import { ThemedChevron, chevronColorMapping } from "@/git/themed-chevron";
|
||||
import type { ClassifiedCheckoutCommit } from "@/git/use-commits-query";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { dotStyles } from "./shared";
|
||||
import { CommitGraphNode } from "./commit-graph-node";
|
||||
|
||||
interface CommitRowProps {
|
||||
commit: CheckoutCommit;
|
||||
commit: ClassifiedCheckoutCommit;
|
||||
isFirst: boolean;
|
||||
isLast: boolean;
|
||||
now: Date;
|
||||
onCommitPress: (sha: string) => void;
|
||||
}
|
||||
|
||||
export const CommitRow = memo(function CommitRow({ commit, now, onCommitPress }: CommitRowProps) {
|
||||
function commitRowPressableStyle({
|
||||
hovered,
|
||||
pressed,
|
||||
}: PressableStateCallbackType & { hovered?: boolean }) {
|
||||
return [styles.row, (Boolean(hovered) || pressed) && styles.rowActive];
|
||||
}
|
||||
|
||||
export const CommitRow = memo(function CommitRow({
|
||||
commit,
|
||||
isFirst,
|
||||
isLast,
|
||||
now,
|
||||
onCommitPress,
|
||||
}: CommitRowProps) {
|
||||
const handlePress = useCallback(() => {
|
||||
onCommitPress(commit.sha);
|
||||
}, [commit.sha, onCommitPress]);
|
||||
@@ -22,16 +38,17 @@ export const CommitRow = memo(function CommitRow({ commit, now, onCommitPress }:
|
||||
accessibilityRole="button"
|
||||
testID={`commit-row-${commit.shortSha}`}
|
||||
onPress={handlePress}
|
||||
style={styles.row}
|
||||
style={commitRowPressableStyle}
|
||||
>
|
||||
<View
|
||||
testID={commit.isOnRemote ? "commit-dot-remote" : "commit-dot-local"}
|
||||
style={commit.isOnRemote ? dotStyles.dotRemote : dotStyles.dotLocal}
|
||||
/>
|
||||
<Text style={styles.shortSha}>{commit.shortSha}</Text>
|
||||
<Text style={styles.subject} numberOfLines={1}>
|
||||
{commit.subject}
|
||||
</Text>
|
||||
<CommitGraphNode commit={commit} isFirst={isFirst} isLast={isLast} />
|
||||
<View style={styles.commitDetails}>
|
||||
<Text dataSet={CODE_SURFACE_DATASET} style={styles.shortSha} numberOfLines={1}>
|
||||
{commit.shortSha}
|
||||
</Text>
|
||||
<Text style={styles.subject} numberOfLines={1}>
|
||||
{commit.subject}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.timestamp}>{formatTimeAgo(new Date(commit.authorDate), now)}</Text>
|
||||
<View style={styles.caret}>
|
||||
<ThemedChevron size={14} uniProps={chevronColorMapping} />
|
||||
@@ -49,10 +66,21 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingRight: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1],
|
||||
},
|
||||
rowActive: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
commitDetails: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
shortSha: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
color: theme.colors.foregroundMuted,
|
||||
width: 70,
|
||||
flexShrink: 0,
|
||||
},
|
||||
subject: {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
|
||||
import { useCheckoutCommitsQuery, type CheckoutCommitsQueryResult } from "@/git/use-commits-query";
|
||||
import { ThemedChevron, chevronColorMapping } from "@/git/themed-chevron";
|
||||
import { normalizeBranchOptionName } from "@/utils/branch-suggestions";
|
||||
import { CommitRow } from "./commit-row";
|
||||
|
||||
interface CommitsSectionProps {
|
||||
@@ -14,8 +15,6 @@ interface CommitsSectionProps {
|
||||
onCommitPress: (sha: string) => void;
|
||||
}
|
||||
|
||||
const SKELETON_ROW_KEYS = ["commit-skeleton-1", "commit-skeleton-2", "commit-skeleton-3"];
|
||||
|
||||
function CommitsSectionSkeleton() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
@@ -25,15 +24,13 @@ function CommitsSectionSkeleton() {
|
||||
style={styles.skeleton}
|
||||
testID="commits-section-skeleton"
|
||||
>
|
||||
{SKELETON_ROW_KEYS.map((key) => (
|
||||
<View key={key} style={styles.skeletonRow}>
|
||||
<View style={styles.skeletonDot} />
|
||||
<View style={styles.skeletonSha} />
|
||||
<View style={styles.skeletonSubject} />
|
||||
<View style={styles.skeletonTimestamp} />
|
||||
<View style={styles.skeletonCaret} />
|
||||
</View>
|
||||
))}
|
||||
<View style={styles.skeletonRow}>
|
||||
<View style={styles.skeletonDot} />
|
||||
<View style={styles.skeletonSha} />
|
||||
<View style={styles.skeletonSubject} />
|
||||
<View style={styles.skeletonTimestamp} />
|
||||
<View style={styles.skeletonCaret} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -58,17 +55,28 @@ function CommitsSectionContent({
|
||||
if (query.status !== "loaded") {
|
||||
return <CommitsSectionSkeleton />;
|
||||
}
|
||||
if (query.data.commits.length === 0) {
|
||||
const workspaceCommits = query.data.commits.filter((commit) => !commit.isOnBase);
|
||||
const baseRef = normalizeBranchOptionName(query.data.baseRef) ?? t("workspace.git.diff.base");
|
||||
if (workspaceCommits.length === 0) {
|
||||
return (
|
||||
<Text style={styles.emptyRow} testID="commits-section-empty">
|
||||
{t("workspace.git.diff.commits.empty")}
|
||||
</Text>
|
||||
<View style={styles.noWorkspaceCommitsRow} testID="commits-section-no-workspace-commits">
|
||||
<Text style={styles.noWorkspaceCommitsText}>
|
||||
{t("workspace.git.diff.commits.noneAhead", { baseRef })}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View style={styles.list}>
|
||||
{query.data.commits.map((commit) => (
|
||||
<CommitRow key={commit.sha} commit={commit} now={now} onCommitPress={onCommitPress} />
|
||||
{workspaceCommits.map((commit, index) => (
|
||||
<CommitRow
|
||||
key={commit.sha}
|
||||
commit={commit}
|
||||
isFirst={index === 0}
|
||||
isLast={index === workspaceCommits.length - 1}
|
||||
now={now}
|
||||
onCommitPress={onCommitPress}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
@@ -110,7 +118,10 @@ export function CommitsSection({ serverId, cwd, onCommitPress }: CommitsSectionP
|
||||
if (query.status === "unsupported") {
|
||||
return null;
|
||||
}
|
||||
const commitCount = query.status === "loaded" ? query.data.commits.length : null;
|
||||
const commitCount =
|
||||
query.status === "loaded"
|
||||
? query.data.commits.filter((commit) => !commit.isOnBase).length
|
||||
: null;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
@@ -183,12 +194,17 @@ const styles = StyleSheet.create((theme) => ({
|
||||
list: {
|
||||
paddingBottom: theme.spacing[1],
|
||||
},
|
||||
emptyRow: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
noWorkspaceCommitsRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingLeft: theme.spacing[2],
|
||||
paddingRight: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingTop: theme.spacing[1],
|
||||
paddingBottom: theme.spacing[2],
|
||||
},
|
||||
noWorkspaceCommitsText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
errorRow: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { CheckoutCommit } from "@getpaseo/protocol/messages";
|
||||
|
||||
export type CheckoutCommitFile = CheckoutCommit["files"][number];
|
||||
|
||||
export type FilePressHandler = (commit: CheckoutCommit, file: CheckoutCommitFile) => void;
|
||||
|
||||
export const DOT_SIZE = 8;
|
||||
|
||||
// The "on remote" dot is intentionally understated: the local-only ring is the
|
||||
// state worth noticing (you still have work to push), so the remote fill is
|
||||
// dimmed toward the background rather than rendered at full-strength green.
|
||||
const REMOTE_DOT_OPACITY = 0.55;
|
||||
|
||||
/**
|
||||
* A local-only commit is a hollow ring; a commit that has reached the remote
|
||||
* is a subtle (dimmed) filled green dot.
|
||||
*/
|
||||
export const dotStyles = StyleSheet.create((theme) => ({
|
||||
dotLocal: {
|
||||
width: DOT_SIZE,
|
||||
height: DOT_SIZE,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: "transparent",
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.foregroundMuted,
|
||||
flexShrink: 0,
|
||||
},
|
||||
dotRemote: {
|
||||
width: DOT_SIZE,
|
||||
height: DOT_SIZE,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.statusSuccess,
|
||||
opacity: REMOTE_DOT_OPACITY,
|
||||
flexShrink: 0,
|
||||
},
|
||||
}));
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CheckoutCommit } from "@getpaseo/protocol/messages";
|
||||
import invariant from "tiny-invariant";
|
||||
import { useFetchQuery } from "@/data/query";
|
||||
import { checkoutCommitsQueryKey } from "@/git/query-keys";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
@@ -16,7 +17,11 @@ interface UseCheckoutCommitsQueryOptions {
|
||||
|
||||
export interface CheckoutCommitsData {
|
||||
baseRef: string | null;
|
||||
commits: CheckoutCommit[];
|
||||
commits: ClassifiedCheckoutCommit[];
|
||||
}
|
||||
|
||||
export interface ClassifiedCheckoutCommit extends CheckoutCommit {
|
||||
isOnBase: boolean;
|
||||
}
|
||||
|
||||
export type CheckoutCommitsQueryResult =
|
||||
@@ -69,10 +74,13 @@ export function useCheckoutCommitsQuery({
|
||||
}: UseCheckoutCommitsQueryOptions): CheckoutCommitsQueryResult {
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
// COMPAT(commitsList): added in v0.1.110, remove gate after 2027-01-16.
|
||||
// COMPAT(commitsList): added in v0.1.110, remove after 2027-01-16.
|
||||
// COMPAT(commitBaseClassification): added in v0.2.0, remove after 2027-01-23.
|
||||
// Single capability-detection site; downstream reads a clean load-state union.
|
||||
const capabilityPresent = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.serverInfo?.features?.commitsList === true,
|
||||
(state) =>
|
||||
state.sessions[serverId]?.serverInfo?.features?.commitsList === true &&
|
||||
state.sessions[serverId]?.serverInfo?.features?.commitBaseClassification === true,
|
||||
);
|
||||
|
||||
const canFetch = Boolean(cwd) && Boolean(client) && isConnected;
|
||||
@@ -84,7 +92,12 @@ export function useCheckoutCommitsQuery({
|
||||
if (!client) {
|
||||
throw new Error("Host disconnected");
|
||||
}
|
||||
return client.listCheckoutCommits(cwd);
|
||||
const data = await client.listCheckoutCommits(cwd);
|
||||
const commits = data.commits.map((commit) => {
|
||||
invariant(commit.isOnBase !== undefined, "Host omitted commit base classification");
|
||||
return { ...commit, isOnBase: commit.isOnBase };
|
||||
});
|
||||
return { baseRef: data.baseRef, commits };
|
||||
},
|
||||
enabled: queryEnabled,
|
||||
staleTimeMs: CHECKOUT_COMMITS_STALE_TIME,
|
||||
|
||||
@@ -308,7 +308,9 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
const modelSelectorProviders = snapshotModelSelectorProviders;
|
||||
const availableModels = snapshotSelectedProviderModels;
|
||||
const modeOptions = snapshotSelectedProviderModes;
|
||||
const isAllModelsLoading = snapshotIsLoading || selectedProviderIsLoading;
|
||||
const isModelSelectionLoading =
|
||||
resolution.status === "pending" || snapshotIsLoading || selectedProviderIsLoading;
|
||||
const isAllModelsLoading = isModelSelectionLoading;
|
||||
|
||||
const combinedInitialValues = useMemo(
|
||||
() => combineInitialValues(initialValues, initialServerId),
|
||||
@@ -567,7 +569,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
() => availableThinkingOptionsRaw ?? [],
|
||||
[availableThinkingOptionsRaw],
|
||||
);
|
||||
const isModelLoading = snapshotIsLoading || selectedProviderIsLoading;
|
||||
const isModelLoading = isModelSelectionLoading;
|
||||
const modelError = snapshotError;
|
||||
|
||||
const workingDirIsEmpty = !formState.workingDir.trim();
|
||||
|
||||
@@ -9,7 +9,10 @@ import {
|
||||
import { Platform } from "react-native";
|
||||
import type { ViewStyle } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
|
||||
import {
|
||||
useGenericKeyboardHandler,
|
||||
useReanimatedKeyboardAnimation,
|
||||
} from "react-native-keyboard-controller";
|
||||
import {
|
||||
useAnimatedStyle,
|
||||
useDerivedValue,
|
||||
@@ -40,6 +43,19 @@ export function KeyboardShiftProvider({ children }: { children: ReactNode }) {
|
||||
bottomInset.value = insets.bottom;
|
||||
}, [bottomInset, insets.bottom]);
|
||||
|
||||
useGenericKeyboardHandler(
|
||||
{
|
||||
onEnd: (event) => {
|
||||
"worklet";
|
||||
if (isIos) {
|
||||
keyboardHeight.value = -event.height;
|
||||
keyboardProgress.value = event.progress;
|
||||
}
|
||||
},
|
||||
},
|
||||
[isIos, keyboardHeight, keyboardProgress],
|
||||
);
|
||||
|
||||
const shift = useDerivedValue(() => {
|
||||
"worklet";
|
||||
return resolveKeyboardShift({
|
||||
|
||||
@@ -795,7 +795,8 @@ export const ar: TranslationResources = {
|
||||
deletedFile: "تم الحذف",
|
||||
commits: {
|
||||
title: "الإيداعات",
|
||||
countLabel: "{{count}} من الإيداعات الأخيرة",
|
||||
countLabel: "{{count}} من إيداعات مساحة العمل",
|
||||
noneAhead: "لا توجد إيداعات متقدمة على {{baseRef}} بعد",
|
||||
fileDiffEmpty: "لا توجد تغييرات لعرضها",
|
||||
fileDiffError: "تعذّر تحميل فروق الملف",
|
||||
loading: "جارٍ تحميل الإيداعات…",
|
||||
|
||||
@@ -805,7 +805,8 @@ export const en = {
|
||||
deletedFile: "Deleted",
|
||||
commits: {
|
||||
title: "Commits",
|
||||
countLabel: "{{count}} recent commits",
|
||||
countLabel: "{{count}} workspace commits",
|
||||
noneAhead: "No commits ahead of {{baseRef}} yet",
|
||||
fileDiffEmpty: "No changes to display",
|
||||
fileDiffError: "Failed to load file diff",
|
||||
loading: "Loading commits…",
|
||||
|
||||
@@ -826,7 +826,8 @@ export const es: TranslationResources = {
|
||||
deletedFile: "Eliminado",
|
||||
commits: {
|
||||
title: "Commits",
|
||||
countLabel: "{{count}} commits recientes",
|
||||
countLabel: "{{count}} commits del espacio de trabajo",
|
||||
noneAhead: "Aún no hay commits por delante de {{baseRef}}",
|
||||
fileDiffEmpty: "No hay cambios para mostrar",
|
||||
fileDiffError: "Error al cargar el diff del archivo",
|
||||
loading: "Cargando commits…",
|
||||
|
||||
@@ -825,7 +825,8 @@ export const fr: TranslationResources = {
|
||||
deletedFile: "Supprimé",
|
||||
commits: {
|
||||
title: "Commits",
|
||||
countLabel: "{{count}} commits récents",
|
||||
countLabel: "{{count}} commits de l’espace de travail",
|
||||
noneAhead: "Aucun commit en avance sur {{baseRef}} pour le moment",
|
||||
fileDiffEmpty: "Aucune modification à afficher",
|
||||
fileDiffError: "Échec du chargement du diff du fichier",
|
||||
loading: "Chargement des commits…",
|
||||
|
||||
@@ -806,7 +806,8 @@ export const ja: TranslationResources = {
|
||||
deletedFile: "削除済み",
|
||||
commits: {
|
||||
title: "コミット",
|
||||
countLabel: "最近のコミット数: {{count}}",
|
||||
countLabel: "ワークスペースのコミット数: {{count}}",
|
||||
noneAhead: "{{baseRef}} より先のコミットはまだありません",
|
||||
fileDiffEmpty: "表示する変更はありません",
|
||||
fileDiffError: "ファイル差分の読み込みに失敗しました",
|
||||
loading: "コミットを読み込み中…",
|
||||
|
||||
@@ -817,7 +817,8 @@ export const ptBR: TranslationResources = {
|
||||
deletedFile: "Excluído",
|
||||
commits: {
|
||||
title: "Commits",
|
||||
countLabel: "{{count}} commits recentes",
|
||||
countLabel: "{{count}} commits do espaço de trabalho",
|
||||
noneAhead: "Ainda não há commits à frente de {{baseRef}}",
|
||||
fileDiffEmpty: "Nenhuma alteração para exibir",
|
||||
fileDiffError: "Falha ao carregar diff do arquivo",
|
||||
loading: "Carregando commits…",
|
||||
|
||||
@@ -817,7 +817,8 @@ export const ru: TranslationResources = {
|
||||
deletedFile: "Удалено",
|
||||
commits: {
|
||||
title: "Коммиты",
|
||||
countLabel: "{{count}} последних коммитов",
|
||||
countLabel: "{{count}} коммитов рабочего пространства",
|
||||
noneAhead: "Коммитов впереди {{baseRef}} пока нет",
|
||||
fileDiffEmpty: "Нет изменений для отображения",
|
||||
fileDiffError: "Не удалось загрузить различия файла",
|
||||
loading: "Загрузка коммитов…",
|
||||
|
||||
@@ -787,7 +787,8 @@ export const zhCN: TranslationResources = {
|
||||
deletedFile: "已删除",
|
||||
commits: {
|
||||
title: "提交",
|
||||
countLabel: "最近 {{count}} 个提交",
|
||||
countLabel: "{{count}} 个工作区提交",
|
||||
noneAhead: "尚无领先于 {{baseRef}} 的提交",
|
||||
fileDiffEmpty: "没有可显示的更改",
|
||||
fileDiffError: "加载文件差异失败",
|
||||
loading: "正在加载提交…",
|
||||
|
||||
@@ -24,7 +24,6 @@ import { FileDropZone } from "@/components/file-drop/file-drop-zone";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { SidebarCallout } from "@/components/sidebar-callout";
|
||||
import { Composer } from "@/composer";
|
||||
import { AgentModeControl } from "@/composer/agent-controls/mode-control";
|
||||
import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import {
|
||||
@@ -1529,19 +1528,6 @@ function ActiveAgentComposer({
|
||||
[insets.bottom, composerKeyboardStyle],
|
||||
);
|
||||
|
||||
const composerFooter = useMemo(
|
||||
() =>
|
||||
isCompactComposerLayout ? (
|
||||
<AgentModeControl
|
||||
serverId={serverId}
|
||||
agentId={agentId}
|
||||
placement="footer"
|
||||
isCompactLayout={isCompactComposerLayout}
|
||||
/>
|
||||
) : undefined,
|
||||
[isCompactComposerLayout, serverId, agentId],
|
||||
);
|
||||
|
||||
return (
|
||||
<ReanimatedAnimated.View style={inputAreaStyle} onLayout={onInputAreaLayout}>
|
||||
<SubagentsTrack
|
||||
@@ -1574,7 +1560,6 @@ function ActiveAgentComposer({
|
||||
onComposerHeightChange={onComposerHeightChange}
|
||||
onMessageSent={onMessageSent}
|
||||
onClientSlashCommand={handleClientSlashCommand}
|
||||
footer={composerFooter}
|
||||
isCompactLayout={isCompactComposerLayout}
|
||||
/>
|
||||
</ReanimatedAnimated.View>
|
||||
|
||||
@@ -260,6 +260,25 @@ describe("combined model selector data", () => {
|
||||
).toBe("Default");
|
||||
});
|
||||
|
||||
it("distinguishes a loading selection from a resolved empty selection", () => {
|
||||
expect(
|
||||
resolveSelectedModelLabel({
|
||||
providers: [],
|
||||
selectedProvider: "",
|
||||
selectedModel: "",
|
||||
isLoading: true,
|
||||
}),
|
||||
).toBe("Loading...");
|
||||
expect(
|
||||
resolveSelectedModelLabel({
|
||||
providers: [],
|
||||
selectedProvider: "",
|
||||
selectedModel: "",
|
||||
isLoading: false,
|
||||
}),
|
||||
).toBe("Select model");
|
||||
});
|
||||
|
||||
it("keeps a stored selected model visible when current snapshot rows no longer offer it", () => {
|
||||
const providers = buildSelectableProviderSelectorProviders([
|
||||
snapshotEntry({
|
||||
|
||||
@@ -157,7 +157,9 @@ export function resolveSelectedModelLabel(input: {
|
||||
}): string {
|
||||
const selectedProvider = input.selectedProvider.trim();
|
||||
if (!selectedProvider) {
|
||||
return i18n.t("providerSelection.selectModel");
|
||||
return input.isLoading
|
||||
? i18n.t("providerSelection.loading")
|
||||
: i18n.t("providerSelection.selectModel");
|
||||
}
|
||||
|
||||
const provider = input.providers.find((entry) => entry.id === selectedProvider);
|
||||
|
||||
@@ -1096,7 +1096,7 @@ describe("resolveAgentForm", () => {
|
||||
});
|
||||
|
||||
describe("RESET", () => {
|
||||
it("resets userModified flags while keeping form state", () => {
|
||||
it("keeps form values but marks them unresolved for the next open", () => {
|
||||
const state = makeState(
|
||||
{ provider: "codex", modeId: "full-access", model: "gpt-5.3-codex" },
|
||||
{ provider: true, modeId: true, model: true },
|
||||
@@ -1106,7 +1106,7 @@ describe("resolveAgentForm", () => {
|
||||
|
||||
expect(next.userModified).toEqual(INITIAL_USER_MODIFIED);
|
||||
expect(next.form).toEqual(state.form);
|
||||
expect(next.resolution.status).toBe("completed");
|
||||
expect(next.resolution.status).toBe("pending");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -56,8 +56,8 @@ export const INITIAL_USER_MODIFIED: UserModifiedFields = {
|
||||
workingDir: false,
|
||||
};
|
||||
|
||||
export const INITIAL_AGENT_FORM_RESOLUTION: AgentFormResolutionState = { status: "completed" };
|
||||
export const PENDING_AGENT_FORM_RESOLUTION: AgentFormResolutionState = { status: "pending" };
|
||||
export const INITIAL_AGENT_FORM_RESOLUTION = PENDING_AGENT_FORM_RESOLUTION;
|
||||
|
||||
type ProviderPrefs = NonNullable<FormPreferences["providerPreferences"]>[AgentProvider];
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronDown, Folder, FolderPlus, GitBranch, GitPullRequest } from "lucide-react-native";
|
||||
import { Composer } from "@/composer";
|
||||
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
|
||||
import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control";
|
||||
import {
|
||||
resolveComposerAttachmentSubmitFormat,
|
||||
splitComposerAttachmentsForSubmit,
|
||||
@@ -1382,6 +1381,7 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme
|
||||
open={project.openState}
|
||||
onOpenChange={project.onOpenChange}
|
||||
desktopPlacement="bottom-start"
|
||||
desktopMinWidth={360}
|
||||
anchorRef={project.anchorRef}
|
||||
emptyText="No projects available."
|
||||
renderOption={project.renderOption}
|
||||
@@ -1402,6 +1402,7 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme
|
||||
searchable={false}
|
||||
title="Host"
|
||||
desktopPlacement="bottom-start"
|
||||
desktopMinWidth={200}
|
||||
hostOptionTestID={newWorkspaceHostOptionTestID}
|
||||
>
|
||||
<Pressable
|
||||
@@ -2109,13 +2110,6 @@ export function NewWorkspaceScreen({
|
||||
},
|
||||
});
|
||||
|
||||
const composerFooter = useMemo(
|
||||
() =>
|
||||
agentControlsWithDisabled ? (
|
||||
<DraftAgentModeControl placement="footer" {...agentControlsWithDisabled} />
|
||||
) : null,
|
||||
[agentControlsWithDisabled],
|
||||
);
|
||||
const screenHeaderLeft = useMemo(() => <SidebarMenuToggle />, []);
|
||||
|
||||
return (
|
||||
@@ -2154,7 +2148,6 @@ export function NewWorkspaceScreen({
|
||||
autoFocus
|
||||
commandDraftConfig={composerState?.commandDraftConfig}
|
||||
agentControls={agentControlsWithDisabled}
|
||||
footer={composerFooter}
|
||||
/>
|
||||
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
||||
</ReanimatedAnimated.View>
|
||||
|
||||
@@ -44,6 +44,7 @@ const { theme, snapshotState, configState, patchConfigMock, openProviderSettings
|
||||
);
|
||||
|
||||
vi.mock("react-native", () => ({
|
||||
Platform: { OS: "web" },
|
||||
View: ({ children, testID }: { children?: React.ReactNode; testID?: string }) =>
|
||||
React.createElement("div", { "data-testid": testID }, children),
|
||||
Text: ({ children }: { children?: React.ReactNode }) =>
|
||||
@@ -90,7 +91,7 @@ vi.mock("react-native-unistyles", () => ({
|
||||
create: (factory: unknown) =>
|
||||
typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
|
||||
},
|
||||
useUnistyles: () => ({ theme }),
|
||||
useUnistyles: () => ({ theme, rt: { breakpoint: "md" } }),
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react-native", () => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useHostFeature } from "@/runtime/host-features";
|
||||
@@ -178,6 +179,7 @@ function ProviderRow({
|
||||
}: ProviderRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const ProviderIcon = getProviderIcon(def.id);
|
||||
const providerError =
|
||||
enabled &&
|
||||
@@ -229,10 +231,10 @@ function ProviderRow({
|
||||
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
|
||||
{def.label}
|
||||
</Text>
|
||||
<Text style={styles.separator}>·</Text>
|
||||
<StatusIndicator status={providerStatus} />
|
||||
{!isCompact ? <Text style={styles.separator}>·</Text> : null}
|
||||
<StatusIndicator status={providerStatus} compact={isCompact} />
|
||||
</View>
|
||||
{providerError ? (
|
||||
{providerError && !isCompact ? (
|
||||
<Text style={styles.errorText} numberOfLines={3}>
|
||||
{providerError}
|
||||
</Text>
|
||||
@@ -246,18 +248,20 @@ function ProviderRow({
|
||||
disabled={isToggling || isRemoving}
|
||||
accessibilityLabel={t("settings.providers.enableProvider", { name: def.label })}
|
||||
/>
|
||||
{canRemove ? (
|
||||
<ProviderActionsMenu
|
||||
providerId={def.id}
|
||||
providerLabel={def.label}
|
||||
isRemoving={isRemoving}
|
||||
iconSize={theme.iconSize.sm}
|
||||
foregroundColor={theme.colors.foreground}
|
||||
foregroundMutedColor={theme.colors.foregroundMuted}
|
||||
dangerColor={theme.colors.statusDanger}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
) : null}
|
||||
<View style={styles.menuSlot}>
|
||||
{canRemove ? (
|
||||
<ProviderActionsMenu
|
||||
providerId={def.id}
|
||||
providerLabel={def.label}
|
||||
isRemoving={isRemoving}
|
||||
iconSize={theme.iconSize.sm}
|
||||
foregroundColor={theme.colors.foreground}
|
||||
foregroundMutedColor={theme.colors.foregroundMuted}
|
||||
dangerColor={theme.colors.statusDanger}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
@@ -278,7 +282,7 @@ function getDotColor(tone: StatusTone, theme: ReturnType<typeof useUnistyles>["t
|
||||
}
|
||||
}
|
||||
|
||||
function StatusIndicator({ status }: { status: ProviderStatus }) {
|
||||
function StatusIndicator({ status, compact }: { status: ProviderStatus; compact: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const dotStyle = useMemo(
|
||||
@@ -293,15 +297,19 @@ function StatusIndicator({ status }: { status: ProviderStatus }) {
|
||||
) : (
|
||||
<View style={dotStyle} />
|
||||
)}
|
||||
<Text style={styles.statusLabel}>{status.label}</Text>
|
||||
{status.modelCount !== null ? (
|
||||
{!compact ? (
|
||||
<>
|
||||
<Text style={styles.separator}>·</Text>
|
||||
<Text style={styles.statusLabel}>
|
||||
{status.modelCount === 1
|
||||
? t("settings.providers.models.one")
|
||||
: t("settings.providers.models.many", { count: status.modelCount })}
|
||||
</Text>
|
||||
<Text style={styles.statusLabel}>{status.label}</Text>
|
||||
{status.modelCount !== null ? (
|
||||
<>
|
||||
<Text style={styles.separator}>·</Text>
|
||||
<Text style={styles.statusLabel}>
|
||||
{status.modelCount === 1
|
||||
? t("settings.providers.models.one")
|
||||
: t("settings.providers.models.many", { count: status.modelCount })}
|
||||
</Text>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
@@ -536,6 +544,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
menuSlot: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
},
|
||||
menuButtonHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
|
||||
@@ -3536,7 +3536,7 @@ function WorkspaceScreenContent({
|
||||
tooltipLabel={t("workspace.tabs.explorer.toggle")}
|
||||
tooltipKeys={EXPLORER_TOGGLE_KEYS}
|
||||
tooltipSide="left"
|
||||
style={styles.compactHeaderActionButton}
|
||||
style={[styles.compactHeaderActionButton, styles.explorerPanelButton]}
|
||||
accessible
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={explorerToggleLabel}
|
||||
@@ -3959,6 +3959,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
explorerPanelButton: {
|
||||
// The 32px trigger and 16px panel glyph leave 8px more trailing space than
|
||||
// the 22px split-pane trigger and its 14px glyph in the row below.
|
||||
marginRight: -theme.spacing[2],
|
||||
},
|
||||
compactHeaderMenuCluster: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
@@ -3977,6 +3982,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
minHeight: Math.ceil(theme.fontSize.sm * 1.5) + theme.spacing[1] * 2,
|
||||
minWidth: Math.ceil(theme.fontSize.sm * 1.5) + theme.spacing[1] * 2,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
// Match the painted right edge of the trailing split-pane glyph below. The
|
||||
// two header rows intentionally use different control sizes and padding.
|
||||
marginRight: -7,
|
||||
},
|
||||
sourceControlButtonWithStats: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
|
||||
@@ -278,6 +278,8 @@ export interface ExplorerFile {
|
||||
kind: ExplorerFileKind;
|
||||
encoding: ExplorerEncoding;
|
||||
content?: string;
|
||||
// TextDecoder removes a leading UTF-8 BOM; retain this bit so file writes can restore it.
|
||||
hasBom: boolean;
|
||||
mimeType?: string;
|
||||
size: number;
|
||||
modifiedAt: string;
|
||||
|
||||
@@ -22,6 +22,36 @@ function assistant(id: string, timestamp: Date): StreamItem {
|
||||
}
|
||||
|
||||
describe("deriveStreamTurnTiming", () => {
|
||||
it("reserves a running footer for an optimistic prompt before the host starts the turn", () => {
|
||||
const optimisticPrompt = {
|
||||
...user("optimistic", new Date("2026-05-15T00:00:00.000Z")),
|
||||
optimistic: true as const,
|
||||
};
|
||||
|
||||
const timing = deriveStreamTurnTiming({
|
||||
agentStatus: "idle",
|
||||
tail: [],
|
||||
head: [optimisticPrompt],
|
||||
});
|
||||
|
||||
assert.equal(timing.isActive, true);
|
||||
});
|
||||
|
||||
it("does not start elapsed time from an optimistic prompt", () => {
|
||||
const optimisticPrompt = {
|
||||
...user("optimistic", new Date("2026-05-15T00:00:00.000Z")),
|
||||
optimistic: true as const,
|
||||
};
|
||||
|
||||
const timing = deriveStreamTurnTiming({
|
||||
agentStatus: "running",
|
||||
tail: [],
|
||||
head: [optimisticPrompt],
|
||||
});
|
||||
|
||||
assert.equal(timing.runningStartedAt, null);
|
||||
});
|
||||
|
||||
it("uses the last user message as the running turn start", () => {
|
||||
const firstUserAt = new Date("2026-05-15T00:00:00.000Z");
|
||||
const secondUserAt = new Date("2026-05-15T00:01:00.000Z");
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface TurnTiming {
|
||||
export interface StreamTurnTiming {
|
||||
byAssistantId: Map<string, TurnTiming>;
|
||||
runningStartedAt: Date | null;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export function deriveStreamTurnTiming(params: {
|
||||
@@ -18,6 +19,8 @@ export function deriveStreamTurnTiming(params: {
|
||||
}): StreamTurnTiming {
|
||||
const byAssistantId = new Map<string, TurnTiming>();
|
||||
let currentUserAt: Date | null = null;
|
||||
let currentAuthoritativeUserAt: Date | null = null;
|
||||
let currentUserIsOptimistic = false;
|
||||
let currentLastItemAt: Date | null = null;
|
||||
let currentAssistantIds: string[] = [];
|
||||
|
||||
@@ -39,6 +42,8 @@ export function deriveStreamTurnTiming(params: {
|
||||
if (item.kind === "user_message") {
|
||||
flushCompletedTurn();
|
||||
currentUserAt = item.timestamp;
|
||||
currentAuthoritativeUserAt = item.optimistic ? null : item.timestamp;
|
||||
currentUserIsOptimistic = item.optimistic === true;
|
||||
currentLastItemAt = null;
|
||||
currentAssistantIds = [];
|
||||
return;
|
||||
@@ -59,10 +64,8 @@ export function deriveStreamTurnTiming(params: {
|
||||
visitItem(item);
|
||||
}
|
||||
|
||||
const runningStartedAt =
|
||||
params.agentStatus === "running"
|
||||
? (findLastUserMessageTimestamp(params.head) ?? currentUserAt)
|
||||
: null;
|
||||
const isRunning = params.agentStatus === "running";
|
||||
const runningStartedAt = isRunning ? currentAuthoritativeUserAt : null;
|
||||
if (params.agentStatus !== "running") {
|
||||
flushCompletedTurn();
|
||||
}
|
||||
@@ -70,15 +73,6 @@ export function deriveStreamTurnTiming(params: {
|
||||
return {
|
||||
byAssistantId,
|
||||
runningStartedAt,
|
||||
isActive: isRunning || currentUserIsOptimistic,
|
||||
};
|
||||
}
|
||||
|
||||
function findLastUserMessageTimestamp(items: StreamItem[]): Date | null {
|
||||
for (let i = items.length - 1; i >= 0; i -= 1) {
|
||||
const item = items[i];
|
||||
if (item?.kind === "user_message") {
|
||||
return item.timestamp;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.2.0-beta.3",
|
||||
"version": "0.2.0",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"bin": {
|
||||
"paseo": "bin/paseo"
|
||||
@@ -27,9 +27,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.2.0-beta.3",
|
||||
"@getpaseo/protocol": "0.2.0-beta.3",
|
||||
"@getpaseo/server": "0.2.0-beta.3",
|
||||
"@getpaseo/client": "0.2.0",
|
||||
"@getpaseo/protocol": "0.2.0",
|
||||
"@getpaseo/server": "0.2.0",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { generateLocalPairingOffer, loadConfig, resolvePaseoHome } from "@getpas
|
||||
import { tryConnectToDaemon } from "../../utils/client.js";
|
||||
import { resolveLocalDaemonState, resolveTcpHostFromListen } from "./local-daemon.js";
|
||||
import { addJsonOption } from "../../utils/command-options.js";
|
||||
import { formatPairingInstructions } from "../../output/pairing.js";
|
||||
|
||||
interface PairOptions {
|
||||
home?: string;
|
||||
@@ -97,6 +98,11 @@ function outputPairingResult(
|
||||
return;
|
||||
}
|
||||
|
||||
const qrBlock = pairing.qr ? `${pairing.qr}\n` : "";
|
||||
process.stdout.write(`\nScan to pair:\n${qrBlock}${pairing.url}\n`);
|
||||
process.stdout.write(
|
||||
formatPairingInstructions({
|
||||
url: pairing.url,
|
||||
qr: pairing.qr,
|
||||
columns: process.stdout.columns,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
type DaemonStartOptions,
|
||||
} from "./daemon/local-daemon.js";
|
||||
import { tryConnectToDaemon } from "../utils/client.js";
|
||||
import { formatPairingInstructions } from "../output/pairing.js";
|
||||
|
||||
interface OnboardOptions extends DaemonStartOptions {
|
||||
timeout?: string;
|
||||
@@ -520,11 +521,13 @@ export async function runOnboard(options: OnboardOptions): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
renderNote(
|
||||
pairing.qr ?? "QR is unavailable in this terminal. Use the pairing link below.",
|
||||
"Scan to pair",
|
||||
process.stdout.write(
|
||||
formatPairingInstructions({
|
||||
url: pairing.url,
|
||||
qr: pairing.qr,
|
||||
columns: process.stdout.columns,
|
||||
}),
|
||||
);
|
||||
renderNote(pairing.url, "Pairing link");
|
||||
printNextSteps(pairing.url, paseoHome, richUi);
|
||||
if (richUi) {
|
||||
outro("Paseo is ready!");
|
||||
|
||||
30
packages/cli/src/output/pairing.test.ts
Normal file
30
packages/cli/src/output/pairing.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatPairingInstructions } from "./pairing.js";
|
||||
|
||||
const QR = "\u001b[47m\u001b[30m \n ████ \n \u001b[0m";
|
||||
const URL = "https://app.paseo.sh/#offer=pairing-offer";
|
||||
|
||||
describe("formatPairingInstructions", () => {
|
||||
it("prints the QR and an unmodified pairing-link line when the terminal is wide enough", () => {
|
||||
const output = formatPairingInstructions({ qr: QR, url: URL, columns: 7 });
|
||||
|
||||
expect(output).toContain(QR);
|
||||
expect(output.split("\n")).toContain(URL);
|
||||
});
|
||||
|
||||
it("does not print a QR that would reach the terminal edge", () => {
|
||||
const output = formatPairingInstructions({ qr: QR, url: URL, columns: 6 });
|
||||
|
||||
expect(output).not.toContain(QR);
|
||||
expect(output).toContain("Resize the terminal to at least 7 columns");
|
||||
expect(output.split("\n")).toContain(URL);
|
||||
});
|
||||
|
||||
it("does not risk printing a QR when terminal width is unknown", () => {
|
||||
const output = formatPairingInstructions({ qr: QR, url: URL });
|
||||
|
||||
expect(output).not.toContain(QR);
|
||||
expect(output).toContain("terminal width could not be detected");
|
||||
expect(output.split("\n")).toContain(URL);
|
||||
});
|
||||
});
|
||||
37
packages/cli/src/output/pairing.ts
Normal file
37
packages/cli/src/output/pairing.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
const ANSI_PATTERN = new RegExp(`${String.fromCharCode(0x1b)}\\[[0-9;]*m`, "g");
|
||||
|
||||
interface PairingInstructions {
|
||||
url: string;
|
||||
qr: string | null;
|
||||
columns?: number;
|
||||
}
|
||||
|
||||
function visibleWidth(value: string): number {
|
||||
return Math.max(
|
||||
...value
|
||||
.replace(ANSI_PATTERN, "")
|
||||
.split("\n")
|
||||
.map((line) => line.length),
|
||||
);
|
||||
}
|
||||
|
||||
function formatQr(qr: string | null, columns: number | undefined): string {
|
||||
if (!qr) {
|
||||
return "QR code is unavailable. Use the pairing link below.";
|
||||
}
|
||||
|
||||
if (columns === undefined) {
|
||||
return "QR code not shown because terminal width could not be detected.";
|
||||
}
|
||||
|
||||
const width = visibleWidth(qr);
|
||||
if (columns <= width) {
|
||||
return `QR code not shown. Resize the terminal to at least ${width + 1} columns, then run this command again.`;
|
||||
}
|
||||
|
||||
return qr;
|
||||
}
|
||||
|
||||
export function formatPairingInstructions({ url, qr, columns }: PairingInstructions): string {
|
||||
return `\nScan to pair:\n${formatQr(qr, columns)}\n\nPairing link:\n${url}\n`;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.2.0-beta.3",
|
||||
"version": "0.2.0",
|
||||
"description": "Paseo client SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -35,8 +35,8 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.2.0-beta.3",
|
||||
"@getpaseo/relay": "0.2.0-beta.3",
|
||||
"@getpaseo/protocol": "0.2.0",
|
||||
"@getpaseo/relay": "0.2.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.2.0-beta.3",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"homepage": "https://paseo.sh",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.2.0-beta.3",
|
||||
"version": "0.2.0",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"keywords": [
|
||||
"ExpoTwoWayAudio",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.2.0-beta.3",
|
||||
"version": "0.2.0",
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/*.map"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.2.0-beta.3",
|
||||
"version": "0.2.0",
|
||||
"description": "Paseo shared protocol schemas and wire types",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -35,6 +35,7 @@ describe("checkout.commits.list schemas", () => {
|
||||
authorName: "Ada",
|
||||
authorDate: "2026-06-13T10:00:00.000Z",
|
||||
isOnRemote: true,
|
||||
isOnBase: false,
|
||||
files: [
|
||||
{ path: "src/a.ts", additions: 10, deletions: 2, status: "modified" },
|
||||
{ path: "src/b.ts", additions: 5, deletions: 0, status: "added" },
|
||||
@@ -47,6 +48,7 @@ describe("checkout.commits.list schemas", () => {
|
||||
authorName: "Ada",
|
||||
authorDate: "2026-06-13T11:00:00.000Z",
|
||||
isOnRemote: false,
|
||||
isOnBase: true,
|
||||
files: [{ path: "src/c.ts", additions: 1, deletions: 1 }],
|
||||
},
|
||||
],
|
||||
@@ -61,10 +63,37 @@ describe("checkout.commits.list schemas", () => {
|
||||
|
||||
expect(parsed.payload).toEqual(payload);
|
||||
expect(parsed.payload.commits[0]?.isOnRemote).toBe(true);
|
||||
expect(parsed.payload.commits[0]?.isOnBase).toBe(false);
|
||||
expect(parsed.payload.commits[1]?.isOnRemote).toBe(false);
|
||||
expect(parsed.payload.commits[1]?.isOnBase).toBe(true);
|
||||
expect(parsed.payload.commits[1]?.files[0]?.status).toBeUndefined();
|
||||
});
|
||||
|
||||
test("still parses commits from hosts without base classification", () => {
|
||||
const parsed = CheckoutCommitsListResponseSchema.parse({
|
||||
type: "checkout.commits.list.response",
|
||||
payload: {
|
||||
cwd: "/tmp/repo",
|
||||
baseRef: "main",
|
||||
commits: [
|
||||
{
|
||||
sha: "1111111111111111111111111111111111111111",
|
||||
shortSha: "1111111",
|
||||
subject: "Legacy commit",
|
||||
authorName: "Ada",
|
||||
authorDate: "2026-06-13T10:00:00.000Z",
|
||||
isOnRemote: true,
|
||||
files: [],
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
requestId: "request-commits",
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.payload.commits[0]?.isOnBase).toBeUndefined();
|
||||
});
|
||||
|
||||
test("accepts a null baseRef and an error payload", () => {
|
||||
const payload = {
|
||||
cwd: "/tmp/repo",
|
||||
@@ -107,16 +136,17 @@ describe("checkout.commits.list schemas", () => {
|
||||
).toMatchObject({ type: "checkout.commits.list.response" });
|
||||
});
|
||||
|
||||
test("accepts the commitsList server_info feature flag", () => {
|
||||
test("accepts the commit history server_info feature flags", () => {
|
||||
expect(
|
||||
ServerInfoStatusPayloadSchema.parse({
|
||||
status: "server_info",
|
||||
serverId: "srv_test",
|
||||
features: {
|
||||
commitsList: true,
|
||||
commitBaseClassification: true,
|
||||
},
|
||||
}).features,
|
||||
).toEqual({ commitsList: true });
|
||||
).toEqual({ commitsList: true, commitBaseClassification: true });
|
||||
});
|
||||
|
||||
test("still parses server_info without the commitsList feature flag", () => {
|
||||
|
||||
@@ -1732,6 +1732,8 @@ const CheckoutCommitSchema = z.object({
|
||||
authorName: z.string(),
|
||||
authorDate: z.string(), // ISO 8601
|
||||
isOnRemote: z.boolean(), // false = local-only (unpushed)
|
||||
// COMPAT(commitBaseClassification): added in v0.2.0, remove optional after 2027-01-23.
|
||||
isOnBase: z.boolean().optional(),
|
||||
files: z.array(CheckoutCommitFileSchema),
|
||||
});
|
||||
|
||||
@@ -2777,6 +2779,8 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
projectCreateDirectory: z.boolean().optional(),
|
||||
// COMPAT(commitsList): added in v0.1.110, remove gate after 2027-01-16.
|
||||
commitsList: z.boolean().optional(),
|
||||
// COMPAT(commitBaseClassification): added in v0.2.0, remove gate after 2027-01-23.
|
||||
commitBaseClassification: z.boolean().optional(),
|
||||
// COMPAT(providerRemoval): added in v0.1.105, drop the gate when floor >= v0.1.105.
|
||||
providerRemoval: z.boolean().optional(),
|
||||
// COMPAT(importSessionWorkspaceTarget): added in v0.1.110, remove gate after 2027-01-16.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.2.0-beta.3",
|
||||
"version": "0.2.0",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.2.0-beta.3",
|
||||
"version": "0.2.0",
|
||||
"description": "Paseo backend server",
|
||||
"files": [
|
||||
"dist/server",
|
||||
@@ -67,10 +67,10 @@
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.214",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.2.0-beta.3",
|
||||
"@getpaseo/highlight": "0.2.0-beta.3",
|
||||
"@getpaseo/protocol": "0.2.0-beta.3",
|
||||
"@getpaseo/relay": "0.2.0-beta.3",
|
||||
"@getpaseo/client": "0.2.0",
|
||||
"@getpaseo/highlight": "0.2.0",
|
||||
"@getpaseo/protocol": "0.2.0",
|
||||
"@getpaseo/relay": "0.2.0",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
|
||||
82
packages/server/src/server/agent/agent-loading.test.ts
Normal file
82
packages/server/src/server/agent/agent-loading.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import { AgentManager } from "./agent-manager.js";
|
||||
import { ensureAgentLoaded } from "./agent-loading.js";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
import type {
|
||||
AgentClient,
|
||||
AgentLaunchContext,
|
||||
AgentPersistenceHandle,
|
||||
AgentResumeSessionOptions,
|
||||
AgentSession,
|
||||
AgentSessionConfig,
|
||||
} from "./agent-sdk-types.js";
|
||||
import { createTestAgentClients } from "../test-utils/fake-agent-client.js";
|
||||
|
||||
test("loads archived records for history and active records with the interactive default", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "agent-loading-purpose-"));
|
||||
const logger = createTestLogger();
|
||||
const storage = new AgentStorage(path.join(root, "agents"), logger);
|
||||
const baseClient = createTestAgentClients().codex;
|
||||
if (!baseClient) {
|
||||
throw new Error("expected Codex test client");
|
||||
}
|
||||
|
||||
const resumeOptions: Array<AgentResumeSessionOptions | undefined> = [];
|
||||
const client: AgentClient = {
|
||||
provider: baseClient.provider,
|
||||
capabilities: baseClient.capabilities,
|
||||
createSession: async (
|
||||
config: AgentSessionConfig,
|
||||
launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession> => await baseClient.createSession(config, launchContext),
|
||||
resumeSession: async (
|
||||
handle: AgentPersistenceHandle,
|
||||
overrides?: Partial<AgentSessionConfig>,
|
||||
launchContext?: AgentLaunchContext,
|
||||
options?: AgentResumeSessionOptions,
|
||||
): Promise<AgentSession> => {
|
||||
resumeOptions.push(options);
|
||||
return await baseClient.resumeSession(handle, overrides, launchContext);
|
||||
},
|
||||
fetchCatalog: async (options) => await baseClient.fetchCatalog(options),
|
||||
isAvailable: async () => await baseClient.isAvailable(),
|
||||
};
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: client },
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
|
||||
const archivedId = "00000000-0000-4000-8000-000000000301";
|
||||
const activeId = "00000000-0000-4000-8000-000000000302";
|
||||
|
||||
try {
|
||||
const archived = await manager.createAgent({ provider: "codex", cwd: root }, archivedId, {
|
||||
workspaceId: "workspace-archived",
|
||||
});
|
||||
await manager.archiveAgent(archived.id);
|
||||
|
||||
const active = await manager.createAgent({ provider: "codex", cwd: root }, activeId, {
|
||||
workspaceId: "workspace-active",
|
||||
});
|
||||
await manager.closeAgent(active.id);
|
||||
|
||||
await ensureAgentLoaded(archived.id, { agentManager: manager, agentStorage: storage, logger });
|
||||
await ensureAgentLoaded(active.id, { agentManager: manager, agentStorage: storage, logger });
|
||||
|
||||
expect(resumeOptions).toEqual([{ purpose: "history" }, undefined]);
|
||||
} finally {
|
||||
await Promise.all([
|
||||
manager.closeAgent(archivedId).catch(() => undefined),
|
||||
manager.closeAgent(activeId).catch(() => undefined),
|
||||
]);
|
||||
await manager.flush().catch(() => undefined);
|
||||
await storage.flush().catch(() => undefined);
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -111,6 +111,7 @@ export async function ensureAgentLoaded(
|
||||
buildConfigOverrides(record),
|
||||
agentId,
|
||||
extractTimestamps(record),
|
||||
record.archivedAt ? { purpose: "history" } : undefined,
|
||||
);
|
||||
deps.logger.info({ agentId, provider: record.provider }, "Agent resumed from persistence");
|
||||
} else {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user