Compare commits

..

47 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

View File

@@ -4,9 +4,9 @@ on:
push:
tags:
- 'v*'
- '!v*-rc.*'
- '!v*-beta.*'
- 'app-v*'
- '!app-v*-rc.*'
- '!app-v*-beta.*'
workflow_dispatch:
jobs:

View File

@@ -66,7 +66,9 @@ jobs:
--repo "${{ github.repository }}" \
--title "Paseo $RELEASE_TAG" \
--generate-notes \
$prerelease_flag
$prerelease_flag || {
echo "Release creation raced with another workflow; continuing."
}
fi
publish-macos:
@@ -145,6 +147,7 @@ jobs:
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
publish_mode="always"
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
publish_args+=("-c.publish.channel=$RELEASE_CHANNEL")
fi
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
with:
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
finalize-mac-manifest:
@@ -227,8 +230,9 @@ jobs:
return out;
}
const arm64Text = fs.readFileSync('mac-manifest-arm64/latest-mac.yml', 'utf8');
const x64Text = fs.readFileSync('mac-manifest-x64/latest-mac.yml', 'utf8');
const manifestName = `${process.env.RELEASE_CHANNEL}-mac.yml`;
const arm64Text = fs.readFileSync(`mac-manifest-arm64/${manifestName}`, 'utf8');
const x64Text = fs.readFileSync(`mac-manifest-x64/${manifestName}`, 'utf8');
const arm64 = parseManifest(arm64Text);
const x64 = parseManifest(x64Text);
@@ -243,7 +247,7 @@ jobs:
};
const output = toYaml(merged);
fs.writeFileSync('latest-mac.yml', output);
fs.writeFileSync(manifestName, output);
console.log('Merged manifest:\n' + output);
NODE
@@ -251,7 +255,7 @@ jobs:
if: env.IS_SMOKE_TAG != 'true'
env:
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:
needs: [create-release]
@@ -316,6 +320,7 @@ jobs:
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
publish_mode="always"
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
publish_args+=("-c.publish.channel=$RELEASE_CHANNEL")
fi
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --linux --x64 "${publish_args[@]}"
@@ -391,6 +396,7 @@ jobs:
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
publish_mode="always"
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
publish_args+=("-c.publish.channel=$RELEASE_CHANNEL")
fi
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --win --x64 "${publish_args[@]}"

View File

@@ -54,9 +54,7 @@ jobs:
fi
create_if_missing="false"
if [ "$EVENT_NAME" = "push" ] && [[ "$REF" == refs/tags/v* ]]; then
create_if_missing="true"
elif [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "${INPUT_CREATE_IF_MISSING:-false}" = "true" ]; then
if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "${INPUT_CREATE_IF_MISSING:-false}" = "true" ]; then
create_if_missing="true"
fi

View File

@@ -1,5 +1,11 @@
# 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
### Added

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)
- `.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

View File

@@ -7,7 +7,7 @@ All workspaces share one version and release together.
There are two supported ways to ship from `main`:
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
2. **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)
@@ -37,31 +37,32 @@ npm run release:publish # Publish to npm
npm run release:push # Push HEAD + tag (triggers CI workflows)
```
## Release candidate flow
## Beta flow
```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 ...
npm run release:rc:next # Optional: cut X.Y.Z-rc.2, rc.3, ...
npm run release:promote # Promote X.Y.Z-rc.N to stable X.Y.Z
npm run release:beta:next # Optional: cut X.Y.Z-beta.2, beta.3, ...
npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z
```
- RC tags are published GitHub prereleases like `v0.1.41-rc.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
- `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the RC tag
- Beta tags are published GitHub prereleases like `v0.1.41-beta.1`
- Betas publish desktop assets and APKs for testing, but they do not publish npm packages and do not trigger the production web/mobile release flows
- `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`
- **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
- 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
- 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`.
## 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
git tag -f android-v0.1.28 HEAD && git push origin android-v0.1.28 --force
# RC
git tag -f v0.1.29-rc.2 HEAD && git push origin v0.1.29-rc.2 --force
# Beta
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.
- `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-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
@@ -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
- `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
- 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
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-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.
## Changelog policy
- `CHANGELOG.md` is for **final stable releases only**.
- Do not add or edit changelog entries while iterating on RCs.
- Write the proper changelog entry when you are cutting the final stable release that comes after the RC cycle.
- Between stable releases, keep changelog work out of the repo until the final release is ready.
- `CHANGELOG.md` includes stable releases and the current beta line.
- The first beta inserts a top entry like `## 0.1.60-beta.1 - YYYY-MM-DD`.
- The next beta updates that same top entry in place, for example from `0.1.60-beta.1` to `0.1.60-beta.2`.
- 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
@@ -178,7 +181,7 @@ Entries within each section (Added, Improved, Fixed) are ordered by user impact:
## 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:
@@ -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**:
- **RC release**: the diff and release notes cover `latest stable tag HEAD`. RC release notes are auto-generated and not added to `CHANGELOG.md`.
- **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.
- **Beta release**: the diff and release notes cover `latest stable tag -> HEAD`. The current beta changelog entry is updated in place.
- **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

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.
## `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
`@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.

View File

@@ -42,7 +42,7 @@ buildNpmPackage rec {
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# 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
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).

39
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.60-rc.2",
"version": "0.1.60-beta.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.60-rc.2",
"version": "0.1.60-beta.1",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -37303,16 +37303,16 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.60-rc.2",
"version": "0.1.60-beta.1",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.60-rc.2",
"@getpaseo/highlight": "0.1.60-rc.2",
"@getpaseo/server": "0.1.60-rc.2",
"@getpaseo/expo-two-way-audio": "0.1.60-beta.1",
"@getpaseo/highlight": "0.1.60-beta.1",
"@getpaseo/server": "0.1.60-beta.1",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
@@ -37382,6 +37382,7 @@
"react-native-web": "~0.21.0",
"react-native-webview": "^13.16.0",
"react-native-worklets": "0.5.1",
"use-sync-external-store": "^1.6.0",
"zod": "^3.23.8",
"zustand": "^5.0.9"
},
@@ -37455,11 +37456,11 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.60-rc.2",
"version": "0.1.60-beta.1",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.60-rc.2",
"@getpaseo/server": "0.1.60-rc.2",
"@getpaseo/relay": "0.1.60-beta.1",
"@getpaseo/server": "0.1.60-beta.1",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -37500,11 +37501,11 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.60-rc.2",
"version": "0.1.60-beta.1",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "0.1.60-rc.2",
"@getpaseo/server": "0.1.60-rc.2",
"@getpaseo/cli": "0.1.60-beta.1",
"@getpaseo/server": "0.1.60-beta.1",
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2",
"ws": "^8.14.2"
@@ -37538,7 +37539,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.60-rc.2",
"version": "0.1.60-beta.1",
"license": "MIT",
"devDependencies": {
"@biomejs/biome": "1.9.4",
@@ -37739,7 +37740,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.60-rc.2",
"version": "0.1.60-beta.1",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
@@ -37765,7 +37766,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.60-rc.2",
"version": "0.1.60-beta.1",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -37781,14 +37782,14 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.60-rc.2",
"version": "0.1.60-beta.1",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.60-rc.2",
"@getpaseo/relay": "0.1.60-rc.2",
"@getpaseo/highlight": "0.1.60-beta.1",
"@getpaseo/relay": "0.1.60-beta.1",
"@isaacs/ttlcache": "^2.1.4",
"@mariozechner/pi-coding-agent": "^0.67.68",
"@modelcontextprotocol/sdk": "^1.20.1",
@@ -38542,7 +38543,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.60-rc.2",
"version": "0.1.60-beta.1",
"dependencies": {
"@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0",

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.60-rc.2",
"version": "0.1.60-beta.1",
"private": true,
"workspaces": [
"packages/expo-two-way-audio",
@@ -47,19 +47,19 @@
"version:all:patch": "node scripts/set-release-version.mjs --mode patch",
"version:all:minor": "node scripts/set-release-version.mjs --mode minor",
"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:rc:minor": "node scripts/set-release-version.mjs --mode rc-minor",
"version:all:rc:major": "node scripts/set-release-version.mjs --mode rc-major",
"version:all:rc:next": "node scripts/set-release-version.mjs --mode rc-next",
"version:all:beta:patch": "node scripts/set-release-version.mjs --mode beta-patch",
"version:all:beta:minor": "node scripts/set-release-version.mjs --mode beta-minor",
"version:all:beta:major": "node scripts/set-release-version.mjs --mode beta-major",
"version:all:beta:next": "node scripts/set-release-version.mjs --mode beta-next",
"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: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: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:rc:minor": "npm run release:check && npm run version:all:rc:minor && npm run release:push",
"release:rc:major": "npm run release:check && npm run version:all:rc:major && npm run release:push",
"release:rc:next": "npm run release:check && npm run version:all:rc:next && npm run release:push",
"release:beta:patch": "npm run release:check && npm run version:all:beta:patch && npm run release:push",
"release:beta:minor": "npm run release:check && npm run version:all:beta:minor && npm run release:push",
"release:beta:major": "npm run release:check && npm run version:all:beta:major && npm run release:push",
"release:beta:next": "npm run release:check && npm run version:all:beta:next && npm run release:push",
"release:promote": "npm run release:check && npm run version:all:promote && npm run release:publish && npm run release:push",
"release:patch": "npm run release:check && npm run version:all:patch && npm run release:publish && npm run release:push",
"release:minor": "npm run release:check && npm run version:all:minor && npm run release:publish && npm run release:push",

View File

@@ -210,13 +210,15 @@ export async function openWorkspaceWithAgents(
}
export async function expectWorkspaceTabVisible(page: Page, agentId: string): Promise<void> {
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`).first()).toBeVisible({
timeout: 30_000,
});
await expect(
page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }).first(),
).toBeVisible({ timeout: 30_000 });
}
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,
});
}

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. */
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,
});
}
/** Return all tab test IDs currently in the tab bar. */
export async function getTabTestIds(page: Page): Promise<string[]> {
const tabs = page.locator(
'[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])',
);
const tabs = page
.locator('[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])')
.filter({ visible: true });
const count = await tabs.count();
const ids: string[] = [];
for (let i = 0; i < count; i++) {
@@ -55,6 +57,7 @@ export async function getActiveTabTestId(page: Page): Promise<string | null> {
.locator(
'[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])[aria-selected="true"]',
)
.filter({ visible: true })
.first();
if (await activeTab.isVisible().catch(() => false)) {
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. */
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 button.click();
}
/** Click the new terminal button in the workspace tab bar. Creates a terminal tab directly. */
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 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. */
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. */
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 ─────────────────────────────────────────────────
/** Click the new agent tab button to create a draft/chat tab. */
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 button.click();
}
/** Click the new terminal button to create a terminal tab. */
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 button.click();
}
@@ -131,13 +138,14 @@ export async function waitForTabWithTitle(
page
.locator('[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])')
.filter({ hasText: matcher })
.filter({ visible: true })
.first(),
).toBeVisible({ timeout });
}
/** Assert the new agent tab button is visible in the tab bar. */
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();
expect(count).toBeGreaterThanOrEqual(1);
}

View File

