Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 0d88211636 fix(terminal): preserve adjacent wide characters
Terminal snapshots converted zero-width continuation cells into spaces, which altered captured and restored output.
2026-07-22 12:13:27 +00:00
292 changed files with 5392 additions and 13338 deletions
+7 -7
View File
@@ -1,10 +1,10 @@
[vars]
android_sdk_version = '{{ read_file(path=".tool-versions") | split(pat="android-sdk") | last | trim | split(pat="\n") | first | trim }}'
[env]
ANDROID_HOME = "{{ env.HOME }}/.local/share/mise/installs/android-sdk/{{ vars.android_sdk_version }}"
ANDROID_HOME = "{{env.HOME}}/.local/share/mise/installs/android-sdk/21.0"
_.path = [
"{{ env.HOME }}/.local/share/mise/installs/android-sdk/{{ vars.android_sdk_version }}/cmdline-tools/{{ vars.android_sdk_version }}/bin",
"{{ env.HOME }}/.local/share/mise/installs/android-sdk/{{ vars.android_sdk_version }}/platform-tools",
"{{ env.HOME }}/.local/share/mise/installs/android-sdk/{{ vars.android_sdk_version }}/emulator",
"{{env.HOME}}/.local/share/mise/installs/android-sdk/21.0/cmdline-tools/21.0/bin",
"{{env.HOME}}/.local/share/mise/installs/android-sdk/21.0/platform-tools",
"{{env.HOME}}/.local/share/mise/installs/android-sdk/21.0/emulator",
]
[tools]
java = "21"
+39 -47
View File
@@ -1,42 +1,56 @@
# Changelog
## 0.2.1 - 2026-07-24
## 0.2.0-beta.2 - 2026-07-22
### Added
- Claude Opus 5 is available
- 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))
## 0.2.0 - 2026-07-24
### 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
### Added
- Work with pull requests and merge requests from GitLab, Gitea, Forgejo, and Codeberg ([#1913](https://github.com/getpaseo/paseo/pull/1913) by [@nllptrx](https://github.com/nllptrx))
- Edit files directly in the web and desktop apps ([#2270](https://github.com/getpaseo/paseo/pull/2270), [#2309](https://github.com/getpaseo/paseo/pull/2309), [#2277](https://github.com/getpaseo/paseo/pull/2277), [#2382](https://github.com/getpaseo/paseo/pull/2382) by [@dwyanewang](https://github.com/dwyanewang))
- Oh My Pi (OMP) as a native agent provider ([#2067](https://github.com/getpaseo/paseo/pull/2067) by [@ebg1223](https://github.com/ebg1223))
- Open the complete Changes view as a workspace tab ([#2298](https://github.com/getpaseo/paseo/pull/2298) by [@nikuscs](https://github.com/nikuscs))
- Add files to chat directly from Files and Changes ([#2275](https://github.com/getpaseo/paseo/pull/2275) by [@nikuscs](https://github.com/nikuscs))
- Browse workspace commit history and open individual commit diffs from Changes ([#1534](https://github.com/getpaseo/paseo/pull/1534), [#2146](https://github.com/getpaseo/paseo/pull/2146), [#2312](https://github.com/getpaseo/paseo/pull/2312) by [@adradr](https://github.com/adradr))
- Switch models from the Command Center for active agents and new drafts ([#2147](https://github.com/getpaseo/paseo/pull/2147) by [@kedrzu](https://github.com/kedrzu))
- Open existing agents from Paseo links or the CLI ([#2324](https://github.com/getpaseo/paseo/pull/2324))
- Configure workspace service ports with a fixed range or external allocator ([#2165](https://github.com/getpaseo/paseo/pull/2165) by [@mcowger](https://github.com/mcowger))
- Search keyboard shortcuts by action, note, or key combination ([#2160](https://github.com/getpaseo/paseo/pull/2160))
- Turn thinking off for supported Claude models ([#2257](https://github.com/getpaseo/paseo/pull/2257))
- Allow Pi's Max thinking level ([#2267](https://github.com/getpaseo/paseo/pull/2267) by [@ByteTrue](https://github.com/ByteTrue))
- Use Oh My Pi as a native agent provider ([#2067](https://github.com/getpaseo/paseo/pull/2067) by [@ebg1223](https://github.com/ebg1223))
- Browse branch commit history and open individual commit diffs from Changes ([#1534](https://github.com/getpaseo/paseo/pull/1534), [#2146](https://github.com/getpaseo/paseo/pull/2146) by [@adradr](https://github.com/adradr))
- Open workspace files in more installed editors and file managers ([#2119](https://github.com/getpaseo/paseo/pull/2119))
- Remove individual custom providers from Settings ([#1951](https://github.com/getpaseo/paseo/pull/1951))
### Improved
- Improved model selection on mobile ([#2361](https://github.com/getpaseo/paseo/pull/2361))
- Selector popovers stay readable on iPad ([#2360](https://github.com/getpaseo/paseo/pull/2360) by [@yzim](https://github.com/yzim))
- Projects, workspaces and chat syncing is more efficient ([#2028](https://github.com/getpaseo/paseo/pull/2028), [#2185](https://github.com/getpaseo/paseo/pull/2185), [#2196](https://github.com/getpaseo/paseo/pull/2196), [#2206](https://github.com/getpaseo/paseo/pull/2206), [#2259](https://github.com/getpaseo/paseo/pull/2259), [#2263](https://github.com/getpaseo/paseo/pull/2263))
- CLI and MCP tools manage workspaces, agents, and schedules more consistently ([#2186](https://github.com/getpaseo/paseo/pull/2186))
- Pasted PR/MR links in the composer become auto-selected as a checkout option ([#2290](https://github.com/getpaseo/paseo/pull/2290))
- Make project creation more explicit ([#2098](https://github.com/getpaseo/paseo/pull/2098), [#2187](https://github.com/getpaseo/paseo/pull/2187))
- Idle agents release processes automatically and resume when needed ([#2203](https://github.com/getpaseo/paseo/pull/2203), [#2209](https://github.com/getpaseo/paseo/pull/2209))
- New Claude and Codex agents default to safer automatic approval modes when supported ([#2213](https://github.com/getpaseo/paseo/pull/2213))
- Permission and thinking changes made during a turn now show when they take effect ([#2201](https://github.com/getpaseo/paseo/pull/2201))
- Usage bars now warn as provider limits approach ([#2322](https://github.com/getpaseo/paseo/pull/2322) by [@cleiter](https://github.com/cleiter))
- ACP provider catalog updated to the latest registry versions
- Workspace focus mode stays confined to the active workspace with a visible exit control ([#2151](https://github.com/getpaseo/paseo/pull/2151))
- Desktop installs the newest available update instead of a cached older release ([#2149](https://github.com/getpaseo/paseo/pull/2149))
- Remote daemon update failures now show specific recovery steps ([#2120](https://github.com/getpaseo/paseo/pull/2120))
@@ -44,34 +58,12 @@
### Fixed
- Terminal pairing QR codes remain scannable in narrow terminals ([#2381](https://github.com/getpaseo/paseo/pull/2381))
- Workspace creation stays responsive even with many active or archived workspaces ([#2355](https://github.com/getpaseo/paseo/pull/2355), [#2379](https://github.com/getpaseo/paseo/pull/2379))
- Failed agent starts no longer leave provider processes running ([#2348](https://github.com/getpaseo/paseo/pull/2348) by [@dwyanewang](https://github.com/dwyanewang))
- Completed OpenCode turns stay idle when late metadata updates arrive ([#2336](https://github.com/getpaseo/paseo/pull/2336) by [@mcowger](https://github.com/mcowger))
- ACP image prompts no longer appear twice ([#2363](https://github.com/getpaseo/paseo/pull/2363))
- Web chats stay pinned to the latest message at non-default browser zoom ([#2368](https://github.com/getpaseo/paseo/pull/2368))
- Grouped tool-call loading animations display correctly ([#2369](https://github.com/getpaseo/paseo/pull/2369))
- Notifications now open the correct workspace and agent ([#2331](https://github.com/getpaseo/paseo/pull/2331))
- Archived agents can be restored directly from History ([#2316](https://github.com/getpaseo/paseo/pull/2316))
- CLI agent runs stay in the current workspace unless a new workspace is requested ([#2315](https://github.com/getpaseo/paseo/pull/2315))
- Reused branches no longer attach an unrelated merged or closed pull request ([#2172](https://github.com/getpaseo/paseo/pull/2172) by [@nllptrx](https://github.com/nllptrx))
- Pi compaction waits for long summaries instead of reporting a false timeout ([#2181](https://github.com/getpaseo/paseo/pull/2181) by [@jasonhnd](https://github.com/jasonhnd))
- Pi chats keep new messages aligned with the correct history after an idle agent resumes ([#2313](https://github.com/getpaseo/paseo/pull/2313))
- OpenCode follow-ups triggered by completed background work now remain visible ([#2258](https://github.com/getpaseo/paseo/pull/2258))
- Codex no longer shows the parent agent as a phantom subagent ([#2214](https://github.com/getpaseo/paseo/pull/2214))
- Oh My Pi background notices appear as task notifications instead of raw system text ([#2218](https://github.com/getpaseo/paseo/pull/2218) by [@ebg1223](https://github.com/ebg1223))
- Local dictation now works in Nix-packaged installations ([#1587](https://github.com/getpaseo/paseo/pull/1587) by [@yhori991](https://github.com/yhori991))
- The composer remains visible after submitting dictated text and returning to the app ([#2194](https://github.com/getpaseo/paseo/pull/2194))
- Desktop's dictation shortcut remains responsive after finishing a recording ([#2268](https://github.com/getpaseo/paseo/pull/2268))
- Projects can be renamed before their first workspace ([#2252](https://github.com/getpaseo/paseo/pull/2252) by [@albertodeago](https://github.com/albertodeago))
- Settings keep showing a connected remote host when the local daemon is stopped ([#1749](https://github.com/getpaseo/paseo/pull/1749) by [@dwyanewang](https://github.com/dwyanewang))
- Pinned workspaces no longer disappear briefly when reopening the compact sidebar ([#2210](https://github.com/getpaseo/paseo/pull/2210))
- Terminal panes no longer remain at 80x24 after focus or visibility changes ([#2059](https://github.com/getpaseo/paseo/pull/2059), [#2154](https://github.com/getpaseo/paseo/pull/2154) by [@cleiter](https://github.com/cleiter))
- Sign-in popups in the desktop browser now complete successfully ([#2137](https://github.com/getpaseo/paseo/pull/2137))
- Browser typing and shortcuts no longer submit the active Paseo prompt ([#1982](https://github.com/getpaseo/paseo/pull/1982))
- Agent browser tabs remain controllable after switching workspaces ([#2156](https://github.com/getpaseo/paseo/pull/2156))
- Archived workspaces now show the correct Unarchive or Restore action ([#2002](https://github.com/getpaseo/paseo/pull/2002))
- Archived sessions can be reimported into the current workspace ([#2123](https://github.com/getpaseo/paseo/pull/2123), [#2265](https://github.com/getpaseo/paseo/pull/2265) by [@nikuscs](https://github.com/nikuscs))
- Archived sessions can be reimported into the current workspace ([#2123](https://github.com/getpaseo/paseo/pull/2123))
- Browser shortcuts no longer appear where browser tabs are unavailable ([#2116](https://github.com/getpaseo/paseo/pull/2116) by [@jasonhnd](https://github.com/jasonhnd))
## 0.1.110 - 2026-07-16
-14
View File
@@ -80,20 +80,6 @@ archived workspace. History navigation must not infer workspace lifecycle from `
or mutate either lifecycle. The workspace route asks the daemon for authoritative recovery state;
only the route's explicit Unarchive or Restore action changes the archived workspace.
History navigation preserves the selected agent as an explicit recovery target. If both that agent
and its workspace are archived, the workspace recovery action restores the workspace and unarchives
the selected agent as one user action. Other archived agents in the restored workspace remain
recoverable from History. Opening one pins its tab and renders the archived-agent callout. Authoritative
timeline catch-up may load provider history with a runtime-only `history` resume purpose, which must
leave both Paseo's `archivedAt` and the provider's native archive state unchanged. **Unarchive** remains
the only transition back to an interactive runtime: it runs the provider's native unarchive hook
(including Codex `thread/unarchive`) before the normal agent resume and timeline hydration flow.
Provider session connection owns every process it spawns until the session is registered with
`AgentManager`. If initialization, persisted-session resume, or initial history hydration fails,
`connect()` must dispose that process before rethrowing; the manager cannot clean up a session it never
received.
## Tabs vs archive
These are two distinct concepts that used to be conflated:
+2 -12
View File
@@ -27,13 +27,13 @@ The formula reserves three digits each for minor and patch. If either reaches `1
## Prerequisites (local dev)
Local Android builds run on macOS (or Linux) and need the Android toolchain, pinned in `.tool-versions` (`java 21`, `android-sdk 21.0`) and wired up by `.mise.toml` (which derives `ANDROID_HOME` and the command-line tool paths from the `android-sdk` entry). With [mise](https://mise.jdx.dev):
Local Android builds run on macOS (or Linux) and need the Android toolchain, pinned in `.tool-versions` (`java 21`, `android-sdk 21.0`) and wired up by `.mise.toml` (which sets `ANDROID_HOME` and puts `cmdline-tools/21.0/bin`, `platform-tools`, and `emulator` on `PATH`). With [mise](https://mise.jdx.dev):
```bash
mise install # java 21 + android-sdk 21.0 command-line tools
```
> **Pin a real `android-sdk` version, not `latest`.** The mise `android-sdk` plugin's `latest` resolved to the ancient `1.0` bundle, whose `sdkmanager` (3.6.0) predates the `emulator` package and fails with `Failed to find package emulator`. `21.0` ships a current `sdkmanager`. If you bump it, update only the version in `.tool-versions`; `.mise.toml` derives its paths from that tool entry.
> **Pin a real `android-sdk` version, not `latest`.** The mise `android-sdk` plugin's `latest` resolved to the ancient `1.0` bundle, whose `sdkmanager` (3.6.0) predates the `emulator` package and fails with `Failed to find package emulator`. `21.0` ships a current `sdkmanager`. If you bump it, update the version in `.tool-versions` and in all four paths in `.mise.toml`.
`mise install` only lays down the command-line tools. Install the rest and create an emulator. On Apple Silicon:
@@ -120,16 +120,6 @@ PASEO_FDROID_BUILD=1 ./gradlew assembleRelease --no-daemon --max-workers=1 -Dorg
The flag must be present for both prebuild and Gradle because Gradle starts Metro for the release bundle. Keep the source build serial and daemon-free as shown above: compiling every Expo module can exhaust memory when Gradle workers run in parallel. The profile enables source-built Expo modules, excludes the proprietary camera, Firebase notification, and Expo development-client native modules, disables EAS updates and Gradle dependency metadata, and substitutes JavaScript stubs for camera and notifications. The resulting app supports direct and pasted-link pairing but not QR scanning or push notifications.
For a single-ABI APK, pass React Native's architecture property to Gradle:
```bash
PASEO_FDROID_BUILD=1 ./gradlew assembleRelease \
-PreactNativeArchitectures=arm64-v8a \
--no-daemon --max-workers=1 -Dorg.gradle.parallel=false
```
Supported values are `armeabi-v7a`, `arm64-v8a`, `x86`, and `x86_64`. The F-Droid profile filters native libraries to that ABI and changes the APK version code to `baseVersionCode * 10 + abiSuffix`, where the suffixes are ordered `1` through `4` in that same sequence. F-Droid metadata should use four build blocks with `VercodeOperation` entries `10 * %c + 1` through `10 * %c + 4` and pass the matching `reactNativeArchitectures` value in each build command. Builds without a single architecture keep the base version code.
Keep the excluded npm packages installed. Normal builds use them, while the F-Droid profile removes only their Android native modules and config plugins. Paseo always applies `expo-gradle-jvmargs` with `-Xmx4096m` and `-XX:MaxMetaspaceSize=1024m` so local Expo prebuilds have enough Gradle heap whether they use precompiled AARs or source-built Expo modules.
The EAS `production-apk` profile uses the large Android resource class. Release builds compile the native ABIs and run Hermes bundling in the same Gradle invocation; the default worker can exhaust its remaining memory and kill Hermes with exit code 137 even when Gradle's own heap is correctly sized.
-6
View File
@@ -390,7 +390,6 @@ npm run cli -- ls -a -g # List all agents globally
npm run cli -- ls -a -g --json # Same, as JSON
npm run cli -- inspect <id> # Show detailed agent info
npm run cli -- logs <id> # View agent timeline
npm run cli -- agent open <id> # Focus an existing agent in Paseo Desktop
npm run cli -- daemon status # Check daemon status
npm run cli -- clone owner/repo --dir ~/workspace # Clone GitHub repo and register project
```
@@ -401,11 +400,6 @@ Use `--host <host:port>` to point the CLI at a different daemon:
npm run cli -- --host localhost:7777 ls -a
```
Desktop integrations can focus an existing agent without creating one or
sending a message. Use `paseo://h/<server-id>/agent/<agent-id>`, or run
`paseo agent open <agent-id>`. The CLI reads the local daemon's server ID by
default; pass `--server <server-id>` when targeting another server.
## Agent state
Agent data lives at:
-14
View File
@@ -73,20 +73,6 @@ only use local param fallback during cold mount (`/` or empty pathname), or a
hidden workspace can overwrite the remembered workspace before Settings or
History returns.
## Agent Targets
Notifications and agent URLs enter the router with different authoritative
targets.
- Notifications carry `serverId`, `workspaceId`, and `agentId`. Route them
directly to the workspace with the agent open intent.
- Agent URLs carry only `serverId` and `agentId`. Route them through
`/h/[serverId]/agent/[agentId]`; that route waits for the named host, resolves
the agent's workspace from the host, and then opens the agent there.
Both paths converge on `navigateToAgent()`. Do not make notification routing
guess a workspace, and do not add a workspace to the stable agent URL format.
## Params
Required dynamic params belong to the matched route.
-7
View File
@@ -129,13 +129,6 @@ lockstep, no re-measurement needed. Do not call
can briefly report a stale nonzero height with closed progress, and the shared
provider is where that is normalized.
The provider also reconciles iOS from the controller's native `onEnd` event.
The controller's stock iOS shared values update at move start and during an
interactive move, but not at the terminal event, so JS contention can otherwise
leave the last height/progress pair stuck in either the open or closed state.
Keep that terminal reconciliation on the UI thread; a later focus or blur must
not be required to repair the offset.
Re-measure on `Keyboard.addListener('keyboardDidShow'|'keyboardDidHide')` only
to refresh the snapshot if the keyboard was mid-transition when the popover
opened.
-5
View File
@@ -32,11 +32,6 @@ Terminal frames share the daemon main event loop with all agent traffic. The `ev
- **Browser perf specs (user-perceived path):** gated behind `PASEO_TERMINAL_PERF_E2E=1` —
`packages/app/e2e/terminal-performance.spec.ts` and `packages/app/e2e/terminal-keystroke-stress.spec.ts` (per-stage keydown→xterm-commit breakdown under mock-agent load). Healthy: keydown→commit p50 ~18ms under 600-key burst.
- **Production:** grep `daemon.log` for `ws_runtime_metrics` and read `eventLoopDelay` + `bufferedAmount`.
- **Git pressure:** the same log line includes `git.commands` (limiter occupancy, queue age,
queue wait, execution time, failures, timeouts, and top operations),
`git.workspaceService` (daemon-global Git observer ownership), and per-session workspace Git
subscription totals under `runtime`. Queue wait and execution time are separate because the Git
command timeout begins only after a command acquires a limiter slot.
## Known remaining contention (follow-up candidates)
+1 -1
View File
@@ -1 +1 @@
sha256-t7FqvPj7U5YidGH9OZ1kFnD2dPTYUyjcxBlzAJU7Mak=
sha256-Ys/R5+0O3SDYeCSd7hatTuCwV9S0kIO8vhmPKGZsvn0=
+21 -21
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.2.1",
"version": "0.2.0-beta.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.2.1",
"version": "0.2.0-beta.2",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -35211,7 +35211,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.2.1",
"version": "0.2.0-beta.2",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -36236,12 +36236,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.2.1",
"version": "0.2.0-beta.2",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.2.1",
"@getpaseo/protocol": "0.2.1",
"@getpaseo/server": "0.2.1",
"@getpaseo/client": "0.2.0-beta.2",
"@getpaseo/protocol": "0.2.0-beta.2",
"@getpaseo/server": "0.2.0-beta.2",
"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.1",
"version": "0.2.0-beta.2",
"dependencies": {
"@getpaseo/protocol": "0.2.1",
"@getpaseo/relay": "0.2.1",
"@getpaseo/protocol": "0.2.0-beta.2",
"@getpaseo/relay": "0.2.0-beta.2",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -36501,7 +36501,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.2.1",
"version": "0.2.0-beta.2",
"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.1",
"version": "0.2.0-beta.2",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -37640,7 +37640,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.2.1",
"version": "0.2.0-beta.2",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -37872,7 +37872,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.2.1",
"version": "0.2.0-beta.2",
"dependencies": {
"zod": "^4.4.3"
},
@@ -37885,7 +37885,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.2.1",
"version": "0.2.0-beta.2",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38103,15 +38103,15 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.2.1",
"version": "0.2.0-beta.2",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.214",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.2.1",
"@getpaseo/highlight": "0.2.1",
"@getpaseo/protocol": "0.2.1",
"@getpaseo/relay": "0.2.1",
"@getpaseo/client": "0.2.0-beta.2",
"@getpaseo/highlight": "0.2.0-beta.2",
"@getpaseo/protocol": "0.2.0-beta.2",
"@getpaseo/relay": "0.2.0-beta.2",
"@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.1",
"version": "0.2.0-beta.2",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.2.1",
"version": "0.2.0-beta.2",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -1,49 +0,0 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { test, expect, type Page } from "./fixtures";
import { seedMockAgentWorkspace, openAgentRoute } from "./helpers/mock-agent";
function visibleComposer(page: Page) {
return page.locator("textarea[data-composer-input]").filter({ visible: true }).first();
}
test("adds a changed file to the focused chat without replacing its composer draft", async ({
page,
}) => {
const workspace = await seedMockAgentWorkspace({
repoPrefix: "add-file-to-chat-",
title: "Target chat",
});
const relativePath = "src/changed file.ts";
try {
await mkdir(path.join(workspace.cwd, "src"), { recursive: true });
await writeFile(path.join(workspace.cwd, relativePath), "export const changed = true;\n");
await workspace.client.checkoutRefresh(workspace.cwd);
await page.setViewportSize({ width: 1400, height: 900 });
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
});
const agentComposer = visibleComposer(page);
await expect(agentComposer).toBeEditable({ timeout: 30_000 });
await agentComposer.fill("Preserve this thought");
await page.getByRole("button", { name: "Open explorer" }).click();
await page.getByTestId("explorer-tab-changes").click();
const changedFile = page.getByText("changed file.ts", { exact: true }).first();
await expect(changedFile).toBeVisible({ timeout: 30_000 });
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
await page.getByTestId("diff-file-0-add-to-chat").click();
const attachment = page.getByTestId("composer-workspace-file-attachment-pill");
await expect(attachment).toContainText("changed file.ts");
await expect(attachment).toContainText(relativePath);
await expect(agentComposer).toHaveValue("Preserve this thought");
await expect(agentComposer).toBeFocused();
} finally {
await workspace.cleanup();
}
});
@@ -1,115 +0,0 @@
import { mkdtempSync, realpathSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { test, expect } from "./fixtures";
import { openSessions } from "./helpers/archive-tab";
import {
assertChatTranscript,
cleanupRewindFlow,
launchAgent,
sendMessage,
type AgentHandle,
} from "./helpers/rewind-flow";
import type { SeedDaemonClient } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
interface TimelineClient extends SeedDaemonClient {
fetchAgentTimeline(
agentId: string,
options: { direction: "tail"; projection: "projected"; limit: number },
): Promise<unknown>;
}
const INITIAL_PROMPT = "Reply with exactly CODEX_ARCHIVE_TIMELINE_SENTINEL and nothing else.";
const INITIAL_REPLY = "CODEX_ARCHIVE_TIMELINE_SENTINEL";
const FOLLOW_UP_PROMPT = "Reply with exactly CODEX_UNARCHIVED_SENTINEL and nothing else.";
const FOLLOW_UP_REPLY = "CODEX_UNARCHIVED_SENTINEL";
async function historyContainsAgent(client: SeedDaemonClient, agentId: string): Promise<boolean> {
const history = await client.fetchAgentHistory({ page: { limit: 200 } });
return history.entries.some((entry) => entry.agent.id === agentId);
}
test.describe("archived Codex agent recovery", () => {
test.setTimeout(600_000);
test("cold-opens without provider history, then unarchives and restores the conversation", async ({
page,
}) => {
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-archived-codex-")));
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({
page,
provider: "codex",
cwd,
mode: "full-access",
});
await sendMessage(handle, INITIAL_PROMPT);
await assertChatTranscript(handle, [
{ role: "user", text: INITIAL_PROMPT },
{ role: "assistant", text: INITIAL_REPLY },
]);
await handle.client.archiveAgent(handle.agentId);
await expect
.poll(
async () =>
(await handle?.client.fetchAgent({ agentId: handle.agentId }))?.agent.archivedAt ??
null,
{ timeout: 30_000 },
)
.not.toBeNull();
const timelineClient = handle.client as TimelineClient;
await expect(
timelineClient.fetchAgentTimeline(handle.agentId, {
direction: "tail",
projection: "projected",
limit: 100,
}),
).rejects.toThrow(/archiv/i);
await expect
.poll(async () => (handle ? historyContainsAgent(handle.client, handle.agentId) : false), {
timeout: 30_000,
})
.toBe(true);
await page.reload();
await waitForSidebarHydration(page);
await openSessions(page);
await page.getByTestId(`agent-row-${getServerId()}-${handle.agentId}`).click();
await expect(
page.getByTestId(`workspace-tab-agent_${handle.agentId}`).filter({ visible: true }).first(),
).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("This agent is archived", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(page.getByTestId("agent-load-error")).toHaveCount(0);
await expect(page.getByTestId("agent-timeline-sync-error")).toHaveCount(0);
await expect(page.getByTestId("user-message")).toHaveCount(0);
await page.getByRole("button", { name: "Unarchive" }).click();
await expect(page.getByRole("button", { name: "Unarchive" })).toHaveCount(0, {
timeout: 60_000,
});
await assertChatTranscript(handle, [
{ role: "user", text: INITIAL_PROMPT },
{ role: "assistant", text: INITIAL_REPLY },
]);
await sendMessage(handle, FOLLOW_UP_PROMPT);
await assertChatTranscript(handle, [
{ role: "user", text: INITIAL_PROMPT },
{ role: "assistant", text: INITIAL_REPLY },
{ role: "user", text: FOLLOW_UP_PROMPT },
{ role: "assistant", text: FOLLOW_UP_REPLY },
]);
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
});
@@ -123,16 +123,4 @@ test.describe("mobile bottom sheet reopen", () => {
await openAndCloseModelSelectorTwice(page);
});
});
test("model selector closes after model selection", async ({ page }) => {
await withMobileMockAgent(page, async () => {
await openModelSelector(page);
const sheet = page.getByLabel("Bottom Sheet", { exact: true });
await sheet.getByText("Ten second stream", { exact: true }).click();
await expect(sheet).not.toBeVisible({ timeout: 10_000 });
await expect(page.getByRole("button", { name: /Ten second stream/ })).toBeVisible();
});
});
});
+12 -26
View File
@@ -5,25 +5,6 @@ import { test, expect } from "./fixtures";
const COMMIT_SUBJECT = "Show commit timestamps";
test("commit history explains when the workspace has no commits ahead of its base", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace({ prefix: "commit-history-empty-workspace-" });
execFileSync("git", ["checkout", "-b", "feature"], { cwd: workspace.repoPath, stdio: "ignore" });
await workspace.navigateTo();
await page.getByRole("button", { name: "Open explorer" }).click();
const commitsSection = page.getByRole("button", { name: /Commits/i });
await expect(commitsSection).toBeVisible({ timeout: 30_000 });
await commitsSection.click();
await expect(page.getByTestId("commits-section-no-workspace-commits")).toHaveText(
"No commits ahead of main yet",
{ timeout: 30_000 },
);
});
test("commit history shows dates and shares diff layout preferences", async ({
page,
withWorkspace,
@@ -42,26 +23,31 @@ test("commit history shows dates and shares diff layout preferences", async ({
hasText: COMMIT_SUBJECT,
});
await expect(commitRow).toContainText(COMMIT_SUBJECT, { timeout: 30_000 });
await expect(page.locator('[data-testid^="commit-row-"]')).toHaveCount(1);
await expect(commitRow).toContainText("Jan 15");
await commitRow.click();
const panel = page.getByTestId("commit-diff-panel").filter({ visible: true });
await expect(panel.getByTestId("commit-diff-toolbar")).toBeVisible({ timeout: 30_000 });
const layoutToggle = panel.getByTestId("commit-diff-toggle-layout");
await expect(layoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
await expect(panel.getByTestId("commit-diff-layout-unified")).toHaveAttribute(
"aria-selected",
"true",
);
await expect(panel.getByTestId("diff-code-row-0")).toBeVisible({ timeout: 30_000 });
await layoutToggle.click();
await expect(layoutToggle).toHaveAccessibleName("Switch to unified diff");
await panel.getByTestId("commit-diff-layout-split").click();
await expect(panel.getByTestId("commit-diff-layout-split")).toHaveAttribute(
"aria-selected",
"true",
);
await expect(panel.getByTestId("diff-code-row-0")).toHaveCount(0);
await expect(panel.getByTestId("diff-file-0-body")).toBeVisible();
await page.getByTestId(/^workspace-commit-diff-close-/).click();
await expect(panel).toHaveCount(0);
await commitRow.click();
await expect(panel.getByTestId("commit-diff-toggle-layout")).toHaveAccessibleName(
"Switch to unified diff",
await expect(panel.getByTestId("commit-diff-layout-split")).toHaveAttribute(
"aria-selected",
"true",
{ timeout: 30_000 },
);
+6 -178
View File
@@ -1,4 +1,4 @@
import { unlink, writeFile } from "node:fs/promises";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { type Page } from "@playwright/test";
import { buildHostWorkspaceRoute, buildSettingsSectionRoute } from "../src/utils/host-routes";
@@ -10,11 +10,6 @@ import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
interface DirtyWorkspace {
id: string;
repoPath: string;
}
interface WorkspaceFixtureOptions {
includeDeletedFile?: boolean;
}
interface CleanupTask {
@@ -223,99 +218,6 @@ test("changes diff keeps code rows aligned with the gutter", async ({ page }) =>
});
});
test("changes file actions open from the kebab and right-click", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true });
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await expect(page.getByTestId("diff-file-1")).toContainText("zz-deleted.ts");
await page.getByTestId("diff-file-1-actions").click();
await expect(page.getByText("Copy path")).toBeVisible();
await expect(page.getByTestId("diff-file-1-open-file")).toHaveCount(0);
await page.keyboard.press("Escape");
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
await expect(page.getByTestId("diff-file-0-open-file")).toBeVisible();
await page.getByTestId("diff-file-0-open-file").click();
await expect(page.getByTestId("workspace-file-pane")).toBeVisible();
await expect(page.getByTestId("workspace-tab-file_src/use-mounted-tab-set.ts")).toBeVisible();
});
test("Changes switches between inline and full-tab navigation", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true });
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
const changesTabToggle = page.getByTestId("changes-open-tab");
await expect(changesTabToggle).toHaveAccessibleName("Open Changes tab");
await changesTabToggle.click();
await expect(changesTabToggle).toHaveAccessibleName("Close Changes tab");
const visiblePanel = page.getByTestId("working-diff-panel").filter({ visible: true });
await expect(visiblePanel).toBeVisible();
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible();
await expect(visiblePanel).toContainText("zz-deleted.ts");
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
await expect(page.getByTestId("workspace-file-pane")).toHaveCount(0);
await visiblePanel.getByTestId("diff-file-0-toggle").click();
await expect(visiblePanel.getByTestId("diff-file-0-body")).toHaveCount(0);
await visiblePanel.getByTestId("diff-file-0-toggle").click();
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
const workingDiffLayoutToggle = visiblePanel.getByTestId("working-diff-toggle-layout");
await expect(workingDiffLayoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
await workingDiffLayoutToggle.click();
await expect(workingDiffLayoutToggle).toHaveAccessibleName("Switch to unified diff");
await visiblePanel.getByTestId("working-diff-options-menu").click();
await expect(page.getByTestId("working-diff-toggle-whitespace")).toContainText("Hide whitespace");
await expect(page.getByTestId("working-diff-toggle-wrap-lines")).toContainText("Wrap long lines");
await expect(page.getByTestId("working-diff-refresh")).toContainText("Refresh");
await page.getByTestId("working-diff-toggle-wrap-lines").click();
await visiblePanel.getByTestId("working-diff-options-menu").click();
await expect(page.getByTestId("working-diff-toggle-wrap-lines")).toContainText(
"Scroll long lines",
);
await page.keyboard.press("Escape");
await visiblePanel.getByTestId("working-diff-toggle-expand-all").click();
await expect(visiblePanel.getByTestId(/^diff-file-\d+-body$/)).toHaveCount(0);
await visiblePanel.getByTestId("working-diff-toggle-expand-all").click();
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
await page.getByTestId("explorer-content-area").getByTestId("diff-file-0-toggle").click();
await expect(
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
).toHaveCount(0);
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE);
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toHaveCount(0, {
timeout: 30_000,
});
await expect(visiblePanel).toContainText("zz-deleted.ts");
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), AFTER);
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(page.getByTestId("explorer-content-area").getByTestId("diff-file-1")).toContainText(
"zz-deleted.ts",
);
await page.getByTestId("explorer-content-area").getByTestId("diff-file-1-toggle").click();
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
await expect(visiblePanel.getByText("zz-deleted.ts", { exact: true })).toBeVisible();
await expect(visiblePanel).toContainText("Deleted");
await changesTabToggle.click();
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(0);
await expect(
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
).toBeVisible();
await page.getByTestId("explorer-content-area").getByTestId("diff-file-0-toggle").click();
await expect(
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
).toHaveCount(0);
});
test("changes diff switches between flat and tree file lists", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useUnwrappedDiffLines(page);
@@ -341,11 +243,6 @@ test("changes diff switches between flat and tree file lists", async ({ page })
await expect(page.getByTestId("diff-folder-src")).toBeVisible();
await expect(page.getByTestId("diff-file-0")).toBeVisible();
await page.getByRole("button", { name: "Collapse all" }).click();
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
await page.getByRole("button", { name: "Expand all" }).click();
await expect(page.getByTestId("diff-file-0-body")).toBeVisible();
await page.getByTestId("diff-folder-src-toggle").click();
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
@@ -353,53 +250,6 @@ test("changes diff switches between flat and tree file lists", async ({ page })
await expectFlatFileList(page);
});
test("workspace file panes keep their controls on shared alignment rails", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await openWorkspaceChanges(page, workspace);
await page.getByTestId("changes-toggle-view-mode").click();
await expect(page.getByTestId("diff-folder-src")).toBeVisible();
const changesRightRail = await Promise.all([
readSvgRight(page, "explorer-close"),
readSvgRight(page, "changes-options-menu"),
readSvgRight(page, "diff-file-0-actions"),
]);
expectAligned(changesRightRail);
const [folderStat, fileStat] = await Promise.all([
page.getByTestId("diff-folder-src-stat").boundingBox(),
page.getByTestId("diff-file-0-stat").boundingBox(),
]);
expect(folderStat).not.toBeNull();
expect(fileStat).not.toBeNull();
expect(folderStat!.x + folderStat!.width).toBeCloseTo(fileStat!.x + fileStat!.width, 0);
await page.getByTestId("explorer-tab-files").click();
await expect(page.getByTestId("file-explorer-row-0")).toBeVisible();
const filesRightRail = await Promise.all([
readSvgRight(page, "explorer-close"),
readSvgRight(page, "files-refresh"),
readSvgRight(page, "file-explorer-row-0-actions"),
]);
expectAligned(filesRightRail);
const [sortLabel, firstRowIcon, treeBounds, rowBounds] = await Promise.all([
page.getByTestId("files-sort-label").boundingBox(),
page.getByTestId("file-explorer-row-0").locator("svg").first().boundingBox(),
page.getByTestId("file-explorer-tree-scroll").boundingBox(),
page.getByTestId("file-explorer-row-0").boundingBox(),
]);
expect(sortLabel).not.toBeNull();
expect(firstRowIcon).not.toBeNull();
expect(treeBounds).not.toBeNull();
expect(rowBounds).not.toBeNull();
expect(sortLabel!.x).toBeCloseTo(firstRowIcon!.x, 0);
expect(rowBounds!.x).toBeCloseTo(treeBounds!.x, 0);
expect(rowBounds!.x + rowBounds!.width).toBeCloseTo(treeBounds!.x + treeBounds!.width, 0);
});
test("changes diff keeps unwrapped gutter and code rows aligned after code size changes", async ({
page,
}) => {
@@ -549,14 +399,10 @@ async function readVisibleDiffRowGeometry(page: Page): Promise<{
});
}
async function createWorkspaceWithMountedTabDiff(
options: WorkspaceFixtureOptions = {},
): Promise<DirtyWorkspace> {
const files = [{ path: "src/use-mounted-tab-set.ts", content: BEFORE }];
if (options.includeDeletedFile) {
files.push({ path: "src/zz-deleted.ts", content: "export const deleted = true;\n" });
}
const repo = await createTempGitRepo("diff-row-alignment-", { files });
async function createWorkspaceWithMountedTabDiff(): Promise<DirtyWorkspace> {
const repo = await createTempGitRepo("diff-row-alignment-", {
files: [{ path: "src/use-mounted-tab-set.ts", content: BEFORE }],
});
const client = await connectSeedClient();
cleanupTasks.push({
run: async () => {
@@ -566,16 +412,13 @@ async function createWorkspaceWithMountedTabDiff(
});
await writeFile(path.join(repo.path, "src/use-mounted-tab-set.ts"), AFTER);
if (options.includeDeletedFile) {
await unlink(path.join(repo.path, "src/zz-deleted.ts"));
}
const createdWorkspace = await client.createWorkspace({
source: { kind: "directory", path: repo.path },
});
if (!createdWorkspace.workspace) {
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${repo.path}`);
}
return { id: createdWorkspace.workspace.id, repoPath: repo.path };
return { id: createdWorkspace.workspace.id };
}
async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
@@ -593,21 +436,6 @@ async function openChangesInVisibleExplorer(page: Page): Promise<void> {
await expect(page.getByText("use-mounted-tab-set.ts")).toBeVisible({ timeout: 30_000 });
}
async function readSvgRight(page: Page, testID: string): Promise<number> {
const box = await page.getByTestId(testID).locator("svg").first().boundingBox();
if (!box) {
throw new Error(`Could not measure ${testID}`);
}
return box.x + box.width;
}
function expectAligned(values: number[]): void {
const [first, ...rest] = values;
for (const value of rest) {
expect(value).toBeCloseTo(first, 0);
}
}
async function expectExpandedMountedTabDiff(page: Page): Promise<void> {
await expect(page.getByTestId("diff-file-0-body")).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("function createInitialMountedTabIds")).toBeVisible({
-23
View File
@@ -239,29 +239,6 @@ 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");
+1 -1
View File
@@ -144,7 +144,7 @@ export interface SeedDaemonClient {
} | null;
fetchAgentHistory(options?: {
page?: { limit: number };
}): Promise<{ entries: Array<{ agent: { id: string } }> }>;
}): Promise<{ entries: Array<{ id: string }> }>;
subscribeTerminal(
terminalId: string,
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
-238
View File
@@ -1,238 +0,0 @@
import type { Locator, Page } from "@playwright/test";
import { test, expect } from "./fixtures";
import { daemonWsRoutePattern } from "./helpers/daemon-port";
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
type WebSocketMessage = string | Buffer;
interface ShimmerEvidence {
animationDuration: string;
endPx: number;
label: string;
renderedWidth: number;
startPx: number;
}
function parseSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
const raw = typeof message === "string" ? message : message.toString("utf8");
try {
const envelope = JSON.parse(raw) as { type?: unknown; message?: unknown };
return envelope.type === "session" && envelope.message && typeof envelope.message === "object"
? (envelope.message as Record<string, unknown>)
: null;
} catch {
return null;
}
}
function getToolCallStatus(
message: WebSocketMessage,
agentId: string,
): { callId: string; status: string } | null {
const sessionMessage = parseSessionMessage(message);
if (sessionMessage?.type !== "agent_stream") {
return null;
}
const payload = sessionMessage.payload as Record<string, unknown> | undefined;
if (payload?.agentId !== agentId) {
return null;
}
const event = payload.event as Record<string, unknown> | undefined;
const item = event?.item as Record<string, unknown> | undefined;
return event?.type === "timeline" &&
item?.type === "tool_call" &&
typeof item.callId === "string" &&
typeof item.status === "string"
? { callId: item.callId, status: item.status }
: null;
}
function replaceToolCallStatus(message: WebSocketMessage, status: string): string {
const raw = typeof message === "string" ? message : message.toString("utf8");
const envelope = JSON.parse(raw) as {
message?: { payload?: { event?: { item?: { status?: string } } } };
};
const item = envelope.message?.payload?.event?.item;
if (!item) {
throw new Error("Expected a tool-call session message");
}
item.status = status;
return JSON.stringify(envelope);
}
async function gateSecondToolCall(page: Page, agentId: string) {
let firstCallId: string | null = null;
let secondCallId: string | null = null;
let secondRunningMessage: WebSocketMessage | null = null;
let releaseSecondRequested = false;
let pauseServerMessages = false;
let secondRunningForwarded = false;
let forwardSecondRunning: (() => void) | null = null;
let resolveFirstCompleted!: () => void;
let resolveSecondRunning!: () => void;
const firstCompleted = new Promise<void>((resolve) => {
resolveFirstCompleted = resolve;
});
const secondRunning = new Promise<void>((resolve) => {
resolveSecondRunning = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
forwardSecondRunning = () => {
if (!secondRunningMessage || secondRunningForwarded) {
return;
}
ws.send(secondRunningMessage);
secondRunningForwarded = true;
};
ws.onMessage((message) => server.send(message));
server.onMessage((message) => {
if (pauseServerMessages) {
return;
}
const toolCall = getToolCallStatus(message, agentId);
if (!toolCall) {
ws.send(message);
return;
}
if (!firstCallId) {
firstCallId = toolCall.callId;
}
if (toolCall.callId === firstCallId) {
if (toolCall.status === "running" || toolCall.status === "executing") {
return;
}
if (toolCall.status === "completed") {
// The mock tool completes inside the daemon's coalescing window, so the
// browser naturally receives its authoritative completed state first.
ws.send(message);
resolveFirstCompleted();
return;
}
}
secondCallId ??= toolCall.callId;
if (
toolCall.callId === secondCallId &&
(toolCall.status === "running" || toolCall.status === "executing")
) {
secondRunningMessage = message;
pauseServerMessages = true;
resolveSecondRunning();
if (releaseSecondRequested) {
forwardSecondRunning?.();
}
return;
}
if (toolCall.callId === secondCallId && toolCall.status === "completed") {
// Keep the second real tool call inspectably active. Production providers
// send this same status shape while their work remains in flight.
secondRunningMessage = replaceToolCallStatus(message, "running");
pauseServerMessages = true;
resolveSecondRunning();
if (releaseSecondRequested) {
forwardSecondRunning?.();
}
return;
}
ws.send(message);
});
});
return {
waitForFirstCompleted: () => firstCompleted,
waitForSecondRunning: () => secondRunning,
releaseSecondRunning() {
releaseSecondRequested = true;
if (secondRunningMessage) {
forwardSecondRunning?.();
}
},
};
}
async function readShimmerEvidence(locator: Locator): Promise<ShimmerEvidence> {
return locator.evaluate((root) => {
const shimmer = Array.from(root.querySelectorAll<HTMLElement>("*")).find((element) =>
getComputedStyle(element).animationName.includes("paseo-toolcall-shimmer"),
);
if (!shimmer) {
throw new Error("Expected a running shimmer inside the badge");
}
const style = getComputedStyle(shimmer);
return {
animationDuration: style.animationDuration,
endPx: Number.parseFloat(style.getPropertyValue("--paseo-shimmer-end")),
label: shimmer.textContent ?? "",
renderedWidth: shimmer.getBoundingClientRect().width,
startPx: Number.parseFloat(style.getPropertyValue("--paseo-shimmer-start")),
};
});
}
test("measures an overview heading that becomes loading after its idle mount", async ({
page,
}, testInfo) => {
test.setTimeout(120_000);
await page.addInitScript(() => {
localStorage.setItem(
"@paseo:app-settings",
JSON.stringify({ toolCallDetailLevel: "overview" }),
);
});
const agent = await seedMockAgentWorkspace({
repoPrefix: "tool-call-shimmer-",
title: "Tool-call shimmer",
model: "ten-second-stream",
});
try {
const gate = await gateSecondToolCall(page, agent.agentId);
await openAgentRoute(page, {
workspaceId: agent.workspaceId,
agentId: agent.agentId,
});
await agent.client.sendAgentMessage(agent.agentId, "Prove the overview shimmer lifecycle.");
await gate.waitForFirstCompleted();
const group = page.getByTestId("tool-call-group");
await expect(group).toBeVisible();
const idleGroupHandle = await group.elementHandle();
if (!idleGroupHandle) {
throw new Error("Expected the idle tool-call group to be mounted");
}
await expect(group.locator('[style*="paseo-toolcall-shimmer"]')).toHaveCount(0);
await gate.waitForSecondRunning();
gate.releaseSecondRunning();
await expect(group.locator('[style*="paseo-toolcall-shimmer"]')).not.toHaveCount(0);
const sameGroupNode = await group.evaluate(
(node, previous) => node === previous,
idleGroupHandle,
);
await group.click();
const runningChild = page.getByTestId("tool-call-badge").last();
await expect(runningChild).toBeVisible();
const header = await readShimmerEvidence(group);
const child = await readShimmerEvidence(runningChild);
const evidence = { sameGroupNode, header, child };
await testInfo.attach("tool-call-shimmer-evidence", {
body: JSON.stringify(evidence, null, 2),
contentType: "application/json",
});
expect(sameGroupNode).toBe(true);
expect(child.endPx).toBeGreaterThan(0);
expect(
header.endPx,
`The retained header rendered ${header.renderedWidth}px wide but its shimmer still ends at ${header.endPx}px`,
).toBeGreaterThan(0);
} finally {
await agent.cleanup();
}
});
@@ -1,8 +1,4 @@
import {
buildHostAgentDetailRoute,
buildHostWorkspaceOpenRoute,
buildHostWorkspaceRoute,
} from "@/utils/host-routes";
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
import { expect, test, type Page } from "./fixtures";
import { gotoAppShell, openSettings } from "./helpers/app";
import {
@@ -38,7 +34,6 @@ import { getServerId } from "./helpers/server-id";
import { injectDesktopBridge, waitForDesktopDaemonStartRequest } from "./helpers/desktop-updates";
import { expectAppRoute } from "./helpers/route-assertions";
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
import { addOfflineHostAndReload } from "./helpers/hosts";
const LOADING_WORKSPACE_TEXT_PATTERN = /Loading workspace/i;
type StartupPresentation = "splash" | "app";
@@ -163,43 +158,6 @@ async function expectWorkspaceLocation(
test.describe("Workspace navigation regression", () => {
test.describe.configure({ timeout: 240_000 });
test("opens a notification's workspace on a different offline host", async ({ page }) => {
const target = {
serverId: "notification-offline-host",
workspaceId: "notification-workspace",
agentId: "notification-agent",
};
await gotoAppShell(page);
await addOfflineHostAndReload(page, {
serverId: target.serverId,
label: "Notification Host",
});
await expect(
page.getByTestId("sidebar-settings").filter({ visible: true }).first(),
).toBeVisible({
timeout: 30_000,
});
await page.evaluate((data) => {
globalThis.dispatchEvent(
new CustomEvent("paseo:web-notification-click", {
detail: { data: { ...data, reason: "finished" } },
cancelable: true,
}),
);
}, target);
await expectAppRoute(
page,
buildHostWorkspaceOpenRoute(target.serverId, target.workspaceId, `agent:${target.agentId}`),
{ timeout: 30_000 },
);
await expect(page.getByText("Connecting", { exact: true })).toBeVisible();
await expect(page.getByText("Notification Host", { exact: true })).toBeVisible();
await expect(page.getByText("Add a project", { exact: true })).toHaveCount(0);
});
test("keeps one replacement draft after returning from settings and closing the last tab", async ({
page,
withWorkspace,
@@ -462,13 +420,12 @@ test.describe("Workspace navigation regression", () => {
await expectWorkspaceDeckEntryCount(page, 2);
await page.evaluate(
({ agentId, serverId: targetServerId, workspaceId }) => {
({ agentId, serverId: targetServerId }) => {
globalThis.dispatchEvent(
new CustomEvent("paseo:web-notification-click", {
detail: {
data: {
serverId: targetServerId,
workspaceId,
agentId,
reason: "finished",
},
@@ -477,7 +434,7 @@ test.describe("Workspace navigation regression", () => {
}),
);
},
{ agentId: secondAgent.id, serverId, workspaceId: secondWorkspace.workspaceId },
{ agentId: secondAgent.id, serverId },
);
await waitForWorkspaceTabsVisible(page);
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), {
+9 -78
View File
@@ -44,10 +44,7 @@ test.describe("Worktree restore", () => {
tempRepo = await createTempGitRepo("wt-restore-");
});
async function createArchivedMissingWorktree(
prefix: string,
options: { agentCount?: number; keepAgentsArchived?: boolean } = {},
) {
async function createArchivedMissingWorktree(prefix: string) {
const project = await openProjectViaDaemon(worktreeClient, tempRepo.path);
createdProjectIds.add(project.projectKey);
const worktree = await createWorktreeViaDaemon(worktreeClient, {
@@ -56,19 +53,11 @@ test.describe("Worktree restore", () => {
});
createdProjectIds.add(worktree.projectKey);
createdWorktreeDirectories.add(worktree.workspaceDirectory);
const agents = await Promise.all(
Array.from({ length: options.agentCount ?? 1 }, () =>
createIdleAgent(client, {
cwd: worktree.workspaceDirectory,
workspaceId: worktree.workspaceId,
title: `${prefix}-${randomUUID().slice(0, 8)}`,
}),
),
);
const agent = agents[0];
if (!agent) {
throw new Error("Expected at least one archived-worktree agent");
}
const agent = await createIdleAgent(client, {
cwd: worktree.workspaceDirectory,
workspaceId: worktree.workspaceId,
title: `${prefix}-${randomUUID().slice(0, 8)}`,
});
await archiveWorkspaceFromDaemon(worktreeClient, worktree.workspaceDirectory);
await expect
@@ -78,21 +67,11 @@ test.describe("Worktree restore", () => {
// Match the remote cloud-race record: workspace archived and absent, while
// the surviving closed agent record is not agent-archived. Refresh now owns
// only agent lifecycle, so its expected cwd failure cannot recover the workspace.
if (options.keepAgentsArchived) {
for (const archivedAgent of agents) {
await expect
.poll(() => fetchAgentArchivedAt(client, archivedAgent.id), { timeout: 30_000 })
.not.toBeNull();
}
} else {
await client.refreshAgent(agent.id).catch(() => undefined);
await expect
.poll(() => fetchAgentArchivedAt(client, agent.id), { timeout: 30_000 })
.toBeNull();
}
await client.refreshAgent(agent.id).catch(() => undefined);
await expect.poll(() => fetchAgentArchivedAt(client, agent.id), { timeout: 30_000 }).toBeNull();
expect(existsSync(worktree.workspaceDirectory)).toBe(false);
return { agent, agents, worktree };
return { agent, worktree };
}
async function openArchivedWorkspaceFromHistory(page: Page, prefix: string) {
@@ -262,54 +241,6 @@ test.describe("Worktree restore", () => {
).toHaveText(switchedBranch, { timeout: 30_000 });
});
test("recovers the selected agent with its workspace and later rescues another archived agent", async ({
page,
}) => {
const { agents, worktree } = await createArchivedMissingWorktree("restore-agents", {
agentCount: 2,
keepAgentsArchived: true,
});
const [firstAgent, secondAgent] = agents;
if (!firstAgent || !secondAgent) {
throw new Error("Expected two archived agents");
}
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openSessions(page);
await page.getByTestId(`agent-row-${getServerId()}-${firstAgent.id}`).click();
await expect(page.getByText("Workspace archived", { exact: true })).toBeVisible({
timeout: 30_000,
});
await page.getByTestId("workspace-recovery-action").click();
await expect
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
.toBe(true);
await expect(
page.getByTestId(`workspace-tab-agent_${firstAgent.id}`).filter({ visible: true }).first(),
).toBeVisible({ timeout: 30_000 });
await expect
.poll(() => fetchAgentArchivedAt(client, firstAgent.id), { timeout: 30_000 })
.toBeNull();
await expect(page.getByRole("button", { name: "Unarchive" })).toHaveCount(0, {
timeout: 30_000,
});
await openSessions(page);
await page.getByTestId(`agent-row-${getServerId()}-${secondAgent.id}`).click();
await expect(
page.getByTestId(`workspace-tab-agent_${secondAgent.id}`).filter({ visible: true }).first(),
).toBeVisible({ timeout: 30_000 });
await expect(page.getByRole("button", { name: "Unarchive" })).toBeVisible({
timeout: 30_000,
});
await page.getByRole("button", { name: "Unarchive" }).click();
await expect
.poll(() => fetchAgentArchivedAt(client, secondAgent.id), { timeout: 30_000 })
.toBeNull();
});
test("restore failure stays visible and permits a successful retry", async ({ page }) => {
const { agent, worktree } = await openArchivedWorkspaceFromHistory(page, "restore-retry");
const displacedProjectPath = `${tempRepo.path}-temporarily-unavailable`;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.2.1",
"version": "0.2.0-beta.2",
"private": true,
"main": "index.ts",
"scripts": {
+13 -46
View File
@@ -11,51 +11,6 @@ const EXCLUDED_ANDROID_MODULES = [
"expo-dev-menu-interface",
];
const FDROID_ABI_VERSION_CODE_BLOCK = `// Paseo F-Droid single-ABI version codes
def paseoAbiVersionCodes = [
"armeabi-v7a": 1,
"arm64-v8a": 2,
"x86": 3,
"x86_64": 4,
]
def paseoArchitectures = (findProperty("reactNativeArchitectures") ?: "")
.toString()
.split(",")
.collect { it.trim() }
.findAll { !it.isEmpty() }
if (paseoArchitectures.size() == 1) {
def paseoAbi = paseoArchitectures[0]
def paseoAbiVersionCode = paseoAbiVersionCodes[paseoAbi]
if (paseoAbiVersionCode == null) {
throw new GradleException("Unsupported Paseo Android ABI: " + paseoAbi)
}
android.defaultConfig.versionCode = android.defaultConfig.versionCode * 10 + paseoAbiVersionCode
}
`;
function configureFdroidAppBuildGradle(contents) {
let configuredContents = contents;
if (!configuredContents.includes("dependenciesInfo {")) {
const androidBlock = "android {";
if (!configuredContents.includes(androidBlock)) {
throw new Error("Could not disable F-Droid dependency metadata in app/build.gradle");
}
configuredContents = configuredContents.replace(
androidBlock,
`${androidBlock}\n dependenciesInfo {\n includeInApk = false\n includeInBundle = false\n }`,
);
}
if (!configuredContents.includes("// Paseo F-Droid single-ABI version codes")) {
configuredContents = `${configuredContents.trimEnd()}\n\n${FDROID_ABI_VERSION_CODE_BLOCK}`;
}
return configuredContents;
}
function withFdroidAutolinking(config) {
config = withDangerousMod(config, [
"android",
@@ -110,7 +65,19 @@ function withFdroidAutolinking(config) {
});
return withAppBuildGradle(config, (modConfig) => {
modConfig.modResults.contents = configureFdroidAppBuildGradle(modConfig.modResults.contents);
if (modConfig.modResults.contents.includes("dependenciesInfo {")) {
return modConfig;
}
const androidBlock = "android {";
if (!modConfig.modResults.contents.includes(androidBlock)) {
throw new Error("Could not disable F-Droid dependency metadata in app/build.gradle");
}
modConfig.modResults.contents = modConfig.modResults.contents.replace(
androidBlock,
`${androidBlock}\n dependenciesInfo {\n includeInApk = false\n includeInBundle = false\n }`,
);
return modConfig;
});
}
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import {
__private__,
deriveBottomAnchorBlockedReason,
@@ -126,15 +126,10 @@ function createDriverHarness(input?: {
measurementState,
nearBottom: input?.isNearBottom ?? true,
};
const scrollAttempts: boolean[] = [];
let scrollToBottomBehavior = () => {
const scrollToBottom = vi.fn(() => {
context.nearBottom = true;
context.measurementState.offsetY = 720;
};
const scrollToBottom = (animated: boolean) => {
scrollAttempts.push(animated);
scrollToBottomBehavior();
};
});
const modeChanges: BottomAnchorMode[] = [];
const driver = __private__.createBottomAnchorControllerDriver({
getAgentId: () => context.agentId,
@@ -155,10 +150,7 @@ function createDriverHarness(input?: {
context,
driver,
scheduler,
scrollAttempts,
setScrollToBottomBehavior(next: () => void) {
scrollToBottomBehavior = next;
},
scrollToBottom,
modeChanges,
};
}
@@ -218,7 +210,7 @@ describe("bottom anchor controller driver", () => {
});
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(0);
expect(harness.scrollToBottom).not.toHaveBeenCalled();
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
blockedReason: "waiting_for_history_readiness",
@@ -237,7 +229,7 @@ describe("bottom anchor controller driver", () => {
harness.driver.reevaluate();
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(1);
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
expect(harness.driver.getSnapshot()).toMatchObject({
blockedReason: null,
pendingRequest: null,
@@ -245,97 +237,6 @@ describe("bottom anchor controller driver", () => {
});
});
it("preserves a blocked route anchor while a user scroll ends at the bottom", () => {
const harness = createDriverHarness({ authoritativeReady: false });
harness.driver.applyRouteRequest({
agentId: "agent-1",
reason: "initial-entry",
requestKey: "route:agent-1:initial-entry",
});
harness.scheduler.flushAll();
harness.driver.beginUserScroll();
harness.context.authoritativeReady = true;
harness.driver.notifyAuthoritativeHistoryMaybeChanged();
harness.driver.reevaluate();
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(0);
harness.driver.endUserScroll({ isNearBottom: true });
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(1);
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
blockedReason: null,
pendingRequest: null,
pendingVerification: null,
});
});
it("preserves a blocked route anchor when layout moves after drag release", () => {
const harness = createDriverHarness({ authoritativeReady: false });
harness.driver.applyRouteRequest({
agentId: "agent-1",
reason: "resume",
requestKey: "route:agent-1:resume",
});
harness.scheduler.flushAll();
harness.driver.beginUserScroll();
harness.context.nearBottom = false;
harness.driver.handleScrollNearBottomChange({
nextIsNearBottom: false,
scrollDelta: 0,
});
harness.context.authoritativeReady = true;
harness.driver.notifyAuthoritativeHistoryMaybeChanged();
harness.driver.endUserScroll({ isNearBottom: true });
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(1);
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
blockedReason: null,
pendingRequest: null,
pendingVerification: null,
});
});
it("lets a user scroll away supersede a blocked route anchor", () => {
const harness = createDriverHarness({ authoritativeReady: false });
harness.driver.applyRouteRequest({
agentId: "agent-1",
reason: "resume",
requestKey: "route:agent-1:resume",
});
harness.scheduler.flushAll();
harness.driver.beginUserScroll();
harness.context.nearBottom = false;
harness.driver.handleScrollNearBottomChange({
nextIsNearBottom: false,
scrollDelta: 48,
});
harness.driver.endUserScroll({ isNearBottom: false });
harness.context.authoritativeReady = true;
harness.driver.notifyAuthoritativeHistoryMaybeChanged();
harness.driver.reevaluate();
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(0);
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "detached",
blockedReason: null,
pendingRequest: null,
pendingVerification: null,
});
});
it("suppresses sticky maintenance while detached", () => {
const harness = createDriverHarness();
@@ -353,74 +254,7 @@ describe("bottom anchor controller driver", () => {
harness.scheduler.flushAll();
expect(harness.driver.getSnapshot().mode).toBe("detached");
expect(harness.scrollAttempts).toHaveLength(0);
});
it("pauses sticky maintenance while a user scroll owns the viewport", () => {
const harness = createDriverHarness({
transportBehavior: {
verificationDelayFrames: 2,
verificationRetryMode: "recheck",
},
});
harness.driver.prepareForStickyContentChange();
harness.driver.beginUserScroll();
harness.driver.handleContentSizeChange({
previousContentHeight: 1200,
contentHeight: 1400,
});
harness.context.nearBottom = false;
harness.driver.handleScrollNearBottomChange({
nextIsNearBottom: false,
scrollDelta: 1,
});
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(1);
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
pendingRequest: null,
pendingVerification: null,
});
harness.driver.endUserScroll({ isNearBottom: false });
expect(harness.driver.getSnapshot().mode).toBe("detached");
});
it("restores sticky maintenance when a user scroll returns to the bottom", () => {
const harness = createDriverHarness({
transportBehavior: {
verificationDelayFrames: 2,
verificationRetryMode: "recheck",
},
});
harness.driver.beginUserScroll();
harness.context.nearBottom = false;
harness.driver.handleScrollNearBottomChange({
nextIsNearBottom: false,
scrollDelta: 48,
});
harness.driver.handleContentSizeChange({
previousContentHeight: 1200,
contentHeight: 1400,
});
harness.context.nearBottom = true;
harness.driver.handleScrollNearBottomChange({
nextIsNearBottom: true,
scrollDelta: -48,
});
harness.driver.endUserScroll({ isNearBottom: true });
harness.scheduler.flushAll();
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
pendingRequest: null,
pendingVerification: null,
});
expect(harness.scrollAttempts).toHaveLength(1);
expect(harness.scrollToBottom).not.toHaveBeenCalled();
});
it("switches back to sticky-bottom for explicit jump-to-bottom", () => {
@@ -437,7 +271,7 @@ describe("bottom anchor controller driver", () => {
expect(harness.modeChanges).toContain("detached");
expect(harness.modeChanges).toContain("sticky-bottom");
expect(harness.scrollAttempts).toHaveLength(1);
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
expect(harness.driver.getSnapshot().mode).toBe("sticky-bottom");
});
@@ -458,7 +292,7 @@ describe("bottom anchor controller driver", () => {
});
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(2);
expect(harness.scrollToBottom).toHaveBeenCalledTimes(2);
});
it("keeps a pending request blocked when stale container measurements arrive", () => {
@@ -479,7 +313,7 @@ describe("bottom anchor controller driver", () => {
});
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(0);
expect(harness.scrollToBottom).not.toHaveBeenCalled();
expect(harness.driver.getSnapshot()).toMatchObject({
blockedReason: "waiting_for_measurable_viewport",
pendingRequest: {
@@ -492,7 +326,7 @@ describe("bottom anchor controller driver", () => {
harness.driver.reevaluate();
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(1);
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
expect(harness.driver.getSnapshot().pendingRequest).toBeNull();
});
@@ -504,7 +338,7 @@ describe("bottom anchor controller driver", () => {
},
isNearBottom: false,
});
harness.setScrollToBottomBehavior(() => {
harness.scrollToBottom.mockImplementation(() => {
harness.context.measurementState.offsetY = 0;
});
@@ -514,16 +348,16 @@ describe("bottom anchor controller driver", () => {
});
harness.scheduler.flushFrame();
expect(harness.scrollAttempts).toHaveLength(1);
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
harness.scheduler.flushFrame();
harness.scheduler.flushFrame();
expect(harness.scrollAttempts).toHaveLength(1);
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
harness.context.nearBottom = true;
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(1);
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
expect(harness.driver.getSnapshot().pendingRequest).toBeNull();
});
@@ -541,7 +375,7 @@ describe("bottom anchor controller driver", () => {
isNearBottom: false,
});
harness.setScrollToBottomBehavior(() => {
harness.scrollToBottom.mockImplementation(() => {
harness.context.measurementState.offsetY = 13476;
});
@@ -552,7 +386,7 @@ describe("bottom anchor controller driver", () => {
});
harness.scheduler.flushFrame();
expect(harness.scrollAttempts).toHaveLength(1);
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
harness.context.measurementState.contentHeight = 14804;
harness.context.nearBottom = false;
@@ -575,7 +409,7 @@ describe("bottom anchor controller driver", () => {
harness.scheduler.flushFrame();
expect(harness.scrollAttempts).toHaveLength(2);
expect(harness.scrollToBottom).toHaveBeenCalledTimes(2);
expect(harness.driver.getSnapshot()).toMatchObject({
blockedReason: "waiting_for_post_layout_verification",
pendingRequest: {
@@ -601,7 +435,7 @@ describe("bottom anchor controller driver", () => {
isNearBottom: false,
});
harness.setScrollToBottomBehavior(() => {
harness.scrollToBottom.mockImplementation(() => {
harness.context.measurementState.offsetY = Math.max(
0,
harness.context.measurementState.contentHeight -
@@ -637,7 +471,7 @@ describe("bottom anchor controller driver", () => {
harness.scheduler.flushFrame();
harness.scheduler.flushFrame();
expect(harness.scrollAttempts).toHaveLength(2);
expect(harness.scrollToBottom).toHaveBeenCalledTimes(2);
expect(harness.driver.getSnapshot().pendingRequest).toMatchObject({
reason: "resume",
});
@@ -646,7 +480,7 @@ describe("bottom anchor controller driver", () => {
it("keeps sticky-bottom during viewport growth until bottom is re-verified", () => {
const harness = createDriverHarness();
harness.context.nearBottom = false;
harness.setScrollToBottomBehavior(() => {
harness.scrollToBottom.mockImplementation(() => {
harness.context.measurementState.offsetY = 720;
});
@@ -658,7 +492,7 @@ describe("bottom anchor controller driver", () => {
});
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(4);
expect(harness.scrollToBottom).toHaveBeenCalledTimes(4);
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
pendingRequest: null,
@@ -689,7 +523,7 @@ describe("bottom anchor controller driver", () => {
it("keeps sticky-bottom during streaming growth until bottom is re-verified", () => {
const harness = createDriverHarness();
harness.context.nearBottom = false;
harness.setScrollToBottomBehavior(() => {
harness.scrollToBottom.mockImplementation(() => {
harness.context.measurementState.offsetY = 900;
});
@@ -699,7 +533,7 @@ describe("bottom anchor controller driver", () => {
});
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toHaveLength(4);
expect(harness.scrollToBottom).toHaveBeenCalledTimes(4);
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
pendingRequest: null,
@@ -743,7 +577,7 @@ describe("bottom anchor controller driver", () => {
contentMeasuredForKey: null,
}),
});
harness.setScrollToBottomBehavior(() => {
harness.scrollToBottom.mockImplementation(() => {
harness.context.measurementState.offsetY = 0;
});
@@ -754,7 +588,7 @@ describe("bottom anchor controller driver", () => {
contentHeight: 1348,
});
expect(harness.scrollAttempts).toHaveLength(1);
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
pendingVerification: {
@@ -791,14 +625,14 @@ describe("bottom anchor controller driver", () => {
contentMeasuredForKey: "native-virtualized",
}),
});
harness.setScrollToBottomBehavior(() => {
harness.scrollToBottom.mockImplementation(() => {
harness.context.measurementState.offsetY = 0;
harness.context.nearBottom = true;
});
harness.driver.prepareForStickyContentChange();
expect(harness.scrollAttempts).toHaveLength(1);
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
pendingVerification: {
@@ -68,8 +68,6 @@ interface BottomAnchorControllerDriver {
resetForAgent: () => void;
applyRouteRequest: (request: BottomAnchorRouteRequest | null) => void;
requestLocalAnchor: (request: BottomAnchorLocalRequest) => void;
beginUserScroll: () => void;
endUserScroll: (params: { isNearBottom: boolean }) => void;
detachByUser: () => void;
handleViewportMetricsChange: (params: {
previousViewportWidth: number;
@@ -245,7 +243,6 @@ function createBottomAnchorControllerDriver(
let lastRouteRequestKey: string | null = null;
let stickyMeasurementRevision = 0;
let lastVerifiedStickyMeasurementRevision = 0;
let isUserScrollActive = false;
const setBlockedReason = (nextBlockedReason: BottomAnchorBlockedReason | null) => {
if (blockedReason === nextBlockedReason) {
@@ -414,14 +411,10 @@ function createBottomAnchorControllerDriver(
| "viewport_change"
| "content_size_change"
| "scroll_near_bottom_change"
| "user_scroll_end"
| "history_readiness_change"
| "manual_reevaluate"
| "retry_scroll",
) => {
if (isUserScrollActive) {
return;
}
if (attemptHandle) {
return;
}
@@ -488,7 +481,6 @@ function createBottomAnchorControllerDriver(
cancelPendingAttempt();
stickyMeasurementRevision = 0;
lastVerifiedStickyMeasurementRevision = 0;
isUserScrollActive = false;
mode = "sticky-bottom";
input.onModeChange("sticky-bottom");
},
@@ -505,38 +497,6 @@ function createBottomAnchorControllerDriver(
requestLocalAnchor(request) {
createRequest(request);
},
beginUserScroll() {
isUserScrollActive = true;
cancelPendingAttempt();
},
endUserScroll(params) {
isUserScrollActive = false;
if (params.isNearBottom) {
if (mode === "detached") {
setModeInternal("sticky-bottom");
pendingVerification = { requestId: null, retries: 0 };
evaluate(false, "user_scroll_end");
return;
}
if (pendingRequest) {
evaluate(false, "user_scroll_end");
return;
}
if (
!input.isNearBottom() ||
stickyMeasurementRevision !== lastVerifiedStickyMeasurementRevision
) {
pendingVerification = { requestId: null, retries: 0 };
evaluate(false, "user_scroll_end");
return;
}
markStickyMeasurementVerified();
return;
}
if (mode === "sticky-bottom") {
this.detachByUser();
}
},
detachByUser() {
if (mode === "detached") {
return;
@@ -551,9 +511,6 @@ function createBottomAnchorControllerDriver(
) {
markStickyMeasurementChanged();
}
if (isUserScrollActive) {
return;
}
const shouldRestick = __private__.shouldRestickOnViewportChange({
mode,
previousViewportWidth: params.previousViewportWidth,
@@ -572,9 +529,6 @@ function createBottomAnchorControllerDriver(
if (params.previousContentHeight !== params.contentHeight) {
markStickyMeasurementChanged();
}
if (isUserScrollActive) {
return;
}
const shouldRestick = __private__.shouldRestickOnContentChange({
mode,
previousContentHeight: params.previousContentHeight,
@@ -604,9 +558,6 @@ function createBottomAnchorControllerDriver(
return;
}
markStickyMeasurementChanged();
if (isUserScrollActive) {
return;
}
if (!pendingRequest) {
pendingVerification = { requestId: null, retries: 0 };
if (attemptHandle) {
@@ -620,9 +571,6 @@ function createBottomAnchorControllerDriver(
},
handleScrollNearBottomChange(params) {
const { nextIsNearBottom, scrollDelta } = params;
if (isUserScrollActive) {
return;
}
if (
nextIsNearBottom &&
mode === "sticky-bottom" &&
@@ -789,12 +737,6 @@ export function useBottomAnchorController(input: {
requestLocalAnchor(request: BottomAnchorLocalRequest) {
driverRef.current?.requestLocalAnchor(request);
},
beginUserScroll() {
driverRef.current?.beginUserScroll();
},
endUserScroll(params: { isNearBottom: boolean }) {
driverRef.current?.endUserScroll(params);
},
detachByUser() {
driverRef.current?.detachByUser();
},
@@ -89,8 +89,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
contentMeasuredForKey: null as string | null,
});
const scrollOffsetYRef = useRef(0);
const isUserScrollActiveRef = useRef(false);
const userScrollEndFrameIdRef = useRef<number | null>(null);
const programmaticScrollEventBudgetRef = useRef(0);
const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false);
const nativeViewportSettlingFrameIdRef = useRef<number | null>(null);
@@ -131,13 +129,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
}
}, []);
const clearPendingUserScrollEnd = useCallback(() => {
if (userScrollEndFrameIdRef.current !== null) {
cancelAnimationFrame(userScrollEndFrameIdRef.current);
userScrollEndFrameIdRef.current = null;
}
}, []);
const markNativeViewportSettling = useCallback(() => {
clearNativeViewportSettling();
setIsNativeViewportSettling(true);
@@ -217,8 +208,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
contentMeasuredForKey: null,
};
scrollOffsetYRef.current = 0;
isUserScrollActiveRef.current = false;
clearPendingUserScrollEnd();
clearNativeViewportSettling();
setIsNativeViewportSettling(false);
historyStartReadyRef.current = false;
@@ -227,9 +216,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
});
return () => {
cancelAnimationFrame(frame);
clearPendingUserScrollEnd();
};
}, [agentId, clearNativeViewportSettling, clearPendingUserScrollEnd]);
}, [agentId, clearNativeViewportSettling]);
useEffect(() => {
const keyboardEvents = [
@@ -278,19 +266,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
};
}, [agentId, bottomAnchorController, markNativeViewportSettling, viewportRef]);
const isScrollEventNearBottom = useStableEvent(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
return isNearBottomForStreamRenderStrategy({
strategy,
offsetY: contentOffset.y,
threshold: 32,
contentHeight: contentSize.height,
viewportHeight: layoutMeasurement.height,
});
},
);
const handleScroll = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
const previousOffsetY = scrollOffsetYRef.current;
@@ -305,7 +280,13 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
contentMeasuredForKey: "native-virtualized",
};
const nearBottom = isScrollEventNearBottom(event);
const nearBottom = isNearBottomForStreamRenderStrategy({
strategy,
offsetY: contentOffset.y,
threshold: 32,
contentHeight: streamViewportMetricsRef.current.contentHeight,
viewportHeight: streamViewportMetricsRef.current.viewportHeight,
});
onNearBottomChange(nearBottom);
const distanceFromOldestEdge =
@@ -320,11 +301,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
onNearHistoryStart();
}
if (
!isUserScrollActiveRef.current &&
programmaticScrollEventBudgetRef.current > 0 &&
contentOffset.y <= 8
) {
if (programmaticScrollEventBudgetRef.current > 0 && contentOffset.y <= 8) {
programmaticScrollEventBudgetRef.current -= 1;
} else {
programmaticScrollEventBudgetRef.current = 0;
@@ -335,37 +312,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
}
});
const handleScrollBeginDrag = useStableEvent(() => {
clearPendingUserScrollEnd();
isUserScrollActiveRef.current = true;
bottomAnchorController.beginUserScroll();
});
// Defer drag end so momentum can take ownership, but capture the terminal
// gesture position now because layout may move the viewport in the meantime.
const handleScrollEndDrag = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
const isNearBottom = isScrollEventNearBottom(event);
clearPendingUserScrollEnd();
userScrollEndFrameIdRef.current = requestAnimationFrame(() => {
userScrollEndFrameIdRef.current = null;
isUserScrollActiveRef.current = false;
bottomAnchorController.endUserScroll({ isNearBottom });
});
});
const handleMomentumScrollBegin = useStableEvent(() => {
clearPendingUserScrollEnd();
});
const handleMomentumScrollEnd = useStableEvent(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
const isNearBottom = isScrollEventNearBottom(event);
clearPendingUserScrollEnd();
isUserScrollActiveRef.current = false;
bottomAnchorController.endUserScroll({ isNearBottom });
},
);
const handleListLayout = useStableEvent((event: LayoutChangeEvent) => {
const previousViewportWidth = streamViewportMetricsRef.current.viewportWidth;
const previousViewportHeight = streamViewportMetricsRef.current.viewportHeight;
@@ -473,10 +419,6 @@ 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,89 +199,6 @@ describe("createWebStreamStrategy", () => {
expect(renderLiveHeadRow).toHaveBeenCalledTimes(2);
});
it("keeps bottom anchoring through subpixel browser rounding", () => {
const scrollTo = vi.fn();
HTMLElement.prototype.scrollTo = scrollTo;
const strategy = createWebStreamStrategy({ isMobileBreakpoint: false });
const viewportRef = React.createRef<StreamViewportHandle>();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root?.render(
strategy.render({
agentId: "agent",
segments: {
historyVirtualized: [],
historyMounted: [userMessage(1)],
liveHead: [],
},
boundary: {
hasVirtualizedHistory: false,
hasMountedHistory: true,
hasLiveHead: false,
},
renderers: createRenderers(vi.fn()),
listEmptyComponent: null,
viewportRef,
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: vi.fn(),
onNearHistoryStart: vi.fn(),
isLoadingOlderHistory: false,
hasOlderHistory: false,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
}),
);
});
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
if (!(scrollContainer instanceof HTMLElement)) {
throw new Error("Expected agent chat scroll container");
}
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 766 });
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 5725 });
Object.defineProperty(scrollContainer, "scrollTop", {
configurable: true,
value: 4959.1708984375,
});
scrollTo.mockClear();
act(() => {
viewportRef.current?.scrollToBottom("message-sent");
});
expect(scrollTo).toHaveBeenCalledWith({ top: 5725, behavior: "auto" });
scrollTo.mockClear();
Object.defineProperty(scrollContainer, "scrollTop", {
configurable: true,
value: 4960.5,
});
act(() => {
viewportRef.current?.scrollToBottom("message-sent");
});
expect(scrollTo).toHaveBeenCalledWith({ top: 5725, behavior: "auto" });
scrollTo.mockClear();
Object.defineProperty(scrollContainer, "scrollTop", {
configurable: true,
value: 4967,
});
act(() => {
viewportRef.current?.scrollToBottom("message-sent");
});
expect(scrollTo).not.toHaveBeenCalled();
});
it("fires near-history-start when the user scrolls near the top", async () => {
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
const viewportRef = React.createRef<StreamViewportHandle>();
@@ -22,7 +22,6 @@ type ScrollBehaviorLike = "auto" | "smooth";
const WEB_BOTTOM_SETTLE_TIMEOUT_MS = 200;
const USER_SCROLL_DELTA_EPSILON = 1;
const BOTTOM_OVERSCROLL_TOLERANCE_PX = 2;
const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64;
const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1;
const HISTORY_START_THRESHOLD_PX = 96;
@@ -89,8 +88,7 @@ function getScrollContainerDistanceFromBottom(
function isScrollContainerOverscrolledPastBottom(
scrollContainer: Pick<HTMLElement, "scrollTop" | "clientHeight" | "scrollHeight">,
): boolean {
// Browser zoom can leave scrollTop fractional while the height metrics remain integer-valued.
return getScrollContainerDistanceFromBottom(scrollContainer) < -BOTTOM_OVERSCROLL_TOLERANCE_PX;
return getScrollContainerDistanceFromBottom(scrollContainer) < 0;
}
function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: boolean }) {
+2 -2
View File
@@ -99,7 +99,7 @@ import {
useWorkspaceAttachmentsStore,
} from "@/attachments/workspace-attachments-store";
import type { WorkspaceComposerAttachment } from "@/attachments/types";
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/workspace-tabs/model";
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { toErrorMessage } from "@/utils/error-messages";
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
@@ -876,7 +876,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[pendingPermissions, agentId],
);
const showRunningTurnFooter = baseRenderModel.turnTiming.isActive;
const showRunningTurnFooter = context.status === "running";
const pendingPermissionsNode = useMemo(
() =>
renderPendingPermissionsNode({
+2 -5
View File
@@ -66,7 +66,6 @@ import {
import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation";
import { ThemedStack } from "@/navigation/themed-stack";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { AgentNavigationListener } from "@/desktop/agent-navigation";
import { listenToDesktopEvent } from "@/desktop/electron/events";
import { updateDesktopWindowControls } from "@/desktop/electron/window";
import { getDesktopHost } from "@/desktop/host";
@@ -147,10 +146,9 @@ function PushNotificationRouter() {
const openNotification = useStableEvent((data: Record<string, unknown> | undefined) => {
const target = resolveNotificationTarget(data);
const serverId = target.serverId;
const workspaceId = target.workspaceId;
const agentId = target.agentId;
if (serverId && workspaceId && agentId) {
navigateToAgent({ serverId, workspaceId, agentId, pin: true });
if (serverId && agentId) {
navigateToAgent({ serverId, agentId, pin: true });
return;
}
@@ -910,7 +908,6 @@ function AppShell() {
<MobilePanelsProvider>
<HorizontalScrollProvider>
<OpenProjectListener />
<AgentNavigationListener />
<AppWithSidebar>
<WorkspaceRouteNavigationBridge />
<RootStack />
@@ -1,13 +1,10 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import { useEffect, useRef } from "react";
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { useFetchQuery } from "@/data/query";
import { resolveAgentRoute, type AgentRouteLookup } from "@/navigation/agent-route-resolution";
import { AgentRouteResolutionView } from "@/navigation/agent-route-resolution-view";
import { useSessionStore } from "@/stores/session-store";
import { getHostRuntimeStore, useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
import { toErrorMessage } from "@/utils/error-messages";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { buildHostRootRoute } from "@/utils/host-routes";
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
import { navigateToAgent } from "@/utils/navigate-to-agent";
export default function HostAgentReadyRoute() {
@@ -24,128 +21,97 @@ function HostAgentReadyRouteContent() {
serverId?: string;
agentId?: string;
}>();
const handledNavigationRef = useRef<string | null>(null);
const redirectedRef = useRef(false);
const serverId = typeof params.serverId === "string" ? params.serverId : "";
const agentId = typeof params.agentId === "string" ? params.agentId : "";
const hosts = useHosts();
const runtimeSnapshot = useHostRuntimeSnapshot(serverId);
const client = runtimeSnapshot?.client ?? null;
const connectionStatus = runtimeSnapshot?.connectionStatus ?? "connecting";
const hostName = hosts.find((host) => host.serverId === serverId)?.label ?? serverId;
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const agentWorkspaceId = useSessionStore((state) => {
if (!serverId || !agentId) {
return null;
}
return state.sessions[serverId]?.agents?.get(agentId)?.workspaceId ?? null;
});
const shouldLookupAgent = Boolean(
serverId && agentId && client && connectionStatus === "online" && !agentWorkspaceId,
const hasHydratedWorkspaces = useSessionStore((state) =>
serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false,
);
const lookupQuery = useFetchQuery({
queryKey: ["agentRouteResolution", serverId, agentId, runtimeSnapshot?.clientGeneration ?? 0],
queryFn: async () => {
if (!client) {
throw new Error("Target host client is unavailable");
}
const result = await client.fetchAgent({ agentId });
return result?.agent?.workspaceId ?? null;
},
enabled: shouldLookupAgent,
retry: false,
dataShape: "value",
staleTimeMs: 0,
});
const lookup = useMemo<AgentRouteLookup>(() => {
if (!shouldLookupAgent) {
return { kind: "idle" };
}
if (lookupQuery.isFetching) {
return { kind: "fetching" };
}
if (lookupQuery.isError) {
return { kind: "failed", error: toErrorMessage(lookupQuery.error) };
}
if (lookupQuery.isSuccess) {
return { kind: "found", workspaceId: lookupQuery.data };
}
return { kind: "fetching" };
}, [
lookupQuery.data,
lookupQuery.error,
lookupQuery.isError,
lookupQuery.isFetching,
lookupQuery.isSuccess,
shouldLookupAgent,
]);
const resolution = resolveAgentRoute({
serverId,
agentId,
cachedWorkspaceId: agentWorkspaceId,
connectionStatus,
lookup,
});
const resolvedWorkspaceId = normalizeWorkspaceOpaqueId(agentWorkspaceId);
useEffect(() => {
let navigationKey: string | null = null;
if (resolution.kind === "invalid") {
navigationKey = "invalid";
} else if (resolution.kind === "resolved") {
navigationKey = `workspace:${resolution.workspaceId}`;
} else if (resolution.kind === "notFound") {
navigationKey = "not-found";
if (redirectedRef.current) {
return;
}
if (!navigationKey || handledNavigationRef.current === navigationKey) {
if (!serverId || !agentId) {
redirectedRef.current = true;
router.replace("/" as Href);
return;
}
handledNavigationRef.current = navigationKey;
if (resolution.kind === "resolved") {
navigateToAgent({ serverId, agentId, workspaceId: resolution.workspaceId });
return;
if (resolvedWorkspaceId) {
redirectedRef.current = true;
navigateToAgent({
serverId,
agentId,
});
}
router.replace(resolution.kind === "invalid" ? ("/" as Href) : buildHostRootRoute(serverId));
}, [agentId, resolution, router, serverId]);
}, [agentId, resolvedWorkspaceId, router, serverId]);
const handleRetry = useCallback(() => {
if (resolution.kind === "lookupError") {
void lookupQuery.refetch();
useEffect(() => {
if (redirectedRef.current) {
return;
}
if (serverId) {
void getHostRuntimeStore().runProbeCycleNow(serverId);
}
}, [lookupQuery, resolution.kind, serverId]);
const handleManageHost = useCallback(() => {
if (serverId) {
router.push(buildSettingsHostRoute(serverId));
}
}, [router, serverId]);
const handleBack = useCallback(() => {
if (router.canGoBack()) {
router.back();
if (!serverId || !agentId) {
return;
}
router.replace(serverId ? buildHostRootRoute(serverId) : ("/" as Href));
}, [router, serverId]);
if (agentWorkspaceId && !hasHydratedWorkspaces) {
return;
}
if (!client || !isConnected) {
redirectedRef.current = true;
router.replace(buildHostRootRoute(serverId));
}
}, [agentWorkspaceId, agentId, client, hasHydratedWorkspaces, isConnected, router, serverId]);
if (
resolution.kind === "waitingForHost" ||
resolution.kind === "fetchingAgent" ||
resolution.kind === "lookupError"
) {
// Agent URLs intentionally omit workspaceId. Keep this route mounted while the target host
// reconnects, then resolve the workspace from the authoritative agent record.
return (
<AgentRouteResolutionView
resolution={resolution}
hostName={hostName}
lastHostError={runtimeSnapshot?.lastError ?? null}
onRetry={handleRetry}
onManageHost={handleManageHost}
onBack={handleBack}
/>
);
}
useEffect(() => {
if (redirectedRef.current) {
return;
}
if (!serverId || !agentId || !client || !isConnected) {
return;
}
let cancelled = false;
void client
.fetchAgent({ agentId })
.then((result) => {
if (cancelled || redirectedRef.current) {
return;
}
const workspaceId = normalizeWorkspaceOpaqueId(result?.agent?.workspaceId);
redirectedRef.current = true;
if (workspaceId) {
navigateToAgent({
serverId,
agentId,
workspaceId,
});
return;
}
router.replace(buildHostRootRoute(serverId));
return;
})
.catch(() => {
if (cancelled || redirectedRef.current) {
return;
}
redirectedRef.current = true;
router.replace(buildHostRootRoute(serverId));
});
return () => {
cancelled = true;
};
}, [agentId, client, isConnected, router, serverId]);
return null;
}
@@ -9,7 +9,7 @@ import {
useActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
import { useHasHydratedWorkspaces, useWorkspaceExists } from "@/stores/session-store-hooks";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store";
import {
@@ -110,9 +110,7 @@ function HostWorkspaceRouteContent() {
const openValue = getParamValue(globalParams.open);
const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId);
const workspaceExists = useWorkspaceExists(serverId, workspaceId);
const openIntent = useMemo(() => parseWorkspaceOpenIntent(openValue), [openValue]);
const recoveryAgentId = openIntent?.kind === "agent" ? openIntent.agentId : null;
const isAgentOpenIntent = recoveryAgentId !== null;
const isAgentOpenIntent = parseWorkspaceOpenIntent(openValue)?.kind === "agent";
const isOpenIntentWaitingForWorkspace = Boolean(
isAgentOpenIntent && (!hasHydratedWorkspaces || !workspaceExists),
);
@@ -149,6 +147,7 @@ function HostWorkspaceRouteContent() {
}
consumedIntentRef.current = consumptionKey;
const openIntent = parseWorkspaceOpenIntent(openValue);
if (openIntent) {
prepareWorkspaceTab({
serverId,
@@ -172,7 +171,6 @@ function HostWorkspaceRouteContent() {
hasHydratedWorkspaceLayoutStore,
isOpenIntentWaitingForWorkspace,
navigation,
openIntent,
openValue,
rootNavigationState?.key,
serverId,
@@ -187,16 +185,10 @@ function HostWorkspaceRouteContent() {
return null;
}
return <WorkspaceDeck recoveryRequested={isAgentOpenIntent} recoveryAgentId={recoveryAgentId} />;
return <WorkspaceDeck recoveryRequested={isAgentOpenIntent} />;
}
function WorkspaceDeck({
recoveryRequested,
recoveryAgentId,
}: {
recoveryRequested: boolean;
recoveryAgentId: string | null;
}) {
function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) {
const activeSelection = useActiveWorkspaceSelection();
const [mountedSelections, setMountedSelections] = useState<ActiveWorkspaceSelection[]>(() =>
activeSelection ? [activeSelection] : [],
@@ -242,7 +234,6 @@ function WorkspaceDeck({
selection={selection}
activeSelection={activeSelection}
recoveryRequested={recoveryRequested}
recoveryAgentId={recoveryAgentId}
onUnmountInactive={unmountWorkspaceSelection}
/>
);
@@ -255,13 +246,11 @@ function WorkspaceDeckEntry({
selection,
activeSelection,
recoveryRequested,
recoveryAgentId,
onUnmountInactive,
}: {
selection: ActiveWorkspaceSelection;
activeSelection: ActiveWorkspaceSelection;
recoveryRequested: boolean;
recoveryAgentId: string | null;
onUnmountInactive: (selection: ActiveWorkspaceSelection) => void;
}) {
const isActive = areWorkspaceSelectionsEqual(selection, activeSelection);
@@ -293,7 +282,6 @@ function WorkspaceDeckEntry({
workspaceId={selection.workspaceId}
isRouteFocused={isActive}
recoveryRequested={isActive && recoveryRequested}
recoveryAgentId={isActive ? recoveryAgentId : null}
/>
</RetainedPanel>
);
-11
View File
@@ -92,20 +92,9 @@ export interface ChatHistoryContextAttachment {
export const NEW_WORKSPACE_PICKER_ATTACHMENT_OWNER = "new-workspace-picker";
export type WorkspaceFileSelection =
| { kind: "whole_file" }
| { kind: "line_range"; startLine: number; endLine: number };
export interface WorkspaceFileComposerAttachment {
kind: "workspace_file";
path: string;
selection: WorkspaceFileSelection;
}
export type UserComposerAttachment =
| { kind: "image"; metadata: AttachmentMetadata }
| { kind: "file"; attachment: UploadedFileAttachment }
| WorkspaceFileComposerAttachment
| { kind: "forge_issue"; item: ForgeSearchItem }
| { kind: "forge_change_request"; item: ForgeSearchItem }
// COMPAT(githubAttachmentKinds): added in v0.1.106, remove after 2026-12-28 once daemon floor >= v0.1.106
@@ -1,9 +0,0 @@
import type { RefCallback } from "react";
import type { View } from "react-native";
import type { WorkspaceFileDragSourceInput } from "./workspace-file-drag-source.types";
export function useWorkspaceFileDragSource(
_input: WorkspaceFileDragSourceInput,
): RefCallback<View> | undefined {
return undefined;
}
@@ -1,54 +0,0 @@
import { useCallback, useEffect, useState, type RefCallback } from "react";
import type { View } from "react-native";
import { createWorkspaceFileAttachment } from "./workspace-file";
import { serializeWorkspaceFileDragPayload, WORKSPACE_FILE_DRAG_MIME } from "./workspace-file-drag";
import type { WorkspaceFileDragSourceInput } from "./workspace-file-drag-source.types";
export function useWorkspaceFileDragSource({
enabled,
disabled = false,
serverId,
workspaceId,
path,
selection,
}: WorkspaceFileDragSourceInput): RefCallback<View> {
const [element, setElement] = useState<HTMLElement | null>(null);
const dragSourceRef = useCallback((node: View | null) => {
setElement(node as unknown as HTMLElement | null);
}, []);
useEffect(() => {
if (!element || !enabled || disabled || !serverId || !workspaceId) {
if (element) {
element.draggable = false;
}
return;
}
const sourceServerId = serverId;
const sourceWorkspaceId = workspaceId;
element.draggable = true;
function handleDragStart(event: DragEvent) {
if (!event.dataTransfer) {
return;
}
event.dataTransfer.effectAllowed = "copy";
event.dataTransfer.setData(
WORKSPACE_FILE_DRAG_MIME,
serializeWorkspaceFileDragPayload({
version: 1,
serverId: sourceServerId,
workspaceId: sourceWorkspaceId,
attachment: createWorkspaceFileAttachment({ path, selection }),
}),
);
}
element.addEventListener("dragstart", handleDragStart);
return () => {
element.draggable = false;
element.removeEventListener("dragstart", handleDragStart);
};
}, [disabled, element, enabled, path, selection, serverId, workspaceId]);
return dragSourceRef;
}
@@ -1,10 +0,0 @@
import type { WorkspaceFileSelection } from "./types";
export interface WorkspaceFileDragSourceInput {
enabled: boolean;
disabled?: boolean;
serverId?: string;
workspaceId: string | null | undefined;
path: string;
selection?: WorkspaceFileSelection;
}
@@ -1,62 +0,0 @@
import { describe, expect, it } from "vitest";
import { createWorkspaceFileAttachment } from "./workspace-file";
import {
parseWorkspaceFileDragPayload,
resolveWorkspaceFileDrop,
serializeWorkspaceFileDragPayload,
type WorkspaceFileDragPayload,
} from "./workspace-file-drag";
function payload(): WorkspaceFileDragPayload {
return {
version: 1,
serverId: "server-1",
workspaceId: "workspace-1",
attachment: createWorkspaceFileAttachment({
path: "src/app.ts",
selection: { kind: "line_range", startLine: 12, endLine: 24 },
}),
};
}
describe("workspace file drag payload", () => {
it("round-trips workspace identity and future line selections", () => {
expect(parseWorkspaceFileDragPayload(serializeWorkspaceFileDragPayload(payload()))).toEqual(
payload(),
);
});
it("rejects malformed and invalid payloads", () => {
expect(parseWorkspaceFileDragPayload("not json")).toBeNull();
expect(
parseWorkspaceFileDragPayload(
JSON.stringify({
...payload(),
attachment: {
kind: "workspace_file",
path: "src/app.ts",
selection: { kind: "line_range", startLine: 24, endLine: 12 },
},
}),
),
).toBeNull();
});
it("accepts drops only within the originating server and workspace", () => {
const dragged = payload();
expect(
resolveWorkspaceFileDrop({
payload: dragged,
serverId: "server-1",
workspaceId: "workspace-1",
}),
).toEqual(dragged.attachment);
expect(
resolveWorkspaceFileDrop({
payload: dragged,
serverId: "server-1",
workspaceId: "workspace-2",
}),
).toBeNull();
});
});
@@ -1,55 +0,0 @@
import type { WorkspaceFileComposerAttachment } from "./types";
import { isWorkspaceFileComposerAttachment } from "./workspace-file";
export const WORKSPACE_FILE_DRAG_MIME = "application/x-paseo-workspace-file+json";
export interface WorkspaceFileDragPayload {
version: 1;
serverId: string;
workspaceId: string;
attachment: WorkspaceFileComposerAttachment;
}
export function serializeWorkspaceFileDragPayload(payload: WorkspaceFileDragPayload): string {
return JSON.stringify(payload);
}
export function parseWorkspaceFileDragPayload(serialized: string): WorkspaceFileDragPayload | null {
let value: unknown;
try {
value = JSON.parse(serialized);
} catch {
return null;
}
if (!value || typeof value !== "object") {
return null;
}
const record = value as Record<string, unknown>;
if (
record.version !== 1 ||
typeof record.serverId !== "string" ||
record.serverId.length === 0 ||
typeof record.workspaceId !== "string" ||
record.workspaceId.length === 0 ||
!isWorkspaceFileComposerAttachment(record.attachment)
) {
return null;
}
return {
version: 1,
serverId: record.serverId,
workspaceId: record.workspaceId,
attachment: record.attachment,
};
}
export function resolveWorkspaceFileDrop(input: {
payload: WorkspaceFileDragPayload;
serverId: string;
workspaceId: string;
}): WorkspaceFileComposerAttachment | null {
return input.payload.serverId === input.serverId &&
input.payload.workspaceId === input.workspaceId
? input.payload.attachment
: null;
}
@@ -1,71 +0,0 @@
import { describe, expect, it } from "vitest";
import type { UserComposerAttachment } from "@/attachments/types";
import {
appendWorkspaceFileAttachment,
createWorkspaceFileAttachment,
getWorkspaceFileAttachmentKey,
getWorkspaceFileAttachmentSubtitle,
workspaceFileAttachmentToAgentAttachment,
} from "./workspace-file";
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
describe("workspace file attachments", () => {
it("models whole files and line ranges as distinct selections", () => {
const wholeFile = createWorkspaceFileAttachment({ path: "src/app.ts" });
const lineRange = createWorkspaceFileAttachment({
path: "src/app.ts",
selection: { kind: "line_range", startLine: 12, endLine: 24 },
});
expect(wholeFile).toEqual({
kind: "workspace_file",
path: "src/app.ts",
selection: { kind: "whole_file" },
});
expect(getWorkspaceFileAttachmentKey(wholeFile)).not.toBe(
getWorkspaceFileAttachmentKey(lineRange),
);
expect(getWorkspaceFileAttachmentSubtitle(wholeFile)).toBe("src/app.ts");
expect(getWorkspaceFileAttachmentSubtitle(lineRange)).toBe("src/app.ts · 12-24");
});
it("deduplicates only identical paths and selections", () => {
const image = {
kind: "image" as const,
metadata: {
id: "image-1",
mimeType: "image/png",
storageType: "web-indexeddb" as const,
storageKey: "image-1",
createdAt: 1,
},
};
const wholeFile = createWorkspaceFileAttachment({ path: "src/app.ts" });
const range = createWorkspaceFileAttachment({
path: "src/app.ts",
selection: { kind: "line_range", startLine: 1, endLine: 5 },
});
const current: UserComposerAttachment[] = [image, wholeFile];
expect(appendWorkspaceFileAttachment(current, wholeFile)).toBe(current);
expect(appendWorkspaceFileAttachment(current, range)).toEqual([image, wholeFile, range]);
});
it("submits a path reference without uploading or inserting prompt text", () => {
const attachment = createWorkspaceFileAttachment({
path: "src/app.ts",
selection: { kind: "line_range", startLine: 12, endLine: 24 },
});
expect(workspaceFileAttachmentToAgentAttachment(attachment)).toEqual({
type: "text",
mimeType: "text/plain",
title: "app.ts",
text: "Workspace file: src/app.ts\nLines: 12-24",
});
expect(splitComposerAttachmentsForSubmit([attachment])).toEqual({
images: [],
attachments: [workspaceFileAttachmentToAgentAttachment(attachment)],
});
});
});
@@ -1,106 +0,0 @@
import type { AgentAttachment } from "@getpaseo/protocol/messages";
import type {
UserComposerAttachment,
WorkspaceFileComposerAttachment,
WorkspaceFileSelection,
} from "./types";
interface CreateWorkspaceFileAttachmentInput {
path: string;
selection?: WorkspaceFileSelection;
}
function normalizePath(path: string): string {
return path.trim().replace(/^\.\//, "");
}
export function isWorkspaceFileComposerAttachment(
value: unknown,
): value is WorkspaceFileComposerAttachment {
if (!value || typeof value !== "object") {
return false;
}
const record = value as Record<string, unknown>;
if (
record.kind !== "workspace_file" ||
typeof record.path !== "string" ||
record.path.trim().length === 0
) {
return false;
}
const selection = record.selection;
if (!selection || typeof selection !== "object") {
return false;
}
const { kind, startLine, endLine } = selection as Record<string, unknown>;
if (kind === "whole_file") {
return true;
}
return (
kind === "line_range" &&
typeof startLine === "number" &&
Number.isInteger(startLine) &&
typeof endLine === "number" &&
Number.isInteger(endLine) &&
startLine > 0 &&
endLine >= startLine
);
}
export function createWorkspaceFileAttachment({
path,
selection = { kind: "whole_file" },
}: CreateWorkspaceFileAttachmentInput): WorkspaceFileComposerAttachment {
return {
kind: "workspace_file",
path: normalizePath(path),
selection,
};
}
export function getWorkspaceFileAttachmentKey(attachment: WorkspaceFileComposerAttachment): string {
const selection = attachment.selection;
const selectionKey =
selection.kind === "whole_file"
? selection.kind
: `${selection.kind}:${selection.startLine}-${selection.endLine}`;
return `${normalizePath(attachment.path)}:${selectionKey}`;
}
export function appendWorkspaceFileAttachment(
current: UserComposerAttachment[],
attachment: WorkspaceFileComposerAttachment,
): UserComposerAttachment[] {
const attachmentKey = getWorkspaceFileAttachmentKey(attachment);
const alreadyAttached = current.some(
(candidate) =>
candidate.kind === "workspace_file" &&
getWorkspaceFileAttachmentKey(candidate) === attachmentKey,
);
return alreadyAttached ? current : [...current, attachment];
}
export function workspaceFileAttachmentToAgentAttachment(
attachment: WorkspaceFileComposerAttachment,
): Extract<AgentAttachment, { type: "text" }> {
const fileName = attachment.path.split("/").pop() ?? attachment.path;
const lines =
attachment.selection.kind === "line_range"
? `\nLines: ${attachment.selection.startLine}-${attachment.selection.endLine}`
: "";
return {
type: "text",
mimeType: "text/plain",
title: fileName,
text: `Workspace file: ${attachment.path}${lines}`,
};
}
export function getWorkspaceFileAttachmentSubtitle(
attachment: WorkspaceFileComposerAttachment,
): string {
if (attachment.selection.kind === "whole_file") {
return attachment.path;
}
return `${attachment.path} · ${attachment.selection.startLine}-${attachment.selection.endLine}`;
}
@@ -4,8 +4,10 @@ import { createJSONStorage, type StateStorage } from "zustand/middleware";
import { mountBrowserAutomationHandler } from "./handler";
import type { DesktopHostBridge } from "@/desktop/host";
import { useBrowserStore } from "@/stores/browser-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import {
buildWorkspaceTabPersistenceKey,
useWorkspaceLayoutStore,
} from "@/stores/workspace-layout-store";
type BrowserAutomationExecuteRequest = Extract<
SessionOutboundMessage,
@@ -6,8 +6,11 @@ import {
resizeResidentBrowserWebview,
} from "@/components/browser-webview-resident";
import { createWorkspaceBrowser, getBrowserRecord, useBrowserStore } from "@/stores/browser-store";
import { collectAllTabs, useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import {
buildWorkspaceTabPersistenceKey,
collectAllTabs,
useWorkspaceLayoutStore,
} from "@/stores/workspace-layout-store";
type BrowserAutomationExecuteRequest = Extract<
SessionOutboundMessage,
@@ -1,5 +1,5 @@
import type { Agent } from "@/stores/session-store";
import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model";
import type { WorkspaceDraftTabSetup } from "@/stores/workspace-tabs-store";
export type ClientSlashCommandKind = "archive-agent" | "replace-agent-with-draft";
export type ClientSlashCommandExecution = "immediate" | "insert";
@@ -3,7 +3,7 @@ import type { ReactNode, Ref } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { Modal, Platform, Pressable, ScrollView, Text, TextInput, View } from "react-native";
import type { StyleProp, TextInputProps, ViewStyle } from "react-native";
import type { TextInputProps } from "react-native";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root";
@@ -11,10 +11,9 @@ import {
BottomSheetBackdrop,
BottomSheetScrollView,
BottomSheetTextInput,
useBottomSheetInternal,
type BottomSheetBackgroundProps,
} from "@gorhom/bottom-sheet";
import Animated, { useAnimatedStyle } from "react-native-reanimated";
import Animated from "react-native-reanimated";
import { ArrowLeft, Search, X } from "lucide-react-native";
import {
IsolatedBottomSheetModal,
@@ -204,14 +203,6 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[4],
minHeight: 0,
},
bottomSheetVisibleContent: {
minHeight: 0,
overflow: "hidden",
},
bottomSheetVisibleScroll: {
flex: 1,
minHeight: 0,
},
desktopStaticContent: {
flexShrink: 1,
minHeight: 0,
@@ -257,39 +248,6 @@ function SheetBackground({ style }: BottomSheetBackgroundProps) {
return <Animated.View pointerEvents="none" style={combinedStyle} />;
}
function BottomSheetVisibleContent({ children }: { children: ReactNode }) {
const { animatedDetentsState, animatedKeyboardState, animatedLayoutState, animatedPosition } =
useBottomSheetInternal();
const visibleContentStyle = useAnimatedStyle(() => {
const { containerHeight, handleHeight } = animatedLayoutState.get();
if (containerHeight < 0 || handleHeight < 0) {
return { height: 0 };
}
const initialDetentPosition = animatedDetentsState.get().detents?.[0];
const contentPosition =
initialDetentPosition == null
? animatedPosition.get()
: Math.min(animatedPosition.get(), initialDetentPosition);
return {
height: Math.max(
0,
containerHeight -
contentPosition -
handleHeight -
animatedKeyboardState.get().heightWithinContainer,
),
};
}, [animatedDetentsState, animatedKeyboardState, animatedLayoutState, animatedPosition]);
return (
<Animated.View style={[styles.bottomSheetVisibleContent, visibleContentStyle]}>
{children}
</Animated.View>
);
}
export type AdaptiveTextInputProps = TextInputProps & {
initialValue?: string;
resetKey?: string | number;
@@ -494,16 +452,12 @@ 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({
@@ -513,14 +467,11 @@ export function AdaptiveModalSheet({
onDismiss,
children,
footer,
footerContainerStyle,
snapPoints,
testID,
desktopMaxWidth,
scrollable = true,
presentation,
contentContainerStyle,
sizeContentToCurrentSnapPoint = false,
}: AdaptiveModalSheetProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
@@ -539,37 +490,31 @@ export function AdaptiveModalSheet({
[footer, insets.bottom, isMobile, theme.spacing],
);
const bottomSheetContentStyle = useMemo(
// Gorhom spreads this outer array into StyleSheet.compose, which accepts two arguments on web.
() => [
styles.bottomSheetContent,
[
contentContainerStyle,
compactSafeAreaPadding.contentPaddingBottom != null
? { paddingBottom: compactSafeAreaPadding.contentPaddingBottom }
: null,
],
],
[compactSafeAreaPadding.contentPaddingBottom, contentContainerStyle],
);
const bottomSheetStaticContentStyle = useMemo(
() => [
styles.bottomSheetStaticContent,
contentContainerStyle,
compactSafeAreaPadding.contentPaddingBottom != null
? { paddingBottom: compactSafeAreaPadding.contentPaddingBottom }
: null,
],
[compactSafeAreaPadding.contentPaddingBottom, contentContainerStyle],
[compactSafeAreaPadding.contentPaddingBottom],
);
const bottomSheetStaticContentStyle = useMemo(
() => [
styles.bottomSheetStaticContent,
compactSafeAreaPadding.contentPaddingBottom != null
? { paddingBottom: compactSafeAreaPadding.contentPaddingBottom }
: null,
],
[compactSafeAreaPadding.contentPaddingBottom],
);
const footerStyle = useMemo(
() => [
styles.footer,
footerContainerStyle,
compactSafeAreaPadding.footerPaddingBottom != null
? { paddingBottom: compactSafeAreaPadding.footerPaddingBottom }
: null,
],
[compactSafeAreaPadding.footerPaddingBottom, footerContainerStyle],
[compactSafeAreaPadding.footerPaddingBottom],
);
const handleIndicatorStyle = useMemo(
() => ({ backgroundColor: theme.colors.palette.zinc[600] }),
@@ -654,25 +599,6 @@ 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}
@@ -690,11 +616,19 @@ export function AdaptiveModalSheet({
accessible={false}
presentation={presentation}
>
{sizeContentToCurrentSnapPoint ? (
<BottomSheetVisibleContent>{sheetContent}</BottomSheetVisibleContent>
<SheetHeaderView header={header} onClose={onClose} testID={testID} />
{scrollable ? (
<BottomSheetScrollView
contentContainerStyle={bottomSheetContentStyle}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{children}
</BottomSheetScrollView>
) : (
sheetContent
<View style={bottomSheetStaticContentStyle}>{children}</View>
)}
{footer ? <View style={footerStyle}>{footer}</View> : null}
</IsolatedBottomSheetModal>
);
}
@@ -706,7 +640,7 @@ export function AdaptiveModalSheet({
<View style={styles.desktopScrollContainer}>
<ScrollView
style={styles.desktopScroll}
contentContainerStyle={[styles.desktopContent, contentContainerStyle]}
contentContainerStyle={styles.desktopContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator
>
@@ -714,7 +648,7 @@ export function AdaptiveModalSheet({
</ScrollView>
</View>
) : (
<View style={[styles.desktopStaticContent, contentContainerStyle]}>{children}</View>
<View style={styles.desktopStaticContent}>{children}</View>
)}
{footer ? <View style={footerStyle}>{footer}</View> : null}
</>
@@ -394,7 +394,6 @@ export function AgentList({
serverId,
agentId,
workspaceId: agent.workspaceId,
pin: true,
});
},
[isActionSheetVisible, onAgentSelect],
@@ -9,7 +9,6 @@ import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { Button } from "@/components/ui/button";
import type { Theme } from "@/styles/theme";
import { toErrorMessage } from "@/utils/error-messages";
interface ArchivedAgentCalloutProps {
serverId: string;
@@ -22,7 +21,6 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const [isUnarchiving, setIsUnarchiving] = useState(false);
const [unarchiveError, setUnarchiveError] = useState<string | null>(null);
const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({ mode: "translate" });
@@ -34,11 +32,10 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
const handleUnarchive = useCallback(async () => {
if (!client || !isConnected || isUnarchiving) return;
setIsUnarchiving(true);
setUnarchiveError(null);
try {
await client.refreshAgent(agentId);
} catch (error) {
setUnarchiveError(toErrorMessage(error));
console.error("[ArchivedAgentCallout] Failed to unarchive agent:", error);
setIsUnarchiving(false);
}
}, [client, isConnected, isUnarchiving, agentId]);
@@ -47,23 +44,16 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
<Animated.View style={containerStyle}>
<View style={styles.inputAreaContainer}>
<View style={styles.inputAreaContent}>
<View style={styles.calloutStack}>
<View style={styles.callout}>
<Text style={styles.calloutText}>{t("agentPanel.archived.callout")}</Text>
<Button
size="sm"
variant="secondary"
onPress={handleUnarchive}
disabled={!isConnected || isUnarchiving}
>
{t("agentPanel.archived.unarchive")}
</Button>
</View>
{unarchiveError ? (
<Text style={styles.errorText} testID="agent-unarchive-error">
{unarchiveError}
</Text>
) : null}
<View style={styles.callout}>
<Text style={styles.calloutText}>{t("agentPanel.archived.callout")}</Text>
<Button
size="sm"
variant="secondary"
onPress={handleUnarchive}
disabled={!isConnected || isUnarchiving}
>
{t("agentPanel.archived.unarchive")}
</Button>
</View>
</View>
</View>
@@ -107,16 +97,8 @@ const styles = StyleSheet.create((theme: Theme) => ({
md: theme.spacing[6],
},
},
calloutStack: {
gap: theme.spacing[2],
},
calloutText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
},
errorText: {
color: theme.colors.statusDanger,
fontSize: theme.fontSize.sm,
textAlign: "center",
},
})) as unknown as Record<string, object>;
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,4 @@
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-layout-store";
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import type { WorkspaceDescriptor } from "@/stores/session-store";
@@ -18,16 +18,17 @@ interface ContextWindowMeterProps {
provider?: string | null;
/** Reserve the meter footprint and show a loading ring while usage is pending. */
pending?: boolean;
/** Optional glyph envelope for icon-toolbar alignment. */
glyphSize?: number;
}
const SVG_SIZE = 14;
const COMPACT_SVG_SIZE = 12;
const CENTER = SVG_SIZE / 2;
const COMPACT_CENTER = COMPACT_SVG_SIZE / 2;
const RADIUS = 6;
const COMPACT_RADIUS = 5;
const STROKE_WIDTH = 2;
const COMPACT_STROKE_WIDTH = 1.75;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
const COMPACT_CIRCUMFERENCE = 2 * Math.PI * COMPACT_RADIUS;
function isValidMaxTokens(value: number): boolean {
@@ -73,7 +74,7 @@ function getMeterColors(
return { progress: theme.colors.foregroundMuted, track };
}
function getMeterGeometry(showPercentage: boolean, glyphSize?: number) {
function getMeterGeometry(showPercentage: boolean) {
if (showPercentage) {
return {
svgSize: COMPACT_SVG_SIZE,
@@ -84,14 +85,12 @@ function getMeterGeometry(showPercentage: boolean, glyphSize?: number) {
containerStyle: styles.containerWithLabel,
};
}
const resolvedSize = glyphSize ?? SVG_SIZE;
const resolvedStrokeWidth = glyphSize ? 2 : STROKE_WIDTH;
return {
svgSize: resolvedSize,
center: resolvedSize / 2,
radius: (resolvedSize - resolvedStrokeWidth) / 2,
strokeWidth: resolvedStrokeWidth,
circumference: Math.PI * (resolvedSize - resolvedStrokeWidth),
svgSize: SVG_SIZE,
center: CENTER,
radius: RADIUS,
strokeWidth: STROKE_WIDTH,
circumference: CIRCUMFERENCE,
containerStyle: styles.container,
};
}
@@ -104,7 +103,6 @@ export function ContextWindowMeter({
serverId,
provider,
pending = false,
glyphSize,
}: ContextWindowMeterProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
@@ -125,7 +123,7 @@ export function ContextWindowMeter({
[refreshProviderUsage],
);
const geometry = getMeterGeometry(showPercentage, glyphSize);
const geometry = getMeterGeometry(showPercentage);
// No usage yet: reserve the footprint with a track-only ring while a session is
// active so the real ring fades in without shifting siblings. Render nothing when
+2 -3
View File
@@ -4,7 +4,6 @@ import { StyleSheet } from "react-native-unistyles";
interface DiffStatProps {
additions: number;
deletions: number;
testID?: string;
}
const compactFormatter = new Intl.NumberFormat("en-US", {
@@ -16,9 +15,9 @@ export function formatDiffCount(value: number): string {
return compactFormatter.format(value).toLowerCase();
}
export function DiffStat({ additions, deletions, testID }: DiffStatProps) {
export function DiffStat({ additions, deletions }: DiffStatProps) {
return (
<View style={styles.row} testID={testID}>
<View style={styles.row}>
<Text style={styles.additions}>+{formatDiffCount(additions)}</Text>
<Text style={styles.deletions}>-{formatDiffCount(deletions)}</Text>
</View>
@@ -36,11 +36,6 @@ import { RetainedPanelActivity } from "@/components/retained-panel";
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
import { buildWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
import { resolveDesktopExplorerWidth } from "@/components/desktop-sidebar-layout";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { resolveFocusedChatTarget } from "@/composer/focused-chat-target";
import { createWorkspaceFileAttachment } from "@/attachments/workspace-file";
import { useDraftStore } from "@/stores/draft-store";
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
@@ -391,16 +386,15 @@ function ExplorerSidebarContent({
{/* Content based on active tab */}
<View style={styles.contentArea} testID="explorer-content-area">
{resolvedTab === "changes" && (
<ChangedFilesPane
<GitDiffPane
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
isOpen={isOpen}
onOpenFile={onOpenFile}
cwd={workspaceRoot}
enabled={isOpen}
/>
)}
{resolvedTab === "files" && (
<FilesPane
<FileExplorerPane
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
@@ -421,83 +415,6 @@ function ExplorerSidebarContent({
);
}
/**
* Shared add-to-chat state for the changes/files panes: both expose an "add file
* to chat" action that attaches the file to the focused chat's composer.
* Available only when a workspace with a focused chat is available.
*/
function useAddFileToChat({
serverId,
workspaceId,
}: Pick<SidebarContentProps, "serverId" | "workspaceId">) {
const workspaceKey = workspaceId
? buildWorkspaceTabPersistenceKey({ serverId, workspaceId })
: null;
const layout = useWorkspaceLayoutStore((state) =>
workspaceKey ? state.layoutByWorkspace[workspaceKey] : undefined,
);
const focusTab = useWorkspaceLayoutStore((state) => state.focusTab);
const focusedChat = useMemo(
() => resolveFocusedChatTarget({ serverId, layout }),
[serverId, layout],
);
const addFile = useCallback(
(filePath: string) => {
if (!focusedChat || !workspaceKey) {
return;
}
void useDraftStore.getState().attachWorkspaceFile({
draftKey: focusedChat.draftKey,
attachment: createWorkspaceFileAttachment({ path: filePath }),
});
focusTab(workspaceKey, focusedChat.tabId);
},
[focusTab, focusedChat, workspaceKey],
);
return { addFile, canAddToChat: focusedChat !== null };
}
function ChangedFilesPane({
serverId,
workspaceId,
workspaceRoot,
isOpen,
onOpenFile,
}: Pick<
SidebarContentProps,
"serverId" | "workspaceId" | "workspaceRoot" | "isOpen" | "onOpenFile"
>) {
const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId });
return (
<GitDiffPane
serverId={serverId}
workspaceId={workspaceId}
cwd={workspaceRoot}
enabled={isOpen}
onOpenFile={onOpenFile}
onAddToChat={canAddToChat ? addFile : undefined}
/>
);
}
function FilesPane({
serverId,
workspaceId,
workspaceRoot,
onOpenFile,
}: Pick<SidebarContentProps, "serverId" | "workspaceId" | "workspaceRoot" | "onOpenFile">) {
const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId });
return (
<FileExplorerPane
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
onOpenFile={onOpenFile}
onAddToChat={canAddToChat ? addFile : undefined}
/>
);
}
interface PrTabContentProps {
serverId: string;
cwd: string;
@@ -1,185 +0,0 @@
import { useMemo, type ReactElement, type ReactNode } from "react";
import { type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
Copy,
Download,
FileText,
MessageSquarePlus,
MoreVertical,
type LucideIcon,
} from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { ICON_SIZE, SPACING, type Theme } from "@/styles/theme";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const ThemedMoreVertical = withUnistyles(MoreVertical);
/** Width occupied by a file action trigger, including its visual padding. */
export const FILE_ACTIONS_MENU_WIDTH = ICON_SIZE.sm + 2 * SPACING[1];
interface FileAction {
key: string;
label: string;
icon: LucideIcon;
onSelect: () => void;
testID?: string;
}
interface FileActionsMenuProps {
fileKind: "file" | "directory";
fileExists?: boolean;
onOpenFile?: () => void;
onCopyPath?: () => void;
onDownload?: () => void;
onAddToChat?: () => void;
/** Optional metadata block rendered above the actions (e.g. size/modified). */
header?: ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
hitSlop?: number;
accessibilityLabel: string;
testIDPrefix?: string;
}
// The menu lives inside pressable rows (diff header, explorer entry); stop the
// press so opening it doesn't also trigger the row.
function stopTriggerPropagation(event: { stopPropagation?: () => void }) {
event.stopPropagation?.();
}
function triggerStyle({
hovered,
pressed,
open,
}: PressableStateCallbackType & { hovered?: boolean; open?: boolean }) {
return [styles.trigger, (Boolean(hovered) || pressed || Boolean(open)) && styles.triggerActive];
}
/**
* Shared kebab (⋮) menu for per-file actions. Used by the file explorer tree and
* git diff pane so both surfaces share action availability, ordering, and chrome.
*/
export function FileActionsMenu({
fileKind,
fileExists = true,
onOpenFile,
onCopyPath,
onDownload,
onAddToChat,
header,
open,
onOpenChange,
hitSlop = 12,
accessibilityLabel,
testIDPrefix,
}: FileActionsMenuProps): ReactElement | null {
const { t } = useTranslation();
const actions = useMemo<FileAction[]>(() => {
const availableFile = fileKind === "file" && fileExists;
const next: FileAction[] = [];
if (availableFile && onOpenFile) {
next.push({
key: "open-file",
label: t("workspace.fileActions.openFile"),
icon: FileText,
onSelect: onOpenFile,
testID: testIDPrefix ? `${testIDPrefix}-open-file` : undefined,
});
}
if (onCopyPath) {
next.push({
key: "copy-path",
label: t("workspace.fileActions.copyPath"),
icon: Copy,
onSelect: onCopyPath,
});
}
if (availableFile && onDownload) {
next.push({
key: "download",
label: t("workspace.fileActions.download"),
icon: Download,
onSelect: onDownload,
});
}
if (availableFile && onAddToChat) {
next.push({
key: "add-to-chat",
label: t("workspace.fileActions.addToChat"),
icon: MessageSquarePlus,
onSelect: onAddToChat,
testID: testIDPrefix ? `${testIDPrefix}-add-to-chat` : undefined,
});
}
return next;
}, [fileExists, fileKind, onAddToChat, onCopyPath, onDownload, onOpenFile, t, testIDPrefix]);
if (actions.length === 0) {
return null;
}
return (
<DropdownMenu open={open} onOpenChange={onOpenChange}>
<DropdownMenuTrigger
hitSlop={hitSlop}
onPressIn={stopTriggerPropagation}
style={triggerStyle}
accessibilityLabel={accessibilityLabel}
testID={testIDPrefix ? `${testIDPrefix}-actions` : undefined}
>
<ThemedMoreVertical size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={220}>
{header ? (
<>
{header}
<DropdownMenuSeparator />
</>
) : null}
{actions.map((action) => (
<FileActionMenuItem key={action.key} action={action} />
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
function FileActionMenuItem({ action }: { action: FileAction }): ReactElement {
const Icon = action.icon;
const ThemedIcon = useMemo(() => withUnistyles(Icon), [Icon]);
const leading = useMemo(
() => <ThemedIcon size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />,
[ThemedIcon],
);
return (
<DropdownMenuItem leading={leading} onSelect={action.onSelect} testID={action.testID}>
{action.label}
</DropdownMenuItem>
);
}
const styles = StyleSheet.create((theme) => ({
trigger: {
// The hover box comes from padding, but an equal negative vertical margin
// cancels its height contribution so the trigger overlaps the row's natural
// line height instead of growing it. The comfortable tap target is `hitSlop`,
// never padding.
padding: theme.spacing[1],
width: FILE_ACTIONS_MENU_WIDTH,
marginVertical: -theme.spacing[1],
borderRadius: theme.borderRadius.md,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
triggerActive: {
backgroundColor: theme.colors.surface2,
},
}));
@@ -1,5 +1,4 @@
import type { ImageAttachment } from "@/composer/types";
import type { WorkspaceFileDragPayload } from "@/attachments/workspace-file-drag";
export interface DroppedFileItem {
kind: "web-file";
@@ -19,5 +18,4 @@ export type DroppedItem = DroppedFileItem | DroppedPathItem;
export interface FileDropSink {
onFiles: (images: ImageAttachment[]) => void;
onGenericFiles?: (items: DroppedItem[]) => void;
onWorkspaceFile?: (payload: WorkspaceFileDragPayload) => void;
}
@@ -11,10 +11,6 @@ import {
} from "@/attachments/file-types";
import { isWeb } from "@/constants/platform";
import type { DroppedItem, DroppedPathItem, FileDropSink } from "./types";
import {
parseWorkspaceFileDragPayload,
WORKSPACE_FILE_DRAG_MIME,
} from "@/attachments/workspace-file-drag";
type DesktopDragDropPayload =
| { type: "enter"; paths: string[] }
@@ -189,11 +185,7 @@ export function useDropListeners({
if (disabledRef.current) return;
dragCounter.current++;
if (suppressed.value || !hasSink.value) return;
const types = new Set(e.dataTransfer?.types ?? []);
const acceptsWorkspaceFile =
types.has(WORKSPACE_FILE_DRAG_MIME) && Boolean(getSink()?.onWorkspaceFile);
if (types.has("Files") || acceptsWorkspaceFile) {
if (e.dataTransfer?.types.includes("Files")) {
isDragging.value = true;
}
}
@@ -205,11 +197,7 @@ export function useDropListeners({
if (!e.dataTransfer) return;
// Only advertise "copy" when the drop would actually be accepted, so the cursor doesn't
// promise a drop that the handler then discards (suppressed/archived/no consumer mounted).
const types = new Set(e.dataTransfer.types);
const acceptsWorkspaceFile =
types.has(WORKSPACE_FILE_DRAG_MIME) && Boolean(getSink()?.onWorkspaceFile);
const acceptsDrop = types.has("Files") || acceptsWorkspaceFile;
const canAccept = acceptsDrop && !disabledRef.current && !suppressed.value && hasSink.value;
const canAccept = !disabledRef.current && !suppressed.value && hasSink.value;
e.dataTransfer.dropEffect = canAccept ? "copy" : "none";
}
@@ -237,14 +225,6 @@ export function useDropListeners({
const sink = getSink();
if (!sink) return;
const serializedWorkspaceFile = e.dataTransfer?.getData(WORKSPACE_FILE_DRAG_MIME);
if (serializedWorkspaceFile && sink.onWorkspaceFile) {
const payload = parseWorkspaceFileDragPayload(serializedWorkspaceFile);
if (payload) {
sink.onWorkspaceFile(payload);
}
}
const files = Array.from(e.dataTransfer?.files ?? []);
const genericItems: DroppedItem[] = files.map((file) => ({
kind: "web-file",
+155 -110
View File
@@ -15,26 +15,35 @@ import {
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import * as Clipboard from "expo-clipboard";
import { ChevronDown, Eye, EyeOff, RotateCw } from "lucide-react-native";
import { MaterialFileIcon } from "@/components/material-file-icon";
import {
TreeChevron,
TreeIndentGuides,
treeRowPaddingLeft,
WORKSPACE_FILE_ROW_VERTICAL_PADDING,
} from "@/components/tree-primitives";
ChevronDown,
Copy,
Download,
Eye,
EyeOff,
MoreVertical,
RotateCw,
} from "lucide-react-native";
import { MaterialFileIcon } from "@/components/material-file-icon";
import { TreeChevron, TreeIndentGuides, TREE_INDENT_PER_LEVEL } from "@/components/tree-primitives";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
import { useHosts } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { FileActionsMenu } from "@/components/file-actions-menu";
import { useFileDownload } from "@/hooks/use-file-download";
import { useDownloadStore } from "@/stores/download-store";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
import { usePanelStore, type SortOption } from "@/stores/panel-store";
import { formatTimeAgo } from "@/utils/time";
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
import { filterVisibleExplorerEntries, isHiddenExplorerPath } from "@/file-explorer/visibility";
import { useWorkspaceFileDragSource } from "@/attachments/use-workspace-file-drag-source";
const SORT_OPTIONS: { value: SortOption }[] = [
{ value: "name" },
@@ -53,8 +62,6 @@ function formatFileSize({ size }: { size: number }): string {
}
interface TreeRowItemProps {
serverId: string;
workspaceId?: string | null;
entry: ExplorerEntry;
depth: number;
isExpanded: boolean;
@@ -63,8 +70,21 @@ interface TreeRowItemProps {
onEntryPress: (entry: ExplorerEntry) => void;
onCopyPath: (path: string) => void;
onDownloadEntry: (entry: ExplorerEntry) => void;
onAddToChat?: (path: string) => void;
testID?: string;
}
function stopPressInPropagation(event: { stopPropagation?: () => void }) {
event.stopPropagation?.();
}
function menuButtonStyle({
hovered,
pressed,
open,
}: PressableStateCallbackType & { hovered?: boolean; open?: boolean }) {
return [
styles.menuButton,
(Boolean(hovered) || pressed || Boolean(open)) && styles.menuButtonActive,
];
}
function sortTriggerStyle({
@@ -83,8 +103,6 @@ function treeRowKeyExtractor(row: TreeRow) {
}
function TreeRowItem({
serverId,
workspaceId,
entry,
depth,
isExpanded,
@@ -93,17 +111,10 @@ function TreeRowItem({
onEntryPress,
onCopyPath,
onDownloadEntry,
onAddToChat,
testID,
}: TreeRowItemProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const isDirectory = entry.kind === "directory";
const dragSourceRef = useWorkspaceFileDragSource({
enabled: !isDirectory,
serverId,
workspaceId,
path: entry.path,
});
const handlePress = useCallback(() => {
onEntryPress(entry);
@@ -112,10 +123,10 @@ function TreeRowItem({
const pressableStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.entryRow,
{ paddingLeft: treeRowPaddingLeft(depth) },
{ paddingLeft: theme.spacing[2] + depth * TREE_INDENT_PER_LEVEL },
(Boolean(hovered) || pressed || isSelected) && styles.entryRowActive,
],
[depth, isSelected],
[depth, isSelected, theme.spacing],
);
const handleCopy = useCallback(() => {
@@ -126,38 +137,19 @@ function TreeRowItem({
onDownloadEntry(entry);
}, [onDownloadEntry, entry]);
const handleAddToChat = useCallback(() => {
onAddToChat?.(entry.path);
}, [onAddToChat, entry.path]);
const metaHeader = useMemo(
() => (
<View style={styles.contextMetaBlock}>
<View style={styles.contextMetaRow}>
<Text style={styles.contextMetaLabel} numberOfLines={1}>
{t("workspace.fileExplorer.context.size")}
</Text>
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
{formatFileSize({ size: entry.size })}
</Text>
</View>
<View style={styles.contextMetaRow}>
<Text style={styles.contextMetaLabel} numberOfLines={1}>
{t("workspace.fileExplorer.context.modified")}
</Text>
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
{formatTimeAgo(new Date(entry.modifiedAt))}
</Text>
</View>
</View>
),
[entry.modifiedAt, entry.size, t],
const copyLeading = useMemo(
() => <Copy size={14} color={theme.colors.foregroundMuted} />,
[theme.colors.foregroundMuted],
);
const downloadLeading = useMemo(
() => <Download size={14} color={theme.colors.foregroundMuted} />,
[theme.colors.foregroundMuted],
);
return (
<Pressable onPress={handlePress} style={pressableStyle} testID={testID}>
<Pressable onPress={handlePress} style={pressableStyle}>
<TreeIndentGuides depth={depth} />
<View ref={dragSourceRef} style={styles.entryInfo}>
<View style={styles.entryInfo}>
<View style={styles.entryIcon}>
{(() => {
if (!isDirectory) {
@@ -171,15 +163,40 @@ function TreeRowItem({
{entry.name}
</Text>
</View>
<FileActionsMenu
fileKind={entry.kind}
onCopyPath={handleCopy}
onDownload={handleDownload}
onAddToChat={onAddToChat ? handleAddToChat : undefined}
header={metaHeader}
accessibilityLabel={t("workspace.fileActions.moreActions")}
testIDPrefix={testID}
/>
<DropdownMenu>
<DropdownMenuTrigger hitSlop={8} onPressIn={stopPressInPropagation} style={menuButtonStyle}>
<MoreVertical size={16} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={220}>
<View style={styles.contextMetaBlock}>
<View style={styles.contextMetaRow}>
<Text style={styles.contextMetaLabel} numberOfLines={1}>
{t("workspace.fileExplorer.context.size")}
</Text>
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
{formatFileSize({ size: entry.size })}
</Text>
</View>
<View style={styles.contextMetaRow}>
<Text style={styles.contextMetaLabel} numberOfLines={1}>
{t("workspace.fileExplorer.context.modified")}
</Text>
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
{formatTimeAgo(new Date(entry.modifiedAt))}
</Text>
</View>
</View>
<DropdownMenuSeparator />
<DropdownMenuItem leading={copyLeading} onSelect={handleCopy}>
{t("workspace.fileExplorer.context.copyPath")}
</DropdownMenuItem>
{entry.kind === "file" ? (
<DropdownMenuItem leading={downloadLeading} onSelect={handleDownload}>
{t("workspace.fileExplorer.context.download")}
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</Pressable>
);
}
@@ -189,7 +206,6 @@ interface FileExplorerPaneProps {
workspaceId?: string | null;
workspaceRoot: string;
onOpenFile?: (filePath: string) => void;
onAddToChat?: (path: string) => void;
}
interface TreeRow {
@@ -202,10 +218,14 @@ export function FileExplorerPane({
workspaceId,
workspaceRoot,
onOpenFile,
onAddToChat,
}: FileExplorerPaneProps) {
const { t } = useTranslation();
const daemons = useHosts();
const daemonProfile = useMemo(
() => daemons.find((daemon) => daemon.serverId === serverId),
[daemons, serverId],
);
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
const workspaceStateKey = useMemo(
() =>
@@ -215,6 +235,10 @@ export function FileExplorerPane({
}),
[normalizedWorkspaceRoot, workspaceId],
);
const workspaceScopeId = useMemo(
() => workspaceId?.trim() || normalizedWorkspaceRoot,
[normalizedWorkspaceRoot, workspaceId],
);
const hasWorkspaceScope = Boolean(workspaceStateKey && normalizedWorkspaceRoot);
const explorerState = useSessionStore((state) =>
workspaceStateKey && state.sessions[serverId]
@@ -222,16 +246,12 @@ export function FileExplorerPane({
: undefined,
);
const { requestDirectoryListing, selectExplorerEntry } = useFileExplorerActions({
serverId,
workspaceId,
workspaceRoot: normalizedWorkspaceRoot,
});
const downloadFile = useFileDownload({
serverId,
workspaceId,
workspaceRoot: normalizedWorkspaceRoot,
});
const { requestDirectoryListing, requestFileDownloadToken, selectExplorerEntry } =
useFileExplorerActions({
serverId,
workspaceId,
workspaceRoot: normalizedWorkspaceRoot,
});
const sortOption = usePanelStore((state) => state.explorerSortOption);
const showHiddenFiles = usePanelStore((state) => state.explorerShowHiddenFiles);
const setSortOption = usePanelStore((state) => state.setExplorerSortOption);
@@ -326,14 +346,18 @@ export function FileExplorerPane({
[normalizedWorkspaceRoot],
);
const startDownload = useDownloadStore((state) => state.startDownload);
const handleDownloadEntry = useCallback(
(entry: ExplorerEntry) => {
if (entry.kind !== "file") {
return;
}
downloadFile({ fileName: entry.name, path: entry.path });
},
[downloadFile],
(entry: ExplorerEntry) =>
downloadExplorerEntry({
entry,
workspaceScopeId,
serverId,
daemonProfile,
startDownload,
requestFileDownloadToken,
}),
[daemonProfile, requestFileDownloadToken, serverId, startDownload, workspaceScopeId],
);
const handleSortCycle = useCallback(() => {
@@ -395,8 +419,6 @@ export function FileExplorerPane({
const renderTreeRow = useCallback(
(info: ListRenderItemInfo<TreeRow>) => (
<TreeRowDispatcher
serverId={serverId}
workspaceId={workspaceId}
info={info}
expandedPaths={expandedPaths}
selectedEntryPath={selectedEntryPath}
@@ -404,7 +426,6 @@ export function FileExplorerPane({
onEntryPress={handleEntryPress}
onCopyPath={handleCopyPath}
onDownloadEntry={handleDownloadEntry}
onAddToChat={onAddToChat}
/>
),
[
@@ -414,9 +435,6 @@ export function FileExplorerPane({
handleDownloadEntry,
isDirectoryLoading,
selectedEntryPath,
onAddToChat,
serverId,
workspaceId,
],
);
@@ -558,14 +576,8 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
return (
<View style={[styles.treePane, styles.treePaneFill]}>
<View style={styles.paneHeader} testID="files-pane-header">
<Pressable
onPress={handleSortCycle}
style={sortTriggerStyleProp}
testID="files-sort-trigger"
>
<Text style={styles.sortTriggerText} testID="files-sort-label">
{currentSortLabel}
</Text>
<Pressable onPress={handleSortCycle} style={sortTriggerStyleProp}>
<Text style={styles.sortTriggerText}>{currentSortLabel}</Text>
<ChevronDown size={12} color={theme.colors.foregroundMuted} />
</Pressable>
<View style={styles.headerActions}>
@@ -576,7 +588,6 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
accessibilityRole="button"
accessibilityLabel={hiddenFilesToggleAccessibilityLabel}
accessibilityState={hiddenFilesToggleAccessibilityState}
testID="files-hidden-toggle"
>
{showHiddenFiles ? (
<Eye size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
@@ -595,7 +606,6 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
? t("workspace.fileExplorer.actions.refreshing")
: t("workspace.fileExplorer.actions.refresh")
}
testID="files-refresh"
>
<View style={styles.refreshIcon}>
{isRefreshFetching ? (
@@ -768,6 +778,39 @@ function resolveTreeRows({
});
}
type StartDownloadFn = ReturnType<typeof useDownloadStore.getState>["startDownload"];
type StartDownloadParams = Parameters<StartDownloadFn>[0];
function downloadExplorerEntry({
entry,
workspaceScopeId,
serverId,
daemonProfile,
startDownload,
requestFileDownloadToken,
}: {
entry: ExplorerEntry;
workspaceScopeId: string | undefined;
serverId: string;
daemonProfile: StartDownloadParams["daemonProfile"];
startDownload: StartDownloadFn;
requestFileDownloadToken: (
targetPath: string,
) => ReturnType<StartDownloadParams["requestFileDownloadToken"]>;
}): void {
if (!workspaceScopeId || entry.kind !== "file") {
return;
}
startDownload({
serverId,
scopeId: workspaceScopeId,
fileName: entry.name,
path: entry.path,
daemonProfile,
requestFileDownloadToken: (targetPath) => requestFileDownloadToken(targetPath),
});
}
function toggleDirectory({
entry,
workspaceStateKey,
@@ -807,8 +850,6 @@ function toggleDirectory({
}
function TreeRowDispatcher({
serverId,
workspaceId,
info,
expandedPaths,
selectedEntryPath,
@@ -816,10 +857,7 @@ function TreeRowDispatcher({
onEntryPress,
onCopyPath,
onDownloadEntry,
onAddToChat,
}: {
serverId: string;
workspaceId?: string | null;
info: ListRenderItemInfo<TreeRow>;
expandedPaths: Set<string>;
selectedEntryPath: string | null;
@@ -827,7 +865,6 @@ function TreeRowDispatcher({
onEntryPress: (entry: ExplorerEntry) => void;
onCopyPath: (path: string) => void | Promise<void>;
onDownloadEntry: (entry: ExplorerEntry) => void;
onAddToChat?: (path: string) => void;
}) {
const entry = info.item.entry;
const depth = info.item.depth;
@@ -838,8 +875,6 @@ function TreeRowDispatcher({
return (
<TreeRowItem
serverId={serverId}
workspaceId={workspaceId}
entry={entry}
depth={depth}
isExpanded={isExpanded}
@@ -848,8 +883,6 @@ function TreeRowDispatcher({
onEntryPress={onEntryPress}
onCopyPath={onCopyPath}
onDownloadEntry={onDownloadEntry}
onAddToChat={onAddToChat}
testID={`file-explorer-row-${info.index}`}
/>
);
}
@@ -1028,6 +1061,7 @@ const styles = StyleSheet.create((theme) => ({
minHeight: 0,
},
entriesContent: {
paddingHorizontal: theme.spacing[2],
paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[4],
},
@@ -1077,8 +1111,9 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingVertical: WORKSPACE_FILE_ROW_VERTICAL_PADDING,
paddingRight: theme.spacing[3],
paddingVertical: 2,
paddingRight: theme.spacing[2],
borderRadius: theme.borderRadius.md,
},
entryRowActive: {
backgroundColor: theme.colors.surfaceSidebarHover,
@@ -1098,6 +1133,16 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
menuButton: {
width: 30,
height: 30,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
},
menuButtonActive: {
backgroundColor: theme.colors.surface2,
},
contextMetaBlock: {
paddingVertical: theme.spacing[1],
},
@@ -7,7 +7,7 @@ interface OmpIconProps {
export function OmpIcon({ size = 16, color = "currentColor" }: OmpIconProps) {
return (
<Svg width={size} height={size} viewBox="4 4 56 56" fill={color}>
<Svg width={size} height={size} viewBox="0 0 64 64" fill={color}>
<Path d="M10 14h44v9H43v33h-9V23h-9v22h-9V23H10z" />
</Svg>
);
@@ -7,7 +7,7 @@ interface PiIconProps {
export function PiIcon({ size = 16, color = "currentColor" }: PiIconProps) {
return (
<Svg width={size} height={size} viewBox="100 100 600 600" fill={color}>
<Svg width={size} height={size} viewBox="0 0 800 800" fill={color}>
<Path
d="M165.29 165.29 H517.36 V400 H400 V517.36 H282.65 V634.72 H165.29 Z M282.65 282.65 V400 H400 V282.65 Z"
fill={color}
+1 -3
View File
@@ -2657,9 +2657,7 @@ function computeShimmerMetrics(input: {
Math.min(120, input.labelRowWidth > 0 ? input.labelRowWidth * 0.28 : 0),
);
const isWebShimmer = input.isLoading && isWeb;
// React Native Web only observes a node when onLayout exists at mount. Keep
// measuring while idle so a retained badge has dimensions when it starts loading.
const shouldMeasureWebShimmer = isWeb;
const shouldMeasureWebShimmer = isWebShimmer;
const shouldMeasureNativeShimmer = input.isLoading && isNative;
const isNativeShimmer =
shouldMeasureNativeShimmer && input.labelRowWidth > 0 && input.labelRowHeight > 0;
@@ -1,49 +0,0 @@
import { describe, expect, it } from "vitest";
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
import { resolveInitialModelBrowserView } from "./model-browser-view";
function provider(id: string, label: string): ProviderSelectorProvider {
return {
id,
label,
modelSelection: { kind: "models", rows: [] },
};
}
describe("model browser initial view", () => {
const codex = provider("codex", "Codex");
const pi = provider("pi", "Pi");
it("opens a sole provider directly", () => {
expect(
resolveInitialModelBrowserView({
providers: [pi],
selectedProvider: "",
selectedModel: "",
favoriteKeys: new Set(),
}),
).toEqual({ kind: "provider", providerId: "pi", providerLabel: "Pi" });
});
it("opens the selected provider when its model is not a favorite", () => {
expect(
resolveInitialModelBrowserView({
providers: [codex, pi],
selectedProvider: "pi",
selectedModel: "pi-pro",
favoriteKeys: new Set(),
}),
).toEqual({ kind: "provider", providerId: "pi", providerLabel: "Pi" });
});
it("opens the provider overview when the selected model is a favorite", () => {
expect(
resolveInitialModelBrowserView({
providers: [codex, pi],
selectedProvider: "pi",
selectedModel: "pi-pro",
favoriteKeys: new Set(["pi:pi-pro"]),
}),
).toEqual({ kind: "all" });
});
});
@@ -1,40 +0,0 @@
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
export type ModelBrowserView =
| { kind: "all" }
| { kind: "provider"; providerId: string; providerLabel: string };
export function resolveInitialModelBrowserView({
providers,
selectedProvider,
selectedModel,
favoriteKeys,
}: {
providers: ProviderSelectorProvider[];
selectedProvider: string;
selectedModel: string;
favoriteKeys: Set<string>;
}): ModelBrowserView {
const singleProvider = providers.length === 1 ? providers[0] : undefined;
if (singleProvider) {
return {
kind: "provider",
providerId: singleProvider.id,
providerLabel: singleProvider.label,
};
}
const selectedFavoriteKey = `${selectedProvider}:${selectedModel}`;
const shouldOpenSelectedProvider =
selectedProvider.length > 0 &&
selectedModel.length > 0 &&
!favoriteKeys.has(selectedFavoriteKey);
if (shouldOpenSelectedProvider) {
const provider = providers.find((entry) => entry.id === selectedProvider);
if (provider) {
return { kind: "provider", providerId: provider.id, providerLabel: provider.label };
}
}
return { kind: "all" };
}
File diff suppressed because it is too large Load Diff
@@ -76,7 +76,7 @@ import {
type SplitPane,
type WorkspaceLayout,
} from "@/stores/workspace-layout-store";
import type { WorkspaceTab } from "@/workspace-tabs/model";
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
import { RenderProfile } from "@/utils/render-profiler";
import { workspaceTabTargetsEqual } from "@/workspace-tabs/identity";
import { isNative } from "@/constants/platform";
@@ -11,7 +11,6 @@ import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
// indentation, guide lines, and chevron. Keep those here so the two trees can't
// drift apart.
export const TREE_INDENT_PER_LEVEL = 16;
export const WORKSPACE_FILE_ROW_VERTICAL_PADDING = SPACING[1.5];
/** Left padding for a tree row at `depth`. Shared by folder rows and file headers
* in the Changes tree so their indentation can't drift apart. */
+34 -61
View File
@@ -24,7 +24,6 @@ import {
type ViewStyle,
} from "react-native";
import { useTranslation } from "react-i18next";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import {
@@ -101,8 +100,6 @@ export interface ComboboxProps {
*/
header?: SheetHeader;
mobileChildrenScrollEnabled?: boolean;
/** Overrides the mobile scroll container spacing for custom child content. */
mobileChildrenContentContainerStyle?: StyleProp<ViewStyle>;
presentation?: "push" | "replace";
open?: boolean;
onOpenChange?: (open: boolean) => void;
@@ -937,7 +934,6 @@ interface MobileBodyProps {
searchable: boolean;
hasChildren: boolean;
mobileChildrenScrollEnabled: boolean;
mobileChildrenContentContainerStyle: StyleProp<ViewStyle>;
presentation?: "push" | "replace";
searchResetKey: number;
searchPlaceholder: string;
@@ -951,7 +947,6 @@ interface MobileBodyProps {
handleSelect: (id: string) => void;
renderOption: RenderOptionFn | undefined;
children: ReactNode;
safeAreaBottom: number;
}
function MobileComboboxBody(props: MobileBodyProps): ReactElement {
@@ -971,10 +966,6 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement {
() => [styles.comboboxTitle, { color: props.titleColor }],
[props.titleColor],
);
const frameStyle = useMemo(
() => [styles.mobileSheetFrame, { paddingBottom: props.safeAreaBottom }],
[props.safeAreaBottom],
);
const body = props.hasChildren ? (
props.children
@@ -1005,46 +996,40 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement {
keyboardBlurBehavior="none"
presentation={props.presentation}
>
<View style={frameStyle}>
{props.header ? (
<SheetHeaderView header={props.header} onClose={props.onClose} />
) : (
<>
<View style={styles.bottomSheetHeader}>
<Text key={props.titleColor} style={comboboxTitleStyle}>
{props.title}
</Text>
</View>
{props.stickyHeader}
{!props.hasChildren && props.searchable ? (
<SearchInput
placeholder={props.searchPlaceholder}
onChangeText={props.setSearchQueryWithCallback}
onSubmitEditing={props.handleSubmitSearch}
autoFocus={false}
useBottomSheetInput
resetKey={props.searchResetKey}
/>
) : null}
</>
)}
{props.hasChildren && !props.mobileChildrenScrollEnabled ? (
body
) : (
<BottomSheetScrollView
style={styles.mobileSheetBody}
contentContainerStyle={[
styles.comboboxScrollContent,
props.mobileChildrenContentContainerStyle,
]}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{body}
</BottomSheetScrollView>
)}
{props.footer ? <View style={styles.footer}>{props.footer}</View> : null}
</View>
{props.header ? (
<SheetHeaderView header={props.header} onClose={props.onClose} />
) : (
<>
<View style={styles.bottomSheetHeader}>
<Text key={props.titleColor} style={comboboxTitleStyle}>
{props.title}
</Text>
</View>
{props.stickyHeader}
{!props.hasChildren && props.searchable ? (
<SearchInput
placeholder={props.searchPlaceholder}
onChangeText={props.setSearchQueryWithCallback}
onSubmitEditing={props.handleSubmitSearch}
autoFocus={false}
useBottomSheetInput
resetKey={props.searchResetKey}
/>
) : null}
</>
)}
{props.hasChildren && !props.mobileChildrenScrollEnabled ? (
body
) : (
<BottomSheetScrollView
contentContainerStyle={styles.comboboxScrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{body}
</BottomSheetScrollView>
)}
{props.footer ? <View style={styles.footer}>{props.footer}</View> : null}
</IsolatedBottomSheetModal>
);
}
@@ -1237,7 +1222,6 @@ export function Combobox({
title,
header,
mobileChildrenScrollEnabled = true,
mobileChildrenContentContainerStyle,
presentation,
open,
onOpenChange,
@@ -1257,7 +1241,6 @@ export function Combobox({
const resolvedEmptyText = emptyText ?? t("common.empty.noOptionsMatchSearch");
const resolvedTitle = title ?? t("common.actions.select");
const isMobile = useIsCompactFormFactor();
const safeAreaInsets = useSafeAreaInsets();
const titleColor = theme.colors.foreground;
const effectiveOptionsPosition = resolveEffectiveOptionsPosition(isMobile, optionsPosition);
const isDesktopAboveSearch = resolveIsDesktopAboveSearch(isMobile, effectiveOptionsPosition);
@@ -1532,7 +1515,6 @@ export function Combobox({
searchable={searchable}
hasChildren={hasChildren}
mobileChildrenScrollEnabled={mobileChildrenScrollEnabled}
mobileChildrenContentContainerStyle={mobileChildrenContentContainerStyle}
presentation={presentation}
searchResetKey={searchResetKey}
searchPlaceholder={effectiveSearchPlaceholder}
@@ -1545,7 +1527,6 @@ export function Combobox({
emptyText={resolvedEmptyText}
handleSelect={handleSelect}
renderOption={renderOption}
safeAreaBottom={safeAreaInsets.bottom}
>
{children}
</MobileComboboxBody>
@@ -1588,14 +1569,6 @@ export function Combobox({
}
const styles = StyleSheet.create((theme) => ({
mobileSheetFrame: {
flex: 1,
minHeight: 0,
},
mobileSheetBody: {
flex: 1,
minHeight: 0,
},
searchInputContainer: {
flexDirection: "row",
alignItems: "center",
@@ -6,9 +6,11 @@ import { createNameId } from "mnemonic-id";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
import { Composer } from "@/composer";
import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control";
import { useToast } from "@/contexts/toast-context";
import { useAgentInputDraft } from "@/composer/draft/input-draft";
import { useProjectIconQuery } from "@/hooks/use-project-icon-query";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
@@ -380,6 +382,7 @@ export function WorkspaceSetupDialog() {
const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(workspaceTitle);
const placeholderInitial = placeholderLabel.charAt(0).toUpperCase();
const isCompact = useIsCompactFormFactor();
const iconSource = useMemo(() => (iconDataUri ? { uri: iconDataUri } : null), [iconDataUri]);
const agentControlsWithDisabled = useMemo(
() =>
@@ -392,6 +395,14 @@ export function WorkspaceSetupDialog() {
[composerState, pendingAction],
);
const composerFooter = useMemo(
() =>
isCompact && agentControlsWithDisabled ? (
<DraftAgentModeControl placement="footer" {...agentControlsWithDisabled} />
) : undefined,
[isCompact, agentControlsWithDisabled],
);
const subtitleContent = useMemo(
() => (
<View style={styles.subtitleRow}>
@@ -446,6 +457,7 @@ export function WorkspaceSetupDialog() {
commandDraftConfig={composerState?.commandDraftConfig}
agentControls={agentControlsWithDisabled}
inputWrapperStyle={styles.composerInputWrapper}
footer={composerFooter}
/>
</FileDropZone>
-20
View File
@@ -333,26 +333,6 @@ 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();
+22 -32
View File
@@ -186,50 +186,40 @@ export async function dispatchComposerAgentMessage(
images: wirePayload.images,
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;
}
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,
});
}
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",
});
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.changedHead) {
stream.setHead((prev) => {
const next = new Map(prev);
next.set(agentId, result.head);
return next;
});
};
}
if (result.changedTail) {
stream.setTail((prev) => {
const next = new Map(prev);
next.set(agentId, result.tail);
return next;
});
}
}
export interface QueueComposerMessageInput {
@@ -348,7 +338,7 @@ export function openComposerAttachment(input: OpenComposerAttachmentInput): void
input.setLightboxMetadata(input.attachment.metadata);
return;
}
if (input.attachment.kind === "file" || input.attachment.kind === "workspace_file") {
if (input.attachment.kind === "file") {
return;
}
if (isWorkspaceAttachment(input.attachment)) {
@@ -1,173 +0,0 @@
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,
},
}));
@@ -1,32 +0,0 @@
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
@@ -1,37 +0,0 @@
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);
}
@@ -1,168 +0,0 @@
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);
});
});
@@ -1,134 +0,0 @@
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,4 +1,5 @@
import {
memo,
useCallback,
useMemo,
useRef,
@@ -7,7 +8,7 @@ import {
type ReactElement,
} from "react";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useShallow } from "zustand/shallow";
import { useStoreWithEqualityFn } from "zustand/traditional";
@@ -21,6 +22,7 @@ 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";
@@ -30,6 +32,7 @@ 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";
@@ -38,11 +41,15 @@ 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 { useComposerControlLayout } from "@/composer/agent-controls/layout-context";
import { AgentControlTrigger } from "@/composer/agent-controls/control";
import type { AgentMode } from "@getpaseo/protocol/agent-types";
import type { AgentMode, AgentProvider } 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;
@@ -95,7 +102,7 @@ function ModeComboboxOption({
);
}
export interface AgentModeControlValue {
interface AgentModeControlViewProps {
provider: string;
providerDefinitions: AgentProviderDefinition[];
modeOptions: AgentMode[];
@@ -108,24 +115,20 @@ function normalizeSearchQuery(value: string): string {
return value.trim().toLowerCase();
}
export function AgentModeControl({
function AgentModeControlView({
provider,
providerDefinitions,
modeOptions,
selectedModeId,
onSelectMode,
disabled = false,
surface = "toolbar",
onClose,
}: AgentModeControlValue & { surface?: "toolbar" | "sheet"; onClose?: () => void }) {
}: AgentModeControlViewProps) {
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("");
@@ -137,7 +140,7 @@ export function AgentModeControl({
const visuals = selectedMode
? getModeVisuals(provider, selectedMode.id, providerDefinitions)
: undefined;
const Icon = visuals?.icon ? (MODE_ICONS[visuals.icon] ?? Bot) : Bot;
const Icon = visuals?.icon ? MODE_ICONS[visuals.icon] : undefined;
const iconColor = theme.colors.foregroundMuted;
const selectedModeLabel = selectedMode ? formatAgentModeLabel(selectedMode) : "";
@@ -151,18 +154,10 @@ export function AgentModeControl({
return allOptions.filter((o) => o.label.toLowerCase().includes(q));
}, [allOptions, searchQuery]);
const handleOpenChange = useCallback(
(next: boolean) => {
const wasOpen = openRef.current;
openRef.current = next;
setOpen(next);
if (!next) {
setSearchQuery("");
if (wasOpen) onClose?.();
}
},
[onClose],
);
const handleOpenChange = useCallback((next: boolean) => {
setOpen(next);
if (!next) setSearchQuery("");
}, []);
const handlePress = useCallback(() => handleOpenChange(!open), [handleOpenChange, open]);
const handleSelect = useCallback(
@@ -213,6 +208,18 @@ export function AgentModeControl({
[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"),
@@ -231,23 +238,21 @@ export function AgentModeControl({
<>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild triggerRefProp="ref">
<AgentControlTrigger
<ComboboxTrigger
ref={anchorRef}
icon={Icon}
iconColor={iconColor}
surface={surface}
label={t("agentControls.mode.title")}
value={selectedModeLabel}
showToolbarLabel={presentation.showModeLabel}
showCaret={surface === "toolbar" && presentation.showCarets}
open={open}
collapsable={false}
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}>
@@ -264,7 +269,6 @@ export function AgentModeControl({
onOpenChange={handleOpenChange}
anchorRef={anchorRef}
desktopPlacement="top-start"
desktopMinWidth={260}
header={sheetHeader}
renderOption={renderOption}
/>
@@ -278,10 +282,21 @@ function compareAvailableModes(a: AgentMode[], b: AgentMode[]): boolean {
return a === b || JSON.stringify(a) === JSON.stringify(b);
}
export function useLiveAgentModeControl(
serverId: string,
agentId: string,
): AgentModeControlValue | null {
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;
const slice = useSessionStore(
useShallow((state) => {
const agent = state.sessions[serverId]?.agents?.get(agentId);
@@ -334,20 +349,82 @@ export function useLiveAgentModeControl(
[agentId, client, slice?.provider, toast, updatePreferences],
);
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]);
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}
/>
);
}
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",
@@ -1,275 +0,0 @@
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],
},
}));
@@ -9,7 +9,6 @@ import {
buildForgeAttachmentFromSearchItem,
buildLegacyGitHubAttachmentFromSearchItem,
} from "@/utils/review-attachments";
import { workspaceFileAttachmentToAgentAttachment } from "@/attachments/workspace-file";
export type ComposerAttachmentSubmitFormat = "forge" | "legacy-github";
@@ -50,11 +49,6 @@ export function splitComposerAttachmentsForSubmit(
continue;
}
if (attachment.kind === "workspace_file") {
agentAttachments.push(workspaceFileAttachmentToAgentAttachment(attachment));
continue;
}
if (isWorkspaceAttachment(attachment)) {
if (attachment.kind === "browser_element" && attachment.attachment.screenshot) {
images.push(attachment.attachment.screenshot);
@@ -45,7 +45,6 @@ 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;
@@ -197,22 +196,12 @@ 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" || result === "failed") {
if (result === "queued" || result === "submitted") {
resetSuppression();
}
},
@@ -224,7 +213,6 @@ function useWorkspaceAttachmentBinding({
buildOutgoingAttachments,
removeAttachment,
openAttachment,
beginSubmit,
clearSentAttachments,
completeSubmit,
resetSuppression,
@@ -19,7 +19,6 @@ 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
@@ -33,7 +32,7 @@ export function ComposerImportPill({ onPress, disabled = false }: ComposerImport
style={bodyStyle}
>
<ThemedImportIcon size={14} uniProps={iconColorMapping} />
<Text style={labelStyle} numberOfLines={1}>
<Text style={styles.label} numberOfLines={1}>
{t("importSession.title")}
</Text>
</Pressable>
@@ -51,7 +50,7 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.xl,
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.borderAccent,
backgroundColor: theme.colors.surface1,
@@ -60,10 +59,7 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface2,
},
label: {
color: theme.colors.foregroundMuted,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
labelHovered: {
color: theme.colors.foreground,
},
}));
@@ -5,7 +5,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { useDraftStore } from "@/stores/draft-store";
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
import { createWorkspaceFileAttachment } from "@/attachments/workspace-file";
const { asyncStorage } = vi.hoisted(() => ({
asyncStorage: new Map<string, string>(),
@@ -160,11 +159,7 @@ describe("useAgentInputDraft live contract", () => {
configurable: true,
});
useDraftStore.setState({
drafts: {},
createModalDraft: null,
attachmentFocusRequestByDraftKey: {},
});
useDraftStore.setState({ drafts: {}, createModalDraft: null });
});
it("hydrates persisted text and attachments and returns draft-mode composer state for a caller-provided key", async () => {
@@ -429,39 +424,6 @@ describe("useAgentInputDraft live contract", () => {
});
});
it("attaches to an unmounted legacy draft without losing its input", async () => {
const image: AttachmentMetadata = {
id: "legacy-image",
mimeType: "image/png",
storageType: "web-indexeddb",
storageKey: "attachments/legacy-image",
createdAt: 10,
};
useDraftStore.setState({
drafts: {
"draft:legacy-workspace-file": {
input: { text: "legacy text", images: [image] },
lifecycle: "active",
updatedAt: Date.now(),
version: 1,
} as unknown as DraftRecordForTest,
},
});
await useDraftStore.getState().attachWorkspaceFile({
draftKey: "draft:legacy-workspace-file",
attachment: createWorkspaceFileAttachment({ path: "src/app.ts" }),
});
expect(useDraftStore.getState().getDraftInput("draft:legacy-workspace-file")).toEqual({
text: "legacy text",
attachments: [
{ kind: "image", metadata: image },
createWorkspaceFileAttachment({ path: "src/app.ts" }),
],
});
});
it("clear resets text and attachments", async () => {
let latest: ReturnType<typeof useAgentInputDraft> | null = null;
const image: AttachmentMetadata = {
+96 -51
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { UserComposerAttachment } from "@/attachments/types";
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
@@ -9,6 +9,7 @@ import {
} from "@/hooks/use-agent-form-state";
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
import {
areAttachmentsEqual,
buildDraftAgentControls,
hasDraftContent,
resolveDraftKey,
@@ -21,7 +22,6 @@ import {
type ProviderSelectionState,
} from "@/provider-selection/provider-selection";
import { useDraftStore } from "@/stores/draft-store";
import { toDraftInputIfReady } from "@/stores/draft-store/state";
type AttachmentUpdater =
| UserComposerAttachment[]
@@ -57,7 +57,6 @@ export interface AgentInputDraft {
setAttachments: (updater: AttachmentUpdater) => void;
clear: (lifecycle: "sent" | "abandoned") => void;
isHydrated: boolean;
attachmentFocusRequestId: number;
composerState: DraftComposerState | null;
}
@@ -78,66 +77,67 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
}),
[formState.selectedServerId, input.draftKey],
);
const draftRecord = useDraftStore((state) => state.drafts[draftKey]);
const draft = useMemo(() => toDraftInputIfReady(draftRecord), [draftRecord]);
const attachmentFocusRequestId = useDraftStore(
(state) => state.attachmentFocusRequestByDraftKey[draftKey] ?? 0,
);
const [hydratedDraftKey, setHydratedDraftKey] = useState<string | null>(null);
const text = draft?.text ?? "";
const attachments = draft?.attachments ?? [];
const isHydrated = hydratedDraftKey === draftKey;
const [text, setText] = useState("");
const [attachments, setAttachmentsState] = useState<UserComposerAttachment[]>([]);
const [isHydrated, setIsHydrated] = useState(false);
const draftGenerationRef = useRef(0);
const hydratedGenerationRef = useRef(0);
const saveDraft = useCallback(
(
update: (draft: { text: string; attachments: UserComposerAttachment[] }) => {
text: string;
attachments: UserComposerAttachment[];
},
) => {
const store = useDraftStore.getState();
const current = store.getDraftInput(draftKey) ?? { text: "", attachments: [] };
const next = update(current);
if (!hasDraftContent(next)) {
store.clearDraftInput({ draftKey, lifecycle: "abandoned" });
return;
const setAttachments = useCallback((updater: AttachmentUpdater) => {
setAttachmentsState((previousAttachments) => {
if (typeof updater === "function") {
return updater(previousAttachments);
}
store.saveDraftInput({ draftKey, draft: next });
},
[draftKey],
);
const setText = useCallback(
(nextText: string) => {
saveDraft((current) => ({ ...current, text: nextText }));
},
[saveDraft],
);
const setAttachments = useCallback(
(updater: AttachmentUpdater) => {
saveDraft((current) => ({
...current,
attachments: typeof updater === "function" ? updater(current.attachments) : updater,
}));
},
[saveDraft],
);
return updater;
});
}, []);
const clear = useCallback(
(lifecycle: "sent" | "abandoned") => {
useDraftStore.getState().clearDraftInput({ draftKey, lifecycle });
const store = useDraftStore.getState();
store.clearDraftInput({ draftKey, lifecycle });
const generation = store.beginDraftGeneration(draftKey);
draftGenerationRef.current = generation;
hydratedGenerationRef.current = generation;
setText("");
setAttachmentsState([]);
setIsHydrated(true);
},
[draftKey],
);
useEffect(() => {
const store = useDraftStore.getState();
const generation = store.beginDraftGeneration(draftKey);
draftGenerationRef.current = generation;
hydratedGenerationRef.current = 0;
setText("");
setAttachmentsState([]);
setIsHydrated(false);
let cancelled = false;
void (async () => {
await useDraftStore.getState().hydrateDraftInput({ draftKey });
if (!cancelled) {
setHydratedDraftKey(draftKey);
const draft = await store.hydrateDraftInput({
draftKey,
});
if (cancelled) {
return;
}
if (!useDraftStore.getState().isDraftGenerationCurrent({ draftKey, generation })) {
return;
}
if (draft) {
setText(draft.text);
setAttachmentsState(draft.attachments);
}
hydratedGenerationRef.current = generation;
setIsHydrated(true);
})();
return () => {
@@ -145,6 +145,52 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
};
}, [draftKey]);
useEffect(() => {
const currentGeneration = draftGenerationRef.current;
if (currentGeneration <= 0) {
return;
}
const store = useDraftStore.getState();
const isCurrentGeneration = store.isDraftGenerationCurrent({
draftKey,
generation: currentGeneration,
});
if (!isCurrentGeneration) {
return;
}
if (hydratedGenerationRef.current !== currentGeneration) {
return;
}
const existing = store.getDraftInput(draftKey);
const isSameDraft =
existing !== undefined &&
existing.text === text &&
areAttachmentsEqual({
left: existing.attachments,
right: attachments,
});
if (isSameDraft) {
return;
}
if (!hasDraftContent({ text, attachments })) {
if (existing) {
store.clearDraftInput({ draftKey, lifecycle: "abandoned" });
}
return;
}
store.saveDraftInput({
draftKey,
draft: {
text,
attachments,
},
});
}, [attachments, draftKey, text]);
const lockedWorkingDir = composerOptions?.lockedWorkingDir?.trim() ?? "";
useEffect(() => {
if (!composerOptions || !lockedWorkingDir) {
@@ -258,7 +304,6 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
setAttachments,
clear,
isHydrated,
attachmentFocusRequestId,
composerState,
};
}
@@ -9,6 +9,7 @@ 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";
@@ -49,7 +50,7 @@ import {
useIsCompactFormFactor,
} from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model";
import type { WorkspaceDraftTabSetup } from "@/stores/workspace-tabs-store";
const EMPTY_PENDING_PERMISSIONS = new Map();
const EMPTY_ONLINE_SERVER_IDS: string[] = [];
@@ -608,6 +609,57 @@ 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",
});
@@ -628,11 +680,39 @@ 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, handleDropdownCloseFocus, isSubmitting],
[
composerState.agentControls,
handleProviderSelectWithFocus,
handleModeSelectWithFocus,
handleModelSelectWithFocus,
handleProviderAndModelSelectWithFocus,
handleThinkingOptionSelectWithFocus,
handleSetFeatureWithFocus,
handleDropdownCloseFocus,
isSubmitting,
],
);
const composerFooter = useMemo(
() =>
isCompactComposerLayout ? (
<DraftAgentModeControl
placement="footer"
{...composerAgentControls}
isCompactLayout={isCompactComposerLayout}
/>
) : undefined,
[isCompactComposerLayout, composerAgentControls],
);
return (
<FileDropZone style={styles.container}>
<View style={styles.contentContainer}>
@@ -671,7 +751,6 @@ export function WorkspaceDraftAgentTab({
<Composer
agentId={tabId}
serverId={serverId}
workspaceId={workspaceId}
externalKeyboardShift
isPaneFocused={isPaneFocused}
onSubmitMessage={handleCreateFromInput}
@@ -686,10 +765,10 @@ export function WorkspaceDraftAgentTab({
cwd={composerState.workingDir}
clearDraft={draftInput.clear}
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
autoFocusKey={String(draftInput.attachmentFocusRequestId)}
onFocusInput={handleFocusInputCallback}
commandDraftConfig={composerState.commandDraftConfig}
agentControls={composerAgentControls}
footer={composerFooter}
isCompactLayout={isCompactComposerLayout}
/>
</ReanimatedAnimated.View>
@@ -1,49 +0,0 @@
import { describe, expect, it } from "vitest";
import type { WorkspaceLayout } from "@/stores/workspace-layout-store";
import { resolveFocusedChatTarget } from "./focused-chat-target";
function layoutWithTarget(
target: import("@/workspace-tabs/model").WorkspaceTab["target"],
): WorkspaceLayout {
return {
root: {
kind: "pane",
pane: {
id: "pane-1",
tabIds: ["focused-tab"],
focusedTabId: "focused-tab",
tabs: [{ tabId: "focused-tab", target, createdAt: 1 }],
},
},
focusedPaneId: "pane-1",
} as WorkspaceLayout;
}
describe("focused chat target", () => {
it("targets the focused agent draft", () => {
expect(
resolveFocusedChatTarget({
serverId: "server-1",
layout: layoutWithTarget({ kind: "agent", agentId: "agent-1" }),
}),
).toEqual({ tabId: "focused-tab", draftKey: "agent:server-1:agent-1" });
});
it("targets the focused unsent draft", () => {
expect(
resolveFocusedChatTarget({
serverId: "server-1",
layout: layoutWithTarget({ kind: "draft", draftId: "draft-1" }),
}),
).toEqual({ tabId: "focused-tab", draftKey: "draft:server-1:draft-1" });
});
it("does not guess when the focused tab is not a chat", () => {
expect(
resolveFocusedChatTarget({
serverId: "server-1",
layout: layoutWithTarget({ kind: "terminal", terminalId: "terminal-1" }),
}),
).toBeNull();
});
});
@@ -1,48 +0,0 @@
import { buildDraftStoreKey } from "@/stores/draft-keys";
import {
collectAllTabs,
findPaneById,
type WorkspaceLayout,
} from "@/stores/workspace-layout-store";
export interface FocusedChatTarget {
tabId: string;
draftKey: string;
}
export function resolveFocusedChatTarget(input: {
serverId: string;
layout: WorkspaceLayout | undefined;
}): FocusedChatTarget | null {
if (!input.layout) {
return null;
}
const pane = findPaneById(input.layout.root, input.layout.focusedPaneId);
const focusedTabId = pane?.focusedTabId;
if (!focusedTabId) {
return null;
}
const tab = collectAllTabs(input.layout.root).find(
(candidate) => candidate.tabId === focusedTabId,
);
if (!tab) {
return null;
}
if (tab.target.kind === "agent") {
return {
tabId: tab.tabId,
draftKey: buildDraftStoreKey({ serverId: input.serverId, agentId: tab.target.agentId }),
};
}
if (tab.target.kind === "draft") {
return {
tabId: tab.tabId,
draftKey: buildDraftStoreKey({
serverId: input.serverId,
agentId: tab.tabId,
draftId: tab.target.draftId,
}),
};
}
return null;
}
+81 -103
View File
@@ -99,7 +99,6 @@ import type {
AttachmentMetadata,
ComposerAttachment,
UserComposerAttachment,
WorkspaceFileComposerAttachment,
WorkspaceComposerAttachment,
} from "@/attachments/types";
import type { PickedFile } from "@/attachments/picked-file";
@@ -120,15 +119,6 @@ import { getForgePresentation } from "@/git/forge";
import { ForgeBrandIcon } from "@/git/forge-icon";
import { useComposerGithubAutoAttach } from "./github/auto-attach";
import { resolveClientSlashCommand, type ClientSlashCommand } from "@/client-slash-commands";
import {
appendWorkspaceFileAttachment,
getWorkspaceFileAttachmentKey,
getWorkspaceFileAttachmentSubtitle,
} from "@/attachments/workspace-file";
import {
resolveWorkspaceFileDrop,
type WorkspaceFileDragPayload,
} from "@/attachments/workspace-file-drag";
type QueuedMessage = QueuedComposerMessage;
@@ -232,7 +222,6 @@ function renderContextWindowMeter(
serverId: string,
provider: string | null,
pending: boolean,
glyphSize: number,
): ReactElement | null {
const hasData = contextWindowMaxTokens !== null && contextWindowUsedTokens !== null;
if (!hasData && !pending) {
@@ -247,16 +236,21 @@ function renderContextWindowMeter(
serverId={serverId}
provider={provider}
pending={pending}
glyphSize={glyphSize}
/>
);
}
function resolveContextWindowPlacement(
meter: ReactElement | null,
reserveSlot: boolean,
): ReactNode {
return reserveSlot ? <View style={styles.contextWindowMeterSlot}>{meter}</View> : null;
isMobile: boolean,
): { beforeVoiceContent: ReactNode; footerInlineContent: ReactNode } {
if (isMobile) {
return { beforeVoiceContent: null, footerInlineContent: meter };
}
return {
beforeVoiceContent: <View style={styles.contextWindowMeterSlot}>{meter}</View>,
footerInlineContent: null,
};
}
interface RenderLeftContentArgs {
@@ -298,6 +292,23 @@ 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,
@@ -388,18 +399,6 @@ function renderComposerAttachmentPill(args: RenderComposerAttachmentPillArgs): R
/>
);
}
if (attachment.kind === "workspace_file") {
return (
<WorkspaceFileAttachmentPill
key={`workspace-file:${getWorkspaceFileAttachmentKey(attachment)}`}
attachment={attachment}
index={index}
disabled={disabled}
onRemove={onRemove}
removeLabel={labels.removeFile}
/>
);
}
if (composerWorkspaceAttachment.is(attachment)) {
return composerWorkspaceAttachment.renderPill({
attachment,
@@ -712,43 +711,6 @@ function FileAttachmentPill({
);
}
interface WorkspaceFileAttachmentPillProps {
attachment: WorkspaceFileComposerAttachment;
index: number;
disabled: boolean;
onRemove: (index: number) => void;
removeLabel: string;
}
function WorkspaceFileAttachmentPill({
attachment,
index,
disabled,
onRemove,
removeLabel,
}: WorkspaceFileAttachmentPillProps) {
const handleRemove = useCallback(() => {
onRemove(index);
}, [index, onRemove]);
const fileName = attachment.path.split("/").pop() ?? attachment.path;
return (
<AttachmentPill
testID="composer-workspace-file-attachment-pill"
onOpen={noopCallback}
onRemove={handleRemove}
openAccessibilityLabel={fileName}
removeAccessibilityLabel={removeLabel}
disabled={disabled}
>
<AttachmentLabel
icon={filePillIcon}
title={fileName}
subtitle={getWorkspaceFileAttachmentSubtitle(attachment)}
/>
</AttachmentPill>
);
}
interface GithubPickerOptionProps {
label: string;
testID: string;
@@ -793,7 +755,6 @@ function GithubPickerOption({
interface ComposerProps {
agentId: string;
serverId: string;
workspaceId?: string | null;
isPaneFocused: boolean;
onSubmitMessage?: (payload: MessagePayload) => Promise<void>;
onClientSlashCommand?: (command: ClientSlashCommand) => Promise<void>;
@@ -825,8 +786,6 @@ interface ComposerProps {
clearDraft: (lifecycle: "sent" | "abandoned") => void;
/** When true, auto-focuses the text input on web. */
autoFocus?: boolean;
/** Changing this value requests focus again while autoFocus remains true. */
autoFocusKey?: string;
/** Callback to expose a focus function to parent components (desktop only). */
onFocusInput?: (focus: () => void) => void;
/** Optional draft context for listing commands before an agent exists. */
@@ -840,6 +799,8 @@ 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. */
@@ -1016,7 +977,6 @@ function ComposerVoiceModeButton({
export function Composer({
agentId,
serverId,
workspaceId,
isPaneFocused,
onSubmitMessage,
onClientSlashCommand,
@@ -1040,7 +1000,6 @@ export function Composer({
cwd,
clearDraft,
autoFocus = false,
autoFocusKey,
onFocusInput,
commandDraftConfig,
onMessageSent,
@@ -1049,6 +1008,7 @@ export function Composer({
onAttentionPromptSend,
agentControls,
inputWrapperStyle,
footer,
externalKeyboardShift,
isCompactLayout: isCompactLayoutOverride,
}: ComposerProps) {
@@ -1095,7 +1055,6 @@ export function Composer({
buildOutgoingAttachments,
removeAttachment,
openAttachment,
beginSubmit,
clearSentAttachments,
completeSubmit,
resetSuppression,
@@ -1236,21 +1195,6 @@ export function Composer({
});
}, []);
const handleWorkspaceFileDropped = useCallback(
(payload: WorkspaceFileDragPayload) => {
if (!workspaceId) {
return;
}
const attachment = resolveWorkspaceFileDrop({ payload, serverId, workspaceId });
if (!attachment) {
return;
}
setSelectedAttachments((current) => appendWorkspaceFileAttachment(current, attachment));
focusInput();
},
[focusInput, serverId, setSelectedAttachments, workspaceId],
);
useEffect(() => {
onFocusInput?.(focusInput);
}, [focusInput, onFocusInput]);
@@ -1373,9 +1317,6 @@ export function Composer({
queueMessage(queuedText, queuedAttachments);
},
submitMessage: async ({ message: submitText, attachments: submitAttachments }) => {
if (submitBehavior !== "preserve-and-lock") {
beginSubmit(submitAttachments);
}
await submitMessage(submitText, submitAttachments);
},
clearDraft,
@@ -1397,7 +1338,6 @@ export function Composer({
},
[
allowEmptySubmit,
beginSubmit,
clearDraft,
completeSubmit,
hasExternalContent,
@@ -1781,7 +1721,6 @@ export function Composer({
const contextWindowPending =
agentState.status === "initializing" || agentState.status === "running";
const contextWindowMeterGlyphSize = isCompactLayout ? ICON_SIZE.md : buttonIconSize;
const contextWindowMeter = useMemo(
() =>
@@ -1789,25 +1728,24 @@ export function Composer({
contextWindowMaxTokens,
contextWindowUsedTokens,
agentState.totalCostUsd,
false,
isCompactLayout,
serverId,
agentState.provider,
contextWindowPending,
contextWindowMeterGlyphSize,
),
[
contextWindowMaxTokens,
contextWindowUsedTokens,
agentState.totalCostUsd,
isCompactLayout,
serverId,
agentState.provider,
contextWindowPending,
contextWindowMeterGlyphSize,
],
);
const beforeVoiceContent = useMemo(
() => resolveContextWindowPlacement(contextWindowMeter, hasAgent),
[contextWindowMeter, hasAgent],
const { beforeVoiceContent, footerInlineContent } = useMemo(
() => resolveContextWindowPlacement(contextWindowMeter, isCompactLayout),
[contextWindowMeter, isCompactLayout],
);
const hasGithubAttachment = useMemo(
@@ -2031,11 +1969,7 @@ export function Composer({
// so a drop in that window would be lost or land on a locked draft. `disabled` hides the
// backdrop and rejects the drop atomically, instead of accepting a drop with no feedback.
useFileDrop(
{
onFiles: addImages,
onGenericFiles: handleGenericFilesDropped,
onWorkspaceFile: handleWorkspaceFileDropped,
},
{ onFiles: addImages, onGenericFiles: handleGenericFilesDropped },
{ disabled: isSubmitBusy },
);
@@ -2096,7 +2030,7 @@ export function Composer({
isReadyForDictation={isDictationReady}
placeholder={messagePlaceholder}
autoFocus={messageInputAutoFocus}
autoFocusKey={`${serverId}:${agentId}:${autoFocusKey ?? ""}`}
autoFocusKey={`${serverId}:${agentId}`}
disabled={isSubmitLoading}
isPaneFocused={isPaneFocused}
leftContent={leftContent}
@@ -2138,6 +2072,7 @@ export function Composer({
</View>
</View>
</View>
{renderComposerFooter(footer, footerInlineContent)}
</Animated.View>
</ComposerKeyboardScopeProvider>
);
@@ -2173,6 +2108,50 @@ 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%",
@@ -2195,7 +2174,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
contextWindowMeterSlot: {
width: 28,
height: 28,
flexShrink: 0,
alignItems: "center",
justifyContent: "center",
},
+14 -43
View File
@@ -29,7 +29,6 @@ import type { AgentAttachment, SessionOutboundMessage } from "@getpaseo/protocol
import { parseServerInfoStatusPayload } from "@getpaseo/protocol/messages";
import {
buildAgentAttentionNotificationPayload,
type AgentAttentionReason,
type AgentAttentionNotificationPayload,
type NotificationPermissionRequest,
} from "@getpaseo/protocol/agent-attention-notification";
@@ -155,35 +154,6 @@ const getLatestPermissionRequest = (
return null;
};
interface AgentAttentionNotificationInput {
notification?: AgentAttentionNotificationPayload;
reason: AgentAttentionReason;
serverId: string;
workspaceId: string | undefined;
agentId: string;
assistantMessage: string | null;
permissionRequest: NotificationPermissionRequest | null;
}
function resolveAgentAttentionNotification(
input: AgentAttentionNotificationInput,
): AgentAttentionNotificationPayload | null {
if (input.notification) {
return input.notification.data.workspaceId ? input.notification : null;
}
if (!input.workspaceId) {
return null;
}
return buildAgentAttentionNotificationPayload({
reason: input.reason,
serverId: input.serverId,
workspaceId: input.workspaceId,
agentId: input.agentId,
assistantMessage: input.reason === "finished" ? input.assistantMessage : null,
permissionRequest: input.reason === "permission" ? input.permissionRequest : null,
});
}
type WorkspaceSetupProgressPayload = Extract<
SessionOutboundMessage,
{ type: "workspace_setup_progress" }
@@ -485,6 +455,11 @@ 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],
@@ -525,20 +500,16 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const assistantMessage =
findLatestAssistantMessageText(head) ?? findLatestAssistantMessageText(tail);
const permissionRequest = getLatestPermissionRequest(session, params.agentId);
const workspaceId = session?.agents?.get(params.agentId)?.workspaceId;
const notification = resolveAgentAttentionNotification({
notification: params.notification,
reason: params.reason,
serverId,
workspaceId,
agentId: params.agentId,
assistantMessage,
permissionRequest,
});
if (!notification) {
return;
}
const notification =
params.notification ??
buildAgentAttentionNotificationPayload({
reason: params.reason,
serverId,
agentId: params.agentId,
assistantMessage: params.reason === "finished" ? assistantMessage : null,
permissionRequest: params.reason === "permission" ? permissionRequest : null,
});
void sendOsNotification({
title: notification.title,
@@ -32,21 +32,4 @@ 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,11 +10,7 @@ export async function revalidateSessionAfterResume(input: {
return false;
}
try {
input.bumpHistorySyncGeneration(input.serverId);
await input.refreshDirectories();
return true;
} catch {
return false;
}
input.bumpHistorySyncGeneration(input.serverId);
await input.refreshDirectories();
return true;
}
+14 -14
View File
@@ -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.6",
version: "1.3.3",
iconId: "agoragentic-acp",
installLink: "https://agoragentic.com",
command: ["npx", "-y", "agoragentic-mcp@1.3.6", "--acp"],
command: ["npx", "-y", "agoragentic-mcp@1.3.3", "--acp"],
},
{
id: "amp-acp",
@@ -140,29 +140,29 @@ const CATALOG_DATA = [
id: "dimcode",
title: "DimCode",
description: "A coding agent that puts leading models at your command.",
version: "0.2.36",
version: "0.2.35",
iconId: "dimcode",
installLink: "https://dimcode.dev/docs/acp.html",
command: ["npx", "-y", "dimcode@0.2.36", "acp"],
command: ["npx", "-y", "dimcode@0.2.35", "acp"],
},
{
id: "dirac",
title: "Dirac",
description:
"Reduces API costs by more than 50%, produces better and faster work. Uses Hash anchored parallel edits, AST manipulation and a whole lot of neat optimizations. Fully Open Source.",
version: "0.4.22",
version: "0.4.21",
iconId: "dirac",
installLink: "https://dirac.run",
command: ["npx", "-y", "dirac-cli@0.4.22", "--acp"],
command: ["npx", "-y", "dirac-cli@0.4.21", "--acp"],
},
{
id: "factory-droid",
title: "Factory Droid",
description: "Factory Droid - AI coding agent powered by Factory AI",
version: "0.179.0",
version: "0.177.0",
iconId: "factory-droid",
installLink: "https://factory.ai/product/cli",
command: ["npx", "-y", "droid@0.179.0", "exec", "--output-format", "acp-daemon"],
command: ["npx", "-y", "droid@0.177.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.22",
version: "0.9.20",
iconId: "fast-agent",
installLink: "https://fast-agent.ai/acp/",
command: ["uvx", "--from", "fast-agent-acp==0.9.22", "fast-agent-acp", "-x"],
command: ["uvx", "--from", "fast-agent-acp==0.9.20", "fast-agent-acp", "-x"],
},
{
id: "gemini",
title: "Gemini CLI",
description: "Google's official CLI for Gemini",
version: "0.52.0",
version: "0.51.0",
iconId: "gemini",
installLink: "https://geminicli.com",
command: ["npx", "-y", "@google/gemini-cli@0.52.0", "--acp"],
command: ["npx", "-y", "@google/gemini-cli@0.51.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.4",
version: "1.1.2",
iconId: "qoder",
installLink: "https://qoder.com",
command: ["npx", "-y", "@qoder-ai/qodercli@1.1.4", "--acp"],
command: ["npx", "-y", "@qoder-ai/qodercli@1.1.2", "--acp"],
},
{
id: "qwen-code",
+1
View File
@@ -4,6 +4,7 @@ export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: Infinity,
gcTime: Infinity,
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
@@ -1,69 +0,0 @@
import { useEffect } from "react";
import { listenToDesktopEvent } from "@/desktop/electron/events";
import { getDesktopHost } from "@/desktop/host";
import { useStableEvent } from "@/hooks/use-stable-event";
import { navigateToAgent } from "@/utils/navigate-to-agent";
interface OpenAgentEventPayload {
serverId?: unknown;
agentId?: unknown;
}
export function AgentNavigationListener() {
const openAgent = useStableEvent((payload: OpenAgentEventPayload | null) => {
const serverId = typeof payload?.serverId === "string" ? payload.serverId.trim() : "";
const agentId = typeof payload?.agentId === "string" ? payload.agentId.trim() : "";
if (!serverId || !agentId) {
return;
}
navigateToAgent({ serverId, agentId });
});
useEffect(() => {
const host = getDesktopHost();
const ready = host?.agentNavigation?.ready;
if (typeof host?.events?.on !== "function" || typeof ready !== "function") {
return;
}
let disposed = false;
let unlisten: (() => void) | null = null;
let retryTimer: ReturnType<typeof setTimeout> | null = null;
const connect = async () => {
let dispose: (() => void) | null = null;
try {
dispose = await listenToDesktopEvent<OpenAgentEventPayload>("open-agent", openAgent);
if (disposed) {
dispose();
return;
}
unlisten = dispose;
const pending = await ready();
if (!disposed && pending) {
openAgent(pending);
}
} catch {
dispose?.();
if (unlisten === dispose) {
unlisten = null;
}
if (!disposed) {
retryTimer = setTimeout(() => void connect(), 1_000);
}
}
};
void connect();
return () => {
disposed = true;
if (retryTimer) {
clearTimeout(retryTimer);
}
unlisten?.();
};
}, [openAgent]);
return null;
}
-5
View File
@@ -123,10 +123,6 @@ export interface DesktopEventsBridge {
on?: (event: string, handler: (payload: unknown) => void) => Promise<() => void> | (() => void);
}
export interface DesktopAgentNavigationBridge {
ready?: () => Promise<{ serverId: string; agentId: string } | null>;
}
export type DesktopBrowserShortcutEvent =
| { browserId?: string; action: "focus-url" }
| { browserId: string; action: "new-tab" };
@@ -173,7 +169,6 @@ export interface DesktopHostBridge {
platform?: string;
invoke?: DesktopInvokeBridge["invoke"];
getPendingOpenProject?: () => Promise<string | null>;
agentNavigation?: DesktopAgentNavigationBridge;
events?: DesktopEventsBridge;
window?: DesktopWindowModuleBridge;
dialog?: DesktopDialogBridge;
@@ -1,34 +0,0 @@
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,13 +8,8 @@ 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;
}
+5 -114
View File
@@ -77,17 +77,8 @@ function ready(
return { status: "ready", cwd: "/workspace", path: "file.ts", size, modifiedAt };
}
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" }>,
};
function makeModel() {
const file = { content: "one", 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 };
@@ -151,55 +142,10 @@ 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" }>,
};
@@ -209,26 +155,6 @@ 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> = [];
@@ -238,9 +164,9 @@ describe("FileEditorModel", () => {
model.receiveFileVersion(firstVersion);
model.receiveFileVersion(latestVersion);
reads[0]?.({ content: "first", hasBom: false, version: firstVersion });
reads[0]?.({ content: "first", version: firstVersion });
await Promise.resolve();
reads[1]?.({ content: "latest", hasBom: false, version: latestVersion });
reads[1]?.({ content: "latest", version: latestVersion });
await Promise.resolve();
expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "latest" });
@@ -260,21 +186,6 @@ 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");
@@ -282,7 +193,7 @@ describe("FileEditorModel", () => {
FileVersion,
{ status: "ready" }
>;
session.file = { content: "disk", hasBom: false, version: diskVersion };
session.file = { content: "disk", version: diskVersion };
model.receiveFileVersion(diskVersion);
await model.reload();
@@ -290,26 +201,6 @@ 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 -19
View File
@@ -1,12 +1,10 @@
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;
@@ -15,7 +13,6 @@ export interface FileEditorSnapshot {
export interface FileEditorFile {
content: string;
hasBom: boolean;
version: Extract<FileVersion, { status: "ready" }>;
}
@@ -52,7 +49,6 @@ export class FileEditorModel {
private disposed = false;
private observedWhileSaving: FileVersion | null = null;
private persistedContent: string;
private hasBom: boolean;
constructor(input: {
file: FileEditorFile;
@@ -62,11 +58,9 @@ 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,
@@ -173,11 +167,10 @@ 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: serializedContent,
content,
expectedModifiedAt: expectedVersion.modifiedAt,
expectedRevision: expectedVersion.revision,
});
@@ -248,11 +241,9 @@ 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,
@@ -299,15 +290,6 @@ 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,8 +56,7 @@ export function FileEditorView({
update.docChanged &&
!update.transactions.some((tr) => tr.annotation(remoteUpdate))
) {
const { lineSeparator } = values.model.getSnapshot();
values.model.edit(update.state.doc.sliceString(0, undefined, lineSeparator));
values.model.edit(update.state.doc.toString());
}
if (update.selectionSet || update.docChanged) {
const head = update.state.selection.main.head;
@@ -78,12 +77,10 @@ export function FileEditorView({
useEffect(() => {
const view = viewRef.current;
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);
if (!view || view.state.doc.toString() === snapshot.content) return;
const head = Math.min(view.state.selection.main.head, snapshot.content.length);
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: document },
changes: { from: 0, to: view.state.doc.length, insert: snapshot.content },
selection: { anchor: head },
annotations: [remoteUpdate.of(true), Transaction.addToHistory.of(false)],
});
+4 -10
View File
@@ -459,7 +459,6 @@ 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"));
@@ -472,8 +471,8 @@ export function FilePane({
preview={preview}
version={version}
filename={getFileNameFromPath(location.path) ?? location.path}
markdownMode={canToggleMarkdownMode ? markdownMode : undefined}
onMarkdownModeChange={canToggleMarkdownMode ? setMarkdownMode : undefined}
markdownMode={isMarkdown ? markdownMode : undefined}
onMarkdownModeChange={isMarkdown ? setMarkdownMode : undefined}
lineCount={lineCount}
editable={editable}
disconnectedMessage={t("workspace.terminal.hostDisconnected")}
@@ -638,13 +637,9 @@ function EditableFilePane({
() => ({
async read(): Promise<FileEditorFile> {
const file = await client.readFile(cwd, path);
const decodedFile = explorerFileFromReadResult(file);
if (decodedFile.kind !== "text" || decodedFile.content === undefined) {
throw new Error("File is no longer text.");
}
if (file.kind !== "text") throw new Error("File is no longer text.");
return {
content: decodedFile.content,
hasBom: decodedFile.hasBom,
content: new TextDecoder().decode(file.bytes),
version: {
status: "ready",
cwd,
@@ -666,7 +661,6 @@ function EditableFilePane({
new FileEditorModel({
file: {
content: preview.content ?? "",
hasBom: preview.hasBom,
version: {
status: "ready",
cwd,
@@ -1,79 +0,0 @@
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,34 +1,18 @@
import { memo, useCallback } from "react";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { Pressable, Text, View } 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 { CommitGraphNode } from "./commit-graph-node";
import { dotStyles } from "./shared";
interface CommitRowProps {
commit: ClassifiedCheckoutCommit;
isFirst: boolean;
isLast: boolean;
commit: CheckoutCommit;
now: Date;
onCommitPress: (sha: string) => void;
}
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) {
export const CommitRow = memo(function CommitRow({ commit, now, onCommitPress }: CommitRowProps) {
const handlePress = useCallback(() => {
onCommitPress(commit.sha);
}, [commit.sha, onCommitPress]);
@@ -38,17 +22,16 @@ export const CommitRow = memo(function CommitRow({
accessibilityRole="button"
testID={`commit-row-${commit.shortSha}`}
onPress={handlePress}
style={commitRowPressableStyle}
style={styles.row}
>
<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>
<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>
<Text style={styles.timestamp}>{formatTimeAgo(new Date(commit.authorDate), now)}</Text>
<View style={styles.caret}>
<ThemedChevron size={14} uniProps={chevronColorMapping} />
@@ -66,21 +49,10 @@ 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,7 +6,6 @@ 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 {
@@ -15,6 +14,8 @@ 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 (
@@ -24,13 +25,15 @@ function CommitsSectionSkeleton() {
style={styles.skeleton}
testID="commits-section-skeleton"
>
<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>
{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>
);
}
@@ -55,28 +58,17 @@ function CommitsSectionContent({
if (query.status !== "loaded") {
return <CommitsSectionSkeleton />;
}
const workspaceCommits = query.data.commits.filter((commit) => !commit.isOnBase);
const baseRef = normalizeBranchOptionName(query.data.baseRef) ?? t("workspace.git.diff.base");
if (workspaceCommits.length === 0) {
if (query.data.commits.length === 0) {
return (
<View style={styles.noWorkspaceCommitsRow} testID="commits-section-no-workspace-commits">
<Text style={styles.noWorkspaceCommitsText}>
{t("workspace.git.diff.commits.noneAhead", { baseRef })}
</Text>
</View>
<Text style={styles.emptyRow} testID="commits-section-empty">
{t("workspace.git.diff.commits.empty")}
</Text>
);
}
return (
<View style={styles.list}>
{workspaceCommits.map((commit, index) => (
<CommitRow
key={commit.sha}
commit={commit}
isFirst={index === 0}
isLast={index === workspaceCommits.length - 1}
now={now}
onCommitPress={onCommitPress}
/>
{query.data.commits.map((commit) => (
<CommitRow key={commit.sha} commit={commit} now={now} onCommitPress={onCommitPress} />
))}
</View>
);
@@ -118,10 +110,7 @@ export function CommitsSection({ serverId, cwd, onCommitPress }: CommitsSectionP
if (query.status === "unsupported") {
return null;
}
const commitCount =
query.status === "loaded"
? query.data.commits.filter((commit) => !commit.isOnBase).length
: null;
const commitCount = query.status === "loaded" ? query.data.commits.length : null;
return (
<View style={styles.container}>
@@ -194,17 +183,12 @@ const styles = StyleSheet.create((theme) => ({
list: {
paddingBottom: theme.spacing[1],
},
noWorkspaceCommitsRow: {
flexDirection: "row",
alignItems: "center",
paddingLeft: theme.spacing[2],
paddingRight: theme.spacing[3],
paddingTop: theme.spacing[1],
paddingBottom: theme.spacing[2],
},
noWorkspaceCommitsText: {
emptyRow: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
paddingLeft: theme.spacing[2],
paddingRight: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
errorRow: {
fontSize: theme.fontSize.xs,

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