Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 3448e8b023 Keep agent tools bound to active sessions 2026-06-08 14:47:39 +07:00
Mohamed Boudra 6524677960 Document checkout-local dev home defaults 2026-06-08 10:34:54 +07:00
Mohamed Boudra 09a1fe46fe Hide OpenCode compaction summaries 2026-06-08 10:14:54 +07:00
Mohamed Boudra 06a8f952db Keep Paseo MCP config out of agent storage
Runtime-injected MCP endpoints can change with daemon listen settings, so compute them for launches instead of persisting them.
2026-06-07 23:31:03 +07:00
Mohamed Boudra 893e3376b0 Recover stale supervised daemons
Teach daemon stop to use lifecycle shutdown when the API is reachable even if the owner pid is stale, then wait for the API to disappear and clean the stale pidfile.

Launch desktop-managed daemons detached from desktop stdio, preserve stale reachable daemon ownership for version checks, and allow desktop stop/restart to use the CLI recovery path.

Add supervisor heartbeats so supervised workers shut down when their supervisor disappears instead of surviving as orphaned reachable daemons.
2026-06-07 23:18:48 +07:00
Mohamed Boudra 7c8b290e2f Stabilize local dev daemon homes
Use a checkout-local .dev/paseo-home for root and worktree dev flows so development daemons do not collide with the packaged app home.

