mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 11:42:52 +00:00
Compare commits
47
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00870740ef | ||
|
|
ebf8a21c08 | ||
|
|
515ba62be6 | ||
|
|
32a20cbeea | ||
|
|
9877936d13 | ||
|
|
1a473ed7f3 | ||
|
|
3832053736 | ||
|
|
b01e274cce | ||
|
|
92e701be8f | ||
|
|
c2c990e763 | ||
|
|
76b9f89f68 | ||
|
|
970bf828b3 | ||
|
|
dfc814147a | ||
|
|
831d990ef7 | ||
|
|
e09fd41adc | ||
|
|
8d32167826 | ||
|
|
1cfae98bf5 | ||
|
|
4cbfe95473 | ||
|
|
242f61937a | ||
|
|
d913c4a9b7 | ||
|
|
2cd296e38f | ||
|
|
95c1d1fe0f | ||
|
|
33ac657fed | ||
|
|
067125bd4f | ||
|
|
93095aa1d2 | ||
|
|
ac45f23213 | ||
|
|
b91749a316 | ||
|
|
a6423f7f0a | ||
|
|
403fb03c97 | ||
|
|
f8411d121e | ||
|
|
9ea75afd37 | ||
|
|
006894274e | ||
|
|
951fadc104 | ||
|
|
8600f183b1 | ||
|
|
002946d4dd | ||
|
|
fb02bc8795 | ||
|
|
53baa16bee | ||
|
|
67eddbd6a9 | ||
|
|
044546ae0c | ||
|
|
bd748b6edc | ||
|
|
427664dfa7 | ||
|
|
a0169bf7f4 | ||
|
|
79968ee02d | ||
|
|
afe0eaab5b | ||
|
|
04465f124c | ||
|
|
86b83c3366 | ||
|
|
4a5dd986e1 |
@@ -4,9 +4,9 @@ on:
|
|||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
- '!v*-rc.*'
|
- '!v*-beta.*'
|
||||||
- 'app-v*'
|
- 'app-v*'
|
||||||
- '!app-v*-rc.*'
|
- '!app-v*-beta.*'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|||||||
@@ -66,7 +66,9 @@ jobs:
|
|||||||
--repo "${{ github.repository }}" \
|
--repo "${{ github.repository }}" \
|
||||||
--title "Paseo $RELEASE_TAG" \
|
--title "Paseo $RELEASE_TAG" \
|
||||||
--generate-notes \
|
--generate-notes \
|
||||||
$prerelease_flag
|
$prerelease_flag || {
|
||||||
|
echo "Release creation raced with another workflow; continuing."
|
||||||
|
}
|
||||||
fi
|
fi
|
||||||
|
|
||||||
publish-macos:
|
publish-macos:
|
||||||
@@ -145,6 +147,7 @@ jobs:
|
|||||||
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
|
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
|
||||||
publish_mode="always"
|
publish_mode="always"
|
||||||
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||||
|
publish_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --mac --${{ matrix.electron_arch }} "${publish_args[@]}"
|
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --mac --${{ matrix.electron_arch }} "${publish_args[@]}"
|
||||||
@@ -154,7 +157,7 @@ jobs:
|
|||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: mac-manifest-${{ matrix.electron_arch }}
|
name: mac-manifest-${{ matrix.electron_arch }}
|
||||||
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/latest-mac.yml
|
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/${{ env.RELEASE_CHANNEL }}-mac.yml
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|
||||||
finalize-mac-manifest:
|
finalize-mac-manifest:
|
||||||
@@ -227,8 +230,9 @@ jobs:
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
const arm64Text = fs.readFileSync('mac-manifest-arm64/latest-mac.yml', 'utf8');
|
const manifestName = `${process.env.RELEASE_CHANNEL}-mac.yml`;
|
||||||
const x64Text = fs.readFileSync('mac-manifest-x64/latest-mac.yml', 'utf8');
|
const arm64Text = fs.readFileSync(`mac-manifest-arm64/${manifestName}`, 'utf8');
|
||||||
|
const x64Text = fs.readFileSync(`mac-manifest-x64/${manifestName}`, 'utf8');
|
||||||
|
|
||||||
const arm64 = parseManifest(arm64Text);
|
const arm64 = parseManifest(arm64Text);
|
||||||
const x64 = parseManifest(x64Text);
|
const x64 = parseManifest(x64Text);
|
||||||
@@ -243,7 +247,7 @@ jobs:
|
|||||||
};
|
};
|
||||||
|
|
||||||
const output = toYaml(merged);
|
const output = toYaml(merged);
|
||||||
fs.writeFileSync('latest-mac.yml', output);
|
fs.writeFileSync(manifestName, output);
|
||||||
console.log('Merged manifest:\n' + output);
|
console.log('Merged manifest:\n' + output);
|
||||||
NODE
|
NODE
|
||||||
|
|
||||||
@@ -251,7 +255,7 @@ jobs:
|
|||||||
if: env.IS_SMOKE_TAG != 'true'
|
if: env.IS_SMOKE_TAG != 'true'
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: gh release upload "$RELEASE_TAG" latest-mac.yml --clobber --repo "${{ github.repository }}"
|
run: gh release upload "$RELEASE_TAG" "$RELEASE_CHANNEL-mac.yml" --clobber --repo "${{ github.repository }}"
|
||||||
|
|
||||||
publish-linux:
|
publish-linux:
|
||||||
needs: [create-release]
|
needs: [create-release]
|
||||||
@@ -316,6 +320,7 @@ jobs:
|
|||||||
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
|
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
|
||||||
publish_mode="always"
|
publish_mode="always"
|
||||||
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||||
|
publish_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --linux --x64 "${publish_args[@]}"
|
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --linux --x64 "${publish_args[@]}"
|
||||||
@@ -391,6 +396,7 @@ jobs:
|
|||||||
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
|
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
|
||||||
publish_mode="always"
|
publish_mode="always"
|
||||||
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||||
|
publish_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --win --x64 "${publish_args[@]}"
|
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --win --x64 "${publish_args[@]}"
|
||||||
|
|||||||
@@ -54,9 +54,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
create_if_missing="false"
|
create_if_missing="false"
|
||||||
if [ "$EVENT_NAME" = "push" ] && [[ "$REF" == refs/tags/v* ]]; then
|
if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "${INPUT_CREATE_IF_MISSING:-false}" = "true" ]; then
|
||||||
create_if_missing="true"
|
|
||||||
elif [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "${INPUT_CREATE_IF_MISSING:-false}" = "true" ]; then
|
|
||||||
create_if_missing="true"
|
create_if_missing="true"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.1.60-beta.1 - 2026-04-20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Beta release channel for desktop updates, with a Settings toggle for receiving beta builds before they are promoted to stable.
|
||||||
|
- Release candidates are now called beta releases, starting with `0.1.60-beta.1`.
|
||||||
|
|
||||||
## 0.1.59 - 2026-04-16
|
## 0.1.59 - 2026-04-16
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+1
-1
@@ -51,7 +51,7 @@ Stable tag pushes like `v0.1.0` trigger:
|
|||||||
- `packages/app/.eas/workflows/release-mobile.yml` on Expo servers (iOS + Android build + submit)
|
- `packages/app/.eas/workflows/release-mobile.yml` on Expo servers (iOS + Android build + submit)
|
||||||
- `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release)
|
- `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release)
|
||||||
|
|
||||||
Release candidate tags like `v0.1.1-rc.1` only trigger the GitHub APK workflow. They publish a GitHub prerelease APK for testing and do not submit to the stores.
|
Beta tags like `v0.1.1-beta.1` only trigger the GitHub APK workflow. They publish a GitHub prerelease APK for testing and do not submit to the stores.
|
||||||
|
|
||||||
### Useful commands
|
### Useful commands
|
||||||
|
|
||||||
|
|||||||
+28
-25
@@ -7,7 +7,7 @@ All workspaces share one version and release together.
|
|||||||
There are two supported ways to ship from `main`:
|
There are two supported ways to ship from `main`:
|
||||||
|
|
||||||
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
|
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
|
||||||
2. **Release candidate flow**: you want public test builds first, but you are not ready for the website, npm, or production mobile release flows to move yet.
|
2. **Beta flow**: you want public test builds first, but you are not ready for the website, npm, or production mobile release flows to move yet.
|
||||||
|
|
||||||
## Standard release (patch)
|
## Standard release (patch)
|
||||||
|
|
||||||
@@ -37,31 +37,32 @@ npm run release:publish # Publish to npm
|
|||||||
npm run release:push # Push HEAD + tag (triggers CI workflows)
|
npm run release:push # Push HEAD + tag (triggers CI workflows)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Release candidate flow
|
## Beta flow
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run release:rc:patch # Bump to X.Y.Z-rc.1, push commit + tag
|
npm run release:beta:patch # Bump to X.Y.Z-beta.1, push commit + tag
|
||||||
# ... test desktop and APK prerelease assets from GitHub Releases ...
|
# ... test desktop and APK prerelease assets from GitHub Releases ...
|
||||||
npm run release:rc:next # Optional: cut X.Y.Z-rc.2, rc.3, ...
|
npm run release:beta:next # Optional: cut X.Y.Z-beta.2, beta.3, ...
|
||||||
npm run release:promote # Promote X.Y.Z-rc.N to stable X.Y.Z
|
npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z
|
||||||
```
|
```
|
||||||
|
|
||||||
- RC tags are published GitHub prereleases like `v0.1.41-rc.1`
|
- Beta tags are published GitHub prereleases like `v0.1.41-beta.1`
|
||||||
- RCs publish desktop assets and APKs for testing, but they do not publish npm packages and do not trigger the production web/mobile release flows
|
- Betas publish desktop assets and APKs for testing, but they do not publish npm packages and do not trigger the production web/mobile release flows
|
||||||
- `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the RC tag
|
- `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the beta tag
|
||||||
- Desktop assets now come from the Electron package at `packages/desktop`
|
- Desktop assets now come from the Electron package at `packages/desktop`
|
||||||
- **Do NOT create a changelog entry for RCs.** The changelog remains stable-only. RC release notes are generated automatically so the website stays pinned to the latest published stable release.
|
- Beta releases use Electron's `beta` update channel. Users on the stable channel only receive stable releases; users on the beta channel receive beta releases and the final stable release when it is published.
|
||||||
|
- **Do create a changelog entry for betas.** The beta entry is temporary and gets updated in place until promotion.
|
||||||
|
|
||||||
Use the RC path when you need to:
|
Use the beta path when you need to:
|
||||||
|
|
||||||
- test a build manually in a Linux or Windows VM
|
- test a build manually in a Linux or Windows VM
|
||||||
- send a build to a user who is hitting a specific problem
|
- send a build to a user who is hitting a specific problem
|
||||||
- iterate on `rc.1`, `rc.2`, `rc.3`, and so on before deciding to ship broadly
|
- iterate on `beta.1`, `beta.2`, `beta.3`, and so on before deciding to ship broadly
|
||||||
|
|
||||||
## Website behavior
|
## Website behavior
|
||||||
|
|
||||||
- The website download page points to GitHub's latest published **stable** release.
|
- The website download page points to GitHub's latest published **stable** release.
|
||||||
- Published RC prereleases are public on GitHub Releases, but they do **not** become the website download target.
|
- Published beta prereleases are public on GitHub Releases, but they do **not** become the website download target.
|
||||||
- The website only moves when you publish the final stable release tag like `v0.1.41`.
|
- The website only moves when you publish the final stable release tag like `v0.1.41`.
|
||||||
|
|
||||||
## Fixing a failed release build
|
## Fixing a failed release build
|
||||||
@@ -88,13 +89,13 @@ git tag -f desktop-windows-v0.1.28 HEAD && git push origin desktop-windows-v0.1.
|
|||||||
# Android APK
|
# Android APK
|
||||||
git tag -f android-v0.1.28 HEAD && git push origin android-v0.1.28 --force
|
git tag -f android-v0.1.28 HEAD && git push origin android-v0.1.28 --force
|
||||||
|
|
||||||
# RC
|
# Beta
|
||||||
git tag -f v0.1.29-rc.2 HEAD && git push origin v0.1.29-rc.2 --force
|
git tag -f v0.1.29-beta.2 HEAD && git push origin v0.1.29-beta.2 --force
|
||||||
```
|
```
|
||||||
|
|
||||||
This ensures the checkout ref matches the actual code on `main` with the fix included.
|
This ensures the checkout ref matches the actual code on `main` with the fix included.
|
||||||
|
|
||||||
- `vX.Y.Z` or `vX.Y.Z-rc.N` rebuilds the full tagged release
|
- `vX.Y.Z` or `vX.Y.Z-beta.N` rebuilds the full tagged release
|
||||||
- `desktop-vX.Y.Z` rebuilds desktop for all desktop platforms only
|
- `desktop-vX.Y.Z` rebuilds desktop for all desktop platforms only
|
||||||
- `desktop-macos-vX.Y.Z`, `desktop-linux-vX.Y.Z`, and `desktop-windows-vX.Y.Z` rebuild only that desktop platform
|
- `desktop-macos-vX.Y.Z`, `desktop-linux-vX.Y.Z`, and `desktop-windows-vX.Y.Z` rebuild only that desktop platform
|
||||||
- `android-vX.Y.Z` rebuilds the Android APK release only
|
- `android-vX.Y.Z` rebuilds the Android APK release only
|
||||||
@@ -105,24 +106,26 @@ This ensures the checkout ref matches the actual code on `main` with the fix inc
|
|||||||
- `release:prepare` refreshes workspace `node_modules` links to prevent stale types
|
- `release:prepare` refreshes workspace `node_modules` links to prevent stale types
|
||||||
- `npm run dev:desktop` and `npm run build:desktop` target the Electron desktop package in `packages/desktop`
|
- `npm run dev:desktop` and `npm run build:desktop` target the Electron desktop package in `packages/desktop`
|
||||||
- If `release:publish` partially fails, re-run it — npm skips already-published versions
|
- If `release:publish` partially fails, re-run it — npm skips already-published versions
|
||||||
- The website uses GitHub's latest published release API for download links, so published RC prereleases do not replace the stable download target.
|
- The website uses GitHub's latest published release API for download links, so published beta prereleases do not replace the stable download target.
|
||||||
|
|
||||||
## Changelog format
|
## Changelog format
|
||||||
|
|
||||||
Stable release notes depend on the changelog heading format. The heading **must** be strictly followed:
|
Release notes depend on the changelog heading format. The heading **must** be strictly followed:
|
||||||
|
|
||||||
```
|
```
|
||||||
## X.Y.Z - YYYY-MM-DD
|
## X.Y.Z - YYYY-MM-DD
|
||||||
|
## X.Y.Z-beta.N - YYYY-MM-DD
|
||||||
```
|
```
|
||||||
|
|
||||||
No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to extract the version. A malformed heading will break download links on the homepage.
|
No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to extract the version. A malformed heading will break download links on the homepage.
|
||||||
|
|
||||||
## Changelog policy
|
## Changelog policy
|
||||||
|
|
||||||
- `CHANGELOG.md` is for **final stable releases only**.
|
- `CHANGELOG.md` includes stable releases and the current beta line.
|
||||||
- Do not add or edit changelog entries while iterating on RCs.
|
- The first beta inserts a top entry like `## 0.1.60-beta.1 - YYYY-MM-DD`.
|
||||||
- Write the proper changelog entry when you are cutting the final stable release that comes after the RC cycle.
|
- The next beta updates that same top entry in place, for example from `0.1.60-beta.1` to `0.1.60-beta.2`.
|
||||||
- Between stable releases, keep changelog work out of the repo until the final release is ready.
|
- Stable promotion updates that same entry in place, for example from `0.1.60-beta.2` to `0.1.60`.
|
||||||
|
- Do not create duplicate entries for each beta on the same version line.
|
||||||
|
|
||||||
## Changelog ownership
|
## Changelog ownership
|
||||||
|
|
||||||
@@ -178,7 +181,7 @@ Entries within each section (Added, Improved, Fixed) are ordered by user impact:
|
|||||||
|
|
||||||
## Pre-release sanity check
|
## Pre-release sanity check
|
||||||
|
|
||||||
Before cutting any release (RC or stable), run a Codex review of the diff as a last line of defence against shipping bugs.
|
Before cutting any release (beta or stable), run a Codex review of the diff as a last line of defence against shipping bugs.
|
||||||
|
|
||||||
Load the `paseo` skill and launch a **Codex 5.4** agent with a prompt like:
|
Load the `paseo` skill and launch a **Codex 5.4** agent with a prompt like:
|
||||||
|
|
||||||
@@ -196,10 +199,10 @@ The agent's job is a deep sanity check, not a full code review. If it flags anyt
|
|||||||
|
|
||||||
The changelog always covers **stable-to-HEAD**:
|
The changelog always covers **stable-to-HEAD**:
|
||||||
|
|
||||||
- **RC release**: the diff and release notes cover `latest stable tag → HEAD`. RC release notes are auto-generated and not added to `CHANGELOG.md`.
|
- **Beta release**: the diff and release notes cover `latest stable tag -> HEAD`. The current beta changelog entry is updated in place.
|
||||||
- **Stable release**: the diff and changelog entry cover `latest stable tag → HEAD`. Any intermediate RCs are skipped — the changelog captures the full delta from the previous stable release, not just what changed since the last RC.
|
- **Stable release**: the same changelog entry is promoted in place. It still captures the full delta from the previous stable release, not just what changed since the last beta.
|
||||||
|
|
||||||
In other words, RCs are checkpoints along the way; the changelog only records the final jump from one stable version to the next.
|
In other words, betas are checkpoints along the way; the changelog entry remains the single record for the final jump from one stable version to the next.
|
||||||
|
|
||||||
## Completion checklist
|
## Completion checklist
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,38 @@ const { theme } = useUnistyles();
|
|||||||
|
|
||||||
Use this sparingly. It works because React re-renders the prop, but it gives up the main Unistyles native-update path for that value.
|
Use this sparingly. It works because React re-renders the prop, but it gives up the main Unistyles native-update path for that value.
|
||||||
|
|
||||||
|
## `withUnistyles` And The `> *` Child-Selector Leak
|
||||||
|
|
||||||
|
`withUnistyles` on a component with a theme-dependent `style` prop works by wrapping the component in a `<div style={{display: 'contents'}} className={hash}>` and emitting the style under a `.hash > *` child selector so the styles cascade onto the wrapped component. This is how auto-mapping for `style` and `contentContainerStyle` works on web.
|
||||||
|
|
||||||
|
The sharp edge: Unistyles hashes styles by value. If `withUnistyles` receives a style whose value is **identical** to a style used elsewhere in the app on a plain `View`, both usages get the same hash — and both CSS rules (the element rule and the `> *` child rule) are emitted under the same class name. The `> *` rule then leaks onto the direct children of every `View` that shares the hash.
|
||||||
|
|
||||||
|
Concrete regression we hit: `welcome-screen.tsx` had `const ThemedScrollView = withUnistyles(ScrollView)` with `style={{ flex: 1, backgroundColor: theme.colors.surface0 }}`. `agent-panel.tsx` had `root` and `container` styles with the exact same value. All three collided on class `unistyles_j2k2iilhfz`, so the browser stylesheet contained:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.unistyles_j2k2iilhfz { flex: 1 1 0%; background-color: var(--colors-surface0); }
|
||||||
|
.unistyles_j2k2iilhfz > * { flex: 1 1 0%; background-color: var(--colors-surface0); }
|
||||||
|
```
|
||||||
|
|
||||||
|
The child-selector rule forced `flex:1` and `background-color: surface0` onto the Composer's outer `Animated.View` (a direct child of `container`), stretching it to fill remaining space and leaving a large empty gap between the composer UI and the bottom of the screen. It also painted a `surface0` band behind the scroll-to-bottom button. The bug only appeared in the browser — Electron skips `WelcomeScreen` after pairing, so the `> *` rule was never injected there.
|
||||||
|
|
||||||
|
Symptoms to watch for:
|
||||||
|
|
||||||
|
- A sibling of a themed panel-background `View` stretches unexpectedly on web only.
|
||||||
|
- Random direct children of a `{ flex: 1, backgroundColor: surface0 }` `View` pick up an unexpected background.
|
||||||
|
- DevTools shows a `.unistyles_xxx > *` rule you did not write.
|
||||||
|
|
||||||
|
Quick confirmation in DevTools console:
|
||||||
|
|
||||||
|
```js
|
||||||
|
[...document.styleSheets].flatMap(s => [...(s.cssRules || [])])
|
||||||
|
.map(r => r.cssText).filter(t => t.includes("unistyles") && t.includes("> *"));
|
||||||
|
```
|
||||||
|
|
||||||
|
Any match beyond benign `r-pointerEvents-* > *` rules from react-native-web is a leak.
|
||||||
|
|
||||||
|
Avoid the bug by preferring the wrapper-`View` pattern from the previous section whenever possible: put `{ flex: 1, backgroundColor: surface0 }` on a plain `View` and give the `ScrollView` a theme-free `style`/`contentContainerStyle`. That keeps `withUnistyles` off the hot path and avoids the hash collision. Only reach for `withUnistyles(ScrollView)` when a wrapper view is genuinely awkward, and when you do, give the wrapped style a distinctive shape (extra key, different layout) so it does not hash-collide with a common panel background used elsewhere.
|
||||||
|
|
||||||
## Hidden Sheet Content
|
## Hidden Sheet Content
|
||||||
|
|
||||||
`@gorhom/bottom-sheet` can keep `BottomSheetModal` content mounted while the sheet is hidden. That matters during Paseo's startup theme transition: a header node can be created under the initial adaptive theme, stay hidden, then appear later with stale native style values even though surrounding content has re-rendered correctly.
|
`@gorhom/bottom-sheet` can keep `BottomSheetModal` content mounted while the sheet is hidden. That matters during Paseo's startup theme transition: a header node can be created under the initial adaptive theme, stay hidden, then appear later with stale native style values even though surrounding content has re-rendered correctly.
|
||||||
|
|||||||
+1
-1
@@ -42,7 +42,7 @@ buildNpmPackage rec {
|
|||||||
|
|
||||||
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
||||||
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
||||||
npmDepsHash = "sha256-n1Q7uZumewPUZZFacSkSXeLotTApjxq+BpDEQ8V2MQs=";
|
npmDepsHash = "sha256-6v597rirYsPQJYXvVpd0+MZfbY0I6Oqlmd/7z5eHiOw=";
|
||||||
|
|
||||||
# Prevent onnxruntime-node's install script from running during automatic
|
# Prevent onnxruntime-node's install script from running during automatic
|
||||||
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
||||||
|
|||||||
Generated
+20
-19
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.60-rc.2",
|
"version": "0.1.60-beta.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.60-rc.2",
|
"version": "0.1.60-beta.1",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
@@ -37303,16 +37303,16 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.1.60-rc.2",
|
"version": "0.1.60-beta.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@expo/vector-icons": "^15.0.2",
|
"@expo/vector-icons": "^15.0.2",
|
||||||
"@floating-ui/react-native": "^0.10.7",
|
"@floating-ui/react-native": "^0.10.7",
|
||||||
"@getpaseo/expo-two-way-audio": "0.1.60-rc.2",
|
"@getpaseo/expo-two-way-audio": "0.1.60-beta.1",
|
||||||
"@getpaseo/highlight": "0.1.60-rc.2",
|
"@getpaseo/highlight": "0.1.60-beta.1",
|
||||||
"@getpaseo/server": "0.1.60-rc.2",
|
"@getpaseo/server": "0.1.60-beta.1",
|
||||||
"@gorhom/bottom-sheet": "^5.2.6",
|
"@gorhom/bottom-sheet": "^5.2.6",
|
||||||
"@gorhom/portal": "^1.0.14",
|
"@gorhom/portal": "^1.0.14",
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
@@ -37382,6 +37382,7 @@
|
|||||||
"react-native-web": "~0.21.0",
|
"react-native-web": "~0.21.0",
|
||||||
"react-native-webview": "^13.16.0",
|
"react-native-webview": "^13.16.0",
|
||||||
"react-native-worklets": "0.5.1",
|
"react-native-worklets": "0.5.1",
|
||||||
|
"use-sync-external-store": "^1.6.0",
|
||||||
"zod": "^3.23.8",
|
"zod": "^3.23.8",
|
||||||
"zustand": "^5.0.9"
|
"zustand": "^5.0.9"
|
||||||
},
|
},
|
||||||
@@ -37455,11 +37456,11 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@getpaseo/cli",
|
"name": "@getpaseo/cli",
|
||||||
"version": "0.1.60-rc.2",
|
"version": "0.1.60-beta.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/relay": "0.1.60-rc.2",
|
"@getpaseo/relay": "0.1.60-beta.1",
|
||||||
"@getpaseo/server": "0.1.60-rc.2",
|
"@getpaseo/server": "0.1.60-beta.1",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
@@ -37500,11 +37501,11 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@getpaseo/desktop",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.1.60-rc.2",
|
"version": "0.1.60-beta.1",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/cli": "0.1.60-rc.2",
|
"@getpaseo/cli": "0.1.60-beta.1",
|
||||||
"@getpaseo/server": "0.1.60-rc.2",
|
"@getpaseo/server": "0.1.60-beta.1",
|
||||||
"electron-log": "^5.4.3",
|
"electron-log": "^5.4.3",
|
||||||
"electron-updater": "^6.6.2",
|
"electron-updater": "^6.6.2",
|
||||||
"ws": "^8.14.2"
|
"ws": "^8.14.2"
|
||||||
@@ -37538,7 +37539,7 @@
|
|||||||
},
|
},
|
||||||
"packages/expo-two-way-audio": {
|
"packages/expo-two-way-audio": {
|
||||||
"name": "@getpaseo/expo-two-way-audio",
|
"name": "@getpaseo/expo-two-way-audio",
|
||||||
"version": "0.1.60-rc.2",
|
"version": "0.1.60-beta.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "1.9.4",
|
"@biomejs/biome": "1.9.4",
|
||||||
@@ -37739,7 +37740,7 @@
|
|||||||
},
|
},
|
||||||
"packages/highlight": {
|
"packages/highlight": {
|
||||||
"name": "@getpaseo/highlight",
|
"name": "@getpaseo/highlight",
|
||||||
"version": "0.1.60-rc.2",
|
"version": "0.1.60-beta.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lezer/common": "^1.5.0",
|
"@lezer/common": "^1.5.0",
|
||||||
"@lezer/cpp": "^1.1.5",
|
"@lezer/cpp": "^1.1.5",
|
||||||
@@ -37765,7 +37766,7 @@
|
|||||||
},
|
},
|
||||||
"packages/relay": {
|
"packages/relay": {
|
||||||
"name": "@getpaseo/relay",
|
"name": "@getpaseo/relay",
|
||||||
"version": "0.1.60-rc.2",
|
"version": "0.1.60-beta.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"base64-js": "^1.5.1",
|
"base64-js": "^1.5.1",
|
||||||
"tweetnacl": "^1.0.3",
|
"tweetnacl": "^1.0.3",
|
||||||
@@ -37781,14 +37782,14 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@getpaseo/server",
|
"name": "@getpaseo/server",
|
||||||
"version": "0.1.60-rc.2",
|
"version": "0.1.60-beta.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.17.1",
|
"@agentclientprotocol/sdk": "^0.17.1",
|
||||||
"@ai-sdk/openai": "2.0.52",
|
"@ai-sdk/openai": "2.0.52",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||||
"@deepgram/sdk": "^3.4.0",
|
"@deepgram/sdk": "^3.4.0",
|
||||||
"@getpaseo/highlight": "0.1.60-rc.2",
|
"@getpaseo/highlight": "0.1.60-beta.1",
|
||||||
"@getpaseo/relay": "0.1.60-rc.2",
|
"@getpaseo/relay": "0.1.60-beta.1",
|
||||||
"@isaacs/ttlcache": "^2.1.4",
|
"@isaacs/ttlcache": "^2.1.4",
|
||||||
"@mariozechner/pi-coding-agent": "^0.67.68",
|
"@mariozechner/pi-coding-agent": "^0.67.68",
|
||||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||||
@@ -38542,7 +38543,7 @@
|
|||||||
},
|
},
|
||||||
"packages/website": {
|
"packages/website": {
|
||||||
"name": "@getpaseo/website",
|
"name": "@getpaseo/website",
|
||||||
"version": "0.1.60-rc.2",
|
"version": "0.1.60-beta.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "^1.20.3",
|
"@cloudflare/vite-plugin": "^1.20.3",
|
||||||
"@cloudflare/workers-types": "^4.20260114.0",
|
"@cloudflare/workers-types": "^4.20260114.0",
|
||||||
|
|||||||
+9
-9
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.60-rc.2",
|
"version": "0.1.60-beta.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/expo-two-way-audio",
|
"packages/expo-two-way-audio",
|
||||||
@@ -47,19 +47,19 @@
|
|||||||
"version:all:patch": "node scripts/set-release-version.mjs --mode patch",
|
"version:all:patch": "node scripts/set-release-version.mjs --mode patch",
|
||||||
"version:all:minor": "node scripts/set-release-version.mjs --mode minor",
|
"version:all:minor": "node scripts/set-release-version.mjs --mode minor",
|
||||||
"version:all:major": "node scripts/set-release-version.mjs --mode major",
|
"version:all:major": "node scripts/set-release-version.mjs --mode major",
|
||||||
"version:all:rc:patch": "node scripts/set-release-version.mjs --mode rc-patch",
|
"version:all:beta:patch": "node scripts/set-release-version.mjs --mode beta-patch",
|
||||||
"version:all:rc:minor": "node scripts/set-release-version.mjs --mode rc-minor",
|
"version:all:beta:minor": "node scripts/set-release-version.mjs --mode beta-minor",
|
||||||
"version:all:rc:major": "node scripts/set-release-version.mjs --mode rc-major",
|
"version:all:beta:major": "node scripts/set-release-version.mjs --mode beta-major",
|
||||||
"version:all:rc:next": "node scripts/set-release-version.mjs --mode rc-next",
|
"version:all:beta:next": "node scripts/set-release-version.mjs --mode beta-next",
|
||||||
"version:all:promote": "node scripts/set-release-version.mjs --mode promote",
|
"version:all:promote": "node scripts/set-release-version.mjs --mode promote",
|
||||||
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
|
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
|
||||||
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
|
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
|
||||||
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
|
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
|
||||||
"release:push": "node scripts/push-current-release-tag.mjs",
|
"release:push": "node scripts/push-current-release-tag.mjs",
|
||||||
"release:rc:patch": "npm run release:check && npm run version:all:rc:patch && npm run release:push",
|
"release:beta:patch": "npm run release:check && npm run version:all:beta:patch && npm run release:push",
|
||||||
"release:rc:minor": "npm run release:check && npm run version:all:rc:minor && npm run release:push",
|
"release:beta:minor": "npm run release:check && npm run version:all:beta:minor && npm run release:push",
|
||||||
"release:rc:major": "npm run release:check && npm run version:all:rc:major && npm run release:push",
|
"release:beta:major": "npm run release:check && npm run version:all:beta:major && npm run release:push",
|
||||||
"release:rc:next": "npm run release:check && npm run version:all:rc:next && npm run release:push",
|
"release:beta:next": "npm run release:check && npm run version:all:beta:next && npm run release:push",
|
||||||
"release:promote": "npm run release:check && npm run version:all:promote && npm run release:publish && npm run release:push",
|
"release:promote": "npm run release:check && npm run version:all:promote && npm run release:publish && npm run release:push",
|
||||||
"release:patch": "npm run release:check && npm run version:all:patch && npm run release:publish && npm run release:push",
|
"release:patch": "npm run release:check && npm run version:all:patch && npm run release:publish && npm run release:push",
|
||||||
"release:minor": "npm run release:check && npm run version:all:minor && npm run release:publish && npm run release:push",
|
"release:minor": "npm run release:check && npm run version:all:minor && npm run release:publish && npm run release:push",
|
||||||
|
|||||||
@@ -210,13 +210,15 @@ export async function openWorkspaceWithAgents(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function expectWorkspaceTabVisible(page: Page, agentId: string): Promise<void> {
|
export async function expectWorkspaceTabVisible(page: Page, agentId: string): Promise<void> {
|
||||||
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`).first()).toBeVisible({
|
await expect(
|
||||||
timeout: 30_000,
|
page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }).first(),
|
||||||
});
|
).toBeVisible({ timeout: 30_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function expectWorkspaceTabHidden(page: Page, agentId: string): Promise<void> {
|
export async function expectWorkspaceTabHidden(page: Page, agentId: string): Promise<void> {
|
||||||
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`)).toHaveCount(0, {
|
await expect(
|
||||||
|
page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }),
|
||||||
|
).toHaveCount(0, {
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,16 +23,18 @@ export async function gotoWorkspace(page: Page, cwd: string): Promise<void> {
|
|||||||
|
|
||||||
/** Wait for the workspace tab bar to be visible. */
|
/** Wait for the workspace tab bar to be visible. */
|
||||||
export async function waitForTabBar(page: Page): Promise<void> {
|
export async function waitForTabBar(page: Page): Promise<void> {
|
||||||
await expect(page.getByTestId("workspace-tabs-row").first()).toBeVisible({
|
await expect(
|
||||||
|
page.getByTestId("workspace-tabs-row").filter({ visible: true }).first(),
|
||||||
|
).toBeVisible({
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Return all tab test IDs currently in the tab bar. */
|
/** Return all tab test IDs currently in the tab bar. */
|
||||||
export async function getTabTestIds(page: Page): Promise<string[]> {
|
export async function getTabTestIds(page: Page): Promise<string[]> {
|
||||||
const tabs = page.locator(
|
const tabs = page
|
||||||
'[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])',
|
.locator('[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])')
|
||||||
);
|
.filter({ visible: true });
|
||||||
const count = await tabs.count();
|
const count = await tabs.count();
|
||||||
const ids: string[] = [];
|
const ids: string[] = [];
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
@@ -55,6 +57,7 @@ export async function getActiveTabTestId(page: Page): Promise<string | null> {
|
|||||||
.locator(
|
.locator(
|
||||||
'[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])[aria-selected="true"]',
|
'[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])[aria-selected="true"]',
|
||||||
)
|
)
|
||||||
|
.filter({ visible: true })
|
||||||
.first();
|
.first();
|
||||||
if (await activeTab.isVisible().catch(() => false)) {
|
if (await activeTab.isVisible().catch(() => false)) {
|
||||||
return activeTab.getAttribute("data-testid");
|
return activeTab.getAttribute("data-testid");
|
||||||
@@ -67,14 +70,14 @@ export async function getActiveTabTestId(page: Page): Promise<string | null> {
|
|||||||
|
|
||||||
/** Click the new agent tab button in the tab bar. Creates a draft/chat tab directly. */
|
/** Click the new agent tab button in the tab bar. Creates a draft/chat tab directly. */
|
||||||
export async function clickNewTabButton(page: Page): Promise<void> {
|
export async function clickNewTabButton(page: Page): Promise<void> {
|
||||||
const button = page.getByTestId("workspace-new-agent-tab");
|
const button = page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first();
|
||||||
await expect(button).toBeVisible({ timeout: 10_000 });
|
await expect(button).toBeVisible({ timeout: 10_000 });
|
||||||
await button.click();
|
await button.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Click the new terminal button in the workspace tab bar. Creates a terminal tab directly. */
|
/** Click the new terminal button in the workspace tab bar. Creates a terminal tab directly. */
|
||||||
export async function clickNewTerminalButton(page: Page): Promise<void> {
|
export async function clickNewTerminalButton(page: Page): Promise<void> {
|
||||||
const button = page.getByTestId("workspace-new-terminal");
|
const button = page.getByTestId("workspace-new-terminal").filter({ visible: true }).first();
|
||||||
await expect(button).toBeVisible({ timeout: 10_000 });
|
await expect(button).toBeVisible({ timeout: 10_000 });
|
||||||
await button.click();
|
await button.click();
|
||||||
}
|
}
|
||||||
@@ -94,26 +97,30 @@ export async function waitForLauncherPanel(page: Page): Promise<void> {
|
|||||||
|
|
||||||
/** Assert the new agent tab button is visible in the tab bar. */
|
/** Assert the new agent tab button is visible in the tab bar. */
|
||||||
export async function assertNewChatTileVisible(page: Page): Promise<void> {
|
export async function assertNewChatTileVisible(page: Page): Promise<void> {
|
||||||
await expect(page.getByTestId("workspace-new-agent-tab").first()).toBeVisible();
|
await expect(
|
||||||
|
page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first(),
|
||||||
|
).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Assert the new terminal button is visible in the tab bar. */
|
/** Assert the new terminal button is visible in the tab bar. */
|
||||||
export async function assertTerminalTileVisible(page: Page): Promise<void> {
|
export async function assertTerminalTileVisible(page: Page): Promise<void> {
|
||||||
await expect(page.getByTestId("workspace-new-terminal").first()).toBeVisible();
|
await expect(
|
||||||
|
page.getByTestId("workspace-new-terminal").filter({ visible: true }).first(),
|
||||||
|
).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Tab creation actions ─────────────────────────────────────────────────
|
// ─── Tab creation actions ─────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Click the new agent tab button to create a draft/chat tab. */
|
/** Click the new agent tab button to create a draft/chat tab. */
|
||||||
export async function clickNewChat(page: Page): Promise<void> {
|
export async function clickNewChat(page: Page): Promise<void> {
|
||||||
const button = page.getByTestId("workspace-new-agent-tab");
|
const button = page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first();
|
||||||
await expect(button).toBeVisible({ timeout: 10_000 });
|
await expect(button).toBeVisible({ timeout: 10_000 });
|
||||||
await button.click();
|
await button.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Click the new terminal button to create a terminal tab. */
|
/** Click the new terminal button to create a terminal tab. */
|
||||||
export async function clickTerminal(page: Page): Promise<void> {
|
export async function clickTerminal(page: Page): Promise<void> {
|
||||||
const button = page.getByTestId("workspace-new-terminal");
|
const button = page.getByTestId("workspace-new-terminal").filter({ visible: true }).first();
|
||||||
await expect(button).toBeVisible({ timeout: 10_000 });
|
await expect(button).toBeVisible({ timeout: 10_000 });
|
||||||
await button.click();
|
await button.click();
|
||||||
}
|
}
|
||||||
@@ -131,13 +138,14 @@ export async function waitForTabWithTitle(
|
|||||||
page
|
page
|
||||||
.locator('[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])')
|
.locator('[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])')
|
||||||
.filter({ hasText: matcher })
|
.filter({ hasText: matcher })
|
||||||
|
.filter({ visible: true })
|
||||||
.first(),
|
.first(),
|
||||||
).toBeVisible({ timeout });
|
).toBeVisible({ timeout });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Assert the new agent tab button is visible in the tab bar. */
|
/** Assert the new agent tab button is visible in the tab bar. */
|
||||||
export async function assertSingleNewTabButton(page: Page): Promise<void> {
|
export async function assertSingleNewTabButton(page: Page): Promise<void> {
|
||||||
const buttons = page.getByTestId("workspace-new-agent-tab");
|
const buttons = page.getByTestId("workspace-new-agent-tab").filter({ visible: true });
|
||||||
const count = await buttons.count();
|
const count = await buttons.count();
|
||||||
expect(count).toBeGreaterThanOrEqual(1);
|
expect(count).toBeGreaterThanOrEqual(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,18 +13,30 @@ export async function getWorkspaceTabTestIds(page: Page): Promise<string[]> {
|
|||||||
return ids;
|
return ids;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function visibleTestId(page: Page, testId: string) {
|
||||||
|
return page.getByTestId(testId).filter({ visible: true });
|
||||||
|
}
|
||||||
|
|
||||||
export async function waitForWorkspaceTabsVisible(page: Page): Promise<void> {
|
export async function waitForWorkspaceTabsVisible(page: Page): Promise<void> {
|
||||||
await expect(page.getByTestId("workspace-tabs-row").first()).toBeVisible({
|
await expect(visibleTestId(page, "workspace-tabs-row").first()).toBeVisible({
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
await expect(page.getByTestId("workspace-new-agent-tab").first()).toBeVisible({
|
await expect(visibleTestId(page, "workspace-new-agent-tab").first()).toBeVisible({
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getVisibleWorkspaceAgentTabIds(page: Page): Promise<string[]> {
|
export async function getVisibleWorkspaceAgentTabIds(page: Page): Promise<string[]> {
|
||||||
const allTabIds = await getWorkspaceTabTestIds(page);
|
const tabs = page.locator('[data-testid^="workspace-tab-agent_"]').filter({ visible: true });
|
||||||
return allTabIds.filter((id) => id.startsWith("workspace-tab-agent_"));
|
const count = await tabs.count();
|
||||||
|
const ids: string[] = [];
|
||||||
|
for (let index = 0; index < count; index += 1) {
|
||||||
|
const testId = await tabs.nth(index).getAttribute("data-testid");
|
||||||
|
if (testId && !ids.includes(testId)) {
|
||||||
|
ids.push(testId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function expectOnlyWorkspaceAgentTabsVisible(
|
export async function expectOnlyWorkspaceAgentTabsVisible(
|
||||||
@@ -38,7 +50,7 @@ export async function expectOnlyWorkspaceAgentTabsVisible(
|
|||||||
expect(unexpected).toEqual([]);
|
expect(unexpected).toEqual([]);
|
||||||
expect(visible.length).toBe(expected.size);
|
expect(visible.length).toBe(expected.size);
|
||||||
for (const expectedId of expectedAgentIds) {
|
for (const expectedId of expectedAgentIds) {
|
||||||
await expect(page.getByTestId(`workspace-tab-agent_${expectedId}`)).toBeVisible({
|
await expect(visibleTestId(page, `workspace-tab-agent_${expectedId}`).first()).toBeVisible({
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,8 +103,8 @@ export async function expectWorkspaceHeader(
|
|||||||
page: Page,
|
page: Page,
|
||||||
input: { title: string; subtitle: string },
|
input: { title: string; subtitle: string },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const titleLocator = page.getByTestId("workspace-header-title");
|
const titleLocator = page.getByTestId("workspace-header-title").filter({ visible: true });
|
||||||
const subtitleLocator = page.getByTestId("workspace-header-subtitle");
|
const subtitleLocator = page.getByTestId("workspace-header-subtitle").filter({ visible: true });
|
||||||
|
|
||||||
await expect(titleLocator.first()).toHaveText(input.title, {
|
await expect(titleLocator.first()).toHaveText(input.title, {
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
|
|||||||
@@ -248,7 +248,12 @@ test.describe("New workspace flow", () => {
|
|||||||
subtitle: openedProject.projectDisplayName,
|
subtitle: openedProject.projectDisplayName,
|
||||||
});
|
});
|
||||||
|
|
||||||
const agentTabs = page.locator('[data-testid^="workspace-tab-agent_"]');
|
const activeWorkspaceDeckEntry = page
|
||||||
|
.getByTestId(`workspace-deck-entry-${serverId}:${createdWorkspace.workspaceId}`)
|
||||||
|
.filter({ visible: true });
|
||||||
|
await expect(activeWorkspaceDeckEntry).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
const agentTabs = activeWorkspaceDeckEntry.locator('[data-testid^="workspace-tab-agent_"]');
|
||||||
await expect(agentTabs).toHaveCount(1, { timeout: 30_000 });
|
await expect(agentTabs).toHaveCount(1, { timeout: 30_000 });
|
||||||
|
|
||||||
// Workspace setup may auto-open a setup tab that steals focus,
|
// Workspace setup may auto-open a setup tab that steals focus,
|
||||||
|
|||||||
@@ -96,6 +96,14 @@ test.describe("Workspace navigation regression", () => {
|
|||||||
`workspace-tab-agent_${firstAgent.id}`,
|
`workspace-tab-agent_${firstAgent.id}`,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const firstDeckEntry = page.getByTestId(
|
||||||
|
`workspace-deck-entry-${serverId}:${firstWorkspace.workspaceId}`,
|
||||||
|
);
|
||||||
|
const secondDeckEntry = page.getByTestId(
|
||||||
|
`workspace-deck-entry-${serverId}:${secondWorkspace.workspaceId}`,
|
||||||
|
);
|
||||||
|
await expect(firstDeckEntry).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
await switchWorkspaceViaSidebar({
|
await switchWorkspaceViaSidebar({
|
||||||
page,
|
page,
|
||||||
serverId,
|
serverId,
|
||||||
@@ -126,27 +134,45 @@ test.describe("Workspace navigation regression", () => {
|
|||||||
await expect(getVisibleWorkspaceAgentTabIds(page)).resolves.toEqual([
|
await expect(getVisibleWorkspaceAgentTabIds(page)).resolves.toEqual([
|
||||||
`workspace-tab-agent_${secondAgent.id}`,
|
`workspace-tab-agent_${secondAgent.id}`,
|
||||||
]);
|
]);
|
||||||
|
await expect(firstDeckEntry).toBeAttached();
|
||||||
|
await expect(firstDeckEntry).toBeHidden();
|
||||||
|
await expect(secondDeckEntry).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(2);
|
||||||
|
|
||||||
|
await switchWorkspaceViaSidebar({
|
||||||
|
page,
|
||||||
|
serverId,
|
||||||
|
targetWorkspacePath: firstWorkspace.workspaceId,
|
||||||
|
});
|
||||||
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId), {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await expect(firstDeckEntry).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(secondDeckEntry).toBeAttached();
|
||||||
|
await expect(secondDeckEntry).toBeHidden();
|
||||||
|
await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(2);
|
||||||
|
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await waitForSidebarHydration(page);
|
await waitForSidebarHydration(page);
|
||||||
await waitForWorkspaceTabsVisible(page);
|
await waitForWorkspaceTabsVisible(page);
|
||||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), {
|
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId), {
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
await expectSidebarWorkspaceSelected({
|
await expectSidebarWorkspaceSelected({
|
||||||
page,
|
page,
|
||||||
serverId,
|
serverId,
|
||||||
workspaceId: secondWorkspace.workspaceId,
|
workspaceId: firstWorkspace.workspaceId,
|
||||||
});
|
});
|
||||||
await expectWorkspaceHeader(page, {
|
await expectWorkspaceHeader(page, {
|
||||||
title: secondWorkspace.workspaceName,
|
title: firstWorkspace.workspaceName,
|
||||||
subtitle: secondWorkspace.projectDisplayName,
|
subtitle: firstWorkspace.projectDisplayName,
|
||||||
});
|
});
|
||||||
await expectWorkspaceTabVisible(page, secondAgent.id);
|
await expectWorkspaceTabVisible(page, firstAgent.id);
|
||||||
await expectWorkspaceTabHidden(page, firstAgent.id);
|
await expectWorkspaceTabHidden(page, secondAgent.id);
|
||||||
await expectOnlyWorkspaceAgentTabsVisible(page, [secondAgent.id]);
|
await expectOnlyWorkspaceAgentTabsVisible(page, [firstAgent.id]);
|
||||||
await expect(getVisibleWorkspaceAgentTabIds(page)).resolves.toEqual([
|
await expect(getVisibleWorkspaceAgentTabIds(page)).resolves.toEqual([
|
||||||
`workspace-tab-agent_${secondAgent.id}`,
|
`workspace-tab-agent_${firstAgent.id}`,
|
||||||
]);
|
]);
|
||||||
} finally {
|
} finally {
|
||||||
for (const agentId of agentIds) {
|
for (const agentId of agentIds) {
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ test.describe("Workspace setup streaming", () => {
|
|||||||
await navigateToWorkspaceViaSidebar(page, workspace.id);
|
await navigateToWorkspaceViaSidebar(page, workspace.id);
|
||||||
|
|
||||||
await waitForWorkspaceTabsVisible(page);
|
await waitForWorkspaceTabsVisible(page);
|
||||||
await page.getByTestId("workspace-new-agent-tab").first().click();
|
await page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first().click();
|
||||||
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
|
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"version": "0.1.60-rc.2",
|
"version": "0.1.60-beta.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "APP_VARIANT=development expo start",
|
||||||
"reset-project": "node ./scripts/reset-project.js",
|
"reset-project": "node ./scripts/reset-project.js",
|
||||||
"build:workspace-deps": "npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/expo-two-way-audio",
|
"build:workspace-deps": "npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/expo-two-way-audio",
|
||||||
"eas-build-post-install": "npm run build:workspace-deps",
|
"eas-build-post-install": "npm run build:workspace-deps",
|
||||||
@@ -31,9 +31,9 @@
|
|||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@expo/vector-icons": "^15.0.2",
|
"@expo/vector-icons": "^15.0.2",
|
||||||
"@floating-ui/react-native": "^0.10.7",
|
"@floating-ui/react-native": "^0.10.7",
|
||||||
"@getpaseo/expo-two-way-audio": "0.1.60-rc.2",
|
"@getpaseo/expo-two-way-audio": "0.1.60-beta.1",
|
||||||
"@getpaseo/highlight": "0.1.60-rc.2",
|
"@getpaseo/highlight": "0.1.60-beta.1",
|
||||||
"@getpaseo/server": "0.1.60-rc.2",
|
"@getpaseo/server": "0.1.60-beta.1",
|
||||||
"@gorhom/bottom-sheet": "^5.2.6",
|
"@gorhom/bottom-sheet": "^5.2.6",
|
||||||
"@gorhom/portal": "^1.0.14",
|
"@gorhom/portal": "^1.0.14",
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
@@ -103,6 +103,7 @@
|
|||||||
"react-native-web": "~0.21.0",
|
"react-native-web": "~0.21.0",
|
||||||
"react-native-webview": "^13.16.0",
|
"react-native-webview": "^13.16.0",
|
||||||
"react-native-worklets": "0.5.1",
|
"react-native-worklets": "0.5.1",
|
||||||
|
"use-sync-external-store": "^1.6.0",
|
||||||
"zod": "^3.23.8",
|
"zod": "^3.23.8",
|
||||||
"zustand": "^5.0.9"
|
"zustand": "^5.0.9"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||||
import { KeyboardProvider } from "react-native-keyboard-controller";
|
import { KeyboardProvider } from "react-native-keyboard-controller";
|
||||||
import { GestureHandlerRootView, Gesture, GestureDetector } from "react-native-gesture-handler";
|
import { GestureHandlerRootView, Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
|
||||||
import { PortalProvider } from "@gorhom/portal";
|
import { PortalProvider } from "@gorhom/portal";
|
||||||
import { VoiceProvider } from "@/contexts/voice-context";
|
import { VoiceProvider } from "@/contexts/voice-context";
|
||||||
import { useAppSettings } from "@/hooks/use-settings";
|
import { useAppSettings } from "@/hooks/use-settings";
|
||||||
@@ -63,9 +62,13 @@ import { CommandCenter } from "@/components/command-center";
|
|||||||
import { ProjectPickerModal } from "@/components/project-picker-modal";
|
import { ProjectPickerModal } from "@/components/project-picker-modal";
|
||||||
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
||||||
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
|
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
|
||||||
|
import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber";
|
||||||
|
import { resolveActiveHost } from "@/utils/active-host";
|
||||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||||
import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action";
|
import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action";
|
||||||
|
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
||||||
import { queryClient } from "@/query/query-client";
|
import { queryClient } from "@/query/query-client";
|
||||||
|
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
|
||||||
import {
|
import {
|
||||||
WEB_NOTIFICATION_CLICK_EVENT,
|
WEB_NOTIFICATION_CLICK_EVENT,
|
||||||
type WebNotificationClickDetail,
|
type WebNotificationClickDetail,
|
||||||
@@ -83,7 +86,10 @@ import {
|
|||||||
parseWorkspaceOpenIntent,
|
parseWorkspaceOpenIntent,
|
||||||
decodeWorkspaceIdFromPathSegment,
|
decodeWorkspaceIdFromPathSegment,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store";
|
import {
|
||||||
|
addBrowserActiveWorkspaceLocationListener,
|
||||||
|
syncNavigationActiveWorkspace,
|
||||||
|
} from "@/stores/navigation-active-workspace-store";
|
||||||
import { isWeb, isNative } from "@/constants/platform";
|
import { isWeb, isNative } from "@/constants/platform";
|
||||||
|
|
||||||
polyfillCrypto();
|
polyfillCrypto();
|
||||||
@@ -388,9 +394,11 @@ function AppContainer({
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
const { settings, updateSettings } = useAppSettings();
|
const { settings, updateSettings } = useAppSettings();
|
||||||
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
|
const toggleMobileAgentList = usePanelStore((state) => state.toggleMobileAgentList);
|
||||||
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
|
const toggleDesktopAgentList = usePanelStore((state) => state.toggleDesktopAgentList);
|
||||||
const toggleBothSidebars = usePanelStore((state) => state.toggleBothSidebars);
|
const openDesktopAgentList = usePanelStore((state) => state.openDesktopAgentList);
|
||||||
|
const closeDesktopAgentList = usePanelStore((state) => state.closeDesktopAgentList);
|
||||||
|
const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer);
|
||||||
const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode);
|
const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode);
|
||||||
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
|
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
|
||||||
const agentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
const agentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||||
@@ -405,6 +413,26 @@ function AppContainer({
|
|||||||
const isCompactLayout = useIsCompactFormFactor();
|
const isCompactLayout = useIsCompactFormFactor();
|
||||||
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
|
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const activeServerId = useMemo(
|
||||||
|
() => resolveActiveHost({ hosts: daemons, pathname })?.serverId ?? null,
|
||||||
|
[daemons, pathname],
|
||||||
|
);
|
||||||
|
const toggleAgentList = isCompactLayout ? toggleMobileAgentList : toggleDesktopAgentList;
|
||||||
|
const toggleDesktopSidebars = useCallback(() => {
|
||||||
|
const { desktop } = usePanelStore.getState();
|
||||||
|
toggleDesktopSidebarsWithCheckoutIntent({
|
||||||
|
isAgentListOpen: desktop.agentListOpen,
|
||||||
|
isFileExplorerOpen: desktop.fileExplorerOpen,
|
||||||
|
openAgentList: openDesktopAgentList,
|
||||||
|
closeAgentList: closeDesktopAgentList,
|
||||||
|
closeFileExplorer: closeDesktopFileExplorer,
|
||||||
|
toggleFocusedFileExplorer: () =>
|
||||||
|
keyboardActionDispatcher.dispatch({
|
||||||
|
id: "sidebar.toggle.right",
|
||||||
|
scope: "sidebar",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}, [closeDesktopAgentList, closeDesktopFileExplorer, openDesktopAgentList]);
|
||||||
// TODO: stop matching pathname here as a branch. `chromeEnabled` should not
|
// TODO: stop matching pathname here as a branch. `chromeEnabled` should not
|
||||||
// conflate workspace/project-specific chrome (sidebar, mobile gesture) with
|
// conflate workspace/project-specific chrome (sidebar, mobile gesture) with
|
||||||
// global concerns like keyboard shortcuts. Split those out so settings (and
|
// global concerns like keyboard shortcuts. Split those out so settings (and
|
||||||
@@ -444,8 +472,7 @@ function AppContainer({
|
|||||||
enabled: keyboardShortcutsEnabled,
|
enabled: keyboardShortcutsEnabled,
|
||||||
isMobile: isCompactLayout,
|
isMobile: isCompactLayout,
|
||||||
toggleAgentList,
|
toggleAgentList,
|
||||||
toggleFileExplorer,
|
toggleBothSidebars: toggleDesktopSidebars,
|
||||||
toggleBothSidebars,
|
|
||||||
toggleFocusMode,
|
toggleFocusMode,
|
||||||
cycleTheme,
|
cycleTheme,
|
||||||
});
|
});
|
||||||
@@ -470,6 +497,10 @@ function AppContainer({
|
|||||||
<UpdateBanner />
|
<UpdateBanner />
|
||||||
<CommandCenter />
|
<CommandCenter />
|
||||||
<ProjectPickerModal />
|
<ProjectPickerModal />
|
||||||
|
<WorkspaceShortcutTargetsSubscriber
|
||||||
|
enabled={keyboardShortcutsEnabled}
|
||||||
|
serverId={activeServerId}
|
||||||
|
/>
|
||||||
<WorkspaceSetupDialog />
|
<WorkspaceSetupDialog />
|
||||||
<KeyboardShortcutsDialog />
|
<KeyboardShortcutsDialog />
|
||||||
</View>
|
</View>
|
||||||
@@ -490,7 +521,7 @@ function MobileGestureWrapper({
|
|||||||
chromeEnabled: boolean;
|
chromeEnabled: boolean;
|
||||||
}) {
|
}) {
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const mobileView = usePanelStore((state) => state.mobileView);
|
||||||
const openAgentList = usePanelStore((state) => state.openAgentList);
|
const showMobileAgentList = usePanelStore((state) => state.showMobileAgentList);
|
||||||
const horizontalScroll = useHorizontalScrollOptional();
|
const horizontalScroll = useHorizontalScrollOptional();
|
||||||
const {
|
const {
|
||||||
translateX,
|
translateX,
|
||||||
@@ -507,8 +538,8 @@ function MobileGestureWrapper({
|
|||||||
|
|
||||||
const handleGestureOpen = useCallback(() => {
|
const handleGestureOpen = useCallback(() => {
|
||||||
gestureAnimatingRef.current = true;
|
gestureAnimatingRef.current = true;
|
||||||
openAgentList();
|
showMobileAgentList();
|
||||||
}, [openAgentList, gestureAnimatingRef]);
|
}, [showMobileAgentList, gestureAnimatingRef]);
|
||||||
|
|
||||||
const openGesture = useMemo(
|
const openGesture = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -815,14 +846,14 @@ function RootStack() {
|
|||||||
<Stack.Screen name="settings/[section]" />
|
<Stack.Screen name="settings/[section]" />
|
||||||
<Stack.Screen name="pair-scan" />
|
<Stack.Screen name="pair-scan" />
|
||||||
</Stack.Protected>
|
</Stack.Protected>
|
||||||
<Stack.Screen
|
{/*
|
||||||
name="h/[serverId]/workspace/[workspaceId]"
|
Do not add getId or dangerouslySingular back to the workspace route.
|
||||||
getId={({ params }) => {
|
Expo Router maps dangerouslySingular to React Navigation getId, and
|
||||||
const serverId = getRouteParamValue(params?.serverId);
|
getId repeatedly breaks Android native-stack/Fabric by reordering an
|
||||||
const workspaceId = getRouteParamValue(params?.workspaceId);
|
already-mounted workspace screen. Keep workspace identity/retention
|
||||||
return serverId && workspaceId ? `${serverId}:${workspaceId}` : undefined;
|
outside this route-level native-stack API.
|
||||||
}}
|
*/}
|
||||||
/>
|
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
|
||||||
<Stack.Screen name="h/[serverId]/agent/[agentId]" options={{ gestureEnabled: false }} />
|
<Stack.Screen name="h/[serverId]/agent/[agentId]" options={{ gestureEnabled: false }} />
|
||||||
<Stack.Screen name="h/[serverId]/index" />
|
<Stack.Screen name="h/[serverId]/index" />
|
||||||
<Stack.Screen name="h/[serverId]/sessions" />
|
<Stack.Screen name="h/[serverId]/sessions" />
|
||||||
@@ -839,6 +870,7 @@ function NavigationActiveWorkspaceObserver() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
syncNavigationActiveWorkspace(navigationRef);
|
syncNavigationActiveWorkspace(navigationRef);
|
||||||
|
const unsubscribeBrowserLocation = addBrowserActiveWorkspaceLocationListener();
|
||||||
const unsubscribeState = navigationRef.addListener("state", () => {
|
const unsubscribeState = navigationRef.addListener("state", () => {
|
||||||
syncNavigationActiveWorkspace(navigationRef);
|
syncNavigationActiveWorkspace(navigationRef);
|
||||||
});
|
});
|
||||||
@@ -846,6 +878,7 @@ function NavigationActiveWorkspaceObserver() {
|
|||||||
syncNavigationActiveWorkspace(navigationRef);
|
syncNavigationActiveWorkspace(navigationRef);
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
|
unsubscribeBrowserLocation();
|
||||||
unsubscribeState();
|
unsubscribeState();
|
||||||
unsubscribeReady();
|
unsubscribeReady();
|
||||||
};
|
};
|
||||||
@@ -864,23 +897,21 @@ export default function RootLayout() {
|
|||||||
<SafeAreaProvider>
|
<SafeAreaProvider>
|
||||||
<KeyboardProvider>
|
<KeyboardProvider>
|
||||||
<QueryProvider>
|
<QueryProvider>
|
||||||
<BottomSheetModalProvider>
|
<HostRuntimeBootstrapProvider>
|
||||||
<HostRuntimeBootstrapProvider>
|
<PushNotificationRouter />
|
||||||
<PushNotificationRouter />
|
<ToastProvider>
|
||||||
<ToastProvider>
|
<ProvidersWrapper>
|
||||||
<ProvidersWrapper>
|
<SidebarAnimationProvider>
|
||||||
<SidebarAnimationProvider>
|
<HorizontalScrollProvider>
|
||||||
<HorizontalScrollProvider>
|
<OpenProjectListener />
|
||||||
<OpenProjectListener />
|
<AppWithSidebar>
|
||||||
<AppWithSidebar>
|
<RootStack />
|
||||||
<RootStack />
|
</AppWithSidebar>
|
||||||
</AppWithSidebar>
|
</HorizontalScrollProvider>
|
||||||
</HorizontalScrollProvider>
|
</SidebarAnimationProvider>
|
||||||
</SidebarAnimationProvider>
|
</ProvidersWrapper>
|
||||||
</ProvidersWrapper>
|
</ToastProvider>
|
||||||
</ToastProvider>
|
</HostRuntimeBootstrapProvider>
|
||||||
</HostRuntimeBootstrapProvider>
|
|
||||||
</BottomSheetModalProvider>
|
|
||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
</KeyboardProvider>
|
</KeyboardProvider>
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useNavigation } from "@react-navigation/native";
|
import { useNavigation } from "@react-navigation/native";
|
||||||
|
import { StyleSheet, View } from "react-native";
|
||||||
import {
|
import {
|
||||||
useGlobalSearchParams,
|
useGlobalSearchParams,
|
||||||
useLocalSearchParams,
|
useLocalSearchParams,
|
||||||
@@ -7,6 +8,11 @@ import {
|
|||||||
useRootNavigationState,
|
useRootNavigationState,
|
||||||
} from "expo-router";
|
} from "expo-router";
|
||||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||||
|
import {
|
||||||
|
activateNavigationWorkspaceSelection,
|
||||||
|
type ActiveWorkspaceSelection,
|
||||||
|
useNavigationActiveWorkspaceSelection,
|
||||||
|
} from "@/stores/navigation-active-workspace-store";
|
||||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||||
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
||||||
import {
|
import {
|
||||||
@@ -95,6 +101,23 @@ function HostWorkspaceLayoutContent() {
|
|||||||
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
|
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
|
||||||
: "";
|
: "";
|
||||||
const openValue = getParamValue(globalParams.open);
|
const openValue = getParamValue(globalParams.open);
|
||||||
|
const routeWorkspaceSelection = useMemo(
|
||||||
|
() =>
|
||||||
|
serverId && workspaceId
|
||||||
|
? {
|
||||||
|
serverId,
|
||||||
|
workspaceId,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
[serverId, workspaceId],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!routeWorkspaceSelection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
activateNavigationWorkspaceSelection(routeWorkspaceSelection);
|
||||||
|
}, [routeWorkspaceSelection]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!openValue) {
|
if (!openValue) {
|
||||||
@@ -144,11 +167,73 @@ function HostWorkspaceLayoutContent() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return <WorkspaceDeck fallbackSelection={routeWorkspaceSelection} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function areWorkspaceSelectionsEqual(
|
||||||
|
left: ActiveWorkspaceSelection | null,
|
||||||
|
right: ActiveWorkspaceSelection | null,
|
||||||
|
): boolean {
|
||||||
|
return left?.serverId === right?.serverId && left?.workspaceId === right?.workspaceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkspaceDeck({
|
||||||
|
fallbackSelection,
|
||||||
|
}: {
|
||||||
|
fallbackSelection: ActiveWorkspaceSelection | null;
|
||||||
|
}) {
|
||||||
|
const activeSelection = useNavigationActiveWorkspaceSelection() ?? fallbackSelection;
|
||||||
|
const [mountedSelections, setMountedSelections] = useState<ActiveWorkspaceSelection[]>(() =>
|
||||||
|
activeSelection ? [activeSelection] : [],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeSelection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMountedSelections((current) => {
|
||||||
|
if (current.some((selection) => areWorkspaceSelectionsEqual(selection, activeSelection))) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
return [...current, activeSelection];
|
||||||
|
});
|
||||||
|
}, [activeSelection]);
|
||||||
|
|
||||||
|
if (!activeSelection) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WorkspaceScreen
|
<View style={styles.deck}>
|
||||||
key={`${serverId}:${workspaceId}`}
|
{mountedSelections.map((selection) => {
|
||||||
serverId={serverId}
|
const isActive = areWorkspaceSelectionsEqual(selection, activeSelection);
|
||||||
workspaceId={workspaceId}
|
return (
|
||||||
/>
|
<View
|
||||||
|
key={`${selection.serverId}:${selection.workspaceId}`}
|
||||||
|
style={isActive ? styles.activeDeckEntry : styles.inactiveDeckEntry}
|
||||||
|
testID={`workspace-deck-entry-${selection.serverId}:${selection.workspaceId}`}
|
||||||
|
>
|
||||||
|
<WorkspaceScreen
|
||||||
|
serverId={selection.serverId}
|
||||||
|
workspaceId={selection.workspaceId}
|
||||||
|
isRouteFocused={isActive}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
deck: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
activeDeckEntry: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
inactiveDeckEntry: {
|
||||||
|
display: "none",
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react";
|
import { forwardRef, useCallback, useEffect, useMemo } from "react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
|
import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
|
||||||
@@ -7,7 +7,6 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
|||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root";
|
import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root";
|
||||||
import {
|
import {
|
||||||
BottomSheetModal,
|
|
||||||
BottomSheetBackdrop,
|
BottomSheetBackdrop,
|
||||||
BottomSheetScrollView,
|
BottomSheetScrollView,
|
||||||
BottomSheetTextInput,
|
BottomSheetTextInput,
|
||||||
@@ -16,6 +15,10 @@ import {
|
|||||||
import { X } from "lucide-react-native";
|
import { X } from "lucide-react-native";
|
||||||
import { FileDropZone } from "@/components/file-drop-zone";
|
import { FileDropZone } from "@/components/file-drop-zone";
|
||||||
import type { ImageAttachment } from "@/components/message-input";
|
import type { ImageAttachment } from "@/components/message-input";
|
||||||
|
import {
|
||||||
|
IsolatedBottomSheetModal,
|
||||||
|
useIsolatedBottomSheetVisibility,
|
||||||
|
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
type EscHandler = () => void;
|
type EscHandler = () => void;
|
||||||
@@ -163,7 +166,6 @@ export interface AdaptiveModalSheetProps {
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
headerActions?: ReactNode;
|
headerActions?: ReactNode;
|
||||||
snapPoints?: string[];
|
snapPoints?: string[];
|
||||||
stackBehavior?: "push" | "switch" | "replace";
|
|
||||||
testID?: string;
|
testID?: string;
|
||||||
/** Override the max width of the desktop card. */
|
/** Override the max width of the desktop card. */
|
||||||
desktopMaxWidth?: number;
|
desktopMaxWidth?: number;
|
||||||
@@ -180,7 +182,6 @@ export function AdaptiveModalSheet({
|
|||||||
children,
|
children,
|
||||||
headerActions,
|
headerActions,
|
||||||
snapPoints,
|
snapPoints,
|
||||||
stackBehavior,
|
|
||||||
testID,
|
testID,
|
||||||
desktopMaxWidth,
|
desktopMaxWidth,
|
||||||
onFilesDropped,
|
onFilesDropped,
|
||||||
@@ -188,33 +189,13 @@ export function AdaptiveModalSheet({
|
|||||||
}: AdaptiveModalSheetProps) {
|
}: AdaptiveModalSheetProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
const sheetRef = useRef<BottomSheetModal>(null);
|
const titleColor = theme.colors.foreground;
|
||||||
const dismissingForVisibilityRef = useRef(false);
|
|
||||||
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
|
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
|
||||||
|
const { sheetRef, handleSheetChange } = useIsolatedBottomSheetVisibility({
|
||||||
useEffect(() => {
|
visible,
|
||||||
if (!isMobile) return;
|
isEnabled: isMobile,
|
||||||
if (visible) {
|
onClose,
|
||||||
dismissingForVisibilityRef.current = false;
|
});
|
||||||
sheetRef.current?.present();
|
|
||||||
} else {
|
|
||||||
dismissingForVisibilityRef.current = true;
|
|
||||||
sheetRef.current?.dismiss();
|
|
||||||
}
|
|
||||||
}, [visible, isMobile]);
|
|
||||||
|
|
||||||
const handleSheetChange = useCallback(
|
|
||||||
(index: number) => {
|
|
||||||
if (index === -1) {
|
|
||||||
if (dismissingForVisibilityRef.current) {
|
|
||||||
dismissingForVisibilityRef.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[onClose],
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
const renderBackdrop = useCallback(
|
||||||
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||||
@@ -230,7 +211,7 @@ export function AdaptiveModalSheet({
|
|||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
<BottomSheetModal
|
<IsolatedBottomSheetModal
|
||||||
ref={sheetRef}
|
ref={sheetRef}
|
||||||
snapPoints={resolvedSnapPoints}
|
snapPoints={resolvedSnapPoints}
|
||||||
index={0}
|
index={0}
|
||||||
@@ -238,7 +219,6 @@ export function AdaptiveModalSheet({
|
|||||||
onChange={handleSheetChange}
|
onChange={handleSheetChange}
|
||||||
backdropComponent={renderBackdrop}
|
backdropComponent={renderBackdrop}
|
||||||
enablePanDownToClose
|
enablePanDownToClose
|
||||||
stackBehavior={stackBehavior}
|
|
||||||
backgroundComponent={SheetBackground}
|
backgroundComponent={SheetBackground}
|
||||||
handleIndicatorStyle={{ backgroundColor: theme.colors.surface2 }}
|
handleIndicatorStyle={{ backgroundColor: theme.colors.surface2 }}
|
||||||
keyboardBehavior="extend"
|
keyboardBehavior="extend"
|
||||||
@@ -247,7 +227,7 @@ export function AdaptiveModalSheet({
|
|||||||
>
|
>
|
||||||
<View style={styles.bottomSheetHeader} testID={testID}>
|
<View style={styles.bottomSheetHeader} testID={testID}>
|
||||||
<View style={styles.headerTitleGroup}>
|
<View style={styles.headerTitleGroup}>
|
||||||
<Text style={[styles.title, { color: theme.colors.foreground }]} numberOfLines={1}>
|
<Text key={titleColor} style={[styles.title, { color: titleColor }]} numberOfLines={1}>
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
{subtitle}
|
{subtitle}
|
||||||
@@ -268,7 +248,7 @@ export function AdaptiveModalSheet({
|
|||||||
) : (
|
) : (
|
||||||
<View style={styles.bottomSheetStaticContent}>{children}</View>
|
<View style={styles.bottomSheetStaticContent}>{children}</View>
|
||||||
)}
|
)}
|
||||||
</BottomSheetModal>
|
</IsolatedBottomSheetModal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,7 +256,7 @@ export function AdaptiveModalSheet({
|
|||||||
<>
|
<>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<View style={styles.headerTitleGroup}>
|
<View style={styles.headerTitleGroup}>
|
||||||
<Text style={[styles.title, { color: theme.colors.foreground }]} numberOfLines={1}>
|
<Text key={titleColor} style={[styles.title, { color: titleColor }]} numberOfLines={1}>
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
{subtitle}
|
{subtitle}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { View, Text, Pressable, TextInput, ActivityIndicator } from "react-nativ
|
|||||||
import type { StyleProp, ViewStyle, TextProps } from "react-native";
|
import type { StyleProp, ViewStyle, TextProps } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import {
|
import {
|
||||||
BottomSheetModal,
|
|
||||||
BottomSheetScrollView,
|
BottomSheetScrollView,
|
||||||
BottomSheetBackdrop,
|
BottomSheetBackdrop,
|
||||||
BottomSheetBackgroundProps,
|
BottomSheetBackgroundProps,
|
||||||
@@ -30,6 +29,10 @@ import type {
|
|||||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||||
import { getModeVisuals, type AgentModeIcon } from "@server/server/agent/provider-manifest";
|
import { getModeVisuals, type AgentModeIcon } from "@server/server/agent/provider-manifest";
|
||||||
import { Combobox, ComboboxItem, ComboboxEmpty } from "@/components/ui/combobox";
|
import { Combobox, ComboboxItem, ComboboxEmpty } from "@/components/ui/combobox";
|
||||||
|
import {
|
||||||
|
IsolatedBottomSheetModal,
|
||||||
|
useIsolatedBottomSheetVisibility,
|
||||||
|
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||||
import { baseColors } from "@/styles/theme";
|
import { baseColors } from "@/styles/theme";
|
||||||
import { isNative } from "@/constants/platform";
|
import { isNative } from "@/constants/platform";
|
||||||
|
|
||||||
@@ -256,31 +259,17 @@ export function DropdownSheet({
|
|||||||
children,
|
children,
|
||||||
}: DropdownSheetProps): ReactElement {
|
}: DropdownSheetProps): ReactElement {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const bottomSheetRef = useRef<BottomSheetModal>(null);
|
const titleColor = theme.colors.foreground;
|
||||||
const snapPoints = useMemo(() => ["60%", "90%"], []);
|
const snapPoints = useMemo(() => ["60%", "90%"], []);
|
||||||
|
const { sheetRef, handleSheetChange } = useIsolatedBottomSheetVisibility({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
});
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
bottomSheetRef.current?.dismiss();
|
|
||||||
onClose();
|
onClose();
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (visible) {
|
|
||||||
bottomSheetRef.current?.present();
|
|
||||||
} else {
|
|
||||||
bottomSheetRef.current?.dismiss();
|
|
||||||
}
|
|
||||||
}, [visible]);
|
|
||||||
|
|
||||||
const handleSheetChange = useCallback(
|
|
||||||
(index: number) => {
|
|
||||||
if (index === -1) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[onClose],
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
const renderBackdrop = useCallback(
|
||||||
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||||
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
|
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
|
||||||
@@ -289,8 +278,8 @@ export function DropdownSheet({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BottomSheetModal
|
<IsolatedBottomSheetModal
|
||||||
ref={bottomSheetRef}
|
ref={sheetRef}
|
||||||
snapPoints={snapPoints}
|
snapPoints={snapPoints}
|
||||||
index={0}
|
index={0}
|
||||||
enableDynamicSizing={false}
|
enableDynamicSizing={false}
|
||||||
@@ -303,7 +292,9 @@ export function DropdownSheet({
|
|||||||
keyboardBlurBehavior="restore"
|
keyboardBlurBehavior="restore"
|
||||||
>
|
>
|
||||||
<View style={styles.bottomSheetHeader}>
|
<View style={styles.bottomSheetHeader}>
|
||||||
<Text style={[styles.dropdownSheetTitle, { color: theme.colors.foreground }]}>{title}</Text>
|
<Text key={titleColor} style={[styles.dropdownSheetTitle, { color: titleColor }]}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
<Pressable
|
<Pressable
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel="Close sheet"
|
accessibilityLabel="Close sheet"
|
||||||
@@ -321,7 +312,7 @@ export function DropdownSheet({
|
|||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</BottomSheetScrollView>
|
</BottomSheetScrollView>
|
||||||
</BottomSheetModal>
|
</IsolatedBottomSheetModal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1229,7 +1220,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
dropdownSheetTitle: {
|
dropdownSheetTitle: {
|
||||||
fontSize: theme.fontSize.lg,
|
fontSize: theme.fontSize.lg,
|
||||||
fontWeight: theme.fontWeight.semibold,
|
fontWeight: theme.fontWeight.semibold,
|
||||||
color: theme.colors.foreground,
|
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
},
|
},
|
||||||
dropdownSheetScrollContent: {
|
dropdownSheetScrollContent: {
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ type ControlledAgentStatusBarProps = {
|
|||||||
|
|
||||||
export interface DraftAgentStatusBarProps {
|
export interface DraftAgentStatusBarProps {
|
||||||
providerDefinitions: AgentProviderDefinition[];
|
providerDefinitions: AgentProviderDefinition[];
|
||||||
selectedProvider: AgentProvider;
|
selectedProvider: AgentProvider | null;
|
||||||
onSelectProvider: (provider: AgentProvider) => void;
|
onSelectProvider: (provider: AgentProvider) => void;
|
||||||
modeOptions: AgentMode[];
|
modeOptions: AgentMode[];
|
||||||
selectedMode: string;
|
selectedMode: string;
|
||||||
@@ -255,7 +255,8 @@ function ControlledStatusBar({
|
|||||||
: undefined;
|
: undefined;
|
||||||
const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null;
|
const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null;
|
||||||
const modeIconColor = getModeIconColor(modeVisuals?.colorTier, theme.colors.palette);
|
const modeIconColor = getModeIconColor(modeVisuals?.colorTier, theme.colors.palette);
|
||||||
const ProviderIcon = getProviderIcon(provider);
|
const hasSelectedProvider = provider.trim().length > 0;
|
||||||
|
const ProviderIcon = hasSelectedProvider ? getProviderIcon(provider) : null;
|
||||||
|
|
||||||
const hasAnyControl =
|
const hasAnyControl =
|
||||||
Boolean(providerOptions?.length) ||
|
Boolean(providerOptions?.length) ||
|
||||||
@@ -620,7 +621,9 @@ function ControlledStatusBar({
|
|||||||
accessibilityLabel="Agent preferences"
|
accessibilityLabel="Agent preferences"
|
||||||
testID="agent-preferences-button"
|
testID="agent-preferences-button"
|
||||||
>
|
>
|
||||||
<ProviderIcon size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
|
{ProviderIcon ? (
|
||||||
|
<ProviderIcon size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
|
||||||
|
) : null}
|
||||||
<Text style={styles.prefsButtonText} numberOfLines={1}>
|
<Text style={styles.prefsButtonText} numberOfLines={1}>
|
||||||
{displayModel}
|
{displayModel}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -630,7 +633,6 @@ function ControlledStatusBar({
|
|||||||
title="Preferences"
|
title="Preferences"
|
||||||
visible={prefsOpen}
|
visible={prefsOpen}
|
||||||
onClose={() => setPrefsOpen(false)}
|
onClose={() => setPrefsOpen(false)}
|
||||||
stackBehavior="replace"
|
|
||||||
testID="agent-preferences-sheet"
|
testID="agent-preferences-sheet"
|
||||||
>
|
>
|
||||||
{canSelectModel ? (
|
{canSelectModel ? (
|
||||||
@@ -663,7 +665,12 @@ function ControlledStatusBar({
|
|||||||
pointerEvents="none"
|
pointerEvents="none"
|
||||||
testID="agent-preferences-model"
|
testID="agent-preferences-model"
|
||||||
>
|
>
|
||||||
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
{ProviderIcon ? (
|
||||||
|
<ProviderIcon
|
||||||
|
size={theme.iconSize.md}
|
||||||
|
color={theme.colors.foregroundMuted}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<Text style={styles.sheetSelectText}>{selectedModelLabel}</Text>
|
<Text style={styles.sheetSelectText}>{selectedModelLabel}</Text>
|
||||||
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
</View>
|
</View>
|
||||||
@@ -873,15 +880,15 @@ export const AgentStatusBar = memo(function AgentStatusBar({
|
|||||||
refetchIfStale: refetchSnapshotIfStale,
|
refetchIfStale: refetchSnapshotIfStale,
|
||||||
} = useProvidersSnapshot(serverId, agent?.cwd);
|
} = useProvidersSnapshot(serverId, agent?.cwd);
|
||||||
|
|
||||||
const snapshotModels = useMemo(() => {
|
const snapshotSelectedEntry = useMemo(() => {
|
||||||
if (!snapshotEntries || !agent?.provider) {
|
if (!snapshotEntries || !agent?.provider) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const entry = snapshotEntries.find((e) => e.provider === agent.provider);
|
return snapshotEntries.find((e) => e.provider === agent.provider) ?? null;
|
||||||
return entry?.models ?? null;
|
|
||||||
}, [snapshotEntries, agent?.provider]);
|
}, [snapshotEntries, agent?.provider]);
|
||||||
|
|
||||||
const models = snapshotModels;
|
const models = snapshotSelectedEntry?.models ?? null;
|
||||||
|
const selectedProviderIsLoading = snapshotSelectedEntry?.status === "loading";
|
||||||
|
|
||||||
const agentProviderDefinitions = useMemo(() => {
|
const agentProviderDefinitions = useMemo(() => {
|
||||||
const definition = agent?.provider
|
const definition = agent?.provider
|
||||||
@@ -892,11 +899,11 @@ export const AgentStatusBar = memo(function AgentStatusBar({
|
|||||||
|
|
||||||
const agentProviderModels = useMemo(() => {
|
const agentProviderModels = useMemo(() => {
|
||||||
const map = new Map<string, AgentModelDefinition[]>();
|
const map = new Map<string, AgentModelDefinition[]>();
|
||||||
if (agent?.provider && snapshotModels) {
|
if (agent?.provider && models) {
|
||||||
map.set(agent.provider, snapshotModels);
|
map.set(agent.provider, models);
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}, [agent?.provider, snapshotModels]);
|
}, [agent?.provider, models]);
|
||||||
|
|
||||||
const displayMode =
|
const displayMode =
|
||||||
availableModes.find((mode) => mode.id === agent?.currentModeId)?.label ||
|
availableModes.find((mode) => mode.id === agent?.currentModeId)?.label ||
|
||||||
@@ -1040,8 +1047,8 @@ export const AgentStatusBar = memo(function AgentStatusBar({
|
|||||||
toast.error(toErrorMessage(error));
|
toast.error(toErrorMessage(error));
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
isModelLoading={snapshotIsLoading}
|
isModelLoading={snapshotIsLoading || selectedProviderIsLoading}
|
||||||
onModelSelectorOpen={refetchSnapshotIfStale}
|
onModelSelectorOpen={() => refetchSnapshotIfStale(agent?.provider)}
|
||||||
onDropdownClose={onDropdownClose}
|
onDropdownClose={onDropdownClose}
|
||||||
disabled={!client}
|
disabled={!client}
|
||||||
/>
|
/>
|
||||||
@@ -1097,6 +1104,7 @@ export function DraftAgentStatusBar({
|
|||||||
const effectiveSelectedMode = selectedMode || mappedModeOptions[0]?.id || "";
|
const effectiveSelectedMode = selectedMode || mappedModeOptions[0]?.id || "";
|
||||||
const effectiveSelectedThinkingOption =
|
const effectiveSelectedThinkingOption =
|
||||||
selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined;
|
selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined;
|
||||||
|
const hasSelectedProvider = selectedProvider !== null;
|
||||||
|
|
||||||
if (platformIsWeb) {
|
if (platformIsWeb) {
|
||||||
return (
|
return (
|
||||||
@@ -1104,7 +1112,7 @@ export function DraftAgentStatusBar({
|
|||||||
<CombinedModelSelector
|
<CombinedModelSelector
|
||||||
providerDefinitions={providerDefinitions}
|
providerDefinitions={providerDefinitions}
|
||||||
allProviderModels={allProviderModels}
|
allProviderModels={allProviderModels}
|
||||||
selectedProvider={selectedProvider}
|
selectedProvider={selectedProvider ?? ""}
|
||||||
selectedModel={selectedModel}
|
selectedModel={selectedModel}
|
||||||
onSelect={onSelectProviderAndModel}
|
onSelect={onSelectProviderAndModel}
|
||||||
favoriteKeys={favoriteKeys}
|
favoriteKeys={favoriteKeys}
|
||||||
@@ -1120,20 +1128,22 @@ export function DraftAgentStatusBar({
|
|||||||
onOpen={onModelSelectorOpen}
|
onOpen={onModelSelectorOpen}
|
||||||
onClose={onDropdownClose}
|
onClose={onDropdownClose}
|
||||||
/>
|
/>
|
||||||
<ControlledStatusBar
|
{selectedProvider ? (
|
||||||
provider={selectedProvider}
|
<ControlledStatusBar
|
||||||
providerDefinitions={providerDefinitions}
|
provider={selectedProvider}
|
||||||
modeOptions={mappedModeOptions}
|
providerDefinitions={providerDefinitions}
|
||||||
selectedModeId={effectiveSelectedMode}
|
modeOptions={mappedModeOptions}
|
||||||
onSelectMode={onSelectMode}
|
selectedModeId={effectiveSelectedMode}
|
||||||
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
|
onSelectMode={onSelectMode}
|
||||||
selectedThinkingOptionId={effectiveSelectedThinkingOption}
|
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
|
||||||
onSelectThinkingOption={onSelectThinkingOption}
|
selectedThinkingOptionId={effectiveSelectedThinkingOption}
|
||||||
features={features}
|
onSelectThinkingOption={onSelectThinkingOption}
|
||||||
onSetFeature={onSetFeature}
|
features={features}
|
||||||
onDropdownClose={onDropdownClose}
|
onSetFeature={onSetFeature}
|
||||||
disabled={disabled}
|
onDropdownClose={onDropdownClose}
|
||||||
/>
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1146,10 +1156,10 @@ export function DraftAgentStatusBar({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ControlledStatusBar
|
<ControlledStatusBar
|
||||||
provider={selectedProvider}
|
provider={selectedProvider ?? ""}
|
||||||
providerDefinitions={providerDefinitions}
|
providerDefinitions={providerDefinitions}
|
||||||
allProviderModels={allProviderModels}
|
allProviderModels={allProviderModels}
|
||||||
modeOptions={mappedModeOptions}
|
modeOptions={hasSelectedProvider ? mappedModeOptions : undefined}
|
||||||
selectedModeId={effectiveSelectedMode}
|
selectedModeId={effectiveSelectedMode}
|
||||||
onSelectMode={onSelectMode}
|
onSelectMode={onSelectMode}
|
||||||
modelOptions={modelOptions}
|
modelOptions={modelOptions}
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState<Set<string>>(
|
const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState<Set<string>>(
|
||||||
new Set(),
|
new Set(),
|
||||||
);
|
);
|
||||||
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
|
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
|
||||||
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
||||||
|
|
||||||
// Get serverId (fallback to agent's serverId if not provided)
|
// Get serverId (fallback to agent's serverId if not provided)
|
||||||
@@ -199,17 +199,21 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
setCurrentPath: false,
|
setCurrentPath: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
setExplorerTabForCheckout({
|
const checkout = {
|
||||||
serverId: resolvedServerId,
|
serverId: resolvedServerId,
|
||||||
cwd: agent.cwd,
|
cwd: agent.cwd,
|
||||||
isGit: agent.projectPlacement?.checkout?.isGit ?? true,
|
isGit: agent.projectPlacement?.checkout?.isGit ?? true,
|
||||||
tab: "files",
|
};
|
||||||
|
setExplorerTabForCheckout({ ...checkout, tab: "files" });
|
||||||
|
openFileExplorerForCheckout({
|
||||||
|
isCompact: isMobile,
|
||||||
|
checkout,
|
||||||
});
|
});
|
||||||
openFileExplorer();
|
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
agent.cwd,
|
agent.cwd,
|
||||||
openFileExplorer,
|
isMobile,
|
||||||
|
openFileExplorerForCheckout,
|
||||||
requestDirectoryListing,
|
requestDirectoryListing,
|
||||||
resolvedServerId,
|
resolvedServerId,
|
||||||
router,
|
router,
|
||||||
|
|||||||
@@ -568,10 +568,14 @@ export function CombinedModelSelector({
|
|||||||
[onSelect],
|
[onSelect],
|
||||||
);
|
);
|
||||||
|
|
||||||
const ProviderIcon = getProviderIcon(selectedProvider);
|
const hasSelectedProvider = selectedProvider.trim().length > 0;
|
||||||
|
const ProviderIcon = hasSelectedProvider ? getProviderIcon(selectedProvider) : null;
|
||||||
|
|
||||||
const selectedModelLabel = useMemo(() => {
|
const selectedModelLabel = useMemo(() => {
|
||||||
if (!selectedModel) {
|
if (!selectedModel) {
|
||||||
|
if (!hasSelectedProvider) {
|
||||||
|
return "Select model";
|
||||||
|
}
|
||||||
return isLoading ? "Loading..." : "Select model";
|
return isLoading ? "Loading..." : "Select model";
|
||||||
}
|
}
|
||||||
const models = allProviderModels.get(selectedProvider);
|
const models = allProviderModels.get(selectedProvider);
|
||||||
@@ -580,7 +584,7 @@ export function CombinedModelSelector({
|
|||||||
}
|
}
|
||||||
const model = models.find((entry) => entry.id === selectedModel);
|
const model = models.find((entry) => entry.id === selectedModel);
|
||||||
return model?.label ?? resolveDefaultModelLabel(models);
|
return model?.label ?? resolveDefaultModelLabel(models);
|
||||||
}, [allProviderModels, isLoading, selectedModel, selectedProvider]);
|
}, [allProviderModels, hasSelectedProvider, isLoading, selectedModel, selectedProvider]);
|
||||||
|
|
||||||
const desktopFixedHeight = useMemo(() => {
|
const desktopFixedHeight = useMemo(() => {
|
||||||
if (view.kind !== "provider") {
|
if (view.kind !== "provider") {
|
||||||
@@ -643,7 +647,9 @@ export function CombinedModelSelector({
|
|||||||
})
|
})
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
{ProviderIcon ? (
|
||||||
|
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
|
) : null}
|
||||||
<Text style={styles.triggerText} numberOfLines={1} ellipsizeMode="tail">
|
<Text style={styles.triggerText} numberOfLines={1} ellipsizeMode="tail">
|
||||||
{triggerLabel}
|
{triggerLabel}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -657,7 +663,6 @@ export function CombinedModelSelector({
|
|||||||
onSelect={() => {}}
|
onSelect={() => {}}
|
||||||
open={isOpen}
|
open={isOpen}
|
||||||
onOpenChange={handleOpenChange}
|
onOpenChange={handleOpenChange}
|
||||||
stackBehavior="push"
|
|
||||||
anchorRef={anchorRef}
|
anchorRef={anchorRef}
|
||||||
desktopPlacement="top-start"
|
desktopPlacement="top-start"
|
||||||
desktopMinWidth={360}
|
desktopMinWidth={360}
|
||||||
|
|||||||
@@ -20,10 +20,12 @@ const {
|
|||||||
deleteAttachmentsMock,
|
deleteAttachmentsMock,
|
||||||
encodeImagesMock,
|
encodeImagesMock,
|
||||||
openExternalUrlMock,
|
openExternalUrlMock,
|
||||||
|
markScrollInvestigationRenderMock,
|
||||||
mockSessionState,
|
mockSessionState,
|
||||||
setAgentStreamTailMock,
|
setAgentStreamTailMock,
|
||||||
setAgentStreamHeadMock,
|
setAgentStreamHeadMock,
|
||||||
setQueuedMessagesMock,
|
setQueuedMessagesMock,
|
||||||
|
agentDirectoryStatusMock,
|
||||||
} = vi.hoisted(() => {
|
} = vi.hoisted(() => {
|
||||||
const theme = {
|
const theme = {
|
||||||
spacing: { 1: 4, 2: 8, 3: 12, 4: 16, 6: 24, 8: 32 },
|
spacing: { 1: 4, 2: 8, 3: 12, 4: 16, 6: 24, 8: 32 },
|
||||||
@@ -102,6 +104,17 @@ const {
|
|||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
agents: Map<string, { status: string; lastUsage: null }>;
|
agents: Map<string, { status: string; lastUsage: null }>;
|
||||||
|
serverInfo: {
|
||||||
|
serverId: string;
|
||||||
|
hostname: string | null;
|
||||||
|
version: string | null;
|
||||||
|
capabilities?: {
|
||||||
|
voice?: {
|
||||||
|
dictation: { enabled: boolean; reason: string };
|
||||||
|
voice: { enabled: boolean; reason: string };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
} | null;
|
||||||
queuedMessages: Map<string, unknown[]>;
|
queuedMessages: Map<string, unknown[]>;
|
||||||
agentStreamHead: Map<string, unknown[]>;
|
agentStreamHead: Map<string, unknown[]>;
|
||||||
agentStreamTail: Map<string, unknown[]>;
|
agentStreamTail: Map<string, unknown[]>;
|
||||||
@@ -114,6 +127,17 @@ const {
|
|||||||
sessions: {
|
sessions: {
|
||||||
server: {
|
server: {
|
||||||
agents: new Map([["agent", { status: "idle", lastUsage: null }]]),
|
agents: new Map([["agent", { status: "idle", lastUsage: null }]]),
|
||||||
|
serverInfo: {
|
||||||
|
serverId: "server",
|
||||||
|
hostname: "test",
|
||||||
|
version: "0.0.0",
|
||||||
|
capabilities: {
|
||||||
|
voice: {
|
||||||
|
dictation: { enabled: true, reason: "" },
|
||||||
|
voice: { enabled: true, reason: "" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
queuedMessages: new Map(),
|
queuedMessages: new Map(),
|
||||||
agentStreamHead: new Map(),
|
agentStreamHead: new Map(),
|
||||||
agentStreamTail: new Map(),
|
agentStreamTail: new Map(),
|
||||||
@@ -135,6 +159,8 @@ const {
|
|||||||
);
|
);
|
||||||
mockSessionState.setAgentStreamTail = setAgentStreamTailMock;
|
mockSessionState.setAgentStreamTail = setAgentStreamTailMock;
|
||||||
mockSessionState.setAgentStreamHead = setAgentStreamHeadMock;
|
mockSessionState.setAgentStreamHead = setAgentStreamHeadMock;
|
||||||
|
const markScrollInvestigationRenderMock = vi.fn();
|
||||||
|
const agentDirectoryStatusMock = vi.fn(() => "ready");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
theme,
|
theme,
|
||||||
@@ -147,10 +173,12 @@ const {
|
|||||||
deleteAttachmentsMock: vi.fn(async () => {}),
|
deleteAttachmentsMock: vi.fn(async () => {}),
|
||||||
encodeImagesMock: vi.fn(async (images: AttachmentMetadata[]) => images),
|
encodeImagesMock: vi.fn(async (images: AttachmentMetadata[]) => images),
|
||||||
openExternalUrlMock: vi.fn(async () => {}),
|
openExternalUrlMock: vi.fn(async () => {}),
|
||||||
|
markScrollInvestigationRenderMock,
|
||||||
mockSessionState,
|
mockSessionState,
|
||||||
setAgentStreamTailMock,
|
setAgentStreamTailMock,
|
||||||
setAgentStreamHeadMock,
|
setAgentStreamHeadMock,
|
||||||
setQueuedMessagesMock,
|
setQueuedMessagesMock,
|
||||||
|
agentDirectoryStatusMock,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -217,7 +245,7 @@ vi.mock("react-native-safe-area-context", () => ({
|
|||||||
vi.mock("@/runtime/host-runtime", () => ({
|
vi.mock("@/runtime/host-runtime", () => ({
|
||||||
useHostRuntimeClient: () => mockClient,
|
useHostRuntimeClient: () => mockClient,
|
||||||
useHostRuntimeIsConnected: () => true,
|
useHostRuntimeIsConnected: () => true,
|
||||||
useHostRuntimeAgentDirectoryStatus: () => "ready",
|
useHostRuntimeAgentDirectoryStatus: () => agentDirectoryStatusMock(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/stores/session-store", () => {
|
vi.mock("@/stores/session-store", () => {
|
||||||
@@ -313,7 +341,7 @@ vi.mock("@/contexts/toast-context", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/utils/scroll-jank-investigation", () => ({
|
vi.mock("@/utils/scroll-jank-investigation", () => ({
|
||||||
markScrollInvestigationRender: vi.fn(),
|
markScrollInvestigationRender: markScrollInvestigationRenderMock,
|
||||||
markScrollInvestigationEvent: vi.fn(),
|
markScrollInvestigationEvent: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -360,6 +388,20 @@ vi.mock("@/hooks/use-dictation", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/utils/server-info-capabilities", () => ({
|
vi.mock("@/utils/server-info-capabilities", () => ({
|
||||||
|
getVoiceReadinessState: ({
|
||||||
|
serverInfo,
|
||||||
|
mode,
|
||||||
|
}: {
|
||||||
|
serverInfo: {
|
||||||
|
capabilities?: {
|
||||||
|
voice?: {
|
||||||
|
dictation?: { enabled: boolean; reason: string };
|
||||||
|
voice?: { enabled: boolean; reason: string };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
} | null;
|
||||||
|
mode: "dictation" | "voice";
|
||||||
|
}) => serverInfo?.capabilities?.voice?.[mode] ?? null,
|
||||||
resolveVoiceUnavailableMessage: () => null,
|
resolveVoiceUnavailableMessage: () => null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -547,9 +589,23 @@ beforeEach(() => {
|
|||||||
deleteAttachmentsMock.mockClear();
|
deleteAttachmentsMock.mockClear();
|
||||||
encodeImagesMock.mockClear();
|
encodeImagesMock.mockClear();
|
||||||
openExternalUrlMock.mockClear();
|
openExternalUrlMock.mockClear();
|
||||||
|
markScrollInvestigationRenderMock.mockClear();
|
||||||
setAgentStreamTailMock.mockClear();
|
setAgentStreamTailMock.mockClear();
|
||||||
setAgentStreamHeadMock.mockClear();
|
setAgentStreamHeadMock.mockClear();
|
||||||
setQueuedMessagesMock.mockClear();
|
setQueuedMessagesMock.mockClear();
|
||||||
|
agentDirectoryStatusMock.mockReset();
|
||||||
|
agentDirectoryStatusMock.mockReturnValue("ready");
|
||||||
|
mockSessionState.sessions.server.serverInfo = {
|
||||||
|
serverId: "server",
|
||||||
|
hostname: "test",
|
||||||
|
version: "0.0.0",
|
||||||
|
capabilities: {
|
||||||
|
voice: {
|
||||||
|
dictation: { enabled: true, reason: "" },
|
||||||
|
voice: { enabled: true, reason: "" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
mockSessionState.sessions.server.agentStreamHead = new Map();
|
mockSessionState.sessions.server.agentStreamHead = new Map();
|
||||||
mockSessionState.sessions.server.agentStreamTail = new Map();
|
mockSessionState.sessions.server.agentStreamTail = new Map();
|
||||||
mockSessionState.sessions.server.queuedMessages = new Map();
|
mockSessionState.sessions.server.queuedMessages = new Map();
|
||||||
@@ -663,6 +719,12 @@ function queryAllAttachmentMenuItems(): NodeListOf<HTMLElement> {
|
|||||||
return document.querySelectorAll('[data-testid^="message-input-attachment-menu-item-"]');
|
return document.querySelectorAll('[data-testid^="message-input-attachment-menu-item-"]');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function countMessageInputRenders(): number {
|
||||||
|
return markScrollInvestigationRenderMock.mock.calls.filter(
|
||||||
|
([componentId]) => componentId === "MessageInput:server:agent",
|
||||||
|
).length;
|
||||||
|
}
|
||||||
|
|
||||||
describe("Composer attachments", () => {
|
describe("Composer attachments", () => {
|
||||||
it("opens a Plus menu with image and GitHub attachment actions", () => {
|
it("opens a Plus menu with image and GitHub attachment actions", () => {
|
||||||
renderComposer();
|
renderComposer();
|
||||||
@@ -847,6 +909,38 @@ describe("Composer attachments", () => {
|
|||||||
expect(latestAttachments).toEqual([{ kind: "image", metadata: image }]);
|
expect(latestAttachments).toEqual([{ kind: "image", metadata: image }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not re-render MessageInput when opening the attachment lightbox", () => {
|
||||||
|
const image = imageAttachment("img-lightbox-render");
|
||||||
|
renderComposer({ initialAttachments: [{ kind: "image", metadata: image }] });
|
||||||
|
const renderCountBeforeLightbox = countMessageInputRenders();
|
||||||
|
|
||||||
|
click(queryByTestId("composer-image-attachment-pill")!);
|
||||||
|
|
||||||
|
expect(queryByTestId("attachment-lightbox-image")).not.toBeNull();
|
||||||
|
expect(countMessageInputRenders()).toBe(renderCountBeforeLightbox);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still re-renders MessageInput when submit loading semantics change", () => {
|
||||||
|
renderComposer({ initialText: "pending submit", isSubmitLoading: false });
|
||||||
|
const renderCountBeforeLoading = countMessageInputRenders();
|
||||||
|
|
||||||
|
renderComposer({ initialText: "pending submit", isSubmitLoading: true });
|
||||||
|
|
||||||
|
expect(countMessageInputRenders()).toBe(renderCountBeforeLoading + 1);
|
||||||
|
expect(document.querySelector('[aria-label="Send message"]')).toHaveProperty("disabled", true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enables dictation from server capabilities before the agent directory finishes loading", () => {
|
||||||
|
agentDirectoryStatusMock.mockReturnValue("initial_loading");
|
||||||
|
|
||||||
|
renderComposer();
|
||||||
|
|
||||||
|
expect(document.querySelector('[aria-label="Start dictation"]')).toHaveProperty(
|
||||||
|
"disabled",
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("locks the preserved draft while submit loading", () => {
|
it("locks the preserved draft while submit loading", () => {
|
||||||
renderComposer({
|
renderComposer({
|
||||||
initialText: "keep this prompt",
|
initialText: "keep this prompt",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { View, Pressable, Text, ActivityIndicator, Image } from "react-native";
|
import { View, Pressable, Text, ActivityIndicator, Image } from "react-native";
|
||||||
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
import { useState, useEffect, useRef, useCallback, useMemo, memo } from "react";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { useShallow } from "zustand/shallow";
|
import { useShallow } from "zustand/shallow";
|
||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
Paperclip,
|
Paperclip,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import Animated from "react-native-reanimated";
|
import Animated from "react-native-reanimated";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
|
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||||
import { generateMessageId, type StreamItem } from "@/types/stream";
|
import { generateMessageId, type StreamItem } from "@/types/stream";
|
||||||
@@ -70,6 +69,7 @@ import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachm
|
|||||||
import { AttachmentPill } from "@/components/attachment-pill";
|
import { AttachmentPill } from "@/components/attachment-pill";
|
||||||
import { AttachmentLightbox } from "@/components/attachment-lightbox";
|
import { AttachmentLightbox } from "@/components/attachment-lightbox";
|
||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
|
import { useIsDictationReady } from "@/hooks/use-is-dictation-ready";
|
||||||
|
|
||||||
type QueuedMessage = {
|
type QueuedMessage = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -134,6 +134,7 @@ interface ComposerProps {
|
|||||||
const EMPTY_ARRAY: readonly QueuedMessage[] = [];
|
const EMPTY_ARRAY: readonly QueuedMessage[] = [];
|
||||||
const DESKTOP_MESSAGE_PLACEHOLDER = "Message the agent, tag @files, or use /commands and /skills";
|
const DESKTOP_MESSAGE_PLACEHOLDER = "Message the agent, tag @files, or use /commands and /skills";
|
||||||
const MOBILE_MESSAGE_PLACEHOLDER = "Message, @files, /commands";
|
const MOBILE_MESSAGE_PLACEHOLDER = "Message, @files, /commands";
|
||||||
|
const StableMessageInput = memo(MessageInput);
|
||||||
|
|
||||||
export function Composer({
|
export function Composer({
|
||||||
agentId,
|
agentId,
|
||||||
@@ -167,19 +168,20 @@ export function Composer({
|
|||||||
markScrollInvestigationRender(`Composer:${serverId}:${agentId}`);
|
markScrollInvestigationRender(`Composer:${serverId}:${agentId}`);
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const buttonIconSize = isWeb ? theme.iconSize.md : theme.iconSize.lg;
|
const buttonIconSize = isWeb ? theme.iconSize.md : theme.iconSize.lg;
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const client = useHostRuntimeClient(serverId);
|
const client = useHostRuntimeClient(serverId);
|
||||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||||
const agentDirectoryStatus = useHostRuntimeAgentDirectoryStatus(serverId);
|
const agentDirectoryStatus = useHostRuntimeAgentDirectoryStatus(serverId);
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
const toastErrorRef = useRef(toast.error);
|
||||||
|
toastErrorRef.current = toast.error;
|
||||||
const voice = useVoiceOptional();
|
const voice = useVoiceOptional();
|
||||||
const voiceToggleKeys = useShortcutKeys("voice-toggle");
|
const voiceToggleKeys = useShortcutKeys("voice-toggle");
|
||||||
const dictationCancelKeys = useShortcutKeys("dictation-cancel");
|
const dictationCancelKeys = useShortcutKeys("dictation-cancel");
|
||||||
const isDictationReady =
|
const isDictationReady = useIsDictationReady({
|
||||||
isConnected &&
|
serverId,
|
||||||
(agentDirectoryStatus === "ready" ||
|
isConnected,
|
||||||
agentDirectoryStatus === "revalidating" ||
|
agentDirectoryStatus,
|
||||||
agentDirectoryStatus === "error_after_ready");
|
});
|
||||||
|
|
||||||
const { settings: appSettings } = useAppSettings();
|
const { settings: appSettings } = useAppSettings();
|
||||||
|
|
||||||
@@ -238,6 +240,8 @@ export function Composer({
|
|||||||
messageInputRef.current?.focus();
|
messageInputRef.current?.focus();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const autocompleteOnKeyPressRef = useRef(autocomplete.onKeyPress);
|
||||||
|
autocompleteOnKeyPressRef.current = autocomplete.onKeyPress;
|
||||||
|
|
||||||
// Clear send error when user edits the input
|
// Clear send error when user edits the input
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -375,67 +379,82 @@ export function Composer({
|
|||||||
[agentId, serverId, setQueuedMessages],
|
[agentId, serverId, setQueuedMessages],
|
||||||
);
|
);
|
||||||
|
|
||||||
function queueMessage(message: string, attachments: ComposerAttachment[]) {
|
const queueMessage = useCallback(
|
||||||
const trimmedMessage = message.trim();
|
(message: string, attachments: ComposerAttachment[]) => {
|
||||||
if (!trimmedMessage && attachments.length === 0) return;
|
const trimmedMessage = message.trim();
|
||||||
|
if (!trimmedMessage && attachments.length === 0) return;
|
||||||
|
|
||||||
const newItem = {
|
const newItem = {
|
||||||
id: generateMessageId(),
|
id: generateMessageId(),
|
||||||
text: trimmedMessage,
|
text: trimmedMessage,
|
||||||
attachments,
|
attachments,
|
||||||
};
|
};
|
||||||
|
|
||||||
setQueuedMessages(serverId, (prev: Map<string, QueuedMessage[]>) => {
|
setQueuedMessages(serverId, (prev: Map<string, QueuedMessage[]>) => {
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
next.set(agentId, [...(prev.get(agentId) ?? []), newItem]);
|
next.set(agentId, [...(prev.get(agentId) ?? []), newItem]);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|
||||||
setUserInput("");
|
setUserInput("");
|
||||||
setSelectedAttachments([]);
|
setSelectedAttachments([]);
|
||||||
}
|
},
|
||||||
|
[agentId, serverId, setQueuedMessages, setSelectedAttachments, setUserInput],
|
||||||
|
);
|
||||||
|
|
||||||
async function sendMessageWithContent(
|
const sendMessageWithContent = useCallback(
|
||||||
message: string,
|
async (message: string, attachments: ComposerAttachment[], forceSend?: boolean) => {
|
||||||
attachments: ComposerAttachment[],
|
await submitAgentInput({
|
||||||
forceSend?: boolean,
|
message,
|
||||||
) {
|
attachments,
|
||||||
await submitAgentInput({
|
hasExternalContent,
|
||||||
message,
|
allowEmptySubmit,
|
||||||
attachments,
|
forceSend,
|
||||||
hasExternalContent,
|
submitBehavior,
|
||||||
|
isAgentRunning,
|
||||||
|
// Parent-managed submits are still valid submit paths even when the
|
||||||
|
// transport is disconnected, because the parent decides the failure mode.
|
||||||
|
canSubmit: Boolean(sendAgentMessageRef.current || onSubmitMessageRef.current),
|
||||||
|
queueMessage: ({ message, attachments }) => {
|
||||||
|
queueMessage(message, attachments);
|
||||||
|
},
|
||||||
|
submitMessage: async ({ message, attachments }) => {
|
||||||
|
await submitMessage(message, attachments);
|
||||||
|
},
|
||||||
|
clearDraft,
|
||||||
|
setUserInput,
|
||||||
|
setAttachments: (nextAttachments) => {
|
||||||
|
setSelectedAttachments(nextAttachments);
|
||||||
|
},
|
||||||
|
setSendError,
|
||||||
|
setIsProcessing,
|
||||||
|
onSubmitError: (error) => {
|
||||||
|
console.error("[AgentInput] Failed to send message:", error);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[
|
||||||
allowEmptySubmit,
|
allowEmptySubmit,
|
||||||
forceSend,
|
|
||||||
submitBehavior,
|
|
||||||
isAgentRunning: agentState.status === "running",
|
|
||||||
// Parent-managed submits are still valid submit paths even when the
|
|
||||||
// transport is disconnected, because the parent decides the failure mode.
|
|
||||||
canSubmit: Boolean(sendAgentMessageRef.current || onSubmitMessageRef.current),
|
|
||||||
queueMessage: ({ message, attachments }) => {
|
|
||||||
queueMessage(message, attachments);
|
|
||||||
},
|
|
||||||
submitMessage: async ({ message, attachments }) => {
|
|
||||||
await submitMessage(message, attachments);
|
|
||||||
},
|
|
||||||
clearDraft,
|
clearDraft,
|
||||||
|
hasExternalContent,
|
||||||
|
isAgentRunning,
|
||||||
|
queueMessage,
|
||||||
|
setSelectedAttachments,
|
||||||
setUserInput,
|
setUserInput,
|
||||||
setAttachments: (nextAttachments) => {
|
submitBehavior,
|
||||||
setSelectedAttachments(nextAttachments);
|
submitMessage,
|
||||||
},
|
],
|
||||||
setSendError,
|
);
|
||||||
setIsProcessing,
|
|
||||||
onSubmitError: (error) => {
|
|
||||||
console.error("[AgentInput] Failed to send message:", error);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSubmit(payload: MessagePayload) {
|
const handleSubmit = useCallback(
|
||||||
if (blurOnSubmit) {
|
(payload: MessagePayload) => {
|
||||||
messageInputRef.current?.blur();
|
if (blurOnSubmit) {
|
||||||
}
|
messageInputRef.current?.blur();
|
||||||
void sendMessageWithContent(payload.text, payload.attachments, payload.forceSend);
|
}
|
||||||
}
|
void sendMessageWithContent(payload.text, payload.attachments, payload.forceSend);
|
||||||
|
},
|
||||||
|
[blurOnSubmit, sendMessageWithContent],
|
||||||
|
);
|
||||||
|
|
||||||
const handlePickImage = useCallback(async () => {
|
const handlePickImage = useCallback(async () => {
|
||||||
const result = await pickImages();
|
const result = await pickImages();
|
||||||
@@ -487,6 +506,18 @@ export function Composer({
|
|||||||
}
|
}
|
||||||
}, [isAgentRunning, isConnected]);
|
}, [isAgentRunning, isConnected]);
|
||||||
|
|
||||||
|
const handleCancelAgent = useCallback(() => {
|
||||||
|
if (!isAgentRunning || isCancellingAgent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isConnected || !client) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsCancellingAgent(true);
|
||||||
|
void client.cancelAgent(agentIdRef.current);
|
||||||
|
messageInputRef.current?.focus();
|
||||||
|
}, [client, isAgentRunning, isCancellingAgent, isConnected]);
|
||||||
|
|
||||||
const handleKeyboardAction = useCallback(
|
const handleKeyboardAction = useCallback(
|
||||||
(action: KeyboardActionDefinition): boolean => {
|
(action: KeyboardActionDefinition): boolean => {
|
||||||
if (!isPaneFocused) {
|
if (!isPaneFocused) {
|
||||||
@@ -553,18 +584,6 @@ export function Composer({
|
|||||||
mode: "translate",
|
mode: "translate",
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleCancelAgent() {
|
|
||||||
if (!isAgentRunning || isCancellingAgent) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!isConnected || !client) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsCancellingAgent(true);
|
|
||||||
void client.cancelAgent(agentIdRef.current);
|
|
||||||
messageInputRef.current?.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
const isVoiceModeForAgent = voice?.isVoiceModeForAgent(serverId, agentId) ?? false;
|
const isVoiceModeForAgent = voice?.isVoiceModeForAgent(serverId, agentId) ?? false;
|
||||||
|
|
||||||
const handleToggleRealtimeVoice = useCallback(() => {
|
const handleToggleRealtimeVoice = useCallback(() => {
|
||||||
@@ -582,10 +601,10 @@ export function Composer({
|
|||||||
const message =
|
const message =
|
||||||
error instanceof Error ? error.message : typeof error === "string" ? error : null;
|
error instanceof Error ? error.message : typeof error === "string" ? error : null;
|
||||||
if (message && message.trim().length > 0) {
|
if (message && message.trim().length > 0) {
|
||||||
toast.error(message);
|
toastErrorRef.current(message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [agentId, hasAgent, isConnected, serverId, toast, voice]);
|
}, [agentId, hasAgent, isConnected, serverId, voice]);
|
||||||
|
|
||||||
function handleEditQueuedMessage(id: string) {
|
function handleEditQueuedMessage(id: string) {
|
||||||
const item = queuedMessages.find((q) => q.id === id);
|
const item = queuedMessages.find((q) => q.id === id);
|
||||||
@@ -612,9 +631,12 @@ export function Composer({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleQueue = useCallback((payload: MessagePayload) => {
|
const handleQueue = useCallback(
|
||||||
queueMessage(payload.text, payload.attachments);
|
(payload: MessagePayload) => {
|
||||||
}, []);
|
queueMessage(payload.text, payload.attachments);
|
||||||
|
},
|
||||||
|
[queueMessage],
|
||||||
|
);
|
||||||
|
|
||||||
const hasSendableContent = userInput.trim().length > 0 || selectedAttachments.length > 0;
|
const hasSendableContent = userInput.trim().length > 0 || selectedAttachments.length > 0;
|
||||||
|
|
||||||
@@ -633,91 +655,109 @@ export function Composer({
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return autocomplete.onKeyPress(event);
|
return autocompleteOnKeyPressRef.current(event);
|
||||||
},
|
},
|
||||||
|
[hasSendableContent, isAgentRunning, isCancellingAgent, isConnected, handleCancelAgent],
|
||||||
|
);
|
||||||
|
|
||||||
|
const cancelButton = useMemo(
|
||||||
|
() =>
|
||||||
|
isAgentRunning && !hasSendableContent && !isProcessing ? (
|
||||||
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||||
|
<TooltipTrigger
|
||||||
|
onPress={handleCancelAgent}
|
||||||
|
disabled={!isConnected || isCancellingAgent}
|
||||||
|
accessibilityLabel={isCancellingAgent ? "Canceling agent" : "Stop agent"}
|
||||||
|
accessibilityRole="button"
|
||||||
|
style={[
|
||||||
|
styles.cancelButton as any,
|
||||||
|
(!isConnected || isCancellingAgent ? styles.buttonDisabled : undefined) as any,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{isCancellingAgent ? (
|
||||||
|
<ActivityIndicator size="small" color="white" />
|
||||||
|
) : (
|
||||||
|
<Square size={buttonIconSize} color="white" fill="white" />
|
||||||
|
)}
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" align="center" offset={8}>
|
||||||
|
<View style={styles.tooltipRow}>
|
||||||
|
<Text style={styles.tooltipText}>Interrupt</Text>
|
||||||
|
{dictationCancelKeys ? (
|
||||||
|
<Shortcut chord={dictationCancelKeys} style={styles.tooltipShortcut} />
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
) : null,
|
||||||
[
|
[
|
||||||
autocomplete,
|
buttonIconSize,
|
||||||
|
dictationCancelKeys,
|
||||||
|
handleCancelAgent,
|
||||||
hasSendableContent,
|
hasSendableContent,
|
||||||
isAgentRunning,
|
isAgentRunning,
|
||||||
isCancellingAgent,
|
isCancellingAgent,
|
||||||
isConnected,
|
isConnected,
|
||||||
handleCancelAgent,
|
isProcessing,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const cancelButton =
|
|
||||||
isAgentRunning && !hasSendableContent && !isProcessing ? (
|
|
||||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
|
||||||
<TooltipTrigger
|
|
||||||
onPress={handleCancelAgent}
|
|
||||||
disabled={!isConnected || isCancellingAgent}
|
|
||||||
accessibilityLabel={isCancellingAgent ? "Canceling agent" : "Stop agent"}
|
|
||||||
accessibilityRole="button"
|
|
||||||
style={[
|
|
||||||
styles.cancelButton as any,
|
|
||||||
(!isConnected || isCancellingAgent ? styles.buttonDisabled : undefined) as any,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{isCancellingAgent ? (
|
|
||||||
<ActivityIndicator size="small" color="white" />
|
|
||||||
) : (
|
|
||||||
<Square size={buttonIconSize} color="white" fill="white" />
|
|
||||||
)}
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top" align="center" offset={8}>
|
|
||||||
<View style={styles.tooltipRow}>
|
|
||||||
<Text style={styles.tooltipText}>Interrupt</Text>
|
|
||||||
{dictationCancelKeys ? (
|
|
||||||
<Shortcut chord={dictationCancelKeys} style={styles.tooltipShortcut} />
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
) : null;
|
|
||||||
|
|
||||||
const showVoiceModeButton = !isVoiceModeForAgent && hasAgent;
|
const showVoiceModeButton = !isVoiceModeForAgent && hasAgent;
|
||||||
const rightContent =
|
const rightContent = useMemo(
|
||||||
showVoiceModeButton || cancelButton ? (
|
() =>
|
||||||
<View style={styles.rightControls}>
|
showVoiceModeButton || cancelButton ? (
|
||||||
{showVoiceModeButton ? (
|
<View style={styles.rightControls}>
|
||||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
{showVoiceModeButton ? (
|
||||||
<TooltipTrigger
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||||
onPress={handleToggleRealtimeVoice}
|
<TooltipTrigger
|
||||||
disabled={!isConnected || voice?.isVoiceSwitching}
|
onPress={handleToggleRealtimeVoice}
|
||||||
accessibilityLabel="Enable Voice mode"
|
disabled={!isConnected || voice?.isVoiceSwitching}
|
||||||
accessibilityRole="button"
|
accessibilityLabel="Enable Voice mode"
|
||||||
style={({ hovered }) => [
|
accessibilityRole="button"
|
||||||
styles.realtimeVoiceButton as any,
|
style={({ hovered }) => [
|
||||||
(hovered ? styles.iconButtonHovered : undefined) as any,
|
styles.realtimeVoiceButton as any,
|
||||||
(!isConnected || voice?.isVoiceSwitching
|
(hovered ? styles.iconButtonHovered : undefined) as any,
|
||||||
? styles.buttonDisabled
|
(!isConnected || voice?.isVoiceSwitching
|
||||||
: undefined) as any,
|
? styles.buttonDisabled
|
||||||
]}
|
: undefined) as any,
|
||||||
>
|
]}
|
||||||
{({ hovered }) =>
|
>
|
||||||
voice?.isVoiceSwitching ? (
|
{({ hovered }) =>
|
||||||
<ActivityIndicator size="small" color="white" />
|
voice?.isVoiceSwitching ? (
|
||||||
) : (
|
<ActivityIndicator size="small" color="white" />
|
||||||
<AudioLines
|
) : (
|
||||||
size={buttonIconSize}
|
<AudioLines
|
||||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
size={buttonIconSize}
|
||||||
/>
|
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||||
)
|
/>
|
||||||
}
|
)
|
||||||
</TooltipTrigger>
|
}
|
||||||
<TooltipContent side="top" align="center" offset={8}>
|
</TooltipTrigger>
|
||||||
<View style={styles.tooltipRow}>
|
<TooltipContent side="top" align="center" offset={8}>
|
||||||
<Text style={styles.tooltipText}>Voice mode</Text>
|
<View style={styles.tooltipRow}>
|
||||||
{voiceToggleKeys ? (
|
<Text style={styles.tooltipText}>Voice mode</Text>
|
||||||
<Shortcut chord={voiceToggleKeys} style={styles.tooltipShortcut} />
|
{voiceToggleKeys ? (
|
||||||
) : null}
|
<Shortcut chord={voiceToggleKeys} style={styles.tooltipShortcut} />
|
||||||
</View>
|
) : null}
|
||||||
</TooltipContent>
|
</View>
|
||||||
</Tooltip>
|
</TooltipContent>
|
||||||
) : null}
|
</Tooltip>
|
||||||
{cancelButton}
|
) : null}
|
||||||
</View>
|
{cancelButton}
|
||||||
) : null;
|
</View>
|
||||||
|
) : null,
|
||||||
|
[
|
||||||
|
buttonIconSize,
|
||||||
|
cancelButton,
|
||||||
|
handleToggleRealtimeVoice,
|
||||||
|
isConnected,
|
||||||
|
showVoiceModeButton,
|
||||||
|
theme.colors.foreground,
|
||||||
|
theme.colors.foregroundMuted,
|
||||||
|
voice,
|
||||||
|
voiceToggleKeys,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
const hasContextWindowMeter =
|
const hasContextWindowMeter =
|
||||||
typeof agentState.contextWindowMaxTokens === "number" &&
|
typeof agentState.contextWindowMaxTokens === "number" &&
|
||||||
@@ -725,15 +765,18 @@ export function Composer({
|
|||||||
const contextWindowMaxTokens = hasContextWindowMeter ? agentState.contextWindowMaxTokens : null;
|
const contextWindowMaxTokens = hasContextWindowMeter ? agentState.contextWindowMaxTokens : null;
|
||||||
const contextWindowUsedTokens = hasContextWindowMeter ? agentState.contextWindowUsedTokens : null;
|
const contextWindowUsedTokens = hasContextWindowMeter ? agentState.contextWindowUsedTokens : null;
|
||||||
|
|
||||||
const beforeVoiceContent = (
|
const beforeVoiceContent = useMemo(
|
||||||
<View style={styles.contextWindowMeterSlot}>
|
() => (
|
||||||
{contextWindowMaxTokens !== null && contextWindowUsedTokens !== null ? (
|
<View style={styles.contextWindowMeterSlot}>
|
||||||
<ContextWindowMeter
|
{contextWindowMaxTokens !== null && contextWindowUsedTokens !== null ? (
|
||||||
maxTokens={contextWindowMaxTokens}
|
<ContextWindowMeter
|
||||||
usedTokens={contextWindowUsedTokens}
|
maxTokens={contextWindowMaxTokens}
|
||||||
/>
|
usedTokens={contextWindowUsedTokens}
|
||||||
) : null}
|
/>
|
||||||
</View>
|
) : null}
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
[contextWindowMaxTokens, contextWindowUsedTokens],
|
||||||
);
|
);
|
||||||
|
|
||||||
const githubSearchQueryTrimmed = githubSearchQuery.trim();
|
const githubSearchQueryTrimmed = githubSearchQuery.trim();
|
||||||
@@ -808,17 +851,36 @@ export function Composer({
|
|||||||
[setSelectedAttachments, setGithubSearchQuery, setIsGithubPickerOpen],
|
[setSelectedAttachments, setGithubSearchQuery, setIsGithubPickerOpen],
|
||||||
);
|
);
|
||||||
|
|
||||||
const leftContent =
|
const leftContent = useMemo(
|
||||||
resolveStatusControlMode(statusControls) === "draft" && statusControls ? (
|
() =>
|
||||||
<DraftAgentStatusBar {...statusControls} />
|
resolveStatusControlMode(statusControls) === "draft" && statusControls ? (
|
||||||
) : (
|
<DraftAgentStatusBar {...statusControls} />
|
||||||
<AgentStatusBar agentId={agentId} serverId={serverId} onDropdownClose={focusInput} />
|
) : (
|
||||||
);
|
<AgentStatusBar agentId={agentId} serverId={serverId} onDropdownClose={focusInput} />
|
||||||
|
),
|
||||||
|
[agentId, focusInput, serverId, statusControls],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAttachButtonRef = useCallback((node: View | null) => {
|
||||||
|
attachButtonRef.current = node;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSelectionChange = useCallback((selection: { start: number; end: number }) => {
|
||||||
|
setCursorIndex(selection.start);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFocusChange = useCallback(
|
||||||
|
(focused: boolean) => {
|
||||||
|
setIsMessageInputFocused(focused);
|
||||||
|
if (focused) {
|
||||||
|
onAttentionInputFocus?.();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onAttentionInputFocus],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View
|
<Animated.View style={[styles.container, keyboardAnimatedStyle]}>
|
||||||
style={[styles.container, { paddingBottom: insets.bottom }, keyboardAnimatedStyle]}
|
|
||||||
>
|
|
||||||
<AttachmentLightbox metadata={lightboxMetadata} onClose={() => setLightboxMetadata(null)} />
|
<AttachmentLightbox metadata={lightboxMetadata} onClose={() => setLightboxMetadata(null)} />
|
||||||
{/* Input area */}
|
{/* Input area */}
|
||||||
<View style={[styles.inputAreaContainer, isComposerLocked && styles.inputAreaLocked]}>
|
<View style={[styles.inputAreaContainer, isComposerLocked && styles.inputAreaLocked]}>
|
||||||
@@ -924,7 +986,7 @@ export function Composer({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
|
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
|
||||||
<MessageInput
|
<StableMessageInput
|
||||||
ref={messageInputRef}
|
ref={messageInputRef}
|
||||||
value={userInput}
|
value={userInput}
|
||||||
onChangeText={setUserInput}
|
onChangeText={setUserInput}
|
||||||
@@ -938,9 +1000,7 @@ export function Composer({
|
|||||||
attachments={selectedAttachments}
|
attachments={selectedAttachments}
|
||||||
cwd={cwd}
|
cwd={cwd}
|
||||||
attachmentMenuItems={attachmentMenuItems}
|
attachmentMenuItems={attachmentMenuItems}
|
||||||
onAttachButtonRef={(node) => {
|
onAttachButtonRef={handleAttachButtonRef}
|
||||||
attachButtonRef.current = node;
|
|
||||||
}}
|
|
||||||
onAddImages={addImages}
|
onAddImages={addImages}
|
||||||
client={client}
|
client={client}
|
||||||
isReadyForDictation={isDictationReady}
|
isReadyForDictation={isDictationReady}
|
||||||
@@ -959,15 +1019,8 @@ export function Composer({
|
|||||||
onQueue={handleQueue}
|
onQueue={handleQueue}
|
||||||
onSubmitLoadingPress={isAgentRunning ? handleCancelAgent : undefined}
|
onSubmitLoadingPress={isAgentRunning ? handleCancelAgent : undefined}
|
||||||
onKeyPress={handleCommandKeyPress}
|
onKeyPress={handleCommandKeyPress}
|
||||||
onSelectionChange={(selection) => {
|
onSelectionChange={handleSelectionChange}
|
||||||
setCursorIndex(selection.start);
|
onFocusChange={handleFocusChange}
|
||||||
}}
|
|
||||||
onFocusChange={(focused) => {
|
|
||||||
setIsMessageInputFocused(focused);
|
|
||||||
if (focused) {
|
|
||||||
onAttentionInputFocus?.();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onHeightChange={onComposerHeightChange}
|
onHeightChange={onComposerHeightChange}
|
||||||
inputWrapperStyle={inputWrapperStyle}
|
inputWrapperStyle={inputWrapperStyle}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ export function DraggableList<T>({
|
|||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, {
|
useSensor(PointerSensor, {
|
||||||
activationConstraint: {
|
activationConstraint: {
|
||||||
distance: 8,
|
distance: 6,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
useSensor(KeyboardSensor, {
|
useSensor(KeyboardSensor, {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { PrPane } from "./pr-pane";
|
|||||||
import { usePrPaneData } from "@/hooks/use-pr-pane-data";
|
import { usePrPaneData } from "@/hooks/use-pr-pane-data";
|
||||||
import {
|
import {
|
||||||
usePanelStore,
|
usePanelStore,
|
||||||
|
selectIsFileExplorerOpen,
|
||||||
MIN_EXPLORER_SIDEBAR_WIDTH,
|
MIN_EXPLORER_SIDEBAR_WIDTH,
|
||||||
MAX_EXPLORER_SIDEBAR_WIDTH,
|
MAX_EXPLORER_SIDEBAR_WIDTH,
|
||||||
type ExplorerTab,
|
type ExplorerTab,
|
||||||
@@ -52,9 +53,9 @@ export function ExplorerSidebar({
|
|||||||
const isScreenFocused = useIsFocused();
|
const isScreenFocused = useIsFocused();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const isOpen = usePanelStore((state) => selectIsFileExplorerOpen(state, { isCompact: isMobile }));
|
||||||
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||||
const closeToAgent = usePanelStore((state) => state.closeToAgent);
|
const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer);
|
||||||
const explorerTab = usePanelStore((state) => state.explorerTab);
|
const explorerTab = usePanelStore((state) => state.explorerTab);
|
||||||
const explorerWidth = usePanelStore((state) => state.explorerWidth);
|
const explorerWidth = usePanelStore((state) => state.explorerWidth);
|
||||||
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
||||||
@@ -81,9 +82,6 @@ export function ExplorerSidebar({
|
|||||||
}
|
}
|
||||||
}, [explorerWidth, isMobile, setExplorerWidth, viewportWidth]);
|
}, [explorerWidth, isMobile, setExplorerWidth, viewportWidth]);
|
||||||
|
|
||||||
// Derive isOpen from the unified panel state
|
|
||||||
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
translateX,
|
translateX,
|
||||||
backdropOpacity,
|
backdropOpacity,
|
||||||
@@ -104,18 +102,20 @@ export function ExplorerSidebar({
|
|||||||
logExplorerSidebar("handleClose", {
|
logExplorerSidebar("handleClose", {
|
||||||
reason,
|
reason,
|
||||||
isOpen,
|
isOpen,
|
||||||
mobileView,
|
|
||||||
desktopFileExplorerOpen,
|
|
||||||
});
|
});
|
||||||
closeToAgent();
|
if (isMobile) {
|
||||||
|
showMobileAgent();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closeDesktopFileExplorer();
|
||||||
},
|
},
|
||||||
[closeToAgent, desktopFileExplorerOpen, isOpen, mobileView],
|
[closeDesktopFileExplorer, isMobile, isOpen, showMobileAgent],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCloseFromGesture = useCallback(() => {
|
const handleCloseFromGesture = useCallback(() => {
|
||||||
gestureAnimatingRef.current = true;
|
gestureAnimatingRef.current = true;
|
||||||
closeToAgent();
|
showMobileAgent();
|
||||||
}, [closeToAgent, gestureAnimatingRef]);
|
}, [gestureAnimatingRef, showMobileAgent]);
|
||||||
|
|
||||||
const enableSidebarCloseGesture = isMobile && isOpen;
|
const enableSidebarCloseGesture = isMobile && isOpen;
|
||||||
|
|
||||||
@@ -292,6 +292,7 @@ export function ExplorerSidebar({
|
|||||||
workspaceRoot={workspaceRoot}
|
workspaceRoot={workspaceRoot}
|
||||||
isGit={isGit}
|
isGit={isGit}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
|
isOpen={isOpen}
|
||||||
onOpenFile={onOpenFile}
|
onOpenFile={onOpenFile}
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
@@ -324,6 +325,7 @@ export function ExplorerSidebar({
|
|||||||
workspaceRoot={workspaceRoot}
|
workspaceRoot={workspaceRoot}
|
||||||
isGit={isGit}
|
isGit={isGit}
|
||||||
isMobile={false}
|
isMobile={false}
|
||||||
|
isOpen={isOpen}
|
||||||
onOpenFile={onOpenFile}
|
onOpenFile={onOpenFile}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -340,6 +342,7 @@ interface SidebarContentProps {
|
|||||||
workspaceRoot: string;
|
workspaceRoot: string;
|
||||||
isGit: boolean;
|
isGit: boolean;
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
|
isOpen: boolean;
|
||||||
onOpenFile?: (filePath: string) => void;
|
onOpenFile?: (filePath: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,6 +355,7 @@ function SidebarContent({
|
|||||||
workspaceRoot,
|
workspaceRoot,
|
||||||
isGit,
|
isGit,
|
||||||
isMobile,
|
isMobile,
|
||||||
|
isOpen,
|
||||||
onOpenFile,
|
onOpenFile,
|
||||||
}: SidebarContentProps) {
|
}: SidebarContentProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
@@ -360,8 +364,8 @@ function SidebarContent({
|
|||||||
const prPane = usePrPaneData({
|
const prPane = usePrPaneData({
|
||||||
serverId,
|
serverId,
|
||||||
cwd: workspaceRoot,
|
cwd: workspaceRoot,
|
||||||
enabled: canQueryPullRequest,
|
enabled: canQueryPullRequest && isOpen,
|
||||||
timelineEnabled: activeTab === "pr" && canQueryPullRequest,
|
timelineEnabled: activeTab === "pr" && canQueryPullRequest && isOpen,
|
||||||
});
|
});
|
||||||
const hasPullRequest = prPane.prNumber !== null;
|
const hasPullRequest = prPane.prNumber !== null;
|
||||||
const requestedTab: ExplorerTab =
|
const requestedTab: ExplorerTab =
|
||||||
|
|||||||
@@ -8,18 +8,8 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Gesture } from "react-native-gesture-handler";
|
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import Animated, {
|
|
||||||
cancelAnimation,
|
|
||||||
Easing,
|
|
||||||
runOnJS,
|
|
||||||
useAnimatedStyle,
|
|
||||||
useSharedValue,
|
|
||||||
withRepeat,
|
|
||||||
withTiming,
|
|
||||||
} from "react-native-reanimated";
|
|
||||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||||
import { Fonts } from "@/constants/theme";
|
import { Fonts } from "@/constants/theme";
|
||||||
import * as Clipboard from "expo-clipboard";
|
import * as Clipboard from "expo-clipboard";
|
||||||
@@ -34,6 +24,7 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { getFileIconSvg } from "@/components/material-file-icons";
|
import { getFileIconSvg } from "@/components/material-file-icons";
|
||||||
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||||
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
|
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
|
||||||
import { useHosts } from "@/runtime/host-runtime";
|
import { useHosts } from "@/runtime/host-runtime";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
@@ -312,48 +303,6 @@ export function FileExplorerPane({
|
|||||||
const handleRefresh = useCallback(() => {
|
const handleRefresh = useCallback(() => {
|
||||||
void refetchExplorer();
|
void refetchExplorer();
|
||||||
}, [refetchExplorer]);
|
}, [refetchExplorer]);
|
||||||
const refreshIconRotation = useSharedValue(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isRefreshFetching) {
|
|
||||||
refreshIconRotation.value = 0;
|
|
||||||
refreshIconRotation.value = withRepeat(
|
|
||||||
withTiming(360, {
|
|
||||||
duration: 700,
|
|
||||||
easing: Easing.linear,
|
|
||||||
}),
|
|
||||||
-1,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
cancelAnimation(refreshIconRotation);
|
|
||||||
const remainder = refreshIconRotation.value % 360;
|
|
||||||
if (Math.abs(remainder) < 0.001) {
|
|
||||||
refreshIconRotation.value = 0;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const remaining = 360 - remainder;
|
|
||||||
const duration = Math.max(80, Math.round((remaining / 360) * 700));
|
|
||||||
refreshIconRotation.value = withTiming(
|
|
||||||
360,
|
|
||||||
{
|
|
||||||
duration,
|
|
||||||
easing: Easing.linear,
|
|
||||||
},
|
|
||||||
(finished) => {
|
|
||||||
if (finished) {
|
|
||||||
refreshIconRotation.value = 0;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}, [isRefreshFetching, refreshIconRotation]);
|
|
||||||
|
|
||||||
const refreshIconAnimatedStyle = useAnimatedStyle(() => ({
|
|
||||||
transform: [{ rotate: `${refreshIconRotation.value}deg` }],
|
|
||||||
}));
|
|
||||||
|
|
||||||
const currentSortLabel = SORT_OPTIONS.find((opt) => opt.value === sortOption)?.label ?? "Name";
|
const currentSortLabel = SORT_OPTIONS.find((opt) => opt.value === sortOption)?.label ?? "Name";
|
||||||
|
|
||||||
@@ -563,11 +512,15 @@ export function FileExplorerPane({
|
|||||||
(hovered || pressed) && styles.iconButtonHovered,
|
(hovered || pressed) && styles.iconButtonHovered,
|
||||||
]}
|
]}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel="Refresh files"
|
accessibilityLabel={isRefreshFetching ? "Refreshing files" : "Refresh files"}
|
||||||
>
|
>
|
||||||
<Animated.View style={[styles.refreshIcon, refreshIconAnimatedStyle]}>
|
<View style={styles.refreshIcon}>
|
||||||
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
{isRefreshFetching ? (
|
||||||
</Animated.View>
|
<LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
|
) : (
|
||||||
|
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
<FlatList
|
<FlatList
|
||||||
|
|||||||
@@ -681,8 +681,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
files,
|
files,
|
||||||
payloadError: diffPayloadError,
|
payloadError: diffPayloadError,
|
||||||
isLoading: isDiffLoading,
|
isLoading: isDiffLoading,
|
||||||
isError: isDiffError,
|
|
||||||
error: diffError,
|
|
||||||
} = useCheckoutDiffQuery({
|
} = useCheckoutDiffQuery({
|
||||||
serverId,
|
serverId,
|
||||||
cwd,
|
cwd,
|
||||||
@@ -1071,9 +1069,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
);
|
);
|
||||||
|
|
||||||
const hasChanges = files.length > 0;
|
const hasChanges = files.length > 0;
|
||||||
const diffErrorMessage =
|
const diffErrorMessage = diffPayloadError?.message ?? null;
|
||||||
diffPayloadError?.message ??
|
|
||||||
(isDiffError && diffError instanceof Error ? diffError.message : null);
|
|
||||||
const prErrorMessage = githubFeaturesEnabled ? (prPayloadError?.message ?? null) : null;
|
const prErrorMessage = githubFeaturesEnabled ? (prPayloadError?.message ?? null) : null;
|
||||||
const branchLabel =
|
const branchLabel =
|
||||||
gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD"
|
gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { PanelLeft } from "lucide-react-native";
|
|||||||
import { ScreenHeader } from "./screen-header";
|
import { ScreenHeader } from "./screen-header";
|
||||||
import { ScreenTitle } from "./screen-title";
|
import { ScreenTitle } from "./screen-title";
|
||||||
import { HeaderToggleButton } from "./header-toggle-button";
|
import { HeaderToggleButton } from "./header-toggle-button";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||||
|
|
||||||
@@ -46,18 +46,16 @@ export function SidebarMenuToggle({
|
|||||||
}: SidebarMenuToggleProps = {}) {
|
}: SidebarMenuToggleProps = {}) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const isOpen = usePanelStore((state) => selectIsAgentListOpen(state, { isCompact: isMobile }));
|
||||||
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
const toggleAgentListForLayout = usePanelStore((state) => state.toggleAgentListForLayout);
|
||||||
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
|
|
||||||
const toggleShortcutKeys = getShortcutOs() === "mac" ? ["mod", "B"] : ["mod", "."];
|
const toggleShortcutKeys = getShortcutOs() === "mac" ? ["mod", "B"] : ["mod", "."];
|
||||||
|
|
||||||
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
|
|
||||||
const menuIconColor =
|
const menuIconColor =
|
||||||
!isMobile && isOpen ? theme.colors.foreground : theme.colors.foregroundMuted;
|
!isMobile && isOpen ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HeaderToggleButton
|
<HeaderToggleButton
|
||||||
onPress={toggleAgentList}
|
onPress={() => toggleAgentListForLayout({ isCompact: isMobile })}
|
||||||
tooltipLabel="Toggle sidebar"
|
tooltipLabel="Toggle sidebar"
|
||||||
tooltipKeys={toggleShortcutKeys}
|
tooltipKeys={toggleShortcutKeys}
|
||||||
tooltipSide={tooltipSide}
|
tooltipSide={tooltipSide}
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*/
|
||||||
|
import React from "react";
|
||||||
|
import { act } from "@testing-library/react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const { panelState, useSidebarWorkspacesListMock, theme } = vi.hoisted(() => {
|
||||||
|
const panelState = {
|
||||||
|
isOpen: false,
|
||||||
|
showMobileAgent: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
panelState,
|
||||||
|
useSidebarWorkspacesListMock: vi.fn(),
|
||||||
|
theme: {
|
||||||
|
spacing: { 0: 0, 0.5: 2, 1: 4, 1.5: 6, 2: 8, 3: 12, 4: 16, 5: 20 },
|
||||||
|
iconSize: { sm: 14, md: 18, lg: 22 },
|
||||||
|
borderWidth: { 1: 1 },
|
||||||
|
borderRadius: { sm: 4, md: 6, lg: 8, full: 999 },
|
||||||
|
fontSize: { xs: 11, sm: 13, base: 15 },
|
||||||
|
fontWeight: { normal: "400", medium: "500", semibold: "600" },
|
||||||
|
colors: {
|
||||||
|
surfaceSidebar: "#111",
|
||||||
|
surface1: "#111",
|
||||||
|
surface2: "#222",
|
||||||
|
surface3: "#333",
|
||||||
|
surface4: "#444",
|
||||||
|
foreground: "#fff",
|
||||||
|
foregroundMuted: "#aaa",
|
||||||
|
border: "#555",
|
||||||
|
borderAccent: "#666",
|
||||||
|
accent: "#0a84ff",
|
||||||
|
accentForeground: "#fff",
|
||||||
|
palette: {
|
||||||
|
green: { 400: "#30d158" },
|
||||||
|
amber: { 500: "#ffd60a" },
|
||||||
|
red: { 500: "#ff453a" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("react-native-unistyles", () => ({
|
||||||
|
StyleSheet: {
|
||||||
|
absoluteFillObject: {},
|
||||||
|
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
|
||||||
|
},
|
||||||
|
useUnistyles: () => ({ theme }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-native-reanimated", () => ({
|
||||||
|
default: {
|
||||||
|
View: "div",
|
||||||
|
},
|
||||||
|
Extrapolation: { CLAMP: "clamp" },
|
||||||
|
interpolate: () => 0,
|
||||||
|
runOnJS: (fn: (...args: unknown[]) => unknown) => fn,
|
||||||
|
useAnimatedStyle: (factory: () => unknown) => factory(),
|
||||||
|
useSharedValue: (value: unknown) => ({ value }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-native-gesture-handler", () => {
|
||||||
|
const chain = {
|
||||||
|
enabled: () => chain,
|
||||||
|
hitSlop: () => chain,
|
||||||
|
manualActivation: () => chain,
|
||||||
|
onTouchesDown: () => chain,
|
||||||
|
onTouchesMove: () => chain,
|
||||||
|
onStart: () => chain,
|
||||||
|
onUpdate: () => chain,
|
||||||
|
onEnd: () => chain,
|
||||||
|
onFinalize: () => chain,
|
||||||
|
withRef: () => chain,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
Gesture: { Pan: () => chain },
|
||||||
|
GestureDetector: ({ children }: { children: React.ReactNode }) => children,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("lucide-react-native", () => {
|
||||||
|
const createIcon = (name: string) => (props: Record<string, unknown>) =>
|
||||||
|
React.createElement("span", { ...props, "data-icon": name });
|
||||||
|
return {
|
||||||
|
MessagesSquare: createIcon("MessagesSquare"),
|
||||||
|
Plus: createIcon("Plus"),
|
||||||
|
Settings: createIcon("Settings"),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("expo-router", () => ({
|
||||||
|
router: { push: vi.fn() },
|
||||||
|
usePathname: () => "/hosts/srv",
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-native-safe-area-context", () => ({
|
||||||
|
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/constants/layout", () => ({
|
||||||
|
useIsCompactFormFactor: () => true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/constants/platform", () => ({
|
||||||
|
isWeb: true,
|
||||||
|
isNative: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/stores/panel-store", () => ({
|
||||||
|
MIN_SIDEBAR_WIDTH: 260,
|
||||||
|
MAX_SIDEBAR_WIDTH: 420,
|
||||||
|
selectIsAgentListOpen: (state: typeof panelState) => state.isOpen,
|
||||||
|
usePanelStore: (selector: (state: typeof panelState) => unknown) => selector(panelState),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/runtime/host-runtime", () => ({
|
||||||
|
useHosts: () => [{ serverId: "srv", label: "Local" }],
|
||||||
|
useHostRuntimeSnapshot: () => ({ connectionStatus: "online" }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-sidebar-workspaces-list", () => ({
|
||||||
|
useSidebarWorkspacesList: useSidebarWorkspacesListMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-sidebar-shortcut-model", () => ({
|
||||||
|
useSidebarShortcutModel: () => ({
|
||||||
|
collapsedProjectKeys: new Set<string>(),
|
||||||
|
shortcutIndexByWorkspaceKey: new Map<string, number>(),
|
||||||
|
toggleProjectCollapsed: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/contexts/sidebar-animation-context", () => ({
|
||||||
|
useSidebarAnimation: () => ({
|
||||||
|
translateX: { value: 0 },
|
||||||
|
backdropOpacity: { value: 0 },
|
||||||
|
windowWidth: 390,
|
||||||
|
animateToOpen: vi.fn(),
|
||||||
|
animateToClose: vi.fn(),
|
||||||
|
isGesturing: { value: false },
|
||||||
|
gestureAnimatingRef: { current: false },
|
||||||
|
closeGestureRef: { current: undefined },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-shortcut-keys", () => ({
|
||||||
|
useShortcutKeys: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/utils/desktop-window", () => ({
|
||||||
|
useWindowControlsPadding: () => ({ top: 0 }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/utils/host-routes", () => ({
|
||||||
|
buildHostSessionsRoute: (serverId: string) => `/hosts/${serverId}/sessions`,
|
||||||
|
buildSettingsRoute: () => "/settings",
|
||||||
|
mapPathnameToServer: (_pathname: string, serverId: string) => `/hosts/${serverId}`,
|
||||||
|
parseServerIdFromPathname: () => "srv",
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-open-project-picker", () => ({
|
||||||
|
useOpenProjectPicker: () => vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/sidebar/sidebar-header-row", () => ({
|
||||||
|
SidebarHeaderRow: ({ label }: { label: string }) => React.createElement("div", null, label),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./sidebar-workspace-list", () => ({
|
||||||
|
SidebarWorkspaceList: ({ projects }: { projects: Array<{ projectName: string }> }) =>
|
||||||
|
React.createElement(
|
||||||
|
"div",
|
||||||
|
{ "data-testid": "sidebar-workspace-list" },
|
||||||
|
projects.map((project) => project.projectName).join(","),
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./sidebar-agent-list-skeleton", () => ({
|
||||||
|
SidebarAgentListSkeleton: () => React.createElement("div", null, "Loading"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/ui/tooltip", () => ({
|
||||||
|
Tooltip: ({ children }: { children: React.ReactNode }) =>
|
||||||
|
React.createElement("div", null, children),
|
||||||
|
TooltipContent: ({ children }: { children: React.ReactNode }) =>
|
||||||
|
React.createElement("div", null, children),
|
||||||
|
TooltipTrigger: ({ children }: { children: React.ReactNode }) =>
|
||||||
|
React.createElement("div", null, children),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/ui/shortcut", () => ({
|
||||||
|
Shortcut: () => React.createElement("span", null),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/ui/combobox", () => ({
|
||||||
|
Combobox: () => null,
|
||||||
|
ComboboxItem: ({ label }: { label: string }) => React.createElement("div", null, label),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.stubGlobal("React", React);
|
||||||
|
|
||||||
|
import { LeftSidebar } from "./left-sidebar";
|
||||||
|
|
||||||
|
describe("LeftSidebar", () => {
|
||||||
|
let root: Root | null = null;
|
||||||
|
let container: HTMLElement | null = null;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
panelState.isOpen = false;
|
||||||
|
panelState.showMobileAgent.mockReset();
|
||||||
|
useSidebarWorkspacesListMock.mockReset();
|
||||||
|
useSidebarWorkspacesListMock.mockReturnValue({
|
||||||
|
projects: [{ projectKey: "project-1", projectName: "Project 1", workspaces: [] }],
|
||||||
|
isInitialLoad: false,
|
||||||
|
isRevalidating: false,
|
||||||
|
refreshAll: vi.fn(),
|
||||||
|
});
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (root) {
|
||||||
|
act(() => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
root = null;
|
||||||
|
container?.remove();
|
||||||
|
container = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the mobile workspace list subscribed while the sidebar is hidden", async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(<LeftSidebar />);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(useSidebarWorkspacesListMock).toHaveBeenLastCalledWith({
|
||||||
|
serverId: "srv",
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -32,7 +32,12 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
|
|||||||
import { Shortcut } from "@/components/ui/shortcut";
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||||
import { router, usePathname } from "expo-router";
|
import { router, usePathname } from "expo-router";
|
||||||
import { usePanelStore, MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH } from "@/stores/panel-store";
|
import {
|
||||||
|
usePanelStore,
|
||||||
|
selectIsAgentListOpen,
|
||||||
|
MIN_SIDEBAR_WIDTH,
|
||||||
|
MAX_SIDEBAR_WIDTH,
|
||||||
|
} from "@/stores/panel-store";
|
||||||
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
|
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
|
||||||
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
|
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
|
||||||
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
||||||
@@ -52,10 +57,10 @@ import {
|
|||||||
buildHostSessionsRoute,
|
buildHostSessionsRoute,
|
||||||
buildSettingsRoute,
|
buildSettingsRoute,
|
||||||
mapPathnameToServer,
|
mapPathnameToServer,
|
||||||
parseServerIdFromPathname,
|
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
|
import { resolveActiveHost } from "@/utils/active-host";
|
||||||
|
|
||||||
const MIN_CHAT_WIDTH = 400;
|
const MIN_CHAT_WIDTH = 400;
|
||||||
|
|
||||||
@@ -116,24 +121,16 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const isCompactLayout = useIsCompactFormFactor();
|
const isCompactLayout = useIsCompactFormFactor();
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const isOpen = usePanelStore((state) =>
|
||||||
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
selectIsAgentListOpen(state, { isCompact: isCompactLayout }),
|
||||||
const closeToAgent = usePanelStore((state) => state.closeToAgent);
|
);
|
||||||
|
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
const activeServerIdFromPath = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
|
const activeDaemon = useMemo(
|
||||||
const activeDaemon = useMemo(() => {
|
() => resolveActiveHost({ hosts: daemons, pathname }),
|
||||||
if (daemons.length === 0) {
|
[daemons, pathname],
|
||||||
return null;
|
);
|
||||||
}
|
|
||||||
if (activeServerIdFromPath) {
|
|
||||||
const routeMatch = daemons.find((entry) => entry.serverId === activeServerIdFromPath);
|
|
||||||
if (routeMatch) {
|
|
||||||
return routeMatch;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return daemons[0] ?? null;
|
|
||||||
}, [activeServerIdFromPath, daemons]);
|
|
||||||
const activeServerId = activeDaemon?.serverId ?? null;
|
const activeServerId = activeDaemon?.serverId ?? null;
|
||||||
const activeHostLabel = useMemo(() => {
|
const activeHostLabel = useMemo(() => {
|
||||||
if (!activeDaemon) return "No host";
|
if (!activeDaemon) return "No host";
|
||||||
@@ -183,14 +180,12 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|||||||
const hostTriggerRef = useRef<View | null>(null);
|
const hostTriggerRef = useRef<View | null>(null);
|
||||||
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false);
|
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false);
|
||||||
|
|
||||||
const isOpen = isCompactLayout ? mobileView === "agent-list" : desktopAgentListOpen;
|
|
||||||
|
|
||||||
const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarWorkspacesList({
|
const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarWorkspacesList({
|
||||||
serverId: activeServerId,
|
serverId: activeServerId,
|
||||||
enabled: isOpen,
|
enabled: isCompactLayout || isOpen,
|
||||||
});
|
});
|
||||||
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } =
|
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } =
|
||||||
useSidebarShortcutModel(projects);
|
useSidebarShortcutModel({ projects, isInitialLoad });
|
||||||
|
|
||||||
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
||||||
|
|
||||||
@@ -208,18 +203,18 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|||||||
const openProjectPicker = useOpenProjectPicker(activeServerId);
|
const openProjectPicker = useOpenProjectPicker(activeServerId);
|
||||||
|
|
||||||
const handleOpenProjectMobile = useCallback(() => {
|
const handleOpenProjectMobile = useCallback(() => {
|
||||||
closeToAgent();
|
showMobileAgent();
|
||||||
void openProjectPicker();
|
void openProjectPicker();
|
||||||
}, [closeToAgent, openProjectPicker]);
|
}, [showMobileAgent, openProjectPicker]);
|
||||||
|
|
||||||
const handleOpenProjectDesktop = useCallback(() => {
|
const handleOpenProjectDesktop = useCallback(() => {
|
||||||
void openProjectPicker();
|
void openProjectPicker();
|
||||||
}, [openProjectPicker]);
|
}, [openProjectPicker]);
|
||||||
|
|
||||||
const handleSettingsMobile = useCallback(() => {
|
const handleSettingsMobile = useCallback(() => {
|
||||||
closeToAgent();
|
showMobileAgent();
|
||||||
router.push(buildSettingsRoute());
|
router.push(buildSettingsRoute());
|
||||||
}, [closeToAgent]);
|
}, [showMobileAgent]);
|
||||||
|
|
||||||
const handleSettingsDesktop = useCallback(() => {
|
const handleSettingsDesktop = useCallback(() => {
|
||||||
router.push(buildSettingsRoute());
|
router.push(buildSettingsRoute());
|
||||||
@@ -272,7 +267,7 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|||||||
insetsTop={insets.top}
|
insetsTop={insets.top}
|
||||||
insetsBottom={insets.bottom}
|
insetsBottom={insets.bottom}
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
closeToAgent={closeToAgent}
|
closeToAgent={showMobileAgent}
|
||||||
handleOpenProject={handleOpenProjectMobile}
|
handleOpenProject={handleOpenProjectMobile}
|
||||||
handleSettings={handleSettingsMobile}
|
handleSettings={handleSettingsMobile}
|
||||||
handleViewMoreNavigate={handleViewMoreNavigate}
|
handleViewMoreNavigate={handleViewMoreNavigate}
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import React from "react";
|
import React, { createRef } from "react";
|
||||||
import { act } from "react";
|
import { act } from "react";
|
||||||
import { createRoot, type Root } from "react-dom/client";
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
import { JSDOM } from "jsdom";
|
import { JSDOM } from "jsdom";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { MessageInput, type AttachmentMenuItem } from "./message-input";
|
import { MessageInput, type AttachmentMenuItem, type MessageInputRef } from "./message-input";
|
||||||
|
|
||||||
|
const { startDictationMock, cancelDictationMock, confirmDictationMock } = vi.hoisted(() => ({
|
||||||
|
startDictationMock: vi.fn(),
|
||||||
|
cancelDictationMock: vi.fn(),
|
||||||
|
confirmDictationMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
const { theme } = vi.hoisted(() => ({
|
const { theme } = vi.hoisted(() => ({
|
||||||
theme: {
|
theme: {
|
||||||
@@ -85,9 +91,9 @@ vi.mock("@/hooks/use-dictation", () => ({
|
|||||||
duration: 0,
|
duration: 0,
|
||||||
error: null,
|
error: null,
|
||||||
status: "idle",
|
status: "idle",
|
||||||
startDictation: vi.fn(),
|
startDictation: startDictationMock,
|
||||||
cancelDictation: vi.fn(),
|
cancelDictation: cancelDictationMock,
|
||||||
confirmDictation: vi.fn(),
|
confirmDictation: confirmDictationMock,
|
||||||
retryFailedDictation: vi.fn(),
|
retryFailedDictation: vi.fn(),
|
||||||
discardFailedDictation: vi.fn(),
|
discardFailedDictation: vi.fn(),
|
||||||
}),
|
}),
|
||||||
@@ -214,6 +220,9 @@ beforeEach(() => {
|
|||||||
container = document.createElement("div");
|
container = document.createElement("div");
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
root = createRoot(container);
|
root = createRoot(container);
|
||||||
|
startDictationMock.mockReset();
|
||||||
|
cancelDictationMock.mockReset();
|
||||||
|
confirmDictationMock.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -325,3 +334,57 @@ describe("MessageInput attachments", () => {
|
|||||||
expect(document.querySelectorAll('[data-icon="CornerDownLeft"]')).toHaveLength(1);
|
expect(document.querySelectorAll('[data-icon="CornerDownLeft"]')).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("MessageInput dictation shortcuts", () => {
|
||||||
|
it("does not poison the dictation toggle when readiness is temporarily false", () => {
|
||||||
|
const inputRef = createRef<MessageInputRef>();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root?.render(
|
||||||
|
<MessageInput
|
||||||
|
ref={inputRef}
|
||||||
|
value=""
|
||||||
|
onChangeText={vi.fn()}
|
||||||
|
onSubmit={vi.fn()}
|
||||||
|
attachments={[]}
|
||||||
|
cwd="/repo"
|
||||||
|
attachmentMenuItems={[]}
|
||||||
|
client={{ isConnected: true } as never}
|
||||||
|
isAgentRunning={false}
|
||||||
|
isReadyForDictation={false}
|
||||||
|
onQueue={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
inputRef.current?.runKeyboardAction("dictation-toggle");
|
||||||
|
});
|
||||||
|
expect(startDictationMock).not.toHaveBeenCalled();
|
||||||
|
expect(confirmDictationMock).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root?.render(
|
||||||
|
<MessageInput
|
||||||
|
ref={inputRef}
|
||||||
|
value=""
|
||||||
|
onChangeText={vi.fn()}
|
||||||
|
onSubmit={vi.fn()}
|
||||||
|
attachments={[]}
|
||||||
|
cwd="/repo"
|
||||||
|
attachmentMenuItems={[]}
|
||||||
|
client={{ isConnected: true } as never}
|
||||||
|
isAgentRunning={false}
|
||||||
|
isReadyForDictation
|
||||||
|
onQueue={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
inputRef.current?.runKeyboardAction("dictation-toggle");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(startDictationMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(confirmDictationMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -480,11 +480,15 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
toast.error(dictationUnavailableMessage);
|
toast.error(dictationUnavailableMessage);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!canStartDictation()) {
|
||||||
|
isDictatingRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Keep hotkey toggling deterministic between the async start call and the
|
// Keep hotkey toggling deterministic between the async start call and the
|
||||||
// state-ref sync effect, so a rapid second toggle routes to confirm.
|
// state-ref sync effect, so a rapid second toggle routes to confirm.
|
||||||
isDictatingRef.current = true;
|
isDictatingRef.current = true;
|
||||||
await startDictation();
|
await startDictation();
|
||||||
}, [dictationUnavailableMessage, startDictation, toast]);
|
}, [canStartDictation, dictationUnavailableMessage, startDictation, toast]);
|
||||||
|
|
||||||
// Animate overlay
|
// Animate overlay
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -118,6 +118,26 @@ function useDisableOuterSpacing(disableOuterSpacing: boolean | undefined) {
|
|||||||
|
|
||||||
const WEB_TOOLCALL_SHIMMER_KEYFRAME_ID = "paseo-toolcall-shimmer-keyframes";
|
const WEB_TOOLCALL_SHIMMER_KEYFRAME_ID = "paseo-toolcall-shimmer-keyframes";
|
||||||
const WEB_TOOLCALL_SHIMMER_ANIMATION_NAME = "paseo-toolcall-shimmer";
|
const WEB_TOOLCALL_SHIMMER_ANIMATION_NAME = "paseo-toolcall-shimmer";
|
||||||
|
const MARKDOWN_ALLOWED_IMAGE_HANDLERS = [
|
||||||
|
"data:image/png;base64",
|
||||||
|
"data:image/gif;base64",
|
||||||
|
"data:image/jpeg;base64",
|
||||||
|
"https://",
|
||||||
|
"http://",
|
||||||
|
] as const;
|
||||||
|
const MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM = <Text key="dotdotdot">...</Text>;
|
||||||
|
|
||||||
|
type MarkdownWithStableRendererProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
style: ReturnType<typeof createMarkdownStyles>;
|
||||||
|
rules: RenderRules;
|
||||||
|
markdownit: MarkdownIt;
|
||||||
|
onLinkPress: (url: string) => boolean;
|
||||||
|
allowedImageHandlers: readonly string[];
|
||||||
|
topLevelMaxExceededItem: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MarkdownWithStableRenderer = Markdown as ComponentType<MarkdownWithStableRendererProps>;
|
||||||
const WEB_TOOLCALL_SHIMMER_KEYFRAME_CSS = `
|
const WEB_TOOLCALL_SHIMMER_KEYFRAME_CSS = `
|
||||||
@keyframes ${WEB_TOOLCALL_SHIMMER_ANIMATION_NAME} {
|
@keyframes ${WEB_TOOLCALL_SHIMMER_ANIMATION_NAME} {
|
||||||
0% {
|
0% {
|
||||||
@@ -947,9 +967,16 @@ const MemoizedMarkdownBlock = React.memo(function MemoizedMarkdownBlock({
|
|||||||
onLinkPress,
|
onLinkPress,
|
||||||
}: MemoizedMarkdownBlockProps) {
|
}: MemoizedMarkdownBlockProps) {
|
||||||
return (
|
return (
|
||||||
<Markdown style={styles} rules={rules} markdownit={parser} onLinkPress={onLinkPress}>
|
<MarkdownWithStableRenderer
|
||||||
|
style={styles}
|
||||||
|
rules={rules}
|
||||||
|
markdownit={parser}
|
||||||
|
onLinkPress={onLinkPress}
|
||||||
|
allowedImageHandlers={MARKDOWN_ALLOWED_IMAGE_HANDLERS}
|
||||||
|
topLevelMaxExceededItem={MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM}
|
||||||
|
>
|
||||||
{text}
|
{text}
|
||||||
</Markdown>
|
</MarkdownWithStableRenderer>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,30 +3,22 @@ import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-nativ
|
|||||||
import { Folder } from "lucide-react-native";
|
import { Folder } from "lucide-react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { usePathname } from "expo-router";
|
|
||||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||||
import { shortenPath } from "@/utils/shorten-path";
|
import { shortenPath } from "@/utils/shorten-path";
|
||||||
import { useRecommendedProjectPaths } from "@/stores/session-store-hooks";
|
import { useRecommendedProjectPaths } from "@/stores/session-store-hooks";
|
||||||
import { useHosts, useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import { useOpenProject } from "@/hooks/use-open-project";
|
import { useOpenProject } from "@/hooks/use-open-project";
|
||||||
import { parseServerIdFromPathname } from "@/utils/host-routes";
|
|
||||||
import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions";
|
import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions";
|
||||||
import { isNative } from "@/constants/platform";
|
import { isNative } from "@/constants/platform";
|
||||||
|
import { useActiveServerId } from "@/hooks/use-active-server-id";
|
||||||
|
|
||||||
export function ProjectPickerModal() {
|
export function ProjectPickerModal() {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const pathname = usePathname();
|
const serverId = useActiveServerId();
|
||||||
const daemons = useHosts();
|
|
||||||
|
|
||||||
const open = useKeyboardShortcutsStore((s) => s.projectPickerOpen);
|
const open = useKeyboardShortcutsStore((s) => s.projectPickerOpen);
|
||||||
const setOpen = useKeyboardShortcutsStore((s) => s.setProjectPickerOpen);
|
const setOpen = useKeyboardShortcutsStore((s) => s.setProjectPickerOpen);
|
||||||
|
|
||||||
const serverId = useMemo(() => {
|
|
||||||
const fromPath = parseServerIdFromPathname(pathname);
|
|
||||||
if (fromPath) return fromPath;
|
|
||||||
return daemons[0]?.serverId ?? null;
|
|
||||||
}, [pathname, daemons]);
|
|
||||||
|
|
||||||
const client = useHostRuntimeClient(serverId ?? "");
|
const client = useHostRuntimeClient(serverId ?? "");
|
||||||
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
||||||
const recommendedPaths = useRecommendedProjectPaths(serverId);
|
const recommendedPaths = useRecommendedProjectPaths(serverId);
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { AlertCircle, Search } from "lucide-react-native";
|
import { AlertCircle, RotateCw, Search } from "lucide-react-native";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
|
import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
|
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
|
||||||
import { SpinningRefreshIcon } from "@/components/spinning-refresh-icon";
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
import { Fonts } from "@/constants/theme";
|
import { Fonts } from "@/constants/theme";
|
||||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||||
@@ -82,8 +82,12 @@ export function ProviderDiagnosticSheet({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleRefresh = useCallback(() => {
|
const handleRefresh = useCallback(() => {
|
||||||
void refresh([provider as AgentProvider]);
|
if (!provider) {
|
||||||
void fetchDiagnostic({ keepCurrent: true });
|
return;
|
||||||
|
}
|
||||||
|
void Promise.all([refresh([provider as AgentProvider]), fetchDiagnostic()]).catch((err) => {
|
||||||
|
setDiagnostic(err instanceof Error ? err.message : "Failed to refresh provider");
|
||||||
|
});
|
||||||
}, [fetchDiagnostic, provider, refresh]);
|
}, [fetchDiagnostic, provider, refresh]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -157,13 +161,15 @@ export function ProviderDiagnosticSheet({
|
|||||||
refreshInFlight ? sheetStyles.disabled : null,
|
refreshInFlight ? sheetStyles.disabled : null,
|
||||||
]}
|
]}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={`Refresh ${providerLabel}`}
|
accessibilityLabel={
|
||||||
|
refreshInFlight ? `Refreshing ${providerLabel}` : `Refresh ${providerLabel}`
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<SpinningRefreshIcon
|
{refreshInFlight ? (
|
||||||
spinning={refreshInFlight}
|
<LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
size={theme.iconSize.sm}
|
) : (
|
||||||
color={theme.colors.foregroundMuted}
|
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
/>
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ import type { HostProfile } from "@/types/host-connection";
|
|||||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||||
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||||
|
import {
|
||||||
|
activateNavigationWorkspaceSelection,
|
||||||
|
syncNavigationActiveWorkspace,
|
||||||
|
useIsNavigationProjectActive,
|
||||||
|
useIsNavigationWorkspaceSelected,
|
||||||
|
} from "@/stores/navigation-active-workspace-store";
|
||||||
|
|
||||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||||
default: {
|
default: {
|
||||||
@@ -43,6 +49,8 @@ type RenderCounts = {
|
|||||||
frame: number;
|
frame: number;
|
||||||
headers: Record<string, number>;
|
headers: Record<string, number>;
|
||||||
rows: Record<string, number>;
|
rows: Record<string, number>;
|
||||||
|
projectSelection: Record<string, number>;
|
||||||
|
rowSelection: Record<string, number>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const runningScript: WorkspaceScriptPayload = {
|
const runningScript: WorkspaceScriptPayload = {
|
||||||
@@ -166,6 +174,8 @@ function resetCounts(counts: RenderCounts): void {
|
|||||||
counts.frame = 0;
|
counts.frame = 0;
|
||||||
counts.headers = {};
|
counts.headers = {};
|
||||||
counts.rows = {};
|
counts.rows = {};
|
||||||
|
counts.projectSelection = {};
|
||||||
|
counts.rowSelection = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
function incrementRecord(record: Record<string, number>, key: string): void {
|
function incrementRecord(record: Record<string, number>, key: string): void {
|
||||||
@@ -201,6 +211,37 @@ function WorkspaceRowProbe({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ProjectActiveProbe({
|
||||||
|
serverId,
|
||||||
|
project,
|
||||||
|
counts,
|
||||||
|
}: {
|
||||||
|
serverId: string;
|
||||||
|
project: SidebarProjectEntry;
|
||||||
|
counts: RenderCounts;
|
||||||
|
}): null {
|
||||||
|
useIsNavigationProjectActive({
|
||||||
|
serverId,
|
||||||
|
workspaceIds: project.workspaces.map((workspace) => workspace.workspaceId),
|
||||||
|
});
|
||||||
|
incrementRecord(counts.projectSelection, project.projectKey);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkspaceSelectionProbe({
|
||||||
|
serverId,
|
||||||
|
workspaceId,
|
||||||
|
counts,
|
||||||
|
}: {
|
||||||
|
serverId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
counts: RenderCounts;
|
||||||
|
}): null {
|
||||||
|
useIsNavigationWorkspaceSelected({ serverId, workspaceId });
|
||||||
|
incrementRecord(counts.rowSelection, workspaceId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function SidebarFrameProbe({ counts }: { counts: RenderCounts }): ReactElement {
|
function SidebarFrameProbe({ counts }: { counts: RenderCounts }): ReactElement {
|
||||||
counts.frame += 1;
|
counts.frame += 1;
|
||||||
const { projects } = useSidebarWorkspacesList({ serverId: SERVER_ID });
|
const { projects } = useSidebarWorkspacesList({ serverId: SERVER_ID });
|
||||||
@@ -210,13 +251,20 @@ function SidebarFrameProbe({ counts }: { counts: RenderCounts }): ReactElement {
|
|||||||
{projects.map((project) => (
|
{projects.map((project) => (
|
||||||
<div key={project.projectKey}>
|
<div key={project.projectKey}>
|
||||||
<ProjectHeaderProbe project={project} counts={counts} />
|
<ProjectHeaderProbe project={project} counts={counts} />
|
||||||
|
<ProjectActiveProbe serverId={SERVER_ID} project={project} counts={counts} />
|
||||||
{project.workspaces.map((workspace) => (
|
{project.workspaces.map((workspace) => (
|
||||||
<WorkspaceRowProbe
|
<React.Fragment key={workspace.workspaceKey}>
|
||||||
key={workspace.workspaceKey}
|
<WorkspaceRowProbe
|
||||||
serverId={workspace.serverId}
|
serverId={workspace.serverId}
|
||||||
workspaceId={workspace.workspaceId}
|
workspaceId={workspace.workspaceId}
|
||||||
counts={counts}
|
counts={counts}
|
||||||
/>
|
/>
|
||||||
|
<WorkspaceSelectionProbe
|
||||||
|
serverId={workspace.serverId}
|
||||||
|
workspaceId={workspace.workspaceId}
|
||||||
|
counts={counts}
|
||||||
|
/>
|
||||||
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -278,6 +326,7 @@ describe("sidebar workspace render isolation", () => {
|
|||||||
container?.remove();
|
container?.remove();
|
||||||
container = null;
|
container = null;
|
||||||
act(() => {
|
act(() => {
|
||||||
|
syncNavigationActiveWorkspace({ current: null });
|
||||||
getHostRuntimeStore().syncHosts([]);
|
getHostRuntimeStore().syncHosts([]);
|
||||||
useSessionStore.getState().clearSession(SERVER_ID);
|
useSessionStore.getState().clearSession(SERVER_ID);
|
||||||
useSidebarOrderStore.setState({
|
useSidebarOrderStore.setState({
|
||||||
@@ -288,7 +337,13 @@ describe("sidebar workspace render isolation", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("re-renders only the changed workspace row for a status update", async () => {
|
it("re-renders only the changed workspace row for a status update", async () => {
|
||||||
const counts: RenderCounts = { frame: 0, headers: {}, rows: {} };
|
const counts: RenderCounts = {
|
||||||
|
frame: 0,
|
||||||
|
headers: {},
|
||||||
|
rows: {},
|
||||||
|
projectSelection: {},
|
||||||
|
rowSelection: {},
|
||||||
|
};
|
||||||
({ root, container } = await renderProbe(counts));
|
({ root, container } = await renderProbe(counts));
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -306,7 +361,13 @@ describe("sidebar workspace render isolation", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("does not re-render the sidebar for a host-runtime probe tick with no content change", async () => {
|
it("does not re-render the sidebar for a host-runtime probe tick with no content change", async () => {
|
||||||
const counts: RenderCounts = { frame: 0, headers: {}, rows: {} };
|
const counts: RenderCounts = {
|
||||||
|
frame: 0,
|
||||||
|
headers: {},
|
||||||
|
rows: {},
|
||||||
|
projectSelection: {},
|
||||||
|
rowSelection: {},
|
||||||
|
};
|
||||||
({ root, container } = await renderProbe(counts));
|
({ root, container } = await renderProbe(counts));
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -316,11 +377,23 @@ describe("sidebar workspace render isolation", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(counts).toEqual({ frame: 0, headers: {}, rows: {} });
|
expect(counts).toEqual({
|
||||||
|
frame: 0,
|
||||||
|
headers: {},
|
||||||
|
rows: {},
|
||||||
|
projectSelection: {},
|
||||||
|
rowSelection: {},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not re-render for a deep-equal scripts patch", async () => {
|
it("does not re-render for a deep-equal scripts patch", async () => {
|
||||||
const counts: RenderCounts = { frame: 0, headers: {}, rows: {} };
|
const counts: RenderCounts = {
|
||||||
|
frame: 0,
|
||||||
|
headers: {},
|
||||||
|
rows: {},
|
||||||
|
projectSelection: {},
|
||||||
|
rowSelection: {},
|
||||||
|
};
|
||||||
({ root, container } = await renderProbe(counts));
|
({ root, container } = await renderProbe(counts));
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -332,6 +405,49 @@ describe("sidebar workspace render isolation", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(counts).toEqual({ frame: 0, headers: {}, rows: {} });
|
expect(counts).toEqual({
|
||||||
|
frame: 0,
|
||||||
|
headers: {},
|
||||||
|
rows: {},
|
||||||
|
projectSelection: {},
|
||||||
|
rowSelection: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isolates active selection updates to affected row and project boolean probes", async () => {
|
||||||
|
const counts: RenderCounts = {
|
||||||
|
frame: 0,
|
||||||
|
headers: {},
|
||||||
|
rows: {},
|
||||||
|
projectSelection: {},
|
||||||
|
rowSelection: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
activateNavigationWorkspaceSelection({
|
||||||
|
serverId: SERVER_ID,
|
||||||
|
workspaceId: "a-one",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
({ root, container } = await renderProbe(counts));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
activateNavigationWorkspaceSelection({
|
||||||
|
serverId: SERVER_ID,
|
||||||
|
workspaceId: "b-two",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(counts.frame).toBe(0);
|
||||||
|
expect(counts.headers).toEqual({});
|
||||||
|
expect(counts.rows).toEqual({});
|
||||||
|
expect(counts.projectSelection).toEqual({
|
||||||
|
"project-a": 1,
|
||||||
|
"project-b": 1,
|
||||||
|
});
|
||||||
|
expect(counts.rowSelection).toEqual({
|
||||||
|
"a-one": 1,
|
||||||
|
"b-two": 1,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ import * as Clipboard from "expo-clipboard";
|
|||||||
import { DiffStat } from "@/components/diff-stat";
|
import { DiffStat } from "@/components/diff-stat";
|
||||||
import {
|
import {
|
||||||
Archive,
|
Archive,
|
||||||
ArrowUpRight,
|
|
||||||
CircleAlert,
|
CircleAlert,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
@@ -92,14 +91,17 @@ import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
|||||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||||
import { type PrHint, useWorkspacePrHint } from "@/hooks/use-checkout-pr-status-query";
|
import { type PrHint, useWorkspacePrHint } from "@/hooks/use-checkout-pr-status-query";
|
||||||
import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model";
|
import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model";
|
||||||
import { useNavigationActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
import {
|
||||||
|
useIsNavigationProjectActive,
|
||||||
|
useIsNavigationWorkspaceSelected,
|
||||||
|
} from "@/stores/navigation-active-workspace-store";
|
||||||
import {
|
import {
|
||||||
normalizeWorkspaceDescriptor,
|
normalizeWorkspaceDescriptor,
|
||||||
useSessionStore,
|
useSessionStore,
|
||||||
type WorkspaceDescriptor,
|
type WorkspaceDescriptor,
|
||||||
} from "@/stores/session-store";
|
} from "@/stores/session-store";
|
||||||
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||||
import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation";
|
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
import {
|
import {
|
||||||
requireWorkspaceExecutionDirectory,
|
requireWorkspaceExecutionDirectory,
|
||||||
@@ -216,6 +218,7 @@ export function PrBadge({ hint }: { hint: PrHint }) {
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const activeColor = isHovered ? theme.colors.foreground : theme.colors.foregroundMuted;
|
const activeColor = isHovered ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||||
|
const iconColor = getWorkspacePrIconColor(theme, hint.state);
|
||||||
|
|
||||||
const handlePressIn = useCallback((event: GestureResponderEvent) => {
|
const handlePressIn = useCallback((event: GestureResponderEvent) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -240,11 +243,14 @@ export function PrBadge({ hint }: { hint: PrHint }) {
|
|||||||
onHoverOut={() => setIsHovered(false)}
|
onHoverOut={() => setIsHovered(false)}
|
||||||
style={({ pressed }) => [prBadgeStyles.badge, pressed && prBadgeStyles.badgePressed]}
|
style={({ pressed }) => [prBadgeStyles.badge, pressed && prBadgeStyles.badgePressed]}
|
||||||
>
|
>
|
||||||
<GitPullRequest size={12} color={activeColor} />
|
{isHovered ? (
|
||||||
|
<ExternalLink size={12} color={activeColor} />
|
||||||
|
) : (
|
||||||
|
<GitPullRequest size={12} color={iconColor} />
|
||||||
|
)}
|
||||||
<Text style={[prBadgeStyles.text, { color: activeColor }]} numberOfLines={1}>
|
<Text style={[prBadgeStyles.text, { color: activeColor }]} numberOfLines={1}>
|
||||||
#{hint.number}
|
#{hint.number}
|
||||||
</Text>
|
</Text>
|
||||||
<ArrowUpRight size={10} color={activeColor} style={{ opacity: isHovered ? 1 : 0 }} />
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -830,12 +836,7 @@ function ProjectHeaderRow({
|
|||||||
|
|
||||||
const rowChildren = (
|
const rowChildren = (
|
||||||
<>
|
<>
|
||||||
<View
|
<View style={styles.projectRowLeft}>
|
||||||
{...(dragHandleProps?.attributes as any)}
|
|
||||||
{...(dragHandleProps?.listeners as any)}
|
|
||||||
ref={dragHandleProps?.setActivatorNodeRef as any}
|
|
||||||
style={styles.projectRowLeft}
|
|
||||||
>
|
|
||||||
<ProjectLeadingVisual
|
<ProjectLeadingVisual
|
||||||
displayName={displayName}
|
displayName={displayName}
|
||||||
iconDataUri={iconDataUri}
|
iconDataUri={iconDataUri}
|
||||||
@@ -912,7 +913,13 @@ function ProjectHeaderRow({
|
|||||||
|
|
||||||
if (menuController) {
|
if (menuController) {
|
||||||
return (
|
return (
|
||||||
<View onPointerEnter={() => setIsHovered(true)} onPointerLeave={() => setIsHovered(false)}>
|
<View
|
||||||
|
{...(dragHandleProps?.attributes as any)}
|
||||||
|
{...(dragHandleProps?.listeners as any)}
|
||||||
|
ref={dragHandleProps?.setActivatorNodeRef as any}
|
||||||
|
onPointerEnter={() => setIsHovered(true)}
|
||||||
|
onPointerLeave={() => setIsHovered(false)}
|
||||||
|
>
|
||||||
<ContextMenuTrigger
|
<ContextMenuTrigger
|
||||||
enabledOnMobile={false}
|
enabledOnMobile={false}
|
||||||
style={({ pressed }) => [
|
style={({ pressed }) => [
|
||||||
@@ -935,7 +942,13 @@ function ProjectHeaderRow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View onPointerEnter={() => setIsHovered(true)} onPointerLeave={() => setIsHovered(false)}>
|
<View
|
||||||
|
{...(dragHandleProps?.attributes as any)}
|
||||||
|
{...(dragHandleProps?.listeners as any)}
|
||||||
|
ref={dragHandleProps?.setActivatorNodeRef as any}
|
||||||
|
onPointerEnter={() => setIsHovered(true)}
|
||||||
|
onPointerLeave={() => setIsHovered(false)}
|
||||||
|
>
|
||||||
<Pressable
|
<Pressable
|
||||||
style={({ pressed }) => [
|
style={({ pressed }) => [
|
||||||
styles.projectRow,
|
styles.projectRow,
|
||||||
@@ -1010,6 +1023,9 @@ function WorkspaceRowInner({
|
|||||||
return (
|
return (
|
||||||
<WorkspaceHoverCard workspace={workspace} prHint={prHint} isDragging={isDragging}>
|
<WorkspaceHoverCard workspace={workspace} prHint={prHint} isDragging={isDragging}>
|
||||||
<View
|
<View
|
||||||
|
{...(dragHandleProps?.attributes as any)}
|
||||||
|
{...(dragHandleProps?.listeners as any)}
|
||||||
|
ref={dragHandleProps?.setActivatorNodeRef as any}
|
||||||
style={styles.workspaceRowContainer}
|
style={styles.workspaceRowContainer}
|
||||||
onPointerEnter={() => setIsHovered(true)}
|
onPointerEnter={() => setIsHovered(true)}
|
||||||
onPointerLeave={() => setIsHovered(false)}
|
onPointerLeave={() => setIsHovered(false)}
|
||||||
@@ -1033,12 +1049,7 @@ function WorkspaceRowInner({
|
|||||||
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
||||||
>
|
>
|
||||||
<View style={styles.workspaceRowMain}>
|
<View style={styles.workspaceRowMain}>
|
||||||
<View
|
<View style={styles.workspaceRowLeft}>
|
||||||
{...(dragHandleProps?.attributes as any)}
|
|
||||||
{...(dragHandleProps?.listeners as any)}
|
|
||||||
ref={dragHandleProps?.setActivatorNodeRef as any}
|
|
||||||
style={styles.workspaceRowLeft}
|
|
||||||
>
|
|
||||||
<WorkspaceStatusIndicator
|
<WorkspaceStatusIndicator
|
||||||
bucket={workspace.statusBucket}
|
bucket={workspace.statusBucket}
|
||||||
workspaceKind={workspace.workspaceKind}
|
workspaceKind={workspace.workspaceKind}
|
||||||
@@ -1133,8 +1144,8 @@ function WorkspaceRowInner({
|
|||||||
</View>
|
</View>
|
||||||
{prHint ? (
|
{prHint ? (
|
||||||
<View style={styles.workspacePrBadgeRow}>
|
<View style={styles.workspacePrBadgeRow}>
|
||||||
<ChecksBadge checks={prHint.checks} />
|
|
||||||
<PrBadge hint={prHint} />
|
<PrBadge hint={prHint} />
|
||||||
|
<ChecksBadge checks={prHint.checks} />
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -1167,7 +1178,6 @@ function WorkspaceRowWithMenu({
|
|||||||
isCreating?: boolean;
|
isCreating?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection();
|
|
||||||
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
|
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
|
||||||
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
|
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
|
||||||
const workspaceDirectory = resolveWorkspaceExecutionDirectory({
|
const workspaceDirectory = resolveWorkspaceExecutionDirectory({
|
||||||
@@ -1185,22 +1195,11 @@ function WorkspaceRowWithMenu({
|
|||||||
const isWorktree = workspace.workspaceKind === "worktree";
|
const isWorktree = workspace.workspaceKind === "worktree";
|
||||||
const isArchiving = isWorktree ? archiveStatus === "pending" : isArchivingWorkspace;
|
const isArchiving = isWorktree ? archiveStatus === "pending" : isArchivingWorkspace;
|
||||||
const redirectAfterArchive = useCallback(() => {
|
const redirectAfterArchive = useCallback(() => {
|
||||||
if (
|
redirectIfArchivingActiveWorkspace({
|
||||||
activeWorkspaceSelection?.serverId !== workspace.serverId ||
|
serverId: workspace.serverId,
|
||||||
activeWorkspaceSelection.workspaceId !== workspace.workspaceId
|
workspaceId: workspace.workspaceId,
|
||||||
) {
|
});
|
||||||
return;
|
}, [workspace.serverId, workspace.workspaceId]);
|
||||||
}
|
|
||||||
|
|
||||||
router.replace(
|
|
||||||
buildWorkspaceArchiveRedirectRoute({
|
|
||||||
serverId: workspace.serverId,
|
|
||||||
archivedWorkspaceId: workspace.workspaceId,
|
|
||||||
workspaces:
|
|
||||||
useSessionStore.getState().sessions[workspace.serverId]?.workspaces.values() ?? [],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}, [activeWorkspaceSelection, workspace.serverId, workspace.workspaceId]);
|
|
||||||
|
|
||||||
const handleArchiveWorktree = useCallback(() => {
|
const handleArchiveWorktree = useCallback(() => {
|
||||||
if (isArchiving) {
|
if (isArchiving) {
|
||||||
@@ -1393,25 +1392,13 @@ function NonGitProjectRowWithMenuContent({
|
|||||||
}) {
|
}) {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const contextMenu = useContextMenu();
|
const contextMenu = useContextMenu();
|
||||||
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection();
|
|
||||||
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
|
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
|
||||||
const redirectAfterArchive = useCallback(() => {
|
const redirectAfterArchive = useCallback(() => {
|
||||||
if (
|
redirectIfArchivingActiveWorkspace({
|
||||||
activeWorkspaceSelection?.serverId !== workspace.serverId ||
|
serverId: workspace.serverId,
|
||||||
activeWorkspaceSelection.workspaceId !== workspace.workspaceId
|
workspaceId: workspace.workspaceId,
|
||||||
) {
|
});
|
||||||
return;
|
}, [workspace.serverId, workspace.workspaceId]);
|
||||||
}
|
|
||||||
|
|
||||||
router.replace(
|
|
||||||
buildWorkspaceArchiveRedirectRoute({
|
|
||||||
serverId: workspace.serverId,
|
|
||||||
archivedWorkspaceId: workspace.workspaceId,
|
|
||||||
workspaces:
|
|
||||||
useSessionStore.getState().sessions[workspace.serverId]?.workspaces.values() ?? [],
|
|
||||||
}) as any,
|
|
||||||
);
|
|
||||||
}, [activeWorkspaceSelection, workspace.serverId, workspace.workspaceId]);
|
|
||||||
|
|
||||||
const handleArchiveWorkspace = useCallback(() => {
|
const handleArchiveWorkspace = useCallback(() => {
|
||||||
if (isArchivingWorkspace) {
|
if (isArchivingWorkspace) {
|
||||||
@@ -1538,6 +1525,7 @@ function FlattenedProjectRow({
|
|||||||
isProjectActive = false,
|
isProjectActive = false,
|
||||||
onRemoveProject,
|
onRemoveProject,
|
||||||
removeProjectStatus,
|
removeProjectStatus,
|
||||||
|
selectionEnabled,
|
||||||
}: {
|
}: {
|
||||||
project: SidebarProjectEntry;
|
project: SidebarProjectEntry;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
@@ -1555,8 +1543,14 @@ function FlattenedProjectRow({
|
|||||||
isProjectActive?: boolean;
|
isProjectActive?: boolean;
|
||||||
onRemoveProject?: () => void;
|
onRemoveProject?: () => void;
|
||||||
removeProjectStatus?: "idle" | "pending";
|
removeProjectStatus?: "idle" | "pending";
|
||||||
|
selectionEnabled: boolean;
|
||||||
}) {
|
}) {
|
||||||
const workspace = useSidebarWorkspaceEntry(serverId, rowModel.workspace.workspaceId);
|
const workspace = useSidebarWorkspaceEntry(serverId, rowModel.workspace.workspaceId);
|
||||||
|
const selected = useIsNavigationWorkspaceSelected({
|
||||||
|
serverId,
|
||||||
|
workspaceId: rowModel.workspace.workspaceId,
|
||||||
|
enabled: selectionEnabled,
|
||||||
|
});
|
||||||
|
|
||||||
if (!workspace) {
|
if (!workspace) {
|
||||||
return null;
|
return null;
|
||||||
@@ -1569,7 +1563,7 @@ function FlattenedProjectRow({
|
|||||||
displayName={displayName}
|
displayName={displayName}
|
||||||
iconDataUri={iconDataUri}
|
iconDataUri={iconDataUri}
|
||||||
workspace={workspace}
|
workspace={workspace}
|
||||||
selected={rowModel.selected}
|
selected={selected}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
shortcutNumber={shortcutNumber}
|
shortcutNumber={shortcutNumber}
|
||||||
showShortcutBadge={showShortcutBadge}
|
showShortcutBadge={showShortcutBadge}
|
||||||
@@ -1586,7 +1580,7 @@ function FlattenedProjectRow({
|
|||||||
displayName={displayName}
|
displayName={displayName}
|
||||||
iconDataUri={iconDataUri}
|
iconDataUri={iconDataUri}
|
||||||
workspace={workspace}
|
workspace={workspace}
|
||||||
selected={rowModel.selected}
|
selected={selected}
|
||||||
chevron={rowModel.chevron}
|
chevron={rowModel.chevron}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
@@ -1608,7 +1602,6 @@ function FlattenedProjectRow({
|
|||||||
|
|
||||||
function WorkspaceRow({
|
function WorkspaceRow({
|
||||||
workspace,
|
workspace,
|
||||||
selected,
|
|
||||||
shortcutNumber,
|
shortcutNumber,
|
||||||
showShortcutBadge,
|
showShortcutBadge,
|
||||||
onPress,
|
onPress,
|
||||||
@@ -1617,9 +1610,9 @@ function WorkspaceRow({
|
|||||||
dragHandleProps,
|
dragHandleProps,
|
||||||
canCopyBranchName,
|
canCopyBranchName,
|
||||||
isCreating = false,
|
isCreating = false,
|
||||||
|
selectionEnabled,
|
||||||
}: {
|
}: {
|
||||||
workspace: SidebarWorkspaceEntry;
|
workspace: SidebarWorkspaceEntry;
|
||||||
selected: boolean;
|
|
||||||
shortcutNumber: number | null;
|
shortcutNumber: number | null;
|
||||||
showShortcutBadge: boolean;
|
showShortcutBadge: boolean;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
@@ -1628,8 +1621,14 @@ function WorkspaceRow({
|
|||||||
dragHandleProps?: DraggableListDragHandleProps;
|
dragHandleProps?: DraggableListDragHandleProps;
|
||||||
canCopyBranchName: boolean;
|
canCopyBranchName: boolean;
|
||||||
isCreating?: boolean;
|
isCreating?: boolean;
|
||||||
|
selectionEnabled: boolean;
|
||||||
}) {
|
}) {
|
||||||
const hydratedWorkspace = useSidebarWorkspaceEntry(workspace.serverId, workspace.workspaceId);
|
const hydratedWorkspace = useSidebarWorkspaceEntry(workspace.serverId, workspace.workspaceId);
|
||||||
|
const selected = useIsNavigationWorkspaceSelected({
|
||||||
|
serverId: workspace.serverId,
|
||||||
|
workspaceId: workspace.workspaceId,
|
||||||
|
enabled: selectionEnabled,
|
||||||
|
});
|
||||||
|
|
||||||
if (!hydratedWorkspace) {
|
if (!hydratedWorkspace) {
|
||||||
return null;
|
return null;
|
||||||
@@ -1657,7 +1656,7 @@ function ProjectBlock({
|
|||||||
displayName,
|
displayName,
|
||||||
iconDataUri,
|
iconDataUri,
|
||||||
serverId,
|
serverId,
|
||||||
activeWorkspaceSelection,
|
selectionEnabled,
|
||||||
showShortcutBadges,
|
showShortcutBadges,
|
||||||
shortcutIndexByWorkspaceKey,
|
shortcutIndexByWorkspaceKey,
|
||||||
parentGestureRef,
|
parentGestureRef,
|
||||||
@@ -1665,6 +1664,7 @@ function ProjectBlock({
|
|||||||
onWorkspacePress,
|
onWorkspacePress,
|
||||||
onWorkspaceReorder,
|
onWorkspaceReorder,
|
||||||
onWorktreeCreated,
|
onWorktreeCreated,
|
||||||
|
currentPathname,
|
||||||
drag,
|
drag,
|
||||||
isDragging,
|
isDragging,
|
||||||
dragHandleProps,
|
dragHandleProps,
|
||||||
@@ -1676,7 +1676,7 @@ function ProjectBlock({
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
iconDataUri: string | null;
|
iconDataUri: string | null;
|
||||||
serverId: string | null;
|
serverId: string | null;
|
||||||
activeWorkspaceSelection: { serverId: string; workspaceId: string } | null;
|
selectionEnabled: boolean;
|
||||||
showShortcutBadges: boolean;
|
showShortcutBadges: boolean;
|
||||||
shortcutIndexByWorkspaceKey: Map<string, number>;
|
shortcutIndexByWorkspaceKey: Map<string, number>;
|
||||||
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
||||||
@@ -1684,6 +1684,7 @@ function ProjectBlock({
|
|||||||
onWorkspacePress?: () => void;
|
onWorkspacePress?: () => void;
|
||||||
onWorkspaceReorder: (projectKey: string, workspaces: SidebarWorkspaceEntry[]) => void;
|
onWorkspaceReorder: (projectKey: string, workspaces: SidebarWorkspaceEntry[]) => void;
|
||||||
onWorktreeCreated?: (workspaceId: string) => void;
|
onWorktreeCreated?: (workspaceId: string) => void;
|
||||||
|
currentPathname: string | null;
|
||||||
drag: () => void;
|
drag: () => void;
|
||||||
isDragging: boolean;
|
isDragging: boolean;
|
||||||
dragHandleProps?: DraggableListDragHandleProps;
|
dragHandleProps?: DraggableListDragHandleProps;
|
||||||
@@ -1695,18 +1696,19 @@ function ProjectBlock({
|
|||||||
buildSidebarProjectRowModel({
|
buildSidebarProjectRowModel({
|
||||||
project,
|
project,
|
||||||
collapsed,
|
collapsed,
|
||||||
serverId,
|
|
||||||
activeWorkspaceSelection,
|
|
||||||
}),
|
}),
|
||||||
[activeWorkspaceSelection, collapsed, project, serverId],
|
[collapsed, project],
|
||||||
);
|
);
|
||||||
|
|
||||||
const isProjectActive = useMemo(() => {
|
const projectWorkspaceIds = useMemo(
|
||||||
if (!serverId || !activeWorkspaceSelection || activeWorkspaceSelection.serverId !== serverId) {
|
() => project.workspaces.map((workspace) => workspace.workspaceId),
|
||||||
return false;
|
[project.workspaces],
|
||||||
}
|
);
|
||||||
return project.workspaces.some((w) => w.workspaceId === activeWorkspaceSelection.workspaceId);
|
const isProjectActive = useIsNavigationProjectActive({
|
||||||
}, [serverId, activeWorkspaceSelection, project.workspaces]);
|
serverId,
|
||||||
|
workspaceIds: projectWorkspaceIds,
|
||||||
|
enabled: selectionEnabled,
|
||||||
|
});
|
||||||
|
|
||||||
const renderWorkspaceRow = useCallback(
|
const renderWorkspaceRow = useCallback(
|
||||||
(
|
(
|
||||||
@@ -1717,25 +1719,20 @@ function ProjectBlock({
|
|||||||
dragHandleProps?: DraggableListDragHandleProps;
|
dragHandleProps?: DraggableListDragHandleProps;
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
const isSelected =
|
|
||||||
Boolean(serverId) &&
|
|
||||||
activeWorkspaceSelection?.serverId === serverId &&
|
|
||||||
activeWorkspaceSelection.workspaceId === item.workspaceId;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WorkspaceRow
|
<WorkspaceRow
|
||||||
workspace={item}
|
workspace={item}
|
||||||
selected={isSelected}
|
|
||||||
shortcutNumber={shortcutIndexByWorkspaceKey.get(item.workspaceKey) ?? null}
|
shortcutNumber={shortcutIndexByWorkspaceKey.get(item.workspaceKey) ?? null}
|
||||||
showShortcutBadge={showShortcutBadges}
|
showShortcutBadge={showShortcutBadges}
|
||||||
canCopyBranchName={project.projectKind === "git"}
|
canCopyBranchName={project.projectKind === "git"}
|
||||||
isCreating={creatingWorkspaceIds.has(item.workspaceId)}
|
isCreating={creatingWorkspaceIds.has(item.workspaceId)}
|
||||||
|
selectionEnabled={selectionEnabled}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (!serverId) {
|
if (!serverId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onWorkspacePress?.();
|
onWorkspacePress?.();
|
||||||
navigateToWorkspace(serverId, item.workspaceId);
|
navigateToWorkspace(serverId, item.workspaceId, { currentPathname });
|
||||||
}}
|
}}
|
||||||
drag={input?.drag ?? (() => {})}
|
drag={input?.drag ?? (() => {})}
|
||||||
isDragging={input?.isDragging ?? false}
|
isDragging={input?.isDragging ?? false}
|
||||||
@@ -1744,11 +1741,12 @@ function ProjectBlock({
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
activeWorkspaceSelection,
|
|
||||||
project.projectKind,
|
project.projectKind,
|
||||||
creatingWorkspaceIds,
|
creatingWorkspaceIds,
|
||||||
|
currentPathname,
|
||||||
onWorkspacePress,
|
onWorkspacePress,
|
||||||
serverId,
|
serverId,
|
||||||
|
selectionEnabled,
|
||||||
shortcutIndexByWorkspaceKey,
|
shortcutIndexByWorkspaceKey,
|
||||||
showShortcutBadges,
|
showShortcutBadges,
|
||||||
],
|
],
|
||||||
@@ -1835,7 +1833,9 @@ function ProjectBlock({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onWorkspacePress?.();
|
onWorkspacePress?.();
|
||||||
navigateToWorkspace(serverId, rowModel.workspace.workspaceId);
|
navigateToWorkspace(serverId, rowModel.workspace.workspaceId, {
|
||||||
|
currentPathname,
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
onWorkspacePress={onWorkspacePress}
|
onWorkspacePress={onWorkspacePress}
|
||||||
@@ -1848,6 +1848,7 @@ function ProjectBlock({
|
|||||||
isProjectActive={isProjectActive}
|
isProjectActive={isProjectActive}
|
||||||
onRemoveProject={handleRemoveProject}
|
onRemoveProject={handleRemoveProject}
|
||||||
removeProjectStatus={isRemovingProject ? "pending" : "idle"}
|
removeProjectStatus={isRemovingProject ? "pending" : "idle"}
|
||||||
|
selectionEnabled={selectionEnabled}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -1906,9 +1907,7 @@ export function SidebarWorkspaceList({
|
|||||||
listFooterComponent,
|
listFooterComponent,
|
||||||
parentGestureRef,
|
parentGestureRef,
|
||||||
}: SidebarWorkspaceListProps) {
|
}: SidebarWorkspaceListProps) {
|
||||||
const isMobile = useIsCompactFormFactor();
|
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection();
|
|
||||||
const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set());
|
const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set());
|
||||||
const creatingWorkspaceTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
|
const creatingWorkspaceTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
|
||||||
new Map(),
|
new Map(),
|
||||||
@@ -1924,7 +1923,7 @@ export function SidebarWorkspaceList({
|
|||||||
() => Boolean(pathname && parseHostWorkspaceRouteFromPathname(pathname)),
|
() => Boolean(pathname && parseHostWorkspaceRouteFromPathname(pathname)),
|
||||||
[pathname],
|
[pathname],
|
||||||
);
|
);
|
||||||
const effectiveActiveWorkspaceSelection = isWorkspaceRoute ? activeWorkspaceSelection : null;
|
const selectionEnabled = isWorkspaceRoute;
|
||||||
|
|
||||||
const projectIconRequests = useMemo(() => {
|
const projectIconRequests = useMemo(() => {
|
||||||
if (!serverId) {
|
if (!serverId) {
|
||||||
@@ -2129,7 +2128,7 @@ export function SidebarWorkspaceList({
|
|||||||
displayName={item.projectName}
|
displayName={item.projectName}
|
||||||
iconDataUri={projectIconByProjectKey.get(item.projectKey) ?? null}
|
iconDataUri={projectIconByProjectKey.get(item.projectKey) ?? null}
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
activeWorkspaceSelection={effectiveActiveWorkspaceSelection}
|
selectionEnabled={selectionEnabled}
|
||||||
showShortcutBadges={showShortcutBadges}
|
showShortcutBadges={showShortcutBadges}
|
||||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||||
parentGestureRef={parentGestureRef}
|
parentGestureRef={parentGestureRef}
|
||||||
@@ -2137,6 +2136,7 @@ export function SidebarWorkspaceList({
|
|||||||
onWorkspacePress={onWorkspacePress}
|
onWorkspacePress={onWorkspacePress}
|
||||||
onWorkspaceReorder={handleWorkspaceReorder}
|
onWorkspaceReorder={handleWorkspaceReorder}
|
||||||
onWorktreeCreated={handleWorktreeCreated}
|
onWorktreeCreated={handleWorktreeCreated}
|
||||||
|
currentPathname={pathname}
|
||||||
drag={drag}
|
drag={drag}
|
||||||
isDragging={isActive}
|
isDragging={isActive}
|
||||||
dragHandleProps={dragHandleProps}
|
dragHandleProps={dragHandleProps}
|
||||||
@@ -2147,13 +2147,14 @@ export function SidebarWorkspaceList({
|
|||||||
},
|
},
|
||||||
[
|
[
|
||||||
collapsedProjectKeys,
|
collapsedProjectKeys,
|
||||||
effectiveActiveWorkspaceSelection,
|
|
||||||
handleWorktreeCreated,
|
handleWorktreeCreated,
|
||||||
handleWorkspaceReorder,
|
handleWorkspaceReorder,
|
||||||
onWorkspacePress,
|
onWorkspacePress,
|
||||||
onToggleProjectCollapsed,
|
onToggleProjectCollapsed,
|
||||||
parentGestureRef,
|
parentGestureRef,
|
||||||
|
pathname,
|
||||||
projectIconByProjectKey,
|
projectIconByProjectKey,
|
||||||
|
selectionEnabled,
|
||||||
serverId,
|
serverId,
|
||||||
shortcutIndexByWorkspaceKey,
|
shortcutIndexByWorkspaceKey,
|
||||||
showShortcutBadges,
|
showShortcutBadges,
|
||||||
@@ -2266,6 +2267,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
|
userSelect: "none",
|
||||||
},
|
},
|
||||||
projectRowHovered: {
|
projectRowHovered: {
|
||||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||||
@@ -2409,6 +2411,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
alignItems: "stretch",
|
alignItems: "stretch",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
gap: theme.spacing[1],
|
gap: theme.spacing[1],
|
||||||
|
userSelect: "none",
|
||||||
},
|
},
|
||||||
workspaceRowMain: {
|
workspaceRowMain: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
import { useEffect } from "react";
|
|
||||||
import { RefreshCw } from "lucide-react-native";
|
|
||||||
import Animated, {
|
|
||||||
cancelAnimation,
|
|
||||||
Easing,
|
|
||||||
useAnimatedStyle,
|
|
||||||
useSharedValue,
|
|
||||||
withRepeat,
|
|
||||||
withTiming,
|
|
||||||
} from "react-native-reanimated";
|
|
||||||
|
|
||||||
interface SpinningRefreshIconProps {
|
|
||||||
spinning: boolean;
|
|
||||||
size: number;
|
|
||||||
color: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SpinningRefreshIcon({ spinning, size, color }: SpinningRefreshIconProps) {
|
|
||||||
const rotation = useSharedValue(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (spinning) {
|
|
||||||
rotation.value = 0;
|
|
||||||
rotation.value = withRepeat(
|
|
||||||
withTiming(360, {
|
|
||||||
duration: 1000,
|
|
||||||
easing: Easing.linear,
|
|
||||||
}),
|
|
||||||
-1,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
cancelAnimation(rotation);
|
|
||||||
const remainder = rotation.value % 360;
|
|
||||||
if (Math.abs(remainder) < 0.001) {
|
|
||||||
rotation.value = 0;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
rotation.value = withTiming(360, {
|
|
||||||
duration: Math.max(80, Math.round(((360 - remainder) / 360) * 1000)),
|
|
||||||
easing: Easing.linear,
|
|
||||||
});
|
|
||||||
}, [rotation, spinning]);
|
|
||||||
|
|
||||||
const animatedStyle = useAnimatedStyle(() => ({
|
|
||||||
transform: [{ rotate: `${rotation.value}deg` }],
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Animated.View
|
|
||||||
style={[
|
|
||||||
{
|
|
||||||
width: size,
|
|
||||||
height: size,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
},
|
|
||||||
animatedStyle,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<RefreshCw size={size} color={color} />
|
|
||||||
</Animated.View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -76,6 +76,7 @@ interface SplitContainerProps {
|
|||||||
workspaceKey: string;
|
workspaceKey: string;
|
||||||
normalizedServerId: string;
|
normalizedServerId: string;
|
||||||
normalizedWorkspaceId: string;
|
normalizedWorkspaceId: string;
|
||||||
|
isWorkspaceFocused: boolean;
|
||||||
uiTabs: WorkspaceTab[];
|
uiTabs: WorkspaceTab[];
|
||||||
hoveredCloseTabKey: string | null;
|
hoveredCloseTabKey: string | null;
|
||||||
setHoveredTabKey: Dispatch<SetStateAction<string | null>>;
|
setHoveredTabKey: Dispatch<SetStateAction<string | null>>;
|
||||||
@@ -93,7 +94,6 @@ interface SplitContainerProps {
|
|||||||
onCreateTerminalTab: (input: { paneId?: string }) => void;
|
onCreateTerminalTab: (input: { paneId?: string }) => void;
|
||||||
buildPaneContentModel: (input: {
|
buildPaneContentModel: (input: {
|
||||||
paneId: string;
|
paneId: string;
|
||||||
isPaneFocused: boolean;
|
|
||||||
tab: WorkspaceTabDescriptor;
|
tab: WorkspaceTabDescriptor;
|
||||||
}) => WorkspacePaneContentModel;
|
}) => WorkspacePaneContentModel;
|
||||||
onFocusPane: (paneId: string) => void;
|
onFocusPane: (paneId: string) => void;
|
||||||
@@ -158,11 +158,11 @@ interface SplitPaneViewProps
|
|||||||
interface MountedTabSlotProps {
|
interface MountedTabSlotProps {
|
||||||
tabDescriptor: WorkspaceTabDescriptor;
|
tabDescriptor: WorkspaceTabDescriptor;
|
||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
|
isWorkspaceFocused: boolean;
|
||||||
isPaneFocused: boolean;
|
isPaneFocused: boolean;
|
||||||
paneId: string;
|
paneId: string;
|
||||||
buildPaneContentModel: (input: {
|
buildPaneContentModel: (input: {
|
||||||
paneId: string;
|
paneId: string;
|
||||||
isPaneFocused: boolean;
|
|
||||||
tab: WorkspaceTabDescriptor;
|
tab: WorkspaceTabDescriptor;
|
||||||
}) => WorkspacePaneContentModel;
|
}) => WorkspacePaneContentModel;
|
||||||
}
|
}
|
||||||
@@ -170,6 +170,7 @@ interface MountedTabSlotProps {
|
|||||||
const MountedTabSlot = memo(function MountedTabSlot({
|
const MountedTabSlot = memo(function MountedTabSlot({
|
||||||
tabDescriptor,
|
tabDescriptor,
|
||||||
isVisible,
|
isVisible,
|
||||||
|
isWorkspaceFocused,
|
||||||
isPaneFocused,
|
isPaneFocused,
|
||||||
paneId,
|
paneId,
|
||||||
buildPaneContentModel,
|
buildPaneContentModel,
|
||||||
@@ -178,15 +179,18 @@ const MountedTabSlot = memo(function MountedTabSlot({
|
|||||||
() =>
|
() =>
|
||||||
buildPaneContentModel({
|
buildPaneContentModel({
|
||||||
paneId,
|
paneId,
|
||||||
isPaneFocused,
|
|
||||||
tab: tabDescriptor,
|
tab: tabDescriptor,
|
||||||
}),
|
}),
|
||||||
[buildPaneContentModel, isPaneFocused, paneId, tabDescriptor],
|
[buildPaneContentModel, paneId, tabDescriptor],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ display: isVisible ? "flex" : "none", flex: 1 }}>
|
<View style={{ display: isVisible ? "flex" : "none", flex: 1 }}>
|
||||||
<WorkspacePaneContent content={content} />
|
<WorkspacePaneContent
|
||||||
|
content={content}
|
||||||
|
isWorkspaceFocused={isWorkspaceFocused}
|
||||||
|
isPaneFocused={isPaneFocused}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -241,6 +245,7 @@ export function SplitContainer({
|
|||||||
workspaceKey,
|
workspaceKey,
|
||||||
normalizedServerId,
|
normalizedServerId,
|
||||||
normalizedWorkspaceId,
|
normalizedWorkspaceId,
|
||||||
|
isWorkspaceFocused,
|
||||||
uiTabs,
|
uiTabs,
|
||||||
hoveredCloseTabKey,
|
hoveredCloseTabKey,
|
||||||
setHoveredTabKey,
|
setHoveredTabKey,
|
||||||
@@ -511,6 +516,7 @@ export function SplitContainer({
|
|||||||
focusedPaneId={layout.focusedPaneId}
|
focusedPaneId={layout.focusedPaneId}
|
||||||
normalizedServerId={normalizedServerId}
|
normalizedServerId={normalizedServerId}
|
||||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||||
|
isWorkspaceFocused={isWorkspaceFocused}
|
||||||
hoveredCloseTabKey={hoveredCloseTabKey}
|
hoveredCloseTabKey={hoveredCloseTabKey}
|
||||||
setHoveredTabKey={setHoveredTabKey}
|
setHoveredTabKey={setHoveredTabKey}
|
||||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||||
@@ -633,6 +639,7 @@ function SplitNodeView({
|
|||||||
focusedPaneId,
|
focusedPaneId,
|
||||||
normalizedServerId,
|
normalizedServerId,
|
||||||
normalizedWorkspaceId,
|
normalizedWorkspaceId,
|
||||||
|
isWorkspaceFocused,
|
||||||
hoveredCloseTabKey,
|
hoveredCloseTabKey,
|
||||||
setHoveredTabKey,
|
setHoveredTabKey,
|
||||||
setHoveredCloseTabKey,
|
setHoveredCloseTabKey,
|
||||||
@@ -668,6 +675,7 @@ function SplitNodeView({
|
|||||||
isFocused={node.pane.id === focusedPaneId}
|
isFocused={node.pane.id === focusedPaneId}
|
||||||
normalizedServerId={normalizedServerId}
|
normalizedServerId={normalizedServerId}
|
||||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||||
|
isWorkspaceFocused={isWorkspaceFocused}
|
||||||
hoveredCloseTabKey={hoveredCloseTabKey}
|
hoveredCloseTabKey={hoveredCloseTabKey}
|
||||||
setHoveredTabKey={setHoveredTabKey}
|
setHoveredTabKey={setHoveredTabKey}
|
||||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||||
@@ -718,6 +726,7 @@ function SplitNodeView({
|
|||||||
focusedPaneId={focusedPaneId}
|
focusedPaneId={focusedPaneId}
|
||||||
normalizedServerId={normalizedServerId}
|
normalizedServerId={normalizedServerId}
|
||||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||||
|
isWorkspaceFocused={isWorkspaceFocused}
|
||||||
hoveredCloseTabKey={hoveredCloseTabKey}
|
hoveredCloseTabKey={hoveredCloseTabKey}
|
||||||
setHoveredTabKey={setHoveredTabKey}
|
setHoveredTabKey={setHoveredTabKey}
|
||||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||||
@@ -767,6 +776,7 @@ function SplitPaneView({
|
|||||||
isFocused,
|
isFocused,
|
||||||
normalizedServerId,
|
normalizedServerId,
|
||||||
normalizedWorkspaceId,
|
normalizedWorkspaceId,
|
||||||
|
isWorkspaceFocused,
|
||||||
hoveredCloseTabKey,
|
hoveredCloseTabKey,
|
||||||
setHoveredTabKey,
|
setHoveredTabKey,
|
||||||
setHoveredCloseTabKey,
|
setHoveredCloseTabKey,
|
||||||
@@ -916,6 +926,7 @@ function SplitPaneView({
|
|||||||
key={tabId}
|
key={tabId}
|
||||||
tabDescriptor={tabDescriptor}
|
tabDescriptor={tabDescriptor}
|
||||||
isVisible={tabId === activeTabDescriptor?.tabId}
|
isVisible={tabId === activeTabDescriptor?.tabId}
|
||||||
|
isWorkspaceFocused={isWorkspaceFocused}
|
||||||
isPaneFocused={isFocused && tabId === activeTabDescriptor?.tabId}
|
isPaneFocused={isFocused && tabId === activeTabDescriptor?.tabId}
|
||||||
paneId={pane.id}
|
paneId={pane.id}
|
||||||
buildPaneContentModel={buildPaneContentModel}
|
buildPaneContentModel={buildPaneContentModel}
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*/
|
||||||
|
import React from "react";
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { StreamItem } from "@/types/stream";
|
||||||
|
import type { StreamSegmentRenderers, StreamViewportHandle } from "./stream-strategy";
|
||||||
|
import { createWebStreamStrategy } from "./stream-strategy-web";
|
||||||
|
|
||||||
|
function userMessage(index: number): StreamItem {
|
||||||
|
return {
|
||||||
|
kind: "user_message",
|
||||||
|
id: `message-${index}`,
|
||||||
|
text: `Message ${index}`,
|
||||||
|
timestamp: new Date(`2026-04-20T00:00:${String(index % 60).padStart(2, "0")}.000Z`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRenderers(onRowRender: () => void): StreamSegmentRenderers {
|
||||||
|
return {
|
||||||
|
renderHistoryVirtualizedRow: (item) => {
|
||||||
|
onRowRender();
|
||||||
|
return <div style={{ height: 24 }}>{item.id}</div>;
|
||||||
|
},
|
||||||
|
renderHistoryMountedRow: (item) => <div>{item.id}</div>,
|
||||||
|
renderLiveHeadRow: (item) => <div>{item.id}</div>,
|
||||||
|
renderLiveAuxiliary: () => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("createWebStreamStrategy", () => {
|
||||||
|
let root: Root | null = null;
|
||||||
|
let container: HTMLDivElement | null = null;
|
||||||
|
let originalScrollTo: HTMLElement["scrollTo"] | undefined;
|
||||||
|
let originalOffsetHeight: PropertyDescriptor | undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
||||||
|
value: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, "ResizeObserver", {
|
||||||
|
value: class ResizeObserver {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
},
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
originalScrollTo = HTMLElement.prototype.scrollTo;
|
||||||
|
HTMLElement.prototype.scrollTo = vi.fn();
|
||||||
|
originalOffsetHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetHeight");
|
||||||
|
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
|
||||||
|
configurable: true,
|
||||||
|
get() {
|
||||||
|
return 24;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (root) {
|
||||||
|
act(() => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
root = null;
|
||||||
|
container?.remove();
|
||||||
|
container = null;
|
||||||
|
if (originalScrollTo) {
|
||||||
|
HTMLElement.prototype.scrollTo = originalScrollTo;
|
||||||
|
} else {
|
||||||
|
Reflect.deleteProperty(HTMLElement.prototype, "scrollTo");
|
||||||
|
}
|
||||||
|
if (originalOffsetHeight) {
|
||||||
|
Object.defineProperty(HTMLElement.prototype, "offsetHeight", originalOffsetHeight);
|
||||||
|
} else {
|
||||||
|
Reflect.deleteProperty(HTMLElement.prototype, "offsetHeight");
|
||||||
|
}
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mounts virtualized history without recursive row measurement updates", () => {
|
||||||
|
const rowRenderCount = vi.fn();
|
||||||
|
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||||
|
const viewportRef = React.createRef<StreamViewportHandle>();
|
||||||
|
const historyVirtualized = Array.from({ length: 16 }, (_, index) => userMessage(index));
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root?.render(
|
||||||
|
<>
|
||||||
|
{strategy.render({
|
||||||
|
agentId: "agent",
|
||||||
|
segments: {
|
||||||
|
historyVirtualized,
|
||||||
|
historyMounted: [],
|
||||||
|
liveHead: [],
|
||||||
|
},
|
||||||
|
boundary: {
|
||||||
|
hasVirtualizedHistory: true,
|
||||||
|
hasMountedHistory: false,
|
||||||
|
hasLiveHead: false,
|
||||||
|
historyToHeadGap: 0,
|
||||||
|
},
|
||||||
|
renderers: createRenderers(rowRenderCount),
|
||||||
|
listEmptyComponent: null,
|
||||||
|
viewportRef,
|
||||||
|
routeBottomAnchorRequest: null,
|
||||||
|
isAuthoritativeHistoryReady: true,
|
||||||
|
onNearBottomChange: vi.fn(),
|
||||||
|
scrollEnabled: true,
|
||||||
|
listStyle: null,
|
||||||
|
baseListContentContainerStyle: null,
|
||||||
|
forwardListContentContainerStyle: null,
|
||||||
|
})}
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(rowRenderCount.mock.calls.length).toBeGreaterThan(0);
|
||||||
|
expect(rowRenderCount.mock.calls.length).toBeLessThanOrEqual(historyVirtualized.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import {
|
import React, {
|
||||||
Fragment,
|
Fragment,
|
||||||
type CSSProperties,
|
type CSSProperties,
|
||||||
useCallback,
|
useCallback,
|
||||||
@@ -126,6 +126,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
const lastTouchClientYRef = useRef<number | null>(null);
|
const lastTouchClientYRef = useRef<number | null>(null);
|
||||||
const pendingAutoScrollFrameRef = useRef<number | null>(null);
|
const pendingAutoScrollFrameRef = useRef<number | null>(null);
|
||||||
const pendingAutoScrollTimeoutRef = useRef<number | null>(null);
|
const pendingAutoScrollTimeoutRef = useRef<number | null>(null);
|
||||||
|
const pendingVirtualRowMeasureFramesRef = useRef(new Map<Element, number>());
|
||||||
const showDesktopWebScrollbar = !isMobileBreakpoint;
|
const showDesktopWebScrollbar = !isMobileBreakpoint;
|
||||||
const scrollbarOverlay = useWebElementScrollbar(scrollContainerRef, {
|
const scrollbarOverlay = useWebElementScrollbar(scrollContainerRef, {
|
||||||
enabled: showDesktopWebScrollbar,
|
enabled: showDesktopWebScrollbar,
|
||||||
@@ -181,6 +182,38 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
const virtualRows = rowVirtualizer.getVirtualItems();
|
const virtualRows = rowVirtualizer.getVirtualItems();
|
||||||
const virtualTotalSize = rowVirtualizer.getTotalSize();
|
const virtualTotalSize = rowVirtualizer.getTotalSize();
|
||||||
|
|
||||||
|
const measureVirtualizedRowElement = useCallback(
|
||||||
|
(node: HTMLDivElement | null) => {
|
||||||
|
if (!node) {
|
||||||
|
rowVirtualizer.measureElement(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pendingFrames = pendingVirtualRowMeasureFramesRef.current;
|
||||||
|
const existingFrame = pendingFrames.get(node);
|
||||||
|
if (existingFrame !== undefined) {
|
||||||
|
window.cancelAnimationFrame(existingFrame);
|
||||||
|
}
|
||||||
|
const frame = window.requestAnimationFrame(() => {
|
||||||
|
pendingFrames.delete(node);
|
||||||
|
if (node.isConnected) {
|
||||||
|
rowVirtualizer.measureElement(node);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
pendingFrames.set(node, frame);
|
||||||
|
},
|
||||||
|
[rowVirtualizer],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const pendingFrames = pendingVirtualRowMeasureFramesRef.current;
|
||||||
|
return () => {
|
||||||
|
for (const frame of pendingFrames.values()) {
|
||||||
|
window.cancelAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
pendingFrames.clear();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const cancelPendingStickToBottom = useCallback(() => {
|
const cancelPendingStickToBottom = useCallback(() => {
|
||||||
const pendingFrame = pendingAutoScrollFrameRef.current;
|
const pendingFrame = pendingAutoScrollFrameRef.current;
|
||||||
if (pendingFrame !== null) {
|
if (pendingFrame !== null) {
|
||||||
@@ -696,7 +729,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
<div
|
<div
|
||||||
key={virtualRow.key}
|
key={virtualRow.key}
|
||||||
data-index={virtualRow.index}
|
data-index={virtualRow.index}
|
||||||
ref={rowVirtualizer.measureElement}
|
ref={measureVirtualizedRowElement}
|
||||||
style={renderVirtualRowStyle(virtualRow.start)}
|
style={renderVirtualRowStyle(virtualRow.start)}
|
||||||
>
|
>
|
||||||
{renderHistoryVirtualizedRow(
|
{renderHistoryVirtualizedRow(
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useFocusEffect, useIsFocused } from "@react-navigation/native";
|
|
||||||
import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
|
import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
|
||||||
import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated";
|
import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
@@ -27,7 +26,9 @@ interface TerminalPaneProps {
|
|||||||
serverId: string;
|
serverId: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
terminalId: string;
|
terminalId: string;
|
||||||
|
isWorkspaceFocused: boolean;
|
||||||
isPaneFocused: boolean;
|
isPaneFocused: boolean;
|
||||||
|
onOpenFileExplorer: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TERMINAL_REFIT_DELAYS_MS = [0, 48, 144, 320];
|
const TERMINAL_REFIT_DELAYS_MS = [0, 48, 144, 320];
|
||||||
@@ -82,15 +83,20 @@ function terminalScopeKey(input: { serverId: string; cwd: string }): string {
|
|||||||
return `${input.serverId}:${input.cwd}`;
|
return `${input.serverId}:${input.cwd}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: TerminalPaneProps) {
|
export function TerminalPane({
|
||||||
const isScreenFocused = useIsFocused();
|
serverId,
|
||||||
|
cwd,
|
||||||
|
terminalId,
|
||||||
|
isWorkspaceFocused,
|
||||||
|
isPaneFocused,
|
||||||
|
onOpenFileExplorer,
|
||||||
|
}: TerminalPaneProps) {
|
||||||
const isAppVisible = useAppVisible();
|
const isAppVisible = useAppVisible();
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme]);
|
const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme]);
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const mobileView = usePanelStore((state) => state.mobileView);
|
||||||
const openAgentList = usePanelStore((state) => state.openAgentList);
|
const showMobileAgentList = usePanelStore((state) => state.showMobileAgentList);
|
||||||
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
|
|
||||||
const swipeGesturesEnabled = isMobile && mobileView === "agent";
|
const swipeGesturesEnabled = isMobile && mobileView === "agent";
|
||||||
const { shift: keyboardShift, style: keyboardPaddingStyle } = useKeyboardShiftStyle({
|
const { shift: keyboardShift, style: keyboardPaddingStyle } = useKeyboardShiftStyle({
|
||||||
mode: "padding",
|
mode: "padding",
|
||||||
@@ -131,7 +137,7 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isMobile || !isScreenFocused || !isPaneFocused || !terminalId) {
|
if (isMobile || !isWorkspaceFocused || !isPaneFocused || !terminalId) {
|
||||||
lastAutoFocusKeyRef.current = null;
|
lastAutoFocusKeyRef.current = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -143,14 +149,14 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
|
|||||||
|
|
||||||
lastAutoFocusKeyRef.current = nextFocusKey;
|
lastAutoFocusKeyRef.current = nextFocusKey;
|
||||||
requestTerminalFocus();
|
requestTerminalFocus();
|
||||||
}, [isMobile, isPaneFocused, isScreenFocused, requestTerminalFocus, scopeKey, terminalId]);
|
}, [isMobile, isPaneFocused, isWorkspaceFocused, requestTerminalFocus, scopeKey, terminalId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isPaneFocused && isScreenFocused && isAppVisible && terminalId) {
|
if (isPaneFocused && isWorkspaceFocused && isAppVisible && terminalId) {
|
||||||
lastReportedSizeRef.current = null;
|
lastReportedSizeRef.current = null;
|
||||||
requestTerminalReflow();
|
requestTerminalReflow();
|
||||||
}
|
}
|
||||||
}, [isAppVisible, isPaneFocused, isScreenFocused, requestTerminalReflow, terminalId]);
|
}, [isAppVisible, isPaneFocused, isWorkspaceFocused, requestTerminalReflow, terminalId]);
|
||||||
|
|
||||||
const clearKeyboardRefitTimeouts = useCallback(() => {
|
const clearKeyboardRefitTimeouts = useCallback(() => {
|
||||||
if (keyboardRefitTimeoutsRef.current.length === 0) {
|
if (keyboardRefitTimeoutsRef.current.length === 0) {
|
||||||
@@ -187,29 +193,27 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
|
|||||||
[pulseKeyboardRefits],
|
[pulseKeyboardRefits],
|
||||||
);
|
);
|
||||||
|
|
||||||
useFocusEffect(
|
useEffect(() => {
|
||||||
useCallback(() => {
|
if (!isWorkspaceFocused || !terminalId) {
|
||||||
if (!terminalId) {
|
return;
|
||||||
return;
|
}
|
||||||
}
|
// Focus transitions can temporarily report stale dimensions.
|
||||||
// Navigation transitions can temporarily report stale dimensions.
|
// Pulse forced refits so xterm fills the pane when returning to a terminal.
|
||||||
// Pulse forced refits so xterm fills the pane when returning to an agent.
|
const timeoutHandles = TERMINAL_REFIT_DELAYS_MS.map((delayMs) =>
|
||||||
const timeoutHandles = TERMINAL_REFIT_DELAYS_MS.map((delayMs) =>
|
setTimeout(() => {
|
||||||
setTimeout(() => {
|
requestTerminalReflow();
|
||||||
requestTerminalReflow();
|
}, delayMs),
|
||||||
}, delayMs),
|
);
|
||||||
);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
for (const handle of timeoutHandles) {
|
for (const handle of timeoutHandles) {
|
||||||
clearTimeout(handle);
|
clearTimeout(handle);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [requestTerminalReflow, terminalId]),
|
}, [isWorkspaceFocused, requestTerminalReflow, terminalId]);
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!client || !isConnected || !isScreenFocused) {
|
if (!client || !isConnected || !isWorkspaceFocused) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,7 +236,7 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
|
|||||||
});
|
});
|
||||||
setModifiers({ ...EMPTY_MODIFIERS });
|
setModifiers({ ...EMPTY_MODIFIERS });
|
||||||
});
|
});
|
||||||
}, [client, isConnected, workspaceTerminalSession.snapshots]);
|
}, [client, isConnected, isWorkspaceFocused, workspaceTerminalSession.snapshots]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
lastReportedSizeRef.current = null;
|
lastReportedSizeRef.current = null;
|
||||||
@@ -257,14 +261,14 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
|
|||||||
client,
|
client,
|
||||||
getPreferredSize: () => lastReportedSizeRef.current,
|
getPreferredSize: () => lastReportedSizeRef.current,
|
||||||
onOutput: ({ terminalId, text }) => {
|
onOutput: ({ terminalId, text }) => {
|
||||||
if (!isScreenFocused || terminalIdRef.current !== terminalId) {
|
if (!isWorkspaceFocused || terminalIdRef.current !== terminalId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
emulatorRef.current?.writeOutput(text);
|
emulatorRef.current?.writeOutput(text);
|
||||||
},
|
},
|
||||||
onSnapshot: ({ terminalId, state }) => {
|
onSnapshot: ({ terminalId, state }) => {
|
||||||
workspaceTerminalSession.snapshots.set({ terminalId, state });
|
workspaceTerminalSession.snapshots.set({ terminalId, state });
|
||||||
if (!isScreenFocused || terminalIdRef.current !== terminalId) {
|
if (!isWorkspaceFocused || terminalIdRef.current !== terminalId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
emulatorRef.current?.renderSnapshot(state);
|
emulatorRef.current?.renderSnapshot(state);
|
||||||
@@ -274,7 +278,7 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
|
|||||||
|
|
||||||
streamControllerRef.current = controller;
|
streamControllerRef.current = controller;
|
||||||
controller.setTerminal({
|
controller.setTerminal({
|
||||||
terminalId: isScreenFocused ? terminalIdRef.current : null,
|
terminalId: isWorkspaceFocused ? terminalIdRef.current : null,
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -287,17 +291,17 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
|
|||||||
client,
|
client,
|
||||||
handleStreamControllerStatus,
|
handleStreamControllerStatus,
|
||||||
isConnected,
|
isConnected,
|
||||||
isScreenFocused,
|
isWorkspaceFocused,
|
||||||
workspaceTerminalSession.snapshots,
|
workspaceTerminalSession.snapshots,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
pendingTerminalInputRef.current = [];
|
pendingTerminalInputRef.current = [];
|
||||||
const nextTerminalId = isScreenFocused ? terminalId : null;
|
const nextTerminalId = isWorkspaceFocused ? terminalId : null;
|
||||||
streamControllerRef.current?.setTerminal({
|
streamControllerRef.current?.setTerminal({
|
||||||
terminalId: nextTerminalId,
|
terminalId: nextTerminalId,
|
||||||
});
|
});
|
||||||
}, [isScreenFocused, terminalId]);
|
}, [isWorkspaceFocused, terminalId]);
|
||||||
|
|
||||||
const enqueuePendingTerminalInput = useCallback((entry: PendingTerminalInput) => {
|
const enqueuePendingTerminalInput = useCallback((entry: PendingTerminalInput) => {
|
||||||
const queue = pendingTerminalInputRef.current;
|
const queue = pendingTerminalInputRef.current;
|
||||||
@@ -477,7 +481,7 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
|
|||||||
!client ||
|
!client ||
|
||||||
!terminalId ||
|
!terminalId ||
|
||||||
!isPaneFocused ||
|
!isPaneFocused ||
|
||||||
!isScreenFocused ||
|
!isWorkspaceFocused ||
|
||||||
!isAppVisible ||
|
!isAppVisible ||
|
||||||
rows <= 0 ||
|
rows <= 0 ||
|
||||||
cols <= 0
|
cols <= 0
|
||||||
@@ -550,7 +554,7 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
|
|||||||
return (
|
return (
|
||||||
<Animated.View style={[styles.container, keyboardPaddingStyle]}>
|
<Animated.View style={[styles.container, keyboardPaddingStyle]}>
|
||||||
<View style={styles.outputContainer}>
|
<View style={styles.outputContainer}>
|
||||||
{isScreenFocused ? (
|
{isWorkspaceFocused ? (
|
||||||
<View style={styles.terminalGestureContainer}>
|
<View style={styles.terminalGestureContainer}>
|
||||||
<TerminalEmulator
|
<TerminalEmulator
|
||||||
ref={emulatorRef}
|
ref={emulatorRef}
|
||||||
@@ -573,13 +577,13 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
|
|||||||
if (!swipeGesturesEnabled) {
|
if (!swipeGesturesEnabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
openAgentList();
|
showMobileAgentList();
|
||||||
}}
|
}}
|
||||||
onSwipeLeft={() => {
|
onSwipeLeft={() => {
|
||||||
if (!swipeGesturesEnabled) {
|
if (!swipeGesturesEnabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
openFileExplorer();
|
onOpenFileExplorer();
|
||||||
}}
|
}}
|
||||||
onInput={handleTerminalData}
|
onInput={handleTerminalData}
|
||||||
onResize={handleTerminalResize}
|
onResize={handleTerminalResize}
|
||||||
@@ -594,7 +598,7 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
|
|||||||
<View style={styles.terminalGestureContainer} />
|
<View style={styles.terminalGestureContainer} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isAttaching && isScreenFocused ? (
|
{isAttaching && isWorkspaceFocused ? (
|
||||||
<View style={styles.attachOverlay} pointerEvents="none" testID="terminal-attach-loading">
|
<View style={styles.attachOverlay} pointerEvents="none" testID="terminal-attach-loading">
|
||||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -3,13 +3,16 @@ import { View, Text, Pressable } from "react-native";
|
|||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import Animated from "react-native-reanimated";
|
import Animated from "react-native-reanimated";
|
||||||
import {
|
import {
|
||||||
BottomSheetModal,
|
|
||||||
BottomSheetScrollView,
|
BottomSheetScrollView,
|
||||||
BottomSheetBackdrop,
|
BottomSheetBackdrop,
|
||||||
BottomSheetBackgroundProps,
|
BottomSheetBackgroundProps,
|
||||||
} from "@gorhom/bottom-sheet";
|
} from "@gorhom/bottom-sheet";
|
||||||
import { X } from "lucide-react-native";
|
import { X } from "lucide-react-native";
|
||||||
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
||||||
|
import {
|
||||||
|
IsolatedBottomSheetModal,
|
||||||
|
type IsolatedBottomSheetModalRef,
|
||||||
|
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||||
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
|
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
|
||||||
import { ToolCallDetailsContent } from "./tool-call-details";
|
import { ToolCallDetailsContent } from "./tool-call-details";
|
||||||
|
|
||||||
@@ -60,18 +63,25 @@ interface ToolCallSheetProviderProps {
|
|||||||
|
|
||||||
export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps) {
|
export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const bottomSheetRef = useRef<BottomSheetModal>(null);
|
const bottomSheetRef = useRef<IsolatedBottomSheetModalRef>(null);
|
||||||
|
const hasPresentedRef = useRef(false);
|
||||||
const [sheetData, setSheetData] = React.useState<ToolCallSheetData | null>(null);
|
const [sheetData, setSheetData] = React.useState<ToolCallSheetData | null>(null);
|
||||||
|
|
||||||
const snapPoints = useMemo(() => ["60%", "95%"], []);
|
const snapPoints = useMemo(() => ["60%", "95%"], []);
|
||||||
|
|
||||||
const openToolCall = useCallback((data: ToolCallSheetData) => {
|
const openToolCall = useCallback((data: ToolCallSheetData) => {
|
||||||
setSheetData(data);
|
setSheetData(data);
|
||||||
|
if (hasPresentedRef.current) {
|
||||||
|
requestAnimationFrame(() => bottomSheetRef.current?.snapToIndex(0));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasPresentedRef.current = true;
|
||||||
bottomSheetRef.current?.present();
|
bottomSheetRef.current?.present();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const closeToolCall = useCallback(() => {
|
const closeToolCall = useCallback(() => {
|
||||||
bottomSheetRef.current?.dismiss();
|
bottomSheetRef.current?.close();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSheetChange = useCallback((index: number) => {
|
const handleSheetChange = useCallback((index: number) => {
|
||||||
@@ -95,11 +105,10 @@ export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps)
|
|||||||
return (
|
return (
|
||||||
<ToolCallSheetContext.Provider value={contextValue}>
|
<ToolCallSheetContext.Provider value={contextValue}>
|
||||||
{children}
|
{children}
|
||||||
<BottomSheetModal
|
<IsolatedBottomSheetModal
|
||||||
ref={bottomSheetRef}
|
ref={bottomSheetRef}
|
||||||
snapPoints={snapPoints}
|
snapPoints={snapPoints}
|
||||||
index={0}
|
index={0}
|
||||||
stackBehavior="replace"
|
|
||||||
enableDynamicSizing={false}
|
enableDynamicSizing={false}
|
||||||
onChange={handleSheetChange}
|
onChange={handleSheetChange}
|
||||||
backdropComponent={renderBackdrop}
|
backdropComponent={renderBackdrop}
|
||||||
@@ -108,7 +117,7 @@ export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps)
|
|||||||
handleIndicatorStyle={{ backgroundColor: theme.colors.palette.zinc[600] }}
|
handleIndicatorStyle={{ backgroundColor: theme.colors.palette.zinc[600] }}
|
||||||
>
|
>
|
||||||
{sheetData && <ToolCallSheetContent data={sheetData} onClose={closeToolCall} />}
|
{sheetData && <ToolCallSheetContent data={sheetData} onClose={closeToolCall} />}
|
||||||
</BottomSheetModal>
|
</IsolatedBottomSheetModal>
|
||||||
</ToolCallSheetContext.Provider>
|
</ToolCallSheetContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import {
|
import {
|
||||||
BottomSheetModal,
|
|
||||||
BottomSheetScrollView,
|
BottomSheetScrollView,
|
||||||
BottomSheetBackdrop,
|
BottomSheetBackdrop,
|
||||||
BottomSheetTextInput,
|
BottomSheetTextInput,
|
||||||
@@ -38,6 +37,10 @@ import {
|
|||||||
} from "./combobox-options";
|
} from "./combobox-options";
|
||||||
import type { ComboboxOptionModel } from "./combobox-options";
|
import type { ComboboxOptionModel } from "./combobox-options";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
|
import {
|
||||||
|
IsolatedBottomSheetModal,
|
||||||
|
useIsolatedBottomSheetVisibility,
|
||||||
|
} from "./isolated-bottom-sheet-modal";
|
||||||
|
|
||||||
const IS_WEB = isWeb;
|
const IS_WEB = isWeb;
|
||||||
|
|
||||||
@@ -66,8 +69,6 @@ export interface ComboboxProps {
|
|||||||
title?: string;
|
title?: string;
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
onOpenChange?: (open: boolean) => void;
|
onOpenChange?: (open: boolean) => void;
|
||||||
enableDismissOnClose?: boolean;
|
|
||||||
stackBehavior?: "push" | "switch" | "replace";
|
|
||||||
desktopPlacement?: "top-start" | "bottom-start";
|
desktopPlacement?: "top-start" | "bottom-start";
|
||||||
/**
|
/**
|
||||||
* Prevents an initial frame at 0,0 by hiding desktop content until floating
|
* Prevents an initial frame at 0,0 by hiding desktop content until floating
|
||||||
@@ -271,8 +272,6 @@ export function Combobox({
|
|||||||
title = "Select",
|
title = "Select",
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
enableDismissOnClose,
|
|
||||||
stackBehavior,
|
|
||||||
desktopPlacement = "top-start",
|
desktopPlacement = "top-start",
|
||||||
desktopPreventInitialFlash = true,
|
desktopPreventInitialFlash = true,
|
||||||
desktopMinWidth,
|
desktopMinWidth,
|
||||||
@@ -284,11 +283,10 @@ export function Combobox({
|
|||||||
}: ComboboxProps): ReactElement {
|
}: ComboboxProps): ReactElement {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
|
const titleColor = theme.colors.foreground;
|
||||||
const effectiveOptionsPosition = isMobile ? "below-search" : optionsPosition;
|
const effectiveOptionsPosition = isMobile ? "below-search" : optionsPosition;
|
||||||
const isDesktopAboveSearch = !isMobile && isWeb && effectiveOptionsPosition === "above-search";
|
const isDesktopAboveSearch = !isMobile && isWeb && effectiveOptionsPosition === "above-search";
|
||||||
const { height: windowHeight, width: windowWidth } = useWindowDimensions();
|
const { height: windowHeight, width: windowWidth } = useWindowDimensions();
|
||||||
const bottomSheetRef = useRef<BottomSheetModal>(null);
|
|
||||||
const hasPresentedBottomSheetRef = useRef(false);
|
|
||||||
const snapPoints = useMemo(() => ["60%", "90%"], []);
|
const snapPoints = useMemo(() => ["60%", "90%"], []);
|
||||||
const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>(
|
const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>(
|
||||||
null,
|
null,
|
||||||
@@ -474,32 +472,11 @@ export function Combobox({
|
|||||||
}
|
}
|
||||||
: floatingStyles;
|
: floatingStyles;
|
||||||
|
|
||||||
useEffect(() => {
|
const { sheetRef: bottomSheetRef, handleSheetChange } = useIsolatedBottomSheetVisibility({
|
||||||
if (!isMobile) return;
|
visible: isOpen,
|
||||||
if (isOpen) {
|
isEnabled: isMobile,
|
||||||
if (enableDismissOnClose === false && hasPresentedBottomSheetRef.current) {
|
onClose: handleClose,
|
||||||
bottomSheetRef.current?.snapToIndex(0);
|
});
|
||||||
} else {
|
|
||||||
hasPresentedBottomSheetRef.current = true;
|
|
||||||
bottomSheetRef.current?.present();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (enableDismissOnClose === false) {
|
|
||||||
bottomSheetRef.current?.close();
|
|
||||||
} else {
|
|
||||||
bottomSheetRef.current?.dismiss();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [enableDismissOnClose, isOpen, isMobile]);
|
|
||||||
|
|
||||||
const handleSheetChange = useCallback(
|
|
||||||
(index: number) => {
|
|
||||||
if (index === -1) {
|
|
||||||
handleClose();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[handleClose],
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
const renderBackdrop = useCallback(
|
||||||
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||||
@@ -718,7 +695,7 @@ export function Combobox({
|
|||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
<BottomSheetModal
|
<IsolatedBottomSheetModal
|
||||||
ref={bottomSheetRef}
|
ref={bottomSheetRef}
|
||||||
snapPoints={snapPoints}
|
snapPoints={snapPoints}
|
||||||
index={0}
|
index={0}
|
||||||
@@ -726,15 +703,15 @@ export function Combobox({
|
|||||||
onChange={handleSheetChange}
|
onChange={handleSheetChange}
|
||||||
backdropComponent={renderBackdrop}
|
backdropComponent={renderBackdrop}
|
||||||
enablePanDownToClose
|
enablePanDownToClose
|
||||||
enableDismissOnClose={enableDismissOnClose}
|
|
||||||
stackBehavior={stackBehavior}
|
|
||||||
backgroundComponent={ComboboxSheetBackground}
|
backgroundComponent={ComboboxSheetBackground}
|
||||||
handleIndicatorStyle={{ backgroundColor: theme.colors.palette.zinc[600] }}
|
handleIndicatorStyle={{ backgroundColor: theme.colors.palette.zinc[600] }}
|
||||||
keyboardBehavior="extend"
|
keyboardBehavior="extend"
|
||||||
keyboardBlurBehavior="restore"
|
keyboardBlurBehavior="restore"
|
||||||
>
|
>
|
||||||
<View style={styles.bottomSheetHeader}>
|
<View style={styles.bottomSheetHeader}>
|
||||||
<Text style={[styles.comboboxTitle, { color: theme.colors.foreground }]}>{title}</Text>
|
<Text key={titleColor} style={[styles.comboboxTitle, { color: titleColor }]}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
{stickyHeader}
|
{stickyHeader}
|
||||||
{!children && searchable ? searchInput : null}
|
{!children && searchable ? searchInput : null}
|
||||||
@@ -745,7 +722,7 @@ export function Combobox({
|
|||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
</BottomSheetScrollView>
|
</BottomSheetScrollView>
|
||||||
</BottomSheetModal>
|
</IsolatedBottomSheetModal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -931,7 +908,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
comboboxTitle: {
|
comboboxTitle: {
|
||||||
fontSize: theme.fontSize.lg,
|
fontSize: theme.fontSize.lg,
|
||||||
fontWeight: theme.fontWeight.medium,
|
fontWeight: theme.fontWeight.medium,
|
||||||
color: theme.colors.foreground,
|
|
||||||
textAlign: "left",
|
textAlign: "left",
|
||||||
},
|
},
|
||||||
comboboxScrollContent: {
|
comboboxScrollContent: {
|
||||||
|
|||||||
@@ -30,8 +30,12 @@ import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
|||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { Check, CheckCircle } from "lucide-react-native";
|
import { Check, CheckCircle } from "lucide-react-native";
|
||||||
import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
import { BottomSheetBackdrop, BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import {
|
||||||
|
IsolatedBottomSheetModal,
|
||||||
|
useIsolatedBottomSheetVisibility,
|
||||||
|
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||||
import { isWeb, isNative } from "@/constants/platform";
|
import { isWeb, isNative } from "@/constants/platform";
|
||||||
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||||
|
|
||||||
@@ -354,7 +358,6 @@ export function ContextMenuContent({
|
|||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
const useMobileSheet = isMobile && mobileMode === "sheet";
|
const useMobileSheet = isMobile && mobileMode === "sheet";
|
||||||
const { open, setOpen, triggerRef, anchorRect } = context;
|
const { open, setOpen, triggerRef, anchorRect } = context;
|
||||||
const bottomSheetRef = useRef<BottomSheetModal>(null);
|
|
||||||
const sheetSnapPoints = useMemo(() => ["30%", "55%"], []);
|
const sheetSnapPoints = useMemo(() => ["30%", "55%"], []);
|
||||||
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
|
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
|
||||||
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
|
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
|
||||||
@@ -364,23 +367,11 @@ export function ContextMenuContent({
|
|||||||
setOpen(false);
|
setOpen(false);
|
||||||
}, [setOpen]);
|
}, [setOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
const { sheetRef: bottomSheetRef, handleSheetChange } = useIsolatedBottomSheetVisibility({
|
||||||
if (!useMobileSheet) return;
|
visible: open,
|
||||||
if (open) {
|
isEnabled: useMobileSheet,
|
||||||
bottomSheetRef.current?.present();
|
onClose: handleClose,
|
||||||
return;
|
});
|
||||||
}
|
|
||||||
bottomSheetRef.current?.dismiss();
|
|
||||||
}, [open, useMobileSheet]);
|
|
||||||
|
|
||||||
const handleSheetChange = useCallback(
|
|
||||||
(index: number) => {
|
|
||||||
if (index === -1) {
|
|
||||||
handleClose();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[handleClose],
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderSheetBackdrop = useCallback(
|
const renderSheetBackdrop = useCallback(
|
||||||
(props: ComponentProps<typeof BottomSheetBackdrop>) => (
|
(props: ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||||
@@ -468,7 +459,7 @@ export function ContextMenuContent({
|
|||||||
if (useMobileSheet) {
|
if (useMobileSheet) {
|
||||||
return (
|
return (
|
||||||
<ContextMenuContext.Provider value={context}>
|
<ContextMenuContext.Provider value={context}>
|
||||||
<BottomSheetModal
|
<IsolatedBottomSheetModal
|
||||||
ref={bottomSheetRef}
|
ref={bottomSheetRef}
|
||||||
index={0}
|
index={0}
|
||||||
snapPoints={sheetSnapPoints}
|
snapPoints={sheetSnapPoints}
|
||||||
@@ -495,7 +486,7 @@ export function ContextMenuContent({
|
|||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</BottomSheetScrollView>
|
</BottomSheetScrollView>
|
||||||
</BottomSheetModal>
|
</IsolatedBottomSheetModal>
|
||||||
</ContextMenuContext.Provider>
|
</ContextMenuContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { cleanup, render } from "@testing-library/react";
|
||||||
|
import React from "react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
IsolatedBottomSheetModal,
|
||||||
|
useIsolatedBottomSheetVisibility,
|
||||||
|
} from "./isolated-bottom-sheet-modal";
|
||||||
|
|
||||||
|
const { modalMethods, modalProps } = vi.hoisted(() => ({
|
||||||
|
modalMethods: {
|
||||||
|
present: vi.fn(),
|
||||||
|
close: vi.fn(),
|
||||||
|
snapToIndex: vi.fn(),
|
||||||
|
dismiss: vi.fn(),
|
||||||
|
},
|
||||||
|
modalProps: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@gorhom/bottom-sheet", async () => {
|
||||||
|
const React = await import("react");
|
||||||
|
const MockBottomSheetModal = React.forwardRef(
|
||||||
|
(props: Record<string, unknown>, ref: React.ForwardedRef<unknown>) => {
|
||||||
|
modalProps(props);
|
||||||
|
React.useImperativeHandle(ref, () => modalMethods);
|
||||||
|
return React.createElement(
|
||||||
|
"div",
|
||||||
|
{ "data-testid": "bottom-sheet" },
|
||||||
|
props.children as ReactNode,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
BottomSheetModal: MockBottomSheetModal,
|
||||||
|
BottomSheetModalProvider: ({ children }: { children: ReactNode }) =>
|
||||||
|
React.createElement("div", { "data-testid": "bottom-sheet-provider" }, children),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@gorhom/portal", async () => {
|
||||||
|
const React = await import("react");
|
||||||
|
return {
|
||||||
|
Portal: ({ children, hostName }: { children: ReactNode; hostName?: string }) =>
|
||||||
|
React.createElement("div", { "data-host": hostName, "data-testid": "app-portal" }, children),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function Harness({ visible, onClose }: { visible: boolean; onClose: () => void }) {
|
||||||
|
const { sheetRef, handleSheetChange } = useIsolatedBottomSheetVisibility({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IsolatedBottomSheetModal
|
||||||
|
ref={sheetRef}
|
||||||
|
index={0}
|
||||||
|
snapPoints={["50%"]}
|
||||||
|
onChange={handleSheetChange}
|
||||||
|
>
|
||||||
|
<div>Sheet content</div>
|
||||||
|
</IsolatedBottomSheetModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("IsolatedBottomSheetModal", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forces sheet isolation and keeps modal content mounted while hidden", () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
const { getByTestId, rerender } = render(<Harness visible={false} onClose={onClose} />);
|
||||||
|
|
||||||
|
expect(getByTestId("app-portal").getAttribute("data-host")).toBe("root");
|
||||||
|
expect(modalProps).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
enableDismissOnClose: false,
|
||||||
|
stackBehavior: "replace",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(modalMethods.present).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
rerender(<Harness visible onClose={onClose} />);
|
||||||
|
expect(modalMethods.present).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
rerender(<Harness visible={false} onClose={onClose} />);
|
||||||
|
expect(modalMethods.close).toHaveBeenCalledTimes(1);
|
||||||
|
expect(modalMethods.dismiss).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
rerender(<Harness visible onClose={onClose} />);
|
||||||
|
expect(modalMethods.present).toHaveBeenCalledTimes(1);
|
||||||
|
expect(modalMethods.snapToIndex).toHaveBeenCalledWith(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only reports a user close when the sheet was visible", () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
const { rerender } = render(<Harness visible onClose={onClose} />);
|
||||||
|
|
||||||
|
const latestProps = modalProps.mock.lastCall?.[0] as { onChange: (index: number) => void };
|
||||||
|
latestProps.onChange(-1);
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
rerender(<Harness visible={false} onClose={onClose} />);
|
||||||
|
const closedProps = modalProps.mock.lastCall?.[0] as { onChange: (index: number) => void };
|
||||||
|
closedProps.onChange(-1);
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows nested sheets inside a parent sheet without creating a sibling provider", () => {
|
||||||
|
const { getAllByTestId } = render(
|
||||||
|
<IsolatedBottomSheetModal index={0} snapPoints={["90%"]}>
|
||||||
|
<IsolatedBottomSheetModal index={0} snapPoints={["60%"]} onChange={() => {}}>
|
||||||
|
<div>Nested model picker</div>
|
||||||
|
</IsolatedBottomSheetModal>
|
||||||
|
</IsolatedBottomSheetModal>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getAllByTestId("bottom-sheet-provider")).toHaveLength(1);
|
||||||
|
expect(modalProps).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
expect.objectContaining({
|
||||||
|
stackBehavior: "replace",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(modalProps).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
expect.objectContaining({
|
||||||
|
stackBehavior: "push",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import {
|
||||||
|
BottomSheetModal as GorhomBottomSheetModal,
|
||||||
|
BottomSheetModalProvider,
|
||||||
|
type BottomSheetModalProps,
|
||||||
|
} from "@gorhom/bottom-sheet";
|
||||||
|
import { Portal } from "@gorhom/portal";
|
||||||
|
import React, { createContext, useContext } from "react";
|
||||||
|
import { forwardRef, useCallback, useEffect, useRef } from "react";
|
||||||
|
import type { ElementRef } from "react";
|
||||||
|
|
||||||
|
type GorhomBottomSheetModalMethods = ElementRef<typeof GorhomBottomSheetModal>;
|
||||||
|
|
||||||
|
type IsolatedBottomSheetModalProps = Omit<
|
||||||
|
BottomSheetModalProps,
|
||||||
|
"enableDismissOnClose" | "stackBehavior"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type IsolatedBottomSheetModalRef = GorhomBottomSheetModalMethods;
|
||||||
|
|
||||||
|
const IsolatedBottomSheetScopeContext = createContext(false);
|
||||||
|
|
||||||
|
export const IsolatedBottomSheetModal = forwardRef<
|
||||||
|
IsolatedBottomSheetModalRef,
|
||||||
|
IsolatedBottomSheetModalProps
|
||||||
|
>(function IsolatedBottomSheetModal(props, ref) {
|
||||||
|
const isNestedSheet = useContext(IsolatedBottomSheetScopeContext);
|
||||||
|
const { children, ...bottomSheetProps } = props;
|
||||||
|
const scopedChildren =
|
||||||
|
typeof children === "function" ? (
|
||||||
|
(input: { data?: unknown }) => (
|
||||||
|
<IsolatedBottomSheetScopeContext.Provider value={true}>
|
||||||
|
{children(input) as React.ReactNode}
|
||||||
|
</IsolatedBottomSheetScopeContext.Provider>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<IsolatedBottomSheetScopeContext.Provider value={true}>
|
||||||
|
{children}
|
||||||
|
</IsolatedBottomSheetScopeContext.Provider>
|
||||||
|
);
|
||||||
|
const modal = (
|
||||||
|
<GorhomBottomSheetModal
|
||||||
|
{...bottomSheetProps}
|
||||||
|
ref={ref}
|
||||||
|
enableDismissOnClose={false}
|
||||||
|
stackBehavior={isNestedSheet ? "push" : "replace"}
|
||||||
|
>
|
||||||
|
{scopedChildren}
|
||||||
|
</GorhomBottomSheetModal>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isNestedSheet) {
|
||||||
|
return modal;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Portal hostName="root">
|
||||||
|
<BottomSheetModalProvider>{modal}</BottomSheetModalProvider>
|
||||||
|
</Portal>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export function useIsolatedBottomSheetVisibility({
|
||||||
|
visible,
|
||||||
|
isEnabled,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
visible: boolean;
|
||||||
|
isEnabled?: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const sheetRef = useRef<IsolatedBottomSheetModalRef>(null);
|
||||||
|
const hasPresentedRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEnabled === false) return;
|
||||||
|
|
||||||
|
if (visible) {
|
||||||
|
if (hasPresentedRef.current) {
|
||||||
|
sheetRef.current?.snapToIndex(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasPresentedRef.current = true;
|
||||||
|
sheetRef.current?.present();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasPresentedRef.current) {
|
||||||
|
sheetRef.current?.close();
|
||||||
|
}
|
||||||
|
}, [isEnabled, visible]);
|
||||||
|
|
||||||
|
const handleSheetChange = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
if (index === -1 && visible) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onClose, visible],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sheetRef,
|
||||||
|
handleSheetChange,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { ActivityIndicator, type ActivityIndicatorProps } from "react-native";
|
||||||
|
|
||||||
|
interface LoadingSpinnerProps {
|
||||||
|
color: string;
|
||||||
|
size?: ActivityIndicatorProps["size"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoadingSpinner({ color, size = "small" }: LoadingSpinnerProps) {
|
||||||
|
return <ActivityIndicator size={size} color={color} />;
|
||||||
|
}
|
||||||
@@ -35,12 +35,13 @@ export function SegmentedControl<T extends string>({
|
|||||||
testID,
|
testID,
|
||||||
}: SegmentedControlProps<T>) {
|
}: SegmentedControlProps<T>) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
|
const containerSizeStyle = size === "sm" ? styles.containerSm : styles.containerMd;
|
||||||
const segmentSizeStyle = size === "sm" ? styles.segmentSm : styles.segmentMd;
|
const segmentSizeStyle = size === "sm" ? styles.segmentSm : styles.segmentMd;
|
||||||
const labelSizeStyle = size === "sm" ? styles.labelSm : styles.labelMd;
|
const labelSizeStyle = size === "sm" ? styles.labelSm : styles.labelMd;
|
||||||
const iconSize = size === "sm" ? theme.iconSize.sm : theme.iconSize.md;
|
const iconSize = size === "sm" ? theme.iconSize.sm : theme.iconSize.md;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, style]} testID={testID}>
|
<View style={[styles.container, containerSizeStyle, style]} testID={testID}>
|
||||||
{options.map((option) => {
|
{options.map((option) => {
|
||||||
const isSelected = option.value === value;
|
const isSelected = option.value === value;
|
||||||
const iconColor = isSelected ? theme.colors.foreground : theme.colors.foregroundMuted;
|
const iconColor = isSelected ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||||
@@ -89,39 +90,46 @@ export function SegmentedControl<T extends string>({
|
|||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
container: {
|
container: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "stretch",
|
||||||
maxWidth: "100%",
|
maxWidth: "100%",
|
||||||
gap: 4,
|
backgroundColor: theme.colors.surface2,
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
},
|
||||||
|
containerSm: {
|
||||||
|
padding: 2,
|
||||||
|
},
|
||||||
|
containerMd: {
|
||||||
|
padding: 3,
|
||||||
},
|
},
|
||||||
segment: {
|
segment: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
borderRadius: theme.borderRadius.full,
|
borderRadius: theme.borderRadius.lg,
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: "transparent",
|
|
||||||
gap: theme.spacing[1],
|
gap: theme.spacing[1],
|
||||||
},
|
},
|
||||||
segmentSm: {
|
segmentSm: {
|
||||||
paddingVertical: theme.spacing[1],
|
paddingVertical: theme.spacing[2],
|
||||||
paddingHorizontal: theme.spacing[2],
|
paddingHorizontal: theme.spacing[4],
|
||||||
},
|
},
|
||||||
segmentMd: {
|
segmentMd: {
|
||||||
paddingVertical: theme.spacing[2],
|
paddingVertical: theme.spacing[3],
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[6],
|
||||||
},
|
},
|
||||||
segmentSelected: {
|
segmentSelected: {
|
||||||
backgroundColor: theme.colors.surface3,
|
backgroundColor: theme.colors.surface0,
|
||||||
borderColor: theme.colors.border,
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
shadowOpacity: 0.08,
|
||||||
|
shadowRadius: 2,
|
||||||
|
elevation: 1,
|
||||||
},
|
},
|
||||||
segmentHover: {
|
segmentHover: {
|
||||||
backgroundColor: theme.colors.surface3,
|
backgroundColor: theme.colors.surface1,
|
||||||
borderColor: theme.colors.borderAccent,
|
|
||||||
},
|
},
|
||||||
segmentPressed: {
|
segmentPressed: {
|
||||||
backgroundColor: theme.colors.surface3,
|
backgroundColor: theme.colors.surface1,
|
||||||
borderColor: theme.colors.borderAccent,
|
|
||||||
},
|
},
|
||||||
segmentDisabled: {
|
segmentDisabled: {
|
||||||
opacity: theme.opacity[50],
|
opacity: theme.opacity[50],
|
||||||
@@ -135,10 +143,10 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontWeight: theme.fontWeight.normal,
|
fontWeight: theme.fontWeight.normal,
|
||||||
},
|
},
|
||||||
labelSm: {
|
labelSm: {
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.sm,
|
||||||
},
|
},
|
||||||
labelMd: {
|
labelMd: {
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.base,
|
||||||
},
|
},
|
||||||
labelSelected: {
|
labelSelected: {
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
||||||
import { Pressable, Text, View, ScrollView } from "react-native";
|
import { Pressable, Text, View, ScrollView } from "react-native";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { QrCode, Link2, ClipboardPaste, ExternalLink, Settings } from "lucide-react-native";
|
import { QrCode, Link2, ClipboardPaste, ExternalLink, Settings } from "lucide-react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import type { HostProfile } from "@/types/host-connection";
|
import type { HostProfile } from "@/types/host-connection";
|
||||||
@@ -21,8 +21,6 @@ import { PaseoLogo } from "@/components/icons/paseo-logo";
|
|||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
import { isWeb, isNative } from "@/constants/platform";
|
import { isWeb, isNative } from "@/constants/platform";
|
||||||
|
|
||||||
const ThemedScrollView = withUnistyles(ScrollView);
|
|
||||||
|
|
||||||
type WelcomeAction = {
|
type WelcomeAction = {
|
||||||
key: "scan-qr" | "direct-connection" | "paste-pairing-link";
|
key: "scan-qr" | "direct-connection" | "paste-pairing-link";
|
||||||
label: string;
|
label: string;
|
||||||
@@ -33,13 +31,15 @@ type WelcomeAction = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
scrollView: {
|
root: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: theme.colors.surface0,
|
backgroundColor: theme.colors.surface0,
|
||||||
},
|
},
|
||||||
|
scrollView: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
container: {
|
container: {
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
backgroundColor: theme.colors.surface0,
|
|
||||||
padding: theme.spacing[6],
|
padding: theme.spacing[6],
|
||||||
paddingBottom: 0,
|
paddingBottom: 0,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -309,97 +309,101 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
|||||||
const showHostList = hosts.length > 0 && !anyOnlineServerId;
|
const showHostList = hosts.length > 0 && !anyOnlineServerId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemedScrollView
|
<View style={styles.root}>
|
||||||
style={styles.scrollView}
|
<ScrollView
|
||||||
contentContainerStyle={[
|
style={styles.scrollView}
|
||||||
styles.container,
|
contentContainerStyle={[
|
||||||
{ paddingBottom: theme.spacing[6] + insets.bottom },
|
styles.container,
|
||||||
]}
|
{ paddingBottom: theme.spacing[6] + insets.bottom },
|
||||||
showsVerticalScrollIndicator={false}
|
]}
|
||||||
testID="welcome-screen"
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
testID="welcome-screen"
|
||||||
<View style={styles.content}>
|
>
|
||||||
<PaseoLogo size={96} />
|
<View style={styles.content}>
|
||||||
<View style={styles.copyBlock}>
|
<PaseoLogo size={96} />
|
||||||
<Text style={styles.title}>Welcome to Paseo</Text>
|
<View style={styles.copyBlock}>
|
||||||
{showHostList ? (
|
<Text style={styles.title}>Welcome to Paseo</Text>
|
||||||
<Text style={styles.subtitle}>Connecting to your hosts…</Text>
|
{showHostList ? (
|
||||||
) : (
|
<Text style={styles.subtitle}>Connecting to your hosts…</Text>
|
||||||
<>
|
) : (
|
||||||
<Text style={styles.subtitle}>Connect your computer to get started</Text>
|
<>
|
||||||
{isNative ? (
|
<Text style={styles.subtitle}>Connect your computer to get started</Text>
|
||||||
<Pressable
|
{isNative ? (
|
||||||
style={styles.setupLink}
|
<Pressable
|
||||||
onPress={() => openExternalUrl("https://paseo.sh")}
|
style={styles.setupLink}
|
||||||
>
|
onPress={() => openExternalUrl("https://paseo.sh")}
|
||||||
<Text style={styles.setupLinkText}>paseo.sh</Text>
|
>
|
||||||
<ExternalLink size={14} color={theme.colors.accent} />
|
<Text style={styles.setupLinkText}>paseo.sh</Text>
|
||||||
</Pressable>
|
<ExternalLink size={14} color={theme.colors.accent} />
|
||||||
) : null}
|
</Pressable>
|
||||||
</>
|
) : null}
|
||||||
)}
|
</>
|
||||||
</View>
|
)}
|
||||||
|
|
||||||
<View style={styles.actions}>
|
|
||||||
{actions.map((action) => {
|
|
||||||
const Icon = action.icon;
|
|
||||||
return (
|
|
||||||
<Pressable
|
|
||||||
key={action.key}
|
|
||||||
style={[styles.actionButton, action.primary ? styles.actionButtonPrimary : null]}
|
|
||||||
onPress={action.onPress}
|
|
||||||
testID={action.testID}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
size={18}
|
|
||||||
color={action.primary ? theme.colors.accentForeground : theme.colors.foreground}
|
|
||||||
/>
|
|
||||||
<Text style={[styles.actionText, action.primary ? styles.actionTextPrimary : null]}>
|
|
||||||
{action.label}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{showHostList && (
|
|
||||||
<View style={styles.hostList}>
|
|
||||||
{hosts.map((host) => (
|
|
||||||
<HostStatusRow key={host.serverId} serverId={host.serverId} label={host.label} />
|
|
||||||
))}
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
<View style={styles.actions}>
|
||||||
variant="ghost"
|
{actions.map((action) => {
|
||||||
size="sm"
|
const Icon = action.icon;
|
||||||
leftIcon={Settings}
|
return (
|
||||||
onPress={() => router.push("/settings")}
|
<Pressable
|
||||||
style={styles.settingsButton}
|
key={action.key}
|
||||||
testID="welcome-open-settings"
|
style={[styles.actionButton, action.primary ? styles.actionButtonPrimary : null]}
|
||||||
>
|
onPress={action.onPress}
|
||||||
Settings
|
testID={action.testID}
|
||||||
</Button>
|
>
|
||||||
</View>
|
<Icon
|
||||||
<Text style={styles.versionLabel}>{appVersionText}</Text>
|
size={18}
|
||||||
|
color={action.primary ? theme.colors.accentForeground : theme.colors.foreground}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[styles.actionText, action.primary ? styles.actionTextPrimary : null]}
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
|
||||||
<AddHostModal
|
{showHostList && (
|
||||||
visible={isDirectOpen}
|
<View style={styles.hostList}>
|
||||||
onClose={() => setIsDirectOpen(false)}
|
{hosts.map((host) => (
|
||||||
onSaved={({ profile, serverId }) => {
|
<HostStatusRow key={host.serverId} serverId={host.serverId} label={host.label} />
|
||||||
onHostAdded?.(profile);
|
))}
|
||||||
finishOnboarding(serverId);
|
</View>
|
||||||
}}
|
)}
|
||||||
/>
|
|
||||||
|
|
||||||
<PairLinkModal
|
<Button
|
||||||
visible={isPasteLinkOpen}
|
variant="ghost"
|
||||||
onClose={() => setIsPasteLinkOpen(false)}
|
size="sm"
|
||||||
onSaved={({ profile, serverId }) => {
|
leftIcon={Settings}
|
||||||
onHostAdded?.(profile);
|
onPress={() => router.push("/settings")}
|
||||||
finishOnboarding(serverId);
|
style={styles.settingsButton}
|
||||||
}}
|
testID="welcome-open-settings"
|
||||||
/>
|
>
|
||||||
</ThemedScrollView>
|
Settings
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.versionLabel}>{appVersionText}</Text>
|
||||||
|
|
||||||
|
<AddHostModal
|
||||||
|
visible={isDirectOpen}
|
||||||
|
onClose={() => setIsDirectOpen(false)}
|
||||||
|
onSaved={({ profile, serverId }) => {
|
||||||
|
onHostAdded?.(profile);
|
||||||
|
finishOnboarding(serverId);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PairLinkModal
|
||||||
|
visible={isPasteLinkOpen}
|
||||||
|
onClose={() => setIsPasteLinkOpen(false)}
|
||||||
|
onSaved={({ profile, serverId }) => {
|
||||||
|
onHostAdded?.(profile);
|
||||||
|
finishOnboarding(serverId);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -306,14 +306,24 @@ function WorkspaceHoverCardContent({
|
|||||||
badgeLabel = `${checks.length} passed`;
|
badgeLabel = `${checks.length} passed`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const iconColor = hovered
|
||||||
|
? theme.colors.foreground
|
||||||
|
: theme.colors.foregroundMuted;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{hovered ? (
|
{hovered ? (
|
||||||
<ExternalLink size={12} color={theme.colors.foregroundMuted} />
|
<ExternalLink size={12} color={iconColor} />
|
||||||
) : (
|
) : (
|
||||||
<GitHubIcon size={12} color={theme.colors.foregroundMuted} />
|
<GitHubIcon size={12} color={iconColor} />
|
||||||
)}
|
)}
|
||||||
<Text style={styles.checksSummaryLabel}>Checks</Text>
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.checksSummaryLabel,
|
||||||
|
hovered && styles.checksSummaryLabelHovered,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Checks
|
||||||
|
</Text>
|
||||||
<View style={styles.checksSummaryCounts}>
|
<View style={styles.checksSummaryCounts}>
|
||||||
<View style={[styles.checksDot, { backgroundColor: badgeColor }]} />
|
<View style={[styles.checksDot, { backgroundColor: badgeColor }]} />
|
||||||
<Text style={[styles.checksStatusText, { color: badgeColor }]}>
|
<Text style={[styles.checksStatusText, { color: badgeColor }]}>
|
||||||
@@ -395,6 +405,9 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontWeight: theme.fontWeight.normal,
|
fontWeight: theme.fontWeight.normal,
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
},
|
},
|
||||||
|
checksSummaryLabelHovered: {
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
},
|
||||||
checksSummaryCounts: {
|
checksSummaryCounts: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -179,6 +179,9 @@ export function WorkspaceSetupDialog() {
|
|||||||
if (!composerState) {
|
if (!composerState) {
|
||||||
throw new Error("Workspace setup composer state is required");
|
throw new Error("Workspace setup composer state is required");
|
||||||
}
|
}
|
||||||
|
if (!composerState.selectedProvider) {
|
||||||
|
throw new Error("Select a model");
|
||||||
|
}
|
||||||
|
|
||||||
const wirePayload = splitComposerAttachmentsForSubmit(attachments);
|
const wirePayload = splitComposerAttachmentsForSubmit(attachments);
|
||||||
const encodedImages = await encodeImages(wirePayload.images);
|
const encodedImages = await encodeImages(wirePayload.images);
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*/
|
||||||
|
import React from "react";
|
||||||
|
import { act } from "@testing-library/react";
|
||||||
|
import type { DaemonClient } from "@server/client/daemon-client";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||||
|
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||||
|
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
||||||
|
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||||
|
import { WorkspaceShortcutTargetsSubscriber } from "./workspace-shortcut-targets-subscriber";
|
||||||
|
|
||||||
|
vi.hoisted(() => {
|
||||||
|
(globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
function workspaceDescriptor(input: {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
projectId?: string;
|
||||||
|
}): WorkspaceDescriptor {
|
||||||
|
return {
|
||||||
|
id: input.id,
|
||||||
|
projectId: input.projectId ?? "project-1",
|
||||||
|
projectDisplayName: "Project 1",
|
||||||
|
projectRootPath: "/repo/main",
|
||||||
|
workspaceDirectory: `/repo/main/${input.id}`,
|
||||||
|
projectKind: "git",
|
||||||
|
workspaceKind: "worktree",
|
||||||
|
name: input.name ?? input.id,
|
||||||
|
status: "done",
|
||||||
|
diffStat: null,
|
||||||
|
scripts: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("WorkspaceShortcutTargetsSubscriber", () => {
|
||||||
|
let root: Root | null = null;
|
||||||
|
let container: HTMLElement | null = null;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
|
||||||
|
useKeyboardShortcutsStore.setState({
|
||||||
|
sidebarShortcutWorkspaceTargets: [],
|
||||||
|
});
|
||||||
|
useSidebarCollapsedSectionsStore.setState({
|
||||||
|
collapsedProjectKeys: new Set(),
|
||||||
|
});
|
||||||
|
useSidebarOrderStore.setState({
|
||||||
|
projectOrderByServerId: {},
|
||||||
|
workspaceOrderByServerAndProject: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
useSessionStore.getState().initializeSession("srv", null as unknown as DaemonClient);
|
||||||
|
useSessionStore.getState().setWorkspaces(
|
||||||
|
"srv",
|
||||||
|
new Map([
|
||||||
|
["ws-1", workspaceDescriptor({ id: "ws-1", name: "Workspace 1" })],
|
||||||
|
["ws-2", workspaceDescriptor({ id: "ws-2", name: "Workspace 2" })],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
useSessionStore.getState().setHasHydratedWorkspaces("srv", true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (root) {
|
||||||
|
act(() => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
root = null;
|
||||||
|
container?.remove();
|
||||||
|
container = null;
|
||||||
|
act(() => {
|
||||||
|
useSessionStore.getState().clearSession("srv");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("publishes workspace shortcut targets without rendering the sidebar", async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={true} serverId="srv" />);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([
|
||||||
|
{ serverId: "srv", workspaceId: "ws-1" },
|
||||||
|
{ serverId: "srv", workspaceId: "ws-2" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears targets when disabled", async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={true} serverId="srv" />);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={false} serverId="srv" />);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { useEffect, useMemo } from "react";
|
||||||
|
import { useSidebarWorkspacesList } from "@/hooks/use-sidebar-workspaces-list";
|
||||||
|
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||||
|
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
||||||
|
import { buildSidebarShortcutModel } from "@/utils/sidebar-shortcuts";
|
||||||
|
|
||||||
|
export function WorkspaceShortcutTargetsSubscriber({
|
||||||
|
enabled,
|
||||||
|
serverId,
|
||||||
|
}: {
|
||||||
|
enabled: boolean;
|
||||||
|
serverId: string | null;
|
||||||
|
}) {
|
||||||
|
const { projects } = useSidebarWorkspacesList({ serverId, enabled });
|
||||||
|
const collapsedProjectKeys = useSidebarCollapsedSectionsStore(
|
||||||
|
(state) => state.collapsedProjectKeys,
|
||||||
|
);
|
||||||
|
const setSidebarShortcutWorkspaceTargets = useKeyboardShortcutsStore(
|
||||||
|
(state) => state.setSidebarShortcutWorkspaceTargets,
|
||||||
|
);
|
||||||
|
|
||||||
|
const shortcutModel = useMemo(
|
||||||
|
() =>
|
||||||
|
buildSidebarShortcutModel({
|
||||||
|
projects,
|
||||||
|
collapsedProjectKeys,
|
||||||
|
}),
|
||||||
|
[collapsedProjectKeys, projects],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || !serverId) {
|
||||||
|
setSidebarShortcutWorkspaceTargets([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSidebarShortcutWorkspaceTargets(shortcutModel.shortcutTargets);
|
||||||
|
}, [enabled, serverId, setSidebarShortcutWorkspaceTargets, shortcutModel.shortcutTargets]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
setSidebarShortcutWorkspaceTargets([]);
|
||||||
|
};
|
||||||
|
}, [setSidebarShortcutWorkspaceTargets]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { isElectronRuntime, isElectronRuntimeMac } from "@/desktop/host";
|
|||||||
// Default is cross-platform. Gate only when you must:
|
// Default is cross-platform. Gate only when you must:
|
||||||
// isWeb → DOM APIs (document, window, <div>, addEventListener)
|
// isWeb → DOM APIs (document, window, <div>, addEventListener)
|
||||||
// isNative → Native-only APIs (Haptics, StatusBar, push tokens, camera)
|
// isNative → Native-only APIs (Haptics, StatusBar, push tokens, camera)
|
||||||
|
// isDev → Development-only diagnostics and instrumentation
|
||||||
// isElectron → Desktop wrapper features (file dialogs, titlebar, updates)
|
// isElectron → Desktop wrapper features (file dialogs, titlebar, updates)
|
||||||
//
|
//
|
||||||
// For layout decisions, use useIsCompactFormFactor() from constants/layout.ts.
|
// For layout decisions, use useIsCompactFormFactor() from constants/layout.ts.
|
||||||
@@ -22,6 +23,9 @@ export const isWeb = Platform.OS === "web";
|
|||||||
/** iOS or Android — the JS runtime is React Native. */
|
/** iOS or Android — the JS runtime is React Native. */
|
||||||
export const isNative = Platform.OS !== "web";
|
export const isNative = Platform.OS !== "web";
|
||||||
|
|
||||||
|
/** Development build/runtime — true in Metro dev bundles, false in production. */
|
||||||
|
export const isDev = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Electron detection (cached — only caches `true`, keeps checking if false
|
// Electron detection (cached — only caches `true`, keeps checking if false
|
||||||
// because the desktop bridge may load after initial module evaluation)
|
// because the desktop bridge may load after initial module evaluation)
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import { createContext, useContext, useEffect, useRef, type ReactNode } from "react";
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
import { useWindowDimensions } from "react-native";
|
import { useWindowDimensions } from "react-native";
|
||||||
import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated";
|
import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated";
|
||||||
import { type GestureType } from "react-native-gesture-handler";
|
import { type GestureType } from "react-native-gesture-handler";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
|
||||||
import {
|
import {
|
||||||
getRightSidebarAnimationTargets,
|
getRightSidebarAnimationTargets,
|
||||||
shouldSyncSidebarAnimation,
|
shouldSyncSidebarAnimation,
|
||||||
@@ -30,11 +38,9 @@ const ExplorerSidebarAnimationContext = createContext<ExplorerSidebarAnimationCo
|
|||||||
export function ExplorerSidebarAnimationProvider({ children }: { children: ReactNode }) {
|
export function ExplorerSidebarAnimationProvider({ children }: { children: ReactNode }) {
|
||||||
const { width: windowWidth } = useWindowDimensions();
|
const { width: windowWidth } = useWindowDimensions();
|
||||||
const isCompactLayout = useIsCompactFormFactor();
|
const isCompactLayout = useIsCompactFormFactor();
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const isOpen = usePanelStore((state) =>
|
||||||
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
selectIsFileExplorerOpen(state, { isCompact: isCompactLayout }),
|
||||||
|
);
|
||||||
// Derive isOpen from the unified panel state
|
|
||||||
const isOpen = isCompactLayout ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
|
||||||
|
|
||||||
// Right sidebar: closed = +windowWidth (off-screen right), open = 0
|
// Right sidebar: closed = +windowWidth (off-screen right), open = 0
|
||||||
const initialTargets = getRightSidebarAnimationTargets({ isOpen, windowWidth });
|
const initialTargets = getRightSidebarAnimationTargets({ isOpen, windowWidth });
|
||||||
@@ -93,7 +99,7 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
|
|||||||
backdropOpacity.value = targets.backdropOpacity;
|
backdropOpacity.value = targets.backdropOpacity;
|
||||||
}, [isOpen, translateX, backdropOpacity, windowWidth, isGesturing]);
|
}, [isOpen, translateX, backdropOpacity, windowWidth, isGesturing]);
|
||||||
|
|
||||||
const animateToOpen = () => {
|
const animateToOpen = useCallback(() => {
|
||||||
"worklet";
|
"worklet";
|
||||||
translateX.value = withTiming(0, {
|
translateX.value = withTiming(0, {
|
||||||
duration: ANIMATION_DURATION,
|
duration: ANIMATION_DURATION,
|
||||||
@@ -103,9 +109,9 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
|
|||||||
duration: ANIMATION_DURATION,
|
duration: ANIMATION_DURATION,
|
||||||
easing: ANIMATION_EASING,
|
easing: ANIMATION_EASING,
|
||||||
});
|
});
|
||||||
};
|
}, [translateX, backdropOpacity]);
|
||||||
|
|
||||||
const animateToClose = () => {
|
const animateToClose = useCallback(() => {
|
||||||
"worklet";
|
"worklet";
|
||||||
translateX.value = withTiming(windowWidth, {
|
translateX.value = withTiming(windowWidth, {
|
||||||
duration: ANIMATION_DURATION,
|
duration: ANIMATION_DURATION,
|
||||||
@@ -115,22 +121,25 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
|
|||||||
duration: ANIMATION_DURATION,
|
duration: ANIMATION_DURATION,
|
||||||
easing: ANIMATION_EASING,
|
easing: ANIMATION_EASING,
|
||||||
});
|
});
|
||||||
};
|
}, [translateX, backdropOpacity, windowWidth]);
|
||||||
|
|
||||||
|
const value = useMemo<ExplorerSidebarAnimationContextValue>(
|
||||||
|
() => ({
|
||||||
|
translateX,
|
||||||
|
backdropOpacity,
|
||||||
|
windowWidth,
|
||||||
|
animateToOpen,
|
||||||
|
animateToClose,
|
||||||
|
isGesturing,
|
||||||
|
gestureAnimatingRef,
|
||||||
|
openGestureRef,
|
||||||
|
closeGestureRef,
|
||||||
|
}),
|
||||||
|
[translateX, backdropOpacity, windowWidth, animateToOpen, animateToClose, isGesturing],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ExplorerSidebarAnimationContext.Provider
|
<ExplorerSidebarAnimationContext.Provider value={value}>
|
||||||
value={{
|
|
||||||
translateX,
|
|
||||||
backdropOpacity,
|
|
||||||
windowWidth,
|
|
||||||
animateToOpen,
|
|
||||||
animateToClose,
|
|
||||||
isGesturing,
|
|
||||||
gestureAnimatingRef,
|
|
||||||
openGestureRef,
|
|
||||||
closeGestureRef,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</ExplorerSidebarAnimationContext.Provider>
|
</ExplorerSidebarAnimationContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ import {
|
|||||||
type Agent,
|
type Agent,
|
||||||
type SessionState,
|
type SessionState,
|
||||||
type WorkspaceDescriptor,
|
type WorkspaceDescriptor,
|
||||||
mergeWorkspaceSnapshotWithExisting,
|
|
||||||
normalizeWorkspaceDescriptor,
|
normalizeWorkspaceDescriptor,
|
||||||
} from "@/stores/session-store";
|
} from "@/stores/session-store";
|
||||||
import { useDraftStore } from "@/stores/draft-store";
|
import { useDraftStore } from "@/stores/draft-store";
|
||||||
@@ -348,7 +347,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
const workspaces = new Map<string, WorkspaceDescriptor>();
|
const workspaces = new Map<string, WorkspaceDescriptor>();
|
||||||
const existingWorkspaces = useSessionStore.getState().sessions[serverId]?.workspaces;
|
|
||||||
let cursor: string | null = null;
|
let cursor: string | null = null;
|
||||||
let includeSubscribe = options?.subscribe ?? false;
|
let includeSubscribe = options?.subscribe ?? false;
|
||||||
|
|
||||||
@@ -364,13 +362,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
|
|
||||||
for (const entry of payload.entries) {
|
for (const entry of payload.entries) {
|
||||||
const workspace = normalizeWorkspaceDescriptor(entry);
|
const workspace = normalizeWorkspaceDescriptor(entry);
|
||||||
workspaces.set(
|
workspaces.set(workspace.id, workspace);
|
||||||
workspace.id,
|
|
||||||
mergeWorkspaceSnapshotWithExisting({
|
|
||||||
incoming: workspace,
|
|
||||||
existing: existingWorkspaces?.get(workspace.id),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
|
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { useWindowDimensions } from "react-native";
|
|||||||
import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated";
|
import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated";
|
||||||
import { type GestureType } from "react-native-gesture-handler";
|
import { type GestureType } from "react-native-gesture-handler";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
|
||||||
import {
|
import {
|
||||||
getLeftSidebarAnimationTargets,
|
getLeftSidebarAnimationTargets,
|
||||||
shouldSyncSidebarAnimation,
|
shouldSyncSidebarAnimation,
|
||||||
@@ -37,11 +37,9 @@ const SidebarAnimationContext = createContext<SidebarAnimationContextValue | nul
|
|||||||
export function SidebarAnimationProvider({ children }: { children: ReactNode }) {
|
export function SidebarAnimationProvider({ children }: { children: ReactNode }) {
|
||||||
const { width: windowWidth } = useWindowDimensions();
|
const { width: windowWidth } = useWindowDimensions();
|
||||||
const isCompactLayout = useIsCompactFormFactor();
|
const isCompactLayout = useIsCompactFormFactor();
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const isOpen = usePanelStore((state) =>
|
||||||
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
selectIsAgentListOpen(state, { isCompact: isCompactLayout }),
|
||||||
|
);
|
||||||
// Derive isOpen from the unified panel state
|
|
||||||
const isOpen = isCompactLayout ? mobileView === "agent-list" : desktopAgentListOpen;
|
|
||||||
|
|
||||||
// Initialize based on current state
|
// Initialize based on current state
|
||||||
const initialTargets = getLeftSidebarAnimationTargets({ isOpen, windowWidth });
|
const initialTargets = getLeftSidebarAnimationTargets({ isOpen, windowWidth });
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export interface DesktopAppUpdateInstallResult {
|
|||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DesktopReleaseChannel = "stable" | "beta";
|
||||||
|
|
||||||
export interface LocalDaemonUpdateResult {
|
export interface LocalDaemonUpdateResult {
|
||||||
exitCode: number;
|
exitCode: number;
|
||||||
stdout: string;
|
stdout: string;
|
||||||
@@ -69,8 +71,12 @@ export async function getLocalDaemonVersion(): Promise<LocalDaemonVersionResult>
|
|||||||
return parseLocalDaemonVersionResult(result);
|
return parseLocalDaemonVersionResult(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkDesktopAppUpdate(): Promise<DesktopAppUpdateCheckResult> {
|
export async function checkDesktopAppUpdate({
|
||||||
const result = await invokeDesktopCommand<unknown>("check_app_update");
|
releaseChannel,
|
||||||
|
}: {
|
||||||
|
releaseChannel: DesktopReleaseChannel;
|
||||||
|
}): Promise<DesktopAppUpdateCheckResult> {
|
||||||
|
const result = await invokeDesktopCommand<unknown>("check_app_update", { releaseChannel });
|
||||||
if (!isRecord(result)) {
|
if (!isRecord(result)) {
|
||||||
throw new Error("Unexpected response while checking desktop updates.");
|
throw new Error("Unexpected response while checking desktop updates.");
|
||||||
}
|
}
|
||||||
@@ -85,8 +91,12 @@ export async function checkDesktopAppUpdate(): Promise<DesktopAppUpdateCheckResu
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function installDesktopAppUpdate(): Promise<DesktopAppUpdateInstallResult> {
|
export async function installDesktopAppUpdate({
|
||||||
const result = await invokeDesktopCommand<unknown>("install_app_update");
|
releaseChannel,
|
||||||
|
}: {
|
||||||
|
releaseChannel: DesktopReleaseChannel;
|
||||||
|
}): Promise<DesktopAppUpdateInstallResult> {
|
||||||
|
const result = await invokeDesktopCommand<unknown>("install_app_update", { releaseChannel });
|
||||||
if (!isRecord(result)) {
|
if (!isRecord(result)) {
|
||||||
throw new Error("Unexpected response while installing desktop update.");
|
throw new Error("Unexpected response while installing desktop update.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
type DesktopAppUpdateCheckResult,
|
type DesktopAppUpdateCheckResult,
|
||||||
type DesktopAppUpdateInstallResult,
|
type DesktopAppUpdateInstallResult,
|
||||||
} from "@/desktop/updates/desktop-updates";
|
} from "@/desktop/updates/desktop-updates";
|
||||||
|
import { useAppSettings } from "@/hooks/use-settings";
|
||||||
|
|
||||||
export type DesktopAppUpdateStatus =
|
export type DesktopAppUpdateStatus =
|
||||||
| "idle"
|
| "idle"
|
||||||
@@ -83,6 +84,8 @@ function formatStatusText(input: {
|
|||||||
|
|
||||||
export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
|
export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
|
||||||
const isDesktopApp = shouldShowDesktopUpdateSection();
|
const isDesktopApp = shouldShowDesktopUpdateSection();
|
||||||
|
const { settings } = useAppSettings();
|
||||||
|
const releaseChannel = settings.releaseChannel;
|
||||||
const requestVersionRef = useRef(0);
|
const requestVersionRef = useRef(0);
|
||||||
const [status, setStatus] = useState<DesktopAppUpdateStatus>("idle");
|
const [status, setStatus] = useState<DesktopAppUpdateStatus>("idle");
|
||||||
const [availableUpdate, setAvailableUpdate] = useState<DesktopAppUpdateCheckResult | null>(null);
|
const [availableUpdate, setAvailableUpdate] = useState<DesktopAppUpdateCheckResult | null>(null);
|
||||||
@@ -105,7 +108,7 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
|
|||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await checkDesktopAppUpdate();
|
const result = await checkDesktopAppUpdate({ releaseChannel });
|
||||||
if (requestVersion !== requestVersionRef.current) {
|
if (requestVersion !== requestVersionRef.current) {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -140,9 +143,17 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isDesktopApp],
|
[isDesktopApp, releaseChannel],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isDesktopApp) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void checkForUpdates({ silent: true });
|
||||||
|
}, [checkForUpdates, isDesktopApp]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isDesktopApp || status !== "pending") {
|
if (!isDesktopApp || status !== "pending") {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -166,7 +177,7 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
|
|||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await installDesktopAppUpdate();
|
const result = await installDesktopAppUpdate({ releaseChannel });
|
||||||
setLastCheckedAt(Date.now());
|
setLastCheckedAt(Date.now());
|
||||||
|
|
||||||
if (result.installed) {
|
if (result.installed) {
|
||||||
@@ -186,7 +197,7 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
|
|||||||
setErrorMessage(message);
|
setErrorMessage(message);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}, [isDesktopApp]);
|
}, [isDesktopApp, releaseChannel]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isDesktopApp,
|
isDesktopApp,
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { usePathname } from "expo-router";
|
||||||
|
import { useHosts } from "@/runtime/host-runtime";
|
||||||
|
import { resolveActiveHost } from "@/utils/active-host";
|
||||||
|
|
||||||
|
export function useActiveHost() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const hosts = useHosts();
|
||||||
|
|
||||||
|
return useMemo(() => resolveActiveHost({ hosts, pathname }), [hosts, pathname]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useActiveServerId(): string | null {
|
||||||
|
return useActiveHost()?.serverId ?? null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import { JSDOM } from "jsdom";
|
||||||
|
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { AgentProvider, ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
|
||||||
|
import type { FormPreferences } from "./use-form-preferences";
|
||||||
|
import { useAgentFormState } from "./use-agent-form-state";
|
||||||
|
|
||||||
|
const { mocks } = vi.hoisted(() => ({
|
||||||
|
mocks: {
|
||||||
|
preferences: {} as FormPreferences,
|
||||||
|
isPreferencesLoading: false,
|
||||||
|
snapshotEntries: [] as ProviderSnapshotEntry[],
|
||||||
|
updatePreferences: vi.fn(),
|
||||||
|
refreshSnapshot: vi.fn(),
|
||||||
|
refetchSnapshotIfStale: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/runtime/host-runtime", () => ({
|
||||||
|
useHosts: () => [{ serverId: "host-1" }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./use-form-preferences", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("./use-form-preferences")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useFormPreferences: () => ({
|
||||||
|
preferences: mocks.preferences,
|
||||||
|
isLoading: mocks.isPreferencesLoading,
|
||||||
|
updatePreferences: mocks.updatePreferences,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("./use-providers-snapshot", () => ({
|
||||||
|
useProvidersSnapshot: () => ({
|
||||||
|
entries: mocks.snapshotEntries,
|
||||||
|
isLoading: false,
|
||||||
|
isFetching: false,
|
||||||
|
isRefreshing: false,
|
||||||
|
error: null,
|
||||||
|
supportsSnapshot: true,
|
||||||
|
refresh: mocks.refreshSnapshot,
|
||||||
|
refetchIfStale: mocks.refetchSnapshotIfStale,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
||||||
|
value: true,
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useAgentFormState live preference hydration", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
|
||||||
|
url: "http://localhost",
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, "document", {
|
||||||
|
value: dom.window.document,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, "window", {
|
||||||
|
value: dom.window,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, "navigator", {
|
||||||
|
value: dom.window.navigator,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
mocks.preferences = {};
|
||||||
|
mocks.isPreferencesLoading = false;
|
||||||
|
mocks.snapshotEntries = [
|
||||||
|
{
|
||||||
|
provider: "codex",
|
||||||
|
status: "ready",
|
||||||
|
label: "Codex",
|
||||||
|
description: "Codex test provider",
|
||||||
|
defaultModeId: "auto",
|
||||||
|
modes: [
|
||||||
|
{ id: "auto", label: "Auto", icon: "ShieldAlert", colorTier: "moderate" },
|
||||||
|
{
|
||||||
|
id: "full-access",
|
||||||
|
label: "Full Access",
|
||||||
|
icon: "ShieldAlert",
|
||||||
|
colorTier: "dangerous",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
provider: "codex",
|
||||||
|
id: "gpt-5.4",
|
||||||
|
label: "gpt-5.4",
|
||||||
|
isDefault: true,
|
||||||
|
defaultThinkingOptionId: "low",
|
||||||
|
thinkingOptions: [
|
||||||
|
{ id: "low", label: "Low" },
|
||||||
|
{ id: "xhigh", label: "XHigh" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: "claude",
|
||||||
|
status: "ready",
|
||||||
|
label: "Claude",
|
||||||
|
description: "Claude test provider",
|
||||||
|
defaultModeId: "default",
|
||||||
|
modes: [{ id: "default", label: "Default", icon: "ShieldCheck", colorTier: "safe" }],
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
provider: "claude",
|
||||||
|
id: "claude-sonnet-4-6",
|
||||||
|
label: "Sonnet 4.6",
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
mocks.updatePreferences.mockReset();
|
||||||
|
mocks.refreshSnapshot.mockReset();
|
||||||
|
mocks.refetchSnapshotIfStale.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hydrates from stored preferences once and ignores later preference writes from other composers", async () => {
|
||||||
|
mocks.preferences = {
|
||||||
|
provider: "codex",
|
||||||
|
providerPreferences: {
|
||||||
|
codex: {
|
||||||
|
model: "gpt-5.4",
|
||||||
|
mode: "full-access",
|
||||||
|
thinkingByModel: {
|
||||||
|
"gpt-5.4": "xhigh",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const { result, rerender } = renderHook(() =>
|
||||||
|
useAgentFormState({
|
||||||
|
initialServerId: "host-1",
|
||||||
|
isVisible: true,
|
||||||
|
onlineServerIds: ["host-1"],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.selectedProvider).toBe("codex");
|
||||||
|
expect(result.current.selectedModel).toBe("gpt-5.4");
|
||||||
|
expect(result.current.selectedMode).toBe("full-access");
|
||||||
|
expect(result.current.selectedThinkingOptionId).toBe("xhigh");
|
||||||
|
});
|
||||||
|
|
||||||
|
mocks.preferences = {
|
||||||
|
provider: "claude",
|
||||||
|
providerPreferences: {
|
||||||
|
claude: {
|
||||||
|
model: "claude-sonnet-4-6",
|
||||||
|
mode: "default",
|
||||||
|
},
|
||||||
|
codex: {
|
||||||
|
model: "gpt-5.4",
|
||||||
|
mode: "auto",
|
||||||
|
thinkingByModel: {
|
||||||
|
"gpt-5.4": "low",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
rerender();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.selectedProvider).toBe("codex");
|
||||||
|
expect(result.current.selectedModel).toBe("gpt-5.4");
|
||||||
|
expect(result.current.selectedMode).toBe("full-access");
|
||||||
|
expect(result.current.selectedThinkingOptionId).toBe("xhigh");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the latest preferences when a separate composer hydrates later", async () => {
|
||||||
|
mocks.preferences = {
|
||||||
|
provider: "claude",
|
||||||
|
providerPreferences: {
|
||||||
|
claude: {
|
||||||
|
model: "claude-sonnet-4-6",
|
||||||
|
mode: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useAgentFormState({
|
||||||
|
initialServerId: "host-1",
|
||||||
|
isVisible: true,
|
||||||
|
onlineServerIds: ["host-1"],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.selectedProvider).toBe("claude" as AgentProvider);
|
||||||
|
expect(result.current.selectedModel).toBe("claude-sonnet-4-6");
|
||||||
|
expect(result.current.selectedMode).toBe("default");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -146,6 +146,112 @@ describe("useAgentFormState", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("__private__.mergeSelectedComposerPreferences", () => {
|
||||||
|
it("stores the selected model for the selected provider", () => {
|
||||||
|
expect(
|
||||||
|
__private__.mergeSelectedComposerPreferences({
|
||||||
|
preferences: {},
|
||||||
|
provider: "codex",
|
||||||
|
updates: {
|
||||||
|
model: "gpt-5.4",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
provider: "codex",
|
||||||
|
providerPreferences: {
|
||||||
|
codex: {
|
||||||
|
model: "gpt-5.4",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves existing provider preferences when the selected model changes", () => {
|
||||||
|
expect(
|
||||||
|
__private__.mergeSelectedComposerPreferences({
|
||||||
|
preferences: {
|
||||||
|
provider: "claude",
|
||||||
|
providerPreferences: {
|
||||||
|
codex: {
|
||||||
|
mode: "full-access",
|
||||||
|
thinkingByModel: {
|
||||||
|
"gpt-5.4-mini": "medium",
|
||||||
|
},
|
||||||
|
featureValues: {
|
||||||
|
fast_mode: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
claude: {
|
||||||
|
model: "claude-sonnet-4-6",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
favoriteModels: [{ provider: "codex", modelId: "gpt-5.4-mini" }],
|
||||||
|
},
|
||||||
|
provider: "codex",
|
||||||
|
updates: {
|
||||||
|
model: "gpt-5.4",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
provider: "codex",
|
||||||
|
providerPreferences: {
|
||||||
|
codex: {
|
||||||
|
model: "gpt-5.4",
|
||||||
|
mode: "full-access",
|
||||||
|
thinkingByModel: {
|
||||||
|
"gpt-5.4-mini": "medium",
|
||||||
|
},
|
||||||
|
featureValues: {
|
||||||
|
fast_mode: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
claude: {
|
||||||
|
model: "claude-sonnet-4-6",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
favoriteModels: [{ provider: "codex", modelId: "gpt-5.4-mini" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores mode and thinking preferences without dropping the selected model", () => {
|
||||||
|
expect(
|
||||||
|
__private__.mergeSelectedComposerPreferences({
|
||||||
|
preferences: {
|
||||||
|
provider: "codex",
|
||||||
|
providerPreferences: {
|
||||||
|
codex: {
|
||||||
|
model: "gpt-5.4",
|
||||||
|
mode: "auto",
|
||||||
|
thinkingByModel: {
|
||||||
|
"gpt-5.4-mini": "low",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
provider: "codex",
|
||||||
|
updates: {
|
||||||
|
mode: "full-access",
|
||||||
|
thinkingByModel: {
|
||||||
|
"gpt-5.4": "xhigh",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
provider: "codex",
|
||||||
|
providerPreferences: {
|
||||||
|
codex: {
|
||||||
|
model: "gpt-5.4",
|
||||||
|
mode: "full-access",
|
||||||
|
thinkingByModel: {
|
||||||
|
"gpt-5.4-mini": "low",
|
||||||
|
"gpt-5.4": "xhigh",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("__private__.resolveFormState", () => {
|
describe("__private__.resolveFormState", () => {
|
||||||
const codexModels: AgentModelDefinition[] = [
|
const codexModels: AgentModelDefinition[] = [
|
||||||
{
|
{
|
||||||
@@ -161,6 +267,37 @@ describe("useAgentFormState", () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
it("keeps provider, mode, and model unset on first open without preferences or explicit values", () => {
|
||||||
|
const resolved = __private__.resolveFormState(
|
||||||
|
undefined,
|
||||||
|
{},
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
serverId: false,
|
||||||
|
provider: false,
|
||||||
|
modeId: false,
|
||||||
|
model: false,
|
||||||
|
thinkingOptionId: false,
|
||||||
|
workingDir: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
serverId: null,
|
||||||
|
provider: null,
|
||||||
|
modeId: "",
|
||||||
|
model: "",
|
||||||
|
thinkingOptionId: "",
|
||||||
|
workingDir: "",
|
||||||
|
},
|
||||||
|
new Set<string>(),
|
||||||
|
makeProviderMap(TEST_CLAUDE_DEFINITION, TEST_CODEX_DEFINITION),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved.provider).toBeNull();
|
||||||
|
expect(resolved.modeId).toBe("");
|
||||||
|
expect(resolved.model).toBe("");
|
||||||
|
expect(resolved.thinkingOptionId).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
it("does not auto-select a model on fresh drafts without preferences", () => {
|
it("does not auto-select a model on fresh drafts without preferences", () => {
|
||||||
const resolved = __private__.resolveFormState(
|
const resolved = __private__.resolveFormState(
|
||||||
undefined,
|
undefined,
|
||||||
@@ -374,7 +511,7 @@ describe("useAgentFormState", () => {
|
|||||||
expect(resolved.thinkingOptionId).toBe("low");
|
expect(resolved.thinkingOptionId).toBe("low");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resolves provider only from allowed provider map", () => {
|
it("clears an invalid provider instead of falling back to the first allowed provider", () => {
|
||||||
const resolved = __private__.resolveFormState(
|
const resolved = __private__.resolveFormState(
|
||||||
undefined,
|
undefined,
|
||||||
{ provider: "codex" },
|
{ provider: "codex" },
|
||||||
@@ -399,7 +536,121 @@ describe("useAgentFormState", () => {
|
|||||||
claudeProviderMap,
|
claudeProviderMap,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(resolved.provider).toBe("claude");
|
expect(resolved.provider).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves a user-selected provider and model while that provider is loading during refresh", () => {
|
||||||
|
const loadingEntries: ProviderSnapshotEntry[] = [
|
||||||
|
{
|
||||||
|
provider: "codex",
|
||||||
|
status: "loading",
|
||||||
|
label: TEST_CODEX_DEFINITION.label,
|
||||||
|
description: TEST_CODEX_DEFINITION.description,
|
||||||
|
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
|
||||||
|
modes: TEST_CODEX_DEFINITION.modes,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: "claude",
|
||||||
|
status: "ready",
|
||||||
|
label: TEST_CLAUDE_DEFINITION.label,
|
||||||
|
description: TEST_CLAUDE_DEFINITION.description,
|
||||||
|
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
|
||||||
|
modes: TEST_CLAUDE_DEFINITION.modes,
|
||||||
|
models: [{ provider: "claude", id: "default", label: "Default", isDefault: true }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const providerDefinitions = buildProviderDefinitions(loadingEntries);
|
||||||
|
const resolvableProviderMap = __private__.buildProviderDefinitionMapForStatuses({
|
||||||
|
snapshotEntries: loadingEntries,
|
||||||
|
providerDefinitions,
|
||||||
|
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready", "loading"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolved = __private__.resolveFormState(
|
||||||
|
undefined,
|
||||||
|
{},
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
serverId: false,
|
||||||
|
provider: true,
|
||||||
|
modeId: true,
|
||||||
|
model: true,
|
||||||
|
thinkingOptionId: true,
|
||||||
|
workingDir: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
serverId: null,
|
||||||
|
provider: "codex",
|
||||||
|
modeId: "full-access",
|
||||||
|
model: "gpt-5.3-codex",
|
||||||
|
thinkingOptionId: "xhigh",
|
||||||
|
workingDir: "",
|
||||||
|
},
|
||||||
|
new Set<string>(),
|
||||||
|
resolvableProviderMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved.provider).toBe("codex");
|
||||||
|
expect(resolved.modeId).toBe("full-access");
|
||||||
|
expect(resolved.model).toBe("gpt-5.3-codex");
|
||||||
|
expect(resolved.thinkingOptionId).toBe("xhigh");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears a user-selected provider when the refreshed snapshot marks it unavailable", () => {
|
||||||
|
const unavailableEntries: ProviderSnapshotEntry[] = [
|
||||||
|
{
|
||||||
|
provider: "codex",
|
||||||
|
status: "unavailable",
|
||||||
|
label: TEST_CODEX_DEFINITION.label,
|
||||||
|
description: TEST_CODEX_DEFINITION.description,
|
||||||
|
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
|
||||||
|
modes: TEST_CODEX_DEFINITION.modes,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: "claude",
|
||||||
|
status: "ready",
|
||||||
|
label: TEST_CLAUDE_DEFINITION.label,
|
||||||
|
description: TEST_CLAUDE_DEFINITION.description,
|
||||||
|
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
|
||||||
|
modes: TEST_CLAUDE_DEFINITION.modes,
|
||||||
|
models: [{ provider: "claude", id: "default", label: "Default", isDefault: true }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const providerDefinitions = buildProviderDefinitions(unavailableEntries);
|
||||||
|
const resolvableProviderMap = __private__.buildProviderDefinitionMapForStatuses({
|
||||||
|
snapshotEntries: unavailableEntries,
|
||||||
|
providerDefinitions,
|
||||||
|
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready", "loading"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolved = __private__.resolveFormState(
|
||||||
|
undefined,
|
||||||
|
{},
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
serverId: false,
|
||||||
|
provider: true,
|
||||||
|
modeId: false,
|
||||||
|
model: false,
|
||||||
|
thinkingOptionId: false,
|
||||||
|
workingDir: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
serverId: null,
|
||||||
|
provider: "codex",
|
||||||
|
modeId: "full-access",
|
||||||
|
model: "gpt-5.3-codex",
|
||||||
|
thinkingOptionId: "xhigh",
|
||||||
|
workingDir: "",
|
||||||
|
},
|
||||||
|
new Set<string>(),
|
||||||
|
resolvableProviderMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved.provider).toBeNull();
|
||||||
|
expect(resolved.modeId).toBe("");
|
||||||
|
expect(resolved.model).toBe("");
|
||||||
|
expect(resolved.thinkingOptionId).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not force fallback provider when allowed provider map is empty", () => {
|
it("does not force fallback provider when allowed provider map is empty", () => {
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ const INITIAL_USER_MODIFIED: UserModifiedFields = {
|
|||||||
// Internal form state
|
// Internal form state
|
||||||
interface FormState {
|
interface FormState {
|
||||||
serverId: string | null;
|
serverId: string | null;
|
||||||
provider: AgentProvider;
|
provider: AgentProvider | null;
|
||||||
modeId: string;
|
modeId: string;
|
||||||
model: string;
|
model: string;
|
||||||
thinkingOptionId: string;
|
thinkingOptionId: string;
|
||||||
@@ -68,7 +68,7 @@ export type UseAgentFormStateResult = {
|
|||||||
selectedServerId: string | null;
|
selectedServerId: string | null;
|
||||||
setSelectedServerId: (value: string | null) => void;
|
setSelectedServerId: (value: string | null) => void;
|
||||||
setSelectedServerIdFromUser: (value: string | null) => void;
|
setSelectedServerIdFromUser: (value: string | null) => void;
|
||||||
selectedProvider: AgentProvider;
|
selectedProvider: AgentProvider | null;
|
||||||
setProviderFromUser: (provider: AgentProvider) => void;
|
setProviderFromUser: (provider: AgentProvider) => void;
|
||||||
selectedMode: string;
|
selectedMode: string;
|
||||||
setModeFromUser: (modeId: string) => void;
|
setModeFromUser: (modeId: string) => void;
|
||||||
@@ -97,9 +97,6 @@ export type UseAgentFormStateResult = {
|
|||||||
persistFormPreferences: () => Promise<void>;
|
persistFormPreferences: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_PROVIDER: AgentProvider = "claude";
|
|
||||||
const DEFAULT_MODE_FOR_DEFAULT_PROVIDER = "default";
|
|
||||||
|
|
||||||
function normalizeSelectedModelId(modelId: string | null | undefined): string {
|
function normalizeSelectedModelId(modelId: string | null | undefined): string {
|
||||||
const normalized = typeof modelId === "string" ? modelId.trim() : "";
|
const normalized = typeof modelId === "string" ? modelId.trim() : "";
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
@@ -160,6 +157,18 @@ function resolveThinkingOptionId(args: {
|
|||||||
return effectiveModel?.defaultThinkingOptionId ?? thinkingOptions[0]?.id ?? "";
|
return effectiveModel?.defaultThinkingOptionId ?? thinkingOptions[0]?.id ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mergeSelectedComposerPreferences(args: {
|
||||||
|
preferences: FormPreferences;
|
||||||
|
provider: AgentProvider;
|
||||||
|
updates: Partial<ProviderPreferences>;
|
||||||
|
}): FormPreferences {
|
||||||
|
return mergeProviderPreferences({
|
||||||
|
preferences: args.preferences,
|
||||||
|
provider: args.provider,
|
||||||
|
updates: args.updates,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pure function that resolves form state from multiple data sources.
|
* Pure function that resolves form state from multiple data sources.
|
||||||
* Priority: explicit (URL params) > provider defaults > lightweight app prefs > fallback
|
* Priority: explicit (URL params) > provider defaults > lightweight app prefs > fallback
|
||||||
@@ -177,8 +186,6 @@ function resolveFormState(
|
|||||||
): FormState {
|
): FormState {
|
||||||
// Start with current state - we only update non-user-modified fields
|
// Start with current state - we only update non-user-modified fields
|
||||||
const result = { ...currentState };
|
const result = { ...currentState };
|
||||||
const fallbackProvider = allowedProviderMap.keys().next().value as AgentProvider | undefined;
|
|
||||||
|
|
||||||
// 1. Resolve provider first (other fields depend on it)
|
// 1. Resolve provider first (other fields depend on it)
|
||||||
if (!userModified.provider) {
|
if (!userModified.provider) {
|
||||||
if (initialValues?.provider && allowedProviderMap.has(initialValues.provider)) {
|
if (initialValues?.provider && allowedProviderMap.has(initialValues.provider)) {
|
||||||
@@ -188,22 +195,33 @@ function resolveFormState(
|
|||||||
allowedProviderMap.has(preferences.provider as AgentProvider)
|
allowedProviderMap.has(preferences.provider as AgentProvider)
|
||||||
) {
|
) {
|
||||||
result.provider = preferences.provider as AgentProvider;
|
result.provider = preferences.provider as AgentProvider;
|
||||||
} else if (!allowedProviderMap.has(result.provider) && fallbackProvider) {
|
} else if (
|
||||||
result.provider = fallbackProvider;
|
result.provider &&
|
||||||
|
allowedProviderMap.size > 0 &&
|
||||||
|
!allowedProviderMap.has(result.provider)
|
||||||
|
) {
|
||||||
|
result.provider = null;
|
||||||
}
|
}
|
||||||
// else keep current (initialized to DEFAULT_PROVIDER)
|
} else if (
|
||||||
} else if (!allowedProviderMap.has(result.provider) && fallbackProvider) {
|
result.provider &&
|
||||||
result.provider = fallbackProvider;
|
allowedProviderMap.size > 0 &&
|
||||||
|
!allowedProviderMap.has(result.provider)
|
||||||
|
) {
|
||||||
|
result.provider = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const providerDef = allowedProviderMap.get(result.provider);
|
const providerDef = result.provider ? allowedProviderMap.get(result.provider) : undefined;
|
||||||
const providerPrefs = preferences?.providerPreferences?.[result.provider];
|
const providerPrefs = result.provider
|
||||||
|
? preferences?.providerPreferences?.[result.provider]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
// 2. Resolve modeId (depends on provider)
|
// 2. Resolve modeId (depends on provider)
|
||||||
if (!userModified.modeId) {
|
if (!userModified.modeId) {
|
||||||
const validModeIds = providerDef?.modes.map((m) => m.id) ?? [];
|
const validModeIds = providerDef?.modes.map((m) => m.id) ?? [];
|
||||||
|
|
||||||
if (
|
if (!result.provider) {
|
||||||
|
result.modeId = "";
|
||||||
|
} else if (
|
||||||
typeof initialValues?.modeId === "string" &&
|
typeof initialValues?.modeId === "string" &&
|
||||||
initialValues.modeId.length > 0 &&
|
initialValues.modeId.length > 0 &&
|
||||||
validModeIds.includes(initialValues.modeId)
|
validModeIds.includes(initialValues.modeId)
|
||||||
@@ -223,7 +241,9 @@ function resolveFormState(
|
|||||||
const preferredModel = normalizeSelectedModelId(providerPrefs?.model);
|
const preferredModel = normalizeSelectedModelId(providerPrefs?.model);
|
||||||
const defaultModelId = resolveDefaultModelId(availableModels);
|
const defaultModelId = resolveDefaultModelId(availableModels);
|
||||||
|
|
||||||
if (initialModel) {
|
if (!result.provider) {
|
||||||
|
result.model = "";
|
||||||
|
} else if (initialModel) {
|
||||||
if (!availableModels || isValidModel(initialModel)) {
|
if (!availableModels || isValidModel(initialModel)) {
|
||||||
result.model = initialModel;
|
result.model = initialModel;
|
||||||
} else {
|
} else {
|
||||||
@@ -246,7 +266,9 @@ function resolveFormState(
|
|||||||
? initialValues.thinkingOptionId.trim()
|
? initialValues.thinkingOptionId.trim()
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
if (!userModified.thinkingOptionId) {
|
if (!result.provider) {
|
||||||
|
result.thinkingOptionId = "";
|
||||||
|
} else if (!userModified.thinkingOptionId) {
|
||||||
const effectiveModelId = result.model.trim();
|
const effectiveModelId = result.model.trim();
|
||||||
const preferredThinking = effectiveModelId
|
const preferredThinking = effectiveModelId
|
||||||
? (providerPrefs?.thinkingByModel?.[effectiveModelId]?.trim() ?? "")
|
? (providerPrefs?.thinkingByModel?.[effectiveModelId]?.trim() ?? "")
|
||||||
@@ -262,7 +284,7 @@ function resolveFormState(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate thinking option once model metadata is available.
|
// Validate thinking option once model metadata is available.
|
||||||
if (availableModels) {
|
if (result.provider && availableModels) {
|
||||||
result.thinkingOptionId = resolveThinkingOptionId({
|
result.thinkingOptionId = resolveThinkingOptionId({
|
||||||
availableModels,
|
availableModels,
|
||||||
modelId: result.model,
|
modelId: result.model,
|
||||||
@@ -312,6 +334,37 @@ function combineInitialValues(
|
|||||||
return initialValues;
|
return initialValues;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const RESOLVABLE_PROVIDER_STATUSES = new Set<ProviderSnapshotEntry["status"]>(["ready", "loading"]);
|
||||||
|
const SELECTABLE_PROVIDER_STATUSES = new Set<ProviderSnapshotEntry["status"]>(["ready"]);
|
||||||
|
|
||||||
|
function buildProviderDefinitionMap(
|
||||||
|
providerDefinitions: AgentProviderDefinition[],
|
||||||
|
): Map<AgentProvider, AgentProviderDefinition> {
|
||||||
|
return new Map<AgentProvider, AgentProviderDefinition>(
|
||||||
|
providerDefinitions.map((definition) => [definition.id, definition]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildProviderDefinitionMapForStatuses(args: {
|
||||||
|
snapshotEntries: ProviderSnapshotEntry[] | undefined;
|
||||||
|
providerDefinitions: AgentProviderDefinition[];
|
||||||
|
statuses: ReadonlySet<ProviderSnapshotEntry["status"]>;
|
||||||
|
}): Map<AgentProvider, AgentProviderDefinition> {
|
||||||
|
if (!args.snapshotEntries?.length) {
|
||||||
|
return buildProviderDefinitionMap(args.providerDefinitions);
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchingProviders = new Set(
|
||||||
|
args.snapshotEntries
|
||||||
|
.filter((entry) => args.statuses.has(entry.status))
|
||||||
|
.map((entry) => entry.provider),
|
||||||
|
);
|
||||||
|
|
||||||
|
return buildProviderDefinitionMap(
|
||||||
|
args.providerDefinitions.filter((definition) => matchingProviders.has(definition.id)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAgentFormStateResult {
|
export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAgentFormStateResult {
|
||||||
const {
|
const {
|
||||||
initialServerId = null,
|
initialServerId = null,
|
||||||
@@ -335,8 +388,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
// Form state
|
// Form state
|
||||||
const [formState, setFormState] = useState<FormState>(() => ({
|
const [formState, setFormState] = useState<FormState>(() => ({
|
||||||
serverId: initialServerId,
|
serverId: initialServerId,
|
||||||
provider: DEFAULT_PROVIDER,
|
provider: null,
|
||||||
modeId: DEFAULT_MODE_FOR_DEFAULT_PROVIDER,
|
modeId: "",
|
||||||
model: "",
|
model: "",
|
||||||
thinkingOptionId: "",
|
thinkingOptionId: "",
|
||||||
workingDir: "",
|
workingDir: "",
|
||||||
@@ -348,12 +401,14 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
|
|
||||||
// Track if we've done initial resolution (to avoid flickering)
|
// Track if we've done initial resolution (to avoid flickering)
|
||||||
const hasResolvedRef = useRef(false);
|
const hasResolvedRef = useRef(false);
|
||||||
|
const hydrationPreferencesRef = useRef<FormPreferences | null>(null);
|
||||||
|
|
||||||
// Reset user modifications when form becomes invisible
|
// Reset user modifications when form becomes invisible
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isVisible) {
|
if (!isVisible) {
|
||||||
setUserModified(INITIAL_USER_MODIFIED);
|
setUserModified(INITIAL_USER_MODIFIED);
|
||||||
hasResolvedRef.current = false;
|
hasResolvedRef.current = false;
|
||||||
|
hydrationPreferencesRef.current = null;
|
||||||
}
|
}
|
||||||
}, [isVisible]);
|
}, [isVisible]);
|
||||||
|
|
||||||
@@ -371,25 +426,25 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
[snapshotEntries],
|
[snapshotEntries],
|
||||||
);
|
);
|
||||||
const snapshotProviderDefinitionMap = useMemo(
|
const snapshotProviderDefinitionMap = useMemo(
|
||||||
() =>
|
() => buildProviderDefinitionMap(snapshotProviderDefinitions),
|
||||||
new Map<AgentProvider, AgentProviderDefinition>(
|
|
||||||
snapshotProviderDefinitions.map((definition) => [definition.id, definition]),
|
|
||||||
),
|
|
||||||
[snapshotProviderDefinitions],
|
[snapshotProviderDefinitions],
|
||||||
);
|
);
|
||||||
|
const snapshotResolvableProviderDefinitionMap = useMemo(
|
||||||
|
() =>
|
||||||
|
buildProviderDefinitionMapForStatuses({
|
||||||
|
snapshotEntries,
|
||||||
|
providerDefinitions: snapshotProviderDefinitions,
|
||||||
|
statuses: RESOLVABLE_PROVIDER_STATUSES,
|
||||||
|
}),
|
||||||
|
[snapshotEntries, snapshotProviderDefinitions],
|
||||||
|
);
|
||||||
const snapshotSelectableProviderDefinitionMap = useMemo(() => {
|
const snapshotSelectableProviderDefinitionMap = useMemo(() => {
|
||||||
if (!snapshotEntries?.length) {
|
return buildProviderDefinitionMapForStatuses({
|
||||||
return snapshotProviderDefinitionMap;
|
snapshotEntries,
|
||||||
}
|
providerDefinitions: snapshotProviderDefinitions,
|
||||||
const readyProviders = new Set(
|
statuses: SELECTABLE_PROVIDER_STATUSES,
|
||||||
snapshotEntries.filter((entry) => entry.status === "ready").map((entry) => entry.provider),
|
});
|
||||||
);
|
}, [snapshotEntries, snapshotProviderDefinitions]);
|
||||||
return new Map<AgentProvider, AgentProviderDefinition>(
|
|
||||||
snapshotProviderDefinitions
|
|
||||||
.filter((definition) => readyProviders.has(definition.id))
|
|
||||||
.map((definition) => [definition.id, definition]),
|
|
||||||
);
|
|
||||||
}, [snapshotEntries, snapshotProviderDefinitionMap, snapshotProviderDefinitions]);
|
|
||||||
const snapshotAllProviderModels = useMemo(() => {
|
const snapshotAllProviderModels = useMemo(() => {
|
||||||
const map = new Map<string, AgentModelDefinition[]>();
|
const map = new Map<string, AgentModelDefinition[]>();
|
||||||
for (const entry of snapshotEntries ?? []) {
|
for (const entry of snapshotEntries ?? []) {
|
||||||
@@ -398,13 +453,17 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
return map;
|
return map;
|
||||||
}, [snapshotEntries]);
|
}, [snapshotEntries]);
|
||||||
const snapshotSelectedEntry = useMemo(
|
const snapshotSelectedEntry = useMemo(
|
||||||
() => (snapshotEntries ?? []).find((entry) => entry.provider === formState.provider) ?? null,
|
() =>
|
||||||
|
formState.provider
|
||||||
|
? ((snapshotEntries ?? []).find((entry) => entry.provider === formState.provider) ?? null)
|
||||||
|
: null,
|
||||||
[formState.provider, snapshotEntries],
|
[formState.provider, snapshotEntries],
|
||||||
);
|
);
|
||||||
const snapshotSelectedProviderModels = snapshotSelectedEntry?.models ?? null;
|
const snapshotSelectedProviderModels = snapshotSelectedEntry?.models ?? null;
|
||||||
|
const selectedProviderIsLoading = snapshotSelectedEntry?.status === "loading";
|
||||||
const snapshotSelectedProviderModes =
|
const snapshotSelectedProviderModes =
|
||||||
snapshotSelectedEntry?.modes ??
|
snapshotSelectedEntry?.modes ??
|
||||||
snapshotProviderDefinitionMap.get(formState.provider)?.modes ??
|
(formState.provider ? snapshotProviderDefinitionMap.get(formState.provider)?.modes : []) ??
|
||||||
[];
|
[];
|
||||||
const providerDefinitions = snapshotProviderDefinitions;
|
const providerDefinitions = snapshotProviderDefinitions;
|
||||||
const providerDefinitionMap = snapshotProviderDefinitionMap;
|
const providerDefinitionMap = snapshotProviderDefinitionMap;
|
||||||
@@ -412,7 +471,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
const allProviderModels = snapshotAllProviderModels;
|
const allProviderModels = snapshotAllProviderModels;
|
||||||
const availableModels = snapshotSelectedProviderModels;
|
const availableModels = snapshotSelectedProviderModels;
|
||||||
const modeOptions = snapshotSelectedProviderModes;
|
const modeOptions = snapshotSelectedProviderModes;
|
||||||
const isAllModelsLoading = snapshotIsLoading;
|
const isAllModelsLoading = snapshotIsLoading || selectedProviderIsLoading;
|
||||||
|
|
||||||
// Combine initialValues with initialServerId for resolution
|
// Combine initialValues with initialServerId for resolution
|
||||||
const combinedInitialValues = useMemo((): FormInitialValues | undefined => {
|
const combinedInitialValues = useMemo((): FormInitialValues | undefined => {
|
||||||
@@ -425,19 +484,23 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for preferences to load before first resolution, unless explicit URL overrides exist.
|
if (isPreferencesLoading && !hasResolvedRef.current) {
|
||||||
if (isPreferencesLoading && !hasResolvedRef.current && !combinedInitialValues) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!hasResolvedRef.current) {
|
||||||
|
hydrationPreferencesRef.current = preferences;
|
||||||
|
}
|
||||||
|
const hydrationPreferences = hydrationPreferencesRef.current ?? preferences;
|
||||||
|
|
||||||
const resolved = resolveFormState(
|
const resolved = resolveFormState(
|
||||||
combinedInitialValues,
|
combinedInitialValues,
|
||||||
preferences,
|
hydrationPreferences,
|
||||||
availableModels,
|
availableModels,
|
||||||
userModified,
|
userModified,
|
||||||
formStateRef.current,
|
formStateRef.current,
|
||||||
validServerIds,
|
validServerIds,
|
||||||
selectableProviderDefinitionMap,
|
snapshotResolvableProviderDefinitionMap,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Only update if something changed
|
// Only update if something changed
|
||||||
@@ -462,7 +525,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
availableModels,
|
availableModels,
|
||||||
userModified,
|
userModified,
|
||||||
validServerIds,
|
validServerIds,
|
||||||
selectableProviderDefinitionMap,
|
snapshotResolvableProviderDefinitionMap,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Auto-select the first online host when:
|
// Auto-select the first online host when:
|
||||||
@@ -571,15 +634,38 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
thinkingOptionId: nextThinkingOptionId,
|
thinkingOptionId: nextThinkingOptionId,
|
||||||
}));
|
}));
|
||||||
setUserModified((prev) => ({ ...prev, provider: true, model: true }));
|
setUserModified((prev) => ({ ...prev, provider: true, model: true }));
|
||||||
void updatePreferences({ provider });
|
void updatePreferences((current) =>
|
||||||
|
mergeSelectedComposerPreferences({
|
||||||
|
preferences: current,
|
||||||
|
provider,
|
||||||
|
updates: {
|
||||||
|
model: nextModelId || undefined,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[allProviderModels, selectableProviderDefinitionMap, updatePreferences],
|
[allProviderModels, selectableProviderDefinitionMap, updatePreferences],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setModeFromUser = useCallback((modeId: string) => {
|
const setModeFromUser = useCallback(
|
||||||
setFormState((prev) => ({ ...prev, modeId }));
|
(modeId: string) => {
|
||||||
setUserModified((prev) => ({ ...prev, modeId: true }));
|
setFormState((prev) => ({ ...prev, modeId }));
|
||||||
}, []);
|
setUserModified((prev) => ({ ...prev, modeId: true }));
|
||||||
|
const provider = formStateRef.current.provider;
|
||||||
|
if (provider) {
|
||||||
|
void updatePreferences((current) =>
|
||||||
|
mergeSelectedComposerPreferences({
|
||||||
|
preferences: current,
|
||||||
|
provider,
|
||||||
|
updates: {
|
||||||
|
mode: modeId || undefined,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[updatePreferences],
|
||||||
|
);
|
||||||
|
|
||||||
const setModelFromUser = useCallback(
|
const setModelFromUser = useCallback(
|
||||||
(modelId: string) => {
|
(modelId: string) => {
|
||||||
@@ -598,14 +684,44 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
thinkingOptionId: nextThinkingOptionId,
|
thinkingOptionId: nextThinkingOptionId,
|
||||||
}));
|
}));
|
||||||
setUserModified((prev) => ({ ...prev, model: true }));
|
setUserModified((prev) => ({ ...prev, model: true }));
|
||||||
|
const provider = formStateRef.current.provider;
|
||||||
|
if (provider) {
|
||||||
|
void updatePreferences((current) =>
|
||||||
|
mergeSelectedComposerPreferences({
|
||||||
|
preferences: current,
|
||||||
|
provider,
|
||||||
|
updates: {
|
||||||
|
model: nextModelId || undefined,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[availableModels, userModified.thinkingOptionId],
|
[availableModels, updatePreferences, userModified.thinkingOptionId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setThinkingOptionFromUser = useCallback((thinkingOptionId: string) => {
|
const setThinkingOptionFromUser = useCallback(
|
||||||
setFormState((prev) => ({ ...prev, thinkingOptionId }));
|
(thinkingOptionId: string) => {
|
||||||
setUserModified((prev) => ({ ...prev, thinkingOptionId: true }));
|
setFormState((prev) => ({ ...prev, thinkingOptionId }));
|
||||||
}, []);
|
setUserModified((prev) => ({ ...prev, thinkingOptionId: true }));
|
||||||
|
const provider = formStateRef.current.provider;
|
||||||
|
const modelId = formStateRef.current.model;
|
||||||
|
if (provider && modelId) {
|
||||||
|
void updatePreferences((current) =>
|
||||||
|
mergeSelectedComposerPreferences({
|
||||||
|
preferences: current,
|
||||||
|
provider,
|
||||||
|
updates: {
|
||||||
|
thinkingByModel: {
|
||||||
|
[modelId]: thinkingOptionId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[updatePreferences],
|
||||||
|
);
|
||||||
|
|
||||||
const setWorkingDir = useCallback((value: string) => {
|
const setWorkingDir = useCallback((value: string) => {
|
||||||
setFormState((prev) => ({ ...prev, workingDir: value }));
|
setFormState((prev) => ({ ...prev, workingDir: value }));
|
||||||
@@ -625,16 +741,21 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
}, [refreshSnapshot]);
|
}, [refreshSnapshot]);
|
||||||
|
|
||||||
const refetchProviderModelsIfStale = useCallback(() => {
|
const refetchProviderModelsIfStale = useCallback(() => {
|
||||||
refetchSnapshotIfStale();
|
refetchSnapshotIfStale(formStateRef.current.provider);
|
||||||
}, [refetchSnapshotIfStale]);
|
}, [refetchSnapshotIfStale]);
|
||||||
|
|
||||||
const persistFormPreferences = useCallback(async () => {
|
const persistFormPreferences = useCallback(async () => {
|
||||||
|
if (!formState.provider) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = formState.provider;
|
||||||
const resolvedModel = resolveEffectiveModel(availableModels, formState.model);
|
const resolvedModel = resolveEffectiveModel(availableModels, formState.model);
|
||||||
const modelId = resolvedModel?.id ?? formState.model;
|
const modelId = resolvedModel?.id ?? formState.model;
|
||||||
await updatePreferences((current) =>
|
await updatePreferences((current) =>
|
||||||
mergeProviderPreferences({
|
mergeProviderPreferences({
|
||||||
preferences: current,
|
preferences: current,
|
||||||
provider: formState.provider,
|
provider,
|
||||||
updates: {
|
updates: {
|
||||||
model: modelId || undefined,
|
model: modelId || undefined,
|
||||||
mode: formState.modeId || undefined,
|
mode: formState.modeId || undefined,
|
||||||
@@ -657,11 +778,13 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
updatePreferences,
|
updatePreferences,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const agentDefinition = providerDefinitionMap.get(formState.provider);
|
const agentDefinition = formState.provider
|
||||||
|
? providerDefinitionMap.get(formState.provider)
|
||||||
|
: undefined;
|
||||||
const effectiveModel = resolveEffectiveModel(availableModels, formState.model);
|
const effectiveModel = resolveEffectiveModel(availableModels, formState.model);
|
||||||
const resolvedModelId = effectiveModel?.id ?? formState.model;
|
const resolvedModelId = effectiveModel?.id ?? formState.model;
|
||||||
const availableThinkingOptions = effectiveModel?.thinkingOptions ?? [];
|
const availableThinkingOptions = effectiveModel?.thinkingOptions ?? [];
|
||||||
const isModelLoading = snapshotIsLoading;
|
const isModelLoading = snapshotIsLoading || selectedProviderIsLoading;
|
||||||
const modelError = snapshotError;
|
const modelError = snapshotError;
|
||||||
|
|
||||||
const workingDirIsEmpty = !formState.workingDir.trim();
|
const workingDirIsEmpty = !formState.workingDir.trim();
|
||||||
@@ -738,7 +861,10 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
export type CreateAgentInitialValues = FormInitialValues;
|
export type CreateAgentInitialValues = FormInitialValues;
|
||||||
|
|
||||||
export const __private__ = {
|
export const __private__ = {
|
||||||
|
buildProviderDefinitionMap,
|
||||||
|
buildProviderDefinitionMapForStatuses,
|
||||||
combineInitialValues,
|
combineInitialValues,
|
||||||
|
mergeSelectedComposerPreferences,
|
||||||
resolveDefaultModel,
|
resolveDefaultModel,
|
||||||
resolveFormState,
|
resolveFormState,
|
||||||
resolveThinkingOptionId,
|
resolveThinkingOptionId,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
|
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
|
||||||
import { useDraftStore } from "@/stores/draft-store";
|
import { useDraftStore } from "@/stores/draft-store";
|
||||||
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
|
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
|
||||||
|
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||||
|
|
||||||
type AttachmentUpdater =
|
type AttachmentUpdater =
|
||||||
| ComposerAttachment[]
|
| ComposerAttachment[]
|
||||||
@@ -113,7 +114,7 @@ function resolveEffectiveComposerThinkingOptionId(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildDraftComposerCommandConfig(input: {
|
function buildDraftComposerCommandConfig(input: {
|
||||||
provider: DraftAgentStatusBarProps["selectedProvider"];
|
provider: AgentProvider | null;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
modeOptions: DraftAgentStatusBarProps["modeOptions"];
|
modeOptions: DraftAgentStatusBarProps["modeOptions"];
|
||||||
selectedMode: string;
|
selectedMode: string;
|
||||||
@@ -122,7 +123,7 @@ function buildDraftComposerCommandConfig(input: {
|
|||||||
featureValues?: Record<string, unknown>;
|
featureValues?: Record<string, unknown>;
|
||||||
}): DraftCommandConfig | undefined {
|
}): DraftCommandConfig | undefined {
|
||||||
const cwd = input.cwd.trim();
|
const cwd = input.cwd.trim();
|
||||||
if (!cwd) {
|
if (!input.provider || !cwd) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { skipToken, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useEffect, useId, useMemo } from "react";
|
import { useEffect, useId, useMemo } from "react";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import type { SubscribeCheckoutDiffResponse } from "@server/shared/messages";
|
import type { SubscribeCheckoutDiffResponse } from "@server/shared/messages";
|
||||||
import { orderCheckoutDiffFiles } from "./checkout-diff-order";
|
import { orderCheckoutDiffFiles } from "./checkout-diff-order";
|
||||||
@@ -59,10 +59,8 @@ export function useCheckoutDiffQuery({
|
|||||||
const client = useHostRuntimeClient(serverId);
|
const client = useHostRuntimeClient(serverId);
|
||||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
|
||||||
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
|
||||||
const explorerTab = usePanelStore((state) => state.explorerTab);
|
const explorerTab = usePanelStore((state) => state.explorerTab);
|
||||||
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
const isOpen = usePanelStore((state) => selectIsFileExplorerOpen(state, { isCompact: isMobile }));
|
||||||
const hookInstanceId = useId();
|
const hookInstanceId = useId();
|
||||||
const normalizedCompare = useMemo(
|
const normalizedCompare = useMemo(
|
||||||
() => normalizeCheckoutDiffCompare({ mode, baseRef, ignoreWhitespace }),
|
() => normalizeCheckoutDiffCompare({ mode, baseRef, ignoreWhitespace }),
|
||||||
@@ -76,27 +74,10 @@ export function useCheckoutDiffQuery({
|
|||||||
[serverId, cwd, mode, baseRef, compareIgnoreWhitespace],
|
[serverId, cwd, mode, baseRef, compareIgnoreWhitespace],
|
||||||
);
|
);
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery<CheckoutDiffQueryPayload>({
|
||||||
queryKey,
|
queryKey,
|
||||||
queryFn: async () => {
|
queryFn: skipToken,
|
||||||
if (!client) {
|
|
||||||
throw new Error("Daemon client not available");
|
|
||||||
}
|
|
||||||
const payload = await client.getCheckoutDiff(cwd, {
|
|
||||||
mode: compareMode,
|
|
||||||
baseRef: compareBaseRef,
|
|
||||||
ignoreWhitespace: compareIgnoreWhitespace,
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
...payload,
|
|
||||||
files: orderCheckoutDiffFiles(payload.files),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
enabled: !!client && isConnected && !!cwd && enabled,
|
|
||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
refetchOnMount: false,
|
|
||||||
refetchOnReconnect: false,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -208,9 +189,9 @@ export function useCheckoutDiffQuery({
|
|||||||
return {
|
return {
|
||||||
files: payload?.files ?? [],
|
files: payload?.files ?? [],
|
||||||
payloadError,
|
payloadError,
|
||||||
isLoading: query.isLoading,
|
isLoading: payload === null && enabled && isConnected,
|
||||||
isFetching: query.isFetching,
|
isFetching: false,
|
||||||
isError: query.isError || Boolean(payloadError),
|
isError: Boolean(payloadError),
|
||||||
error: query.error,
|
error: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { router, usePathname, type Href } from "expo-router";
|
|||||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
||||||
import { useHosts } from "@/runtime/host-runtime";
|
|
||||||
import { useAllAgentsList } from "@/hooks/use-all-agents-list";
|
import { useAllAgentsList } from "@/hooks/use-all-agents-list";
|
||||||
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||||
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||||
@@ -12,11 +11,7 @@ import {
|
|||||||
clearCommandCenterFocusRestoreElement,
|
clearCommandCenterFocusRestoreElement,
|
||||||
takeCommandCenterFocusRestoreElement,
|
takeCommandCenterFocusRestoreElement,
|
||||||
} from "@/utils/command-center-focus-restore";
|
} from "@/utils/command-center-focus-restore";
|
||||||
import {
|
import { buildHostAgentDetailRoute, buildSettingsRoute } from "@/utils/host-routes";
|
||||||
buildHostAgentDetailRoute,
|
|
||||||
buildSettingsRoute,
|
|
||||||
parseServerIdFromPathname,
|
|
||||||
} from "@/utils/host-routes";
|
|
||||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||||
import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string";
|
import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string";
|
||||||
import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts";
|
import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts";
|
||||||
@@ -26,6 +21,7 @@ import { getIsElectronRuntime } from "@/constants/layout";
|
|||||||
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
import { focusWithRetries } from "@/utils/web-focus";
|
import { focusWithRetries } from "@/utils/web-focus";
|
||||||
|
import { useActiveServerId } from "@/hooks/use-active-server-id";
|
||||||
|
|
||||||
const EMPTY_AGENTS: AggregatedAgent[] = [];
|
const EMPTY_AGENTS: AggregatedAgent[] = [];
|
||||||
const EMPTY_ACTION_ITEMS: CommandCenterActionItem[] = [];
|
const EMPTY_ACTION_ITEMS: CommandCenterActionItem[] = [];
|
||||||
@@ -128,7 +124,7 @@ function resolveActionShortcutKeys(
|
|||||||
|
|
||||||
export function useCommandCenter() {
|
export function useCommandCenter() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const daemons = useHosts();
|
const routeActiveServerId = useActiveServerId();
|
||||||
const { overrides } = useKeyboardShortcutOverrides();
|
const { overrides } = useKeyboardShortcutOverrides();
|
||||||
const open = useKeyboardShortcutsStore((s) => s.commandCenterOpen);
|
const open = useKeyboardShortcutsStore((s) => s.commandCenterOpen);
|
||||||
const setOpen = useKeyboardShortcutsStore((s) => s.setCommandCenterOpen);
|
const setOpen = useKeyboardShortcutsStore((s) => s.setCommandCenterOpen);
|
||||||
@@ -142,19 +138,7 @@ export function useCommandCenter() {
|
|||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [activeIndex, setActiveIndex] = useState(0);
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
|
|
||||||
const activeServerId = useMemo(() => {
|
const activeServerId = open ? routeActiveServerId : null;
|
||||||
if (!open) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const serverIdFromPath = parseServerIdFromPathname(pathname);
|
|
||||||
if (serverIdFromPath) {
|
|
||||||
const routeMatch = daemons.find((entry) => entry.serverId === serverIdFromPath);
|
|
||||||
if (routeMatch) {
|
|
||||||
return routeMatch.serverId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return daemons[0]?.serverId ?? null;
|
|
||||||
}, [daemons, open, pathname]);
|
|
||||||
|
|
||||||
const { agents } = useAllAgentsList({
|
const { agents } = useAllAgentsList({
|
||||||
serverId: activeServerId,
|
serverId: activeServerId,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ type DraftFeatureConfig = Pick<
|
|||||||
|
|
||||||
export function useDraftAgentFeatures(input: {
|
export function useDraftAgentFeatures(input: {
|
||||||
serverId: string | null | undefined;
|
serverId: string | null | undefined;
|
||||||
provider: AgentProvider;
|
provider: AgentProvider | null;
|
||||||
cwd: string | null | undefined;
|
cwd: string | null | undefined;
|
||||||
modeId: string | null | undefined;
|
modeId: string | null | undefined;
|
||||||
modelId: string | null | undefined;
|
modelId: string | null | undefined;
|
||||||
@@ -28,30 +28,31 @@ export function useDraftAgentFeatures(input: {
|
|||||||
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
||||||
const { preferences, updatePreferences } = useFormPreferences();
|
const { preferences, updatePreferences } = useFormPreferences();
|
||||||
const normalizedCwd = cwd?.trim() || "";
|
const normalizedCwd = cwd?.trim() || "";
|
||||||
|
const normalizedProvider = provider ?? null;
|
||||||
const persistedFeatureValues = useMemo(
|
const persistedFeatureValues = useMemo(
|
||||||
() => preferences.providerPreferences?.[provider]?.featureValues ?? {},
|
() => (provider ? (preferences.providerPreferences?.[provider]?.featureValues ?? {}) : {}),
|
||||||
[preferences.providerPreferences, provider],
|
[preferences.providerPreferences, provider],
|
||||||
);
|
);
|
||||||
|
|
||||||
const draftConfig = useMemo<DraftFeatureConfig | null>(() => {
|
const draftConfig = useMemo<DraftFeatureConfig | null>(() => {
|
||||||
if (!normalizedCwd) {
|
if (!normalizedProvider || !normalizedCwd) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
provider,
|
provider: normalizedProvider,
|
||||||
cwd: normalizedCwd,
|
cwd: normalizedCwd,
|
||||||
...(modeId ? { modeId } : {}),
|
...(modeId ? { modeId } : {}),
|
||||||
...(modelId ? { model: modelId } : {}),
|
...(modelId ? { model: modelId } : {}),
|
||||||
...(thinkingOptionId ? { thinkingOptionId } : {}),
|
...(thinkingOptionId ? { thinkingOptionId } : {}),
|
||||||
};
|
};
|
||||||
}, [modeId, modelId, normalizedCwd, provider, thinkingOptionId]);
|
}, [modeId, modelId, normalizedCwd, normalizedProvider, thinkingOptionId]);
|
||||||
|
|
||||||
const featuresQuery = useQuery({
|
const featuresQuery = useQuery({
|
||||||
queryKey: [
|
queryKey: [
|
||||||
"providerFeatures",
|
"providerFeatures",
|
||||||
serverId ?? null,
|
serverId ?? null,
|
||||||
provider,
|
normalizedProvider,
|
||||||
normalizedCwd || null,
|
normalizedCwd || null,
|
||||||
modeId ?? null,
|
modeId ?? null,
|
||||||
modelId ?? null,
|
modelId ?? null,
|
||||||
@@ -106,6 +107,9 @@ export function useDraftAgentFeatures(input: {
|
|||||||
|
|
||||||
return { ...current, [featureId]: value };
|
return { ...current, [featureId]: value };
|
||||||
});
|
});
|
||||||
|
if (!provider) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
void updatePreferences((current) =>
|
void updatePreferences((current) =>
|
||||||
mergeProviderPreferences({
|
mergeProviderPreferences({
|
||||||
preferences: current,
|
preferences: current,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useCallback, useMemo } from "react";
|
|||||||
import { Gesture } from "react-native-gesture-handler";
|
import { Gesture } from "react-native-gesture-handler";
|
||||||
import { Extrapolation, interpolate, runOnJS, useSharedValue } from "react-native-reanimated";
|
import { Extrapolation, interpolate, runOnJS, useSharedValue } from "react-native-reanimated";
|
||||||
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
||||||
|
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
||||||
|
|
||||||
interface UseExplorerOpenGestureParams {
|
interface UseExplorerOpenGestureParams {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -19,6 +20,7 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu
|
|||||||
gestureAnimatingRef,
|
gestureAnimatingRef,
|
||||||
openGestureRef,
|
openGestureRef,
|
||||||
} = useExplorerSidebarAnimation();
|
} = useExplorerSidebarAnimation();
|
||||||
|
const { openGestureRef: leftOpenGestureRef } = useSidebarAnimation();
|
||||||
const touchStartX = useSharedValue(0);
|
const touchStartX = useSharedValue(0);
|
||||||
const touchStartY = useSharedValue(0);
|
const touchStartY = useSharedValue(0);
|
||||||
|
|
||||||
@@ -31,6 +33,7 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu
|
|||||||
() =>
|
() =>
|
||||||
Gesture.Pan()
|
Gesture.Pan()
|
||||||
.withRef(openGestureRef)
|
.withRef(openGestureRef)
|
||||||
|
.simultaneousWithExternalGesture(leftOpenGestureRef)
|
||||||
.enabled(enabled)
|
.enabled(enabled)
|
||||||
.manualActivation(true)
|
.manualActivation(true)
|
||||||
.onTouchesDown((event) => {
|
.onTouchesDown((event) => {
|
||||||
@@ -109,6 +112,7 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu
|
|||||||
animateToClose,
|
animateToClose,
|
||||||
isGesturing,
|
isGesturing,
|
||||||
openGestureRef,
|
openGestureRef,
|
||||||
|
leftOpenGestureRef,
|
||||||
handleGestureOpen,
|
handleGestureOpen,
|
||||||
touchStartX,
|
touchStartX,
|
||||||
touchStartY,
|
touchStartY,
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
|
||||||
|
import type { HostRuntimeAgentDirectoryStatus } from "@/runtime/host-runtime";
|
||||||
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
import { getVoiceReadinessState } from "@/utils/server-info-capabilities";
|
||||||
|
|
||||||
|
function isLegacyDictationReady(agentDirectoryStatus: HostRuntimeAgentDirectoryStatus): boolean {
|
||||||
|
return (
|
||||||
|
agentDirectoryStatus === "ready" ||
|
||||||
|
agentDirectoryStatus === "revalidating" ||
|
||||||
|
agentDirectoryStatus === "error_after_ready"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useIsDictationReady({
|
||||||
|
serverId,
|
||||||
|
isConnected,
|
||||||
|
agentDirectoryStatus,
|
||||||
|
}: {
|
||||||
|
serverId: string;
|
||||||
|
isConnected: boolean;
|
||||||
|
agentDirectoryStatus: HostRuntimeAgentDirectoryStatus;
|
||||||
|
}): boolean {
|
||||||
|
const dictationCapabilityEnabled = useSessionStore(
|
||||||
|
useCallback(
|
||||||
|
(state) => {
|
||||||
|
const serverInfo = state.sessions[serverId]?.serverInfo ?? null;
|
||||||
|
return (
|
||||||
|
getVoiceReadinessState({
|
||||||
|
serverInfo,
|
||||||
|
mode: "dictation",
|
||||||
|
})?.enabled ?? null
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[serverId],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isConnected) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return dictationCapabilityEnabled ?? isLegacyDictationReady(agentDirectoryStatus);
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*/
|
||||||
|
import React from "react";
|
||||||
|
import { act } from "@testing-library/react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const { navigateToWorkspaceMock, routerMock, pathState } = vi.hoisted(() => ({
|
||||||
|
navigateToWorkspaceMock: vi.fn(),
|
||||||
|
routerMock: {
|
||||||
|
back: vi.fn(),
|
||||||
|
push: vi.fn(),
|
||||||
|
},
|
||||||
|
pathState: {
|
||||||
|
pathname: "/h/srv/workspace/ws-2",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.hoisted(() => {
|
||||||
|
(globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("expo-router", () => ({
|
||||||
|
usePathname: () => pathState.pathname,
|
||||||
|
useRouter: () => routerMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/constants/layout", () => ({
|
||||||
|
getIsElectronRuntime: () => true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/constants/platform", () => ({
|
||||||
|
isNative: false,
|
||||||
|
isWeb: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/utils/shortcut-platform", () => ({
|
||||||
|
getShortcutOs: () => "mac",
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-active-server-id", () => ({
|
||||||
|
useActiveServerId: () => "srv",
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-open-project-picker", () => ({
|
||||||
|
useOpenProjectPicker: () => vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-keyboard-shortcut-overrides", () => ({
|
||||||
|
useKeyboardShortcutOverrides: () => ({ overrides: {} }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-workspace-navigation", () => ({
|
||||||
|
navigateToWorkspace: navigateToWorkspaceMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { useKeyboardShortcuts } from "./use-keyboard-shortcuts";
|
||||||
|
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||||
|
import {
|
||||||
|
activateNavigationWorkspaceSelection,
|
||||||
|
syncNavigationActiveWorkspace,
|
||||||
|
} from "@/stores/navigation-active-workspace-store";
|
||||||
|
|
||||||
|
function Probe() {
|
||||||
|
useKeyboardShortcuts({
|
||||||
|
enabled: true,
|
||||||
|
isMobile: false,
|
||||||
|
toggleAgentList: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useKeyboardShortcuts", () => {
|
||||||
|
let root: Root | null = null;
|
||||||
|
let container: HTMLElement | null = null;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
navigateToWorkspaceMock.mockReset();
|
||||||
|
routerMock.back.mockReset();
|
||||||
|
routerMock.push.mockReset();
|
||||||
|
pathState.pathname = "/h/srv/workspace/ws-2";
|
||||||
|
syncNavigationActiveWorkspace({ current: null });
|
||||||
|
useKeyboardShortcutsStore.setState({
|
||||||
|
capturingShortcut: false,
|
||||||
|
commandCenterOpen: false,
|
||||||
|
sidebarShortcutWorkspaceTargets: [
|
||||||
|
{ serverId: "srv", workspaceId: "ws-1" },
|
||||||
|
{ serverId: "srv", workspaceId: "ws-2" },
|
||||||
|
{ serverId: "srv", workspaceId: "ws-3" },
|
||||||
|
{ serverId: "srv", workspaceId: "ws-4" },
|
||||||
|
{ serverId: "srv", workspaceId: "ws-5" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (root) {
|
||||||
|
act(() => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
root = null;
|
||||||
|
container?.remove();
|
||||||
|
container = null;
|
||||||
|
syncNavigationActiveWorkspace({ current: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the retained active workspace instead of stale pathname for bracket navigation", async () => {
|
||||||
|
activateNavigationWorkspaceSelection({ serverId: "srv", workspaceId: "ws-4" });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(<Probe />);
|
||||||
|
});
|
||||||
|
|
||||||
|
const event = new KeyboardEvent("keydown", {
|
||||||
|
key: "]",
|
||||||
|
code: "BracketRight",
|
||||||
|
metaKey: true,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
});
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
|
||||||
|
expect(navigateToWorkspaceMock).toHaveBeenCalledWith("srv", "ws-5", {
|
||||||
|
currentPathname: "/h/srv/workspace/ws-2",
|
||||||
|
});
|
||||||
|
expect(event.defaultPrevented).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,13 +1,11 @@
|
|||||||
import { useEffect, useMemo, useRef } from "react";
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
import { usePathname, useRouter } from "expo-router";
|
import { usePathname, useRouter } from "expo-router";
|
||||||
import { getIsElectronRuntime } from "@/constants/layout";
|
import { getIsElectronRuntime } from "@/constants/layout";
|
||||||
import { useHosts } from "@/runtime/host-runtime";
|
|
||||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||||
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
||||||
import {
|
import {
|
||||||
buildSettingsRoute,
|
buildSettingsRoute,
|
||||||
parseHostAgentRouteFromPathname,
|
parseHostAgentRouteFromPathname,
|
||||||
parseServerIdFromPathname,
|
|
||||||
parseHostWorkspaceRouteFromPathname,
|
parseHostWorkspaceRouteFromPathname,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
|
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
|
||||||
@@ -27,12 +25,14 @@ import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
|||||||
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
||||||
import { isNative } from "@/constants/platform";
|
import { isNative } from "@/constants/platform";
|
||||||
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
|
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
|
||||||
|
import { getRelativeSidebarShortcutTarget } from "@/utils/sidebar-shortcuts";
|
||||||
|
import { useActiveServerId } from "@/hooks/use-active-server-id";
|
||||||
|
import { getNavigationActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||||
|
|
||||||
export function useKeyboardShortcuts({
|
export function useKeyboardShortcuts({
|
||||||
enabled,
|
enabled,
|
||||||
isMobile,
|
isMobile,
|
||||||
toggleAgentList,
|
toggleAgentList,
|
||||||
toggleFileExplorer,
|
|
||||||
toggleBothSidebars,
|
toggleBothSidebars,
|
||||||
toggleFocusMode,
|
toggleFocusMode,
|
||||||
cycleTheme,
|
cycleTheme,
|
||||||
@@ -40,14 +40,12 @@ export function useKeyboardShortcuts({
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
toggleAgentList: () => void;
|
toggleAgentList: () => void;
|
||||||
toggleFileExplorer?: () => void;
|
|
||||||
toggleBothSidebars?: () => void;
|
toggleBothSidebars?: () => void;
|
||||||
toggleFocusMode?: () => void;
|
toggleFocusMode?: () => void;
|
||||||
cycleTheme?: () => void;
|
cycleTheme?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const hosts = useHosts();
|
|
||||||
const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers);
|
const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers);
|
||||||
const { overrides } = useKeyboardShortcutOverrides();
|
const { overrides } = useKeyboardShortcutOverrides();
|
||||||
const bindings = useMemo(() => buildEffectiveBindings(overrides), [overrides]);
|
const bindings = useMemo(() => buildEffectiveBindings(overrides), [overrides]);
|
||||||
@@ -56,11 +54,7 @@ export function useKeyboardShortcuts({
|
|||||||
step: 0,
|
step: 0,
|
||||||
timeoutId: null,
|
timeoutId: null,
|
||||||
});
|
});
|
||||||
const activeServerIdFromPath = parseServerIdFromPathname(pathname);
|
const activeServerId = useActiveServerId();
|
||||||
const activeServerId =
|
|
||||||
hosts.find((host) => host.serverId === activeServerIdFromPath)?.serverId ??
|
|
||||||
hosts[0]?.serverId ??
|
|
||||||
null;
|
|
||||||
const openProjectPickerAction = useOpenProjectPicker(activeServerId);
|
const openProjectPickerAction = useOpenProjectPicker(activeServerId);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -84,38 +78,32 @@ export function useKeyboardShortcuts({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
navigateToWorkspace(target.serverId, target.workspaceId);
|
navigateToWorkspace(target.serverId, target.workspaceId, { currentPathname: pathname });
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
const navigateRelativeWorkspace = (delta: 1 | -1): boolean => {
|
const navigateRelativeWorkspace = (delta: 1 | -1): boolean => {
|
||||||
const state = useKeyboardShortcutsStore.getState();
|
const state = useKeyboardShortcutsStore.getState();
|
||||||
const targets = state.visibleWorkspaceTargets;
|
const targets = state.sidebarShortcutWorkspaceTargets;
|
||||||
if (targets.length === 0) {
|
if (targets.length === 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const workspaceRoute = parseHostWorkspaceRouteFromPathname(pathname);
|
const workspaceRoute =
|
||||||
if (!workspaceRoute) {
|
getNavigationActiveWorkspaceSelection() ?? parseHostWorkspaceRouteFromPathname(pathname);
|
||||||
const fallback = targets[delta > 0 ? 0 : targets.length - 1] ?? null;
|
const target = getRelativeSidebarShortcutTarget({
|
||||||
if (!fallback) {
|
targets,
|
||||||
return false;
|
currentTarget: workspaceRoute
|
||||||
}
|
? {
|
||||||
navigateToWorkspace(fallback.serverId, fallback.workspaceId);
|
serverId: workspaceRoute.serverId,
|
||||||
return true;
|
workspaceId: workspaceRoute.workspaceId,
|
||||||
}
|
}
|
||||||
|
: null,
|
||||||
const currentIndex = targets.findIndex(
|
delta,
|
||||||
(target) =>
|
});
|
||||||
target.serverId === workspaceRoute.serverId &&
|
|
||||||
target.workspaceId === workspaceRoute.workspaceId,
|
|
||||||
);
|
|
||||||
const fromIndex = currentIndex >= 0 ? currentIndex : delta > 0 ? -1 : 0;
|
|
||||||
const nextIndex = (fromIndex + delta + targets.length) % targets.length;
|
|
||||||
const target = targets[nextIndex] ?? null;
|
|
||||||
if (!target) {
|
if (!target) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
navigateToWorkspace(target.serverId, target.workspaceId);
|
navigateToWorkspace(target.serverId, target.workspaceId, { currentPathname: pathname });
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -257,10 +245,10 @@ export function useKeyboardShortcuts({
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
case "sidebar.toggle.right":
|
case "sidebar.toggle.right":
|
||||||
if (toggleFileExplorer) {
|
return keyboardActionDispatcher.dispatch({
|
||||||
toggleFileExplorer();
|
id: "sidebar.toggle.right",
|
||||||
}
|
scope: "sidebar",
|
||||||
return true;
|
});
|
||||||
case "view.toggle.focus":
|
case "view.toggle.focus":
|
||||||
if (toggleFocusMode) {
|
if (toggleFocusMode) {
|
||||||
toggleFocusMode();
|
toggleFocusMode();
|
||||||
@@ -425,7 +413,6 @@ export function useKeyboardShortcuts({
|
|||||||
pathname,
|
pathname,
|
||||||
resetModifiers,
|
resetModifiers,
|
||||||
toggleAgentList,
|
toggleAgentList,
|
||||||
toggleFileExplorer,
|
|
||||||
toggleFocusMode,
|
toggleFocusMode,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*/
|
||||||
|
import React from "react";
|
||||||
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { DaemonClient } from "@server/client/daemon-client";
|
||||||
|
import type { ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
|
||||||
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
import {
|
||||||
|
providersSnapshotQueryKey,
|
||||||
|
shouldApplyProvidersSnapshotUpdate,
|
||||||
|
useProvidersSnapshot,
|
||||||
|
} from "./use-providers-snapshot";
|
||||||
|
|
||||||
|
type ProviderSnapshotUpdateMessage = {
|
||||||
|
type: "providers_snapshot_update";
|
||||||
|
payload: {
|
||||||
|
cwd: string;
|
||||||
|
entries: ProviderSnapshotEntry[];
|
||||||
|
generatedAt: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
type ProviderSnapshotUpdateListener = (message: ProviderSnapshotUpdateMessage) => void;
|
||||||
|
type ProvidersSnapshot = {
|
||||||
|
entries: ProviderSnapshotEntry[];
|
||||||
|
generatedAt: string;
|
||||||
|
requestId: string;
|
||||||
|
};
|
||||||
|
type HookResult = ReturnType<typeof renderProvidersSnapshotHook>["result"];
|
||||||
|
|
||||||
|
const { mockClient, mockRuntime, snapshotUpdateListeners } = vi.hoisted(() => {
|
||||||
|
const snapshotUpdateListeners: ProviderSnapshotUpdateListener[] = [];
|
||||||
|
const mockClient = {
|
||||||
|
getProvidersSnapshot: vi.fn(),
|
||||||
|
refreshProvidersSnapshot: vi.fn(),
|
||||||
|
on: vi.fn((_event: string, listener: ProviderSnapshotUpdateListener) => {
|
||||||
|
snapshotUpdateListeners.push(listener);
|
||||||
|
return () => {};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
mockClient,
|
||||||
|
mockRuntime: {
|
||||||
|
client: mockClient,
|
||||||
|
isConnected: true,
|
||||||
|
},
|
||||||
|
snapshotUpdateListeners,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@/runtime/host-runtime", () => ({
|
||||||
|
useHostRuntimeClient: () => mockRuntime.client,
|
||||||
|
useHostRuntimeIsConnected: () => mockRuntime.isConnected,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const serverId = "server-1";
|
||||||
|
|
||||||
|
function createQueryClient(): QueryClient {
|
||||||
|
return new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function enableProvidersSnapshot(): void {
|
||||||
|
act(() => {
|
||||||
|
useSessionStore.getState().initializeSession(serverId, mockClient as unknown as DaemonClient);
|
||||||
|
useSessionStore.getState().updateSessionServerInfo(serverId, {
|
||||||
|
serverId,
|
||||||
|
hostname: "localhost",
|
||||||
|
version: "test",
|
||||||
|
features: { providersSnapshot: true },
|
||||||
|
} as never);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProvidersSnapshotHook(cwd?: string | null) {
|
||||||
|
const queryClient = createQueryClient();
|
||||||
|
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||||
|
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||||
|
|
||||||
|
return renderHook(() => useProvidersSnapshot(serverId, cwd), { wrapper });
|
||||||
|
}
|
||||||
|
|
||||||
|
const readyCodexModel = { provider: "codex", id: "gpt-5.4", label: "GPT-5.4" } as const;
|
||||||
|
|
||||||
|
function providersSnapshot(entries: ProviderSnapshotEntry[]): ProvidersSnapshot {
|
||||||
|
return {
|
||||||
|
entries,
|
||||||
|
generatedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
requestId: "snapshot",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function codexEntry(
|
||||||
|
status: ProviderSnapshotEntry["status"],
|
||||||
|
models?: ProviderSnapshotEntry["models"],
|
||||||
|
): ProviderSnapshotEntry {
|
||||||
|
return {
|
||||||
|
provider: "codex",
|
||||||
|
status,
|
||||||
|
...(models ? { models } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForSnapshotReads(count: number): Promise<void> {
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockClient.getProvidersSnapshot).toHaveBeenCalledTimes(count);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForSnapshotEntries(
|
||||||
|
result: HookResult,
|
||||||
|
entries: ProviderSnapshotEntry[],
|
||||||
|
): Promise<void> {
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.entries).toEqual(entries);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function emitProvidersSnapshotUpdate(entries: ProviderSnapshotEntry[]): Promise<void> {
|
||||||
|
const listener = snapshotUpdateListeners.at(-1);
|
||||||
|
expect(listener).toBeDefined();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
listener?.({
|
||||||
|
type: "providers_snapshot_update",
|
||||||
|
payload: {
|
||||||
|
cwd: "/repo",
|
||||||
|
entries,
|
||||||
|
generatedAt: "2026-01-01T00:00:01.000Z",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSelectorForSelectedProvider(result: HookResult): Promise<void> {
|
||||||
|
await act(async () => {
|
||||||
|
result.current.refetchIfStale("codex");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
act(() => {
|
||||||
|
useSessionStore.getState().clearSession(serverId);
|
||||||
|
});
|
||||||
|
vi.clearAllMocks();
|
||||||
|
snapshotUpdateListeners.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("providers snapshot hook cache scope", () => {
|
||||||
|
it("uses no cwd in the settings query key", () => {
|
||||||
|
expect(providersSnapshotQueryKey(serverId)).toEqual(["providersSnapshot", serverId, null]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts concrete home update events for settings snapshots", () => {
|
||||||
|
expect(shouldApplyProvidersSnapshotUpdate(undefined, "/Users/alex")).toBe(true);
|
||||||
|
expect(shouldApplyProvidersSnapshotUpdate(null, "/home/alex")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps workspace snapshot updates scoped to matching cwd keys", () => {
|
||||||
|
expect(shouldApplyProvidersSnapshotUpdate("/Users/alex/project", "/Users/alex/project")).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
shouldApplyProvidersSnapshotUpdate("/Users/alex/project-a", "/Users/alex/project-b"),
|
||||||
|
).toBe(false);
|
||||||
|
expect(shouldApplyProvidersSnapshotUpdate("/Users/alex/project", "/Users/alex")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends no cwd for settings snapshot loads and refreshes", async () => {
|
||||||
|
enableProvidersSnapshot();
|
||||||
|
mockClient.getProvidersSnapshot.mockResolvedValue(providersSnapshot([]));
|
||||||
|
mockClient.refreshProvidersSnapshot.mockResolvedValue({
|
||||||
|
acknowledged: true,
|
||||||
|
requestId: "settings-refresh",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderProvidersSnapshotHook();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockClient.getProvidersSnapshot).toHaveBeenCalledWith({});
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.refresh(["codex"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockClient.refreshProvidersSnapshot).toHaveBeenCalledWith({ providers: ["codex"] });
|
||||||
|
expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends cwd for workspace snapshot loads and refreshes", async () => {
|
||||||
|
enableProvidersSnapshot();
|
||||||
|
mockClient.getProvidersSnapshot.mockResolvedValue(providersSnapshot([]));
|
||||||
|
mockClient.refreshProvidersSnapshot.mockResolvedValue({
|
||||||
|
acknowledged: true,
|
||||||
|
requestId: "workspace-refresh",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderProvidersSnapshotHook("/repo");
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockClient.getProvidersSnapshot).toHaveBeenCalledWith({ cwd: "/repo" });
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.refresh(["codex"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockClient.refreshProvidersSnapshot).toHaveBeenCalledWith({
|
||||||
|
cwd: "/repo",
|
||||||
|
providers: ["codex"],
|
||||||
|
});
|
||||||
|
expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({ cwd: "/repo" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refetches loading snapshot updates through the read path but ignores empty updates", async () => {
|
||||||
|
enableProvidersSnapshot();
|
||||||
|
mockClient.getProvidersSnapshot
|
||||||
|
.mockResolvedValueOnce(providersSnapshot([codexEntry("ready", [])]))
|
||||||
|
.mockResolvedValueOnce(providersSnapshot([codexEntry("ready", [readyCodexModel])]));
|
||||||
|
|
||||||
|
renderProvidersSnapshotHook("/repo");
|
||||||
|
|
||||||
|
await waitForSnapshotReads(1);
|
||||||
|
await emitProvidersSnapshotUpdate([]);
|
||||||
|
|
||||||
|
expect(mockClient.getProvidersSnapshot).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await emitProvidersSnapshotUpdate([codexEntry("loading")]);
|
||||||
|
await waitForSnapshotReads(2);
|
||||||
|
|
||||||
|
expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({ cwd: "/repo" });
|
||||||
|
expect(mockClient.refreshProvidersSnapshot).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ name: "missing", entries: [] },
|
||||||
|
{ name: "loading", entries: [codexEntry("loading")] },
|
||||||
|
])("ensures a selected provider snapshot on selector open when it is $name", async ({
|
||||||
|
entries,
|
||||||
|
}) => {
|
||||||
|
enableProvidersSnapshot();
|
||||||
|
mockClient.getProvidersSnapshot
|
||||||
|
.mockResolvedValueOnce(providersSnapshot(entries))
|
||||||
|
.mockResolvedValueOnce(providersSnapshot([codexEntry("ready", [readyCodexModel])]));
|
||||||
|
|
||||||
|
const { result } = renderProvidersSnapshotHook("/repo");
|
||||||
|
|
||||||
|
await waitForSnapshotEntries(result, entries);
|
||||||
|
await openSelectorForSelectedProvider(result);
|
||||||
|
await waitForSnapshotReads(2);
|
||||||
|
|
||||||
|
expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({ cwd: "/repo" });
|
||||||
|
expect(mockClient.refreshProvidersSnapshot).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not ensure a selected provider snapshot on selector open when the provider is ready with no models", async () => {
|
||||||
|
enableProvidersSnapshot();
|
||||||
|
mockClient.getProvidersSnapshot.mockResolvedValue(providersSnapshot([codexEntry("ready", [])]));
|
||||||
|
|
||||||
|
const { result } = renderProvidersSnapshotHook("/repo");
|
||||||
|
|
||||||
|
await waitForSnapshotEntries(result, [codexEntry("ready", [])]);
|
||||||
|
await openSelectorForSelectedProvider(result);
|
||||||
|
|
||||||
|
expect(mockClient.getProvidersSnapshot).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockClient.refreshProvidersSnapshot).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,7 +6,7 @@ import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-
|
|||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { queryClient as singletonQueryClient } from "@/query/query-client";
|
import { queryClient as singletonQueryClient } from "@/query/query-client";
|
||||||
|
|
||||||
function normalizeProvidersSnapshotCwdKey(cwd?: string | null): string | null {
|
export function normalizeProvidersSnapshotCwdKey(cwd?: string | null): string | null {
|
||||||
const trimmed = cwd?.trim();
|
const trimmed = cwd?.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
return null;
|
return null;
|
||||||
@@ -15,10 +15,33 @@ function normalizeProvidersSnapshotCwdKey(cwd?: string | null): string | null {
|
|||||||
return trimmed.replace(/^\/(?:Users|home)\/[^/]+/, "~");
|
return trimmed.replace(/^\/(?:Users|home)\/[^/]+/, "~");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function shouldApplyProvidersSnapshotUpdate(
|
||||||
|
currentCwd?: string | null,
|
||||||
|
messageCwd?: string | null,
|
||||||
|
): boolean {
|
||||||
|
const currentCwdKey = normalizeProvidersSnapshotCwdKey(currentCwd);
|
||||||
|
const messageCwdKey = normalizeProvidersSnapshotCwdKey(messageCwd);
|
||||||
|
return messageCwdKey === currentCwdKey || (currentCwdKey === null && messageCwdKey === "~");
|
||||||
|
}
|
||||||
|
|
||||||
export function providersSnapshotQueryKey(serverId: string | null, cwd?: string | null) {
|
export function providersSnapshotQueryKey(serverId: string | null, cwd?: string | null) {
|
||||||
return ["providersSnapshot", serverId, normalizeProvidersSnapshotCwdKey(cwd)] as const;
|
return ["providersSnapshot", serverId, normalizeProvidersSnapshotCwdKey(cwd)] as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function providersSnapshotRequest(cwd?: string): { cwd?: string } {
|
||||||
|
return cwd ? { cwd } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshProvidersSnapshotRequest(
|
||||||
|
cwd: string | undefined,
|
||||||
|
providers: AgentProvider[] | undefined,
|
||||||
|
): { cwd?: string; providers?: AgentProvider[] } {
|
||||||
|
return {
|
||||||
|
...providersSnapshotRequest(cwd),
|
||||||
|
...(providers ? { providers } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
interface UseProvidersSnapshotResult {
|
interface UseProvidersSnapshotResult {
|
||||||
entries: ProviderSnapshotEntry[] | undefined;
|
entries: ProviderSnapshotEntry[] | undefined;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
@@ -27,16 +50,22 @@ interface UseProvidersSnapshotResult {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
supportsSnapshot: boolean;
|
supportsSnapshot: boolean;
|
||||||
refresh: (providers?: AgentProvider[]) => Promise<void>;
|
refresh: (providers?: AgentProvider[]) => Promise<void>;
|
||||||
refetchIfStale: () => void;
|
refetchIfStale: (selectedProvider?: AgentProvider | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseProvidersSnapshotOptions {
|
||||||
|
enabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useProvidersSnapshot(
|
export function useProvidersSnapshot(
|
||||||
serverId: string | null,
|
serverId: string | null,
|
||||||
cwd?: string | null,
|
cwd?: string | null,
|
||||||
|
options: UseProvidersSnapshotOptions = {},
|
||||||
): UseProvidersSnapshotResult {
|
): UseProvidersSnapshotResult {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const client = useHostRuntimeClient(serverId ?? "");
|
const client = useHostRuntimeClient(serverId ?? "");
|
||||||
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
||||||
|
const enabled = options.enabled ?? true;
|
||||||
const normalizedCwd = cwd?.trim() || undefined;
|
const normalizedCwd = cwd?.trim() || undefined;
|
||||||
const normalizedCwdKey = normalizeProvidersSnapshotCwdKey(normalizedCwd);
|
const normalizedCwdKey = normalizeProvidersSnapshotCwdKey(normalizedCwd);
|
||||||
const supportsSnapshot = useSessionStore(
|
const supportsSnapshot = useSessionStore(
|
||||||
@@ -50,13 +79,13 @@ export function useProvidersSnapshot(
|
|||||||
|
|
||||||
const snapshotQuery = useQuery({
|
const snapshotQuery = useQuery({
|
||||||
queryKey,
|
queryKey,
|
||||||
enabled: Boolean(supportsSnapshot && serverId && client && isConnected),
|
enabled: Boolean(enabled && supportsSnapshot && serverId && client && isConnected),
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!client) {
|
if (!client) {
|
||||||
throw new Error("Host is not connected");
|
throw new Error("Host is not connected");
|
||||||
}
|
}
|
||||||
return client.getProvidersSnapshot({ cwd: normalizedCwd });
|
return client.getProvidersSnapshot(providersSnapshotRequest(normalizedCwd));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -65,13 +94,15 @@ export function useProvidersSnapshot(
|
|||||||
if (!client) {
|
if (!client) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await client.refreshProvidersSnapshot({ cwd: normalizedCwd, providers });
|
await client.refreshProvidersSnapshot(
|
||||||
|
refreshProvidersSnapshotRequest(normalizedCwd, providers),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const { mutateAsync: refreshSnapshot, isPending: isRefreshing } = refreshMutation;
|
const { mutateAsync: refreshSnapshot, isPending: isRefreshing } = refreshMutation;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!supportsSnapshot || !client || !isConnected || !serverId) {
|
if (!enabled || !supportsSnapshot || !client || !isConnected || !serverId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,8 +110,7 @@ export function useProvidersSnapshot(
|
|||||||
if (message.type !== "providers_snapshot_update") {
|
if (message.type !== "providers_snapshot_update") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const messageCwdKey = normalizeProvidersSnapshotCwdKey(message.payload.cwd);
|
if (!shouldApplyProvidersSnapshotUpdate(normalizedCwd, message.payload.cwd)) {
|
||||||
if (messageCwdKey !== normalizedCwdKey) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
queryClient.setQueryData(queryKey, {
|
queryClient.setQueryData(queryKey, {
|
||||||
@@ -88,19 +118,59 @@ export function useProvidersSnapshot(
|
|||||||
generatedAt: message.payload.generatedAt,
|
generatedAt: message.payload.generatedAt,
|
||||||
requestId: "providers_snapshot_update",
|
requestId: "providers_snapshot_update",
|
||||||
});
|
});
|
||||||
|
const shouldRefetch = message.payload.entries.some((entry) => entry.status === "loading");
|
||||||
|
if (shouldRefetch) {
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey,
|
||||||
|
exact: true,
|
||||||
|
refetchType: "active",
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}, [client, isConnected, normalizedCwdKey, queryClient, queryKey, serverId, supportsSnapshot]);
|
}, [
|
||||||
|
client,
|
||||||
|
enabled,
|
||||||
|
isConnected,
|
||||||
|
normalizedCwd,
|
||||||
|
normalizedCwdKey,
|
||||||
|
queryClient,
|
||||||
|
queryKey,
|
||||||
|
serverId,
|
||||||
|
supportsSnapshot,
|
||||||
|
]);
|
||||||
|
|
||||||
const refresh = useCallback(
|
const refresh = useCallback(
|
||||||
async (providers?: AgentProvider[]) => {
|
async (providers?: AgentProvider[]) => {
|
||||||
|
if (!client) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
await refreshSnapshot(providers);
|
await refreshSnapshot(providers);
|
||||||
|
const snapshot = await client.getProvidersSnapshot(providersSnapshotRequest(normalizedCwd));
|
||||||
|
queryClient.setQueryData(queryKey, snapshot);
|
||||||
},
|
},
|
||||||
[refreshSnapshot],
|
[client, normalizedCwd, queryClient, queryKey, refreshSnapshot],
|
||||||
);
|
);
|
||||||
|
|
||||||
const refetchIfStale = useCallback(() => {
|
const refetchIfStale = useCallback(
|
||||||
void queryClient.refetchQueries({ queryKey, type: "active", stale: true });
|
(selectedProvider?: AgentProvider | null) => {
|
||||||
}, [queryClient, queryKey]);
|
if (!selectedProvider) {
|
||||||
|
void queryClient.refetchQueries({ queryKey, type: "active", stale: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedEntry = snapshotQuery.data?.entries.find(
|
||||||
|
(entry) => entry.provider === selectedProvider,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!selectedEntry || selectedEntry.status === "loading") {
|
||||||
|
void queryClient.refetchQueries({ queryKey, type: "active" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void queryClient.refetchQueries({ queryKey, type: "active", stale: true });
|
||||||
|
},
|
||||||
|
[queryClient, queryKey, snapshotQuery.data?.entries],
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
entries: snapshotQuery.data?.entries ?? undefined,
|
entries: snapshotQuery.data?.entries ?? undefined,
|
||||||
@@ -124,6 +194,6 @@ export function prefetchProvidersSnapshot(
|
|||||||
void singletonQueryClient.prefetchQuery({
|
void singletonQueryClient.prefetchQuery({
|
||||||
queryKey,
|
queryKey,
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
queryFn: () => client.getProvidersSnapshot({ cwd: normalizedCwd }),
|
queryFn: () => client.getProvidersSnapshot(providersSnapshotRequest(normalizedCwd)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,16 @@ describe("use-settings", () => {
|
|||||||
expect(result.theme).toBe("auto");
|
expect(result.theme).toBe("auto");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("defaults release channel to stable when storage is empty", async () => {
|
||||||
|
asyncStorageMock.getItem.mockResolvedValue(null);
|
||||||
|
asyncStorageMock.setItem.mockResolvedValue();
|
||||||
|
|
||||||
|
const mod = await import("./use-settings");
|
||||||
|
const result = await mod.loadSettingsFromStorage();
|
||||||
|
|
||||||
|
expect(result.releaseChannel).toBe("stable");
|
||||||
|
});
|
||||||
|
|
||||||
it("loads persisted built-in daemon management state", async () => {
|
it("loads persisted built-in daemon management state", async () => {
|
||||||
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
|
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
|
||||||
if (key === "@paseo:app-settings") {
|
if (key === "@paseo:app-settings") {
|
||||||
@@ -58,7 +68,24 @@ describe("use-settings", () => {
|
|||||||
theme: "light",
|
theme: "light",
|
||||||
manageBuiltInDaemon: false,
|
manageBuiltInDaemon: false,
|
||||||
sendBehavior: "interrupt",
|
sendBehavior: "interrupt",
|
||||||
|
releaseChannel: "stable",
|
||||||
});
|
});
|
||||||
expect(asyncStorageMock.setItem).not.toHaveBeenCalled();
|
expect(asyncStorageMock.setItem).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("loads persisted beta release channel", async () => {
|
||||||
|
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
|
||||||
|
if (key === "@paseo:app-settings") {
|
||||||
|
return JSON.stringify({
|
||||||
|
releaseChannel: "beta",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const mod = await import("./use-settings");
|
||||||
|
const result = await mod.loadSettingsFromStorage();
|
||||||
|
|
||||||
|
expect(result.releaseChannel).toBe("beta");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,19 +9,23 @@ const APP_SETTINGS_QUERY_KEY = ["app-settings"];
|
|||||||
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
|
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
|
||||||
|
|
||||||
export type SendBehavior = "interrupt" | "queue";
|
export type SendBehavior = "interrupt" | "queue";
|
||||||
|
export type ReleaseChannel = "stable" | "beta";
|
||||||
|
|
||||||
const VALID_THEMES = new Set<string>([...Object.keys(THEME_TO_UNISTYLES), "auto"]);
|
const VALID_THEMES = new Set<string>([...Object.keys(THEME_TO_UNISTYLES), "auto"]);
|
||||||
|
const VALID_RELEASE_CHANNELS = new Set<string>(["stable", "beta"]);
|
||||||
|
|
||||||
export interface AppSettings {
|
export interface AppSettings {
|
||||||
theme: ThemeName | "auto";
|
theme: ThemeName | "auto";
|
||||||
manageBuiltInDaemon: boolean;
|
manageBuiltInDaemon: boolean;
|
||||||
sendBehavior: SendBehavior;
|
sendBehavior: SendBehavior;
|
||||||
|
releaseChannel: ReleaseChannel;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_APP_SETTINGS: AppSettings = {
|
export const DEFAULT_APP_SETTINGS: AppSettings = {
|
||||||
theme: "auto",
|
theme: "auto",
|
||||||
manageBuiltInDaemon: true,
|
manageBuiltInDaemon: true,
|
||||||
sendBehavior: "interrupt",
|
sendBehavior: "interrupt",
|
||||||
|
releaseChannel: "stable",
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface UseAppSettingsReturn {
|
export interface UseAppSettingsReturn {
|
||||||
@@ -85,6 +89,9 @@ export async function loadSettingsFromStorage(): Promise<AppSettings> {
|
|||||||
if (parsed.theme && !VALID_THEMES.has(parsed.theme)) {
|
if (parsed.theme && !VALID_THEMES.has(parsed.theme)) {
|
||||||
parsed.theme = DEFAULT_APP_SETTINGS.theme;
|
parsed.theme = DEFAULT_APP_SETTINGS.theme;
|
||||||
}
|
}
|
||||||
|
if (parsed.releaseChannel && !VALID_RELEASE_CHANNELS.has(parsed.releaseChannel)) {
|
||||||
|
parsed.releaseChannel = DEFAULT_APP_SETTINGS.releaseChannel;
|
||||||
|
}
|
||||||
return { ...DEFAULT_APP_SETTINGS, ...parsed };
|
return { ...DEFAULT_APP_SETTINGS, ...parsed };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,6 +122,9 @@ function pickAppSettingsFromLegacy(legacy: Record<string, unknown>): Partial<App
|
|||||||
if (typeof legacy.manageBuiltInDaemon === "boolean") {
|
if (typeof legacy.manageBuiltInDaemon === "boolean") {
|
||||||
result.manageBuiltInDaemon = legacy.manageBuiltInDaemon;
|
result.manageBuiltInDaemon = legacy.manageBuiltInDaemon;
|
||||||
}
|
}
|
||||||
|
if (legacy.releaseChannel === "stable" || legacy.releaseChannel === "beta") {
|
||||||
|
result.releaseChannel = legacy.releaseChannel;
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
|
import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
|
||||||
import { buildSidebarShortcutModel } from "@/utils/sidebar-shortcuts";
|
import { buildSidebarShortcutModel } from "@/utils/sidebar-shortcuts";
|
||||||
import { isSidebarProjectFlattened } from "@/utils/sidebar-project-row-model";
|
import { isSidebarProjectFlattened } from "@/utils/sidebar-project-row-model";
|
||||||
|
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
||||||
|
|
||||||
export function useSidebarShortcutModel(projects: SidebarProjectEntry[]) {
|
export function useSidebarShortcutModel(input: {
|
||||||
const [collapsedProjectKeys, setCollapsedProjectKeys] = useState<Set<string>>(new Set());
|
projects: SidebarProjectEntry[];
|
||||||
const setSidebarShortcutWorkspaceTargets = useKeyboardShortcutsStore(
|
isInitialLoad: boolean;
|
||||||
(state) => state.setSidebarShortcutWorkspaceTargets,
|
}) {
|
||||||
|
const { projects, isInitialLoad } = input;
|
||||||
|
const collapsedProjectKeys = useSidebarCollapsedSectionsStore(
|
||||||
|
(state) => state.collapsedProjectKeys,
|
||||||
);
|
);
|
||||||
const setVisibleWorkspaceTargets = useKeyboardShortcutsStore(
|
const setProjectCollapsed = useSidebarCollapsedSectionsStore(
|
||||||
(state) => state.setVisibleWorkspaceTargets,
|
(state) => state.setProjectCollapsed,
|
||||||
|
);
|
||||||
|
const toggleProjectCollapsed = useSidebarCollapsedSectionsStore(
|
||||||
|
(state) => state.toggleProjectCollapsed,
|
||||||
);
|
);
|
||||||
|
|
||||||
const shortcutModel = useMemo(
|
const shortcutModel = useMemo(
|
||||||
@@ -23,62 +29,21 @@ export function useSidebarShortcutModel(projects: SidebarProjectEntry[]) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCollapsedProjectKeys((prev) => {
|
if (isInitialLoad || projects.length === 0) {
|
||||||
const collapsibleProjectKeys = new Set(
|
return;
|
||||||
projects
|
}
|
||||||
.filter((project) => !isSidebarProjectFlattened(project))
|
|
||||||
.map((project) => project.projectKey),
|
const collapsibleProjectKeys = new Set(
|
||||||
);
|
projects
|
||||||
const next = new Set<string>();
|
.filter((project) => !isSidebarProjectFlattened(project))
|
||||||
for (const key of prev) {
|
.map((project) => project.projectKey),
|
||||||
if (collapsibleProjectKeys.has(key)) {
|
);
|
||||||
next.add(key);
|
for (const key of collapsedProjectKeys) {
|
||||||
}
|
if (!collapsibleProjectKeys.has(key)) {
|
||||||
|
setProjectCollapsed(key, false);
|
||||||
}
|
}
|
||||||
return next;
|
}
|
||||||
});
|
}, [collapsedProjectKeys, isInitialLoad, projects, setProjectCollapsed]);
|
||||||
}, [projects]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setSidebarShortcutWorkspaceTargets(shortcutModel.shortcutTargets);
|
|
||||||
setVisibleWorkspaceTargets(shortcutModel.visibleTargets);
|
|
||||||
}, [
|
|
||||||
setSidebarShortcutWorkspaceTargets,
|
|
||||||
setVisibleWorkspaceTargets,
|
|
||||||
shortcutModel.shortcutTargets,
|
|
||||||
shortcutModel.visibleTargets,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
setSidebarShortcutWorkspaceTargets([]);
|
|
||||||
setVisibleWorkspaceTargets([]);
|
|
||||||
};
|
|
||||||
}, [setSidebarShortcutWorkspaceTargets, setVisibleWorkspaceTargets]);
|
|
||||||
|
|
||||||
const toggleProjectCollapsed = useCallback((projectKey: string) => {
|
|
||||||
setCollapsedProjectKeys((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(projectKey)) {
|
|
||||||
next.delete(projectKey);
|
|
||||||
} else {
|
|
||||||
next.add(projectKey);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const setProjectCollapsed = useCallback((projectKey: string, collapsed: boolean) => {
|
|
||||||
setCollapsedProjectKeys((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (collapsed) {
|
|
||||||
next.add(projectKey);
|
|
||||||
} else {
|
|
||||||
next.delete(projectKey);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
collapsedProjectKeys,
|
collapsedProjectKeys,
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*/
|
||||||
|
import { act } from "@testing-library/react";
|
||||||
|
import type { DaemonClient } from "@server/client/daemon-client";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import React from "react";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
vi.hoisted(() => {
|
vi.hoisted(() => {
|
||||||
(globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
|
(globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
|
||||||
@@ -8,8 +15,12 @@ import {
|
|||||||
appendMissingOrderKeys,
|
appendMissingOrderKeys,
|
||||||
applyStoredOrdering,
|
applyStoredOrdering,
|
||||||
buildSidebarProjectsFromStructure,
|
buildSidebarProjectsFromStructure,
|
||||||
|
useSidebarWorkspacesList,
|
||||||
} from "./use-sidebar-workspaces-list";
|
} from "./use-sidebar-workspaces-list";
|
||||||
import type { WorkspaceStructureProject } from "@/stores/session-store-hooks";
|
import type { WorkspaceStructureProject } from "@/stores/session-store-hooks";
|
||||||
|
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||||
|
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||||
|
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||||
|
|
||||||
interface OrderedItem {
|
interface OrderedItem {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -35,6 +46,45 @@ function project(input: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function workspaceDescriptor(id: string): WorkspaceDescriptor {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
projectId: "project-1",
|
||||||
|
projectDisplayName: "Project 1",
|
||||||
|
projectRootPath: "/repo/main",
|
||||||
|
workspaceDirectory: `/repo/main/${id}`,
|
||||||
|
projectKind: "git",
|
||||||
|
workspaceKind: "worktree",
|
||||||
|
name: id,
|
||||||
|
status: "done",
|
||||||
|
diffStat: null,
|
||||||
|
scripts: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function DisabledHookProbe({ serverId }: { serverId: string }): null {
|
||||||
|
const result = useSidebarWorkspacesList({ serverId, enabled: false });
|
||||||
|
|
||||||
|
expect(result.projects).toEqual([]);
|
||||||
|
expect(result.isLoading).toBe(false);
|
||||||
|
expect(result.isInitialLoad).toBe(false);
|
||||||
|
expect(result.isRevalidating).toBe(false);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DisabledRenderCountProbe({
|
||||||
|
onRender,
|
||||||
|
serverId,
|
||||||
|
}: {
|
||||||
|
onRender: () => void;
|
||||||
|
serverId: string;
|
||||||
|
}): null {
|
||||||
|
useSidebarWorkspacesList({ serverId, enabled: false });
|
||||||
|
onRender();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
describe("applyStoredOrdering", () => {
|
describe("applyStoredOrdering", () => {
|
||||||
it("keeps unknown items on the baseline while applying stored order", () => {
|
it("keeps unknown items on the baseline while applying stored order", () => {
|
||||||
const result = applyStoredOrdering({
|
const result = applyStoredOrdering({
|
||||||
@@ -139,3 +189,73 @@ describe("buildSidebarProjectsFromStructure", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("useSidebarWorkspacesList", () => {
|
||||||
|
let root: Root | null = null;
|
||||||
|
let container: HTMLElement | null = null;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (root) {
|
||||||
|
act(() => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
root = null;
|
||||||
|
container?.remove();
|
||||||
|
container = null;
|
||||||
|
act(() => {
|
||||||
|
getHostRuntimeStore().syncHosts([]);
|
||||||
|
useSessionStore.getState().clearSession("srv-disabled");
|
||||||
|
useSidebarOrderStore.setState({
|
||||||
|
projectOrderByServerId: {},
|
||||||
|
workspaceOrderByServerAndProject: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honors enabled false without appending persisted order keys", async () => {
|
||||||
|
act(() => {
|
||||||
|
useSessionStore.getState().initializeSession("srv-disabled", null as unknown as DaemonClient);
|
||||||
|
useSessionStore
|
||||||
|
.getState()
|
||||||
|
.setWorkspaces("srv-disabled", new Map([["ws-main", workspaceDescriptor("ws-main")]]));
|
||||||
|
useSessionStore.getState().setHasHydratedWorkspaces("srv-disabled", true);
|
||||||
|
});
|
||||||
|
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(React.createElement(DisabledHookProbe, { serverId: "srv-disabled" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(useSidebarOrderStore.getState().projectOrderByServerId).toEqual({});
|
||||||
|
expect(useSidebarOrderStore.getState().workspaceOrderByServerAndProject).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not subscribe to order updates while disabled", async () => {
|
||||||
|
const onRender = vi.fn();
|
||||||
|
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(
|
||||||
|
React.createElement(DisabledRenderCountProbe, {
|
||||||
|
serverId: "srv-disabled",
|
||||||
|
onRender,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onRender).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
useSidebarOrderStore.getState().setProjectOrder("srv-disabled", ["project-a"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onRender).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
|
import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
|
||||||
import type { WorkspaceDescriptorPayload } from "@server/shared/messages";
|
import type { WorkspaceDescriptorPayload } from "@server/shared/messages";
|
||||||
import {
|
import {
|
||||||
mergeWorkspaceSnapshotWithExisting,
|
|
||||||
normalizeWorkspaceDescriptor,
|
normalizeWorkspaceDescriptor,
|
||||||
useSessionStore,
|
useSessionStore,
|
||||||
type WorkspaceDescriptor,
|
type WorkspaceDescriptor,
|
||||||
@@ -191,11 +190,10 @@ export function useSidebarWorkspacesList(options?: {
|
|||||||
const value = options?.serverId;
|
const value = options?.serverId;
|
||||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||||
}, [options?.serverId]);
|
}, [options?.serverId]);
|
||||||
|
const isActive = Boolean(serverId) && options?.enabled !== false;
|
||||||
const persistedProjectOrder = useSidebarOrderStore((state) =>
|
const persistedProjectOrder = useSidebarOrderStore((state) =>
|
||||||
serverId ? (state.projectOrderByServerId[serverId] ?? EMPTY_ORDER) : EMPTY_ORDER,
|
isActive && serverId ? (state.projectOrderByServerId[serverId] ?? EMPTY_ORDER) : EMPTY_ORDER,
|
||||||
);
|
);
|
||||||
|
|
||||||
const isActive = Boolean(serverId);
|
|
||||||
const hasHydratedWorkspaces = useSessionStore((state) =>
|
const hasHydratedWorkspaces = useSessionStore((state) =>
|
||||||
isActive && serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false,
|
isActive && serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false,
|
||||||
);
|
);
|
||||||
@@ -275,7 +273,6 @@ export function useSidebarWorkspacesList(options?: {
|
|||||||
}
|
}
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const next = new Map<string, WorkspaceDescriptor>();
|
const next = new Map<string, WorkspaceDescriptor>();
|
||||||
const existingWorkspaces = useSessionStore.getState().sessions[serverId]?.workspaces;
|
|
||||||
let cursor: string | null = null;
|
let cursor: string | null = null;
|
||||||
try {
|
try {
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -285,13 +282,7 @@ export function useSidebarWorkspacesList(options?: {
|
|||||||
});
|
});
|
||||||
for (const entry of payload.entries) {
|
for (const entry of payload.entries) {
|
||||||
const workspace = toWorkspaceDescriptor(entry);
|
const workspace = toWorkspaceDescriptor(entry);
|
||||||
next.set(
|
next.set(workspace.id, workspace);
|
||||||
workspace.id,
|
|
||||||
mergeWorkspaceSnapshotWithExisting({
|
|
||||||
incoming: workspace,
|
|
||||||
existing: existingWorkspaces?.get(workspace.id),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
|
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const { navigateMock } = vi.hoisted(() => ({
|
||||||
|
navigateMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("expo-router", () => ({
|
||||||
|
router: {
|
||||||
|
navigate: navigateMock,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
|
||||||
|
import {
|
||||||
|
activateNavigationWorkspaceSelection,
|
||||||
|
getNavigationActiveWorkspaceSelection,
|
||||||
|
syncNavigationActiveWorkspace,
|
||||||
|
} from "@/stores/navigation-active-workspace-store";
|
||||||
|
|
||||||
|
describe("navigateToWorkspace", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
navigateMock.mockReset();
|
||||||
|
syncNavigationActiveWorkspace({ current: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses router navigation from a non-workspace route even when active selection is stale", () => {
|
||||||
|
activateNavigationWorkspaceSelection({
|
||||||
|
serverId: "server-1",
|
||||||
|
workspaceId: "workspace-a",
|
||||||
|
});
|
||||||
|
|
||||||
|
navigateToWorkspace("server-1", "workspace-b", {
|
||||||
|
currentPathname: "/h/server-1/sessions",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(navigateMock).toHaveBeenCalledWith("/h/server-1/workspace/workspace-b");
|
||||||
|
expect(getNavigationActiveWorkspaceSelection()).toEqual({
|
||||||
|
serverId: "server-1",
|
||||||
|
workspaceId: "workspace-a",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps retained workspace switching on a workspace route", () => {
|
||||||
|
activateNavigationWorkspaceSelection({
|
||||||
|
serverId: "server-1",
|
||||||
|
workspaceId: "workspace-a",
|
||||||
|
});
|
||||||
|
|
||||||
|
navigateToWorkspace("server-1", "workspace-b", {
|
||||||
|
currentPathname: "/h/server-1/workspace/workspace-a",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(navigateMock).not.toHaveBeenCalled();
|
||||||
|
expect(getNavigationActiveWorkspaceSelection()).toEqual({
|
||||||
|
serverId: "server-1",
|
||||||
|
workspaceId: "workspace-b",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,23 +1,64 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
import {
|
||||||
|
activateNavigationWorkspaceSelection,
|
||||||
|
getNavigationActiveWorkspaceSelection,
|
||||||
|
} from "@/stores/navigation-active-workspace-store";
|
||||||
|
import { buildHostWorkspaceRoute, parseHostWorkspaceRouteFromPathname } from "@/utils/host-routes";
|
||||||
|
|
||||||
|
interface NavigateToWorkspaceOptions {
|
||||||
|
currentPathname?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldUseRetainedWorkspaceSwitch(input: {
|
||||||
|
hasActiveWorkspace: boolean;
|
||||||
|
currentPathname?: string | null;
|
||||||
|
}): boolean {
|
||||||
|
if (!input.hasActiveWorkspace) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.currentPathname == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseHostWorkspaceRouteFromPathname(input.currentPathname) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Navigate to a workspace screen. Uses router.navigate() which,
|
* Open a workspace. Once the workspace shell is mounted, switching workspaces
|
||||||
* combined with getId on the workspace Stack.Screen, ensures:
|
* is app-level state so native-stack does not rebuild every retained screen.
|
||||||
* - Only one instance of each workspace exists in the stack
|
|
||||||
* - History is preserved (back button works)
|
|
||||||
* - No duplicate workspace screens
|
|
||||||
*/
|
*/
|
||||||
export function navigateToWorkspace(serverId: string, workspaceId: string) {
|
export function navigateToWorkspace(
|
||||||
|
serverId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
options: NavigateToWorkspaceOptions = {},
|
||||||
|
) {
|
||||||
|
const activeWorkspace = getNavigationActiveWorkspaceSelection();
|
||||||
|
if (
|
||||||
|
shouldUseRetainedWorkspaceSwitch({
|
||||||
|
hasActiveWorkspace: activeWorkspace !== null,
|
||||||
|
currentPathname: options.currentPathname,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
activateNavigationWorkspaceSelection(
|
||||||
|
{ serverId, workspaceId },
|
||||||
|
{ updateBrowserHistory: true, historyMode: "push" },
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const href = buildHostWorkspaceRoute(serverId, workspaceId);
|
const href = buildHostWorkspaceRoute(serverId, workspaceId);
|
||||||
router.navigate(href);
|
router.navigate(href);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useWorkspaceNavigation() {
|
export function useWorkspaceNavigation() {
|
||||||
return {
|
return {
|
||||||
navigateToWorkspace: useCallback((serverId: string, workspaceId: string) => {
|
navigateToWorkspace: useCallback(
|
||||||
navigateToWorkspace(serverId, workspaceId);
|
(serverId: string, workspaceId: string, options?: NavigateToWorkspaceOptions) => {
|
||||||
}, []),
|
navigateToWorkspace(serverId, workspaceId, options);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export type KeyboardActionId =
|
|||||||
| "workspace.pane.move-tab.down"
|
| "workspace.pane.move-tab.down"
|
||||||
| "workspace.pane.close"
|
| "workspace.pane.close"
|
||||||
| "workspace.terminal.new"
|
| "workspace.terminal.new"
|
||||||
|
| "sidebar.toggle.right"
|
||||||
| "worktree.new"
|
| "worktree.new"
|
||||||
| "worktree.archive";
|
| "worktree.archive";
|
||||||
|
|
||||||
@@ -51,6 +52,7 @@ export type KeyboardActionDefinition =
|
|||||||
| { id: "workspace.pane.move-tab.down"; scope: KeyboardActionScope }
|
| { id: "workspace.pane.move-tab.down"; scope: KeyboardActionScope }
|
||||||
| { id: "workspace.pane.close"; scope: KeyboardActionScope }
|
| { id: "workspace.pane.close"; scope: KeyboardActionScope }
|
||||||
| { id: "workspace.terminal.new"; scope: KeyboardActionScope }
|
| { id: "workspace.terminal.new"; scope: KeyboardActionScope }
|
||||||
|
| { id: "sidebar.toggle.right"; scope: KeyboardActionScope }
|
||||||
| { id: "worktree.new"; scope: KeyboardActionScope }
|
| { id: "worktree.new"; scope: KeyboardActionScope }
|
||||||
| { id: "worktree.archive"; scope: KeyboardActionScope };
|
| { id: "worktree.archive"; scope: KeyboardActionScope };
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,466 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*/
|
||||||
|
import React, { useImperativeHandle } from "react";
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { DaemonClient } from "@server/client/daemon-client";
|
||||||
|
import { PaneFocusProvider, PaneProvider, type PaneFocusContextValue } from "@/panels/pane-context";
|
||||||
|
import { agentPanelRegistration } from "@/panels/agent-panel";
|
||||||
|
import { useDraftStore } from "@/stores/draft-store";
|
||||||
|
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||||
|
import type { PendingPermission } from "@/types/shared";
|
||||||
|
import type { StreamItem } from "@/types/stream";
|
||||||
|
import type { AgentPermissionRequest } from "@server/server/agent/agent-sdk-types";
|
||||||
|
|
||||||
|
type PanelTestTheme = {
|
||||||
|
colors: {
|
||||||
|
foreground: string;
|
||||||
|
foregroundMuted: string;
|
||||||
|
surface0: string;
|
||||||
|
surface1: string;
|
||||||
|
surface2: string;
|
||||||
|
surface3: string;
|
||||||
|
border: string;
|
||||||
|
destructive: string;
|
||||||
|
};
|
||||||
|
spacing: Record<number, number>;
|
||||||
|
borderRadius: Record<string, number>;
|
||||||
|
fontSize: Record<string, number>;
|
||||||
|
fontWeight: Record<string, string>;
|
||||||
|
iconSize: Record<string, number>;
|
||||||
|
};
|
||||||
|
type PanelTestStyles = Record<string, unknown>;
|
||||||
|
type PanelTestStyleFactory = (input: PanelTestTheme) => PanelTestStyles;
|
||||||
|
|
||||||
|
const {
|
||||||
|
composerRenderCount,
|
||||||
|
composerUnmountCount,
|
||||||
|
latestComposerCwd,
|
||||||
|
latestComposerIsPaneFocused,
|
||||||
|
streamRenderCount,
|
||||||
|
latestStreamPermissionKeys,
|
||||||
|
latestStreamText,
|
||||||
|
theme,
|
||||||
|
runtimeClient,
|
||||||
|
} = vi.hoisted(() => {
|
||||||
|
Object.defineProperty(globalThis, "__DEV__", {
|
||||||
|
value: false,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
||||||
|
value: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, "ResizeObserver", {
|
||||||
|
value: class ResizeObserver {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
},
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
composerRenderCount: vi.fn(),
|
||||||
|
composerUnmountCount: vi.fn(),
|
||||||
|
latestComposerCwd: { current: null as string | null },
|
||||||
|
latestComposerIsPaneFocused: { current: null as boolean | null },
|
||||||
|
streamRenderCount: vi.fn(),
|
||||||
|
latestStreamPermissionKeys: { current: [] as string[] },
|
||||||
|
latestStreamText: { current: null as string | null },
|
||||||
|
theme: {
|
||||||
|
colors: {
|
||||||
|
foreground: "#ffffff",
|
||||||
|
foregroundMuted: "#999999",
|
||||||
|
surface0: "#000000",
|
||||||
|
surface1: "#111111",
|
||||||
|
surface2: "#222222",
|
||||||
|
surface3: "#333333",
|
||||||
|
border: "#444444",
|
||||||
|
destructive: "#ff0000",
|
||||||
|
},
|
||||||
|
spacing: { 1: 4, 2: 8, 3: 12, 4: 16, 6: 24 },
|
||||||
|
borderRadius: { md: 6, lg: 8, xl: 12 },
|
||||||
|
fontSize: { sm: 13, base: 15, lg: 18 },
|
||||||
|
fontWeight: { medium: "500" },
|
||||||
|
iconSize: { lg: 22 },
|
||||||
|
} satisfies PanelTestTheme,
|
||||||
|
runtimeClient: {
|
||||||
|
fetchAgent: vi.fn(),
|
||||||
|
fetchAgentTimeline: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("react-native-reanimated", () => ({
|
||||||
|
default: {
|
||||||
|
View: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-native-unistyles", () => {
|
||||||
|
return {
|
||||||
|
StyleSheet: {
|
||||||
|
create: createPanelTestStyles,
|
||||||
|
},
|
||||||
|
useUnistyles: () => ({ theme }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("react-native-safe-area-context", () => ({
|
||||||
|
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function createPanelTestStyles(factory: PanelTestStyleFactory | PanelTestStyles): PanelTestStyles {
|
||||||
|
return typeof factory === "function" ? factory(theme) : factory;
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock("@/runtime/host-runtime", () => ({
|
||||||
|
useHosts: () => [{ serverId: "server", label: "Test server" }],
|
||||||
|
useHostRuntimeClient: () => runtimeClient,
|
||||||
|
useHostRuntimeIsConnected: () => false,
|
||||||
|
useHostRuntimeConnectionStatus: () => "offline",
|
||||||
|
useHostRuntimeLastError: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/attachments/service", () => ({
|
||||||
|
garbageCollectAttachments: vi.fn(async () => {}),
|
||||||
|
persistAttachmentFromDataUrl: vi.fn(),
|
||||||
|
persistAttachmentFromFileUri: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/provider-icons", () => ({
|
||||||
|
getProviderIcon: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/file-drop-zone", () => ({
|
||||||
|
FileDropZone: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/toast-host", () => ({
|
||||||
|
ToastViewport: () => null,
|
||||||
|
useToastHost: () => ({
|
||||||
|
toast: null,
|
||||||
|
dismiss: vi.fn(),
|
||||||
|
api: {
|
||||||
|
show: vi.fn(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/archived-agent-callout", () => ({
|
||||||
|
ArchivedAgentCallout: ({ agentId }: { agentId: string }) => (
|
||||||
|
<div data-testid="archived-agent-callout">{agentId}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-keyboard-shift-style", () => ({
|
||||||
|
useKeyboardShiftStyle: () => ({ style: null }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-archive-agent", () => ({
|
||||||
|
useArchiveAgent: () => ({
|
||||||
|
isArchivingAgent: () => false,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/composer", () => ({
|
||||||
|
Composer: ({ cwd, isPaneFocused }: { cwd: string; isPaneFocused: boolean }) => {
|
||||||
|
React.useEffect(
|
||||||
|
() => () => {
|
||||||
|
composerUnmountCount();
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
composerRenderCount();
|
||||||
|
latestComposerCwd.current = cwd;
|
||||||
|
latestComposerIsPaneFocused.current = isPaneFocused;
|
||||||
|
return <div data-testid="composer">{cwd}</div>;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/agent-stream-view", () => ({
|
||||||
|
AgentStreamView: React.memo(
|
||||||
|
React.forwardRef<
|
||||||
|
{ prepareForViewportChange: () => void; scrollToBottom: (reason: "message-sent") => void },
|
||||||
|
{ pendingPermissions: Map<string, PendingPermission>; streamItems: StreamItem[] }
|
||||||
|
>(function AgentStreamView({ pendingPermissions, streamItems }, ref) {
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
prepareForViewportChange: vi.fn(),
|
||||||
|
scrollToBottom: vi.fn(),
|
||||||
|
}));
|
||||||
|
streamRenderCount();
|
||||||
|
latestStreamPermissionKeys.current = Array.from(pendingPermissions.keys());
|
||||||
|
latestStreamText.current =
|
||||||
|
streamItems.find((item) => item.kind === "user_message")?.text ?? null;
|
||||||
|
return <div data-testid="agent-stream-view">{latestStreamText.current}</div>;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function makeClient(): DaemonClient {
|
||||||
|
return new DaemonClient({
|
||||||
|
url: "ws://127.0.0.1:1",
|
||||||
|
clientId: "panel-render-isolation-test",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeAgent(overrides: Partial<Agent> = {}): Agent {
|
||||||
|
const now = new Date("2026-04-20T00:00:00.000Z");
|
||||||
|
return {
|
||||||
|
serverId: "server",
|
||||||
|
id: "agent",
|
||||||
|
provider: "codex",
|
||||||
|
status: "running",
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
lastUserMessageAt: null,
|
||||||
|
lastActivityAt: now,
|
||||||
|
capabilities: {
|
||||||
|
supportsStreaming: true,
|
||||||
|
supportsSessionPersistence: true,
|
||||||
|
supportsDynamicModes: true,
|
||||||
|
supportsMcpServers: true,
|
||||||
|
supportsReasoningStream: true,
|
||||||
|
supportsToolInvocations: true,
|
||||||
|
},
|
||||||
|
currentModeId: null,
|
||||||
|
availableModes: [],
|
||||||
|
pendingPermissions: [],
|
||||||
|
persistence: null,
|
||||||
|
lastError: null,
|
||||||
|
title: "Render isolation",
|
||||||
|
cwd: "/workspace/one",
|
||||||
|
model: null,
|
||||||
|
labels: {},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedReadyAgent(agent: Agent = makeAgent()) {
|
||||||
|
const store = useSessionStore.getState();
|
||||||
|
store.initializeSession("server", makeClient());
|
||||||
|
store.setAgents("server", new Map([["agent", agent]]));
|
||||||
|
store.setAgentAuthoritativeHistoryApplied("server", "agent", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderAgentPanel(
|
||||||
|
root: Root,
|
||||||
|
focus: PaneFocusContextValue = {
|
||||||
|
isWorkspaceFocused: true,
|
||||||
|
isPaneFocused: false,
|
||||||
|
isInteractive: false,
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const AgentPanel = agentPanelRegistration.component;
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false },
|
||||||
|
mutations: { retry: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<PaneProvider
|
||||||
|
value={{
|
||||||
|
serverId: "server",
|
||||||
|
workspaceId: "workspace",
|
||||||
|
tabId: "agent-agent",
|
||||||
|
target: { kind: "agent", agentId: "agent" },
|
||||||
|
openTab: vi.fn(),
|
||||||
|
closeCurrentTab: vi.fn(),
|
||||||
|
retargetCurrentTab: vi.fn(),
|
||||||
|
openFileInWorkspace: vi.fn(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PaneFocusProvider value={focus}>
|
||||||
|
<AgentPanel />
|
||||||
|
</PaneFocusProvider>
|
||||||
|
</PaneProvider>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCurrentAgentStream(text: string) {
|
||||||
|
act(() => {
|
||||||
|
useSessionStore.getState().setAgentStreamTail("server", (previous) => {
|
||||||
|
const next = new Map(previous);
|
||||||
|
next.set("agent", [
|
||||||
|
{
|
||||||
|
kind: "user_message",
|
||||||
|
id: `message-${text}`,
|
||||||
|
text,
|
||||||
|
timestamp: new Date("2026-04-20T00:00:01.000Z"),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makePendingPermission(agentId: string, requestId: string): PendingPermission {
|
||||||
|
const request: AgentPermissionRequest = {
|
||||||
|
id: requestId,
|
||||||
|
provider: "codex",
|
||||||
|
name: `permission-${requestId}`,
|
||||||
|
kind: "tool",
|
||||||
|
};
|
||||||
|
const key = `${agentId}:${requestId}`;
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
agentId,
|
||||||
|
request,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePendingPermissions(permissions: PendingPermission[]) {
|
||||||
|
act(() => {
|
||||||
|
useSessionStore
|
||||||
|
.getState()
|
||||||
|
.setPendingPermissions(
|
||||||
|
"server",
|
||||||
|
new Map(permissions.map((permission) => [permission.key, permission])),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateCurrentAgentCwd(cwd: string) {
|
||||||
|
await act(async () => {
|
||||||
|
useSessionStore.getState().setAgents("server", (previous) => {
|
||||||
|
const current = previous.get("agent");
|
||||||
|
if (!current) {
|
||||||
|
throw new Error("Expected seeded agent");
|
||||||
|
}
|
||||||
|
const next = new Map(previous);
|
||||||
|
next.set("agent", { ...current, cwd });
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("AgentPanel render isolation", () => {
|
||||||
|
let root: Root | null = null;
|
||||||
|
let container: HTMLElement | null = null;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (root) {
|
||||||
|
act(() => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
root = null;
|
||||||
|
container?.remove();
|
||||||
|
container = null;
|
||||||
|
useSessionStore.setState({ sessions: {}, agentLastActivity: new Map() });
|
||||||
|
useDraftStore.setState({ drafts: {}, createModalDraft: null });
|
||||||
|
composerRenderCount.mockClear();
|
||||||
|
composerUnmountCount.mockClear();
|
||||||
|
latestComposerCwd.current = null;
|
||||||
|
latestComposerIsPaneFocused.current = null;
|
||||||
|
streamRenderCount.mockClear();
|
||||||
|
latestStreamPermissionKeys.current = [];
|
||||||
|
latestStreamText.current = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refreshes the stream view without invoking Composer for stream-only updates", async () => {
|
||||||
|
seedReadyAgent();
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
|
||||||
|
await renderAgentPanel(root);
|
||||||
|
const composerBaseline = composerRenderCount.mock.calls.length;
|
||||||
|
const streamBaseline = streamRenderCount.mock.calls.length;
|
||||||
|
expect(composerBaseline).toBeGreaterThan(0);
|
||||||
|
expect(streamBaseline).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
updateCurrentAgentStream("stream-only update");
|
||||||
|
|
||||||
|
expect(latestStreamText.current).toBe("stream-only update");
|
||||||
|
expect(streamRenderCount).toHaveBeenCalledTimes(streamBaseline + 1);
|
||||||
|
expect(composerRenderCount).toHaveBeenCalledTimes(composerBaseline);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not advertise the composer as focused when its workspace is hidden", async () => {
|
||||||
|
seedReadyAgent();
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
|
||||||
|
await renderAgentPanel(root, {
|
||||||
|
isWorkspaceFocused: false,
|
||||||
|
isPaneFocused: true,
|
||||||
|
isInteractive: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(latestComposerIsPaneFocused.current).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still invokes Composer for current-agent cwd changes and unmounts it for archives", async () => {
|
||||||
|
seedReadyAgent();
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
|
||||||
|
await renderAgentPanel(root);
|
||||||
|
expect(latestComposerCwd.current).toBe("/workspace/one");
|
||||||
|
const composerBaseline = composerRenderCount.mock.calls.length;
|
||||||
|
expect(composerBaseline).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
await updateCurrentAgentCwd("/workspace/two");
|
||||||
|
|
||||||
|
expect(latestComposerCwd.current).toBe("/workspace/two");
|
||||||
|
expect(composerRenderCount.mock.calls.length).toBeGreaterThan(composerBaseline);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
useSessionStore.getState().setAgents("server", (previous) => {
|
||||||
|
const current = previous.get("agent");
|
||||||
|
if (!current) {
|
||||||
|
throw new Error("Expected seeded agent");
|
||||||
|
}
|
||||||
|
const next = new Map(previous);
|
||||||
|
next.set("agent", { ...current, archivedAt: new Date("2026-04-20T00:00:02.000Z") });
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(composerUnmountCount).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps stream permissions stable for unrelated pending permission updates", async () => {
|
||||||
|
seedReadyAgent();
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
|
||||||
|
await renderAgentPanel(root);
|
||||||
|
const initialRenderCount = streamRenderCount.mock.calls.length;
|
||||||
|
expect(initialRenderCount).toBeGreaterThan(0);
|
||||||
|
expect(latestStreamPermissionKeys.current).toEqual([]);
|
||||||
|
|
||||||
|
const unrelatedPermission = makePendingPermission("other-agent", "unrelated");
|
||||||
|
updatePendingPermissions([unrelatedPermission]);
|
||||||
|
|
||||||
|
expect(latestStreamPermissionKeys.current).toEqual([]);
|
||||||
|
expect(streamRenderCount).toHaveBeenCalledTimes(initialRenderCount);
|
||||||
|
|
||||||
|
const agentPermission = makePendingPermission("agent", "current-agent");
|
||||||
|
updatePendingPermissions([unrelatedPermission, agentPermission]);
|
||||||
|
|
||||||
|
expect(latestStreamPermissionKeys.current).toEqual(["agent:current-agent"]);
|
||||||
|
expect(streamRenderCount).toHaveBeenCalledTimes(initialRenderCount + 1);
|
||||||
|
|
||||||
|
updatePendingPermissions([unrelatedPermission]);
|
||||||
|
|
||||||
|
expect(latestStreamPermissionKeys.current).toEqual([]);
|
||||||
|
expect(streamRenderCount).toHaveBeenCalledTimes(initialRenderCount + 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { ActivityIndicator, Text, View } from "react-native";
|
import { ActivityIndicator, Text, View } from "react-native";
|
||||||
import ReanimatedAnimated from "react-native-reanimated";
|
import ReanimatedAnimated from "react-native-reanimated";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { useShallow } from "zustand/shallow";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { shallow, useShallow } from "zustand/shallow";
|
||||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||||
import invariant from "tiny-invariant";
|
import invariant from "tiny-invariant";
|
||||||
import { AgentStreamView, type AgentStreamViewHandle } from "@/components/agent-stream-view";
|
import { AgentStreamView, type AgentStreamViewHandle } from "@/components/agent-stream-view";
|
||||||
@@ -23,7 +24,7 @@ import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
|||||||
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
|
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
|
||||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||||
import { usePaneContext } from "@/panels/pane-context";
|
import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
|
||||||
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
|
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
|
||||||
import {
|
import {
|
||||||
useHostRuntimeClient,
|
useHostRuntimeClient,
|
||||||
@@ -111,7 +112,8 @@ function useAgentPanelDescriptor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AgentPanel() {
|
function AgentPanel() {
|
||||||
const { serverId, target, isPaneFocused, openFileInWorkspace } = usePaneContext();
|
const { serverId, target, openFileInWorkspace } = usePaneContext();
|
||||||
|
const { isInteractive } = usePaneFocus();
|
||||||
invariant(target.kind === "agent", "AgentPanel requires agent target");
|
invariant(target.kind === "agent", "AgentPanel requires agent target");
|
||||||
|
|
||||||
function openWorkspaceFile(input: { filePath: string }) {
|
function openWorkspaceFile(input: { filePath: string }) {
|
||||||
@@ -124,7 +126,7 @@ function AgentPanel() {
|
|||||||
<AgentPanelContent
|
<AgentPanelContent
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
agentId={target.agentId}
|
agentId={target.agentId}
|
||||||
isPaneFocused={isPaneFocused}
|
isPaneFocused={isInteractive}
|
||||||
onOpenWorkspaceFile={handleOpenWorkspaceFile}
|
onOpenWorkspaceFile={handleOpenWorkspaceFile}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -137,6 +139,12 @@ export const agentPanelRegistration: PanelRegistration<"agent"> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
|
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
|
||||||
|
const EMPTY_PENDING_PERMISSIONS = new Map<string, PendingPermission>();
|
||||||
|
const EMPTY_PENDING_PERMISSION_LIST: PendingPermission[] = [];
|
||||||
|
|
||||||
|
type RouteBottomAnchorRequest = ReturnType<typeof deriveRouteBottomAnchorRequest>;
|
||||||
|
type PendingCreateByDraftId = ReturnType<typeof useCreateFlowStore.getState>["pendingByDraftId"];
|
||||||
|
type PendingCreateAttempt = PendingCreateByDraftId[string];
|
||||||
|
|
||||||
function logWebStickyBottom(_event: string, _details: Record<string, unknown>): void {}
|
function logWebStickyBottom(_event: string, _details: Record<string, unknown>): void {}
|
||||||
|
|
||||||
@@ -151,6 +159,27 @@ function isNotFoundErrorMessage(message: string): boolean {
|
|||||||
return /agent not found|not found/i.test(message);
|
return /agent not found|not found/i.test(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findPendingCreateForPanel(input: {
|
||||||
|
pendingByDraftId: PendingCreateByDraftId;
|
||||||
|
serverId: string;
|
||||||
|
agentId?: string;
|
||||||
|
}): PendingCreateAttempt | null {
|
||||||
|
if (!input.agentId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const values = Object.values(input.pendingByDraftId);
|
||||||
|
for (const entry of values) {
|
||||||
|
if (
|
||||||
|
entry.lifecycle === "active" &&
|
||||||
|
entry.serverId === input.serverId &&
|
||||||
|
entry.agentId === input.agentId
|
||||||
|
) {
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
type AgentLookupState =
|
type AgentLookupState =
|
||||||
| { tag: "idle" }
|
| { tag: "idle" }
|
||||||
| { tag: "loading" }
|
| { tag: "loading" }
|
||||||
@@ -455,26 +484,13 @@ function ChatAgentContent({
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const agentInputDraft = useAgentInputDraft({
|
|
||||||
draftKey: buildDraftStoreKey({
|
|
||||||
serverId,
|
|
||||||
agentId: agentId ?? "__pending__",
|
|
||||||
}),
|
|
||||||
initialCwd: agentState.cwd ?? "",
|
|
||||||
});
|
|
||||||
const projectPlacement = useStoreWithEqualityFn(
|
const projectPlacement = useStoreWithEqualityFn(
|
||||||
useSessionStore,
|
useSessionStore,
|
||||||
(state) =>
|
(state) =>
|
||||||
agentId ? (state.sessions[serverId]?.agents?.get(agentId)?.projectPlacement ?? null) : null,
|
agentId ? (state.sessions[serverId]?.agents?.get(agentId)?.projectPlacement ?? null) : null,
|
||||||
(a, b) => a === b || JSON.stringify(a) === JSON.stringify(b),
|
(a, b) => a === b || JSON.stringify(a) === JSON.stringify(b),
|
||||||
);
|
);
|
||||||
const streamItemsRaw = useSessionStore((state) =>
|
|
||||||
agentId ? state.sessions[serverId]?.agentStreamTail?.get(agentId) : undefined,
|
|
||||||
);
|
|
||||||
const streamItems = streamItemsRaw ?? EMPTY_STREAM_ITEMS;
|
|
||||||
const pendingByDraftId = useCreateFlowStore((state) => state.pendingByDraftId);
|
const pendingByDraftId = useCreateFlowStore((state) => state.pendingByDraftId);
|
||||||
const markPendingCreateLifecycle = useCreateFlowStore((state) => state.markLifecycle);
|
|
||||||
const clearPendingCreate = useCreateFlowStore((state) => state.clear);
|
|
||||||
const isInitializingFromMap = useSessionStore((state) =>
|
const isInitializingFromMap = useSessionStore((state) =>
|
||||||
agentId ? (state.sessions[serverId]?.initializingAgents?.get(agentId) ?? false) : false,
|
agentId ? (state.sessions[serverId]?.initializingAgents?.get(agentId) ?? false) : false,
|
||||||
);
|
);
|
||||||
@@ -489,11 +505,7 @@ function ChatAgentContent({
|
|||||||
const agentHistorySyncGeneration = useSessionStore((state) =>
|
const agentHistorySyncGeneration = useSessionStore((state) =>
|
||||||
agentId ? (state.sessions[serverId]?.agentHistorySyncGeneration?.get(agentId) ?? -1) : -1,
|
agentId ? (state.sessions[serverId]?.agentHistorySyncGeneration?.get(agentId) ?? -1) : -1,
|
||||||
);
|
);
|
||||||
const allPendingPermissions = useSessionStore(
|
|
||||||
(state) => state.sessions[serverId]?.pendingPermissions,
|
|
||||||
);
|
|
||||||
const setAgents = useSessionStore((state) => state.setAgents);
|
const setAgents = useSessionStore((state) => state.setAgents);
|
||||||
const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail);
|
|
||||||
const setPendingPermissions = useSessionStore((state) => state.setPendingPermissions);
|
const setPendingPermissions = useSessionStore((state) => state.setPendingPermissions);
|
||||||
const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId]));
|
const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId]));
|
||||||
const { ensureAgentIsInitialized } = useAgentInitialization({
|
const { ensureAgentIsInitialized } = useAgentInitialization({
|
||||||
@@ -504,38 +516,13 @@ function ChatAgentContent({
|
|||||||
kind: "idle",
|
kind: "idle",
|
||||||
});
|
});
|
||||||
|
|
||||||
const pendingCreate = useMemo(() => {
|
const pendingCreate = useMemo(
|
||||||
if (!agentId) {
|
() => findPendingCreateForPanel({ pendingByDraftId, serverId, agentId }),
|
||||||
return null;
|
[agentId, pendingByDraftId, serverId],
|
||||||
}
|
);
|
||||||
const values = Object.values(pendingByDraftId);
|
|
||||||
for (const entry of values) {
|
|
||||||
if (
|
|
||||||
entry.lifecycle === "active" &&
|
|
||||||
entry.serverId === serverId &&
|
|
||||||
entry.agentId === agentId
|
|
||||||
) {
|
|
||||||
return entry;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}, [agentId, pendingByDraftId, serverId]);
|
|
||||||
const isPendingCreateForPanel = Boolean(pendingCreate);
|
const isPendingCreateForPanel = Boolean(pendingCreate);
|
||||||
const hasHydratedHistoryBefore = hasAppliedAuthoritativeHistory;
|
const hasHydratedHistoryBefore = hasAppliedAuthoritativeHistory;
|
||||||
|
|
||||||
const pendingPermissions = useMemo(() => {
|
|
||||||
if (!allPendingPermissions || !agentId) {
|
|
||||||
return new Map<string, PendingPermission>();
|
|
||||||
}
|
|
||||||
const filtered = new Map<string, PendingPermission>();
|
|
||||||
for (const [key, permission] of allPendingPermissions) {
|
|
||||||
if (permission.agentId === agentId) {
|
|
||||||
filtered.set(key, permission);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return filtered;
|
|
||||||
}, [agentId, allPendingPermissions]);
|
|
||||||
|
|
||||||
const attentionController = useAgentAttentionClear({
|
const attentionController = useAgentAttentionClear({
|
||||||
agentId,
|
agentId,
|
||||||
client,
|
client,
|
||||||
@@ -640,38 +627,7 @@ function ChatAgentContent({
|
|||||||
return agentHistorySyncGeneration < historySyncGeneration;
|
return agentHistorySyncGeneration < historySyncGeneration;
|
||||||
}, [agentHistorySyncGeneration, agentId, historySyncGeneration]);
|
}, [agentHistorySyncGeneration, agentId, historySyncGeneration]);
|
||||||
|
|
||||||
const optimisticStreamItems = useMemo<StreamItem[]>(() => {
|
const shouldUseOptimisticStream = isPendingCreateForPanel;
|
||||||
if (!isPendingCreateForPanel || !pendingCreate) {
|
|
||||||
return EMPTY_STREAM_ITEMS;
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
kind: "user_message",
|
|
||||||
id: pendingCreate.clientMessageId,
|
|
||||||
text: pendingCreate.text,
|
|
||||||
timestamp: new Date(pendingCreate.timestamp),
|
|
||||||
...(pendingCreate.images && pendingCreate.images.length > 0
|
|
||||||
? { images: pendingCreate.images }
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}, [isPendingCreateForPanel, pendingCreate]);
|
|
||||||
|
|
||||||
const mergedStreamItems = useMemo<StreamItem[]>(() => {
|
|
||||||
if (optimisticStreamItems.length === 0) {
|
|
||||||
return streamItems;
|
|
||||||
}
|
|
||||||
const optimistic = optimisticStreamItems[0];
|
|
||||||
if (!optimistic) {
|
|
||||||
return streamItems;
|
|
||||||
}
|
|
||||||
const alreadyHasOptimistic = streamItems.some(
|
|
||||||
(item) => item.kind === "user_message" && item.id === optimistic.id,
|
|
||||||
);
|
|
||||||
return alreadyHasOptimistic ? streamItems : [...optimisticStreamItems, ...streamItems];
|
|
||||||
}, [optimisticStreamItems, streamItems]);
|
|
||||||
|
|
||||||
const shouldUseOptimisticStream = isPendingCreateForPanel && optimisticStreamItems.length > 0;
|
|
||||||
const authoritativeStatus = agentState.status;
|
const authoritativeStatus = agentState.status;
|
||||||
const isAuthoritativeBootstrapping =
|
const isAuthoritativeBootstrapping =
|
||||||
authoritativeStatus === "initializing" || authoritativeStatus === "idle";
|
authoritativeStatus === "initializing" || authoritativeStatus === "idle";
|
||||||
@@ -745,52 +701,29 @@ function ChatAgentContent({
|
|||||||
[effectiveAgent?.id],
|
[effectiveAgent?.id],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
const handleComposerHeightChange = useCallback(
|
||||||
if (!isPendingCreateForPanel || !pendingCreate) {
|
(height: number) => {
|
||||||
|
if (!agentId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logWebStickyBottom("screen_composer_height_change", {
|
||||||
|
agentId,
|
||||||
|
height,
|
||||||
|
});
|
||||||
|
streamViewRef.current?.prepareForViewportChange();
|
||||||
|
},
|
||||||
|
[agentId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMessageSent = useCallback(() => {
|
||||||
|
if (!agentId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const hasUserMessage = streamItems.some(
|
logWebStickyBottom("screen_message_sent_scroll_to_bottom", {
|
||||||
(item) => item.kind === "user_message" && item.id === pendingCreate.clientMessageId,
|
agentId,
|
||||||
);
|
});
|
||||||
if (hasUserMessage && canFinalizePendingCreate) {
|
streamViewRef.current?.scrollToBottom("message-sent");
|
||||||
if (agentId && pendingCreate.images && pendingCreate.images.length > 0) {
|
}, [agentId]);
|
||||||
setAgentStreamTail(serverId, (previous) => {
|
|
||||||
const current = previous.get(agentId);
|
|
||||||
if (!current) {
|
|
||||||
return previous;
|
|
||||||
}
|
|
||||||
|
|
||||||
const merged = mergePendingCreateImages({
|
|
||||||
streamItems: current,
|
|
||||||
clientMessageId: pendingCreate.clientMessageId,
|
|
||||||
images: pendingCreate.images,
|
|
||||||
});
|
|
||||||
if (merged === current) {
|
|
||||||
return previous;
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = new Map(previous);
|
|
||||||
next.set(agentId, merged);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
markPendingCreateLifecycle({
|
|
||||||
draftId: pendingCreate.draftId,
|
|
||||||
lifecycle: "sent",
|
|
||||||
});
|
|
||||||
clearPendingCreate({ draftId: pendingCreate.draftId });
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
agentId,
|
|
||||||
canFinalizePendingCreate,
|
|
||||||
clearPendingCreate,
|
|
||||||
isPendingCreateForPanel,
|
|
||||||
markPendingCreateLifecycle,
|
|
||||||
pendingCreate,
|
|
||||||
serverId,
|
|
||||||
setAgentStreamTail,
|
|
||||||
streamItems,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!agentId) {
|
if (!agentId) {
|
||||||
@@ -947,53 +880,35 @@ function ChatAgentContent({
|
|||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<View style={styles.contentContainer}>
|
<View style={styles.contentContainer}>
|
||||||
<ReanimatedAnimated.View style={[styles.content, animatedKeyboardStyle]}>
|
<ReanimatedAnimated.View style={[styles.content, animatedKeyboardStyle]}>
|
||||||
<AgentStreamView
|
<AgentStreamSection
|
||||||
ref={streamViewRef}
|
streamViewRef={streamViewRef}
|
||||||
agentId={effectiveAgent.id}
|
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
|
agentId={agentId}
|
||||||
agent={effectiveAgent}
|
agent={effectiveAgent}
|
||||||
streamItems={shouldUseOptimisticStream ? mergedStreamItems : streamItems}
|
pendingCreate={pendingCreate}
|
||||||
pendingPermissions={pendingPermissions}
|
shouldUseOptimisticStream={shouldUseOptimisticStream}
|
||||||
|
canFinalizePendingCreate={canFinalizePendingCreate}
|
||||||
routeBottomAnchorRequest={routeBottomAnchorRequest}
|
routeBottomAnchorRequest={routeBottomAnchorRequest}
|
||||||
isAuthoritativeHistoryReady={hasAppliedAuthoritativeHistory}
|
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
|
||||||
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
||||||
/>
|
/>
|
||||||
</ReanimatedAnimated.View>
|
</ReanimatedAnimated.View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{agentId && !isArchivingCurrentAgent && !agentState.archivedAt ? (
|
<AgentComposerSection
|
||||||
<Composer
|
agentId={agentId}
|
||||||
agentId={agentId}
|
serverId={serverId}
|
||||||
serverId={serverId}
|
isPaneFocused={isPaneFocused}
|
||||||
isPaneFocused={isPaneFocused}
|
isArchivingCurrentAgent={isArchivingCurrentAgent}
|
||||||
value={agentInputDraft.text}
|
archivedAt={agentState.archivedAt}
|
||||||
onChangeText={agentInputDraft.setText}
|
initialCwd={agentState.cwd ?? ""}
|
||||||
attachments={agentInputDraft.attachments}
|
isSubmitLoading={showPendingCreateSubmitLoading}
|
||||||
onChangeAttachments={agentInputDraft.setAttachments}
|
onAttentionInputFocus={attentionController.clearOnInputFocus}
|
||||||
cwd={agentInputDraft.cwd}
|
onAttentionPromptSend={attentionController.clearOnPromptSend}
|
||||||
clearDraft={agentInputDraft.clear}
|
onAddImages={handleAddImagesCallback}
|
||||||
autoFocus={isPaneFocused}
|
onComposerHeightChange={handleComposerHeightChange}
|
||||||
isSubmitLoading={showPendingCreateSubmitLoading}
|
onMessageSent={handleMessageSent}
|
||||||
onAttentionInputFocus={attentionController.clearOnInputFocus}
|
/>
|
||||||
onAttentionPromptSend={attentionController.clearOnPromptSend}
|
|
||||||
onAddImages={handleAddImagesCallback}
|
|
||||||
onComposerHeightChange={(height) => {
|
|
||||||
logWebStickyBottom("screen_composer_height_change", {
|
|
||||||
agentId,
|
|
||||||
height,
|
|
||||||
});
|
|
||||||
streamViewRef.current?.prepareForViewportChange();
|
|
||||||
}}
|
|
||||||
onMessageSent={() => {
|
|
||||||
logWebStickyBottom("screen_message_sent_scroll_to_bottom", {
|
|
||||||
agentId,
|
|
||||||
});
|
|
||||||
streamViewRef.current?.scrollToBottom("message-sent");
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : agentId && agentState.archivedAt ? (
|
|
||||||
<ArchivedAgentCallout serverId={serverId} agentId={agentId} />
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{viewState.tag === "ready" &&
|
{viewState.tag === "ready" &&
|
||||||
viewState.sync.status === "catching_up" &&
|
viewState.sync.status === "catching_up" &&
|
||||||
@@ -1022,6 +937,268 @@ function ChatAgentContent({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AgentStreamSection({
|
||||||
|
streamViewRef,
|
||||||
|
serverId,
|
||||||
|
agentId,
|
||||||
|
agent,
|
||||||
|
pendingCreate,
|
||||||
|
shouldUseOptimisticStream,
|
||||||
|
canFinalizePendingCreate,
|
||||||
|
routeBottomAnchorRequest,
|
||||||
|
hasAppliedAuthoritativeHistory,
|
||||||
|
onOpenWorkspaceFile,
|
||||||
|
}: {
|
||||||
|
streamViewRef: React.RefObject<AgentStreamViewHandle | null>;
|
||||||
|
serverId: string;
|
||||||
|
agentId?: string;
|
||||||
|
agent: AgentScreenAgent;
|
||||||
|
pendingCreate: PendingCreateAttempt | null;
|
||||||
|
shouldUseOptimisticStream: boolean;
|
||||||
|
canFinalizePendingCreate: boolean;
|
||||||
|
routeBottomAnchorRequest: RouteBottomAnchorRequest;
|
||||||
|
hasAppliedAuthoritativeHistory: boolean;
|
||||||
|
onOpenWorkspaceFile?: (input: { filePath: string }) => void;
|
||||||
|
}) {
|
||||||
|
const streamItemsRaw = useSessionStore((state) =>
|
||||||
|
agentId ? state.sessions[serverId]?.agentStreamTail?.get(agentId) : undefined,
|
||||||
|
);
|
||||||
|
const streamItems = streamItemsRaw ?? EMPTY_STREAM_ITEMS;
|
||||||
|
const pendingPermissionList = useStoreWithEqualityFn(
|
||||||
|
useSessionStore,
|
||||||
|
(state) => {
|
||||||
|
if (!agentId) {
|
||||||
|
return EMPTY_PENDING_PERMISSION_LIST;
|
||||||
|
}
|
||||||
|
const allPendingPermissions = state.sessions[serverId]?.pendingPermissions;
|
||||||
|
if (!allPendingPermissions) {
|
||||||
|
return EMPTY_PENDING_PERMISSION_LIST;
|
||||||
|
}
|
||||||
|
const filtered: PendingPermission[] = [];
|
||||||
|
for (const permission of allPendingPermissions.values()) {
|
||||||
|
if (permission.agentId === agentId) {
|
||||||
|
filtered.push(permission);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filtered.length > 0 ? filtered : EMPTY_PENDING_PERMISSION_LIST;
|
||||||
|
},
|
||||||
|
shallow,
|
||||||
|
);
|
||||||
|
const pendingPermissions = useMemo(() => {
|
||||||
|
if (pendingPermissionList.length === 0) {
|
||||||
|
return EMPTY_PENDING_PERMISSIONS;
|
||||||
|
}
|
||||||
|
return new Map(pendingPermissionList.map((permission) => [permission.key, permission]));
|
||||||
|
}, [pendingPermissionList]);
|
||||||
|
const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail);
|
||||||
|
const markPendingCreateLifecycle = useCreateFlowStore((state) => state.markLifecycle);
|
||||||
|
const clearPendingCreate = useCreateFlowStore((state) => state.clear);
|
||||||
|
|
||||||
|
const optimisticStreamItems = useMemo<StreamItem[]>(() => {
|
||||||
|
if (!shouldUseOptimisticStream || !pendingCreate) {
|
||||||
|
return EMPTY_STREAM_ITEMS;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
kind: "user_message",
|
||||||
|
id: pendingCreate.clientMessageId,
|
||||||
|
text: pendingCreate.text,
|
||||||
|
timestamp: new Date(pendingCreate.timestamp),
|
||||||
|
...(pendingCreate.images && pendingCreate.images.length > 0
|
||||||
|
? { images: pendingCreate.images }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [pendingCreate, shouldUseOptimisticStream]);
|
||||||
|
|
||||||
|
const mergedStreamItems = useMemo<StreamItem[]>(() => {
|
||||||
|
if (optimisticStreamItems.length === 0) {
|
||||||
|
return streamItems;
|
||||||
|
}
|
||||||
|
const optimistic = optimisticStreamItems[0];
|
||||||
|
if (!optimistic) {
|
||||||
|
return streamItems;
|
||||||
|
}
|
||||||
|
const alreadyHasOptimistic = streamItems.some(
|
||||||
|
(item) => item.kind === "user_message" && item.id === optimistic.id,
|
||||||
|
);
|
||||||
|
return alreadyHasOptimistic ? streamItems : [...optimisticStreamItems, ...streamItems];
|
||||||
|
}, [optimisticStreamItems, streamItems]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!shouldUseOptimisticStream || !pendingCreate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const hasUserMessage = streamItems.some(
|
||||||
|
(item) => item.kind === "user_message" && item.id === pendingCreate.clientMessageId,
|
||||||
|
);
|
||||||
|
if (!hasUserMessage || !canFinalizePendingCreate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingImages = pendingCreate.images;
|
||||||
|
if (agentId && pendingImages && pendingImages.length > 0) {
|
||||||
|
setAgentStreamTail(serverId, (previous) => {
|
||||||
|
const current = previous.get(agentId);
|
||||||
|
if (!current) {
|
||||||
|
return previous;
|
||||||
|
}
|
||||||
|
|
||||||
|
const merged = mergePendingCreateImages({
|
||||||
|
streamItems: current,
|
||||||
|
clientMessageId: pendingCreate.clientMessageId,
|
||||||
|
images: pendingImages,
|
||||||
|
});
|
||||||
|
if (merged === current) {
|
||||||
|
return previous;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = new Map(previous);
|
||||||
|
next.set(agentId, merged);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
markPendingCreateLifecycle({
|
||||||
|
draftId: pendingCreate.draftId,
|
||||||
|
lifecycle: "sent",
|
||||||
|
});
|
||||||
|
clearPendingCreate({ draftId: pendingCreate.draftId });
|
||||||
|
}, [
|
||||||
|
agentId,
|
||||||
|
canFinalizePendingCreate,
|
||||||
|
clearPendingCreate,
|
||||||
|
markPendingCreateLifecycle,
|
||||||
|
pendingCreate,
|
||||||
|
serverId,
|
||||||
|
setAgentStreamTail,
|
||||||
|
shouldUseOptimisticStream,
|
||||||
|
streamItems,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AgentStreamView
|
||||||
|
ref={streamViewRef}
|
||||||
|
agentId={agent.id}
|
||||||
|
serverId={serverId}
|
||||||
|
agent={agent}
|
||||||
|
streamItems={shouldUseOptimisticStream ? mergedStreamItems : streamItems}
|
||||||
|
pendingPermissions={pendingPermissions}
|
||||||
|
routeBottomAnchorRequest={routeBottomAnchorRequest}
|
||||||
|
isAuthoritativeHistoryReady={hasAppliedAuthoritativeHistory}
|
||||||
|
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AgentComposerSection({
|
||||||
|
agentId,
|
||||||
|
serverId,
|
||||||
|
isPaneFocused,
|
||||||
|
isArchivingCurrentAgent,
|
||||||
|
archivedAt,
|
||||||
|
initialCwd,
|
||||||
|
isSubmitLoading,
|
||||||
|
onAttentionInputFocus,
|
||||||
|
onAttentionPromptSend,
|
||||||
|
onAddImages,
|
||||||
|
onComposerHeightChange,
|
||||||
|
onMessageSent,
|
||||||
|
}: {
|
||||||
|
agentId?: string;
|
||||||
|
serverId: string;
|
||||||
|
isPaneFocused: boolean;
|
||||||
|
isArchivingCurrentAgent: boolean;
|
||||||
|
archivedAt: Date | null;
|
||||||
|
initialCwd: string;
|
||||||
|
isSubmitLoading: boolean;
|
||||||
|
onAttentionInputFocus: () => void;
|
||||||
|
onAttentionPromptSend: () => void;
|
||||||
|
onAddImages: (addImages: (images: ImageAttachment[]) => void) => void;
|
||||||
|
onComposerHeightChange: (height: number) => void;
|
||||||
|
onMessageSent: () => void;
|
||||||
|
}) {
|
||||||
|
if (!agentId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (archivedAt) {
|
||||||
|
return <ArchivedAgentCallout serverId={serverId} agentId={agentId} />;
|
||||||
|
}
|
||||||
|
if (isArchivingCurrentAgent) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ActiveAgentComposer
|
||||||
|
agentId={agentId}
|
||||||
|
serverId={serverId}
|
||||||
|
isPaneFocused={isPaneFocused}
|
||||||
|
initialCwd={initialCwd}
|
||||||
|
isSubmitLoading={isSubmitLoading}
|
||||||
|
onAttentionInputFocus={onAttentionInputFocus}
|
||||||
|
onAttentionPromptSend={onAttentionPromptSend}
|
||||||
|
onAddImages={onAddImages}
|
||||||
|
onComposerHeightChange={onComposerHeightChange}
|
||||||
|
onMessageSent={onMessageSent}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActiveAgentComposer({
|
||||||
|
agentId,
|
||||||
|
serverId,
|
||||||
|
isPaneFocused,
|
||||||
|
initialCwd,
|
||||||
|
isSubmitLoading,
|
||||||
|
onAttentionInputFocus,
|
||||||
|
onAttentionPromptSend,
|
||||||
|
onAddImages,
|
||||||
|
onComposerHeightChange,
|
||||||
|
onMessageSent,
|
||||||
|
}: {
|
||||||
|
agentId: string;
|
||||||
|
serverId: string;
|
||||||
|
isPaneFocused: boolean;
|
||||||
|
initialCwd: string;
|
||||||
|
isSubmitLoading: boolean;
|
||||||
|
onAttentionInputFocus: () => void;
|
||||||
|
onAttentionPromptSend: () => void;
|
||||||
|
onAddImages: (addImages: (images: ImageAttachment[]) => void) => void;
|
||||||
|
onComposerHeightChange: (height: number) => void;
|
||||||
|
onMessageSent: () => void;
|
||||||
|
}) {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const agentInputDraft = useAgentInputDraft({
|
||||||
|
draftKey: buildDraftStoreKey({
|
||||||
|
serverId,
|
||||||
|
agentId,
|
||||||
|
}),
|
||||||
|
initialCwd,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.inputAreaWrapper, { paddingBottom: insets.bottom }]}>
|
||||||
|
<Composer
|
||||||
|
agentId={agentId}
|
||||||
|
serverId={serverId}
|
||||||
|
isPaneFocused={isPaneFocused}
|
||||||
|
value={agentInputDraft.text}
|
||||||
|
onChangeText={agentInputDraft.setText}
|
||||||
|
attachments={agentInputDraft.attachments}
|
||||||
|
onChangeAttachments={agentInputDraft.setAttachments}
|
||||||
|
cwd={agentInputDraft.cwd}
|
||||||
|
clearDraft={agentInputDraft.clear}
|
||||||
|
autoFocus={isPaneFocused}
|
||||||
|
isSubmitLoading={isSubmitLoading}
|
||||||
|
onAttentionInputFocus={onAttentionInputFocus}
|
||||||
|
onAttentionPromptSend={onAttentionPromptSend}
|
||||||
|
onAddImages={onAddImages}
|
||||||
|
onComposerHeightChange={onComposerHeightChange}
|
||||||
|
onMessageSent={onMessageSent}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function AgentSessionUnavailableState({
|
function AgentSessionUnavailableState({
|
||||||
serverLabel,
|
serverLabel,
|
||||||
connectionStatus,
|
connectionStatus,
|
||||||
@@ -1098,6 +1275,10 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
content: {
|
content: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
|
inputAreaWrapper: {
|
||||||
|
width: "100%",
|
||||||
|
backgroundColor: theme.colors.surface0,
|
||||||
|
},
|
||||||
historySyncOverlay: {
|
historySyncOverlay: {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: 0,
|
top: 0,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { SquarePen } from "lucide-react-native";
|
import { SquarePen } from "lucide-react-native";
|
||||||
import invariant from "tiny-invariant";
|
import invariant from "tiny-invariant";
|
||||||
import { WorkspaceDraftAgentTab } from "@/screens/workspace/workspace-draft-agent-tab";
|
import { WorkspaceDraftAgentTab } from "@/screens/workspace/workspace-draft-agent-tab";
|
||||||
import { usePaneContext } from "@/panels/pane-context";
|
import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
|
||||||
import type { PanelRegistration } from "@/panels/panel-registry";
|
import type { PanelRegistration } from "@/panels/panel-registry";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||||
@@ -17,15 +17,9 @@ function useDraftPanelDescriptor() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function DraftPanel() {
|
function DraftPanel() {
|
||||||
const {
|
const { serverId, workspaceId, tabId, target, openFileInWorkspace, retargetCurrentTab } =
|
||||||
serverId,
|
usePaneContext();
|
||||||
workspaceId,
|
const { isInteractive } = usePaneFocus();
|
||||||
tabId,
|
|
||||||
target,
|
|
||||||
isPaneFocused,
|
|
||||||
openFileInWorkspace,
|
|
||||||
retargetCurrentTab,
|
|
||||||
} = usePaneContext();
|
|
||||||
invariant(target.kind === "draft", "DraftPanel requires draft target");
|
invariant(target.kind === "draft", "DraftPanel requires draft target");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -34,7 +28,7 @@ function DraftPanel() {
|
|||||||
workspaceId={workspaceId}
|
workspaceId={workspaceId}
|
||||||
tabId={tabId}
|
tabId={tabId}
|
||||||
draftId={target.draftId}
|
draftId={target.draftId}
|
||||||
isPaneFocused={isPaneFocused}
|
isPaneFocused={isInteractive}
|
||||||
onOpenWorkspaceFile={({ filePath }) => {
|
onOpenWorkspaceFile={({ filePath }) => {
|
||||||
openFileInWorkspace(filePath);
|
openFileInWorkspace(filePath);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createPaneFocusContextValue } from "@/panels/pane-context";
|
||||||
|
|
||||||
|
describe("createPaneFocusContextValue", () => {
|
||||||
|
it("derives interactivity from both workspace and pane focus", () => {
|
||||||
|
expect(
|
||||||
|
createPaneFocusContextValue({
|
||||||
|
isWorkspaceFocused: true,
|
||||||
|
isPaneFocused: true,
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
isWorkspaceFocused: true,
|
||||||
|
isPaneFocused: true,
|
||||||
|
isInteractive: true,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
createPaneFocusContextValue({
|
||||||
|
isWorkspaceFocused: false,
|
||||||
|
isPaneFocused: true,
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
isWorkspaceFocused: false,
|
||||||
|
isPaneFocused: true,
|
||||||
|
isInteractive: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createContext, useContext, type ReactNode } from "react";
|
import React, { createContext, useContext, type ReactNode } from "react";
|
||||||
import invariant from "tiny-invariant";
|
import invariant from "tiny-invariant";
|
||||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||||
|
|
||||||
@@ -6,7 +6,6 @@ export interface PaneContextValue {
|
|||||||
serverId: string;
|
serverId: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
tabId: string;
|
tabId: string;
|
||||||
isPaneFocused: boolean;
|
|
||||||
target: WorkspaceTabTarget;
|
target: WorkspaceTabTarget;
|
||||||
openTab(target: WorkspaceTabTarget): void;
|
openTab(target: WorkspaceTabTarget): void;
|
||||||
closeCurrentTab(): void;
|
closeCurrentTab(): void;
|
||||||
@@ -14,7 +13,25 @@ export interface PaneContextValue {
|
|||||||
openFileInWorkspace(filePath: string): void;
|
openFileInWorkspace(filePath: string): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PaneFocusContextValue {
|
||||||
|
isWorkspaceFocused: boolean;
|
||||||
|
isPaneFocused: boolean;
|
||||||
|
isInteractive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
const PaneContext = createContext<PaneContextValue | null>(null);
|
const PaneContext = createContext<PaneContextValue | null>(null);
|
||||||
|
const PaneFocusContext = createContext<PaneFocusContextValue | null>(null);
|
||||||
|
|
||||||
|
export function createPaneFocusContextValue(input: {
|
||||||
|
isWorkspaceFocused: boolean;
|
||||||
|
isPaneFocused: boolean;
|
||||||
|
}): PaneFocusContextValue {
|
||||||
|
return {
|
||||||
|
isWorkspaceFocused: input.isWorkspaceFocused,
|
||||||
|
isPaneFocused: input.isPaneFocused,
|
||||||
|
isInteractive: input.isWorkspaceFocused && input.isPaneFocused,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function PaneProvider({
|
export function PaneProvider({
|
||||||
value,
|
value,
|
||||||
@@ -26,8 +43,24 @@ export function PaneProvider({
|
|||||||
return <PaneContext.Provider value={value}>{children}</PaneContext.Provider>;
|
return <PaneContext.Provider value={value}>{children}</PaneContext.Provider>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function PaneFocusProvider({
|
||||||
|
value,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
value: PaneFocusContextValue;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return <PaneFocusContext.Provider value={value}>{children}</PaneFocusContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
export function usePaneContext(): PaneContextValue {
|
export function usePaneContext(): PaneContextValue {
|
||||||
const value = useContext(PaneContext);
|
const value = useContext(PaneContext);
|
||||||
invariant(value, "PaneContext is required");
|
invariant(value, "PaneContext is required");
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function usePaneFocus(): PaneFocusContextValue {
|
||||||
|
const value = useContext(PaneFocusContext);
|
||||||
|
invariant(value, "PaneFocusContext is required");
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Terminal } from "lucide-react-native";
|
import { Terminal } from "lucide-react-native";
|
||||||
import { Text, View } from "react-native";
|
import { Text, View } from "react-native";
|
||||||
import { useIsFocused } from "@react-navigation/native";
|
|
||||||
import invariant from "tiny-invariant";
|
import invariant from "tiny-invariant";
|
||||||
import type { ListTerminalsResponse } from "@server/shared/messages";
|
import type { ListTerminalsResponse } from "@server/shared/messages";
|
||||||
import { TerminalPane } from "@/components/terminal-pane";
|
import { TerminalPane } from "@/components/terminal-pane";
|
||||||
import { usePaneContext } from "@/panels/pane-context";
|
import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
|
||||||
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
|
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
|
||||||
|
import { queryClient } from "@/query/query-client";
|
||||||
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useWorkspaceExecutionAuthority } from "@/stores/session-store-hooks";
|
import { useWorkspaceExecutionAuthority } from "@/stores/session-store-hooks";
|
||||||
|
|
||||||
@@ -29,21 +31,24 @@ function useTerminalPanelDescriptor(
|
|||||||
const workspaceDirectory = workspaceAuthority.ok
|
const workspaceDirectory = workspaceAuthority.ok
|
||||||
? workspaceAuthority.authority.workspaceDirectory
|
? workspaceAuthority.authority.workspaceDirectory
|
||||||
: null;
|
: null;
|
||||||
const terminalsQuery = useQuery({
|
const terminalsQuery = useQuery(
|
||||||
queryKey: ["terminals", context.serverId, workspaceDirectory] as const,
|
{
|
||||||
enabled: Boolean(client && workspaceDirectory),
|
queryKey: ["terminals", context.serverId, workspaceDirectory] as const,
|
||||||
queryFn: async (): Promise<ListTerminalsPayload> => {
|
enabled: Boolean(client && workspaceDirectory),
|
||||||
if (!client || !workspaceDirectory) {
|
queryFn: async (): Promise<ListTerminalsPayload> => {
|
||||||
throw new Error(
|
if (!client || !workspaceDirectory) {
|
||||||
workspaceAuthority.ok
|
throw new Error(
|
||||||
? "Workspace execution directory not found"
|
workspaceAuthority.ok
|
||||||
: workspaceAuthority.message,
|
? "Workspace execution directory not found"
|
||||||
);
|
: workspaceAuthority.message,
|
||||||
}
|
);
|
||||||
return client.listTerminals(workspaceDirectory);
|
}
|
||||||
|
return client.listTerminals(workspaceDirectory);
|
||||||
|
},
|
||||||
|
staleTime: 5_000,
|
||||||
},
|
},
|
||||||
staleTime: 5_000,
|
queryClient,
|
||||||
});
|
);
|
||||||
const terminal =
|
const terminal =
|
||||||
terminalsQuery.data?.terminals.find((entry) => entry.id === target.terminalId) ?? null;
|
terminalsQuery.data?.terminals.find((entry) => entry.id === target.terminalId) ?? null;
|
||||||
|
|
||||||
@@ -57,15 +62,28 @@ function useTerminalPanelDescriptor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function TerminalPanel() {
|
function TerminalPanel() {
|
||||||
const isFocused = useIsFocused();
|
const { serverId, workspaceId, target } = usePaneContext();
|
||||||
const { serverId, workspaceId, target, isPaneFocused } = usePaneContext();
|
const { isWorkspaceFocused, isPaneFocused } = usePaneFocus();
|
||||||
const workspaceAuthority = useWorkspaceExecutionAuthority(serverId, workspaceId)!;
|
const workspaceAuthority = useWorkspaceExecutionAuthority(serverId, workspaceId)!;
|
||||||
const workspaceDirectory = workspaceAuthority.ok
|
const workspaceDirectory = workspaceAuthority.ok
|
||||||
? workspaceAuthority.authority.workspaceDirectory
|
? workspaceAuthority.authority.workspaceDirectory
|
||||||
: null;
|
: null;
|
||||||
|
const isGitCheckout = workspaceAuthority.ok
|
||||||
|
? workspaceAuthority.authority.workspace.projectKind === "git"
|
||||||
|
: false;
|
||||||
|
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
|
||||||
|
const handleOpenFileExplorer = useCallback(() => {
|
||||||
|
if (!workspaceDirectory) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openFileExplorerForCheckout({
|
||||||
|
isCompact: true,
|
||||||
|
checkout: { serverId, cwd: workspaceDirectory, isGit: isGitCheckout },
|
||||||
|
});
|
||||||
|
}, [isGitCheckout, openFileExplorerForCheckout, serverId, workspaceDirectory]);
|
||||||
invariant(target.kind === "terminal", "TerminalPanel requires terminal target");
|
invariant(target.kind === "terminal", "TerminalPanel requires terminal target");
|
||||||
|
|
||||||
if (!isFocused) {
|
if (!isWorkspaceFocused) {
|
||||||
return <View style={{ flex: 1 }} />;
|
return <View style={{ flex: 1 }} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +104,9 @@ function TerminalPanel() {
|
|||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
cwd={workspaceDirectory}
|
cwd={workspaceDirectory}
|
||||||
terminalId={target.terminalId}
|
terminalId={target.terminalId}
|
||||||
|
isWorkspaceFocused={isWorkspaceFocused}
|
||||||
isPaneFocused={isPaneFocused}
|
isPaneFocused={isPaneFocused}
|
||||||
|
onOpenFileExplorer={handleOpenFileExplorer}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
buildLocalDaemonTransportUrl,
|
buildLocalDaemonTransportUrl,
|
||||||
createDesktopLocalDaemonTransportFactory,
|
createDesktopLocalDaemonTransportFactory,
|
||||||
} from "@/desktop/daemon/desktop-daemon-transport";
|
} from "@/desktop/daemon/desktop-daemon-transport";
|
||||||
|
import { isDev } from "@/constants/platform";
|
||||||
import { applyFetchedAgentDirectory } from "@/utils/agent-directory-sync";
|
import { applyFetchedAgentDirectory } from "@/utils/agent-directory-sync";
|
||||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||||
|
|
||||||
@@ -433,6 +434,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
|
|||||||
clientType: "mobile" as const,
|
clientType: "mobile" as const,
|
||||||
appVersion: resolveAppVersion() ?? undefined,
|
appVersion: resolveAppVersion() ?? undefined,
|
||||||
runtimeGeneration,
|
runtimeGeneration,
|
||||||
|
...(isDev ? { runtimeMetricsIntervalMs: 10_000 } : {}),
|
||||||
};
|
};
|
||||||
if (connection.type === "directSocket" || connection.type === "directPipe") {
|
if (connection.type === "directSocket" || connection.type === "directPipe") {
|
||||||
return new DaemonClient({
|
return new DaemonClient({
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ import {
|
|||||||
useHostRuntimeIsConnected,
|
useHostRuntimeIsConnected,
|
||||||
} from "@/runtime/host-runtime";
|
} from "@/runtime/host-runtime";
|
||||||
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
|
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
|
||||||
import { usePanelStore, type ExplorerCheckoutContext } from "@/stores/panel-store";
|
import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
|
||||||
|
import { type ExplorerCheckoutContext } from "@/stores/explorer-checkout-context";
|
||||||
import { MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
|
import { MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { WelcomeScreen } from "@/components/welcome-screen";
|
import { WelcomeScreen } from "@/components/welcome-screen";
|
||||||
import type { Agent } from "@/contexts/session-context";
|
import type { Agent } from "@/contexts/session-context";
|
||||||
@@ -51,6 +52,8 @@ import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execut
|
|||||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||||
|
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||||
|
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||||
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
|
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
|
||||||
import { useDraftAgentCreateFlow } from "@/hooks/use-draft-agent-create-flow";
|
import { useDraftAgentCreateFlow } from "@/hooks/use-draft-agent-create-flow";
|
||||||
@@ -235,16 +238,22 @@ function DraftAgentScreenContent({
|
|||||||
statusControls,
|
statusControls,
|
||||||
} = composerState;
|
} = composerState;
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const isExplorerOpen = usePanelStore((state) =>
|
||||||
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
selectIsFileExplorerOpen(state, { isCompact: isMobile }),
|
||||||
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
|
);
|
||||||
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
|
const canOpenExplorerFromAgentView = usePanelStore(
|
||||||
const closeFileExplorer = usePanelStore((state) => state.closeFileExplorer);
|
(state) =>
|
||||||
const setActiveExplorerCheckout = usePanelStore((state) => state.setActiveExplorerCheckout);
|
state.mobileView === "agent" && !selectIsFileExplorerOpen(state, { isCompact: true }),
|
||||||
|
);
|
||||||
|
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||||
|
const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer);
|
||||||
|
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
|
||||||
|
const toggleFileExplorerForCheckout = usePanelStore(
|
||||||
|
(state) => state.toggleFileExplorerForCheckout,
|
||||||
|
);
|
||||||
const activateExplorerTabForCheckout = usePanelStore(
|
const activateExplorerTabForCheckout = usePanelStore(
|
||||||
(state) => state.activateExplorerTabForCheckout,
|
(state) => state.activateExplorerTabForCheckout,
|
||||||
);
|
);
|
||||||
const isExplorerOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
|
||||||
|
|
||||||
const [worktreeMode, setWorktreeMode] = useState<"none" | "create" | "attach">(
|
const [worktreeMode, setWorktreeMode] = useState<"none" | "create" | "attach">(
|
||||||
initialWorktreeMode,
|
initialWorktreeMode,
|
||||||
@@ -645,23 +654,42 @@ function DraftAgentScreenContent({
|
|||||||
if (!draftExplorerCheckout) {
|
if (!draftExplorerCheckout) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
activateExplorerTabForCheckout(draftExplorerCheckout);
|
openFileExplorerForCheckout({
|
||||||
openFileExplorer();
|
isCompact: isMobile,
|
||||||
}, [activateExplorerTabForCheckout, draftExplorerCheckout, openFileExplorer]);
|
checkout: draftExplorerCheckout,
|
||||||
|
});
|
||||||
|
}, [draftExplorerCheckout, isMobile, openFileExplorerForCheckout]);
|
||||||
const handleToggleExplorer = useCallback(() => {
|
const handleToggleExplorer = useCallback(() => {
|
||||||
if (!canOpenExplorer) {
|
if (!canOpenExplorer) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isExplorerOpen) {
|
toggleFileExplorerForCheckout({
|
||||||
toggleFileExplorer();
|
isCompact: isMobile,
|
||||||
return;
|
checkout: draftExplorerCheckout,
|
||||||
}
|
});
|
||||||
openExplorerForDraftCheckout();
|
}, [canOpenExplorer, draftExplorerCheckout, isMobile, toggleFileExplorerForCheckout]);
|
||||||
}, [canOpenExplorer, isExplorerOpen, openExplorerForDraftCheckout, toggleFileExplorer]);
|
|
||||||
const explorerOpenGesture = useExplorerOpenGesture({
|
const explorerOpenGesture = useExplorerOpenGesture({
|
||||||
enabled: isMobile && mobileView === "agent" && canOpenExplorer,
|
enabled: isMobile && canOpenExplorerFromAgentView && canOpenExplorer,
|
||||||
onOpen: openExplorerForDraftCheckout,
|
onOpen: openExplorerForDraftCheckout,
|
||||||
});
|
});
|
||||||
|
const handleDraftSidebarAction = useCallback(
|
||||||
|
(action: KeyboardActionDefinition): boolean => {
|
||||||
|
if (action.id !== "sidebar.toggle.right") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
handleToggleExplorer();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[handleToggleExplorer],
|
||||||
|
);
|
||||||
|
useKeyboardActionHandler({
|
||||||
|
handlerId: `draft-agent-sidebar-actions:${draftIdRef.current}`,
|
||||||
|
actions: ["sidebar.toggle.right"] as const,
|
||||||
|
enabled: Boolean(isFocused && canOpenExplorer),
|
||||||
|
priority: 100,
|
||||||
|
isActive: () => true,
|
||||||
|
handle: handleDraftSidebarAction,
|
||||||
|
});
|
||||||
const hasWorkingDirectorySearch = debouncedWorkingDirSearchQuery.length > 0;
|
const hasWorkingDirectorySearch = debouncedWorkingDirSearchQuery.length > 0;
|
||||||
const workingDirSearchError =
|
const workingDirSearchError =
|
||||||
directorySuggestionsQuery.error instanceof Error
|
directorySuggestionsQuery.error instanceof Error
|
||||||
@@ -778,6 +806,9 @@ function DraftAgentScreenContent({
|
|||||||
if (providerDefinitions.length === 0) {
|
if (providerDefinitions.length === 0) {
|
||||||
return "No available providers on the selected host";
|
return "No available providers on the selected host";
|
||||||
}
|
}
|
||||||
|
if (!composerState.selectedProvider) {
|
||||||
|
return "Select a model";
|
||||||
|
}
|
||||||
if (gitBlockingError) {
|
if (gitBlockingError) {
|
||||||
return gitBlockingError;
|
return gitBlockingError;
|
||||||
}
|
}
|
||||||
@@ -818,6 +849,9 @@ function DraftAgentScreenContent({
|
|||||||
(isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir).trim() ||
|
(isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir).trim() ||
|
||||||
".";
|
".";
|
||||||
const provider = composerState.selectedProvider;
|
const provider = composerState.selectedProvider;
|
||||||
|
if (!provider) {
|
||||||
|
throw new Error("Select a model");
|
||||||
|
}
|
||||||
const model = effectiveModelId || null;
|
const model = effectiveModelId || null;
|
||||||
const thinkingOptionId = effectiveThinkingOptionId || null;
|
const thinkingOptionId = effectiveThinkingOptionId || null;
|
||||||
const modeId =
|
const modeId =
|
||||||
@@ -861,8 +895,12 @@ function DraftAgentScreenContent({
|
|||||||
composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
|
composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
|
||||||
? composerState.selectedMode
|
? composerState.selectedMode
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const provider = composerState.selectedProvider;
|
||||||
|
if (!provider) {
|
||||||
|
throw new Error("Select a model");
|
||||||
|
}
|
||||||
const config: AgentSessionConfig = {
|
const config: AgentSessionConfig = {
|
||||||
provider: composerState.selectedProvider,
|
provider,
|
||||||
cwd: resolvedWorkingDir,
|
cwd: resolvedWorkingDir,
|
||||||
...(modeId ? { modeId } : {}),
|
...(modeId ? { modeId } : {}),
|
||||||
...(effectiveModelId ? { model: effectiveModelId } : {}),
|
...(effectiveModelId ? { model: effectiveModelId } : {}),
|
||||||
@@ -940,12 +978,6 @@ function DraftAgentScreenContent({
|
|||||||
router.replace(route as any);
|
router.replace(route as any);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
useEffect(() => {
|
|
||||||
if (!isFocused) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setActiveExplorerCheckout(draftExplorerCheckout);
|
|
||||||
}, [draftExplorerCheckout, isFocused, setActiveExplorerCheckout]);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isFocused || !draftExplorerCheckout) {
|
if (!isFocused || !draftExplorerCheckout) {
|
||||||
return;
|
return;
|
||||||
@@ -956,14 +988,19 @@ function DraftAgentScreenContent({
|
|||||||
if (!isFocused || canOpenExplorer || !isExplorerOpen) {
|
if (!isFocused || canOpenExplorer || !isExplorerOpen) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
closeFileExplorer();
|
if (isMobile) {
|
||||||
}, [canOpenExplorer, closeFileExplorer, isExplorerOpen, isFocused]);
|
showMobileAgent();
|
||||||
useEffect(() => {
|
return;
|
||||||
return () => {
|
}
|
||||||
setActiveExplorerCheckout(null);
|
closeDesktopFileExplorer();
|
||||||
};
|
}, [
|
||||||
}, [setActiveExplorerCheckout]);
|
canOpenExplorer,
|
||||||
|
closeDesktopFileExplorer,
|
||||||
|
isExplorerOpen,
|
||||||
|
isFocused,
|
||||||
|
isMobile,
|
||||||
|
showMobileAgent,
|
||||||
|
]);
|
||||||
if (daemons.length === 0) {
|
if (daemons.length === 0) {
|
||||||
return (
|
return (
|
||||||
<WelcomeScreen
|
<WelcomeScreen
|
||||||
@@ -1194,7 +1231,7 @@ function DraftAgentScreenContent({
|
|||||||
</ScrollView>
|
</ScrollView>
|
||||||
)}
|
)}
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
<View style={styles.inputAreaWrapper}>
|
<View style={[styles.inputAreaWrapper, { paddingBottom: insets.bottom }]}>
|
||||||
<Composer
|
<Composer
|
||||||
agentId={draftAgentIdRef.current}
|
agentId={draftAgentIdRef.current}
|
||||||
serverId={selectedServerId ?? ""}
|
serverId={selectedServerId ?? ""}
|
||||||
|
|||||||
@@ -145,6 +145,10 @@ vi.mock("@/constants/layout", () => ({
|
|||||||
useIsCompactFormFactor: () => false,
|
useIsCompactFormFactor: () => false,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-native-safe-area-context", () => ({
|
||||||
|
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("lucide-react-native", () => {
|
vi.mock("lucide-react-native", () => {
|
||||||
const createIcon = (name: string) => (props: Record<string, unknown>) =>
|
const createIcon = (name: string) => (props: Record<string, unknown>) =>
|
||||||
React.createElement("span", { ...props, "data-icon": name });
|
React.createElement("span", { ...props, "data-icon": name });
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Pressable, Text, View } from "react-native";
|
import { Pressable, Text, View } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { createNameId } from "mnemonic-id";
|
import { createNameId } from "mnemonic-id";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { ChevronDown, GitBranch, GitPullRequest } from "lucide-react-native";
|
import { ChevronDown, GitBranch, GitPullRequest } from "lucide-react-native";
|
||||||
@@ -12,12 +13,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
|
|||||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||||
import { SidebarMenuToggle } from "@/components/headers/menu-header";
|
import { SidebarMenuToggle } from "@/components/headers/menu-header";
|
||||||
import { ScreenHeader } from "@/components/headers/screen-header";
|
import { ScreenHeader } from "@/components/headers/screen-header";
|
||||||
import {
|
import { HEADER_INNER_HEIGHT, MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
|
||||||
HEADER_INNER_HEIGHT,
|
|
||||||
HEADER_INNER_HEIGHT_MOBILE,
|
|
||||||
HEADER_TOP_PADDING_MOBILE,
|
|
||||||
MAX_CONTENT_WIDTH,
|
|
||||||
} from "@/constants/layout";
|
|
||||||
import { useToast } from "@/contexts/toast-context";
|
import { useToast } from "@/contexts/toast-context";
|
||||||
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
|
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
@@ -107,6 +103,8 @@ export function NewWorkspaceScreen({
|
|||||||
displayName: displayNameProp,
|
displayName: displayNameProp,
|
||||||
}: NewWorkspaceScreenProps) {
|
}: NewWorkspaceScreenProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const isCompact = useIsCompactFormFactor();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
||||||
const setAgents = useSessionStore((state) => state.setAgents);
|
const setAgents = useSessionStore((state) => state.setAgents);
|
||||||
@@ -328,6 +326,9 @@ export function NewWorkspaceScreen({
|
|||||||
if (!composerState) {
|
if (!composerState) {
|
||||||
throw new Error("Composer state is required");
|
throw new Error("Composer state is required");
|
||||||
}
|
}
|
||||||
|
if (!composerState.selectedProvider) {
|
||||||
|
throw new Error("Select a model");
|
||||||
|
}
|
||||||
|
|
||||||
const initialPrompt = text.trim();
|
const initialPrompt = text.trim();
|
||||||
const encodedImages = await encodeImages(images);
|
const encodedImages = await encodeImages(images);
|
||||||
@@ -453,7 +454,13 @@ export function NewWorkspaceScreen({
|
|||||||
leftStyle={styles.headerLeft}
|
leftStyle={styles.headerLeft}
|
||||||
borderless
|
borderless
|
||||||
/>
|
/>
|
||||||
<View style={styles.content}>
|
<View
|
||||||
|
style={[
|
||||||
|
styles.content,
|
||||||
|
isCompact ? styles.contentCompact : styles.contentCentered,
|
||||||
|
isCompact ? { paddingBottom: insets.bottom } : null,
|
||||||
|
]}
|
||||||
|
>
|
||||||
<TitlebarDragRegion />
|
<TitlebarDragRegion />
|
||||||
<View style={styles.centered}>
|
<View style={styles.centered}>
|
||||||
<Composer
|
<Composer
|
||||||
@@ -567,12 +574,14 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
content: {
|
content: {
|
||||||
position: "relative",
|
position: "relative",
|
||||||
flex: 1,
|
flex: 1,
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
paddingBottom: {
|
},
|
||||||
xs: HEADER_INNER_HEIGHT_MOBILE + HEADER_TOP_PADDING_MOBILE + theme.spacing[6],
|
contentCentered: {
|
||||||
md: HEADER_INNER_HEIGHT + theme.spacing[6],
|
justifyContent: "center",
|
||||||
},
|
paddingBottom: HEADER_INNER_HEIGHT + theme.spacing[6],
|
||||||
|
},
|
||||||
|
contentCompact: {
|
||||||
|
justifyContent: "flex-end",
|
||||||
},
|
},
|
||||||
centered: {
|
centered: {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
|||||||
import { PairDeviceModal } from "@/desktop/components/pair-device-modal";
|
import { PairDeviceModal } from "@/desktop/components/pair-device-modal";
|
||||||
|
|
||||||
export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
||||||
const openAgentList = usePanelStore((s) => s.openAgentList);
|
const openDesktopAgentList = usePanelStore((s) => s.openDesktopAgentList);
|
||||||
const openProjectPicker = useOpenProjectPicker(serverId);
|
const openProjectPicker = useOpenProjectPicker(serverId);
|
||||||
const hasHydrated = useSessionStore((s) => s.sessions[serverId]?.hasHydratedWorkspaces ?? false);
|
const hasHydrated = useSessionStore((s) => s.sessions[serverId]?.hasHydratedWorkspaces ?? false);
|
||||||
const hasProjects = useHasWorkspaces(serverId);
|
const hasProjects = useHasWorkspaces(serverId);
|
||||||
@@ -31,9 +31,9 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isCompactLayout) {
|
if (!isCompactLayout) {
|
||||||
openAgentList();
|
openDesktopAgentList();
|
||||||
}
|
}
|
||||||
}, [isCompactLayout, openAgentList]);
|
}, [isCompactLayout, openDesktopAgentList]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
|
|||||||
@@ -281,6 +281,7 @@ function AboutSection({ appVersionText, isDesktopApp }: AboutSectionProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function DesktopAppUpdateRow() {
|
function DesktopAppUpdateRow() {
|
||||||
|
const { settings, updateSettings } = useAppSettings();
|
||||||
const {
|
const {
|
||||||
isDesktopApp,
|
isDesktopApp,
|
||||||
statusText,
|
statusText,
|
||||||
@@ -309,6 +310,13 @@ function DesktopAppUpdateRow() {
|
|||||||
void checkForUpdates();
|
void checkForUpdates();
|
||||||
}, [checkForUpdates, isDesktopApp]);
|
}, [checkForUpdates, isDesktopApp]);
|
||||||
|
|
||||||
|
const handleReleaseChannelChange = useCallback(
|
||||||
|
(releaseChannel: AppSettings["releaseChannel"]) => {
|
||||||
|
void updateSettings({ releaseChannel });
|
||||||
|
},
|
||||||
|
[updateSettings],
|
||||||
|
);
|
||||||
|
|
||||||
const handleInstallUpdate = useCallback(() => {
|
const handleInstallUpdate = useCallback(() => {
|
||||||
if (!isDesktopApp) {
|
if (!isDesktopApp) {
|
||||||
return;
|
return;
|
||||||
@@ -316,7 +324,7 @@ function DesktopAppUpdateRow() {
|
|||||||
|
|
||||||
void confirmDialog({
|
void confirmDialog({
|
||||||
title: "Install desktop update",
|
title: "Install desktop update",
|
||||||
message: "This updates Paseo on this computer.",
|
message: "This updates Paseo on this computer",
|
||||||
confirmLabel: "Install update",
|
confirmLabel: "Install update",
|
||||||
cancelLabel: "Cancel",
|
cancelLabel: "Cancel",
|
||||||
})
|
})
|
||||||
@@ -337,40 +345,59 @@ function DesktopAppUpdateRow() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
<>
|
||||||
<View style={settingsStyles.rowContent}>
|
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||||
<Text style={settingsStyles.rowTitle}>App updates</Text>
|
<View style={settingsStyles.rowContent}>
|
||||||
<Text style={settingsStyles.rowHint}>{statusText}</Text>
|
<Text style={settingsStyles.rowTitle}>Release channel</Text>
|
||||||
{availableUpdate?.latestVersion ? (
|
|
||||||
<Text style={settingsStyles.rowHint}>
|
<Text style={settingsStyles.rowHint}>
|
||||||
Ready to install: {formatVersionWithPrefix(availableUpdate.latestVersion)}
|
Switch to Beta to get updates sooner and help shape them
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
</View>
|
||||||
{errorMessage ? <Text style={styles.aboutErrorText}>{errorMessage}</Text> : null}
|
<SegmentedControl
|
||||||
</View>
|
|
||||||
<View style={styles.aboutUpdateActions}>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
size="sm"
|
||||||
onPress={handleCheckForUpdates}
|
value={settings.releaseChannel}
|
||||||
disabled={isChecking || isInstalling}
|
onValueChange={handleReleaseChannelChange}
|
||||||
>
|
options={[
|
||||||
{isChecking ? "Checking..." : "Check"}
|
{ value: "stable", label: "Stable" },
|
||||||
</Button>
|
{ value: "beta", label: "Beta" },
|
||||||
<Button
|
]}
|
||||||
variant="default"
|
/>
|
||||||
size="sm"
|
|
||||||
onPress={handleInstallUpdate}
|
|
||||||
disabled={isChecking || isInstalling || !availableUpdate}
|
|
||||||
>
|
|
||||||
{isInstalling
|
|
||||||
? "Installing..."
|
|
||||||
: availableUpdate?.latestVersion
|
|
||||||
? `Update to ${formatVersionWithPrefix(availableUpdate.latestVersion)}`
|
|
||||||
: "Update"}
|
|
||||||
</Button>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||||
|
<View style={settingsStyles.rowContent}>
|
||||||
|
<Text style={settingsStyles.rowTitle}>App updates</Text>
|
||||||
|
<Text style={settingsStyles.rowHint}>{statusText}</Text>
|
||||||
|
{availableUpdate?.latestVersion ? (
|
||||||
|
<Text style={settingsStyles.rowHint}>
|
||||||
|
Ready to install: {formatVersionWithPrefix(availableUpdate.latestVersion)}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{errorMessage ? <Text style={styles.aboutErrorText}>{errorMessage}</Text> : null}
|
||||||
|
</View>
|
||||||
|
<View style={styles.aboutUpdateActions}>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onPress={handleCheckForUpdates}
|
||||||
|
disabled={isChecking || isInstalling}
|
||||||
|
>
|
||||||
|
{isChecking ? "Checking..." : "Check"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onPress={handleInstallUpdate}
|
||||||
|
disabled={isChecking || isInstalling || !availableUpdate}
|
||||||
|
>
|
||||||
|
{isInstalling
|
||||||
|
? "Installing..."
|
||||||
|
: availableUpdate?.latestVersion
|
||||||
|
? `Update to ${formatVersionWithPrefix(availableUpdate.latestVersion)}`
|
||||||
|
: "Update"}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -858,9 +885,10 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
|||||||
layout="desktop"
|
layout="desktop"
|
||||||
/>
|
/>
|
||||||
<View style={desktopStyles.contentPane}>
|
<View style={desktopStyles.contentPane}>
|
||||||
{detailHeader ? (
|
<ScreenHeader
|
||||||
<ScreenHeader
|
borderless={!detailHeader}
|
||||||
left={
|
left={
|
||||||
|
detailHeader ? (
|
||||||
<>
|
<>
|
||||||
<HeaderIconBadge>
|
<HeaderIconBadge>
|
||||||
<detailHeader.Icon
|
<detailHeader.Icon
|
||||||
@@ -873,10 +901,10 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
|||||||
</ScreenTitle>
|
</ScreenTitle>
|
||||||
{detailHeader.titleAccessory}
|
{detailHeader.titleAccessory}
|
||||||
</>
|
</>
|
||||||
}
|
) : null
|
||||||
leftStyle={desktopStyles.detailLeft}
|
}
|
||||||
/>
|
leftStyle={desktopStyles.detailLeft}
|
||||||
) : null}
|
/>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
style={styles.scrollView}
|
style={styles.scrollView}
|
||||||
contentContainerStyle={{ paddingBottom: insets.bottom }}
|
contentContainerStyle={{ paddingBottom: insets.bottom }}
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export function HostPage({ serverId, onHostRemoved }: HostPageProps) {
|
|||||||
return (
|
return (
|
||||||
<View testID={`settings-host-page-${serverId}`}>
|
<View testID={`settings-host-page-${serverId}`}>
|
||||||
<View style={[settingsStyles.card, styles.emptyCard]}>
|
<View style={[settingsStyles.card, styles.emptyCard]}>
|
||||||
<Text style={styles.emptyText}>Host not found.</Text>
|
<Text style={styles.emptyText}>Host not found</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -591,7 +591,7 @@ function PairDeviceRow() {
|
|||||||
<View style={settingsStyles.rowContent}>
|
<View style={settingsStyles.rowContent}>
|
||||||
<Text style={settingsStyles.rowTitle}>Pair a device</Text>
|
<Text style={settingsStyles.rowTitle}>Pair a device</Text>
|
||||||
<Text style={settingsStyles.rowHint}>
|
<Text style={settingsStyles.rowHint}>
|
||||||
Scan a QR code or copy a link to connect your phone to this host.
|
Scan a QR code or copy a link to connect your phone to this host
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
@@ -619,7 +619,7 @@ function RemoveHostSection({ host, onRemoved }: { host: HostProfile; onRemoved?:
|
|||||||
<View style={settingsStyles.rowContent}>
|
<View style={settingsStyles.rowContent}>
|
||||||
<Text style={settingsStyles.rowTitle}>Remove host</Text>
|
<Text style={settingsStyles.rowTitle}>Remove host</Text>
|
||||||
<Text style={settingsStyles.rowHint}>
|
<Text style={settingsStyles.rowHint}>
|
||||||
Removes this host and its saved connections from this device.
|
Removes this host and its saved connections from this device
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ export function KeyboardShortcutsSection() {
|
|||||||
return (
|
return (
|
||||||
<SettingsSection title="Shortcuts">
|
<SettingsSection title="Shortcuts">
|
||||||
<View style={[settingsStyles.card, styles.mobileCard]}>
|
<View style={[settingsStyles.card, styles.mobileCard]}>
|
||||||
<Text style={styles.mobileText}>Keyboard shortcuts are only available on desktop.</Text>
|
<Text style={styles.mobileText}>Keyboard shortcuts are only available on desktop</Text>
|
||||||
</View>
|
</View>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
);
|
);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user