Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 00870740ef Fix committed diff excluding dirty worktree 2026-04-21 15:00:08 +07:00
Mohamed Boudra ebf8a21c08 Fix TTLCache import and rename schedule provider resolver 2026-04-21 14:19:51 +07:00
Mohamed Boudra 515ba62be6 Persist sidebar collapse state and stabilize Metro port 2026-04-21 14:01:16 +07:00
Mohamed Boudra 32a20cbeea Merge branch 'research/mcp-list-agents-summary' 2026-04-21 13:46:25 +07:00
Mohamed Boudra 9877936d13 feat: require provider/model format and return compact list items 2026-04-21 13:44:55 +07:00
Mohamed Boudra 1a473ed7f3 Persist composer preferences and stabilize hydration 2026-04-21 13:43:22 +07:00
Mohamed Boudra 3832053736 fix: provider-scoped snapshot refresh with self-healing invalidation 2026-04-21 13:14:59 +07:00
Mohamed Boudra b01e274cce Fix provider refresh scope and self-healing 2026-04-21 12:41:28 +07:00
Mohamed Boudra 92e701be8f style: adjust typography and standardize ui text 2026-04-20 21:31:59 +07:00
Mohamed Boudra c2c990e763 docs: note beta rename in changelog 2026-04-20 20:49:38 +07:00
Mohamed Boudra 76b9f89f68 fix: tolerate beta release creation races 2026-04-20 20:41:24 +07:00
github-actions[bot] 970bf828b3 fix: update lockfile signatures and Nix hash 2026-04-20 13:40:07 +00:00
Mohamed Boudra dfc814147a feat: add beta release channel 2026-04-20 20:38:56 +07:00
Mohamed Boudra 831d990ef7 fix(release): avoid notes sync create races 2026-04-20 19:37:46 +07:00
Mohamed Boudra e09fd41adc fix(release): patch existing release notes by id 2026-04-20 19:33:39 +07:00
github-actions[bot] 8d32167826 fix: update lockfile signatures and Nix hash 2026-04-20 12:29:27 +00:00
Mohamed Boudra 1cfae98bf5 chore(release): cut 0.1.60-rc.3 2026-04-20 19:28:23 +07:00
Mohamed Boudra 4cbfe95473 fix(server): treat pi aborts as cancellations 2026-04-20 19:02:37 +07:00
Mohamed Boudra 242f61937a fix(app): stabilize terminal creation handler 2026-04-20 19:02:34 +07:00
Mohamed Boudra d913c4a9b7 fix(app): isolate bottom sheet modal scopes 2026-04-20 19:02:31 +07:00
Mohamed Boudra 2cd296e38f Fix workspace shortcut navigation 2026-04-20 18:06:09 +07:00
Mohamed Boudra 95c1d1fe0f Fix workspace composer focus routing 2026-04-20 17:41:00 +07:00
Mohamed Boudra 33ac657fed fix(app): treat workspace diff stats as authoritative 2026-04-20 17:40:11 +07:00
Mohamed Boudra 067125bd4f Fix composer safe-area ownership 2026-04-20 17:35:20 +07:00
Mohamed Boudra 93095aa1d2 Fix dictation readiness after app refresh 2026-04-20 17:23:17 +07:00
Mohamed Boudra ac45f23213 Fix mobile sidebar regressions and workspace e2e 2026-04-20 16:38:12 +07:00
github-actions[bot] b91749a316 fix: update lockfile signatures and Nix hash 2026-04-20 09:15:24 +00:00
Mohamed Boudra a6423f7f0a Improve workspace navigation and render isolation 2026-04-20 16:14:22 +07:00
Mohamed Boudra 403fb03c97 feat(app): retain workspace screens on sidebar switch 2026-04-20 12:55:58 +07:00
Mohamed Boudra f8411d121e Separate explorer checkout from panel state 2026-04-20 12:40:39 +07:00
Mohamed Boudra 9ea75afd37 Fix welcome screen unistyles leak 2026-04-20 12:40:30 +07:00
Mohamed Boudra 006894274e fix(desktop): patch React DevTools manifest for Electron compatibility 2026-04-20 11:16:21 +07:00
Mohamed Boudra 951fadc104 fix(mcp): accept null in list_models output schema 2026-04-20 11:06:21 +07:00
Mohamed Boudra 8600f183b1 feat(sidebar): restore PR state color, swap badge order, brighten checks on hover
- PR icon color reflects merge state (open/merged/closed) via the
  existing getWorkspacePrIconColor helper.
- PR badge is rendered before the failed-checks badge in the workspace
  row.
- Hovering the PR badge swaps the GitPullRequest icon for ExternalLink
  in place (same size, no layout shift) and removes the trailing arrow.
- Hovering the checks row in the workspace hover card brightens the
  icon and "Checks" label to foreground.
2026-04-20 10:57:27 +07:00
Mohamed Boudra 002946d4dd fix(sidebar): prevent text selection when dragging project/workspace rows
The drag handle was attached only to the left sub-view of each row, so
grabbing the right half initiated no drag, and pointerdown on the branch
or project title raced the browser's text-selection start before
dnd-kit's activation threshold — often producing a text selection
instead of a drag.

Move dragHandleProps to the outer row wrapper, add userSelect: none to
the row styles, and tighten PointerSensor activation distance.
2026-04-20 10:49:46 +07:00
Mohamed Boudra fb02bc8795 fix(workspace): collapse header loading skeleton to a single bar
The header now shows only one row of text, so drop the second skeleton
and widen the primary bar to match.
2026-04-20 10:46:23 +07:00
Mohamed Boudra 53baa16bee refactor(checkout-diff): rely solely on subscription for data
Drop the one-shot getCheckoutDiff query and use skipToken; the
subscribe_checkout_diff flow populates the cache on subscribe response
and updates. Derive isLoading from the presence of cached payload and
remove the now-unused isError/error fields from git-diff-pane.
2026-04-20 10:45:59 +07:00
Mohamed Boudra 67eddbd6a9 perf(explorer-sidebar): memoize animation context value
Stabilize animateToOpen/animateToClose with useCallback and wrap the
provider value in useMemo so consumers don't re-render on every parent
re-render.
2026-04-20 10:45:55 +07:00
Mohamed Boudra 044546ae0c perf(explorer-sidebar): skip PR queries when sidebar is closed
Thread isOpen down to SidebarContent so usePrPaneData stays disabled
until the user actually opens the sidebar.
2026-04-20 10:45:51 +07:00
Mohamed Boudra bd748b6edc perf(workspace): gate side effects on stack route focus
Pass useIsFocused through as isRouteFocused rather than short-circuiting
the whole tree. Terminals query/subscription, checkout diff, providers
snapshot, keybindings, tab reconciliation, focused-agent writes, and
sidebar rendering all no-op when the route is in the stack but not
focused, so a hidden workspace screen no longer drives background work.
2026-04-20 10:45:48 +07:00
Mohamed Boudra 427664dfa7 fix(app): drop getId from workspace stack route
Expo Router maps getId to React Navigation getId, which reorders an
already-mounted workspace screen on Android native-stack/Fabric. Keep
workspace identity/retention outside the route-level API and leave a
comment warning against re-adding it.
2026-04-20 10:45:02 +07:00
Mohamed Boudra a0169bf7f4 feat(app): export isDev platform flag and enable runtime metrics in dev
Adds a shared isDev constant alongside isWeb/isNative and wires daemon
clients to collect runtime metrics (10s interval) only in development.
2026-04-20 10:44:57 +07:00
Mohamed Boudra 79968ee02d feat(daemon-client): add opt-in runtime metrics collector
Aggregates inbound message counts, bytes, handler timings, and
agent_stream breakdowns over a rolling window and flushes via logger.
Gated on runtimeMetricsIntervalMs config; disabled by default.
2026-04-20 10:44:43 +07:00
Mohamed Boudra afe0eaab5b Set APP_VARIANT=development in app startup scripts 2026-04-20 09:07:34 +07:00
Mohamed Boudra 04465f124c Add dev mock load-test provider 2026-04-20 00:38:10 +07:00
Mohamed Boudra 86b83c3366 Fix workspace diff stat watch refresh 2026-04-20 00:36:54 +07:00
github-actions[bot] 4a5dd986e1 fix: update lockfile signatures and Nix hash 2026-04-19 16:23:48 +00:00
207 changed files with 10607 additions and 2941 deletions
+2 -2
View File
@@ -4,9 +4,9 @@ on:
push: push:
tags: tags:
- 'v*' - 'v*'
- '!v*-rc.*' - '!v*-beta.*'
- 'app-v*' - 'app-v*'
- '!app-v*-rc.*' - '!app-v*-beta.*'
workflow_dispatch: workflow_dispatch:
jobs: jobs:
+12 -6
View File
@@ -66,7 +66,9 @@ jobs:
--repo "${{ github.repository }}" \ --repo "${{ github.repository }}" \
--title "Paseo $RELEASE_TAG" \ --title "Paseo $RELEASE_TAG" \
--generate-notes \ --generate-notes \
$prerelease_flag $prerelease_flag || {
echo "Release creation raced with another workflow; continuing."
}
fi fi
publish-macos: publish-macos:
@@ -145,6 +147,7 @@ jobs:
if [[ "$IS_SMOKE_TAG" != "true" ]]; then if [[ "$IS_SMOKE_TAG" != "true" ]]; then
publish_mode="always" publish_mode="always"
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE") publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
publish_args+=("-c.publish.channel=$RELEASE_CHANNEL")
fi fi
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --mac --${{ matrix.electron_arch }} "${publish_args[@]}" npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --mac --${{ matrix.electron_arch }} "${publish_args[@]}"
@@ -154,7 +157,7 @@ jobs:
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: mac-manifest-${{ matrix.electron_arch }} name: mac-manifest-${{ matrix.electron_arch }}
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/latest-mac.yml path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/${{ env.RELEASE_CHANNEL }}-mac.yml
retention-days: 1 retention-days: 1
finalize-mac-manifest: finalize-mac-manifest:
@@ -227,8 +230,9 @@ jobs:
return out; return out;
} }
const arm64Text = fs.readFileSync('mac-manifest-arm64/latest-mac.yml', 'utf8'); const manifestName = `${process.env.RELEASE_CHANNEL}-mac.yml`;
const x64Text = fs.readFileSync('mac-manifest-x64/latest-mac.yml', 'utf8'); const arm64Text = fs.readFileSync(`mac-manifest-arm64/${manifestName}`, 'utf8');
const x64Text = fs.readFileSync(`mac-manifest-x64/${manifestName}`, 'utf8');
const arm64 = parseManifest(arm64Text); const arm64 = parseManifest(arm64Text);
const x64 = parseManifest(x64Text); const x64 = parseManifest(x64Text);
@@ -243,7 +247,7 @@ jobs:
}; };
const output = toYaml(merged); const output = toYaml(merged);
fs.writeFileSync('latest-mac.yml', output); fs.writeFileSync(manifestName, output);
console.log('Merged manifest:\n' + output); console.log('Merged manifest:\n' + output);
NODE NODE
@@ -251,7 +255,7 @@ jobs:
if: env.IS_SMOKE_TAG != 'true' if: env.IS_SMOKE_TAG != 'true'
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload "$RELEASE_TAG" latest-mac.yml --clobber --repo "${{ github.repository }}" run: gh release upload "$RELEASE_TAG" "$RELEASE_CHANNEL-mac.yml" --clobber --repo "${{ github.repository }}"
publish-linux: publish-linux:
needs: [create-release] needs: [create-release]
@@ -316,6 +320,7 @@ jobs:
if [[ "$IS_SMOKE_TAG" != "true" ]]; then if [[ "$IS_SMOKE_TAG" != "true" ]]; then
publish_mode="always" publish_mode="always"
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE") publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
publish_args+=("-c.publish.channel=$RELEASE_CHANNEL")
fi fi
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --linux --x64 "${publish_args[@]}" npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --linux --x64 "${publish_args[@]}"
@@ -391,6 +396,7 @@ jobs:
if [[ "$IS_SMOKE_TAG" != "true" ]]; then if [[ "$IS_SMOKE_TAG" != "true" ]]; then
publish_mode="always" publish_mode="always"
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE") publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
publish_args+=("-c.publish.channel=$RELEASE_CHANNEL")
fi fi
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --win --x64 "${publish_args[@]}" npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --win --x64 "${publish_args[@]}"
+1 -3
View File
@@ -54,9 +54,7 @@ jobs:
fi fi
create_if_missing="false" create_if_missing="false"
if [ "$EVENT_NAME" = "push" ] && [[ "$REF" == refs/tags/v* ]]; then if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "${INPUT_CREATE_IF_MISSING:-false}" = "true" ]; then
create_if_missing="true"
elif [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "${INPUT_CREATE_IF_MISSING:-false}" = "true" ]; then
create_if_missing="true" create_if_missing="true"
fi fi
+6
View File
@@ -1,5 +1,11 @@
# Changelog # Changelog
## 0.1.60-beta.1 - 2026-04-20
### Added
- Beta release channel for desktop updates, with a Settings toggle for receiving beta builds before they are promoted to stable.
- Release candidates are now called beta releases, starting with `0.1.60-beta.1`.
## 0.1.59 - 2026-04-16 ## 0.1.59 - 2026-04-16
### Added ### Added
+1 -1
View File
@@ -51,7 +51,7 @@ Stable tag pushes like `v0.1.0` trigger:
- `packages/app/.eas/workflows/release-mobile.yml` on Expo servers (iOS + Android build + submit) - `packages/app/.eas/workflows/release-mobile.yml` on Expo servers (iOS + Android build + submit)
- `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release) - `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release)
Release candidate tags like `v0.1.1-rc.1` only trigger the GitHub APK workflow. They publish a GitHub prerelease APK for testing and do not submit to the stores. Beta tags like `v0.1.1-beta.1` only trigger the GitHub APK workflow. They publish a GitHub prerelease APK for testing and do not submit to the stores.
### Useful commands ### Useful commands
+28 -25
View File
@@ -7,7 +7,7 @@ All workspaces share one version and release together.
There are two supported ways to ship from `main`: 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. 1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
2. **Release candidate flow**: you want public test builds first, but you are not ready for the website, npm, or production mobile release flows to move yet. 2. **Beta flow**: you want public test builds first, but you are not ready for the website, npm, or production mobile release flows to move yet.
## Standard release (patch) ## Standard release (patch)
@@ -37,31 +37,32 @@ npm run release:publish # Publish to npm
npm run release:push # Push HEAD + tag (triggers CI workflows) npm run release:push # Push HEAD + tag (triggers CI workflows)
``` ```
## Release candidate flow ## Beta flow
```bash ```bash
npm run release:rc:patch # Bump to X.Y.Z-rc.1, push commit + tag npm run release:beta:patch # Bump to X.Y.Z-beta.1, push commit + tag
# ... test desktop and APK prerelease assets from GitHub Releases ... # ... test desktop and APK prerelease assets from GitHub Releases ...
npm run release:rc:next # Optional: cut X.Y.Z-rc.2, rc.3, ... npm run release:beta:next # Optional: cut X.Y.Z-beta.2, beta.3, ...
npm run release:promote # Promote X.Y.Z-rc.N to stable X.Y.Z npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z
``` ```
- RC tags are published GitHub prereleases like `v0.1.41-rc.1` - Beta tags are published GitHub prereleases like `v0.1.41-beta.1`
- RCs 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 desktop assets and APKs for testing, but they do not publish npm packages and 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 RC tag - `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` - Desktop assets now come from the Electron package at `packages/desktop`
- **Do NOT create a changelog entry for RCs.** The changelog remains stable-only. RC release notes are generated automatically so the website stays pinned to the latest published stable release. - 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.
- **Do create a changelog entry for betas.** The beta entry is temporary and gets updated in place until promotion.
Use the RC path when you need to: Use the beta path when you need to:
- test a build manually in a Linux or Windows VM - test a build manually in a Linux or Windows VM
- send a build to a user who is hitting a specific problem - send a build to a user who is hitting a specific problem
- iterate on `rc.1`, `rc.2`, `rc.3`, and so on before deciding to ship broadly - iterate on `beta.1`, `beta.2`, `beta.3`, and so on before deciding to ship broadly
## Website behavior ## Website behavior
- The website download page points to GitHub's latest published **stable** release. - The website download page points to GitHub's latest published **stable** release.
- Published RC prereleases are public on GitHub Releases, but they do **not** become the website download target. - Published beta prereleases are public on GitHub Releases, but they do **not** become the website download target.
- The website only moves when you publish the final stable release tag like `v0.1.41`. - The website only moves when you publish the final stable release tag like `v0.1.41`.
## Fixing a failed release build ## Fixing a failed release build
@@ -88,13 +89,13 @@ git tag -f desktop-windows-v0.1.28 HEAD && git push origin desktop-windows-v0.1.
# Android APK # Android APK
git tag -f android-v0.1.28 HEAD && git push origin android-v0.1.28 --force git tag -f android-v0.1.28 HEAD && git push origin android-v0.1.28 --force
# RC # Beta
git tag -f v0.1.29-rc.2 HEAD && git push origin v0.1.29-rc.2 --force git tag -f v0.1.29-beta.2 HEAD && git push origin v0.1.29-beta.2 --force
``` ```
This ensures the checkout ref matches the actual code on `main` with the fix included. This ensures the checkout ref matches the actual code on `main` with the fix included.
- `vX.Y.Z` or `vX.Y.Z-rc.N` rebuilds the full tagged release - `vX.Y.Z` or `vX.Y.Z-beta.N` rebuilds the full tagged release
- `desktop-vX.Y.Z` rebuilds desktop for all desktop platforms only - `desktop-vX.Y.Z` rebuilds desktop for all desktop platforms only
- `desktop-macos-vX.Y.Z`, `desktop-linux-vX.Y.Z`, and `desktop-windows-vX.Y.Z` rebuild only that desktop platform - `desktop-macos-vX.Y.Z`, `desktop-linux-vX.Y.Z`, and `desktop-windows-vX.Y.Z` rebuild only that desktop platform
- `android-vX.Y.Z` rebuilds the Android APK release only - `android-vX.Y.Z` rebuilds the Android APK release only
@@ -105,24 +106,26 @@ 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 - `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` - `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` partially fails, re-run it — npm skips already-published versions
- The website uses GitHub's latest published release API for download links, so published RC prereleases do not replace the stable download target. - 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 ## Changelog format
Stable release notes depend on the changelog heading format. The heading **must** be strictly followed: Release notes depend on the changelog heading format. The heading **must** be strictly followed:
``` ```
## X.Y.Z - YYYY-MM-DD ## X.Y.Z - YYYY-MM-DD
## X.Y.Z-beta.N - YYYY-MM-DD
``` ```
No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to extract the version. A malformed heading will break download links on the homepage. No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to extract the version. A malformed heading will break download links on the homepage.
## Changelog policy ## Changelog policy
- `CHANGELOG.md` is for **final stable releases only**. - `CHANGELOG.md` includes stable releases and the current beta line.
- Do not add or edit changelog entries while iterating on RCs. - The first beta inserts a top entry like `## 0.1.60-beta.1 - YYYY-MM-DD`.
- Write the proper changelog entry when you are cutting the final stable release that comes after the RC cycle. - The next beta updates that same top entry in place, for example from `0.1.60-beta.1` to `0.1.60-beta.2`.
- Between stable releases, keep changelog work out of the repo until the final release is ready. - Stable promotion updates that same entry in place, for example from `0.1.60-beta.2` to `0.1.60`.
- Do not create duplicate entries for each beta on the same version line.
## Changelog ownership ## Changelog ownership
@@ -178,7 +181,7 @@ Entries within each section (Added, Improved, Fixed) are ordered by user impact:
## Pre-release sanity check ## Pre-release sanity check
Before cutting any release (RC or stable), run a Codex review of the diff as a last line of defence against shipping bugs. Before cutting any release (beta or stable), run a Codex review of the diff as a last line of defence against shipping bugs.
Load the `paseo` skill and launch a **Codex 5.4** agent with a prompt like: Load the `paseo` skill and launch a **Codex 5.4** agent with a prompt like:
@@ -196,10 +199,10 @@ The agent's job is a deep sanity check, not a full code review. If it flags anyt
The changelog always covers **stable-to-HEAD**: The changelog always covers **stable-to-HEAD**:
- **RC release**: the diff and release notes cover `latest stable tag → HEAD`. RC release notes are auto-generated and not added to `CHANGELOG.md`. - **Beta release**: the diff and release notes cover `latest stable tag -> HEAD`. The current beta changelog entry is updated in place.
- **Stable release**: the diff and changelog entry cover `latest stable tag → HEAD`. Any intermediate RCs are skipped — the changelog captures the full delta from the previous stable release, not just what changed since the last RC. - **Stable release**: the same changelog entry is promoted in place. It still captures the full delta from the previous stable release, not just what changed since the last beta.
In other words, RCs are checkpoints along the way; the changelog only records the final jump from one stable version to the next. In other words, betas are checkpoints along the way; the changelog entry remains the single record for the final jump from one stable version to the next.
## Completion checklist ## Completion checklist
+32
View File
@@ -89,6 +89,38 @@ const { theme } = useUnistyles();
Use this sparingly. It works because React re-renders the prop, but it gives up the main Unistyles native-update path for that value. Use this sparingly. It works because React re-renders the prop, but it gives up the main Unistyles native-update path for that value.
## `withUnistyles` And The `> *` Child-Selector Leak
`withUnistyles` on a component with a theme-dependent `style` prop works by wrapping the component in a `<div style={{display: 'contents'}} className={hash}>` and emitting the style under a `.hash > *` child selector so the styles cascade onto the wrapped component. This is how auto-mapping for `style` and `contentContainerStyle` works on web.
The sharp edge: Unistyles hashes styles by value. If `withUnistyles` receives a style whose value is **identical** to a style used elsewhere in the app on a plain `View`, both usages get the same hash — and both CSS rules (the element rule and the `> *` child rule) are emitted under the same class name. The `> *` rule then leaks onto the direct children of every `View` that shares the hash.
Concrete regression we hit: `welcome-screen.tsx` had `const ThemedScrollView = withUnistyles(ScrollView)` with `style={{ flex: 1, backgroundColor: theme.colors.surface0 }}`. `agent-panel.tsx` had `root` and `container` styles with the exact same value. All three collided on class `unistyles_j2k2iilhfz`, so the browser stylesheet contained:
```css
.unistyles_j2k2iilhfz { flex: 1 1 0%; background-color: var(--colors-surface0); }
.unistyles_j2k2iilhfz > * { flex: 1 1 0%; background-color: var(--colors-surface0); }
```
The child-selector rule forced `flex:1` and `background-color: surface0` onto the Composer's outer `Animated.View` (a direct child of `container`), stretching it to fill remaining space and leaving a large empty gap between the composer UI and the bottom of the screen. It also painted a `surface0` band behind the scroll-to-bottom button. The bug only appeared in the browser — Electron skips `WelcomeScreen` after pairing, so the `> *` rule was never injected there.
Symptoms to watch for:
- A sibling of a themed panel-background `View` stretches unexpectedly on web only.
- Random direct children of a `{ flex: 1, backgroundColor: surface0 }` `View` pick up an unexpected background.
- DevTools shows a `.unistyles_xxx > *` rule you did not write.
Quick confirmation in DevTools console:
```js
[...document.styleSheets].flatMap(s => [...(s.cssRules || [])])
.map(r => r.cssText).filter(t => t.includes("unistyles") && t.includes("> *"));
```
Any match beyond benign `r-pointerEvents-* > *` rules from react-native-web is a leak.
Avoid the bug by preferring the wrapper-`View` pattern from the previous section whenever possible: put `{ flex: 1, backgroundColor: surface0 }` on a plain `View` and give the `ScrollView` a theme-free `style`/`contentContainerStyle`. That keeps `withUnistyles` off the hot path and avoids the hash collision. Only reach for `withUnistyles(ScrollView)` when a wrapper view is genuinely awkward, and when you do, give the wrapped style a distinctive shape (extra key, different layout) so it does not hash-collide with a common panel background used elsewhere.
## Hidden Sheet Content ## Hidden Sheet Content
`@gorhom/bottom-sheet` can keep `BottomSheetModal` content mounted while the sheet is hidden. That matters during Paseo's startup theme transition: a header node can be created under the initial adaptive theme, stay hidden, then appear later with stale native style values even though surrounding content has re-rendered correctly. `@gorhom/bottom-sheet` can keep `BottomSheetModal` content mounted while the sheet is hidden. That matters during Paseo's startup theme transition: a header node can be created under the initial adaptive theme, stay hidden, then appear later with stale native style values even though surrounding content has re-rendered correctly.
+1 -1
View File
@@ -42,7 +42,7 @@ buildNpmPackage rec {
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash. # To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/). # CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-n1Q7uZumewPUZZFacSkSXeLotTApjxq+BpDEQ8V2MQs="; npmDepsHash = "sha256-6v597rirYsPQJYXvVpd0+MZfbY0I6Oqlmd/7z5eHiOw=";
# Prevent onnxruntime-node's install script from running during automatic # Prevent onnxruntime-node's install script from running during automatic
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox). # npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
+20 -19
View File
@@ -1,12 +1,12 @@
{ {
"name": "paseo", "name": "paseo",
"version": "0.1.60-rc.2", "version": "0.1.60-beta.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "paseo", "name": "paseo",
"version": "0.1.60-rc.2", "version": "0.1.60-beta.1",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"workspaces": [ "workspaces": [
@@ -37303,16 +37303,16 @@
}, },
"packages/app": { "packages/app": {
"name": "@getpaseo/app", "name": "@getpaseo/app",
"version": "0.1.60-rc.2", "version": "0.1.60-beta.1",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2", "@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7", "@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.60-rc.2", "@getpaseo/expo-two-way-audio": "0.1.60-beta.1",
"@getpaseo/highlight": "0.1.60-rc.2", "@getpaseo/highlight": "0.1.60-beta.1",
"@getpaseo/server": "0.1.60-rc.2", "@getpaseo/server": "0.1.60-beta.1",
"@gorhom/bottom-sheet": "^5.2.6", "@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14", "@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0", "@react-native-async-storage/async-storage": "2.2.0",
@@ -37382,6 +37382,7 @@
"react-native-web": "~0.21.0", "react-native-web": "~0.21.0",
"react-native-webview": "^13.16.0", "react-native-webview": "^13.16.0",
"react-native-worklets": "0.5.1", "react-native-worklets": "0.5.1",
"use-sync-external-store": "^1.6.0",
"zod": "^3.23.8", "zod": "^3.23.8",
"zustand": "^5.0.9" "zustand": "^5.0.9"
}, },
@@ -37455,11 +37456,11 @@
}, },
"packages/cli": { "packages/cli": {
"name": "@getpaseo/cli", "name": "@getpaseo/cli",
"version": "0.1.60-rc.2", "version": "0.1.60-beta.1",
"dependencies": { "dependencies": {
"@clack/prompts": "^1.0.0", "@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.60-rc.2", "@getpaseo/relay": "0.1.60-beta.1",
"@getpaseo/server": "0.1.60-rc.2", "@getpaseo/server": "0.1.60-beta.1",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"commander": "^12.0.0", "commander": "^12.0.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
@@ -37500,11 +37501,11 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@getpaseo/desktop", "name": "@getpaseo/desktop",
"version": "0.1.60-rc.2", "version": "0.1.60-beta.1",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"@getpaseo/cli": "0.1.60-rc.2", "@getpaseo/cli": "0.1.60-beta.1",
"@getpaseo/server": "0.1.60-rc.2", "@getpaseo/server": "0.1.60-beta.1",
"electron-log": "^5.4.3", "electron-log": "^5.4.3",
"electron-updater": "^6.6.2", "electron-updater": "^6.6.2",
"ws": "^8.14.2" "ws": "^8.14.2"
@@ -37538,7 +37539,7 @@
}, },
"packages/expo-two-way-audio": { "packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio", "name": "@getpaseo/expo-two-way-audio",
"version": "0.1.60-rc.2", "version": "0.1.60-beta.1",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@biomejs/biome": "1.9.4", "@biomejs/biome": "1.9.4",
@@ -37739,7 +37740,7 @@
}, },
"packages/highlight": { "packages/highlight": {
"name": "@getpaseo/highlight", "name": "@getpaseo/highlight",
"version": "0.1.60-rc.2", "version": "0.1.60-beta.1",
"dependencies": { "dependencies": {
"@lezer/common": "^1.5.0", "@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5", "@lezer/cpp": "^1.1.5",
@@ -37765,7 +37766,7 @@
}, },
"packages/relay": { "packages/relay": {
"name": "@getpaseo/relay", "name": "@getpaseo/relay",
"version": "0.1.60-rc.2", "version": "0.1.60-beta.1",
"dependencies": { "dependencies": {
"base64-js": "^1.5.1", "base64-js": "^1.5.1",
"tweetnacl": "^1.0.3", "tweetnacl": "^1.0.3",
@@ -37781,14 +37782,14 @@
}, },
"packages/server": { "packages/server": {
"name": "@getpaseo/server", "name": "@getpaseo/server",
"version": "0.1.60-rc.2", "version": "0.1.60-beta.1",
"dependencies": { "dependencies": {
"@agentclientprotocol/sdk": "^0.17.1", "@agentclientprotocol/sdk": "^0.17.1",
"@ai-sdk/openai": "2.0.52", "@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11", "@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0", "@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.60-rc.2", "@getpaseo/highlight": "0.1.60-beta.1",
"@getpaseo/relay": "0.1.60-rc.2", "@getpaseo/relay": "0.1.60-beta.1",
"@isaacs/ttlcache": "^2.1.4", "@isaacs/ttlcache": "^2.1.4",
"@mariozechner/pi-coding-agent": "^0.67.68", "@mariozechner/pi-coding-agent": "^0.67.68",
"@modelcontextprotocol/sdk": "^1.20.1", "@modelcontextprotocol/sdk": "^1.20.1",
@@ -38542,7 +38543,7 @@
}, },
"packages/website": { "packages/website": {
"name": "@getpaseo/website", "name": "@getpaseo/website",
"version": "0.1.60-rc.2", "version": "0.1.60-beta.1",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "^1.20.3", "@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0", "@cloudflare/workers-types": "^4.20260114.0",
+9 -9
View File
@@ -1,6 +1,6 @@
{ {
"name": "paseo", "name": "paseo",
"version": "0.1.60-rc.2", "version": "0.1.60-beta.1",
"private": true, "private": true,
"workspaces": [ "workspaces": [
"packages/expo-two-way-audio", "packages/expo-two-way-audio",
@@ -47,19 +47,19 @@
"version:all:patch": "node scripts/set-release-version.mjs --mode patch", "version:all:patch": "node scripts/set-release-version.mjs --mode patch",
"version:all:minor": "node scripts/set-release-version.mjs --mode minor", "version:all:minor": "node scripts/set-release-version.mjs --mode minor",
"version:all:major": "node scripts/set-release-version.mjs --mode major", "version:all:major": "node scripts/set-release-version.mjs --mode major",
"version:all:rc:patch": "node scripts/set-release-version.mjs --mode rc-patch", "version:all:beta:patch": "node scripts/set-release-version.mjs --mode beta-patch",
"version:all:rc:minor": "node scripts/set-release-version.mjs --mode rc-minor", "version:all:beta:minor": "node scripts/set-release-version.mjs --mode beta-minor",
"version:all:rc:major": "node scripts/set-release-version.mjs --mode rc-major", "version:all:beta:major": "node scripts/set-release-version.mjs --mode beta-major",
"version:all:rc:next": "node scripts/set-release-version.mjs --mode rc-next", "version:all:beta:next": "node scripts/set-release-version.mjs --mode beta-next",
"version:all:promote": "node scripts/set-release-version.mjs --mode promote", "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 build --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli", "release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && 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/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public", "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/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public", "release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
"release:push": "node scripts/push-current-release-tag.mjs", "release:push": "node scripts/push-current-release-tag.mjs",
"release:rc:patch": "npm run release:check && npm run version:all:rc:patch && npm run release:push", "release:beta:patch": "npm run release:check && npm run version:all:beta:patch && npm run release:push",
"release:rc:minor": "npm run release:check && npm run version:all:rc:minor && npm run release:push", "release:beta:minor": "npm run release:check && npm run version:all:beta:minor && npm run release:push",
"release:rc:major": "npm run release:check && npm run version:all:rc:major && npm run release:push", "release:beta:major": "npm run release:check && npm run version:all:beta:major && npm run release:push",
"release:rc:next": "npm run release:check && npm run version:all:rc:next && npm run release:push", "release:beta:next": "npm run release:check && npm run version:all:beta:next && npm run release:push",
"release:promote": "npm run release:check && npm run version:all:promote && npm run release:publish && 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: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", "release:minor": "npm run release:check && npm run version:all:minor && npm run release:publish && npm run release:push",
+6 -4
View File
@@ -210,13 +210,15 @@ export async function openWorkspaceWithAgents(
} }
export async function expectWorkspaceTabVisible(page: Page, agentId: string): Promise<void> { export async function expectWorkspaceTabVisible(page: Page, agentId: string): Promise<void> {
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`).first()).toBeVisible({ await expect(
timeout: 30_000, page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }).first(),
}); ).toBeVisible({ timeout: 30_000 });
} }
export async function expectWorkspaceTabHidden(page: Page, agentId: string): Promise<void> { export async function expectWorkspaceTabHidden(page: Page, agentId: string): Promise<void> {
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`)).toHaveCount(0, { await expect(
page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }),
).toHaveCount(0, {
timeout: 30_000, timeout: 30_000,
}); });
} }
+19 -11
View File
@@ -23,16 +23,18 @@ export async function gotoWorkspace(page: Page, cwd: string): Promise<void> {
/** Wait for the workspace tab bar to be visible. */ /** Wait for the workspace tab bar to be visible. */
export async function waitForTabBar(page: Page): Promise<void> { export async function waitForTabBar(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-tabs-row").first()).toBeVisible({ await expect(
page.getByTestId("workspace-tabs-row").filter({ visible: true }).first(),
).toBeVisible({
timeout: 30_000, timeout: 30_000,
}); });
} }
/** Return all tab test IDs currently in the tab bar. */ /** Return all tab test IDs currently in the tab bar. */
export async function getTabTestIds(page: Page): Promise<string[]> { export async function getTabTestIds(page: Page): Promise<string[]> {
const tabs = page.locator( const tabs = page
'[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])', .locator('[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])')
); .filter({ visible: true });
const count = await tabs.count(); const count = await tabs.count();
const ids: string[] = []; const ids: string[] = [];
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
@@ -55,6 +57,7 @@ export async function getActiveTabTestId(page: Page): Promise<string | null> {
.locator( .locator(
'[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])[aria-selected="true"]', '[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])[aria-selected="true"]',
) )
.filter({ visible: true })
.first(); .first();
if (await activeTab.isVisible().catch(() => false)) { if (await activeTab.isVisible().catch(() => false)) {
return activeTab.getAttribute("data-testid"); return activeTab.getAttribute("data-testid");
@@ -67,14 +70,14 @@ export async function getActiveTabTestId(page: Page): Promise<string | null> {
/** Click the new agent tab button in the tab bar. Creates a draft/chat tab directly. */ /** Click the new agent tab button in the tab bar. Creates a draft/chat tab directly. */
export async function clickNewTabButton(page: Page): Promise<void> { export async function clickNewTabButton(page: Page): Promise<void> {
const button = page.getByTestId("workspace-new-agent-tab"); const button = page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first();
await expect(button).toBeVisible({ timeout: 10_000 }); await expect(button).toBeVisible({ timeout: 10_000 });
await button.click(); await button.click();
} }
/** Click the new terminal button in the workspace tab bar. Creates a terminal tab directly. */ /** Click the new terminal button in the workspace tab bar. Creates a terminal tab directly. */
export async function clickNewTerminalButton(page: Page): Promise<void> { export async function clickNewTerminalButton(page: Page): Promise<void> {
const button = page.getByTestId("workspace-new-terminal"); const button = page.getByTestId("workspace-new-terminal").filter({ visible: true }).first();
await expect(button).toBeVisible({ timeout: 10_000 }); await expect(button).toBeVisible({ timeout: 10_000 });
await button.click(); await button.click();
} }
@@ -94,26 +97,30 @@ export async function waitForLauncherPanel(page: Page): Promise<void> {
/** Assert the new agent tab button is visible in the tab bar. */ /** Assert the new agent tab button is visible in the tab bar. */
export async function assertNewChatTileVisible(page: Page): Promise<void> { export async function assertNewChatTileVisible(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-new-agent-tab").first()).toBeVisible(); await expect(
page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first(),
).toBeVisible();
} }
/** Assert the new terminal button is visible in the tab bar. */ /** Assert the new terminal button is visible in the tab bar. */
export async function assertTerminalTileVisible(page: Page): Promise<void> { export async function assertTerminalTileVisible(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-new-terminal").first()).toBeVisible(); await expect(
page.getByTestId("workspace-new-terminal").filter({ visible: true }).first(),
).toBeVisible();
} }
// ─── Tab creation actions ───────────────────────────────────────────────── // ─── Tab creation actions ─────────────────────────────────────────────────
/** Click the new agent tab button to create a draft/chat tab. */ /** Click the new agent tab button to create a draft/chat tab. */
export async function clickNewChat(page: Page): Promise<void> { export async function clickNewChat(page: Page): Promise<void> {
const button = page.getByTestId("workspace-new-agent-tab"); const button = page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first();
await expect(button).toBeVisible({ timeout: 10_000 }); await expect(button).toBeVisible({ timeout: 10_000 });
await button.click(); await button.click();
} }
/** Click the new terminal button to create a terminal tab. */ /** Click the new terminal button to create a terminal tab. */
export async function clickTerminal(page: Page): Promise<void> { export async function clickTerminal(page: Page): Promise<void> {
const button = page.getByTestId("workspace-new-terminal"); const button = page.getByTestId("workspace-new-terminal").filter({ visible: true }).first();
await expect(button).toBeVisible({ timeout: 10_000 }); await expect(button).toBeVisible({ timeout: 10_000 });
await button.click(); await button.click();
} }
@@ -131,13 +138,14 @@ export async function waitForTabWithTitle(
page page
.locator('[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])') .locator('[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])')
.filter({ hasText: matcher }) .filter({ hasText: matcher })
.filter({ visible: true })
.first(), .first(),
).toBeVisible({ timeout }); ).toBeVisible({ timeout });
} }
/** Assert the new agent tab button is visible in the tab bar. */ /** Assert the new agent tab button is visible in the tab bar. */
export async function assertSingleNewTabButton(page: Page): Promise<void> { export async function assertSingleNewTabButton(page: Page): Promise<void> {
const buttons = page.getByTestId("workspace-new-agent-tab"); const buttons = page.getByTestId("workspace-new-agent-tab").filter({ visible: true });
const count = await buttons.count(); const count = await buttons.count();
expect(count).toBeGreaterThanOrEqual(1); expect(count).toBeGreaterThanOrEqual(1);
} }
+17 -5
View File
@@ -13,18 +13,30 @@ export async function getWorkspaceTabTestIds(page: Page): Promise<string[]> {
return ids; return ids;
} }
function visibleTestId(page: Page, testId: string) {
return page.getByTestId(testId).filter({ visible: true });
}
export async function waitForWorkspaceTabsVisible(page: Page): Promise<void> { export async function waitForWorkspaceTabsVisible(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-tabs-row").first()).toBeVisible({ await expect(visibleTestId(page, "workspace-tabs-row").first()).toBeVisible({
timeout: 30_000, timeout: 30_000,
}); });
await expect(page.getByTestId("workspace-new-agent-tab").first()).toBeVisible({ await expect(visibleTestId(page, "workspace-new-agent-tab").first()).toBeVisible({
timeout: 30_000, timeout: 30_000,
}); });
} }
export async function getVisibleWorkspaceAgentTabIds(page: Page): Promise<string[]> { export async function getVisibleWorkspaceAgentTabIds(page: Page): Promise<string[]> {
const allTabIds = await getWorkspaceTabTestIds(page); const tabs = page.locator('[data-testid^="workspace-tab-agent_"]').filter({ visible: true });
return allTabIds.filter((id) => id.startsWith("workspace-tab-agent_")); const count = await tabs.count();
const ids: string[] = [];
for (let index = 0; index < count; index += 1) {
const testId = await tabs.nth(index).getAttribute("data-testid");
if (testId && !ids.includes(testId)) {
ids.push(testId);
}
}
return ids;
} }
export async function expectOnlyWorkspaceAgentTabsVisible( export async function expectOnlyWorkspaceAgentTabsVisible(
@@ -38,7 +50,7 @@ export async function expectOnlyWorkspaceAgentTabsVisible(
expect(unexpected).toEqual([]); expect(unexpected).toEqual([]);
expect(visible.length).toBe(expected.size); expect(visible.length).toBe(expected.size);
for (const expectedId of expectedAgentIds) { for (const expectedId of expectedAgentIds) {
await expect(page.getByTestId(`workspace-tab-agent_${expectedId}`)).toBeVisible({ await expect(visibleTestId(page, `workspace-tab-agent_${expectedId}`).first()).toBeVisible({
timeout: 30_000, timeout: 30_000,
}); });
} }
+2 -2
View File
@@ -103,8 +103,8 @@ export async function expectWorkspaceHeader(
page: Page, page: Page,
input: { title: string; subtitle: string }, input: { title: string; subtitle: string },
): Promise<void> { ): Promise<void> {
const titleLocator = page.getByTestId("workspace-header-title"); const titleLocator = page.getByTestId("workspace-header-title").filter({ visible: true });
const subtitleLocator = page.getByTestId("workspace-header-subtitle"); const subtitleLocator = page.getByTestId("workspace-header-subtitle").filter({ visible: true });
await expect(titleLocator.first()).toHaveText(input.title, { await expect(titleLocator.first()).toHaveText(input.title, {
timeout: 30_000, timeout: 30_000,
+6 -1
View File
@@ -248,7 +248,12 @@ test.describe("New workspace flow", () => {
subtitle: openedProject.projectDisplayName, subtitle: openedProject.projectDisplayName,
}); });
const agentTabs = page.locator('[data-testid^="workspace-tab-agent_"]'); const activeWorkspaceDeckEntry = page
.getByTestId(`workspace-deck-entry-${serverId}:${createdWorkspace.workspaceId}`)
.filter({ visible: true });
await expect(activeWorkspaceDeckEntry).toBeVisible({ timeout: 30_000 });
const agentTabs = activeWorkspaceDeckEntry.locator('[data-testid^="workspace-tab-agent_"]');
await expect(agentTabs).toHaveCount(1, { timeout: 30_000 }); await expect(agentTabs).toHaveCount(1, { timeout: 30_000 });
// Workspace setup may auto-open a setup tab that steals focus, // Workspace setup may auto-open a setup tab that steals focus,
@@ -96,6 +96,14 @@ test.describe("Workspace navigation regression", () => {
`workspace-tab-agent_${firstAgent.id}`, `workspace-tab-agent_${firstAgent.id}`,
]); ]);
const firstDeckEntry = page.getByTestId(
`workspace-deck-entry-${serverId}:${firstWorkspace.workspaceId}`,
);
const secondDeckEntry = page.getByTestId(
`workspace-deck-entry-${serverId}:${secondWorkspace.workspaceId}`,
);
await expect(firstDeckEntry).toBeVisible({ timeout: 30_000 });
await switchWorkspaceViaSidebar({ await switchWorkspaceViaSidebar({
page, page,
serverId, serverId,
@@ -126,27 +134,45 @@ test.describe("Workspace navigation regression", () => {
await expect(getVisibleWorkspaceAgentTabIds(page)).resolves.toEqual([ await expect(getVisibleWorkspaceAgentTabIds(page)).resolves.toEqual([
`workspace-tab-agent_${secondAgent.id}`, `workspace-tab-agent_${secondAgent.id}`,
]); ]);
await expect(firstDeckEntry).toBeAttached();
await expect(firstDeckEntry).toBeHidden();
await expect(secondDeckEntry).toBeVisible({ timeout: 30_000 });
await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(2);
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: firstWorkspace.workspaceId,
});
await waitForWorkspaceTabsVisible(page);
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId), {
timeout: 30_000,
});
await expect(firstDeckEntry).toBeVisible({ timeout: 30_000 });
await expect(secondDeckEntry).toBeAttached();
await expect(secondDeckEntry).toBeHidden();
await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(2);
await page.reload(); await page.reload();
await waitForSidebarHydration(page); await waitForSidebarHydration(page);
await waitForWorkspaceTabsVisible(page); await waitForWorkspaceTabsVisible(page);
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), { await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId), {
timeout: 30_000, timeout: 30_000,
}); });
await expectSidebarWorkspaceSelected({ await expectSidebarWorkspaceSelected({
page, page,
serverId, serverId,
workspaceId: secondWorkspace.workspaceId, workspaceId: firstWorkspace.workspaceId,
}); });
await expectWorkspaceHeader(page, { await expectWorkspaceHeader(page, {
title: secondWorkspace.workspaceName, title: firstWorkspace.workspaceName,
subtitle: secondWorkspace.projectDisplayName, subtitle: firstWorkspace.projectDisplayName,
}); });
await expectWorkspaceTabVisible(page, secondAgent.id); await expectWorkspaceTabVisible(page, firstAgent.id);
await expectWorkspaceTabHidden(page, firstAgent.id); await expectWorkspaceTabHidden(page, secondAgent.id);
await expectOnlyWorkspaceAgentTabsVisible(page, [secondAgent.id]); await expectOnlyWorkspaceAgentTabsVisible(page, [firstAgent.id]);
await expect(getVisibleWorkspaceAgentTabIds(page)).resolves.toEqual([ await expect(getVisibleWorkspaceAgentTabIds(page)).resolves.toEqual([
`workspace-tab-agent_${secondAgent.id}`, `workspace-tab-agent_${firstAgent.id}`,
]); ]);
} finally { } finally {
for (const agentId of agentIds) { for (const agentId of agentIds) {
@@ -94,7 +94,7 @@ test.describe("Workspace setup streaming", () => {
await navigateToWorkspaceViaSidebar(page, workspace.id); await navigateToWorkspaceViaSidebar(page, workspace.id);
await waitForWorkspaceTabsVisible(page); await waitForWorkspaceTabsVisible(page);
await page.getByTestId("workspace-new-agent-tab").first().click(); await page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first().click();
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({ await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
timeout: 30_000, timeout: 30_000,
}); });
+6 -5
View File
@@ -1,10 +1,10 @@
{ {
"name": "@getpaseo/app", "name": "@getpaseo/app",
"main": "index.ts", "main": "index.ts",
"version": "0.1.60-rc.2", "version": "0.1.60-beta.1",
"private": true, "private": true,
"scripts": { "scripts": {
"start": "expo start", "start": "APP_VARIANT=development expo start",
"reset-project": "node ./scripts/reset-project.js", "reset-project": "node ./scripts/reset-project.js",
"build:workspace-deps": "npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/expo-two-way-audio", "build:workspace-deps": "npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/expo-two-way-audio",
"eas-build-post-install": "npm run build:workspace-deps", "eas-build-post-install": "npm run build:workspace-deps",
@@ -31,9 +31,9 @@
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2", "@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7", "@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.60-rc.2", "@getpaseo/expo-two-way-audio": "0.1.60-beta.1",
"@getpaseo/highlight": "0.1.60-rc.2", "@getpaseo/highlight": "0.1.60-beta.1",
"@getpaseo/server": "0.1.60-rc.2", "@getpaseo/server": "0.1.60-beta.1",
"@gorhom/bottom-sheet": "^5.2.6", "@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14", "@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0", "@react-native-async-storage/async-storage": "2.2.0",
@@ -103,6 +103,7 @@
"react-native-web": "~0.21.0", "react-native-web": "~0.21.0",
"react-native-webview": "^13.16.0", "react-native-webview": "^13.16.0",
"react-native-worklets": "0.5.1", "react-native-worklets": "0.5.1",
"use-sync-external-store": "^1.6.0",
"zod": "^3.23.8", "zod": "^3.23.8",
"zustand": "^5.0.9" "zustand": "^5.0.9"
}, },
+66 -35
View File
@@ -10,7 +10,6 @@ import {
import { SafeAreaProvider } from "react-native-safe-area-context"; import { SafeAreaProvider } from "react-native-safe-area-context";
import { KeyboardProvider } from "react-native-keyboard-controller"; import { KeyboardProvider } from "react-native-keyboard-controller";
import { GestureHandlerRootView, Gesture, GestureDetector } from "react-native-gesture-handler"; import { GestureHandlerRootView, Gesture, GestureDetector } from "react-native-gesture-handler";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { PortalProvider } from "@gorhom/portal"; import { PortalProvider } from "@gorhom/portal";
import { VoiceProvider } from "@/contexts/voice-context"; import { VoiceProvider } from "@/contexts/voice-context";
import { useAppSettings } from "@/hooks/use-settings"; import { useAppSettings } from "@/hooks/use-settings";
@@ -63,9 +62,13 @@ import { CommandCenter } from "@/components/command-center";
import { ProjectPickerModal } from "@/components/project-picker-modal"; import { ProjectPickerModal } from "@/components/project-picker-modal";
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog"; import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog"; import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber";
import { resolveActiveHost } from "@/utils/active-host";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action"; import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
import { queryClient } from "@/query/query-client"; import { queryClient } from "@/query/query-client";
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
import { import {
WEB_NOTIFICATION_CLICK_EVENT, WEB_NOTIFICATION_CLICK_EVENT,
type WebNotificationClickDetail, type WebNotificationClickDetail,
@@ -83,7 +86,10 @@ import {
parseWorkspaceOpenIntent, parseWorkspaceOpenIntent,
decodeWorkspaceIdFromPathSegment, decodeWorkspaceIdFromPathSegment,
} from "@/utils/host-routes"; } from "@/utils/host-routes";
import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store"; import {
addBrowserActiveWorkspaceLocationListener,
syncNavigationActiveWorkspace,
} from "@/stores/navigation-active-workspace-store";
import { isWeb, isNative } from "@/constants/platform"; import { isWeb, isNative } from "@/constants/platform";
polyfillCrypto(); polyfillCrypto();
@@ -388,9 +394,11 @@ function AppContainer({
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const daemons = useHosts(); const daemons = useHosts();
const { settings, updateSettings } = useAppSettings(); const { settings, updateSettings } = useAppSettings();
const toggleAgentList = usePanelStore((state) => state.toggleAgentList); const toggleMobileAgentList = usePanelStore((state) => state.toggleMobileAgentList);
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer); const toggleDesktopAgentList = usePanelStore((state) => state.toggleDesktopAgentList);
const toggleBothSidebars = usePanelStore((state) => state.toggleBothSidebars); const openDesktopAgentList = usePanelStore((state) => state.openDesktopAgentList);
const closeDesktopAgentList = usePanelStore((state) => state.closeDesktopAgentList);
const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer);
const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode); const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode);
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled); const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
const agentListOpen = usePanelStore((state) => state.desktop.agentListOpen); const agentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
@@ -405,6 +413,26 @@ function AppContainer({
const isCompactLayout = useIsCompactFormFactor(); const isCompactLayout = useIsCompactFormFactor();
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0; const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
const pathname = usePathname(); const pathname = usePathname();
const activeServerId = useMemo(
() => resolveActiveHost({ hosts: daemons, pathname })?.serverId ?? null,
[daemons, pathname],
);
const toggleAgentList = isCompactLayout ? toggleMobileAgentList : toggleDesktopAgentList;
const toggleDesktopSidebars = useCallback(() => {
const { desktop } = usePanelStore.getState();
toggleDesktopSidebarsWithCheckoutIntent({
isAgentListOpen: desktop.agentListOpen,
isFileExplorerOpen: desktop.fileExplorerOpen,
openAgentList: openDesktopAgentList,
closeAgentList: closeDesktopAgentList,
closeFileExplorer: closeDesktopFileExplorer,
toggleFocusedFileExplorer: () =>
keyboardActionDispatcher.dispatch({
id: "sidebar.toggle.right",
scope: "sidebar",
}),
});
}, [closeDesktopAgentList, closeDesktopFileExplorer, openDesktopAgentList]);
// TODO: stop matching pathname here as a branch. `chromeEnabled` should not // TODO: stop matching pathname here as a branch. `chromeEnabled` should not
// conflate workspace/project-specific chrome (sidebar, mobile gesture) with // conflate workspace/project-specific chrome (sidebar, mobile gesture) with
// global concerns like keyboard shortcuts. Split those out so settings (and // global concerns like keyboard shortcuts. Split those out so settings (and
@@ -444,8 +472,7 @@ function AppContainer({
enabled: keyboardShortcutsEnabled, enabled: keyboardShortcutsEnabled,
isMobile: isCompactLayout, isMobile: isCompactLayout,
toggleAgentList, toggleAgentList,
toggleFileExplorer, toggleBothSidebars: toggleDesktopSidebars,
toggleBothSidebars,
toggleFocusMode, toggleFocusMode,
cycleTheme, cycleTheme,
}); });
@@ -470,6 +497,10 @@ function AppContainer({
<UpdateBanner /> <UpdateBanner />
<CommandCenter /> <CommandCenter />
<ProjectPickerModal /> <ProjectPickerModal />
<WorkspaceShortcutTargetsSubscriber
enabled={keyboardShortcutsEnabled}
serverId={activeServerId}
/>
<WorkspaceSetupDialog /> <WorkspaceSetupDialog />
<KeyboardShortcutsDialog /> <KeyboardShortcutsDialog />
</View> </View>
@@ -490,7 +521,7 @@ function MobileGestureWrapper({
chromeEnabled: boolean; chromeEnabled: boolean;
}) { }) {
const mobileView = usePanelStore((state) => state.mobileView); const mobileView = usePanelStore((state) => state.mobileView);
const openAgentList = usePanelStore((state) => state.openAgentList); const showMobileAgentList = usePanelStore((state) => state.showMobileAgentList);
const horizontalScroll = useHorizontalScrollOptional(); const horizontalScroll = useHorizontalScrollOptional();
const { const {
translateX, translateX,
@@ -507,8 +538,8 @@ function MobileGestureWrapper({
const handleGestureOpen = useCallback(() => { const handleGestureOpen = useCallback(() => {
gestureAnimatingRef.current = true; gestureAnimatingRef.current = true;
openAgentList(); showMobileAgentList();
}, [openAgentList, gestureAnimatingRef]); }, [showMobileAgentList, gestureAnimatingRef]);
const openGesture = useMemo( const openGesture = useMemo(
() => () =>
@@ -815,14 +846,14 @@ function RootStack() {
<Stack.Screen name="settings/[section]" /> <Stack.Screen name="settings/[section]" />
<Stack.Screen name="pair-scan" /> <Stack.Screen name="pair-scan" />
</Stack.Protected> </Stack.Protected>
<Stack.Screen {/*
name="h/[serverId]/workspace/[workspaceId]" Do not add getId or dangerouslySingular back to the workspace route.
getId={({ params }) => { Expo Router maps dangerouslySingular to React Navigation getId, and
const serverId = getRouteParamValue(params?.serverId); getId repeatedly breaks Android native-stack/Fabric by reordering an
const workspaceId = getRouteParamValue(params?.workspaceId); already-mounted workspace screen. Keep workspace identity/retention
return serverId && workspaceId ? `${serverId}:${workspaceId}` : undefined; outside this route-level native-stack API.
}} */}
/> <Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
<Stack.Screen name="h/[serverId]/agent/[agentId]" options={{ gestureEnabled: false }} /> <Stack.Screen name="h/[serverId]/agent/[agentId]" options={{ gestureEnabled: false }} />
<Stack.Screen name="h/[serverId]/index" /> <Stack.Screen name="h/[serverId]/index" />
<Stack.Screen name="h/[serverId]/sessions" /> <Stack.Screen name="h/[serverId]/sessions" />
@@ -839,6 +870,7 @@ function NavigationActiveWorkspaceObserver() {
useEffect(() => { useEffect(() => {
syncNavigationActiveWorkspace(navigationRef); syncNavigationActiveWorkspace(navigationRef);
const unsubscribeBrowserLocation = addBrowserActiveWorkspaceLocationListener();
const unsubscribeState = navigationRef.addListener("state", () => { const unsubscribeState = navigationRef.addListener("state", () => {
syncNavigationActiveWorkspace(navigationRef); syncNavigationActiveWorkspace(navigationRef);
}); });
@@ -846,6 +878,7 @@ function NavigationActiveWorkspaceObserver() {
syncNavigationActiveWorkspace(navigationRef); syncNavigationActiveWorkspace(navigationRef);
}); });
return () => { return () => {
unsubscribeBrowserLocation();
unsubscribeState(); unsubscribeState();
unsubscribeReady(); unsubscribeReady();
}; };
@@ -864,23 +897,21 @@ export default function RootLayout() {
<SafeAreaProvider> <SafeAreaProvider>
<KeyboardProvider> <KeyboardProvider>
<QueryProvider> <QueryProvider>
<BottomSheetModalProvider> <HostRuntimeBootstrapProvider>
<HostRuntimeBootstrapProvider> <PushNotificationRouter />
<PushNotificationRouter /> <ToastProvider>
<ToastProvider> <ProvidersWrapper>
<ProvidersWrapper> <SidebarAnimationProvider>
<SidebarAnimationProvider> <HorizontalScrollProvider>
<HorizontalScrollProvider> <OpenProjectListener />
<OpenProjectListener /> <AppWithSidebar>
<AppWithSidebar> <RootStack />
<RootStack /> </AppWithSidebar>
</AppWithSidebar> </HorizontalScrollProvider>
</HorizontalScrollProvider> </SidebarAnimationProvider>
</SidebarAnimationProvider> </ProvidersWrapper>
</ProvidersWrapper> </ToastProvider>
</ToastProvider> </HostRuntimeBootstrapProvider>
</HostRuntimeBootstrapProvider>
</BottomSheetModalProvider>
</QueryProvider> </QueryProvider>
</KeyboardProvider> </KeyboardProvider>
</SafeAreaProvider> </SafeAreaProvider>
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigation } from "@react-navigation/native"; import { useNavigation } from "@react-navigation/native";
import { StyleSheet, View } from "react-native";
import { import {
useGlobalSearchParams, useGlobalSearchParams,
useLocalSearchParams, useLocalSearchParams,
@@ -7,6 +8,11 @@ import {
useRootNavigationState, useRootNavigationState,
} from "expo-router"; } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary"; import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import {
activateNavigationWorkspaceSelection,
type ActiveWorkspaceSelection,
useNavigationActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen"; import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
import { import {
@@ -95,6 +101,23 @@ function HostWorkspaceLayoutContent() {
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "") ? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
: ""; : "";
const openValue = getParamValue(globalParams.open); const openValue = getParamValue(globalParams.open);
const routeWorkspaceSelection = useMemo(
() =>
serverId && workspaceId
? {
serverId,
workspaceId,
}
: null,
[serverId, workspaceId],
);
useEffect(() => {
if (!routeWorkspaceSelection) {
return;
}
activateNavigationWorkspaceSelection(routeWorkspaceSelection);
}, [routeWorkspaceSelection]);
useEffect(() => { useEffect(() => {
if (!openValue) { if (!openValue) {
@@ -144,11 +167,73 @@ function HostWorkspaceLayoutContent() {
return null; return null;
} }
return <WorkspaceDeck fallbackSelection={routeWorkspaceSelection} />;
}
function areWorkspaceSelectionsEqual(
left: ActiveWorkspaceSelection | null,
right: ActiveWorkspaceSelection | null,
): boolean {
return left?.serverId === right?.serverId && left?.workspaceId === right?.workspaceId;
}
function WorkspaceDeck({
fallbackSelection,
}: {
fallbackSelection: ActiveWorkspaceSelection | null;
}) {
const activeSelection = useNavigationActiveWorkspaceSelection() ?? fallbackSelection;
const [mountedSelections, setMountedSelections] = useState<ActiveWorkspaceSelection[]>(() =>
activeSelection ? [activeSelection] : [],
);
useEffect(() => {
if (!activeSelection) {
return;
}
setMountedSelections((current) => {
if (current.some((selection) => areWorkspaceSelectionsEqual(selection, activeSelection))) {
return current;
}
return [...current, activeSelection];
});
}, [activeSelection]);
if (!activeSelection) {
return null;
}
return ( return (
<WorkspaceScreen <View style={styles.deck}>
key={`${serverId}:${workspaceId}`} {mountedSelections.map((selection) => {
serverId={serverId} const isActive = areWorkspaceSelectionsEqual(selection, activeSelection);
workspaceId={workspaceId} return (
/> <View
key={`${selection.serverId}:${selection.workspaceId}`}
style={isActive ? styles.activeDeckEntry : styles.inactiveDeckEntry}
testID={`workspace-deck-entry-${selection.serverId}:${selection.workspaceId}`}
>
<WorkspaceScreen
serverId={selection.serverId}
workspaceId={selection.workspaceId}
isRouteFocused={isActive}
/>
</View>
);
})}
</View>
); );
} }
const styles = StyleSheet.create({
deck: {
flex: 1,
},
activeDeckEntry: {
flex: 1,
},
inactiveDeckEntry: {
display: "none",
flex: 1,
},
});
@@ -1,4 +1,4 @@
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react"; import { forwardRef, useCallback, useEffect, useMemo } from "react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native"; import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
@@ -7,7 +7,6 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root"; import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root";
import { import {
BottomSheetModal,
BottomSheetBackdrop, BottomSheetBackdrop,
BottomSheetScrollView, BottomSheetScrollView,
BottomSheetTextInput, BottomSheetTextInput,
@@ -16,6 +15,10 @@ import {
import { X } from "lucide-react-native"; import { X } from "lucide-react-native";
import { FileDropZone } from "@/components/file-drop-zone"; import { FileDropZone } from "@/components/file-drop-zone";
import type { ImageAttachment } from "@/components/message-input"; import type { ImageAttachment } from "@/components/message-input";
import {
IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility,
} from "@/components/ui/isolated-bottom-sheet-modal";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
type EscHandler = () => void; type EscHandler = () => void;
@@ -163,7 +166,6 @@ export interface AdaptiveModalSheetProps {
children: ReactNode; children: ReactNode;
headerActions?: ReactNode; headerActions?: ReactNode;
snapPoints?: string[]; snapPoints?: string[];
stackBehavior?: "push" | "switch" | "replace";
testID?: string; testID?: string;
/** Override the max width of the desktop card. */ /** Override the max width of the desktop card. */
desktopMaxWidth?: number; desktopMaxWidth?: number;
@@ -180,7 +182,6 @@ export function AdaptiveModalSheet({
children, children,
headerActions, headerActions,
snapPoints, snapPoints,
stackBehavior,
testID, testID,
desktopMaxWidth, desktopMaxWidth,
onFilesDropped, onFilesDropped,
@@ -188,33 +189,13 @@ export function AdaptiveModalSheet({
}: AdaptiveModalSheetProps) { }: AdaptiveModalSheetProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const sheetRef = useRef<BottomSheetModal>(null); const titleColor = theme.colors.foreground;
const dismissingForVisibilityRef = useRef(false);
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]); const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
const { sheetRef, handleSheetChange } = useIsolatedBottomSheetVisibility({
useEffect(() => { visible,
if (!isMobile) return; isEnabled: isMobile,
if (visible) { onClose,
dismissingForVisibilityRef.current = false; });
sheetRef.current?.present();
} else {
dismissingForVisibilityRef.current = true;
sheetRef.current?.dismiss();
}
}, [visible, isMobile]);
const handleSheetChange = useCallback(
(index: number) => {
if (index === -1) {
if (dismissingForVisibilityRef.current) {
dismissingForVisibilityRef.current = false;
return;
}
onClose();
}
},
[onClose],
);
const renderBackdrop = useCallback( const renderBackdrop = useCallback(
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => ( (props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
@@ -230,7 +211,7 @@ export function AdaptiveModalSheet({
if (isMobile) { if (isMobile) {
return ( return (
<BottomSheetModal <IsolatedBottomSheetModal
ref={sheetRef} ref={sheetRef}
snapPoints={resolvedSnapPoints} snapPoints={resolvedSnapPoints}
index={0} index={0}
@@ -238,7 +219,6 @@ export function AdaptiveModalSheet({
onChange={handleSheetChange} onChange={handleSheetChange}
backdropComponent={renderBackdrop} backdropComponent={renderBackdrop}
enablePanDownToClose enablePanDownToClose
stackBehavior={stackBehavior}
backgroundComponent={SheetBackground} backgroundComponent={SheetBackground}
handleIndicatorStyle={{ backgroundColor: theme.colors.surface2 }} handleIndicatorStyle={{ backgroundColor: theme.colors.surface2 }}
keyboardBehavior="extend" keyboardBehavior="extend"
@@ -247,7 +227,7 @@ export function AdaptiveModalSheet({
> >
<View style={styles.bottomSheetHeader} testID={testID}> <View style={styles.bottomSheetHeader} testID={testID}>
<View style={styles.headerTitleGroup}> <View style={styles.headerTitleGroup}>
<Text style={[styles.title, { color: theme.colors.foreground }]} numberOfLines={1}> <Text key={titleColor} style={[styles.title, { color: titleColor }]} numberOfLines={1}>
{title} {title}
</Text> </Text>
{subtitle} {subtitle}
@@ -268,7 +248,7 @@ export function AdaptiveModalSheet({
) : ( ) : (
<View style={styles.bottomSheetStaticContent}>{children}</View> <View style={styles.bottomSheetStaticContent}>{children}</View>
)} )}
</BottomSheetModal> </IsolatedBottomSheetModal>
); );
} }
@@ -276,7 +256,7 @@ export function AdaptiveModalSheet({
<> <>
<View style={styles.header}> <View style={styles.header}>
<View style={styles.headerTitleGroup}> <View style={styles.headerTitleGroup}>
<Text style={[styles.title, { color: theme.colors.foreground }]} numberOfLines={1}> <Text key={titleColor} style={[styles.title, { color: titleColor }]} numberOfLines={1}>
{title} {title}
</Text> </Text>
{subtitle} {subtitle}
@@ -4,7 +4,6 @@ import { View, Text, Pressable, TextInput, ActivityIndicator } from "react-nativ
import type { StyleProp, ViewStyle, TextProps } from "react-native"; import type { StyleProp, ViewStyle, TextProps } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { import {
BottomSheetModal,
BottomSheetScrollView, BottomSheetScrollView,
BottomSheetBackdrop, BottomSheetBackdrop,
BottomSheetBackgroundProps, BottomSheetBackgroundProps,
@@ -30,6 +29,10 @@ import type {
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest"; import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
import { getModeVisuals, type AgentModeIcon } from "@server/server/agent/provider-manifest"; import { getModeVisuals, type AgentModeIcon } from "@server/server/agent/provider-manifest";
import { Combobox, ComboboxItem, ComboboxEmpty } from "@/components/ui/combobox"; import { Combobox, ComboboxItem, ComboboxEmpty } from "@/components/ui/combobox";
import {
IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility,
} from "@/components/ui/isolated-bottom-sheet-modal";
import { baseColors } from "@/styles/theme"; import { baseColors } from "@/styles/theme";
import { isNative } from "@/constants/platform"; import { isNative } from "@/constants/platform";
@@ -256,31 +259,17 @@ export function DropdownSheet({
children, children,
}: DropdownSheetProps): ReactElement { }: DropdownSheetProps): ReactElement {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const bottomSheetRef = useRef<BottomSheetModal>(null); const titleColor = theme.colors.foreground;
const snapPoints = useMemo(() => ["60%", "90%"], []); const snapPoints = useMemo(() => ["60%", "90%"], []);
const { sheetRef, handleSheetChange } = useIsolatedBottomSheetVisibility({
visible,
onClose,
});
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
bottomSheetRef.current?.dismiss();
onClose(); onClose();
}, [onClose]); }, [onClose]);
useEffect(() => {
if (visible) {
bottomSheetRef.current?.present();
} else {
bottomSheetRef.current?.dismiss();
}
}, [visible]);
const handleSheetChange = useCallback(
(index: number) => {
if (index === -1) {
onClose();
}
},
[onClose],
);
const renderBackdrop = useCallback( const renderBackdrop = useCallback(
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => ( (props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} /> <BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
@@ -289,8 +278,8 @@ export function DropdownSheet({
); );
return ( return (
<BottomSheetModal <IsolatedBottomSheetModal
ref={bottomSheetRef} ref={sheetRef}
snapPoints={snapPoints} snapPoints={snapPoints}
index={0} index={0}
enableDynamicSizing={false} enableDynamicSizing={false}
@@ -303,7 +292,9 @@ export function DropdownSheet({
keyboardBlurBehavior="restore" keyboardBlurBehavior="restore"
> >
<View style={styles.bottomSheetHeader}> <View style={styles.bottomSheetHeader}>
<Text style={[styles.dropdownSheetTitle, { color: theme.colors.foreground }]}>{title}</Text> <Text key={titleColor} style={[styles.dropdownSheetTitle, { color: titleColor }]}>
{title}
</Text>
<Pressable <Pressable
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Close sheet" accessibilityLabel="Close sheet"
@@ -321,7 +312,7 @@ export function DropdownSheet({
> >
{children} {children}
</BottomSheetScrollView> </BottomSheetScrollView>
</BottomSheetModal> </IsolatedBottomSheetModal>
); );
} }
@@ -1229,7 +1220,6 @@ const styles = StyleSheet.create((theme) => ({
dropdownSheetTitle: { dropdownSheetTitle: {
fontSize: theme.fontSize.lg, fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.semibold, fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
textAlign: "center", textAlign: "center",
}, },
dropdownSheetScrollContent: { dropdownSheetScrollContent: {
@@ -92,7 +92,7 @@ type ControlledAgentStatusBarProps = {
export interface DraftAgentStatusBarProps { export interface DraftAgentStatusBarProps {
providerDefinitions: AgentProviderDefinition[]; providerDefinitions: AgentProviderDefinition[];
selectedProvider: AgentProvider; selectedProvider: AgentProvider | null;
onSelectProvider: (provider: AgentProvider) => void; onSelectProvider: (provider: AgentProvider) => void;
modeOptions: AgentMode[]; modeOptions: AgentMode[];
selectedMode: string; selectedMode: string;
@@ -255,7 +255,8 @@ function ControlledStatusBar({
: undefined; : undefined;
const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null; const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null;
const modeIconColor = getModeIconColor(modeVisuals?.colorTier, theme.colors.palette); const modeIconColor = getModeIconColor(modeVisuals?.colorTier, theme.colors.palette);
const ProviderIcon = getProviderIcon(provider); const hasSelectedProvider = provider.trim().length > 0;
const ProviderIcon = hasSelectedProvider ? getProviderIcon(provider) : null;
const hasAnyControl = const hasAnyControl =
Boolean(providerOptions?.length) || Boolean(providerOptions?.length) ||
@@ -620,7 +621,9 @@ function ControlledStatusBar({
accessibilityLabel="Agent preferences" accessibilityLabel="Agent preferences"
testID="agent-preferences-button" testID="agent-preferences-button"
> >
<ProviderIcon size={theme.iconSize.lg} color={theme.colors.foregroundMuted} /> {ProviderIcon ? (
<ProviderIcon size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
) : null}
<Text style={styles.prefsButtonText} numberOfLines={1}> <Text style={styles.prefsButtonText} numberOfLines={1}>
{displayModel} {displayModel}
</Text> </Text>
@@ -630,7 +633,6 @@ function ControlledStatusBar({
title="Preferences" title="Preferences"
visible={prefsOpen} visible={prefsOpen}
onClose={() => setPrefsOpen(false)} onClose={() => setPrefsOpen(false)}
stackBehavior="replace"
testID="agent-preferences-sheet" testID="agent-preferences-sheet"
> >
{canSelectModel ? ( {canSelectModel ? (
@@ -663,7 +665,12 @@ function ControlledStatusBar({
pointerEvents="none" pointerEvents="none"
testID="agent-preferences-model" testID="agent-preferences-model"
> >
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} /> {ProviderIcon ? (
<ProviderIcon
size={theme.iconSize.md}
color={theme.colors.foregroundMuted}
/>
) : null}
<Text style={styles.sheetSelectText}>{selectedModelLabel}</Text> <Text style={styles.sheetSelectText}>{selectedModelLabel}</Text>
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} /> <ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</View> </View>
@@ -873,15 +880,15 @@ export const AgentStatusBar = memo(function AgentStatusBar({
refetchIfStale: refetchSnapshotIfStale, refetchIfStale: refetchSnapshotIfStale,
} = useProvidersSnapshot(serverId, agent?.cwd); } = useProvidersSnapshot(serverId, agent?.cwd);
const snapshotModels = useMemo(() => { const snapshotSelectedEntry = useMemo(() => {
if (!snapshotEntries || !agent?.provider) { if (!snapshotEntries || !agent?.provider) {
return null; return null;
} }
const entry = snapshotEntries.find((e) => e.provider === agent.provider); return snapshotEntries.find((e) => e.provider === agent.provider) ?? null;
return entry?.models ?? null;
}, [snapshotEntries, agent?.provider]); }, [snapshotEntries, agent?.provider]);
const models = snapshotModels; const models = snapshotSelectedEntry?.models ?? null;
const selectedProviderIsLoading = snapshotSelectedEntry?.status === "loading";
const agentProviderDefinitions = useMemo(() => { const agentProviderDefinitions = useMemo(() => {
const definition = agent?.provider const definition = agent?.provider
@@ -892,11 +899,11 @@ export const AgentStatusBar = memo(function AgentStatusBar({
const agentProviderModels = useMemo(() => { const agentProviderModels = useMemo(() => {
const map = new Map<string, AgentModelDefinition[]>(); const map = new Map<string, AgentModelDefinition[]>();
if (agent?.provider && snapshotModels) { if (agent?.provider && models) {
map.set(agent.provider, snapshotModels); map.set(agent.provider, models);
} }
return map; return map;
}, [agent?.provider, snapshotModels]); }, [agent?.provider, models]);
const displayMode = const displayMode =
availableModes.find((mode) => mode.id === agent?.currentModeId)?.label || availableModes.find((mode) => mode.id === agent?.currentModeId)?.label ||
@@ -1040,8 +1047,8 @@ export const AgentStatusBar = memo(function AgentStatusBar({
toast.error(toErrorMessage(error)); toast.error(toErrorMessage(error));
}); });
}} }}
isModelLoading={snapshotIsLoading} isModelLoading={snapshotIsLoading || selectedProviderIsLoading}
onModelSelectorOpen={refetchSnapshotIfStale} onModelSelectorOpen={() => refetchSnapshotIfStale(agent?.provider)}
onDropdownClose={onDropdownClose} onDropdownClose={onDropdownClose}
disabled={!client} disabled={!client}
/> />
@@ -1097,6 +1104,7 @@ export function DraftAgentStatusBar({
const effectiveSelectedMode = selectedMode || mappedModeOptions[0]?.id || ""; const effectiveSelectedMode = selectedMode || mappedModeOptions[0]?.id || "";
const effectiveSelectedThinkingOption = const effectiveSelectedThinkingOption =
selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined; selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined;
const hasSelectedProvider = selectedProvider !== null;
if (platformIsWeb) { if (platformIsWeb) {
return ( return (
@@ -1104,7 +1112,7 @@ export function DraftAgentStatusBar({
<CombinedModelSelector <CombinedModelSelector
providerDefinitions={providerDefinitions} providerDefinitions={providerDefinitions}
allProviderModels={allProviderModels} allProviderModels={allProviderModels}
selectedProvider={selectedProvider} selectedProvider={selectedProvider ?? ""}
selectedModel={selectedModel} selectedModel={selectedModel}
onSelect={onSelectProviderAndModel} onSelect={onSelectProviderAndModel}
favoriteKeys={favoriteKeys} favoriteKeys={favoriteKeys}
@@ -1120,20 +1128,22 @@ export function DraftAgentStatusBar({
onOpen={onModelSelectorOpen} onOpen={onModelSelectorOpen}
onClose={onDropdownClose} onClose={onDropdownClose}
/> />
<ControlledStatusBar {selectedProvider ? (
provider={selectedProvider} <ControlledStatusBar
providerDefinitions={providerDefinitions} provider={selectedProvider}
modeOptions={mappedModeOptions} providerDefinitions={providerDefinitions}
selectedModeId={effectiveSelectedMode} modeOptions={mappedModeOptions}
onSelectMode={onSelectMode} selectedModeId={effectiveSelectedMode}
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined} onSelectMode={onSelectMode}
selectedThinkingOptionId={effectiveSelectedThinkingOption} thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
onSelectThinkingOption={onSelectThinkingOption} selectedThinkingOptionId={effectiveSelectedThinkingOption}
features={features} onSelectThinkingOption={onSelectThinkingOption}
onSetFeature={onSetFeature} features={features}
onDropdownClose={onDropdownClose} onSetFeature={onSetFeature}
disabled={disabled} onDropdownClose={onDropdownClose}
/> disabled={disabled}
/>
) : null}
</View> </View>
); );
} }
@@ -1146,10 +1156,10 @@ export function DraftAgentStatusBar({
return ( return (
<> <>
<ControlledStatusBar <ControlledStatusBar
provider={selectedProvider} provider={selectedProvider ?? ""}
providerDefinitions={providerDefinitions} providerDefinitions={providerDefinitions}
allProviderModels={allProviderModels} allProviderModels={allProviderModels}
modeOptions={mappedModeOptions} modeOptions={hasSelectedProvider ? mappedModeOptions : undefined}
selectedModeId={effectiveSelectedMode} selectedModeId={effectiveSelectedMode}
onSelectMode={onSelectMode} onSelectMode={onSelectMode}
modelOptions={modelOptions} modelOptions={modelOptions}
@@ -125,7 +125,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState<Set<string>>( const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState<Set<string>>(
new Set(), new Set(),
); );
const openFileExplorer = usePanelStore((state) => state.openFileExplorer); const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout); const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
// Get serverId (fallback to agent's serverId if not provided) // Get serverId (fallback to agent's serverId if not provided)
@@ -199,17 +199,21 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
setCurrentPath: false, setCurrentPath: false,
}); });
setExplorerTabForCheckout({ const checkout = {
serverId: resolvedServerId, serverId: resolvedServerId,
cwd: agent.cwd, cwd: agent.cwd,
isGit: agent.projectPlacement?.checkout?.isGit ?? true, isGit: agent.projectPlacement?.checkout?.isGit ?? true,
tab: "files", };
setExplorerTabForCheckout({ ...checkout, tab: "files" });
openFileExplorerForCheckout({
isCompact: isMobile,
checkout,
}); });
openFileExplorer();
}, },
[ [
agent.cwd, agent.cwd,
openFileExplorer, isMobile,
openFileExplorerForCheckout,
requestDirectoryListing, requestDirectoryListing,
resolvedServerId, resolvedServerId,
router, router,
@@ -568,10 +568,14 @@ export function CombinedModelSelector({
[onSelect], [onSelect],
); );
const ProviderIcon = getProviderIcon(selectedProvider); const hasSelectedProvider = selectedProvider.trim().length > 0;
const ProviderIcon = hasSelectedProvider ? getProviderIcon(selectedProvider) : null;
const selectedModelLabel = useMemo(() => { const selectedModelLabel = useMemo(() => {
if (!selectedModel) { if (!selectedModel) {
if (!hasSelectedProvider) {
return "Select model";
}
return isLoading ? "Loading..." : "Select model"; return isLoading ? "Loading..." : "Select model";
} }
const models = allProviderModels.get(selectedProvider); const models = allProviderModels.get(selectedProvider);
@@ -580,7 +584,7 @@ export function CombinedModelSelector({
} }
const model = models.find((entry) => entry.id === selectedModel); const model = models.find((entry) => entry.id === selectedModel);
return model?.label ?? resolveDefaultModelLabel(models); return model?.label ?? resolveDefaultModelLabel(models);
}, [allProviderModels, isLoading, selectedModel, selectedProvider]); }, [allProviderModels, hasSelectedProvider, isLoading, selectedModel, selectedProvider]);
const desktopFixedHeight = useMemo(() => { const desktopFixedHeight = useMemo(() => {
if (view.kind !== "provider") { if (view.kind !== "provider") {
@@ -643,7 +647,9 @@ export function CombinedModelSelector({
}) })
) : ( ) : (
<> <>
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} /> {ProviderIcon ? (
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
) : null}
<Text style={styles.triggerText} numberOfLines={1} ellipsizeMode="tail"> <Text style={styles.triggerText} numberOfLines={1} ellipsizeMode="tail">
{triggerLabel} {triggerLabel}
</Text> </Text>
@@ -657,7 +663,6 @@ export function CombinedModelSelector({
onSelect={() => {}} onSelect={() => {}}
open={isOpen} open={isOpen}
onOpenChange={handleOpenChange} onOpenChange={handleOpenChange}
stackBehavior="push"
anchorRef={anchorRef} anchorRef={anchorRef}
desktopPlacement="top-start" desktopPlacement="top-start"
desktopMinWidth={360} desktopMinWidth={360}
+96 -2
View File
@@ -20,10 +20,12 @@ const {
deleteAttachmentsMock, deleteAttachmentsMock,
encodeImagesMock, encodeImagesMock,
openExternalUrlMock, openExternalUrlMock,
markScrollInvestigationRenderMock,
mockSessionState, mockSessionState,
setAgentStreamTailMock, setAgentStreamTailMock,
setAgentStreamHeadMock, setAgentStreamHeadMock,
setQueuedMessagesMock, setQueuedMessagesMock,
agentDirectoryStatusMock,
} = vi.hoisted(() => { } = vi.hoisted(() => {
const theme = { const theme = {
spacing: { 1: 4, 2: 8, 3: 12, 4: 16, 6: 24, 8: 32 }, spacing: { 1: 4, 2: 8, 3: 12, 4: 16, 6: 24, 8: 32 },
@@ -102,6 +104,17 @@ const {
string, string,
{ {
agents: Map<string, { status: string; lastUsage: null }>; agents: Map<string, { status: string; lastUsage: null }>;
serverInfo: {
serverId: string;
hostname: string | null;
version: string | null;
capabilities?: {
voice?: {
dictation: { enabled: boolean; reason: string };
voice: { enabled: boolean; reason: string };
};
};
} | null;
queuedMessages: Map<string, unknown[]>; queuedMessages: Map<string, unknown[]>;
agentStreamHead: Map<string, unknown[]>; agentStreamHead: Map<string, unknown[]>;
agentStreamTail: Map<string, unknown[]>; agentStreamTail: Map<string, unknown[]>;
@@ -114,6 +127,17 @@ const {
sessions: { sessions: {
server: { server: {
agents: new Map([["agent", { status: "idle", lastUsage: null }]]), agents: new Map([["agent", { status: "idle", lastUsage: null }]]),
serverInfo: {
serverId: "server",
hostname: "test",
version: "0.0.0",
capabilities: {
voice: {
dictation: { enabled: true, reason: "" },
voice: { enabled: true, reason: "" },
},
},
},
queuedMessages: new Map(), queuedMessages: new Map(),
agentStreamHead: new Map(), agentStreamHead: new Map(),
agentStreamTail: new Map(), agentStreamTail: new Map(),
@@ -135,6 +159,8 @@ const {
); );
mockSessionState.setAgentStreamTail = setAgentStreamTailMock; mockSessionState.setAgentStreamTail = setAgentStreamTailMock;
mockSessionState.setAgentStreamHead = setAgentStreamHeadMock; mockSessionState.setAgentStreamHead = setAgentStreamHeadMock;
const markScrollInvestigationRenderMock = vi.fn();
const agentDirectoryStatusMock = vi.fn(() => "ready");
return { return {
theme, theme,
@@ -147,10 +173,12 @@ const {
deleteAttachmentsMock: vi.fn(async () => {}), deleteAttachmentsMock: vi.fn(async () => {}),
encodeImagesMock: vi.fn(async (images: AttachmentMetadata[]) => images), encodeImagesMock: vi.fn(async (images: AttachmentMetadata[]) => images),
openExternalUrlMock: vi.fn(async () => {}), openExternalUrlMock: vi.fn(async () => {}),
markScrollInvestigationRenderMock,
mockSessionState, mockSessionState,
setAgentStreamTailMock, setAgentStreamTailMock,
setAgentStreamHeadMock, setAgentStreamHeadMock,
setQueuedMessagesMock, setQueuedMessagesMock,
agentDirectoryStatusMock,
}; };
}); });
@@ -217,7 +245,7 @@ vi.mock("react-native-safe-area-context", () => ({
vi.mock("@/runtime/host-runtime", () => ({ vi.mock("@/runtime/host-runtime", () => ({
useHostRuntimeClient: () => mockClient, useHostRuntimeClient: () => mockClient,
useHostRuntimeIsConnected: () => true, useHostRuntimeIsConnected: () => true,
useHostRuntimeAgentDirectoryStatus: () => "ready", useHostRuntimeAgentDirectoryStatus: () => agentDirectoryStatusMock(),
})); }));
vi.mock("@/stores/session-store", () => { vi.mock("@/stores/session-store", () => {
@@ -313,7 +341,7 @@ vi.mock("@/contexts/toast-context", () => ({
})); }));
vi.mock("@/utils/scroll-jank-investigation", () => ({ vi.mock("@/utils/scroll-jank-investigation", () => ({
markScrollInvestigationRender: vi.fn(), markScrollInvestigationRender: markScrollInvestigationRenderMock,
markScrollInvestigationEvent: vi.fn(), markScrollInvestigationEvent: vi.fn(),
})); }));
@@ -360,6 +388,20 @@ vi.mock("@/hooks/use-dictation", () => ({
})); }));
vi.mock("@/utils/server-info-capabilities", () => ({ vi.mock("@/utils/server-info-capabilities", () => ({
getVoiceReadinessState: ({
serverInfo,
mode,
}: {
serverInfo: {
capabilities?: {
voice?: {
dictation?: { enabled: boolean; reason: string };
voice?: { enabled: boolean; reason: string };
};
};
} | null;
mode: "dictation" | "voice";
}) => serverInfo?.capabilities?.voice?.[mode] ?? null,
resolveVoiceUnavailableMessage: () => null, resolveVoiceUnavailableMessage: () => null,
})); }));
@@ -547,9 +589,23 @@ beforeEach(() => {
deleteAttachmentsMock.mockClear(); deleteAttachmentsMock.mockClear();
encodeImagesMock.mockClear(); encodeImagesMock.mockClear();
openExternalUrlMock.mockClear(); openExternalUrlMock.mockClear();
markScrollInvestigationRenderMock.mockClear();
setAgentStreamTailMock.mockClear(); setAgentStreamTailMock.mockClear();
setAgentStreamHeadMock.mockClear(); setAgentStreamHeadMock.mockClear();
setQueuedMessagesMock.mockClear(); setQueuedMessagesMock.mockClear();
agentDirectoryStatusMock.mockReset();
agentDirectoryStatusMock.mockReturnValue("ready");
mockSessionState.sessions.server.serverInfo = {
serverId: "server",
hostname: "test",
version: "0.0.0",
capabilities: {
voice: {
dictation: { enabled: true, reason: "" },
voice: { enabled: true, reason: "" },
},
},
};
mockSessionState.sessions.server.agentStreamHead = new Map(); mockSessionState.sessions.server.agentStreamHead = new Map();
mockSessionState.sessions.server.agentStreamTail = new Map(); mockSessionState.sessions.server.agentStreamTail = new Map();
mockSessionState.sessions.server.queuedMessages = new Map(); mockSessionState.sessions.server.queuedMessages = new Map();
@@ -663,6 +719,12 @@ function queryAllAttachmentMenuItems(): NodeListOf<HTMLElement> {
return document.querySelectorAll('[data-testid^="message-input-attachment-menu-item-"]'); return document.querySelectorAll('[data-testid^="message-input-attachment-menu-item-"]');
} }
function countMessageInputRenders(): number {
return markScrollInvestigationRenderMock.mock.calls.filter(
([componentId]) => componentId === "MessageInput:server:agent",
).length;
}
describe("Composer attachments", () => { describe("Composer attachments", () => {
it("opens a Plus menu with image and GitHub attachment actions", () => { it("opens a Plus menu with image and GitHub attachment actions", () => {
renderComposer(); renderComposer();
@@ -847,6 +909,38 @@ describe("Composer attachments", () => {
expect(latestAttachments).toEqual([{ kind: "image", metadata: image }]); expect(latestAttachments).toEqual([{ kind: "image", metadata: image }]);
}); });
it("does not re-render MessageInput when opening the attachment lightbox", () => {
const image = imageAttachment("img-lightbox-render");
renderComposer({ initialAttachments: [{ kind: "image", metadata: image }] });
const renderCountBeforeLightbox = countMessageInputRenders();
click(queryByTestId("composer-image-attachment-pill")!);
expect(queryByTestId("attachment-lightbox-image")).not.toBeNull();
expect(countMessageInputRenders()).toBe(renderCountBeforeLightbox);
});
it("still re-renders MessageInput when submit loading semantics change", () => {
renderComposer({ initialText: "pending submit", isSubmitLoading: false });
const renderCountBeforeLoading = countMessageInputRenders();
renderComposer({ initialText: "pending submit", isSubmitLoading: true });
expect(countMessageInputRenders()).toBe(renderCountBeforeLoading + 1);
expect(document.querySelector('[aria-label="Send message"]')).toHaveProperty("disabled", true);
});
it("enables dictation from server capabilities before the agent directory finishes loading", () => {
agentDirectoryStatusMock.mockReturnValue("initial_loading");
renderComposer();
expect(document.querySelector('[aria-label="Start dictation"]')).toHaveProperty(
"disabled",
false,
);
});
it("locks the preserved draft while submit loading", () => { it("locks the preserved draft while submit loading", () => {
renderComposer({ renderComposer({
initialText: "keep this prompt", initialText: "keep this prompt",
+237 -184
View File
@@ -1,5 +1,5 @@
import { View, Pressable, Text, ActivityIndicator, Image } from "react-native"; import { View, Pressable, Text, ActivityIndicator, Image } from "react-native";
import { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { useState, useEffect, useRef, useCallback, useMemo, memo } from "react";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { useShallow } from "zustand/shallow"; import { useShallow } from "zustand/shallow";
@@ -14,7 +14,6 @@ import {
Paperclip, Paperclip,
} from "lucide-react-native"; } from "lucide-react-native";
import Animated from "react-native-reanimated"; import Animated from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout"; import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
import { generateMessageId, type StreamItem } from "@/types/stream"; import { generateMessageId, type StreamItem } from "@/types/stream";
@@ -70,6 +69,7 @@ import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachm
import { AttachmentPill } from "@/components/attachment-pill"; import { AttachmentPill } from "@/components/attachment-pill";
import { AttachmentLightbox } from "@/components/attachment-lightbox"; import { AttachmentLightbox } from "@/components/attachment-lightbox";
import { openExternalUrl } from "@/utils/open-external-url"; import { openExternalUrl } from "@/utils/open-external-url";
import { useIsDictationReady } from "@/hooks/use-is-dictation-ready";
type QueuedMessage = { type QueuedMessage = {
id: string; id: string;
@@ -134,6 +134,7 @@ interface ComposerProps {
const EMPTY_ARRAY: readonly QueuedMessage[] = []; const EMPTY_ARRAY: readonly QueuedMessage[] = [];
const DESKTOP_MESSAGE_PLACEHOLDER = "Message the agent, tag @files, or use /commands and /skills"; const DESKTOP_MESSAGE_PLACEHOLDER = "Message the agent, tag @files, or use /commands and /skills";
const MOBILE_MESSAGE_PLACEHOLDER = "Message, @files, /commands"; const MOBILE_MESSAGE_PLACEHOLDER = "Message, @files, /commands";
const StableMessageInput = memo(MessageInput);
export function Composer({ export function Composer({
agentId, agentId,
@@ -167,19 +168,20 @@ export function Composer({
markScrollInvestigationRender(`Composer:${serverId}:${agentId}`); markScrollInvestigationRender(`Composer:${serverId}:${agentId}`);
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const buttonIconSize = isWeb ? theme.iconSize.md : theme.iconSize.lg; const buttonIconSize = isWeb ? theme.iconSize.md : theme.iconSize.lg;
const insets = useSafeAreaInsets();
const client = useHostRuntimeClient(serverId); const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId); const isConnected = useHostRuntimeIsConnected(serverId);
const agentDirectoryStatus = useHostRuntimeAgentDirectoryStatus(serverId); const agentDirectoryStatus = useHostRuntimeAgentDirectoryStatus(serverId);
const toast = useToast(); const toast = useToast();
const toastErrorRef = useRef(toast.error);
toastErrorRef.current = toast.error;
const voice = useVoiceOptional(); const voice = useVoiceOptional();
const voiceToggleKeys = useShortcutKeys("voice-toggle"); const voiceToggleKeys = useShortcutKeys("voice-toggle");
const dictationCancelKeys = useShortcutKeys("dictation-cancel"); const dictationCancelKeys = useShortcutKeys("dictation-cancel");
const isDictationReady = const isDictationReady = useIsDictationReady({
isConnected && serverId,
(agentDirectoryStatus === "ready" || isConnected,
agentDirectoryStatus === "revalidating" || agentDirectoryStatus,
agentDirectoryStatus === "error_after_ready"); });
const { settings: appSettings } = useAppSettings(); const { settings: appSettings } = useAppSettings();
@@ -238,6 +240,8 @@ export function Composer({
messageInputRef.current?.focus(); messageInputRef.current?.focus();
}, },
}); });
const autocompleteOnKeyPressRef = useRef(autocomplete.onKeyPress);
autocompleteOnKeyPressRef.current = autocomplete.onKeyPress;
// Clear send error when user edits the input // Clear send error when user edits the input
useEffect(() => { useEffect(() => {
@@ -375,67 +379,82 @@ export function Composer({
[agentId, serverId, setQueuedMessages], [agentId, serverId, setQueuedMessages],
); );
function queueMessage(message: string, attachments: ComposerAttachment[]) { const queueMessage = useCallback(
const trimmedMessage = message.trim(); (message: string, attachments: ComposerAttachment[]) => {
if (!trimmedMessage && attachments.length === 0) return; const trimmedMessage = message.trim();
if (!trimmedMessage && attachments.length === 0) return;
const newItem = { const newItem = {
id: generateMessageId(), id: generateMessageId(),
text: trimmedMessage, text: trimmedMessage,
attachments, attachments,
}; };
setQueuedMessages(serverId, (prev: Map<string, QueuedMessage[]>) => { setQueuedMessages(serverId, (prev: Map<string, QueuedMessage[]>) => {
const next = new Map(prev); const next = new Map(prev);
next.set(agentId, [...(prev.get(agentId) ?? []), newItem]); next.set(agentId, [...(prev.get(agentId) ?? []), newItem]);
return next; return next;
}); });
setUserInput(""); setUserInput("");
setSelectedAttachments([]); setSelectedAttachments([]);
} },
[agentId, serverId, setQueuedMessages, setSelectedAttachments, setUserInput],
);
async function sendMessageWithContent( const sendMessageWithContent = useCallback(
message: string, async (message: string, attachments: ComposerAttachment[], forceSend?: boolean) => {
attachments: ComposerAttachment[], await submitAgentInput({
forceSend?: boolean, message,
) { attachments,
await submitAgentInput({ hasExternalContent,
message, allowEmptySubmit,
attachments, forceSend,
hasExternalContent, submitBehavior,
isAgentRunning,
// Parent-managed submits are still valid submit paths even when the
// transport is disconnected, because the parent decides the failure mode.
canSubmit: Boolean(sendAgentMessageRef.current || onSubmitMessageRef.current),
queueMessage: ({ message, attachments }) => {
queueMessage(message, attachments);
},
submitMessage: async ({ message, attachments }) => {
await submitMessage(message, attachments);
},
clearDraft,
setUserInput,
setAttachments: (nextAttachments) => {
setSelectedAttachments(nextAttachments);
},
setSendError,
setIsProcessing,
onSubmitError: (error) => {
console.error("[AgentInput] Failed to send message:", error);
},
});
},
[
allowEmptySubmit, allowEmptySubmit,
forceSend,
submitBehavior,
isAgentRunning: agentState.status === "running",
// Parent-managed submits are still valid submit paths even when the
// transport is disconnected, because the parent decides the failure mode.
canSubmit: Boolean(sendAgentMessageRef.current || onSubmitMessageRef.current),
queueMessage: ({ message, attachments }) => {
queueMessage(message, attachments);
},
submitMessage: async ({ message, attachments }) => {
await submitMessage(message, attachments);
},
clearDraft, clearDraft,
hasExternalContent,
isAgentRunning,
queueMessage,
setSelectedAttachments,
setUserInput, setUserInput,
setAttachments: (nextAttachments) => { submitBehavior,
setSelectedAttachments(nextAttachments); submitMessage,
}, ],
setSendError, );
setIsProcessing,
onSubmitError: (error) => {
console.error("[AgentInput] Failed to send message:", error);
},
});
}
function handleSubmit(payload: MessagePayload) { const handleSubmit = useCallback(
if (blurOnSubmit) { (payload: MessagePayload) => {
messageInputRef.current?.blur(); if (blurOnSubmit) {
} messageInputRef.current?.blur();
void sendMessageWithContent(payload.text, payload.attachments, payload.forceSend); }
} void sendMessageWithContent(payload.text, payload.attachments, payload.forceSend);
},
[blurOnSubmit, sendMessageWithContent],
);
const handlePickImage = useCallback(async () => { const handlePickImage = useCallback(async () => {
const result = await pickImages(); const result = await pickImages();
@@ -487,6 +506,18 @@ export function Composer({
} }
}, [isAgentRunning, isConnected]); }, [isAgentRunning, isConnected]);
const handleCancelAgent = useCallback(() => {
if (!isAgentRunning || isCancellingAgent) {
return;
}
if (!isConnected || !client) {
return;
}
setIsCancellingAgent(true);
void client.cancelAgent(agentIdRef.current);
messageInputRef.current?.focus();
}, [client, isAgentRunning, isCancellingAgent, isConnected]);
const handleKeyboardAction = useCallback( const handleKeyboardAction = useCallback(
(action: KeyboardActionDefinition): boolean => { (action: KeyboardActionDefinition): boolean => {
if (!isPaneFocused) { if (!isPaneFocused) {
@@ -553,18 +584,6 @@ export function Composer({
mode: "translate", mode: "translate",
}); });
function handleCancelAgent() {
if (!isAgentRunning || isCancellingAgent) {
return;
}
if (!isConnected || !client) {
return;
}
setIsCancellingAgent(true);
void client.cancelAgent(agentIdRef.current);
messageInputRef.current?.focus();
}
const isVoiceModeForAgent = voice?.isVoiceModeForAgent(serverId, agentId) ?? false; const isVoiceModeForAgent = voice?.isVoiceModeForAgent(serverId, agentId) ?? false;
const handleToggleRealtimeVoice = useCallback(() => { const handleToggleRealtimeVoice = useCallback(() => {
@@ -582,10 +601,10 @@ export function Composer({
const message = const message =
error instanceof Error ? error.message : typeof error === "string" ? error : null; error instanceof Error ? error.message : typeof error === "string" ? error : null;
if (message && message.trim().length > 0) { if (message && message.trim().length > 0) {
toast.error(message); toastErrorRef.current(message);
} }
}); });
}, [agentId, hasAgent, isConnected, serverId, toast, voice]); }, [agentId, hasAgent, isConnected, serverId, voice]);
function handleEditQueuedMessage(id: string) { function handleEditQueuedMessage(id: string) {
const item = queuedMessages.find((q) => q.id === id); const item = queuedMessages.find((q) => q.id === id);
@@ -612,9 +631,12 @@ export function Composer({
} }
} }
const handleQueue = useCallback((payload: MessagePayload) => { const handleQueue = useCallback(
queueMessage(payload.text, payload.attachments); (payload: MessagePayload) => {
}, []); queueMessage(payload.text, payload.attachments);
},
[queueMessage],
);
const hasSendableContent = userInput.trim().length > 0 || selectedAttachments.length > 0; const hasSendableContent = userInput.trim().length > 0 || selectedAttachments.length > 0;
@@ -633,91 +655,109 @@ export function Composer({
return true; return true;
} }
return autocomplete.onKeyPress(event); return autocompleteOnKeyPressRef.current(event);
}, },
[hasSendableContent, isAgentRunning, isCancellingAgent, isConnected, handleCancelAgent],
);
const cancelButton = useMemo(
() =>
isAgentRunning && !hasSendableContent && !isProcessing ? (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={handleCancelAgent}
disabled={!isConnected || isCancellingAgent}
accessibilityLabel={isCancellingAgent ? "Canceling agent" : "Stop agent"}
accessibilityRole="button"
style={[
styles.cancelButton as any,
(!isConnected || isCancellingAgent ? styles.buttonDisabled : undefined) as any,
]}
>
{isCancellingAgent ? (
<ActivityIndicator size="small" color="white" />
) : (
<Square size={buttonIconSize} color="white" fill="white" />
)}
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Interrupt</Text>
{dictationCancelKeys ? (
<Shortcut chord={dictationCancelKeys} style={styles.tooltipShortcut} />
) : null}
</View>
</TooltipContent>
</Tooltip>
) : null,
[ [
autocomplete, buttonIconSize,
dictationCancelKeys,
handleCancelAgent,
hasSendableContent, hasSendableContent,
isAgentRunning, isAgentRunning,
isCancellingAgent, isCancellingAgent,
isConnected, isConnected,
handleCancelAgent, isProcessing,
], ],
); );
const cancelButton =
isAgentRunning && !hasSendableContent && !isProcessing ? (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={handleCancelAgent}
disabled={!isConnected || isCancellingAgent}
accessibilityLabel={isCancellingAgent ? "Canceling agent" : "Stop agent"}
accessibilityRole="button"
style={[
styles.cancelButton as any,
(!isConnected || isCancellingAgent ? styles.buttonDisabled : undefined) as any,
]}
>
{isCancellingAgent ? (
<ActivityIndicator size="small" color="white" />
) : (
<Square size={buttonIconSize} color="white" fill="white" />
)}
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Interrupt</Text>
{dictationCancelKeys ? (
<Shortcut chord={dictationCancelKeys} style={styles.tooltipShortcut} />
) : null}
</View>
</TooltipContent>
</Tooltip>
) : null;
const showVoiceModeButton = !isVoiceModeForAgent && hasAgent; const showVoiceModeButton = !isVoiceModeForAgent && hasAgent;
const rightContent = const rightContent = useMemo(
showVoiceModeButton || cancelButton ? ( () =>
<View style={styles.rightControls}> showVoiceModeButton || cancelButton ? (
{showVoiceModeButton ? ( <View style={styles.rightControls}>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}> {showVoiceModeButton ? (
<TooltipTrigger <Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
onPress={handleToggleRealtimeVoice} <TooltipTrigger
disabled={!isConnected || voice?.isVoiceSwitching} onPress={handleToggleRealtimeVoice}
accessibilityLabel="Enable Voice mode" disabled={!isConnected || voice?.isVoiceSwitching}
accessibilityRole="button" accessibilityLabel="Enable Voice mode"
style={({ hovered }) => [ accessibilityRole="button"
styles.realtimeVoiceButton as any, style={({ hovered }) => [
(hovered ? styles.iconButtonHovered : undefined) as any, styles.realtimeVoiceButton as any,
(!isConnected || voice?.isVoiceSwitching (hovered ? styles.iconButtonHovered : undefined) as any,
? styles.buttonDisabled (!isConnected || voice?.isVoiceSwitching
: undefined) as any, ? styles.buttonDisabled
]} : undefined) as any,
> ]}
{({ hovered }) => >
voice?.isVoiceSwitching ? ( {({ hovered }) =>
<ActivityIndicator size="small" color="white" /> voice?.isVoiceSwitching ? (
) : ( <ActivityIndicator size="small" color="white" />
<AudioLines ) : (
size={buttonIconSize} <AudioLines
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} size={buttonIconSize}
/> color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
) />
} )
</TooltipTrigger> }
<TooltipContent side="top" align="center" offset={8}> </TooltipTrigger>
<View style={styles.tooltipRow}> <TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>Voice mode</Text> <View style={styles.tooltipRow}>
{voiceToggleKeys ? ( <Text style={styles.tooltipText}>Voice mode</Text>
<Shortcut chord={voiceToggleKeys} style={styles.tooltipShortcut} /> {voiceToggleKeys ? (
) : null} <Shortcut chord={voiceToggleKeys} style={styles.tooltipShortcut} />
</View> ) : null}
</TooltipContent> </View>
</Tooltip> </TooltipContent>
) : null} </Tooltip>
{cancelButton} ) : null}
</View> {cancelButton}
) : null; </View>
) : null,
[
buttonIconSize,
cancelButton,
handleToggleRealtimeVoice,
isConnected,
showVoiceModeButton,
theme.colors.foreground,
theme.colors.foregroundMuted,
voice,
voiceToggleKeys,
],
);
const hasContextWindowMeter = const hasContextWindowMeter =
typeof agentState.contextWindowMaxTokens === "number" && typeof agentState.contextWindowMaxTokens === "number" &&
@@ -725,15 +765,18 @@ export function Composer({
const contextWindowMaxTokens = hasContextWindowMeter ? agentState.contextWindowMaxTokens : null; const contextWindowMaxTokens = hasContextWindowMeter ? agentState.contextWindowMaxTokens : null;
const contextWindowUsedTokens = hasContextWindowMeter ? agentState.contextWindowUsedTokens : null; const contextWindowUsedTokens = hasContextWindowMeter ? agentState.contextWindowUsedTokens : null;
const beforeVoiceContent = ( const beforeVoiceContent = useMemo(
<View style={styles.contextWindowMeterSlot}> () => (
{contextWindowMaxTokens !== null && contextWindowUsedTokens !== null ? ( <View style={styles.contextWindowMeterSlot}>
<ContextWindowMeter {contextWindowMaxTokens !== null && contextWindowUsedTokens !== null ? (
maxTokens={contextWindowMaxTokens} <ContextWindowMeter
usedTokens={contextWindowUsedTokens} maxTokens={contextWindowMaxTokens}
/> usedTokens={contextWindowUsedTokens}
) : null} />
</View> ) : null}
</View>
),
[contextWindowMaxTokens, contextWindowUsedTokens],
); );
const githubSearchQueryTrimmed = githubSearchQuery.trim(); const githubSearchQueryTrimmed = githubSearchQuery.trim();
@@ -808,17 +851,36 @@ export function Composer({
[setSelectedAttachments, setGithubSearchQuery, setIsGithubPickerOpen], [setSelectedAttachments, setGithubSearchQuery, setIsGithubPickerOpen],
); );
const leftContent = const leftContent = useMemo(
resolveStatusControlMode(statusControls) === "draft" && statusControls ? ( () =>
<DraftAgentStatusBar {...statusControls} /> resolveStatusControlMode(statusControls) === "draft" && statusControls ? (
) : ( <DraftAgentStatusBar {...statusControls} />
<AgentStatusBar agentId={agentId} serverId={serverId} onDropdownClose={focusInput} /> ) : (
); <AgentStatusBar agentId={agentId} serverId={serverId} onDropdownClose={focusInput} />
),
[agentId, focusInput, serverId, statusControls],
);
const handleAttachButtonRef = useCallback((node: View | null) => {
attachButtonRef.current = node;
}, []);
const handleSelectionChange = useCallback((selection: { start: number; end: number }) => {
setCursorIndex(selection.start);
}, []);
const handleFocusChange = useCallback(
(focused: boolean) => {
setIsMessageInputFocused(focused);
if (focused) {
onAttentionInputFocus?.();
}
},
[onAttentionInputFocus],
);
return ( return (
<Animated.View <Animated.View style={[styles.container, keyboardAnimatedStyle]}>
style={[styles.container, { paddingBottom: insets.bottom }, keyboardAnimatedStyle]}
>
<AttachmentLightbox metadata={lightboxMetadata} onClose={() => setLightboxMetadata(null)} /> <AttachmentLightbox metadata={lightboxMetadata} onClose={() => setLightboxMetadata(null)} />
{/* Input area */} {/* Input area */}
<View style={[styles.inputAreaContainer, isComposerLocked && styles.inputAreaLocked]}> <View style={[styles.inputAreaContainer, isComposerLocked && styles.inputAreaLocked]}>
@@ -924,7 +986,7 @@ export function Composer({
) : null} ) : null}
{/* MessageInput handles everything: text, dictation, attachments, all buttons */} {/* MessageInput handles everything: text, dictation, attachments, all buttons */}
<MessageInput <StableMessageInput
ref={messageInputRef} ref={messageInputRef}
value={userInput} value={userInput}
onChangeText={setUserInput} onChangeText={setUserInput}
@@ -938,9 +1000,7 @@ export function Composer({
attachments={selectedAttachments} attachments={selectedAttachments}
cwd={cwd} cwd={cwd}
attachmentMenuItems={attachmentMenuItems} attachmentMenuItems={attachmentMenuItems}
onAttachButtonRef={(node) => { onAttachButtonRef={handleAttachButtonRef}
attachButtonRef.current = node;
}}
onAddImages={addImages} onAddImages={addImages}
client={client} client={client}
isReadyForDictation={isDictationReady} isReadyForDictation={isDictationReady}
@@ -959,15 +1019,8 @@ export function Composer({
onQueue={handleQueue} onQueue={handleQueue}
onSubmitLoadingPress={isAgentRunning ? handleCancelAgent : undefined} onSubmitLoadingPress={isAgentRunning ? handleCancelAgent : undefined}
onKeyPress={handleCommandKeyPress} onKeyPress={handleCommandKeyPress}
onSelectionChange={(selection) => { onSelectionChange={handleSelectionChange}
setCursorIndex(selection.start); onFocusChange={handleFocusChange}
}}
onFocusChange={(focused) => {
setIsMessageInputFocused(focused);
if (focused) {
onAttentionInputFocus?.();
}
}}
onHeightChange={onComposerHeightChange} onHeightChange={onComposerHeightChange}
inputWrapperStyle={inputWrapperStyle} inputWrapperStyle={inputWrapperStyle}
/> />
@@ -142,7 +142,7 @@ export function DraggableList<T>({
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { useSensor(PointerSensor, {
activationConstraint: { activationConstraint: {
distance: 8, distance: 6,
}, },
}), }),
useSensor(KeyboardSensor, { useSensor(KeyboardSensor, {
@@ -17,6 +17,7 @@ import { PrPane } from "./pr-pane";
import { usePrPaneData } from "@/hooks/use-pr-pane-data"; import { usePrPaneData } from "@/hooks/use-pr-pane-data";
import { import {
usePanelStore, usePanelStore,
selectIsFileExplorerOpen,
MIN_EXPLORER_SIDEBAR_WIDTH, MIN_EXPLORER_SIDEBAR_WIDTH,
MAX_EXPLORER_SIDEBAR_WIDTH, MAX_EXPLORER_SIDEBAR_WIDTH,
type ExplorerTab, type ExplorerTab,
@@ -52,9 +53,9 @@ export function ExplorerSidebar({
const isScreenFocused = useIsFocused(); const isScreenFocused = useIsFocused();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView); const isOpen = usePanelStore((state) => selectIsFileExplorerOpen(state, { isCompact: isMobile }));
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const closeToAgent = usePanelStore((state) => state.closeToAgent); const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer);
const explorerTab = usePanelStore((state) => state.explorerTab); const explorerTab = usePanelStore((state) => state.explorerTab);
const explorerWidth = usePanelStore((state) => state.explorerWidth); const explorerWidth = usePanelStore((state) => state.explorerWidth);
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout); const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
@@ -81,9 +82,6 @@ export function ExplorerSidebar({
} }
}, [explorerWidth, isMobile, setExplorerWidth, viewportWidth]); }, [explorerWidth, isMobile, setExplorerWidth, viewportWidth]);
// Derive isOpen from the unified panel state
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
const { const {
translateX, translateX,
backdropOpacity, backdropOpacity,
@@ -104,18 +102,20 @@ export function ExplorerSidebar({
logExplorerSidebar("handleClose", { logExplorerSidebar("handleClose", {
reason, reason,
isOpen, isOpen,
mobileView,
desktopFileExplorerOpen,
}); });
closeToAgent(); if (isMobile) {
showMobileAgent();
return;
}
closeDesktopFileExplorer();
}, },
[closeToAgent, desktopFileExplorerOpen, isOpen, mobileView], [closeDesktopFileExplorer, isMobile, isOpen, showMobileAgent],
); );
const handleCloseFromGesture = useCallback(() => { const handleCloseFromGesture = useCallback(() => {
gestureAnimatingRef.current = true; gestureAnimatingRef.current = true;
closeToAgent(); showMobileAgent();
}, [closeToAgent, gestureAnimatingRef]); }, [gestureAnimatingRef, showMobileAgent]);
const enableSidebarCloseGesture = isMobile && isOpen; const enableSidebarCloseGesture = isMobile && isOpen;
@@ -292,6 +292,7 @@ export function ExplorerSidebar({
workspaceRoot={workspaceRoot} workspaceRoot={workspaceRoot}
isGit={isGit} isGit={isGit}
isMobile={isMobile} isMobile={isMobile}
isOpen={isOpen}
onOpenFile={onOpenFile} onOpenFile={onOpenFile}
/> />
</Animated.View> </Animated.View>
@@ -324,6 +325,7 @@ export function ExplorerSidebar({
workspaceRoot={workspaceRoot} workspaceRoot={workspaceRoot}
isGit={isGit} isGit={isGit}
isMobile={false} isMobile={false}
isOpen={isOpen}
onOpenFile={onOpenFile} onOpenFile={onOpenFile}
/> />
</View> </View>
@@ -340,6 +342,7 @@ interface SidebarContentProps {
workspaceRoot: string; workspaceRoot: string;
isGit: boolean; isGit: boolean;
isMobile: boolean; isMobile: boolean;
isOpen: boolean;
onOpenFile?: (filePath: string) => void; onOpenFile?: (filePath: string) => void;
} }
@@ -352,6 +355,7 @@ function SidebarContent({
workspaceRoot, workspaceRoot,
isGit, isGit,
isMobile, isMobile,
isOpen,
onOpenFile, onOpenFile,
}: SidebarContentProps) { }: SidebarContentProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
@@ -360,8 +364,8 @@ function SidebarContent({
const prPane = usePrPaneData({ const prPane = usePrPaneData({
serverId, serverId,
cwd: workspaceRoot, cwd: workspaceRoot,
enabled: canQueryPullRequest, enabled: canQueryPullRequest && isOpen,
timelineEnabled: activeTab === "pr" && canQueryPullRequest, timelineEnabled: activeTab === "pr" && canQueryPullRequest && isOpen,
}); });
const hasPullRequest = prPane.prNumber !== null; const hasPullRequest = prPane.prNumber !== null;
const requestedTab: ExplorerTab = const requestedTab: ExplorerTab =
@@ -8,18 +8,8 @@ import {
Text, Text,
View, View,
} from "react-native"; } from "react-native";
import { Gesture } from "react-native-gesture-handler";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import Animated, {
cancelAnimation,
Easing,
runOnJS,
useAnimatedStyle,
useSharedValue,
withRepeat,
withTiming,
} from "react-native-reanimated";
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { Fonts } from "@/constants/theme"; import { Fonts } from "@/constants/theme";
import * as Clipboard from "expo-clipboard"; import * as Clipboard from "expo-clipboard";
@@ -34,6 +24,7 @@ import {
X, X,
} from "lucide-react-native"; } from "lucide-react-native";
import { getFileIconSvg } from "@/components/material-file-icons"; import { getFileIconSvg } from "@/components/material-file-icons";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store"; import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
import { useHosts } from "@/runtime/host-runtime"; import { useHosts } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
@@ -312,48 +303,6 @@ export function FileExplorerPane({
const handleRefresh = useCallback(() => { const handleRefresh = useCallback(() => {
void refetchExplorer(); void refetchExplorer();
}, [refetchExplorer]); }, [refetchExplorer]);
const refreshIconRotation = useSharedValue(0);
useEffect(() => {
if (isRefreshFetching) {
refreshIconRotation.value = 0;
refreshIconRotation.value = withRepeat(
withTiming(360, {
duration: 700,
easing: Easing.linear,
}),
-1,
false,
);
return;
}
cancelAnimation(refreshIconRotation);
const remainder = refreshIconRotation.value % 360;
if (Math.abs(remainder) < 0.001) {
refreshIconRotation.value = 0;
return;
}
const remaining = 360 - remainder;
const duration = Math.max(80, Math.round((remaining / 360) * 700));
refreshIconRotation.value = withTiming(
360,
{
duration,
easing: Easing.linear,
},
(finished) => {
if (finished) {
refreshIconRotation.value = 0;
}
},
);
}, [isRefreshFetching, refreshIconRotation]);
const refreshIconAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${refreshIconRotation.value}deg` }],
}));
const currentSortLabel = SORT_OPTIONS.find((opt) => opt.value === sortOption)?.label ?? "Name"; const currentSortLabel = SORT_OPTIONS.find((opt) => opt.value === sortOption)?.label ?? "Name";
@@ -563,11 +512,15 @@ export function FileExplorerPane({
(hovered || pressed) && styles.iconButtonHovered, (hovered || pressed) && styles.iconButtonHovered,
]} ]}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Refresh files" accessibilityLabel={isRefreshFetching ? "Refreshing files" : "Refresh files"}
> >
<Animated.View style={[styles.refreshIcon, refreshIconAnimatedStyle]}> <View style={styles.refreshIcon}>
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} /> {isRefreshFetching ? (
</Animated.View> <LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
) : (
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
)}
</View>
</Pressable> </Pressable>
</View> </View>
<FlatList <FlatList
@@ -681,8 +681,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
files, files,
payloadError: diffPayloadError, payloadError: diffPayloadError,
isLoading: isDiffLoading, isLoading: isDiffLoading,
isError: isDiffError,
error: diffError,
} = useCheckoutDiffQuery({ } = useCheckoutDiffQuery({
serverId, serverId,
cwd, cwd,
@@ -1071,9 +1069,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
); );
const hasChanges = files.length > 0; const hasChanges = files.length > 0;
const diffErrorMessage = const diffErrorMessage = diffPayloadError?.message ?? null;
diffPayloadError?.message ??
(isDiffError && diffError instanceof Error ? diffError.message : null);
const prErrorMessage = githubFeaturesEnabled ? (prPayloadError?.message ?? null) : null; const prErrorMessage = githubFeaturesEnabled ? (prPayloadError?.message ?? null) : null;
const branchLabel = const branchLabel =
gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD" gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD"
@@ -5,7 +5,7 @@ import { PanelLeft } from "lucide-react-native";
import { ScreenHeader } from "./screen-header"; import { ScreenHeader } from "./screen-header";
import { ScreenTitle } from "./screen-title"; import { ScreenTitle } from "./screen-title";
import { HeaderToggleButton } from "./header-toggle-button"; import { HeaderToggleButton } from "./header-toggle-button";
import { usePanelStore } from "@/stores/panel-store"; import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { getShortcutOs } from "@/utils/shortcut-platform"; import { getShortcutOs } from "@/utils/shortcut-platform";
@@ -46,18 +46,16 @@ export function SidebarMenuToggle({
}: SidebarMenuToggleProps = {}) { }: SidebarMenuToggleProps = {}) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView); const isOpen = usePanelStore((state) => selectIsAgentListOpen(state, { isCompact: isMobile }));
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen); const toggleAgentListForLayout = usePanelStore((state) => state.toggleAgentListForLayout);
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
const toggleShortcutKeys = getShortcutOs() === "mac" ? ["mod", "B"] : ["mod", "."]; const toggleShortcutKeys = getShortcutOs() === "mac" ? ["mod", "B"] : ["mod", "."];
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
const menuIconColor = const menuIconColor =
!isMobile && isOpen ? theme.colors.foreground : theme.colors.foregroundMuted; !isMobile && isOpen ? theme.colors.foreground : theme.colors.foregroundMuted;
return ( return (
<HeaderToggleButton <HeaderToggleButton
onPress={toggleAgentList} onPress={() => toggleAgentListForLayout({ isCompact: isMobile })}
tooltipLabel="Toggle sidebar" tooltipLabel="Toggle sidebar"
tooltipKeys={toggleShortcutKeys} tooltipKeys={toggleShortcutKeys}
tooltipSide={tooltipSide} tooltipSide={tooltipSide}
@@ -0,0 +1,248 @@
/**
* @vitest-environment jsdom
*/
import React from "react";
import { act } from "@testing-library/react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { panelState, useSidebarWorkspacesListMock, theme } = vi.hoisted(() => {
const panelState = {
isOpen: false,
showMobileAgent: vi.fn(),
};
return {
panelState,
useSidebarWorkspacesListMock: vi.fn(),
theme: {
spacing: { 0: 0, 0.5: 2, 1: 4, 1.5: 6, 2: 8, 3: 12, 4: 16, 5: 20 },
iconSize: { sm: 14, md: 18, lg: 22 },
borderWidth: { 1: 1 },
borderRadius: { sm: 4, md: 6, lg: 8, full: 999 },
fontSize: { xs: 11, sm: 13, base: 15 },
fontWeight: { normal: "400", medium: "500", semibold: "600" },
colors: {
surfaceSidebar: "#111",
surface1: "#111",
surface2: "#222",
surface3: "#333",
surface4: "#444",
foreground: "#fff",
foregroundMuted: "#aaa",
border: "#555",
borderAccent: "#666",
accent: "#0a84ff",
accentForeground: "#fff",
palette: {
green: { 400: "#30d158" },
amber: { 500: "#ffd60a" },
red: { 500: "#ff453a" },
},
},
},
};
});
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
absoluteFillObject: {},
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
},
useUnistyles: () => ({ theme }),
}));
vi.mock("react-native-reanimated", () => ({
default: {
View: "div",
},
Extrapolation: { CLAMP: "clamp" },
interpolate: () => 0,
runOnJS: (fn: (...args: unknown[]) => unknown) => fn,
useAnimatedStyle: (factory: () => unknown) => factory(),
useSharedValue: (value: unknown) => ({ value }),
}));
vi.mock("react-native-gesture-handler", () => {
const chain = {
enabled: () => chain,
hitSlop: () => chain,
manualActivation: () => chain,
onTouchesDown: () => chain,
onTouchesMove: () => chain,
onStart: () => chain,
onUpdate: () => chain,
onEnd: () => chain,
onFinalize: () => chain,
withRef: () => chain,
};
return {
Gesture: { Pan: () => chain },
GestureDetector: ({ children }: { children: React.ReactNode }) => children,
};
});
vi.mock("lucide-react-native", () => {
const createIcon = (name: string) => (props: Record<string, unknown>) =>
React.createElement("span", { ...props, "data-icon": name });
return {
MessagesSquare: createIcon("MessagesSquare"),
Plus: createIcon("Plus"),
Settings: createIcon("Settings"),
};
});
vi.mock("expo-router", () => ({
router: { push: vi.fn() },
usePathname: () => "/hosts/srv",
}));
vi.mock("react-native-safe-area-context", () => ({
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
}));
vi.mock("@/constants/layout", () => ({
useIsCompactFormFactor: () => true,
}));
vi.mock("@/constants/platform", () => ({
isWeb: true,
isNative: false,
}));
vi.mock("@/stores/panel-store", () => ({
MIN_SIDEBAR_WIDTH: 260,
MAX_SIDEBAR_WIDTH: 420,
selectIsAgentListOpen: (state: typeof panelState) => state.isOpen,
usePanelStore: (selector: (state: typeof panelState) => unknown) => selector(panelState),
}));
vi.mock("@/runtime/host-runtime", () => ({
useHosts: () => [{ serverId: "srv", label: "Local" }],
useHostRuntimeSnapshot: () => ({ connectionStatus: "online" }),
}));
vi.mock("@/hooks/use-sidebar-workspaces-list", () => ({
useSidebarWorkspacesList: useSidebarWorkspacesListMock,
}));
vi.mock("@/hooks/use-sidebar-shortcut-model", () => ({
useSidebarShortcutModel: () => ({
collapsedProjectKeys: new Set<string>(),
shortcutIndexByWorkspaceKey: new Map<string, number>(),
toggleProjectCollapsed: vi.fn(),
}),
}));
vi.mock("@/contexts/sidebar-animation-context", () => ({
useSidebarAnimation: () => ({
translateX: { value: 0 },
backdropOpacity: { value: 0 },
windowWidth: 390,
animateToOpen: vi.fn(),
animateToClose: vi.fn(),
isGesturing: { value: false },
gestureAnimatingRef: { current: false },
closeGestureRef: { current: undefined },
}),
}));
vi.mock("@/hooks/use-shortcut-keys", () => ({
useShortcutKeys: () => null,
}));
vi.mock("@/utils/desktop-window", () => ({
useWindowControlsPadding: () => ({ top: 0 }),
}));
vi.mock("@/utils/host-routes", () => ({
buildHostSessionsRoute: (serverId: string) => `/hosts/${serverId}/sessions`,
buildSettingsRoute: () => "/settings",
mapPathnameToServer: (_pathname: string, serverId: string) => `/hosts/${serverId}`,
parseServerIdFromPathname: () => "srv",
}));
vi.mock("@/hooks/use-open-project-picker", () => ({
useOpenProjectPicker: () => vi.fn(),
}));
vi.mock("@/components/sidebar/sidebar-header-row", () => ({
SidebarHeaderRow: ({ label }: { label: string }) => React.createElement("div", null, label),
}));
vi.mock("./sidebar-workspace-list", () => ({
SidebarWorkspaceList: ({ projects }: { projects: Array<{ projectName: string }> }) =>
React.createElement(
"div",
{ "data-testid": "sidebar-workspace-list" },
projects.map((project) => project.projectName).join(","),
),
}));
vi.mock("./sidebar-agent-list-skeleton", () => ({
SidebarAgentListSkeleton: () => React.createElement("div", null, "Loading"),
}));
vi.mock("@/components/ui/tooltip", () => ({
Tooltip: ({ children }: { children: React.ReactNode }) =>
React.createElement("div", null, children),
TooltipContent: ({ children }: { children: React.ReactNode }) =>
React.createElement("div", null, children),
TooltipTrigger: ({ children }: { children: React.ReactNode }) =>
React.createElement("div", null, children),
}));
vi.mock("@/components/ui/shortcut", () => ({
Shortcut: () => React.createElement("span", null),
}));
vi.mock("@/components/ui/combobox", () => ({
Combobox: () => null,
ComboboxItem: ({ label }: { label: string }) => React.createElement("div", null, label),
}));
vi.stubGlobal("React", React);
import { LeftSidebar } from "./left-sidebar";
describe("LeftSidebar", () => {
let root: Root | null = null;
let container: HTMLElement | null = null;
beforeEach(() => {
panelState.isOpen = false;
panelState.showMobileAgent.mockReset();
useSidebarWorkspacesListMock.mockReset();
useSidebarWorkspacesListMock.mockReturnValue({
projects: [{ projectKey: "project-1", projectName: "Project 1", workspaces: [] }],
isInitialLoad: false,
isRevalidating: false,
refreshAll: vi.fn(),
});
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container?.remove();
container = null;
});
it("keeps the mobile workspace list subscribed while the sidebar is hidden", async () => {
await act(async () => {
root?.render(<LeftSidebar />);
});
expect(useSidebarWorkspacesListMock).toHaveBeenLastCalledWith({
serverId: "srv",
enabled: true,
});
});
});
+22 -27
View File
@@ -32,7 +32,12 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
import { Shortcut } from "@/components/ui/shortcut"; import { Shortcut } from "@/components/ui/shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { router, usePathname } from "expo-router"; import { router, usePathname } from "expo-router";
import { usePanelStore, MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH } from "@/stores/panel-store"; import {
usePanelStore,
selectIsAgentListOpen,
MIN_SIDEBAR_WIDTH,
MAX_SIDEBAR_WIDTH,
} from "@/stores/panel-store";
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row"; import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
import { SidebarWorkspaceList } from "./sidebar-workspace-list"; import { SidebarWorkspaceList } from "./sidebar-workspace-list";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton"; import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
@@ -52,10 +57,10 @@ import {
buildHostSessionsRoute, buildHostSessionsRoute,
buildSettingsRoute, buildSettingsRoute,
mapPathnameToServer, mapPathnameToServer,
parseServerIdFromPathname,
} from "@/utils/host-routes"; } from "@/utils/host-routes";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker"; import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import { resolveActiveHost } from "@/utils/active-host";
const MIN_CHAT_WIDTH = 400; const MIN_CHAT_WIDTH = 400;
@@ -116,24 +121,16 @@ export const LeftSidebar = memo(function LeftSidebar({
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const isCompactLayout = useIsCompactFormFactor(); const isCompactLayout = useIsCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView); const isOpen = usePanelStore((state) =>
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen); selectIsAgentListOpen(state, { isCompact: isCompactLayout }),
const closeToAgent = usePanelStore((state) => state.closeToAgent); );
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const pathname = usePathname(); const pathname = usePathname();
const daemons = useHosts(); const daemons = useHosts();
const activeServerIdFromPath = useMemo(() => parseServerIdFromPathname(pathname), [pathname]); const activeDaemon = useMemo(
const activeDaemon = useMemo(() => { () => resolveActiveHost({ hosts: daemons, pathname }),
if (daemons.length === 0) { [daemons, pathname],
return null; );
}
if (activeServerIdFromPath) {
const routeMatch = daemons.find((entry) => entry.serverId === activeServerIdFromPath);
if (routeMatch) {
return routeMatch;
}
}
return daemons[0] ?? null;
}, [activeServerIdFromPath, daemons]);
const activeServerId = activeDaemon?.serverId ?? null; const activeServerId = activeDaemon?.serverId ?? null;
const activeHostLabel = useMemo(() => { const activeHostLabel = useMemo(() => {
if (!activeDaemon) return "No host"; if (!activeDaemon) return "No host";
@@ -183,14 +180,12 @@ export const LeftSidebar = memo(function LeftSidebar({
const hostTriggerRef = useRef<View | null>(null); const hostTriggerRef = useRef<View | null>(null);
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false); const [isHostPickerOpen, setIsHostPickerOpen] = useState(false);
const isOpen = isCompactLayout ? mobileView === "agent-list" : desktopAgentListOpen;
const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarWorkspacesList({ const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarWorkspacesList({
serverId: activeServerId, serverId: activeServerId,
enabled: isOpen, enabled: isCompactLayout || isOpen,
}); });
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } = const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } =
useSidebarShortcutModel(projects); useSidebarShortcutModel({ projects, isInitialLoad });
const [isManualRefresh, setIsManualRefresh] = useState(false); const [isManualRefresh, setIsManualRefresh] = useState(false);
@@ -208,18 +203,18 @@ export const LeftSidebar = memo(function LeftSidebar({
const openProjectPicker = useOpenProjectPicker(activeServerId); const openProjectPicker = useOpenProjectPicker(activeServerId);
const handleOpenProjectMobile = useCallback(() => { const handleOpenProjectMobile = useCallback(() => {
closeToAgent(); showMobileAgent();
void openProjectPicker(); void openProjectPicker();
}, [closeToAgent, openProjectPicker]); }, [showMobileAgent, openProjectPicker]);
const handleOpenProjectDesktop = useCallback(() => { const handleOpenProjectDesktop = useCallback(() => {
void openProjectPicker(); void openProjectPicker();
}, [openProjectPicker]); }, [openProjectPicker]);
const handleSettingsMobile = useCallback(() => { const handleSettingsMobile = useCallback(() => {
closeToAgent(); showMobileAgent();
router.push(buildSettingsRoute()); router.push(buildSettingsRoute());
}, [closeToAgent]); }, [showMobileAgent]);
const handleSettingsDesktop = useCallback(() => { const handleSettingsDesktop = useCallback(() => {
router.push(buildSettingsRoute()); router.push(buildSettingsRoute());
@@ -272,7 +267,7 @@ export const LeftSidebar = memo(function LeftSidebar({
insetsTop={insets.top} insetsTop={insets.top}
insetsBottom={insets.bottom} insetsBottom={insets.bottom}
isOpen={isOpen} isOpen={isOpen}
closeToAgent={closeToAgent} closeToAgent={showMobileAgent}
handleOpenProject={handleOpenProjectMobile} handleOpenProject={handleOpenProjectMobile}
handleSettings={handleSettingsMobile} handleSettings={handleSettingsMobile}
handleViewMoreNavigate={handleViewMoreNavigate} handleViewMoreNavigate={handleViewMoreNavigate}
@@ -1,9 +1,15 @@
import React from "react"; import React, { createRef } from "react";
import { act } from "react"; import { act } from "react";
import { createRoot, type Root } from "react-dom/client"; import { createRoot, type Root } from "react-dom/client";
import { JSDOM } from "jsdom"; import { JSDOM } from "jsdom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MessageInput, type AttachmentMenuItem } from "./message-input"; import { MessageInput, type AttachmentMenuItem, type MessageInputRef } from "./message-input";
const { startDictationMock, cancelDictationMock, confirmDictationMock } = vi.hoisted(() => ({
startDictationMock: vi.fn(),
cancelDictationMock: vi.fn(),
confirmDictationMock: vi.fn(),
}));
const { theme } = vi.hoisted(() => ({ const { theme } = vi.hoisted(() => ({
theme: { theme: {
@@ -85,9 +91,9 @@ vi.mock("@/hooks/use-dictation", () => ({
duration: 0, duration: 0,
error: null, error: null,
status: "idle", status: "idle",
startDictation: vi.fn(), startDictation: startDictationMock,
cancelDictation: vi.fn(), cancelDictation: cancelDictationMock,
confirmDictation: vi.fn(), confirmDictation: confirmDictationMock,
retryFailedDictation: vi.fn(), retryFailedDictation: vi.fn(),
discardFailedDictation: vi.fn(), discardFailedDictation: vi.fn(),
}), }),
@@ -214,6 +220,9 @@ beforeEach(() => {
container = document.createElement("div"); container = document.createElement("div");
document.body.appendChild(container); document.body.appendChild(container);
root = createRoot(container); root = createRoot(container);
startDictationMock.mockReset();
cancelDictationMock.mockReset();
confirmDictationMock.mockReset();
}); });
afterEach(() => { afterEach(() => {
@@ -325,3 +334,57 @@ describe("MessageInput attachments", () => {
expect(document.querySelectorAll('[data-icon="CornerDownLeft"]')).toHaveLength(1); expect(document.querySelectorAll('[data-icon="CornerDownLeft"]')).toHaveLength(1);
}); });
}); });
describe("MessageInput dictation shortcuts", () => {
it("does not poison the dictation toggle when readiness is temporarily false", () => {
const inputRef = createRef<MessageInputRef>();
act(() => {
root?.render(
<MessageInput
ref={inputRef}
value=""
onChangeText={vi.fn()}
onSubmit={vi.fn()}
attachments={[]}
cwd="/repo"
attachmentMenuItems={[]}
client={{ isConnected: true } as never}
isAgentRunning={false}
isReadyForDictation={false}
onQueue={vi.fn()}
/>,
);
});
act(() => {
inputRef.current?.runKeyboardAction("dictation-toggle");
});
expect(startDictationMock).not.toHaveBeenCalled();
expect(confirmDictationMock).not.toHaveBeenCalled();
act(() => {
root?.render(
<MessageInput
ref={inputRef}
value=""
onChangeText={vi.fn()}
onSubmit={vi.fn()}
attachments={[]}
cwd="/repo"
attachmentMenuItems={[]}
client={{ isConnected: true } as never}
isAgentRunning={false}
isReadyForDictation
onQueue={vi.fn()}
/>,
);
});
act(() => {
inputRef.current?.runKeyboardAction("dictation-toggle");
});
expect(startDictationMock).toHaveBeenCalledTimes(1);
expect(confirmDictationMock).not.toHaveBeenCalled();
});
});
@@ -480,11 +480,15 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
toast.error(dictationUnavailableMessage); toast.error(dictationUnavailableMessage);
return; return;
} }
if (!canStartDictation()) {
isDictatingRef.current = false;
return;
}
// Keep hotkey toggling deterministic between the async start call and the // Keep hotkey toggling deterministic between the async start call and the
// state-ref sync effect, so a rapid second toggle routes to confirm. // state-ref sync effect, so a rapid second toggle routes to confirm.
isDictatingRef.current = true; isDictatingRef.current = true;
await startDictation(); await startDictation();
}, [dictationUnavailableMessage, startDictation, toast]); }, [canStartDictation, dictationUnavailableMessage, startDictation, toast]);
// Animate overlay // Animate overlay
useEffect(() => { useEffect(() => {
+29 -2
View File
@@ -118,6 +118,26 @@ function useDisableOuterSpacing(disableOuterSpacing: boolean | undefined) {
const WEB_TOOLCALL_SHIMMER_KEYFRAME_ID = "paseo-toolcall-shimmer-keyframes"; const WEB_TOOLCALL_SHIMMER_KEYFRAME_ID = "paseo-toolcall-shimmer-keyframes";
const WEB_TOOLCALL_SHIMMER_ANIMATION_NAME = "paseo-toolcall-shimmer"; const WEB_TOOLCALL_SHIMMER_ANIMATION_NAME = "paseo-toolcall-shimmer";
const MARKDOWN_ALLOWED_IMAGE_HANDLERS = [
"data:image/png;base64",
"data:image/gif;base64",
"data:image/jpeg;base64",
"https://",
"http://",
] as const;
const MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM = <Text key="dotdotdot">...</Text>;
type MarkdownWithStableRendererProps = {
children: ReactNode;
style: ReturnType<typeof createMarkdownStyles>;
rules: RenderRules;
markdownit: MarkdownIt;
onLinkPress: (url: string) => boolean;
allowedImageHandlers: readonly string[];
topLevelMaxExceededItem: ReactNode;
};
const MarkdownWithStableRenderer = Markdown as ComponentType<MarkdownWithStableRendererProps>;
const WEB_TOOLCALL_SHIMMER_KEYFRAME_CSS = ` const WEB_TOOLCALL_SHIMMER_KEYFRAME_CSS = `
@keyframes ${WEB_TOOLCALL_SHIMMER_ANIMATION_NAME} { @keyframes ${WEB_TOOLCALL_SHIMMER_ANIMATION_NAME} {
0% { 0% {
@@ -947,9 +967,16 @@ const MemoizedMarkdownBlock = React.memo(function MemoizedMarkdownBlock({
onLinkPress, onLinkPress,
}: MemoizedMarkdownBlockProps) { }: MemoizedMarkdownBlockProps) {
return ( return (
<Markdown style={styles} rules={rules} markdownit={parser} onLinkPress={onLinkPress}> <MarkdownWithStableRenderer
style={styles}
rules={rules}
markdownit={parser}
onLinkPress={onLinkPress}
allowedImageHandlers={MARKDOWN_ALLOWED_IMAGE_HANDLERS}
topLevelMaxExceededItem={MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM}
>
{text} {text}
</Markdown> </MarkdownWithStableRenderer>
); );
}); });
@@ -3,30 +3,22 @@ import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-nativ
import { Folder } from "lucide-react-native"; import { Folder } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { usePathname } from "expo-router";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { shortenPath } from "@/utils/shorten-path"; import { shortenPath } from "@/utils/shorten-path";
import { useRecommendedProjectPaths } from "@/stores/session-store-hooks"; import { useRecommendedProjectPaths } from "@/stores/session-store-hooks";
import { useHosts, useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useOpenProject } from "@/hooks/use-open-project"; import { useOpenProject } from "@/hooks/use-open-project";
import { parseServerIdFromPathname } from "@/utils/host-routes";
import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions"; import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions";
import { isNative } from "@/constants/platform"; import { isNative } from "@/constants/platform";
import { useActiveServerId } from "@/hooks/use-active-server-id";
export function ProjectPickerModal() { export function ProjectPickerModal() {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const pathname = usePathname(); const serverId = useActiveServerId();
const daemons = useHosts();
const open = useKeyboardShortcutsStore((s) => s.projectPickerOpen); const open = useKeyboardShortcutsStore((s) => s.projectPickerOpen);
const setOpen = useKeyboardShortcutsStore((s) => s.setProjectPickerOpen); const setOpen = useKeyboardShortcutsStore((s) => s.setProjectPickerOpen);
const serverId = useMemo(() => {
const fromPath = parseServerIdFromPathname(pathname);
if (fromPath) return fromPath;
return daemons[0]?.serverId ?? null;
}, [pathname, daemons]);
const client = useHostRuntimeClient(serverId ?? ""); const client = useHostRuntimeClient(serverId ?? "");
const isConnected = useHostRuntimeIsConnected(serverId ?? ""); const isConnected = useHostRuntimeIsConnected(serverId ?? "");
const recommendedPaths = useRecommendedProjectPaths(serverId); const recommendedPaths = useRecommendedProjectPaths(serverId);
@@ -1,9 +1,9 @@
import { AlertCircle, Search } from "lucide-react-native"; import { AlertCircle, RotateCw, Search } from "lucide-react-native";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native"; import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet"; import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
import { SpinningRefreshIcon } from "@/components/spinning-refresh-icon"; import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import { Fonts } from "@/constants/theme"; import { Fonts } from "@/constants/theme";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
@@ -82,8 +82,12 @@ export function ProviderDiagnosticSheet({
); );
const handleRefresh = useCallback(() => { const handleRefresh = useCallback(() => {
void refresh([provider as AgentProvider]); if (!provider) {
void fetchDiagnostic({ keepCurrent: true }); return;
}
void Promise.all([refresh([provider as AgentProvider]), fetchDiagnostic()]).catch((err) => {
setDiagnostic(err instanceof Error ? err.message : "Failed to refresh provider");
});
}, [fetchDiagnostic, provider, refresh]); }, [fetchDiagnostic, provider, refresh]);
useEffect(() => { useEffect(() => {
@@ -157,13 +161,15 @@ export function ProviderDiagnosticSheet({
refreshInFlight ? sheetStyles.disabled : null, refreshInFlight ? sheetStyles.disabled : null,
]} ]}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={`Refresh ${providerLabel}`} accessibilityLabel={
refreshInFlight ? `Refreshing ${providerLabel}` : `Refresh ${providerLabel}`
}
> >
<SpinningRefreshIcon {refreshInFlight ? (
spinning={refreshInFlight} <LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
size={theme.iconSize.sm} ) : (
color={theme.colors.foregroundMuted} <RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
/> )}
</Pressable> </Pressable>
} }
> >
@@ -28,6 +28,12 @@ import type { HostProfile } from "@/types/host-connection";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import { useWorkspaceFields } from "@/stores/session-store-hooks"; import { useWorkspaceFields } from "@/stores/session-store-hooks";
import {
activateNavigationWorkspaceSelection,
syncNavigationActiveWorkspace,
useIsNavigationProjectActive,
useIsNavigationWorkspaceSelected,
} from "@/stores/navigation-active-workspace-store";
vi.mock("@react-native-async-storage/async-storage", () => ({ vi.mock("@react-native-async-storage/async-storage", () => ({
default: { default: {
@@ -43,6 +49,8 @@ type RenderCounts = {
frame: number; frame: number;
headers: Record<string, number>; headers: Record<string, number>;
rows: Record<string, number>; rows: Record<string, number>;
projectSelection: Record<string, number>;
rowSelection: Record<string, number>;
}; };
const runningScript: WorkspaceScriptPayload = { const runningScript: WorkspaceScriptPayload = {
@@ -166,6 +174,8 @@ function resetCounts(counts: RenderCounts): void {
counts.frame = 0; counts.frame = 0;
counts.headers = {}; counts.headers = {};
counts.rows = {}; counts.rows = {};
counts.projectSelection = {};
counts.rowSelection = {};
} }
function incrementRecord(record: Record<string, number>, key: string): void { function incrementRecord(record: Record<string, number>, key: string): void {
@@ -201,6 +211,37 @@ function WorkspaceRowProbe({
return null; return null;
} }
function ProjectActiveProbe({
serverId,
project,
counts,
}: {
serverId: string;
project: SidebarProjectEntry;
counts: RenderCounts;
}): null {
useIsNavigationProjectActive({
serverId,
workspaceIds: project.workspaces.map((workspace) => workspace.workspaceId),
});
incrementRecord(counts.projectSelection, project.projectKey);
return null;
}
function WorkspaceSelectionProbe({
serverId,
workspaceId,
counts,
}: {
serverId: string;
workspaceId: string;
counts: RenderCounts;
}): null {
useIsNavigationWorkspaceSelected({ serverId, workspaceId });
incrementRecord(counts.rowSelection, workspaceId);
return null;
}
function SidebarFrameProbe({ counts }: { counts: RenderCounts }): ReactElement { function SidebarFrameProbe({ counts }: { counts: RenderCounts }): ReactElement {
counts.frame += 1; counts.frame += 1;
const { projects } = useSidebarWorkspacesList({ serverId: SERVER_ID }); const { projects } = useSidebarWorkspacesList({ serverId: SERVER_ID });
@@ -210,13 +251,20 @@ function SidebarFrameProbe({ counts }: { counts: RenderCounts }): ReactElement {
{projects.map((project) => ( {projects.map((project) => (
<div key={project.projectKey}> <div key={project.projectKey}>
<ProjectHeaderProbe project={project} counts={counts} /> <ProjectHeaderProbe project={project} counts={counts} />
<ProjectActiveProbe serverId={SERVER_ID} project={project} counts={counts} />
{project.workspaces.map((workspace) => ( {project.workspaces.map((workspace) => (
<WorkspaceRowProbe <React.Fragment key={workspace.workspaceKey}>
key={workspace.workspaceKey} <WorkspaceRowProbe
serverId={workspace.serverId} serverId={workspace.serverId}
workspaceId={workspace.workspaceId} workspaceId={workspace.workspaceId}
counts={counts} counts={counts}
/> />
<WorkspaceSelectionProbe
serverId={workspace.serverId}
workspaceId={workspace.workspaceId}
counts={counts}
/>
</React.Fragment>
))} ))}
</div> </div>
))} ))}
@@ -278,6 +326,7 @@ describe("sidebar workspace render isolation", () => {
container?.remove(); container?.remove();
container = null; container = null;
act(() => { act(() => {
syncNavigationActiveWorkspace({ current: null });
getHostRuntimeStore().syncHosts([]); getHostRuntimeStore().syncHosts([]);
useSessionStore.getState().clearSession(SERVER_ID); useSessionStore.getState().clearSession(SERVER_ID);
useSidebarOrderStore.setState({ useSidebarOrderStore.setState({
@@ -288,7 +337,13 @@ describe("sidebar workspace render isolation", () => {
}); });
it("re-renders only the changed workspace row for a status update", async () => { it("re-renders only the changed workspace row for a status update", async () => {
const counts: RenderCounts = { frame: 0, headers: {}, rows: {} }; const counts: RenderCounts = {
frame: 0,
headers: {},
rows: {},
projectSelection: {},
rowSelection: {},
};
({ root, container } = await renderProbe(counts)); ({ root, container } = await renderProbe(counts));
act(() => { act(() => {
@@ -306,7 +361,13 @@ describe("sidebar workspace render isolation", () => {
}); });
it("does not re-render the sidebar for a host-runtime probe tick with no content change", async () => { it("does not re-render the sidebar for a host-runtime probe tick with no content change", async () => {
const counts: RenderCounts = { frame: 0, headers: {}, rows: {} }; const counts: RenderCounts = {
frame: 0,
headers: {},
rows: {},
projectSelection: {},
rowSelection: {},
};
({ root, container } = await renderProbe(counts)); ({ root, container } = await renderProbe(counts));
act(() => { act(() => {
@@ -316,11 +377,23 @@ describe("sidebar workspace render isolation", () => {
}); });
}); });
expect(counts).toEqual({ frame: 0, headers: {}, rows: {} }); expect(counts).toEqual({
frame: 0,
headers: {},
rows: {},
projectSelection: {},
rowSelection: {},
});
}); });
it("does not re-render for a deep-equal scripts patch", async () => { it("does not re-render for a deep-equal scripts patch", async () => {
const counts: RenderCounts = { frame: 0, headers: {}, rows: {} }; const counts: RenderCounts = {
frame: 0,
headers: {},
rows: {},
projectSelection: {},
rowSelection: {},
};
({ root, container } = await renderProbe(counts)); ({ root, container } = await renderProbe(counts));
act(() => { act(() => {
@@ -332,6 +405,49 @@ describe("sidebar workspace render isolation", () => {
); );
}); });
expect(counts).toEqual({ frame: 0, headers: {}, rows: {} }); expect(counts).toEqual({
frame: 0,
headers: {},
rows: {},
projectSelection: {},
rowSelection: {},
});
});
it("isolates active selection updates to affected row and project boolean probes", async () => {
const counts: RenderCounts = {
frame: 0,
headers: {},
rows: {},
projectSelection: {},
rowSelection: {},
};
act(() => {
activateNavigationWorkspaceSelection({
serverId: SERVER_ID,
workspaceId: "a-one",
});
});
({ root, container } = await renderProbe(counts));
act(() => {
activateNavigationWorkspaceSelection({
serverId: SERVER_ID,
workspaceId: "b-two",
});
});
expect(counts.frame).toBe(0);
expect(counts.headers).toEqual({});
expect(counts.rows).toEqual({});
expect(counts.projectSelection).toEqual({
"project-a": 1,
"project-b": 1,
});
expect(counts.rowSelection).toEqual({
"a-one": 1,
"b-two": 1,
});
}); });
}); });
@@ -28,7 +28,6 @@ import * as Clipboard from "expo-clipboard";
import { DiffStat } from "@/components/diff-stat"; import { DiffStat } from "@/components/diff-stat";
import { import {
Archive, Archive,
ArrowUpRight,
CircleAlert, CircleAlert,
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
@@ -92,14 +91,17 @@ import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import { type PrHint, useWorkspacePrHint } from "@/hooks/use-checkout-pr-status-query"; import { type PrHint, useWorkspacePrHint } from "@/hooks/use-checkout-pr-status-query";
import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model"; import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model";
import { useNavigationActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import {
useIsNavigationProjectActive,
useIsNavigationWorkspaceSelected,
} from "@/stores/navigation-active-workspace-store";
import { import {
normalizeWorkspaceDescriptor, normalizeWorkspaceDescriptor,
useSessionStore, useSessionStore,
type WorkspaceDescriptor, type WorkspaceDescriptor,
} from "@/stores/session-store"; } from "@/stores/session-store";
import { useWorkspaceFields } from "@/stores/session-store-hooks"; import { useWorkspaceFields } from "@/stores/session-store-hooks";
import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation"; import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import { openExternalUrl } from "@/utils/open-external-url"; import { openExternalUrl } from "@/utils/open-external-url";
import { import {
requireWorkspaceExecutionDirectory, requireWorkspaceExecutionDirectory,
@@ -216,6 +218,7 @@ export function PrBadge({ hint }: { hint: PrHint }) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const activeColor = isHovered ? theme.colors.foreground : theme.colors.foregroundMuted; const activeColor = isHovered ? theme.colors.foreground : theme.colors.foregroundMuted;
const iconColor = getWorkspacePrIconColor(theme, hint.state);
const handlePressIn = useCallback((event: GestureResponderEvent) => { const handlePressIn = useCallback((event: GestureResponderEvent) => {
event.stopPropagation(); event.stopPropagation();
@@ -240,11 +243,14 @@ export function PrBadge({ hint }: { hint: PrHint }) {
onHoverOut={() => setIsHovered(false)} onHoverOut={() => setIsHovered(false)}
style={({ pressed }) => [prBadgeStyles.badge, pressed && prBadgeStyles.badgePressed]} style={({ pressed }) => [prBadgeStyles.badge, pressed && prBadgeStyles.badgePressed]}
> >
<GitPullRequest size={12} color={activeColor} /> {isHovered ? (
<ExternalLink size={12} color={activeColor} />
) : (
<GitPullRequest size={12} color={iconColor} />
)}
<Text style={[prBadgeStyles.text, { color: activeColor }]} numberOfLines={1}> <Text style={[prBadgeStyles.text, { color: activeColor }]} numberOfLines={1}>
#{hint.number} #{hint.number}
</Text> </Text>
<ArrowUpRight size={10} color={activeColor} style={{ opacity: isHovered ? 1 : 0 }} />
</Pressable> </Pressable>
); );
} }
@@ -830,12 +836,7 @@ function ProjectHeaderRow({
const rowChildren = ( const rowChildren = (
<> <>
<View <View style={styles.projectRowLeft}>
{...(dragHandleProps?.attributes as any)}
{...(dragHandleProps?.listeners as any)}
ref={dragHandleProps?.setActivatorNodeRef as any}
style={styles.projectRowLeft}
>
<ProjectLeadingVisual <ProjectLeadingVisual
displayName={displayName} displayName={displayName}
iconDataUri={iconDataUri} iconDataUri={iconDataUri}
@@ -912,7 +913,13 @@ function ProjectHeaderRow({
if (menuController) { if (menuController) {
return ( return (
<View onPointerEnter={() => setIsHovered(true)} onPointerLeave={() => setIsHovered(false)}> <View
{...(dragHandleProps?.attributes as any)}
{...(dragHandleProps?.listeners as any)}
ref={dragHandleProps?.setActivatorNodeRef as any}
onPointerEnter={() => setIsHovered(true)}
onPointerLeave={() => setIsHovered(false)}
>
<ContextMenuTrigger <ContextMenuTrigger
enabledOnMobile={false} enabledOnMobile={false}
style={({ pressed }) => [ style={({ pressed }) => [
@@ -935,7 +942,13 @@ function ProjectHeaderRow({
} }
return ( return (
<View onPointerEnter={() => setIsHovered(true)} onPointerLeave={() => setIsHovered(false)}> <View
{...(dragHandleProps?.attributes as any)}
{...(dragHandleProps?.listeners as any)}
ref={dragHandleProps?.setActivatorNodeRef as any}
onPointerEnter={() => setIsHovered(true)}
onPointerLeave={() => setIsHovered(false)}
>
<Pressable <Pressable
style={({ pressed }) => [ style={({ pressed }) => [
styles.projectRow, styles.projectRow,
@@ -1010,6 +1023,9 @@ function WorkspaceRowInner({
return ( return (
<WorkspaceHoverCard workspace={workspace} prHint={prHint} isDragging={isDragging}> <WorkspaceHoverCard workspace={workspace} prHint={prHint} isDragging={isDragging}>
<View <View
{...(dragHandleProps?.attributes as any)}
{...(dragHandleProps?.listeners as any)}
ref={dragHandleProps?.setActivatorNodeRef as any}
style={styles.workspaceRowContainer} style={styles.workspaceRowContainer}
onPointerEnter={() => setIsHovered(true)} onPointerEnter={() => setIsHovered(true)}
onPointerLeave={() => setIsHovered(false)} onPointerLeave={() => setIsHovered(false)}
@@ -1033,12 +1049,7 @@ function WorkspaceRowInner({
testID={`sidebar-workspace-row-${workspace.workspaceKey}`} testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
> >
<View style={styles.workspaceRowMain}> <View style={styles.workspaceRowMain}>
<View <View style={styles.workspaceRowLeft}>
{...(dragHandleProps?.attributes as any)}
{...(dragHandleProps?.listeners as any)}
ref={dragHandleProps?.setActivatorNodeRef as any}
style={styles.workspaceRowLeft}
>
<WorkspaceStatusIndicator <WorkspaceStatusIndicator
bucket={workspace.statusBucket} bucket={workspace.statusBucket}
workspaceKind={workspace.workspaceKind} workspaceKind={workspace.workspaceKind}
@@ -1133,8 +1144,8 @@ function WorkspaceRowInner({
</View> </View>
{prHint ? ( {prHint ? (
<View style={styles.workspacePrBadgeRow}> <View style={styles.workspacePrBadgeRow}>
<ChecksBadge checks={prHint.checks} />
<PrBadge hint={prHint} /> <PrBadge hint={prHint} />
<ChecksBadge checks={prHint.checks} />
</View> </View>
) : null} ) : null}
</Pressable> </Pressable>
@@ -1167,7 +1178,6 @@ function WorkspaceRowWithMenu({
isCreating?: boolean; isCreating?: boolean;
}) { }) {
const toast = useToast(); const toast = useToast();
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection();
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree); const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false); const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
const workspaceDirectory = resolveWorkspaceExecutionDirectory({ const workspaceDirectory = resolveWorkspaceExecutionDirectory({
@@ -1185,22 +1195,11 @@ function WorkspaceRowWithMenu({
const isWorktree = workspace.workspaceKind === "worktree"; const isWorktree = workspace.workspaceKind === "worktree";
const isArchiving = isWorktree ? archiveStatus === "pending" : isArchivingWorkspace; const isArchiving = isWorktree ? archiveStatus === "pending" : isArchivingWorkspace;
const redirectAfterArchive = useCallback(() => { const redirectAfterArchive = useCallback(() => {
if ( redirectIfArchivingActiveWorkspace({
activeWorkspaceSelection?.serverId !== workspace.serverId || serverId: workspace.serverId,
activeWorkspaceSelection.workspaceId !== workspace.workspaceId workspaceId: workspace.workspaceId,
) { });
return; }, [workspace.serverId, workspace.workspaceId]);
}
router.replace(
buildWorkspaceArchiveRedirectRoute({
serverId: workspace.serverId,
archivedWorkspaceId: workspace.workspaceId,
workspaces:
useSessionStore.getState().sessions[workspace.serverId]?.workspaces.values() ?? [],
}),
);
}, [activeWorkspaceSelection, workspace.serverId, workspace.workspaceId]);
const handleArchiveWorktree = useCallback(() => { const handleArchiveWorktree = useCallback(() => {
if (isArchiving) { if (isArchiving) {
@@ -1393,25 +1392,13 @@ function NonGitProjectRowWithMenuContent({
}) { }) {
const toast = useToast(); const toast = useToast();
const contextMenu = useContextMenu(); const contextMenu = useContextMenu();
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection();
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false); const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
const redirectAfterArchive = useCallback(() => { const redirectAfterArchive = useCallback(() => {
if ( redirectIfArchivingActiveWorkspace({
activeWorkspaceSelection?.serverId !== workspace.serverId || serverId: workspace.serverId,
activeWorkspaceSelection.workspaceId !== workspace.workspaceId workspaceId: workspace.workspaceId,
) { });
return; }, [workspace.serverId, workspace.workspaceId]);
}
router.replace(
buildWorkspaceArchiveRedirectRoute({
serverId: workspace.serverId,
archivedWorkspaceId: workspace.workspaceId,
workspaces:
useSessionStore.getState().sessions[workspace.serverId]?.workspaces.values() ?? [],
}) as any,
);
}, [activeWorkspaceSelection, workspace.serverId, workspace.workspaceId]);
const handleArchiveWorkspace = useCallback(() => { const handleArchiveWorkspace = useCallback(() => {
if (isArchivingWorkspace) { if (isArchivingWorkspace) {
@@ -1538,6 +1525,7 @@ function FlattenedProjectRow({
isProjectActive = false, isProjectActive = false,
onRemoveProject, onRemoveProject,
removeProjectStatus, removeProjectStatus,
selectionEnabled,
}: { }: {
project: SidebarProjectEntry; project: SidebarProjectEntry;
displayName: string; displayName: string;
@@ -1555,8 +1543,14 @@ function FlattenedProjectRow({
isProjectActive?: boolean; isProjectActive?: boolean;
onRemoveProject?: () => void; onRemoveProject?: () => void;
removeProjectStatus?: "idle" | "pending"; removeProjectStatus?: "idle" | "pending";
selectionEnabled: boolean;
}) { }) {
const workspace = useSidebarWorkspaceEntry(serverId, rowModel.workspace.workspaceId); const workspace = useSidebarWorkspaceEntry(serverId, rowModel.workspace.workspaceId);
const selected = useIsNavigationWorkspaceSelected({
serverId,
workspaceId: rowModel.workspace.workspaceId,
enabled: selectionEnabled,
});
if (!workspace) { if (!workspace) {
return null; return null;
@@ -1569,7 +1563,7 @@ function FlattenedProjectRow({
displayName={displayName} displayName={displayName}
iconDataUri={iconDataUri} iconDataUri={iconDataUri}
workspace={workspace} workspace={workspace}
selected={rowModel.selected} selected={selected}
onPress={onPress} onPress={onPress}
shortcutNumber={shortcutNumber} shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge} showShortcutBadge={showShortcutBadge}
@@ -1586,7 +1580,7 @@ function FlattenedProjectRow({
displayName={displayName} displayName={displayName}
iconDataUri={iconDataUri} iconDataUri={iconDataUri}
workspace={workspace} workspace={workspace}
selected={rowModel.selected} selected={selected}
chevron={rowModel.chevron} chevron={rowModel.chevron}
onPress={onPress} onPress={onPress}
serverId={serverId} serverId={serverId}
@@ -1608,7 +1602,6 @@ function FlattenedProjectRow({
function WorkspaceRow({ function WorkspaceRow({
workspace, workspace,
selected,
shortcutNumber, shortcutNumber,
showShortcutBadge, showShortcutBadge,
onPress, onPress,
@@ -1617,9 +1610,9 @@ function WorkspaceRow({
dragHandleProps, dragHandleProps,
canCopyBranchName, canCopyBranchName,
isCreating = false, isCreating = false,
selectionEnabled,
}: { }: {
workspace: SidebarWorkspaceEntry; workspace: SidebarWorkspaceEntry;
selected: boolean;
shortcutNumber: number | null; shortcutNumber: number | null;
showShortcutBadge: boolean; showShortcutBadge: boolean;
onPress: () => void; onPress: () => void;
@@ -1628,8 +1621,14 @@ function WorkspaceRow({
dragHandleProps?: DraggableListDragHandleProps; dragHandleProps?: DraggableListDragHandleProps;
canCopyBranchName: boolean; canCopyBranchName: boolean;
isCreating?: boolean; isCreating?: boolean;
selectionEnabled: boolean;
}) { }) {
const hydratedWorkspace = useSidebarWorkspaceEntry(workspace.serverId, workspace.workspaceId); const hydratedWorkspace = useSidebarWorkspaceEntry(workspace.serverId, workspace.workspaceId);
const selected = useIsNavigationWorkspaceSelected({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
enabled: selectionEnabled,
});
if (!hydratedWorkspace) { if (!hydratedWorkspace) {
return null; return null;
@@ -1657,7 +1656,7 @@ function ProjectBlock({
displayName, displayName,
iconDataUri, iconDataUri,
serverId, serverId,
activeWorkspaceSelection, selectionEnabled,
showShortcutBadges, showShortcutBadges,
shortcutIndexByWorkspaceKey, shortcutIndexByWorkspaceKey,
parentGestureRef, parentGestureRef,
@@ -1665,6 +1664,7 @@ function ProjectBlock({
onWorkspacePress, onWorkspacePress,
onWorkspaceReorder, onWorkspaceReorder,
onWorktreeCreated, onWorktreeCreated,
currentPathname,
drag, drag,
isDragging, isDragging,
dragHandleProps, dragHandleProps,
@@ -1676,7 +1676,7 @@ function ProjectBlock({
displayName: string; displayName: string;
iconDataUri: string | null; iconDataUri: string | null;
serverId: string | null; serverId: string | null;
activeWorkspaceSelection: { serverId: string; workspaceId: string } | null; selectionEnabled: boolean;
showShortcutBadges: boolean; showShortcutBadges: boolean;
shortcutIndexByWorkspaceKey: Map<string, number>; shortcutIndexByWorkspaceKey: Map<string, number>;
parentGestureRef?: MutableRefObject<GestureType | undefined>; parentGestureRef?: MutableRefObject<GestureType | undefined>;
@@ -1684,6 +1684,7 @@ function ProjectBlock({
onWorkspacePress?: () => void; onWorkspacePress?: () => void;
onWorkspaceReorder: (projectKey: string, workspaces: SidebarWorkspaceEntry[]) => void; onWorkspaceReorder: (projectKey: string, workspaces: SidebarWorkspaceEntry[]) => void;
onWorktreeCreated?: (workspaceId: string) => void; onWorktreeCreated?: (workspaceId: string) => void;
currentPathname: string | null;
drag: () => void; drag: () => void;
isDragging: boolean; isDragging: boolean;
dragHandleProps?: DraggableListDragHandleProps; dragHandleProps?: DraggableListDragHandleProps;
@@ -1695,18 +1696,19 @@ function ProjectBlock({
buildSidebarProjectRowModel({ buildSidebarProjectRowModel({
project, project,
collapsed, collapsed,
serverId,
activeWorkspaceSelection,
}), }),
[activeWorkspaceSelection, collapsed, project, serverId], [collapsed, project],
); );
const isProjectActive = useMemo(() => { const projectWorkspaceIds = useMemo(
if (!serverId || !activeWorkspaceSelection || activeWorkspaceSelection.serverId !== serverId) { () => project.workspaces.map((workspace) => workspace.workspaceId),
return false; [project.workspaces],
} );
return project.workspaces.some((w) => w.workspaceId === activeWorkspaceSelection.workspaceId); const isProjectActive = useIsNavigationProjectActive({
}, [serverId, activeWorkspaceSelection, project.workspaces]); serverId,
workspaceIds: projectWorkspaceIds,
enabled: selectionEnabled,
});
const renderWorkspaceRow = useCallback( const renderWorkspaceRow = useCallback(
( (
@@ -1717,25 +1719,20 @@ function ProjectBlock({
dragHandleProps?: DraggableListDragHandleProps; dragHandleProps?: DraggableListDragHandleProps;
}, },
) => { ) => {
const isSelected =
Boolean(serverId) &&
activeWorkspaceSelection?.serverId === serverId &&
activeWorkspaceSelection.workspaceId === item.workspaceId;
return ( return (
<WorkspaceRow <WorkspaceRow
workspace={item} workspace={item}
selected={isSelected}
shortcutNumber={shortcutIndexByWorkspaceKey.get(item.workspaceKey) ?? null} shortcutNumber={shortcutIndexByWorkspaceKey.get(item.workspaceKey) ?? null}
showShortcutBadge={showShortcutBadges} showShortcutBadge={showShortcutBadges}
canCopyBranchName={project.projectKind === "git"} canCopyBranchName={project.projectKind === "git"}
isCreating={creatingWorkspaceIds.has(item.workspaceId)} isCreating={creatingWorkspaceIds.has(item.workspaceId)}
selectionEnabled={selectionEnabled}
onPress={() => { onPress={() => {
if (!serverId) { if (!serverId) {
return; return;
} }
onWorkspacePress?.(); onWorkspacePress?.();
navigateToWorkspace(serverId, item.workspaceId); navigateToWorkspace(serverId, item.workspaceId, { currentPathname });
}} }}
drag={input?.drag ?? (() => {})} drag={input?.drag ?? (() => {})}
isDragging={input?.isDragging ?? false} isDragging={input?.isDragging ?? false}
@@ -1744,11 +1741,12 @@ function ProjectBlock({
); );
}, },
[ [
activeWorkspaceSelection,
project.projectKind, project.projectKind,
creatingWorkspaceIds, creatingWorkspaceIds,
currentPathname,
onWorkspacePress, onWorkspacePress,
serverId, serverId,
selectionEnabled,
shortcutIndexByWorkspaceKey, shortcutIndexByWorkspaceKey,
showShortcutBadges, showShortcutBadges,
], ],
@@ -1835,7 +1833,9 @@ function ProjectBlock({
return; return;
} }
onWorkspacePress?.(); onWorkspacePress?.();
navigateToWorkspace(serverId, rowModel.workspace.workspaceId); navigateToWorkspace(serverId, rowModel.workspace.workspaceId, {
currentPathname,
});
}} }}
serverId={serverId} serverId={serverId}
onWorkspacePress={onWorkspacePress} onWorkspacePress={onWorkspacePress}
@@ -1848,6 +1848,7 @@ function ProjectBlock({
isProjectActive={isProjectActive} isProjectActive={isProjectActive}
onRemoveProject={handleRemoveProject} onRemoveProject={handleRemoveProject}
removeProjectStatus={isRemovingProject ? "pending" : "idle"} removeProjectStatus={isRemovingProject ? "pending" : "idle"}
selectionEnabled={selectionEnabled}
/> />
) : ( ) : (
<> <>
@@ -1906,9 +1907,7 @@ export function SidebarWorkspaceList({
listFooterComponent, listFooterComponent,
parentGestureRef, parentGestureRef,
}: SidebarWorkspaceListProps) { }: SidebarWorkspaceListProps) {
const isMobile = useIsCompactFormFactor();
const pathname = usePathname(); const pathname = usePathname();
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection();
const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set()); const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set());
const creatingWorkspaceTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>( const creatingWorkspaceTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
new Map(), new Map(),
@@ -1924,7 +1923,7 @@ export function SidebarWorkspaceList({
() => Boolean(pathname && parseHostWorkspaceRouteFromPathname(pathname)), () => Boolean(pathname && parseHostWorkspaceRouteFromPathname(pathname)),
[pathname], [pathname],
); );
const effectiveActiveWorkspaceSelection = isWorkspaceRoute ? activeWorkspaceSelection : null; const selectionEnabled = isWorkspaceRoute;
const projectIconRequests = useMemo(() => { const projectIconRequests = useMemo(() => {
if (!serverId) { if (!serverId) {
@@ -2129,7 +2128,7 @@ export function SidebarWorkspaceList({
displayName={item.projectName} displayName={item.projectName}
iconDataUri={projectIconByProjectKey.get(item.projectKey) ?? null} iconDataUri={projectIconByProjectKey.get(item.projectKey) ?? null}
serverId={serverId} serverId={serverId}
activeWorkspaceSelection={effectiveActiveWorkspaceSelection} selectionEnabled={selectionEnabled}
showShortcutBadges={showShortcutBadges} showShortcutBadges={showShortcutBadges}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey} shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
parentGestureRef={parentGestureRef} parentGestureRef={parentGestureRef}
@@ -2137,6 +2136,7 @@ export function SidebarWorkspaceList({
onWorkspacePress={onWorkspacePress} onWorkspacePress={onWorkspacePress}
onWorkspaceReorder={handleWorkspaceReorder} onWorkspaceReorder={handleWorkspaceReorder}
onWorktreeCreated={handleWorktreeCreated} onWorktreeCreated={handleWorktreeCreated}
currentPathname={pathname}
drag={drag} drag={drag}
isDragging={isActive} isDragging={isActive}
dragHandleProps={dragHandleProps} dragHandleProps={dragHandleProps}
@@ -2147,13 +2147,14 @@ export function SidebarWorkspaceList({
}, },
[ [
collapsedProjectKeys, collapsedProjectKeys,
effectiveActiveWorkspaceSelection,
handleWorktreeCreated, handleWorktreeCreated,
handleWorkspaceReorder, handleWorkspaceReorder,
onWorkspacePress, onWorkspacePress,
onToggleProjectCollapsed, onToggleProjectCollapsed,
parentGestureRef, parentGestureRef,
pathname,
projectIconByProjectKey, projectIconByProjectKey,
selectionEnabled,
serverId, serverId,
shortcutIndexByWorkspaceKey, shortcutIndexByWorkspaceKey,
showShortcutBadges, showShortcutBadges,
@@ -2266,6 +2267,7 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center", alignItems: "center",
justifyContent: "space-between", justifyContent: "space-between",
gap: theme.spacing[2], gap: theme.spacing[2],
userSelect: "none",
}, },
projectRowHovered: { projectRowHovered: {
backgroundColor: theme.colors.surfaceSidebarHover, backgroundColor: theme.colors.surfaceSidebarHover,
@@ -2409,6 +2411,7 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "stretch", alignItems: "stretch",
justifyContent: "center", justifyContent: "center",
gap: theme.spacing[1], gap: theme.spacing[1],
userSelect: "none",
}, },
workspaceRowMain: { workspaceRowMain: {
flexDirection: "row", flexDirection: "row",
@@ -1,67 +0,0 @@
import { useEffect } from "react";
import { RefreshCw } from "lucide-react-native";
import Animated, {
cancelAnimation,
Easing,
useAnimatedStyle,
useSharedValue,
withRepeat,
withTiming,
} from "react-native-reanimated";
interface SpinningRefreshIconProps {
spinning: boolean;
size: number;
color: string;
}
export function SpinningRefreshIcon({ spinning, size, color }: SpinningRefreshIconProps) {
const rotation = useSharedValue(0);
useEffect(() => {
if (spinning) {
rotation.value = 0;
rotation.value = withRepeat(
withTiming(360, {
duration: 1000,
easing: Easing.linear,
}),
-1,
false,
);
return;
}
cancelAnimation(rotation);
const remainder = rotation.value % 360;
if (Math.abs(remainder) < 0.001) {
rotation.value = 0;
return;
}
rotation.value = withTiming(360, {
duration: Math.max(80, Math.round(((360 - remainder) / 360) * 1000)),
easing: Easing.linear,
});
}, [rotation, spinning]);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${rotation.value}deg` }],
}));
return (
<Animated.View
style={[
{
width: size,
height: size,
alignItems: "center",
justifyContent: "center",
},
animatedStyle,
]}
>
<RefreshCw size={size} color={color} />
</Animated.View>
);
}
@@ -76,6 +76,7 @@ interface SplitContainerProps {
workspaceKey: string; workspaceKey: string;
normalizedServerId: string; normalizedServerId: string;
normalizedWorkspaceId: string; normalizedWorkspaceId: string;
isWorkspaceFocused: boolean;
uiTabs: WorkspaceTab[]; uiTabs: WorkspaceTab[];
hoveredCloseTabKey: string | null; hoveredCloseTabKey: string | null;
setHoveredTabKey: Dispatch<SetStateAction<string | null>>; setHoveredTabKey: Dispatch<SetStateAction<string | null>>;
@@ -93,7 +94,6 @@ interface SplitContainerProps {
onCreateTerminalTab: (input: { paneId?: string }) => void; onCreateTerminalTab: (input: { paneId?: string }) => void;
buildPaneContentModel: (input: { buildPaneContentModel: (input: {
paneId: string; paneId: string;
isPaneFocused: boolean;
tab: WorkspaceTabDescriptor; tab: WorkspaceTabDescriptor;
}) => WorkspacePaneContentModel; }) => WorkspacePaneContentModel;
onFocusPane: (paneId: string) => void; onFocusPane: (paneId: string) => void;
@@ -158,11 +158,11 @@ interface SplitPaneViewProps
interface MountedTabSlotProps { interface MountedTabSlotProps {
tabDescriptor: WorkspaceTabDescriptor; tabDescriptor: WorkspaceTabDescriptor;
isVisible: boolean; isVisible: boolean;
isWorkspaceFocused: boolean;
isPaneFocused: boolean; isPaneFocused: boolean;
paneId: string; paneId: string;
buildPaneContentModel: (input: { buildPaneContentModel: (input: {
paneId: string; paneId: string;
isPaneFocused: boolean;
tab: WorkspaceTabDescriptor; tab: WorkspaceTabDescriptor;
}) => WorkspacePaneContentModel; }) => WorkspacePaneContentModel;
} }
@@ -170,6 +170,7 @@ interface MountedTabSlotProps {
const MountedTabSlot = memo(function MountedTabSlot({ const MountedTabSlot = memo(function MountedTabSlot({
tabDescriptor, tabDescriptor,
isVisible, isVisible,
isWorkspaceFocused,
isPaneFocused, isPaneFocused,
paneId, paneId,
buildPaneContentModel, buildPaneContentModel,
@@ -178,15 +179,18 @@ const MountedTabSlot = memo(function MountedTabSlot({
() => () =>
buildPaneContentModel({ buildPaneContentModel({
paneId, paneId,
isPaneFocused,
tab: tabDescriptor, tab: tabDescriptor,
}), }),
[buildPaneContentModel, isPaneFocused, paneId, tabDescriptor], [buildPaneContentModel, paneId, tabDescriptor],
); );
return ( return (
<View style={{ display: isVisible ? "flex" : "none", flex: 1 }}> <View style={{ display: isVisible ? "flex" : "none", flex: 1 }}>
<WorkspacePaneContent content={content} /> <WorkspacePaneContent
content={content}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isPaneFocused}
/>
</View> </View>
); );
}); });
@@ -241,6 +245,7 @@ export function SplitContainer({
workspaceKey, workspaceKey,
normalizedServerId, normalizedServerId,
normalizedWorkspaceId, normalizedWorkspaceId,
isWorkspaceFocused,
uiTabs, uiTabs,
hoveredCloseTabKey, hoveredCloseTabKey,
setHoveredTabKey, setHoveredTabKey,
@@ -511,6 +516,7 @@ export function SplitContainer({
focusedPaneId={layout.focusedPaneId} focusedPaneId={layout.focusedPaneId}
normalizedServerId={normalizedServerId} normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId} normalizedWorkspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isWorkspaceFocused}
hoveredCloseTabKey={hoveredCloseTabKey} hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredTabKey={setHoveredTabKey} setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey} setHoveredCloseTabKey={setHoveredCloseTabKey}
@@ -633,6 +639,7 @@ function SplitNodeView({
focusedPaneId, focusedPaneId,
normalizedServerId, normalizedServerId,
normalizedWorkspaceId, normalizedWorkspaceId,
isWorkspaceFocused,
hoveredCloseTabKey, hoveredCloseTabKey,
setHoveredTabKey, setHoveredTabKey,
setHoveredCloseTabKey, setHoveredCloseTabKey,
@@ -668,6 +675,7 @@ function SplitNodeView({
isFocused={node.pane.id === focusedPaneId} isFocused={node.pane.id === focusedPaneId}
normalizedServerId={normalizedServerId} normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId} normalizedWorkspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isWorkspaceFocused}
hoveredCloseTabKey={hoveredCloseTabKey} hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredTabKey={setHoveredTabKey} setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey} setHoveredCloseTabKey={setHoveredCloseTabKey}
@@ -718,6 +726,7 @@ function SplitNodeView({
focusedPaneId={focusedPaneId} focusedPaneId={focusedPaneId}
normalizedServerId={normalizedServerId} normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId} normalizedWorkspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isWorkspaceFocused}
hoveredCloseTabKey={hoveredCloseTabKey} hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredTabKey={setHoveredTabKey} setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey} setHoveredCloseTabKey={setHoveredCloseTabKey}
@@ -767,6 +776,7 @@ function SplitPaneView({
isFocused, isFocused,
normalizedServerId, normalizedServerId,
normalizedWorkspaceId, normalizedWorkspaceId,
isWorkspaceFocused,
hoveredCloseTabKey, hoveredCloseTabKey,
setHoveredTabKey, setHoveredTabKey,
setHoveredCloseTabKey, setHoveredCloseTabKey,
@@ -916,6 +926,7 @@ function SplitPaneView({
key={tabId} key={tabId}
tabDescriptor={tabDescriptor} tabDescriptor={tabDescriptor}
isVisible={tabId === activeTabDescriptor?.tabId} isVisible={tabId === activeTabDescriptor?.tabId}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isFocused && tabId === activeTabDescriptor?.tabId} isPaneFocused={isFocused && tabId === activeTabDescriptor?.tabId}
paneId={pane.id} paneId={pane.id}
buildPaneContentModel={buildPaneContentModel} buildPaneContentModel={buildPaneContentModel}
@@ -0,0 +1,128 @@
/**
* @vitest-environment jsdom
*/
import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { StreamItem } from "@/types/stream";
import type { StreamSegmentRenderers, StreamViewportHandle } from "./stream-strategy";
import { createWebStreamStrategy } from "./stream-strategy-web";
function userMessage(index: number): StreamItem {
return {
kind: "user_message",
id: `message-${index}`,
text: `Message ${index}`,
timestamp: new Date(`2026-04-20T00:00:${String(index % 60).padStart(2, "0")}.000Z`),
};
}
function createRenderers(onRowRender: () => void): StreamSegmentRenderers {
return {
renderHistoryVirtualizedRow: (item) => {
onRowRender();
return <div style={{ height: 24 }}>{item.id}</div>;
},
renderHistoryMountedRow: (item) => <div>{item.id}</div>,
renderLiveHeadRow: (item) => <div>{item.id}</div>,
renderLiveAuxiliary: () => null,
};
}
describe("createWebStreamStrategy", () => {
let root: Root | null = null;
let container: HTMLDivElement | null = null;
let originalScrollTo: HTMLElement["scrollTo"] | undefined;
let originalOffsetHeight: PropertyDescriptor | undefined;
beforeEach(() => {
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
value: true,
configurable: true,
});
Object.defineProperty(globalThis, "ResizeObserver", {
value: class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
},
configurable: true,
});
originalScrollTo = HTMLElement.prototype.scrollTo;
HTMLElement.prototype.scrollTo = vi.fn();
originalOffsetHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetHeight");
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
configurable: true,
get() {
return 24;
},
});
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container?.remove();
container = null;
if (originalScrollTo) {
HTMLElement.prototype.scrollTo = originalScrollTo;
} else {
Reflect.deleteProperty(HTMLElement.prototype, "scrollTo");
}
if (originalOffsetHeight) {
Object.defineProperty(HTMLElement.prototype, "offsetHeight", originalOffsetHeight);
} else {
Reflect.deleteProperty(HTMLElement.prototype, "offsetHeight");
}
vi.restoreAllMocks();
});
it("mounts virtualized history without recursive row measurement updates", () => {
const rowRenderCount = vi.fn();
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
const viewportRef = React.createRef<StreamViewportHandle>();
const historyVirtualized = Array.from({ length: 16 }, (_, index) => userMessage(index));
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root?.render(
<>
{strategy.render({
agentId: "agent",
segments: {
historyVirtualized,
historyMounted: [],
liveHead: [],
},
boundary: {
hasVirtualizedHistory: true,
hasMountedHistory: false,
hasLiveHead: false,
historyToHeadGap: 0,
},
renderers: createRenderers(rowRenderCount),
listEmptyComponent: null,
viewportRef,
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: vi.fn(),
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
})}
</>,
);
});
expect(rowRenderCount.mock.calls.length).toBeGreaterThan(0);
expect(rowRenderCount.mock.calls.length).toBeLessThanOrEqual(historyVirtualized.length);
});
});
@@ -1,4 +1,4 @@
import { import React, {
Fragment, Fragment,
type CSSProperties, type CSSProperties,
useCallback, useCallback,
@@ -126,6 +126,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const lastTouchClientYRef = useRef<number | null>(null); const lastTouchClientYRef = useRef<number | null>(null);
const pendingAutoScrollFrameRef = useRef<number | null>(null); const pendingAutoScrollFrameRef = useRef<number | null>(null);
const pendingAutoScrollTimeoutRef = useRef<number | null>(null); const pendingAutoScrollTimeoutRef = useRef<number | null>(null);
const pendingVirtualRowMeasureFramesRef = useRef(new Map<Element, number>());
const showDesktopWebScrollbar = !isMobileBreakpoint; const showDesktopWebScrollbar = !isMobileBreakpoint;
const scrollbarOverlay = useWebElementScrollbar(scrollContainerRef, { const scrollbarOverlay = useWebElementScrollbar(scrollContainerRef, {
enabled: showDesktopWebScrollbar, enabled: showDesktopWebScrollbar,
@@ -181,6 +182,38 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const virtualRows = rowVirtualizer.getVirtualItems(); const virtualRows = rowVirtualizer.getVirtualItems();
const virtualTotalSize = rowVirtualizer.getTotalSize(); const virtualTotalSize = rowVirtualizer.getTotalSize();
const measureVirtualizedRowElement = useCallback(
(node: HTMLDivElement | null) => {
if (!node) {
rowVirtualizer.measureElement(null);
return;
}
const pendingFrames = pendingVirtualRowMeasureFramesRef.current;
const existingFrame = pendingFrames.get(node);
if (existingFrame !== undefined) {
window.cancelAnimationFrame(existingFrame);
}
const frame = window.requestAnimationFrame(() => {
pendingFrames.delete(node);
if (node.isConnected) {
rowVirtualizer.measureElement(node);
}
});
pendingFrames.set(node, frame);
},
[rowVirtualizer],
);
useEffect(() => {
const pendingFrames = pendingVirtualRowMeasureFramesRef.current;
return () => {
for (const frame of pendingFrames.values()) {
window.cancelAnimationFrame(frame);
}
pendingFrames.clear();
};
}, []);
const cancelPendingStickToBottom = useCallback(() => { const cancelPendingStickToBottom = useCallback(() => {
const pendingFrame = pendingAutoScrollFrameRef.current; const pendingFrame = pendingAutoScrollFrameRef.current;
if (pendingFrame !== null) { if (pendingFrame !== null) {
@@ -696,7 +729,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
<div <div
key={virtualRow.key} key={virtualRow.key}
data-index={virtualRow.index} data-index={virtualRow.index}
ref={rowVirtualizer.measureElement} ref={measureVirtualizedRowElement}
style={renderVirtualRowStyle(virtualRow.start)} style={renderVirtualRowStyle(virtualRow.start)}
> >
{renderHistoryVirtualizedRow( {renderHistoryVirtualizedRow(
+45 -41
View File
@@ -1,5 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useFocusEffect, useIsFocused } from "@react-navigation/native";
import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native"; import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated"; import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -27,7 +26,9 @@ interface TerminalPaneProps {
serverId: string; serverId: string;
cwd: string; cwd: string;
terminalId: string; terminalId: string;
isWorkspaceFocused: boolean;
isPaneFocused: boolean; isPaneFocused: boolean;
onOpenFileExplorer: () => void;
} }
const TERMINAL_REFIT_DELAYS_MS = [0, 48, 144, 320]; const TERMINAL_REFIT_DELAYS_MS = [0, 48, 144, 320];
@@ -82,15 +83,20 @@ function terminalScopeKey(input: { serverId: string; cwd: string }): string {
return `${input.serverId}:${input.cwd}`; return `${input.serverId}:${input.cwd}`;
} }
export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: TerminalPaneProps) { export function TerminalPane({
const isScreenFocused = useIsFocused(); serverId,
cwd,
terminalId,
isWorkspaceFocused,
isPaneFocused,
onOpenFileExplorer,
}: TerminalPaneProps) {
const isAppVisible = useAppVisible(); const isAppVisible = useAppVisible();
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme]); const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme]);
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView); const mobileView = usePanelStore((state) => state.mobileView);
const openAgentList = usePanelStore((state) => state.openAgentList); const showMobileAgentList = usePanelStore((state) => state.showMobileAgentList);
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
const swipeGesturesEnabled = isMobile && mobileView === "agent"; const swipeGesturesEnabled = isMobile && mobileView === "agent";
const { shift: keyboardShift, style: keyboardPaddingStyle } = useKeyboardShiftStyle({ const { shift: keyboardShift, style: keyboardPaddingStyle } = useKeyboardShiftStyle({
mode: "padding", mode: "padding",
@@ -131,7 +137,7 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
}, []); }, []);
useEffect(() => { useEffect(() => {
if (isMobile || !isScreenFocused || !isPaneFocused || !terminalId) { if (isMobile || !isWorkspaceFocused || !isPaneFocused || !terminalId) {
lastAutoFocusKeyRef.current = null; lastAutoFocusKeyRef.current = null;
return; return;
} }
@@ -143,14 +149,14 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
lastAutoFocusKeyRef.current = nextFocusKey; lastAutoFocusKeyRef.current = nextFocusKey;
requestTerminalFocus(); requestTerminalFocus();
}, [isMobile, isPaneFocused, isScreenFocused, requestTerminalFocus, scopeKey, terminalId]); }, [isMobile, isPaneFocused, isWorkspaceFocused, requestTerminalFocus, scopeKey, terminalId]);
useEffect(() => { useEffect(() => {
if (isPaneFocused && isScreenFocused && isAppVisible && terminalId) { if (isPaneFocused && isWorkspaceFocused && isAppVisible && terminalId) {
lastReportedSizeRef.current = null; lastReportedSizeRef.current = null;
requestTerminalReflow(); requestTerminalReflow();
} }
}, [isAppVisible, isPaneFocused, isScreenFocused, requestTerminalReflow, terminalId]); }, [isAppVisible, isPaneFocused, isWorkspaceFocused, requestTerminalReflow, terminalId]);
const clearKeyboardRefitTimeouts = useCallback(() => { const clearKeyboardRefitTimeouts = useCallback(() => {
if (keyboardRefitTimeoutsRef.current.length === 0) { if (keyboardRefitTimeoutsRef.current.length === 0) {
@@ -187,29 +193,27 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
[pulseKeyboardRefits], [pulseKeyboardRefits],
); );
useFocusEffect( useEffect(() => {
useCallback(() => { if (!isWorkspaceFocused || !terminalId) {
if (!terminalId) { return;
return; }
} // Focus transitions can temporarily report stale dimensions.
// Navigation transitions can temporarily report stale dimensions. // Pulse forced refits so xterm fills the pane when returning to a terminal.
// Pulse forced refits so xterm fills the pane when returning to an agent. const timeoutHandles = TERMINAL_REFIT_DELAYS_MS.map((delayMs) =>
const timeoutHandles = TERMINAL_REFIT_DELAYS_MS.map((delayMs) => setTimeout(() => {
setTimeout(() => { requestTerminalReflow();
requestTerminalReflow(); }, delayMs),
}, delayMs), );
);
return () => { return () => {
for (const handle of timeoutHandles) { for (const handle of timeoutHandles) {
clearTimeout(handle); clearTimeout(handle);
} }
}; };
}, [requestTerminalReflow, terminalId]), }, [isWorkspaceFocused, requestTerminalReflow, terminalId]);
);
useEffect(() => { useEffect(() => {
if (!client || !isConnected || !isScreenFocused) { if (!client || !isConnected || !isWorkspaceFocused) {
return; return;
} }
@@ -232,7 +236,7 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
}); });
setModifiers({ ...EMPTY_MODIFIERS }); setModifiers({ ...EMPTY_MODIFIERS });
}); });
}, [client, isConnected, workspaceTerminalSession.snapshots]); }, [client, isConnected, isWorkspaceFocused, workspaceTerminalSession.snapshots]);
useEffect(() => { useEffect(() => {
lastReportedSizeRef.current = null; lastReportedSizeRef.current = null;
@@ -257,14 +261,14 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
client, client,
getPreferredSize: () => lastReportedSizeRef.current, getPreferredSize: () => lastReportedSizeRef.current,
onOutput: ({ terminalId, text }) => { onOutput: ({ terminalId, text }) => {
if (!isScreenFocused || terminalIdRef.current !== terminalId) { if (!isWorkspaceFocused || terminalIdRef.current !== terminalId) {
return; return;
} }
emulatorRef.current?.writeOutput(text); emulatorRef.current?.writeOutput(text);
}, },
onSnapshot: ({ terminalId, state }) => { onSnapshot: ({ terminalId, state }) => {
workspaceTerminalSession.snapshots.set({ terminalId, state }); workspaceTerminalSession.snapshots.set({ terminalId, state });
if (!isScreenFocused || terminalIdRef.current !== terminalId) { if (!isWorkspaceFocused || terminalIdRef.current !== terminalId) {
return; return;
} }
emulatorRef.current?.renderSnapshot(state); emulatorRef.current?.renderSnapshot(state);
@@ -274,7 +278,7 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
streamControllerRef.current = controller; streamControllerRef.current = controller;
controller.setTerminal({ controller.setTerminal({
terminalId: isScreenFocused ? terminalIdRef.current : null, terminalId: isWorkspaceFocused ? terminalIdRef.current : null,
}); });
return () => { return () => {
@@ -287,17 +291,17 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
client, client,
handleStreamControllerStatus, handleStreamControllerStatus,
isConnected, isConnected,
isScreenFocused, isWorkspaceFocused,
workspaceTerminalSession.snapshots, workspaceTerminalSession.snapshots,
]); ]);
useEffect(() => { useEffect(() => {
pendingTerminalInputRef.current = []; pendingTerminalInputRef.current = [];
const nextTerminalId = isScreenFocused ? terminalId : null; const nextTerminalId = isWorkspaceFocused ? terminalId : null;
streamControllerRef.current?.setTerminal({ streamControllerRef.current?.setTerminal({
terminalId: nextTerminalId, terminalId: nextTerminalId,
}); });
}, [isScreenFocused, terminalId]); }, [isWorkspaceFocused, terminalId]);
const enqueuePendingTerminalInput = useCallback((entry: PendingTerminalInput) => { const enqueuePendingTerminalInput = useCallback((entry: PendingTerminalInput) => {
const queue = pendingTerminalInputRef.current; const queue = pendingTerminalInputRef.current;
@@ -477,7 +481,7 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
!client || !client ||
!terminalId || !terminalId ||
!isPaneFocused || !isPaneFocused ||
!isScreenFocused || !isWorkspaceFocused ||
!isAppVisible || !isAppVisible ||
rows <= 0 || rows <= 0 ||
cols <= 0 cols <= 0
@@ -550,7 +554,7 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
return ( return (
<Animated.View style={[styles.container, keyboardPaddingStyle]}> <Animated.View style={[styles.container, keyboardPaddingStyle]}>
<View style={styles.outputContainer}> <View style={styles.outputContainer}>
{isScreenFocused ? ( {isWorkspaceFocused ? (
<View style={styles.terminalGestureContainer}> <View style={styles.terminalGestureContainer}>
<TerminalEmulator <TerminalEmulator
ref={emulatorRef} ref={emulatorRef}
@@ -573,13 +577,13 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
if (!swipeGesturesEnabled) { if (!swipeGesturesEnabled) {
return; return;
} }
openAgentList(); showMobileAgentList();
}} }}
onSwipeLeft={() => { onSwipeLeft={() => {
if (!swipeGesturesEnabled) { if (!swipeGesturesEnabled) {
return; return;
} }
openFileExplorer(); onOpenFileExplorer();
}} }}
onInput={handleTerminalData} onInput={handleTerminalData}
onResize={handleTerminalResize} onResize={handleTerminalResize}
@@ -594,7 +598,7 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
<View style={styles.terminalGestureContainer} /> <View style={styles.terminalGestureContainer} />
)} )}
{isAttaching && isScreenFocused ? ( {isAttaching && isWorkspaceFocused ? (
<View style={styles.attachOverlay} pointerEvents="none" testID="terminal-attach-loading"> <View style={styles.attachOverlay} pointerEvents="none" testID="terminal-attach-loading">
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} /> <ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
</View> </View>
@@ -3,13 +3,16 @@ import { View, Text, Pressable } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import Animated from "react-native-reanimated"; import Animated from "react-native-reanimated";
import { import {
BottomSheetModal,
BottomSheetScrollView, BottomSheetScrollView,
BottomSheetBackdrop, BottomSheetBackdrop,
BottomSheetBackgroundProps, BottomSheetBackgroundProps,
} from "@gorhom/bottom-sheet"; } from "@gorhom/bottom-sheet";
import { X } from "lucide-react-native"; import { X } from "lucide-react-native";
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types"; import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
import {
IsolatedBottomSheetModal,
type IsolatedBottomSheetModalRef,
} from "@/components/ui/isolated-bottom-sheet-modal";
import { resolveToolCallIcon } from "@/utils/tool-call-icon"; import { resolveToolCallIcon } from "@/utils/tool-call-icon";
import { ToolCallDetailsContent } from "./tool-call-details"; import { ToolCallDetailsContent } from "./tool-call-details";
@@ -60,18 +63,25 @@ interface ToolCallSheetProviderProps {
export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps) { export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const bottomSheetRef = useRef<BottomSheetModal>(null); const bottomSheetRef = useRef<IsolatedBottomSheetModalRef>(null);
const hasPresentedRef = useRef(false);
const [sheetData, setSheetData] = React.useState<ToolCallSheetData | null>(null); const [sheetData, setSheetData] = React.useState<ToolCallSheetData | null>(null);
const snapPoints = useMemo(() => ["60%", "95%"], []); const snapPoints = useMemo(() => ["60%", "95%"], []);
const openToolCall = useCallback((data: ToolCallSheetData) => { const openToolCall = useCallback((data: ToolCallSheetData) => {
setSheetData(data); setSheetData(data);
if (hasPresentedRef.current) {
requestAnimationFrame(() => bottomSheetRef.current?.snapToIndex(0));
return;
}
hasPresentedRef.current = true;
bottomSheetRef.current?.present(); bottomSheetRef.current?.present();
}, []); }, []);
const closeToolCall = useCallback(() => { const closeToolCall = useCallback(() => {
bottomSheetRef.current?.dismiss(); bottomSheetRef.current?.close();
}, []); }, []);
const handleSheetChange = useCallback((index: number) => { const handleSheetChange = useCallback((index: number) => {
@@ -95,11 +105,10 @@ export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps)
return ( return (
<ToolCallSheetContext.Provider value={contextValue}> <ToolCallSheetContext.Provider value={contextValue}>
{children} {children}
<BottomSheetModal <IsolatedBottomSheetModal
ref={bottomSheetRef} ref={bottomSheetRef}
snapPoints={snapPoints} snapPoints={snapPoints}
index={0} index={0}
stackBehavior="replace"
enableDynamicSizing={false} enableDynamicSizing={false}
onChange={handleSheetChange} onChange={handleSheetChange}
backdropComponent={renderBackdrop} backdropComponent={renderBackdrop}
@@ -108,7 +117,7 @@ export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps)
handleIndicatorStyle={{ backgroundColor: theme.colors.palette.zinc[600] }} handleIndicatorStyle={{ backgroundColor: theme.colors.palette.zinc[600] }}
> >
{sheetData && <ToolCallSheetContent data={sheetData} onClose={closeToolCall} />} {sheetData && <ToolCallSheetContent data={sheetData} onClose={closeToolCall} />}
</BottomSheetModal> </IsolatedBottomSheetModal>
</ToolCallSheetContext.Provider> </ToolCallSheetContext.Provider>
); );
} }
+15 -39
View File
@@ -14,7 +14,6 @@ import {
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { import {
BottomSheetModal,
BottomSheetScrollView, BottomSheetScrollView,
BottomSheetBackdrop, BottomSheetBackdrop,
BottomSheetTextInput, BottomSheetTextInput,
@@ -38,6 +37,10 @@ import {
} from "./combobox-options"; } from "./combobox-options";
import type { ComboboxOptionModel } from "./combobox-options"; import type { ComboboxOptionModel } from "./combobox-options";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import {
IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility,
} from "./isolated-bottom-sheet-modal";
const IS_WEB = isWeb; const IS_WEB = isWeb;
@@ -66,8 +69,6 @@ export interface ComboboxProps {
title?: string; title?: string;
open?: boolean; open?: boolean;
onOpenChange?: (open: boolean) => void; onOpenChange?: (open: boolean) => void;
enableDismissOnClose?: boolean;
stackBehavior?: "push" | "switch" | "replace";
desktopPlacement?: "top-start" | "bottom-start"; desktopPlacement?: "top-start" | "bottom-start";
/** /**
* Prevents an initial frame at 0,0 by hiding desktop content until floating * Prevents an initial frame at 0,0 by hiding desktop content until floating
@@ -271,8 +272,6 @@ export function Combobox({
title = "Select", title = "Select",
open, open,
onOpenChange, onOpenChange,
enableDismissOnClose,
stackBehavior,
desktopPlacement = "top-start", desktopPlacement = "top-start",
desktopPreventInitialFlash = true, desktopPreventInitialFlash = true,
desktopMinWidth, desktopMinWidth,
@@ -284,11 +283,10 @@ export function Combobox({
}: ComboboxProps): ReactElement { }: ComboboxProps): ReactElement {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const titleColor = theme.colors.foreground;
const effectiveOptionsPosition = isMobile ? "below-search" : optionsPosition; const effectiveOptionsPosition = isMobile ? "below-search" : optionsPosition;
const isDesktopAboveSearch = !isMobile && isWeb && effectiveOptionsPosition === "above-search"; const isDesktopAboveSearch = !isMobile && isWeb && effectiveOptionsPosition === "above-search";
const { height: windowHeight, width: windowWidth } = useWindowDimensions(); const { height: windowHeight, width: windowWidth } = useWindowDimensions();
const bottomSheetRef = useRef<BottomSheetModal>(null);
const hasPresentedBottomSheetRef = useRef(false);
const snapPoints = useMemo(() => ["60%", "90%"], []); const snapPoints = useMemo(() => ["60%", "90%"], []);
const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>( const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>(
null, null,
@@ -474,32 +472,11 @@ export function Combobox({
} }
: floatingStyles; : floatingStyles;
useEffect(() => { const { sheetRef: bottomSheetRef, handleSheetChange } = useIsolatedBottomSheetVisibility({
if (!isMobile) return; visible: isOpen,
if (isOpen) { isEnabled: isMobile,
if (enableDismissOnClose === false && hasPresentedBottomSheetRef.current) { onClose: handleClose,
bottomSheetRef.current?.snapToIndex(0); });
} else {
hasPresentedBottomSheetRef.current = true;
bottomSheetRef.current?.present();
}
} else {
if (enableDismissOnClose === false) {
bottomSheetRef.current?.close();
} else {
bottomSheetRef.current?.dismiss();
}
}
}, [enableDismissOnClose, isOpen, isMobile]);
const handleSheetChange = useCallback(
(index: number) => {
if (index === -1) {
handleClose();
}
},
[handleClose],
);
const renderBackdrop = useCallback( const renderBackdrop = useCallback(
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => ( (props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
@@ -718,7 +695,7 @@ export function Combobox({
if (isMobile) { if (isMobile) {
return ( return (
<BottomSheetModal <IsolatedBottomSheetModal
ref={bottomSheetRef} ref={bottomSheetRef}
snapPoints={snapPoints} snapPoints={snapPoints}
index={0} index={0}
@@ -726,15 +703,15 @@ export function Combobox({
onChange={handleSheetChange} onChange={handleSheetChange}
backdropComponent={renderBackdrop} backdropComponent={renderBackdrop}
enablePanDownToClose enablePanDownToClose
enableDismissOnClose={enableDismissOnClose}
stackBehavior={stackBehavior}
backgroundComponent={ComboboxSheetBackground} backgroundComponent={ComboboxSheetBackground}
handleIndicatorStyle={{ backgroundColor: theme.colors.palette.zinc[600] }} handleIndicatorStyle={{ backgroundColor: theme.colors.palette.zinc[600] }}
keyboardBehavior="extend" keyboardBehavior="extend"
keyboardBlurBehavior="restore" keyboardBlurBehavior="restore"
> >
<View style={styles.bottomSheetHeader}> <View style={styles.bottomSheetHeader}>
<Text style={[styles.comboboxTitle, { color: theme.colors.foreground }]}>{title}</Text> <Text key={titleColor} style={[styles.comboboxTitle, { color: titleColor }]}>
{title}
</Text>
</View> </View>
{stickyHeader} {stickyHeader}
{!children && searchable ? searchInput : null} {!children && searchable ? searchInput : null}
@@ -745,7 +722,7 @@ export function Combobox({
> >
{content} {content}
</BottomSheetScrollView> </BottomSheetScrollView>
</BottomSheetModal> </IsolatedBottomSheetModal>
); );
} }
@@ -931,7 +908,6 @@ const styles = StyleSheet.create((theme) => ({
comboboxTitle: { comboboxTitle: {
fontSize: theme.fontSize.lg, fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.medium, fontWeight: theme.fontWeight.medium,
color: theme.colors.foreground,
textAlign: "left", textAlign: "left",
}, },
comboboxScrollContent: { comboboxScrollContent: {
+12 -21
View File
@@ -30,8 +30,12 @@ import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { Check, CheckCircle } from "lucide-react-native"; import { Check, CheckCircle } from "lucide-react-native";
import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from "@gorhom/bottom-sheet"; import { BottomSheetBackdrop, BottomSheetScrollView } from "@gorhom/bottom-sheet";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import {
IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility,
} from "@/components/ui/isolated-bottom-sheet-modal";
import { isWeb, isNative } from "@/constants/platform"; import { isWeb, isNative } from "@/constants/platform";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
@@ -354,7 +358,6 @@ export function ContextMenuContent({
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const useMobileSheet = isMobile && mobileMode === "sheet"; const useMobileSheet = isMobile && mobileMode === "sheet";
const { open, setOpen, triggerRef, anchorRect } = context; const { open, setOpen, triggerRef, anchorRect } = context;
const bottomSheetRef = useRef<BottomSheetModal>(null);
const sheetSnapPoints = useMemo(() => ["30%", "55%"], []); const sheetSnapPoints = useMemo(() => ["30%", "55%"], []);
const [triggerRect, setTriggerRect] = useState<Rect | null>(null); const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null); const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
@@ -364,23 +367,11 @@ export function ContextMenuContent({
setOpen(false); setOpen(false);
}, [setOpen]); }, [setOpen]);
useEffect(() => { const { sheetRef: bottomSheetRef, handleSheetChange } = useIsolatedBottomSheetVisibility({
if (!useMobileSheet) return; visible: open,
if (open) { isEnabled: useMobileSheet,
bottomSheetRef.current?.present(); onClose: handleClose,
return; });
}
bottomSheetRef.current?.dismiss();
}, [open, useMobileSheet]);
const handleSheetChange = useCallback(
(index: number) => {
if (index === -1) {
handleClose();
}
},
[handleClose],
);
const renderSheetBackdrop = useCallback( const renderSheetBackdrop = useCallback(
(props: ComponentProps<typeof BottomSheetBackdrop>) => ( (props: ComponentProps<typeof BottomSheetBackdrop>) => (
@@ -468,7 +459,7 @@ export function ContextMenuContent({
if (useMobileSheet) { if (useMobileSheet) {
return ( return (
<ContextMenuContext.Provider value={context}> <ContextMenuContext.Provider value={context}>
<BottomSheetModal <IsolatedBottomSheetModal
ref={bottomSheetRef} ref={bottomSheetRef}
index={0} index={0}
snapPoints={sheetSnapPoints} snapPoints={sheetSnapPoints}
@@ -495,7 +486,7 @@ export function ContextMenuContent({
> >
{children} {children}
</BottomSheetScrollView> </BottomSheetScrollView>
</BottomSheetModal> </IsolatedBottomSheetModal>
</ContextMenuContext.Provider> </ContextMenuContext.Provider>
); );
} }
@@ -0,0 +1,136 @@
// @vitest-environment jsdom
import { cleanup, render } from "@testing-library/react";
import React from "react";
import type { ReactNode } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility,
} from "./isolated-bottom-sheet-modal";
const { modalMethods, modalProps } = vi.hoisted(() => ({
modalMethods: {
present: vi.fn(),
close: vi.fn(),
snapToIndex: vi.fn(),
dismiss: vi.fn(),
},
modalProps: vi.fn(),
}));
vi.mock("@gorhom/bottom-sheet", async () => {
const React = await import("react");
const MockBottomSheetModal = React.forwardRef(
(props: Record<string, unknown>, ref: React.ForwardedRef<unknown>) => {
modalProps(props);
React.useImperativeHandle(ref, () => modalMethods);
return React.createElement(
"div",
{ "data-testid": "bottom-sheet" },
props.children as ReactNode,
);
},
);
return {
BottomSheetModal: MockBottomSheetModal,
BottomSheetModalProvider: ({ children }: { children: ReactNode }) =>
React.createElement("div", { "data-testid": "bottom-sheet-provider" }, children),
};
});
vi.mock("@gorhom/portal", async () => {
const React = await import("react");
return {
Portal: ({ children, hostName }: { children: ReactNode; hostName?: string }) =>
React.createElement("div", { "data-host": hostName, "data-testid": "app-portal" }, children),
};
});
function Harness({ visible, onClose }: { visible: boolean; onClose: () => void }) {
const { sheetRef, handleSheetChange } = useIsolatedBottomSheetVisibility({
visible,
onClose,
});
return (
<IsolatedBottomSheetModal
ref={sheetRef}
index={0}
snapPoints={["50%"]}
onChange={handleSheetChange}
>
<div>Sheet content</div>
</IsolatedBottomSheetModal>
);
}
describe("IsolatedBottomSheetModal", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it("forces sheet isolation and keeps modal content mounted while hidden", () => {
const onClose = vi.fn();
const { getByTestId, rerender } = render(<Harness visible={false} onClose={onClose} />);
expect(getByTestId("app-portal").getAttribute("data-host")).toBe("root");
expect(modalProps).toHaveBeenLastCalledWith(
expect.objectContaining({
enableDismissOnClose: false,
stackBehavior: "replace",
}),
);
expect(modalMethods.present).not.toHaveBeenCalled();
rerender(<Harness visible onClose={onClose} />);
expect(modalMethods.present).toHaveBeenCalledTimes(1);
rerender(<Harness visible={false} onClose={onClose} />);
expect(modalMethods.close).toHaveBeenCalledTimes(1);
expect(modalMethods.dismiss).not.toHaveBeenCalled();
rerender(<Harness visible onClose={onClose} />);
expect(modalMethods.present).toHaveBeenCalledTimes(1);
expect(modalMethods.snapToIndex).toHaveBeenCalledWith(0);
});
it("only reports a user close when the sheet was visible", () => {
const onClose = vi.fn();
const { rerender } = render(<Harness visible onClose={onClose} />);
const latestProps = modalProps.mock.lastCall?.[0] as { onChange: (index: number) => void };
latestProps.onChange(-1);
expect(onClose).toHaveBeenCalledTimes(1);
rerender(<Harness visible={false} onClose={onClose} />);
const closedProps = modalProps.mock.lastCall?.[0] as { onChange: (index: number) => void };
closedProps.onChange(-1);
expect(onClose).toHaveBeenCalledTimes(1);
});
it("allows nested sheets inside a parent sheet without creating a sibling provider", () => {
const { getAllByTestId } = render(
<IsolatedBottomSheetModal index={0} snapPoints={["90%"]}>
<IsolatedBottomSheetModal index={0} snapPoints={["60%"]} onChange={() => {}}>
<div>Nested model picker</div>
</IsolatedBottomSheetModal>
</IsolatedBottomSheetModal>,
);
expect(getAllByTestId("bottom-sheet-provider")).toHaveLength(1);
expect(modalProps).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
stackBehavior: "replace",
}),
);
expect(modalProps).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
stackBehavior: "push",
}),
);
});
});
@@ -0,0 +1,106 @@
import {
BottomSheetModal as GorhomBottomSheetModal,
BottomSheetModalProvider,
type BottomSheetModalProps,
} from "@gorhom/bottom-sheet";
import { Portal } from "@gorhom/portal";
import React, { createContext, useContext } from "react";
import { forwardRef, useCallback, useEffect, useRef } from "react";
import type { ElementRef } from "react";
type GorhomBottomSheetModalMethods = ElementRef<typeof GorhomBottomSheetModal>;
type IsolatedBottomSheetModalProps = Omit<
BottomSheetModalProps,
"enableDismissOnClose" | "stackBehavior"
>;
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 modal = (
<GorhomBottomSheetModal
{...bottomSheetProps}
ref={ref}
enableDismissOnClose={false}
stackBehavior={isNestedSheet ? "push" : "replace"}
>
{scopedChildren}
</GorhomBottomSheetModal>
);
if (isNestedSheet) {
return modal;
}
return (
<Portal hostName="root">
<BottomSheetModalProvider>{modal}</BottomSheetModalProvider>
</Portal>
);
});
export function useIsolatedBottomSheetVisibility({
visible,
isEnabled,
onClose,
}: {
visible: boolean;
isEnabled?: boolean;
onClose: () => void;
}) {
const sheetRef = useRef<IsolatedBottomSheetModalRef>(null);
const hasPresentedRef = useRef(false);
useEffect(() => {
if (isEnabled === false) return;
if (visible) {
if (hasPresentedRef.current) {
sheetRef.current?.snapToIndex(0);
return;
}
hasPresentedRef.current = true;
sheetRef.current?.present();
return;
}
if (hasPresentedRef.current) {
sheetRef.current?.close();
}
}, [isEnabled, visible]);
const handleSheetChange = useCallback(
(index: number) => {
if (index === -1 && visible) {
onClose();
}
},
[onClose, visible],
);
return {
sheetRef,
handleSheetChange,
};
}
@@ -0,0 +1,10 @@
import { ActivityIndicator, type ActivityIndicatorProps } from "react-native";
interface LoadingSpinnerProps {
color: string;
size?: ActivityIndicatorProps["size"];
}
export function LoadingSpinner({ color, size = "small" }: LoadingSpinnerProps) {
return <ActivityIndicator size={size} color={color} />;
}
@@ -35,12 +35,13 @@ export function SegmentedControl<T extends string>({
testID, testID,
}: SegmentedControlProps<T>) { }: SegmentedControlProps<T>) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const containerSizeStyle = size === "sm" ? styles.containerSm : styles.containerMd;
const segmentSizeStyle = size === "sm" ? styles.segmentSm : styles.segmentMd; const segmentSizeStyle = size === "sm" ? styles.segmentSm : styles.segmentMd;
const labelSizeStyle = size === "sm" ? styles.labelSm : styles.labelMd; const labelSizeStyle = size === "sm" ? styles.labelSm : styles.labelMd;
const iconSize = size === "sm" ? theme.iconSize.sm : theme.iconSize.md; const iconSize = size === "sm" ? theme.iconSize.sm : theme.iconSize.md;
return ( return (
<View style={[styles.container, style]} testID={testID}> <View style={[styles.container, containerSizeStyle, style]} testID={testID}>
{options.map((option) => { {options.map((option) => {
const isSelected = option.value === value; const isSelected = option.value === value;
const iconColor = isSelected ? theme.colors.foreground : theme.colors.foregroundMuted; const iconColor = isSelected ? theme.colors.foreground : theme.colors.foregroundMuted;
@@ -89,39 +90,46 @@ export function SegmentedControl<T extends string>({
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
container: { container: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "stretch",
maxWidth: "100%", maxWidth: "100%",
gap: 4, backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
},
containerSm: {
padding: 2,
},
containerMd: {
padding: 3,
}, },
segment: { segment: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
flexShrink: 0, flexShrink: 0,
borderRadius: theme.borderRadius.full, borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: "transparent",
gap: theme.spacing[1], gap: theme.spacing[1],
}, },
segmentSm: { segmentSm: {
paddingVertical: theme.spacing[1], paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[2], paddingHorizontal: theme.spacing[4],
}, },
segmentMd: { segmentMd: {
paddingVertical: theme.spacing[2], paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[3], paddingHorizontal: theme.spacing[6],
}, },
segmentSelected: { segmentSelected: {
backgroundColor: theme.colors.surface3, backgroundColor: theme.colors.surface0,
borderColor: theme.colors.border, shadowColor: "#000",
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.08,
shadowRadius: 2,
elevation: 1,
}, },
segmentHover: { segmentHover: {
backgroundColor: theme.colors.surface3, backgroundColor: theme.colors.surface1,
borderColor: theme.colors.borderAccent,
}, },
segmentPressed: { segmentPressed: {
backgroundColor: theme.colors.surface3, backgroundColor: theme.colors.surface1,
borderColor: theme.colors.borderAccent,
}, },
segmentDisabled: { segmentDisabled: {
opacity: theme.opacity[50], opacity: theme.opacity[50],
@@ -135,10 +143,10 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
}, },
labelSm: { labelSm: {
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.sm,
}, },
labelMd: { labelMd: {
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.base,
}, },
labelSelected: { labelSelected: {
color: theme.colors.foreground, color: theme.colors.foreground,
+97 -93
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import { Pressable, Text, View, ScrollView } from "react-native"; import { Pressable, Text, View, ScrollView } from "react-native";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { QrCode, Link2, ClipboardPaste, ExternalLink, Settings } from "lucide-react-native"; import { QrCode, Link2, ClipboardPaste, ExternalLink, Settings } from "lucide-react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { HostProfile } from "@/types/host-connection"; import type { HostProfile } from "@/types/host-connection";
@@ -21,8 +21,6 @@ import { PaseoLogo } from "@/components/icons/paseo-logo";
import { openExternalUrl } from "@/utils/open-external-url"; import { openExternalUrl } from "@/utils/open-external-url";
import { isWeb, isNative } from "@/constants/platform"; import { isWeb, isNative } from "@/constants/platform";
const ThemedScrollView = withUnistyles(ScrollView);
type WelcomeAction = { type WelcomeAction = {
key: "scan-qr" | "direct-connection" | "paste-pairing-link"; key: "scan-qr" | "direct-connection" | "paste-pairing-link";
label: string; label: string;
@@ -33,13 +31,15 @@ type WelcomeAction = {
}; };
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
scrollView: { root: {
flex: 1, flex: 1,
backgroundColor: theme.colors.surface0, backgroundColor: theme.colors.surface0,
}, },
scrollView: {
flex: 1,
},
container: { container: {
flexGrow: 1, flexGrow: 1,
backgroundColor: theme.colors.surface0,
padding: theme.spacing[6], padding: theme.spacing[6],
paddingBottom: 0, paddingBottom: 0,
alignItems: "center", alignItems: "center",
@@ -309,97 +309,101 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
const showHostList = hosts.length > 0 && !anyOnlineServerId; const showHostList = hosts.length > 0 && !anyOnlineServerId;
return ( return (
<ThemedScrollView <View style={styles.root}>
style={styles.scrollView} <ScrollView
contentContainerStyle={[ style={styles.scrollView}
styles.container, contentContainerStyle={[
{ paddingBottom: theme.spacing[6] + insets.bottom }, styles.container,
]} { paddingBottom: theme.spacing[6] + insets.bottom },
showsVerticalScrollIndicator={false} ]}
testID="welcome-screen" showsVerticalScrollIndicator={false}
> testID="welcome-screen"
<View style={styles.content}> >
<PaseoLogo size={96} /> <View style={styles.content}>
<View style={styles.copyBlock}> <PaseoLogo size={96} />
<Text style={styles.title}>Welcome to Paseo</Text> <View style={styles.copyBlock}>
{showHostList ? ( <Text style={styles.title}>Welcome to Paseo</Text>
<Text style={styles.subtitle}>Connecting to your hosts…</Text> {showHostList ? (
) : ( <Text style={styles.subtitle}>Connecting to your hosts…</Text>
<> ) : (
<Text style={styles.subtitle}>Connect your computer to get started</Text> <>
{isNative ? ( <Text style={styles.subtitle}>Connect your computer to get started</Text>
<Pressable {isNative ? (
style={styles.setupLink} <Pressable
onPress={() => openExternalUrl("https://paseo.sh")} style={styles.setupLink}
> onPress={() => openExternalUrl("https://paseo.sh")}
<Text style={styles.setupLinkText}>paseo.sh</Text> >
<ExternalLink size={14} color={theme.colors.accent} /> <Text style={styles.setupLinkText}>paseo.sh</Text>
</Pressable> <ExternalLink size={14} color={theme.colors.accent} />
) : null} </Pressable>
</> ) : null}
)} </>
</View> )}
<View style={styles.actions}>
{actions.map((action) => {
const Icon = action.icon;
return (
<Pressable
key={action.key}
style={[styles.actionButton, action.primary ? styles.actionButtonPrimary : null]}
onPress={action.onPress}
testID={action.testID}
>
<Icon
size={18}
color={action.primary ? theme.colors.accentForeground : theme.colors.foreground}
/>
<Text style={[styles.actionText, action.primary ? styles.actionTextPrimary : null]}>
{action.label}
</Text>
</Pressable>
);
})}
</View>
{showHostList && (
<View style={styles.hostList}>
{hosts.map((host) => (
<HostStatusRow key={host.serverId} serverId={host.serverId} label={host.label} />
))}
</View> </View>
)}
<Button <View style={styles.actions}>
variant="ghost" {actions.map((action) => {
size="sm" const Icon = action.icon;
leftIcon={Settings} return (
onPress={() => router.push("/settings")} <Pressable
style={styles.settingsButton} key={action.key}
testID="welcome-open-settings" style={[styles.actionButton, action.primary ? styles.actionButtonPrimary : null]}
> onPress={action.onPress}
Settings testID={action.testID}
</Button> >
</View> <Icon
<Text style={styles.versionLabel}>{appVersionText}</Text> size={18}
color={action.primary ? theme.colors.accentForeground : theme.colors.foreground}
/>
<Text
style={[styles.actionText, action.primary ? styles.actionTextPrimary : null]}
>
{action.label}
</Text>
</Pressable>
);
})}
</View>
<AddHostModal {showHostList && (
visible={isDirectOpen} <View style={styles.hostList}>
onClose={() => setIsDirectOpen(false)} {hosts.map((host) => (
onSaved={({ profile, serverId }) => { <HostStatusRow key={host.serverId} serverId={host.serverId} label={host.label} />
onHostAdded?.(profile); ))}
finishOnboarding(serverId); </View>
}} )}
/>
<PairLinkModal <Button
visible={isPasteLinkOpen} variant="ghost"
onClose={() => setIsPasteLinkOpen(false)} size="sm"
onSaved={({ profile, serverId }) => { leftIcon={Settings}
onHostAdded?.(profile); onPress={() => router.push("/settings")}
finishOnboarding(serverId); style={styles.settingsButton}
}} testID="welcome-open-settings"
/> >
</ThemedScrollView> Settings
</Button>
</View>
<Text style={styles.versionLabel}>{appVersionText}</Text>
<AddHostModal
visible={isDirectOpen}
onClose={() => setIsDirectOpen(false)}
onSaved={({ profile, serverId }) => {
onHostAdded?.(profile);
finishOnboarding(serverId);
}}
/>
<PairLinkModal
visible={isPasteLinkOpen}
onClose={() => setIsPasteLinkOpen(false)}
onSaved={({ profile, serverId }) => {
onHostAdded?.(profile);
finishOnboarding(serverId);
}}
/>
</ScrollView>
</View>
); );
} }
@@ -306,14 +306,24 @@ function WorkspaceHoverCardContent({
badgeLabel = `${checks.length} passed`; badgeLabel = `${checks.length} passed`;
} }
const iconColor = hovered
? theme.colors.foreground
: theme.colors.foregroundMuted;
return ( return (
<> <>
{hovered ? ( {hovered ? (
<ExternalLink size={12} color={theme.colors.foregroundMuted} /> <ExternalLink size={12} color={iconColor} />
) : ( ) : (
<GitHubIcon size={12} color={theme.colors.foregroundMuted} /> <GitHubIcon size={12} color={iconColor} />
)} )}
<Text style={styles.checksSummaryLabel}>Checks</Text> <Text
style={[
styles.checksSummaryLabel,
hovered && styles.checksSummaryLabelHovered,
]}
>
Checks
</Text>
<View style={styles.checksSummaryCounts}> <View style={styles.checksSummaryCounts}>
<View style={[styles.checksDot, { backgroundColor: badgeColor }]} /> <View style={[styles.checksDot, { backgroundColor: badgeColor }]} />
<Text style={[styles.checksStatusText, { color: badgeColor }]}> <Text style={[styles.checksStatusText, { color: badgeColor }]}>
@@ -395,6 +405,9 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
}, },
checksSummaryLabelHovered: {
color: theme.colors.foreground,
},
checksSummaryCounts: { checksSummaryCounts: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
@@ -179,6 +179,9 @@ export function WorkspaceSetupDialog() {
if (!composerState) { if (!composerState) {
throw new Error("Workspace setup composer state is required"); throw new Error("Workspace setup composer state is required");
} }
if (!composerState.selectedProvider) {
throw new Error("Select a model");
}
const wirePayload = splitComposerAttachmentsForSubmit(attachments); const wirePayload = splitComposerAttachmentsForSubmit(attachments);
const encodedImages = await encodeImages(wirePayload.images); const encodedImages = await encodeImages(wirePayload.images);
@@ -0,0 +1,108 @@
/**
* @vitest-environment jsdom
*/
import React from "react";
import { act } from "@testing-library/react";
import type { DaemonClient } from "@server/client/daemon-client";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import { WorkspaceShortcutTargetsSubscriber } from "./workspace-shortcut-targets-subscriber";
vi.hoisted(() => {
(globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
});
function workspaceDescriptor(input: {
id: string;
name?: string;
projectId?: string;
}): WorkspaceDescriptor {
return {
id: input.id,
projectId: input.projectId ?? "project-1",
projectDisplayName: "Project 1",
projectRootPath: "/repo/main",
workspaceDirectory: `/repo/main/${input.id}`,
projectKind: "git",
workspaceKind: "worktree",
name: input.name ?? input.id,
status: "done",
diffStat: null,
scripts: [],
};
}
describe("WorkspaceShortcutTargetsSubscriber", () => {
let root: Root | null = null;
let container: HTMLElement | null = null;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
useKeyboardShortcutsStore.setState({
sidebarShortcutWorkspaceTargets: [],
});
useSidebarCollapsedSectionsStore.setState({
collapsedProjectKeys: new Set(),
});
useSidebarOrderStore.setState({
projectOrderByServerId: {},
workspaceOrderByServerAndProject: {},
});
act(() => {
useSessionStore.getState().initializeSession("srv", null as unknown as DaemonClient);
useSessionStore.getState().setWorkspaces(
"srv",
new Map([
["ws-1", workspaceDescriptor({ id: "ws-1", name: "Workspace 1" })],
["ws-2", workspaceDescriptor({ id: "ws-2", name: "Workspace 2" })],
]),
);
useSessionStore.getState().setHasHydratedWorkspaces("srv", true);
});
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container?.remove();
container = null;
act(() => {
useSessionStore.getState().clearSession("srv");
});
});
it("publishes workspace shortcut targets without rendering the sidebar", async () => {
await act(async () => {
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={true} serverId="srv" />);
});
expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([
{ serverId: "srv", workspaceId: "ws-1" },
{ serverId: "srv", workspaceId: "ws-2" },
]);
});
it("clears targets when disabled", async () => {
await act(async () => {
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={true} serverId="srv" />);
});
await act(async () => {
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={false} serverId="srv" />);
});
expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([]);
});
});
@@ -0,0 +1,47 @@
import { useEffect, useMemo } from "react";
import { useSidebarWorkspacesList } from "@/hooks/use-sidebar-workspaces-list";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
import { buildSidebarShortcutModel } from "@/utils/sidebar-shortcuts";
export function WorkspaceShortcutTargetsSubscriber({
enabled,
serverId,
}: {
enabled: boolean;
serverId: string | null;
}) {
const { projects } = useSidebarWorkspacesList({ serverId, enabled });
const collapsedProjectKeys = useSidebarCollapsedSectionsStore(
(state) => state.collapsedProjectKeys,
);
const setSidebarShortcutWorkspaceTargets = useKeyboardShortcutsStore(
(state) => state.setSidebarShortcutWorkspaceTargets,
);
const shortcutModel = useMemo(
() =>
buildSidebarShortcutModel({
projects,
collapsedProjectKeys,
}),
[collapsedProjectKeys, projects],
);
useEffect(() => {
if (!enabled || !serverId) {
setSidebarShortcutWorkspaceTargets([]);
return;
}
setSidebarShortcutWorkspaceTargets(shortcutModel.shortcutTargets);
}, [enabled, serverId, setSidebarShortcutWorkspaceTargets, shortcutModel.shortcutTargets]);
useEffect(() => {
return () => {
setSidebarShortcutWorkspaceTargets([]);
};
}, [setSidebarShortcutWorkspaceTargets]);
return null;
}
+4
View File
@@ -10,6 +10,7 @@ import { isElectronRuntime, isElectronRuntimeMac } from "@/desktop/host";
// Default is cross-platform. Gate only when you must: // Default is cross-platform. Gate only when you must:
// isWeb → DOM APIs (document, window, <div>, addEventListener) // isWeb → DOM APIs (document, window, <div>, addEventListener)
// isNative → Native-only APIs (Haptics, StatusBar, push tokens, camera) // isNative → Native-only APIs (Haptics, StatusBar, push tokens, camera)
// isDev → Development-only diagnostics and instrumentation
// isElectron → Desktop wrapper features (file dialogs, titlebar, updates) // isElectron → Desktop wrapper features (file dialogs, titlebar, updates)
// //
// For layout decisions, use useIsCompactFormFactor() from constants/layout.ts. // For layout decisions, use useIsCompactFormFactor() from constants/layout.ts.
@@ -22,6 +23,9 @@ export const isWeb = Platform.OS === "web";
/** iOS or Android — the JS runtime is React Native. */ /** iOS or Android — the JS runtime is React Native. */
export const isNative = Platform.OS !== "web"; export const isNative = Platform.OS !== "web";
/** Development build/runtime — true in Metro dev bundles, false in production. */
export const isDev = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Electron detection (cached — only caches `true`, keeps checking if false // Electron detection (cached — only caches `true`, keeps checking if false
// because the desktop bridge may load after initial module evaluation) // because the desktop bridge may load after initial module evaluation)
@@ -1,9 +1,17 @@
import { createContext, useContext, useEffect, useRef, type ReactNode } from "react"; import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
type ReactNode,
} from "react";
import { useWindowDimensions } from "react-native"; import { useWindowDimensions } from "react-native";
import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated"; import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated";
import { type GestureType } from "react-native-gesture-handler"; import { type GestureType } from "react-native-gesture-handler";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { usePanelStore } from "@/stores/panel-store"; import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
import { import {
getRightSidebarAnimationTargets, getRightSidebarAnimationTargets,
shouldSyncSidebarAnimation, shouldSyncSidebarAnimation,
@@ -30,11 +38,9 @@ const ExplorerSidebarAnimationContext = createContext<ExplorerSidebarAnimationCo
export function ExplorerSidebarAnimationProvider({ children }: { children: ReactNode }) { export function ExplorerSidebarAnimationProvider({ children }: { children: ReactNode }) {
const { width: windowWidth } = useWindowDimensions(); const { width: windowWidth } = useWindowDimensions();
const isCompactLayout = useIsCompactFormFactor(); const isCompactLayout = useIsCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView); const isOpen = usePanelStore((state) =>
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); selectIsFileExplorerOpen(state, { isCompact: isCompactLayout }),
);
// Derive isOpen from the unified panel state
const isOpen = isCompactLayout ? mobileView === "file-explorer" : desktopFileExplorerOpen;
// Right sidebar: closed = +windowWidth (off-screen right), open = 0 // Right sidebar: closed = +windowWidth (off-screen right), open = 0
const initialTargets = getRightSidebarAnimationTargets({ isOpen, windowWidth }); const initialTargets = getRightSidebarAnimationTargets({ isOpen, windowWidth });
@@ -93,7 +99,7 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
backdropOpacity.value = targets.backdropOpacity; backdropOpacity.value = targets.backdropOpacity;
}, [isOpen, translateX, backdropOpacity, windowWidth, isGesturing]); }, [isOpen, translateX, backdropOpacity, windowWidth, isGesturing]);
const animateToOpen = () => { const animateToOpen = useCallback(() => {
"worklet"; "worklet";
translateX.value = withTiming(0, { translateX.value = withTiming(0, {
duration: ANIMATION_DURATION, duration: ANIMATION_DURATION,
@@ -103,9 +109,9 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
duration: ANIMATION_DURATION, duration: ANIMATION_DURATION,
easing: ANIMATION_EASING, easing: ANIMATION_EASING,
}); });
}; }, [translateX, backdropOpacity]);
const animateToClose = () => { const animateToClose = useCallback(() => {
"worklet"; "worklet";
translateX.value = withTiming(windowWidth, { translateX.value = withTiming(windowWidth, {
duration: ANIMATION_DURATION, duration: ANIMATION_DURATION,
@@ -115,22 +121,25 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
duration: ANIMATION_DURATION, duration: ANIMATION_DURATION,
easing: ANIMATION_EASING, easing: ANIMATION_EASING,
}); });
}; }, [translateX, backdropOpacity, windowWidth]);
const value = useMemo<ExplorerSidebarAnimationContextValue>(
() => ({
translateX,
backdropOpacity,
windowWidth,
animateToOpen,
animateToClose,
isGesturing,
gestureAnimatingRef,
openGestureRef,
closeGestureRef,
}),
[translateX, backdropOpacity, windowWidth, animateToOpen, animateToClose, isGesturing],
);
return ( return (
<ExplorerSidebarAnimationContext.Provider <ExplorerSidebarAnimationContext.Provider value={value}>
value={{
translateX,
backdropOpacity,
windowWidth,
animateToOpen,
animateToClose,
isGesturing,
gestureAnimatingRef,
openGestureRef,
closeGestureRef,
}}
>
{children} {children}
</ExplorerSidebarAnimationContext.Provider> </ExplorerSidebarAnimationContext.Provider>
); );
@@ -34,7 +34,6 @@ import {
type Agent, type Agent,
type SessionState, type SessionState,
type WorkspaceDescriptor, type WorkspaceDescriptor,
mergeWorkspaceSnapshotWithExisting,
normalizeWorkspaceDescriptor, normalizeWorkspaceDescriptor,
} from "@/stores/session-store"; } from "@/stores/session-store";
import { useDraftStore } from "@/stores/draft-store"; import { useDraftStore } from "@/stores/draft-store";
@@ -348,7 +347,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
} }
const workspaces = new Map<string, WorkspaceDescriptor>(); const workspaces = new Map<string, WorkspaceDescriptor>();
const existingWorkspaces = useSessionStore.getState().sessions[serverId]?.workspaces;
let cursor: string | null = null; let cursor: string | null = null;
let includeSubscribe = options?.subscribe ?? false; let includeSubscribe = options?.subscribe ?? false;
@@ -364,13 +362,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
for (const entry of payload.entries) { for (const entry of payload.entries) {
const workspace = normalizeWorkspaceDescriptor(entry); const workspace = normalizeWorkspaceDescriptor(entry);
workspaces.set( workspaces.set(workspace.id, workspace);
workspace.id,
mergeWorkspaceSnapshotWithExisting({
incoming: workspace,
existing: existingWorkspaces?.get(workspace.id),
}),
);
} }
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) { if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
@@ -11,7 +11,7 @@ import { useWindowDimensions } from "react-native";
import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated"; import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated";
import { type GestureType } from "react-native-gesture-handler"; import { type GestureType } from "react-native-gesture-handler";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { usePanelStore } from "@/stores/panel-store"; import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
import { import {
getLeftSidebarAnimationTargets, getLeftSidebarAnimationTargets,
shouldSyncSidebarAnimation, shouldSyncSidebarAnimation,
@@ -37,11 +37,9 @@ const SidebarAnimationContext = createContext<SidebarAnimationContextValue | nul
export function SidebarAnimationProvider({ children }: { children: ReactNode }) { export function SidebarAnimationProvider({ children }: { children: ReactNode }) {
const { width: windowWidth } = useWindowDimensions(); const { width: windowWidth } = useWindowDimensions();
const isCompactLayout = useIsCompactFormFactor(); const isCompactLayout = useIsCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView); const isOpen = usePanelStore((state) =>
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen); selectIsAgentListOpen(state, { isCompact: isCompactLayout }),
);
// Derive isOpen from the unified panel state
const isOpen = isCompactLayout ? mobileView === "agent-list" : desktopAgentListOpen;
// Initialize based on current state // Initialize based on current state
const initialTargets = getLeftSidebarAnimationTargets({ isOpen, windowWidth }); const initialTargets = getLeftSidebarAnimationTargets({ isOpen, windowWidth });
@@ -17,6 +17,8 @@ export interface DesktopAppUpdateInstallResult {
message: string; message: string;
} }
export type DesktopReleaseChannel = "stable" | "beta";
export interface LocalDaemonUpdateResult { export interface LocalDaemonUpdateResult {
exitCode: number; exitCode: number;
stdout: string; stdout: string;
@@ -69,8 +71,12 @@ export async function getLocalDaemonVersion(): Promise<LocalDaemonVersionResult>
return parseLocalDaemonVersionResult(result); return parseLocalDaemonVersionResult(result);
} }
export async function checkDesktopAppUpdate(): Promise<DesktopAppUpdateCheckResult> { export async function checkDesktopAppUpdate({
const result = await invokeDesktopCommand<unknown>("check_app_update"); releaseChannel,
}: {
releaseChannel: DesktopReleaseChannel;
}): Promise<DesktopAppUpdateCheckResult> {
const result = await invokeDesktopCommand<unknown>("check_app_update", { releaseChannel });
if (!isRecord(result)) { if (!isRecord(result)) {
throw new Error("Unexpected response while checking desktop updates."); throw new Error("Unexpected response while checking desktop updates.");
} }
@@ -85,8 +91,12 @@ export async function checkDesktopAppUpdate(): Promise<DesktopAppUpdateCheckResu
}; };
} }
export async function installDesktopAppUpdate(): Promise<DesktopAppUpdateInstallResult> { export async function installDesktopAppUpdate({
const result = await invokeDesktopCommand<unknown>("install_app_update"); releaseChannel,
}: {
releaseChannel: DesktopReleaseChannel;
}): Promise<DesktopAppUpdateInstallResult> {
const result = await invokeDesktopCommand<unknown>("install_app_update", { releaseChannel });
if (!isRecord(result)) { if (!isRecord(result)) {
throw new Error("Unexpected response while installing desktop update."); throw new Error("Unexpected response while installing desktop update.");
} }
@@ -7,6 +7,7 @@ import {
type DesktopAppUpdateCheckResult, type DesktopAppUpdateCheckResult,
type DesktopAppUpdateInstallResult, type DesktopAppUpdateInstallResult,
} from "@/desktop/updates/desktop-updates"; } from "@/desktop/updates/desktop-updates";
import { useAppSettings } from "@/hooks/use-settings";
export type DesktopAppUpdateStatus = export type DesktopAppUpdateStatus =
| "idle" | "idle"
@@ -83,6 +84,8 @@ function formatStatusText(input: {
export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn { export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
const isDesktopApp = shouldShowDesktopUpdateSection(); const isDesktopApp = shouldShowDesktopUpdateSection();
const { settings } = useAppSettings();
const releaseChannel = settings.releaseChannel;
const requestVersionRef = useRef(0); const requestVersionRef = useRef(0);
const [status, setStatus] = useState<DesktopAppUpdateStatus>("idle"); const [status, setStatus] = useState<DesktopAppUpdateStatus>("idle");
const [availableUpdate, setAvailableUpdate] = useState<DesktopAppUpdateCheckResult | null>(null); const [availableUpdate, setAvailableUpdate] = useState<DesktopAppUpdateCheckResult | null>(null);
@@ -105,7 +108,7 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
setErrorMessage(null); setErrorMessage(null);
try { try {
const result = await checkDesktopAppUpdate(); const result = await checkDesktopAppUpdate({ releaseChannel });
if (requestVersion !== requestVersionRef.current) { if (requestVersion !== requestVersionRef.current) {
return result; return result;
} }
@@ -140,9 +143,17 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
return null; return null;
} }
}, },
[isDesktopApp], [isDesktopApp, releaseChannel],
); );
useEffect(() => {
if (!isDesktopApp) {
return;
}
void checkForUpdates({ silent: true });
}, [checkForUpdates, isDesktopApp]);
useEffect(() => { useEffect(() => {
if (!isDesktopApp || status !== "pending") { if (!isDesktopApp || status !== "pending") {
return undefined; return undefined;
@@ -166,7 +177,7 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
setErrorMessage(null); setErrorMessage(null);
try { try {
const result = await installDesktopAppUpdate(); const result = await installDesktopAppUpdate({ releaseChannel });
setLastCheckedAt(Date.now()); setLastCheckedAt(Date.now());
if (result.installed) { if (result.installed) {
@@ -186,7 +197,7 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
setErrorMessage(message); setErrorMessage(message);
return null; return null;
} }
}, [isDesktopApp]); }, [isDesktopApp, releaseChannel]);
return { return {
isDesktopApp, isDesktopApp,
@@ -0,0 +1,15 @@
import { useMemo } from "react";
import { usePathname } from "expo-router";
import { useHosts } from "@/runtime/host-runtime";
import { resolveActiveHost } from "@/utils/active-host";
export function useActiveHost() {
const pathname = usePathname();
const hosts = useHosts();
return useMemo(() => resolveActiveHost({ hosts, pathname }), [hosts, pathname]);
}
export function useActiveServerId(): string | null {
return useActiveHost()?.serverId ?? null;
}
@@ -0,0 +1,209 @@
import { renderHook, waitFor } from "@testing-library/react";
import { JSDOM } from "jsdom";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { AgentProvider, ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
import type { FormPreferences } from "./use-form-preferences";
import { useAgentFormState } from "./use-agent-form-state";
const { mocks } = vi.hoisted(() => ({
mocks: {
preferences: {} as FormPreferences,
isPreferencesLoading: false,
snapshotEntries: [] as ProviderSnapshotEntry[],
updatePreferences: vi.fn(),
refreshSnapshot: vi.fn(),
refetchSnapshotIfStale: vi.fn(),
},
}));
vi.mock("@/runtime/host-runtime", () => ({
useHosts: () => [{ serverId: "host-1" }],
}));
vi.mock("./use-form-preferences", async (importOriginal) => {
const actual = await importOriginal<typeof import("./use-form-preferences")>();
return {
...actual,
useFormPreferences: () => ({
preferences: mocks.preferences,
isLoading: mocks.isPreferencesLoading,
updatePreferences: mocks.updatePreferences,
}),
};
});
vi.mock("./use-providers-snapshot", () => ({
useProvidersSnapshot: () => ({
entries: mocks.snapshotEntries,
isLoading: false,
isFetching: false,
isRefreshing: false,
error: null,
supportsSnapshot: true,
refresh: mocks.refreshSnapshot,
refetchIfStale: mocks.refetchSnapshotIfStale,
}),
}));
beforeAll(() => {
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
value: true,
configurable: true,
writable: true,
});
});
describe("useAgentFormState live preference hydration", () => {
beforeEach(() => {
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: "http://localhost",
});
Object.defineProperty(globalThis, "document", {
value: dom.window.document,
configurable: true,
});
Object.defineProperty(globalThis, "window", {
value: dom.window,
configurable: true,
});
Object.defineProperty(globalThis, "navigator", {
value: dom.window.navigator,
configurable: true,
});
mocks.preferences = {};
mocks.isPreferencesLoading = false;
mocks.snapshotEntries = [
{
provider: "codex",
status: "ready",
label: "Codex",
description: "Codex test provider",
defaultModeId: "auto",
modes: [
{ id: "auto", label: "Auto", icon: "ShieldAlert", colorTier: "moderate" },
{
id: "full-access",
label: "Full Access",
icon: "ShieldAlert",
colorTier: "dangerous",
},
],
models: [
{
provider: "codex",
id: "gpt-5.4",
label: "gpt-5.4",
isDefault: true,
defaultThinkingOptionId: "low",
thinkingOptions: [
{ id: "low", label: "Low" },
{ id: "xhigh", label: "XHigh" },
],
},
],
},
{
provider: "claude",
status: "ready",
label: "Claude",
description: "Claude test provider",
defaultModeId: "default",
modes: [{ id: "default", label: "Default", icon: "ShieldCheck", colorTier: "safe" }],
models: [
{
provider: "claude",
id: "claude-sonnet-4-6",
label: "Sonnet 4.6",
isDefault: true,
},
],
},
];
mocks.updatePreferences.mockReset();
mocks.refreshSnapshot.mockReset();
mocks.refetchSnapshotIfStale.mockReset();
});
it("hydrates from stored preferences once and ignores later preference writes from other composers", async () => {
mocks.preferences = {
provider: "codex",
providerPreferences: {
codex: {
model: "gpt-5.4",
mode: "full-access",
thinkingByModel: {
"gpt-5.4": "xhigh",
},
},
},
};
const { result, rerender } = renderHook(() =>
useAgentFormState({
initialServerId: "host-1",
isVisible: true,
onlineServerIds: ["host-1"],
}),
);
await waitFor(() => {
expect(result.current.selectedProvider).toBe("codex");
expect(result.current.selectedModel).toBe("gpt-5.4");
expect(result.current.selectedMode).toBe("full-access");
expect(result.current.selectedThinkingOptionId).toBe("xhigh");
});
mocks.preferences = {
provider: "claude",
providerPreferences: {
claude: {
model: "claude-sonnet-4-6",
mode: "default",
},
codex: {
model: "gpt-5.4",
mode: "auto",
thinkingByModel: {
"gpt-5.4": "low",
},
},
},
};
rerender();
await waitFor(() => {
expect(result.current.selectedProvider).toBe("codex");
expect(result.current.selectedModel).toBe("gpt-5.4");
expect(result.current.selectedMode).toBe("full-access");
expect(result.current.selectedThinkingOptionId).toBe("xhigh");
});
});
it("uses the latest preferences when a separate composer hydrates later", async () => {
mocks.preferences = {
provider: "claude",
providerPreferences: {
claude: {
model: "claude-sonnet-4-6",
mode: "default",
},
},
};
const { result } = renderHook(() =>
useAgentFormState({
initialServerId: "host-1",
isVisible: true,
onlineServerIds: ["host-1"],
}),
);
await waitFor(() => {
expect(result.current.selectedProvider).toBe("claude" as AgentProvider);
expect(result.current.selectedModel).toBe("claude-sonnet-4-6");
expect(result.current.selectedMode).toBe("default");
});
});
});
@@ -146,6 +146,112 @@ describe("useAgentFormState", () => {
}); });
}); });
describe("__private__.mergeSelectedComposerPreferences", () => {
it("stores the selected model for the selected provider", () => {
expect(
__private__.mergeSelectedComposerPreferences({
preferences: {},
provider: "codex",
updates: {
model: "gpt-5.4",
},
}),
).toEqual({
provider: "codex",
providerPreferences: {
codex: {
model: "gpt-5.4",
},
},
});
});
it("preserves existing provider preferences when the selected model changes", () => {
expect(
__private__.mergeSelectedComposerPreferences({
preferences: {
provider: "claude",
providerPreferences: {
codex: {
mode: "full-access",
thinkingByModel: {
"gpt-5.4-mini": "medium",
},
featureValues: {
fast_mode: true,
},
},
claude: {
model: "claude-sonnet-4-6",
},
},
favoriteModels: [{ provider: "codex", modelId: "gpt-5.4-mini" }],
},
provider: "codex",
updates: {
model: "gpt-5.4",
},
}),
).toEqual({
provider: "codex",
providerPreferences: {
codex: {
model: "gpt-5.4",
mode: "full-access",
thinkingByModel: {
"gpt-5.4-mini": "medium",
},
featureValues: {
fast_mode: true,
},
},
claude: {
model: "claude-sonnet-4-6",
},
},
favoriteModels: [{ provider: "codex", modelId: "gpt-5.4-mini" }],
});
});
it("stores mode and thinking preferences without dropping the selected model", () => {
expect(
__private__.mergeSelectedComposerPreferences({
preferences: {
provider: "codex",
providerPreferences: {
codex: {
model: "gpt-5.4",
mode: "auto",
thinkingByModel: {
"gpt-5.4-mini": "low",
},
},
},
},
provider: "codex",
updates: {
mode: "full-access",
thinkingByModel: {
"gpt-5.4": "xhigh",
},
},
}),
).toEqual({
provider: "codex",
providerPreferences: {
codex: {
model: "gpt-5.4",
mode: "full-access",
thinkingByModel: {
"gpt-5.4-mini": "low",
"gpt-5.4": "xhigh",
},
},
},
});
});
});
describe("__private__.resolveFormState", () => { describe("__private__.resolveFormState", () => {
const codexModels: AgentModelDefinition[] = [ const codexModels: AgentModelDefinition[] = [
{ {
@@ -161,6 +267,37 @@ describe("useAgentFormState", () => {
}, },
]; ];
it("keeps provider, mode, and model unset on first open without preferences or explicit values", () => {
const resolved = __private__.resolveFormState(
undefined,
{},
null,
{
serverId: false,
provider: false,
modeId: false,
model: false,
thinkingOptionId: false,
workingDir: false,
},
{
serverId: null,
provider: null,
modeId: "",
model: "",
thinkingOptionId: "",
workingDir: "",
},
new Set<string>(),
makeProviderMap(TEST_CLAUDE_DEFINITION, TEST_CODEX_DEFINITION),
);
expect(resolved.provider).toBeNull();
expect(resolved.modeId).toBe("");
expect(resolved.model).toBe("");
expect(resolved.thinkingOptionId).toBe("");
});
it("does not auto-select a model on fresh drafts without preferences", () => { it("does not auto-select a model on fresh drafts without preferences", () => {
const resolved = __private__.resolveFormState( const resolved = __private__.resolveFormState(
undefined, undefined,
@@ -374,7 +511,7 @@ describe("useAgentFormState", () => {
expect(resolved.thinkingOptionId).toBe("low"); expect(resolved.thinkingOptionId).toBe("low");
}); });
it("resolves provider only from allowed provider map", () => { it("clears an invalid provider instead of falling back to the first allowed provider", () => {
const resolved = __private__.resolveFormState( const resolved = __private__.resolveFormState(
undefined, undefined,
{ provider: "codex" }, { provider: "codex" },
@@ -399,7 +536,121 @@ describe("useAgentFormState", () => {
claudeProviderMap, claudeProviderMap,
); );
expect(resolved.provider).toBe("claude"); expect(resolved.provider).toBeNull();
});
it("preserves a user-selected provider and model while that provider is loading during refresh", () => {
const loadingEntries: ProviderSnapshotEntry[] = [
{
provider: "codex",
status: "loading",
label: TEST_CODEX_DEFINITION.label,
description: TEST_CODEX_DEFINITION.description,
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
modes: TEST_CODEX_DEFINITION.modes,
},
{
provider: "claude",
status: "ready",
label: TEST_CLAUDE_DEFINITION.label,
description: TEST_CLAUDE_DEFINITION.description,
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
modes: TEST_CLAUDE_DEFINITION.modes,
models: [{ provider: "claude", id: "default", label: "Default", isDefault: true }],
},
];
const providerDefinitions = buildProviderDefinitions(loadingEntries);
const resolvableProviderMap = __private__.buildProviderDefinitionMapForStatuses({
snapshotEntries: loadingEntries,
providerDefinitions,
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready", "loading"]),
});
const resolved = __private__.resolveFormState(
undefined,
{},
null,
{
serverId: false,
provider: true,
modeId: true,
model: true,
thinkingOptionId: true,
workingDir: false,
},
{
serverId: null,
provider: "codex",
modeId: "full-access",
model: "gpt-5.3-codex",
thinkingOptionId: "xhigh",
workingDir: "",
},
new Set<string>(),
resolvableProviderMap,
);
expect(resolved.provider).toBe("codex");
expect(resolved.modeId).toBe("full-access");
expect(resolved.model).toBe("gpt-5.3-codex");
expect(resolved.thinkingOptionId).toBe("xhigh");
});
it("clears a user-selected provider when the refreshed snapshot marks it unavailable", () => {
const unavailableEntries: ProviderSnapshotEntry[] = [
{
provider: "codex",
status: "unavailable",
label: TEST_CODEX_DEFINITION.label,
description: TEST_CODEX_DEFINITION.description,
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
modes: TEST_CODEX_DEFINITION.modes,
},
{
provider: "claude",
status: "ready",
label: TEST_CLAUDE_DEFINITION.label,
description: TEST_CLAUDE_DEFINITION.description,
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
modes: TEST_CLAUDE_DEFINITION.modes,
models: [{ provider: "claude", id: "default", label: "Default", isDefault: true }],
},
];
const providerDefinitions = buildProviderDefinitions(unavailableEntries);
const resolvableProviderMap = __private__.buildProviderDefinitionMapForStatuses({
snapshotEntries: unavailableEntries,
providerDefinitions,
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready", "loading"]),
});
const resolved = __private__.resolveFormState(
undefined,
{},
null,
{
serverId: false,
provider: true,
modeId: false,
model: false,
thinkingOptionId: false,
workingDir: false,
},
{
serverId: null,
provider: "codex",
modeId: "full-access",
model: "gpt-5.3-codex",
thinkingOptionId: "xhigh",
workingDir: "",
},
new Set<string>(),
resolvableProviderMap,
);
expect(resolved.provider).toBeNull();
expect(resolved.modeId).toBe("");
expect(resolved.model).toBe("");
expect(resolved.thinkingOptionId).toBe("");
}); });
it("does not force fallback provider when allowed provider map is empty", () => { it("does not force fallback provider when allowed provider map is empty", () => {
+184 -58
View File
@@ -48,7 +48,7 @@ const INITIAL_USER_MODIFIED: UserModifiedFields = {
// Internal form state // Internal form state
interface FormState { interface FormState {
serverId: string | null; serverId: string | null;
provider: AgentProvider; provider: AgentProvider | null;
modeId: string; modeId: string;
model: string; model: string;
thinkingOptionId: string; thinkingOptionId: string;
@@ -68,7 +68,7 @@ export type UseAgentFormStateResult = {
selectedServerId: string | null; selectedServerId: string | null;
setSelectedServerId: (value: string | null) => void; setSelectedServerId: (value: string | null) => void;
setSelectedServerIdFromUser: (value: string | null) => void; setSelectedServerIdFromUser: (value: string | null) => void;
selectedProvider: AgentProvider; selectedProvider: AgentProvider | null;
setProviderFromUser: (provider: AgentProvider) => void; setProviderFromUser: (provider: AgentProvider) => void;
selectedMode: string; selectedMode: string;
setModeFromUser: (modeId: string) => void; setModeFromUser: (modeId: string) => void;
@@ -97,9 +97,6 @@ export type UseAgentFormStateResult = {
persistFormPreferences: () => Promise<void>; persistFormPreferences: () => Promise<void>;
}; };
const DEFAULT_PROVIDER: AgentProvider = "claude";
const DEFAULT_MODE_FOR_DEFAULT_PROVIDER = "default";
function normalizeSelectedModelId(modelId: string | null | undefined): string { function normalizeSelectedModelId(modelId: string | null | undefined): string {
const normalized = typeof modelId === "string" ? modelId.trim() : ""; const normalized = typeof modelId === "string" ? modelId.trim() : "";
if (!normalized) { if (!normalized) {
@@ -160,6 +157,18 @@ function resolveThinkingOptionId(args: {
return effectiveModel?.defaultThinkingOptionId ?? thinkingOptions[0]?.id ?? ""; return effectiveModel?.defaultThinkingOptionId ?? thinkingOptions[0]?.id ?? "";
} }
function mergeSelectedComposerPreferences(args: {
preferences: FormPreferences;
provider: AgentProvider;
updates: Partial<ProviderPreferences>;
}): FormPreferences {
return mergeProviderPreferences({
preferences: args.preferences,
provider: args.provider,
updates: args.updates,
});
}
/** /**
* Pure function that resolves form state from multiple data sources. * Pure function that resolves form state from multiple data sources.
* Priority: explicit (URL params) > provider defaults > lightweight app prefs > fallback * Priority: explicit (URL params) > provider defaults > lightweight app prefs > fallback
@@ -177,8 +186,6 @@ function resolveFormState(
): FormState { ): FormState {
// Start with current state - we only update non-user-modified fields // Start with current state - we only update non-user-modified fields
const result = { ...currentState }; const result = { ...currentState };
const fallbackProvider = allowedProviderMap.keys().next().value as AgentProvider | undefined;
// 1. Resolve provider first (other fields depend on it) // 1. Resolve provider first (other fields depend on it)
if (!userModified.provider) { if (!userModified.provider) {
if (initialValues?.provider && allowedProviderMap.has(initialValues.provider)) { if (initialValues?.provider && allowedProviderMap.has(initialValues.provider)) {
@@ -188,22 +195,33 @@ function resolveFormState(
allowedProviderMap.has(preferences.provider as AgentProvider) allowedProviderMap.has(preferences.provider as AgentProvider)
) { ) {
result.provider = preferences.provider as AgentProvider; result.provider = preferences.provider as AgentProvider;
} else if (!allowedProviderMap.has(result.provider) && fallbackProvider) { } else if (
result.provider = fallbackProvider; result.provider &&
allowedProviderMap.size > 0 &&
!allowedProviderMap.has(result.provider)
) {
result.provider = null;
} }
// else keep current (initialized to DEFAULT_PROVIDER) } else if (
} else if (!allowedProviderMap.has(result.provider) && fallbackProvider) { result.provider &&
result.provider = fallbackProvider; allowedProviderMap.size > 0 &&
!allowedProviderMap.has(result.provider)
) {
result.provider = null;
} }
const providerDef = allowedProviderMap.get(result.provider); const providerDef = result.provider ? allowedProviderMap.get(result.provider) : undefined;
const providerPrefs = preferences?.providerPreferences?.[result.provider]; const providerPrefs = result.provider
? preferences?.providerPreferences?.[result.provider]
: undefined;
// 2. Resolve modeId (depends on provider) // 2. Resolve modeId (depends on provider)
if (!userModified.modeId) { if (!userModified.modeId) {
const validModeIds = providerDef?.modes.map((m) => m.id) ?? []; const validModeIds = providerDef?.modes.map((m) => m.id) ?? [];
if ( if (!result.provider) {
result.modeId = "";
} else if (
typeof initialValues?.modeId === "string" && typeof initialValues?.modeId === "string" &&
initialValues.modeId.length > 0 && initialValues.modeId.length > 0 &&
validModeIds.includes(initialValues.modeId) validModeIds.includes(initialValues.modeId)
@@ -223,7 +241,9 @@ function resolveFormState(
const preferredModel = normalizeSelectedModelId(providerPrefs?.model); const preferredModel = normalizeSelectedModelId(providerPrefs?.model);
const defaultModelId = resolveDefaultModelId(availableModels); const defaultModelId = resolveDefaultModelId(availableModels);
if (initialModel) { if (!result.provider) {
result.model = "";
} else if (initialModel) {
if (!availableModels || isValidModel(initialModel)) { if (!availableModels || isValidModel(initialModel)) {
result.model = initialModel; result.model = initialModel;
} else { } else {
@@ -246,7 +266,9 @@ function resolveFormState(
? initialValues.thinkingOptionId.trim() ? initialValues.thinkingOptionId.trim()
: ""; : "";
if (!userModified.thinkingOptionId) { if (!result.provider) {
result.thinkingOptionId = "";
} else if (!userModified.thinkingOptionId) {
const effectiveModelId = result.model.trim(); const effectiveModelId = result.model.trim();
const preferredThinking = effectiveModelId const preferredThinking = effectiveModelId
? (providerPrefs?.thinkingByModel?.[effectiveModelId]?.trim() ?? "") ? (providerPrefs?.thinkingByModel?.[effectiveModelId]?.trim() ?? "")
@@ -262,7 +284,7 @@ function resolveFormState(
} }
// Validate thinking option once model metadata is available. // Validate thinking option once model metadata is available.
if (availableModels) { if (result.provider && availableModels) {
result.thinkingOptionId = resolveThinkingOptionId({ result.thinkingOptionId = resolveThinkingOptionId({
availableModels, availableModels,
modelId: result.model, modelId: result.model,
@@ -312,6 +334,37 @@ function combineInitialValues(
return initialValues; return initialValues;
} }
const RESOLVABLE_PROVIDER_STATUSES = new Set<ProviderSnapshotEntry["status"]>(["ready", "loading"]);
const SELECTABLE_PROVIDER_STATUSES = new Set<ProviderSnapshotEntry["status"]>(["ready"]);
function buildProviderDefinitionMap(
providerDefinitions: AgentProviderDefinition[],
): Map<AgentProvider, AgentProviderDefinition> {
return new Map<AgentProvider, AgentProviderDefinition>(
providerDefinitions.map((definition) => [definition.id, definition]),
);
}
function buildProviderDefinitionMapForStatuses(args: {
snapshotEntries: ProviderSnapshotEntry[] | undefined;
providerDefinitions: AgentProviderDefinition[];
statuses: ReadonlySet<ProviderSnapshotEntry["status"]>;
}): Map<AgentProvider, AgentProviderDefinition> {
if (!args.snapshotEntries?.length) {
return buildProviderDefinitionMap(args.providerDefinitions);
}
const matchingProviders = new Set(
args.snapshotEntries
.filter((entry) => args.statuses.has(entry.status))
.map((entry) => entry.provider),
);
return buildProviderDefinitionMap(
args.providerDefinitions.filter((definition) => matchingProviders.has(definition.id)),
);
}
export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAgentFormStateResult { export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAgentFormStateResult {
const { const {
initialServerId = null, initialServerId = null,
@@ -335,8 +388,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
// Form state // Form state
const [formState, setFormState] = useState<FormState>(() => ({ const [formState, setFormState] = useState<FormState>(() => ({
serverId: initialServerId, serverId: initialServerId,
provider: DEFAULT_PROVIDER, provider: null,
modeId: DEFAULT_MODE_FOR_DEFAULT_PROVIDER, modeId: "",
model: "", model: "",
thinkingOptionId: "", thinkingOptionId: "",
workingDir: "", workingDir: "",
@@ -348,12 +401,14 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
// Track if we've done initial resolution (to avoid flickering) // Track if we've done initial resolution (to avoid flickering)
const hasResolvedRef = useRef(false); const hasResolvedRef = useRef(false);
const hydrationPreferencesRef = useRef<FormPreferences | null>(null);
// Reset user modifications when form becomes invisible // Reset user modifications when form becomes invisible
useEffect(() => { useEffect(() => {
if (!isVisible) { if (!isVisible) {
setUserModified(INITIAL_USER_MODIFIED); setUserModified(INITIAL_USER_MODIFIED);
hasResolvedRef.current = false; hasResolvedRef.current = false;
hydrationPreferencesRef.current = null;
} }
}, [isVisible]); }, [isVisible]);
@@ -371,25 +426,25 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
[snapshotEntries], [snapshotEntries],
); );
const snapshotProviderDefinitionMap = useMemo( const snapshotProviderDefinitionMap = useMemo(
() => () => buildProviderDefinitionMap(snapshotProviderDefinitions),
new Map<AgentProvider, AgentProviderDefinition>(
snapshotProviderDefinitions.map((definition) => [definition.id, definition]),
),
[snapshotProviderDefinitions], [snapshotProviderDefinitions],
); );
const snapshotResolvableProviderDefinitionMap = useMemo(
() =>
buildProviderDefinitionMapForStatuses({
snapshotEntries,
providerDefinitions: snapshotProviderDefinitions,
statuses: RESOLVABLE_PROVIDER_STATUSES,
}),
[snapshotEntries, snapshotProviderDefinitions],
);
const snapshotSelectableProviderDefinitionMap = useMemo(() => { const snapshotSelectableProviderDefinitionMap = useMemo(() => {
if (!snapshotEntries?.length) { return buildProviderDefinitionMapForStatuses({
return snapshotProviderDefinitionMap; snapshotEntries,
} providerDefinitions: snapshotProviderDefinitions,
const readyProviders = new Set( statuses: SELECTABLE_PROVIDER_STATUSES,
snapshotEntries.filter((entry) => entry.status === "ready").map((entry) => entry.provider), });
); }, [snapshotEntries, snapshotProviderDefinitions]);
return new Map<AgentProvider, AgentProviderDefinition>(
snapshotProviderDefinitions
.filter((definition) => readyProviders.has(definition.id))
.map((definition) => [definition.id, definition]),
);
}, [snapshotEntries, snapshotProviderDefinitionMap, snapshotProviderDefinitions]);
const snapshotAllProviderModels = useMemo(() => { const snapshotAllProviderModels = useMemo(() => {
const map = new Map<string, AgentModelDefinition[]>(); const map = new Map<string, AgentModelDefinition[]>();
for (const entry of snapshotEntries ?? []) { for (const entry of snapshotEntries ?? []) {
@@ -398,13 +453,17 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
return map; return map;
}, [snapshotEntries]); }, [snapshotEntries]);
const snapshotSelectedEntry = useMemo( const snapshotSelectedEntry = useMemo(
() => (snapshotEntries ?? []).find((entry) => entry.provider === formState.provider) ?? null, () =>
formState.provider
? ((snapshotEntries ?? []).find((entry) => entry.provider === formState.provider) ?? null)
: null,
[formState.provider, snapshotEntries], [formState.provider, snapshotEntries],
); );
const snapshotSelectedProviderModels = snapshotSelectedEntry?.models ?? null; const snapshotSelectedProviderModels = snapshotSelectedEntry?.models ?? null;
const selectedProviderIsLoading = snapshotSelectedEntry?.status === "loading";
const snapshotSelectedProviderModes = const snapshotSelectedProviderModes =
snapshotSelectedEntry?.modes ?? snapshotSelectedEntry?.modes ??
snapshotProviderDefinitionMap.get(formState.provider)?.modes ?? (formState.provider ? snapshotProviderDefinitionMap.get(formState.provider)?.modes : []) ??
[]; [];
const providerDefinitions = snapshotProviderDefinitions; const providerDefinitions = snapshotProviderDefinitions;
const providerDefinitionMap = snapshotProviderDefinitionMap; const providerDefinitionMap = snapshotProviderDefinitionMap;
@@ -412,7 +471,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
const allProviderModels = snapshotAllProviderModels; const allProviderModels = snapshotAllProviderModels;
const availableModels = snapshotSelectedProviderModels; const availableModels = snapshotSelectedProviderModels;
const modeOptions = snapshotSelectedProviderModes; const modeOptions = snapshotSelectedProviderModes;
const isAllModelsLoading = snapshotIsLoading; const isAllModelsLoading = snapshotIsLoading || selectedProviderIsLoading;
// Combine initialValues with initialServerId for resolution // Combine initialValues with initialServerId for resolution
const combinedInitialValues = useMemo((): FormInitialValues | undefined => { const combinedInitialValues = useMemo((): FormInitialValues | undefined => {
@@ -425,19 +484,23 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
return; return;
} }
// Wait for preferences to load before first resolution, unless explicit URL overrides exist. if (isPreferencesLoading && !hasResolvedRef.current) {
if (isPreferencesLoading && !hasResolvedRef.current && !combinedInitialValues) {
return; return;
} }
if (!hasResolvedRef.current) {
hydrationPreferencesRef.current = preferences;
}
const hydrationPreferences = hydrationPreferencesRef.current ?? preferences;
const resolved = resolveFormState( const resolved = resolveFormState(
combinedInitialValues, combinedInitialValues,
preferences, hydrationPreferences,
availableModels, availableModels,
userModified, userModified,
formStateRef.current, formStateRef.current,
validServerIds, validServerIds,
selectableProviderDefinitionMap, snapshotResolvableProviderDefinitionMap,
); );
// Only update if something changed // Only update if something changed
@@ -462,7 +525,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
availableModels, availableModels,
userModified, userModified,
validServerIds, validServerIds,
selectableProviderDefinitionMap, snapshotResolvableProviderDefinitionMap,
]); ]);
// Auto-select the first online host when: // Auto-select the first online host when:
@@ -571,15 +634,38 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
thinkingOptionId: nextThinkingOptionId, thinkingOptionId: nextThinkingOptionId,
})); }));
setUserModified((prev) => ({ ...prev, provider: true, model: true })); setUserModified((prev) => ({ ...prev, provider: true, model: true }));
void updatePreferences({ provider }); void updatePreferences((current) =>
mergeSelectedComposerPreferences({
preferences: current,
provider,
updates: {
model: nextModelId || undefined,
},
}),
);
}, },
[allProviderModels, selectableProviderDefinitionMap, updatePreferences], [allProviderModels, selectableProviderDefinitionMap, updatePreferences],
); );
const setModeFromUser = useCallback((modeId: string) => { const setModeFromUser = useCallback(
setFormState((prev) => ({ ...prev, modeId })); (modeId: string) => {
setUserModified((prev) => ({ ...prev, modeId: true })); setFormState((prev) => ({ ...prev, modeId }));
}, []); setUserModified((prev) => ({ ...prev, modeId: true }));
const provider = formStateRef.current.provider;
if (provider) {
void updatePreferences((current) =>
mergeSelectedComposerPreferences({
preferences: current,
provider,
updates: {
mode: modeId || undefined,
},
}),
);
}
},
[updatePreferences],
);
const setModelFromUser = useCallback( const setModelFromUser = useCallback(
(modelId: string) => { (modelId: string) => {
@@ -598,14 +684,44 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
thinkingOptionId: nextThinkingOptionId, thinkingOptionId: nextThinkingOptionId,
})); }));
setUserModified((prev) => ({ ...prev, model: true })); setUserModified((prev) => ({ ...prev, model: true }));
const provider = formStateRef.current.provider;
if (provider) {
void updatePreferences((current) =>
mergeSelectedComposerPreferences({
preferences: current,
provider,
updates: {
model: nextModelId || undefined,
},
}),
);
}
}, },
[availableModels, userModified.thinkingOptionId], [availableModels, updatePreferences, userModified.thinkingOptionId],
); );
const setThinkingOptionFromUser = useCallback((thinkingOptionId: string) => { const setThinkingOptionFromUser = useCallback(
setFormState((prev) => ({ ...prev, thinkingOptionId })); (thinkingOptionId: string) => {
setUserModified((prev) => ({ ...prev, thinkingOptionId: true })); setFormState((prev) => ({ ...prev, thinkingOptionId }));
}, []); setUserModified((prev) => ({ ...prev, thinkingOptionId: true }));
const provider = formStateRef.current.provider;
const modelId = formStateRef.current.model;
if (provider && modelId) {
void updatePreferences((current) =>
mergeSelectedComposerPreferences({
preferences: current,
provider,
updates: {
thinkingByModel: {
[modelId]: thinkingOptionId,
},
},
}),
);
}
},
[updatePreferences],
);
const setWorkingDir = useCallback((value: string) => { const setWorkingDir = useCallback((value: string) => {
setFormState((prev) => ({ ...prev, workingDir: value })); setFormState((prev) => ({ ...prev, workingDir: value }));
@@ -625,16 +741,21 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
}, [refreshSnapshot]); }, [refreshSnapshot]);
const refetchProviderModelsIfStale = useCallback(() => { const refetchProviderModelsIfStale = useCallback(() => {
refetchSnapshotIfStale(); refetchSnapshotIfStale(formStateRef.current.provider);
}, [refetchSnapshotIfStale]); }, [refetchSnapshotIfStale]);
const persistFormPreferences = useCallback(async () => { const persistFormPreferences = useCallback(async () => {
if (!formState.provider) {
return;
}
const provider = formState.provider;
const resolvedModel = resolveEffectiveModel(availableModels, formState.model); const resolvedModel = resolveEffectiveModel(availableModels, formState.model);
const modelId = resolvedModel?.id ?? formState.model; const modelId = resolvedModel?.id ?? formState.model;
await updatePreferences((current) => await updatePreferences((current) =>
mergeProviderPreferences({ mergeProviderPreferences({
preferences: current, preferences: current,
provider: formState.provider, provider,
updates: { updates: {
model: modelId || undefined, model: modelId || undefined,
mode: formState.modeId || undefined, mode: formState.modeId || undefined,
@@ -657,11 +778,13 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
updatePreferences, updatePreferences,
]); ]);
const agentDefinition = providerDefinitionMap.get(formState.provider); const agentDefinition = formState.provider
? providerDefinitionMap.get(formState.provider)
: undefined;
const effectiveModel = resolveEffectiveModel(availableModels, formState.model); const effectiveModel = resolveEffectiveModel(availableModels, formState.model);
const resolvedModelId = effectiveModel?.id ?? formState.model; const resolvedModelId = effectiveModel?.id ?? formState.model;
const availableThinkingOptions = effectiveModel?.thinkingOptions ?? []; const availableThinkingOptions = effectiveModel?.thinkingOptions ?? [];
const isModelLoading = snapshotIsLoading; const isModelLoading = snapshotIsLoading || selectedProviderIsLoading;
const modelError = snapshotError; const modelError = snapshotError;
const workingDirIsEmpty = !formState.workingDir.trim(); const workingDirIsEmpty = !formState.workingDir.trim();
@@ -738,7 +861,10 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
export type CreateAgentInitialValues = FormInitialValues; export type CreateAgentInitialValues = FormInitialValues;
export const __private__ = { export const __private__ = {
buildProviderDefinitionMap,
buildProviderDefinitionMapForStatuses,
combineInitialValues, combineInitialValues,
mergeSelectedComposerPreferences,
resolveDefaultModel, resolveDefaultModel,
resolveFormState, resolveFormState,
resolveThinkingOptionId, resolveThinkingOptionId,
@@ -10,6 +10,7 @@ import {
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features"; import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
import { useDraftStore } from "@/stores/draft-store"; import { useDraftStore } from "@/stores/draft-store";
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types"; import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
type AttachmentUpdater = type AttachmentUpdater =
| ComposerAttachment[] | ComposerAttachment[]
@@ -113,7 +114,7 @@ function resolveEffectiveComposerThinkingOptionId(input: {
} }
function buildDraftComposerCommandConfig(input: { function buildDraftComposerCommandConfig(input: {
provider: DraftAgentStatusBarProps["selectedProvider"]; provider: AgentProvider | null;
cwd: string; cwd: string;
modeOptions: DraftAgentStatusBarProps["modeOptions"]; modeOptions: DraftAgentStatusBarProps["modeOptions"];
selectedMode: string; selectedMode: string;
@@ -122,7 +123,7 @@ function buildDraftComposerCommandConfig(input: {
featureValues?: Record<string, unknown>; featureValues?: Record<string, unknown>;
}): DraftCommandConfig | undefined { }): DraftCommandConfig | undefined {
const cwd = input.cwd.trim(); const cwd = input.cwd.trim();
if (!cwd) { if (!input.provider || !cwd) {
return undefined; return undefined;
} }
@@ -1,7 +1,7 @@
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { skipToken, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useId, useMemo } from "react"; import { useEffect, useId, useMemo } from "react";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { usePanelStore } from "@/stores/panel-store"; import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import type { SubscribeCheckoutDiffResponse } from "@server/shared/messages"; import type { SubscribeCheckoutDiffResponse } from "@server/shared/messages";
import { orderCheckoutDiffFiles } from "./checkout-diff-order"; import { orderCheckoutDiffFiles } from "./checkout-diff-order";
@@ -59,10 +59,8 @@ export function useCheckoutDiffQuery({
const client = useHostRuntimeClient(serverId); const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId); const isConnected = useHostRuntimeIsConnected(serverId);
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView);
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
const explorerTab = usePanelStore((state) => state.explorerTab); const explorerTab = usePanelStore((state) => state.explorerTab);
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen; const isOpen = usePanelStore((state) => selectIsFileExplorerOpen(state, { isCompact: isMobile }));
const hookInstanceId = useId(); const hookInstanceId = useId();
const normalizedCompare = useMemo( const normalizedCompare = useMemo(
() => normalizeCheckoutDiffCompare({ mode, baseRef, ignoreWhitespace }), () => normalizeCheckoutDiffCompare({ mode, baseRef, ignoreWhitespace }),
@@ -76,27 +74,10 @@ export function useCheckoutDiffQuery({
[serverId, cwd, mode, baseRef, compareIgnoreWhitespace], [serverId, cwd, mode, baseRef, compareIgnoreWhitespace],
); );
const query = useQuery({ const query = useQuery<CheckoutDiffQueryPayload>({
queryKey, queryKey,
queryFn: async () => { queryFn: skipToken,
if (!client) {
throw new Error("Daemon client not available");
}
const payload = await client.getCheckoutDiff(cwd, {
mode: compareMode,
baseRef: compareBaseRef,
ignoreWhitespace: compareIgnoreWhitespace,
});
return {
...payload,
files: orderCheckoutDiffFiles(payload.files),
};
},
enabled: !!client && isConnected && !!cwd && enabled,
staleTime: Infinity, staleTime: Infinity,
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
}); });
useEffect(() => { useEffect(() => {
@@ -208,9 +189,9 @@ export function useCheckoutDiffQuery({
return { return {
files: payload?.files ?? [], files: payload?.files ?? [],
payloadError, payloadError,
isLoading: query.isLoading, isLoading: payload === null && enabled && isConnected,
isFetching: query.isFetching, isFetching: false,
isError: query.isError || Boolean(payloadError), isError: Boolean(payloadError),
error: query.error, error: null,
}; };
} }
+4 -20
View File
@@ -4,7 +4,6 @@ import { router, usePathname, type Href } from "expo-router";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher"; import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
import { useHosts } from "@/runtime/host-runtime";
import { useAllAgentsList } from "@/hooks/use-all-agents-list"; import { useAllAgentsList } from "@/hooks/use-all-agents-list";
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents"; import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker"; import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
@@ -12,11 +11,7 @@ import {
clearCommandCenterFocusRestoreElement, clearCommandCenterFocusRestoreElement,
takeCommandCenterFocusRestoreElement, takeCommandCenterFocusRestoreElement,
} from "@/utils/command-center-focus-restore"; } from "@/utils/command-center-focus-restore";
import { import { buildHostAgentDetailRoute, buildSettingsRoute } from "@/utils/host-routes";
buildHostAgentDetailRoute,
buildSettingsRoute,
parseServerIdFromPathname,
} from "@/utils/host-routes";
import type { ShortcutKey } from "@/utils/format-shortcut"; import type { ShortcutKey } from "@/utils/format-shortcut";
import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string"; import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string";
import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts"; import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts";
@@ -26,6 +21,7 @@ import { getIsElectronRuntime } from "@/constants/layout";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution"; import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { focusWithRetries } from "@/utils/web-focus"; import { focusWithRetries } from "@/utils/web-focus";
import { useActiveServerId } from "@/hooks/use-active-server-id";
const EMPTY_AGENTS: AggregatedAgent[] = []; const EMPTY_AGENTS: AggregatedAgent[] = [];
const EMPTY_ACTION_ITEMS: CommandCenterActionItem[] = []; const EMPTY_ACTION_ITEMS: CommandCenterActionItem[] = [];
@@ -128,7 +124,7 @@ function resolveActionShortcutKeys(
export function useCommandCenter() { export function useCommandCenter() {
const pathname = usePathname(); const pathname = usePathname();
const daemons = useHosts(); const routeActiveServerId = useActiveServerId();
const { overrides } = useKeyboardShortcutOverrides(); const { overrides } = useKeyboardShortcutOverrides();
const open = useKeyboardShortcutsStore((s) => s.commandCenterOpen); const open = useKeyboardShortcutsStore((s) => s.commandCenterOpen);
const setOpen = useKeyboardShortcutsStore((s) => s.setCommandCenterOpen); const setOpen = useKeyboardShortcutsStore((s) => s.setCommandCenterOpen);
@@ -142,19 +138,7 @@ export function useCommandCenter() {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(0); const [activeIndex, setActiveIndex] = useState(0);
const activeServerId = useMemo(() => { const activeServerId = open ? routeActiveServerId : null;
if (!open) {
return null;
}
const serverIdFromPath = parseServerIdFromPathname(pathname);
if (serverIdFromPath) {
const routeMatch = daemons.find((entry) => entry.serverId === serverIdFromPath);
if (routeMatch) {
return routeMatch.serverId;
}
}
return daemons[0]?.serverId ?? null;
}, [daemons, open, pathname]);
const { agents } = useAllAgentsList({ const { agents } = useAllAgentsList({
serverId: activeServerId, serverId: activeServerId,
@@ -16,7 +16,7 @@ type DraftFeatureConfig = Pick<
export function useDraftAgentFeatures(input: { export function useDraftAgentFeatures(input: {
serverId: string | null | undefined; serverId: string | null | undefined;
provider: AgentProvider; provider: AgentProvider | null;
cwd: string | null | undefined; cwd: string | null | undefined;
modeId: string | null | undefined; modeId: string | null | undefined;
modelId: string | null | undefined; modelId: string | null | undefined;
@@ -28,30 +28,31 @@ export function useDraftAgentFeatures(input: {
const isConnected = useHostRuntimeIsConnected(serverId ?? ""); const isConnected = useHostRuntimeIsConnected(serverId ?? "");
const { preferences, updatePreferences } = useFormPreferences(); const { preferences, updatePreferences } = useFormPreferences();
const normalizedCwd = cwd?.trim() || ""; const normalizedCwd = cwd?.trim() || "";
const normalizedProvider = provider ?? null;
const persistedFeatureValues = useMemo( const persistedFeatureValues = useMemo(
() => preferences.providerPreferences?.[provider]?.featureValues ?? {}, () => (provider ? (preferences.providerPreferences?.[provider]?.featureValues ?? {}) : {}),
[preferences.providerPreferences, provider], [preferences.providerPreferences, provider],
); );
const draftConfig = useMemo<DraftFeatureConfig | null>(() => { const draftConfig = useMemo<DraftFeatureConfig | null>(() => {
if (!normalizedCwd) { if (!normalizedProvider || !normalizedCwd) {
return null; return null;
} }
return { return {
provider, provider: normalizedProvider,
cwd: normalizedCwd, cwd: normalizedCwd,
...(modeId ? { modeId } : {}), ...(modeId ? { modeId } : {}),
...(modelId ? { model: modelId } : {}), ...(modelId ? { model: modelId } : {}),
...(thinkingOptionId ? { thinkingOptionId } : {}), ...(thinkingOptionId ? { thinkingOptionId } : {}),
}; };
}, [modeId, modelId, normalizedCwd, provider, thinkingOptionId]); }, [modeId, modelId, normalizedCwd, normalizedProvider, thinkingOptionId]);
const featuresQuery = useQuery({ const featuresQuery = useQuery({
queryKey: [ queryKey: [
"providerFeatures", "providerFeatures",
serverId ?? null, serverId ?? null,
provider, normalizedProvider,
normalizedCwd || null, normalizedCwd || null,
modeId ?? null, modeId ?? null,
modelId ?? null, modelId ?? null,
@@ -106,6 +107,9 @@ export function useDraftAgentFeatures(input: {
return { ...current, [featureId]: value }; return { ...current, [featureId]: value };
}); });
if (!provider) {
return;
}
void updatePreferences((current) => void updatePreferences((current) =>
mergeProviderPreferences({ mergeProviderPreferences({
preferences: current, preferences: current,
@@ -2,6 +2,7 @@ import { useCallback, useMemo } from "react";
import { Gesture } from "react-native-gesture-handler"; import { Gesture } from "react-native-gesture-handler";
import { Extrapolation, interpolate, runOnJS, useSharedValue } from "react-native-reanimated"; import { Extrapolation, interpolate, runOnJS, useSharedValue } from "react-native-reanimated";
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context"; import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
interface UseExplorerOpenGestureParams { interface UseExplorerOpenGestureParams {
enabled: boolean; enabled: boolean;
@@ -19,6 +20,7 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu
gestureAnimatingRef, gestureAnimatingRef,
openGestureRef, openGestureRef,
} = useExplorerSidebarAnimation(); } = useExplorerSidebarAnimation();
const { openGestureRef: leftOpenGestureRef } = useSidebarAnimation();
const touchStartX = useSharedValue(0); const touchStartX = useSharedValue(0);
const touchStartY = useSharedValue(0); const touchStartY = useSharedValue(0);
@@ -31,6 +33,7 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu
() => () =>
Gesture.Pan() Gesture.Pan()
.withRef(openGestureRef) .withRef(openGestureRef)
.simultaneousWithExternalGesture(leftOpenGestureRef)
.enabled(enabled) .enabled(enabled)
.manualActivation(true) .manualActivation(true)
.onTouchesDown((event) => { .onTouchesDown((event) => {
@@ -109,6 +112,7 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu
animateToClose, animateToClose,
isGesturing, isGesturing,
openGestureRef, openGestureRef,
leftOpenGestureRef,
handleGestureOpen, handleGestureOpen,
touchStartX, touchStartX,
touchStartY, touchStartY,
@@ -0,0 +1,44 @@
import { useCallback } from "react";
import type { HostRuntimeAgentDirectoryStatus } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { getVoiceReadinessState } from "@/utils/server-info-capabilities";
function isLegacyDictationReady(agentDirectoryStatus: HostRuntimeAgentDirectoryStatus): boolean {
return (
agentDirectoryStatus === "ready" ||
agentDirectoryStatus === "revalidating" ||
agentDirectoryStatus === "error_after_ready"
);
}
export function useIsDictationReady({
serverId,
isConnected,
agentDirectoryStatus,
}: {
serverId: string;
isConnected: boolean;
agentDirectoryStatus: HostRuntimeAgentDirectoryStatus;
}): boolean {
const dictationCapabilityEnabled = useSessionStore(
useCallback(
(state) => {
const serverInfo = state.sessions[serverId]?.serverInfo ?? null;
return (
getVoiceReadinessState({
serverInfo,
mode: "dictation",
})?.enabled ?? null
);
},
[serverId],
),
);
if (!isConnected) {
return false;
}
return dictationCapabilityEnabled ?? isLegacyDictationReady(agentDirectoryStatus);
}
@@ -0,0 +1,135 @@
/**
* @vitest-environment jsdom
*/
import React from "react";
import { act } from "@testing-library/react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { navigateToWorkspaceMock, routerMock, pathState } = vi.hoisted(() => ({
navigateToWorkspaceMock: vi.fn(),
routerMock: {
back: vi.fn(),
push: vi.fn(),
},
pathState: {
pathname: "/h/srv/workspace/ws-2",
},
}));
vi.hoisted(() => {
(globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
});
vi.mock("expo-router", () => ({
usePathname: () => pathState.pathname,
useRouter: () => routerMock,
}));
vi.mock("@/constants/layout", () => ({
getIsElectronRuntime: () => true,
}));
vi.mock("@/constants/platform", () => ({
isNative: false,
isWeb: true,
}));
vi.mock("@/utils/shortcut-platform", () => ({
getShortcutOs: () => "mac",
}));
vi.mock("@/hooks/use-active-server-id", () => ({
useActiveServerId: () => "srv",
}));
vi.mock("@/hooks/use-open-project-picker", () => ({
useOpenProjectPicker: () => vi.fn(),
}));
vi.mock("@/hooks/use-keyboard-shortcut-overrides", () => ({
useKeyboardShortcutOverrides: () => ({ overrides: {} }),
}));
vi.mock("@/hooks/use-workspace-navigation", () => ({
navigateToWorkspace: navigateToWorkspaceMock,
}));
import { useKeyboardShortcuts } from "./use-keyboard-shortcuts";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import {
activateNavigationWorkspaceSelection,
syncNavigationActiveWorkspace,
} from "@/stores/navigation-active-workspace-store";
function Probe() {
useKeyboardShortcuts({
enabled: true,
isMobile: false,
toggleAgentList: vi.fn(),
});
return null;
}
describe("useKeyboardShortcuts", () => {
let root: Root | null = null;
let container: HTMLElement | null = null;
beforeEach(() => {
navigateToWorkspaceMock.mockReset();
routerMock.back.mockReset();
routerMock.push.mockReset();
pathState.pathname = "/h/srv/workspace/ws-2";
syncNavigationActiveWorkspace({ current: null });
useKeyboardShortcutsStore.setState({
capturingShortcut: false,
commandCenterOpen: false,
sidebarShortcutWorkspaceTargets: [
{ serverId: "srv", workspaceId: "ws-1" },
{ serverId: "srv", workspaceId: "ws-2" },
{ serverId: "srv", workspaceId: "ws-3" },
{ serverId: "srv", workspaceId: "ws-4" },
{ serverId: "srv", workspaceId: "ws-5" },
],
});
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container?.remove();
container = null;
syncNavigationActiveWorkspace({ current: null });
});
it("uses the retained active workspace instead of stale pathname for bracket navigation", async () => {
activateNavigationWorkspaceSelection({ serverId: "srv", workspaceId: "ws-4" });
await act(async () => {
root?.render(<Probe />);
});
const event = new KeyboardEvent("keydown", {
key: "]",
code: "BracketRight",
metaKey: true,
bubbles: true,
cancelable: true,
});
window.dispatchEvent(event);
expect(navigateToWorkspaceMock).toHaveBeenCalledWith("srv", "ws-5", {
currentPathname: "/h/srv/workspace/ws-2",
});
expect(event.defaultPrevented).toBe(true);
});
});
@@ -1,13 +1,11 @@
import { useEffect, useMemo, useRef } from "react"; import { useEffect, useMemo, useRef } from "react";
import { usePathname, useRouter } from "expo-router"; import { usePathname, useRouter } from "expo-router";
import { getIsElectronRuntime } from "@/constants/layout"; import { getIsElectronRuntime } from "@/constants/layout";
import { useHosts } from "@/runtime/host-runtime";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore"; import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
import { import {
buildSettingsRoute, buildSettingsRoute,
parseHostAgentRouteFromPathname, parseHostAgentRouteFromPathname,
parseServerIdFromPathname,
parseHostWorkspaceRouteFromPathname, parseHostWorkspaceRouteFromPathname,
} from "@/utils/host-routes"; } from "@/utils/host-routes";
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation"; import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
@@ -27,12 +25,14 @@ import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides"; import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { isNative } from "@/constants/platform"; import { isNative } from "@/constants/platform";
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime"; import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
import { getRelativeSidebarShortcutTarget } from "@/utils/sidebar-shortcuts";
import { useActiveServerId } from "@/hooks/use-active-server-id";
import { getNavigationActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
export function useKeyboardShortcuts({ export function useKeyboardShortcuts({
enabled, enabled,
isMobile, isMobile,
toggleAgentList, toggleAgentList,
toggleFileExplorer,
toggleBothSidebars, toggleBothSidebars,
toggleFocusMode, toggleFocusMode,
cycleTheme, cycleTheme,
@@ -40,14 +40,12 @@ export function useKeyboardShortcuts({
enabled: boolean; enabled: boolean;
isMobile: boolean; isMobile: boolean;
toggleAgentList: () => void; toggleAgentList: () => void;
toggleFileExplorer?: () => void;
toggleBothSidebars?: () => void; toggleBothSidebars?: () => void;
toggleFocusMode?: () => void; toggleFocusMode?: () => void;
cycleTheme?: () => void; cycleTheme?: () => void;
}) { }) {
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
const hosts = useHosts();
const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers); const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers);
const { overrides } = useKeyboardShortcutOverrides(); const { overrides } = useKeyboardShortcutOverrides();
const bindings = useMemo(() => buildEffectiveBindings(overrides), [overrides]); const bindings = useMemo(() => buildEffectiveBindings(overrides), [overrides]);
@@ -56,11 +54,7 @@ export function useKeyboardShortcuts({
step: 0, step: 0,
timeoutId: null, timeoutId: null,
}); });
const activeServerIdFromPath = parseServerIdFromPathname(pathname); const activeServerId = useActiveServerId();
const activeServerId =
hosts.find((host) => host.serverId === activeServerIdFromPath)?.serverId ??
hosts[0]?.serverId ??
null;
const openProjectPickerAction = useOpenProjectPicker(activeServerId); const openProjectPickerAction = useOpenProjectPicker(activeServerId);
useEffect(() => { useEffect(() => {
@@ -84,38 +78,32 @@ export function useKeyboardShortcuts({
return false; return false;
} }
navigateToWorkspace(target.serverId, target.workspaceId); navigateToWorkspace(target.serverId, target.workspaceId, { currentPathname: pathname });
return true; return true;
}; };
const navigateRelativeWorkspace = (delta: 1 | -1): boolean => { const navigateRelativeWorkspace = (delta: 1 | -1): boolean => {
const state = useKeyboardShortcutsStore.getState(); const state = useKeyboardShortcutsStore.getState();
const targets = state.visibleWorkspaceTargets; const targets = state.sidebarShortcutWorkspaceTargets;
if (targets.length === 0) { if (targets.length === 0) {
return false; return false;
} }
const workspaceRoute = parseHostWorkspaceRouteFromPathname(pathname); const workspaceRoute =
if (!workspaceRoute) { getNavigationActiveWorkspaceSelection() ?? parseHostWorkspaceRouteFromPathname(pathname);
const fallback = targets[delta > 0 ? 0 : targets.length - 1] ?? null; const target = getRelativeSidebarShortcutTarget({
if (!fallback) { targets,
return false; currentTarget: workspaceRoute
} ? {
navigateToWorkspace(fallback.serverId, fallback.workspaceId); serverId: workspaceRoute.serverId,
return true; workspaceId: workspaceRoute.workspaceId,
} }
: null,
const currentIndex = targets.findIndex( delta,
(target) => });
target.serverId === workspaceRoute.serverId &&
target.workspaceId === workspaceRoute.workspaceId,
);
const fromIndex = currentIndex >= 0 ? currentIndex : delta > 0 ? -1 : 0;
const nextIndex = (fromIndex + delta + targets.length) % targets.length;
const target = targets[nextIndex] ?? null;
if (!target) { if (!target) {
return false; return false;
} }
navigateToWorkspace(target.serverId, target.workspaceId); navigateToWorkspace(target.serverId, target.workspaceId, { currentPathname: pathname });
return true; return true;
}; };
@@ -257,10 +245,10 @@ export function useKeyboardShortcuts({
} }
return true; return true;
case "sidebar.toggle.right": case "sidebar.toggle.right":
if (toggleFileExplorer) { return keyboardActionDispatcher.dispatch({
toggleFileExplorer(); id: "sidebar.toggle.right",
} scope: "sidebar",
return true; });
case "view.toggle.focus": case "view.toggle.focus":
if (toggleFocusMode) { if (toggleFocusMode) {
toggleFocusMode(); toggleFocusMode();
@@ -425,7 +413,6 @@ export function useKeyboardShortcuts({
pathname, pathname,
resetModifiers, resetModifiers,
toggleAgentList, toggleAgentList,
toggleFileExplorer,
toggleFocusMode, toggleFocusMode,
]); ]);
} }
@@ -0,0 +1,277 @@
/**
* @vitest-environment jsdom
*/
import React from "react";
import { act, renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { DaemonClient } from "@server/client/daemon-client";
import type { ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
import { useSessionStore } from "@/stores/session-store";
import {
providersSnapshotQueryKey,
shouldApplyProvidersSnapshotUpdate,
useProvidersSnapshot,
} from "./use-providers-snapshot";
type ProviderSnapshotUpdateMessage = {
type: "providers_snapshot_update";
payload: {
cwd: string;
entries: ProviderSnapshotEntry[];
generatedAt: string;
};
};
type ProviderSnapshotUpdateListener = (message: ProviderSnapshotUpdateMessage) => void;
type ProvidersSnapshot = {
entries: ProviderSnapshotEntry[];
generatedAt: string;
requestId: string;
};
type HookResult = ReturnType<typeof renderProvidersSnapshotHook>["result"];
const { mockClient, mockRuntime, snapshotUpdateListeners } = vi.hoisted(() => {
const snapshotUpdateListeners: ProviderSnapshotUpdateListener[] = [];
const mockClient = {
getProvidersSnapshot: vi.fn(),
refreshProvidersSnapshot: vi.fn(),
on: vi.fn((_event: string, listener: ProviderSnapshotUpdateListener) => {
snapshotUpdateListeners.push(listener);
return () => {};
}),
};
return {
mockClient,
mockRuntime: {
client: mockClient,
isConnected: true,
},
snapshotUpdateListeners,
};
});
vi.mock("@/runtime/host-runtime", () => ({
useHostRuntimeClient: () => mockRuntime.client,
useHostRuntimeIsConnected: () => mockRuntime.isConnected,
}));
const serverId = "server-1";
function createQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
}
function enableProvidersSnapshot(): void {
act(() => {
useSessionStore.getState().initializeSession(serverId, mockClient as unknown as DaemonClient);
useSessionStore.getState().updateSessionServerInfo(serverId, {
serverId,
hostname: "localhost",
version: "test",
features: { providersSnapshot: true },
} as never);
});
}
function renderProvidersSnapshotHook(cwd?: string | null) {
const queryClient = createQueryClient();
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
return renderHook(() => useProvidersSnapshot(serverId, cwd), { wrapper });
}
const readyCodexModel = { provider: "codex", id: "gpt-5.4", label: "GPT-5.4" } as const;
function providersSnapshot(entries: ProviderSnapshotEntry[]): ProvidersSnapshot {
return {
entries,
generatedAt: "2026-01-01T00:00:00.000Z",
requestId: "snapshot",
};
}
function codexEntry(
status: ProviderSnapshotEntry["status"],
models?: ProviderSnapshotEntry["models"],
): ProviderSnapshotEntry {
return {
provider: "codex",
status,
...(models ? { models } : {}),
};
}
async function waitForSnapshotReads(count: number): Promise<void> {
await waitFor(() => {
expect(mockClient.getProvidersSnapshot).toHaveBeenCalledTimes(count);
});
}
async function waitForSnapshotEntries(
result: HookResult,
entries: ProviderSnapshotEntry[],
): Promise<void> {
await waitFor(() => {
expect(result.current.entries).toEqual(entries);
});
}
async function emitProvidersSnapshotUpdate(entries: ProviderSnapshotEntry[]): Promise<void> {
const listener = snapshotUpdateListeners.at(-1);
expect(listener).toBeDefined();
await act(async () => {
listener?.({
type: "providers_snapshot_update",
payload: {
cwd: "/repo",
entries,
generatedAt: "2026-01-01T00:00:01.000Z",
},
});
});
}
async function openSelectorForSelectedProvider(result: HookResult): Promise<void> {
await act(async () => {
result.current.refetchIfStale("codex");
});
}
afterEach(() => {
act(() => {
useSessionStore.getState().clearSession(serverId);
});
vi.clearAllMocks();
snapshotUpdateListeners.length = 0;
});
describe("providers snapshot hook cache scope", () => {
it("uses no cwd in the settings query key", () => {
expect(providersSnapshotQueryKey(serverId)).toEqual(["providersSnapshot", serverId, null]);
});
it("accepts concrete home update events for settings snapshots", () => {
expect(shouldApplyProvidersSnapshotUpdate(undefined, "/Users/alex")).toBe(true);
expect(shouldApplyProvidersSnapshotUpdate(null, "/home/alex")).toBe(true);
});
it("keeps workspace snapshot updates scoped to matching cwd keys", () => {
expect(shouldApplyProvidersSnapshotUpdate("/Users/alex/project", "/Users/alex/project")).toBe(
true,
);
expect(
shouldApplyProvidersSnapshotUpdate("/Users/alex/project-a", "/Users/alex/project-b"),
).toBe(false);
expect(shouldApplyProvidersSnapshotUpdate("/Users/alex/project", "/Users/alex")).toBe(false);
});
it("sends no cwd for settings snapshot loads and refreshes", async () => {
enableProvidersSnapshot();
mockClient.getProvidersSnapshot.mockResolvedValue(providersSnapshot([]));
mockClient.refreshProvidersSnapshot.mockResolvedValue({
acknowledged: true,
requestId: "settings-refresh",
});
const { result } = renderProvidersSnapshotHook();
await waitFor(() => {
expect(mockClient.getProvidersSnapshot).toHaveBeenCalledWith({});
});
await act(async () => {
await result.current.refresh(["codex"]);
});
expect(mockClient.refreshProvidersSnapshot).toHaveBeenCalledWith({ providers: ["codex"] });
expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({});
});
it("sends cwd for workspace snapshot loads and refreshes", async () => {
enableProvidersSnapshot();
mockClient.getProvidersSnapshot.mockResolvedValue(providersSnapshot([]));
mockClient.refreshProvidersSnapshot.mockResolvedValue({
acknowledged: true,
requestId: "workspace-refresh",
});
const { result } = renderProvidersSnapshotHook("/repo");
await waitFor(() => {
expect(mockClient.getProvidersSnapshot).toHaveBeenCalledWith({ cwd: "/repo" });
});
await act(async () => {
await result.current.refresh(["codex"]);
});
expect(mockClient.refreshProvidersSnapshot).toHaveBeenCalledWith({
cwd: "/repo",
providers: ["codex"],
});
expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({ cwd: "/repo" });
});
it("refetches loading snapshot updates through the read path but ignores empty updates", async () => {
enableProvidersSnapshot();
mockClient.getProvidersSnapshot
.mockResolvedValueOnce(providersSnapshot([codexEntry("ready", [])]))
.mockResolvedValueOnce(providersSnapshot([codexEntry("ready", [readyCodexModel])]));
renderProvidersSnapshotHook("/repo");
await waitForSnapshotReads(1);
await emitProvidersSnapshotUpdate([]);
expect(mockClient.getProvidersSnapshot).toHaveBeenCalledTimes(1);
await emitProvidersSnapshotUpdate([codexEntry("loading")]);
await waitForSnapshotReads(2);
expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({ cwd: "/repo" });
expect(mockClient.refreshProvidersSnapshot).not.toHaveBeenCalled();
});
it.each([
{ name: "missing", entries: [] },
{ name: "loading", entries: [codexEntry("loading")] },
])("ensures a selected provider snapshot on selector open when it is $name", async ({
entries,
}) => {
enableProvidersSnapshot();
mockClient.getProvidersSnapshot
.mockResolvedValueOnce(providersSnapshot(entries))
.mockResolvedValueOnce(providersSnapshot([codexEntry("ready", [readyCodexModel])]));
const { result } = renderProvidersSnapshotHook("/repo");
await waitForSnapshotEntries(result, entries);
await openSelectorForSelectedProvider(result);
await waitForSnapshotReads(2);
expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({ cwd: "/repo" });
expect(mockClient.refreshProvidersSnapshot).not.toHaveBeenCalled();
});
it("does not ensure a selected provider snapshot on selector open when the provider is ready with no models", async () => {
enableProvidersSnapshot();
mockClient.getProvidersSnapshot.mockResolvedValue(providersSnapshot([codexEntry("ready", [])]));
const { result } = renderProvidersSnapshotHook("/repo");
await waitForSnapshotEntries(result, [codexEntry("ready", [])]);
await openSelectorForSelectedProvider(result);
expect(mockClient.getProvidersSnapshot).toHaveBeenCalledTimes(1);
expect(mockClient.refreshProvidersSnapshot).not.toHaveBeenCalled();
});
});
@@ -6,7 +6,7 @@ import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { queryClient as singletonQueryClient } from "@/query/query-client"; import { queryClient as singletonQueryClient } from "@/query/query-client";
function normalizeProvidersSnapshotCwdKey(cwd?: string | null): string | null { export function normalizeProvidersSnapshotCwdKey(cwd?: string | null): string | null {
const trimmed = cwd?.trim(); const trimmed = cwd?.trim();
if (!trimmed) { if (!trimmed) {
return null; return null;
@@ -15,10 +15,33 @@ function normalizeProvidersSnapshotCwdKey(cwd?: string | null): string | null {
return trimmed.replace(/^\/(?:Users|home)\/[^/]+/, "~"); return trimmed.replace(/^\/(?:Users|home)\/[^/]+/, "~");
} }
export function shouldApplyProvidersSnapshotUpdate(
currentCwd?: string | null,
messageCwd?: string | null,
): boolean {
const currentCwdKey = normalizeProvidersSnapshotCwdKey(currentCwd);
const messageCwdKey = normalizeProvidersSnapshotCwdKey(messageCwd);
return messageCwdKey === currentCwdKey || (currentCwdKey === null && messageCwdKey === "~");
}
export function providersSnapshotQueryKey(serverId: string | null, cwd?: string | null) { export function providersSnapshotQueryKey(serverId: string | null, cwd?: string | null) {
return ["providersSnapshot", serverId, normalizeProvidersSnapshotCwdKey(cwd)] as const; return ["providersSnapshot", serverId, normalizeProvidersSnapshotCwdKey(cwd)] as const;
} }
function providersSnapshotRequest(cwd?: string): { cwd?: string } {
return cwd ? { cwd } : {};
}
function refreshProvidersSnapshotRequest(
cwd: string | undefined,
providers: AgentProvider[] | undefined,
): { cwd?: string; providers?: AgentProvider[] } {
return {
...providersSnapshotRequest(cwd),
...(providers ? { providers } : {}),
};
}
interface UseProvidersSnapshotResult { interface UseProvidersSnapshotResult {
entries: ProviderSnapshotEntry[] | undefined; entries: ProviderSnapshotEntry[] | undefined;
isLoading: boolean; isLoading: boolean;
@@ -27,16 +50,22 @@ interface UseProvidersSnapshotResult {
error: string | null; error: string | null;
supportsSnapshot: boolean; supportsSnapshot: boolean;
refresh: (providers?: AgentProvider[]) => Promise<void>; refresh: (providers?: AgentProvider[]) => Promise<void>;
refetchIfStale: () => void; refetchIfStale: (selectedProvider?: AgentProvider | null) => void;
}
interface UseProvidersSnapshotOptions {
enabled?: boolean;
} }
export function useProvidersSnapshot( export function useProvidersSnapshot(
serverId: string | null, serverId: string | null,
cwd?: string | null, cwd?: string | null,
options: UseProvidersSnapshotOptions = {},
): UseProvidersSnapshotResult { ): UseProvidersSnapshotResult {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const client = useHostRuntimeClient(serverId ?? ""); const client = useHostRuntimeClient(serverId ?? "");
const isConnected = useHostRuntimeIsConnected(serverId ?? ""); const isConnected = useHostRuntimeIsConnected(serverId ?? "");
const enabled = options.enabled ?? true;
const normalizedCwd = cwd?.trim() || undefined; const normalizedCwd = cwd?.trim() || undefined;
const normalizedCwdKey = normalizeProvidersSnapshotCwdKey(normalizedCwd); const normalizedCwdKey = normalizeProvidersSnapshotCwdKey(normalizedCwd);
const supportsSnapshot = useSessionStore( const supportsSnapshot = useSessionStore(
@@ -50,13 +79,13 @@ export function useProvidersSnapshot(
const snapshotQuery = useQuery({ const snapshotQuery = useQuery({
queryKey, queryKey,
enabled: Boolean(supportsSnapshot && serverId && client && isConnected), enabled: Boolean(enabled && supportsSnapshot && serverId && client && isConnected),
staleTime: 60_000, staleTime: 60_000,
queryFn: async () => { queryFn: async () => {
if (!client) { if (!client) {
throw new Error("Host is not connected"); throw new Error("Host is not connected");
} }
return client.getProvidersSnapshot({ cwd: normalizedCwd }); return client.getProvidersSnapshot(providersSnapshotRequest(normalizedCwd));
}, },
}); });
@@ -65,13 +94,15 @@ export function useProvidersSnapshot(
if (!client) { if (!client) {
return; return;
} }
await client.refreshProvidersSnapshot({ cwd: normalizedCwd, providers }); await client.refreshProvidersSnapshot(
refreshProvidersSnapshotRequest(normalizedCwd, providers),
);
}, },
}); });
const { mutateAsync: refreshSnapshot, isPending: isRefreshing } = refreshMutation; const { mutateAsync: refreshSnapshot, isPending: isRefreshing } = refreshMutation;
useEffect(() => { useEffect(() => {
if (!supportsSnapshot || !client || !isConnected || !serverId) { if (!enabled || !supportsSnapshot || !client || !isConnected || !serverId) {
return; return;
} }
@@ -79,8 +110,7 @@ export function useProvidersSnapshot(
if (message.type !== "providers_snapshot_update") { if (message.type !== "providers_snapshot_update") {
return; return;
} }
const messageCwdKey = normalizeProvidersSnapshotCwdKey(message.payload.cwd); if (!shouldApplyProvidersSnapshotUpdate(normalizedCwd, message.payload.cwd)) {
if (messageCwdKey !== normalizedCwdKey) {
return; return;
} }
queryClient.setQueryData(queryKey, { queryClient.setQueryData(queryKey, {
@@ -88,19 +118,59 @@ export function useProvidersSnapshot(
generatedAt: message.payload.generatedAt, generatedAt: message.payload.generatedAt,
requestId: "providers_snapshot_update", requestId: "providers_snapshot_update",
}); });
const shouldRefetch = message.payload.entries.some((entry) => entry.status === "loading");
if (shouldRefetch) {
void queryClient.invalidateQueries({
queryKey,
exact: true,
refetchType: "active",
});
}
}); });
}, [client, isConnected, normalizedCwdKey, queryClient, queryKey, serverId, supportsSnapshot]); }, [
client,
enabled,
isConnected,
normalizedCwd,
normalizedCwdKey,
queryClient,
queryKey,
serverId,
supportsSnapshot,
]);
const refresh = useCallback( const refresh = useCallback(
async (providers?: AgentProvider[]) => { async (providers?: AgentProvider[]) => {
if (!client) {
return;
}
await refreshSnapshot(providers); await refreshSnapshot(providers);
const snapshot = await client.getProvidersSnapshot(providersSnapshotRequest(normalizedCwd));
queryClient.setQueryData(queryKey, snapshot);
}, },
[refreshSnapshot], [client, normalizedCwd, queryClient, queryKey, refreshSnapshot],
); );
const refetchIfStale = useCallback(() => { const refetchIfStale = useCallback(
void queryClient.refetchQueries({ queryKey, type: "active", stale: true }); (selectedProvider?: AgentProvider | null) => {
}, [queryClient, queryKey]); if (!selectedProvider) {
void queryClient.refetchQueries({ queryKey, type: "active", stale: true });
return;
}
const selectedEntry = snapshotQuery.data?.entries.find(
(entry) => entry.provider === selectedProvider,
);
if (!selectedEntry || selectedEntry.status === "loading") {
void queryClient.refetchQueries({ queryKey, type: "active" });
return;
}
void queryClient.refetchQueries({ queryKey, type: "active", stale: true });
},
[queryClient, queryKey, snapshotQuery.data?.entries],
);
return { return {
entries: snapshotQuery.data?.entries ?? undefined, entries: snapshotQuery.data?.entries ?? undefined,
@@ -124,6 +194,6 @@ export function prefetchProvidersSnapshot(
void singletonQueryClient.prefetchQuery({ void singletonQueryClient.prefetchQuery({
queryKey, queryKey,
staleTime: 60_000, staleTime: 60_000,
queryFn: () => client.getProvidersSnapshot({ cwd: normalizedCwd }), queryFn: () => client.getProvidersSnapshot(providersSnapshotRequest(normalizedCwd)),
}); });
} }
@@ -40,6 +40,16 @@ describe("use-settings", () => {
expect(result.theme).toBe("auto"); expect(result.theme).toBe("auto");
}); });
it("defaults release channel to stable when storage is empty", async () => {
asyncStorageMock.getItem.mockResolvedValue(null);
asyncStorageMock.setItem.mockResolvedValue();
const mod = await import("./use-settings");
const result = await mod.loadSettingsFromStorage();
expect(result.releaseChannel).toBe("stable");
});
it("loads persisted built-in daemon management state", async () => { it("loads persisted built-in daemon management state", async () => {
asyncStorageMock.getItem.mockImplementation(async (key: string) => { asyncStorageMock.getItem.mockImplementation(async (key: string) => {
if (key === "@paseo:app-settings") { if (key === "@paseo:app-settings") {
@@ -58,7 +68,24 @@ describe("use-settings", () => {
theme: "light", theme: "light",
manageBuiltInDaemon: false, manageBuiltInDaemon: false,
sendBehavior: "interrupt", sendBehavior: "interrupt",
releaseChannel: "stable",
}); });
expect(asyncStorageMock.setItem).not.toHaveBeenCalled(); expect(asyncStorageMock.setItem).not.toHaveBeenCalled();
}); });
it("loads persisted beta release channel", async () => {
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
if (key === "@paseo:app-settings") {
return JSON.stringify({
releaseChannel: "beta",
});
}
return null;
});
const mod = await import("./use-settings");
const result = await mod.loadSettingsFromStorage();
expect(result.releaseChannel).toBe("beta");
});
}); });
+10
View File
@@ -9,19 +9,23 @@ const APP_SETTINGS_QUERY_KEY = ["app-settings"];
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme"; import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
export type SendBehavior = "interrupt" | "queue"; export type SendBehavior = "interrupt" | "queue";
export type ReleaseChannel = "stable" | "beta";
const VALID_THEMES = new Set<string>([...Object.keys(THEME_TO_UNISTYLES), "auto"]); const VALID_THEMES = new Set<string>([...Object.keys(THEME_TO_UNISTYLES), "auto"]);
const VALID_RELEASE_CHANNELS = new Set<string>(["stable", "beta"]);
export interface AppSettings { export interface AppSettings {
theme: ThemeName | "auto"; theme: ThemeName | "auto";
manageBuiltInDaemon: boolean; manageBuiltInDaemon: boolean;
sendBehavior: SendBehavior; sendBehavior: SendBehavior;
releaseChannel: ReleaseChannel;
} }
export const DEFAULT_APP_SETTINGS: AppSettings = { export const DEFAULT_APP_SETTINGS: AppSettings = {
theme: "auto", theme: "auto",
manageBuiltInDaemon: true, manageBuiltInDaemon: true,
sendBehavior: "interrupt", sendBehavior: "interrupt",
releaseChannel: "stable",
}; };
export interface UseAppSettingsReturn { export interface UseAppSettingsReturn {
@@ -85,6 +89,9 @@ export async function loadSettingsFromStorage(): Promise<AppSettings> {
if (parsed.theme && !VALID_THEMES.has(parsed.theme)) { if (parsed.theme && !VALID_THEMES.has(parsed.theme)) {
parsed.theme = DEFAULT_APP_SETTINGS.theme; parsed.theme = DEFAULT_APP_SETTINGS.theme;
} }
if (parsed.releaseChannel && !VALID_RELEASE_CHANNELS.has(parsed.releaseChannel)) {
parsed.releaseChannel = DEFAULT_APP_SETTINGS.releaseChannel;
}
return { ...DEFAULT_APP_SETTINGS, ...parsed }; return { ...DEFAULT_APP_SETTINGS, ...parsed };
} }
@@ -115,6 +122,9 @@ function pickAppSettingsFromLegacy(legacy: Record<string, unknown>): Partial<App
if (typeof legacy.manageBuiltInDaemon === "boolean") { if (typeof legacy.manageBuiltInDaemon === "boolean") {
result.manageBuiltInDaemon = legacy.manageBuiltInDaemon; result.manageBuiltInDaemon = legacy.manageBuiltInDaemon;
} }
if (legacy.releaseChannel === "stable" || legacy.releaseChannel === "beta") {
result.releaseChannel = legacy.releaseChannel;
}
return result; return result;
} }
@@ -1,16 +1,22 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useEffect, useMemo } from "react";
import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list"; import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { buildSidebarShortcutModel } from "@/utils/sidebar-shortcuts"; import { buildSidebarShortcutModel } from "@/utils/sidebar-shortcuts";
import { isSidebarProjectFlattened } from "@/utils/sidebar-project-row-model"; import { isSidebarProjectFlattened } from "@/utils/sidebar-project-row-model";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
export function useSidebarShortcutModel(projects: SidebarProjectEntry[]) { export function useSidebarShortcutModel(input: {
const [collapsedProjectKeys, setCollapsedProjectKeys] = useState<Set<string>>(new Set()); projects: SidebarProjectEntry[];
const setSidebarShortcutWorkspaceTargets = useKeyboardShortcutsStore( isInitialLoad: boolean;
(state) => state.setSidebarShortcutWorkspaceTargets, }) {
const { projects, isInitialLoad } = input;
const collapsedProjectKeys = useSidebarCollapsedSectionsStore(
(state) => state.collapsedProjectKeys,
); );
const setVisibleWorkspaceTargets = useKeyboardShortcutsStore( const setProjectCollapsed = useSidebarCollapsedSectionsStore(
(state) => state.setVisibleWorkspaceTargets, (state) => state.setProjectCollapsed,
);
const toggleProjectCollapsed = useSidebarCollapsedSectionsStore(
(state) => state.toggleProjectCollapsed,
); );
const shortcutModel = useMemo( const shortcutModel = useMemo(
@@ -23,62 +29,21 @@ export function useSidebarShortcutModel(projects: SidebarProjectEntry[]) {
); );
useEffect(() => { useEffect(() => {
setCollapsedProjectKeys((prev) => { if (isInitialLoad || projects.length === 0) {
const collapsibleProjectKeys = new Set( return;
projects }
.filter((project) => !isSidebarProjectFlattened(project))
.map((project) => project.projectKey), const collapsibleProjectKeys = new Set(
); projects
const next = new Set<string>(); .filter((project) => !isSidebarProjectFlattened(project))
for (const key of prev) { .map((project) => project.projectKey),
if (collapsibleProjectKeys.has(key)) { );
next.add(key); for (const key of collapsedProjectKeys) {
} if (!collapsibleProjectKeys.has(key)) {
setProjectCollapsed(key, false);
} }
return next; }
}); }, [collapsedProjectKeys, isInitialLoad, projects, setProjectCollapsed]);
}, [projects]);
useEffect(() => {
setSidebarShortcutWorkspaceTargets(shortcutModel.shortcutTargets);
setVisibleWorkspaceTargets(shortcutModel.visibleTargets);
}, [
setSidebarShortcutWorkspaceTargets,
setVisibleWorkspaceTargets,
shortcutModel.shortcutTargets,
shortcutModel.visibleTargets,
]);
useEffect(() => {
return () => {
setSidebarShortcutWorkspaceTargets([]);
setVisibleWorkspaceTargets([]);
};
}, [setSidebarShortcutWorkspaceTargets, setVisibleWorkspaceTargets]);
const toggleProjectCollapsed = useCallback((projectKey: string) => {
setCollapsedProjectKeys((prev) => {
const next = new Set(prev);
if (next.has(projectKey)) {
next.delete(projectKey);
} else {
next.add(projectKey);
}
return next;
});
}, []);
const setProjectCollapsed = useCallback((projectKey: string, collapsed: boolean) => {
setCollapsedProjectKeys((prev) => {
const next = new Set(prev);
if (collapsed) {
next.add(projectKey);
} else {
next.delete(projectKey);
}
return next;
});
}, []);
return { return {
collapsedProjectKeys, collapsedProjectKeys,
@@ -1,4 +1,11 @@
import { describe, expect, it, vi } from "vitest"; /**
* @vitest-environment jsdom
*/
import { act } from "@testing-library/react";
import type { DaemonClient } from "@server/client/daemon-client";
import { createRoot, type Root } from "react-dom/client";
import React from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
vi.hoisted(() => { vi.hoisted(() => {
(globalThis as unknown as { __DEV__: boolean }).__DEV__ = false; (globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
@@ -8,8 +15,12 @@ import {
appendMissingOrderKeys, appendMissingOrderKeys,
applyStoredOrdering, applyStoredOrdering,
buildSidebarProjectsFromStructure, buildSidebarProjectsFromStructure,
useSidebarWorkspacesList,
} from "./use-sidebar-workspaces-list"; } from "./use-sidebar-workspaces-list";
import type { WorkspaceStructureProject } from "@/stores/session-store-hooks"; import type { WorkspaceStructureProject } from "@/stores/session-store-hooks";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
interface OrderedItem { interface OrderedItem {
key: string; key: string;
@@ -35,6 +46,45 @@ function project(input: {
}; };
} }
function workspaceDescriptor(id: string): WorkspaceDescriptor {
return {
id,
projectId: "project-1",
projectDisplayName: "Project 1",
projectRootPath: "/repo/main",
workspaceDirectory: `/repo/main/${id}`,
projectKind: "git",
workspaceKind: "worktree",
name: id,
status: "done",
diffStat: null,
scripts: [],
};
}
function DisabledHookProbe({ serverId }: { serverId: string }): null {
const result = useSidebarWorkspacesList({ serverId, enabled: false });
expect(result.projects).toEqual([]);
expect(result.isLoading).toBe(false);
expect(result.isInitialLoad).toBe(false);
expect(result.isRevalidating).toBe(false);
return null;
}
function DisabledRenderCountProbe({
onRender,
serverId,
}: {
onRender: () => void;
serverId: string;
}): null {
useSidebarWorkspacesList({ serverId, enabled: false });
onRender();
return null;
}
describe("applyStoredOrdering", () => { describe("applyStoredOrdering", () => {
it("keeps unknown items on the baseline while applying stored order", () => { it("keeps unknown items on the baseline while applying stored order", () => {
const result = applyStoredOrdering({ const result = applyStoredOrdering({
@@ -139,3 +189,73 @@ describe("buildSidebarProjectsFromStructure", () => {
]); ]);
}); });
}); });
describe("useSidebarWorkspacesList", () => {
let root: Root | null = null;
let container: HTMLElement | null = null;
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container?.remove();
container = null;
act(() => {
getHostRuntimeStore().syncHosts([]);
useSessionStore.getState().clearSession("srv-disabled");
useSidebarOrderStore.setState({
projectOrderByServerId: {},
workspaceOrderByServerAndProject: {},
});
});
});
it("honors enabled false without appending persisted order keys", async () => {
act(() => {
useSessionStore.getState().initializeSession("srv-disabled", null as unknown as DaemonClient);
useSessionStore
.getState()
.setWorkspaces("srv-disabled", new Map([["ws-main", workspaceDescriptor("ws-main")]]));
useSessionStore.getState().setHasHydratedWorkspaces("srv-disabled", true);
});
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(React.createElement(DisabledHookProbe, { serverId: "srv-disabled" }));
});
expect(useSidebarOrderStore.getState().projectOrderByServerId).toEqual({});
expect(useSidebarOrderStore.getState().workspaceOrderByServerAndProject).toEqual({});
});
it("does not subscribe to order updates while disabled", async () => {
const onRender = vi.fn();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
React.createElement(DisabledRenderCountProbe, {
serverId: "srv-disabled",
onRender,
}),
);
});
expect(onRender).toHaveBeenCalledTimes(1);
await act(async () => {
useSidebarOrderStore.getState().setProjectOrder("srv-disabled", ["project-a"]);
});
expect(onRender).toHaveBeenCalledTimes(1);
});
});
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react"; import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
import type { WorkspaceDescriptorPayload } from "@server/shared/messages"; import type { WorkspaceDescriptorPayload } from "@server/shared/messages";
import { import {
mergeWorkspaceSnapshotWithExisting,
normalizeWorkspaceDescriptor, normalizeWorkspaceDescriptor,
useSessionStore, useSessionStore,
type WorkspaceDescriptor, type WorkspaceDescriptor,
@@ -191,11 +190,10 @@ export function useSidebarWorkspacesList(options?: {
const value = options?.serverId; const value = options?.serverId;
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}, [options?.serverId]); }, [options?.serverId]);
const isActive = Boolean(serverId) && options?.enabled !== false;
const persistedProjectOrder = useSidebarOrderStore((state) => const persistedProjectOrder = useSidebarOrderStore((state) =>
serverId ? (state.projectOrderByServerId[serverId] ?? EMPTY_ORDER) : EMPTY_ORDER, isActive && serverId ? (state.projectOrderByServerId[serverId] ?? EMPTY_ORDER) : EMPTY_ORDER,
); );
const isActive = Boolean(serverId);
const hasHydratedWorkspaces = useSessionStore((state) => const hasHydratedWorkspaces = useSessionStore((state) =>
isActive && serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false, isActive && serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false,
); );
@@ -275,7 +273,6 @@ export function useSidebarWorkspacesList(options?: {
} }
void (async () => { void (async () => {
const next = new Map<string, WorkspaceDescriptor>(); const next = new Map<string, WorkspaceDescriptor>();
const existingWorkspaces = useSessionStore.getState().sessions[serverId]?.workspaces;
let cursor: string | null = null; let cursor: string | null = null;
try { try {
while (true) { while (true) {
@@ -285,13 +282,7 @@ export function useSidebarWorkspacesList(options?: {
}); });
for (const entry of payload.entries) { for (const entry of payload.entries) {
const workspace = toWorkspaceDescriptor(entry); const workspace = toWorkspaceDescriptor(entry);
next.set( next.set(workspace.id, workspace);
workspace.id,
mergeWorkspaceSnapshotWithExisting({
incoming: workspace,
existing: existingWorkspaces?.get(workspace.id),
}),
);
} }
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) { if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
break; break;
@@ -0,0 +1,59 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { navigateMock } = vi.hoisted(() => ({
navigateMock: vi.fn(),
}));
vi.mock("expo-router", () => ({
router: {
navigate: navigateMock,
},
}));
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
import {
activateNavigationWorkspaceSelection,
getNavigationActiveWorkspaceSelection,
syncNavigationActiveWorkspace,
} from "@/stores/navigation-active-workspace-store";
describe("navigateToWorkspace", () => {
beforeEach(() => {
navigateMock.mockReset();
syncNavigationActiveWorkspace({ current: null });
});
it("uses router navigation from a non-workspace route even when active selection is stale", () => {
activateNavigationWorkspaceSelection({
serverId: "server-1",
workspaceId: "workspace-a",
});
navigateToWorkspace("server-1", "workspace-b", {
currentPathname: "/h/server-1/sessions",
});
expect(navigateMock).toHaveBeenCalledWith("/h/server-1/workspace/workspace-b");
expect(getNavigationActiveWorkspaceSelection()).toEqual({
serverId: "server-1",
workspaceId: "workspace-a",
});
});
it("keeps retained workspace switching on a workspace route", () => {
activateNavigationWorkspaceSelection({
serverId: "server-1",
workspaceId: "workspace-a",
});
navigateToWorkspace("server-1", "workspace-b", {
currentPathname: "/h/server-1/workspace/workspace-a",
});
expect(navigateMock).not.toHaveBeenCalled();
expect(getNavigationActiveWorkspaceSelection()).toEqual({
serverId: "server-1",
workspaceId: "workspace-b",
});
});
});
@@ -1,23 +1,64 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { router } from "expo-router"; import { router } from "expo-router";
import { buildHostWorkspaceRoute } from "@/utils/host-routes"; import {
activateNavigationWorkspaceSelection,
getNavigationActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
import { buildHostWorkspaceRoute, parseHostWorkspaceRouteFromPathname } from "@/utils/host-routes";
interface NavigateToWorkspaceOptions {
currentPathname?: string | null;
}
function shouldUseRetainedWorkspaceSwitch(input: {
hasActiveWorkspace: boolean;
currentPathname?: string | null;
}): boolean {
if (!input.hasActiveWorkspace) {
return false;
}
if (input.currentPathname == null) {
return true;
}
return parseHostWorkspaceRouteFromPathname(input.currentPathname) !== null;
}
/** /**
* Navigate to a workspace screen. Uses router.navigate() which, * Open a workspace. Once the workspace shell is mounted, switching workspaces
* combined with getId on the workspace Stack.Screen, ensures: * is app-level state so native-stack does not rebuild every retained screen.
* - Only one instance of each workspace exists in the stack
* - History is preserved (back button works)
* - No duplicate workspace screens
*/ */
export function navigateToWorkspace(serverId: string, workspaceId: string) { export function navigateToWorkspace(
serverId: string,
workspaceId: string,
options: NavigateToWorkspaceOptions = {},
) {
const activeWorkspace = getNavigationActiveWorkspaceSelection();
if (
shouldUseRetainedWorkspaceSwitch({
hasActiveWorkspace: activeWorkspace !== null,
currentPathname: options.currentPathname,
})
) {
activateNavigationWorkspaceSelection(
{ serverId, workspaceId },
{ updateBrowserHistory: true, historyMode: "push" },
);
return;
}
const href = buildHostWorkspaceRoute(serverId, workspaceId); const href = buildHostWorkspaceRoute(serverId, workspaceId);
router.navigate(href); router.navigate(href);
} }
export function useWorkspaceNavigation() { export function useWorkspaceNavigation() {
return { return {
navigateToWorkspace: useCallback((serverId: string, workspaceId: string) => { navigateToWorkspace: useCallback(
navigateToWorkspace(serverId, workspaceId); (serverId: string, workspaceId: string, options?: NavigateToWorkspaceOptions) => {
}, []), navigateToWorkspace(serverId, workspaceId, options);
},
[],
),
}; };
} }
@@ -24,6 +24,7 @@ export type KeyboardActionId =
| "workspace.pane.move-tab.down" | "workspace.pane.move-tab.down"
| "workspace.pane.close" | "workspace.pane.close"
| "workspace.terminal.new" | "workspace.terminal.new"
| "sidebar.toggle.right"
| "worktree.new" | "worktree.new"
| "worktree.archive"; | "worktree.archive";
@@ -51,6 +52,7 @@ export type KeyboardActionDefinition =
| { id: "workspace.pane.move-tab.down"; scope: KeyboardActionScope } | { id: "workspace.pane.move-tab.down"; scope: KeyboardActionScope }
| { id: "workspace.pane.close"; scope: KeyboardActionScope } | { id: "workspace.pane.close"; scope: KeyboardActionScope }
| { id: "workspace.terminal.new"; scope: KeyboardActionScope } | { id: "workspace.terminal.new"; scope: KeyboardActionScope }
| { id: "sidebar.toggle.right"; scope: KeyboardActionScope }
| { id: "worktree.new"; scope: KeyboardActionScope } | { id: "worktree.new"; scope: KeyboardActionScope }
| { id: "worktree.archive"; scope: KeyboardActionScope }; | { id: "worktree.archive"; scope: KeyboardActionScope };
@@ -0,0 +1,466 @@
/**
* @vitest-environment jsdom
*/
import React, { useImperativeHandle } from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, describe, expect, it, vi } from "vitest";
import { DaemonClient } from "@server/client/daemon-client";
import { PaneFocusProvider, PaneProvider, type PaneFocusContextValue } from "@/panels/pane-context";
import { agentPanelRegistration } from "@/panels/agent-panel";
import { useDraftStore } from "@/stores/draft-store";
import { useSessionStore, type Agent } from "@/stores/session-store";
import type { PendingPermission } from "@/types/shared";
import type { StreamItem } from "@/types/stream";
import type { AgentPermissionRequest } from "@server/server/agent/agent-sdk-types";
type PanelTestTheme = {
colors: {
foreground: string;
foregroundMuted: string;
surface0: string;
surface1: string;
surface2: string;
surface3: string;
border: string;
destructive: string;
};
spacing: Record<number, number>;
borderRadius: Record<string, number>;
fontSize: Record<string, number>;
fontWeight: Record<string, string>;
iconSize: Record<string, number>;
};
type PanelTestStyles = Record<string, unknown>;
type PanelTestStyleFactory = (input: PanelTestTheme) => PanelTestStyles;
const {
composerRenderCount,
composerUnmountCount,
latestComposerCwd,
latestComposerIsPaneFocused,
streamRenderCount,
latestStreamPermissionKeys,
latestStreamText,
theme,
runtimeClient,
} = vi.hoisted(() => {
Object.defineProperty(globalThis, "__DEV__", {
value: false,
configurable: true,
});
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
value: true,
configurable: true,
});
Object.defineProperty(globalThis, "ResizeObserver", {
value: class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
},
configurable: true,
});
return {
composerRenderCount: vi.fn(),
composerUnmountCount: vi.fn(),
latestComposerCwd: { current: null as string | null },
latestComposerIsPaneFocused: { current: null as boolean | null },
streamRenderCount: vi.fn(),
latestStreamPermissionKeys: { current: [] as string[] },
latestStreamText: { current: null as string | null },
theme: {
colors: {
foreground: "#ffffff",
foregroundMuted: "#999999",
surface0: "#000000",
surface1: "#111111",
surface2: "#222222",
surface3: "#333333",
border: "#444444",
destructive: "#ff0000",
},
spacing: { 1: 4, 2: 8, 3: 12, 4: 16, 6: 24 },
borderRadius: { md: 6, lg: 8, xl: 12 },
fontSize: { sm: 13, base: 15, lg: 18 },
fontWeight: { medium: "500" },
iconSize: { lg: 22 },
} satisfies PanelTestTheme,
runtimeClient: {
fetchAgent: vi.fn(),
fetchAgentTimeline: vi.fn(),
},
};
});
vi.mock("react-native-reanimated", () => ({
default: {
View: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
},
}));
vi.mock("react-native-unistyles", () => {
return {
StyleSheet: {
create: createPanelTestStyles,
},
useUnistyles: () => ({ theme }),
};
});
vi.mock("react-native-safe-area-context", () => ({
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
}));
function createPanelTestStyles(factory: PanelTestStyleFactory | PanelTestStyles): PanelTestStyles {
return typeof factory === "function" ? factory(theme) : factory;
}
vi.mock("@/runtime/host-runtime", () => ({
useHosts: () => [{ serverId: "server", label: "Test server" }],
useHostRuntimeClient: () => runtimeClient,
useHostRuntimeIsConnected: () => false,
useHostRuntimeConnectionStatus: () => "offline",
useHostRuntimeLastError: () => null,
}));
vi.mock("@/attachments/service", () => ({
garbageCollectAttachments: vi.fn(async () => {}),
persistAttachmentFromDataUrl: vi.fn(),
persistAttachmentFromFileUri: vi.fn(),
}));
vi.mock("@/components/provider-icons", () => ({
getProviderIcon: () => null,
}));
vi.mock("@/components/file-drop-zone", () => ({
FileDropZone: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock("@/components/toast-host", () => ({
ToastViewport: () => null,
useToastHost: () => ({
toast: null,
dismiss: vi.fn(),
api: {
show: vi.fn(),
},
}),
}));
vi.mock("@/components/archived-agent-callout", () => ({
ArchivedAgentCallout: ({ agentId }: { agentId: string }) => (
<div data-testid="archived-agent-callout">{agentId}</div>
),
}));
vi.mock("@/hooks/use-keyboard-shift-style", () => ({
useKeyboardShiftStyle: () => ({ style: null }),
}));
vi.mock("@/hooks/use-archive-agent", () => ({
useArchiveAgent: () => ({
isArchivingAgent: () => false,
}),
}));
vi.mock("@/components/composer", () => ({
Composer: ({ cwd, isPaneFocused }: { cwd: string; isPaneFocused: boolean }) => {
React.useEffect(
() => () => {
composerUnmountCount();
},
[],
);
composerRenderCount();
latestComposerCwd.current = cwd;
latestComposerIsPaneFocused.current = isPaneFocused;
return <div data-testid="composer">{cwd}</div>;
},
}));
vi.mock("@/components/agent-stream-view", () => ({
AgentStreamView: React.memo(
React.forwardRef<
{ prepareForViewportChange: () => void; scrollToBottom: (reason: "message-sent") => void },
{ pendingPermissions: Map<string, PendingPermission>; streamItems: StreamItem[] }
>(function AgentStreamView({ pendingPermissions, streamItems }, ref) {
useImperativeHandle(ref, () => ({
prepareForViewportChange: vi.fn(),
scrollToBottom: vi.fn(),
}));
streamRenderCount();
latestStreamPermissionKeys.current = Array.from(pendingPermissions.keys());
latestStreamText.current =
streamItems.find((item) => item.kind === "user_message")?.text ?? null;
return <div data-testid="agent-stream-view">{latestStreamText.current}</div>;
}),
),
}));
function makeClient(): DaemonClient {
return new DaemonClient({
url: "ws://127.0.0.1:1",
clientId: "panel-render-isolation-test",
});
}
function makeAgent(overrides: Partial<Agent> = {}): Agent {
const now = new Date("2026-04-20T00:00:00.000Z");
return {
serverId: "server",
id: "agent",
provider: "codex",
status: "running",
createdAt: now,
updatedAt: now,
lastUserMessageAt: null,
lastActivityAt: now,
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
lastError: null,
title: "Render isolation",
cwd: "/workspace/one",
model: null,
labels: {},
...overrides,
};
}
function seedReadyAgent(agent: Agent = makeAgent()) {
const store = useSessionStore.getState();
store.initializeSession("server", makeClient());
store.setAgents("server", new Map([["agent", agent]]));
store.setAgentAuthoritativeHistoryApplied("server", "agent", true);
}
async function renderAgentPanel(
root: Root,
focus: PaneFocusContextValue = {
isWorkspaceFocused: true,
isPaneFocused: false,
isInteractive: false,
},
) {
const AgentPanel = agentPanelRegistration.component;
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<PaneProvider
value={{
serverId: "server",
workspaceId: "workspace",
tabId: "agent-agent",
target: { kind: "agent", agentId: "agent" },
openTab: vi.fn(),
closeCurrentTab: vi.fn(),
retargetCurrentTab: vi.fn(),
openFileInWorkspace: vi.fn(),
}}
>
<PaneFocusProvider value={focus}>
<AgentPanel />
</PaneFocusProvider>
</PaneProvider>
</QueryClientProvider>,
);
await Promise.resolve();
});
}
function updateCurrentAgentStream(text: string) {
act(() => {
useSessionStore.getState().setAgentStreamTail("server", (previous) => {
const next = new Map(previous);
next.set("agent", [
{
kind: "user_message",
id: `message-${text}`,
text,
timestamp: new Date("2026-04-20T00:00:01.000Z"),
},
]);
return next;
});
});
}
function makePendingPermission(agentId: string, requestId: string): PendingPermission {
const request: AgentPermissionRequest = {
id: requestId,
provider: "codex",
name: `permission-${requestId}`,
kind: "tool",
};
const key = `${agentId}:${requestId}`;
return {
key,
agentId,
request,
};
}
function updatePendingPermissions(permissions: PendingPermission[]) {
act(() => {
useSessionStore
.getState()
.setPendingPermissions(
"server",
new Map(permissions.map((permission) => [permission.key, permission])),
);
});
}
async function updateCurrentAgentCwd(cwd: string) {
await act(async () => {
useSessionStore.getState().setAgents("server", (previous) => {
const current = previous.get("agent");
if (!current) {
throw new Error("Expected seeded agent");
}
const next = new Map(previous);
next.set("agent", { ...current, cwd });
return next;
});
await Promise.resolve();
});
}
describe("AgentPanel render isolation", () => {
let root: Root | null = null;
let container: HTMLElement | null = null;
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container?.remove();
container = null;
useSessionStore.setState({ sessions: {}, agentLastActivity: new Map() });
useDraftStore.setState({ drafts: {}, createModalDraft: null });
composerRenderCount.mockClear();
composerUnmountCount.mockClear();
latestComposerCwd.current = null;
latestComposerIsPaneFocused.current = null;
streamRenderCount.mockClear();
latestStreamPermissionKeys.current = [];
latestStreamText.current = null;
});
it("refreshes the stream view without invoking Composer for stream-only updates", async () => {
seedReadyAgent();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await renderAgentPanel(root);
const composerBaseline = composerRenderCount.mock.calls.length;
const streamBaseline = streamRenderCount.mock.calls.length;
expect(composerBaseline).toBeGreaterThan(0);
expect(streamBaseline).toBeGreaterThan(0);
updateCurrentAgentStream("stream-only update");
expect(latestStreamText.current).toBe("stream-only update");
expect(streamRenderCount).toHaveBeenCalledTimes(streamBaseline + 1);
expect(composerRenderCount).toHaveBeenCalledTimes(composerBaseline);
});
it("does not advertise the composer as focused when its workspace is hidden", async () => {
seedReadyAgent();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await renderAgentPanel(root, {
isWorkspaceFocused: false,
isPaneFocused: true,
isInteractive: false,
});
expect(latestComposerIsPaneFocused.current).toBe(false);
});
it("still invokes Composer for current-agent cwd changes and unmounts it for archives", async () => {
seedReadyAgent();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await renderAgentPanel(root);
expect(latestComposerCwd.current).toBe("/workspace/one");
const composerBaseline = composerRenderCount.mock.calls.length;
expect(composerBaseline).toBeGreaterThan(0);
await updateCurrentAgentCwd("/workspace/two");
expect(latestComposerCwd.current).toBe("/workspace/two");
expect(composerRenderCount.mock.calls.length).toBeGreaterThan(composerBaseline);
await act(async () => {
useSessionStore.getState().setAgents("server", (previous) => {
const current = previous.get("agent");
if (!current) {
throw new Error("Expected seeded agent");
}
const next = new Map(previous);
next.set("agent", { ...current, archivedAt: new Date("2026-04-20T00:00:02.000Z") });
return next;
});
await Promise.resolve();
});
expect(composerUnmountCount).toHaveBeenCalledTimes(1);
});
it("keeps stream permissions stable for unrelated pending permission updates", async () => {
seedReadyAgent();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await renderAgentPanel(root);
const initialRenderCount = streamRenderCount.mock.calls.length;
expect(initialRenderCount).toBeGreaterThan(0);
expect(latestStreamPermissionKeys.current).toEqual([]);
const unrelatedPermission = makePendingPermission("other-agent", "unrelated");
updatePendingPermissions([unrelatedPermission]);
expect(latestStreamPermissionKeys.current).toEqual([]);
expect(streamRenderCount).toHaveBeenCalledTimes(initialRenderCount);
const agentPermission = makePendingPermission("agent", "current-agent");
updatePendingPermissions([unrelatedPermission, agentPermission]);
expect(latestStreamPermissionKeys.current).toEqual(["agent:current-agent"]);
expect(streamRenderCount).toHaveBeenCalledTimes(initialRenderCount + 1);
updatePendingPermissions([unrelatedPermission]);
expect(latestStreamPermissionKeys.current).toEqual([]);
expect(streamRenderCount).toHaveBeenCalledTimes(initialRenderCount + 2);
});
});
+347 -166
View File
@@ -1,8 +1,9 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ActivityIndicator, Text, View } from "react-native"; import { ActivityIndicator, Text, View } from "react-native";
import ReanimatedAnimated from "react-native-reanimated"; import ReanimatedAnimated from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useShallow } from "zustand/shallow"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { shallow, useShallow } from "zustand/shallow";
import { useStoreWithEqualityFn } from "zustand/traditional"; import { useStoreWithEqualityFn } from "zustand/traditional";
import invariant from "tiny-invariant"; import invariant from "tiny-invariant";
import { AgentStreamView, type AgentStreamViewHandle } from "@/components/agent-stream-view"; import { AgentStreamView, type AgentStreamViewHandle } from "@/components/agent-stream-view";
@@ -23,7 +24,7 @@ import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft"; import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useStableEvent } from "@/hooks/use-stable-event"; import { useStableEvent } from "@/hooks/use-stable-event";
import { usePaneContext } from "@/panels/pane-context"; import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
import { import {
useHostRuntimeClient, useHostRuntimeClient,
@@ -111,7 +112,8 @@ function useAgentPanelDescriptor(
} }
function AgentPanel() { function AgentPanel() {
const { serverId, target, isPaneFocused, openFileInWorkspace } = usePaneContext(); const { serverId, target, openFileInWorkspace } = usePaneContext();
const { isInteractive } = usePaneFocus();
invariant(target.kind === "agent", "AgentPanel requires agent target"); invariant(target.kind === "agent", "AgentPanel requires agent target");
function openWorkspaceFile(input: { filePath: string }) { function openWorkspaceFile(input: { filePath: string }) {
@@ -124,7 +126,7 @@ function AgentPanel() {
<AgentPanelContent <AgentPanelContent
serverId={serverId} serverId={serverId}
agentId={target.agentId} agentId={target.agentId}
isPaneFocused={isPaneFocused} isPaneFocused={isInteractive}
onOpenWorkspaceFile={handleOpenWorkspaceFile} onOpenWorkspaceFile={handleOpenWorkspaceFile}
/> />
); );
@@ -137,6 +139,12 @@ export const agentPanelRegistration: PanelRegistration<"agent"> = {
}; };
const EMPTY_STREAM_ITEMS: StreamItem[] = []; const EMPTY_STREAM_ITEMS: StreamItem[] = [];
const EMPTY_PENDING_PERMISSIONS = new Map<string, PendingPermission>();
const EMPTY_PENDING_PERMISSION_LIST: PendingPermission[] = [];
type RouteBottomAnchorRequest = ReturnType<typeof deriveRouteBottomAnchorRequest>;
type PendingCreateByDraftId = ReturnType<typeof useCreateFlowStore.getState>["pendingByDraftId"];
type PendingCreateAttempt = PendingCreateByDraftId[string];
function logWebStickyBottom(_event: string, _details: Record<string, unknown>): void {} function logWebStickyBottom(_event: string, _details: Record<string, unknown>): void {}
@@ -151,6 +159,27 @@ function isNotFoundErrorMessage(message: string): boolean {
return /agent not found|not found/i.test(message); return /agent not found|not found/i.test(message);
} }
function findPendingCreateForPanel(input: {
pendingByDraftId: PendingCreateByDraftId;
serverId: string;
agentId?: string;
}): PendingCreateAttempt | null {
if (!input.agentId) {
return null;
}
const values = Object.values(input.pendingByDraftId);
for (const entry of values) {
if (
entry.lifecycle === "active" &&
entry.serverId === input.serverId &&
entry.agentId === input.agentId
) {
return entry;
}
}
return null;
}
type AgentLookupState = type AgentLookupState =
| { tag: "idle" } | { tag: "idle" }
| { tag: "loading" } | { tag: "loading" }
@@ -455,26 +484,13 @@ function ChatAgentContent({
}; };
}), }),
); );
const agentInputDraft = useAgentInputDraft({
draftKey: buildDraftStoreKey({
serverId,
agentId: agentId ?? "__pending__",
}),
initialCwd: agentState.cwd ?? "",
});
const projectPlacement = useStoreWithEqualityFn( const projectPlacement = useStoreWithEqualityFn(
useSessionStore, useSessionStore,
(state) => (state) =>
agentId ? (state.sessions[serverId]?.agents?.get(agentId)?.projectPlacement ?? null) : null, agentId ? (state.sessions[serverId]?.agents?.get(agentId)?.projectPlacement ?? null) : null,
(a, b) => a === b || JSON.stringify(a) === JSON.stringify(b), (a, b) => a === b || JSON.stringify(a) === JSON.stringify(b),
); );
const streamItemsRaw = useSessionStore((state) =>
agentId ? state.sessions[serverId]?.agentStreamTail?.get(agentId) : undefined,
);
const streamItems = streamItemsRaw ?? EMPTY_STREAM_ITEMS;
const pendingByDraftId = useCreateFlowStore((state) => state.pendingByDraftId); const pendingByDraftId = useCreateFlowStore((state) => state.pendingByDraftId);
const markPendingCreateLifecycle = useCreateFlowStore((state) => state.markLifecycle);
const clearPendingCreate = useCreateFlowStore((state) => state.clear);
const isInitializingFromMap = useSessionStore((state) => const isInitializingFromMap = useSessionStore((state) =>
agentId ? (state.sessions[serverId]?.initializingAgents?.get(agentId) ?? false) : false, agentId ? (state.sessions[serverId]?.initializingAgents?.get(agentId) ?? false) : false,
); );
@@ -489,11 +505,7 @@ function ChatAgentContent({
const agentHistorySyncGeneration = useSessionStore((state) => const agentHistorySyncGeneration = useSessionStore((state) =>
agentId ? (state.sessions[serverId]?.agentHistorySyncGeneration?.get(agentId) ?? -1) : -1, agentId ? (state.sessions[serverId]?.agentHistorySyncGeneration?.get(agentId) ?? -1) : -1,
); );
const allPendingPermissions = useSessionStore(
(state) => state.sessions[serverId]?.pendingPermissions,
);
const setAgents = useSessionStore((state) => state.setAgents); const setAgents = useSessionStore((state) => state.setAgents);
const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail);
const setPendingPermissions = useSessionStore((state) => state.setPendingPermissions); const setPendingPermissions = useSessionStore((state) => state.setPendingPermissions);
const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId])); const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId]));
const { ensureAgentIsInitialized } = useAgentInitialization({ const { ensureAgentIsInitialized } = useAgentInitialization({
@@ -504,38 +516,13 @@ function ChatAgentContent({
kind: "idle", kind: "idle",
}); });
const pendingCreate = useMemo(() => { const pendingCreate = useMemo(
if (!agentId) { () => findPendingCreateForPanel({ pendingByDraftId, serverId, agentId }),
return null; [agentId, pendingByDraftId, serverId],
} );
const values = Object.values(pendingByDraftId);
for (const entry of values) {
if (
entry.lifecycle === "active" &&
entry.serverId === serverId &&
entry.agentId === agentId
) {
return entry;
}
}
return null;
}, [agentId, pendingByDraftId, serverId]);
const isPendingCreateForPanel = Boolean(pendingCreate); const isPendingCreateForPanel = Boolean(pendingCreate);
const hasHydratedHistoryBefore = hasAppliedAuthoritativeHistory; const hasHydratedHistoryBefore = hasAppliedAuthoritativeHistory;
const pendingPermissions = useMemo(() => {
if (!allPendingPermissions || !agentId) {
return new Map<string, PendingPermission>();
}
const filtered = new Map<string, PendingPermission>();
for (const [key, permission] of allPendingPermissions) {
if (permission.agentId === agentId) {
filtered.set(key, permission);
}
}
return filtered;
}, [agentId, allPendingPermissions]);
const attentionController = useAgentAttentionClear({ const attentionController = useAgentAttentionClear({
agentId, agentId,
client, client,
@@ -640,38 +627,7 @@ function ChatAgentContent({
return agentHistorySyncGeneration < historySyncGeneration; return agentHistorySyncGeneration < historySyncGeneration;
}, [agentHistorySyncGeneration, agentId, historySyncGeneration]); }, [agentHistorySyncGeneration, agentId, historySyncGeneration]);
const optimisticStreamItems = useMemo<StreamItem[]>(() => { const shouldUseOptimisticStream = isPendingCreateForPanel;
if (!isPendingCreateForPanel || !pendingCreate) {
return EMPTY_STREAM_ITEMS;
}
return [
{
kind: "user_message",
id: pendingCreate.clientMessageId,
text: pendingCreate.text,
timestamp: new Date(pendingCreate.timestamp),
...(pendingCreate.images && pendingCreate.images.length > 0
? { images: pendingCreate.images }
: {}),
},
];
}, [isPendingCreateForPanel, pendingCreate]);
const mergedStreamItems = useMemo<StreamItem[]>(() => {
if (optimisticStreamItems.length === 0) {
return streamItems;
}
const optimistic = optimisticStreamItems[0];
if (!optimistic) {
return streamItems;
}
const alreadyHasOptimistic = streamItems.some(
(item) => item.kind === "user_message" && item.id === optimistic.id,
);
return alreadyHasOptimistic ? streamItems : [...optimisticStreamItems, ...streamItems];
}, [optimisticStreamItems, streamItems]);
const shouldUseOptimisticStream = isPendingCreateForPanel && optimisticStreamItems.length > 0;
const authoritativeStatus = agentState.status; const authoritativeStatus = agentState.status;
const isAuthoritativeBootstrapping = const isAuthoritativeBootstrapping =
authoritativeStatus === "initializing" || authoritativeStatus === "idle"; authoritativeStatus === "initializing" || authoritativeStatus === "idle";
@@ -745,52 +701,29 @@ function ChatAgentContent({
[effectiveAgent?.id], [effectiveAgent?.id],
); );
useEffect(() => { const handleComposerHeightChange = useCallback(
if (!isPendingCreateForPanel || !pendingCreate) { (height: number) => {
if (!agentId) {
return;
}
logWebStickyBottom("screen_composer_height_change", {
agentId,
height,
});
streamViewRef.current?.prepareForViewportChange();
},
[agentId],
);
const handleMessageSent = useCallback(() => {
if (!agentId) {
return; return;
} }
const hasUserMessage = streamItems.some( logWebStickyBottom("screen_message_sent_scroll_to_bottom", {
(item) => item.kind === "user_message" && item.id === pendingCreate.clientMessageId, agentId,
); });
if (hasUserMessage && canFinalizePendingCreate) { streamViewRef.current?.scrollToBottom("message-sent");
if (agentId && pendingCreate.images && pendingCreate.images.length > 0) { }, [agentId]);
setAgentStreamTail(serverId, (previous) => {
const current = previous.get(agentId);
if (!current) {
return previous;
}
const merged = mergePendingCreateImages({
streamItems: current,
clientMessageId: pendingCreate.clientMessageId,
images: pendingCreate.images,
});
if (merged === current) {
return previous;
}
const next = new Map(previous);
next.set(agentId, merged);
return next;
});
}
markPendingCreateLifecycle({
draftId: pendingCreate.draftId,
lifecycle: "sent",
});
clearPendingCreate({ draftId: pendingCreate.draftId });
}
}, [
agentId,
canFinalizePendingCreate,
clearPendingCreate,
isPendingCreateForPanel,
markPendingCreateLifecycle,
pendingCreate,
serverId,
setAgentStreamTail,
streamItems,
]);
useEffect(() => { useEffect(() => {
if (!agentId) { if (!agentId) {
@@ -947,53 +880,35 @@ function ChatAgentContent({
<View style={styles.container}> <View style={styles.container}>
<View style={styles.contentContainer}> <View style={styles.contentContainer}>
<ReanimatedAnimated.View style={[styles.content, animatedKeyboardStyle]}> <ReanimatedAnimated.View style={[styles.content, animatedKeyboardStyle]}>
<AgentStreamView <AgentStreamSection
ref={streamViewRef} streamViewRef={streamViewRef}
agentId={effectiveAgent.id}
serverId={serverId} serverId={serverId}
agentId={agentId}
agent={effectiveAgent} agent={effectiveAgent}
streamItems={shouldUseOptimisticStream ? mergedStreamItems : streamItems} pendingCreate={pendingCreate}
pendingPermissions={pendingPermissions} shouldUseOptimisticStream={shouldUseOptimisticStream}
canFinalizePendingCreate={canFinalizePendingCreate}
routeBottomAnchorRequest={routeBottomAnchorRequest} routeBottomAnchorRequest={routeBottomAnchorRequest}
isAuthoritativeHistoryReady={hasAppliedAuthoritativeHistory} hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
onOpenWorkspaceFile={onOpenWorkspaceFile} onOpenWorkspaceFile={onOpenWorkspaceFile}
/> />
</ReanimatedAnimated.View> </ReanimatedAnimated.View>
</View> </View>
{agentId && !isArchivingCurrentAgent && !agentState.archivedAt ? ( <AgentComposerSection
<Composer agentId={agentId}
agentId={agentId} serverId={serverId}
serverId={serverId} isPaneFocused={isPaneFocused}
isPaneFocused={isPaneFocused} isArchivingCurrentAgent={isArchivingCurrentAgent}
value={agentInputDraft.text} archivedAt={agentState.archivedAt}
onChangeText={agentInputDraft.setText} initialCwd={agentState.cwd ?? ""}
attachments={agentInputDraft.attachments} isSubmitLoading={showPendingCreateSubmitLoading}
onChangeAttachments={agentInputDraft.setAttachments} onAttentionInputFocus={attentionController.clearOnInputFocus}
cwd={agentInputDraft.cwd} onAttentionPromptSend={attentionController.clearOnPromptSend}
clearDraft={agentInputDraft.clear} onAddImages={handleAddImagesCallback}
autoFocus={isPaneFocused} onComposerHeightChange={handleComposerHeightChange}
isSubmitLoading={showPendingCreateSubmitLoading} onMessageSent={handleMessageSent}
onAttentionInputFocus={attentionController.clearOnInputFocus} />
onAttentionPromptSend={attentionController.clearOnPromptSend}
onAddImages={handleAddImagesCallback}
onComposerHeightChange={(height) => {
logWebStickyBottom("screen_composer_height_change", {
agentId,
height,
});
streamViewRef.current?.prepareForViewportChange();
}}
onMessageSent={() => {
logWebStickyBottom("screen_message_sent_scroll_to_bottom", {
agentId,
});
streamViewRef.current?.scrollToBottom("message-sent");
}}
/>
) : agentId && agentState.archivedAt ? (
<ArchivedAgentCallout serverId={serverId} agentId={agentId} />
) : null}
{viewState.tag === "ready" && {viewState.tag === "ready" &&
viewState.sync.status === "catching_up" && viewState.sync.status === "catching_up" &&
@@ -1022,6 +937,268 @@ function ChatAgentContent({
); );
} }
function AgentStreamSection({
streamViewRef,
serverId,
agentId,
agent,
pendingCreate,
shouldUseOptimisticStream,
canFinalizePendingCreate,
routeBottomAnchorRequest,
hasAppliedAuthoritativeHistory,
onOpenWorkspaceFile,
}: {
streamViewRef: React.RefObject<AgentStreamViewHandle | null>;
serverId: string;
agentId?: string;
agent: AgentScreenAgent;
pendingCreate: PendingCreateAttempt | null;
shouldUseOptimisticStream: boolean;
canFinalizePendingCreate: boolean;
routeBottomAnchorRequest: RouteBottomAnchorRequest;
hasAppliedAuthoritativeHistory: boolean;
onOpenWorkspaceFile?: (input: { filePath: string }) => void;
}) {
const streamItemsRaw = useSessionStore((state) =>
agentId ? state.sessions[serverId]?.agentStreamTail?.get(agentId) : undefined,
);
const streamItems = streamItemsRaw ?? EMPTY_STREAM_ITEMS;
const pendingPermissionList = useStoreWithEqualityFn(
useSessionStore,
(state) => {
if (!agentId) {
return EMPTY_PENDING_PERMISSION_LIST;
}
const allPendingPermissions = state.sessions[serverId]?.pendingPermissions;
if (!allPendingPermissions) {
return EMPTY_PENDING_PERMISSION_LIST;
}
const filtered: PendingPermission[] = [];
for (const permission of allPendingPermissions.values()) {
if (permission.agentId === agentId) {
filtered.push(permission);
}
}
return filtered.length > 0 ? filtered : EMPTY_PENDING_PERMISSION_LIST;
},
shallow,
);
const pendingPermissions = useMemo(() => {
if (pendingPermissionList.length === 0) {
return EMPTY_PENDING_PERMISSIONS;
}
return new Map(pendingPermissionList.map((permission) => [permission.key, permission]));
}, [pendingPermissionList]);
const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail);
const markPendingCreateLifecycle = useCreateFlowStore((state) => state.markLifecycle);
const clearPendingCreate = useCreateFlowStore((state) => state.clear);
const optimisticStreamItems = useMemo<StreamItem[]>(() => {
if (!shouldUseOptimisticStream || !pendingCreate) {
return EMPTY_STREAM_ITEMS;
}
return [
{
kind: "user_message",
id: pendingCreate.clientMessageId,
text: pendingCreate.text,
timestamp: new Date(pendingCreate.timestamp),
...(pendingCreate.images && pendingCreate.images.length > 0
? { images: pendingCreate.images }
: {}),
},
];
}, [pendingCreate, shouldUseOptimisticStream]);
const mergedStreamItems = useMemo<StreamItem[]>(() => {
if (optimisticStreamItems.length === 0) {
return streamItems;
}
const optimistic = optimisticStreamItems[0];
if (!optimistic) {
return streamItems;
}
const alreadyHasOptimistic = streamItems.some(
(item) => item.kind === "user_message" && item.id === optimistic.id,
);
return alreadyHasOptimistic ? streamItems : [...optimisticStreamItems, ...streamItems];
}, [optimisticStreamItems, streamItems]);
useEffect(() => {
if (!shouldUseOptimisticStream || !pendingCreate) {
return;
}
const hasUserMessage = streamItems.some(
(item) => item.kind === "user_message" && item.id === pendingCreate.clientMessageId,
);
if (!hasUserMessage || !canFinalizePendingCreate) {
return;
}
const pendingImages = pendingCreate.images;
if (agentId && pendingImages && pendingImages.length > 0) {
setAgentStreamTail(serverId, (previous) => {
const current = previous.get(agentId);
if (!current) {
return previous;
}
const merged = mergePendingCreateImages({
streamItems: current,
clientMessageId: pendingCreate.clientMessageId,
images: pendingImages,
});
if (merged === current) {
return previous;
}
const next = new Map(previous);
next.set(agentId, merged);
return next;
});
}
markPendingCreateLifecycle({
draftId: pendingCreate.draftId,
lifecycle: "sent",
});
clearPendingCreate({ draftId: pendingCreate.draftId });
}, [
agentId,
canFinalizePendingCreate,
clearPendingCreate,
markPendingCreateLifecycle,
pendingCreate,
serverId,
setAgentStreamTail,
shouldUseOptimisticStream,
streamItems,
]);
return (
<AgentStreamView
ref={streamViewRef}
agentId={agent.id}
serverId={serverId}
agent={agent}
streamItems={shouldUseOptimisticStream ? mergedStreamItems : streamItems}
pendingPermissions={pendingPermissions}
routeBottomAnchorRequest={routeBottomAnchorRequest}
isAuthoritativeHistoryReady={hasAppliedAuthoritativeHistory}
onOpenWorkspaceFile={onOpenWorkspaceFile}
/>
);
}
function AgentComposerSection({
agentId,
serverId,
isPaneFocused,
isArchivingCurrentAgent,
archivedAt,
initialCwd,
isSubmitLoading,
onAttentionInputFocus,
onAttentionPromptSend,
onAddImages,
onComposerHeightChange,
onMessageSent,
}: {
agentId?: string;
serverId: string;
isPaneFocused: boolean;
isArchivingCurrentAgent: boolean;
archivedAt: Date | null;
initialCwd: string;
isSubmitLoading: boolean;
onAttentionInputFocus: () => void;
onAttentionPromptSend: () => void;
onAddImages: (addImages: (images: ImageAttachment[]) => void) => void;
onComposerHeightChange: (height: number) => void;
onMessageSent: () => void;
}) {
if (!agentId) {
return null;
}
if (archivedAt) {
return <ArchivedAgentCallout serverId={serverId} agentId={agentId} />;
}
if (isArchivingCurrentAgent) {
return null;
}
return (
<ActiveAgentComposer
agentId={agentId}
serverId={serverId}
isPaneFocused={isPaneFocused}
initialCwd={initialCwd}
isSubmitLoading={isSubmitLoading}
onAttentionInputFocus={onAttentionInputFocus}
onAttentionPromptSend={onAttentionPromptSend}
onAddImages={onAddImages}
onComposerHeightChange={onComposerHeightChange}
onMessageSent={onMessageSent}
/>
);
}
function ActiveAgentComposer({
agentId,
serverId,
isPaneFocused,
initialCwd,
isSubmitLoading,
onAttentionInputFocus,
onAttentionPromptSend,
onAddImages,
onComposerHeightChange,
onMessageSent,
}: {
agentId: string;
serverId: string;
isPaneFocused: boolean;
initialCwd: string;
isSubmitLoading: boolean;
onAttentionInputFocus: () => void;
onAttentionPromptSend: () => void;
onAddImages: (addImages: (images: ImageAttachment[]) => void) => void;
onComposerHeightChange: (height: number) => void;
onMessageSent: () => void;
}) {
const insets = useSafeAreaInsets();
const agentInputDraft = useAgentInputDraft({
draftKey: buildDraftStoreKey({
serverId,
agentId,
}),
initialCwd,
});
return (
<View style={[styles.inputAreaWrapper, { paddingBottom: insets.bottom }]}>
<Composer
agentId={agentId}
serverId={serverId}
isPaneFocused={isPaneFocused}
value={agentInputDraft.text}
onChangeText={agentInputDraft.setText}
attachments={agentInputDraft.attachments}
onChangeAttachments={agentInputDraft.setAttachments}
cwd={agentInputDraft.cwd}
clearDraft={agentInputDraft.clear}
autoFocus={isPaneFocused}
isSubmitLoading={isSubmitLoading}
onAttentionInputFocus={onAttentionInputFocus}
onAttentionPromptSend={onAttentionPromptSend}
onAddImages={onAddImages}
onComposerHeightChange={onComposerHeightChange}
onMessageSent={onMessageSent}
/>
</View>
);
}
function AgentSessionUnavailableState({ function AgentSessionUnavailableState({
serverLabel, serverLabel,
connectionStatus, connectionStatus,
@@ -1098,6 +1275,10 @@ const styles = StyleSheet.create((theme) => ({
content: { content: {
flex: 1, flex: 1,
}, },
inputAreaWrapper: {
width: "100%",
backgroundColor: theme.colors.surface0,
},
historySyncOverlay: { historySyncOverlay: {
position: "absolute", position: "absolute",
top: 0, top: 0,
+5 -11
View File
@@ -1,7 +1,7 @@
import { SquarePen } from "lucide-react-native"; import { SquarePen } from "lucide-react-native";
import invariant from "tiny-invariant"; import invariant from "tiny-invariant";
import { WorkspaceDraftAgentTab } from "@/screens/workspace/workspace-draft-agent-tab"; import { WorkspaceDraftAgentTab } from "@/screens/workspace/workspace-draft-agent-tab";
import { usePaneContext } from "@/panels/pane-context"; import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
import type { PanelRegistration } from "@/panels/panel-registry"; import type { PanelRegistration } from "@/panels/panel-registry";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
@@ -17,15 +17,9 @@ function useDraftPanelDescriptor() {
} }
function DraftPanel() { function DraftPanel() {
const { const { serverId, workspaceId, tabId, target, openFileInWorkspace, retargetCurrentTab } =
serverId, usePaneContext();
workspaceId, const { isInteractive } = usePaneFocus();
tabId,
target,
isPaneFocused,
openFileInWorkspace,
retargetCurrentTab,
} = usePaneContext();
invariant(target.kind === "draft", "DraftPanel requires draft target"); invariant(target.kind === "draft", "DraftPanel requires draft target");
return ( return (
@@ -34,7 +28,7 @@ function DraftPanel() {
workspaceId={workspaceId} workspaceId={workspaceId}
tabId={tabId} tabId={tabId}
draftId={target.draftId} draftId={target.draftId}
isPaneFocused={isPaneFocused} isPaneFocused={isInteractive}
onOpenWorkspaceFile={({ filePath }) => { onOpenWorkspaceFile={({ filePath }) => {
openFileInWorkspace(filePath); openFileInWorkspace(filePath);
}} }}
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { createPaneFocusContextValue } from "@/panels/pane-context";
describe("createPaneFocusContextValue", () => {
it("derives interactivity from both workspace and pane focus", () => {
expect(
createPaneFocusContextValue({
isWorkspaceFocused: true,
isPaneFocused: true,
}),
).toEqual({
isWorkspaceFocused: true,
isPaneFocused: true,
isInteractive: true,
});
expect(
createPaneFocusContextValue({
isWorkspaceFocused: false,
isPaneFocused: true,
}),
).toEqual({
isWorkspaceFocused: false,
isPaneFocused: true,
isInteractive: false,
});
});
});
+35 -2
View File
@@ -1,4 +1,4 @@
import { createContext, useContext, type ReactNode } from "react"; import React, { createContext, useContext, type ReactNode } from "react";
import invariant from "tiny-invariant"; import invariant from "tiny-invariant";
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
@@ -6,7 +6,6 @@ export interface PaneContextValue {
serverId: string; serverId: string;
workspaceId: string; workspaceId: string;
tabId: string; tabId: string;
isPaneFocused: boolean;
target: WorkspaceTabTarget; target: WorkspaceTabTarget;
openTab(target: WorkspaceTabTarget): void; openTab(target: WorkspaceTabTarget): void;
closeCurrentTab(): void; closeCurrentTab(): void;
@@ -14,7 +13,25 @@ export interface PaneContextValue {
openFileInWorkspace(filePath: string): void; openFileInWorkspace(filePath: string): void;
} }
export interface PaneFocusContextValue {
isWorkspaceFocused: boolean;
isPaneFocused: boolean;
isInteractive: boolean;
}
const PaneContext = createContext<PaneContextValue | null>(null); const PaneContext = createContext<PaneContextValue | null>(null);
const PaneFocusContext = createContext<PaneFocusContextValue | null>(null);
export function createPaneFocusContextValue(input: {
isWorkspaceFocused: boolean;
isPaneFocused: boolean;
}): PaneFocusContextValue {
return {
isWorkspaceFocused: input.isWorkspaceFocused,
isPaneFocused: input.isPaneFocused,
isInteractive: input.isWorkspaceFocused && input.isPaneFocused,
};
}
export function PaneProvider({ export function PaneProvider({
value, value,
@@ -26,8 +43,24 @@ export function PaneProvider({
return <PaneContext.Provider value={value}>{children}</PaneContext.Provider>; return <PaneContext.Provider value={value}>{children}</PaneContext.Provider>;
} }
export function PaneFocusProvider({
value,
children,
}: {
value: PaneFocusContextValue;
children: ReactNode;
}) {
return <PaneFocusContext.Provider value={value}>{children}</PaneFocusContext.Provider>;
}
export function usePaneContext(): PaneContextValue { export function usePaneContext(): PaneContextValue {
const value = useContext(PaneContext); const value = useContext(PaneContext);
invariant(value, "PaneContext is required"); invariant(value, "PaneContext is required");
return value; return value;
} }
export function usePaneFocus(): PaneFocusContextValue {
const value = useContext(PaneFocusContext);
invariant(value, "PaneFocusContext is required");
return value;
}
+39 -19
View File
@@ -1,12 +1,14 @@
import { useCallback } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Terminal } from "lucide-react-native"; import { Terminal } from "lucide-react-native";
import { Text, View } from "react-native"; import { Text, View } from "react-native";
import { useIsFocused } from "@react-navigation/native";
import invariant from "tiny-invariant"; import invariant from "tiny-invariant";
import type { ListTerminalsResponse } from "@server/shared/messages"; import type { ListTerminalsResponse } from "@server/shared/messages";
import { TerminalPane } from "@/components/terminal-pane"; import { TerminalPane } from "@/components/terminal-pane";
import { usePaneContext } from "@/panels/pane-context"; import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
import { queryClient } from "@/query/query-client";
import { usePanelStore } from "@/stores/panel-store";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { useWorkspaceExecutionAuthority } from "@/stores/session-store-hooks"; import { useWorkspaceExecutionAuthority } from "@/stores/session-store-hooks";
@@ -29,21 +31,24 @@ function useTerminalPanelDescriptor(
const workspaceDirectory = workspaceAuthority.ok const workspaceDirectory = workspaceAuthority.ok
? workspaceAuthority.authority.workspaceDirectory ? workspaceAuthority.authority.workspaceDirectory
: null; : null;
const terminalsQuery = useQuery({ const terminalsQuery = useQuery(
queryKey: ["terminals", context.serverId, workspaceDirectory] as const, {
enabled: Boolean(client && workspaceDirectory), queryKey: ["terminals", context.serverId, workspaceDirectory] as const,
queryFn: async (): Promise<ListTerminalsPayload> => { enabled: Boolean(client && workspaceDirectory),
if (!client || !workspaceDirectory) { queryFn: async (): Promise<ListTerminalsPayload> => {
throw new Error( if (!client || !workspaceDirectory) {
workspaceAuthority.ok throw new Error(
? "Workspace execution directory not found" workspaceAuthority.ok
: workspaceAuthority.message, ? "Workspace execution directory not found"
); : workspaceAuthority.message,
} );
return client.listTerminals(workspaceDirectory); }
return client.listTerminals(workspaceDirectory);
},
staleTime: 5_000,
}, },
staleTime: 5_000, queryClient,
}); );
const terminal = const terminal =
terminalsQuery.data?.terminals.find((entry) => entry.id === target.terminalId) ?? null; terminalsQuery.data?.terminals.find((entry) => entry.id === target.terminalId) ?? null;
@@ -57,15 +62,28 @@ function useTerminalPanelDescriptor(
} }
function TerminalPanel() { function TerminalPanel() {
const isFocused = useIsFocused(); const { serverId, workspaceId, target } = usePaneContext();
const { serverId, workspaceId, target, isPaneFocused } = usePaneContext(); const { isWorkspaceFocused, isPaneFocused } = usePaneFocus();
const workspaceAuthority = useWorkspaceExecutionAuthority(serverId, workspaceId)!; const workspaceAuthority = useWorkspaceExecutionAuthority(serverId, workspaceId)!;
const workspaceDirectory = workspaceAuthority.ok const workspaceDirectory = workspaceAuthority.ok
? workspaceAuthority.authority.workspaceDirectory ? workspaceAuthority.authority.workspaceDirectory
: null; : null;
const isGitCheckout = workspaceAuthority.ok
? workspaceAuthority.authority.workspace.projectKind === "git"
: false;
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
const handleOpenFileExplorer = useCallback(() => {
if (!workspaceDirectory) {
return;
}
openFileExplorerForCheckout({
isCompact: true,
checkout: { serverId, cwd: workspaceDirectory, isGit: isGitCheckout },
});
}, [isGitCheckout, openFileExplorerForCheckout, serverId, workspaceDirectory]);
invariant(target.kind === "terminal", "TerminalPanel requires terminal target"); invariant(target.kind === "terminal", "TerminalPanel requires terminal target");
if (!isFocused) { if (!isWorkspaceFocused) {
return <View style={{ flex: 1 }} />; return <View style={{ flex: 1 }} />;
} }
@@ -86,7 +104,9 @@ function TerminalPanel() {
serverId={serverId} serverId={serverId}
cwd={workspaceDirectory} cwd={workspaceDirectory}
terminalId={target.terminalId} terminalId={target.terminalId}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isPaneFocused} isPaneFocused={isPaneFocused}
onOpenFileExplorer={handleOpenFileExplorer}
/> />
); );
} }
+2
View File
@@ -30,6 +30,7 @@ import {
buildLocalDaemonTransportUrl, buildLocalDaemonTransportUrl,
createDesktopLocalDaemonTransportFactory, createDesktopLocalDaemonTransportFactory,
} from "@/desktop/daemon/desktop-daemon-transport"; } from "@/desktop/daemon/desktop-daemon-transport";
import { isDev } from "@/constants/platform";
import { applyFetchedAgentDirectory } from "@/utils/agent-directory-sync"; import { applyFetchedAgentDirectory } from "@/utils/agent-directory-sync";
import { useSessionStore, type Agent } from "@/stores/session-store"; import { useSessionStore, type Agent } from "@/stores/session-store";
@@ -433,6 +434,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
clientType: "mobile" as const, clientType: "mobile" as const,
appVersion: resolveAppVersion() ?? undefined, appVersion: resolveAppVersion() ?? undefined,
runtimeGeneration, runtimeGeneration,
...(isDev ? { runtimeMetricsIntervalMs: 10_000 } : {}),
}; };
if (connection.type === "directSocket" || connection.type === "directPipe") { if (connection.type === "directSocket" || connection.type === "directPipe") {
return new DaemonClient({ return new DaemonClient({
@@ -36,7 +36,8 @@ import {
useHostRuntimeIsConnected, useHostRuntimeIsConnected,
} from "@/runtime/host-runtime"; } from "@/runtime/host-runtime";
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context"; import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
import { usePanelStore, type ExplorerCheckoutContext } from "@/stores/panel-store"; import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
import { type ExplorerCheckoutContext } from "@/stores/explorer-checkout-context";
import { MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout"; import { MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
import { WelcomeScreen } from "@/components/welcome-screen"; import { WelcomeScreen } from "@/components/welcome-screen";
import type { Agent } from "@/contexts/session-context"; import type { Agent } from "@/contexts/session-context";
@@ -51,6 +52,8 @@ import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execut
import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft"; import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
import { useDraftAgentCreateFlow } from "@/hooks/use-draft-agent-create-flow"; import { useDraftAgentCreateFlow } from "@/hooks/use-draft-agent-create-flow";
@@ -235,16 +238,22 @@ function DraftAgentScreenContent({
statusControls, statusControls,
} = composerState; } = composerState;
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView); const isExplorerOpen = usePanelStore((state) =>
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); selectIsFileExplorerOpen(state, { isCompact: isMobile }),
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer); );
const openFileExplorer = usePanelStore((state) => state.openFileExplorer); const canOpenExplorerFromAgentView = usePanelStore(
const closeFileExplorer = usePanelStore((state) => state.closeFileExplorer); (state) =>
const setActiveExplorerCheckout = usePanelStore((state) => state.setActiveExplorerCheckout); state.mobileView === "agent" && !selectIsFileExplorerOpen(state, { isCompact: true }),
);
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer);
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
const toggleFileExplorerForCheckout = usePanelStore(
(state) => state.toggleFileExplorerForCheckout,
);
const activateExplorerTabForCheckout = usePanelStore( const activateExplorerTabForCheckout = usePanelStore(
(state) => state.activateExplorerTabForCheckout, (state) => state.activateExplorerTabForCheckout,
); );
const isExplorerOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
const [worktreeMode, setWorktreeMode] = useState<"none" | "create" | "attach">( const [worktreeMode, setWorktreeMode] = useState<"none" | "create" | "attach">(
initialWorktreeMode, initialWorktreeMode,
@@ -645,23 +654,42 @@ function DraftAgentScreenContent({
if (!draftExplorerCheckout) { if (!draftExplorerCheckout) {
return; return;
} }
activateExplorerTabForCheckout(draftExplorerCheckout); openFileExplorerForCheckout({
openFileExplorer(); isCompact: isMobile,
}, [activateExplorerTabForCheckout, draftExplorerCheckout, openFileExplorer]); checkout: draftExplorerCheckout,
});
}, [draftExplorerCheckout, isMobile, openFileExplorerForCheckout]);
const handleToggleExplorer = useCallback(() => { const handleToggleExplorer = useCallback(() => {
if (!canOpenExplorer) { if (!canOpenExplorer) {
return; return;
} }
if (isExplorerOpen) { toggleFileExplorerForCheckout({
toggleFileExplorer(); isCompact: isMobile,
return; checkout: draftExplorerCheckout,
} });
openExplorerForDraftCheckout(); }, [canOpenExplorer, draftExplorerCheckout, isMobile, toggleFileExplorerForCheckout]);
}, [canOpenExplorer, isExplorerOpen, openExplorerForDraftCheckout, toggleFileExplorer]);
const explorerOpenGesture = useExplorerOpenGesture({ const explorerOpenGesture = useExplorerOpenGesture({
enabled: isMobile && mobileView === "agent" && canOpenExplorer, enabled: isMobile && canOpenExplorerFromAgentView && canOpenExplorer,
onOpen: openExplorerForDraftCheckout, onOpen: openExplorerForDraftCheckout,
}); });
const handleDraftSidebarAction = useCallback(
(action: KeyboardActionDefinition): boolean => {
if (action.id !== "sidebar.toggle.right") {
return false;
}
handleToggleExplorer();
return true;
},
[handleToggleExplorer],
);
useKeyboardActionHandler({
handlerId: `draft-agent-sidebar-actions:${draftIdRef.current}`,
actions: ["sidebar.toggle.right"] as const,
enabled: Boolean(isFocused && canOpenExplorer),
priority: 100,
isActive: () => true,
handle: handleDraftSidebarAction,
});
const hasWorkingDirectorySearch = debouncedWorkingDirSearchQuery.length > 0; const hasWorkingDirectorySearch = debouncedWorkingDirSearchQuery.length > 0;
const workingDirSearchError = const workingDirSearchError =
directorySuggestionsQuery.error instanceof Error directorySuggestionsQuery.error instanceof Error
@@ -778,6 +806,9 @@ function DraftAgentScreenContent({
if (providerDefinitions.length === 0) { if (providerDefinitions.length === 0) {
return "No available providers on the selected host"; return "No available providers on the selected host";
} }
if (!composerState.selectedProvider) {
return "Select a model";
}
if (gitBlockingError) { if (gitBlockingError) {
return gitBlockingError; return gitBlockingError;
} }
@@ -818,6 +849,9 @@ function DraftAgentScreenContent({
(isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir).trim() || (isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir).trim() ||
"."; ".";
const provider = composerState.selectedProvider; const provider = composerState.selectedProvider;
if (!provider) {
throw new Error("Select a model");
}
const model = effectiveModelId || null; const model = effectiveModelId || null;
const thinkingOptionId = effectiveThinkingOptionId || null; const thinkingOptionId = effectiveThinkingOptionId || null;
const modeId = const modeId =
@@ -861,8 +895,12 @@ function DraftAgentScreenContent({
composerState.modeOptions.length > 0 && composerState.selectedMode !== "" composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
? composerState.selectedMode ? composerState.selectedMode
: undefined; : undefined;
const provider = composerState.selectedProvider;
if (!provider) {
throw new Error("Select a model");
}
const config: AgentSessionConfig = { const config: AgentSessionConfig = {
provider: composerState.selectedProvider, provider,
cwd: resolvedWorkingDir, cwd: resolvedWorkingDir,
...(modeId ? { modeId } : {}), ...(modeId ? { modeId } : {}),
...(effectiveModelId ? { model: effectiveModelId } : {}), ...(effectiveModelId ? { model: effectiveModelId } : {}),
@@ -940,12 +978,6 @@ function DraftAgentScreenContent({
router.replace(route as any); router.replace(route as any);
}, },
}); });
useEffect(() => {
if (!isFocused) {
return;
}
setActiveExplorerCheckout(draftExplorerCheckout);
}, [draftExplorerCheckout, isFocused, setActiveExplorerCheckout]);
useEffect(() => { useEffect(() => {
if (!isFocused || !draftExplorerCheckout) { if (!isFocused || !draftExplorerCheckout) {
return; return;
@@ -956,14 +988,19 @@ function DraftAgentScreenContent({
if (!isFocused || canOpenExplorer || !isExplorerOpen) { if (!isFocused || canOpenExplorer || !isExplorerOpen) {
return; return;
} }
closeFileExplorer(); if (isMobile) {
}, [canOpenExplorer, closeFileExplorer, isExplorerOpen, isFocused]); showMobileAgent();
useEffect(() => { return;
return () => { }
setActiveExplorerCheckout(null); closeDesktopFileExplorer();
}; }, [
}, [setActiveExplorerCheckout]); canOpenExplorer,
closeDesktopFileExplorer,
isExplorerOpen,
isFocused,
isMobile,
showMobileAgent,
]);
if (daemons.length === 0) { if (daemons.length === 0) {
return ( return (
<WelcomeScreen <WelcomeScreen
@@ -1194,7 +1231,7 @@ function DraftAgentScreenContent({
</ScrollView> </ScrollView>
)} )}
</Animated.View> </Animated.View>
<View style={styles.inputAreaWrapper}> <View style={[styles.inputAreaWrapper, { paddingBottom: insets.bottom }]}>
<Composer <Composer
agentId={draftAgentIdRef.current} agentId={draftAgentIdRef.current}
serverId={selectedServerId ?? ""} serverId={selectedServerId ?? ""}
@@ -145,6 +145,10 @@ vi.mock("@/constants/layout", () => ({
useIsCompactFormFactor: () => false, useIsCompactFormFactor: () => false,
})); }));
vi.mock("react-native-safe-area-context", () => ({
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
}));
vi.mock("lucide-react-native", () => { vi.mock("lucide-react-native", () => {
const createIcon = (name: string) => (props: Record<string, unknown>) => const createIcon = (name: string) => (props: Record<string, unknown>) =>
React.createElement("span", { ...props, "data-icon": name }); React.createElement("span", { ...props, "data-icon": name });
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Pressable, Text, View } from "react-native"; import { Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { createNameId } from "mnemonic-id"; import { createNameId } from "mnemonic-id";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { ChevronDown, GitBranch, GitPullRequest } from "lucide-react-native"; import { ChevronDown, GitBranch, GitPullRequest } from "lucide-react-native";
@@ -12,12 +13,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { SidebarMenuToggle } from "@/components/headers/menu-header"; import { SidebarMenuToggle } from "@/components/headers/menu-header";
import { ScreenHeader } from "@/components/headers/screen-header"; import { ScreenHeader } from "@/components/headers/screen-header";
import { import { HEADER_INNER_HEIGHT, MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
HEADER_INNER_HEIGHT,
HEADER_INNER_HEIGHT_MOBILE,
HEADER_TOP_PADDING_MOBILE,
MAX_CONTENT_WIDTH,
} from "@/constants/layout";
import { useToast } from "@/contexts/toast-context"; import { useToast } from "@/contexts/toast-context";
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft"; import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
@@ -107,6 +103,8 @@ export function NewWorkspaceScreen({
displayName: displayNameProp, displayName: displayNameProp,
}: NewWorkspaceScreenProps) { }: NewWorkspaceScreenProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const isCompact = useIsCompactFormFactor();
const toast = useToast(); const toast = useToast();
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces); const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
const setAgents = useSessionStore((state) => state.setAgents); const setAgents = useSessionStore((state) => state.setAgents);
@@ -328,6 +326,9 @@ export function NewWorkspaceScreen({
if (!composerState) { if (!composerState) {
throw new Error("Composer state is required"); throw new Error("Composer state is required");
} }
if (!composerState.selectedProvider) {
throw new Error("Select a model");
}
const initialPrompt = text.trim(); const initialPrompt = text.trim();
const encodedImages = await encodeImages(images); const encodedImages = await encodeImages(images);
@@ -453,7 +454,13 @@ export function NewWorkspaceScreen({
leftStyle={styles.headerLeft} leftStyle={styles.headerLeft}
borderless borderless
/> />
<View style={styles.content}> <View
style={[
styles.content,
isCompact ? styles.contentCompact : styles.contentCentered,
isCompact ? { paddingBottom: insets.bottom } : null,
]}
>
<TitlebarDragRegion /> <TitlebarDragRegion />
<View style={styles.centered}> <View style={styles.centered}>
<Composer <Composer
@@ -567,12 +574,14 @@ const styles = StyleSheet.create((theme) => ({
content: { content: {
position: "relative", position: "relative",
flex: 1, flex: 1,
justifyContent: "center",
alignItems: "center", alignItems: "center",
paddingBottom: { },
xs: HEADER_INNER_HEIGHT_MOBILE + HEADER_TOP_PADDING_MOBILE + theme.spacing[6], contentCentered: {
md: HEADER_INNER_HEIGHT + theme.spacing[6], justifyContent: "center",
}, paddingBottom: HEADER_INNER_HEIGHT + theme.spacing[6],
},
contentCompact: {
justifyContent: "flex-end",
}, },
centered: { centered: {
width: "100%", width: "100%",
@@ -20,7 +20,7 @@ import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
import { PairDeviceModal } from "@/desktop/components/pair-device-modal"; import { PairDeviceModal } from "@/desktop/components/pair-device-modal";
export function OpenProjectScreen({ serverId }: { serverId: string }) { export function OpenProjectScreen({ serverId }: { serverId: string }) {
const openAgentList = usePanelStore((s) => s.openAgentList); const openDesktopAgentList = usePanelStore((s) => s.openDesktopAgentList);
const openProjectPicker = useOpenProjectPicker(serverId); const openProjectPicker = useOpenProjectPicker(serverId);
const hasHydrated = useSessionStore((s) => s.sessions[serverId]?.hasHydratedWorkspaces ?? false); const hasHydrated = useSessionStore((s) => s.sessions[serverId]?.hasHydratedWorkspaces ?? false);
const hasProjects = useHasWorkspaces(serverId); const hasProjects = useHasWorkspaces(serverId);
@@ -31,9 +31,9 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
useEffect(() => { useEffect(() => {
if (!isCompactLayout) { if (!isCompactLayout) {
openAgentList(); openDesktopAgentList();
} }
}, [isCompactLayout, openAgentList]); }, [isCompactLayout, openDesktopAgentList]);
return ( return (
<View style={styles.container}> <View style={styles.container}>
+66 -38
View File
@@ -281,6 +281,7 @@ function AboutSection({ appVersionText, isDesktopApp }: AboutSectionProps) {
} }
function DesktopAppUpdateRow() { function DesktopAppUpdateRow() {
const { settings, updateSettings } = useAppSettings();
const { const {
isDesktopApp, isDesktopApp,
statusText, statusText,
@@ -309,6 +310,13 @@ function DesktopAppUpdateRow() {
void checkForUpdates(); void checkForUpdates();
}, [checkForUpdates, isDesktopApp]); }, [checkForUpdates, isDesktopApp]);
const handleReleaseChannelChange = useCallback(
(releaseChannel: AppSettings["releaseChannel"]) => {
void updateSettings({ releaseChannel });
},
[updateSettings],
);
const handleInstallUpdate = useCallback(() => { const handleInstallUpdate = useCallback(() => {
if (!isDesktopApp) { if (!isDesktopApp) {
return; return;
@@ -316,7 +324,7 @@ function DesktopAppUpdateRow() {
void confirmDialog({ void confirmDialog({
title: "Install desktop update", title: "Install desktop update",
message: "This updates Paseo on this computer.", message: "This updates Paseo on this computer",
confirmLabel: "Install update", confirmLabel: "Install update",
cancelLabel: "Cancel", cancelLabel: "Cancel",
}) })
@@ -337,40 +345,59 @@ function DesktopAppUpdateRow() {
} }
return ( return (
<View style={[settingsStyles.row, settingsStyles.rowBorder]}> <>
<View style={settingsStyles.rowContent}> <View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<Text style={settingsStyles.rowTitle}>App updates</Text> <View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowHint}>{statusText}</Text> <Text style={settingsStyles.rowTitle}>Release channel</Text>
{availableUpdate?.latestVersion ? (
<Text style={settingsStyles.rowHint}> <Text style={settingsStyles.rowHint}>
Ready to install: {formatVersionWithPrefix(availableUpdate.latestVersion)} Switch to Beta to get updates sooner and help shape them
</Text> </Text>
) : null} </View>
{errorMessage ? <Text style={styles.aboutErrorText}>{errorMessage}</Text> : null} <SegmentedControl
</View>
<View style={styles.aboutUpdateActions}>
<Button
variant="outline"
size="sm" size="sm"
onPress={handleCheckForUpdates} value={settings.releaseChannel}
disabled={isChecking || isInstalling} onValueChange={handleReleaseChannelChange}
> options={[
{isChecking ? "Checking..." : "Check"} { value: "stable", label: "Stable" },
</Button> { value: "beta", label: "Beta" },
<Button ]}
variant="default" />
size="sm"
onPress={handleInstallUpdate}
disabled={isChecking || isInstalling || !availableUpdate}
>
{isInstalling
? "Installing..."
: availableUpdate?.latestVersion
? `Update to ${formatVersionWithPrefix(availableUpdate.latestVersion)}`
: "Update"}
</Button>
</View> </View>
</View> <View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>App updates</Text>
<Text style={settingsStyles.rowHint}>{statusText}</Text>
{availableUpdate?.latestVersion ? (
<Text style={settingsStyles.rowHint}>
Ready to install: {formatVersionWithPrefix(availableUpdate.latestVersion)}
</Text>
) : null}
{errorMessage ? <Text style={styles.aboutErrorText}>{errorMessage}</Text> : null}
</View>
<View style={styles.aboutUpdateActions}>
<Button
variant="outline"
size="sm"
onPress={handleCheckForUpdates}
disabled={isChecking || isInstalling}
>
{isChecking ? "Checking..." : "Check"}
</Button>
<Button
variant="default"
size="sm"
onPress={handleInstallUpdate}
disabled={isChecking || isInstalling || !availableUpdate}
>
{isInstalling
? "Installing..."
: availableUpdate?.latestVersion
? `Update to ${formatVersionWithPrefix(availableUpdate.latestVersion)}`
: "Update"}
</Button>
</View>
</View>
</>
); );
} }
@@ -858,9 +885,10 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
layout="desktop" layout="desktop"
/> />
<View style={desktopStyles.contentPane}> <View style={desktopStyles.contentPane}>
{detailHeader ? ( <ScreenHeader
<ScreenHeader borderless={!detailHeader}
left={ left={
detailHeader ? (
<> <>
<HeaderIconBadge> <HeaderIconBadge>
<detailHeader.Icon <detailHeader.Icon
@@ -873,10 +901,10 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
</ScreenTitle> </ScreenTitle>
{detailHeader.titleAccessory} {detailHeader.titleAccessory}
</> </>
} ) : null
leftStyle={desktopStyles.detailLeft} }
/> leftStyle={desktopStyles.detailLeft}
) : null} />
<ScrollView <ScrollView
style={styles.scrollView} style={styles.scrollView}
contentContainerStyle={{ paddingBottom: insets.bottom }} contentContainerStyle={{ paddingBottom: insets.bottom }}
@@ -114,7 +114,7 @@ export function HostPage({ serverId, onHostRemoved }: HostPageProps) {
return ( return (
<View testID={`settings-host-page-${serverId}`}> <View testID={`settings-host-page-${serverId}`}>
<View style={[settingsStyles.card, styles.emptyCard]}> <View style={[settingsStyles.card, styles.emptyCard]}>
<Text style={styles.emptyText}>Host not found.</Text> <Text style={styles.emptyText}>Host not found</Text>
</View> </View>
</View> </View>
); );
@@ -591,7 +591,7 @@ function PairDeviceRow() {
<View style={settingsStyles.rowContent}> <View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Pair a device</Text> <Text style={settingsStyles.rowTitle}>Pair a device</Text>
<Text style={settingsStyles.rowHint}> <Text style={settingsStyles.rowHint}>
Scan a QR code or copy a link to connect your phone to this host. Scan a QR code or copy a link to connect your phone to this host
</Text> </Text>
</View> </View>
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} /> <ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
@@ -619,7 +619,7 @@ function RemoveHostSection({ host, onRemoved }: { host: HostProfile; onRemoved?:
<View style={settingsStyles.rowContent}> <View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Remove host</Text> <Text style={settingsStyles.rowTitle}>Remove host</Text>
<Text style={settingsStyles.rowHint}> <Text style={settingsStyles.rowHint}>
Removes this host and its saved connections from this device. Removes this host and its saved connections from this device
</Text> </Text>
</View> </View>
<Button <Button
@@ -179,7 +179,7 @@ export function KeyboardShortcutsSection() {
return ( return (
<SettingsSection title="Shortcuts"> <SettingsSection title="Shortcuts">
<View style={[settingsStyles.card, styles.mobileCard]}> <View style={[settingsStyles.card, styles.mobileCard]}>
<Text style={styles.mobileText}>Keyboard shortcuts are only available on desktop.</Text> <Text style={styles.mobileText}>Keyboard shortcuts are only available on desktop</Text>
</View> </View>
</SettingsSection> </SettingsSection>
); );

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