Split server, app, and desktop dev entrypoints, seed worktree homes from the source checkout metadata, and keep desktop dev on its own user-data directory and Expo port.
2026-06-07 23:18:32 +07:00
Mohamed Boudra d55e1622cb Persist create-agent mode preferences reliably 2026-06-07 15:53:17 +07:00
Mohamed Boudra 69715a77e9 Disable pull and push until the branch diverges 2026-06-07 14:21:32 +07:00
Mohamed Boudra e1b27fc584 Merge branch 'main' of github.com:getpaseo/paseo 2026-06-07 14:07:45 +07:00
Mohamed Boudra 9c16bc474b Add command center to mobile
Renders as a bottom sheet on compact native instead of returning null.
The hook's window.addEventListener keydown is gated behind isWeb; native
uses TextInput onKeyPress and onSubmitEditing through a shared
handleKeyEvent callback.
2026-06-07 14:07:14 +07:00
59d48d235a Fix Pi compaction slash commands (#1338)
* fix: handle Pi compact slash command

* fix: handle Pi compaction slash commands

* fix: harden Pi compaction commands

* fix: handle missing Pi autocompaction state

---------

Co-authored-by: X <X@16x.org>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-06-06 22:41:08 +08:00
Mohamed Boudra 6aa73baaab Show useful local speech crash details (#1379)
* Improve local speech worker crash diagnostics

* Fix speech worker stderr tail diagnostics

* Drain speech worker stderr before reporting exit

* Avoid false speech worker crash logs on shutdown
2026-06-06 21:45:33 +08:00
Mohamed Boudra 0cf1717e04 Fix auto-archive after merged PR branch deletion (#1378) 2026-06-06 21:13:16 +08:00
Mohamed Boudra 2822c02543 Keep Codex request identity vanilla (#1377) 2026-06-06 21:00:25 +08:00
Mohamed Boudra db4376d17a Prepare prompt file attachments for future UI (#1376)
* Support file uploads to agents

* Harden prompt file upload handling

* Fix duplicate upload retry handling

* Update relay reconnect tests for queued messages

* Make upload timeout track idle progress

* Clean failed upload directories
2026-06-06 20:37:38 +08:00
Mohamed Boudra 9a8912b3ef Open browser links in workspace tabs (#1375)
* Open browser window requests as workspace tabs

* Tighten browser new-tab request boundaries
2026-06-06 19:30:21 +08:00
paseo-ai[bot] fd022bc44b fix: update lockfile signatures and Nix hash [skip ci] 2026-06-05 16:13:31 +00:00
Mohamed Boudra 7408de6300 chore(release): cut 0.1.91-beta.2 2026-06-05 23:08:57 +07:00
Mohamed Boudra 9b21ccd7f1 Report daemon auth failures in status 2026-06-05 23:03:32 +07:00
Mohamed Boudra 20c03355f9 Default git shipping to pull requests 2026-06-05 22:56:33 +07:00
Mohamed Boudra e09a1591ac Merge branch 'main' of github.com:getpaseo/paseo 2026-06-05 22:39:32 +07:00
Mohamed Boudra d35bffed8b Allow $schema in config files 2026-06-05 22:38:55 +07:00
dixonl90andMohamed Boudra d454fa3af4 Fix worktree checkout validation for existing git branch refs (#1358)
* fix(server): allow valid existing worktree branch refs (#1357)

* Fix existing branch checkout validation

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-06-05 23:31:38 +08:00
Mohamed Boudra 59b32ab3be Remove epic orchestration skills 2026-06-05 22:30:53 +07:00
Fabian FischerandMohamed Boudra 44e9287389 fix(claude): preserve alwaysLoad on MCP server configs (#1333)
* fix(claude): preserve alwaysLoad on MCP server configs

Paseo's Claude provider normalizes McpServerConfig via toClaudeSdkMcpConfig
before passing it to claude-agent-sdk. The function copied type/command/args/
env (stdio) and type/url/headers (http, sse) but dropped the alwaysLoad
field for all three transports.

Without alwaysLoad making it to the SDK, every MCP server's tools were
deferred behind tool search. In practice the agent never discovered them
via search and would respond 'no chrome-devtools tools available' even
with the server fully running and connected.

Add alwaysLoad to the McpServerConfig types and zod schemas, copy it
through in toClaudeSdkMcpConfig for stdio/http/sse, and add unit tests
covering all three transports plus the default-undefined case.

Other providers ignore the field, consistent with the existing pattern
where each provider normalizes only what its CLI/SDK supports.

* test(claude): drop ternary in toClaudeSdkMcpConfig assertion

The 'alwaysLoad' field is now declared on every branch of the
discriminated union returned by toClaudeSdkMcpConfig, so the in-operator
guard is no longer needed. Direct property access expresses the intent
without embedding a conditional in the assertion.

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-06-05 22:29:47 +08:00
Mohamed Boudra c89177c211 Merge branch 'main' of github.com:getpaseo/paseo 2026-06-05 21:21:17 +07:00
Mohamed Boudra dd329a4f52 Fix stacked provider settings sheets 2026-06-05 21:15:44 +07:00
Mohamed Boudra 0d1eecc388 Keep virtualenvs out of project picker results (#1356)
* Fix project picker virtualenv suggestions

* Ignore more virtualenv names in project picker
2026-06-05 21:35:47 +08:00
Fabian Fischer 7124a82298 Authenticate file downloads with their capability token alone (#1351)
The daemon's bearer middleware gated every route except /api/health behind the daemon password (PASEO_PASSWORD), including /api/files/download. That route already carries a single-use, 60s-TTL, crypto-random download token that is only ever issued over the authenticated WebSocket, so the token is the capability for the route.

Requiring the daemon password on top of the token broke browser and Electron downloads: those trigger the download via an anchor navigation, which cannot attach an Authorization header. The cross-platform download store (packages/app/src/stores/download-store.ts) therefore sent no bearer, and the daemon returned 401 whenever a password was configured.

Add /api/files/download to the bearer-auth bypass list. The endpoint still rejects requests without a valid token (400 missing / 403 invalid), so it stays authenticated; it just no longer demands the password a second time. This also fixes every already-released client without an app update.
2026-06-05 13:22:30 +00:00
Arielle Ostankova df635b570a feat(desktop): support multiple windows
Closes #250
2026-06-05 21:08:24 +08:00
Matteo Pietro Dazzi 1377adbece fix(claude): respect profile models for built-in claude provider (#1311)
* fix(claude): respect profile models for built-in claude provider

The built-in Claude provider was the only one in the registry whose
profile `models` array was treated as additive on top of the hardcoded
first-party catalog. Every other built-in provider (and every custom
provider) replaces the runtime model list when `models` is set, which
matches the documented behavior in docs/custom-providers.md.

For users pointing Claude Code at a third-party Anthropic-compatible
gateway (Z.AI, Alibaba/Qwen, MiniMax, custom proxies, …) and curating
the picker with `agents.providers.claude.models`, the old behavior
leaked the nine first-party Claude models into the dropdown, making it
impossible to ship a curated list. This is issue #1299.

The fix flips the built-in Claude entry in `provider-registry.ts` to
match the other providers, so `models` replaces the runtime catalog
(including the settings.json-discovered entries surfaced by
getClaudeModelsWithSettings). The existing `additionalModels` field
keeps its additive semantics for anyone who still wants to append
entries on top of the first-party list.

- provider-registry.ts: drop the claude-only `profileModelsAreAdditive`
  branch and align with the rest of the registry.
- provider-registry.test.ts: update the "append to runtime models"
  expectation for built-in Claude to "replace runtime models" and add a
  regression test that mirrors the issue scenario (hardcoded catalog +
  configured profile models, expect only the profile models).
- agent.test.ts: make the "returns hardcoded claude models" hermetic by
  pointing CLAUDE_CONFIG_DIR at an empty temp dir; the test was reading
  the host's real ~/.claude/settings.json (which now contains MiniMax
  env vars from the issue report) and leaking that into the assertion.
- custom-providers.md: correct the note about Claude profile models
  being additive and document the new replace semantics alongside
  additionalModels.

Closes #1299

* test(claude): drop explanatory comments from new tests

* refactor(claude): inject configDir into ClaudeAgentClient for test hermeticity

Greptile flagged the previous hermeticity fix on agent.test.ts: mutating
process.env.CLAUDE_CONFIG_DIR inside a test leaks the redirection into
any concurrent test in the same process for the duration of the try
block.

Thread a `configDir` option through ClaudeAgentClient ->
getClaudeModelsWithSettings -> readClaudeSettingsModels ->
resolveClaudeConfigDir instead. The constructor follows the same
injection pattern as `resolveBinary`, so the test passes an empty temp
dir via the option and no shared global state is touched.

- models.ts: add an optional `configDir` to
  getClaudeModelsWithSettings, readClaudeSettingsModels, and
  resolveClaudeConfigDir. Env var remains the fallback for production
  callers.
- agent.ts: add `configDir?` to ClaudeAgentClientOptions, store it on
  the instance, and forward it to getClaudeModelsWithSettings from
  listModels.
- agent.test.ts: drop the process.env save/mutate/restore dance and
  pass `configDir: emptyConfigDir` to the constructor instead.

Refs #1311
2026-06-05 20:58:38 +08:00
Kevin AspesiandKevin Aspesi 350bc08fc4 fix(app): make markdown links tappable on iOS (#1334)
* fix(app): make markdown links tappable on iOS

A markdown link's label renders as nested React elements
(link > textgroup > text -> MarkdownInheritedText spans). The link's
onPress was placed on the wrapping UITextView span, but
react-native-uitextview only attaches onPress to the *string* children
it converts into tappable RNUITextViewChild nodes - element children
pass through untouched, so the handler never reached a node the native
tap recognizer could fire. Link text stayed visible and selectable but
dead to taps; web links never opened.

Thread the link's press handler down to the leaf text spans via a new
AssistantLinkPressProvider so it lands on the real tappable string
nodes. MarkdownInheritedText consumes the context and forwards onPress
to its MarkdownTextSpan; null outside a link, so ordinary text is
unaffected. Gated to iOS - Android forwards onPress through nested
<Text> already, and web uses the <a> path.

* refactor(app): export useAssistantLinkPress from module index

Import it through the assistant-file-links public surface instead of
reaching into the internal link-press-context file, matching the rest of
message.tsx. Addresses Greptile review feedback on #1334.

---------

Co-authored-by: Kevin Aspesi <kevinaspesi@Kevins-Mac-mini.local>
2026-06-05 20:43:09 +08:00
Fabian Fischer dfddda7969 feat(server): make provider refresh timeout configurable via PASEO_PROVIDER_REFRESH_TIMEOUT_MS (#1346)
* feat(server): make provider refresh timeout configurable

Provider refresh probes (isAvailable + listModels/listModes) were hardcoded
to a 30s timeout. With many providers and MCP servers configured, cold
starts of agents like Copilot can exceed this on the first probe, surfacing
'Timed out refreshing Copilot after 30000ms' even though a manual retry
succeeds (warm caches).

Add a PASEO_PROVIDER_REFRESH_TIMEOUT_MS env var so operators can bump the
ceiling without rebuilding. The explicit constructor option still wins
over the env var, and invalid values fall back to the 30s default.

* refactor(server): address review feedback on refresh timeout config

- Use Number() instead of Number.parseInt() so scientific notation like
  '6e4' parses as 60000 instead of being silently truncated to 6.
- Use vi.stubEnv()/vi.unstubAllEnvs() in tests to match the rest of the
  suite and avoid manual process.env save/restore.
2026-06-05 12:19:05 +00:00
Mohamed Boudra abf129f56e Fix iOS markdown style metadata warnings
Resolve markdown UITextView styles to plain React Native text styles before crossing the third-party boundary. react-native-uitextview flattens parent and child styles internally, so Unistyles metadata must not be carried into that handoff.
2026-06-05 19:11:29 +07:00
Mohamed Boudra 45cca8a406 Fix iOS markdown image rendering 2026-06-05 14:11:17 +07:00
Mohamed Boudra e961ceef98 Hide mobile sidebar for new workspace 2026-06-05 10:36:02 +07:00
paseo-ai[bot] f30f217023 fix: update lockfile signatures and Nix hash [skip ci] 2026-06-04 18:35:24 +00:00
Mohamed Boudra 9d3b037d4f chore(release): cut 0.1.91-beta.1 2026-06-05 01:31:13 +07:00
Mohamed Boudra c3515d74b9 Fix Windows workspace provider loading (#1329)
* test: cover Windows provider snapshot cwd updates

* fix: normalize Windows provider snapshot paths

* fix: preserve Claude history path candidates

* fix: match Claude project paths on Windows
2026-06-05 02:29:34 +08:00
Matt CowgerandMohamed Boudra b5192e577a Stop local daemon when removing localhost (#1297)
* fix(app): stop local daemon when removing localhost

* fix(app): handle daemon removal rollback

* fix(app): return listen address in desktop e2e mock

* fix(app): refresh daemon status after registration failure

* fix(app): unslop daemon status error coverage

* fix(app): preserve daemon toggle error context

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-06-04 22:30:40 +08:00
paseo-ai[bot] 9e2c75a8a3 fix: update lockfile signatures and Nix hash [skip ci] 2026-06-04 13:41:28 +00:00
Mohamed Boudra 7048cd5c86 chore(release): cut 0.1.90 2026-06-04 20:37:23 +07:00
Mohamed Boudra 9dbc75e78e Prepare 0.1.90 stable release 2026-06-04 20:36:11 +07:00
Mohamed Boudra 81d973ffc4 Fix diff gutter row alignment 2026-06-04 19:44:45 +07:00
Mohamed Boudra 3d8ef237e3 Fix workspace tab timeline flicker 2026-06-04 19:10:33 +07:00
Mohamed Boudra 74d42cb3d0 Merge branch 'main' of github.com:getpaseo/paseo 2026-06-04 17:51:24 +07:00
Mohamed Boudra 1bca88bd30 Show initial-agent workspaces as Working 2026-06-04 17:49:13 +07:00
Mohamed Boudra 1daa4587e5 Add Dart syntax highlighting (#1326) 2026-06-04 18:26:23 +08:00
Mohamed Boudra 88d44ab2c5 Show free-write answers for OpenCode questions 2026-06-04 15:13:47 +07:00
Mohamed Boudra 3ecf86933f Fix OpenCode child auto accept inheritance 2026-06-04 15:12:12 +07:00
Mohamed Boudra 1b350ac09e Lock skills update status invariant 2026-06-04 15:08:28 +07:00
Mohamed Boudra de9d796567 Merge branch 'main' of github.com:getpaseo/paseo 2026-06-04 14:23:00 +07:00
Mohamed Boudra cd8e0a5e08 Fix sidebar selection and Unistyles warnings
Forward selection state into native draggable lists so virtualized workspace rows update when the active route changes.

Keep floating caller styles array-shaped and strip Unistyles metadata from owned inline geometry to avoid runtime style warnings.
2026-06-04 14:17:08 +07:00
Mohamed Boudra 38f8179bc1 Add a global New workspace picker (#1324)
* Add global New workspace project picker

* Clean up New workspace picker state
2026-06-04 15:16:41 +08:00
Mohamed Boudra 0b62e3bd7f Merge branch 'main' of github.com:getpaseo/paseo 2026-06-04 13:38:54 +07:00
Mohamed Boudra f0370a3c20 Fix long dictation retry backpressure
Treat Node IPC backpressure as a queue signal instead of a failed send, and pace buffered dictation replay so finish waits for the backlog to flush.
2026-06-04 13:35:10 +07:00
Mohamed Boudra e381117b1c Limit OpenCode metadata refresh concurrency 2026-06-04 13:32:51 +07:00
Mohamed Boudra f9791d7ab6 Fix mobile sidebar swipes under load
Move mobile sidebar gesture gating to shared visual panel state so swipes can open and close sidebars before React state catches up. Keep the sidebar list scroll simultaneous with the close pan so vertical scrolling stays immediate.
2026-06-04 13:32:15 +07:00
Mohamed Boudra 66a5bd8a88 Fix sidebar workspace row alignment 2026-06-04 13:26:45 +07:00
Mohamed Boudra de5fa4dc36 Publish beta npm packages under beta tag 2026-06-04 11:53:58 +07:00
Mohamed Boudra 6dcf3ffd6a Keep archived workspaces recent in Done 2026-06-04 11:46:04 +07:00
Mohamed Boudra f139966554 Fix focus mode tab padding 2026-06-04 11:08:37 +07:00
Mohamed Boudra b0c4cc99ed Fix settings host picker navigation 2026-06-04 09:37:14 +07:00
Mohamed Boudra 3e3e944cf4 Fix compact sheet bottom spacing 2026-06-04 09:28:05 +07:00
Mohamed Boudra f3b7dac768 Fix compact provider sheet footer 2026-06-04 08:46:47 +07:00
paseo-ai[bot] b9f3e59d48 fix: update lockfile signatures and Nix hash [skip ci] 2026-06-03 18:06:46 +00:00
250 changed files with 14356 additions and 4869 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
---
name: release-beta
description: Cut a beta release of Paseo. Use when the user says "release beta", "cut a beta", "ship a beta", "beta release", or "/release-beta". Betas are silent release candidates — no changelog, no website move.
description: Cut a beta release of Paseo. Use when the user says "release beta", "cut a beta", "ship a beta", "beta release", or "/release-beta". Betas are silent release candidates — no changelog, no website move, npm only on the beta dist-tag.
user-invocable: true
---
@@ -8,4 +8,4 @@ user-invocable: true
Read `docs/release.md` in the Paseo repo and follow the **Beta flow** section end-to-end. Run the **Beta release** completion checklist at the bottom of that doc.
Key rule the doc enforces — betas don't touch `CHANGELOG.md`. Don't draft release notes.
Key rules the doc enforces — betas don't touch `CHANGELOG.md`, don't draft release notes, and publish npm only with the explicit `beta` dist-tag.
+1
View File
@@ -64,6 +64,7 @@ CLAUDE.local.md
.debug.conversations/
.debug/
.dev/
.paseo/
.wrangler/
**/.wrangler/
+1
View File
@@ -3,6 +3,7 @@
"options": {
"typeAware": false
},
"ignorePatterns": [".dev/**"],
"plugins": ["react", "react-perf", "unicorn", "typescript", "oxc", "import", "promise"],
"categories": {
"correctness": "error",
+33
View File
@@ -1,5 +1,38 @@
# Changelog
## 0.1.90 - 2026-06-04
### Added
- **Group the sidebar by status so workspaces waiting on you, ready to review, working, and done are visible at a glance** ([#1317](https://github.com/getpaseo/paseo/pull/1317))
- **Start a new workspace from the global sidebar button without choosing a project first** ([#1324](https://github.com/getpaseo/paseo/pull/1324))
- **Open the active file directly in your editor, file manager, or GitHub instead of only opening the workspace root** ([#1285](https://github.com/getpaseo/paseo/pull/1285) by [@aaronzhongg](https://github.com/aaronzhongg))
- **Automatically archive clean PR workspaces after the PR is merged from host settings** ([#1313](https://github.com/getpaseo/paseo/pull/1313))
- **Desktop-managed Paseo skills stay current after installing a newer desktop build** ([#1309](https://github.com/getpaseo/paseo/pull/1309))
- **Dart files and Dart code blocks are now syntax-highlighted** ([#1326](https://github.com/getpaseo/paseo/pull/1326))
### Improved
- Sidebar workspaces can be marked as read when they are ready to review or failed ([#1317](https://github.com/getpaseo/paseo/pull/1317))
- Child agents keep unattended permissions when delegated across providers ([#1315](https://github.com/getpaseo/paseo/pull/1315))
- Scheduled agents open with the real prompt and title instead of looking empty ([#1316](https://github.com/getpaseo/paseo/pull/1316))
- Git controls prioritize the action that gets a ready branch shipped ([#1316](https://github.com/getpaseo/paseo/pull/1316))
- Multiple agent questions are shown one at a time
- OpenCode questions with free-write answers show the typed response in Paseo
- Delegated agent activity is visible on the parent workspace
- Sessions are ordered by latest activity
- ACP provider catalog entries are updated for Claude Agent, Cline, Codebuddy Code, Factory Droid, and Qoder
### Fixed
- Timeline catch-up no longer leaves older messages unloaded
- Markdown code in file previews renders correctly
- Long dictation retries no longer stall new audio
- Settings host picker navigation works from host settings pages
- Diff gutter rows stay aligned with changed code
- Mobile sidebar gestures stay responsive under load
- Compact sheets keep their footer and bottom spacing visible
## 0.1.89 - 2026-06-02
### Added
+5 -1
View File
@@ -49,7 +49,9 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
## Quick start
```bash
npm run dev # Start daemon + Expo in Tmux
npm run dev # Start the dev daemon
npm run dev:app # Start Expo against the dev daemon
npm run dev:desktop # Start Electron desktop dev
npm run cli -- ls -a -g # List all agents
npm run cli -- daemon status # Check daemon status
npm run typecheck # Always run after changes
@@ -58,6 +60,8 @@ npm run format # Auto-format with Biome
npm run format:check # Check formatting without writing
```
Repo dev commands use checkout-local state by default. In this checkout, `PASEO_HOME` resolves to `.dev/paseo-home`, and `npm run cli -- ...` targets that same dev home automatically. The packaged desktop app and production-style daemon keep using `~/.paseo` on port `6767`.
See [docs/development.md](docs/development.md) for full setup, build sync requirements, and debugging.
## Critical rules
+6
View File
@@ -132,6 +132,12 @@ Electron wrapper for macOS, Linux, and Windows.
- Native file access for workspace integration
- Same WebSocket client as mobile app
**Multi-window (hybrid land-on model).** `createWindow()` in `main.ts` is reusable: `⌘⇧N`/File→New Window, relaunching the app (`second-instance`), and the sidebar "Open in new window" action each open a fresh `BrowserWindow`. Every window shows the full sidebar — there is no per-window project ownership or filtering. "Land on a project" is delivered by a per-`webContents` `PendingOpenProjectStore`: each window pulls its own pending project path on mount (`paseo:get-pending-open-project`) and runs the normal open-project flow, identical to a CLI `paseo <path>` launch.
> **Window-state v1 limitation:** only the _first_ window of a session restores and persists saved geometry (size/position/maximized). Windows opened via ⌘⇧N / second-instance / "Open in new window" open at the default size, OS-cascaded, and do not persist — this avoids every window stacking on the same restored bounds and fighting over the single window-state store. Lifting this needs per-window state keys.
>
> **In-app browser panes are not yet per-window.** The active-browser id (`features/browser-webviews.ts`) and the webview registration queue (`pendingBrowserWebviewIds` in `main.ts`) are process-global. With browser panes open in two windows, a menu Reload can target the other window's webview, and near-simultaneous webview attach across windows can register under the wrong browser id. Multi-window v1 ships windows; making the browser-webview subsystem window-scoped is a follow-up.
### `packages/website` — Marketing site
TanStack Router + Cloudflare Workers. Serves paseo.sh.
+1 -1
View File
@@ -577,7 +577,7 @@ Each entry in the `models` array:
The built-in `claude` provider appends concrete model IDs from `~/.claude/settings.json` to its first-party Claude model list. Paseo reads the top-level `model` field and these `env` keys: `ANTHROPIC_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, and `ANTHROPIC_DEFAULT_HAIKU_MODEL`.
This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Paseo. `agents.providers.claude.models` is still supported and is additive for the built-in Claude provider; duplicate IDs are de-duplicated.
This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Paseo. When `agents.providers.claude.models` is set it **replaces** both the hardcoded first-party Claude list and any settings.json-discovered entries; use `agents.providers.claude.additionalModels` to keep the first-party list and append curated entries on top.
### Gotcha: `extends: "claude"` with third-party endpoints
+2
View File
@@ -94,6 +94,8 @@ Five primitives. The pick is determined by option count, the need to search, and
`<AdaptiveModalSheet>` is for a focused task. Multi-field forms (`packages/app/src/components/add-host-modal.tsx`, `packages/app/src/components/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx`), confirmations with detail, anything that earns a backdrop. Bottom sheet on compact, centered card on desktop. Raw `Modal` is wrong for any of these.
`<AdaptiveModalSheet>` owns compact bottom safe-area padding inside the sheet so the sheet background still reaches the screen bottom. If a sheet's first snap point is shorter than its header, content, and safe-area clearance, raise that snap point rather than moving the sheet container.
`confirmDialog` is for destructive yes/no and imperative confirmation. Promise-based: `await confirmDialog({ destructive: true, ... })`. Anything where a wrong click loses work.
Three themes is `DropdownMenu`. Thirty hosts is `Combobox`. A label and a value is `AdaptiveModalSheet`. "Are you sure?" is `confirmDialog`.
+23 -11
View File
@@ -8,18 +8,27 @@
## Running the dev server
```bash
npm run dev
npm run dev:server
npm run dev:app
npm run dev:desktop
```
`scripts/dev.sh` runs the daemon and Expo together via `concurrently`, fronted by [`portless`](https://www.npmjs.com/package/portless) so each service is reachable at a stable name like `https://daemon.localhost` / `https://app.localhost` instead of a fixed port. The underlying TCP ports are ephemeral — never hardcode them. (Windows uses `scripts/dev.ps1`, which still binds the daemon to `localhost:6767` directly.)
Root checkout dev is intentionally split across terminals:
- `npm run dev:server` runs the daemon on `127.0.0.1:6768`.
- `npm run dev:app` runs Expo on `http://localhost:8081` and connects to the dev daemon.
- `npm run dev:desktop` runs its own Electron-flavored Expo server on the first free port from `8082` through `8089`. It never claims port `8081`.
`npm run dev` is only a shorthand for `npm run dev:server`. Keep `127.0.0.1:6767` for the packaged app and production-style `~/.paseo` state.
### PASEO_HOME
`PASEO_HOME` is the directory that holds runtime state (agents, sockets, daemon log). Resolution rules:
`PASEO_HOME` is the directory that holds runtime state (agents, worktrees, workspace config, sockets, daemon log). Resolution rules:
- The **server itself** (e.g. when launched by the desktop app or `npm run start`) defaults to `~/.paseo` (see `packages/server/src/server/paseo-home.ts`).
- **`npm run dev` from a git worktree** derives a stable home like `~/.paseo-<worktree-name>` and, on first run, seeds it from `~/.paseo` by copying agent/project JSON metadata and `config.json`. Checkout/worktree directories are not copied.
- **`npm run dev` from the main checkout** (not a worktree) uses a fresh `mktemp` directory under `$TMPDIR` and removes it on exit. Set `PASEO_HOME` explicitly to keep state across runs.
- **Repo dev scripts** default to `$ROOT/.dev/paseo-home`, where `$ROOT` is the current checkout or worktree root. This keeps all dev state scoped to the checkout instead of the packaged desktop app.
- **`npm run cli -- ...`** runs through the same dev-home wrapper as the dev scripts, so the in-repo CLI automatically targets the current checkout's `.dev/paseo-home` and configured dev daemon endpoint.
- **Paseo-created worktrees** seed `$PASEO_WORKTREE_PATH/.dev/paseo-home` from `$PASEO_SOURCE_CHECKOUT_PATH/.dev/paseo-home` by copying durable JSON metadata. Runtime files like pid files, sockets, and logs are not copied.
Override knobs:
@@ -32,16 +41,19 @@ PASEO_DEV_RESET_HOME=1 npm run dev # clear and reseed the derived wor
### Daemon endpoints
- Stable daemon launched by the desktop app: `localhost:6767`.
- `npm run dev` (macOS/Linux): portless URLs only — read them from the `dev.sh` banner or `portless get daemon` / `portless get app`.
- Root checkout dev daemon: `localhost:6768`.
- Root checkout Expo: `http://localhost:8081`.
- Root checkout desktop dev Expo: first free port from `8082` through `8089`.
- `npm run dev` (Windows): `localhost:6767` for the daemon.
In any worktree-style or portless setup, never assume default ports.
In Paseo-managed worktree services, use the injected service environment rather than hardcoded root checkout ports.
### Desktop renderer profiling
`npm run dev:desktop` starts Electron with Chromium remote debugging enabled on
`http://127.0.0.1:9223` so renderer CPU profiles can be captured through CDP.
Override the port with `PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` when `9223` is busy.
It launches its own Electron-flavored Expo server and passes that URL to Electron.
Override the CDP port with `PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` when `9223` is busy.
### React render profiling
@@ -185,7 +197,7 @@ Service proxy hostnames use the double-dash shape: `web--feature-auth--project.l
Package imports resolve through package exports to compiled `dist/` output, not sibling `src/` files. This is true in local dev and in published packages: the app, daemon, CLI, and SDK consumers should all exercise the same runtime paths.
`npm run dev`, `npm run dev:server`, and `npm run dev:app` build the workspace packages they need once, then keep `@getpaseo/protocol` and `@getpaseo/client` fresh with TypeScript watch builds while the daemon or Expo runs. If you change protocol schemas or client code outside those watch workflows, rebuild the producer before trusting runtime behavior.
`npm run dev:server` builds the server-side workspace packages once, then keeps `@getpaseo/protocol` and `@getpaseo/client` fresh with TypeScript watch builds while the daemon runs. If you change protocol schemas or client code outside that watch workflow, rebuild the producer before trusting runtime behavior.
Use the named root build targets instead of remembering workspace dependency chains:
@@ -223,7 +235,7 @@ install.
## CLI reference
Use `npm run cli` to run the in-repo CLI from source (`npx tsx packages/cli/src/index.ts`). The globally installed `paseo` binary on macOS is a symlink into the installed Paseo desktop app, not this checkout — use it to drive the desktop's built-in daemon, but use `npm run cli` when you want to talk to the CLI you are editing.
Use `npm run cli` to run the in-repo CLI from source (`npx tsx packages/cli/src/index.ts`). The script wraps the CLI with `scripts/dev-home.sh`, so it automatically uses this checkout's `.dev/paseo-home` and dev daemon endpoint unless you pass an explicit override. The globally installed `paseo` binary on macOS is a symlink into the installed Paseo desktop app, not this checkout — use it to drive the desktop's built-in daemon, but use `npm run cli` when you want to talk to the CLI you are editing.
```bash
npm run cli -- ls -a -g # List all agents globally
@@ -277,7 +289,7 @@ Get the session ID from the agent JSON (`persistence.sessionId`), then:
## Testing with Playwright MCP
Point Playwright MCP at the running Expo web target. Under `npm run dev` (macOS/Linux) that is the portless URL printed in the dev banner — typically `https://app.localhost`. If you start Expo directly with `expo start --web` (no portless), Metro defaults to `http://localhost:8081`.
Point Playwright MCP at the running Expo web target. For root checkout dev, `npm run dev:app` reserves `http://localhost:8081`. For Paseo-managed worktree app services, use the service URL or port shown by Paseo for that worktree.
Do NOT use browser history (back/forward). Always navigate by clicking UI elements or using `browser_navigate` with the full URL — the app uses client-side routing and browser history breaks state.
+5
View File
@@ -169,6 +169,11 @@ on that attach races Gorhom's dismiss path and leaves the modal unable to reopen
Track an explicit phase (`closed` / `presenting` / `presented` / `dismissing`) and
ignore ref churn while dismissing.
Do not treat `onChange(-1)` as a close by itself. In a stacked
`BottomSheetModal`, `-1` can also mean the sheet is temporarily hidden under
another pushed sheet. Close React state from `onDismiss`; use `onChange` only to
track phase.
## Recipe for a new anchored panel
Before you write a new one, ask:
+6 -3
View File
@@ -33,7 +33,7 @@ Rules that apply to both steps:
There are two supported ways to ship from `main`:
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
2. **Beta flow**: silent release candidates. Betas don't touch the changelog, don't move the website, and don't publish npm or production mobile builds.
2. **Beta flow**: silent release candidates. Betas don't touch the changelog, don't move the website, and publish npm only on the explicit `beta` dist-tag.
## Standard release (patch)
@@ -66,14 +66,15 @@ npm run release:push # Push HEAD + tag (triggers CI workflows)
## Beta flow
```bash
npm run release:beta:patch # Bump to X.Y.Z-beta.1, push commit + tag
npm run release:beta:patch # Bump to X.Y.Z-beta.1, publish npm beta, push commit + tag
# ... test desktop and APK prerelease assets from GitHub Releases ...
npm run release:beta:next # Optional: cut X.Y.Z-beta.2, beta.3, ...
npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z
```
- Beta tags are published GitHub prereleases like `v0.1.41-beta.1`
- Betas publish desktop assets and APKs for testing, but they do not publish npm packages and do not trigger the production web/mobile release flows
- Betas publish npm packages with `--tag beta`, so `npm install @getpaseo/cli@beta` opts in while plain `npm install @getpaseo/cli` stays on `latest`
- Betas publish desktop assets and APKs for testing, but they do not trigger the production web/mobile release flows
- `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the beta tag
- Desktop assets now come from the Electron package at `packages/desktop`
- Beta releases use Electron's `beta` update channel. Users on the stable channel only receive stable releases; users on the beta channel receive beta releases and the final stable release when it is published.
@@ -303,6 +304,7 @@ This ensures the checkout ref matches the actual code on `main` with the fix inc
- `release:prepare` refreshes workspace `node_modules` links to prevent stale types
- `npm run dev:desktop` and `npm run build:desktop` target the Electron desktop package in `packages/desktop`
- If `release:publish` partially fails, re-run it — npm skips already-published versions
- If `release:publish:beta` partially fails, re-run it — npm skips already-published versions and keeps prereleases off `latest` because every publish uses `--tag beta`
- The website uses GitHub's latest published release API for download links, so published beta prereleases do not replace the stable download target.
## Changelog format
@@ -423,6 +425,7 @@ The changelog covers **stable-to-stable**. Betas are not represented. When you p
- [ ] Working tree is clean and the intended commit is on `main`
- [ ] `npm run release:beta:patch` (or `:next`) completes successfully
- [ ] npm shows the version under the `beta` dist-tag, not `latest`
- [ ] GitHub `Desktop Release` workflow for the `v*-beta.N` tag is green
- [ ] GitHub `Android APK Release` workflow for the same tag is green
+2
View File
@@ -88,6 +88,8 @@ Do not split a component into plain and Unistyles variants just to handle one hi
When a reusable component has a prop whose whole job is dynamic geometry, make that prop the seam. For example, `FloatingSurface.frameStyle` and `FloatingScrollView.style` own their own escape hatch so menu, tooltip, hover-card, and combobox callers can stay declarative instead of knowing about Unistyles internals.
Do not flatten a caller-provided style array and pass the flattened object back to a React Native component. Unistyles style entries carry `unistyles_*` metadata; flattening two entries produces one object with multiple metadata keys and triggers the runtime warning: "use array syntax instead of object syntax." Preserve caller styles as arrays, and only flatten the dynamic geometry value you explicitly own. If that owned value was flattened from a mixed style prop, strip `unistyles_*` metadata before sending it through `inlineUnistylesStyle`.
## Main Gotcha: `contentContainerStyle`
`ScrollView.contentContainerStyle` is the canonical trap. It looks like a style prop, but it is not the same prop that Unistyles' remapped native component registers by default. The upstream tutorial calls this out directly in its [ScrollView Background Issue](https://www.unistyl.es/v3/tutorial/settings-screen#scrollview-background-issue) section.
+1 -1
View File
@@ -1 +1 @@
sha256-RKIUil3Ev65LbxSqsgzkD4APgAOCXBnIRWz++jEq91k=
sha256-EpxQim3vnwfPLPi8IAtF82tGrLNZiZP8IwN7rR9P7xo=
+21 -21
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.90-beta.1",
"version": "0.1.91-beta.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.90-beta.1",
"version": "0.1.91-beta.2",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -36953,7 +36953,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.90-beta.1",
"version": "0.1.91-beta.2",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -37178,12 +37178,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.90-beta.1",
"version": "0.1.91-beta.2",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.1.90-beta.1",
"@getpaseo/protocol": "0.1.90-beta.1",
"@getpaseo/server": "0.1.90-beta.1",
"@getpaseo/client": "0.1.91-beta.2",
"@getpaseo/protocol": "0.1.91-beta.2",
"@getpaseo/server": "0.1.91-beta.2",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -37429,10 +37429,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.1.90-beta.1",
"version": "0.1.91-beta.2",
"dependencies": {
"@getpaseo/protocol": "0.1.90-beta.1",
"@getpaseo/relay": "0.1.90-beta.1",
"@getpaseo/protocol": "0.1.91-beta.2",
"@getpaseo/relay": "0.1.91-beta.2",
"zod": "^3.23.8"
},
"devDependencies": {
@@ -37452,7 +37452,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.90-beta.1",
"version": "0.1.91-beta.2",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -37704,7 +37704,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.90-beta.1",
"version": "0.1.91-beta.2",
"license": "MIT",
"devDependencies": {
"@types/react": "^18.0.25",
@@ -37740,7 +37740,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.90-beta.1",
"version": "0.1.91-beta.2",
"dependencies": {
"@codemirror/language": "^6.12.3",
"@codemirror/legacy-modes": "^6.5.3",
@@ -37971,7 +37971,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.1.90-beta.1",
"version": "0.1.91-beta.2",
"dependencies": {
"zod": "^3.23.8"
},
@@ -37992,7 +37992,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.90-beta.1",
"version": "0.1.91-beta.2",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38210,14 +38210,14 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.90-beta.1",
"version": "0.1.91-beta.2",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
"@getpaseo/client": "0.1.90-beta.1",
"@getpaseo/highlight": "0.1.90-beta.1",
"@getpaseo/protocol": "0.1.90-beta.1",
"@getpaseo/relay": "0.1.90-beta.1",
"@getpaseo/client": "0.1.91-beta.2",
"@getpaseo/highlight": "0.1.91-beta.2",
"@getpaseo/protocol": "0.1.91-beta.2",
"@getpaseo/relay": "0.1.91-beta.2",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -38989,7 +38989,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.90-beta.1",
"version": "0.1.91-beta.2",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
+12 -10
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.90-beta.1",
"version": "0.1.91-beta.2",
"private": true,
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"keywords": [
@@ -34,12 +34,12 @@
"packages/cli"
],
"scripts": {
"dev": "./scripts/dev.sh",
"dev": "npm run dev:server",
"dev:win": "powershell ./scripts/dev.ps1",
"dev:server": "npm run build:server-deps && npm run dev:server:watch",
"dev:server": "cross-env PASEO_LISTEN=127.0.0.1:6768 ./scripts/dev-daemon.sh",
"dev:server:watch": "concurrently --kill-others --names protocol,client,server --prefix-colors yellow,blue,cyan \"npm run watch:protocol\" \"npm run watch:client\" \"npm run dev:server:raw\"",
"dev:server:raw": "npm run dev --workspace=@getpaseo/server",
"dev:app": "npm run start --workspace=@getpaseo/app",
"dev:app": "cross-env PASEO_LISTEN=127.0.0.1:6768 EXPO_PORT=8081 ./scripts/dev-app.sh",
"dev:website": "npm run dev --workspace=@getpaseo/website",
"postinstall": "node scripts/postinstall-patches.mjs",
"prepare": "lefthook install --force",
@@ -82,11 +82,11 @@
"android:clean": "npm run android:clean --workspace=@getpaseo/app",
"ios": "npm run ios --workspace=@getpaseo/app",
"web": "npm run web --workspace=@getpaseo/app",
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop",
"dev:desktop": "cross-env PASEO_LISTEN=127.0.0.1:6768 npm run dev --workspace=@getpaseo/desktop",
"dev:win:desktop": "npm run dev:win --workspace=@getpaseo/desktop",
"build:desktop": "npm run build:app-deps:clean && cd packages/app && cross-env PASEO_WEB_PLATFORM=electron npx expo export --platform web && cd ../.. && npm run build --workspace=@getpaseo/desktop --",
"db:query": "npm run db:query --workspace=@getpaseo/server --",
"cli": "npx tsx packages/cli/src/index.js",
"cli": "./scripts/dev-home.sh npx tsx packages/cli/src/index.js",
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
"version:sync-internal": "node scripts/sync-workspace-versions.mjs",
"release:prepare": "npm install --workspaces --include-workspace-root",
@@ -100,12 +100,14 @@
"version:all:promote": "node scripts/set-release-version.mjs --mode promote",
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run build:client:clean && npm run typecheck --workspace=@getpaseo/client && npm run build:server:clean && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/protocol && npm pack --dry-run --workspace=@getpaseo/client && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/protocol --access public && npm publish --dry-run --workspace=@getpaseo/client --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
"release:publish:beta:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/relay --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/protocol --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/client --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/server --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/cli --access public --tag beta",
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/protocol --access public && npm publish --workspace=@getpaseo/client --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
"release:publish:beta": "npm publish --workspace=@getpaseo/highlight --access public --tag beta && npm publish --workspace=@getpaseo/relay --access public --tag beta && npm publish --workspace=@getpaseo/protocol --access public --tag beta && npm publish --workspace=@getpaseo/client --access public --tag beta && npm publish --workspace=@getpaseo/server --access public --tag beta && npm publish --workspace=@getpaseo/cli --access public --tag beta",
"release:push": "node scripts/push-current-release-tag.mjs",
"release:beta:patch": "npm run release:check && npm run version:all:beta:patch && npm run release:push",
"release:beta:minor": "npm run release:check && npm run version:all:beta:minor && npm run release:push",
"release:beta:major": "npm run release:check && npm run version:all:beta:major && npm run release:push",
"release:beta:next": "npm run release:check && npm run version:all:beta:next && npm run release:push",
"release:beta:patch": "npm run release:check && npm run version:all:beta:patch && npm run release:publish:beta && npm run release:push",
"release:beta:minor": "npm run release:check && npm run version:all:beta:minor && npm run release:publish:beta && npm run release:push",
"release:beta:major": "npm run release:check && npm run version:all:beta:major && npm run release:publish:beta && npm run release:push",
"release:beta:next": "npm run release:check && npm run version:all:beta:next && npm run release:publish:beta && npm run release:push",
"release:promote": "npm run release:check && npm run version:all:promote && npm run release:publish && npm run release:push",
"release:patch": "npm run release:check && npm run version:all:patch && npm run release:publish && npm run release:push",
"release:minor": "npm run release:check && npm run version:all:minor && npm run release:publish && npm run release:push",
+505
View File
@@ -0,0 +1,505 @@
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";
import { test, expect } from "./fixtures";
import { getServerId } from "./helpers/server-id";
import { connectSeedClient } from "./helpers/seed-client";
import { createTempGitRepo } from "./helpers/workspace";
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
interface DirtyWorkspace {
id: string;
}
interface CleanupTask {
run: () => Promise<void>;
}
const cleanupTasks: CleanupTask[] = [];
const APP_SETTINGS_KEY = "@paseo:app-settings";
const CHANGES_PREFERENCES_KEY = "@paseo:changes-preferences";
const BEFORE = `import { useLayoutEffect, useMemo, useRef, useState } from "react";
interface UseMountedTabSetInput {
activeTabId: string | null;
allTabIds: string[];
cap: number;
}
interface UseMountedTabSetResult {
mountedTabIds: Set<string>;
}
function createInitialMountedTabIds(input: UseMountedTabSetInput): Set<string> {
if (!input.activeTabId || !input.allTabIds.includes(input.activeTabId)) {
return new Set<string>();
}
return new Set<string>([input.activeTabId]);
}
function setsEqual(left: Set<string>, right: Set<string>): boolean {
if (left.size !== right.size) {
return false;
}
for (const value of left) {
if (!right.has(value)) {
return false;
}
}
return true;
}
export function useMountedTabSet(input: UseMountedTabSetInput): UseMountedTabSetResult {
const { activeTabId, allTabIds, cap } = input;
const allTabIdsKey = allTabIds.join("\\u0000");
const availableTabIds = useMemo(() => {
void allTabIdsKey;
return new Set(allTabIds);
}, [allTabIds, allTabIdsKey]);
const [mountedTabIds, setMountedTabIds] = useState(() => createInitialMountedTabIds(input));
const lruRef = useRef(activeTabId && allTabIds.includes(activeTabId) ? [activeTabId] : []);
useLayoutEffect(() => {
const nextLru = lruRef.current.filter((tabId) => availableTabIds.has(tabId));
if (activeTabId && availableTabIds.has(activeTabId)) {
const existingIndex = nextLru.indexOf(activeTabId);
if (existingIndex >= 0) {
nextLru.splice(existingIndex, 1);
}
nextLru.unshift(activeTabId);
}
if (nextLru.length > cap) {
nextLru.length = cap;
}
lruRef.current = nextLru;
setMountedTabIds((previousMountedTabIds) => {
const nextMountedTabIds = new Set(nextLru);
return setsEqual(previousMountedTabIds, nextMountedTabIds)
? previousMountedTabIds
: nextMountedTabIds;
});
}, [activeTabId, availableTabIds, cap]);
return { mountedTabIds };
}
`;
const AFTER = `import { useLayoutEffect, useMemo, useRef, useState } from "react";
interface UseMountedTabSetInput {
activeTabId: string | null;
allTabIds: string[];
cap: number;
}
interface UseMountedTabSetResult {
mountedTabIds: Set<string>;
}
interface DeriveRenderMountedTabIdsInput {
activeTabId: string | null;
availableTabIds: Set<string>;
cap: number;
mountedTabIds: Set<string>;
}
function createInitialMountedTabIds(input: UseMountedTabSetInput): Set<string> {
if (!input.activeTabId || !input.allTabIds.includes(input.activeTabId)) {
return new Set<string>();
}
return new Set<string>([input.activeTabId]);
}
function setsEqual(left: Set<string>, right: Set<string>): boolean {
if (left.size !== right.size) {
return false;
}
for (const value of left) {
if (!right.has(value)) {
return false;
}
}
return true;
}
function deriveRenderMountedTabIds(input: DeriveRenderMountedTabIdsInput): Set<string> {
const { activeTabId, availableTabIds, cap, mountedTabIds } = input;
if (!activeTabId || !availableTabIds.has(activeTabId) || mountedTabIds.has(activeTabId)) {
return mountedTabIds;
}
const next = new Set<string>([activeTabId]);
const maxSize = Math.max(1, cap);
for (const tabId of mountedTabIds) {
if (next.size >= maxSize) {
break;
}
if (availableTabIds.has(tabId)) {
next.add(tabId);
}
}
return next;
}
export function useMountedTabSet(input: UseMountedTabSetInput): UseMountedTabSetResult {
const { activeTabId, allTabIds, cap } = input;
const allTabIdsKey = allTabIds.join("\\u0000");
const availableTabIds = useMemo(() => {
void allTabIdsKey;
return new Set(allTabIds);
}, [allTabIds, allTabIdsKey]);
const [mountedTabIds, setMountedTabIds] = useState(() => createInitialMountedTabIds(input));
const lruRef = useRef(activeTabId && allTabIds.includes(activeTabId) ? [activeTabId] : []);
const renderMountedTabIds = useMemo(
() =>
deriveRenderMountedTabIds({
activeTabId,
availableTabIds,
cap,
mountedTabIds,
}),
[activeTabId, availableTabIds, cap, mountedTabIds],
);
useLayoutEffect(() => {
const nextLru = lruRef.current.filter((tabId) => availableTabIds.has(tabId));
if (activeTabId && availableTabIds.has(activeTabId)) {
const existingIndex = nextLru.indexOf(activeTabId);
if (existingIndex >= 0) {
nextLru.splice(existingIndex, 1);
}
nextLru.unshift(activeTabId);
}
if (nextLru.length > cap) {
nextLru.length = cap;
}
lruRef.current = nextLru;
setMountedTabIds((previousMountedTabIds) => {
const nextMountedTabIds = new Set(nextLru);
return setsEqual(previousMountedTabIds, nextMountedTabIds)
? previousMountedTabIds
: nextMountedTabIds;
});
}, [activeTabId, availableTabIds, cap]);
return { mountedTabIds: renderMountedTabIds };
}
`;
test.afterEach(async () => {
for (const task of cleanupTasks.splice(0)) {
await task.run();
}
});
test("changes diff keeps code rows aligned with the gutter", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useCodeFont(page, 9);
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await expectDiffCodeFontSize(page, 9);
await expectVisibleDiffRowsAligned(page);
await expectDiffCodeTextAlignedWithGutterText(page, [
{
codeText: "function createInitialMountedTabIds(input: UseMountedTabSetInput)",
lineNumber: "20",
},
{ codeText: "return next;", lineNumber: "55" },
{ codeText: "useLayoutEffect(() => {", lineNumber: "78" },
]);
await expectHoverCommentButtonAlignedWithCodeLine(page, {
codeText: "function createInitialMountedTabIds(input: UseMountedTabSetInput)",
lineNumber: "20",
});
});
test("changes diff keeps unwrapped gutter and code rows aligned after code size changes", async ({
page,
}) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useCodeFont(page, 12);
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await changeCodeFontSizeFromSettings(page, 18);
await returnToWorkspaceChanges(page);
await scrollToLowerUnwrappedDiffRows(page);
await expectDiffCodeFontSize(page, 18);
await expectVisibleDiffRowsShareTypography(page);
await expectVisibleDiffRowsAligned(page);
});
async function useCodeFont(page: Page, codeFontSize: number): Promise<void> {
await page.addInitScript(
({ settingsKey, fontSize }) => {
localStorage.setItem(
settingsKey,
JSON.stringify({
theme: "dark",
sendBehavior: "interrupt",
serviceUrlBehavior: "ask",
terminalScrollbackLines: 10_000,
uiFontFamily: "",
monoFontFamily: "",
uiFontSize: 16,
codeFontSize: fontSize,
syntaxTheme: "one",
}),
);
},
{ settingsKey: APP_SETTINGS_KEY, fontSize: codeFontSize },
);
}
async function useUnwrappedDiffLines(page: Page): Promise<void> {
await page.addInitScript(
({ preferencesKey }) => {
localStorage.setItem(
preferencesKey,
JSON.stringify({ layout: "unified", wrapLines: false, hideWhitespace: false }),
);
},
{ preferencesKey: CHANGES_PREFERENCES_KEY },
);
}
async function expectDiffCodeFontSize(page: Page, fontSize: number): Promise<void> {
const actualFontSize = await page
.getByTestId("diff-code-text-1")
.evaluate((text) => Number.parseFloat(getComputedStyle(text).fontSize));
expect(actualFontSize).toBe(fontSize);
}
async function expectVisibleDiffRowsAligned(page: Page): Promise<void> {
const geometry = await readVisibleDiffRowGeometry(page);
expect(geometry.maxDelta, JSON.stringify(geometry.rows, null, 2)).toBeLessThanOrEqual(1);
}
async function expectVisibleDiffRowsShareTypography(page: Page): Promise<void> {
const geometry = await readVisibleDiffRowGeometry(page);
expect(geometry.mismatchedTypography, JSON.stringify(geometry, null, 2)).toEqual([]);
}
async function readVisibleDiffRowGeometry(page: Page): Promise<{
maxDelta: number;
mismatchedTypography: { index: number; gutterLineHeight: number; codeLineHeight: number }[];
rows: {
index: number;
gutterTop: number;
codeTop: number;
delta: number;
gutterLineHeight: number;
codeLineHeight: number;
}[];
}> {
return page.locator("body").evaluate(({ ownerDocument }) => {
const root = ownerDocument.querySelector('[data-testid="diff-file-0-body"]');
if (!root) {
throw new Error("Expanded diff body is not mounted");
}
const readRows = (prefix: string, textPrefix: string) =>
Array.from(root.querySelectorAll<HTMLElement>(`[data-testid^="${prefix}"]`)).map((row) => {
const testId = row.getAttribute("data-testid") ?? "";
const index = Number(testId.slice(prefix.length));
const rect = row.getBoundingClientRect();
const text = root.querySelector<HTMLElement>(`[data-testid="${textPrefix}${index}"]`);
const lineHeight = text ? Number.parseFloat(getComputedStyle(text).lineHeight) : 0;
return { index, top: rect.top, height: rect.height, lineHeight };
});
const gutters = new Map(
readRows("diff-gutter-row-", "diff-gutter-text-").map((row) => [row.index, row]),
);
const codes = readRows("diff-code-row-", "diff-code-text-");
const rows = codes
.map((code) => {
const gutter = gutters.get(code.index);
if (!gutter) {
throw new Error(`Missing gutter row ${code.index}`);
}
return {
index: code.index,
gutterTop: gutter.top,
codeTop: code.top,
delta: Math.abs(code.top - gutter.top),
gutterLineHeight: gutter.lineHeight,
codeLineHeight: code.lineHeight,
};
})
.filter((row) => row.gutterTop >= 0 && row.codeTop >= 0);
return {
maxDelta: Math.max(...rows.map((row) => row.delta)),
mismatchedTypography: rows
.filter((row) => Math.abs(row.gutterLineHeight - row.codeLineHeight) > 0.5)
.map((row) => ({
index: row.index,
gutterLineHeight: row.gutterLineHeight,
codeLineHeight: row.codeLineHeight,
})),
rows,
};
});
}
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 () => {
await client.close().catch(() => undefined);
await repo.cleanup().catch(() => undefined);
},
});
await writeFile(path.join(repo.path, "src/use-mounted-tab-set.ts"), AFTER);
const opened = await client.openProject(repo.path);
if (!opened.workspace) {
throw new Error(opened.error ?? `Failed to open project ${repo.path}`);
}
return { id: opened.workspace.id };
}
async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
await page.setViewportSize({ width: 1400, height: 900 });
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
await waitForWorkspaceTabsVisible(page);
await page.getByRole("button", { name: "Open explorer" }).click();
await openChangesInVisibleExplorer(page);
await page.getByTestId("diff-file-0").click();
await expectExpandedMountedTabDiff(page);
}
async function openChangesInVisibleExplorer(page: Page): Promise<void> {
await expect(page.getByTestId("explorer-tab-changes")).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("use-mounted-tab-set.ts")).toBeVisible({ timeout: 30_000 });
}
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({
timeout: 30_000,
});
}
async function changeCodeFontSizeFromSettings(page: Page, codeFontSize: number): Promise<void> {
await page.getByTestId("sidebar-settings").click();
await expect(page).toHaveURL(new RegExp(`${buildSettingsSectionRoute("general")}|/settings$`));
await page.getByRole("button", { name: "Appearance" }).click();
await page.getByLabel("Code font size").fill(String(codeFontSize));
await page.getByLabel("Code font size").press("Enter");
await expect(page.getByLabel("Code font size")).toHaveValue(String(codeFontSize));
}
async function returnToWorkspaceChanges(page: Page): Promise<void> {
await page.getByTestId("settings-back-to-workspace").click();
await waitForWorkspaceTabsVisible(page);
await openChangesInVisibleExplorer(page);
await expectExpandedMountedTabDiff(page);
}
async function scrollToLowerUnwrappedDiffRows(page: Page): Promise<void> {
const lastRowIndex = await page.getByTestId("diff-file-0-body").evaluate((root) => {
const rows = Array.from(root.querySelectorAll<HTMLElement>('[data-testid^="diff-code-row-"]'));
if (rows.length === 0) {
throw new Error("No unwrapped code rows are mounted");
}
return Math.max(
...rows.map((row) => Number((row.getAttribute("data-testid") ?? "").slice(14))),
);
});
await page.getByTestId(`diff-code-row-${lastRowIndex}`).scrollIntoViewIfNeeded();
await expect(page.getByTestId(`diff-code-row-${lastRowIndex}`)).toBeVisible();
}
async function expectDiffCodeTextAlignedWithGutterText(
page: Page,
lines: { codeText: string; lineNumber: string }[],
): Promise<void> {
const geometries = await readDiffTextGeometry(page, lines);
for (const geometry of geometries) {
expect(geometry.codeTop, geometry.codeText).toBeCloseTo(geometry.gutterTop, 0);
}
}
async function expectHoverCommentButtonAlignedWithCodeLine(
page: Page,
line: { codeText: string; lineNumber: string },
): Promise<void> {
const target = await readDiffTextGeometry(page, [line]).then((rows) => rows[0]);
if (!target) {
throw new Error(`Could not find target line ${line.lineNumber}`);
}
await page.getByTestId(`diff-code-row-${target.index}`).hover();
const geometry = await page
.getByTestId(`diff-gutter-action-${target.index}`)
.evaluate((action, expectedCodeCenterY) => {
const rect = action.getBoundingClientRect();
return {
actionCenterY: rect.top + rect.height / 2,
codeCenterY: expectedCodeCenterY,
};
}, target.codeCenterY);
expect(geometry.actionCenterY).toBeCloseTo(geometry.codeCenterY, 0);
}
async function readDiffTextGeometry(
page: Page,
lines: { codeText: string; lineNumber: string }[],
): Promise<
{ index: number; codeText: string; codeTop: number; gutterTop: number; codeCenterY: number }[]
> {
return page.locator("body").evaluate(({ ownerDocument }, targets) => {
const root = ownerDocument.querySelector('[data-testid="explorer-content-area"]');
if (!root) {
throw new Error("Changes panel is not mounted");
}
const readIndexedElements = (prefix: string) =>
Array.from(root.querySelectorAll<HTMLElement>(`[data-testid^="${prefix}"]`)).map(
(element) => {
const testId = element.getAttribute("data-testid") ?? "";
return { index: Number(testId.slice(prefix.length)), element };
},
);
const gutterTexts = readIndexedElements("diff-gutter-text-");
const codeTexts = readIndexedElements("diff-code-text-");
return targets.map((target) => {
const gutter = gutterTexts.find(
({ element }) => (element.textContent ?? "").trim() === target.lineNumber,
);
if (!gutter) {
throw new Error(`Could not find gutter line ${target.lineNumber}`);
}
const code = codeTexts.find(
({ index, element }) =>
index === gutter.index && (element.textContent ?? "").includes(target.codeText),
);
if (!code) {
throw new Error(`Could not find code row ${target.codeText}`);
}
const codeRect = code.element.getBoundingClientRect();
const gutterRect = gutter.element.getBoundingClientRect();
return {
index: gutter.index,
codeText: target.codeText,
codeTop: codeRect.top,
gutterTop: gutterRect.top,
codeCenterY: codeRect.top + codeRect.height / 2,
};
});
}, lines);
}
+1 -1
View File
@@ -124,7 +124,7 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
return {
serverId: cfg.serverId,
status: daemonRunning ? "running" : "stopped",
listen: null,
listen: "127.0.0.1:6767",
hostname: null,
pid: currentPid,
home: "",
+50 -14
View File
@@ -124,14 +124,30 @@ export async function openNewWorkspaceComposer(
});
}
export async function clickNewWorkspaceButton(
export async function openGlobalNewWorkspaceComposer(page: Page): Promise<void> {
await page.getByTestId("sidebar-new-workspace").click();
await expect(page).toHaveURL(/\/h\/[^/]+\/new(?:\?.*)?$/, {
timeout: 30_000,
});
}
export async function expectNewWorkspaceProjectSelected(
page: Page,
input: { projectKey: string; projectDisplayName: string; prompt?: string },
projectDisplayName: string,
): Promise<void> {
const projectPicker = page.getByRole("button", { name: "Workspace project" });
await expect(projectPicker).toBeVisible({ timeout: 30_000 });
await expect(projectPicker).toContainText(projectDisplayName);
}
export async function submitNewWorkspacePrompt(
page: Page,
prompt = "Hello from e2e",
): Promise<void> {
await openNewWorkspaceComposer(page, input);
const composer = page.getByRole("textbox", { name: "Message agent..." });
await expect(composer).toBeVisible({ timeout: 30_000 });
await composer.fill(input.prompt ?? "Hello from e2e");
await composer.fill(prompt);
const createButton = page
.getByTestId("message-input-root")
.getByRole("button", { name: "Create" });
@@ -139,6 +155,14 @@ export async function clickNewWorkspaceButton(
await createButton.click();
}
export async function clickNewWorkspaceButton(
page: Page,
input: { projectKey: string; projectDisplayName: string; prompt?: string },
): Promise<void> {
await openNewWorkspaceComposer(page, input);
await submitNewWorkspacePrompt(page, input.prompt);
}
export async function openStartingRefPicker(page: Page): Promise<void> {
const trigger = page.getByTestId("new-workspace-ref-picker-trigger");
await expect(trigger).toBeVisible({ timeout: 30_000 });
@@ -177,7 +201,9 @@ export async function selectPickerOptionByKeyboard(page: Page, label: string): P
const searchInput = page.getByPlaceholder("Search branches and PRs");
await expect(searchInput).toBeVisible({ timeout: 30_000 });
await page.keyboard.type(label);
await page.keyboard.press("ArrowDown");
await expect(page.getByTestId(`new-workspace-ref-picker-branch-${label}`)).toBeVisible({
timeout: 10_000,
});
await page.keyboard.press("Enter");
}
@@ -212,7 +238,13 @@ export async function expectComposerGithubAttachmentPill(
export async function assertNewWorkspaceSidebarAndHeader(
page: Page,
input: { serverId: string; previousWorkspaceId: string; projectDisplayName: string },
input: {
serverId: string;
previousWorkspaceId: string;
projectDisplayName: string;
assertSidebarRow?: boolean;
assertHeader?: boolean;
},
): Promise<{ workspaceId: string }> {
// Wait for URL to redirect to the newly created workspace.
// Uses URL as source of truth to avoid picking up sidebar rows from concurrent tests.
@@ -230,15 +262,19 @@ export async function assertNewWorkspaceSidebarAndHeader(
throw new Error(`Expected URL to redirect to a new workspace.\nCurrent URL: ${page.url()}`);
}
const createdWorkspaceRow = page.getByTestId(
`sidebar-workspace-row-${input.serverId}:${workspaceId}`,
);
await expect(createdWorkspaceRow.first()).toBeVisible({ timeout: 30_000 });
if (input.assertSidebarRow !== false) {
const createdWorkspaceRow = page.getByTestId(
`sidebar-workspace-row-${input.serverId}:${workspaceId}`,
);
await expect(createdWorkspaceRow.first()).toBeVisible({ timeout: 30_000 });
}
await expectWorkspaceHeader(page, {
title: workspaceLabelFromPath(workspaceId),
subtitle: input.projectDisplayName,
});
if (input.assertHeader !== false) {
await expectWorkspaceHeader(page, {
title: workspaceLabelFromPath(workspaceId),
subtitle: input.projectDisplayName,
});
}
return { workspaceId };
}
+65 -1
View File
@@ -1,8 +1,18 @@
import { expect, type Page } from "@playwright/test";
import { TEST_HOST_LABEL } from "./daemon-registry";
import { buildCreateAgentPreferences, buildSeededHost, TEST_HOST_LABEL } from "./daemon-registry";
import { escapeRegex } from "./regex";
import { getServerId } from "./server-id";
const DISABLE_DEFAULT_SEED_ONCE_KEY = "@paseo:e2e-disable-default-seed-once";
const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce";
const REGISTRY_KEY = "@paseo:daemon-registry";
interface SavedSettingsHostInput {
serverId: string;
label: string;
endpoint: string;
}
const SECTION_LABELS = {
general: "General",
appearance: "Appearance",
@@ -84,6 +94,60 @@ export async function openCompactSettings(page: Page): Promise<void> {
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
}
export async function seedSavedSettingsHosts(
page: Page,
hosts: SavedSettingsHostInput[],
): Promise<void> {
await page.goto("/");
const nowIso = new Date().toISOString();
const registry = hosts.map((host) =>
buildSeededHost({
serverId: host.serverId,
label: host.label,
endpoint: host.endpoint,
nowIso,
}),
);
const firstHost = registry[0];
if (!firstHost) {
throw new Error("Expected at least one settings host fixture.");
}
const preferences = buildCreateAgentPreferences(firstHost.serverId);
await page.evaluate(
({ keys, storedRegistry, storedPreferences }) => {
const nonce = localStorage.getItem(keys.seedNonce);
if (!nonce) {
throw new Error("Expected e2e seed nonce before overriding settings host registry.");
}
localStorage.setItem(keys.registry, JSON.stringify(storedRegistry));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(storedPreferences));
localStorage.setItem(keys.disableDefaultSeedOnce, nonce);
},
{
keys: {
disableDefaultSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY,
registry: REGISTRY_KEY,
seedNonce: SEED_NONCE_KEY,
},
storedRegistry: registry,
storedPreferences: preferences,
},
);
}
export async function selectSettingsHost(page: Page, serverId: string): Promise<void> {
await page.getByTestId("settings-host-picker").click();
await page.getByTestId(`settings-host-picker-item-${serverId}`).click();
}
export async function expectSettingsHostPickerLabel(page: Page, label: string): Promise<void> {
await expect(
page.getByTestId("settings-host-picker").getByText(label, { exact: true }),
).toBeVisible();
}
export async function expectCompactSettingsList(page: Page): Promise<void> {
await expect(page).toHaveURL(/\/settings$/);
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
+264 -7
View File
@@ -7,16 +7,17 @@ import {
archiveWorkspaceFromDaemon,
archiveLocalWorkspaceFromDaemon,
assertNewWorkspaceSidebarAndHeader,
clickNewWorkspaceButton,
closeBranchPicker,
connectNewWorkspaceDaemonClient,
createWorktreeViaDaemon,
delayBrowserAgentCreatedStatus,
expectComposerGithubAttachmentPill,
expectNewWorkspaceProjectSelected,
expectPickerClosed,
expectPickerOpen,
expectPickerSelected,
expectStartingRefPickerTriggerPr,
openGlobalNewWorkspaceComposer,
openBranchPicker,
openNewWorkspaceComposer,
openProjectViaDaemon,
@@ -24,6 +25,7 @@ import {
selectBranchInPicker,
selectGitHubPrInPicker,
selectPickerOptionByKeyboard,
submitNewWorkspacePrompt,
} from "./helpers/new-workspace";
import { createTempGitRepo, readWorktreeBranchInfo } from "./helpers/workspace";
import { getServerId } from "./helpers/server-id";
@@ -36,6 +38,147 @@ import {
workspaceLabelFromPath,
} from "./helpers/workspace-ui";
interface WorkspaceStatusGroupEvent {
rowTestId: string;
bucket: string;
indicatorTestId: string | null;
label: string;
at: number;
}
async function switchSidebarToStatusGrouping(page: import("@playwright/test").Page) {
await page.getByTestId("sidebar-grouping-selector").click();
await page.getByTestId("sidebar-grouping-status").click();
await expect(page.getByTestId("sidebar-status-group-done")).toBeVisible({ timeout: 30_000 });
}
async function startTrackingSidebarStatusGroups(page: import("@playwright/test").Page) {
await page.evaluate(() => {
interface StatusGroupEvent {
rowTestId: string;
bucket: string;
indicatorTestId: string | null;
label: string;
at: number;
}
const win = window as typeof window & {
__workspaceStatusGroupEvents?: StatusGroupEvent[];
__workspaceStatusGroupObserver?: MutationObserver;
};
win.__workspaceStatusGroupEvents = [];
win.__workspaceStatusGroupObserver?.disconnect();
const capture = () => {
const events = win.__workspaceStatusGroupEvents;
if (!events) return;
const groups = document.querySelectorAll<HTMLElement>(
'[data-testid^="sidebar-status-group-"]',
);
for (const group of groups) {
const groupTestId = group.getAttribute("data-testid") ?? "";
const bucket = groupTestId.replace("sidebar-status-group-", "");
const label = group.textContent ?? "";
const block = group.parentElement?.parentElement;
if (!block) continue;
const rows = block.querySelectorAll<HTMLElement>('[data-testid^="sidebar-workspace-row-"]');
for (const row of rows) {
const rowTestId = row.getAttribute("data-testid");
if (!rowTestId) continue;
const indicatorTestId =
row
.querySelector<HTMLElement>('[data-testid^="workspace-status-indicator-"]')
?.getAttribute("data-testid") ?? null;
const last = events.at(-1);
if (
last?.rowTestId === rowTestId &&
last.bucket === bucket &&
last.indicatorTestId === indicatorTestId
) {
continue;
}
events.push({ rowTestId, bucket, indicatorTestId, label, at: performance.now() });
}
}
};
capture();
const observer = new MutationObserver(capture);
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
win.__workspaceStatusGroupObserver = observer;
});
}
async function getTrackedSidebarStatusGroups(
page: import("@playwright/test").Page,
): Promise<WorkspaceStatusGroupEvent[]> {
return page.evaluate(() => {
const win = window as typeof window & {
__workspaceStatusGroupEvents?: WorkspaceStatusGroupEvent[];
};
return win.__workspaceStatusGroupEvents ?? [];
});
}
async function waitForWorkspaceStatusGroupEvent(input: {
page: import("@playwright/test").Page;
rowTestId: string;
bucket: string;
}) {
await input.page.waitForFunction(
({ expectedRowTestId, expectedBucket }) => {
const win = window as typeof window & {
__workspaceStatusGroupEvents?: WorkspaceStatusGroupEvent[];
};
for (const event of win.__workspaceStatusGroupEvents ?? []) {
if (event.rowTestId === expectedRowTestId && event.bucket === expectedBucket) {
return true;
}
}
return false;
},
{ expectedRowTestId: input.rowTestId, expectedBucket: input.bucket },
{ timeout: 30_000 },
);
}
async function expectWorkspaceStatusGroupEvents(input: {
page: import("@playwright/test").Page;
rowTestId: string;
includes: string;
excludes: string;
includesIndicator?: string;
excludesIndicator?: string;
}) {
await waitForWorkspaceStatusGroupEvent({
page: input.page,
rowTestId: input.rowTestId,
bucket: input.includes,
});
const createdWorkspaceEvents = (await getTrackedSidebarStatusGroups(input.page)).filter(
(event) => event.rowTestId === input.rowTestId,
);
expect(createdWorkspaceEvents.map((event) => event.bucket)).toContain(input.includes);
expect(createdWorkspaceEvents.filter((event) => event.bucket === input.excludes)).toEqual([]);
if (input.includesIndicator) {
expect(createdWorkspaceEvents.map((event) => event.indicatorTestId)).toContain(
input.includesIndicator,
);
}
if (input.excludesIndicator) {
expect(
createdWorkspaceEvents.filter((event) => event.indicatorTestId === input.excludesIndicator),
).toEqual([]);
}
}
async function submitNewWorkspaceWithoutPrompt(page: import("@playwright/test").Page) {
const createButton = page
.getByTestId("message-input-root")
.getByRole("button", { name: "Create" });
await expect(createButton).toBeVisible({ timeout: 30_000 });
await createButton.click();
}
test.describe("New workspace flow", () => {
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
const localWorkspaceIds = new Set<string>();
@@ -193,7 +336,7 @@ test.describe("New workspace flow", () => {
}
});
test("clicking new workspace redirects, renders header, shows sidebar row, and keeps one agent tab", async ({
test("global new workspace uses the last active project and creates one agent tab", async ({
page,
}) => {
const serverId = getServerId();
@@ -217,15 +360,16 @@ test.describe("New workspace flow", () => {
subtitle: openedProject.projectDisplayName,
});
await clickNewWorkspaceButton(page, {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await openGlobalNewWorkspaceComposer(page);
await expectNewWorkspaceProjectSelected(page, openedProject.projectDisplayName);
await submitNewWorkspacePrompt(page);
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
serverId,
previousWorkspaceId: openedProject.workspaceId,
projectDisplayName: openedProject.projectDisplayName,
assertSidebarRow: false,
assertHeader: false,
});
createdWorktreeIds.add(createdWorkspace.workspaceId);
@@ -312,6 +456,8 @@ test.describe("New workspace flow", () => {
serverId,
previousWorkspaceId: openedProject.workspaceId,
projectDisplayName: openedProject.projectDisplayName,
assertSidebarRow: false,
assertHeader: false,
});
createdWorktreeIds.add(createdWorkspace.workspaceId);
@@ -343,6 +489,117 @@ test.describe("New workspace flow", () => {
}
});
test("new workspace with initial agent never appears in the Done status group", async ({
page,
}) => {
const serverId = getServerId();
const tempRepo = await createTempGitRepo("new-workspace-status-optimistic-");
try {
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
localWorkspaceIds.add(openedProject.workspaceId);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: openedProject.workspaceId,
});
await expectWorkspaceHeader(page, {
title: openedProject.workspaceName,
subtitle: openedProject.projectDisplayName,
});
await switchSidebarToStatusGrouping(page);
await startTrackingSidebarStatusGroups(page);
await openGlobalNewWorkspaceComposer(page);
await expectNewWorkspaceProjectSelected(page, openedProject.projectDisplayName);
await submitNewWorkspacePrompt(page);
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
serverId,
previousWorkspaceId: openedProject.workspaceId,
projectDisplayName: openedProject.projectDisplayName,
assertSidebarRow: false,
assertHeader: false,
});
createdWorktreeIds.add(createdWorkspace.workspaceId);
const rowTestId = `sidebar-workspace-row-${serverId}:${createdWorkspace.workspaceId}`;
await expectWorkspaceStatusGroupEvents({
page,
rowTestId,
includes: "running",
excludes: "done",
includesIndicator: "workspace-status-indicator-running",
});
} finally {
await tempRepo.cleanup();
}
});
test("new workspace without an initial agent appears in the Done status group", async ({
page,
}) => {
const serverId = getServerId();
const tempRepo = await createTempGitRepo("new-workspace-status-empty-");
try {
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
localWorkspaceIds.add(openedProject.workspaceId);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: openedProject.workspaceId,
});
await expectWorkspaceHeader(page, {
title: openedProject.workspaceName,
subtitle: openedProject.projectDisplayName,
});
await switchSidebarToStatusGrouping(page);
await startTrackingSidebarStatusGroups(page);
await openGlobalNewWorkspaceComposer(page);
await expectNewWorkspaceProjectSelected(page, openedProject.projectDisplayName);
await submitNewWorkspaceWithoutPrompt(page);
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
serverId,
previousWorkspaceId: openedProject.workspaceId,
projectDisplayName: openedProject.projectDisplayName,
});
createdWorktreeIds.add(createdWorkspace.workspaceId);
const rowTestId = `sidebar-workspace-row-${serverId}:${createdWorkspace.workspaceId}`;
await expectWorkspaceStatusGroupEvents({
page,
rowTestId,
includes: "done",
excludes: "running",
excludesIndicator: "workspace-status-indicator-loading",
});
await expectWorkspaceStatusGroupEvents({
page,
rowTestId,
includes: "done",
excludes: "running",
excludesIndicator: "workspace-status-indicator-running",
});
} finally {
await tempRepo.cleanup();
}
});
test("selected branch becomes the base of a new workspace worktree", async ({ page }) => {
const serverId = getServerId();
@@ -400,7 +657,7 @@ test.describe("New workspace flow", () => {
}
});
test("branch picker opens via keyboard, navigates options, and selects on Enter", async ({
test("branch picker opens via keyboard and selects the filtered option on Enter", async ({
page,
}) => {
const tempRepo = await createTempGitRepo("picker-keyboard-", { branches: ["main", "dev"] });
@@ -0,0 +1,109 @@
import { expect, test, type Page } from "./fixtures";
import { expectComposerVisible } from "./helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
const MOBILE_VIEWPORT = { width: 390, height: 844 };
async function openMockAgentAtMobileBreakpoint(page: Page) {
await page.setViewportSize(MOBILE_VIEWPORT);
const session = await seedMockAgentWorkspace({
repoPrefix: "provider-sheet-stack-",
title: "Provider sheet stack e2e",
initialPrompt: "Prepare provider sheet stack test agent.",
});
await openAgentRoute(page, session);
await expectComposerVisible(page);
await expect(page.getByRole("button", { name: /Select model/ })).toBeVisible({
timeout: 30_000,
});
return session;
}
async function openProviderSettingsFromModelSelector(page: Page) {
await page.getByRole("button", { name: /Select model/ }).click();
await expect(page.getByLabel("Bottom Sheet", { exact: true })).toBeVisible({ timeout: 10_000 });
const openCodeRow = page.getByText("OpenCode", { exact: true }).first();
if (await openCodeRow.isVisible().catch(() => false)) {
await openCodeRow.click();
}
await page.getByRole("button", { name: /Open .* settings/ }).click();
await expect(page.getByTestId("provider-settings-sheet")).toBeVisible({ timeout: 10_000 });
}
async function expectModelSelectorVisible(page: Page) {
await expect(page.getByRole("button", { name: /Open .* settings/ })).toBeVisible({
timeout: 10_000,
});
}
async function closeTopSheet(page: Page) {
const closeTarget = page.getByLabel("Close", { exact: true }).last();
if (await closeTarget.isVisible().catch(() => false)) {
await closeTarget.click({ force: true });
return;
}
const handle = page.getByRole("slider", { name: "Bottom sheet handle" }).last();
const handleBox = await handle.boundingBox();
if (!handleBox) {
throw new Error("Bottom sheet handle was not measurable");
}
const startX = handleBox.x + handleBox.width / 2;
const startY = handleBox.y + handleBox.height / 2;
await page.mouse.move(startX, startY);
await page.mouse.down();
await page.mouse.move(startX, startY + 400, { steps: 8 });
await page.mouse.up();
}
async function expectProviderSettingsVisible(page: Page) {
await expect(page.getByTestId("provider-settings-sheet")).toBeVisible({ timeout: 10_000 });
await expect(page.getByRole("button", { name: "Add model" })).toBeVisible();
await expect(page.getByRole("button", { name: "Diagnostic", exact: true })).toBeVisible();
}
async function exerciseProviderSettingsStack(page: Page) {
await expectProviderSettingsVisible(page);
await page.getByRole("button", { name: "Add model" }).click();
await expect(page.getByTestId("add-custom-model-sheet")).toBeVisible({ timeout: 10_000 });
await closeTopSheet(page);
await expectProviderSettingsVisible(page);
await page.getByRole("button", { name: "Diagnostic", exact: true }).click();
await expect(page.getByTestId("provider-diagnostic-sheet")).toBeVisible({ timeout: 10_000 });
await page.getByRole("button", { name: /Refresh diagnostic/ }).click();
await expect(page.getByTestId("provider-diagnostic-sheet")).toBeVisible({ timeout: 10_000 });
await closeTopSheet(page);
await expectProviderSettingsVisible(page);
await page.getByRole("button", { name: "Refresh", exact: true }).click();
await expectProviderSettingsVisible(page);
}
test.describe("provider settings bottom-sheet stack", () => {
test("provider settings and children close back through the model selector stack", async ({
page,
}) => {
const session = await openMockAgentAtMobileBreakpoint(page);
try {
await openProviderSettingsFromModelSelector(page);
await exerciseProviderSettingsStack(page);
await closeTopSheet(page);
await expectModelSelectorVisible(page);
await page.getByRole("button", { name: /Open .* settings/ }).click();
await expect(page.getByTestId("provider-settings-sheet")).toBeVisible({ timeout: 10_000 });
await exerciseProviderSettingsStack(page);
await closeTopSheet(page);
await expectModelSelectorVisible(page);
await closeTopSheet(page);
} finally {
await session.cleanup();
}
});
});
@@ -1,5 +1,6 @@
import { test, expect } from "./fixtures";
import { gotoAppShell, openSettings } from "./helpers/app";
import { getE2EDaemonPort } from "./helpers/daemon-port";
import {
openSettingsSection,
expectSettingsHeader,
@@ -26,7 +27,12 @@ import {
expectAboutContent,
expectGeneralContent,
expectAppearanceContent,
seedSavedSettingsHosts,
selectSettingsHost,
expectSettingsHostPickerLabel,
openSettingsHostSection,
} from "./helpers/settings";
import { getServerId } from "./helpers/server-id";
test.describe("Settings sidebar navigation", () => {
test("clicking a sidebar section updates the URL and renders the section", async ({ page }) => {
@@ -157,4 +163,28 @@ test.describe("Settings — compact master-detail", () => {
await expect(page).toHaveURL(/\/settings$/);
await expectSettingsSidebarVisible(page);
});
test("switching the host picker on the settings list scopes host rows without navigating", async ({
page,
}) => {
const primaryServerId = getServerId();
const secondaryServerId = "srv_e2e_settings_secondary";
const secondaryHostLabel = "Stable horse";
const endpoint = `127.0.0.1:${getE2EDaemonPort()}`;
await seedSavedSettingsHosts(page, [
{ serverId: primaryServerId, label: "First horse", endpoint },
{ serverId: secondaryServerId, label: secondaryHostLabel, endpoint },
]);
await gotoAppShell(page);
await openCompactSettings(page);
await selectSettingsHost(page, secondaryServerId);
await expect(page).toHaveURL(/\/settings$/);
await expectSettingsSidebarVisible(page);
await expectSettingsHostPickerLabel(page, secondaryHostLabel);
await openSettingsHostSection(page, secondaryServerId, "connections");
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.90-beta.1",
"version": "0.1.91-beta.2",
"private": true,
"main": "index.ts",
"scripts": {
+19 -5
View File
@@ -1,4 +1,5 @@
import "@/styles/unistyles";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { PortalProvider } from "@gorhom/portal";
import { QueryClientProvider } from "@tanstack/react-query";
import * as Linking from "expo-linking";
@@ -40,6 +41,8 @@ import {
} from "@/contexts/horizontal-scroll-context";
import { SessionProvider } from "@/contexts/session-context";
import {
MOBILE_VISUAL_PANEL_AGENT,
MOBILE_VISUAL_PANEL_AGENT_LIST,
SidebarAnimationProvider,
useSidebarAnimation,
} from "@/contexts/sidebar-animation-context";
@@ -493,7 +496,6 @@ function MobileGestureWrapper({
children: ReactNode;
chromeEnabled: boolean;
}) {
const mobileView = usePanelStore((state) => state.mobileView);
const showMobileAgentList = usePanelStore((state) => state.showMobileAgentList);
const horizontalScroll = useHorizontalScrollOptional();
const {
@@ -503,12 +505,13 @@ function MobileGestureWrapper({
animateToOpen,
animateToClose,
isGesturing,
mobileVisualPanel,
gestureAnimatingRef,
openGestureRef,
} = useSidebarAnimation();
const touchStartX = useSharedValue(0);
const touchStartY = useSharedValue(0);
const openGestureEnabled = chromeEnabled && mobileView === "agent";
const openGestureEnabled = chromeEnabled;
const handleGestureOpen = useCallback(() => {
gestureAnimatingRef.current = true;
@@ -538,6 +541,11 @@ function MobileGestureWrapper({
const absDeltaX = Math.abs(deltaX);
const absDeltaY = Math.abs(deltaY);
if (mobileVisualPanel.value !== MOBILE_VISUAL_PANEL_AGENT) {
stateManager.fail();
return;
}
if (horizontalScroll?.isAnyScrolledRight.value) {
stateManager.fail();
return;
@@ -579,9 +587,11 @@ function MobileGestureWrapper({
isGesturing.value = false;
const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500;
if (shouldOpen) {
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT_LIST;
animateToOpen();
runOnJS(handleGestureOpen)();
} else {
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT;
animateToClose();
}
})
@@ -593,6 +603,7 @@ function MobileGestureWrapper({
windowWidth,
translateX,
backdropOpacity,
mobileVisualPanel,
animateToOpen,
animateToClose,
handleGestureOpen,
@@ -921,17 +932,20 @@ function RuntimeProviders({ children }: { children: ReactNode }) {
);
}
// PortalProvider must remain the innermost global provider here.
// PortalProvider must stay inside normal app-wide context providers here.
// `@gorhom/portal` renders portaled children at the host's location in the
// tree, so any context a portaled sheet might consume (QueryClient, theme,
// auth, settings, …) must wrap PortalProvider — not be wrapped by it.
// Adding a new global provider? Put it above PortalProvider.
// BottomSheetModalProvider is the exception: Gorhom modals consume portal
// context and need one shared provider for sibling sheets to stack.
function RootProviders({ children }: { children: ReactNode }) {
return (
<QueryProvider>
<SafeAreaProvider>
<KeyboardProvider>
<PortalProvider>{children}</PortalProvider>
<PortalProvider>
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
</PortalProvider>
</KeyboardProvider>
</SafeAreaProvider>
</QueryProvider>
+1 -5
View File
@@ -9,14 +9,10 @@ export default function HostNewWorkspaceRoute() {
projectId?: string;
}>();
const serverId = typeof params.serverId === "string" ? params.serverId : "";
const sourceDirectory = typeof params.dir === "string" ? params.dir : "";
const sourceDirectory = typeof params.dir === "string" ? params.dir : undefined;
const displayName = typeof params.name === "string" ? params.name : undefined;
const projectId = typeof params.projectId === "string" ? params.projectId : undefined;
if (!sourceDirectory) {
return null;
}
return (
<NewWorkspaceScreen
serverId={serverId}
@@ -3,6 +3,7 @@ export {
AssistantMarkdownCodeLink,
AssistantMarkdownLink,
} from "./link";
export { type AssistantLinkPress, useAssistantLinkPress } from "./link-press-context";
export {
classifyAssistantFileLink,
normalizeInlinePathTarget,
@@ -0,0 +1,24 @@
import { createContext, useContext } from "react";
import type { TextProps } from "react-native";
// Carries a link's press handler down to the leaf text spans that render its
// label. On iOS an assistant link is a nested UITextView span, and
// react-native-uitextview only attaches onPress to the *string* children it
// converts into RNUITextViewChild nodes (src/Text.tsx) — element children (the
// MarkdownInheritedText spans markdown produces for link text) pass through
// untouched, so an onPress placed on the wrapping span never reaches a tappable
// native node. Threading the handler through context lets each leaf span hand
// onPress to its own string children, where the native tap recognizer can find
// it. Provided only on iOS (Android/web links tap fine via their own paths).
export interface AssistantLinkPress {
onPress: () => void;
accessibilityRole?: TextProps["accessibilityRole"];
}
const AssistantLinkPressContext = createContext<AssistantLinkPress | null>(null);
export const AssistantLinkPressProvider = AssistantLinkPressContext.Provider;
export function useAssistantLinkPress(): AssistantLinkPress | null {
return useContext(AssistantLinkPressContext);
}
+31 -10
View File
@@ -1,5 +1,6 @@
import { useMemo, useState, type CSSProperties, type MouseEvent, type ReactNode } from "react";
import {
Platform,
Pressable,
Text,
View,
@@ -10,6 +11,7 @@ import {
import { StyleSheet } from "react-native-unistyles";
import { isNative, isWeb } from "@/constants/platform";
import { MarkdownTextSpan } from "@/components/markdown-text";
import { AssistantLinkPressProvider, type AssistantLinkPress } from "./link-press-context";
import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useStableEvent } from "@/hooks/use-stable-event";
@@ -56,23 +58,42 @@ export function AssistantMarkdownLink({
() => [style, hovered && { textDecorationLine: "underline" as const }],
[style, hovered],
);
const linkPress = useMemo<AssistantLinkPress>(
() => ({ onPress, accessibilityRole: "link" }),
[onPress],
);
if (isNative) {
// Must be a MarkdownTextSpan, not a plain <Text>: on iOS the link renders
// inside the paragraph's native UITextView, and a plain <Text> nested there
// is not hoisted into a UITextViewChild, so its text is silently dropped
// (the link disappears). The span composes correctly and stays selectable;
// onPress is forwarded (reliable tap-to-open on iOS is tracked by #21).
// (the link disappears). The span composes correctly and stays selectable.
//
// Tap-to-open: react-native-uitextview only wires onPress onto the *string*
// children it turns into RNUITextViewChild nodes — the element children that
// markdown emits for link text pass through untouched, so an onPress placed
// here never reaches a tappable native node. We thread it down through
// AssistantLinkPressProvider so each leaf text span re-attaches it to its
// own string children, where the native tap recognizer can find it. iOS
// only: Android forwards onPress through nested <Text> already, and web uses
// the <a> path below.
const span = (
<MarkdownTextSpan
accessibilityRole="link"
monoSurface={monoSurface}
onPress={onPress}
style={style}
>
{children}
</MarkdownTextSpan>
);
return (
<FileLinkHoverTooltip filePath={tooltipPath}>
<MarkdownTextSpan
accessibilityRole="link"
monoSurface={monoSurface}
onPress={onPress}
style={style}
>
{children}
</MarkdownTextSpan>
{Platform.OS === "ios" ? (
<AssistantLinkPressProvider value={linkPress}>{span}</AssistantLinkPressProvider>
) : (
span
)}
</FileLinkHoverTooltip>
);
}
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import type { WorkspaceLayout } from "@/stores/workspace-layout-store";
import { createDefaultLayout } from "@/stores/workspace-layout-store";
import { openTabInLayoutFocused } from "@/stores/workspace-layout-actions";
import { resolveBrowserNewTabRequest, type BrowserNewTabRequest } from ".";
function createLayoutWithBrowser(browserId: string): WorkspaceLayout {
return openTabInLayoutFocused({
layout: createDefaultLayout(),
target: { kind: "browser", browserId },
now: 1,
}).layout;
}
describe("browser new-tab requests", () => {
it("accepts desktop requests from browser tabs in the current workspace", () => {
const request = resolveBrowserNewTabRequest({
payload: {
sourceBrowserId: "browser-1",
url: "https://example.com/target",
},
workspaceLayout: createLayoutWithBrowser("browser-1"),
});
expect(request).toEqual<BrowserNewTabRequest>({
sourceBrowserId: "browser-1",
url: "https://example.com/target",
});
});
it("ignores desktop requests from another workspace", () => {
const request = resolveBrowserNewTabRequest({
payload: {
sourceBrowserId: "browser-from-other-workspace",
url: "https://example.com/target",
},
workspaceLayout: createLayoutWithBrowser("browser-1"),
});
expect(request).toBeNull();
});
it("rejects unsupported desktop request URLs", () => {
const request = resolveBrowserNewTabRequest({
payload: {
sourceBrowserId: "browser-1",
url: "file:///etc/passwd",
},
workspaceLayout: createLayoutWithBrowser("browser-1"),
});
expect(request).toBeNull();
});
});
@@ -0,0 +1,99 @@
import { useEffect } from "react";
import { getDesktopHost, type DesktopBrowserNewTabRequestEvent } from "@/desktop/host";
import { collectAllTabs, type WorkspaceLayout } from "@/stores/workspace-layout-store";
import { getIsElectron } from "@/constants/platform";
import { useStableEvent } from "@/hooks/use-stable-event";
export type BrowserNewTabRequest = DesktopBrowserNewTabRequestEvent;
function isAllowedBrowserNewTabUrl(value: string): boolean {
try {
const parsed = new URL(value);
return (
parsed.protocol === "http:" || parsed.protocol === "https:" || parsed.href === "about:blank"
);
} catch {
return false;
}
}
function readDesktopBrowserNewTabRequest(payload: unknown): BrowserNewTabRequest | null {
if (!payload || typeof payload !== "object") {
return null;
}
const candidate = payload as Partial<BrowserNewTabRequest>;
if (typeof candidate.sourceBrowserId !== "string" || !candidate.sourceBrowserId.trim()) {
return null;
}
if (typeof candidate.url !== "string" || !isAllowedBrowserNewTabUrl(candidate.url)) {
return null;
}
return {
sourceBrowserId: candidate.sourceBrowserId,
url: candidate.url,
};
}
function workspaceContainsBrowser(input: {
workspaceLayout: WorkspaceLayout | null | undefined;
browserId: string;
}): boolean {
if (!input.workspaceLayout) {
return false;
}
return collectAllTabs(input.workspaceLayout.root).some((tab) => {
return tab.target.kind === "browser" && tab.target.browserId === input.browserId;
});
}
export function resolveBrowserNewTabRequest(input: {
payload: unknown;
workspaceLayout: WorkspaceLayout | null | undefined;
}): BrowserNewTabRequest | null {
const request = readDesktopBrowserNewTabRequest(input.payload);
if (!request) {
return null;
}
if (
!workspaceContainsBrowser({
workspaceLayout: input.workspaceLayout,
browserId: request.sourceBrowserId,
})
) {
return null;
}
return request;
}
export function useDesktopBrowserNewTabRequests(input: {
enabled: boolean;
workspaceLayout: WorkspaceLayout | null | undefined;
openUrl: (url: string) => void;
}): void {
const handleNewTabRequest = useStableEvent((payload: unknown) => {
const request = resolveBrowserNewTabRequest({
payload,
workspaceLayout: input.workspaceLayout,
});
if (!request) {
return;
}
input.openUrl(request.url);
});
useEffect(() => {
if (!input.enabled || !getIsElectron()) {
return;
}
const unsubscribe = getDesktopHost()?.events?.on?.(
"browser-new-tab-request",
handleNewTabRequest,
);
if (typeof unsubscribe === "function") {
return unsubscribe;
}
return () => {
void unsubscribe?.then((dispose) => dispose());
};
}, [handleNewTabRequest, input.enabled]);
}
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-sheet-layout";
describe("getCompactSheetSafeAreaPadding", () => {
it("adds the bottom inset to compact sheet footers", () => {
expect(
getCompactSheetSafeAreaPadding({
isCompact: true,
hasFooter: true,
baseContentPadding: 24,
baseFooterPadding: 12,
safeAreaBottom: 34,
}),
).toEqual({ footerPaddingBottom: 46 });
});
it("adds the bottom inset to compact sheet content when there is no footer", () => {
expect(
getCompactSheetSafeAreaPadding({
isCompact: true,
hasFooter: false,
baseContentPadding: 24,
baseFooterPadding: 12,
safeAreaBottom: 34,
}),
).toEqual({ contentPaddingBottom: 58 });
});
it("does not inset desktop sheets", () => {
expect(
getCompactSheetSafeAreaPadding({
isCompact: false,
hasFooter: false,
baseContentPadding: 24,
baseFooterPadding: 12,
safeAreaBottom: 34,
}),
).toEqual({});
});
});
@@ -0,0 +1,30 @@
export interface CompactSheetSafeAreaPaddingInput {
isCompact: boolean;
hasFooter: boolean;
baseContentPadding: number;
baseFooterPadding: number;
safeAreaBottom: number;
}
export interface CompactSheetSafeAreaPadding {
contentPaddingBottom?: number;
footerPaddingBottom?: number;
}
export function getCompactSheetSafeAreaPadding({
isCompact,
hasFooter,
baseContentPadding,
baseFooterPadding,
safeAreaBottom,
}: CompactSheetSafeAreaPaddingInput): CompactSheetSafeAreaPadding {
if (!isCompact || safeAreaBottom <= 0) {
return {};
}
if (hasFooter) {
return { footerPaddingBottom: baseFooterPadding + safeAreaBottom };
}
return { contentPaddingBottom: baseContentPadding + safeAreaBottom };
}
@@ -12,6 +12,7 @@ import {
BottomSheetTextInput,
type BottomSheetBackgroundProps,
} from "@gorhom/bottom-sheet";
import Animated from "react-native-reanimated";
import { ArrowLeft, Search, X } from "lucide-react-native";
import { FileDropZone } from "@/components/file-drop-zone";
import type { ImageAttachment } from "@/composer/types";
@@ -19,7 +20,9 @@ import {
IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility,
} from "@/components/ui/isolated-bottom-sheet-modal";
import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-sheet-layout";
import { isNative, isWeb } from "@/constants/platform";
import { useSafeAreaInsets } from "react-native-safe-area-context";
// Horizontal indent token shared by the sheet header (title, back arrow,
// leading icon, search input icon) and any row primitive rendered inside the
@@ -230,14 +233,14 @@ function SheetBackground({ style }: BottomSheetBackgroundProps) {
() => [
style,
{
backgroundColor: theme.colors.surface1,
borderTopLeftRadius: theme.borderRadius.xl,
borderTopRightRadius: theme.borderRadius.xl,
backgroundColor: theme.colors.surface0,
borderTopLeftRadius: theme.borderRadius["2xl"],
borderTopRightRadius: theme.borderRadius["2xl"],
},
],
[style, theme.colors.surface1, theme.borderRadius.xl],
[style, theme.colors.surface0, theme.borderRadius],
);
return <View style={combinedStyle} />;
return <Animated.View pointerEvents="none" style={combinedStyle} />;
}
export type AdaptiveTextInputProps = TextInputProps & {
@@ -442,6 +445,7 @@ export interface AdaptiveModalSheetProps {
/** When provided, wraps the card content in a FileDropZone. */
onFilesDropped?: (files: ImageAttachment[]) => void;
scrollable?: boolean;
presentation?: "push" | "replace";
}
export function AdaptiveModalSheet({
@@ -455,13 +459,53 @@ export function AdaptiveModalSheet({
desktopMaxWidth,
onFilesDropped,
scrollable = true,
presentation,
}: AdaptiveModalSheetProps) {
const { theme } = useUnistyles();
const isMobile = useIsCompactFormFactor();
const insets = useSafeAreaInsets();
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
const compactSafeAreaPadding = useMemo(
() =>
getCompactSheetSafeAreaPadding({
isCompact: isMobile,
hasFooter: Boolean(footer),
baseContentPadding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
baseFooterPadding: theme.spacing[3],
safeAreaBottom: insets.bottom,
}),
[footer, insets.bottom, isMobile, theme.spacing],
);
const bottomSheetContentStyle = useMemo(
() => [
styles.bottomSheetContent,
compactSafeAreaPadding.contentPaddingBottom != null
? { paddingBottom: compactSafeAreaPadding.contentPaddingBottom }
: null,
],
[compactSafeAreaPadding.contentPaddingBottom],
);
const bottomSheetStaticContentStyle = useMemo(
() => [
styles.bottomSheetStaticContent,
compactSafeAreaPadding.contentPaddingBottom != null
? { paddingBottom: compactSafeAreaPadding.contentPaddingBottom }
: null,
],
[compactSafeAreaPadding.contentPaddingBottom],
);
const footerStyle = useMemo(
() => [
styles.footer,
compactSafeAreaPadding.footerPaddingBottom != null
? { paddingBottom: compactSafeAreaPadding.footerPaddingBottom }
: null,
],
[compactSafeAreaPadding.footerPaddingBottom],
);
const handleIndicatorStyle = useMemo(
() => ({ backgroundColor: theme.colors.surface2 }),
[theme.colors.surface2],
() => ({ backgroundColor: theme.colors.palette.zinc[600] }),
[theme.colors.palette.zinc],
);
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
visible,
@@ -502,20 +546,21 @@ export function AdaptiveModalSheet({
keyboardBehavior="extend"
keyboardBlurBehavior="restore"
accessible={false}
presentation={presentation}
>
<SheetHeaderView header={header} onClose={onClose} testID={testID} />
{scrollable ? (
<BottomSheetScrollView
contentContainerStyle={styles.bottomSheetContent}
contentContainerStyle={bottomSheetContentStyle}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{children}
</BottomSheetScrollView>
) : (
<View style={styles.bottomSheetStaticContent}>{children}</View>
<View style={bottomSheetStaticContentStyle}>{children}</View>
)}
{footer ? <View style={styles.footer}>{footer}</View> : null}
{footer ? <View style={footerStyle}>{footer}</View> : null}
</IsolatedBottomSheetModal>
);
}
@@ -534,7 +579,7 @@ export function AdaptiveModalSheet({
) : (
<View style={styles.desktopStaticContent}>{children}</View>
)}
{footer ? <View style={styles.footer}>{footer}</View> : null}
{footer ? <View style={footerStyle}>{footer}</View> : null}
</>
);
@@ -685,10 +685,6 @@ export function CombinedModelSelector({
handleOpenChange(!isOpen);
}, [handleOpenChange, isOpen]);
const handleClose = useCallback(() => {
handleOpenChange(false);
}, [handleOpenChange]);
const triggerStyle = useCallback(
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.trigger,
@@ -714,11 +710,10 @@ export function CombinedModelSelector({
setSearchQuery(value);
}, []);
const openHeaderProviderSettings = useCallback(() => {
const openProviderSettings = useCallback(() => {
if (!serverId || view.kind !== "provider") return;
useProviderSettingsStore.getState().open({ serverId, provider: view.providerId });
handleClose();
}, [serverId, view, handleClose]);
}, [serverId, view]);
const sheetHeader = useMemo<SheetHeader>(() => {
if (view.kind === "all") {
@@ -727,7 +722,7 @@ export function CombinedModelSelector({
const ProviderIconForView = getProviderIcon(view.providerId);
const headerActions = (
<Pressable
onPress={openHeaderProviderSettings}
onPress={openProviderSettings}
disabled={!serverId}
hitSlop={8}
style={iconButtonStyle}
@@ -760,7 +755,7 @@ export function CombinedModelSelector({
view,
singleProviderView,
serverId,
openHeaderProviderSettings,
openProviderSettings,
theme.colors.border,
theme.colors.foregroundMuted,
handleBackToAll,
+237 -67
View File
@@ -9,24 +9,39 @@ import {
} from "react-native";
import { memo, useCallback, useEffect, useMemo, useRef, type ReactNode } from "react";
import { Home, Plus, Settings } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { useCommandCenter } from "@/hooks/use-command-center";
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { formatTimeAgo } from "@/utils/time";
import { shortenPath } from "@/utils/shorten-path";
import { AgentStatusDot } from "@/components/agent-status-dot";
import { Shortcut } from "@/components/ui/shortcut";
import { isNative } from "@/constants/platform";
import { isNative, isWeb } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout";
import {
IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility,
} from "@/components/ui/isolated-bottom-sheet-modal";
import {
BottomSheetBackdrop,
BottomSheetScrollView,
BottomSheetTextInput,
} from "@gorhom/bottom-sheet";
function agentKey(agent: Pick<AggregatedAgent, "serverId" | "id">): string {
return `${agent.serverId}:${agent.id}`;
}
const ThemedBottomSheetTextInput = withUnistyles(BottomSheetTextInput, (theme) => ({
placeholderTextColor: theme.colors.foregroundMuted,
}));
interface CommandCenterRowProps {
active: boolean;
children: ReactNode;
onPress: () => void;
registerRow: (el: View | null) => void;
onLayout?: (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;
}
const CommandCenterRow = memo(function CommandCenterRow({
@@ -34,6 +49,7 @@ const CommandCenterRow = memo(function CommandCenterRow({
children,
onPress,
registerRow,
onLayout,
}: CommandCenterRowProps) {
const { theme } = useUnistyles();
@@ -48,7 +64,7 @@ const CommandCenterRow = memo(function CommandCenterRow({
);
return (
<Pressable ref={registerRow} style={pressableStyle} onPress={onPress}>
<Pressable ref={registerRow} style={pressableStyle} onPress={onPress} onLayout={onLayout}>
{children}
</Pressable>
);
@@ -59,6 +75,7 @@ interface CommandCenterRowContainerProps {
active: boolean;
rowRefs: React.MutableRefObject<Map<number, View>>;
onPress: () => void;
onLayout?: (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;
children: ReactNode;
}
@@ -67,6 +84,7 @@ function CommandCenterRowContainer({
active,
rowRefs,
onPress,
onLayout,
children,
}: CommandCenterRowContainerProps) {
const registerRow = useCallback(
@@ -77,7 +95,12 @@ function CommandCenterRowContainer({
[rowRefs, rowIndex],
);
return (
<CommandCenterRow active={active} registerRow={registerRow} onPress={onPress}>
<CommandCenterRow
active={active}
registerRow={registerRow}
onPress={onPress}
onLayout={onLayout}
>
{children}
</CommandCenterRow>
);
@@ -88,6 +111,7 @@ interface CommandCenterActionRowProps {
rowIndex: number;
active: boolean;
rowRefs: React.MutableRefObject<Map<number, View>>;
onLayout?: (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;
onSelect: (item: ReturnType<typeof useCommandCenter>["items"][number]) => void;
}
@@ -96,6 +120,7 @@ function CommandCenterActionRow({
rowIndex,
active,
rowRefs,
onLayout,
onSelect,
}: CommandCenterActionRowProps) {
const { theme } = useUnistyles();
@@ -119,6 +144,7 @@ function CommandCenterActionRow({
active={active}
rowRefs={rowRefs}
onPress={handlePress}
onLayout={onLayout}
>
<View style={styles.rowContent}>
<View style={styles.rowMain}>
@@ -142,6 +168,7 @@ interface CommandCenterAgentRowProps {
rowIndex: number;
active: boolean;
rowRefs: React.MutableRefObject<Map<number, View>>;
onLayout?: (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;
onSelect: (item: ReturnType<typeof useCommandCenter>["items"][number]) => void;
children: ReactNode;
}
@@ -150,6 +177,7 @@ function CommandCenterAgentRow({
rowIndex,
active,
rowRefs,
onLayout,
onSelect,
item,
children,
@@ -161,6 +189,7 @@ function CommandCenterAgentRow({
active={active}
rowRefs={rowRefs}
onPress={handlePress}
onLayout={onLayout}
>
{children}
</CommandCenterRowContainer>
@@ -209,6 +238,9 @@ interface AgentItemsSectionProps {
actionItemsLength: number;
activeIndex: number;
rowRefs: React.MutableRefObject<Map<number, View>>;
onRowLayout: (
rowIndex: number,
) => (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;
onSelect: (item: ReturnType<typeof useCommandCenter>["items"][number]) => void;
sectionDividerStyle: React.ComponentProps<typeof View>["style"];
sectionLabelStyle: React.ComponentProps<typeof Text>["style"];
@@ -219,6 +251,7 @@ function AgentItemsSection({
actionItemsLength,
activeIndex,
rowRefs,
onRowLayout,
onSelect,
sectionDividerStyle,
sectionLabelStyle,
@@ -237,6 +270,7 @@ function AgentItemsSection({
rowIndex={rowIndex}
active={rowIndex === activeIndex}
rowRefs={rowRefs}
onLayout={onRowLayout(rowIndex)}
onSelect={onSelect}
>
<CommandCenterAgentRowContent agent={agent} />
@@ -249,50 +283,119 @@ function AgentItemsSection({
export function CommandCenter() {
const { theme } = useUnistyles();
const { open, inputRef, query, setQuery, activeIndex, items, handleClose, handleSelectItem } =
useCommandCenter();
const {
open,
inputRef,
query,
setQuery,
activeIndex,
items,
handleClose,
handleSelectItem,
handleKeyEvent,
} = useCommandCenter();
const isCompact = useIsCompactFormFactor();
const showBottomSheet = isCompact && isNative;
const rowRefs = useRef<Map<number, View>>(new Map());
const rowLayouts = useRef<Map<number, { y: number; height: number }>>(new Map());
const resultsRef = useRef<ScrollView>(null);
const nativeScrollY = useRef(0);
const nativeViewHeight = useRef(0);
// BottomSheetTextInput wraps a different TextInput type (from react-native-gesture-handler).
// Use a loose ref to avoid the type mismatch — same pattern as AdaptiveTextInput.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const bottomSheetInputRef = useRef<any>(null);
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
visible: open,
isEnabled: showBottomSheet,
onClose: handleClose,
});
// Focus the bottom sheet input when the sheet opens on mobile
useEffect(() => {
if (!open) {
return;
if (showBottomSheet && open) {
const id = setTimeout(() => bottomSheetInputRef.current?.focus(), 300);
return () => clearTimeout(id);
}
const row = rowRefs.current.get(activeIndex);
if (!row || typeof document === "undefined") {
return;
}
const scrollNode =
(
resultsRef.current as
| (ScrollView & {
getScrollableNode?: () => HTMLElement | null;
})
| null
)?.getScrollableNode?.() ?? null;
const rowEl = row as unknown as HTMLElement;
}, [showBottomSheet, open]);
if (!scrollNode) {
rowEl.scrollIntoView?.({ block: "nearest" });
const renderBackdrop = useCallback(
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
),
[],
);
// Scroll active row into view
useEffect(() => {
if (!open) return;
if (isWeb) {
const row = rowRefs.current.get(activeIndex);
if (!row || typeof document === "undefined") return;
const scrollNode =
(
resultsRef.current as
| (ScrollView & {
getScrollableNode?: () => HTMLElement | null;
})
| null
)?.getScrollableNode?.() ?? null;
const rowEl = row as unknown as HTMLElement;
if (!scrollNode) {
rowEl.scrollIntoView?.({ block: "nearest" });
return;
}
const rowTop = rowEl.offsetTop;
const rowBottom = rowTop + rowEl.offsetHeight;
const visibleTop = scrollNode.scrollTop;
const visibleBottom = visibleTop + scrollNode.clientHeight;
if (rowTop < visibleTop) {
scrollNode.scrollTop = rowTop;
return;
}
if (rowBottom > visibleBottom) {
scrollNode.scrollTop = rowBottom - scrollNode.clientHeight;
}
return;
}
const rowTop = rowEl.offsetTop;
const rowBottom = rowTop + rowEl.offsetHeight;
const visibleTop = scrollNode.scrollTop;
const visibleBottom = visibleTop + scrollNode.clientHeight;
// Native: use onLayout-measured positions
const layout = rowLayouts.current.get(activeIndex);
if (!layout || !resultsRef.current) return;
const rowTop = layout.y;
const rowBottom = rowTop + layout.height;
const visibleTop = nativeScrollY.current;
const visibleBottom = visibleTop + nativeViewHeight.current;
if (rowTop < visibleTop) {
scrollNode.scrollTop = rowTop;
return;
}
if (rowBottom > visibleBottom) {
scrollNode.scrollTop = rowBottom - scrollNode.clientHeight;
resultsRef.current.scrollTo?.({ y: rowTop, animated: true });
} else if (rowBottom > visibleBottom) {
resultsRef.current.scrollTo?.({
y: rowBottom - nativeViewHeight.current,
animated: true,
});
}
}, [activeIndex, open]);
const handleRowLayout = useCallback(
(rowIndex: number) => (event: { nativeEvent: { layout: { y: number; height: number } } }) => {
rowLayouts.current.set(rowIndex, {
y: event.nativeEvent.layout.y,
height: event.nativeEvent.layout.height,
});
},
[],
);
const actionItems = useMemo(() => items.filter((item) => item.kind === "action"), [items]);
const agentItems = useMemo(() => items.filter((item) => item.kind === "agent"), [items]);
@@ -324,8 +427,101 @@ export function CommandCenter() {
[theme.colors.border],
);
if (isNative || !open) return null;
const handleKeyPress = useCallback(
({ nativeEvent: { key } }: { nativeEvent: { key: string } }) => {
handleKeyEvent(key);
},
[handleKeyEvent],
);
const handleSubmitEditing = useCallback(() => {
handleKeyEvent("Enter");
}, [handleKeyEvent]);
const snapPoints = useMemo(() => ["60%", "90%"], []);
const resultList =
items.length === 0 ? (
<Text style={emptyTextStyle}>No matches</Text>
) : (
<>
{actionItems.length > 0 ? (
<>
<Text style={sectionLabelStyle}>Actions</Text>
{actionItems.map((item, index) => (
<CommandCenterActionRow
key={`action:${item.action.id}`}
item={item}
rowIndex={index}
active={index === activeIndex}
rowRefs={rowRefs}
onLayout={handleRowLayout(index)}
onSelect={handleSelectItem}
/>
))}
</>
) : null}
{agentItems.length > 0 ? (
<AgentItemsSection
agentItems={agentItems}
actionItemsLength={actionItems.length}
activeIndex={activeIndex}
rowRefs={rowRefs}
onRowLayout={handleRowLayout}
onSelect={handleSelectItem}
sectionDividerStyle={sectionDividerStyle}
sectionLabelStyle={sectionLabelStyle}
/>
) : null}
</>
);
// Mobile: bottom sheet
if (showBottomSheet) {
return (
<IsolatedBottomSheetModal
ref={sheetRef}
snapPoints={snapPoints}
index={0}
enableDynamicSizing={false}
onChange={handleSheetChange}
onDismiss={handleSheetDismiss}
backdropComponent={renderBackdrop}
enablePanDownToClose
keyboardBehavior="extend"
keyboardBlurBehavior="restore"
accessible={false}
>
<View style={styles.bottomSheetHeader}>
<ThemedBottomSheetTextInput
testID="command-center-input"
ref={bottomSheetInputRef as unknown as React.Ref<never>}
value={query}
onChangeText={setQuery}
onKeyPress={handleKeyPress}
onSubmitEditing={handleSubmitEditing}
placeholder="Type a command or search agents..."
style={inputStyle}
autoCapitalize="none"
autoCorrect={false}
autoFocus
/>
</View>
<BottomSheetScrollView
contentContainerStyle={styles.resultsContent}
keyboardShouldPersistTaps="always"
showsVerticalScrollIndicator={false}
>
{resultList}
</BottomSheetScrollView>
</IsolatedBottomSheetModal>
);
}
if (!open) return null;
// Desktop web: centered overlay panel
return (
<Modal visible={open} transparent animationType="fade" onRequestClose={handleClose}>
<View style={styles.overlay}>
@@ -354,39 +550,7 @@ export function CommandCenter() {
keyboardShouldPersistTaps="always"
showsVerticalScrollIndicator={false}
>
{items.length === 0 ? (
<Text style={emptyTextStyle}>No matches</Text>
) : (
<>
{actionItems.length > 0 ? (
<>
<Text style={sectionLabelStyle}>Actions</Text>
{actionItems.map((item, index) => (
<CommandCenterActionRow
key={`action:${item.action.id}`}
item={item}
rowIndex={index}
active={index === activeIndex}
rowRefs={rowRefs}
onSelect={handleSelectItem}
/>
))}
</>
) : null}
{agentItems.length > 0 ? (
<AgentItemsSection
agentItems={agentItems}
actionItemsLength={actionItems.length}
activeIndex={activeIndex}
rowRefs={rowRefs}
onSelect={handleSelectItem}
sectionDividerStyle={sectionDividerStyle}
sectionLabelStyle={sectionLabelStyle}
/>
) : null}
</>
)}
{resultList}
</ScrollView>
</View>
</View>
@@ -395,6 +559,12 @@ export function CommandCenter() {
}
const styles = StyleSheet.create((theme) => ({
bottomSheetHeader: {
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
overlay: {
flex: 1,
justifyContent: "flex-start",
@@ -28,6 +28,7 @@ const styles = StyleSheet.create((theme) => ({
row: {
flexDirection: "row",
alignItems: "center",
height: 20,
gap: 4,
flexShrink: 0,
},
@@ -29,6 +29,7 @@ export function DraggableList<T>({
useDragHandle: _useDragHandle = false,
refreshing,
onRefresh,
extraData,
simultaneousGestureRef,
waitFor,
onDragBegin: onDragBeginProp,
@@ -116,6 +117,7 @@ export function DraggableList<T>({
ListEmptyComponent={ListEmptyComponent}
showsVerticalScrollIndicator={showsVerticalScrollIndicator}
scrollEnabled={scrollEnabled}
extraData={extraData}
simultaneousHandlers={simultaneousHandlers}
// Higher activation distance reduces accidental drag capture while nested
// lists are inside a scroll container.
@@ -46,6 +46,8 @@ export interface DraggableListProps<T> {
onRefresh?: () => void;
/** Fill remaining space when content is smaller than container */
contentContainerFlexGrow?: boolean;
/** External row state that should invalidate virtualized native cells. */
extraData?: unknown;
/** Gesture ref for simultaneous handling with parent gestures (e.g., sidebar close) */
simultaneousGestureRef?: MutableRefObject<GestureType | undefined>;
/** Gesture ref(s) that the list should wait for before handling scroll */
@@ -135,6 +135,7 @@ export function DraggableList<T>({
showsVerticalScrollIndicator = true,
enableDesktopWebScrollbar = false,
scrollEnabled = true,
extraData: _extraData,
useDragHandle = false,
// simultaneousGestureRef is native-only, ignored on web
onDragBegin,
@@ -23,6 +23,11 @@ import {
type ExplorerTab,
} from "@/stores/panel-store";
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
import {
MOBILE_VISUAL_PANEL_AGENT,
MOBILE_VISUAL_PANEL_FILE_EXPLORER,
useSidebarAnimation,
} from "@/contexts/sidebar-animation-context";
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
import { GitDiffPane } from "@/git/diff-pane";
import { FileExplorerPane } from "./file-explorer-pane";
@@ -63,6 +68,8 @@ export function ExplorerSidebar({
const { width: viewportWidth } = useWindowDimensions();
const closeTouchStartX = useSharedValue(0);
const closeTouchStartY = useSharedValue(0);
const { mobileVisualPanel, gestureAnimatingRef: mobilePanelGestureAnimatingRef } =
useSidebarAnimation();
const { style: mobileKeyboardInsetStyle } = useKeyboardShiftStyle({
mode: "padding",
@@ -114,10 +121,11 @@ export function ExplorerSidebar({
const handleCloseFromGesture = useCallback(() => {
gestureAnimatingRef.current = true;
mobilePanelGestureAnimatingRef.current = true;
showMobileAgent();
}, [gestureAnimatingRef, showMobileAgent]);
}, [gestureAnimatingRef, mobilePanelGestureAnimatingRef, showMobileAgent]);
const enableSidebarCloseGesture = isMobile && isOpen;
const enableSidebarCloseGesture = isMobile;
const handleTabPress = useCallback(
(tab: ExplorerTab) => {
@@ -158,6 +166,11 @@ export function ExplorerSidebar({
const absDeltaX = Math.abs(deltaX);
const absDeltaY = Math.abs(deltaY);
if (mobileVisualPanel.value !== MOBILE_VISUAL_PANEL_FILE_EXPLORER) {
stateManager.fail();
return;
}
// Fail quickly on clear leftward or vertical intent so child views keep control.
if (deltaX <= -10) {
stateManager.fail();
@@ -193,9 +206,11 @@ export function ExplorerSidebar({
windowWidth,
});
if (shouldClose) {
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT;
animateToClose();
runOnJS(handleCloseFromGesture)();
} else {
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_FILE_EXPLORER;
animateToOpen();
}
})
@@ -207,6 +222,7 @@ export function ExplorerSidebar({
windowWidth,
translateX,
backdropOpacity,
mobileVisualPanel,
animateToOpen,
animateToClose,
handleCloseFromGesture,
+6 -1
View File
@@ -34,6 +34,7 @@ import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
import { isWeb } from "@/constants/platform";
import { createMarkdownStyles } from "@/styles/markdown-styles";
import { getMarkdownListMarker, getMarkdownListSpacing } from "@/utils/markdown-list";
import { markdownNodeContainsType } from "@/utils/markdown-ast";
import type { AttachmentMetadata } from "@/attachments/types";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { persistAttachmentFromBytes } from "@/attachments/service";
@@ -395,7 +396,11 @@ function createFilePreviewMarkdownRules(): RenderRules {
_parent: ASTNode[],
styles: MarkdownStyles,
) => (
<MarkdownParagraphView key={node.key} paragraphStyle={styles.paragraph}>
<MarkdownParagraphView
key={node.key}
paragraphStyle={styles.paragraph}
containsImage={markdownNodeContainsType(node, "image")}
>
{children}
</MarkdownParagraphView>
),
+80 -17
View File
@@ -1,5 +1,5 @@
import { router, usePathname } from "expo-router";
import { FolderPlus, Home, MessagesSquare, Search, Settings, X } from "lucide-react-native";
import { FolderPlus, Home, MessagesSquare, Plus, Search, Settings, X } from "lucide-react-native";
import {
type Dispatch,
memo,
@@ -40,7 +40,11 @@ import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import {
MOBILE_VISUAL_PANEL_AGENT,
MOBILE_VISUAL_PANEL_AGENT_LIST,
useSidebarAnimation,
} from "@/contexts/sidebar-animation-context";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model";
@@ -62,6 +66,7 @@ import { formatConnectionStatus } from "@/utils/daemons";
import { useWindowControlsPadding } from "@/utils/desktop-window";
import {
buildHostOpenProjectRoute,
buildHostNewWorkspaceRoute,
buildHostSessionsRoute,
buildSettingsRoute,
mapPathnameToServer,
@@ -98,6 +103,7 @@ interface SidebarSharedProps {
toggleProjectCollapsed: SidebarShortcutModel["toggleProjectCollapsed"];
handleRefresh: () => void;
handleHostSelect: (nextServerId: string) => void;
handleNewWorkspaceNavigate: () => void;
handleOpenProject: () => void;
handleHome: () => void;
handleSettings: () => void;
@@ -113,7 +119,7 @@ interface MobileSidebarProps extends SidebarSharedProps {
insetsTop: number;
insetsBottom: number;
isOpen: boolean;
closeToAgent: () => void;
closeSidebar: () => void;
handleViewMoreNavigate: () => void;
}
@@ -224,6 +230,11 @@ export const LeftSidebar = memo(function LeftSidebar({
void openProjectPicker();
}, [openProjectPicker]);
const handleNewWorkspaceNavigate = useCallback(() => {
if (!activeServerId) return;
router.push(buildHostNewWorkspaceRoute(activeServerId));
}, [activeServerId]);
const handleSettingsMobile = useCallback(() => {
showMobileAgent();
router.push(buildSettingsRoute());
@@ -292,7 +303,8 @@ export const LeftSidebar = memo(function LeftSidebar({
insetsTop={insets.top}
insetsBottom={insets.bottom}
isOpen={isOpen}
closeToAgent={showMobileAgent}
closeSidebar={showMobileAgent}
handleNewWorkspaceNavigate={handleNewWorkspaceNavigate}
handleOpenProject={handleOpenProjectMobile}
handleHome={handleHomeMobile}
handleSettings={handleSettingsMobile}
@@ -306,6 +318,7 @@ export const LeftSidebar = memo(function LeftSidebar({
{...sharedProps}
insetsTop={insets.top}
isOpen={isOpen}
handleNewWorkspaceNavigate={handleNewWorkspaceNavigate}
handleOpenProject={handleOpenProjectDesktop}
handleHome={handleHomeDesktop}
handleSettings={handleSettingsDesktop}
@@ -549,13 +562,14 @@ function MobileSidebar({
handleRefresh,
handleHostSelect,
renderHostOption,
handleNewWorkspaceNavigate,
handleOpenProject,
handleHome,
handleSettings,
insetsTop,
insetsBottom,
isOpen,
closeToAgent,
closeSidebar,
handleViewMoreNavigate,
}: MobileSidebarProps) {
const pathname = usePathname();
@@ -567,6 +581,7 @@ function MobileSidebar({
animateToOpen,
animateToClose,
isGesturing,
mobileVisualPanel,
gestureAnimatingRef,
closeGestureRef,
} = useSidebarAnimation();
@@ -575,8 +590,8 @@ function MobileSidebar({
const handleCloseFromGesture = useCallback(() => {
gestureAnimatingRef.current = true;
closeToAgent();
}, [closeToAgent, gestureAnimatingRef]);
closeSidebar();
}, [closeSidebar, gestureAnimatingRef]);
const handleViewMore = useCallback(() => {
if (!activeServerId) {
@@ -584,26 +599,31 @@ function MobileSidebar({
}
translateX.value = -windowWidth;
backdropOpacity.value = 0;
closeToAgent();
closeSidebar();
handleViewMoreNavigate();
}, [
activeServerId,
backdropOpacity,
closeToAgent,
closeSidebar,
handleViewMoreNavigate,
translateX,
windowWidth,
]);
const handleWorkspacePress = useCallback(() => {
closeToAgent();
}, [closeToAgent]);
closeSidebar();
}, [closeSidebar]);
const handleNewWorkspace = useCallback(() => {
closeSidebar();
handleNewWorkspaceNavigate();
}, [closeSidebar, handleNewWorkspaceNavigate]);
const closeGesture = useMemo(
() =>
Gesture.Pan()
.withRef(closeGestureRef)
.enabled(isOpen)
.enabled(true)
.manualActivation(true)
.onTouchesDown((event) => {
const touch = event.changedTouches[0];
@@ -625,6 +645,11 @@ function MobileSidebar({
const absDeltaX = Math.abs(deltaX);
const absDeltaY = Math.abs(deltaY);
if (mobileVisualPanel.value !== MOBILE_VISUAL_PANEL_AGENT_LIST) {
stateManager.fail();
return;
}
if (deltaX >= 10) {
stateManager.fail();
return;
@@ -654,9 +679,11 @@ function MobileSidebar({
isGesturing.value = false;
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500;
if (shouldClose) {
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT;
animateToClose();
runOnJS(handleCloseFromGesture)();
} else {
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT_LIST;
animateToOpen();
}
})
@@ -664,11 +691,11 @@ function MobileSidebar({
isGesturing.value = false;
}),
[
isOpen,
closeGestureRef,
closeTouchStartX,
closeTouchStartY,
isGesturing,
mobileVisualPanel,
windowWidth,
translateX,
backdropOpacity,
@@ -732,10 +759,13 @@ function MobileSidebar({
testID="sidebar-sessions"
/>
</View>
<WorkspacesSectionHeader serverId={activeServerId} />
<WorkspacesSectionHeader
serverId={activeServerId}
onNewWorkspacePress={handleNewWorkspace}
/>
<Pressable
style={styles.mobileCloseButton}
onPress={closeToAgent}
onPress={closeSidebar}
testID="sidebar-close"
nativeID="sidebar-close"
accessible
@@ -813,6 +843,7 @@ function DesktopSidebar({
handleRefresh,
handleHostSelect,
renderHostOption,
handleNewWorkspaceNavigate,
handleOpenProject,
handleHome,
handleSettings,
@@ -900,7 +931,10 @@ function DesktopSidebar({
/>
</View>
</View>
<WorkspacesSectionHeader serverId={activeServerId} />
<WorkspacesSectionHeader
serverId={activeServerId}
onNewWorkspacePress={handleNewWorkspaceNavigate}
/>
{isInitialLoad ? (
<SidebarAgentListSkeleton />
@@ -945,7 +979,13 @@ function DesktopSidebar({
);
}
function WorkspacesSectionHeader({ serverId }: { serverId: string | null }) {
function WorkspacesSectionHeader({
serverId,
onNewWorkspacePress,
}: {
serverId: string | null;
onNewWorkspacePress: () => void;
}) {
const { theme } = useUnistyles();
const setCommandCenterOpen = useKeyboardShortcutsStore((state) => state.setCommandCenterOpen);
const commandCenterKeys = useShortcutKeys("toggle-command-center");
@@ -962,6 +1002,29 @@ function WorkspacesSectionHeader({ serverId }: { serverId: string | null }) {
<View style={styles.workspacesSectionHeader}>
<Text style={styles.workspacesSectionTitle}>Workspaces</Text>
<View style={styles.workspacesSectionActions}>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel="New workspace"
testID="sidebar-new-workspace"
style={searchButtonStyle}
onPress={onNewWorkspacePress}
>
{({ hovered, pressed }) => (
<Plus
size={14}
color={
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted
}
/>
)}
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<HeaderIconTooltipContent label="New workspace" />
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { StyleSheet, type TextStyle } from "react-native";
import { resolvePlainMarkdownTextStyle } from "@/components/markdown-text-style";
function unistylesStyle(id: string, style: Record<string, unknown>) {
return {
...style,
[`unistyles_${id}`]: { id },
};
}
function uiTextViewFlatten(rootStyle: TextStyle, style: TextStyle): Record<string, unknown> {
// react-native-uitextview/src/util.ts:8 flattens [rootStyle, style]
// before passing the result to its native View-backed components.
return { ...(StyleSheet.flatten([rootStyle, style]) as Record<string, unknown>) };
}
function unistylesMetadataKeys(style: Record<string, unknown>) {
return Object.keys(style).filter((key) => key.startsWith("unistyles_"));
}
describe("resolvePlainMarkdownTextStyle", () => {
it("keeps UITextView from collapsing parent and child Unistyles styles into one native View style object", () => {
const merged = uiTextViewFlatten(
resolvePlainMarkdownTextStyle(unistylesStyle("paragraph", { color: "#111" })),
resolvePlainMarkdownTextStyle(unistylesStyle("text", { fontWeight: "600" })),
);
expect(unistylesMetadataKeys(merged)).toHaveLength(0);
expect(merged).toMatchObject({ color: "#111", fontWeight: "600" });
});
});
@@ -0,0 +1,25 @@
import { StyleSheet, type StyleProp, type TextStyle } from "react-native";
export function resolvePlainMarkdownTextStyle(style: StyleProp<TextStyle>): TextStyle {
return stripUnistylesMetadata(StyleSheet.flatten(style) ?? {});
}
function stripUnistylesMetadata(style: TextStyle): TextStyle {
// iOS markdown text goes through react-native-uitextview. That library
// inherits text styles by flattening [parentStyle, childStyle] before handing
// the result to native View-backed components. If both entries are Unistyles
// styles, flattening preserves both `unistyles_*` metadata keys in one object,
// and Unistyles correctly warns that the style should have stayed array-shaped.
//
// `UITextView` is a third-party boundary, not a Unistyles-tracked component in
// our ownership model. Resolve the concrete style values before crossing that
// boundary and drop only Unistyles' private tracking metadata. This preserves
// iOS paragraph/spanning text selection while avoiding the metadata merge.
const plainStyle: Record<string, unknown> = { ...style };
for (const key of Object.keys(plainStyle)) {
if (key.startsWith("unistyles_")) {
delete plainStyle[key];
}
}
return plainStyle as TextStyle;
}
@@ -35,6 +35,7 @@ export function MarkdownTextSpan({
interface MarkdownParagraphViewProps {
paragraphStyle: ViewStyle;
containsImage?: boolean;
children: ReactNode;
}
@@ -1,6 +1,7 @@
import { useMemo, type ReactNode } from "react";
import type { StyleProp, TextProps, TextStyle, ViewStyle } from "react-native";
import { View, type StyleProp, type TextProps, type TextStyle, type ViewStyle } from "react-native";
import { UITextView } from "react-native-uitextview";
import { resolvePlainMarkdownTextStyle } from "@/components/markdown-text-style";
interface MarkdownTextSpanProps {
style?: StyleProp<TextStyle>;
@@ -8,10 +9,11 @@ interface MarkdownTextSpanProps {
children: ReactNode;
// Links route through this span too (see assistant-file-links/link.tsx). A
// plain <Text> nested in the paragraph UITextView is dropped, so the link
// must be a UITextView span to be visible. onPress is forwarded best-effort:
// react-native-uitextview nulls onPress on the root native view, so reliable
// tap-to-open is still tracked by #21 — but visible+selectable text beats an
// invisible link.
// must be a UITextView span to be visible. onPress is wired onto the leaf
// string children here: react-native-uitextview attaches it to the
// RNUITextViewChild nodes it builds from string content, which the native tap
// recognizer dispatches to. The link's handler reaches these leaf spans via
// AssistantLinkPressProvider (see assistant-file-links/link-press-context).
onPress?: TextProps["onPress"];
accessibilityRole?: TextProps["accessibilityRole"];
}
@@ -26,11 +28,13 @@ export function MarkdownTextSpan({
onPress,
accessibilityRole,
}: MarkdownTextSpanProps) {
const plainStyle = useMemo(() => resolvePlainMarkdownTextStyle(style), [style]);
return (
<UITextView
uiTextView
selectable
style={style}
style={plainStyle}
onPress={onPress}
accessibilityRole={accessibilityRole}
>
@@ -41,6 +45,7 @@ export function MarkdownTextSpan({
interface MarkdownParagraphViewProps {
paragraphStyle: ViewStyle;
containsImage?: boolean;
children: ReactNode;
}
@@ -52,13 +57,27 @@ const MARKDOWN_PARAGRAPH_RESET: ViewStyle = { marginBottom: 0 };
// ViewStyle is structurally compatible with the layout props paragraphs use
// (margin, padding, alignment); the cast lets the existing paragraphStyle
// flow through unchanged.
export function MarkdownParagraphView({ paragraphStyle, children }: MarkdownParagraphViewProps) {
const style = useMemo(
() => [paragraphStyle, MARKDOWN_PARAGRAPH_RESET] as StyleProp<TextStyle>,
export function MarkdownParagraphView({
paragraphStyle,
containsImage = false,
children,
}: MarkdownParagraphViewProps) {
const textStyle = useMemo(
() =>
resolvePlainMarkdownTextStyle([
paragraphStyle,
MARKDOWN_PARAGRAPH_RESET,
] as StyleProp<TextStyle>),
[paragraphStyle],
);
const viewStyle = useMemo(() => [paragraphStyle, MARKDOWN_PARAGRAPH_RESET], [paragraphStyle]);
if (containsImage) {
return <View style={viewStyle}>{children}</View>;
}
return (
<UITextView uiTextView selectable style={style}>
<UITextView uiTextView selectable style={textStyle}>
{children}
</UITextView>
);
@@ -46,6 +46,7 @@ export function MarkdownTextSpan({
interface MarkdownParagraphViewProps {
paragraphStyle: ViewStyle;
containsImage?: boolean;
children: ReactNode;
}
+19 -2
View File
@@ -70,6 +70,7 @@ import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
import { buildToolCallPresentation } from "@/tool-calls/presentation";
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
import { getMarkdownListMarker, getMarkdownListSpacing } from "@/utils/markdown-list";
import { markdownNodeContainsType } from "@/utils/markdown-ast";
import { useStableEvent } from "@/hooks/use-stable-event";
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
@@ -99,6 +100,7 @@ import {
AssistantMarkdownLink,
type InlinePathTarget,
useAssistantFileLinkActions,
useAssistantLinkPress,
} from "@/assistant-file-links";
import { getCompactionMarkerLabel } from "./message-compaction-label";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
@@ -1531,8 +1533,19 @@ function MarkdownInheritedText({
() => [inheritedStyles, textStyle, overrideStyle],
[inheritedStyles, textStyle, overrideStyle],
);
// When this span renders link label text on iOS, pick up the link's press
// handler from context and hand it to MarkdownTextSpan, which forwards it to
// the leaf string children react-native-uitextview makes tappable. Null
// outside a link (and on every other platform, where no provider mounts), so
// ordinary text is unaffected. See assistant-file-links/link-press-context.
const linkPress = useAssistantLinkPress();
return (
<MarkdownTextSpan monoSurface={monoSurface} style={style}>
<MarkdownTextSpan
monoSurface={monoSurface}
style={style}
onPress={linkPress?.onPress}
accessibilityRole={linkPress?.accessibilityRole}
>
{children}
</MarkdownTextSpan>
);
@@ -1825,7 +1838,11 @@ export const AssistantMessage = memo(function AssistantMessage({
_parent: ASTNode[],
styles: MarkdownStyles,
) => (
<MarkdownParagraphView key={node.key} paragraphStyle={styles.paragraph}>
<MarkdownParagraphView
key={node.key}
paragraphStyle={styles.paragraph}
containsImage={markdownNodeContainsType(node, "image")}
>
{children}
</MarkdownParagraphView>
),
@@ -16,6 +16,7 @@ import {
} from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
@@ -195,6 +196,7 @@ function AddCustomModelSubSheet({
onClose={onClose}
desktopMaxWidth={420}
snapPoints={ADD_SNAP_POINTS}
testID="add-custom-model-sheet"
>
<View style={sheetStyles.formGroup}>
<Text style={sheetStyles.formLabel}>Model ID</Text>
@@ -338,6 +340,7 @@ function DiagnosticSubSheet({
onClose={onClose}
snapPoints={DIAGNOSTIC_SNAP_POINTS}
scrollable={false}
testID="provider-diagnostic-sheet"
>
<View style={DIAGNOSTIC_CARD_STYLE}>{body}</View>
</AdaptiveModalSheet>
@@ -359,6 +362,69 @@ interface ProviderModalBodyProps {
theme: { iconSize: { md: number }; colors: { foregroundMuted: string } };
}
interface ProviderSheetFooterInput {
fetchedAtLabel: string | null;
isCompact: boolean;
modelsRefreshing: boolean;
onOpenAddSheet: () => void;
onOpenDiagSheet: () => void;
onRefreshModels: () => void;
}
function renderProviderSheetFooter({
fetchedAtLabel,
isCompact,
modelsRefreshing,
onOpenAddSheet,
onOpenDiagSheet,
onRefreshModels,
}: ProviderSheetFooterInput) {
const contentStyle = isCompact ? sheetStyles.compactFooterContent : sheetStyles.footerContent;
const actionsStyle = isCompact ? sheetStyles.compactFooterActions : sheetStyles.footerActions;
const buttonStyle = isCompact ? sheetStyles.compactFooterButton : null;
const metaStyle = isCompact ? COMPACT_FOOTER_META_STYLE : sheetStyles.footerMeta;
return (
<View style={contentStyle}>
{fetchedAtLabel || !isCompact ? (
<Text style={metaStyle} numberOfLines={1}>
{fetchedAtLabel ? `Updated ${fetchedAtLabel}` : ""}
</Text>
) : null}
<View style={actionsStyle}>
<Button
variant="secondary"
size="sm"
leftIcon={Plus}
onPress={onOpenAddSheet}
style={buttonStyle}
>
Add model
</Button>
<Button
variant="secondary"
size="sm"
leftIcon={FileText}
onPress={onOpenDiagSheet}
style={buttonStyle}
>
Diagnostic
</Button>
<Button
variant="default"
size="sm"
leftIcon={modelsRefreshing ? undefined : RotateCw}
onPress={onRefreshModels}
disabled={modelsRefreshing}
style={buttonStyle}
>
{modelsRefreshing ? "Refreshing…" : "Refresh"}
</Button>
</View>
</View>
);
}
function ProviderModalBody(props: ProviderModalBodyProps) {
const {
discoveredCount,
@@ -446,6 +512,7 @@ export function ProviderDiagnosticSheet({
serverId,
}: ProviderDiagnosticSheetProps) {
const { theme } = useUnistyles();
const isCompact = useIsCompactFormFactor();
const { entries: snapshotEntries, refresh, isRefreshing } = useProvidersSnapshot(serverId);
const { config, patchConfig } = useDaemonConfig(serverId);
const [query, setQuery] = useState("");
@@ -545,38 +612,21 @@ export function ProviderDiagnosticSheet({
[providerLabel],
);
const footer = (
<>
<Text style={sheetStyles.footerMeta} numberOfLines={1}>
{fetchedAtLabel ? `Updated ${fetchedAtLabel}` : ""}
</Text>
<View style={sheetStyles.footerActions}>
<Button variant="secondary" size="sm" leftIcon={Plus} onPress={handleOpenAddSheet}>
Add model
</Button>
<Button variant="secondary" size="sm" leftIcon={FileText} onPress={handleOpenDiagSheet}>
Diagnostic
</Button>
<Button
variant="default"
size="sm"
leftIcon={modelsRefreshing ? undefined : RotateCw}
onPress={handleRefreshModels}
disabled={modelsRefreshing}
>
{modelsRefreshing ? "Refreshing…" : "Refresh"}
</Button>
</View>
</>
);
return (
<>
<AdaptiveModalSheet
header={sheetHeader}
visible={visible}
onClose={onClose}
footer={footer}
testID="provider-settings-sheet"
footer={renderProviderSheetFooter({
fetchedAtLabel,
isCompact,
modelsRefreshing,
onOpenAddSheet: handleOpenAddSheet,
onOpenDiagSheet: handleOpenDiagSheet,
onRefreshModels: handleRefreshModels,
})}
snapPoints={MAIN_SNAP_POINTS}
>
<ProviderModalBody
@@ -692,16 +742,36 @@ const sheetStyles = StyleSheet.create((theme) => ({
alignItems: "center",
gap: theme.spacing[3],
},
footerContent: {
flex: 1,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
},
compactFooterContent: {
flex: 1,
gap: theme.spacing[2],
},
footerMeta: {
flex: 1,
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
compactFooterMeta: {
flex: 0,
},
footerActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
compactFooterActions: {
gap: theme.spacing[2],
},
compactFooterButton: {
alignSelf: "stretch",
},
formGroup: {
gap: theme.spacing[3],
},
@@ -741,6 +811,7 @@ const sheetStyles = StyleSheet.create((theme) => ({
}));
const FORM_INPUT_STYLE = [sheetStyles.formInput, isWeb && { outlineStyle: "none" }];
const COMPACT_FOOTER_META_STYLE = [sheetStyles.footerMeta, sheetStyles.compactFooterMeta];
const MAIN_SNAP_POINTS = ["65%", "92%"];
const ADD_SNAP_POINTS = ["40%"];
@@ -5,6 +5,7 @@ import { useProviderSettingsStore } from "@/stores/provider-settings-store";
export function ProviderSettingsHost() {
const serverId = useProviderSettingsStore((state) => state.serverId);
const provider = useProviderSettingsStore((state) => state.provider);
const visible = useProviderSettingsStore((state) => state.visible);
const close = useProviderSettingsStore((state) => state.close);
const handleClose = useCallback(() => {
@@ -19,7 +20,7 @@ export function ProviderSettingsHost() {
<ProviderDiagnosticSheet
provider={provider}
serverId={serverId}
visible
visible={visible}
onClose={handleClose}
/>
);
@@ -76,4 +76,27 @@ describe("question form card core", () => {
Response: "custom",
});
});
test("shows text input for questions that allow other answers", () => {
const questions = parseQuestionFormQuestions({
questions: [
{
question: "Pick or type",
header: "Response",
options: [{ label: "A" }],
allowOther: true,
multiSelect: false,
},
],
});
if (!questions) throw new Error("questions did not parse");
const [question] = questions;
if (!question) throw new Error("question missing");
expect(questionShowsTextInput(question)).toBe(true);
expect(areQuestionsAnswered(questions, {}, { 0: "custom" })).toBe(true);
expect(buildQuestionFormAnswers(questions, {}, { 0: "custom" })).toEqual({
Response: "custom",
});
});
});
@@ -11,7 +11,7 @@ import {
type ViewStyle,
} from "react-native";
import * as Haptics from "expo-haptics";
import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { slugify, validateBranchSlug, MAX_SLUG_LENGTH } from "@getpaseo/protocol/branch-slug";
import { ProjectIconView } from "@/components/project-icon-view";
import { AdaptiveRenameModal } from "@/components/rename-modal";
@@ -48,9 +48,7 @@ import {
ExternalLink,
FolderPlus,
GitPullRequest,
Globe,
Settings,
SquareTerminal,
MoreVertical,
Pencil,
Plus,
@@ -59,16 +57,16 @@ import {
import { NestableScrollContainer } from "react-native-draggable-flatlist";
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
import type { DraggableListDragHandleProps } from "./draggable-list.types";
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useIsCompactFormFactor } from "@/constants/layout";
import { projectIconQueryKey, projectIconToDataUri } from "@/hooks/use-project-icon-query";
import { useProjectIconDataByProjectKey } from "@/projects/project-icons";
import {
buildHostNewWorkspaceRoute,
buildProjectSettingsRoute,
parseHostWorkspaceRouteFromPathname,
} from "@/utils/host-routes";
import {
createSidebarWorkspaceEntry,
useSidebarWorkspaceEntry,
type SidebarProjectEntry,
type SidebarWorkspaceEntry,
} from "@/hooks/use-sidebar-workspaces-list";
@@ -107,8 +105,8 @@ import {
SidebarWorkspaceTrailingActionSlot,
} from "@/components/sidebar/sidebar-workspace-row-content";
import {
useHydratedWorkspaceEntries,
useProjectNamesMap,
useStatusModeWorkspaceEntries,
} from "@/hooks/use-status-mode-workspaces";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@@ -119,8 +117,6 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attention";
import type { PrHint } from "@/git/use-pr-status-query";
import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import { useWorkspaceFields } from "@/stores/session-store-hooks";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import { openExternalUrl } from "@/utils/open-external-url";
import {
@@ -132,7 +128,12 @@ import {
archiveWorkspaceOptimistically,
archiveWorkspacesOptimistically,
} from "@/workspace/workspace-archive";
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
import {
isWeb as platformIsWeb,
isNative as platformIsNative,
getIsElectron,
} from "@/constants/platform";
import { getDesktopHost } from "@/desktop/host";
const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) => workspace.workspaceKey;
@@ -150,8 +151,6 @@ const ThemedCircleAlert = withUnistyles(CircleAlert);
const ThemedCircleCheck = withUnistyles(CircleCheck);
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
const ThemedFolderPlus = withUnistyles(FolderPlus);
const ThemedGlobe = withUnistyles(Globe);
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
const ThemedMoreVertical = withUnistyles(MoreVertical);
const ThemedTrash2 = withUnistyles(Trash2);
const ThemedSettings = withUnistyles(Settings);
@@ -159,15 +158,24 @@ const ThemedCopy = withUnistyles(Copy);
const ThemedArchive = withUnistyles(Archive);
const ThemedPencil = withUnistyles(Pencil);
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundColorMapping = (theme: Theme) => ({
color: theme.colors.foreground,
});
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
const redColorMapping = (theme: Theme) => ({ color: theme.colors.palette.red[500] });
const amberColorMapping = (theme: Theme) => ({ color: theme.colors.palette.amber[500] });
const blueColorMapping = (theme: Theme) => ({ color: theme.colors.palette.blue[500] });
const greenColorMapping = (theme: Theme) => ({ color: theme.colors.palette.green[500] });
const purpleColorMapping = (theme: Theme) => ({ color: theme.colors.palette.purple[500] });
const redColorMapping = (theme: Theme) => ({
color: theme.colors.palette.red[500],
});
const amberColorMapping = (theme: Theme) => ({
color: theme.colors.palette.amber[500],
});
const greenColorMapping = (theme: Theme) => ({
color: theme.colors.palette.green[500],
});
const purpleColorMapping = (theme: Theme) => ({
color: theme.colors.palette.purple[500],
});
const syncedLoaderColorMapping = (theme: Theme) => ({
color:
theme.colorScheme === "light"
@@ -186,17 +194,6 @@ function getPrIconUniMapping(state: PrHint["state"]) {
}
}
function useStableProjectIconData(
data: (string | null)[],
signature: string,
): readonly (string | null)[] {
const stableRef = useRef<{ signature: string; data: (string | null)[] } | null>(null);
if (stableRef.current?.signature !== signature) {
stableRef.current = { signature, data };
}
return stableRef.current.data;
}
function isWorkspaceSelected(input: {
selection: ActiveWorkspaceSelection | null;
serverId: string | null;
@@ -225,6 +222,10 @@ function isProjectSelectedByRoute(input: {
);
}
function activeWorkspaceSelectionKey(selection: ActiveWorkspaceSelection | null): string {
return selection ? `${selection.serverId}:${selection.workspaceId}` : "";
}
function selectionForSelectedWorkspace(
selected: boolean,
workspace: SidebarWorkspaceEntry,
@@ -305,19 +306,6 @@ function getWorkspaceArchiveStatus(
return "idle";
}
export function useSidebarWorkspaceEntry(
serverId: string | null,
workspaceId: string | null,
): SidebarWorkspaceEntry | null {
const projectWorkspaceEntry = useCallback(
(workspace: WorkspaceDescriptor): SidebarWorkspaceEntry =>
createSidebarWorkspaceEntry({ serverId: serverId ?? "", workspace }),
[serverId],
);
return useWorkspaceFields(serverId, workspaceId, projectWorkspaceEntry);
}
export function PrBadge({ hint }: { hint: PrHint }) {
const [isHovered, setIsHovered] = useState(false);
@@ -542,6 +530,7 @@ function ProjectRowTrailingActions({
>
<ProjectKebabMenu
projectKey={project.projectKey}
projectPath={project.iconWorkingDir}
onRemoveProject={onRemoveProject}
removeProjectStatus={removeProjectStatus}
/>
@@ -559,6 +548,9 @@ const markAsReadLeadingIcon = (
);
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
const renameLeadingIcon = <ThemedPencil size={14} uniProps={foregroundMutedColorMapping} />;
const openInNewWindowLeadingIcon = (
<ThemedExternalLink size={14} uniProps={foregroundMutedColorMapping} />
);
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
return (
@@ -571,18 +563,35 @@ function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
function ProjectKebabMenu({
projectKey,
projectPath,
onRemoveProject,
removeProjectStatus,
}: {
projectKey: string;
projectPath: string;
onRemoveProject: () => void;
removeProjectStatus: "idle" | "pending" | "success";
}) {
const toast = useToast();
const handleOpenProjectSettings = useCallback(() => {
if (projectKey.trim().length === 0) return;
router.navigate(buildProjectSettingsRoute(projectKey));
}, [projectKey]);
const canOpenProjectSettings = projectKey.trim().length > 0;
// Desktop-only: open a second window that lands on this project via the same
// open-project flow as a CLI launch. The project stays visible here too — no
// ownership, no move.
const canOpenInNewWindow = getIsElectron() && projectPath.trim().length > 0;
const handleOpenInNewWindow = useCallback(() => {
const trimmedPath = projectPath.trim();
if (trimmedPath.length === 0) return;
void getDesktopHost()
?.window?.openNew?.({ pendingOpenProjectPath: trimmedPath })
?.catch((error) => {
console.warn("[sidebar] openNew failed", error);
toast.error("Couldn't open a new window");
});
}, [projectPath, toast]);
return (
<DropdownMenu>
<DropdownMenuTrigger
@@ -604,6 +613,15 @@ function ProjectKebabMenu({
Open project settings
</DropdownMenuItem>
) : null}
{canOpenInNewWindow ? (
<DropdownMenuItem
testID={`sidebar-project-menu-open-new-window-${projectKey}`}
leading={openInNewWindowLeadingIcon}
onSelect={handleOpenInNewWindow}
>
Open in new window
</DropdownMenuItem>
) : null}
<DropdownMenuItem
testID={`sidebar-project-menu-remove-${projectKey}`}
leading={trash2LeadingIcon}
@@ -622,8 +640,6 @@ function WorkspaceRowRightGroup({
workspace,
isHovered,
isTouchPlatform,
showScriptsIcon,
hasRunningService,
isCreating,
showShortcutBadge,
shortcutNumber,
@@ -640,8 +656,6 @@ function WorkspaceRowRightGroup({
workspace: SidebarWorkspaceEntry;
isHovered: boolean;
isTouchPlatform: boolean;
showScriptsIcon: boolean;
hasRunningService: boolean;
isCreating: boolean;
showShortcutBadge: boolean;
shortcutNumber: number | null;
@@ -661,15 +675,6 @@ function WorkspaceRowRightGroup({
const shouldRenderActionSlot = Boolean(onArchive || workspace.diffStat);
return (
<>
{showScriptsIcon ? (
<View testID="workspace-globe-icon" accessibilityLabel="Scripts available">
{hasRunningService ? (
<ThemedGlobe size={12} uniProps={blueColorMapping} />
) : (
<ThemedSquareTerminal size={12} uniProps={blueColorMapping} />
)}
</View>
) : null}
{isCreating ? <Text style={styles.workspaceCreatingText}>Creating...</Text> : null}
{shouldRenderActionSlot ? (
<SidebarWorkspaceTrailingActionSlot>
@@ -1211,17 +1216,14 @@ function ProjectHeaderRow({
if (!serverId) {
return;
}
onWorkspacePress?.();
router.navigate(
buildHostNewWorkspaceRoute(serverId, project.iconWorkingDir, {
displayName,
projectId: project.projectKey,
}) as Href,
);
onWorkspacePress?.();
}, [displayName, onWorkspacePress, project.iconWorkingDir, project.projectKey, serverId]);
const _mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
const _toast = useToast();
const interaction = useLongPressDragInteraction({
drag,
menuController,
@@ -1393,6 +1395,10 @@ function WorkspaceRowInner({
const hasRunningService = workspace.scripts.some(
(s) => s.lifecycle === "running" && (s.type ?? "service") === "service",
);
let scriptIconKind: "service" | "command" | null = null;
if (showScriptsIcon) {
scriptIconKind = hasRunningService ? "service" : "command";
}
const workspaceRowStyle = getProjectWorkspaceRowStyle({
isDragging,
selected,
@@ -1420,6 +1426,7 @@ function WorkspaceRowInner({
>
<SidebarWorkspaceRowContent
workspace={workspace}
scriptIconKind={scriptIconKind}
isHovered={isHovered}
isLoading={isArchiving || isCreating}
isCreating={isCreating}
@@ -1430,8 +1437,6 @@ function WorkspaceRowInner({
workspace={workspace}
isHovered={isHovered}
isTouchPlatform={isTouchPlatform}
showScriptsIcon={showScriptsIcon}
hasRunningService={hasRunningService}
isCreating={isCreating}
showShortcutBadge={showShortcutBadge}
shortcutNumber={shortcutNumber}
@@ -2314,6 +2319,7 @@ function ProjectBlock({
keyExtractor={workspaceKeyExtractor}
renderItem={renderWorkspace}
onDragEnd={handleWorkspaceDragEnd}
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
scrollEnabled={false}
useDragHandle
nestable={useNestable}
@@ -2330,18 +2336,6 @@ function ProjectBlock({
type ProjectBlockProps = Parameters<typeof ProjectBlock>[0];
function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlockProps): boolean {
const previousActive = isProjectSelectedByRoute({
selection: previous.activeWorkspaceSelection,
project: previous.project,
serverId: previous.serverId,
enabled: previous.selectionEnabled,
});
const nextActive = isProjectSelectedByRoute({
selection: next.activeWorkspaceSelection,
project: next.project,
serverId: next.serverId,
enabled: next.selectionEnabled,
});
return (
previous.project === next.project &&
previous.collapsed === next.collapsed &&
@@ -2361,7 +2355,35 @@ function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlo
previous.dragHandleProps === next.dragHandleProps &&
previous.useNestable === next.useNestable &&
previous.creatingWorkspaceIds === next.creatingWorkspaceIds &&
previousActive === nextActive
areProjectBlockSelectionsEqual(previous, next)
);
}
function areProjectBlockSelectionsEqual(
previous: ProjectBlockProps,
next: ProjectBlockProps,
): boolean {
const previousActive = isProjectSelectedByRoute({
selection: previous.activeWorkspaceSelection,
project: previous.project,
serverId: previous.serverId,
enabled: previous.selectionEnabled,
});
const nextActive = isProjectSelectedByRoute({
selection: next.activeWorkspaceSelection,
project: next.project,
serverId: next.serverId,
enabled: next.selectionEnabled,
});
if (previousActive !== nextActive) {
return false;
}
if (!previousActive) {
return true;
}
return (
activeWorkspaceSelectionKey(previous.activeWorkspaceSelection) ===
activeWorkspaceSelectionKey(next.activeWorkspaceSelection)
);
}
@@ -2387,6 +2409,7 @@ export function SidebarWorkspaceList({
return (
<SidebarStatusModeWrapper
serverId={serverId}
projects={projects}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
onWorkspacePress={onWorkspacePress}
/>
@@ -2411,14 +2434,19 @@ export function SidebarWorkspaceList({
function SidebarStatusModeWrapper({
serverId,
projects,
shortcutIndexByWorkspaceKey: _projectShortcutIndex,
onWorkspacePress,
}: {
serverId: string | null;
projects: SidebarProjectEntry[];
shortcutIndexByWorkspaceKey: Map<string, number>;
onWorkspacePress?: () => void;
}) {
const hydratedWorkspaces = useHydratedWorkspaceEntries(serverId);
const hydratedWorkspaces = useStatusModeWorkspaceEntries({
serverId,
projects,
});
const projectNamesByKey = useProjectNamesMap(serverId);
const showShortcutBadges = useShowShortcutBadges();
@@ -2465,75 +2493,24 @@ function ProjectModeList({
);
const selectionEnabled = isWorkspaceRoute;
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const projectIconRequests = useMemo(() => {
if (!serverId) {
return [];
}
const unique = new Map<string, { serverId: string; cwd: string }>();
for (const project of projects) {
const cwd = project.iconWorkingDir.trim();
if (!cwd) {
continue;
}
unique.set(`${serverId}:${cwd}`, { serverId, cwd });
}
return Array.from(unique.values());
}, [projects, serverId]);
const projectIconQueries = useQueries({
queries: projectIconRequests.map((request) => ({
queryKey: projectIconQueryKey(request.serverId, request.cwd),
queryFn: async () => {
const client = getHostRuntimeStore().getClient(request.serverId);
if (!client) {
return null;
}
const result = await client.requestProjectIcon(request.cwd);
return result.icon;
},
select: projectIconToDataUri,
enabled: Boolean(
getHostRuntimeStore().getClient(request.serverId) &&
isHostRuntimeConnected(getHostRuntimeStore().getSnapshot(request.serverId)) &&
request.cwd,
),
staleTime: Infinity,
gcTime: 1000 * 60 * 60,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
})),
});
const projectIconSignature = projectIconQueries.map((query) => query.data ?? "").join("\u0000");
const projectIconData = useStableProjectIconData(
projectIconQueries.map((query) => query.data ?? null),
projectIconSignature,
const nativeScrollGestureProps = useMemo(
() =>
parentGestureRef
? ({
// NestableScrollContainer forwards props to RNGH ScrollView. Keep
// vertical scroll and sidebar close pan simultaneous: vertical
// intent scrolls immediately, clear horizontal intent can still
// activate close from inside the list.
simultaneousHandlers: parentGestureRef,
} as object)
: undefined,
[parentGestureRef],
);
const projectIconByProjectKey = useMemo(() => {
const iconByServerAndCwd = new Map<string, string | null>();
for (let index = 0; index < projectIconRequests.length; index += 1) {
const request = projectIconRequests[index];
if (!request) {
continue;
}
iconByServerAndCwd.set(`${request.serverId}:${request.cwd}`, projectIconData[index] ?? null);
}
const byProject = new Map<string, string | null>();
for (const project of projects) {
const cwd = project.iconWorkingDir.trim();
if (!cwd || !serverId) {
byProject.set(project.projectKey, null);
continue;
}
byProject.set(project.projectKey, iconByServerAndCwd.get(`${serverId}:${cwd}`) ?? null);
}
return byProject;
}, [projectIconData, projectIconRequests, projects, serverId]);
const projectIconByProjectKey = useProjectIconDataByProjectKey({
serverId,
projects,
});
useEffect(() => {
const timeouts = creatingWorkspaceTimeoutsRef.current;
@@ -2724,6 +2701,7 @@ function ProjectModeList({
keyExtractor={projectKeyExtractor}
renderItem={renderProject}
onDragEnd={handleProjectDragEnd}
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
scrollEnabled={false}
useDragHandle
nestable={platformIsNative}
@@ -2739,6 +2717,7 @@ function ProjectModeList({
<View style={styles.container}>
{platformIsNative ? (
<NestableScrollContainer
{...nativeScrollGestureProps}
style={styles.list}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
@@ -21,14 +21,12 @@ import {
CircleDot,
CircleX,
MoreVertical,
SquareTerminal,
Globe,
Copy,
Archive,
Pencil,
} from "lucide-react-native";
import { DiffStat } from "@/components/diff-stat";
import { useSidebarWorkspaceEntry } from "../sidebar-workspace-list";
import { useSidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import {
DropdownMenu,
DropdownMenuTrigger,
@@ -81,8 +79,6 @@ const ThemedCircleAlert = withUnistyles(CircleAlert);
const ThemedCircleCheck = withUnistyles(CircleCheck);
const ThemedCircleDot = withUnistyles(CircleDot);
const ThemedCircleX = withUnistyles(CircleX);
const ThemedGlobe = withUnistyles(Globe);
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
const ThemedMoreVertical = withUnistyles(MoreVertical);
const ThemedCopy = withUnistyles(Copy);
const ThemedArchive = withUnistyles(Archive);
@@ -607,6 +603,10 @@ function StatusWorkspaceRowInner({
const hasRunningService = workspace.scripts.some(
(s) => s.lifecycle === "running" && (s.type ?? "service") === "service",
);
let scriptIconKind: "service" | "command" | null = null;
if (showScriptsIcon) {
scriptIconKind = hasRunningService ? "service" : "command";
}
const accessibilityState = useMemo(() => ({ selected }), [selected]);
@@ -631,38 +631,28 @@ function StatusWorkspaceRowInner({
<SidebarWorkspaceRowContent
workspace={workspace}
subtitle={projectName}
scriptIconKind={scriptIconKind}
isHovered={isHovered}
isLoading={isArchiving}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
>
<>
{showScriptsIcon ? (
<View accessibilityLabel="Scripts available">
{hasRunningService ? (
<ThemedGlobe size={12} uniProps={blueColorMapping} />
) : (
<ThemedSquareTerminal size={12} uniProps={blueColorMapping} />
)}
</View>
) : null}
{shouldRenderActionSlot ? (
<StatusWorkspaceActionSlot
workspace={workspace}
showBase={Boolean(workspace.diffStat && !showKebabInSlot && !showShortcut)}
showOverlay={showKebabInSlot}
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
onRename={onRename}
onMarkAsRead={onMarkAsRead}
onArchive={onArchive}
archiveLabel={archiveLabel}
archiveStatus={archiveStatus}
archivePendingLabel={archivePendingLabel}
archiveShortcutKeys={archiveShortcutKeys}
/>
) : null}
</>
{shouldRenderActionSlot ? (
<StatusWorkspaceActionSlot
workspace={workspace}
showBase={Boolean(workspace.diffStat && !showKebabInSlot && !showShortcut)}
showOverlay={showKebabInSlot}
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
onRename={onRename}
onMarkAsRead={onMarkAsRead}
onArchive={onArchive}
archiveLabel={archiveLabel}
archiveStatus={archiveStatus}
archivePendingLabel={archivePendingLabel}
archiveShortcutKeys={archiveShortcutKeys}
/>
) : null}
</SidebarWorkspaceRowContent>
</Pressable>
</View>
@@ -14,7 +14,9 @@ import {
Folder,
FolderGit2,
GitPullRequest,
Globe,
Monitor,
SquareTerminal,
} from "lucide-react-native";
import { GitHubIcon } from "@/components/icons/github-icon";
import { WorkspaceHoverCard } from "@/components/workspace-hover-card";
@@ -42,6 +44,7 @@ const syncedLoaderColorMapping = (theme: Theme) => ({
? theme.colors.palette.amber[700]
: theme.colors.palette.amber[500],
});
const blueColorMapping = (theme: Theme) => ({ color: theme.colors.palette.blue[500] });
const greenColorMapping = (theme: Theme) => ({ color: theme.colors.palette.green[500] });
const redColorMapping = (theme: Theme) => ({ color: theme.colors.palette.red[500] });
const purpleColorMapping = (theme: Theme) => ({ color: theme.colors.palette.purple[500] });
@@ -55,6 +58,10 @@ const ThemedSyncedLoader = withUnistyles(SyncedLoader);
const ThemedMonitor = withUnistyles(Monitor);
const ThemedFolder = withUnistyles(Folder);
const ThemedFolderGit2 = withUnistyles(FolderGit2);
const ThemedGlobe = withUnistyles(Globe);
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
type SidebarWorkspaceScriptIconKind = "service" | "command";
export function SidebarWorkspaceRowFrame({
workspace,
@@ -86,6 +93,7 @@ export function SidebarWorkspaceRowFrame({
export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowContent({
workspace,
subtitle,
scriptIconKind = null,
isHovered,
isLoading,
isCreating = false,
@@ -95,6 +103,7 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
}: {
workspace: SidebarWorkspaceEntry;
subtitle?: string | null;
scriptIconKind?: SidebarWorkspaceScriptIconKind | null;
isHovered: boolean;
isLoading: boolean;
isCreating?: boolean;
@@ -105,10 +114,11 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
const workspaceBranchTextStyle = useMemo(
() => [
styles.workspaceBranchText,
scriptIconKind ? styles.workspaceBranchTextWithAccessory : styles.workspaceBranchTextFlexible,
isHovered && styles.workspaceBranchTextHovered,
isCreating && styles.workspaceBranchTextCreating,
],
[isHovered, isCreating],
[scriptIconKind, isHovered, isCreating],
);
return (
@@ -123,6 +133,7 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
<Text style={workspaceBranchTextStyle} numberOfLines={1}>
{workspace.name}
</Text>
{scriptIconKind ? <WorkspaceScriptIcon kind={scriptIconKind} /> : null}
</View>
<View style={styles.workspaceRowRight}>{children}</View>
</View>
@@ -146,6 +157,22 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
);
});
function WorkspaceScriptIcon({ kind }: { kind: SidebarWorkspaceScriptIconKind }) {
return (
<View
style={styles.workspaceTitleAccessory}
accessibilityLabel="Scripts available"
testID={kind === "service" ? "workspace-globe-icon" : "workspace-terminal-icon"}
>
{kind === "service" ? (
<ThemedGlobe size={12} uniProps={blueColorMapping} />
) : (
<ThemedSquareTerminal size={12} uniProps={blueColorMapping} />
)}
</View>
);
}
function WorkspaceStatusIndicator({
bucket,
workspaceKind,
@@ -159,7 +186,7 @@ function WorkspaceStatusIndicator({
if (loading) {
return (
<View style={styles.workspaceStatusDot}>
<View style={styles.workspaceStatusDot} testID="workspace-status-indicator-loading">
<ThemedActivityIndicator size={8} uniProps={foregroundMutedColorMapping} />
</View>
);
@@ -167,7 +194,7 @@ function WorkspaceStatusIndicator({
if (shouldShowSyncedLoader) {
return (
<View style={styles.workspaceStatusDot}>
<View style={styles.workspaceStatusDot} testID="workspace-status-indicator-running">
<ThemedSyncedLoader size={11} uniProps={syncedLoaderColorMapping} />
</View>
);
@@ -454,7 +481,7 @@ const styles = StyleSheet.create((theme) => ({
workspaceStatusDot: {
position: "relative",
width: WORKSPACE_STATUS_DOT_WIDTH,
height: 16,
height: 20,
borderRadius: theme.borderRadius.full,
flexShrink: 0,
alignItems: "center",
@@ -471,9 +498,20 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: "400",
lineHeight: 20,
opacity: 0.76,
flex: 1,
minWidth: 0,
},
workspaceBranchTextFlexible: {
flex: 1,
},
workspaceBranchTextWithAccessory: {
flexShrink: 1,
},
workspaceTitleAccessory: {
height: 20,
flexShrink: 0,
alignItems: "center",
justifyContent: "center",
},
workspaceBranchTextCreating: {
opacity: 0.92,
},
@@ -490,6 +528,7 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
marginTop: theme.spacing[1],
paddingLeft: WORKSPACE_STATUS_DOT_WIDTH + theme.spacing[2],
},
statusDotNeedsInput: {
@@ -95,6 +95,7 @@ export interface ComboboxProps {
*/
header?: SheetHeader;
mobileChildrenScrollEnabled?: boolean;
presentation?: "push" | "replace";
open?: boolean;
onOpenChange?: (open: boolean) => void;
desktopPlacement?: "top-start" | "bottom-start";
@@ -965,6 +966,7 @@ interface MobileBodyProps {
searchable: boolean;
hasChildren: boolean;
mobileChildrenScrollEnabled: boolean;
presentation?: "push" | "replace";
searchResetKey: number;
searchPlaceholder: string;
searchQuery: string;
@@ -1024,6 +1026,7 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement {
handleIndicatorStyle={props.handleIndicatorStyle}
keyboardBehavior="extend"
keyboardBlurBehavior="none"
presentation={props.presentation}
>
{props.header ? (
<SheetHeaderView header={props.header} onClose={props.onClose} />
@@ -1248,6 +1251,7 @@ export function Combobox({
title = "Select",
header,
mobileChildrenScrollEnabled = true,
presentation,
open,
onOpenChange,
desktopPlacement = "top-start",
@@ -1534,6 +1538,7 @@ export function Combobox({
searchable={searchable}
hasChildren={hasChildren}
mobileChildrenScrollEnabled={mobileChildrenScrollEnabled}
presentation={presentation}
searchResetKey={searchResetKey}
searchPlaceholder={effectiveSearchPlaceholder}
searchQuery={searchQuery}
+29 -3
View File
@@ -21,9 +21,12 @@ export const FloatingSurface = forwardRef<View, FloatingSurfaceProps>(function F
): ReactElement {
const inlineFrameStyle = useMemo(() => {
const flattened = StyleSheet.flatten(frameStyle);
return flattened ? inlineUnistylesStyle(flattened) : undefined;
return flattened ? inlineUnistylesStyle(stripUnistylesMetadata(flattened)) : undefined;
}, [frameStyle]);
const surfaceStyle = useMemo(() => [style, inlineFrameStyle], [inlineFrameStyle, style]);
const surfaceStyle = useMemo(
() => appendStyle(style, inlineFrameStyle),
[inlineFrameStyle, style],
);
return <Animated.View {...props} ref={ref} style={surfaceStyle} />;
});
@@ -46,7 +49,7 @@ export function FloatingScrollView({
}: FloatingScrollViewProps): ReactElement {
const inlineStyle = useMemo(() => {
const flattened = StyleSheet.flatten(style);
return flattened ? inlineUnistylesStyle(flattened) : undefined;
return flattened ? inlineUnistylesStyle(stripUnistylesMetadata(flattened)) : undefined;
}, [style]);
return (
@@ -61,3 +64,26 @@ export function FloatingScrollView({
</ScrollView>
);
}
function appendStyle(
style: StyleProp<ViewStyle>,
extraStyle: ViewStyle | undefined,
): StyleProp<ViewStyle> {
if (!extraStyle) {
return style;
}
if (Array.isArray(style)) {
return [...style, extraStyle];
}
return [style, extraStyle];
}
function stripUnistylesMetadata(style: ViewStyle): ViewStyle {
const cleanStyle: Record<string, unknown> = { ...style };
for (const key of Object.keys(cleanStyle)) {
if (key.startsWith("unistyles_")) {
delete cleanStyle[key];
}
}
return cleanStyle as ViewStyle;
}
@@ -0,0 +1,76 @@
/**
* @vitest-environment jsdom
*/
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@gorhom/portal", () => ({
Portal: ({ children, hostName }: { children?: React.ReactNode; hostName?: string }) =>
React.createElement("div", { "data-portal-host": hostName }, children),
PortalHost: ({ name }: { name?: string }) => React.createElement("div", { "data-host": name }),
}));
vi.mock("@gorhom/bottom-sheet", () => ({
BottomSheetModalProvider: ({ children }: { children?: React.ReactNode }) =>
React.createElement("div", { "data-bottom-sheet-provider": true }, children),
BottomSheetModal: React.forwardRef(
(
{
children,
stackBehavior,
}: {
children?: React.ReactNode;
stackBehavior?: string;
},
_ref,
) => React.createElement("div", { "data-stack-behavior": stackBehavior }, children),
),
}));
import { IsolatedBottomSheetModal } from ".";
describe("IsolatedBottomSheetModal presentation", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it("defaults sibling top-level sheets to push instead of replacing by React ancestry", () => {
act(() => {
root.render(
<>
<IsolatedBottomSheetModal>Settings</IsolatedBottomSheetModal>
<IsolatedBottomSheetModal>Diagnostic</IsolatedBottomSheetModal>
</>,
);
});
expect(
Array.from(container.querySelectorAll("[data-stack-behavior]")).map((node) =>
node.getAttribute("data-stack-behavior"),
),
).toEqual(["push", "push"]);
});
it("only replaces when the callsite asks for replacement", () => {
act(() => {
root.render(
<IsolatedBottomSheetModal presentation="replace">Selector</IsolatedBottomSheetModal>,
);
});
expect(
container.querySelector("[data-stack-behavior]")?.getAttribute("data-stack-behavior"),
).toBe("replace");
});
});
@@ -1,10 +1,8 @@
import {
BottomSheetModal as GorhomBottomSheetModal,
BottomSheetModalProvider,
type BottomSheetModalProps,
} from "@gorhom/bottom-sheet";
import { Portal } from "@gorhom/portal";
import React, { createContext, useContext } from "react";
import React from "react";
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react";
import type { ElementRef } from "react";
import {
@@ -17,50 +15,29 @@ type GorhomBottomSheetModalMethods = ElementRef<typeof GorhomBottomSheetModal>;
type IsolatedBottomSheetModalProps = Omit<
BottomSheetModalProps,
"enableDismissOnClose" | "stackBehavior"
>;
> & {
presentation?: "push" | "replace";
};
export type IsolatedBottomSheetModalRef = GorhomBottomSheetModalMethods;
const IsolatedBottomSheetScopeContext = createContext(false);
export const IsolatedBottomSheetModal = forwardRef<
IsolatedBottomSheetModalRef,
IsolatedBottomSheetModalProps
>(function IsolatedBottomSheetModal(props, ref) {
const isNestedSheet = useContext(IsolatedBottomSheetScopeContext);
const { children, ...bottomSheetProps } = props;
const scopedChildren =
typeof children === "function" ? (
(input: { data?: unknown }) => (
<IsolatedBottomSheetScopeContext.Provider value={true}>
{children(input) as React.ReactNode}
</IsolatedBottomSheetScopeContext.Provider>
)
) : (
<IsolatedBottomSheetScopeContext.Provider value={true}>
{children}
</IsolatedBottomSheetScopeContext.Provider>
);
const { children, presentation = "push", ...bottomSheetProps } = props;
const modal = (
<GorhomBottomSheetModal
{...bottomSheetProps}
ref={ref}
enableDismissOnClose
stackBehavior={isNestedSheet ? "push" : "replace"}
stackBehavior={presentation}
>
{scopedChildren}
{children}
</GorhomBottomSheetModal>
);
if (isNestedSheet) {
return modal;
}
return (
<Portal hostName="root">
<BottomSheetModalProvider>{modal}</BottomSheetModalProvider>
</Portal>
);
return modal;
});
export function useIsolatedBottomSheetVisibility({
@@ -70,17 +70,17 @@ describe("bottom sheet visibility tracker", () => {
expect(sheet.events).toEqual([]);
});
it("only reports a user close when the sheet was visible", () => {
it("does not treat index -1 as a close because stacked sheets can be hidden without dismissing", () => {
const { sheet, tracker, closeCount } = setup();
tracker.attachController(sheet);
tracker.syncDesired({ visible: true });
tracker.handleSheetIndexChange(-1);
expect(closeCount()).toBe(1);
expect(closeCount()).toBe(0);
tracker.syncDesired({ visible: false });
tracker.handleSheetIndexChange(-1);
expect(closeCount()).toBe(1);
expect(closeCount()).toBe(0);
});
it("reports a dismiss while visible as a close request", () => {
@@ -93,7 +93,7 @@ describe("bottom sheet visibility tracker", () => {
expect(closeCount()).toBe(1);
});
it("deduplicates close notifications from change and dismiss callbacks", () => {
it("reports close once when a hidden sheet is actually dismissed", () => {
const { sheet, tracker, closeCount } = setup();
tracker.attachController(sheet);
tracker.syncDesired({ visible: true });
@@ -110,12 +110,14 @@ describe("bottom sheet visibility tracker", () => {
tracker.syncDesired({ visible: true });
tracker.handleSheetIndexChange(-1);
tracker.handleSheetDismiss();
expect(closeCount()).toBe(1);
tracker.syncDesired({ visible: false });
tracker.syncDesired({ visible: true });
tracker.handleSheetIndexChange(-1);
tracker.handleSheetDismiss();
expect(closeCount()).toBe(2);
});
@@ -128,7 +130,7 @@ describe("bottom sheet visibility tracker", () => {
tracker.attachController(null);
tracker.attachController(sheet);
expect(closeCount()).toBe(1);
expect(closeCount()).toBe(0);
expect(sheet.events).toEqual([{ type: "present" }]);
});
@@ -69,7 +69,7 @@ export function createBottomSheetVisibilityTracker(opts: {
},
handleSheetIndexChange(index) {
if (index !== -1) {
if (phase === "presenting") {
if (phase === "presenting" || phase === "dismissing") {
phase = "presented";
}
return;
@@ -77,9 +77,6 @@ export function createBottomSheetVisibilityTracker(opts: {
if (phase === "presenting" || phase === "presented") {
phase = "dismissing";
}
if (visible) {
notifyClose();
}
},
handleSheetDismiss() {
if (visible) {
@@ -1,7 +1,7 @@
import { useEffect, useMemo } from "react";
import {
useHydratedWorkspaceEntries,
useProjectNamesMap,
useStatusModeWorkspaceEntries,
} from "@/hooks/use-status-mode-workspaces";
import { useSidebarWorkspacesList } from "@/hooks/use-sidebar-workspaces-list";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
@@ -20,7 +20,10 @@ export function WorkspaceShortcutTargetsSubscriber({
serverId: string | null;
}) {
const { projects } = useSidebarWorkspacesList({ serverId, enabled });
const statusWorkspaces = useHydratedWorkspaceEntries(enabled ? serverId : null);
const statusWorkspaces = useStatusModeWorkspaceEntries({
serverId: enabled ? serverId : null,
projects,
});
const projectNamesByKey = useProjectNamesMap(enabled ? serverId : null);
const groupMode = useSidebarViewStore((state) =>
enabled && serverId ? state.getGroupMode(serverId) : "project",
@@ -84,7 +84,7 @@ interface UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult> {
initialAttempt?: CreateAttempt | null;
allowEmptyText?: boolean;
validateBeforeSubmit?: (ctx: SubmitContext) => string | null;
onBeforeSubmit?: (ctx: CreateRequestContext) => void;
onBeforeSubmit?: (ctx: CreateRequestContext) => Promise<void> | void;
onCreateStart?: () => void;
createRequest: (ctx: CreateRequestContext) => Promise<CreateRequestResult<TCreateResult>>;
buildDraftAgent: (attempt: CreateAttempt) => TDraftAgent;
@@ -168,7 +168,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
throw error;
}
onBeforeSubmit?.({
await onBeforeSubmit?.({
attempt,
text: attempt.text,
images: attempt.images,
@@ -441,8 +441,8 @@ export function WorkspaceDraftAgentTab({
workspaceDirectory: draftWorkingDirectory,
hasClient: Boolean(client),
}),
onBeforeSubmit: () => {
void composerState.persistFormPreferences();
onBeforeSubmit: async () => {
await composerState.persistFormPreferences();
if (isWeb) {
(document.activeElement as HTMLElement | null)?.blur?.();
}
+1 -1
View File
@@ -143,7 +143,7 @@ const DEFAULT_MAX_INPUT_HEIGHT = 160;
const MAX_INPUT_VIEWPORT_RATIO = 0.5;
const MIN_INPUT_HEIGHT = isWeb ? MIN_INPUT_HEIGHT_DESKTOP : MIN_INPUT_HEIGHT_MOBILE;
const ATTACHMENT_SHEET_HEADER: SheetHeader = { title: "Add attachment" };
const ATTACHMENT_SHEET_SNAP_POINTS = ["28%", "45%"];
const ATTACHMENT_SHEET_SNAP_POINTS = ["34%", "45%"];
type WebTextInputKeyPressEvent = NativeSyntheticEvent<
TextInputKeyPressEventData & {
@@ -11,6 +11,12 @@ import { useWindowDimensions } from "react-native";
import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated";
import { type GestureType } from "react-native-gesture-handler";
import { useIsCompactFormFactor } from "@/constants/layout";
import {
MOBILE_VISUAL_PANEL_AGENT,
MOBILE_VISUAL_PANEL_AGENT_LIST,
MOBILE_VISUAL_PANEL_FILE_EXPLORER,
useSidebarAnimation,
} from "@/contexts/sidebar-animation-context";
import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
import {
getRightSidebarAnimationTargets,
@@ -35,9 +41,21 @@ const ExplorerSidebarAnimationContext = createContext<ExplorerSidebarAnimationCo
null,
);
function getMobileVisualPanel(mobileView: "agent" | "agent-list" | "file-explorer"): number {
if (mobileView === "agent-list") {
return MOBILE_VISUAL_PANEL_AGENT_LIST;
}
if (mobileView === "file-explorer") {
return MOBILE_VISUAL_PANEL_FILE_EXPLORER;
}
return MOBILE_VISUAL_PANEL_AGENT;
}
export function ExplorerSidebarAnimationProvider({ children }: { children: ReactNode }) {
const { mobileVisualPanel } = useSidebarAnimation();
const { width: windowWidth } = useWindowDimensions();
const isCompactLayout = useIsCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView);
const isOpen = usePanelStore((state) =>
selectIsFileExplorerOpen(state, { isCompact: isCompactLayout }),
);
@@ -53,6 +71,7 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
// Track previous isOpen to detect changes
const prevIsOpen = useRef(isOpen);
const prevMobileView = useRef(mobileView);
const prevWindowWidth = useRef(windowWidth);
// Sync animation with store state changes (e.g., backdrop tap, programmatic open/close)
@@ -63,11 +82,13 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
previousWindowWidth: prevWindowWidth.current,
nextWindowWidth: windowWidth,
});
const didMobileViewChange = prevMobileView.current !== mobileView;
const previousIsOpen = prevIsOpen.current;
prevIsOpen.current = isOpen;
prevMobileView.current = mobileView;
prevWindowWidth.current = windowWidth;
if (!didStateChange) {
if (!didStateChange && !didMobileViewChange) {
return;
}
@@ -81,6 +102,10 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
return;
}
if (isCompactLayout) {
mobileVisualPanel.value = getMobileVisualPanel(mobileView);
}
const targets = getRightSidebarAnimationTargets({ isOpen, windowWidth });
if (previousIsOpen !== isOpen) {
@@ -97,7 +122,16 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
translateX.value = targets.translateX;
backdropOpacity.value = targets.backdropOpacity;
}, [isOpen, translateX, backdropOpacity, windowWidth, isGesturing]);
}, [
isOpen,
mobileView,
translateX,
backdropOpacity,
windowWidth,
isGesturing,
isCompactLayout,
mobileVisualPanel,
]);
const animateToOpen = useCallback(() => {
"worklet";
@@ -20,6 +20,9 @@ import {
const ANIMATION_DURATION = 220;
const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1);
export const MOBILE_VISUAL_PANEL_AGENT = 0;
export const MOBILE_VISUAL_PANEL_AGENT_LIST = 1;
export const MOBILE_VISUAL_PANEL_FILE_EXPLORER = 2;
interface SidebarAnimationContextValue {
translateX: SharedValue<number>;
@@ -28,6 +31,7 @@ interface SidebarAnimationContextValue {
animateToOpen: () => void;
animateToClose: () => void;
isGesturing: SharedValue<boolean>;
mobileVisualPanel: SharedValue<number>;
gestureAnimatingRef: React.MutableRefObject<boolean>;
openGestureRef: React.MutableRefObject<GestureType | undefined>;
closeGestureRef: React.MutableRefObject<GestureType | undefined>;
@@ -35,9 +39,20 @@ interface SidebarAnimationContextValue {
const SidebarAnimationContext = createContext<SidebarAnimationContextValue | null>(null);
function getMobileVisualPanel(mobileView: "agent" | "agent-list" | "file-explorer"): number {
if (mobileView === "agent-list") {
return MOBILE_VISUAL_PANEL_AGENT_LIST;
}
if (mobileView === "file-explorer") {
return MOBILE_VISUAL_PANEL_FILE_EXPLORER;
}
return MOBILE_VISUAL_PANEL_AGENT;
}
export function SidebarAnimationProvider({ children }: { children: ReactNode }) {
const { width: windowWidth } = useWindowDimensions();
const isCompactLayout = useIsCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView);
const isOpen = usePanelStore((state) =>
selectIsAgentListOpen(state, { isCompact: isCompactLayout }),
);
@@ -47,12 +62,14 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
const translateX = useSharedValue(initialTargets.translateX);
const backdropOpacity = useSharedValue(initialTargets.backdropOpacity);
const isGesturing = useSharedValue(false);
const mobileVisualPanel = useSharedValue(getMobileVisualPanel(mobileView));
const gestureAnimatingRef = useRef(false);
const openGestureRef = useRef<GestureType | undefined>(undefined);
const closeGestureRef = useRef<GestureType | undefined>(undefined);
// Track previous isOpen to detect changes
const prevIsOpen = useRef(isOpen);
const prevMobileView = useRef(mobileView);
const prevWindowWidth = useRef(windowWidth);
// Sync animation with store state changes (e.g., backdrop tap, programmatic open/close)
@@ -63,12 +80,14 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
previousWindowWidth: prevWindowWidth.current,
nextWindowWidth: windowWidth,
});
const didMobileViewChange = prevMobileView.current !== mobileView;
const previousIsOpen = prevIsOpen.current;
prevIsOpen.current = isOpen;
prevMobileView.current = mobileView;
prevWindowWidth.current = windowWidth;
const didOpen = !previousIsOpen && isOpen;
if (!didStateChange) {
if (!didStateChange && !didMobileViewChange) {
return;
}
@@ -89,6 +108,10 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
return;
}
if (isCompactLayout) {
mobileVisualPanel.value = getMobileVisualPanel(mobileView);
}
const targets = getLeftSidebarAnimationTargets({ isOpen, windowWidth });
if (previousIsOpen !== isOpen) {
@@ -105,7 +128,16 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
translateX.value = targets.translateX;
backdropOpacity.value = targets.backdropOpacity;
}, [isOpen, translateX, backdropOpacity, windowWidth, isGesturing, isCompactLayout]);
}, [
isOpen,
mobileView,
translateX,
backdropOpacity,
windowWidth,
isGesturing,
isCompactLayout,
mobileVisualPanel,
]);
const animateToOpen = useCallback(() => {
"worklet";
@@ -139,6 +171,7 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
animateToOpen,
animateToClose,
isGesturing,
mobileVisualPanel,
gestureAnimatingRef,
openGestureRef,
closeGestureRef,
@@ -150,6 +183,7 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
animateToOpen,
animateToClose,
isGesturing,
mobileVisualPanel,
gestureAnimatingRef,
openGestureRef,
closeGestureRef,
@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import { CreateAgentPreferencesService } from "./service";
import {
mergeCreateAgentSelectionPreferences,
mergeProviderPreferences,
parseFormPreferences,
} from "./preferences";
import { FakeCreateAgentPreferenceStorage } from "./test-utils/fake-preference-storage";
describe("create agent preferences", () => {
it("keeps the selected mode after saving model and thinking", async () => {
const storage = new FakeCreateAgentPreferenceStorage();
const preferences = new CreateAgentPreferencesService(storage);
const modelWrite = preferences.update((current) =>
mergeProviderPreferences({
preferences: current,
provider: "codex",
updates: { model: "gpt-5.5", thinkingByModel: { "gpt-5.5": "high" } },
}),
);
await storage.nextWrite();
const modeWrite = preferences.update((current) =>
mergeProviderPreferences({
preferences: current,
provider: "codex",
updates: { mode: "full-access" },
}),
);
expect(storage.pendingWriteCount()).toBe(1);
storage.finishOldestWrite();
await modelWrite;
await storage.nextWrite();
storage.finishOldestWrite();
await modeWrite;
expect(storage.savedPreferences()).toEqual({
provider: "codex",
providerPreferences: {
codex: {
model: "gpt-5.5",
thinkingByModel: { "gpt-5.5": "high" },
mode: "full-access",
},
},
});
});
it("flushes the full create-agent selection into provider preferences", async () => {
const storage = new FakeCreateAgentPreferenceStorage();
const preferences = new CreateAgentPreferencesService(storage);
const saveSelection = preferences.update((current) =>
mergeCreateAgentSelectionPreferences({
preferences: current,
provider: "codex",
modelId: "gpt-5.5",
modeId: "full-access",
thinkingOptionId: "high",
featureValues: { fast_mode: true },
}),
);
await storage.nextWrite();
storage.finishOldestWrite();
await saveSelection;
expect(storage.savedPreferences()).toEqual({
provider: "codex",
providerPreferences: {
codex: {
model: "gpt-5.5",
mode: "full-access",
thinkingByModel: { "gpt-5.5": "high" },
featureValues: { fast_mode: true },
},
},
});
});
it("loads invalid stored preferences as empty preferences", () => {
expect(parseFormPreferences({ providerPreferences: { codex: { mode: 42 } } })).toEqual({});
});
});
@@ -0,0 +1,147 @@
import { z } from "zod";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
export interface FavoriteModelPreference {
provider: string;
modelId: string;
}
export interface FavoriteModelRow {
favoriteKey: string;
provider: string;
providerLabel: string;
modelId: string;
modelLabel: string;
description?: string;
}
const providerPreferencesSchema = z.object({
model: z.string().optional(),
mode: z.string().optional(),
thinkingByModel: z.record(z.string()).optional(),
featureValues: z.record(z.unknown()).optional(),
});
const formPreferencesSchema = z.object({
provider: z.string().optional(),
providerPreferences: z.record(providerPreferencesSchema).optional(),
favoriteModels: z
.array(
z.object({
provider: z.string(),
modelId: z.string(),
}),
)
.optional(),
});
export type ProviderPreferences = z.infer<typeof providerPreferencesSchema>;
export type FormPreferences = z.infer<typeof formPreferencesSchema>;
export const DEFAULT_FORM_PREFERENCES: FormPreferences = {};
export function parseFormPreferences(value: unknown): FormPreferences {
const result = formPreferencesSchema.safeParse(value);
return result.success ? result.data : DEFAULT_FORM_PREFERENCES;
}
export function mergeProviderPreferences(args: {
preferences: FormPreferences;
provider: AgentProvider;
updates: Partial<ProviderPreferences>;
}): FormPreferences {
const { preferences, provider, updates } = args;
const existingProviderPreferences = preferences.providerPreferences ?? {};
const existing = existingProviderPreferences[provider] ?? {};
const nextThinkingByModel =
updates.thinkingByModel === undefined
? existing.thinkingByModel
: {
...existing.thinkingByModel,
...updates.thinkingByModel,
};
const nextFeatureValues =
updates.featureValues === undefined
? existing.featureValues
: {
...existing.featureValues,
...updates.featureValues,
};
return {
...preferences,
provider,
providerPreferences: {
...existingProviderPreferences,
[provider]: {
...existing,
...updates,
...(nextThinkingByModel ? { thinkingByModel: nextThinkingByModel } : {}),
...(nextFeatureValues ? { featureValues: nextFeatureValues } : {}),
},
},
};
}
export function mergeCreateAgentSelectionPreferences(args: {
preferences: FormPreferences;
provider: AgentProvider | null;
modelId?: string | null;
modeId?: string | null;
thinkingOptionId?: string | null;
featureValues?: Record<string, unknown>;
}): FormPreferences {
if (!args.provider) {
return args.preferences;
}
const modelId = args.modelId?.trim() ?? "";
const modeId = args.modeId?.trim() ?? "";
const thinkingOptionId = args.thinkingOptionId?.trim() ?? "";
return mergeProviderPreferences({
preferences: args.preferences,
provider: args.provider,
updates: {
model: modelId || undefined,
mode: modeId || undefined,
...(modelId && thinkingOptionId ? { thinkingByModel: { [modelId]: thinkingOptionId } } : {}),
...(args.featureValues ? { featureValues: args.featureValues } : {}),
},
});
}
export function buildFavoriteModelKey(input: FavoriteModelPreference): string {
return `${input.provider}:${input.modelId}`;
}
export function isFavoriteModel(args: {
preferences: FormPreferences;
provider: string;
modelId: string;
}): boolean {
const favoriteKey = buildFavoriteModelKey({ provider: args.provider, modelId: args.modelId });
return (args.preferences.favoriteModels ?? []).some(
(favorite) => buildFavoriteModelKey(favorite) === favoriteKey,
);
}
export function toggleFavoriteModel(args: {
preferences: FormPreferences;
provider: string;
modelId: string;
}): FormPreferences {
const favorite = { provider: args.provider, modelId: args.modelId };
const favoriteKey = buildFavoriteModelKey(favorite);
const existingFavorites = args.preferences.favoriteModels ?? [];
const hasFavorite = existingFavorites.some(
(entry) => buildFavoriteModelKey(entry) === favoriteKey,
);
return {
...args.preferences,
favoriteModels: hasFavorite
? existingFavorites.filter((entry) => buildFavoriteModelKey(entry) !== favoriteKey)
: [...existingFavorites, favorite],
};
}
@@ -0,0 +1,62 @@
import { AsyncStorageCreateAgentPreferenceStorage } from "./storage";
import {
DEFAULT_FORM_PREFERENCES,
parseFormPreferences,
type FormPreferences,
} from "./preferences";
import type { CreateAgentPreferenceStorage } from "./storage";
export type FormPreferenceUpdate =
| Partial<FormPreferences>
| ((current: FormPreferences) => FormPreferences);
export class CreateAgentPreferencesService {
private preferences: FormPreferences = DEFAULT_FORM_PREFERENCES;
private isLoaded = false;
private loadPromise: Promise<FormPreferences> | null = null;
private writeQueue: Promise<void> = Promise.resolve();
constructor(private readonly storage: CreateAgentPreferenceStorage) {}
async load(): Promise<FormPreferences> {
if (this.isLoaded) {
return this.preferences;
}
if (!this.loadPromise) {
this.loadPromise = this.storage.read().then((stored) => {
this.preferences = parseFormPreferences(stored);
this.isLoaded = true;
return this.preferences;
});
}
return this.loadPromise;
}
async update(update: FormPreferenceUpdate): Promise<FormPreferences> {
const previousWrite = this.writeQueue;
const operation = this.applyQueuedUpdate(previousWrite, update);
this.writeQueue = operation.then(
() => undefined,
() => undefined,
);
return operation;
}
private async applyQueuedUpdate(
previousWrite: Promise<void>,
update: FormPreferenceUpdate,
): Promise<FormPreferences> {
await previousWrite;
const current = await this.load();
const next = typeof update === "function" ? update(current) : { ...current, ...update };
this.preferences = parseFormPreferences(next);
this.isLoaded = true;
await this.storage.write(this.preferences);
return this.preferences;
}
}
export const createAgentPreferencesService = new CreateAgentPreferencesService(
new AsyncStorageCreateAgentPreferenceStorage(),
);
@@ -0,0 +1,28 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import type { FormPreferences } from "./preferences";
export const CREATE_AGENT_PREFERENCES_STORAGE_KEY = "@paseo:create-agent-preferences";
export interface CreateAgentPreferenceStorage {
read(): Promise<unknown>;
write(preferences: FormPreferences): Promise<void>;
}
export class AsyncStorageCreateAgentPreferenceStorage implements CreateAgentPreferenceStorage {
async read(): Promise<unknown> {
const stored = await AsyncStorage.getItem(CREATE_AGENT_PREFERENCES_STORAGE_KEY);
if (!stored) {
return null;
}
try {
return JSON.parse(stored);
} catch {
return null;
}
}
async write(preferences: FormPreferences): Promise<void> {
await AsyncStorage.setItem(CREATE_AGENT_PREFERENCES_STORAGE_KEY, JSON.stringify(preferences));
}
}
@@ -0,0 +1,65 @@
import type { FormPreferences } from "../preferences";
import type { CreateAgentPreferenceStorage } from "../storage";
interface PendingWrite {
preferences: FormPreferences;
finish: () => void;
}
export class FakeCreateAgentPreferenceStorage implements CreateAgentPreferenceStorage {
private stored: unknown;
private readonly pendingWrites: PendingWrite[] = [];
private readonly pendingWriteWaiters: Array<(write: PendingWrite) => void> = [];
constructor(input: { stored?: unknown } = {}) {
this.stored = input.stored ?? null;
}
async read(): Promise<unknown> {
return this.stored;
}
write(preferences: FormPreferences): Promise<void> {
return new Promise((resolve) => {
const write = {
preferences,
finish: () => {
this.stored = clone(preferences);
resolve();
},
};
this.pendingWrites.push(write);
this.pendingWriteWaiters.shift()?.(write);
});
}
nextWrite(): Promise<PendingWrite> {
const next = this.pendingWrites[0];
if (next) {
return Promise.resolve(next);
}
return new Promise((resolve) => {
this.pendingWriteWaiters.push(resolve);
});
}
pendingWriteCount(): number {
return this.pendingWrites.length;
}
finishOldestWrite(): void {
const write = this.pendingWrites.shift();
if (!write) {
throw new Error("No pending create-agent preference write");
}
write.finish();
}
savedPreferences(): unknown {
return this.stored;
}
}
function clone<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
+10 -10
View File
@@ -57,29 +57,29 @@ const CATALOG_DATA = [
id: "claude-acp",
title: "Claude Agent",
description: "ACP wrapper for Anthropic's Claude",
version: "0.40.0",
version: "0.41.0",
iconId: "claude-acp",
installLink: "https://github.com/agentclientprotocol/claude-agent-acp",
command: ["npx", "-y", "@agentclientprotocol/claude-agent-acp@0.40.0"],
command: ["npx", "-y", "@agentclientprotocol/claude-agent-acp@0.41.0"],
},
{
id: "cline",
title: "Cline",
description:
"Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
version: "3.0.15",
version: "3.0.17",
iconId: "cline",
installLink: "https://cline.bot/cli",
command: ["npx", "-y", "cline@3.0.15", "--acp"],
command: ["npx", "-y", "cline@3.0.17", "--acp"],
},
{
id: "codebuddy-code",
title: "Codebuddy Code",
description: "Tencent Cloud's official intelligent coding tool",
version: "2.102.0",
version: "2.103.0",
iconId: "codebuddy-code",
installLink: "https://www.codebuddy.cn/cli/",
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.102.0", "--acp"],
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.103.0", "--acp"],
},
{
id: "codex-acp",
@@ -176,10 +176,10 @@ const CATALOG_DATA = [
id: "factory-droid",
title: "Factory Droid",
description: "Factory Droid - AI coding agent powered by Factory AI",
version: "0.139.0",
version: "0.140.0",
iconId: "factory-droid",
installLink: "https://factory.ai/product/cli",
command: ["npx", "-y", "droid@0.139.0", "exec", "--output-format", "acp-daemon"],
command: ["npx", "-y", "droid@0.140.0", "exec", "--output-format", "acp-daemon"],
env: {
DROID_DISABLE_AUTO_UPDATE: "true",
FACTORY_DROID_AUTO_UPDATE_ENABLED: "false",
@@ -345,10 +345,10 @@ const CATALOG_DATA = [
id: "qoder",
title: "Qoder CLI",
description: "AI coding assistant with agentic capabilities",
version: "1.0.13",
version: "1.0.14",
iconId: "qoder",
installLink: "https://qoder.com",
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.13", "--acp"],
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.14", "--acp"],
},
{
id: "qwen-code",
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import {
DaemonConnectionRegistrationError,
DaemonManagementOperationError,
getDaemonManagementErrorPresentation,
} from "./daemon-management-error";
describe("getDaemonManagementErrorPresentation", () => {
it("refreshes status when the daemon started but localhost registration failed", () => {
const presentation = getDaemonManagementErrorPresentation(
new DaemonConnectionRegistrationError("Desktop daemon did not return a listen address."),
false,
);
expect(presentation).toEqual({
message:
"Built-in daemon started, but Paseo could not save the localhost connection. Toggle daemon management off and on again, or add localhost manually.",
refreshStatus: true,
});
});
it("does not refresh status for daemon stop failures", () => {
const presentation = getDaemonManagementErrorPresentation(new Error("stop failed"), true);
expect(presentation).toEqual({
message: "Built-in daemon management was paused, but Paseo could not stop the daemon.",
refreshStatus: false,
});
});
it("uses the pre-mutation daemon management state for operation failures", () => {
const presentation = getDaemonManagementErrorPresentation(
new DaemonManagementOperationError(new Error("stop failed"), true),
false,
);
expect(presentation).toEqual({
message: "Built-in daemon management was paused, but Paseo could not stop the daemon.",
refreshStatus: false,
});
});
it("does not refresh status for generic update failures", () => {
const presentation = getDaemonManagementErrorPresentation(new Error("settings failed"), false);
expect(presentation).toEqual({
message: "Unable to update built-in daemon management.",
refreshStatus: false,
});
});
});
@@ -0,0 +1,52 @@
export class DaemonConnectionRegistrationError extends Error {
constructor(message: string) {
super(message);
this.name = "DaemonConnectionRegistrationError";
}
}
export class DaemonManagementOperationError extends Error {
readonly originalError: Error;
readonly wasManagingDaemon: boolean;
constructor(error: Error, wasManagingDaemon: boolean) {
super(error.message);
this.name = error.name;
this.cause = error;
this.originalError = error;
this.wasManagingDaemon = wasManagingDaemon;
}
}
export interface DaemonManagementErrorPresentation {
message: string;
refreshStatus: boolean;
}
export function getDaemonManagementErrorPresentation(
error: Error,
isManagingDaemon: boolean,
): DaemonManagementErrorPresentation {
const presentationError =
error instanceof DaemonManagementOperationError ? error.originalError : error;
const wasManagingDaemon =
error instanceof DaemonManagementOperationError ? error.wasManagingDaemon : isManagingDaemon;
if (presentationError instanceof DaemonConnectionRegistrationError) {
return {
message:
"Built-in daemon started, but Paseo could not save the localhost connection. Toggle daemon management off and on again, or add localhost manually.",
refreshStatus: true,
};
}
if (wasManagingDaemon) {
return {
message: "Built-in daemon management was paused, but Paseo could not stop the daemon.",
refreshStatus: false,
};
}
return {
message: "Unable to update built-in daemon management.",
refreshStatus: false,
};
}
@@ -9,8 +9,15 @@ import {
executeDaemonManagementToggle,
type DaemonManagementToggleResult,
} from "@/desktop/daemon/daemon-management-toggle";
import {
DaemonConnectionRegistrationError,
DaemonManagementOperationError,
getDaemonManagementErrorPresentation,
} from "@/desktop/daemon/daemon-management-error";
import { useDesktopIpcErrorReporter } from "@/desktop/hooks/desktop-ipc-error";
import type { DesktopSettings } from "@/desktop/settings/desktop-settings";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { upsertDesktopDaemonConnection } from "@/runtime/daemon-start-service";
import { confirmDialog } from "@/utils/confirm-dialog";
type DesktopDaemonSettings = DesktopSettings["daemon"];
@@ -37,27 +44,51 @@ export function useBuiltInDaemonManagement(
DaemonManagementToggleResult,
Error
>({
mutationFn: () =>
executeDaemonManagementToggle(settings.manageBuiltInDaemon, daemonStatus, {
confirm: () =>
confirmDialog({
title: "Pause built-in daemon",
message:
"This will stop the built-in daemon immediately. Running agents and terminals connected to the built-in daemon will be stopped.",
confirmLabel: "Pause and stop",
cancelLabel: "Cancel",
destructive: true,
}),
persistSettings: (next) => updateSettings(next) as Promise<void>,
startDaemon: startDesktopDaemon,
stopDaemon: stopDesktopDaemon,
}),
mutationFn: async () => {
const wasManagingDaemon = settings.manageBuiltInDaemon;
try {
const result = await executeDaemonManagementToggle(wasManagingDaemon, daemonStatus, {
confirm: () =>
confirmDialog({
title: "Pause built-in daemon",
message:
"This will stop the built-in daemon immediately. Running agents and terminals connected to the built-in daemon will be stopped.",
confirmLabel: "Pause and stop",
cancelLabel: "Cancel",
destructive: true,
}),
persistSettings: (next) => updateSettings(next) as Promise<void>,
startDaemon: startDesktopDaemon,
stopDaemon: stopDesktopDaemon,
});
if (result.kind === "enabled") {
const upsertResult = await upsertDesktopDaemonConnection(
getHostRuntimeStore(),
result.newStatus,
);
if (!upsertResult.ok) {
throw new DaemonConnectionRegistrationError(upsertResult.error);
}
}
return result;
} catch (error) {
throw new DaemonManagementOperationError(
error instanceof Error ? error : new Error(String(error)),
wasManagingDaemon,
);
}
},
onError: (error) => {
const presentation = getDaemonManagementErrorPresentation(
error,
settings.manageBuiltInDaemon,
);
if (presentation.refreshStatus) {
refreshStatus();
}
reportError({
error,
message: settings.manageBuiltInDaemon
? "Built-in daemon management was paused, but Paseo could not stop the daemon."
: "Unable to update built-in daemon management.",
message: presentation.message,
logLabel: "[Settings] Failed to update built-in daemon management",
});
},
+6
View File
@@ -98,6 +98,7 @@ export interface DesktopWindowBridge {
}
export interface DesktopWindowModuleBridge {
openNew?: (options?: { pendingOpenProjectPath?: string | null }) => Promise<void>;
getCurrentWindow?: () => DesktopWindowBridge;
}
@@ -110,6 +111,11 @@ export interface DesktopBrowserShortcutEvent {
action: "focus-url";
}
export interface DesktopBrowserNewTabRequestEvent {
sourceBrowserId: string;
url: string;
}
export interface DesktopBrowserBridge {
setWorkspaceActiveBrowser?: (browserId: string | null) => Promise<void>;
openDevTools?: (browserId: string) => Promise<unknown>;
@@ -137,4 +137,30 @@ describe("DictationStreamSender", () => {
expect(client.chunks.map((c) => c.seq)).toEqual([0, 1]);
});
it("does not replay long buffered native dictation in one synchronous burst", async () => {
const client = new FakeDaemonClient();
client.isConnected = false;
const sender = new DictationStreamSender({
client: client as unknown as DaemonClient,
format: "audio/pcm;rate=16000;bits=16",
createDictationId: () => "d1",
});
for (let seq = 0; seq < 480; seq += 1) {
sender.enqueueSegment(`native-frame-${seq}`);
}
client.isConnected = true;
const finish = sender.finish(sender.getFinalSeq());
await tick();
expect(client.chunks.length).toBeLessThanOrEqual(128);
await expect(finish).resolves.toEqual({
dictationId: "d1",
text: "ok",
});
expect(client.finishes).toEqual([{ dictationId: "d1", finalSeq: 479 }]);
});
});
@@ -1,6 +1,10 @@
import { generateMessageId } from "@/types/stream";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
const MAX_CHUNKS_PER_FLUSH_TURN = 128;
const waitForNextFlushTurn = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
export interface DictationStreamSenderParams {
client: DaemonClient | null;
format: string;
@@ -33,6 +37,8 @@ export class DictationStreamSender {
private sendSeq = 0;
private segments: string[] = [];
private streamReady = false;
private flushTimer: ReturnType<typeof setTimeout> | null = null;
private drainWaiters: Array<() => void> = [];
private startGeneration = 0;
private startPromise: Promise<void> | null = null;
@@ -64,6 +70,7 @@ export class DictationStreamSender {
}
clearAll(): void {
this.clearScheduledFlush();
this.dictationId = null;
this.sendSeq = 0;
this.segments = [];
@@ -73,6 +80,7 @@ export class DictationStreamSender {
}
resetStreamForReplay(): void {
this.clearScheduledFlush();
this.dictationId = null;
this.sendSeq = 0;
this.streamReady = false;
@@ -108,13 +116,18 @@ export class DictationStreamSender {
}
let sent = 0;
while (this.sendSeq < this.segments.length) {
while (this.sendSeq < this.segments.length && sent < MAX_CHUNKS_PER_FLUSH_TURN) {
const seq = this.sendSeq;
const audio = this.segments[seq];
client.sendDictationStreamChunk(dictationId, seq, audio, this.format);
this.sendSeq = seq + 1;
sent += 1;
}
if (this.hasPendingSegments()) {
this.scheduleFlush();
} else {
this.resolveDrainWaiters();
}
return sent;
}
@@ -184,6 +197,7 @@ export class DictationStreamSender {
}
this.flush();
await this.waitForFlushDrain();
return client.finishDictationStream(dictationId, finalSeq);
}
@@ -195,4 +209,47 @@ export class DictationStreamSender {
}
this.resetStreamForReplay();
}
private hasPendingSegments(): boolean {
return this.sendSeq < this.segments.length;
}
private scheduleFlush(): void {
if (this.flushTimer) {
return;
}
this.flushTimer = setTimeout(() => {
this.flushTimer = null;
this.flush();
}, 0);
}
private clearScheduledFlush(): void {
if (!this.flushTimer) {
return;
}
clearTimeout(this.flushTimer);
this.flushTimer = null;
}
private async waitForFlushDrain(): Promise<void> {
while (this.hasPendingSegments()) {
const client = this.client;
if (!client?.isConnected || !this.dictationId || !this.streamReady) {
throw new Error("Failed to flush dictation stream");
}
await new Promise<void>((resolve) => {
this.drainWaiters.push(resolve);
});
await waitForNextFlushTurn();
}
}
private resolveDrainWaiters(): void {
const waiters = this.drainWaiters;
this.drainWaiters = [];
for (const resolve of waiters) {
resolve();
}
}
}
+150 -114
View File
@@ -25,9 +25,9 @@ import {
type ViewStyle,
type TextStyle,
} from "react-native";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { useIsCompactFormFactor } from "@/constants/layout";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { BORDER_WIDTH, ICON_SIZE, SPACING, type Theme } from "@/styles/theme";
import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import {
AlignJustify,
Archive,
@@ -55,10 +55,10 @@ import {
import { useCheckoutStatusQuery } from "@/git/use-status-query";
import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
import { useAppSettings } from "@/hooks/use-settings";
import { DiffScroll } from "@/components/diff-scroll";
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { shouldAnchorHeaderBeforeCollapse } from "@/git/diff-scroll";
import {
buildSplitDiffRows,
@@ -123,6 +123,7 @@ function fileHeaderPressableStyle({ pressed }: PressableStateCallbackType) {
interface HighlightedTextProps {
tokens: HighlightToken[];
wrapLines?: boolean;
testID?: string;
}
type WrappedWebTextStyle = TextStyle & {
@@ -143,13 +144,10 @@ function HighlightedToken({ token }: { token: HighlightToken }) {
return <Text style={syntaxTokenStyleFor(token.style)}>{token.text}</Text>;
}
function HighlightedText({ tokens, wrapLines = false }: HighlightedTextProps) {
const { theme } = useUnistyles();
const lineHeight = theme.lineHeight.diff;
function HighlightedText({ tokens, wrapLines = false, testID }: HighlightedTextProps) {
const containerStyle = useMemo(
() => [styles.diffLineText, { lineHeight, ...getWrappedTextStyle(wrapLines) }],
[lineHeight, wrapLines],
() => [styles.diffTextMetrics, styles.diffLineText, getWrappedTextStyle(wrapLines)],
[wrapLines],
);
const keyedTokens = useMemo(
@@ -158,7 +156,7 @@ function HighlightedText({ tokens, wrapLines = false }: HighlightedTextProps) {
);
return (
<Text style={containerStyle}>
<Text style={containerStyle} testID={testID}>
{keyedTokens.map(({ key, token }) => (
<HighlightedToken key={key} token={token} />
))}
@@ -252,6 +250,8 @@ function DiffGutterCell({
reviewActions,
isLineHovered,
style,
textTestID,
actionTestID,
}: {
lineNumber: number | null;
type: DiffLine["type"] | undefined | null;
@@ -260,6 +260,8 @@ function DiffGutterCell({
reviewActions?: InlineReviewActions;
isLineHovered?: boolean;
style?: StyleProp<ViewStyle>;
textTestID?: string;
actionTestID?: string;
}) {
const containerStyle = useMemo(
() => [
@@ -272,6 +274,7 @@ function DiffGutterCell({
);
const textStyle = useMemo(
() => [
styles.diffTextMetrics,
styles.lineNumberText,
type === "add" && styles.addLineNumberText,
type === "remove" && styles.removeLineNumberText,
@@ -296,8 +299,9 @@ function DiffGutterCell({
isLineHovered={isLineHovered}
onStartComment={onStartComment}
style={containerStyle}
actionTestID={actionTestID}
>
<Text numberOfLines={1} style={textStyle}>
<Text numberOfLines={1} style={textStyle} testID={textTestID}>
{formatDiffGutterText(lineNumber)}
</Text>
</InlineReviewGutterCell>
@@ -312,6 +316,7 @@ function DiffTextLine({
onHoverChange,
hoverTargetKey,
onHoverTargetChange,
textTestID,
}: {
line: DiffLine;
wrapLines: boolean;
@@ -320,6 +325,7 @@ function DiffTextLine({
onHoverChange?: (hovered: boolean) => void;
hoverTargetKey?: string | null;
onHoverTargetChange?: (key: string | null) => void;
textTestID?: string;
}) {
const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
@@ -329,6 +335,7 @@ function DiffTextLine({
);
const textStyle = useMemo(
() => [
styles.diffTextMetrics,
styles.diffLineText,
getWrappedTextStyle(wrapLines),
line.type === "add" && styles.addLineText,
@@ -349,9 +356,11 @@ function DiffTextLine({
style={containerStyle}
>
{line.type !== "header" && visibleTokens ? (
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} />
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} testID={textTestID} />
) : (
<Text style={textStyle}>{formatDiffContentText(line.content)}</Text>
<Text style={textStyle} testID={textTestID}>
{formatDiffContentText(line.content)}
</Text>
)}
</LongPressableLine>
);
@@ -380,6 +389,7 @@ function SplitTextLine({
);
const textStyle = useMemo(
() => [
styles.diffTextMetrics,
styles.diffLineText,
getWrappedTextStyle(wrapLines),
line?.type === "add" && styles.addLineText,
@@ -432,6 +442,7 @@ function DiffLineView({
);
const textStyle = useMemo(
() => [
styles.diffTextMetrics,
styles.diffLineText,
getWrappedTextStyle(wrapLines),
line.type === "add" && styles.addLineText,
@@ -487,6 +498,7 @@ function SplitDiffLine({
);
const textStyle = useMemo(
() => [
styles.diffTextMetrics,
styles.diffLineText,
getWrappedTextStyle(wrapLines),
line?.type === "add" && styles.addLineText,
@@ -896,6 +908,7 @@ function DiffFileBody({
file,
layout,
wrapLines,
codeFontSize,
reviewActions,
onBodyHeightChange,
testID,
@@ -903,6 +916,7 @@ function DiffFileBody({
file: ParsedDiffFile;
layout: "unified" | "split";
wrapLines: boolean;
codeFontSize: number;
reviewActions?: InlineReviewActions;
onBodyHeightChange?: (file: ParsedDiffFile, height: number) => void;
testID?: string;
@@ -910,7 +924,6 @@ function DiffFileBody({
const [scrollViewWidth, setScrollViewWidth] = useState(0);
const [bodyWidth, setBodyWidth] = useState(0);
const [hoveredReviewTargetKey, setHoveredReviewTargetKey] = useState<string | null>(null);
const { theme } = useUnistyles();
const handleLayout = useCallback(
(event: LayoutChangeEvent) => {
@@ -950,7 +963,7 @@ function DiffFileBody({
hunk.newStart + hunk.newCount,
);
}
const gutterWidth = lineNumberGutterWidth(maxLineNo, theme.fontSize.code);
const gutterWidth = lineNumberGutterWidth(maxLineNo, codeFontSize);
if (layout === "split") {
const rows = buildSplitDiffRows(file);
@@ -981,8 +994,8 @@ function DiffFileBody({
return (
<View style={styles.diffContent} dataSet={CODE_SURFACE_DATASET}>
<View style={styles.linesContainer}>
{computedLines.map(({ line, lineNumber, key, reviewTarget }) => (
<View key={key}>
{computedLines.map(({ line, lineNumber, key, reviewTarget }, index) => (
<View key={key} testID={`diff-wrapped-row-${index}`}>
<DiffLineView
line={line}
lineNumber={lineNumber}
@@ -1008,8 +1021,8 @@ function DiffFileBody({
return (
<View style={DIFF_CONTENT_ROW_STYLE} dataSet={CODE_SURFACE_DATASET}>
<View style={styles.gutterColumn}>
{computedLines.map(({ line, lineNumber, key, reviewTarget }) => (
<View key={key}>
{computedLines.map(({ line, lineNumber, key, reviewTarget }, index) => (
<View key={key} testID={`diff-gutter-row-${index}`}>
<DiffGutterCell
lineNumber={lineNumber}
type={line.type}
@@ -1019,6 +1032,8 @@ function DiffFileBody({
isLineHovered={
reviewTarget?.key !== undefined && hoveredReviewTargetKey === reviewTarget.key
}
textTestID={`diff-gutter-text-${index}`}
actionTestID={`diff-gutter-action-${index}`}
/>
<InlineReviewGutterSpacer
reviewTarget={reviewTarget}
@@ -1035,8 +1050,8 @@ function DiffFileBody({
contentContainerStyle={styles.diffContentInner}
>
<View style={linesContainerRowStyle}>
{computedLines.map(({ line, key, reviewTarget }) => (
<View key={key}>
{computedLines.map(({ line, key, reviewTarget }, index) => (
<View key={key} testID={`diff-code-row-${index}`}>
<DiffTextLine
line={line}
wrapLines={false}
@@ -1044,6 +1059,7 @@ function DiffFileBody({
reviewActions={reviewActions}
hoverTargetKey={reviewTarget?.key ?? null}
onHoverTargetChange={setHoveredReviewTargetKey}
textTestID={`diff-code-text-${index}`}
/>
<InlineReviewThreadContent
reviewTarget={reviewTarget}
@@ -1074,6 +1090,27 @@ type PressableStyleFn = (
state: PressableStateCallbackType & { hovered?: boolean; open?: boolean },
) => StyleProp<ViewStyle>;
const foregroundIconColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedIconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
const ThemedAlignJustify = withUnistyles(AlignJustify);
const ThemedColumns2 = withUnistyles(Columns2);
const ThemedPilcrow = withUnistyles(Pilcrow);
const ThemedWrapText = withUnistyles(WrapText);
const ThemedListChevronsDownUp = withUnistyles(ListChevronsDownUp);
const ThemedListChevronsUpDown = withUnistyles(ListChevronsUpDown);
const ThemedGitCommitHorizontal = withUnistyles(GitCommitHorizontal);
const ThemedDownload = withUnistyles(Download);
const ThemedUpload = withUnistyles(Upload);
const ThemedArrowDownUp = withUnistyles(ArrowDownUp);
const ThemedGitHubIcon = withUnistyles(GitHubIcon);
const ThemedGitMerge = withUnistyles(GitMerge);
const ThemedRefreshCcw = withUnistyles(RefreshCcw);
const ThemedArchive = withUnistyles(Archive);
const ThemedGitBranch = withUnistyles(GitBranch);
const ThemedChevronDown = withUnistyles(ChevronDown);
interface DiffLayoutToggleGroupProps {
layout: "unified" | "split";
unifiedToggleStyle: PressableStyleFn;
@@ -1089,7 +1126,6 @@ function DiffLayoutToggleGroup({
onUnified,
onSplit,
}: DiffLayoutToggleGroupProps) {
const { theme } = useUnistyles();
return (
<View style={styles.toggleButtonGroup}>
<Tooltip delayDuration={300}>
@@ -1101,9 +1137,11 @@ function DiffLayoutToggleGroup({
onPress={onUnified}
style={unifiedToggleStyle}
>
<AlignJustify
<ThemedAlignJustify
size={14}
color={layout === "unified" ? theme.colors.foreground : theme.colors.foregroundMuted}
uniProps={
layout === "unified" ? foregroundIconColorMapping : foregroundMutedIconColorMapping
}
/>
</Pressable>
</TooltipTrigger>
@@ -1120,9 +1158,11 @@ function DiffLayoutToggleGroup({
onPress={onSplit}
style={splitToggleStyle}
>
<Columns2
<ThemedColumns2
size={14}
color={layout === "split" ? theme.colors.foreground : theme.colors.foregroundMuted}
uniProps={
layout === "split" ? foregroundIconColorMapping : foregroundMutedIconColorMapping
}
/>
</Pressable>
</TooltipTrigger>
@@ -1147,7 +1187,6 @@ function DiffWhitespaceToggle({
toggleStyle,
onToggle,
}: DiffWhitespaceToggleProps) {
const { theme } = useUnistyles();
return (
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
@@ -1158,9 +1197,9 @@ function DiffWhitespaceToggle({
style={toggleStyle}
onPress={onToggle}
>
<Pilcrow
<ThemedPilcrow
size={isMobile ? 18 : 14}
color={hideWhitespace ? theme.colors.foreground : theme.colors.foregroundMuted}
uniProps={hideWhitespace ? foregroundIconColorMapping : foregroundMutedIconColorMapping}
/>
</Pressable>
</TooltipTrigger>
@@ -1190,15 +1229,14 @@ function DiffFilesToolbar({
onToggleWrapLines,
onToggleExpandAll,
}: DiffFilesToolbarProps) {
const { theme } = useUnistyles();
return (
<View style={styles.diffStatusButtons}>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable style={wrapLinesToggleStyle} onPress={onToggleWrapLines}>
<WrapText
<ThemedWrapText
size={isMobile ? 18 : 14}
color={wrapLines ? theme.colors.foreground : theme.colors.foregroundMuted}
uniProps={wrapLines ? foregroundIconColorMapping : foregroundMutedIconColorMapping}
/>
</Pressable>
</TooltipTrigger>
@@ -1212,9 +1250,15 @@ function DiffFilesToolbar({
<TooltipTrigger asChild>
<Pressable style={expandAllToggleStyle} onPress={onToggleExpandAll}>
{allExpanded ? (
<ListChevronsDownUp size={isMobile ? 18 : 14} color={theme.colors.foregroundMuted} />
<ThemedListChevronsDownUp
size={isMobile ? 18 : 14}
uniProps={foregroundMutedIconColorMapping}
/>
) : (
<ListChevronsUpDown size={isMobile ? 18 : 14} color={theme.colors.foregroundMuted} />
<ThemedListChevronsUpDown
size={isMobile ? 18 : 14}
uniProps={foregroundMutedIconColorMapping}
/>
)}
</Pressable>
</TooltipTrigger>
@@ -1236,7 +1280,6 @@ interface DiffRefreshButtonProps {
const ThemedRotateCw = withUnistyles(RotateCw);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const refreshIconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
function DiffRefreshButton({ isRefreshing, toggleStyle, onPress }: DiffRefreshButtonProps) {
return (
@@ -1252,9 +1295,12 @@ function DiffRefreshButton({ isRefreshing, toggleStyle, onPress }: DiffRefreshBu
>
<View style={styles.refreshIcon}>
{isRefreshing ? (
<ThemedLoadingSpinner size={ICON_SIZE.sm} uniProps={refreshIconColorMapping} />
<ThemedLoadingSpinner
size={ICON_SIZE.sm}
uniProps={foregroundMutedIconColorMapping}
/>
) : (
<ThemedRotateCw size={ICON_SIZE.sm} uniProps={refreshIconColorMapping} />
<ThemedRotateCw size={ICON_SIZE.sm} uniProps={foregroundMutedIconColorMapping} />
)}
</View>
</Pressable>
@@ -1322,7 +1368,6 @@ interface DiffBodyContentProps {
handleDiffListScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
onContentSizeChange: (width: number, height: number) => void;
showDesktopWebScrollbar: boolean;
foregroundMutedColor: string;
}
function DiffBodyContent({
@@ -1344,12 +1389,11 @@ function DiffBodyContent({
handleDiffListScroll,
onContentSizeChange,
showDesktopWebScrollbar,
foregroundMutedColor,
}: DiffBodyContentProps) {
if (isStatusLoading) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={foregroundMutedColor} />
<ThemedActivityIndicator size="large" uniProps={foregroundMutedIconColorMapping} />
<Text style={styles.loadingText}>Checking repository...</Text>
</View>
);
@@ -1371,7 +1415,7 @@ function DiffBodyContent({
if (isDiffLoading) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={foregroundMutedColor} />
<ThemedActivityIndicator size="large" uniProps={foregroundMutedIconColorMapping} />
</View>
);
}
@@ -1483,30 +1527,27 @@ function computePrErrorMessage(
return prPayloadError?.message ?? null;
}
function buildDiffModeTriggerStyle(surfaceColor: string): PressableStyleFn {
function buildDiffModeTriggerStyle(): PressableStyleFn {
return ({ hovered, pressed, open }) => [
styles.diffModeTrigger,
(Boolean(hovered) || pressed || Boolean(open)) &&
inlineUnistylesStyle({ backgroundColor: surfaceColor }),
(Boolean(hovered) || pressed || Boolean(open)) && styles.diffModeTriggerHovered,
];
}
function buildExpandAllButtonStyle(surfaceColor: string): PressableStyleFn {
function buildExpandAllButtonStyle(): PressableStyleFn {
return ({ hovered, pressed }) => [
styles.expandAllButton,
(Boolean(hovered) || pressed) && inlineUnistylesStyle({ backgroundColor: surfaceColor }),
(Boolean(hovered) || pressed) && styles.toggleButtonSelected,
];
}
function buildToggleButtonStyle(
selected: boolean,
baseStyles: StyleProp<ViewStyle> | StyleProp<ViewStyle>[],
surfaceColor: string,
): PressableStyleFn {
return ({ hovered, pressed }) => [
baseStyles,
(selected || Boolean(hovered) || pressed) &&
inlineUnistylesStyle({ backgroundColor: surfaceColor }),
(selected || Boolean(hovered) || pressed) && styles.toggleButtonSelected,
];
}
@@ -1521,7 +1562,7 @@ export function GitDiffPane({
hideHeaderRow,
enabled,
}: GitDiffPaneProps) {
const { theme } = useUnistyles();
const { settings: appSettings } = useAppSettings();
const isMobile = useIsCompactFormFactor();
const showDesktopWebScrollbar = isWeb && !isMobile;
const canUseSplitLayout = isWeb && !isMobile;
@@ -1557,56 +1598,44 @@ export function GitDiffPane({
handleLayoutChange("split");
}, [handleLayoutChange]);
const controlSurfaceColor = theme.colors.surface2;
const diffModeTriggerStyle = useMemo(
() => buildDiffModeTriggerStyle(controlSurfaceColor),
[controlSurfaceColor],
const codeFontSize = appSettings.codeFontSize;
const diffBodyLineHeight = Math.round(codeFontSize * 1.5);
const diffBodyTypographyKey = [appSettings.monoFontFamily, codeFontSize, diffBodyLineHeight].join(
":",
);
const diffModeTriggerStyle = useMemo(() => buildDiffModeTriggerStyle(), []);
const unifiedToggleStyle = useMemo(
() =>
buildToggleButtonStyle(
changesPreferences.layout === "unified",
[styles.toggleButton, styles.toggleButtonGroupStart],
controlSurfaceColor,
),
[changesPreferences.layout, controlSurfaceColor],
buildToggleButtonStyle(changesPreferences.layout === "unified", [
styles.toggleButton,
styles.toggleButtonGroupStart,
]),
[changesPreferences.layout],
);
const splitToggleStyle = useMemo(
() =>
buildToggleButtonStyle(
changesPreferences.layout === "split",
[styles.toggleButton, styles.toggleButtonGroupEnd],
controlSurfaceColor,
),
[changesPreferences.layout, controlSurfaceColor],
buildToggleButtonStyle(changesPreferences.layout === "split", [
styles.toggleButton,
styles.toggleButtonGroupEnd,
]),
[changesPreferences.layout],
);
const hideWhitespaceToggleStyle = useMemo(
() =>
buildToggleButtonStyle(
changesPreferences.hideWhitespace,
styles.expandAllButton,
controlSurfaceColor,
),
[changesPreferences.hideWhitespace, controlSurfaceColor],
() => buildToggleButtonStyle(changesPreferences.hideWhitespace, styles.expandAllButton),
[changesPreferences.hideWhitespace],
);
const wrapLinesToggleStyle = useMemo(
() => buildToggleButtonStyle(wrapLines, styles.expandAllButton, controlSurfaceColor),
[wrapLines, controlSurfaceColor],
() => buildToggleButtonStyle(wrapLines, styles.expandAllButton),
[wrapLines],
);
const expandAllToggleStyle = useMemo(
() => buildExpandAllButtonStyle(controlSurfaceColor),
[controlSurfaceColor],
);
const expandAllToggleStyle = useMemo(() => buildExpandAllButtonStyle(), []);
const refreshToggleStyle = useMemo(
() => buildExpandAllButtonStyle(controlSurfaceColor),
[controlSurfaceColor],
);
const refreshToggleStyle = useMemo(() => buildExpandAllButtonStyle(), []);
const toast = useToast();
const refreshSupported = useSessionStore(
@@ -1754,9 +1783,8 @@ export function GitDiffPane({
const bodyHeightByKeyRef = useRef<Record<string, number>>({});
const defaultHeaderHeightRef = useRef<number>(44);
const [heightVersion, setHeightVersion] = useState(0);
const diffBodyLineHeight = theme.lineHeight.diff;
const diffBodyChromeHeight = theme.borderWidth[1] * 2;
const statusBodyHeightEstimate = diffBodyChromeHeight + theme.spacing[4] * 2 + diffBodyLineHeight;
const diffBodyChromeHeight = BORDER_WIDTH[1] * 2;
const statusBodyHeightEstimate = diffBodyChromeHeight + SPACING[4] * 2 + diffBodyLineHeight;
const { flatItems, stickyHeaderIndices } = useMemo(() => {
const items: DiffFlatItem[] = [];
const stickyIndices: number[] = [];
@@ -1777,12 +1805,13 @@ export function GitDiffPane({
const getBodyHeightKey = useCallback(
(file: ParsedDiffFile): string => {
if (file.status === "too_large" || file.status === "binary") {
return `${effectiveLayout}:${wrapLines ? "wrap" : "scroll"}:${file.path}:${file.status}`;
return `${effectiveLayout}:${wrapLines ? "wrap" : "scroll"}:${diffBodyTypographyKey}:${file.path}:${file.status}`;
}
return [
effectiveLayout,
wrapLines ? "wrap" : "scroll",
diffBodyTypographyKey,
file.path,
file.status ?? "ok",
file.additions,
@@ -1792,7 +1821,7 @@ export function GitDiffPane({
getDiffContentLength(file),
].join(":");
},
[effectiveLayout, wrapLines],
[diffBodyTypographyKey, effectiveLayout, wrapLines],
);
const estimateBodyHeight = useCallback(
@@ -1961,6 +1990,7 @@ export function GitDiffPane({
file={item.file}
layout={effectiveLayout}
wrapLines={wrapLines}
codeFontSize={codeFontSize}
reviewActions={reviewActions}
onBodyHeightChange={handleBodyHeightChange}
testID={`diff-file-${item.fileIndex}-body`}
@@ -1968,6 +1998,7 @@ export function GitDiffPane({
);
},
[
codeFontSize,
effectiveLayout,
handleBodyHeightChange,
handleHeaderHeightChange,
@@ -2015,34 +2046,41 @@ export function GitDiffPane({
() => ({
expandedPathsArray,
effectiveLayout,
diffBodyTypographyKey,
heightVersion,
wrapLines,
reviewActions,
}),
[expandedPathsArray, effectiveLayout, heightVersion, wrapLines, reviewActions],
[
expandedPathsArray,
effectiveLayout,
diffBodyTypographyKey,
heightVersion,
wrapLines,
reviewActions,
],
);
const hasChanges = files.length > 0;
const diffErrorMessage = diffPayloadError?.message ?? null;
const prErrorMessage = computePrErrorMessage(githubFeaturesEnabled, prPayloadError);
const baseRefLabel = useMemo(() => computeBaseRefLabel(baseRef), [baseRef]);
const iconColor = theme.colors.foregroundMuted;
const gitActionsIcons = useMemo(
() => ({
commit: <GitCommitHorizontal size={16} color={iconColor} />,
pull: <Download size={16} color={iconColor} />,
push: <Upload size={16} color={iconColor} />,
pullAndPush: <ArrowDownUp size={16} color={iconColor} />,
viewPr: <GitHubIcon size={16} color={iconColor} />,
createPr: <GitHubIcon size={16} color={iconColor} />,
mergePrSquash: <GitHubIcon size={16} color={iconColor} />,
mergePrMerge: <GitHubIcon size={16} color={iconColor} />,
mergePrRebase: <GitHubIcon size={16} color={iconColor} />,
merge: <GitMerge size={16} color={iconColor} />,
mergeFromBase: <RefreshCcw size={16} color={iconColor} />,
archive: <Archive size={16} color={iconColor} />,
commit: <ThemedGitCommitHorizontal size={16} uniProps={foregroundMutedIconColorMapping} />,
pull: <ThemedDownload size={16} uniProps={foregroundMutedIconColorMapping} />,
push: <ThemedUpload size={16} uniProps={foregroundMutedIconColorMapping} />,
pullAndPush: <ThemedArrowDownUp size={16} uniProps={foregroundMutedIconColorMapping} />,
viewPr: <ThemedGitHubIcon size={16} uniProps={foregroundMutedIconColorMapping} />,
createPr: <ThemedGitHubIcon size={16} uniProps={foregroundMutedIconColorMapping} />,
mergePrSquash: <ThemedGitHubIcon size={16} uniProps={foregroundMutedIconColorMapping} />,
mergePrMerge: <ThemedGitHubIcon size={16} uniProps={foregroundMutedIconColorMapping} />,
mergePrRebase: <ThemedGitHubIcon size={16} uniProps={foregroundMutedIconColorMapping} />,
merge: <ThemedGitMerge size={16} uniProps={foregroundMutedIconColorMapping} />,
mergeFromBase: <ThemedRefreshCcw size={16} uniProps={foregroundMutedIconColorMapping} />,
archive: <ThemedArchive size={16} uniProps={foregroundMutedIconColorMapping} />,
}),
[iconColor],
[],
);
const { gitActions, branchLabel } = useGitActions({ serverId, cwd, icons: gitActionsIcons });
const committedDiffDescription = useMemo(
@@ -2076,7 +2114,6 @@ export function GitDiffPane({
handleDiffListScroll={handleDiffListScroll}
onContentSizeChange={scrollbar.onContentSizeChange}
showDesktopWebScrollbar={showDesktopWebScrollbar}
foregroundMutedColor={theme.colors.foregroundMuted}
/>
);
@@ -2085,7 +2122,7 @@ export function GitDiffPane({
{!hideHeaderRow ? (
<View style={styles.header} testID="changes-header">
<View style={styles.headerLeft}>
<GitBranch size={16} color={theme.colors.foregroundMuted} />
<ThemedGitBranch size={16} uniProps={foregroundMutedIconColorMapping} />
<Text style={styles.branchLabel} testID="changes-branch" numberOfLines={1}>
{branchLabel}
</Text>
@@ -2107,7 +2144,7 @@ export function GitDiffPane({
<Text style={styles.diffStatusText} numberOfLines={1}>
{diffMode === "uncommitted" ? "Uncommitted" : "Committed"}
</Text>
<ChevronDown size={12} color={theme.colors.foregroundMuted} />
<ThemedChevronDown size={12} uniProps={foregroundMutedIconColorMapping} />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" width={260} testID="changes-diff-status-menu">
<DropdownMenuItem
@@ -2551,13 +2588,15 @@ const styles = StyleSheet.create((theme) => ({
elevation: 4,
overflow: "visible",
},
diffTextMetrics: {
fontSize: theme.fontSize.code,
lineHeight: theme.lineHeight.diff,
fontFamily: theme.fontFamily.mono,
},
lineNumberText: {
width: "100%",
textAlign: "right",
paddingRight: theme.spacing[2],
fontSize: theme.fontSize.code,
lineHeight: theme.lineHeight.diff,
fontFamily: theme.fontFamily.mono,
color: theme.colors.foregroundMuted,
userSelect: "none",
},
@@ -2570,9 +2609,6 @@ const styles = StyleSheet.create((theme) => ({
diffLineText: {
flex: 1,
paddingRight: theme.spacing[3],
fontSize: theme.fontSize.code,
lineHeight: theme.lineHeight.diff,
fontFamily: theme.fontFamily.mono,
color: theme.colors.foreground,
userSelect: "text",
},
@@ -2621,7 +2657,7 @@ const styles = StyleSheet.create((theme) => ({
},
}));
const HEADER_LINE_TEXT_STYLE = [styles.diffLineText, styles.headerLineText];
const HEADER_LINE_TEXT_STYLE = [styles.diffTextMetrics, styles.diffLineText, styles.headerLineText];
const FILE_SECTION_BODY_STYLE = [styles.fileSectionBodyContainer, styles.fileSectionBorder];
const DIFF_CONTENT_SPLIT_ROW_STYLE = [styles.diffContent, styles.splitRow];
const DIFF_CONTENT_ROW_STYLE = [styles.diffContent, styles.diffContentRow];
+54 -4
View File
@@ -49,7 +49,7 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitAct
aheadOfOrigin: 0,
behindOfOrigin: 0,
shouldPromoteArchive: false,
shipDefault: "merge",
shipDefault: "pr",
runtime: {
commit: {
disabled: false,
@@ -252,6 +252,38 @@ describe("git-actions-policy", () => {
});
});
it("keeps pull-and-push unavailable when the branch only has outgoing commits", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
aheadOfOrigin: 2,
behindOfOrigin: 0,
}),
);
const action = actions.secondary.find((entry) => entry.id === "pull-and-push");
expect(action).toMatchObject({
label: "Pull and push",
unavailableMessage: expect.any(String),
});
});
it("keeps pull-and-push unavailable when the branch only has incoming commits", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
aheadOfOrigin: 0,
behindOfOrigin: 2,
}),
);
const action = actions.secondary.find((entry) => entry.id === "pull-and-push");
expect(action).toMatchObject({
label: "Pull and push",
unavailableMessage: expect.any(String),
});
});
it("explains why pull-and-push is unavailable when the branch is in sync", () => {
const actions = buildGitActions(createInput({ hasRemote: true }));
const action = actions.secondary.find((entry) => entry.id === "pull-and-push");
@@ -387,13 +419,31 @@ describe("git-actions-policy", () => {
});
});
it("promotes local merge over update-from-base", () => {
it("promotes Create PR over push and local merge when PR is the ship default", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 2,
aheadOfOrigin: 2,
behindBaseCount: 3,
}),
);
expect(actions.primary).toMatchObject({
id: "pr",
label: "Create PR",
});
});
it("uses local merge when merge is the stored ship default", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 2,
behindBaseCount: 3,
shipDefault: "merge",
}),
);
@@ -665,7 +715,7 @@ describe("git-actions-policy", () => {
}),
);
expect(actions.primary).toMatchObject({ id: "merge-branch", label: "Merge locally" });
expect(actions.primary).toMatchObject({ id: "pr", label: "View PR" });
expect(actions.secondary.some((action) => action.id.startsWith("enable-pr-auto-merge"))).toBe(
false,
);
@@ -766,7 +816,7 @@ describe("git-actions-policy", () => {
}),
);
expect(actions.primary).toMatchObject({ id: "merge-branch", label: "Merge locally" });
expect(actions.primary).toMatchObject({ id: "pr", label: "View PR" });
expect(
actions.secondary.some((action) =>
["merge-pr-squash", "merge-pr-merge", "merge-pr-rebase"].includes(action.id),
+22 -3
View File
@@ -333,9 +333,6 @@ function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
if (canPull(input)) {
return "pull";
}
if (canPush(input)) {
return "push";
}
if (canMergePr(input)) {
return getDefaultDirectPullRequestMergeActionId(input);
}
@@ -345,6 +342,12 @@ function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
if (hasEnabledPrAutoMerge(input)) {
return "pr";
}
if (input.shipDefault === "pr" && canUsePullRequestActionAsShipDefault(input)) {
return "pr";
}
if (canPush(input)) {
return "push";
}
if (!input.isOnBaseBranch && input.aheadCount > 0) {
return "merge-branch";
}
@@ -502,6 +505,16 @@ function canMergeFromBase(input: BuildGitActionsInput): boolean {
);
}
function canUsePullRequestActionAsShipDefault(input: BuildGitActionsInput): boolean {
if (input.isOnBaseBranch || !input.githubFeaturesEnabled) {
return false;
}
if (input.hasPullRequest) {
return input.pullRequestUrl !== null;
}
return input.aheadCount > 0;
}
function canMergePr(input: BuildGitActionsInput): boolean {
const github = input.pullRequestGithub;
const canMergeFromPullRequestStatus =
@@ -603,6 +616,12 @@ function getPullAndPushUnavailableMessage(input: BuildGitActionsInput): string |
if (input.behindOfOrigin === 0 && input.aheadOfOrigin === 0) {
return "Pull and push isn't available because this branch is already in sync";
}
if (input.behindOfOrigin === 0) {
return "Pull and push isn't available because there are no incoming changes to pull first";
}
if (input.aheadOfOrigin === 0) {
return "Pull and push isn't available because there is nothing new to send after pulling";
}
return undefined;
}
+6 -2
View File
@@ -155,7 +155,7 @@ interface UseGitActionsResult {
export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): UseGitActionsResult {
const toast = useToast();
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("pr");
const { status, isLoading: isStatusLoading } = useCheckoutStatusQuery({ serverId, cwd });
const gitStatus = status && status.isGit ? status : null;
@@ -186,15 +186,19 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
useEffect(() => {
if (!shipDefaultStorageKey) {
setShipDefault("pr");
return;
}
let isActive = true;
setShipDefault("pr");
AsyncStorage.getItem(shipDefaultStorageKey)
.then((value) => {
if (!isActive) return;
if (value === "pr" || value === "merge") {
setShipDefault(value);
return;
}
setShipDefault("pr");
return;
})
.catch(() => undefined);
@@ -210,7 +214,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
try {
await AsyncStorage.setItem(shipDefaultStorageKey, next);
} catch {
// Ignore persistence failures; default will reset to "merge".
// Ignore persistence failures; default will reset to "pr".
}
},
[shipDefaultStorageKey],
@@ -39,4 +39,13 @@ describe("providers snapshot query scope", () => {
providers: ["codex"],
});
});
it("uses one query scope for Windows cwd values with either separator", () => {
expect(normalizeProvidersSnapshotCwd("C:\\Users\\Ezekiel Bulver\\project")).toBe(
"C:/Users/Ezekiel Bulver/project",
);
expect(providersSnapshotQueryKey("server-1", "C:\\Users\\Ezekiel Bulver\\project")).toEqual(
providersSnapshotQueryKey("server-1", "C:/Users/Ezekiel Bulver/project"),
);
});
});
@@ -1,10 +1,10 @@
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import { normalizeWorkspacePath } from "@/utils/workspace-identity";
export const PROVIDERS_SNAPSHOT_QUERY_ROOT = "providersSnapshot";
export function normalizeProvidersSnapshotCwd(cwd?: string | null): string | null {
const trimmed = cwd?.trim();
return trimmed ? trimmed : null;
return normalizeWorkspacePath(cwd);
}
export function providersSnapshotQueryRoot(serverId: string | null) {
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import type { WorkspaceStructureProject } from "@/stores/session-store-hooks";
import type { WorkspaceStructureProject } from "@/projects/workspace-structure";
import {
appendMissingOrderKeys,
applyStoredOrdering,
@@ -1,6 +1,10 @@
import type { PrHint } from "@/git/use-pr-status-query";
import {
canCreateWorktreeForProjectKind,
type HostProjectListItem,
} from "@/projects/host-project-model";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import type { WorkspaceStructureProject } from "@/stores/session-store-hooks";
import type { WorkspaceStructureProject } from "@/projects/workspace-structure";
const EMPTY_PROJECTS: SidebarProjectEntry[] = [];
@@ -32,12 +36,13 @@ export interface SidebarProjectEntry {
projectName: string;
projectKind: WorkspaceDescriptor["projectKind"];
iconWorkingDir: string;
canCreateWorktree: boolean;
workspaces: SidebarWorkspaceEntry[];
}
function createStructuralWorkspaceEntry(input: {
serverId: string;
project: WorkspaceStructureProject;
project: HostProjectListItem;
workspaceId: string;
}): SidebarWorkspaceEntry {
return {
@@ -65,6 +70,22 @@ function createStructuralWorkspaceEntry(input: {
export function buildSidebarProjectsFromStructure(input: {
serverId: string;
projects: WorkspaceStructureProject[];
}): SidebarProjectEntry[] {
return buildSidebarProjectsFromHostProjects({
projects: input.projects.map((project) => ({
serverId: input.serverId,
projectKey: project.projectKey,
projectName: project.projectName,
projectKind: project.projectKind,
iconWorkingDir: project.iconWorkingDir,
workspaceKeys: project.workspaceKeys,
canCreateWorktree: canCreateWorktreeForProjectKind(project.projectKind),
})),
});
}
export function buildSidebarProjectsFromHostProjects(input: {
projects: readonly HostProjectListItem[];
}): SidebarProjectEntry[] {
if (input.projects.length === 0) {
return EMPTY_PROJECTS;
@@ -75,9 +96,10 @@ export function buildSidebarProjectsFromStructure(input: {
projectName: project.projectName,
projectKind: project.projectKind,
iconWorkingDir: project.iconWorkingDir,
canCreateWorktree: project.canCreateWorktree,
workspaces: project.workspaceKeys.map((workspaceId) =>
createStructuralWorkspaceEntry({
serverId: input.serverId,
serverId: project.serverId,
project,
workspaceId,
}),
+32 -16
View File
@@ -20,6 +20,7 @@ import { getIsElectronRuntime } from "@/constants/layout";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { focusWithRetries } from "@/utils/web-focus";
import { useActiveServerId } from "@/hooks/use-active-server-id";
import { isWeb } from "@/constants/platform";
const EMPTY_AGENTS: AggregatedAgent[] = [];
const EMPTY_ACTION_ITEMS: CommandCenterActionItem[] = [];
@@ -319,33 +320,25 @@ export function useCommandCenter() {
}
}, [activeIndex, items.length, open]);
useEffect(() => {
if (!open) return;
const handler = (event: KeyboardEvent) => {
const handleKeyEvent = useCallback(
(key: string): boolean => {
if (!open) return false;
const currentItems = itemsRef.current;
const key = event.key;
if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Enter" && key !== "Escape") {
return;
}
if (key === "Escape") {
event.preventDefault();
handleCloseRef.current();
return;
return true;
}
if (key === "Enter") {
if (currentItems.length === 0) return;
event.preventDefault();
if (currentItems.length === 0) return false;
const index = Math.max(0, Math.min(activeIndexRef.current, currentItems.length - 1));
handleSelectItemRef.current(currentItems[index]);
return;
return true;
}
if (key === "ArrowDown" || key === "ArrowUp") {
if (currentItems.length === 0) return;
event.preventDefault();
if (currentItems.length === 0) return false;
setActiveIndex((current) => {
const delta = key === "ArrowDown" ? 1 : -1;
const next = current + delta;
@@ -353,13 +346,35 @@ export function useCommandCenter() {
if (next >= currentItems.length) return 0;
return next;
});
return true;
}
return false;
},
[open],
);
useEffect(() => {
if (!open || !isWeb) return;
const handler = (event: KeyboardEvent) => {
if (
event.key !== "ArrowDown" &&
event.key !== "ArrowUp" &&
event.key !== "Enter" &&
event.key !== "Escape"
) {
return;
}
if (handleKeyEvent(event.key)) {
event.preventDefault();
}
};
// react-native-web can stop propagation on key events, so listen in capture phase.
window.addEventListener("keydown", handler, true);
return () => window.removeEventListener("keydown", handler, true);
}, [open]);
}, [open, handleKeyEvent]);
return {
open,
@@ -371,5 +386,6 @@ export function useCommandCenter() {
items,
handleClose,
handleSelectItem,
handleKeyEvent,
};
}
@@ -2,7 +2,11 @@ import { useCallback, useMemo } from "react";
import { Gesture } from "react-native-gesture-handler";
import { Extrapolation, interpolate, runOnJS, useSharedValue } from "react-native-reanimated";
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import {
MOBILE_VISUAL_PANEL_AGENT,
MOBILE_VISUAL_PANEL_FILE_EXPLORER,
useSidebarAnimation,
} from "@/contexts/sidebar-animation-context";
import { isWeb } from "@/constants/platform";
interface UseExplorerOpenGestureParams {
@@ -23,14 +27,19 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu
gestureAnimatingRef,
openGestureRef,
} = useExplorerSidebarAnimation();
const { openGestureRef: leftOpenGestureRef } = useSidebarAnimation();
const {
mobileVisualPanel,
gestureAnimatingRef: mobilePanelGestureAnimatingRef,
openGestureRef: leftOpenGestureRef,
} = useSidebarAnimation();
const touchStartX = useSharedValue(0);
const touchStartY = useSharedValue(0);
const handleGestureOpen = useCallback(() => {
gestureAnimatingRef.current = true;
mobilePanelGestureAnimatingRef.current = true;
onOpen();
}, [onOpen, gestureAnimatingRef]);
}, [onOpen, gestureAnimatingRef, mobilePanelGestureAnimatingRef]);
return useMemo(
() =>
@@ -59,6 +68,11 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu
const absDeltaX = Math.abs(deltaX);
const absDeltaY = Math.abs(deltaY);
if (mobileVisualPanel.value !== MOBILE_VISUAL_PANEL_AGENT) {
stateManager.fail();
return;
}
// Browser back-swipe owns most of the viewport; keep this gesture on the right edge.
if (isWeb && touchStartX.value < windowWidth - MOBILE_WEB_EDGE_SWIPE_WIDTH) {
stateManager.fail();
@@ -103,9 +117,11 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu
const shouldOpenByVelocity = event.velocityX < -500;
const shouldOpen = shouldOpenByPosition || shouldOpenByVelocity;
if (shouldOpen) {
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_FILE_EXPLORER;
animateToOpen();
runOnJS(handleGestureOpen)();
} else {
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT;
animateToClose();
}
})
@@ -117,6 +133,7 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu
windowWidth,
translateX,
backdropOpacity,
mobileVisualPanel,
animateToOpen,
animateToClose,
isGesturing,
+21 -127
View File
@@ -1,137 +1,35 @@
import { useCallback } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { z } from "zod";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import {
buildFavoriteModelKey,
DEFAULT_FORM_PREFERENCES,
isFavoriteModel,
mergeProviderPreferences,
toggleFavoriteModel,
type FavoriteModelPreference,
type FavoriteModelRow,
type FormPreferences,
type ProviderPreferences,
} from "@/create-agent-preferences/preferences";
import {
createAgentPreferencesService,
type FormPreferenceUpdate,
} from "@/create-agent-preferences/service";
const FORM_PREFERENCES_STORAGE_KEY = "@paseo:create-agent-preferences";
const FORM_PREFERENCES_QUERY_KEY = ["form-preferences"];
export interface FavoriteModelPreference {
provider: string;
modelId: string;
}
export type { FavoriteModelPreference, FavoriteModelRow, FormPreferences, ProviderPreferences };
export interface FavoriteModelRow {
favoriteKey: string;
provider: string;
providerLabel: string;
modelId: string;
modelLabel: string;
description?: string;
}
const providerPreferencesSchema = z.object({
model: z.string().optional(),
mode: z.string().optional(),
thinkingByModel: z.record(z.string()).optional(),
featureValues: z.record(z.unknown()).optional(),
});
const formPreferencesSchema = z.object({
provider: z.string().optional(),
providerPreferences: z.record(providerPreferencesSchema).optional(),
favoriteModels: z
.array(
z.object({
provider: z.string(),
modelId: z.string(),
}),
)
.optional(),
});
export type ProviderPreferences = z.infer<typeof providerPreferencesSchema>;
export type FormPreferences = z.infer<typeof formPreferencesSchema>;
const DEFAULT_FORM_PREFERENCES: FormPreferences = {};
export { buildFavoriteModelKey, isFavoriteModel, mergeProviderPreferences, toggleFavoriteModel };
async function loadFormPreferences(): Promise<FormPreferences> {
const stored = await AsyncStorage.getItem(FORM_PREFERENCES_STORAGE_KEY);
if (!stored) return DEFAULT_FORM_PREFERENCES;
const result = formPreferencesSchema.safeParse(JSON.parse(stored));
return result.success ? result.data : DEFAULT_FORM_PREFERENCES;
return createAgentPreferencesService.load();
}
export interface UseFormPreferencesReturn {
preferences: FormPreferences;
isLoading: boolean;
updatePreferences: (
updates: Partial<FormPreferences> | ((current: FormPreferences) => FormPreferences),
) => Promise<void>;
}
export function mergeProviderPreferences(args: {
preferences: FormPreferences;
provider: AgentProvider;
updates: Partial<ProviderPreferences>;
}): FormPreferences {
const { preferences, provider, updates } = args;
const existingProviderPreferences = preferences.providerPreferences ?? {};
const existing = existingProviderPreferences[provider] ?? {};
const nextThinkingByModel =
updates.thinkingByModel === undefined
? existing.thinkingByModel
: {
...existing.thinkingByModel,
...updates.thinkingByModel,
};
const nextFeatureValues =
updates.featureValues === undefined
? existing.featureValues
: {
...existing.featureValues,
...updates.featureValues,
};
return {
...preferences,
provider,
providerPreferences: {
...existingProviderPreferences,
[provider]: {
...existing,
...updates,
...(nextThinkingByModel ? { thinkingByModel: nextThinkingByModel } : {}),
...(nextFeatureValues ? { featureValues: nextFeatureValues } : {}),
},
},
};
}
export function buildFavoriteModelKey(input: FavoriteModelPreference): string {
return `${input.provider}:${input.modelId}`;
}
export function isFavoriteModel(args: {
preferences: FormPreferences;
provider: string;
modelId: string;
}): boolean {
const favoriteKey = buildFavoriteModelKey({ provider: args.provider, modelId: args.modelId });
return (args.preferences.favoriteModels ?? []).some(
(favorite) => buildFavoriteModelKey(favorite) === favoriteKey,
);
}
export function toggleFavoriteModel(args: {
preferences: FormPreferences;
provider: string;
modelId: string;
}): FormPreferences {
const favorite = { provider: args.provider, modelId: args.modelId };
const favoriteKey = buildFavoriteModelKey(favorite);
const existingFavorites = args.preferences.favoriteModels ?? [];
const hasFavorite = existingFavorites.some(
(entry) => buildFavoriteModelKey(entry) === favoriteKey,
);
return {
...args.preferences,
favoriteModels: hasFavorite
? existingFavorites.filter((entry) => buildFavoriteModelKey(entry) !== favoriteKey)
: [...existingFavorites, favorite],
};
updatePreferences: (updates: FormPreferenceUpdate) => Promise<void>;
}
export function useFormPreferences(): UseFormPreferencesReturn {
@@ -146,13 +44,9 @@ export function useFormPreferences(): UseFormPreferencesReturn {
const preferences = data ?? DEFAULT_FORM_PREFERENCES;
const updatePreferences = useCallback(
async (updates: Partial<FormPreferences> | ((current: FormPreferences) => FormPreferences)) => {
const prev =
queryClient.getQueryData<FormPreferences>(FORM_PREFERENCES_QUERY_KEY) ??
DEFAULT_FORM_PREFERENCES;
const next = typeof updates === "function" ? updates(prev) : { ...prev, ...updates };
async (updates: FormPreferenceUpdate) => {
const next = await createAgentPreferencesService.update(updates);
queryClient.setQueryData<FormPreferences>(FORM_PREFERENCES_QUERY_KEY, next);
await AsyncStorage.setItem(FORM_PREFERENCES_STORAGE_KEY, JSON.stringify(next));
},
[queryClient],
);
+1 -1
View File
@@ -6,7 +6,7 @@ import {
type ProjectsHostInput,
type ProjectsRuntime,
type ProjectsRuntimeSnapshot,
} from "./use-projects";
} from "@/projects/aggregated-projects";
type FetchWorkspaces = DaemonClient["fetchWorkspaces"];
type FetchWorkspacesResult = Awaited<ReturnType<FetchWorkspaces>>;
+12 -119
View File
@@ -1,18 +1,21 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
import { normalizeWorkspaceDescriptor, type WorkspaceDescriptor } from "@/stores/session-store";
import { buildProjects, type ProjectHost, type ProjectSummary } from "@/utils/projects";
import type { ProjectSummary } from "@/utils/projects";
import {
fetchAggregatedProjects,
type ProjectHostError,
type ProjectsHostInput,
} from "@/projects/aggregated-projects";
export type {
ProjectHostError,
ProjectsHostInput,
ProjectsRuntime,
} from "@/projects/aggregated-projects";
export const projectsQueryKey = ["projects"] as const;
export interface ProjectHostError {
serverId: string;
serverName: string;
message: string;
}
export interface UseProjectsResult {
projects: ProjectSummary[];
hostErrors: ProjectHostError[];
@@ -21,116 +24,6 @@ export interface UseProjectsResult {
refetch: () => void;
}
export interface ProjectsRuntimeSnapshot {
connectionStatus: string;
}
export interface ProjectsRuntime {
getClient(serverId: string): Pick<DaemonClient, "fetchWorkspaces"> | null;
getSnapshot(serverId: string): ProjectsRuntimeSnapshot | null | undefined;
}
export interface ProjectsHostInput {
serverId: string;
serverName: string;
}
export interface FetchAggregatedProjectsInput {
hosts: ProjectsHostInput[];
runtime: ProjectsRuntime;
}
export interface FetchAggregatedProjectsResult {
projects: ProjectSummary[];
hostErrors: ProjectHostError[];
}
interface HostWorkspacesResult {
host: ProjectHost;
error: ProjectHostError | null;
}
function toErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
async function fetchAllWorkspaceDescriptors(
client: Pick<DaemonClient, "fetchWorkspaces">,
): Promise<WorkspaceDescriptor[]> {
const entries: WorkspaceDescriptor[] = [];
let cursor: string | null = null;
while (true) {
const payload = await client.fetchWorkspaces({
sort: [{ key: "name", direction: "asc" }],
page: cursor ? { limit: 200, cursor } : { limit: 200 },
});
entries.push(...payload.entries.map((entry) => normalizeWorkspaceDescriptor(entry)));
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
break;
}
cursor = payload.pageInfo.nextCursor;
}
return entries;
}
export async function fetchAggregatedProjects(
input: FetchAggregatedProjectsInput,
): Promise<FetchAggregatedProjectsResult> {
const results = await Promise.all(
input.hosts.map(async (host): Promise<HostWorkspacesResult> => {
const snapshot = input.runtime.getSnapshot(host.serverId);
const isOnline = snapshot?.connectionStatus === "online";
const client = input.runtime.getClient(host.serverId);
if (!client || !isOnline) {
return {
host: {
serverId: host.serverId,
serverName: host.serverName,
isOnline,
workspaces: [],
},
error: null,
};
}
try {
return {
host: {
serverId: host.serverId,
serverName: host.serverName,
isOnline,
workspaces: await fetchAllWorkspaceDescriptors(client),
},
error: null,
};
} catch (error) {
return {
host: {
serverId: host.serverId,
serverName: host.serverName,
isOnline,
workspaces: [],
},
error: {
serverId: host.serverId,
serverName: host.serverName,
message: toErrorMessage(error),
},
};
}
}),
);
const hostErrors = results.flatMap((result) => (result.error ? [result.error] : []));
return {
...buildProjects({ hosts: results.map((result) => result.host) }),
hostErrors,
};
}
export function useProjects(): UseProjectsResult {
const hosts = useHosts();
const runtime = getHostRuntimeStore();
@@ -250,6 +250,27 @@ describe("applyProvidersSnapshotUpdate", () => {
providersSnapshot([]),
);
});
it("applies Windows daemon updates to app-normalized workspace paths", () => {
const workspaceCwd = "C:/Users/Ezekiel Bulver/project";
const daemonCwd = "C:\\Users\\Ezekiel Bulver\\project";
queryClient.setQueryData(
providersSnapshotQueryKey(serverId, workspaceCwd),
providersSnapshot([codexEntry("loading")]),
);
applyProvidersSnapshotUpdate({
serverId,
queryClient,
message: updateMessage([codexEntry("ready", [readyCodexModel])], daemonCwd),
});
expect(queryClient.getQueryData(providersSnapshotQueryKey(serverId, workspaceCwd))).toEqual({
entries: [codexEntry("ready", [readyCodexModel])],
generatedAt: "2026-01-01T00:00:01.000Z",
requestId: "providers_snapshot_update",
});
});
});
describe("selectorOpenRefetchDecision", () => {
@@ -1,17 +1,17 @@
import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages";
import {
normalizeWorkspaceDescriptor,
useSessionStore,
type WorkspaceDescriptor,
} from "@/stores/session-store";
import { useCreateFlowStore, type PendingCreateAttempt } from "@/stores/create-flow-store";
import { useSessionStore, type Agent, type WorkspaceDescriptor } from "@/stores/session-store";
import { useWorkspaceFields } from "@/stores/session-store-hooks";
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
import { normalizeWorkspacePath } from "@/utils/workspace-identity";
import { selectPrHintFromStatus } from "@/git/use-pr-status-query";
import { useWorkspaceStructure } from "@/stores/session-store-hooks";
import { useHostProjects } from "@/projects/host-projects";
import { fetchAllWorkspaceDescriptors } from "@/projects/workspace-fetching";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import { shouldSuppressWorkspaceForLocalArchive } from "@/contexts/session-workspace-upserts";
import {
buildSidebarProjectsFromStructure,
buildSidebarProjectsFromHostProjects,
computeSidebarOrderUpdates,
deriveSidebarLoadingState,
type SidebarProjectEntry,
@@ -21,6 +21,7 @@ import {
export {
appendMissingOrderKeys,
applyStoredOrdering,
buildSidebarProjectsFromHostProjects,
buildSidebarProjectsFromStructure,
computeSidebarOrderUpdates,
deriveSidebarLoadingState,
@@ -34,7 +35,10 @@ export {
export function createSidebarWorkspaceEntry(input: {
serverId: string;
workspace: WorkspaceDescriptor;
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
agents?: Map<string, Agent>;
}): SidebarWorkspaceEntry {
const effectiveStatus = deriveEffectiveWorkspaceStatus(input);
return {
workspaceKey: `${input.serverId}:${input.workspace.id}`,
serverId: input.serverId,
@@ -45,8 +49,8 @@ export function createSidebarWorkspaceEntry(input: {
projectKind: input.workspace.projectKind,
workspaceKind: input.workspace.workspaceKind,
name: input.workspace.name,
statusBucket: input.workspace.status,
statusEnteredAt: input.workspace.statusEnteredAt,
statusBucket: effectiveStatus.status,
statusEnteredAt: effectiveStatus.enteredAt,
archivingAt: input.workspace.archivingAt,
diffStat: input.workspace.diffStat,
prHint: selectPrHintFromStatus(input.workspace.githubRuntime?.pullRequest),
@@ -57,6 +61,110 @@ export function createSidebarWorkspaceEntry(input: {
};
}
interface EffectiveWorkspaceStatus {
status: WorkspaceDescriptor["status"];
enteredAt: Date | null;
}
interface WorkspaceAgentActivity extends EffectiveWorkspaceStatus {}
function deriveEffectiveWorkspaceStatus(input: {
serverId: string;
workspace: WorkspaceDescriptor;
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
agents?: Map<string, Agent>;
}): EffectiveWorkspaceStatus {
if (input.workspace.status !== "done") {
return { status: input.workspace.status, enteredAt: input.workspace.statusEnteredAt };
}
const pendingStartedAt = getPendingInitialAgentCreateStartedAt({
serverId: input.serverId,
workspaceId: input.workspace.id,
pendingCreateAttempts: input.pendingCreateAttempts,
});
if (pendingStartedAt) {
return { status: "running", enteredAt: pendingStartedAt };
}
const rootAgentActivity = getRootAgentWorkspaceActivity({
workspace: input.workspace,
agents: input.agents,
});
if (rootAgentActivity && rootAgentActivity.status !== "done") {
return rootAgentActivity;
}
return { status: input.workspace.status, enteredAt: input.workspace.statusEnteredAt };
}
function getPendingInitialAgentCreateStartedAt(input: {
serverId: string;
workspaceId: string;
pendingCreateAttempts: Record<string, PendingCreateAttempt> | undefined;
}): Date | null {
let latestStartedAt: Date | null = null;
for (const pending of Object.values(input.pendingCreateAttempts ?? {})) {
if (pending.serverId !== input.serverId) continue;
if (pending.workspaceId !== input.workspaceId) continue;
if (pending.lifecycle === "abandoned") continue;
const startedAt = new Date(pending.timestamp);
if (!latestStartedAt || startedAt > latestStartedAt) {
latestStartedAt = startedAt;
}
}
return latestStartedAt;
}
function getRootAgentWorkspaceActivity(input: {
workspace: WorkspaceDescriptor;
agents: Map<string, Agent> | undefined;
}): WorkspaceAgentActivity | null {
const workspaceDirectory = normalizeWorkspacePath(input.workspace.workspaceDirectory);
if (!workspaceDirectory) {
return null;
}
let latest: WorkspaceAgentActivity | null = null;
for (const agent of input.agents?.values() ?? []) {
if (agent.archivedAt || agent.parentAgentId) continue;
if (normalizeWorkspacePath(agent.cwd) !== workspaceDirectory) continue;
const status = deriveSidebarStateBucket({
status: agent.status,
pendingPermissionCount: agent.pendingPermissions.length,
requiresAttention: agent.requiresAttention,
attentionReason: agent.attentionReason,
});
const enteredAt = agent.attentionTimestamp ?? agent.updatedAt;
if (!latest || enteredAt > (latest.enteredAt ?? new Date(0))) {
latest = { status, enteredAt };
}
}
return latest;
}
export function useSidebarWorkspaceEntry(
serverId: string | null,
workspaceId: string | null,
): SidebarWorkspaceEntry | null {
const pendingCreateAttempts = useCreateFlowStore((state) => state.pendingByDraftId);
const agents = useSessionStore((state) =>
serverId ? state.sessions[serverId]?.agents : undefined,
);
const projectWorkspaceEntry = useCallback(
(workspace: WorkspaceDescriptor): SidebarWorkspaceEntry =>
createSidebarWorkspaceEntry({
serverId: serverId ?? "",
workspace,
pendingCreateAttempts,
agents,
}),
[agents, pendingCreateAttempts, serverId],
);
return useWorkspaceFields(serverId, workspaceId, projectWorkspaceEntry);
}
const EMPTY_ORDER: string[] = [];
const EMPTY_PROJECTS: SidebarProjectEntry[] = [];
@@ -68,10 +176,6 @@ export interface SidebarWorkspacesListResult {
refreshAll: () => void;
}
function toWorkspaceDescriptor(payload: WorkspaceDescriptorPayload): WorkspaceDescriptor {
return normalizeWorkspaceDescriptor(payload);
}
export function useSidebarWorkspacesList(options?: {
serverId?: string | null;
enabled?: boolean;
@@ -89,7 +193,7 @@ export function useSidebarWorkspacesList(options?: {
const hasHydratedWorkspaces = useSessionStore((state) =>
isActive && serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false,
);
const workspaceStructure = useWorkspaceStructure(isActive ? serverId : null);
const hostProjects = useHostProjects(isActive ? serverId : null);
const connectionStatus = useSyncExternalStore(
(onStoreChange) =>
@@ -111,14 +215,13 @@ export function useSidebarWorkspacesList(options?: {
);
const projects = useMemo(() => {
if (!serverId || workspaceStructure.projects.length === 0) {
if (!serverId || hostProjects.length === 0) {
return EMPTY_PROJECTS;
}
return buildSidebarProjectsFromStructure({
serverId,
projects: workspaceStructure.projects,
return buildSidebarProjectsFromHostProjects({
projects: hostProjects,
});
}, [serverId, workspaceStructure]);
}, [hostProjects, serverId]);
useEffect(() => {
if (!serverId) {
@@ -156,24 +259,16 @@ export function useSidebarWorkspacesList(options?: {
}
void (async () => {
const next = new Map<string, WorkspaceDescriptor>();
let cursor: string | null = null;
try {
while (true) {
const payload = await client.fetchWorkspaces({
sort: [{ key: "activity_at", direction: "desc" }],
page: cursor ? { limit: 200, cursor } : { limit: 200 },
});
for (const entry of payload.entries) {
const workspace = toWorkspaceDescriptor(entry);
if (shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) {
continue;
}
next.set(workspace.id, workspace);
const workspaces = await fetchAllWorkspaceDescriptors({
client,
sort: [{ key: "activity_at", direction: "desc" }],
});
for (const workspace of workspaces) {
if (shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) {
continue;
}
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
break;
}
cursor = payload.pageInfo.nextCursor;
next.set(workspace.id, workspace);
}
const store = useSessionStore.getState();
store.setWorkspaces(serverId, next);
@@ -181,7 +276,6 @@ export function useSidebarWorkspacesList(options?: {
} catch (error) {
console.error("[WorkspaceFetch][sidebar-refresh] failed", {
serverId,
cursor,
error,
});
// ignore explicit refresh failures; hook keeps existing data
@@ -1,21 +1,47 @@
import { useMemo } from "react";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { useSessionStore } from "@/stores/session-store";
import {
createSidebarWorkspaceEntry,
type SidebarProjectEntry,
type SidebarWorkspaceEntry,
} from "./use-sidebar-workspaces-list";
export function useHydratedWorkspaceEntries(serverId: string | null): SidebarWorkspaceEntry[] {
const EMPTY_WORKSPACES: SidebarWorkspaceEntry[] = [];
export function useStatusModeWorkspaceEntries(input: {
serverId: string | null;
projects: SidebarProjectEntry[];
}): SidebarWorkspaceEntry[] {
const workspaces = useSessionStore((state) =>
serverId ? state.sessions[serverId]?.workspaces : undefined,
input.serverId ? state.sessions[input.serverId]?.workspaces : undefined,
);
const agents = useSessionStore((state) =>
input.serverId ? state.sessions[input.serverId]?.agents : undefined,
);
const pendingCreateAttempts = useCreateFlowStore((state) => state.pendingByDraftId);
return useMemo(() => {
if (!serverId || !workspaces || workspaces.size === 0) return [];
return Array.from(workspaces.values()).map((workspace: WorkspaceDescriptor) =>
createSidebarWorkspaceEntry({ serverId, workspace }),
);
}, [serverId, workspaces]);
if (!input.serverId || input.projects.length === 0 || !workspaces) {
return EMPTY_WORKSPACES;
}
const entries: SidebarWorkspaceEntry[] = [];
for (const placedWorkspace of input.projects.flatMap((project) => project.workspaces)) {
const workspace = workspaces.get(placedWorkspace.workspaceId);
entries.push(
workspace
? createSidebarWorkspaceEntry({
serverId: input.serverId,
workspace,
pendingCreateAttempts,
agents,
})
: placedWorkspace,
);
}
return entries;
}, [agents, input.projects, input.serverId, pendingCreateAttempts, workspaces]);
}
export function useProjectNamesMap(serverId: string | null): Map<string, string> {
@@ -0,0 +1,103 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { fetchAllWorkspaceDescriptors } from "@/projects/workspace-fetching";
import { buildProjects, type ProjectHost, type ProjectSummary } from "@/utils/projects";
export interface ProjectHostError {
serverId: string;
serverName: string;
message: string;
}
export interface ProjectsRuntimeSnapshot {
connectionStatus: string;
}
export interface ProjectsRuntime {
getClient(serverId: string): Pick<DaemonClient, "fetchWorkspaces"> | null;
getSnapshot(serverId: string): ProjectsRuntimeSnapshot | null | undefined;
}
export interface ProjectsHostInput {
serverId: string;
serverName: string;
}
export interface FetchAggregatedProjectsInput {
hosts: ProjectsHostInput[];
runtime: ProjectsRuntime;
}
export interface FetchAggregatedProjectsResult {
projects: ProjectSummary[];
hostErrors: ProjectHostError[];
}
interface HostWorkspacesResult {
host: ProjectHost;
error: ProjectHostError | null;
}
function toErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export async function fetchAggregatedProjects(
input: FetchAggregatedProjectsInput,
): Promise<FetchAggregatedProjectsResult> {
const results = await Promise.all(
input.hosts.map(async (host): Promise<HostWorkspacesResult> => {
const snapshot = input.runtime.getSnapshot(host.serverId);
const isOnline = snapshot?.connectionStatus === "online";
const client = input.runtime.getClient(host.serverId);
if (!client || !isOnline) {
return {
host: {
serverId: host.serverId,
serverName: host.serverName,
isOnline,
workspaces: [],
},
error: null,
};
}
try {
const workspaces: WorkspaceDescriptor[] = await fetchAllWorkspaceDescriptors({
client,
sort: [{ key: "name", direction: "asc" }],
});
return {
host: {
serverId: host.serverId,
serverName: host.serverName,
isOnline,
workspaces,
},
error: null,
};
} catch (error) {
return {
host: {
serverId: host.serverId,
serverName: host.serverName,
isOnline,
workspaces: [],
},
error: {
serverId: host.serverId,
serverName: host.serverName,
message: toErrorMessage(error),
},
};
}
}),
);
const hostErrors = results.flatMap((result) => (result.error ? [result.error] : []));
return {
...buildProjects({ hosts: results.map((result) => result.host) }),
hostErrors,
};
}
@@ -0,0 +1,117 @@
import type { WorkspaceDescriptor } from "@/stores/session-store";
import type { WorkspaceStructureProject } from "@/projects/workspace-structure";
export interface HostProjectListItem {
serverId: string;
projectKey: string;
projectName: string;
projectKind: WorkspaceDescriptor["projectKind"];
iconWorkingDir: string;
workspaceKeys: string[];
canCreateWorktree: boolean;
}
export interface HostProjectRouteContext {
serverId: string;
projectId?: string;
displayName?: string;
sourceDirectory?: string;
}
function trimOptional(value: string | undefined): string | undefined {
const trimmed = value?.trim() ?? "";
return trimmed.length > 0 ? trimmed : undefined;
}
export function canCreateWorktreeForProjectKind(
projectKind: WorkspaceDescriptor["projectKind"],
): boolean {
return projectKind === "git";
}
export function buildHostProjectList(input: {
serverId: string;
projects: readonly WorkspaceStructureProject[];
}): HostProjectListItem[] {
return input.projects.map((project) => ({
serverId: input.serverId,
projectKey: project.projectKey,
projectName: project.projectName,
projectKind: project.projectKind,
iconWorkingDir: project.iconWorkingDir,
workspaceKeys: project.workspaceKeys,
canCreateWorktree: canCreateWorktreeForProjectKind(project.projectKind),
}));
}
export function hostProjectFromRoute(route: HostProjectRouteContext): HostProjectListItem | null {
const projectKey = trimOptional(route.projectId);
const iconWorkingDir = trimOptional(route.sourceDirectory);
if (!projectKey || !iconWorkingDir) {
return null;
}
return {
serverId: route.serverId,
projectKey,
projectName: trimOptional(route.displayName) ?? projectKey,
projectKind: "git",
iconWorkingDir,
workspaceKeys: [],
canCreateWorktree: true,
};
}
export function hostProjectFromWorkspace(input: {
serverId: string;
workspace: WorkspaceDescriptor | null;
}): HostProjectListItem | null {
if (!input.workspace) {
return null;
}
const projectKey = input.workspace.projectId.trim();
const iconWorkingDir = input.workspace.projectRootPath.trim();
if (!projectKey || !iconWorkingDir) {
return null;
}
return {
serverId: input.serverId,
projectKey,
projectName: input.workspace.projectDisplayName || projectKey,
projectKind: input.workspace.projectKind,
iconWorkingDir,
workspaceKeys: [input.workspace.id],
canCreateWorktree: canCreateWorktreeForProjectKind(input.workspace.projectKind),
};
}
export function resolveInitialWorktreeProject(input: {
routeProject: HostProjectListItem | null;
lastActiveProject: HostProjectListItem | null;
projects: readonly HostProjectListItem[];
}): HostProjectListItem | null {
if (input.routeProject?.canCreateWorktree) {
return input.routeProject;
}
if (input.lastActiveProject?.canCreateWorktree) {
return input.lastActiveProject;
}
return input.projects.find((project) => project.canCreateWorktree) ?? null;
}
export function resolveSelectedHostProject(input: {
selectedProjectKey: string | null;
projects: readonly HostProjectListItem[];
routeProject: HostProjectListItem | null;
lastActiveProject: HostProjectListItem | null;
}): HostProjectListItem | null {
const selectedProjectKey = input.selectedProjectKey?.trim() ?? "";
if (!selectedProjectKey) {
return null;
}
return (
input.projects.find((project) => project.projectKey === selectedProjectKey) ??
(input.routeProject?.projectKey === selectedProjectKey ? input.routeProject : null) ??
(input.lastActiveProject?.projectKey === selectedProjectKey ? input.lastActiveProject : null)
);
}
@@ -0,0 +1,205 @@
import { describe, expect, it } from "vitest";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import type { WorkspaceStructureProject } from "@/projects/workspace-structure";
import {
buildHostProjectList,
canCreateWorktreeForProjectKind,
hostProjectFromRoute,
hostProjectFromWorkspace,
resolveInitialWorktreeProject,
resolveSelectedHostProject,
type HostProjectListItem,
} from "./host-project-model";
function structureProject(input: Partial<WorkspaceStructureProject>): WorkspaceStructureProject {
return {
projectKey: input.projectKey ?? "project-a",
projectName: input.projectName ?? "Project A",
projectKind: input.projectKind ?? "git",
iconWorkingDir: input.iconWorkingDir ?? "/repo/a",
workspaceKeys: input.workspaceKeys ?? ["workspace-a"],
};
}
function hostProject(input: Partial<HostProjectListItem>): HostProjectListItem {
return {
serverId: input.serverId ?? "host-a",
projectKey: input.projectKey ?? "project-a",
projectName: input.projectName ?? "Project A",
projectKind: input.projectKind ?? "git",
iconWorkingDir: input.iconWorkingDir ?? "/repo/a",
workspaceKeys: input.workspaceKeys ?? ["workspace-a"],
canCreateWorktree: input.canCreateWorktree ?? true,
};
}
function workspace(input: Partial<WorkspaceDescriptor>): WorkspaceDescriptor {
return {
id: input.id ?? "workspace-a",
projectId: input.projectId ?? "project-a",
projectDisplayName: input.projectDisplayName ?? "Project A",
projectRootPath: input.projectRootPath ?? "/repo/a",
workspaceDirectory: input.workspaceDirectory ?? "/repo/a",
projectKind: input.projectKind ?? "git",
workspaceKind: input.workspaceKind ?? "local_checkout",
name: input.name ?? "main",
status: input.status ?? "done",
statusEnteredAt: input.statusEnteredAt ?? null,
archivingAt: input.archivingAt ?? null,
diffStat: input.diffStat ?? null,
scripts: input.scripts ?? [],
};
}
const routeProject = hostProject({
projectKey: "route-project",
projectName: "Route Project",
iconWorkingDir: "/repo/route",
});
const lastActiveProject = hostProject({
projectKey: "last-project",
projectName: "Last Project",
iconWorkingDir: "/repo/last",
});
const firstProject = hostProject({
projectKey: "first-project",
projectName: "First Project",
iconWorkingDir: "/repo/first",
});
describe("host project list", () => {
it("preserves workspace-structure order and project metadata", () => {
expect(
buildHostProjectList({
serverId: "host-a",
projects: [
structureProject({
projectKey: "project-b",
projectName: "Project B",
projectKind: "directory",
iconWorkingDir: "/repo/b",
workspaceKeys: ["workspace-b"],
}),
structureProject({
projectKey: "project-a",
projectName: "Project A",
projectKind: "git",
iconWorkingDir: "/repo/a",
workspaceKeys: ["workspace-a"],
}),
],
}),
).toEqual([
{
serverId: "host-a",
projectKey: "project-b",
projectName: "Project B",
projectKind: "directory",
iconWorkingDir: "/repo/b",
workspaceKeys: ["workspace-b"],
canCreateWorktree: false,
},
{
serverId: "host-a",
projectKey: "project-a",
projectName: "Project A",
projectKind: "git",
iconWorkingDir: "/repo/a",
workspaceKeys: ["workspace-a"],
canCreateWorktree: true,
},
]);
});
it("keeps worktree capability separate from project listability", () => {
expect(canCreateWorktreeForProjectKind("git")).toBe(true);
expect(canCreateWorktreeForProjectKind("directory")).toBe(false);
});
it("uses route project before last active project when it can create worktrees", () => {
expect(
resolveInitialWorktreeProject({
routeProject,
lastActiveProject,
projects: [firstProject],
}),
).toEqual(routeProject);
});
it("skips non-worktree route and last-active projects", () => {
expect(
resolveInitialWorktreeProject({
routeProject: { ...routeProject, projectKind: "directory", canCreateWorktree: false },
lastActiveProject: {
...lastActiveProject,
projectKind: "directory",
canCreateWorktree: false,
},
projects: [
{ ...firstProject, projectKind: "directory", canCreateWorktree: false },
hostProject({ projectKey: "git-project", projectName: "Git Project" }),
],
}),
).toMatchObject({ projectKey: "git-project" });
});
it("leaves the project empty when no worktree-capable project is available", () => {
expect(
resolveInitialWorktreeProject({
routeProject: null,
lastActiveProject: null,
projects: [{ ...firstProject, projectKind: "directory", canCreateWorktree: false }],
}),
).toBeNull();
});
it("keeps a selected route project available before project hydration", () => {
expect(
resolveSelectedHostProject({
selectedProjectKey: routeProject.projectKey,
projects: [],
routeProject,
lastActiveProject: null,
}),
).toEqual(routeProject);
});
it("converts route project only when it has a key and source directory", () => {
expect(
hostProjectFromRoute({
serverId: "host-a",
projectId: "project-a",
displayName: "Project A",
sourceDirectory: "/repo/a",
}),
).toEqual({
serverId: "host-a",
projectKey: "project-a",
projectName: "Project A",
projectKind: "git",
iconWorkingDir: "/repo/a",
workspaceKeys: [],
canCreateWorktree: true,
});
expect(hostProjectFromRoute({ serverId: "host-a", projectId: "project-a" })).toBeNull();
});
it("converts last active workspaces with matching worktree capability", () => {
expect(hostProjectFromWorkspace({ serverId: "host-a", workspace: workspace({}) })).toEqual({
serverId: "host-a",
projectKey: "project-a",
projectName: "Project A",
projectKind: "git",
iconWorkingDir: "/repo/a",
workspaceKeys: ["workspace-a"],
canCreateWorktree: true,
});
expect(
hostProjectFromWorkspace({
serverId: "host-a",
workspace: workspace({ projectKind: "directory" }),
}),
).toMatchObject({ projectKind: "directory", canCreateWorktree: false });
});
});
@@ -0,0 +1,24 @@
import { useMemo } from "react";
import { useWorkspaceStructure } from "@/stores/session-store-hooks";
import { buildHostProjectList, type HostProjectListItem } from "@/projects/host-project-model";
export {
buildHostProjectList,
canCreateWorktreeForProjectKind,
hostProjectFromRoute,
hostProjectFromWorkspace,
resolveInitialWorktreeProject,
resolveSelectedHostProject,
type HostProjectListItem,
type HostProjectRouteContext,
} from "@/projects/host-project-model";
export function useHostProjects(serverId: string | null): HostProjectListItem[] {
const workspaceStructure = useWorkspaceStructure(serverId);
return useMemo(() => {
if (!serverId || workspaceStructure.projects.length === 0) {
return [];
}
return buildHostProjectList({ serverId, projects: workspaceStructure.projects });
}, [serverId, workspaceStructure.projects]);
}
@@ -0,0 +1,97 @@
import { useMemo, useRef } from "react";
import { useQueries } from "@tanstack/react-query";
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
import { projectIconQueryKey, projectIconToDataUri } from "@/hooks/use-project-icon-query";
export interface ProjectIconRequestTarget {
serverId?: string | null;
projectKey: string;
iconWorkingDir: string;
}
function useStableProjectIconData(
data: (string | null)[],
signature: string,
): readonly (string | null)[] {
const stableRef = useRef<{ signature: string; data: (string | null)[] } | null>(null);
if (stableRef.current?.signature !== signature) {
stableRef.current = { signature, data };
}
return stableRef.current.data;
}
export function useProjectIconDataByProjectKey(input: {
serverId: string | null;
projects: readonly ProjectIconRequestTarget[];
}): Map<string, string | null> {
const projectIconRequests = useMemo(() => {
const unique = new Map<string, { serverId: string; cwd: string }>();
for (const project of input.projects) {
const serverId = project.serverId || input.serverId;
if (!serverId) {
continue;
}
const cwd = project.iconWorkingDir.trim();
if (!cwd) {
continue;
}
unique.set(`${serverId}:${cwd}`, { serverId, cwd });
}
return Array.from(unique.values());
}, [input.projects, input.serverId]);
const projectIconQueries = useQueries({
queries: projectIconRequests.map((request) => ({
queryKey: projectIconQueryKey(request.serverId, request.cwd),
queryFn: async () => {
const client = getHostRuntimeStore().getClient(request.serverId);
if (!client) {
return null;
}
const result = await client.requestProjectIcon(request.cwd);
return result.icon;
},
select: projectIconToDataUri,
enabled: Boolean(
getHostRuntimeStore().getClient(request.serverId) &&
isHostRuntimeConnected(getHostRuntimeStore().getSnapshot(request.serverId)) &&
request.cwd,
),
staleTime: Infinity,
gcTime: 1000 * 60 * 60,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
})),
});
const projectIconSignature = projectIconQueries.map((query) => query.data ?? "").join("\u0000");
const projectIconData = useStableProjectIconData(
projectIconQueries.map((query) => query.data ?? null),
projectIconSignature,
);
return useMemo(() => {
const iconByServerAndCwd = new Map<string, string | null>();
for (let index = 0; index < projectIconRequests.length; index += 1) {
const request = projectIconRequests[index];
if (!request) {
continue;
}
iconByServerAndCwd.set(`${request.serverId}:${request.cwd}`, projectIconData[index] ?? null);
}
const byProject = new Map<string, string | null>();
for (const project of input.projects) {
const serverId = project.serverId || input.serverId;
const cwd = project.iconWorkingDir.trim();
if (!cwd || !serverId) {
byProject.set(project.projectKey, null);
continue;
}
byProject.set(project.projectKey, iconByServerAndCwd.get(`${serverId}:${cwd}`) ?? null);
}
return byProject;
}, [input.projects, input.serverId, projectIconData, projectIconRequests]);
}
@@ -0,0 +1,29 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { normalizeWorkspaceDescriptor, type WorkspaceDescriptor } from "@/stores/session-store";
export type FetchWorkspacesClient = Pick<DaemonClient, "fetchWorkspaces">;
export type FetchWorkspacesSort = NonNullable<
Parameters<DaemonClient["fetchWorkspaces"]>[0]
>["sort"];
export async function fetchAllWorkspaceDescriptors(input: {
client: FetchWorkspacesClient;
sort: FetchWorkspacesSort;
}): Promise<WorkspaceDescriptor[]> {
const entries: WorkspaceDescriptor[] = [];
let cursor: string | null = null;
while (true) {
const payload = await input.client.fetchWorkspaces({
sort: input.sort,
page: cursor ? { limit: 200, cursor } : { limit: 200 },
});
entries.push(...payload.entries.map((entry) => normalizeWorkspaceDescriptor(entry)));
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
break;
}
cursor = payload.pageInfo.nextCursor;
}
return entries;
}

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