@@ -13,18 +13,30 @@ export async function getWorkspaceTabTestIds(page: Page): Promise<string[]> {
return ids;
}
function visibleTestId(page: Page, testId: string) {
return page.getByTestId(testId).filter({ visible: true });
}
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,
});
await expect(page.getByTestId("workspace-new-agent-tab").first()).toBeVisible({
await expect(visibleTestId(page, "workspace-new-agent-tab").first()).toBeVisible({
timeout: 30_000,
});
}
export async function getVisibleWorkspaceAgentTabIds(page: Page): Promise<string[]> {
const allTabIds = await getWorkspaceTabTestIds(page);
return allTabIds.filter((id) => id.startsWith("workspace-tab-agent_"));
const tabs = page.locator('[data-testid^="workspace-tab-agent_"]').filter({ visible: true });
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(
@@ -38,7 +50,7 @@ export async function expectOnlyWorkspaceAgentTabsVisible(
expect(unexpected).toEqual([]);
expect(visible.length).toBe(expected.size);
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,
});
}

View File

@@ -103,8 +103,8 @@ export async function expectWorkspaceHeader(
page: Page,
input: { title: string; subtitle: string },
): Promise<void> {
const titleLocator = page.getByTestId("workspace-header-title");
const subtitleLocator = page.getByTestId("workspace-header-subtitle");
const titleLocator = page.getByTestId("workspace-header-title").filter({ visible: true });
const subtitleLocator = page.getByTestId("workspace-header-subtitle").filter({ visible: true });
await expect(titleLocator.first()).toHaveText(input.title, {
timeout: 30_000,

View File

@@ -248,7 +248,12 @@ test.describe("New workspace flow", () => {
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 });
// Workspace setup may auto-open a setup tab that steals focus,

View File

@@ -96,6 +96,14 @@ test.describe("Workspace navigation regression", () => {
`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({
page,
serverId,
@@ -126,27 +134,45 @@ test.describe("Workspace navigation regression", () => {
await expect(getVisibleWorkspaceAgentTabIds(page)).resolves.toEqual([
`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 waitForSidebarHydration(page);
await waitForWorkspaceTabsVisible(page);
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), {
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId), {
timeout: 30_000,
});
await expectSidebarWorkspaceSelected({
page,
serverId,
workspaceId: secondWorkspace.workspaceId,
workspaceId: firstWorkspace.workspaceId,
});
await expectWorkspaceHeader(page, {
title: secondWorkspace.workspaceName,
subtitle: secondWorkspace.projectDisplayName,
title: firstWorkspace.workspaceName,
subtitle: firstWorkspace.projectDisplayName,
});
await expectWorkspaceTabVisible(page, secondAgent.id);
await expectWorkspaceTabHidden(page, firstAgent.id);
await expectOnlyWorkspaceAgentTabsVisible(page, [secondAgent.id]);
await expectWorkspaceTabVisible(page, firstAgent.id);
await expectWorkspaceTabHidden(page, secondAgent.id);
await expectOnlyWorkspaceAgentTabsVisible(page, [firstAgent.id]);
await expect(getVisibleWorkspaceAgentTabIds(page)).resolves.toEqual([
`workspace-tab-agent_${secondAgent.id}`,
`workspace-tab-agent_${firstAgent.id}`,
]);
} finally {
for (const agentId of agentIds) {

View File

@@ -94,7 +94,7 @@ test.describe("Workspace setup streaming", () => {
await navigateToWorkspaceViaSidebar(page, workspace.id);
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({
timeout: 30_000,
});

View File

@@ -1,10 +1,10 @@
{
"name": "@getpaseo/app",
"main": "index.ts",
"version": "0.1.60-rc.2",
"version": "0.1.60-beta.1",
"private": true,
"scripts": {
"start": "expo start",
"start": "APP_VARIANT=development expo start",
"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",
"eas-build-post-install": "npm run build:workspace-deps",
@@ -31,9 +31,9 @@
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.60-rc.2",
"@getpaseo/highlight": "0.1.60-rc.2",
"@getpaseo/server": "0.1.60-rc.2",
"@getpaseo/expo-two-way-audio": "0.1.60-beta.1",
"@getpaseo/highlight": "0.1.60-beta.1",
"@getpaseo/server": "0.1.60-beta.1",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
@@ -103,6 +103,7 @@
"react-native-web": "~0.21.0",
"react-native-webview": "^13.16.0",
"react-native-worklets": "0.5.1",
"use-sync-external-store": "^1.6.0",
"zod": "^3.23.8",
"zustand": "^5.0.9"
},

View File

@@ -10,7 +10,6 @@ import {
import { SafeAreaProvider } from "react-native-safe-area-context";
import { KeyboardProvider } from "react-native-keyboard-controller";
import { GestureHandlerRootView, Gesture, GestureDetector } from "react-native-gesture-handler";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { PortalProvider } from "@gorhom/portal";
import { VoiceProvider } from "@/contexts/voice-context";
import { useAppSettings } from "@/hooks/use-settings";
@@ -63,9 +62,13 @@ import { CommandCenter } from "@/components/command-center";
import { ProjectPickerModal } from "@/components/project-picker-modal";
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-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 { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
import { queryClient } from "@/query/query-client";
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
import {
WEB_NOTIFICATION_CLICK_EVENT,
type WebNotificationClickDetail,
@@ -83,7 +86,10 @@ import {
parseWorkspaceOpenIntent,
decodeWorkspaceIdFromPathSegment,
} 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";
polyfillCrypto();
@@ -388,9 +394,11 @@ function AppContainer({
const { theme } = useUnistyles();
const daemons = useHosts();
const { settings, updateSettings } = useAppSettings();
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
const toggleBothSidebars = usePanelStore((state) => state.toggleBothSidebars);
const toggleMobileAgentList = usePanelStore((state) => state.toggleMobileAgentList);
const toggleDesktopAgentList = usePanelStore((state) => state.toggleDesktopAgentList);
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 isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
const agentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
@@ -405,6 +413,26 @@ function AppContainer({
const isCompactLayout = useIsCompactFormFactor();
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
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
// conflate workspace/project-specific chrome (sidebar, mobile gesture) with
// global concerns like keyboard shortcuts. Split those out so settings (and
@@ -444,8 +472,7 @@ function AppContainer({
enabled: keyboardShortcutsEnabled,
isMobile: isCompactLayout,
toggleAgentList,
toggleFileExplorer,
toggleBothSidebars,
toggleBothSidebars: toggleDesktopSidebars,
toggleFocusMode,
cycleTheme,
});
@@ -470,6 +497,10 @@ function AppContainer({
<UpdateBanner />
<CommandCenter />
<ProjectPickerModal />
<WorkspaceShortcutTargetsSubscriber
enabled={keyboardShortcutsEnabled}
serverId={activeServerId}
/>
<WorkspaceSetupDialog />
<KeyboardShortcutsDialog />
</View>
@@ -490,7 +521,7 @@ function MobileGestureWrapper({
chromeEnabled: boolean;
}) {
const mobileView = usePanelStore((state) => state.mobileView);
const openAgentList = usePanelStore((state) => state.openAgentList);
const showMobileAgentList = usePanelStore((state) => state.showMobileAgentList);
const horizontalScroll = useHorizontalScrollOptional();
const {
translateX,
@@ -507,8 +538,8 @@ function MobileGestureWrapper({
const handleGestureOpen = useCallback(() => {
gestureAnimatingRef.current = true;
openAgentList();
}, [openAgentList, gestureAnimatingRef]);
showMobileAgentList();
}, [showMobileAgentList, gestureAnimatingRef]);
const openGesture = useMemo(
() =>
@@ -815,14 +846,14 @@ function RootStack() {
<Stack.Screen name="settings/[section]" />
<Stack.Screen name="pair-scan" />
</Stack.Protected>
<Stack.Screen
name="h/[serverId]/workspace/[workspaceId]"
getId={({ params }) => {
const serverId = getRouteParamValue(params?.serverId);
const workspaceId = getRouteParamValue(params?.workspaceId);
return serverId && workspaceId ? `${serverId}:${workspaceId}` : undefined;
}}
/>
{/*
Do not add getId or dangerouslySingular back to the workspace route.
Expo Router maps dangerouslySingular to React Navigation getId, and
getId repeatedly breaks Android native-stack/Fabric by reordering an
already-mounted workspace screen. Keep workspace identity/retention
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]/index" />
<Stack.Screen name="h/[serverId]/sessions" />
@@ -839,6 +870,7 @@ function NavigationActiveWorkspaceObserver() {
useEffect(() => {
syncNavigationActiveWorkspace(navigationRef);
const unsubscribeBrowserLocation = addBrowserActiveWorkspaceLocationListener();
const unsubscribeState = navigationRef.addListener("state", () => {
syncNavigationActiveWorkspace(navigationRef);
});
@@ -846,6 +878,7 @@ function NavigationActiveWorkspaceObserver() {
syncNavigationActiveWorkspace(navigationRef);
});
return () => {
unsubscribeBrowserLocation();
unsubscribeState();
unsubscribeReady();
};
@@ -864,23 +897,21 @@ export default function RootLayout() {
<SafeAreaProvider>
<KeyboardProvider>
<QueryProvider>
<BottomSheetModalProvider>
<HostRuntimeBootstrapProvider>
<PushNotificationRouter />
<ToastProvider>
<ProvidersWrapper>
<SidebarAnimationProvider>
<HorizontalScrollProvider>
<OpenProjectListener />
<AppWithSidebar>
<RootStack />
</AppWithSidebar>
</HorizontalScrollProvider>
</SidebarAnimationProvider>
</ProvidersWrapper>
</ToastProvider>
</HostRuntimeBootstrapProvider>
</BottomSheetModalProvider>
<HostRuntimeBootstrapProvider>
<PushNotificationRouter />
<ToastProvider>
<ProvidersWrapper>
<SidebarAnimationProvider>
<HorizontalScrollProvider>
<OpenProjectListener />
<AppWithSidebar>
<RootStack />
</AppWithSidebar>
</HorizontalScrollProvider>
</SidebarAnimationProvider>
</ProvidersWrapper>
</ToastProvider>
</HostRuntimeBootstrapProvider>
</QueryProvider>
</KeyboardProvider>
</SafeAreaProvider>

View File

@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigation } from "@react-navigation/native";
import { StyleSheet, View } from "react-native";
import {
useGlobalSearchParams,
useLocalSearchParams,
@@ -7,6 +8,11 @@ import {
useRootNavigationState,
} from "expo-router";
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 { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
import {
@@ -95,6 +101,23 @@ function HostWorkspaceLayoutContent() {
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
: "";
const openValue = getParamValue(globalParams.open);
const routeWorkspaceSelection = useMemo(
() =>
serverId && workspaceId
? {
serverId,
workspaceId,
}
: null,
[serverId, workspaceId],
);
useEffect(() => {
if (!routeWorkspaceSelection) {
return;
}
activateNavigationWorkspaceSelection(routeWorkspaceSelection);
}, [routeWorkspaceSelection]);
useEffect(() => {
if (!openValue) {
@@ -144,11 +167,73 @@ function HostWorkspaceLayoutContent() {
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 (
<WorkspaceScreen
key={`${serverId}:${workspaceId}`}
serverId={serverId}
workspaceId={workspaceId}
/>
<View style={styles.deck}>
{mountedSelections.map((selection) => {
const isActive = areWorkspaceSelectionsEqual(selection, activeSelection);
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,
},
});

View File

@@ -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 { createPortal } from "react-dom";
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 { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root";
import {
BottomSheetModal,
BottomSheetBackdrop,
BottomSheetScrollView,
BottomSheetTextInput,
@@ -16,6 +15,10 @@ import {
import { X } from "lucide-react-native";
import { FileDropZone } from "@/components/file-drop-zone";
import type { ImageAttachment } from "@/components/message-input";
import {
IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility,
} from "@/components/ui/isolated-bottom-sheet-modal";
import { isWeb } from "@/constants/platform";
type EscHandler = () => void;
@@ -163,7 +166,6 @@ export interface AdaptiveModalSheetProps {
children: ReactNode;
headerActions?: ReactNode;
snapPoints?: string[];
stackBehavior?: "push" | "switch" | "replace";
testID?: string;
/** Override the max width of the desktop card. */
desktopMaxWidth?: number;
@@ -180,7 +182,6 @@ export function AdaptiveModalSheet({
children,
headerActions,
snapPoints,
stackBehavior,
testID,
desktopMaxWidth,
onFilesDropped,
@@ -188,33 +189,13 @@ export function AdaptiveModalSheet({
}: AdaptiveModalSheetProps) {
const { theme } = useUnistyles();
const isMobile = useIsCompactFormFactor();
const sheetRef = useRef<BottomSheetModal>(null);
const dismissingForVisibilityRef = useRef(false);
const titleColor = theme.colors.foreground;
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
useEffect(() => {
if (!isMobile) return;
if (visible) {
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 { sheetRef, handleSheetChange } = useIsolatedBottomSheetVisibility({
visible,
isEnabled: isMobile,
onClose,
});
const renderBackdrop = useCallback(
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
@@ -230,7 +211,7 @@ export function AdaptiveModalSheet({
if (isMobile) {
return (
<BottomSheetModal
<IsolatedBottomSheetModal
ref={sheetRef}
snapPoints={resolvedSnapPoints}
index={0}
@@ -238,7 +219,6 @@ export function AdaptiveModalSheet({
onChange={handleSheetChange}
backdropComponent={renderBackdrop}
enablePanDownToClose
stackBehavior={stackBehavior}
backgroundComponent={SheetBackground}
handleIndicatorStyle={{ backgroundColor: theme.colors.surface2 }}
keyboardBehavior="extend"
@@ -247,7 +227,7 @@ export function AdaptiveModalSheet({
>
<View style={styles.bottomSheetHeader} testID={testID}>
<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}
</Text>
{subtitle}
@@ -268,7 +248,7 @@ export function AdaptiveModalSheet({
) : (
<View style={styles.bottomSheetStaticContent}>{children}</View>
)}
</BottomSheetModal>
</IsolatedBottomSheetModal>
);
}
@@ -276,7 +256,7 @@ export function AdaptiveModalSheet({
<>
<View style={styles.header}>
<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}
</Text>
{subtitle}

View File

@@ -4,7 +4,6 @@ import { View, Text, Pressable, TextInput, ActivityIndicator } from "react-nativ
import type { StyleProp, ViewStyle, TextProps } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import {
BottomSheetModal,
BottomSheetScrollView,
BottomSheetBackdrop,
BottomSheetBackgroundProps,
@@ -30,6 +29,10 @@ import type {
import type { AgentProviderDefinition } 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 {
IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility,
} from "@/components/ui/isolated-bottom-sheet-modal";
import { baseColors } from "@/styles/theme";
import { isNative } from "@/constants/platform";
@@ -256,31 +259,17 @@ export function DropdownSheet({
children,
}: DropdownSheetProps): ReactElement {
const { theme } = useUnistyles();
const bottomSheetRef = useRef<BottomSheetModal>(null);
const titleColor = theme.colors.foreground;
const snapPoints = useMemo(() => ["60%", "90%"], []);
const { sheetRef, handleSheetChange } = useIsolatedBottomSheetVisibility({
visible,
onClose,
});
const handleClose = useCallback(() => {
bottomSheetRef.current?.dismiss();
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(
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
@@ -289,8 +278,8 @@ export function DropdownSheet({
);
return (
<BottomSheetModal
ref={bottomSheetRef}
<IsolatedBottomSheetModal
ref={sheetRef}
snapPoints={snapPoints}
index={0}
enableDynamicSizing={false}
@@ -303,7 +292,9 @@ export function DropdownSheet({
keyboardBlurBehavior="restore"
>
<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
accessibilityRole="button"
accessibilityLabel="Close sheet"
@@ -321,7 +312,7 @@ export function DropdownSheet({
>
{children}
</BottomSheetScrollView>
</BottomSheetModal>
</IsolatedBottomSheetModal>
);
}
@@ -1229,7 +1220,6 @@ const styles = StyleSheet.create((theme) => ({
dropdownSheetTitle: {
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
textAlign: "center",
},
dropdownSheetScrollContent: {

View File

@@ -92,7 +92,7 @@ type ControlledAgentStatusBarProps = {
export interface DraftAgentStatusBarProps {
providerDefinitions: AgentProviderDefinition[];
selectedProvider: AgentProvider;
selectedProvider: AgentProvider | null;
onSelectProvider: (provider: AgentProvider) => void;
modeOptions: AgentMode[];
selectedMode: string;
@@ -255,7 +255,8 @@ function ControlledStatusBar({
: undefined;
const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null;
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 =
Boolean(providerOptions?.length) ||
@@ -620,7 +621,9 @@ function ControlledStatusBar({
accessibilityLabel="Agent preferences"
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}>
{displayModel}
</Text>
@@ -630,7 +633,6 @@ function ControlledStatusBar({
title="Preferences"
visible={prefsOpen}
onClose={() => setPrefsOpen(false)}
stackBehavior="replace"
testID="agent-preferences-sheet"
>
{canSelectModel ? (
@@ -663,7 +665,12 @@ function ControlledStatusBar({
pointerEvents="none"
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>
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</View>
@@ -873,15 +880,15 @@ export const AgentStatusBar = memo(function AgentStatusBar({
refetchIfStale: refetchSnapshotIfStale,
} = useProvidersSnapshot(serverId, agent?.cwd);
const snapshotModels = useMemo(() => {
const snapshotSelectedEntry = useMemo(() => {
if (!snapshotEntries || !agent?.provider) {
return null;
}
const entry = snapshotEntries.find((e) => e.provider === agent.provider);
return entry?.models ?? null;
return snapshotEntries.find((e) => e.provider === agent.provider) ?? null;
}, [snapshotEntries, agent?.provider]);
const models = snapshotModels;
const models = snapshotSelectedEntry?.models ?? null;
const selectedProviderIsLoading = snapshotSelectedEntry?.status === "loading";
const agentProviderDefinitions = useMemo(() => {
const definition = agent?.provider
@@ -892,11 +899,11 @@ export const AgentStatusBar = memo(function AgentStatusBar({
const agentProviderModels = useMemo(() => {
const map = new Map<string, AgentModelDefinition[]>();
if (agent?.provider && snapshotModels) {
map.set(agent.provider, snapshotModels);
if (agent?.provider && models) {
map.set(agent.provider, models);
}
return map;
}, [agent?.provider, snapshotModels]);
}, [agent?.provider, models]);
const displayMode =
availableModes.find((mode) => mode.id === agent?.currentModeId)?.label ||
@@ -1040,8 +1047,8 @@ export const AgentStatusBar = memo(function AgentStatusBar({
toast.error(toErrorMessage(error));
});
}}
isModelLoading={snapshotIsLoading}
onModelSelectorOpen={refetchSnapshotIfStale}
isModelLoading={snapshotIsLoading || selectedProviderIsLoading}
onModelSelectorOpen={() => refetchSnapshotIfStale(agent?.provider)}
onDropdownClose={onDropdownClose}
disabled={!client}
/>
@@ -1097,6 +1104,7 @@ export function DraftAgentStatusBar({
const effectiveSelectedMode = selectedMode || mappedModeOptions[0]?.id || "";
const effectiveSelectedThinkingOption =
selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined;
const hasSelectedProvider = selectedProvider !== null;
if (platformIsWeb) {
return (
@@ -1104,7 +1112,7 @@ export function DraftAgentStatusBar({
<CombinedModelSelector
providerDefinitions={providerDefinitions}
allProviderModels={allProviderModels}
selectedProvider={selectedProvider}
selectedProvider={selectedProvider ?? ""}
selectedModel={selectedModel}
onSelect={onSelectProviderAndModel}
favoriteKeys={favoriteKeys}
@@ -1120,20 +1128,22 @@ export function DraftAgentStatusBar({
onOpen={onModelSelectorOpen}
onClose={onDropdownClose}
/>
<ControlledStatusBar
provider={selectedProvider}
providerDefinitions={providerDefinitions}
modeOptions={mappedModeOptions}
selectedModeId={effectiveSelectedMode}
onSelectMode={onSelectMode}
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
selectedThinkingOptionId={effectiveSelectedThinkingOption}
onSelectThinkingOption={onSelectThinkingOption}
features={features}
onSetFeature={onSetFeature}
onDropdownClose={onDropdownClose}
disabled={disabled}
/>
{selectedProvider ? (
<ControlledStatusBar
provider={selectedProvider}
providerDefinitions={providerDefinitions}
modeOptions={mappedModeOptions}
selectedModeId={effectiveSelectedMode}
onSelectMode={onSelectMode}
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
selectedThinkingOptionId={effectiveSelectedThinkingOption}
onSelectThinkingOption={onSelectThinkingOption}
features={features}
onSetFeature={onSetFeature}
onDropdownClose={onDropdownClose}
disabled={disabled}
/>
) : null}
</View>
);
}
@@ -1146,10 +1156,10 @@ export function DraftAgentStatusBar({
return (
<>
<ControlledStatusBar
provider={selectedProvider}
provider={selectedProvider ?? ""}
providerDefinitions={providerDefinitions}
allProviderModels={allProviderModels}
modeOptions={mappedModeOptions}
modeOptions={hasSelectedProvider ? mappedModeOptions : undefined}
selectedModeId={effectiveSelectedMode}
onSelectMode={onSelectMode}
modelOptions={modelOptions}

View File

@@ -125,7 +125,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState<Set<string>>(
new Set(),
);
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
// Get serverId (fallback to agent's serverId if not provided)
@@ -199,17 +199,21 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
setCurrentPath: false,
});
setExplorerTabForCheckout({
const checkout = {
serverId: resolvedServerId,
cwd: agent.cwd,
isGit: agent.projectPlacement?.checkout?.isGit ?? true,
tab: "files",
};
setExplorerTabForCheckout({ ...checkout, tab: "files" });
openFileExplorerForCheckout({
isCompact: isMobile,
checkout,
});
openFileExplorer();
},
[
agent.cwd,
openFileExplorer,
isMobile,
openFileExplorerForCheckout,
requestDirectoryListing,
resolvedServerId,
router,

View File

@@ -568,10 +568,14 @@ export function CombinedModelSelector({
[onSelect],
);
const ProviderIcon = getProviderIcon(selectedProvider);
const hasSelectedProvider = selectedProvider.trim().length > 0;
const ProviderIcon = hasSelectedProvider ? getProviderIcon(selectedProvider) : null;
const selectedModelLabel = useMemo(() => {
if (!selectedModel) {
if (!hasSelectedProvider) {
return "Select model";
}
return isLoading ? "Loading..." : "Select model";
}
const models = allProviderModels.get(selectedProvider);
@@ -580,7 +584,7 @@ export function CombinedModelSelector({
}
const model = models.find((entry) => entry.id === selectedModel);
return model?.label ?? resolveDefaultModelLabel(models);
}, [allProviderModels, isLoading, selectedModel, selectedProvider]);
}, [allProviderModels, hasSelectedProvider, isLoading, selectedModel, selectedProvider]);
const desktopFixedHeight = useMemo(() => {
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">
{triggerLabel}
</Text>
@@ -657,7 +663,6 @@ export function CombinedModelSelector({
onSelect={() => {}}
open={isOpen}
onOpenChange={handleOpenChange}
stackBehavior="push"
anchorRef={anchorRef}
desktopPlacement="top-start"
desktopMinWidth={360}

View File

@@ -20,10 +20,12 @@ const {
deleteAttachmentsMock,
encodeImagesMock,
openExternalUrlMock,
markScrollInvestigationRenderMock,
mockSessionState,
setAgentStreamTailMock,
setAgentStreamHeadMock,
setQueuedMessagesMock,
agentDirectoryStatusMock,
} = vi.hoisted(() => {
const theme = {
spacing: { 1: 4, 2: 8, 3: 12, 4: 16, 6: 24, 8: 32 },
@@ -102,6 +104,17 @@ const {
string,
{
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[]>;
agentStreamHead: Map<string, unknown[]>;
agentStreamTail: Map<string, unknown[]>;
@@ -114,6 +127,17 @@ const {
sessions: {
server: {
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(),
agentStreamHead: new Map(),
agentStreamTail: new Map(),
@@ -135,6 +159,8 @@ const {
);
mockSessionState.setAgentStreamTail = setAgentStreamTailMock;
mockSessionState.setAgentStreamHead = setAgentStreamHeadMock;
const markScrollInvestigationRenderMock = vi.fn();
const agentDirectoryStatusMock = vi.fn(() => "ready");
return {
theme,
@@ -147,10 +173,12 @@ const {
deleteAttachmentsMock: vi.fn(async () => {}),
encodeImagesMock: vi.fn(async (images: AttachmentMetadata[]) => images),
openExternalUrlMock: vi.fn(async () => {}),
markScrollInvestigationRenderMock,
mockSessionState,
setAgentStreamTailMock,
setAgentStreamHeadMock,
setQueuedMessagesMock,
agentDirectoryStatusMock,
};
});
@@ -217,7 +245,7 @@ vi.mock("react-native-safe-area-context", () => ({
vi.mock("@/runtime/host-runtime", () => ({
useHostRuntimeClient: () => mockClient,
useHostRuntimeIsConnected: () => true,
useHostRuntimeAgentDirectoryStatus: () => "ready",
useHostRuntimeAgentDirectoryStatus: () => agentDirectoryStatusMock(),
}));
vi.mock("@/stores/session-store", () => {
@@ -313,7 +341,7 @@ vi.mock("@/contexts/toast-context", () => ({
}));
vi.mock("@/utils/scroll-jank-investigation", () => ({
markScrollInvestigationRender: vi.fn(),
markScrollInvestigationRender: markScrollInvestigationRenderMock,
markScrollInvestigationEvent: vi.fn(),
}));
@@ -360,6 +388,20 @@ vi.mock("@/hooks/use-dictation", () => ({
}));
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,
}));
@@ -547,9 +589,23 @@ beforeEach(() => {
deleteAttachmentsMock.mockClear();
encodeImagesMock.mockClear();
openExternalUrlMock.mockClear();
markScrollInvestigationRenderMock.mockClear();
setAgentStreamTailMock.mockClear();
setAgentStreamHeadMock.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.agentStreamTail = 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-"]');
}
function countMessageInputRenders(): number {
return markScrollInvestigationRenderMock.mock.calls.filter(
([componentId]) => componentId === "MessageInput:server:agent",
).length;
}
describe("Composer attachments", () => {
it("opens a Plus menu with image and GitHub attachment actions", () => {
renderComposer();
@@ -847,6 +909,38 @@ describe("Composer attachments", () => {
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", () => {
renderComposer({
initialText: "keep this prompt",

View File

@@ -1,5 +1,5 @@
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 { useIsCompactFormFactor } from "@/constants/layout";
import { useShallow } from "zustand/shallow";
@@ -14,7 +14,6 @@ import {
Paperclip,
} from "lucide-react-native";
import Animated from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useQuery } from "@tanstack/react-query";
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
import { generateMessageId, type StreamItem } from "@/types/stream";
@@ -70,6 +69,7 @@ import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachm
import { AttachmentPill } from "@/components/attachment-pill";
import { AttachmentLightbox } from "@/components/attachment-lightbox";
import { openExternalUrl } from "@/utils/open-external-url";
import { useIsDictationReady } from "@/hooks/use-is-dictation-ready";
type QueuedMessage = {
id: string;
@@ -134,6 +134,7 @@ interface ComposerProps {
const EMPTY_ARRAY: readonly QueuedMessage[] = [];
const DESKTOP_MESSAGE_PLACEHOLDER = "Message the agent, tag @files, or use /commands and /skills";
const MOBILE_MESSAGE_PLACEHOLDER = "Message, @files, /commands";
const StableMessageInput = memo(MessageInput);
export function Composer({
agentId,
@@ -167,19 +168,20 @@ export function Composer({
markScrollInvestigationRender(`Composer:${serverId}:${agentId}`);
const { theme } = useUnistyles();
const buttonIconSize = isWeb ? theme.iconSize.md : theme.iconSize.lg;
const insets = useSafeAreaInsets();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const agentDirectoryStatus = useHostRuntimeAgentDirectoryStatus(serverId);
const toast = useToast();
const toastErrorRef = useRef(toast.error);
toastErrorRef.current = toast.error;
const voice = useVoiceOptional();
const voiceToggleKeys = useShortcutKeys("voice-toggle");
const dictationCancelKeys = useShortcutKeys("dictation-cancel");
const isDictationReady =
isConnected &&
(agentDirectoryStatus === "ready" ||
agentDirectoryStatus === "revalidating" ||
agentDirectoryStatus === "error_after_ready");
const isDictationReady = useIsDictationReady({
serverId,
isConnected,
agentDirectoryStatus,
});
const { settings: appSettings } = useAppSettings();
@@ -238,6 +240,8 @@ export function Composer({
messageInputRef.current?.focus();
},
});
const autocompleteOnKeyPressRef = useRef(autocomplete.onKeyPress);
autocompleteOnKeyPressRef.current = autocomplete.onKeyPress;
// Clear send error when user edits the input
useEffect(() => {
@@ -375,67 +379,82 @@ export function Composer({
[agentId, serverId, setQueuedMessages],
);
function queueMessage(message: string, attachments: ComposerAttachment[]) {
const trimmedMessage = message.trim();
if (!trimmedMessage && attachments.length === 0) return;
const queueMessage = useCallback(
(message: string, attachments: ComposerAttachment[]) => {
const trimmedMessage = message.trim();
if (!trimmedMessage && attachments.length === 0) return;
const newItem = {
id: generateMessageId(),
text: trimmedMessage,
attachments,
};
const newItem = {
id: generateMessageId(),
text: trimmedMessage,
attachments,
};
setQueuedMessages(serverId, (prev: Map<string, QueuedMessage[]>) => {
const next = new Map(prev);
next.set(agentId, [...(prev.get(agentId) ?? []), newItem]);
return next;
});
setQueuedMessages(serverId, (prev: Map<string, QueuedMessage[]>) => {
const next = new Map(prev);
next.set(agentId, [...(prev.get(agentId) ?? []), newItem]);
return next;
});
setUserInput("");
setSelectedAttachments([]);
}
setUserInput("");
setSelectedAttachments([]);
},
[agentId, serverId, setQueuedMessages, setSelectedAttachments, setUserInput],
);
async function sendMessageWithContent(
message: string,
attachments: ComposerAttachment[],
forceSend?: boolean,
) {
await submitAgentInput({
message,
attachments,
hasExternalContent,
const sendMessageWithContent = useCallback(
async (message: string, attachments: ComposerAttachment[], forceSend?: boolean) => {
await submitAgentInput({
message,
attachments,
hasExternalContent,
allowEmptySubmit,
forceSend,
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,
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,
hasExternalContent,
isAgentRunning,
queueMessage,
setSelectedAttachments,
setUserInput,
setAttachments: (nextAttachments) => {
setSelectedAttachments(nextAttachments);
},
setSendError,
setIsProcessing,
onSubmitError: (error) => {
console.error("[AgentInput] Failed to send message:", error);
},
});
}
submitBehavior,
submitMessage,
],
);
function handleSubmit(payload: MessagePayload) {
if (blurOnSubmit) {
messageInputRef.current?.blur();
}
void sendMessageWithContent(payload.text, payload.attachments, payload.forceSend);
}
const handleSubmit = useCallback(
(payload: MessagePayload) => {
if (blurOnSubmit) {
messageInputRef.current?.blur();
}
void sendMessageWithContent(payload.text, payload.attachments, payload.forceSend);
},
[blurOnSubmit, sendMessageWithContent],
);
const handlePickImage = useCallback(async () => {
const result = await pickImages();
@@ -487,6 +506,18 @@ export function Composer({
}
}, [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(
(action: KeyboardActionDefinition): boolean => {
if (!isPaneFocused) {
@@ -553,18 +584,6 @@ export function Composer({
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 handleToggleRealtimeVoice = useCallback(() => {
@@ -582,10 +601,10 @@ export function Composer({
const message =
error instanceof Error ? error.message : typeof error === "string" ? error : null;
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) {
const item = queuedMessages.find((q) => q.id === id);
@@ -612,9 +631,12 @@ export function Composer({
}
}
const handleQueue = useCallback((payload: MessagePayload) => {
queueMessage(payload.text, payload.attachments);
}, []);
const handleQueue = useCallback(
(payload: MessagePayload) => {
queueMessage(payload.text, payload.attachments);
},
[queueMessage],
);
const hasSendableContent = userInput.trim().length > 0 || selectedAttachments.length > 0;
@@ -633,91 +655,109 @@ export function Composer({
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,
isAgentRunning,
isCancellingAgent,
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 rightContent =
showVoiceModeButton || cancelButton ? (
<View style={styles.rightControls}>
{showVoiceModeButton ? (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={handleToggleRealtimeVoice}
disabled={!isConnected || voice?.isVoiceSwitching}
accessibilityLabel="Enable Voice mode"
accessibilityRole="button"
style={({ hovered }) => [
styles.realtimeVoiceButton as any,
(hovered ? styles.iconButtonHovered : undefined) as any,
(!isConnected || voice?.isVoiceSwitching
? styles.buttonDisabled
: undefined) as any,
]}
>
{({ hovered }) =>
voice?.isVoiceSwitching ? (
<ActivityIndicator size="small" color="white" />
) : (
<AudioLines
size={buttonIconSize}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)
}
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Voice mode</Text>
{voiceToggleKeys ? (
<Shortcut chord={voiceToggleKeys} style={styles.tooltipShortcut} />
) : null}
</View>
</TooltipContent>
</Tooltip>
) : null}
{cancelButton}
</View>
) : null;
const rightContent = useMemo(
() =>
showVoiceModeButton || cancelButton ? (
<View style={styles.rightControls}>
{showVoiceModeButton ? (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={handleToggleRealtimeVoice}
disabled={!isConnected || voice?.isVoiceSwitching}
accessibilityLabel="Enable Voice mode"
accessibilityRole="button"
style={({ hovered }) => [
styles.realtimeVoiceButton as any,
(hovered ? styles.iconButtonHovered : undefined) as any,
(!isConnected || voice?.isVoiceSwitching
? styles.buttonDisabled
: undefined) as any,
]}
>
{({ hovered }) =>
voice?.isVoiceSwitching ? (
<ActivityIndicator size="small" color="white" />
) : (
<AudioLines
size={buttonIconSize}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)
}
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Voice mode</Text>
{voiceToggleKeys ? (
<Shortcut chord={voiceToggleKeys} style={styles.tooltipShortcut} />
) : null}
</View>
</TooltipContent>
</Tooltip>
) : null}
{cancelButton}
</View>
) : null,
[
buttonIconSize,
cancelButton,
handleToggleRealtimeVoice,
isConnected,
showVoiceModeButton,
theme.colors.foreground,
theme.colors.foregroundMuted,
voice,
voiceToggleKeys,
],
);
const hasContextWindowMeter =
typeof agentState.contextWindowMaxTokens === "number" &&
@@ -725,15 +765,18 @@ export function Composer({
const contextWindowMaxTokens = hasContextWindowMeter ? agentState.contextWindowMaxTokens : null;
const contextWindowUsedTokens = hasContextWindowMeter ? agentState.contextWindowUsedTokens : null;
const beforeVoiceContent = (
<View style={styles.contextWindowMeterSlot}>
{contextWindowMaxTokens !== null && contextWindowUsedTokens !== null ? (
<ContextWindowMeter
maxTokens={contextWindowMaxTokens}
usedTokens={contextWindowUsedTokens}
/>
) : null}
</View>
const beforeVoiceContent = useMemo(
() => (
<View style={styles.contextWindowMeterSlot}>
{contextWindowMaxTokens !== null && contextWindowUsedTokens !== null ? (
<ContextWindowMeter
maxTokens={contextWindowMaxTokens}
usedTokens={contextWindowUsedTokens}
/>
) : null}
</View>
),
[contextWindowMaxTokens, contextWindowUsedTokens],
);
const githubSearchQueryTrimmed = githubSearchQuery.trim();
@@ -808,17 +851,36 @@ export function Composer({
[setSelectedAttachments, setGithubSearchQuery, setIsGithubPickerOpen],
);
const leftContent =
resolveStatusControlMode(statusControls) === "draft" && statusControls ? (
<DraftAgentStatusBar {...statusControls} />
) : (
<AgentStatusBar agentId={agentId} serverId={serverId} onDropdownClose={focusInput} />
);
const leftContent = useMemo(
() =>
resolveStatusControlMode(statusControls) === "draft" && statusControls ? (
<DraftAgentStatusBar {...statusControls} />
) : (
<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 (
<Animated.View
style={[styles.container, { paddingBottom: insets.bottom }, keyboardAnimatedStyle]}
>
<Animated.View style={[styles.container, keyboardAnimatedStyle]}>
<AttachmentLightbox metadata={lightboxMetadata} onClose={() => setLightboxMetadata(null)} />
{/* Input area */}
<View style={[styles.inputAreaContainer, isComposerLocked && styles.inputAreaLocked]}>
@@ -924,7 +986,7 @@ export function Composer({
) : null}
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
<MessageInput
<StableMessageInput
ref={messageInputRef}
value={userInput}
onChangeText={setUserInput}
@@ -938,9 +1000,7 @@ export function Composer({
attachments={selectedAttachments}
cwd={cwd}
attachmentMenuItems={attachmentMenuItems}
onAttachButtonRef={(node) => {
attachButtonRef.current = node;
}}
onAttachButtonRef={handleAttachButtonRef}
onAddImages={addImages}
client={client}
isReadyForDictation={isDictationReady}
@@ -959,15 +1019,8 @@ export function Composer({
onQueue={handleQueue}
onSubmitLoadingPress={isAgentRunning ? handleCancelAgent : undefined}
onKeyPress={handleCommandKeyPress}
onSelectionChange={(selection) => {
setCursorIndex(selection.start);
}}
onFocusChange={(focused) => {
setIsMessageInputFocused(focused);
if (focused) {
onAttentionInputFocus?.();
}
}}
onSelectionChange={handleSelectionChange}
onFocusChange={handleFocusChange}
onHeightChange={onComposerHeightChange}
inputWrapperStyle={inputWrapperStyle}
/>

View File

@@ -142,7 +142,7 @@ export function DraggableList<T>({
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
distance: 6,
},
}),
useSensor(KeyboardSensor, {

View File

@@ -17,6 +17,7 @@ import { PrPane } from "./pr-pane";
import { usePrPaneData } from "@/hooks/use-pr-pane-data";
import {
usePanelStore,
selectIsFileExplorerOpen,
MIN_EXPLORER_SIDEBAR_WIDTH,
MAX_EXPLORER_SIDEBAR_WIDTH,
type ExplorerTab,
@@ -52,9 +53,9 @@ export function ExplorerSidebar({
const isScreenFocused = useIsFocused();
const insets = useSafeAreaInsets();
const isMobile = useIsCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView);
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
const closeToAgent = usePanelStore((state) => state.closeToAgent);
const isOpen = usePanelStore((state) => selectIsFileExplorerOpen(state, { isCompact: isMobile }));
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer);
const explorerTab = usePanelStore((state) => state.explorerTab);
const explorerWidth = usePanelStore((state) => state.explorerWidth);
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
@@ -81,9 +82,6 @@ export function ExplorerSidebar({
}
}, [explorerWidth, isMobile, setExplorerWidth, viewportWidth]);
// Derive isOpen from the unified panel state
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
const {
translateX,
backdropOpacity,
@@ -104,18 +102,20 @@ export function ExplorerSidebar({
logExplorerSidebar("handleClose", {
reason,
isOpen,
mobileView,
desktopFileExplorerOpen,
});
closeToAgent();
if (isMobile) {
showMobileAgent();
return;
}
closeDesktopFileExplorer();
},
[closeToAgent, desktopFileExplorerOpen, isOpen, mobileView],
[closeDesktopFileExplorer, isMobile, isOpen, showMobileAgent],
);
const handleCloseFromGesture = useCallback(() => {
gestureAnimatingRef.current = true;
closeToAgent();
}, [closeToAgent, gestureAnimatingRef]);
showMobileAgent();
}, [gestureAnimatingRef, showMobileAgent]);
const enableSidebarCloseGesture = isMobile && isOpen;
@@ -292,6 +292,7 @@ export function ExplorerSidebar({
workspaceRoot={workspaceRoot}
isGit={isGit}
isMobile={isMobile}
isOpen={isOpen}
onOpenFile={onOpenFile}
/>
</Animated.View>
@@ -324,6 +325,7 @@ export function ExplorerSidebar({
workspaceRoot={workspaceRoot}
isGit={isGit}
isMobile={false}
isOpen={isOpen}
onOpenFile={onOpenFile}
/>
</View>
@@ -340,6 +342,7 @@ interface SidebarContentProps {
workspaceRoot: string;
isGit: boolean;
isMobile: boolean;
isOpen: boolean;
onOpenFile?: (filePath: string) => void;
}
@@ -352,6 +355,7 @@ function SidebarContent({
workspaceRoot,
isGit,
isMobile,
isOpen,
onOpenFile,
}: SidebarContentProps) {
const { theme } = useUnistyles();
@@ -360,8 +364,8 @@ function SidebarContent({
const prPane = usePrPaneData({
serverId,
cwd: workspaceRoot,
enabled: canQueryPullRequest,
timelineEnabled: activeTab === "pr" && canQueryPullRequest,
enabled: canQueryPullRequest && isOpen,
timelineEnabled: activeTab === "pr" && canQueryPullRequest && isOpen,
});
const hasPullRequest = prPane.prNumber !== null;
const requestedTab: ExplorerTab =

View File

@@ -8,18 +8,8 @@ import {
Text,
View,
} from "react-native";
import { Gesture } from "react-native-gesture-handler";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
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 { Fonts } from "@/constants/theme";
import * as Clipboard from "expo-clipboard";
@@ -34,6 +24,7 @@ import {
X,
} from "lucide-react-native";
import { getFileIconSvg } from "@/components/material-file-icons";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
import { useHosts } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
@@ -312,48 +303,6 @@ export function FileExplorerPane({
const handleRefresh = useCallback(() => {
void 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";
@@ -563,11 +512,15 @@ export function FileExplorerPane({
(hovered || pressed) && styles.iconButtonHovered,
]}
accessibilityRole="button"
accessibilityLabel="Refresh files"
accessibilityLabel={isRefreshFetching ? "Refreshing files" : "Refresh files"}
>
<Animated.View style={[styles.refreshIcon, refreshIconAnimatedStyle]}>
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Animated.View>
<View style={styles.refreshIcon}>
{isRefreshFetching ? (
<LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
) : (
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
)}
</View>
</Pressable>
</View>
<FlatList

View File

@@ -681,8 +681,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
files,
payloadError: diffPayloadError,
isLoading: isDiffLoading,
isError: isDiffError,
error: diffError,
} = useCheckoutDiffQuery({
serverId,
cwd,
@@ -1071,9 +1069,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
);
const hasChanges = files.length > 0;
const diffErrorMessage =
diffPayloadError?.message ??
(isDiffError && diffError instanceof Error ? diffError.message : null);
const diffErrorMessage = diffPayloadError?.message ?? null;
const prErrorMessage = githubFeaturesEnabled ? (prPayloadError?.message ?? null) : null;
const branchLabel =
gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD"

View File

@@ -5,7 +5,7 @@ import { PanelLeft } from "lucide-react-native";
import { ScreenHeader } from "./screen-header";
import { ScreenTitle } from "./screen-title";
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 { getShortcutOs } from "@/utils/shortcut-platform";
@@ -46,18 +46,16 @@ export function SidebarMenuToggle({
}: SidebarMenuToggleProps = {}) {
const { theme } = useUnistyles();
const isMobile = useIsCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView);
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
const isOpen = usePanelStore((state) => selectIsAgentListOpen(state, { isCompact: isMobile }));
const toggleAgentListForLayout = usePanelStore((state) => state.toggleAgentListForLayout);
const toggleShortcutKeys = getShortcutOs() === "mac" ? ["mod", "B"] : ["mod", "."];
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
const menuIconColor =
!isMobile && isOpen ? theme.colors.foreground : theme.colors.foregroundMuted;
return (
<HeaderToggleButton
onPress={toggleAgentList}
onPress={() => toggleAgentListForLayout({ isCompact: isMobile })}
tooltipLabel="Toggle sidebar"
tooltipKeys={toggleShortcutKeys}
tooltipSide={tooltipSide}

View File

@@ -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,
});
});
});

View File

@@ -32,7 +32,12 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
import { Shortcut } from "@/components/ui/shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
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 { SidebarWorkspaceList } from "./sidebar-workspace-list";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
@@ -52,10 +57,10 @@ import {
buildHostSessionsRoute,
buildSettingsRoute,
mapPathnameToServer,
parseServerIdFromPathname,
} from "@/utils/host-routes";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { isWeb } from "@/constants/platform";
import { resolveActiveHost } from "@/utils/active-host";
const MIN_CHAT_WIDTH = 400;
@@ -116,24 +121,16 @@ export const LeftSidebar = memo(function LeftSidebar({
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const isCompactLayout = useIsCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView);
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const closeToAgent = usePanelStore((state) => state.closeToAgent);
const isOpen = usePanelStore((state) =>
selectIsAgentListOpen(state, { isCompact: isCompactLayout }),
);
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const pathname = usePathname();
const daemons = useHosts();
const activeServerIdFromPath = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
const activeDaemon = useMemo(() => {
if (daemons.length === 0) {
return null;
}
if (activeServerIdFromPath) {
const routeMatch = daemons.find((entry) => entry.serverId === activeServerIdFromPath);
if (routeMatch) {
return routeMatch;
}
}
return daemons[0] ?? null;
}, [activeServerIdFromPath, daemons]);
const activeDaemon = useMemo(
() => resolveActiveHost({ hosts: daemons, pathname }),
[daemons, pathname],
);
const activeServerId = activeDaemon?.serverId ?? null;
const activeHostLabel = useMemo(() => {
if (!activeDaemon) return "No host";
@@ -183,14 +180,12 @@ export const LeftSidebar = memo(function LeftSidebar({
const hostTriggerRef = useRef<View | null>(null);
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false);
const isOpen = isCompactLayout ? mobileView === "agent-list" : desktopAgentListOpen;
const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarWorkspacesList({
serverId: activeServerId,
enabled: isOpen,
enabled: isCompactLayout || isOpen,
});
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } =
useSidebarShortcutModel(projects);
useSidebarShortcutModel({ projects, isInitialLoad });
const [isManualRefresh, setIsManualRefresh] = useState(false);
@@ -208,18 +203,18 @@ export const LeftSidebar = memo(function LeftSidebar({
const openProjectPicker = useOpenProjectPicker(activeServerId);
const handleOpenProjectMobile = useCallback(() => {
closeToAgent();
showMobileAgent();
void openProjectPicker();
}, [closeToAgent, openProjectPicker]);
}, [showMobileAgent, openProjectPicker]);
const handleOpenProjectDesktop = useCallback(() => {
void openProjectPicker();
}, [openProjectPicker]);
const handleSettingsMobile = useCallback(() => {
closeToAgent();
showMobileAgent();
router.push(buildSettingsRoute());
}, [closeToAgent]);
}, [showMobileAgent]);
const handleSettingsDesktop = useCallback(() => {
router.push(buildSettingsRoute());
@@ -272,7 +267,7 @@ export const LeftSidebar = memo(function LeftSidebar({
insetsTop={insets.top}
insetsBottom={insets.bottom}
isOpen={isOpen}
closeToAgent={closeToAgent}
closeToAgent={showMobileAgent}
handleOpenProject={handleOpenProjectMobile}
handleSettings={handleSettingsMobile}
handleViewMoreNavigate={handleViewMoreNavigate}

View File

@@ -1,9 +1,15 @@
import React from "react";
import React, { createRef } from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { JSDOM } from "jsdom";
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(() => ({
theme: {
@@ -85,9 +91,9 @@ vi.mock("@/hooks/use-dictation", () => ({
duration: 0,
error: null,
status: "idle",
startDictation: vi.fn(),
cancelDictation: vi.fn(),
confirmDictation: vi.fn(),
startDictation: startDictationMock,
cancelDictation: cancelDictationMock,
confirmDictation: confirmDictationMock,
retryFailedDictation: vi.fn(),
discardFailedDictation: vi.fn(),
}),
@@ -214,6 +220,9 @@ beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
startDictationMock.mockReset();
cancelDictationMock.mockReset();
confirmDictationMock.mockReset();
});
afterEach(() => {
@@ -325,3 +334,57 @@ describe("MessageInput attachments", () => {
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();
});
});

View File

@@ -480,11 +480,15 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
toast.error(dictationUnavailableMessage);
return;
}
if (!canStartDictation()) {
isDictatingRef.current = false;
return;
}
// Keep hotkey toggling deterministic between the async start call and the
// state-ref sync effect, so a rapid second toggle routes to confirm.
isDictatingRef.current = true;
await startDictation();
}, [dictationUnavailableMessage, startDictation, toast]);
}, [canStartDictation, dictationUnavailableMessage, startDictation, toast]);
// Animate overlay
useEffect(() => {

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_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 = `
@keyframes ${WEB_TOOLCALL_SHIMMER_ANIMATION_NAME} {
0% {
@@ -947,9 +967,16 @@ const MemoizedMarkdownBlock = React.memo(function MemoizedMarkdownBlock({
onLinkPress,
}: MemoizedMarkdownBlockProps) {
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}
</Markdown>
</MarkdownWithStableRenderer>
);
});

View File

@@ -3,30 +3,22 @@ import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-nativ
import { Folder } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useQuery } from "@tanstack/react-query";
import { usePathname } from "expo-router";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { shortenPath } from "@/utils/shorten-path";
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 { parseServerIdFromPathname } from "@/utils/host-routes";
import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions";
import { isNative } from "@/constants/platform";
import { useActiveServerId } from "@/hooks/use-active-server-id";
export function ProjectPickerModal() {
const { theme } = useUnistyles();
const pathname = usePathname();
const daemons = useHosts();
const serverId = useActiveServerId();
const open = useKeyboardShortcutsStore((s) => s.projectPickerOpen);
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 isConnected = useHostRuntimeIsConnected(serverId ?? "");
const recommendedPaths = useRecommendedProjectPaths(serverId);

View File

@@ -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 { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
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 { Fonts } from "@/constants/theme";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
@@ -82,8 +82,12 @@ export function ProviderDiagnosticSheet({
);
const handleRefresh = useCallback(() => {
void refresh([provider as AgentProvider]);
void fetchDiagnostic({ keepCurrent: true });
if (!provider) {
return;
}
void Promise.all([refresh([provider as AgentProvider]), fetchDiagnostic()]).catch((err) => {
setDiagnostic(err instanceof Error ? err.message : "Failed to refresh provider");
});
}, [fetchDiagnostic, provider, refresh]);
useEffect(() => {
@@ -157,13 +161,15 @@ export function ProviderDiagnosticSheet({
refreshInFlight ? sheetStyles.disabled : null,
]}
accessibilityRole="button"
accessibilityLabel={`Refresh ${providerLabel}`}
accessibilityLabel={
refreshInFlight ? `Refreshing ${providerLabel}` : `Refresh ${providerLabel}`
}
>
<SpinningRefreshIcon
spinning={refreshInFlight}
size={theme.iconSize.sm}
color={theme.colors.foregroundMuted}
/>
{refreshInFlight ? (
<LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
) : (
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
)}
</Pressable>
}
>

View File

@@ -28,6 +28,12 @@ import type { HostProfile } from "@/types/host-connection";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
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", () => ({
default: {
@@ -43,6 +49,8 @@ type RenderCounts = {
frame: number;
headers: Record<string, number>;
rows: Record<string, number>;
projectSelection: Record<string, number>;
rowSelection: Record<string, number>;
};
const runningScript: WorkspaceScriptPayload = {
@@ -166,6 +174,8 @@ function resetCounts(counts: RenderCounts): void {
counts.frame = 0;
counts.headers = {};
counts.rows = {};
counts.projectSelection = {};
counts.rowSelection = {};
}
function incrementRecord(record: Record<string, number>, key: string): void {
@@ -201,6 +211,37 @@ function WorkspaceRowProbe({
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 {
counts.frame += 1;
const { projects } = useSidebarWorkspacesList({ serverId: SERVER_ID });
@@ -210,13 +251,20 @@ function SidebarFrameProbe({ counts }: { counts: RenderCounts }): ReactElement {
{projects.map((project) => (
<div key={project.projectKey}>
<ProjectHeaderProbe project={project} counts={counts} />
<ProjectActiveProbe serverId={SERVER_ID} project={project} counts={counts} />
{project.workspaces.map((workspace) => (
<WorkspaceRowProbe
key={workspace.workspaceKey}
serverId={workspace.serverId}
workspaceId={workspace.workspaceId}
counts={counts}
/>
<React.Fragment key={workspace.workspaceKey}>
<WorkspaceRowProbe
serverId={workspace.serverId}
workspaceId={workspace.workspaceId}
counts={counts}
/>
<WorkspaceSelectionProbe
serverId={workspace.serverId}
workspaceId={workspace.workspaceId}
counts={counts}
/>
</React.Fragment>
))}
</div>
))}
@@ -278,6 +326,7 @@ describe("sidebar workspace render isolation", () => {
container?.remove();
container = null;
act(() => {
syncNavigationActiveWorkspace({ current: null });
getHostRuntimeStore().syncHosts([]);
useSessionStore.getState().clearSession(SERVER_ID);
useSidebarOrderStore.setState({
@@ -288,7 +337,13 @@ describe("sidebar workspace render isolation", () => {
});
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));
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 () => {
const counts: RenderCounts = { frame: 0, headers: {}, rows: {} };
const counts: RenderCounts = {
frame: 0,
headers: {},
rows: {},
projectSelection: {},
rowSelection: {},
};
({ root, container } = await renderProbe(counts));
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 () => {
const counts: RenderCounts = { frame: 0, headers: {}, rows: {} };
const counts: RenderCounts = {
frame: 0,
headers: {},
rows: {},
projectSelection: {},
rowSelection: {},
};
({ root, container } = await renderProbe(counts));
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,
});
});
});

View File

@@ -28,7 +28,6 @@ import * as Clipboard from "expo-clipboard";
import { DiffStat } from "@/components/diff-stat";
import {
Archive,
ArrowUpRight,
CircleAlert,
ChevronDown,
ChevronRight,
@@ -92,14 +91,17 @@ import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import { type PrHint, useWorkspacePrHint } from "@/hooks/use-checkout-pr-status-query";
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 {
normalizeWorkspaceDescriptor,
useSessionStore,
type WorkspaceDescriptor,
} from "@/stores/session-store";
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 {
requireWorkspaceExecutionDirectory,
@@ -216,6 +218,7 @@ export function PrBadge({ hint }: { hint: PrHint }) {
const { theme } = useUnistyles();
const [isHovered, setIsHovered] = useState(false);
const activeColor = isHovered ? theme.colors.foreground : theme.colors.foregroundMuted;
const iconColor = getWorkspacePrIconColor(theme, hint.state);
const handlePressIn = useCallback((event: GestureResponderEvent) => {
event.stopPropagation();
@@ -240,11 +243,14 @@ export function PrBadge({ hint }: { hint: PrHint }) {
onHoverOut={() => setIsHovered(false)}
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}>
#{hint.number}
</Text>
<ArrowUpRight size={10} color={activeColor} style={{ opacity: isHovered ? 1 : 0 }} />
</Pressable>
);
}
@@ -830,12 +836,7 @@ function ProjectHeaderRow({
const rowChildren = (
<>
<View
{...(dragHandleProps?.attributes as any)}
{...(dragHandleProps?.listeners as any)}
ref={dragHandleProps?.setActivatorNodeRef as any}
style={styles.projectRowLeft}
>
<View style={styles.projectRowLeft}>
<ProjectLeadingVisual
displayName={displayName}
iconDataUri={iconDataUri}
@@ -912,7 +913,13 @@ function ProjectHeaderRow({
if (menuController) {
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
enabledOnMobile={false}
style={({ pressed }) => [
@@ -935,7 +942,13 @@ function ProjectHeaderRow({
}
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
style={({ pressed }) => [
styles.projectRow,
@@ -1010,6 +1023,9 @@ function WorkspaceRowInner({
return (
<WorkspaceHoverCard workspace={workspace} prHint={prHint} isDragging={isDragging}>
<View
{...(dragHandleProps?.attributes as any)}
{...(dragHandleProps?.listeners as any)}
ref={dragHandleProps?.setActivatorNodeRef as any}
style={styles.workspaceRowContainer}
onPointerEnter={() => setIsHovered(true)}
onPointerLeave={() => setIsHovered(false)}
@@ -1033,12 +1049,7 @@ function WorkspaceRowInner({
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
>
<View style={styles.workspaceRowMain}>
<View
{...(dragHandleProps?.attributes as any)}
{...(dragHandleProps?.listeners as any)}
ref={dragHandleProps?.setActivatorNodeRef as any}
style={styles.workspaceRowLeft}
>
<View style={styles.workspaceRowLeft}>
<WorkspaceStatusIndicator
bucket={workspace.statusBucket}
workspaceKind={workspace.workspaceKind}
@@ -1133,8 +1144,8 @@ function WorkspaceRowInner({
</View>
{prHint ? (
<View style={styles.workspacePrBadgeRow}>
<ChecksBadge checks={prHint.checks} />
<PrBadge hint={prHint} />
<ChecksBadge checks={prHint.checks} />
</View>
) : null}
</Pressable>
@@ -1167,7 +1178,6 @@ function WorkspaceRowWithMenu({
isCreating?: boolean;
}) {
const toast = useToast();
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection();
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
const workspaceDirectory = resolveWorkspaceExecutionDirectory({
@@ -1185,22 +1195,11 @@ function WorkspaceRowWithMenu({
const isWorktree = workspace.workspaceKind === "worktree";
const isArchiving = isWorktree ? archiveStatus === "pending" : isArchivingWorkspace;
const redirectAfterArchive = useCallback(() => {
if (
activeWorkspaceSelection?.serverId !== workspace.serverId ||
activeWorkspaceSelection.workspaceId !== workspace.workspaceId
) {
return;
}
router.replace(
buildWorkspaceArchiveRedirectRoute({
serverId: workspace.serverId,
archivedWorkspaceId: workspace.workspaceId,
workspaces:
useSessionStore.getState().sessions[workspace.serverId]?.workspaces.values() ?? [],
}),
);
}, [activeWorkspaceSelection, workspace.serverId, workspace.workspaceId]);
redirectIfArchivingActiveWorkspace({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
});
}, [workspace.serverId, workspace.workspaceId]);
const handleArchiveWorktree = useCallback(() => {
if (isArchiving) {
@@ -1393,25 +1392,13 @@ function NonGitProjectRowWithMenuContent({
}) {
const toast = useToast();
const contextMenu = useContextMenu();
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection();
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
const redirectAfterArchive = useCallback(() => {
if (
activeWorkspaceSelection?.serverId !== workspace.serverId ||
activeWorkspaceSelection.workspaceId !== workspace.workspaceId
) {
return;
}
router.replace(
buildWorkspaceArchiveRedirectRoute({
serverId: workspace.serverId,
archivedWorkspaceId: workspace.workspaceId,
workspaces:
useSessionStore.getState().sessions[workspace.serverId]?.workspaces.values() ?? [],
}) as any,
);
}, [activeWorkspaceSelection, workspace.serverId, workspace.workspaceId]);
redirectIfArchivingActiveWorkspace({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
});
}, [workspace.serverId, workspace.workspaceId]);
const handleArchiveWorkspace = useCallback(() => {
if (isArchivingWorkspace) {
@@ -1538,6 +1525,7 @@ function FlattenedProjectRow({
isProjectActive = false,
onRemoveProject,
removeProjectStatus,
selectionEnabled,
}: {
project: SidebarProjectEntry;
displayName: string;
@@ -1555,8 +1543,14 @@ function FlattenedProjectRow({
isProjectActive?: boolean;
onRemoveProject?: () => void;
removeProjectStatus?: "idle" | "pending";
selectionEnabled: boolean;
}) {
const workspace = useSidebarWorkspaceEntry(serverId, rowModel.workspace.workspaceId);
const selected = useIsNavigationWorkspaceSelected({
serverId,
workspaceId: rowModel.workspace.workspaceId,
enabled: selectionEnabled,
});
if (!workspace) {
return null;
@@ -1569,7 +1563,7 @@ function FlattenedProjectRow({
displayName={displayName}
iconDataUri={iconDataUri}
workspace={workspace}
selected={rowModel.selected}
selected={selected}
onPress={onPress}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
@@ -1586,7 +1580,7 @@ function FlattenedProjectRow({
displayName={displayName}
iconDataUri={iconDataUri}
workspace={workspace}
selected={rowModel.selected}
selected={selected}
chevron={rowModel.chevron}
onPress={onPress}
serverId={serverId}
@@ -1608,7 +1602,6 @@ function FlattenedProjectRow({
function WorkspaceRow({
workspace,
selected,
shortcutNumber,
showShortcutBadge,
onPress,
@@ -1617,9 +1610,9 @@ function WorkspaceRow({
dragHandleProps,
canCopyBranchName,
isCreating = false,
selectionEnabled,
}: {
workspace: SidebarWorkspaceEntry;
selected: boolean;
shortcutNumber: number | null;
showShortcutBadge: boolean;
onPress: () => void;
@@ -1628,8 +1621,14 @@ function WorkspaceRow({
dragHandleProps?: DraggableListDragHandleProps;
canCopyBranchName: boolean;
isCreating?: boolean;
selectionEnabled: boolean;
}) {
const hydratedWorkspace = useSidebarWorkspaceEntry(workspace.serverId, workspace.workspaceId);
const selected = useIsNavigationWorkspaceSelected({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
enabled: selectionEnabled,
});
if (!hydratedWorkspace) {
return null;
@@ -1657,7 +1656,7 @@ function ProjectBlock({
displayName,
iconDataUri,
serverId,
activeWorkspaceSelection,
selectionEnabled,
showShortcutBadges,
shortcutIndexByWorkspaceKey,
parentGestureRef,
@@ -1665,6 +1664,7 @@ function ProjectBlock({
onWorkspacePress,
onWorkspaceReorder,
onWorktreeCreated,
currentPathname,
drag,
isDragging,
dragHandleProps,
@@ -1676,7 +1676,7 @@ function ProjectBlock({
displayName: string;
iconDataUri: string | null;
serverId: string | null;
activeWorkspaceSelection: { serverId: string; workspaceId: string } | null;
selectionEnabled: boolean;
showShortcutBadges: boolean;
shortcutIndexByWorkspaceKey: Map<string, number>;
parentGestureRef?: MutableRefObject<GestureType | undefined>;
@@ -1684,6 +1684,7 @@ function ProjectBlock({
onWorkspacePress?: () => void;
onWorkspaceReorder: (projectKey: string, workspaces: SidebarWorkspaceEntry[]) => void;
onWorktreeCreated?: (workspaceId: string) => void;
currentPathname: string | null;
drag: () => void;
isDragging: boolean;
dragHandleProps?: DraggableListDragHandleProps;
@@ -1695,18 +1696,19 @@ function ProjectBlock({
buildSidebarProjectRowModel({
project,
collapsed,
serverId,
activeWorkspaceSelection,
}),
[activeWorkspaceSelection, collapsed, project, serverId],
[collapsed, project],
);
const isProjectActive = useMemo(() => {
if (!serverId || !activeWorkspaceSelection || activeWorkspaceSelection.serverId !== serverId) {
return false;
}
return project.workspaces.some((w) => w.workspaceId === activeWorkspaceSelection.workspaceId);
}, [serverId, activeWorkspaceSelection, project.workspaces]);
const projectWorkspaceIds = useMemo(
() => project.workspaces.map((workspace) => workspace.workspaceId),
[project.workspaces],
);
const isProjectActive = useIsNavigationProjectActive({
serverId,
workspaceIds: projectWorkspaceIds,
enabled: selectionEnabled,
});
const renderWorkspaceRow = useCallback(
(
@@ -1717,25 +1719,20 @@ function ProjectBlock({
dragHandleProps?: DraggableListDragHandleProps;
},
) => {
const isSelected =
Boolean(serverId) &&
activeWorkspaceSelection?.serverId === serverId &&
activeWorkspaceSelection.workspaceId === item.workspaceId;
return (
<WorkspaceRow
workspace={item}
selected={isSelected}
shortcutNumber={shortcutIndexByWorkspaceKey.get(item.workspaceKey) ?? null}
showShortcutBadge={showShortcutBadges}
canCopyBranchName={project.projectKind === "git"}
isCreating={creatingWorkspaceIds.has(item.workspaceId)}
selectionEnabled={selectionEnabled}
onPress={() => {
if (!serverId) {
return;
}
onWorkspacePress?.();
navigateToWorkspace(serverId, item.workspaceId);
navigateToWorkspace(serverId, item.workspaceId, { currentPathname });
}}
drag={input?.drag ?? (() => {})}
isDragging={input?.isDragging ?? false}
@@ -1744,11 +1741,12 @@ function ProjectBlock({
);
},
[
activeWorkspaceSelection,
project.projectKind,
creatingWorkspaceIds,
currentPathname,
onWorkspacePress,
serverId,
selectionEnabled,
shortcutIndexByWorkspaceKey,
showShortcutBadges,
],
@@ -1835,7 +1833,9 @@ function ProjectBlock({
return;
}
onWorkspacePress?.();
navigateToWorkspace(serverId, rowModel.workspace.workspaceId);
navigateToWorkspace(serverId, rowModel.workspace.workspaceId, {
currentPathname,
});
}}
serverId={serverId}
onWorkspacePress={onWorkspacePress}
@@ -1848,6 +1848,7 @@ function ProjectBlock({
isProjectActive={isProjectActive}
onRemoveProject={handleRemoveProject}
removeProjectStatus={isRemovingProject ? "pending" : "idle"}
selectionEnabled={selectionEnabled}
/>
) : (
<>
@@ -1906,9 +1907,7 @@ export function SidebarWorkspaceList({
listFooterComponent,
parentGestureRef,
}: SidebarWorkspaceListProps) {
const isMobile = useIsCompactFormFactor();
const pathname = usePathname();
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection();
const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set());
const creatingWorkspaceTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
new Map(),
@@ -1924,7 +1923,7 @@ export function SidebarWorkspaceList({
() => Boolean(pathname && parseHostWorkspaceRouteFromPathname(pathname)),
[pathname],
);
const effectiveActiveWorkspaceSelection = isWorkspaceRoute ? activeWorkspaceSelection : null;
const selectionEnabled = isWorkspaceRoute;
const projectIconRequests = useMemo(() => {
if (!serverId) {
@@ -2129,7 +2128,7 @@ export function SidebarWorkspaceList({
displayName={item.projectName}
iconDataUri={projectIconByProjectKey.get(item.projectKey) ?? null}
serverId={serverId}
activeWorkspaceSelection={effectiveActiveWorkspaceSelection}
selectionEnabled={selectionEnabled}
showShortcutBadges={showShortcutBadges}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
parentGestureRef={parentGestureRef}
@@ -2137,6 +2136,7 @@ export function SidebarWorkspaceList({
onWorkspacePress={onWorkspacePress}
onWorkspaceReorder={handleWorkspaceReorder}
onWorktreeCreated={handleWorktreeCreated}
currentPathname={pathname}
drag={drag}
isDragging={isActive}
dragHandleProps={dragHandleProps}
@@ -2147,13 +2147,14 @@ export function SidebarWorkspaceList({
},
[
collapsedProjectKeys,
effectiveActiveWorkspaceSelection,
handleWorktreeCreated,
handleWorkspaceReorder,
onWorkspacePress,
onToggleProjectCollapsed,
parentGestureRef,
pathname,
projectIconByProjectKey,
selectionEnabled,
serverId,
shortcutIndexByWorkspaceKey,
showShortcutBadges,
@@ -2266,6 +2267,7 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
userSelect: "none",
},
projectRowHovered: {
backgroundColor: theme.colors.surfaceSidebarHover,
@@ -2409,6 +2411,7 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "stretch",
justifyContent: "center",
gap: theme.spacing[1],
userSelect: "none",
},
workspaceRowMain: {
flexDirection: "row",

View File

@@ -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>
);
}

View File

@@ -76,6 +76,7 @@ interface SplitContainerProps {
workspaceKey: string;
normalizedServerId: string;
normalizedWorkspaceId: string;
isWorkspaceFocused: boolean;
uiTabs: WorkspaceTab[];
hoveredCloseTabKey: string | null;
setHoveredTabKey: Dispatch<SetStateAction<string | null>>;
@@ -93,7 +94,6 @@ interface SplitContainerProps {
onCreateTerminalTab: (input: { paneId?: string }) => void;
buildPaneContentModel: (input: {
paneId: string;
isPaneFocused: boolean;
tab: WorkspaceTabDescriptor;
}) => WorkspacePaneContentModel;
onFocusPane: (paneId: string) => void;
@@ -158,11 +158,11 @@ interface SplitPaneViewProps
interface MountedTabSlotProps {
tabDescriptor: WorkspaceTabDescriptor;
isVisible: boolean;
isWorkspaceFocused: boolean;
isPaneFocused: boolean;
paneId: string;
buildPaneContentModel: (input: {
paneId: string;
isPaneFocused: boolean;
tab: WorkspaceTabDescriptor;
}) => WorkspacePaneContentModel;
}
@@ -170,6 +170,7 @@ interface MountedTabSlotProps {
const MountedTabSlot = memo(function MountedTabSlot({
tabDescriptor,
isVisible,
isWorkspaceFocused,
isPaneFocused,
paneId,
buildPaneContentModel,
@@ -178,15 +179,18 @@ const MountedTabSlot = memo(function MountedTabSlot({
() =>
buildPaneContentModel({
paneId,
isPaneFocused,
tab: tabDescriptor,
}),
[buildPaneContentModel, isPaneFocused, paneId, tabDescriptor],
[buildPaneContentModel, paneId, tabDescriptor],
);
return (
<View style={{ display: isVisible ? "flex" : "none", flex: 1 }}>
<WorkspacePaneContent content={content} />
<WorkspacePaneContent
content={content}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isPaneFocused}
/>
</View>
);
});
@@ -241,6 +245,7 @@ export function SplitContainer({
workspaceKey,
normalizedServerId,
normalizedWorkspaceId,
isWorkspaceFocused,
uiTabs,
hoveredCloseTabKey,
setHoveredTabKey,
@@ -511,6 +516,7 @@ export function SplitContainer({
focusedPaneId={layout.focusedPaneId}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isWorkspaceFocused}
hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
@@ -633,6 +639,7 @@ function SplitNodeView({
focusedPaneId,
normalizedServerId,
normalizedWorkspaceId,
isWorkspaceFocused,
hoveredCloseTabKey,
setHoveredTabKey,
setHoveredCloseTabKey,
@@ -668,6 +675,7 @@ function SplitNodeView({
isFocused={node.pane.id === focusedPaneId}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isWorkspaceFocused}
hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
@@ -718,6 +726,7 @@ function SplitNodeView({
focusedPaneId={focusedPaneId}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isWorkspaceFocused}
hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
@@ -767,6 +776,7 @@ function SplitPaneView({
isFocused,
normalizedServerId,
normalizedWorkspaceId,
isWorkspaceFocused,
hoveredCloseTabKey,
setHoveredTabKey,
setHoveredCloseTabKey,
@@ -916,6 +926,7 @@ function SplitPaneView({
key={tabId}
tabDescriptor={tabDescriptor}
isVisible={tabId === activeTabDescriptor?.tabId}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isFocused && tabId === activeTabDescriptor?.tabId}
paneId={pane.id}
buildPaneContentModel={buildPaneContentModel}

View File

@@ -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);
});
});

View File

@@ -1,4 +1,4 @@
import {
import React, {
Fragment,
type CSSProperties,
useCallback,
@@ -126,6 +126,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const lastTouchClientYRef = useRef<number | null>(null);
const pendingAutoScrollFrameRef = useRef<number | null>(null);
const pendingAutoScrollTimeoutRef = useRef<number | null>(null);
const pendingVirtualRowMeasureFramesRef = useRef(new Map<Element, number>());
const showDesktopWebScrollbar = !isMobileBreakpoint;
const scrollbarOverlay = useWebElementScrollbar(scrollContainerRef, {
enabled: showDesktopWebScrollbar,
@@ -181,6 +182,38 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const virtualRows = rowVirtualizer.getVirtualItems();
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 pendingFrame = pendingAutoScrollFrameRef.current;
if (pendingFrame !== null) {
@@ -696,7 +729,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
ref={measureVirtualizedRowElement}
style={renderVirtualRowStyle(virtualRow.start)}
>
{renderHistoryVirtualizedRow(

View File

@@ -1,5 +1,4 @@
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 Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -27,7 +26,9 @@ interface TerminalPaneProps {
serverId: string;
cwd: string;
terminalId: string;
isWorkspaceFocused: boolean;
isPaneFocused: boolean;
onOpenFileExplorer: () => void;
}
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}`;
}
export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: TerminalPaneProps) {
const isScreenFocused = useIsFocused();
export function TerminalPane({
serverId,
cwd,
terminalId,
isWorkspaceFocused,
isPaneFocused,
onOpenFileExplorer,
}: TerminalPaneProps) {
const isAppVisible = useAppVisible();
const { theme } = useUnistyles();
const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme]);
const isMobile = useIsCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView);
const openAgentList = usePanelStore((state) => state.openAgentList);
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
const showMobileAgentList = usePanelStore((state) => state.showMobileAgentList);
const swipeGesturesEnabled = isMobile && mobileView === "agent";
const { shift: keyboardShift, style: keyboardPaddingStyle } = useKeyboardShiftStyle({
mode: "padding",
@@ -131,7 +137,7 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
}, []);
useEffect(() => {
if (isMobile || !isScreenFocused || !isPaneFocused || !terminalId) {
if (isMobile || !isWorkspaceFocused || !isPaneFocused || !terminalId) {
lastAutoFocusKeyRef.current = null;
return;
}
@@ -143,14 +149,14 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
lastAutoFocusKeyRef.current = nextFocusKey;
requestTerminalFocus();
}, [isMobile, isPaneFocused, isScreenFocused, requestTerminalFocus, scopeKey, terminalId]);
}, [isMobile, isPaneFocused, isWorkspaceFocused, requestTerminalFocus, scopeKey, terminalId]);
useEffect(() => {
if (isPaneFocused && isScreenFocused && isAppVisible && terminalId) {
if (isPaneFocused && isWorkspaceFocused && isAppVisible && terminalId) {
lastReportedSizeRef.current = null;
requestTerminalReflow();
}
}, [isAppVisible, isPaneFocused, isScreenFocused, requestTerminalReflow, terminalId]);
}, [isAppVisible, isPaneFocused, isWorkspaceFocused, requestTerminalReflow, terminalId]);
const clearKeyboardRefitTimeouts = useCallback(() => {
if (keyboardRefitTimeoutsRef.current.length === 0) {
@@ -187,29 +193,27 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
[pulseKeyboardRefits],
);
useFocusEffect(
useCallback(() => {
if (!terminalId) {
return;
}
// Navigation transitions can temporarily report stale dimensions.
// Pulse forced refits so xterm fills the pane when returning to an agent.
const timeoutHandles = TERMINAL_REFIT_DELAYS_MS.map((delayMs) =>
setTimeout(() => {
requestTerminalReflow();
}, delayMs),
);
useEffect(() => {
if (!isWorkspaceFocused || !terminalId) {
return;
}
// Focus transitions can temporarily report stale dimensions.
// Pulse forced refits so xterm fills the pane when returning to a terminal.
const timeoutHandles = TERMINAL_REFIT_DELAYS_MS.map((delayMs) =>
setTimeout(() => {
requestTerminalReflow();
}, delayMs),
);
return () => {
for (const handle of timeoutHandles) {
clearTimeout(handle);
}
};
}, [requestTerminalReflow, terminalId]),
);
return () => {
for (const handle of timeoutHandles) {
clearTimeout(handle);
}
};
}, [isWorkspaceFocused, requestTerminalReflow, terminalId]);
useEffect(() => {
if (!client || !isConnected || !isScreenFocused) {
if (!client || !isConnected || !isWorkspaceFocused) {
return;
}
@@ -232,7 +236,7 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
});
setModifiers({ ...EMPTY_MODIFIERS });
});
}, [client, isConnected, workspaceTerminalSession.snapshots]);
}, [client, isConnected, isWorkspaceFocused, workspaceTerminalSession.snapshots]);
useEffect(() => {
lastReportedSizeRef.current = null;
@@ -257,14 +261,14 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
client,
getPreferredSize: () => lastReportedSizeRef.current,
onOutput: ({ terminalId, text }) => {
if (!isScreenFocused || terminalIdRef.current !== terminalId) {
if (!isWorkspaceFocused || terminalIdRef.current !== terminalId) {
return;
}
emulatorRef.current?.writeOutput(text);
},
onSnapshot: ({ terminalId, state }) => {
workspaceTerminalSession.snapshots.set({ terminalId, state });
if (!isScreenFocused || terminalIdRef.current !== terminalId) {
if (!isWorkspaceFocused || terminalIdRef.current !== terminalId) {
return;
}
emulatorRef.current?.renderSnapshot(state);
@@ -274,7 +278,7 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
streamControllerRef.current = controller;
controller.setTerminal({
terminalId: isScreenFocused ? terminalIdRef.current : null,
terminalId: isWorkspaceFocused ? terminalIdRef.current : null,
});
return () => {
@@ -287,17 +291,17 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
client,
handleStreamControllerStatus,
isConnected,
isScreenFocused,
isWorkspaceFocused,
workspaceTerminalSession.snapshots,
]);
useEffect(() => {
pendingTerminalInputRef.current = [];
const nextTerminalId = isScreenFocused ? terminalId : null;
const nextTerminalId = isWorkspaceFocused ? terminalId : null;
streamControllerRef.current?.setTerminal({
terminalId: nextTerminalId,
});
}, [isScreenFocused, terminalId]);
}, [isWorkspaceFocused, terminalId]);
const enqueuePendingTerminalInput = useCallback((entry: PendingTerminalInput) => {
const queue = pendingTerminalInputRef.current;
@@ -477,7 +481,7 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
!client ||
!terminalId ||
!isPaneFocused ||
!isScreenFocused ||
!isWorkspaceFocused ||
!isAppVisible ||
rows <= 0 ||
cols <= 0
@@ -550,7 +554,7 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
return (
<Animated.View style={[styles.container, keyboardPaddingStyle]}>
<View style={styles.outputContainer}>
{isScreenFocused ? (
{isWorkspaceFocused ? (
<View style={styles.terminalGestureContainer}>
<TerminalEmulator
ref={emulatorRef}
@@ -573,13 +577,13 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
if (!swipeGesturesEnabled) {
return;
}
openAgentList();
showMobileAgentList();
}}
onSwipeLeft={() => {
if (!swipeGesturesEnabled) {
return;
}
openFileExplorer();
onOpenFileExplorer();
}}
onInput={handleTerminalData}
onResize={handleTerminalResize}
@@ -594,7 +598,7 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
<View style={styles.terminalGestureContainer} />
)}
{isAttaching && isScreenFocused ? (
{isAttaching && isWorkspaceFocused ? (
<View style={styles.attachOverlay} pointerEvents="none" testID="terminal-attach-loading">
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
</View>

View File

@@ -3,13 +3,16 @@ import { View, Text, Pressable } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import Animated from "react-native-reanimated";
import {
BottomSheetModal,
BottomSheetScrollView,
BottomSheetBackdrop,
BottomSheetBackgroundProps,
} from "@gorhom/bottom-sheet";
import { X } from "lucide-react-native";
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 { ToolCallDetailsContent } from "./tool-call-details";
@@ -60,18 +63,25 @@ interface ToolCallSheetProviderProps {
export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps) {
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 snapPoints = useMemo(() => ["60%", "95%"], []);
const openToolCall = useCallback((data: ToolCallSheetData) => {
setSheetData(data);
if (hasPresentedRef.current) {
requestAnimationFrame(() => bottomSheetRef.current?.snapToIndex(0));
return;
}
hasPresentedRef.current = true;
bottomSheetRef.current?.present();
}, []);
const closeToolCall = useCallback(() => {
bottomSheetRef.current?.dismiss();
bottomSheetRef.current?.close();
}, []);
const handleSheetChange = useCallback((index: number) => {
@@ -95,11 +105,10 @@ export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps)
return (
<ToolCallSheetContext.Provider value={contextValue}>
{children}
<BottomSheetModal
<IsolatedBottomSheetModal
ref={bottomSheetRef}
snapPoints={snapPoints}
index={0}
stackBehavior="replace"
enableDynamicSizing={false}
onChange={handleSheetChange}
backdropComponent={renderBackdrop}
@@ -108,7 +117,7 @@ export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps)
handleIndicatorStyle={{ backgroundColor: theme.colors.palette.zinc[600] }}
>
{sheetData && <ToolCallSheetContent data={sheetData} onClose={closeToolCall} />}
</BottomSheetModal>
</IsolatedBottomSheetModal>
</ToolCallSheetContext.Provider>
);
}

View File

@@ -14,7 +14,6 @@ import {
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import {
BottomSheetModal,
BottomSheetScrollView,
BottomSheetBackdrop,
BottomSheetTextInput,
@@ -38,6 +37,10 @@ import {
} from "./combobox-options";
import type { ComboboxOptionModel } from "./combobox-options";
import { isWeb } from "@/constants/platform";
import {
IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility,
} from "./isolated-bottom-sheet-modal";
const IS_WEB = isWeb;
@@ -66,8 +69,6 @@ export interface ComboboxProps {
title?: string;
open?: boolean;
onOpenChange?: (open: boolean) => void;
enableDismissOnClose?: boolean;
stackBehavior?: "push" | "switch" | "replace";
desktopPlacement?: "top-start" | "bottom-start";
/**
* Prevents an initial frame at 0,0 by hiding desktop content until floating
@@ -271,8 +272,6 @@ export function Combobox({
title = "Select",
open,
onOpenChange,
enableDismissOnClose,
stackBehavior,
desktopPlacement = "top-start",
desktopPreventInitialFlash = true,
desktopMinWidth,
@@ -284,11 +283,10 @@ export function Combobox({
}: ComboboxProps): ReactElement {
const { theme } = useUnistyles();
const isMobile = useIsCompactFormFactor();
const titleColor = theme.colors.foreground;
const effectiveOptionsPosition = isMobile ? "below-search" : optionsPosition;
const isDesktopAboveSearch = !isMobile && isWeb && effectiveOptionsPosition === "above-search";
const { height: windowHeight, width: windowWidth } = useWindowDimensions();
const bottomSheetRef = useRef<BottomSheetModal>(null);
const hasPresentedBottomSheetRef = useRef(false);
const snapPoints = useMemo(() => ["60%", "90%"], []);
const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>(
null,
@@ -474,32 +472,11 @@ export function Combobox({
}
: floatingStyles;
useEffect(() => {
if (!isMobile) return;
if (isOpen) {
if (enableDismissOnClose === false && hasPresentedBottomSheetRef.current) {
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 { sheetRef: bottomSheetRef, handleSheetChange } = useIsolatedBottomSheetVisibility({
visible: isOpen,
isEnabled: isMobile,
onClose: handleClose,
});
const renderBackdrop = useCallback(
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
@@ -718,7 +695,7 @@ export function Combobox({
if (isMobile) {
return (
<BottomSheetModal
<IsolatedBottomSheetModal
ref={bottomSheetRef}
snapPoints={snapPoints}
index={0}
@@ -726,15 +703,15 @@ export function Combobox({
onChange={handleSheetChange}
backdropComponent={renderBackdrop}
enablePanDownToClose
enableDismissOnClose={enableDismissOnClose}
stackBehavior={stackBehavior}
backgroundComponent={ComboboxSheetBackground}
handleIndicatorStyle={{ backgroundColor: theme.colors.palette.zinc[600] }}
keyboardBehavior="extend"
keyboardBlurBehavior="restore"
>
<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>
{stickyHeader}
{!children && searchable ? searchInput : null}
@@ -745,7 +722,7 @@ export function Combobox({
>
{content}
</BottomSheetScrollView>
</BottomSheetModal>
</IsolatedBottomSheetModal>
);
}
@@ -931,7 +908,6 @@ const styles = StyleSheet.create((theme) => ({
comboboxTitle: {
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foreground,
textAlign: "left",
},
comboboxScrollContent: {

View File

@@ -30,8 +30,12 @@ import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
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 {
IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility,
} from "@/components/ui/isolated-bottom-sheet-modal";
import { isWeb, isNative } from "@/constants/platform";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
@@ -354,7 +358,6 @@ export function ContextMenuContent({
const isMobile = useIsCompactFormFactor();
const useMobileSheet = isMobile && mobileMode === "sheet";
const { open, setOpen, triggerRef, anchorRect } = context;
const bottomSheetRef = useRef<BottomSheetModal>(null);
const sheetSnapPoints = useMemo(() => ["30%", "55%"], []);
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
@@ -364,23 +367,11 @@ export function ContextMenuContent({
setOpen(false);
}, [setOpen]);
useEffect(() => {
if (!useMobileSheet) return;
if (open) {
bottomSheetRef.current?.present();
return;
}
bottomSheetRef.current?.dismiss();
}, [open, useMobileSheet]);
const handleSheetChange = useCallback(
(index: number) => {
if (index === -1) {
handleClose();
}
},
[handleClose],
);
const { sheetRef: bottomSheetRef, handleSheetChange } = useIsolatedBottomSheetVisibility({
visible: open,
isEnabled: useMobileSheet,
onClose: handleClose,
});
const renderSheetBackdrop = useCallback(
(props: ComponentProps<typeof BottomSheetBackdrop>) => (
@@ -468,7 +459,7 @@ export function ContextMenuContent({
if (useMobileSheet) {
return (
<ContextMenuContext.Provider value={context}>
<BottomSheetModal
<IsolatedBottomSheetModal
ref={bottomSheetRef}
index={0}
snapPoints={sheetSnapPoints}
@@ -495,7 +486,7 @@ export function ContextMenuContent({
>
{children}
</BottomSheetScrollView>
</BottomSheetModal>
</IsolatedBottomSheetModal>
</ContextMenuContext.Provider>
);
}

View File

@@ -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",
}),
);
});
});

View File

@@ -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,
};
}

View File

@@ -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} />;
}

View File

@@ -35,12 +35,13 @@ export function SegmentedControl<T extends string>({
testID,
}: SegmentedControlProps<T>) {
const { theme } = useUnistyles();
const containerSizeStyle = size === "sm" ? styles.containerSm : styles.containerMd;
const segmentSizeStyle = size === "sm" ? styles.segmentSm : styles.segmentMd;
const labelSizeStyle = size === "sm" ? styles.labelSm : styles.labelMd;
const iconSize = size === "sm" ? theme.iconSize.sm : theme.iconSize.md;
return (
<View style={[styles.container, style]} testID={testID}>
<View style={[styles.container, containerSizeStyle, style]} testID={testID}>
{options.map((option) => {
const isSelected = option.value === value;
const iconColor = isSelected ? theme.colors.foreground : theme.colors.foregroundMuted;
@@ -89,39 +90,46 @@ export function SegmentedControl<T extends string>({
const styles = StyleSheet.create((theme) => ({
container: {
flexDirection: "row",
alignItems: "center",
alignItems: "stretch",
maxWidth: "100%",
gap: 4,
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
},
containerSm: {
padding: 2,
},
containerMd: {
padding: 3,
},
segment: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
borderRadius: theme.borderRadius.full,
borderWidth: 1,
borderColor: "transparent",
borderRadius: theme.borderRadius.lg,
gap: theme.spacing[1],
},
segmentSm: {
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[4],
},
segmentMd: {
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[6],
},
segmentSelected: {
backgroundColor: theme.colors.surface3,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface0,
shadowColor: "#000",
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.08,
shadowRadius: 2,
elevation: 1,
},
segmentHover: {
backgroundColor: theme.colors.surface3,
borderColor: theme.colors.borderAccent,
backgroundColor: theme.colors.surface1,
},
segmentPressed: {
backgroundColor: theme.colors.surface3,
borderColor: theme.colors.borderAccent,
backgroundColor: theme.colors.surface1,
},
segmentDisabled: {
opacity: theme.opacity[50],
@@ -135,10 +143,10 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.normal,
},
labelSm: {
fontSize: theme.fontSize.xs,
fontSize: theme.fontSize.sm,
},
labelMd: {
fontSize: theme.fontSize.sm,
fontSize: theme.fontSize.base,
},
labelSelected: {
color: theme.colors.foreground,

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import { Pressable, Text, View, ScrollView } from "react-native";
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 { useSafeAreaInsets } from "react-native-safe-area-context";
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 { isWeb, isNative } from "@/constants/platform";
const ThemedScrollView = withUnistyles(ScrollView);
type WelcomeAction = {
key: "scan-qr" | "direct-connection" | "paste-pairing-link";
label: string;
@@ -33,13 +31,15 @@ type WelcomeAction = {
};
const styles = StyleSheet.create((theme) => ({
scrollView: {
root: {
flex: 1,
backgroundColor: theme.colors.surface0,
},
scrollView: {
flex: 1,
},
container: {
flexGrow: 1,
backgroundColor: theme.colors.surface0,
padding: theme.spacing[6],
paddingBottom: 0,
alignItems: "center",
@@ -309,97 +309,101 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
const showHostList = hosts.length > 0 && !anyOnlineServerId;
return (
<ThemedScrollView
style={styles.scrollView}
contentContainerStyle={[
styles.container,
{ paddingBottom: theme.spacing[6] + insets.bottom },
]}
showsVerticalScrollIndicator={false}
testID="welcome-screen"
>
<View style={styles.content}>
<PaseoLogo size={96} />
<View style={styles.copyBlock}>
<Text style={styles.title}>Welcome to Paseo</Text>
{showHostList ? (
<Text style={styles.subtitle}>Connecting to your hosts</Text>
) : (
<>
<Text style={styles.subtitle}>Connect your computer to get started</Text>
{isNative ? (
<Pressable
style={styles.setupLink}
onPress={() => openExternalUrl("https://paseo.sh")}
>
<Text style={styles.setupLinkText}>paseo.sh</Text>
<ExternalLink size={14} color={theme.colors.accent} />
</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 style={styles.root}>
<ScrollView
style={styles.scrollView}
contentContainerStyle={[
styles.container,
{ paddingBottom: theme.spacing[6] + insets.bottom },
]}
showsVerticalScrollIndicator={false}
testID="welcome-screen"
>
<View style={styles.content}>
<PaseoLogo size={96} />
<View style={styles.copyBlock}>
<Text style={styles.title}>Welcome to Paseo</Text>
{showHostList ? (
<Text style={styles.subtitle}>Connecting to your hosts</Text>
) : (
<>
<Text style={styles.subtitle}>Connect your computer to get started</Text>
{isNative ? (
<Pressable
style={styles.setupLink}
onPress={() => openExternalUrl("https://paseo.sh")}
>
<Text style={styles.setupLinkText}>paseo.sh</Text>
<ExternalLink size={14} color={theme.colors.accent} />
</Pressable>
) : null}
</>
)}
</View>
)}
<Button
variant="ghost"
size="sm"
leftIcon={Settings}
onPress={() => router.push("/settings")}
style={styles.settingsButton}
testID="welcome-open-settings"
>
Settings
</Button>
</View>
<Text style={styles.versionLabel}>{appVersionText}</Text>
<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>
<AddHostModal
visible={isDirectOpen}
onClose={() => setIsDirectOpen(false)}
onSaved={({ profile, serverId }) => {
onHostAdded?.(profile);
finishOnboarding(serverId);
}}
/>
{showHostList && (
<View style={styles.hostList}>
{hosts.map((host) => (
<HostStatusRow key={host.serverId} serverId={host.serverId} label={host.label} />
))}
</View>
)}
<PairLinkModal
visible={isPasteLinkOpen}
onClose={() => setIsPasteLinkOpen(false)}
onSaved={({ profile, serverId }) => {
onHostAdded?.(profile);
finishOnboarding(serverId);
}}
/>
</ThemedScrollView>
<Button
variant="ghost"
size="sm"
leftIcon={Settings}
onPress={() => router.push("/settings")}
style={styles.settingsButton}
testID="welcome-open-settings"
>
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>
);
}

View File

@@ -306,14 +306,24 @@ function WorkspaceHoverCardContent({
badgeLabel = `${checks.length} passed`;
}
const iconColor = hovered
? theme.colors.foreground
: theme.colors.foregroundMuted;
return (
<>
{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.checksDot, { backgroundColor: badgeColor }]} />
<Text style={[styles.checksStatusText, { color: badgeColor }]}>
@@ -395,6 +405,9 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted,
},
checksSummaryLabelHovered: {
color: theme.colors.foreground,
},
checksSummaryCounts: {
flexDirection: "row",
alignItems: "center",

View File

@@ -179,6 +179,9 @@ export function WorkspaceSetupDialog() {
if (!composerState) {
throw new Error("Workspace setup composer state is required");
}
if (!composerState.selectedProvider) {
throw new Error("Select a model");
}
const wirePayload = splitComposerAttachmentsForSubmit(attachments);
const encodedImages = await encodeImages(wirePayload.images);

View File

@@ -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([]);
});
});

View File

@@ -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;
}

View File

@@ -10,6 +10,7 @@ import { isElectronRuntime, isElectronRuntimeMac } from "@/desktop/host";
// Default is cross-platform. Gate only when you must:
// isWeb → DOM APIs (document, window, <div>, addEventListener)
// isNative → Native-only APIs (Haptics, StatusBar, push tokens, camera)
// isDev → Development-only diagnostics and instrumentation
// isElectron → Desktop wrapper features (file dialogs, titlebar, updates)
//
// 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. */
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
// because the desktop bridge may load after initial module evaluation)

View File

@@ -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 { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated";
import { type GestureType } from "react-native-gesture-handler";
import { useIsCompactFormFactor } from "@/constants/layout";
import { usePanelStore } from "@/stores/panel-store";
import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
import {
getRightSidebarAnimationTargets,
shouldSyncSidebarAnimation,
@@ -30,11 +38,9 @@ const ExplorerSidebarAnimationContext = createContext<ExplorerSidebarAnimationCo
export function ExplorerSidebarAnimationProvider({ children }: { children: ReactNode }) {
const { width: windowWidth } = useWindowDimensions();
const isCompactLayout = useIsCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView);
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
// Derive isOpen from the unified panel state
const isOpen = isCompactLayout ? mobileView === "file-explorer" : desktopFileExplorerOpen;
const isOpen = usePanelStore((state) =>
selectIsFileExplorerOpen(state, { isCompact: isCompactLayout }),
);
// Right sidebar: closed = +windowWidth (off-screen right), open = 0
const initialTargets = getRightSidebarAnimationTargets({ isOpen, windowWidth });
@@ -93,7 +99,7 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
backdropOpacity.value = targets.backdropOpacity;
}, [isOpen, translateX, backdropOpacity, windowWidth, isGesturing]);
const animateToOpen = () => {
const animateToOpen = useCallback(() => {
"worklet";
translateX.value = withTiming(0, {
duration: ANIMATION_DURATION,
@@ -103,9 +109,9 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
};
}, [translateX, backdropOpacity]);
const animateToClose = () => {
const animateToClose = useCallback(() => {
"worklet";
translateX.value = withTiming(windowWidth, {
duration: ANIMATION_DURATION,
@@ -115,22 +121,25 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
duration: ANIMATION_DURATION,
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 (
<ExplorerSidebarAnimationContext.Provider
value={{
translateX,
backdropOpacity,
windowWidth,
animateToOpen,
animateToClose,
isGesturing,
gestureAnimatingRef,
openGestureRef,
closeGestureRef,
}}
>
<ExplorerSidebarAnimationContext.Provider value={value}>
{children}
</ExplorerSidebarAnimationContext.Provider>
);

View File

@@ -34,7 +34,6 @@ import {
type Agent,
type SessionState,
type WorkspaceDescriptor,
mergeWorkspaceSnapshotWithExisting,
normalizeWorkspaceDescriptor,
} from "@/stores/session-store";
import { useDraftStore } from "@/stores/draft-store";
@@ -348,7 +347,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
}
const workspaces = new Map<string, WorkspaceDescriptor>();
const existingWorkspaces = useSessionStore.getState().sessions[serverId]?.workspaces;
let cursor: string | null = null;
let includeSubscribe = options?.subscribe ?? false;
@@ -364,13 +362,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
for (const entry of payload.entries) {
const workspace = normalizeWorkspaceDescriptor(entry);
workspaces.set(
workspace.id,
mergeWorkspaceSnapshotWithExisting({
incoming: workspace,
existing: existingWorkspaces?.get(workspace.id),
}),
);
workspaces.set(workspace.id, workspace);
}
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {

View File

@@ -11,7 +11,7 @@ import { useWindowDimensions } from "react-native";
import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated";
import { type GestureType } from "react-native-gesture-handler";
import { useIsCompactFormFactor } from "@/constants/layout";
import { usePanelStore } from "@/stores/panel-store";
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
import {
getLeftSidebarAnimationTargets,
shouldSyncSidebarAnimation,
@@ -37,11 +37,9 @@ const SidebarAnimationContext = createContext<SidebarAnimationContextValue | nul
export function SidebarAnimationProvider({ children }: { children: ReactNode }) {
const { width: windowWidth } = useWindowDimensions();
const isCompactLayout = useIsCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView);
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
// Derive isOpen from the unified panel state
const isOpen = isCompactLayout ? mobileView === "agent-list" : desktopAgentListOpen;
const isOpen = usePanelStore((state) =>
selectIsAgentListOpen(state, { isCompact: isCompactLayout }),
);
// Initialize based on current state
const initialTargets = getLeftSidebarAnimationTargets({ isOpen, windowWidth });

View File

@@ -17,6 +17,8 @@ export interface DesktopAppUpdateInstallResult {
message: string;
}
export type DesktopReleaseChannel = "stable" | "beta";
export interface LocalDaemonUpdateResult {
exitCode: number;
stdout: string;
@@ -69,8 +71,12 @@ export async function getLocalDaemonVersion(): Promise<LocalDaemonVersionResult>
return parseLocalDaemonVersionResult(result);
}
export async function checkDesktopAppUpdate(): Promise<DesktopAppUpdateCheckResult> {
const result = await invokeDesktopCommand<unknown>("check_app_update");
export async function checkDesktopAppUpdate({
releaseChannel,
}: {
releaseChannel: DesktopReleaseChannel;
}): Promise<DesktopAppUpdateCheckResult> {
const result = await invokeDesktopCommand<unknown>("check_app_update", { releaseChannel });
if (!isRecord(result)) {
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> {
const result = await invokeDesktopCommand<unknown>("install_app_update");
export async function installDesktopAppUpdate({
releaseChannel,
}: {
releaseChannel: DesktopReleaseChannel;
}): Promise<DesktopAppUpdateInstallResult> {
const result = await invokeDesktopCommand<unknown>("install_app_update", { releaseChannel });
if (!isRecord(result)) {
throw new Error("Unexpected response while installing desktop update.");
}

View File

@@ -7,6 +7,7 @@ import {
type DesktopAppUpdateCheckResult,
type DesktopAppUpdateInstallResult,
} from "@/desktop/updates/desktop-updates";
import { useAppSettings } from "@/hooks/use-settings";
export type DesktopAppUpdateStatus =
| "idle"
@@ -83,6 +84,8 @@ function formatStatusText(input: {
export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
const isDesktopApp = shouldShowDesktopUpdateSection();
const { settings } = useAppSettings();
const releaseChannel = settings.releaseChannel;
const requestVersionRef = useRef(0);
const [status, setStatus] = useState<DesktopAppUpdateStatus>("idle");
const [availableUpdate, setAvailableUpdate] = useState<DesktopAppUpdateCheckResult | null>(null);
@@ -105,7 +108,7 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
setErrorMessage(null);
try {
const result = await checkDesktopAppUpdate();
const result = await checkDesktopAppUpdate({ releaseChannel });
if (requestVersion !== requestVersionRef.current) {
return result;
}
@@ -140,9 +143,17 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
return null;
}
},
[isDesktopApp],
[isDesktopApp, releaseChannel],
);
useEffect(() => {
if (!isDesktopApp) {
return;
}
void checkForUpdates({ silent: true });
}, [checkForUpdates, isDesktopApp]);
useEffect(() => {
if (!isDesktopApp || status !== "pending") {
return undefined;
@@ -166,7 +177,7 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
setErrorMessage(null);
try {
const result = await installDesktopAppUpdate();
const result = await installDesktopAppUpdate({ releaseChannel });
setLastCheckedAt(Date.now());
if (result.installed) {
@@ -186,7 +197,7 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
setErrorMessage(message);
return null;
}
}, [isDesktopApp]);
}, [isDesktopApp, releaseChannel]);
return {
isDesktopApp,

View File

@@ -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;
}

View File

@@ -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");
});
});
});

View File

@@ -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", () => {
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", () => {
const resolved = __private__.resolveFormState(
undefined,
@@ -374,7 +511,7 @@ describe("useAgentFormState", () => {
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(
undefined,
{ provider: "codex" },
@@ -399,7 +536,121 @@ describe("useAgentFormState", () => {
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", () => {

View File

@@ -48,7 +48,7 @@ const INITIAL_USER_MODIFIED: UserModifiedFields = {
// Internal form state
interface FormState {
serverId: string | null;
provider: AgentProvider;
provider: AgentProvider | null;
modeId: string;
model: string;
thinkingOptionId: string;
@@ -68,7 +68,7 @@ export type UseAgentFormStateResult = {
selectedServerId: string | null;
setSelectedServerId: (value: string | null) => void;
setSelectedServerIdFromUser: (value: string | null) => void;
selectedProvider: AgentProvider;
selectedProvider: AgentProvider | null;
setProviderFromUser: (provider: AgentProvider) => void;
selectedMode: string;
setModeFromUser: (modeId: string) => void;
@@ -97,9 +97,6 @@ export type UseAgentFormStateResult = {
persistFormPreferences: () => Promise<void>;
};
const DEFAULT_PROVIDER: AgentProvider = "claude";
const DEFAULT_MODE_FOR_DEFAULT_PROVIDER = "default";
function normalizeSelectedModelId(modelId: string | null | undefined): string {
const normalized = typeof modelId === "string" ? modelId.trim() : "";
if (!normalized) {
@@ -160,6 +157,18 @@ function resolveThinkingOptionId(args: {
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.
* Priority: explicit (URL params) > provider defaults > lightweight app prefs > fallback
@@ -177,8 +186,6 @@ function resolveFormState(
): FormState {
// Start with current state - we only update non-user-modified fields
const result = { ...currentState };
const fallbackProvider = allowedProviderMap.keys().next().value as AgentProvider | undefined;
// 1. Resolve provider first (other fields depend on it)
if (!userModified.provider) {
if (initialValues?.provider && allowedProviderMap.has(initialValues.provider)) {
@@ -188,22 +195,33 @@ function resolveFormState(
allowedProviderMap.has(preferences.provider as AgentProvider)
) {
result.provider = preferences.provider as AgentProvider;
} else if (!allowedProviderMap.has(result.provider) && fallbackProvider) {
result.provider = fallbackProvider;
} else if (
result.provider &&
allowedProviderMap.size > 0 &&
!allowedProviderMap.has(result.provider)
) {
result.provider = null;
}
// else keep current (initialized to DEFAULT_PROVIDER)
} else if (!allowedProviderMap.has(result.provider) && fallbackProvider) {
result.provider = fallbackProvider;
} else if (
result.provider &&
allowedProviderMap.size > 0 &&
!allowedProviderMap.has(result.provider)
) {
result.provider = null;
}
const providerDef = allowedProviderMap.get(result.provider);
const providerPrefs = preferences?.providerPreferences?.[result.provider];
const providerDef = result.provider ? allowedProviderMap.get(result.provider) : undefined;
const providerPrefs = result.provider
? preferences?.providerPreferences?.[result.provider]
: undefined;
// 2. Resolve modeId (depends on provider)
if (!userModified.modeId) {
const validModeIds = providerDef?.modes.map((m) => m.id) ?? [];
if (
if (!result.provider) {
result.modeId = "";
} else if (
typeof initialValues?.modeId === "string" &&
initialValues.modeId.length > 0 &&
validModeIds.includes(initialValues.modeId)
@@ -223,7 +241,9 @@ function resolveFormState(
const preferredModel = normalizeSelectedModelId(providerPrefs?.model);
const defaultModelId = resolveDefaultModelId(availableModels);
if (initialModel) {
if (!result.provider) {
result.model = "";
} else if (initialModel) {
if (!availableModels || isValidModel(initialModel)) {
result.model = initialModel;
} else {
@@ -246,7 +266,9 @@ function resolveFormState(
? initialValues.thinkingOptionId.trim()
: "";
if (!userModified.thinkingOptionId) {
if (!result.provider) {
result.thinkingOptionId = "";
} else if (!userModified.thinkingOptionId) {
const effectiveModelId = result.model.trim();
const preferredThinking = effectiveModelId
? (providerPrefs?.thinkingByModel?.[effectiveModelId]?.trim() ?? "")
@@ -262,7 +284,7 @@ function resolveFormState(
}
// Validate thinking option once model metadata is available.
if (availableModels) {
if (result.provider && availableModels) {
result.thinkingOptionId = resolveThinkingOptionId({
availableModels,
modelId: result.model,
@@ -312,6 +334,37 @@ function combineInitialValues(
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 {
const {
initialServerId = null,
@@ -335,8 +388,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
// Form state
const [formState, setFormState] = useState<FormState>(() => ({
serverId: initialServerId,
provider: DEFAULT_PROVIDER,
modeId: DEFAULT_MODE_FOR_DEFAULT_PROVIDER,
provider: null,
modeId: "",
model: "",
thinkingOptionId: "",
workingDir: "",
@@ -348,12 +401,14 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
// Track if we've done initial resolution (to avoid flickering)
const hasResolvedRef = useRef(false);
const hydrationPreferencesRef = useRef<FormPreferences | null>(null);
// Reset user modifications when form becomes invisible
useEffect(() => {
if (!isVisible) {
setUserModified(INITIAL_USER_MODIFIED);
hasResolvedRef.current = false;
hydrationPreferencesRef.current = null;
}
}, [isVisible]);
@@ -371,25 +426,25 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
[snapshotEntries],
);
const snapshotProviderDefinitionMap = useMemo(
() =>
new Map<AgentProvider, AgentProviderDefinition>(
snapshotProviderDefinitions.map((definition) => [definition.id, definition]),
),
() => buildProviderDefinitionMap(snapshotProviderDefinitions),
[snapshotProviderDefinitions],
);
const snapshotResolvableProviderDefinitionMap = useMemo(
() =>
buildProviderDefinitionMapForStatuses({
snapshotEntries,
providerDefinitions: snapshotProviderDefinitions,
statuses: RESOLVABLE_PROVIDER_STATUSES,
}),
[snapshotEntries, snapshotProviderDefinitions],
);
const snapshotSelectableProviderDefinitionMap = useMemo(() => {
if (!snapshotEntries?.length) {
return snapshotProviderDefinitionMap;
}
const readyProviders = new Set(
snapshotEntries.filter((entry) => entry.status === "ready").map((entry) => entry.provider),
);
return new Map<AgentProvider, AgentProviderDefinition>(
snapshotProviderDefinitions
.filter((definition) => readyProviders.has(definition.id))
.map((definition) => [definition.id, definition]),
);
}, [snapshotEntries, snapshotProviderDefinitionMap, snapshotProviderDefinitions]);
return buildProviderDefinitionMapForStatuses({
snapshotEntries,
providerDefinitions: snapshotProviderDefinitions,
statuses: SELECTABLE_PROVIDER_STATUSES,
});
}, [snapshotEntries, snapshotProviderDefinitions]);
const snapshotAllProviderModels = useMemo(() => {
const map = new Map<string, AgentModelDefinition[]>();
for (const entry of snapshotEntries ?? []) {
@@ -398,13 +453,17 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
return map;
}, [snapshotEntries]);
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],
);
const snapshotSelectedProviderModels = snapshotSelectedEntry?.models ?? null;
const selectedProviderIsLoading = snapshotSelectedEntry?.status === "loading";
const snapshotSelectedProviderModes =
snapshotSelectedEntry?.modes ??
snapshotProviderDefinitionMap.get(formState.provider)?.modes ??
(formState.provider ? snapshotProviderDefinitionMap.get(formState.provider)?.modes : []) ??
[];
const providerDefinitions = snapshotProviderDefinitions;
const providerDefinitionMap = snapshotProviderDefinitionMap;
@@ -412,7 +471,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
const allProviderModels = snapshotAllProviderModels;
const availableModels = snapshotSelectedProviderModels;
const modeOptions = snapshotSelectedProviderModes;
const isAllModelsLoading = snapshotIsLoading;
const isAllModelsLoading = snapshotIsLoading || selectedProviderIsLoading;
// Combine initialValues with initialServerId for resolution
const combinedInitialValues = useMemo((): FormInitialValues | undefined => {
@@ -425,19 +484,23 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
return;
}
// Wait for preferences to load before first resolution, unless explicit URL overrides exist.
if (isPreferencesLoading && !hasResolvedRef.current && !combinedInitialValues) {
if (isPreferencesLoading && !hasResolvedRef.current) {
return;
}
if (!hasResolvedRef.current) {
hydrationPreferencesRef.current = preferences;
}
const hydrationPreferences = hydrationPreferencesRef.current ?? preferences;
const resolved = resolveFormState(
combinedInitialValues,
preferences,
hydrationPreferences,
availableModels,
userModified,
formStateRef.current,
validServerIds,
selectableProviderDefinitionMap,
snapshotResolvableProviderDefinitionMap,
);
// Only update if something changed
@@ -462,7 +525,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
availableModels,
userModified,
validServerIds,
selectableProviderDefinitionMap,
snapshotResolvableProviderDefinitionMap,
]);
// Auto-select the first online host when:
@@ -571,15 +634,38 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
thinkingOptionId: nextThinkingOptionId,
}));
setUserModified((prev) => ({ ...prev, provider: true, model: true }));
void updatePreferences({ provider });
void updatePreferences((current) =>
mergeSelectedComposerPreferences({
preferences: current,
provider,
updates: {
model: nextModelId || undefined,
},
}),
);
},
[allProviderModels, selectableProviderDefinitionMap, updatePreferences],
);
const setModeFromUser = useCallback((modeId: string) => {
setFormState((prev) => ({ ...prev, modeId }));
setUserModified((prev) => ({ ...prev, modeId: true }));
}, []);
const setModeFromUser = useCallback(
(modeId: string) => {
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(
(modelId: string) => {
@@ -598,14 +684,44 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
thinkingOptionId: nextThinkingOptionId,
}));
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) => {
setFormState((prev) => ({ ...prev, thinkingOptionId }));
setUserModified((prev) => ({ ...prev, thinkingOptionId: true }));
}, []);
const setThinkingOptionFromUser = useCallback(
(thinkingOptionId: string) => {
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) => {
setFormState((prev) => ({ ...prev, workingDir: value }));
@@ -625,16 +741,21 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
}, [refreshSnapshot]);
const refetchProviderModelsIfStale = useCallback(() => {
refetchSnapshotIfStale();
refetchSnapshotIfStale(formStateRef.current.provider);
}, [refetchSnapshotIfStale]);
const persistFormPreferences = useCallback(async () => {
if (!formState.provider) {
return;
}
const provider = formState.provider;
const resolvedModel = resolveEffectiveModel(availableModels, formState.model);
const modelId = resolvedModel?.id ?? formState.model;
await updatePreferences((current) =>
mergeProviderPreferences({
preferences: current,
provider: formState.provider,
provider,
updates: {
model: modelId || undefined,
mode: formState.modeId || undefined,
@@ -657,11 +778,13 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
updatePreferences,
]);
const agentDefinition = providerDefinitionMap.get(formState.provider);
const agentDefinition = formState.provider
? providerDefinitionMap.get(formState.provider)
: undefined;
const effectiveModel = resolveEffectiveModel(availableModels, formState.model);
const resolvedModelId = effectiveModel?.id ?? formState.model;
const availableThinkingOptions = effectiveModel?.thinkingOptions ?? [];
const isModelLoading = snapshotIsLoading;
const isModelLoading = snapshotIsLoading || selectedProviderIsLoading;
const modelError = snapshotError;
const workingDirIsEmpty = !formState.workingDir.trim();
@@ -738,7 +861,10 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
export type CreateAgentInitialValues = FormInitialValues;
export const __private__ = {
buildProviderDefinitionMap,
buildProviderDefinitionMapForStatuses,
combineInitialValues,
mergeSelectedComposerPreferences,
resolveDefaultModel,
resolveFormState,
resolveThinkingOptionId,

View File

@@ -10,6 +10,7 @@ import {
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
import { useDraftStore } from "@/stores/draft-store";
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
type AttachmentUpdater =
| ComposerAttachment[]
@@ -113,7 +114,7 @@ function resolveEffectiveComposerThinkingOptionId(input: {
}
function buildDraftComposerCommandConfig(input: {
provider: DraftAgentStatusBarProps["selectedProvider"];
provider: AgentProvider | null;
cwd: string;
modeOptions: DraftAgentStatusBarProps["modeOptions"];
selectedMode: string;
@@ -122,7 +123,7 @@ function buildDraftComposerCommandConfig(input: {
featureValues?: Record<string, unknown>;
}): DraftCommandConfig | undefined {
const cwd = input.cwd.trim();
if (!cwd) {
if (!input.provider || !cwd) {
return undefined;
}

View File

@@ -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 { 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 type { SubscribeCheckoutDiffResponse } from "@server/shared/messages";
import { orderCheckoutDiffFiles } from "./checkout-diff-order";
@@ -59,10 +59,8 @@ export function useCheckoutDiffQuery({
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const isMobile = useIsCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView);
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
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 normalizedCompare = useMemo(
() => normalizeCheckoutDiffCompare({ mode, baseRef, ignoreWhitespace }),
@@ -76,27 +74,10 @@ export function useCheckoutDiffQuery({
[serverId, cwd, mode, baseRef, compareIgnoreWhitespace],
);
const query = useQuery({
const query = useQuery<CheckoutDiffQueryPayload>({
queryKey,
queryFn: async () => {
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,
queryFn: skipToken,
staleTime: Infinity,
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
});
useEffect(() => {
@@ -208,9 +189,9 @@ export function useCheckoutDiffQuery({
return {
files: payload?.files ?? [],
payloadError,
isLoading: query.isLoading,
isFetching: query.isFetching,
isError: query.isError || Boolean(payloadError),
error: query.error,
isLoading: payload === null && enabled && isConnected,
isFetching: false,
isError: Boolean(payloadError),
error: null,
};
}

View File

@@ -4,7 +4,6 @@ import { router, usePathname, type Href } from "expo-router";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useSessionStore } from "@/stores/session-store";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
import { useHosts } from "@/runtime/host-runtime";
import { useAllAgentsList } from "@/hooks/use-all-agents-list";
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
@@ -12,11 +11,7 @@ import {
clearCommandCenterFocusRestoreElement,
takeCommandCenterFocusRestoreElement,
} from "@/utils/command-center-focus-restore";
import {
buildHostAgentDetailRoute,
buildSettingsRoute,
parseServerIdFromPathname,
} from "@/utils/host-routes";
import { buildHostAgentDetailRoute, buildSettingsRoute } from "@/utils/host-routes";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string";
import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts";
@@ -26,6 +21,7 @@ import { getIsElectronRuntime } from "@/constants/layout";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { focusWithRetries } from "@/utils/web-focus";
import { useActiveServerId } from "@/hooks/use-active-server-id";
const EMPTY_AGENTS: AggregatedAgent[] = [];
const EMPTY_ACTION_ITEMS: CommandCenterActionItem[] = [];
@@ -128,7 +124,7 @@ function resolveActionShortcutKeys(
export function useCommandCenter() {
const pathname = usePathname();
const daemons = useHosts();
const routeActiveServerId = useActiveServerId();
const { overrides } = useKeyboardShortcutOverrides();
const open = useKeyboardShortcutsStore((s) => s.commandCenterOpen);
const setOpen = useKeyboardShortcutsStore((s) => s.setCommandCenterOpen);
@@ -142,19 +138,7 @@ export function useCommandCenter() {
const [query, setQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(0);
const activeServerId = useMemo(() => {
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 activeServerId = open ? routeActiveServerId : null;
const { agents } = useAllAgentsList({
serverId: activeServerId,

View File

@@ -16,7 +16,7 @@ type DraftFeatureConfig = Pick<
export function useDraftAgentFeatures(input: {
serverId: string | null | undefined;
provider: AgentProvider;
provider: AgentProvider | null;
cwd: string | null | undefined;
modeId: string | null | undefined;
modelId: string | null | undefined;
@@ -28,30 +28,31 @@ export function useDraftAgentFeatures(input: {
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
const { preferences, updatePreferences } = useFormPreferences();
const normalizedCwd = cwd?.trim() || "";
const normalizedProvider = provider ?? null;
const persistedFeatureValues = useMemo(
() => preferences.providerPreferences?.[provider]?.featureValues ?? {},
() => (provider ? (preferences.providerPreferences?.[provider]?.featureValues ?? {}) : {}),
[preferences.providerPreferences, provider],
);
const draftConfig = useMemo<DraftFeatureConfig | null>(() => {
if (!normalizedCwd) {
if (!normalizedProvider || !normalizedCwd) {
return null;
}
return {
provider,
provider: normalizedProvider,
cwd: normalizedCwd,
...(modeId ? { modeId } : {}),
...(modelId ? { model: modelId } : {}),
...(thinkingOptionId ? { thinkingOptionId } : {}),
};
}, [modeId, modelId, normalizedCwd, provider, thinkingOptionId]);
}, [modeId, modelId, normalizedCwd, normalizedProvider, thinkingOptionId]);
const featuresQuery = useQuery({
queryKey: [
"providerFeatures",
serverId ?? null,
provider,
normalizedProvider,
normalizedCwd || null,
modeId ?? null,
modelId ?? null,
@@ -106,6 +107,9 @@ export function useDraftAgentFeatures(input: {
return { ...current, [featureId]: value };
});
if (!provider) {
return;
}
void updatePreferences((current) =>
mergeProviderPreferences({
preferences: current,

View File

@@ -2,6 +2,7 @@ import { useCallback, useMemo } from "react";
import { Gesture } from "react-native-gesture-handler";
import { Extrapolation, interpolate, runOnJS, useSharedValue } from "react-native-reanimated";
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
interface UseExplorerOpenGestureParams {
enabled: boolean;
@@ -19,6 +20,7 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu
gestureAnimatingRef,
openGestureRef,
} = useExplorerSidebarAnimation();
const { openGestureRef: leftOpenGestureRef } = useSidebarAnimation();
const touchStartX = useSharedValue(0);
const touchStartY = useSharedValue(0);
@@ -31,6 +33,7 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu
() =>
Gesture.Pan()
.withRef(openGestureRef)
.simultaneousWithExternalGesture(leftOpenGestureRef)
.enabled(enabled)
.manualActivation(true)
.onTouchesDown((event) => {
@@ -109,6 +112,7 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu
animateToClose,
isGesturing,
openGestureRef,
leftOpenGestureRef,
handleGestureOpen,
touchStartX,
touchStartY,

View File

@@ -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);
}

View File

@@ -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);
});
});

View File

@@ -1,13 +1,11 @@
import { useEffect, useMemo, useRef } from "react";
import { usePathname, useRouter } from "expo-router";
import { getIsElectronRuntime } from "@/constants/layout";
import { useHosts } from "@/runtime/host-runtime";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
import {
buildSettingsRoute,
parseHostAgentRouteFromPathname,
parseServerIdFromPathname,
parseHostWorkspaceRouteFromPathname,
} from "@/utils/host-routes";
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 { isNative } from "@/constants/platform";
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({
enabled,
isMobile,
toggleAgentList,
toggleFileExplorer,
toggleBothSidebars,
toggleFocusMode,
cycleTheme,
@@ -40,14 +40,12 @@ export function useKeyboardShortcuts({
enabled: boolean;
isMobile: boolean;
toggleAgentList: () => void;
toggleFileExplorer?: () => void;
toggleBothSidebars?: () => void;
toggleFocusMode?: () => void;
cycleTheme?: () => void;
}) {
const pathname = usePathname();
const router = useRouter();
const hosts = useHosts();
const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers);
const { overrides } = useKeyboardShortcutOverrides();
const bindings = useMemo(() => buildEffectiveBindings(overrides), [overrides]);
@@ -56,11 +54,7 @@ export function useKeyboardShortcuts({
step: 0,
timeoutId: null,
});
const activeServerIdFromPath = parseServerIdFromPathname(pathname);
const activeServerId =
hosts.find((host) => host.serverId === activeServerIdFromPath)?.serverId ??
hosts[0]?.serverId ??
null;
const activeServerId = useActiveServerId();
const openProjectPickerAction = useOpenProjectPicker(activeServerId);
useEffect(() => {
@@ -84,38 +78,32 @@ export function useKeyboardShortcuts({
return false;
}
navigateToWorkspace(target.serverId, target.workspaceId);
navigateToWorkspace(target.serverId, target.workspaceId, { currentPathname: pathname });
return true;
};
const navigateRelativeWorkspace = (delta: 1 | -1): boolean => {
const state = useKeyboardShortcutsStore.getState();
const targets = state.visibleWorkspaceTargets;
const targets = state.sidebarShortcutWorkspaceTargets;
if (targets.length === 0) {
return false;
}
const workspaceRoute = parseHostWorkspaceRouteFromPathname(pathname);
if (!workspaceRoute) {
const fallback = targets[delta > 0 ? 0 : targets.length - 1] ?? null;
if (!fallback) {
return false;
}
navigateToWorkspace(fallback.serverId, fallback.workspaceId);
return true;
}
const currentIndex = targets.findIndex(
(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;
const workspaceRoute =
getNavigationActiveWorkspaceSelection() ?? parseHostWorkspaceRouteFromPathname(pathname);
const target = getRelativeSidebarShortcutTarget({
targets,
currentTarget: workspaceRoute
? {
serverId: workspaceRoute.serverId,
workspaceId: workspaceRoute.workspaceId,
}
: null,
delta,
});
if (!target) {
return false;
}
navigateToWorkspace(target.serverId, target.workspaceId);
navigateToWorkspace(target.serverId, target.workspaceId, { currentPathname: pathname });
return true;
};
@@ -257,10 +245,10 @@ export function useKeyboardShortcuts({
}
return true;
case "sidebar.toggle.right":
if (toggleFileExplorer) {
toggleFileExplorer();
}
return true;
return keyboardActionDispatcher.dispatch({
id: "sidebar.toggle.right",
scope: "sidebar",
});
case "view.toggle.focus":
if (toggleFocusMode) {
toggleFocusMode();
@@ -425,7 +413,6 @@ export function useKeyboardShortcuts({
pathname,
resetModifiers,
toggleAgentList,
toggleFileExplorer,
toggleFocusMode,
]);
}

View File

@@ -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();
});
});

View File

@@ -6,7 +6,7 @@ import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-
import { useSessionStore } from "@/stores/session-store";
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();
if (!trimmed) {
return null;
@@ -15,10 +15,33 @@ function normalizeProvidersSnapshotCwdKey(cwd?: string | null): string | null {
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) {
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 {
entries: ProviderSnapshotEntry[] | undefined;
isLoading: boolean;
@@ -27,16 +50,22 @@ interface UseProvidersSnapshotResult {
error: string | null;
supportsSnapshot: boolean;
refresh: (providers?: AgentProvider[]) => Promise<void>;
refetchIfStale: () => void;
refetchIfStale: (selectedProvider?: AgentProvider | null) => void;
}
interface UseProvidersSnapshotOptions {
enabled?: boolean;
}
export function useProvidersSnapshot(
serverId: string | null,
cwd?: string | null,
options: UseProvidersSnapshotOptions = {},
): UseProvidersSnapshotResult {
const queryClient = useQueryClient();
const client = useHostRuntimeClient(serverId ?? "");
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
const enabled = options.enabled ?? true;
const normalizedCwd = cwd?.trim() || undefined;
const normalizedCwdKey = normalizeProvidersSnapshotCwdKey(normalizedCwd);
const supportsSnapshot = useSessionStore(
@@ -50,13 +79,13 @@ export function useProvidersSnapshot(
const snapshotQuery = useQuery({
queryKey,
enabled: Boolean(supportsSnapshot && serverId && client && isConnected),
enabled: Boolean(enabled && supportsSnapshot && serverId && client && isConnected),
staleTime: 60_000,
queryFn: async () => {
if (!client) {
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) {
return;
}
await client.refreshProvidersSnapshot({ cwd: normalizedCwd, providers });
await client.refreshProvidersSnapshot(
refreshProvidersSnapshotRequest(normalizedCwd, providers),
);
},
});
const { mutateAsync: refreshSnapshot, isPending: isRefreshing } = refreshMutation;
useEffect(() => {
if (!supportsSnapshot || !client || !isConnected || !serverId) {
if (!enabled || !supportsSnapshot || !client || !isConnected || !serverId) {
return;
}
@@ -79,8 +110,7 @@ export function useProvidersSnapshot(
if (message.type !== "providers_snapshot_update") {
return;
}
const messageCwdKey = normalizeProvidersSnapshotCwdKey(message.payload.cwd);
if (messageCwdKey !== normalizedCwdKey) {
if (!shouldApplyProvidersSnapshotUpdate(normalizedCwd, message.payload.cwd)) {
return;
}
queryClient.setQueryData(queryKey, {
@@ -88,19 +118,59 @@ export function useProvidersSnapshot(
generatedAt: message.payload.generatedAt,
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(
async (providers?: AgentProvider[]) => {
if (!client) {
return;
}
await refreshSnapshot(providers);
const snapshot = await client.getProvidersSnapshot(providersSnapshotRequest(normalizedCwd));
queryClient.setQueryData(queryKey, snapshot);
},
[refreshSnapshot],
[client, normalizedCwd, queryClient, queryKey, refreshSnapshot],
);
const refetchIfStale = useCallback(() => {
void queryClient.refetchQueries({ queryKey, type: "active", stale: true });
}, [queryClient, queryKey]);
const refetchIfStale = useCallback(
(selectedProvider?: AgentProvider | null) => {
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 {
entries: snapshotQuery.data?.entries ?? undefined,
@@ -124,6 +194,6 @@ export function prefetchProvidersSnapshot(
void singletonQueryClient.prefetchQuery({
queryKey,
staleTime: 60_000,
queryFn: () => client.getProvidersSnapshot({ cwd: normalizedCwd }),
queryFn: () => client.getProvidersSnapshot(providersSnapshotRequest(normalizedCwd)),
});
}

View File

@@ -40,6 +40,16 @@ describe("use-settings", () => {
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 () => {
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
if (key === "@paseo:app-settings") {
@@ -58,7 +68,24 @@ describe("use-settings", () => {
theme: "light",
manageBuiltInDaemon: false,
sendBehavior: "interrupt",
releaseChannel: "stable",
});
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");
});
});

View File

@@ -9,19 +9,23 @@ const APP_SETTINGS_QUERY_KEY = ["app-settings"];
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
export type SendBehavior = "interrupt" | "queue";
export type ReleaseChannel = "stable" | "beta";
const VALID_THEMES = new Set<string>([...Object.keys(THEME_TO_UNISTYLES), "auto"]);
const VALID_RELEASE_CHANNELS = new Set<string>(["stable", "beta"]);
export interface AppSettings {
theme: ThemeName | "auto";
manageBuiltInDaemon: boolean;
sendBehavior: SendBehavior;
releaseChannel: ReleaseChannel;
}
export const DEFAULT_APP_SETTINGS: AppSettings = {
theme: "auto",
manageBuiltInDaemon: true,
sendBehavior: "interrupt",
releaseChannel: "stable",
};
export interface UseAppSettingsReturn {
@@ -85,6 +89,9 @@ export async function loadSettingsFromStorage(): Promise<AppSettings> {
if (parsed.theme && !VALID_THEMES.has(parsed.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 };
}
@@ -115,6 +122,9 @@ function pickAppSettingsFromLegacy(legacy: Record<string, unknown>): Partial<App
if (typeof legacy.manageBuiltInDaemon === "boolean") {
result.manageBuiltInDaemon = legacy.manageBuiltInDaemon;
}
if (legacy.releaseChannel === "stable" || legacy.releaseChannel === "beta") {
result.releaseChannel = legacy.releaseChannel;
}
return result;
}

View File

@@ -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 { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { buildSidebarShortcutModel } from "@/utils/sidebar-shortcuts";
import { isSidebarProjectFlattened } from "@/utils/sidebar-project-row-model";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
export function useSidebarShortcutModel(projects: SidebarProjectEntry[]) {
const [collapsedProjectKeys, setCollapsedProjectKeys] = useState<Set<string>>(new Set());
const setSidebarShortcutWorkspaceTargets = useKeyboardShortcutsStore(
(state) => state.setSidebarShortcutWorkspaceTargets,
export function useSidebarShortcutModel(input: {
projects: SidebarProjectEntry[];
isInitialLoad: boolean;
}) {
const { projects, isInitialLoad } = input;
const collapsedProjectKeys = useSidebarCollapsedSectionsStore(
(state) => state.collapsedProjectKeys,
);
const setVisibleWorkspaceTargets = useKeyboardShortcutsStore(
(state) => state.setVisibleWorkspaceTargets,
const setProjectCollapsed = useSidebarCollapsedSectionsStore(
(state) => state.setProjectCollapsed,
);
const toggleProjectCollapsed = useSidebarCollapsedSectionsStore(
(state) => state.toggleProjectCollapsed,
);
const shortcutModel = useMemo(
@@ -23,62 +29,21 @@ export function useSidebarShortcutModel(projects: SidebarProjectEntry[]) {
);
useEffect(() => {
setCollapsedProjectKeys((prev) => {
const collapsibleProjectKeys = new Set(
projects
.filter((project) => !isSidebarProjectFlattened(project))
.map((project) => project.projectKey),
);
const next = new Set<string>();
for (const key of prev) {
if (collapsibleProjectKeys.has(key)) {
next.add(key);
}
if (isInitialLoad || projects.length === 0) {
return;
}
const collapsibleProjectKeys = new Set(
projects
.filter((project) => !isSidebarProjectFlattened(project))
.map((project) => project.projectKey),
);
for (const key of collapsedProjectKeys) {
if (!collapsibleProjectKeys.has(key)) {
setProjectCollapsed(key, false);
}
return next;
});
}, [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;
});
}, []);
}
}, [collapsedProjectKeys, isInitialLoad, projects, setProjectCollapsed]);
return {
collapsedProjectKeys,

View File

@@ -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(() => {
(globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
@@ -8,8 +15,12 @@ import {
appendMissingOrderKeys,
applyStoredOrdering,
buildSidebarProjectsFromStructure,
useSidebarWorkspacesList,
} from "./use-sidebar-workspaces-list";
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 {
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", () => {
it("keeps unknown items on the baseline while applying stored order", () => {
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);
});
});

View File

@@ -1,7 +1,6 @@
import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
import type { WorkspaceDescriptorPayload } from "@server/shared/messages";
import {
mergeWorkspaceSnapshotWithExisting,
normalizeWorkspaceDescriptor,
useSessionStore,
type WorkspaceDescriptor,
@@ -191,11 +190,10 @@ export function useSidebarWorkspacesList(options?: {
const value = options?.serverId;
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}, [options?.serverId]);
const isActive = Boolean(serverId) && options?.enabled !== false;
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) =>
isActive && serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false,
);
@@ -275,7 +273,6 @@ export function useSidebarWorkspacesList(options?: {
}
void (async () => {
const next = new Map<string, WorkspaceDescriptor>();
const existingWorkspaces = useSessionStore.getState().sessions[serverId]?.workspaces;
let cursor: string | null = null;
try {
while (true) {
@@ -285,13 +282,7 @@ export function useSidebarWorkspacesList(options?: {
});
for (const entry of payload.entries) {
const workspace = toWorkspaceDescriptor(entry);
next.set(
workspace.id,
mergeWorkspaceSnapshotWithExisting({
incoming: workspace,
existing: existingWorkspaces?.get(workspace.id),
}),
);
next.set(workspace.id, workspace);
}
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
break;

View File

@@ -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",
});
});
});

View File

@@ -1,23 +1,64 @@
import { useCallback } from "react";
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,
* combined with getId on the workspace Stack.Screen, ensures:
* - Only one instance of each workspace exists in the stack
* - History is preserved (back button works)
* - No duplicate workspace screens
* Open a workspace. Once the workspace shell is mounted, switching workspaces
* is app-level state so native-stack does not rebuild every retained screen.
*/
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);
router.navigate(href);
}
export function useWorkspaceNavigation() {
return {
navigateToWorkspace: useCallback((serverId: string, workspaceId: string) => {
navigateToWorkspace(serverId, workspaceId);
}, []),
navigateToWorkspace: useCallback(
(serverId: string, workspaceId: string, options?: NavigateToWorkspaceOptions) => {
navigateToWorkspace(serverId, workspaceId, options);
},
[],
),
};
}

View File

@@ -24,6 +24,7 @@ export type KeyboardActionId =
| "workspace.pane.move-tab.down"
| "workspace.pane.close"
| "workspace.terminal.new"
| "sidebar.toggle.right"
| "worktree.new"
| "worktree.archive";
@@ -51,6 +52,7 @@ export type KeyboardActionDefinition =
| { id: "workspace.pane.move-tab.down"; scope: KeyboardActionScope }
| { id: "workspace.pane.close"; scope: KeyboardActionScope }
| { id: "workspace.terminal.new"; scope: KeyboardActionScope }
| { id: "sidebar.toggle.right"; scope: KeyboardActionScope }
| { id: "worktree.new"; scope: KeyboardActionScope }
| { id: "worktree.archive"; scope: KeyboardActionScope };

View File

@@ -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);
});
});

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 ReanimatedAnimated from "react-native-reanimated";
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 invariant from "tiny-invariant";
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 { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
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 {
useHostRuntimeClient,
@@ -111,7 +112,8 @@ function useAgentPanelDescriptor(
}
function AgentPanel() {
const { serverId, target, isPaneFocused, openFileInWorkspace } = usePaneContext();
const { serverId, target, openFileInWorkspace } = usePaneContext();
const { isInteractive } = usePaneFocus();
invariant(target.kind === "agent", "AgentPanel requires agent target");
function openWorkspaceFile(input: { filePath: string }) {
@@ -124,7 +126,7 @@ function AgentPanel() {
<AgentPanelContent
serverId={serverId}
agentId={target.agentId}
isPaneFocused={isPaneFocused}
isPaneFocused={isInteractive}
onOpenWorkspaceFile={handleOpenWorkspaceFile}
/>
);
@@ -137,6 +139,12 @@ export const agentPanelRegistration: PanelRegistration<"agent"> = {
};
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 {}
@@ -151,6 +159,27 @@ function isNotFoundErrorMessage(message: string): boolean {
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 =
| { tag: "idle" }
| { tag: "loading" }
@@ -455,26 +484,13 @@ function ChatAgentContent({
};
}),
);
const agentInputDraft = useAgentInputDraft({
draftKey: buildDraftStoreKey({
serverId,
agentId: agentId ?? "__pending__",
}),
initialCwd: agentState.cwd ?? "",
});
const projectPlacement = useStoreWithEqualityFn(
useSessionStore,
(state) =>
agentId ? (state.sessions[serverId]?.agents?.get(agentId)?.projectPlacement ?? null) : null,
(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 markPendingCreateLifecycle = useCreateFlowStore((state) => state.markLifecycle);
const clearPendingCreate = useCreateFlowStore((state) => state.clear);
const isInitializingFromMap = useSessionStore((state) =>
agentId ? (state.sessions[serverId]?.initializingAgents?.get(agentId) ?? false) : false,
);
@@ -489,11 +505,7 @@ function ChatAgentContent({
const agentHistorySyncGeneration = useSessionStore((state) =>
agentId ? (state.sessions[serverId]?.agentHistorySyncGeneration?.get(agentId) ?? -1) : -1,
);
const allPendingPermissions = useSessionStore(
(state) => state.sessions[serverId]?.pendingPermissions,
);
const setAgents = useSessionStore((state) => state.setAgents);
const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail);
const setPendingPermissions = useSessionStore((state) => state.setPendingPermissions);
const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId]));
const { ensureAgentIsInitialized } = useAgentInitialization({
@@ -504,38 +516,13 @@ function ChatAgentContent({
kind: "idle",
});
const pendingCreate = useMemo(() => {
if (!agentId) {
return null;
}
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 pendingCreate = useMemo(
() => findPendingCreateForPanel({ pendingByDraftId, serverId, agentId }),
[agentId, pendingByDraftId, serverId],
);
const isPendingCreateForPanel = Boolean(pendingCreate);
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({
agentId,
client,
@@ -640,38 +627,7 @@ function ChatAgentContent({
return agentHistorySyncGeneration < historySyncGeneration;
}, [agentHistorySyncGeneration, agentId, historySyncGeneration]);
const optimisticStreamItems = useMemo<StreamItem[]>(() => {
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 shouldUseOptimisticStream = isPendingCreateForPanel;
const authoritativeStatus = agentState.status;
const isAuthoritativeBootstrapping =
authoritativeStatus === "initializing" || authoritativeStatus === "idle";
@@ -745,52 +701,29 @@ function ChatAgentContent({
[effectiveAgent?.id],
);
useEffect(() => {
if (!isPendingCreateForPanel || !pendingCreate) {
const handleComposerHeightChange = useCallback(
(height: number) => {
if (!agentId) {
return;
}
logWebStickyBottom("screen_composer_height_change", {
agentId,
height,
});
streamViewRef.current?.prepareForViewportChange();
},
[agentId],
);
const handleMessageSent = useCallback(() => {
if (!agentId) {
return;
}
const hasUserMessage = streamItems.some(
(item) => item.kind === "user_message" && item.id === pendingCreate.clientMessageId,
);
if (hasUserMessage && canFinalizePendingCreate) {
if (agentId && pendingCreate.images && pendingCreate.images.length > 0) {
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,
]);
logWebStickyBottom("screen_message_sent_scroll_to_bottom", {
agentId,
});
streamViewRef.current?.scrollToBottom("message-sent");
}, [agentId]);
useEffect(() => {
if (!agentId) {
@@ -947,53 +880,35 @@ function ChatAgentContent({
<View style={styles.container}>
<View style={styles.contentContainer}>
<ReanimatedAnimated.View style={[styles.content, animatedKeyboardStyle]}>
<AgentStreamView
ref={streamViewRef}
agentId={effectiveAgent.id}
<AgentStreamSection
streamViewRef={streamViewRef}
serverId={serverId}
agentId={agentId}
agent={effectiveAgent}
streamItems={shouldUseOptimisticStream ? mergedStreamItems : streamItems}
pendingPermissions={pendingPermissions}
pendingCreate={pendingCreate}
shouldUseOptimisticStream={shouldUseOptimisticStream}
canFinalizePendingCreate={canFinalizePendingCreate}
routeBottomAnchorRequest={routeBottomAnchorRequest}
isAuthoritativeHistoryReady={hasAppliedAuthoritativeHistory}
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
onOpenWorkspaceFile={onOpenWorkspaceFile}
/>
</ReanimatedAnimated.View>
</View>
{agentId && !isArchivingCurrentAgent && !agentState.archivedAt ? (
<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={showPendingCreateSubmitLoading}
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}
<AgentComposerSection
agentId={agentId}
serverId={serverId}
isPaneFocused={isPaneFocused}
isArchivingCurrentAgent={isArchivingCurrentAgent}
archivedAt={agentState.archivedAt}
initialCwd={agentState.cwd ?? ""}
isSubmitLoading={showPendingCreateSubmitLoading}
onAttentionInputFocus={attentionController.clearOnInputFocus}
onAttentionPromptSend={attentionController.clearOnPromptSend}
onAddImages={handleAddImagesCallback}
onComposerHeightChange={handleComposerHeightChange}
onMessageSent={handleMessageSent}
/>
{viewState.tag === "ready" &&
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({
serverLabel,
connectionStatus,
@@ -1098,6 +1275,10 @@ const styles = StyleSheet.create((theme) => ({
content: {
flex: 1,
},
inputAreaWrapper: {
width: "100%",
backgroundColor: theme.colors.surface0,
},
historySyncOverlay: {
position: "absolute",
top: 0,

View File

@@ -1,7 +1,7 @@
import { SquarePen } from "lucide-react-native";
import invariant from "tiny-invariant";
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 { useSessionStore } from "@/stores/session-store";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
@@ -17,15 +17,9 @@ function useDraftPanelDescriptor() {
}
function DraftPanel() {
const {
serverId,
workspaceId,
tabId,
target,
isPaneFocused,
openFileInWorkspace,
retargetCurrentTab,
} = usePaneContext();
const { serverId, workspaceId, tabId, target, openFileInWorkspace, retargetCurrentTab } =
usePaneContext();
const { isInteractive } = usePaneFocus();
invariant(target.kind === "draft", "DraftPanel requires draft target");
return (
@@ -34,7 +28,7 @@ function DraftPanel() {
workspaceId={workspaceId}
tabId={tabId}
draftId={target.draftId}
isPaneFocused={isPaneFocused}
isPaneFocused={isInteractive}
onOpenWorkspaceFile={({ filePath }) => {
openFileInWorkspace(filePath);
}}

View File

@@ -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,
});
});
});

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 type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
@@ -6,7 +6,6 @@ export interface PaneContextValue {
serverId: string;
workspaceId: string;
tabId: string;
isPaneFocused: boolean;
target: WorkspaceTabTarget;
openTab(target: WorkspaceTabTarget): void;
closeCurrentTab(): void;
@@ -14,7 +13,25 @@ export interface PaneContextValue {
openFileInWorkspace(filePath: string): void;
}
export interface PaneFocusContextValue {
isWorkspaceFocused: boolean;
isPaneFocused: boolean;
isInteractive: boolean;
}
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({
value,
@@ -26,8 +43,24 @@ export function PaneProvider({
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 {
const value = useContext(PaneContext);
invariant(value, "PaneContext is required");
return value;
}
export function usePaneFocus(): PaneFocusContextValue {
const value = useContext(PaneFocusContext);
invariant(value, "PaneFocusContext is required");
return value;
}

View File

@@ -1,12 +1,14 @@
import { useCallback } from "react";
import { useQuery } from "@tanstack/react-query";
import { Terminal } from "lucide-react-native";
import { Text, View } from "react-native";
import { useIsFocused } from "@react-navigation/native";
import invariant from "tiny-invariant";
import type { ListTerminalsResponse } from "@server/shared/messages";
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 { queryClient } from "@/query/query-client";
import { usePanelStore } from "@/stores/panel-store";
import { useSessionStore } from "@/stores/session-store";
import { useWorkspaceExecutionAuthority } from "@/stores/session-store-hooks";
@@ -29,21 +31,24 @@ function useTerminalPanelDescriptor(
const workspaceDirectory = workspaceAuthority.ok
? workspaceAuthority.authority.workspaceDirectory
: null;
const terminalsQuery = useQuery({
queryKey: ["terminals", context.serverId, workspaceDirectory] as const,
enabled: Boolean(client && workspaceDirectory),
queryFn: async (): Promise<ListTerminalsPayload> => {
if (!client || !workspaceDirectory) {
throw new Error(
workspaceAuthority.ok
? "Workspace execution directory not found"
: workspaceAuthority.message,
);
}
return client.listTerminals(workspaceDirectory);
const terminalsQuery = useQuery(
{
queryKey: ["terminals", context.serverId, workspaceDirectory] as const,
enabled: Boolean(client && workspaceDirectory),
queryFn: async (): Promise<ListTerminalsPayload> => {
if (!client || !workspaceDirectory) {
throw new Error(
workspaceAuthority.ok
? "Workspace execution directory not found"
: workspaceAuthority.message,
);
}
return client.listTerminals(workspaceDirectory);
},
staleTime: 5_000,
},
staleTime: 5_000,
});
queryClient,
);
const terminal =
terminalsQuery.data?.terminals.find((entry) => entry.id === target.terminalId) ?? null;
@@ -57,15 +62,28 @@ function useTerminalPanelDescriptor(
}
function TerminalPanel() {
const isFocused = useIsFocused();
const { serverId, workspaceId, target, isPaneFocused } = usePaneContext();
const { serverId, workspaceId, target } = usePaneContext();
const { isWorkspaceFocused, isPaneFocused } = usePaneFocus();
const workspaceAuthority = useWorkspaceExecutionAuthority(serverId, workspaceId)!;
const workspaceDirectory = workspaceAuthority.ok
? workspaceAuthority.authority.workspaceDirectory
: 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");
if (!isFocused) {
if (!isWorkspaceFocused) {
return <View style={{ flex: 1 }} />;
}
@@ -86,7 +104,9 @@ function TerminalPanel() {
serverId={serverId}
cwd={workspaceDirectory}
terminalId={target.terminalId}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isPaneFocused}
onOpenFileExplorer={handleOpenFileExplorer}
/>
);
}

View File

@@ -30,6 +30,7 @@ import {
buildLocalDaemonTransportUrl,
createDesktopLocalDaemonTransportFactory,
} from "@/desktop/daemon/desktop-daemon-transport";
import { isDev } from "@/constants/platform";
import { applyFetchedAgentDirectory } from "@/utils/agent-directory-sync";
import { useSessionStore, type Agent } from "@/stores/session-store";
@@ -433,6 +434,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
clientType: "mobile" as const,
appVersion: resolveAppVersion() ?? undefined,
runtimeGeneration,
...(isDev ? { runtimeMetricsIntervalMs: 10_000 } : {}),
};
if (connection.type === "directSocket" || connection.type === "directPipe") {
return new DaemonClient({

View File

@@ -36,7 +36,8 @@ import {
useHostRuntimeIsConnected,
} from "@/runtime/host-runtime";
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 { WelcomeScreen } from "@/components/welcome-screen";
import type { Agent } from "@/contexts/session-context";
@@ -51,6 +52,8 @@ import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execut
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
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 { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
import { useDraftAgentCreateFlow } from "@/hooks/use-draft-agent-create-flow";
@@ -235,16 +238,22 @@ function DraftAgentScreenContent({
statusControls,
} = composerState;
const isMobile = useIsCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView);
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
const closeFileExplorer = usePanelStore((state) => state.closeFileExplorer);
const setActiveExplorerCheckout = usePanelStore((state) => state.setActiveExplorerCheckout);
const isExplorerOpen = usePanelStore((state) =>
selectIsFileExplorerOpen(state, { isCompact: isMobile }),
);
const canOpenExplorerFromAgentView = usePanelStore(
(state) =>
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(
(state) => state.activateExplorerTabForCheckout,
);
const isExplorerOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
const [worktreeMode, setWorktreeMode] = useState<"none" | "create" | "attach">(
initialWorktreeMode,
@@ -645,23 +654,42 @@ function DraftAgentScreenContent({
if (!draftExplorerCheckout) {
return;
}
activateExplorerTabForCheckout(draftExplorerCheckout);
openFileExplorer();
}, [activateExplorerTabForCheckout, draftExplorerCheckout, openFileExplorer]);
openFileExplorerForCheckout({
isCompact: isMobile,
checkout: draftExplorerCheckout,
});
}, [draftExplorerCheckout, isMobile, openFileExplorerForCheckout]);
const handleToggleExplorer = useCallback(() => {
if (!canOpenExplorer) {
return;
}
if (isExplorerOpen) {
toggleFileExplorer();
return;
}
openExplorerForDraftCheckout();
}, [canOpenExplorer, isExplorerOpen, openExplorerForDraftCheckout, toggleFileExplorer]);
toggleFileExplorerForCheckout({
isCompact: isMobile,
checkout: draftExplorerCheckout,
});
}, [canOpenExplorer, draftExplorerCheckout, isMobile, toggleFileExplorerForCheckout]);
const explorerOpenGesture = useExplorerOpenGesture({
enabled: isMobile && mobileView === "agent" && canOpenExplorer,
enabled: isMobile && canOpenExplorerFromAgentView && canOpenExplorer,
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 workingDirSearchError =
directorySuggestionsQuery.error instanceof Error
@@ -778,6 +806,9 @@ function DraftAgentScreenContent({
if (providerDefinitions.length === 0) {
return "No available providers on the selected host";
}
if (!composerState.selectedProvider) {
return "Select a model";
}
if (gitBlockingError) {
return gitBlockingError;
}
@@ -818,6 +849,9 @@ function DraftAgentScreenContent({
(isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir).trim() ||
".";
const provider = composerState.selectedProvider;
if (!provider) {
throw new Error("Select a model");
}
const model = effectiveModelId || null;
const thinkingOptionId = effectiveThinkingOptionId || null;
const modeId =
@@ -861,8 +895,12 @@ function DraftAgentScreenContent({
composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
? composerState.selectedMode
: undefined;
const provider = composerState.selectedProvider;
if (!provider) {
throw new Error("Select a model");
}
const config: AgentSessionConfig = {
provider: composerState.selectedProvider,
provider,
cwd: resolvedWorkingDir,
...(modeId ? { modeId } : {}),
...(effectiveModelId ? { model: effectiveModelId } : {}),
@@ -940,12 +978,6 @@ function DraftAgentScreenContent({
router.replace(route as any);
},
});
useEffect(() => {
if (!isFocused) {
return;
}
setActiveExplorerCheckout(draftExplorerCheckout);
}, [draftExplorerCheckout, isFocused, setActiveExplorerCheckout]);
useEffect(() => {
if (!isFocused || !draftExplorerCheckout) {
return;
@@ -956,14 +988,19 @@ function DraftAgentScreenContent({
if (!isFocused || canOpenExplorer || !isExplorerOpen) {
return;
}
closeFileExplorer();
}, [canOpenExplorer, closeFileExplorer, isExplorerOpen, isFocused]);
useEffect(() => {
return () => {
setActiveExplorerCheckout(null);
};
}, [setActiveExplorerCheckout]);
if (isMobile) {
showMobileAgent();
return;
}
closeDesktopFileExplorer();
}, [
canOpenExplorer,
closeDesktopFileExplorer,
isExplorerOpen,
isFocused,
isMobile,
showMobileAgent,
]);
if (daemons.length === 0) {
return (
<WelcomeScreen
@@ -1194,7 +1231,7 @@ function DraftAgentScreenContent({
</ScrollView>
)}
</Animated.View>
<View style={styles.inputAreaWrapper}>
<View style={[styles.inputAreaWrapper, { paddingBottom: insets.bottom }]}>
<Composer
agentId={draftAgentIdRef.current}
serverId={selectedServerId ?? ""}

View File

@@ -145,6 +145,10 @@ vi.mock("@/constants/layout", () => ({
useIsCompactFormFactor: () => false,
}));
vi.mock("react-native-safe-area-context", () => ({
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
}));
vi.mock("lucide-react-native", () => {
const createIcon = (name: string) => (props: Record<string, unknown>) =>
React.createElement("span", { ...props, "data-icon": name });

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { createNameId } from "mnemonic-id";
import { useQuery } from "@tanstack/react-query";
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 { SidebarMenuToggle } from "@/components/headers/menu-header";
import { ScreenHeader } from "@/components/headers/screen-header";
import {
HEADER_INNER_HEIGHT,
HEADER_INNER_HEIGHT_MOBILE,
HEADER_TOP_PADDING_MOBILE,
MAX_CONTENT_WIDTH,
} from "@/constants/layout";
import { HEADER_INNER_HEIGHT, MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
import { useToast } from "@/contexts/toast-context";
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
@@ -107,6 +103,8 @@ export function NewWorkspaceScreen({
displayName: displayNameProp,
}: NewWorkspaceScreenProps) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const isCompact = useIsCompactFormFactor();
const toast = useToast();
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
const setAgents = useSessionStore((state) => state.setAgents);
@@ -328,6 +326,9 @@ export function NewWorkspaceScreen({
if (!composerState) {
throw new Error("Composer state is required");
}
if (!composerState.selectedProvider) {
throw new Error("Select a model");
}
const initialPrompt = text.trim();
const encodedImages = await encodeImages(images);
@@ -453,7 +454,13 @@ export function NewWorkspaceScreen({
leftStyle={styles.headerLeft}
borderless
/>
<View style={styles.content}>
<View
style={[
styles.content,
isCompact ? styles.contentCompact : styles.contentCentered,
isCompact ? { paddingBottom: insets.bottom } : null,
]}
>
<TitlebarDragRegion />
<View style={styles.centered}>
<Composer
@@ -567,12 +574,14 @@ const styles = StyleSheet.create((theme) => ({
content: {
position: "relative",
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingBottom: {
xs: HEADER_INNER_HEIGHT_MOBILE + HEADER_TOP_PADDING_MOBILE + theme.spacing[6],
md: HEADER_INNER_HEIGHT + theme.spacing[6],
},
},
contentCentered: {
justifyContent: "center",
paddingBottom: HEADER_INNER_HEIGHT + theme.spacing[6],
},
contentCompact: {
justifyContent: "flex-end",
},
centered: {
width: "100%",

View File

@@ -20,7 +20,7 @@ import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
import { PairDeviceModal } from "@/desktop/components/pair-device-modal";
export function OpenProjectScreen({ serverId }: { serverId: string }) {
const openAgentList = usePanelStore((s) => s.openAgentList);
const openDesktopAgentList = usePanelStore((s) => s.openDesktopAgentList);
const openProjectPicker = useOpenProjectPicker(serverId);
const hasHydrated = useSessionStore((s) => s.sessions[serverId]?.hasHydratedWorkspaces ?? false);
const hasProjects = useHasWorkspaces(serverId);
@@ -31,9 +31,9 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
useEffect(() => {
if (!isCompactLayout) {
openAgentList();
openDesktopAgentList();
}
}, [isCompactLayout, openAgentList]);
}, [isCompactLayout, openDesktopAgentList]);
return (
<View style={styles.container}>

View File

@@ -281,6 +281,7 @@ function AboutSection({ appVersionText, isDesktopApp }: AboutSectionProps) {
}
function DesktopAppUpdateRow() {
const { settings, updateSettings } = useAppSettings();
const {
isDesktopApp,
statusText,
@@ -309,6 +310,13 @@ function DesktopAppUpdateRow() {
void checkForUpdates();
}, [checkForUpdates, isDesktopApp]);
const handleReleaseChannelChange = useCallback(
(releaseChannel: AppSettings["releaseChannel"]) => {
void updateSettings({ releaseChannel });
},
[updateSettings],
);
const handleInstallUpdate = useCallback(() => {
if (!isDesktopApp) {
return;
@@ -316,7 +324,7 @@ function DesktopAppUpdateRow() {
void confirmDialog({
title: "Install desktop update",
message: "This updates Paseo on this computer.",
message: "This updates Paseo on this computer",
confirmLabel: "Install update",
cancelLabel: "Cancel",
})
@@ -337,40 +345,59 @@ function DesktopAppUpdateRow() {
}
return (
<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 ? (
<>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Release channel</Text>
<Text style={settingsStyles.rowHint}>
Ready to install: {formatVersionWithPrefix(availableUpdate.latestVersion)}
Switch to Beta to get updates sooner and help shape them
</Text>
) : null}
{errorMessage ? <Text style={styles.aboutErrorText}>{errorMessage}</Text> : null}
</View>
<View style={styles.aboutUpdateActions}>
<Button
variant="outline"
</View>
<SegmentedControl
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>
value={settings.releaseChannel}
onValueChange={handleReleaseChannelChange}
options={[
{ value: "stable", label: "Stable" },
{ value: "beta", label: "Beta" },
]}
/>
</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"
/>
<View style={desktopStyles.contentPane}>
{detailHeader ? (
<ScreenHeader
left={
<ScreenHeader
borderless={!detailHeader}
left={
detailHeader ? (
<>
<HeaderIconBadge>
<detailHeader.Icon
@@ -873,10 +901,10 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
</ScreenTitle>
{detailHeader.titleAccessory}
</>
}
leftStyle={desktopStyles.detailLeft}
/>
) : null}
) : null
}
leftStyle={desktopStyles.detailLeft}
/>
<ScrollView
style={styles.scrollView}
contentContainerStyle={{ paddingBottom: insets.bottom }}

View File

@@ -114,7 +114,7 @@ export function HostPage({ serverId, onHostRemoved }: HostPageProps) {
return (
<View testID={`settings-host-page-${serverId}`}>
<View style={[settingsStyles.card, styles.emptyCard]}>
<Text style={styles.emptyText}>Host not found.</Text>
<Text style={styles.emptyText}>Host not found</Text>
</View>
</View>
);
@@ -591,7 +591,7 @@ function PairDeviceRow() {
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Pair a device</Text>
<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>
</View>
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
@@ -619,7 +619,7 @@ function RemoveHostSection({ host, onRemoved }: { host: HostProfile; onRemoved?:
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Remove host</Text>
<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>
</View>
<Button

View File

@@ -179,7 +179,7 @@ export function KeyboardShortcutsSection() {
return (
<SettingsSection title="Shortcuts">
<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>
</SettingsSection>
);

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