mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-30 05:02:58 +00:00
Compare commits
66
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3448e8b023 | ||
|
|
6524677960 | ||
|
|
09a1fe46fe | ||
|
|
06a8f952db | ||
|
|
893e3376b0 | ||
|
|
7c8b290e2f | ||
|
|
d55e1622cb | ||
|
|
69715a77e9 | ||
|
|
e1b27fc584 | ||
|
|
9c16bc474b | ||
|
|
59d48d235a | ||
|
|
6aa73baaab | ||
|
|
0cf1717e04 | ||
|
|
2822c02543 | ||
|
|
db4376d17a | ||
|
|
9a8912b3ef | ||
|
|
fd022bc44b | ||
|
|
7408de6300 | ||
|
|
9b21ccd7f1 | ||
|
|
20c03355f9 | ||
|
|
e09a1591ac | ||
|
|
d35bffed8b | ||
|
|
d454fa3af4 | ||
|
|
59b32ab3be | ||
|
|
44e9287389 | ||
|
|
c89177c211 | ||
|
|
dd329a4f52 | ||
|
|
0d1eecc388 | ||
|
|
7124a82298 | ||
|
|
df635b570a | ||
|
|
1377adbece | ||
|
|
350bc08fc4 | ||
|
|
dfddda7969 | ||
|
|
abf129f56e | ||
|
|
45cca8a406 | ||
|
|
e961ceef98 | ||
|
|
f30f217023 | ||
|
|
9d3b037d4f | ||
|
|
c3515d74b9 | ||
|
|
b5192e577a | ||
|
|
9e2c75a8a3 | ||
|
|
7048cd5c86 | ||
|
|
9dbc75e78e | ||
|
|
81d973ffc4 | ||
|
|
3d8ef237e3 | ||
|
|
74d42cb3d0 | ||
|
|
1bca88bd30 | ||
|
|
1daa4587e5 | ||
|
|
88d44ab2c5 | ||
|
|
3ecf86933f | ||
|
|
1b350ac09e | ||
|
|
de9d796567 | ||
|
|
cd8e0a5e08 | ||
|
|
38f8179bc1 | ||
|
|
0b62e3bd7f | ||
|
|
f0370a3c20 | ||
|
|
e381117b1c | ||
|
|
f9791d7ab6 | ||
|
|
66a5bd8a88 | ||
|
|
de5fa4dc36 | ||
|
|
6dcf3ffd6a | ||
|
|
f139966554 | ||
|
|
b0c4cc99ed | ||
|
|
3e3e944cf4 | ||
|
|
f3b7dac768 | ||
|
|
b9f3e59d48 |
@@ -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.
|
||||
|
||||
@@ -64,6 +64,7 @@ CLAUDE.local.md
|
||||
|
||||
.debug.conversations/
|
||||
.debug/
|
||||
.dev/
|
||||
.paseo/
|
||||
.wrangler/
|
||||
**/.wrangler/
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"options": {
|
||||
"typeAware": false
|
||||
},
|
||||
"ignorePatterns": [".dev/**"],
|
||||
"plugins": ["react", "react-perf", "unicorn", "typescript", "oxc", "import", "promise"],
|
||||
"categories": {
|
||||
"correctness": "error",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -1 +1 @@
|
||||
sha256-RKIUil3Ev65LbxSqsgzkD4APgAOCXBnIRWz++jEq91k=
|
||||
sha256-EpxQim3vnwfPLPi8IAtF82tGrLNZiZP8IwN7rR9P7xo=
|
||||
|
||||
Generated
+21
-21
@@ -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
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.90-beta.1",
|
||||
"version": "0.1.91-beta.2",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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({
|
||||
|
||||
+7
-5
@@ -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?.();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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
@@ -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];
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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>>;
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user