mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
96 Commits
config-wor
...
codex/fix-
| 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 | ||
|
|
64065061c5 | ||
|
|
7b9636a22d | ||
|
|
8165de7b06 | ||
|
|
75f83b0b9b | ||
|
|
badec6f587 | ||
|
|
5a2708653c | ||
|
|
06b3fc2ec6 | ||
|
|
bd1fb311ea | ||
|
|
8991d34f78 | ||
|
|
128d6668ae | ||
|
|
d9d6509880 | ||
|
|
2d9cbb0453 | ||
|
|
e373c33108 | ||
|
|
6a36db9924 | ||
|
|
d3393b434a | ||
|
|
43bbf8cb12 | ||
|
|
cfa3b009e3 | ||
|
|
f411a2a383 | ||
|
|
c4d84ff2a3 | ||
|
|
73b0b60bdd | ||
|
|
5a232d8cf1 | ||
|
|
2238171955 | ||
|
|
fbc2371b95 | ||
|
|
f8815bf1c2 | ||
|
|
93cda8e60f | ||
|
|
c53d42acf8 | ||
|
|
2a519d96e5 | ||
|
|
07714773cc | ||
|
|
ada623c1c6 | ||
|
|
9fb0ab9b2d | ||
|
|
83bbc8f659 | ||
|
|
7819019cea | ||
|
|
23b20169ef | ||
|
|
e5b0ea6d1b | ||
|
|
4de4e90325 | ||
|
|
fa41ba76cd | ||
|
|
9f6570ca3e | ||
|
|
ccb956f98f | ||
|
|
f483e62231 | ||
|
|
c0bb34c514 | ||
|
|
6b5282ad43 | ||
|
|
72e4476acc | ||
|
|
5d76cc8840 | ||
|
|
b0e0786419 | ||
|
|
14cc1cc83c | ||
|
|
82f97e942d | ||
|
|
59d9b2ebdd | ||
|
|
d8ff049aff | ||
|
|
f08bff1b2d |
4
.github/workflows/deploy-app.yml
vendored
4
.github/workflows/deploy-app.yml
vendored
@@ -4,9 +4,9 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- '!v*-rc.*'
|
||||
- '!v*-beta.*'
|
||||
- 'app-v*'
|
||||
- '!app-v*-rc.*'
|
||||
- '!app-v*-beta.*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
||||
18
.github/workflows/desktop-release.yml
vendored
18
.github/workflows/desktop-release.yml
vendored
@@ -66,7 +66,9 @@ jobs:
|
||||
--repo "${{ github.repository }}" \
|
||||
--title "Paseo $RELEASE_TAG" \
|
||||
--generate-notes \
|
||||
$prerelease_flag
|
||||
$prerelease_flag || {
|
||||
echo "Release creation raced with another workflow; continuing."
|
||||
}
|
||||
fi
|
||||
|
||||
publish-macos:
|
||||
@@ -145,6 +147,7 @@ jobs:
|
||||
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
|
||||
publish_mode="always"
|
||||
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||
publish_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
||||
fi
|
||||
|
||||
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --mac --${{ matrix.electron_arch }} "${publish_args[@]}"
|
||||
@@ -154,7 +157,7 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mac-manifest-${{ matrix.electron_arch }}
|
||||
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/latest-mac.yml
|
||||
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/${{ env.RELEASE_CHANNEL }}-mac.yml
|
||||
retention-days: 1
|
||||
|
||||
finalize-mac-manifest:
|
||||
@@ -227,8 +230,9 @@ jobs:
|
||||
return out;
|
||||
}
|
||||
|
||||
const arm64Text = fs.readFileSync('mac-manifest-arm64/latest-mac.yml', 'utf8');
|
||||
const x64Text = fs.readFileSync('mac-manifest-x64/latest-mac.yml', 'utf8');
|
||||
const manifestName = `${process.env.RELEASE_CHANNEL}-mac.yml`;
|
||||
const arm64Text = fs.readFileSync(`mac-manifest-arm64/${manifestName}`, 'utf8');
|
||||
const x64Text = fs.readFileSync(`mac-manifest-x64/${manifestName}`, 'utf8');
|
||||
|
||||
const arm64 = parseManifest(arm64Text);
|
||||
const x64 = parseManifest(x64Text);
|
||||
@@ -243,7 +247,7 @@ jobs:
|
||||
};
|
||||
|
||||
const output = toYaml(merged);
|
||||
fs.writeFileSync('latest-mac.yml', output);
|
||||
fs.writeFileSync(manifestName, output);
|
||||
console.log('Merged manifest:\n' + output);
|
||||
NODE
|
||||
|
||||
@@ -251,7 +255,7 @@ jobs:
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: gh release upload "$RELEASE_TAG" latest-mac.yml --clobber --repo "${{ github.repository }}"
|
||||
run: gh release upload "$RELEASE_TAG" "$RELEASE_CHANNEL-mac.yml" --clobber --repo "${{ github.repository }}"
|
||||
|
||||
publish-linux:
|
||||
needs: [create-release]
|
||||
@@ -316,6 +320,7 @@ jobs:
|
||||
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
|
||||
publish_mode="always"
|
||||
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||
publish_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
||||
fi
|
||||
|
||||
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --linux --x64 "${publish_args[@]}"
|
||||
@@ -391,6 +396,7 @@ jobs:
|
||||
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
|
||||
publish_mode="always"
|
||||
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||
publish_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
||||
fi
|
||||
|
||||
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --win --x64 "${publish_args[@]}"
|
||||
|
||||
4
.github/workflows/release-notes-sync.yml
vendored
4
.github/workflows/release-notes-sync.yml
vendored
@@ -54,9 +54,7 @@ jobs:
|
||||
fi
|
||||
|
||||
create_if_missing="false"
|
||||
if [ "$EVENT_NAME" = "push" ] && [[ "$REF" == refs/tags/v* ]]; then
|
||||
create_if_missing="true"
|
||||
elif [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "${INPUT_CREATE_IF_MISSING:-false}" = "true" ]; then
|
||||
if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "${INPUT_CREATE_IF_MISSING:-false}" = "true" ]; then
|
||||
create_if_missing="true"
|
||||
fi
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.60-beta.1 - 2026-04-20
|
||||
|
||||
### Added
|
||||
- Beta release channel for desktop updates, with a Settings toggle for receiving beta builds before they are promoted to stable.
|
||||
- Release candidates are now called beta releases, starting with `0.1.60-beta.1`.
|
||||
|
||||
## 0.1.59 - 2026-04-16
|
||||
|
||||
### Added
|
||||
|
||||
@@ -51,7 +51,7 @@ Stable tag pushes like `v0.1.0` trigger:
|
||||
- `packages/app/.eas/workflows/release-mobile.yml` on Expo servers (iOS + Android build + submit)
|
||||
- `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release)
|
||||
|
||||
Release candidate tags like `v0.1.1-rc.1` only trigger the GitHub APK workflow. They publish a GitHub prerelease APK for testing and do not submit to the stores.
|
||||
Beta tags like `v0.1.1-beta.1` only trigger the GitHub APK workflow. They publish a GitHub prerelease APK for testing and do not submit to the stores.
|
||||
|
||||
### Useful commands
|
||||
|
||||
|
||||
@@ -54,6 +54,33 @@ npm run db:query -- --db /path/to/db "SELECT ..."
|
||||
Auto-detects the running dev daemon's database from `/tmp/paseo-dev.*`, `PASEO_HOME`, or `~/.paseo/db`.
|
||||
Pass either a DB directory or a `paseo.sqlite` file to `--db`. The script opens the database directly in read-only mode.
|
||||
|
||||
## paseo.json service scripts
|
||||
|
||||
Every `scripts` entry with `"type": "service"` receives these environment variables:
|
||||
|
||||
| Variable | Value |
|
||||
|---|---|
|
||||
| `PASEO_SERVICE_<NAME>_URL` | Proxied daemon URL for a declared peer service. Prefer this for peer discovery; it survives peer restarts. |
|
||||
| `PASEO_SERVICE_<NAME>_PORT` | Raw ephemeral port for a declared peer service. Use only as a bypass escape hatch; it can go stale if that peer restarts. |
|
||||
| `PASEO_URL` | Self alias for `PASEO_SERVICE_<SELF>_URL`. |
|
||||
| `PASEO_PORT` | Self alias for `PASEO_SERVICE_<SELF>_PORT`. |
|
||||
| `HOST` | Bind host for the service process. |
|
||||
|
||||
`<NAME>` is normalized from the script name by uppercasing it, replacing each run of non-`A-Z0-9` characters with `_`, and trimming leading or trailing `_`. For example, `app-server` and `app.server` both normalize to `APP_SERVER`; that collision fails at spawn time with an actionable error.
|
||||
|
||||
`PORT` is not injected by default. If a framework requires `PORT`, set it in the command:
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"web": {
|
||||
"type": "service",
|
||||
"command": "PORT=$PASEO_PORT npm run dev:web"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Build sync gotchas
|
||||
|
||||
### Relay → Daemon
|
||||
|
||||
@@ -7,7 +7,7 @@ All workspaces share one version and release together.
|
||||
There are two supported ways to ship from `main`:
|
||||
|
||||
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
|
||||
2. **Release candidate flow**: you want public test builds first, but you are not ready for the website, npm, or production mobile release flows to move yet.
|
||||
2. **Beta flow**: you want public test builds first, but you are not ready for the website, npm, or production mobile release flows to move yet.
|
||||
|
||||
## Standard release (patch)
|
||||
|
||||
@@ -37,31 +37,32 @@ npm run release:publish # Publish to npm
|
||||
npm run release:push # Push HEAD + tag (triggers CI workflows)
|
||||
```
|
||||
|
||||
## Release candidate flow
|
||||
## Beta flow
|
||||
|
||||
```bash
|
||||
npm run release:rc:patch # Bump to X.Y.Z-rc.1, push commit + tag
|
||||
npm run release:beta:patch # Bump to X.Y.Z-beta.1, push commit + tag
|
||||
# ... test desktop and APK prerelease assets from GitHub Releases ...
|
||||
npm run release:rc:next # Optional: cut X.Y.Z-rc.2, rc.3, ...
|
||||
npm run release:promote # Promote X.Y.Z-rc.N to stable X.Y.Z
|
||||
npm run release:beta:next # Optional: cut X.Y.Z-beta.2, beta.3, ...
|
||||
npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z
|
||||
```
|
||||
|
||||
- RC tags are published GitHub prereleases like `v0.1.41-rc.1`
|
||||
- RCs publish desktop assets and APKs for testing, but they do not publish npm packages and do not trigger the production web/mobile release flows
|
||||
- `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the RC tag
|
||||
- Beta tags are published GitHub prereleases like `v0.1.41-beta.1`
|
||||
- Betas publish desktop assets and APKs for testing, but they do not publish npm packages and do not trigger the production web/mobile release flows
|
||||
- `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the beta tag
|
||||
- Desktop assets now come from the Electron package at `packages/desktop`
|
||||
- **Do NOT create a changelog entry for RCs.** The changelog remains stable-only. RC release notes are generated automatically so the website stays pinned to the latest published stable release.
|
||||
- Beta releases use Electron's `beta` update channel. Users on the stable channel only receive stable releases; users on the beta channel receive beta releases and the final stable release when it is published.
|
||||
- **Do create a changelog entry for betas.** The beta entry is temporary and gets updated in place until promotion.
|
||||
|
||||
Use the RC path when you need to:
|
||||
Use the beta path when you need to:
|
||||
|
||||
- test a build manually in a Linux or Windows VM
|
||||
- send a build to a user who is hitting a specific problem
|
||||
- iterate on `rc.1`, `rc.2`, `rc.3`, and so on before deciding to ship broadly
|
||||
- iterate on `beta.1`, `beta.2`, `beta.3`, and so on before deciding to ship broadly
|
||||
|
||||
## Website behavior
|
||||
|
||||
- The website download page points to GitHub's latest published **stable** release.
|
||||
- Published RC prereleases are public on GitHub Releases, but they do **not** become the website download target.
|
||||
- Published beta prereleases are public on GitHub Releases, but they do **not** become the website download target.
|
||||
- The website only moves when you publish the final stable release tag like `v0.1.41`.
|
||||
|
||||
## Fixing a failed release build
|
||||
@@ -88,13 +89,13 @@ git tag -f desktop-windows-v0.1.28 HEAD && git push origin desktop-windows-v0.1.
|
||||
# Android APK
|
||||
git tag -f android-v0.1.28 HEAD && git push origin android-v0.1.28 --force
|
||||
|
||||
# RC
|
||||
git tag -f v0.1.29-rc.2 HEAD && git push origin v0.1.29-rc.2 --force
|
||||
# Beta
|
||||
git tag -f v0.1.29-beta.2 HEAD && git push origin v0.1.29-beta.2 --force
|
||||
```
|
||||
|
||||
This ensures the checkout ref matches the actual code on `main` with the fix included.
|
||||
|
||||
- `vX.Y.Z` or `vX.Y.Z-rc.N` rebuilds the full tagged release
|
||||
- `vX.Y.Z` or `vX.Y.Z-beta.N` rebuilds the full tagged release
|
||||
- `desktop-vX.Y.Z` rebuilds desktop for all desktop platforms only
|
||||
- `desktop-macos-vX.Y.Z`, `desktop-linux-vX.Y.Z`, and `desktop-windows-vX.Y.Z` rebuild only that desktop platform
|
||||
- `android-vX.Y.Z` rebuilds the Android APK release only
|
||||
@@ -105,24 +106,26 @@ This ensures the checkout ref matches the actual code on `main` with the fix inc
|
||||
- `release:prepare` refreshes workspace `node_modules` links to prevent stale types
|
||||
- `npm run dev:desktop` and `npm run build:desktop` target the Electron desktop package in `packages/desktop`
|
||||
- If `release:publish` partially fails, re-run it — npm skips already-published versions
|
||||
- The website uses GitHub's latest published release API for download links, so published RC prereleases do not replace the stable download target.
|
||||
- The website uses GitHub's latest published release API for download links, so published beta prereleases do not replace the stable download target.
|
||||
|
||||
## Changelog format
|
||||
|
||||
Stable release notes depend on the changelog heading format. The heading **must** be strictly followed:
|
||||
Release notes depend on the changelog heading format. The heading **must** be strictly followed:
|
||||
|
||||
```
|
||||
## X.Y.Z - YYYY-MM-DD
|
||||
## X.Y.Z-beta.N - YYYY-MM-DD
|
||||
```
|
||||
|
||||
No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to extract the version. A malformed heading will break download links on the homepage.
|
||||
|
||||
## Changelog policy
|
||||
|
||||
- `CHANGELOG.md` is for **final stable releases only**.
|
||||
- Do not add or edit changelog entries while iterating on RCs.
|
||||
- Write the proper changelog entry when you are cutting the final stable release that comes after the RC cycle.
|
||||
- Between stable releases, keep changelog work out of the repo until the final release is ready.
|
||||
- `CHANGELOG.md` includes stable releases and the current beta line.
|
||||
- The first beta inserts a top entry like `## 0.1.60-beta.1 - YYYY-MM-DD`.
|
||||
- The next beta updates that same top entry in place, for example from `0.1.60-beta.1` to `0.1.60-beta.2`.
|
||||
- Stable promotion updates that same entry in place, for example from `0.1.60-beta.2` to `0.1.60`.
|
||||
- Do not create duplicate entries for each beta on the same version line.
|
||||
|
||||
## Changelog ownership
|
||||
|
||||
@@ -178,7 +181,7 @@ Entries within each section (Added, Improved, Fixed) are ordered by user impact:
|
||||
|
||||
## Pre-release sanity check
|
||||
|
||||
Before cutting any release (RC or stable), run a Codex review of the diff as a last line of defence against shipping bugs.
|
||||
Before cutting any release (beta or stable), run a Codex review of the diff as a last line of defence against shipping bugs.
|
||||
|
||||
Load the `paseo` skill and launch a **Codex 5.4** agent with a prompt like:
|
||||
|
||||
@@ -196,10 +199,10 @@ The agent's job is a deep sanity check, not a full code review. If it flags anyt
|
||||
|
||||
The changelog always covers **stable-to-HEAD**:
|
||||
|
||||
- **RC release**: the diff and release notes cover `latest stable tag → HEAD`. RC release notes are auto-generated and not added to `CHANGELOG.md`.
|
||||
- **Stable release**: the diff and changelog entry cover `latest stable tag → HEAD`. Any intermediate RCs are skipped — the changelog captures the full delta from the previous stable release, not just what changed since the last RC.
|
||||
- **Beta release**: the diff and release notes cover `latest stable tag -> HEAD`. The current beta changelog entry is updated in place.
|
||||
- **Stable release**: the same changelog entry is promoted in place. It still captures the full delta from the previous stable release, not just what changed since the last beta.
|
||||
|
||||
In other words, RCs are checkpoints along the way; the changelog only records the final jump from one stable version to the next.
|
||||
In other words, betas are checkpoints along the way; the changelog entry remains the single record for the final jump from one stable version to the next.
|
||||
|
||||
## Completion checklist
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
|
||||
On first mount this can paint with the current adaptive or initial theme. If app settings later load a persisted theme and call [`UnistylesRuntime.setTheme`](https://www.unistyl.es/v3/guides/theming#change-theme), the JS-side style proxy may report the new theme while the native content container keeps the old background. That is how the welcome screen ended up with a light background and dark foreground/buttons.
|
||||
|
||||
This applies broadly to non-`style` props that carry theme-dependent values, such as component props named `color`, `trackColor`, `tintColor`, and library-specific style props. The [3rd-party view decision algorithm](https://www.unistyl.es/v3/references/3rd-party-views) recommends explicit handling for these cases, and [issue #1030](https://github.com/jpudysz/react-native-unistyles/issues/1030) shows a related native-prop update edge case around `Image.tintColor`. Treat these values as React props unless wrapped with `withUnistyles`.
|
||||
This applies broadly to non-`style` props that carry theme-dependent values, such as component props named `color`, `trackColor`, `tintColor`, `backgroundStyle`, `handleIndicatorStyle`, and other library-specific style props. The [3rd-party view decision algorithm](https://www.unistyl.es/v3/references/3rd-party-views) recommends explicit handling for these cases, and [issue #1030](https://github.com/jpudysz/react-native-unistyles/issues/1030) shows a related native-prop update edge case around `Image.tintColor`. Treat these values as React props unless wrapped with `withUnistyles`.
|
||||
|
||||
## Fix Patterns
|
||||
|
||||
@@ -89,6 +89,38 @@ const { theme } = useUnistyles();
|
||||
|
||||
Use this sparingly. It works because React re-renders the prop, but it gives up the main Unistyles native-update path for that value.
|
||||
|
||||
## `withUnistyles` And The `> *` Child-Selector Leak
|
||||
|
||||
`withUnistyles` on a component with a theme-dependent `style` prop works by wrapping the component in a `<div style={{display: 'contents'}} className={hash}>` and emitting the style under a `.hash > *` child selector so the styles cascade onto the wrapped component. This is how auto-mapping for `style` and `contentContainerStyle` works on web.
|
||||
|
||||
The sharp edge: Unistyles hashes styles by value. If `withUnistyles` receives a style whose value is **identical** to a style used elsewhere in the app on a plain `View`, both usages get the same hash — and both CSS rules (the element rule and the `> *` child rule) are emitted under the same class name. The `> *` rule then leaks onto the direct children of every `View` that shares the hash.
|
||||
|
||||
Concrete regression we hit: `welcome-screen.tsx` had `const ThemedScrollView = withUnistyles(ScrollView)` with `style={{ flex: 1, backgroundColor: theme.colors.surface0 }}`. `agent-panel.tsx` had `root` and `container` styles with the exact same value. All three collided on class `unistyles_j2k2iilhfz`, so the browser stylesheet contained:
|
||||
|
||||
```css
|
||||
.unistyles_j2k2iilhfz { flex: 1 1 0%; background-color: var(--colors-surface0); }
|
||||
.unistyles_j2k2iilhfz > * { flex: 1 1 0%; background-color: var(--colors-surface0); }
|
||||
```
|
||||
|
||||
The child-selector rule forced `flex:1` and `background-color: surface0` onto the Composer's outer `Animated.View` (a direct child of `container`), stretching it to fill remaining space and leaving a large empty gap between the composer UI and the bottom of the screen. It also painted a `surface0` band behind the scroll-to-bottom button. The bug only appeared in the browser — Electron skips `WelcomeScreen` after pairing, so the `> *` rule was never injected there.
|
||||
|
||||
Symptoms to watch for:
|
||||
|
||||
- A sibling of a themed panel-background `View` stretches unexpectedly on web only.
|
||||
- Random direct children of a `{ flex: 1, backgroundColor: surface0 }` `View` pick up an unexpected background.
|
||||
- DevTools shows a `.unistyles_xxx > *` rule you did not write.
|
||||
|
||||
Quick confirmation in DevTools console:
|
||||
|
||||
```js
|
||||
[...document.styleSheets].flatMap(s => [...(s.cssRules || [])])
|
||||
.map(r => r.cssText).filter(t => t.includes("unistyles") && t.includes("> *"));
|
||||
```
|
||||
|
||||
Any match beyond benign `r-pointerEvents-* > *` rules from react-native-web is a leak.
|
||||
|
||||
Avoid the bug by preferring the wrapper-`View` pattern from the previous section whenever possible: put `{ flex: 1, backgroundColor: surface0 }` on a plain `View` and give the `ScrollView` a theme-free `style`/`contentContainerStyle`. That keeps `withUnistyles` off the hot path and avoids the hash collision. Only reach for `withUnistyles(ScrollView)` when a wrapper view is genuinely awkward, and when you do, give the wrapped style a distinctive shape (extra key, different layout) so it does not hash-collide with a common panel background used elsewhere.
|
||||
|
||||
## Hidden Sheet Content
|
||||
|
||||
`@gorhom/bottom-sheet` can keep `BottomSheetModal` content mounted while the sheet is hidden. That matters during Paseo's startup theme transition: a header node can be created under the initial adaptive theme, stay hidden, then appear later with stale native style values even though surrounding content has re-rendered correctly.
|
||||
@@ -105,6 +137,44 @@ const { theme } = useUnistyles();
|
||||
|
||||
Keep layout and typography in `StyleSheet.create`; move only the stale theme-dependent value through React. If a larger subtree shows the same behavior, consider remounting the sheet on theme changes or moving the themed paint onto a wrapper that is mounted with the visible content.
|
||||
|
||||
The same rule applies to bottom-sheet component props such as `backgroundStyle` and `handleIndicatorStyle`: they are library props, not the direct React Native `style` prop Unistyles registers. Prefer a custom `backgroundComponent` that calls `useUnistyles()`, or pass a small inline object from the hook theme.
|
||||
|
||||
## Memoized Style Objects
|
||||
|
||||
When a third-party library receives a plain style object, it is outside Unistyles' native tracking path. Make sure any memo that builds that style object depends on the actual theme values it reads.
|
||||
|
||||
Avoid indirect keys like this:
|
||||
|
||||
```tsx
|
||||
const { theme, rt } = useUnistyles();
|
||||
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [rt.themeName]);
|
||||
```
|
||||
|
||||
On adaptive system-theme changes, the hook can provide a light/dark theme update while an indirect runtime key is not the value that invalidates the memo. That leaves the library rendering stale colors. Assistant markdown hit this exact failure: the workspace shell switched to light, but assistant text and code spans kept the old dark-theme markdown style object.
|
||||
|
||||
Prefer the hook theme itself, or explicit theme tokens, as the dependency:
|
||||
|
||||
```tsx
|
||||
const { theme } = useUnistyles();
|
||||
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
|
||||
```
|
||||
|
||||
If a style factory is cheap, skipping `useMemo` entirely is also fine.
|
||||
|
||||
## Static Theme Imports
|
||||
|
||||
Do not import `theme` from `@/styles/theme` for live UI colors. That export is a dark-theme compatibility default, so using it in render code leaves icons, placeholders, or third-party props pinned to dark colors in light mode.
|
||||
|
||||
Use `useUnistyles()` inside the component instead:
|
||||
|
||||
```tsx
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
```
|
||||
|
||||
Importing `baseColors`, theme-name constants, or `type Theme` is fine when the value is intentionally static or type-only.
|
||||
|
||||
## Adaptive Themes And Persisted Settings
|
||||
|
||||
Unistyles [`initialTheme`](https://www.unistyl.es/v3/guides/theming#select-theme) and [`adaptiveThemes`](https://www.unistyl.es/v3/guides/theming#adaptive-themes) are mutually exclusive. `initialTheme` can be a string or a synchronous function, but it cannot wait on async storage.
|
||||
|
||||
@@ -42,7 +42,7 @@ buildNpmPackage rec {
|
||||
|
||||
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
||||
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
||||
npmDepsHash = "sha256-Q8FCZ5cQs45ZL0XeaRzgZXmROmywT0YI/1Rqdlpbt/w=";
|
||||
npmDepsHash = "sha256-6v597rirYsPQJYXvVpd0+MZfbY0I6Oqlmd/7z5eHiOw=";
|
||||
|
||||
# Prevent onnxruntime-node's install script from running during automatic
|
||||
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
||||
|
||||
2909
package-lock.json
generated
2909
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
18
package.json
18
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.59",
|
||||
"version": "0.1.60-beta.1",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/expo-two-way-audio",
|
||||
@@ -47,19 +47,19 @@
|
||||
"version:all:patch": "node scripts/set-release-version.mjs --mode patch",
|
||||
"version:all:minor": "node scripts/set-release-version.mjs --mode minor",
|
||||
"version:all:major": "node scripts/set-release-version.mjs --mode major",
|
||||
"version:all:rc:patch": "node scripts/set-release-version.mjs --mode rc-patch",
|
||||
"version:all:rc:minor": "node scripts/set-release-version.mjs --mode rc-minor",
|
||||
"version:all:rc:major": "node scripts/set-release-version.mjs --mode rc-major",
|
||||
"version:all:rc:next": "node scripts/set-release-version.mjs --mode rc-next",
|
||||
"version:all:beta:patch": "node scripts/set-release-version.mjs --mode beta-patch",
|
||||
"version:all:beta:minor": "node scripts/set-release-version.mjs --mode beta-minor",
|
||||
"version:all:beta:major": "node scripts/set-release-version.mjs --mode beta-major",
|
||||
"version:all:beta:next": "node scripts/set-release-version.mjs --mode beta-next",
|
||||
"version:all:promote": "node scripts/set-release-version.mjs --mode promote",
|
||||
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
|
||||
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
|
||||
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
|
||||
"release:push": "node scripts/push-current-release-tag.mjs",
|
||||
"release:rc:patch": "npm run release:check && npm run version:all:rc:patch && npm run release:push",
|
||||
"release:rc:minor": "npm run release:check && npm run version:all:rc:minor && npm run release:push",
|
||||
"release:rc:major": "npm run release:check && npm run version:all:rc:major && npm run release:push",
|
||||
"release:rc:next": "npm run release:check && npm run version:all:rc:next && npm run release:push",
|
||||
"release:beta:patch": "npm run release:check && npm run version:all:beta:patch && npm run release:push",
|
||||
"release:beta:minor": "npm run release:check && npm run version:all:beta:minor && npm run release:push",
|
||||
"release:beta:major": "npm run release:check && npm run version:all:beta:major && npm run release:push",
|
||||
"release:beta:next": "npm run release:check && npm run version:all:beta:next && npm run release:push",
|
||||
"release:promote": "npm run release:check && npm run version:all:promote && npm run release:publish && npm run release:push",
|
||||
"release:patch": "npm run release:check && npm run version:all:patch && npm run release:publish && npm run release:push",
|
||||
"release:minor": "npm run release:check && npm run version:all:minor && npm run release:publish && npm run release:push",
|
||||
|
||||
61
packages/app/e2e/file-explorer-collapse.spec.ts
Normal file
61
packages/app/e2e/file-explorer-collapse.spec.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { test } from "./fixtures";
|
||||
import {
|
||||
collapseFolder,
|
||||
expandFolder,
|
||||
expectExplorerEntryHidden,
|
||||
expectExplorerEntryVisible,
|
||||
expectFileTabOpen,
|
||||
openFileExplorer,
|
||||
openFileFromExplorer,
|
||||
} from "./helpers/file-explorer";
|
||||
import { gotoWorkspace } from "./helpers/launcher";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
connectWorkspaceSetupClient,
|
||||
type WorkspaceSetupDaemonClient,
|
||||
} from "./helpers/workspace-setup";
|
||||
|
||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||
let workspaceId: string;
|
||||
let seedClient: WorkspaceSetupDaemonClient;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
tempRepo = await createTempGitRepo("file-explorer-collapse-", {
|
||||
files: [
|
||||
{ path: "assets/logo.png", content: "image bytes for explorer e2e\n" },
|
||||
{ path: "docs/guide.md", content: "# Guide\n" },
|
||||
],
|
||||
});
|
||||
seedClient = await connectWorkspaceSetupClient();
|
||||
const result = await seedClient.openProject(tempRepo.path);
|
||||
if (!result.workspace) {
|
||||
throw new Error(result.error ?? "Failed to seed workspace");
|
||||
}
|
||||
workspaceId = String(result.workspace.id);
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await seedClient?.close();
|
||||
await tempRepo?.cleanup();
|
||||
});
|
||||
|
||||
test.describe("File explorer collapse", () => {
|
||||
test("collapses an opened image file parent folder and still expands other folders", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
await openFileExplorer(page);
|
||||
|
||||
await expandFolder(page, "assets");
|
||||
await expectExplorerEntryVisible(page, "logo.png");
|
||||
|
||||
await openFileFromExplorer(page, "logo.png");
|
||||
await expectFileTabOpen(page, "assets/logo.png");
|
||||
|
||||
await collapseFolder(page, "assets");
|
||||
await expectExplorerEntryHidden(page, "logo.png");
|
||||
|
||||
await expandFolder(page, "docs");
|
||||
await expectExplorerEntryVisible(page, "guide.md");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { spawn, type ChildProcess, execFileSync, execSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import net from "node:net";
|
||||
@@ -182,6 +182,7 @@ async function isOpenAiApiKeyUsable(apiKey: string | undefined): Promise<boolean
|
||||
let daemonProcess: ChildProcess | null = null;
|
||||
let metroProcess: ChildProcess | null = null;
|
||||
let paseoHome: string | null = null;
|
||||
let fakeGhBinDir: string | null = null;
|
||||
let relayProcess: ChildProcess | null = null;
|
||||
|
||||
type OfferPayload = {
|
||||
@@ -191,6 +192,47 @@ type OfferPayload = {
|
||||
relay: { endpoint: string };
|
||||
};
|
||||
|
||||
async function createFakeGhBin(): Promise<string> {
|
||||
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-gh-bin-"));
|
||||
const ghPath = path.join(binDir, "gh");
|
||||
await writeFile(
|
||||
ghPath,
|
||||
`#!/usr/bin/env node
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args[0] === "auth" && args[1] === "status") {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (args[0] === "pr" && args[1] === "list") {
|
||||
console.log(JSON.stringify([
|
||||
{
|
||||
number: 515,
|
||||
title: "Review selected start ref",
|
||||
url: "https://github.com/getpaseo/paseo/pull/515",
|
||||
state: "OPEN",
|
||||
body: "Fixture pull request for app e2e.",
|
||||
labels: [],
|
||||
baseRefName: "main",
|
||||
headRefName: "feature/start-from-pr"
|
||||
}
|
||||
]));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (args[0] === "issue" && args[1] === "list") {
|
||||
console.log("[]");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.error("Unsupported fake gh invocation: " + args.join(" "));
|
||||
process.exit(1);
|
||||
`,
|
||||
);
|
||||
await chmod(ghPath, 0o755);
|
||||
return binDir;
|
||||
}
|
||||
|
||||
function stripAnsi(input: string): string {
|
||||
return input.replace(/\u001b\[[0-9;]*m/g, "");
|
||||
}
|
||||
@@ -281,6 +323,7 @@ export default async function globalSetup() {
|
||||
let relayPort = 0;
|
||||
const metroPort = await getAvailablePort();
|
||||
paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-e2e-home-"));
|
||||
fakeGhBinDir = await createFakeGhBin();
|
||||
let relayLineBuffer = createLineBuffer();
|
||||
const metroLineBuffer = createLineBuffer();
|
||||
const daemonLineBuffer = createLineBuffer();
|
||||
@@ -298,6 +341,10 @@ export default async function globalSetup() {
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
paseoHome = null;
|
||||
}
|
||||
if (fakeGhBinDir) {
|
||||
await rm(fakeGhBinDir, { recursive: true, force: true });
|
||||
fakeGhBinDir = null;
|
||||
}
|
||||
};
|
||||
|
||||
const openAiUsable = await isOpenAiApiKeyUsable(process.env.OPENAI_API_KEY);
|
||||
@@ -482,6 +529,7 @@ export default async function globalSetup() {
|
||||
cwd: serverDir,
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${fakeGhBinDir}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||
PASEO_HOME: paseoHome,
|
||||
PASEO_SERVER_ID: "srv_e2e_test_daemon",
|
||||
PASEO_LISTEN: `0.0.0.0:${port}`,
|
||||
|
||||
@@ -210,13 +210,15 @@ export async function openWorkspaceWithAgents(
|
||||
}
|
||||
|
||||
export async function expectWorkspaceTabVisible(page: Page, agentId: string): Promise<void> {
|
||||
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function expectWorkspaceTabHidden(page: Page, agentId: string): Promise<void> {
|
||||
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`)).toHaveCount(0, {
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }),
|
||||
).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
41
packages/app/e2e/helpers/file-explorer.ts
Normal file
41
packages/app/e2e/helpers/file-explorer.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
function fileExplorerTree(page: Page) {
|
||||
return page.getByTestId("file-explorer-tree-scroll");
|
||||
}
|
||||
|
||||
function fileExplorerEntry(page: Page, name: string) {
|
||||
return fileExplorerTree(page).getByText(name, { exact: true }).first();
|
||||
}
|
||||
|
||||
export async function openFileExplorer(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Open explorer" }).first().click();
|
||||
await page.getByTestId("explorer-tab-files").click();
|
||||
await expect(fileExplorerTree(page)).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function expandFolder(page: Page, folderName: string): Promise<void> {
|
||||
await fileExplorerEntry(page, folderName).click();
|
||||
}
|
||||
|
||||
export async function collapseFolder(page: Page, folderName: string): Promise<void> {
|
||||
await fileExplorerEntry(page, folderName).click();
|
||||
}
|
||||
|
||||
export async function openFileFromExplorer(page: Page, fileName: string): Promise<void> {
|
||||
await fileExplorerEntry(page, fileName).click();
|
||||
}
|
||||
|
||||
export async function expectExplorerEntryVisible(page: Page, name: string): Promise<void> {
|
||||
await expect(fileExplorerEntry(page, name)).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function expectExplorerEntryHidden(page: Page, name: string): Promise<void> {
|
||||
await expect(fileExplorerEntry(page, name)).toBeHidden({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function expectFileTabOpen(page: Page, filePath: string): Promise<void> {
|
||||
await expect(page.getByTestId(`workspace-tab-file_${filePath}`).first()).toBeVisible({
|
||||
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. */
|
||||
export async function waitForTabBar(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("workspace-tabs-row").first()).toBeVisible({
|
||||
await expect(
|
||||
page.getByTestId("workspace-tabs-row").filter({ visible: true }).first(),
|
||||
).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Return all tab test IDs currently in the tab bar. */
|
||||
export async function getTabTestIds(page: Page): Promise<string[]> {
|
||||
const tabs = page.locator(
|
||||
'[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])',
|
||||
);
|
||||
const tabs = page
|
||||
.locator('[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])')
|
||||
.filter({ visible: true });
|
||||
const count = await tabs.count();
|
||||
const ids: string[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
@@ -55,6 +57,7 @@ export async function getActiveTabTestId(page: Page): Promise<string | null> {
|
||||
.locator(
|
||||
'[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])[aria-selected="true"]',
|
||||
)
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
if (await activeTab.isVisible().catch(() => false)) {
|
||||
return activeTab.getAttribute("data-testid");
|
||||
@@ -67,14 +70,14 @@ export async function getActiveTabTestId(page: Page): Promise<string | null> {
|
||||
|
||||
/** Click the new agent tab button in the tab bar. Creates a draft/chat tab directly. */
|
||||
export async function clickNewTabButton(page: Page): Promise<void> {
|
||||
const button = page.getByTestId("workspace-new-agent-tab");
|
||||
const button = page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first();
|
||||
await expect(button).toBeVisible({ timeout: 10_000 });
|
||||
await button.click();
|
||||
}
|
||||
|
||||
/** Click the new terminal button in the workspace tab bar. Creates a terminal tab directly. */
|
||||
export async function clickNewTerminalButton(page: Page): Promise<void> {
|
||||
const button = page.getByTestId("workspace-new-terminal");
|
||||
const button = page.getByTestId("workspace-new-terminal").filter({ visible: true }).first();
|
||||
await expect(button).toBeVisible({ timeout: 10_000 });
|
||||
await button.click();
|
||||
}
|
||||
@@ -94,26 +97,30 @@ export async function waitForLauncherPanel(page: Page): Promise<void> {
|
||||
|
||||
/** Assert the new agent tab button is visible in the tab bar. */
|
||||
export async function assertNewChatTileVisible(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("workspace-new-agent-tab").first()).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first(),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
/** Assert the new terminal button is visible in the tab bar. */
|
||||
export async function assertTerminalTileVisible(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("workspace-new-terminal").first()).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("workspace-new-terminal").filter({ visible: true }).first(),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
// ─── Tab creation actions ─────────────────────────────────────────────────
|
||||
|
||||
/** Click the new agent tab button to create a draft/chat tab. */
|
||||
export async function clickNewChat(page: Page): Promise<void> {
|
||||
const button = page.getByTestId("workspace-new-agent-tab");
|
||||
const button = page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first();
|
||||
await expect(button).toBeVisible({ timeout: 10_000 });
|
||||
await button.click();
|
||||
}
|
||||
|
||||
/** Click the new terminal button to create a terminal tab. */
|
||||
export async function clickTerminal(page: Page): Promise<void> {
|
||||
const button = page.getByTestId("workspace-new-terminal");
|
||||
const button = page.getByTestId("workspace-new-terminal").filter({ visible: true }).first();
|
||||
await expect(button).toBeVisible({ timeout: 10_000 });
|
||||
await button.click();
|
||||
}
|
||||
@@ -131,13 +138,14 @@ export async function waitForTabWithTitle(
|
||||
page
|
||||
.locator('[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])')
|
||||
.filter({ hasText: matcher })
|
||||
.filter({ visible: true })
|
||||
.first(),
|
||||
).toBeVisible({ timeout });
|
||||
}
|
||||
|
||||
/** Assert the new agent tab button is visible in the tab bar. */
|
||||
export async function assertSingleNewTabButton(page: Page): Promise<void> {
|
||||
const buttons = page.getByTestId("workspace-new-agent-tab");
|
||||
const buttons = page.getByTestId("workspace-new-agent-tab").filter({ visible: true });
|
||||
const count = await buttons.count();
|
||||
expect(count).toBeGreaterThanOrEqual(1);
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@ export async function createWorktreeViaDaemon(
|
||||
};
|
||||
}
|
||||
|
||||
export async function clickNewWorkspaceButton(
|
||||
export async function openNewWorkspaceComposer(
|
||||
page: Page,
|
||||
input: { projectKey: string; projectDisplayName: string },
|
||||
): Promise<void> {
|
||||
@@ -168,7 +168,13 @@ export async function clickNewWorkspaceButton(
|
||||
await expect(page).toHaveURL(/\/h\/[^/]+\/new(?:\?.*)?$/, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function clickNewWorkspaceButton(
|
||||
page: Page,
|
||||
input: { projectKey: string; projectDisplayName: string },
|
||||
): Promise<void> {
|
||||
await openNewWorkspaceComposer(page, input);
|
||||
const createButton = page
|
||||
.getByTestId("message-input-root")
|
||||
.getByRole("button", { name: "Create" });
|
||||
@@ -176,6 +182,44 @@ export async function clickNewWorkspaceButton(
|
||||
await createButton.click();
|
||||
}
|
||||
|
||||
export async function openStartingRefPicker(page: Page): Promise<void> {
|
||||
const trigger = page.getByTestId("new-workspace-ref-picker-trigger");
|
||||
await expect(trigger).toBeVisible({ timeout: 30_000 });
|
||||
await trigger.click();
|
||||
}
|
||||
|
||||
export async function selectBranchInPicker(page: Page, name: string): Promise<void> {
|
||||
const branchRow = page.getByTestId(`new-workspace-ref-picker-branch-${name}`);
|
||||
await expect(branchRow).toBeVisible({ timeout: 30_000 });
|
||||
await branchRow.click();
|
||||
}
|
||||
|
||||
export async function selectGitHubPrInPicker(page: Page, number: number): Promise<void> {
|
||||
const prRow = page.getByTestId(`new-workspace-ref-picker-pr-${number}`);
|
||||
await expect(prRow).toBeVisible({ timeout: 30_000 });
|
||||
await prRow.click();
|
||||
}
|
||||
|
||||
export async function expectStartingRefPickerTriggerPr(
|
||||
page: Page,
|
||||
input: { number: number; title: string; headRef: string },
|
||||
): Promise<void> {
|
||||
const trigger = page.getByTestId("new-workspace-ref-picker-trigger");
|
||||
await expect(trigger).toContainText(`#${input.number}`);
|
||||
await expect(trigger).toContainText(input.title);
|
||||
await expect(trigger).not.toContainText(input.headRef);
|
||||
}
|
||||
|
||||
export async function expectComposerGithubAttachmentPill(
|
||||
page: Page,
|
||||
input: { number: number; title: string },
|
||||
): Promise<void> {
|
||||
const pills = page.getByTestId("composer-github-attachment-pill");
|
||||
await expect(pills).toHaveCount(1);
|
||||
await expect(pills.first()).toContainText(`#${input.number}`);
|
||||
await expect(pills.first()).toContainText(input.title);
|
||||
}
|
||||
|
||||
export async function assertNewWorkspaceSidebarAndHeader(
|
||||
page: Page,
|
||||
input: { serverId: string; previousWorkspaceId: string; projectDisplayName: string },
|
||||
|
||||
@@ -13,18 +13,30 @@ export async function getWorkspaceTabTestIds(page: Page): Promise<string[]> {
|
||||
return ids;
|
||||
}
|
||||
|
||||
function visibleTestId(page: Page, testId: string) {
|
||||
return page.getByTestId(testId).filter({ visible: true });
|
||||
}
|
||||
|
||||
export async function waitForWorkspaceTabsVisible(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("workspace-tabs-row").first()).toBeVisible({
|
||||
await expect(visibleTestId(page, "workspace-tabs-row").first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByTestId("workspace-new-agent-tab").first()).toBeVisible({
|
||||
await expect(visibleTestId(page, "workspace-new-agent-tab").first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getVisibleWorkspaceAgentTabIds(page: Page): Promise<string[]> {
|
||||
const allTabIds = await getWorkspaceTabTestIds(page);
|
||||
return allTabIds.filter((id) => id.startsWith("workspace-tab-agent_"));
|
||||
const tabs = page.locator('[data-testid^="workspace-tab-agent_"]').filter({ visible: true });
|
||||
const count = await tabs.count();
|
||||
const ids: string[] = [];
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const testId = await tabs.nth(index).getAttribute("data-testid");
|
||||
if (testId && !ids.includes(testId)) {
|
||||
ids.push(testId);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export async function expectOnlyWorkspaceAgentTabsVisible(
|
||||
@@ -38,7 +50,7 @@ export async function expectOnlyWorkspaceAgentTabsVisible(
|
||||
expect(unexpected).toEqual([]);
|
||||
expect(visible.length).toBe(expected.size);
|
||||
for (const expectedId of expectedAgentIds) {
|
||||
await expect(page.getByTestId(`workspace-tab-agent_${expectedId}`)).toBeVisible({
|
||||
await expect(visibleTestId(page, `workspace-tab-agent_${expectedId}`).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -103,8 +103,8 @@ export async function expectWorkspaceHeader(
|
||||
page: Page,
|
||||
input: { title: string; subtitle: string },
|
||||
): Promise<void> {
|
||||
const titleLocator = page.getByTestId("workspace-header-title");
|
||||
const subtitleLocator = page.getByTestId("workspace-header-subtitle");
|
||||
const titleLocator = page.getByTestId("workspace-header-title").filter({ visible: true });
|
||||
const subtitleLocator = page.getByTestId("workspace-header-subtitle").filter({ visible: true });
|
||||
|
||||
await expect(titleLocator.first()).toHaveText(input.title, {
|
||||
timeout: 30_000,
|
||||
|
||||
@@ -5,6 +5,7 @@ import path from "node:path";
|
||||
|
||||
type TempRepo = {
|
||||
path: string;
|
||||
branchHeads: Record<string, string>;
|
||||
cleanup: () => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -14,6 +15,7 @@ export const createTempGitRepo = async (
|
||||
withRemote?: boolean;
|
||||
paseoConfig?: Record<string, unknown>;
|
||||
files?: Array<{ path: string; content: string }>;
|
||||
branches?: string[];
|
||||
},
|
||||
): Promise<TempRepo> => {
|
||||
// Keep E2E repo paths short so terminal prompt + typed commands stay visible without zsh clipping.
|
||||
@@ -47,19 +49,72 @@ export const createTempGitRepo = async (
|
||||
}
|
||||
execSync('git commit -m "Initial commit"', { cwd: repoPath, stdio: "ignore" });
|
||||
|
||||
const branchHeads: Record<string, string> = {};
|
||||
const branches = Array.from(new Set(options?.branches ?? []));
|
||||
for (const branch of branches) {
|
||||
if (branch !== "main") {
|
||||
execSync(`git checkout -b ${JSON.stringify(branch)} main`, {
|
||||
cwd: repoPath,
|
||||
stdio: "ignore",
|
||||
});
|
||||
}
|
||||
const markerPath = `.paseo-e2e-${branch.replace(/[^a-zA-Z0-9._-]/g, "-")}.txt`;
|
||||
await writeFile(path.join(repoPath, markerPath), `branch ${branch}\n`);
|
||||
execSync(`git add ${JSON.stringify(markerPath)}`, { cwd: repoPath, stdio: "ignore" });
|
||||
execSync(`git commit -m ${JSON.stringify(`Add ${branch} marker`)}`, {
|
||||
cwd: repoPath,
|
||||
stdio: "ignore",
|
||||
});
|
||||
branchHeads[branch] = execSync(`git rev-parse ${JSON.stringify(branch)}`, {
|
||||
cwd: repoPath,
|
||||
stdio: "pipe",
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
execSync("git checkout main", { cwd: repoPath, stdio: "ignore" });
|
||||
}
|
||||
|
||||
if (withRemote) {
|
||||
// Deterministic local remote to avoid relying on external auth/network in e2e.
|
||||
const remoteDir = path.join(repoPath, "remote.git");
|
||||
await mkdir(remoteDir, { recursive: true });
|
||||
execSync(`git init --bare -b main ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
|
||||
execSync(`git remote add origin ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
|
||||
execSync("git push -u origin main", { cwd: repoPath, stdio: "ignore" });
|
||||
execSync("git push -u origin --all", { cwd: repoPath, stdio: "ignore" });
|
||||
}
|
||||
|
||||
return {
|
||||
path: repoPath,
|
||||
branchHeads,
|
||||
cleanup: async () => {
|
||||
await rm(repoPath, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export async function readWorktreeBranchInfo({ worktreePath }: { worktreePath: string }): Promise<{
|
||||
currentBranch: string;
|
||||
hasAncestor: (ref: string) => boolean;
|
||||
}> {
|
||||
const currentBranch = execSync("git branch --show-current", {
|
||||
cwd: worktreePath,
|
||||
stdio: "pipe",
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
|
||||
return {
|
||||
currentBranch,
|
||||
hasAncestor: (ref: string) => {
|
||||
try {
|
||||
execSync(`git merge-base --is-ancestor ${JSON.stringify(ref)} HEAD`, {
|
||||
cwd: worktreePath,
|
||||
stdio: "ignore",
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { expect, test } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
@@ -8,9 +10,15 @@ import {
|
||||
clickNewWorkspaceButton,
|
||||
connectNewWorkspaceDaemonClient,
|
||||
createWorktreeViaDaemon,
|
||||
expectComposerGithubAttachmentPill,
|
||||
expectStartingRefPickerTriggerPr,
|
||||
openNewWorkspaceComposer,
|
||||
openStartingRefPicker,
|
||||
openProjectViaDaemon,
|
||||
selectBranchInPicker,
|
||||
selectGitHubPrInPicker,
|
||||
} from "./helpers/new-workspace";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { createTempGitRepo, readWorktreeBranchInfo } from "./helpers/workspace";
|
||||
import {
|
||||
expectSidebarWorkspaceSelected,
|
||||
expectWorkspaceHeader,
|
||||
@@ -240,7 +248,12 @@ test.describe("New workspace flow", () => {
|
||||
subtitle: openedProject.projectDisplayName,
|
||||
});
|
||||
|
||||
const agentTabs = page.locator('[data-testid^="workspace-tab-agent_"]');
|
||||
const activeWorkspaceDeckEntry = page
|
||||
.getByTestId(`workspace-deck-entry-${serverId}:${createdWorkspace.workspaceId}`)
|
||||
.filter({ visible: true });
|
||||
await expect(activeWorkspaceDeckEntry).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const agentTabs = activeWorkspaceDeckEntry.locator('[data-testid^="workspace-tab-agent_"]');
|
||||
await expect(agentTabs).toHaveCount(1, { timeout: 30_000 });
|
||||
|
||||
// Workspace setup may auto-open a setup tab that steals focus,
|
||||
@@ -254,4 +267,94 @@ test.describe("New workspace flow", () => {
|
||||
await tempRepo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("selected branch becomes the base of a new workspace worktree", async ({ page }) => {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
|
||||
const tempRepo = await createTempGitRepo("new-workspace-ref-", {
|
||||
branches: ["main", "dev"],
|
||||
});
|
||||
|
||||
try {
|
||||
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
|
||||
localWorkspaceIds.add(openedProject.workspaceId);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: openedProject.workspaceId,
|
||||
});
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: openedProject.workspaceName,
|
||||
subtitle: openedProject.projectDisplayName,
|
||||
});
|
||||
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await openStartingRefPicker(page);
|
||||
await selectBranchInPicker(page, "dev");
|
||||
|
||||
const createButton = page
|
||||
.getByTestId("message-input-root")
|
||||
.getByRole("button", { name: "Create" });
|
||||
await expect(createButton).toBeVisible({ timeout: 30_000 });
|
||||
await createButton.click();
|
||||
|
||||
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||
serverId,
|
||||
previousWorkspaceId: openedProject.workspaceId,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
createdWorktreeIds.add(createdWorkspace.workspaceId);
|
||||
|
||||
expect(existsSync(createdWorkspace.workspaceId)).toBe(true);
|
||||
|
||||
const branchInfo = await readWorktreeBranchInfo({
|
||||
worktreePath: createdWorkspace.workspaceId,
|
||||
});
|
||||
expect(branchInfo.currentBranch).toBe(path.basename(createdWorkspace.workspaceId));
|
||||
expect(branchInfo.hasAncestor(tempRepo.branchHeads.main)).toBe(true);
|
||||
expect(branchInfo.hasAncestor(tempRepo.branchHeads.dev)).toBe(true);
|
||||
} finally {
|
||||
await tempRepo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("selected GitHub PR shows PR context in the trigger and composer", async ({ page }) => {
|
||||
const tempRepo = await createTempGitRepo("new-workspace-pr-ref-");
|
||||
|
||||
try {
|
||||
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
|
||||
localWorkspaceIds.add(openedProject.workspaceId);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await openStartingRefPicker(page);
|
||||
await selectGitHubPrInPicker(page, 515);
|
||||
|
||||
await expectStartingRefPickerTriggerPr(page, {
|
||||
number: 515,
|
||||
title: "Review selected start ref",
|
||||
headRef: "feature/start-from-pr",
|
||||
});
|
||||
await expectComposerGithubAttachmentPill(page, {
|
||||
number: 515,
|
||||
title: "Review selected start ref",
|
||||
});
|
||||
} finally {
|
||||
await tempRepo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,12 @@ async function openHostPage(page: Page, serverId: string) {
|
||||
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectHostLabelHeader(page: Page) {
|
||||
await expect(page.getByTestId("settings-detail-header-title")).toHaveText(TEST_HOST_LABEL);
|
||||
await expect(page.getByTestId("host-page-label-edit-button")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-label-input")).toHaveCount(0);
|
||||
}
|
||||
|
||||
test.describe("Settings host page", () => {
|
||||
test("host page shows seeded label, connection endpoint, inject MCP toggle, and all action rows", async ({
|
||||
page,
|
||||
@@ -34,16 +40,8 @@ test.describe("Settings host page", () => {
|
||||
await openSettings(page);
|
||||
await openHostPage(page, serverId);
|
||||
|
||||
// Label renders as a title with a pencil edit affordance; the input is hidden until edit.
|
||||
await expect(page.getByTestId("host-page-label-card")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-label-edit-button")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-label-input")).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByTestId("host-page-label-card").getByText(TEST_HOST_LABEL, { exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
// Desktop detail pane shows a ScreenHeader with the host label.
|
||||
await expect(page.getByTestId("settings-detail-header-title")).toHaveText(TEST_HOST_LABEL);
|
||||
// Label renders in the detail header with a pencil edit affordance; the input is hidden until edit.
|
||||
await expectHostLabelHeader(page);
|
||||
|
||||
// Connections is its own section with a "Connections" heading and the seeded endpoint row.
|
||||
const connectionsCard = page.getByTestId("host-page-connections-card");
|
||||
@@ -124,7 +122,7 @@ test.describe("Settings host page", () => {
|
||||
await page.goto(`/settings/hosts/${encodeURIComponent(serverId)}`);
|
||||
|
||||
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-label-card")).toBeVisible();
|
||||
await expectHostLabelHeader(page);
|
||||
await expect(page.getByTestId("host-page-remove-host-card")).toBeVisible();
|
||||
});
|
||||
|
||||
|
||||
@@ -93,6 +93,15 @@ test.describe("Settings — compact master-detail", () => {
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectCompactSettingsRootList(page: Page) {
|
||||
await expect(page).toHaveURL(/\/settings$/);
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||
|
||||
await expect(page.getByText("Theme", { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Play test" })).toHaveCount(0);
|
||||
await expect(page.locator('[data-testid^="settings-host-page-"]')).toHaveCount(0);
|
||||
}
|
||||
|
||||
test("/settings renders only the sidebar list (no section content)", async ({ page }) => {
|
||||
await openCompactSettingsRoot(page);
|
||||
|
||||
@@ -110,12 +119,12 @@ test.describe("Settings — compact master-detail", () => {
|
||||
).toBeVisible();
|
||||
|
||||
// Section detail content is NOT rendered at the root.
|
||||
await expect(page.getByText("Theme", { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Play test" })).toHaveCount(0);
|
||||
await expect(page.getByTestId("host-page-label-card")).toHaveCount(0);
|
||||
await expectCompactSettingsRootList(page);
|
||||
|
||||
// Root shows the menu header, not a back button.
|
||||
await expect(page.getByRole("button", { name: "Back", exact: true })).toHaveCount(0);
|
||||
const rootBackButton = page.getByRole("button", { name: "Back", exact: true });
|
||||
await expect(rootBackButton).toBeVisible();
|
||||
await rootBackButton.click();
|
||||
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
|
||||
});
|
||||
|
||||
test("tapping a section pushes /settings/[section] and shows a back button", async ({ page }) => {
|
||||
@@ -144,9 +153,8 @@ test.describe("Settings — compact master-detail", () => {
|
||||
await expect(page).toHaveURL(/\/settings\/about$/);
|
||||
|
||||
await page.getByRole("button", { name: "Back", exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/settings$/);
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Back", exact: true })).toHaveCount(0);
|
||||
await expectCompactSettingsRootList(page);
|
||||
await expect(page.getByRole("button", { name: "Back", exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("tapping a host entry pushes /settings/hosts/[serverId]", async ({ page }) => {
|
||||
|
||||
@@ -96,6 +96,14 @@ test.describe("Workspace navigation regression", () => {
|
||||
`workspace-tab-agent_${firstAgent.id}`,
|
||||
]);
|
||||
|
||||
const firstDeckEntry = page.getByTestId(
|
||||
`workspace-deck-entry-${serverId}:${firstWorkspace.workspaceId}`,
|
||||
);
|
||||
const secondDeckEntry = page.getByTestId(
|
||||
`workspace-deck-entry-${serverId}:${secondWorkspace.workspaceId}`,
|
||||
);
|
||||
await expect(firstDeckEntry).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
@@ -126,27 +134,45 @@ test.describe("Workspace navigation regression", () => {
|
||||
await expect(getVisibleWorkspaceAgentTabIds(page)).resolves.toEqual([
|
||||
`workspace-tab-agent_${secondAgent.id}`,
|
||||
]);
|
||||
await expect(firstDeckEntry).toBeAttached();
|
||||
await expect(firstDeckEntry).toBeHidden();
|
||||
await expect(secondDeckEntry).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(2);
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: firstWorkspace.workspaceId,
|
||||
});
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(firstDeckEntry).toBeVisible({ timeout: 30_000 });
|
||||
await expect(secondDeckEntry).toBeAttached();
|
||||
await expect(secondDeckEntry).toBeHidden();
|
||||
await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(2);
|
||||
|
||||
await page.reload();
|
||||
await waitForSidebarHydration(page);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), {
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectSidebarWorkspaceSelected({
|
||||
page,
|
||||
serverId,
|
||||
workspaceId: secondWorkspace.workspaceId,
|
||||
workspaceId: firstWorkspace.workspaceId,
|
||||
});
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: secondWorkspace.workspaceName,
|
||||
subtitle: secondWorkspace.projectDisplayName,
|
||||
title: firstWorkspace.workspaceName,
|
||||
subtitle: firstWorkspace.projectDisplayName,
|
||||
});
|
||||
await expectWorkspaceTabVisible(page, secondAgent.id);
|
||||
await expectWorkspaceTabHidden(page, firstAgent.id);
|
||||
await expectOnlyWorkspaceAgentTabsVisible(page, [secondAgent.id]);
|
||||
await expectWorkspaceTabVisible(page, firstAgent.id);
|
||||
await expectWorkspaceTabHidden(page, secondAgent.id);
|
||||
await expectOnlyWorkspaceAgentTabsVisible(page, [firstAgent.id]);
|
||||
await expect(getVisibleWorkspaceAgentTabIds(page)).resolves.toEqual([
|
||||
`workspace-tab-agent_${secondAgent.id}`,
|
||||
`workspace-tab-agent_${firstAgent.id}`,
|
||||
]);
|
||||
} finally {
|
||||
for (const agentId of agentIds) {
|
||||
|
||||
@@ -94,7 +94,7 @@ test.describe("Workspace setup streaming", () => {
|
||||
await navigateToWorkspaceViaSidebar(page, workspace.id);
|
||||
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await page.getByTestId("workspace-new-agent-tab").first().click();
|
||||
await page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first().click();
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"main": "index.ts",
|
||||
"version": "0.1.59",
|
||||
"version": "0.1.60-beta.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"start": "APP_VARIANT=development expo start",
|
||||
"reset-project": "node ./scripts/reset-project.js",
|
||||
"build:workspace-deps": "npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/expo-two-way-audio",
|
||||
"eas-build-post-install": "npm run build:workspace-deps",
|
||||
@@ -31,9 +31,9 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.59",
|
||||
"@getpaseo/highlight": "0.1.59",
|
||||
"@getpaseo/server": "0.1.59",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.60-beta.1",
|
||||
"@getpaseo/highlight": "0.1.60-beta.1",
|
||||
"@getpaseo/server": "0.1.60-beta.1",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
@@ -79,6 +79,7 @@
|
||||
"expo-system-ui": "~6.0.7",
|
||||
"expo-updates": "~29.0.12",
|
||||
"expo-web-browser": "~15.0.8",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"lucide-react-native": "^0.546.0",
|
||||
"mnemonic-id": "^3.2.7",
|
||||
"react": "19.1.0",
|
||||
@@ -102,11 +103,13 @@
|
||||
"react-native-web": "~0.21.0",
|
||||
"react-native-webview": "^13.16.0",
|
||||
"react-native-worklets": "0.5.1",
|
||||
"use-sync-external-store": "^1.6.0",
|
||||
"zod": "^3.23.8",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "~19.2.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"eas-cli": "^16.24.1",
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||
import { KeyboardProvider } from "react-native-keyboard-controller";
|
||||
import { GestureHandlerRootView, Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||
import { PortalProvider } from "@gorhom/portal";
|
||||
import { VoiceProvider } from "@/contexts/voice-context";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
@@ -63,8 +62,13 @@ import { CommandCenter } from "@/components/command-center";
|
||||
import { ProjectPickerModal } from "@/components/project-picker-modal";
|
||||
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
||||
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
|
||||
import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber";
|
||||
import { resolveActiveHost } from "@/utils/active-host";
|
||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action";
|
||||
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import { queryClient } from "@/query/query-client";
|
||||
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
|
||||
import {
|
||||
WEB_NOTIFICATION_CLICK_EVENT,
|
||||
type WebNotificationClickDetail,
|
||||
@@ -82,7 +86,10 @@ import {
|
||||
parseWorkspaceOpenIntent,
|
||||
decodeWorkspaceIdFromPathSegment,
|
||||
} from "@/utils/host-routes";
|
||||
import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||
import {
|
||||
addBrowserActiveWorkspaceLocationListener,
|
||||
syncNavigationActiveWorkspace,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
import { isWeb, isNative } from "@/constants/platform";
|
||||
|
||||
polyfillCrypto();
|
||||
@@ -387,9 +394,11 @@ function AppContainer({
|
||||
const { theme } = useUnistyles();
|
||||
const daemons = useHosts();
|
||||
const { settings, updateSettings } = useAppSettings();
|
||||
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
|
||||
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
|
||||
const toggleBothSidebars = usePanelStore((state) => state.toggleBothSidebars);
|
||||
const toggleMobileAgentList = usePanelStore((state) => state.toggleMobileAgentList);
|
||||
const toggleDesktopAgentList = usePanelStore((state) => state.toggleDesktopAgentList);
|
||||
const openDesktopAgentList = usePanelStore((state) => state.openDesktopAgentList);
|
||||
const closeDesktopAgentList = usePanelStore((state) => state.closeDesktopAgentList);
|
||||
const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer);
|
||||
const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode);
|
||||
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
|
||||
const agentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||
@@ -403,6 +412,32 @@ function AppContainer({
|
||||
|
||||
const isCompactLayout = useIsCompactFormFactor();
|
||||
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
|
||||
const pathname = usePathname();
|
||||
const activeServerId = useMemo(
|
||||
() => resolveActiveHost({ hosts: daemons, pathname })?.serverId ?? null,
|
||||
[daemons, pathname],
|
||||
);
|
||||
const toggleAgentList = isCompactLayout ? toggleMobileAgentList : toggleDesktopAgentList;
|
||||
const toggleDesktopSidebars = useCallback(() => {
|
||||
const { desktop } = usePanelStore.getState();
|
||||
toggleDesktopSidebarsWithCheckoutIntent({
|
||||
isAgentListOpen: desktop.agentListOpen,
|
||||
isFileExplorerOpen: desktop.fileExplorerOpen,
|
||||
openAgentList: openDesktopAgentList,
|
||||
closeAgentList: closeDesktopAgentList,
|
||||
closeFileExplorer: closeDesktopFileExplorer,
|
||||
toggleFocusedFileExplorer: () =>
|
||||
keyboardActionDispatcher.dispatch({
|
||||
id: "sidebar.toggle.right",
|
||||
scope: "sidebar",
|
||||
}),
|
||||
});
|
||||
}, [closeDesktopAgentList, closeDesktopFileExplorer, openDesktopAgentList]);
|
||||
// TODO: stop matching pathname here as a branch. `chromeEnabled` should not
|
||||
// conflate workspace/project-specific chrome (sidebar, mobile gesture) with
|
||||
// global concerns like keyboard shortcuts. Split those out so settings (and
|
||||
// other non-workspace routes) don't need a special-case to keep shortcuts alive.
|
||||
const keyboardShortcutsEnabled = chromeEnabled || pathname.startsWith("/settings");
|
||||
|
||||
useEffect(() => {
|
||||
const bp = UnistylesRuntime.breakpoint;
|
||||
@@ -434,15 +469,16 @@ function AppContainer({
|
||||
}, [isCompactLayout, chromeEnabled, isFocusModeEnabled, agentListOpen, sidebarWidth]);
|
||||
|
||||
useKeyboardShortcuts({
|
||||
enabled: chromeEnabled,
|
||||
enabled: keyboardShortcutsEnabled,
|
||||
isMobile: isCompactLayout,
|
||||
toggleAgentList,
|
||||
toggleFileExplorer,
|
||||
toggleBothSidebars,
|
||||
toggleBothSidebars: toggleDesktopSidebars,
|
||||
toggleFocusMode,
|
||||
cycleTheme,
|
||||
});
|
||||
|
||||
useActiveWorktreeNewAction();
|
||||
|
||||
const containerStyle = useMemo(
|
||||
() => ({ flex: 1 as const, backgroundColor: theme.colors.surface0 }),
|
||||
[theme.colors.surface0],
|
||||
@@ -461,6 +497,10 @@ function AppContainer({
|
||||
<UpdateBanner />
|
||||
<CommandCenter />
|
||||
<ProjectPickerModal />
|
||||
<WorkspaceShortcutTargetsSubscriber
|
||||
enabled={keyboardShortcutsEnabled}
|
||||
serverId={activeServerId}
|
||||
/>
|
||||
<WorkspaceSetupDialog />
|
||||
<KeyboardShortcutsDialog />
|
||||
</View>
|
||||
@@ -481,7 +521,7 @@ function MobileGestureWrapper({
|
||||
chromeEnabled: boolean;
|
||||
}) {
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const openAgentList = usePanelStore((state) => state.openAgentList);
|
||||
const showMobileAgentList = usePanelStore((state) => state.showMobileAgentList);
|
||||
const horizontalScroll = useHorizontalScrollOptional();
|
||||
const {
|
||||
translateX,
|
||||
@@ -498,8 +538,8 @@ function MobileGestureWrapper({
|
||||
|
||||
const handleGestureOpen = useCallback(() => {
|
||||
gestureAnimatingRef.current = true;
|
||||
openAgentList();
|
||||
}, [openAgentList, gestureAnimatingRef]);
|
||||
showMobileAgentList();
|
||||
}, [showMobileAgentList, gestureAnimatingRef]);
|
||||
|
||||
const openGesture = useMemo(
|
||||
() =>
|
||||
@@ -806,14 +846,14 @@ function RootStack() {
|
||||
<Stack.Screen name="settings/[section]" />
|
||||
<Stack.Screen name="pair-scan" />
|
||||
</Stack.Protected>
|
||||
<Stack.Screen
|
||||
name="h/[serverId]/workspace/[workspaceId]"
|
||||
getId={({ params }) => {
|
||||
const serverId = getRouteParamValue(params?.serverId);
|
||||
const workspaceId = getRouteParamValue(params?.workspaceId);
|
||||
return serverId && workspaceId ? `${serverId}:${workspaceId}` : undefined;
|
||||
}}
|
||||
/>
|
||||
{/*
|
||||
Do not add getId or dangerouslySingular back to the workspace route.
|
||||
Expo Router maps dangerouslySingular to React Navigation getId, and
|
||||
getId repeatedly breaks Android native-stack/Fabric by reordering an
|
||||
already-mounted workspace screen. Keep workspace identity/retention
|
||||
outside this route-level native-stack API.
|
||||
*/}
|
||||
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
|
||||
<Stack.Screen name="h/[serverId]/agent/[agentId]" options={{ gestureEnabled: false }} />
|
||||
<Stack.Screen name="h/[serverId]/index" />
|
||||
<Stack.Screen name="h/[serverId]/sessions" />
|
||||
@@ -830,6 +870,7 @@ function NavigationActiveWorkspaceObserver() {
|
||||
|
||||
useEffect(() => {
|
||||
syncNavigationActiveWorkspace(navigationRef);
|
||||
const unsubscribeBrowserLocation = addBrowserActiveWorkspaceLocationListener();
|
||||
const unsubscribeState = navigationRef.addListener("state", () => {
|
||||
syncNavigationActiveWorkspace(navigationRef);
|
||||
});
|
||||
@@ -837,6 +878,7 @@ function NavigationActiveWorkspaceObserver() {
|
||||
syncNavigationActiveWorkspace(navigationRef);
|
||||
});
|
||||
return () => {
|
||||
unsubscribeBrowserLocation();
|
||||
unsubscribeState();
|
||||
unsubscribeReady();
|
||||
};
|
||||
@@ -855,23 +897,21 @@ export default function RootLayout() {
|
||||
<SafeAreaProvider>
|
||||
<KeyboardProvider>
|
||||
<QueryProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<HostRuntimeBootstrapProvider>
|
||||
<PushNotificationRouter />
|
||||
<HostRuntimeBootstrapProvider>
|
||||
<PushNotificationRouter />
|
||||
<ToastProvider>
|
||||
<ProvidersWrapper>
|
||||
<SidebarAnimationProvider>
|
||||
<HorizontalScrollProvider>
|
||||
<ToastProvider>
|
||||
<OpenProjectListener />
|
||||
<AppWithSidebar>
|
||||
<RootStack />
|
||||
</AppWithSidebar>
|
||||
</ToastProvider>
|
||||
<OpenProjectListener />
|
||||
<AppWithSidebar>
|
||||
<RootStack />
|
||||
</AppWithSidebar>
|
||||
</HorizontalScrollProvider>
|
||||
</SidebarAnimationProvider>
|
||||
</ProvidersWrapper>
|
||||
</HostRuntimeBootstrapProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</ToastProvider>
|
||||
</HostRuntimeBootstrapProvider>
|
||||
</QueryProvider>
|
||||
</KeyboardProvider>
|
||||
</SafeAreaProvider>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef } from "react";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useResolveWorkspaceIdByCwd } from "@/stores/session-store-hooks";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||
@@ -32,21 +33,10 @@ function HostAgentReadyRouteContent() {
|
||||
}
|
||||
return state.sessions[serverId]?.agents?.get(agentId)?.cwd ?? null;
|
||||
});
|
||||
const sessionWorkspaces = useSessionStore((state) =>
|
||||
serverId ? state.sessions[serverId]?.workspaces : undefined,
|
||||
);
|
||||
const hasHydratedWorkspaces = useSessionStore((state) =>
|
||||
serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false,
|
||||
);
|
||||
const resolvedWorkspaceId = useSessionStore((state) => {
|
||||
if (!serverId || !agentId) {
|
||||
return null;
|
||||
}
|
||||
return resolveWorkspaceIdByExecutionDirectory({
|
||||
workspaces: state.sessions[serverId]?.workspaces?.values(),
|
||||
workspaceDirectory: state.sessions[serverId]?.agents?.get(agentId)?.cwd,
|
||||
});
|
||||
});
|
||||
const resolvedWorkspaceId = useResolveWorkspaceIdByCwd(serverId, agentCwd);
|
||||
|
||||
useEffect(() => {
|
||||
if (redirectedRef.current) {
|
||||
@@ -102,8 +92,9 @@ function HostAgentReadyRouteContent() {
|
||||
return;
|
||||
}
|
||||
const cwd = result?.agent?.cwd?.trim();
|
||||
const workspaces = useSessionStore.getState().sessions[serverId]?.workspaces;
|
||||
const workspaceId = resolveWorkspaceIdByExecutionDirectory({
|
||||
workspaces: sessionWorkspaces?.values(),
|
||||
workspaces: workspaces?.values(),
|
||||
workspaceDirectory: cwd,
|
||||
});
|
||||
if (!workspaceId && !hasHydratedWorkspaces) {
|
||||
@@ -133,7 +124,7 @@ function HostAgentReadyRouteContent() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [agentId, client, hasHydratedWorkspaces, isConnected, router, serverId, sessionWorkspaces]);
|
||||
}, [agentId, client, hasHydratedWorkspaces, isConnected, router, serverId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { StyleSheet, View } from "react-native";
|
||||
import {
|
||||
useGlobalSearchParams,
|
||||
useLocalSearchParams,
|
||||
@@ -7,6 +8,11 @@ import {
|
||||
useRootNavigationState,
|
||||
} from "expo-router";
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import {
|
||||
activateNavigationWorkspaceSelection,
|
||||
type ActiveWorkspaceSelection,
|
||||
useNavigationActiveWorkspaceSelection,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
||||
import {
|
||||
@@ -95,6 +101,23 @@ function HostWorkspaceLayoutContent() {
|
||||
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
|
||||
: "";
|
||||
const openValue = getParamValue(globalParams.open);
|
||||
const routeWorkspaceSelection = useMemo(
|
||||
() =>
|
||||
serverId && workspaceId
|
||||
? {
|
||||
serverId,
|
||||
workspaceId,
|
||||
}
|
||||
: null,
|
||||
[serverId, workspaceId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!routeWorkspaceSelection) {
|
||||
return;
|
||||
}
|
||||
activateNavigationWorkspaceSelection(routeWorkspaceSelection);
|
||||
}, [routeWorkspaceSelection]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!openValue) {
|
||||
@@ -144,11 +167,73 @@ function HostWorkspaceLayoutContent() {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <WorkspaceDeck fallbackSelection={routeWorkspaceSelection} />;
|
||||
}
|
||||
|
||||
function areWorkspaceSelectionsEqual(
|
||||
left: ActiveWorkspaceSelection | null,
|
||||
right: ActiveWorkspaceSelection | null,
|
||||
): boolean {
|
||||
return left?.serverId === right?.serverId && left?.workspaceId === right?.workspaceId;
|
||||
}
|
||||
|
||||
function WorkspaceDeck({
|
||||
fallbackSelection,
|
||||
}: {
|
||||
fallbackSelection: ActiveWorkspaceSelection | null;
|
||||
}) {
|
||||
const activeSelection = useNavigationActiveWorkspaceSelection() ?? fallbackSelection;
|
||||
const [mountedSelections, setMountedSelections] = useState<ActiveWorkspaceSelection[]>(() =>
|
||||
activeSelection ? [activeSelection] : [],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSelection) {
|
||||
return;
|
||||
}
|
||||
setMountedSelections((current) => {
|
||||
if (current.some((selection) => areWorkspaceSelectionsEqual(selection, activeSelection))) {
|
||||
return current;
|
||||
}
|
||||
return [...current, activeSelection];
|
||||
});
|
||||
}, [activeSelection]);
|
||||
|
||||
if (!activeSelection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkspaceScreen
|
||||
key={`${serverId}:${workspaceId}`}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
<View style={styles.deck}>
|
||||
{mountedSelections.map((selection) => {
|
||||
const isActive = areWorkspaceSelectionsEqual(selection, activeSelection);
|
||||
return (
|
||||
<View
|
||||
key={`${selection.serverId}:${selection.workspaceId}`}
|
||||
style={isActive ? styles.activeDeckEntry : styles.inactiveDeckEntry}
|
||||
testID={`workspace-deck-entry-${selection.serverId}:${selection.workspaceId}`}
|
||||
>
|
||||
<WorkspaceScreen
|
||||
serverId={selection.serverId}
|
||||
workspaceId={selection.workspaceId}
|
||||
isRouteFocused={isActive}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
deck: {
|
||||
flex: 1,
|
||||
},
|
||||
activeDeckEntry: {
|
||||
flex: 1,
|
||||
},
|
||||
inactiveDeckEntry: {
|
||||
display: "none",
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { forwardRef, useCallback, useEffect, useMemo } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
|
||||
@@ -7,7 +7,6 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root";
|
||||
import {
|
||||
BottomSheetModal,
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetScrollView,
|
||||
BottomSheetTextInput,
|
||||
@@ -16,6 +15,10 @@ import {
|
||||
import { X } from "lucide-react-native";
|
||||
import { FileDropZone } from "@/components/file-drop-zone";
|
||||
import type { ImageAttachment } from "@/components/message-input";
|
||||
import {
|
||||
IsolatedBottomSheetModal,
|
||||
useIsolatedBottomSheetVisibility,
|
||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
type EscHandler = () => void;
|
||||
@@ -109,9 +112,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
gap: theme.spacing[4],
|
||||
flexGrow: 1,
|
||||
},
|
||||
bottomSheetHandle: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
bottomSheetHeader: {
|
||||
paddingHorizontal: theme.spacing[6],
|
||||
paddingTop: theme.spacing[4],
|
||||
@@ -166,7 +166,6 @@ export interface AdaptiveModalSheetProps {
|
||||
children: ReactNode;
|
||||
headerActions?: ReactNode;
|
||||
snapPoints?: string[];
|
||||
stackBehavior?: "push" | "switch" | "replace";
|
||||
testID?: string;
|
||||
/** Override the max width of the desktop card. */
|
||||
desktopMaxWidth?: number;
|
||||
@@ -183,7 +182,6 @@ export function AdaptiveModalSheet({
|
||||
children,
|
||||
headerActions,
|
||||
snapPoints,
|
||||
stackBehavior,
|
||||
testID,
|
||||
desktopMaxWidth,
|
||||
onFilesDropped,
|
||||
@@ -191,33 +189,13 @@ export function AdaptiveModalSheet({
|
||||
}: AdaptiveModalSheetProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const sheetRef = useRef<BottomSheetModal>(null);
|
||||
const dismissingForVisibilityRef = useRef(false);
|
||||
const titleColor = theme.colors.foreground;
|
||||
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile) return;
|
||||
if (visible) {
|
||||
dismissingForVisibilityRef.current = false;
|
||||
sheetRef.current?.present();
|
||||
} else {
|
||||
dismissingForVisibilityRef.current = true;
|
||||
sheetRef.current?.dismiss();
|
||||
}
|
||||
}, [visible, isMobile]);
|
||||
|
||||
const handleSheetChange = useCallback(
|
||||
(index: number) => {
|
||||
if (index === -1) {
|
||||
if (dismissingForVisibilityRef.current) {
|
||||
dismissingForVisibilityRef.current = false;
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
const { sheetRef, handleSheetChange } = useIsolatedBottomSheetVisibility({
|
||||
visible,
|
||||
isEnabled: isMobile,
|
||||
onClose,
|
||||
});
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||
@@ -233,7 +211,7 @@ export function AdaptiveModalSheet({
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<BottomSheetModal
|
||||
<IsolatedBottomSheetModal
|
||||
ref={sheetRef}
|
||||
snapPoints={resolvedSnapPoints}
|
||||
index={0}
|
||||
@@ -241,16 +219,15 @@ export function AdaptiveModalSheet({
|
||||
onChange={handleSheetChange}
|
||||
backdropComponent={renderBackdrop}
|
||||
enablePanDownToClose
|
||||
stackBehavior={stackBehavior}
|
||||
backgroundComponent={SheetBackground}
|
||||
handleIndicatorStyle={styles.bottomSheetHandle}
|
||||
handleIndicatorStyle={{ backgroundColor: theme.colors.surface2 }}
|
||||
keyboardBehavior="extend"
|
||||
keyboardBlurBehavior="restore"
|
||||
accessible={false}
|
||||
>
|
||||
<View style={styles.bottomSheetHeader} testID={testID}>
|
||||
<View style={styles.headerTitleGroup}>
|
||||
<Text style={[styles.title, { color: theme.colors.foreground }]} numberOfLines={1}>
|
||||
<Text key={titleColor} style={[styles.title, { color: titleColor }]} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
{subtitle}
|
||||
@@ -271,7 +248,7 @@ export function AdaptiveModalSheet({
|
||||
) : (
|
||||
<View style={styles.bottomSheetStaticContent}>{children}</View>
|
||||
)}
|
||||
</BottomSheetModal>
|
||||
</IsolatedBottomSheetModal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -279,7 +256,7 @@ export function AdaptiveModalSheet({
|
||||
<>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.headerTitleGroup}>
|
||||
<Text style={[styles.title, { color: theme.colors.foreground }]} numberOfLines={1}>
|
||||
<Text key={titleColor} style={[styles.title, { color: titleColor }]} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
{subtitle}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { View, Text, Pressable, TextInput, ActivityIndicator } from "react-nativ
|
||||
import type { StyleProp, ViewStyle, TextProps } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
BottomSheetModal,
|
||||
BottomSheetScrollView,
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetBackgroundProps,
|
||||
@@ -22,7 +21,6 @@ import {
|
||||
ShieldAlert,
|
||||
ShieldOff,
|
||||
} from "lucide-react-native";
|
||||
import { theme as defaultTheme } from "@/styles/theme";
|
||||
import type {
|
||||
AgentMode,
|
||||
AgentModelDefinition,
|
||||
@@ -31,6 +29,10 @@ import type {
|
||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||
import { getModeVisuals, type AgentModeIcon } from "@server/server/agent/provider-manifest";
|
||||
import { Combobox, ComboboxItem, ComboboxEmpty } from "@/components/ui/combobox";
|
||||
import {
|
||||
IsolatedBottomSheetModal,
|
||||
useIsolatedBottomSheetVisibility,
|
||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import { baseColors } from "@/styles/theme";
|
||||
import { isNative } from "@/constants/platform";
|
||||
|
||||
@@ -86,6 +88,8 @@ export function DropdownField({
|
||||
renderTrigger,
|
||||
testID,
|
||||
}: DropdownFieldProps): ReactElement {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
if (renderTrigger) {
|
||||
return (
|
||||
<>
|
||||
@@ -115,7 +119,7 @@ export function DropdownField({
|
||||
<Text style={value ? styles.dropdownValue : styles.dropdownPlaceholder} numberOfLines={1}>
|
||||
{value || placeholder}
|
||||
</Text>
|
||||
<ChevronDown size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
|
||||
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
||||
{warningMessage ? <Text style={styles.warningText}>{warningMessage}</Text> : null}
|
||||
@@ -151,6 +155,8 @@ export function SelectField({
|
||||
valueEllipsizeMode,
|
||||
testID,
|
||||
}: SelectFieldProps): ReactElement {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
const getWebKey = useCallback((event: unknown): string | null => {
|
||||
if (!event || typeof event !== "object") return null;
|
||||
const eventWithNative = event as { nativeEvent?: unknown; key?: unknown };
|
||||
@@ -210,7 +216,7 @@ export function SelectField({
|
||||
{value || placeholder || "Select..."}
|
||||
</Text>
|
||||
</View>
|
||||
<ChevronRight size={defaultTheme.iconSize.lg} color={defaultTheme.colors.foregroundMuted} />
|
||||
<ChevronRight size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
||||
{warningMessage ? <Text style={styles.warningText}>{warningMessage}</Text> : null}
|
||||
@@ -229,7 +235,21 @@ interface DropdownSheetProps {
|
||||
}
|
||||
|
||||
function DropdownSheetBackground({ style }: BottomSheetBackgroundProps) {
|
||||
return <Animated.View pointerEvents="none" style={[style, styles.bottomSheetBackground]} />;
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
pointerEvents="none"
|
||||
style={[
|
||||
style,
|
||||
{
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderTopLeftRadius: theme.borderRadius["2xl"],
|
||||
borderTopRightRadius: theme.borderRadius["2xl"],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function DropdownSheet({
|
||||
@@ -238,31 +258,18 @@ export function DropdownSheet({
|
||||
onClose,
|
||||
children,
|
||||
}: DropdownSheetProps): ReactElement {
|
||||
const bottomSheetRef = useRef<BottomSheetModal>(null);
|
||||
const { theme } = useUnistyles();
|
||||
const titleColor = theme.colors.foreground;
|
||||
const snapPoints = useMemo(() => ["60%", "90%"], []);
|
||||
const { sheetRef, handleSheetChange } = useIsolatedBottomSheetVisibility({
|
||||
visible,
|
||||
onClose,
|
||||
});
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
bottomSheetRef.current?.dismiss();
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
bottomSheetRef.current?.present();
|
||||
} else {
|
||||
bottomSheetRef.current?.dismiss();
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const handleSheetChange = useCallback(
|
||||
(index: number) => {
|
||||
if (index === -1) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
|
||||
@@ -271,8 +278,8 @@ export function DropdownSheet({
|
||||
);
|
||||
|
||||
return (
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetRef}
|
||||
<IsolatedBottomSheetModal
|
||||
ref={sheetRef}
|
||||
snapPoints={snapPoints}
|
||||
index={0}
|
||||
enableDynamicSizing={false}
|
||||
@@ -280,12 +287,14 @@ export function DropdownSheet({
|
||||
backdropComponent={renderBackdrop}
|
||||
enablePanDownToClose
|
||||
backgroundComponent={DropdownSheetBackground}
|
||||
handleIndicatorStyle={styles.bottomSheetHandle}
|
||||
handleIndicatorStyle={{ backgroundColor: theme.colors.palette.zinc[600] }}
|
||||
keyboardBehavior="extend"
|
||||
keyboardBlurBehavior="restore"
|
||||
>
|
||||
<View style={styles.bottomSheetHeader}>
|
||||
<Text style={styles.dropdownSheetTitle}>{title}</Text>
|
||||
<Text key={titleColor} style={[styles.dropdownSheetTitle, { color: titleColor }]}>
|
||||
{title}
|
||||
</Text>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Close sheet"
|
||||
@@ -293,7 +302,7 @@ export function DropdownSheet({
|
||||
hitSlop={10}
|
||||
testID="dropdown-sheet-close"
|
||||
>
|
||||
<X size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
|
||||
<X size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<BottomSheetScrollView
|
||||
@@ -303,7 +312,7 @@ export function DropdownSheet({
|
||||
>
|
||||
{children}
|
||||
</BottomSheetScrollView>
|
||||
</BottomSheetModal>
|
||||
</IsolatedBottomSheetModal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -413,6 +422,8 @@ export function FormSelectTrigger({
|
||||
valueEllipsizeMode,
|
||||
testID,
|
||||
}: CompactSelectFieldProps): ReactElement {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
const getWebKey = useCallback((event: unknown): string | null => {
|
||||
if (!event || typeof event !== "object") return null;
|
||||
const eventWithNative = event as { nativeEvent?: unknown; key?: unknown };
|
||||
@@ -469,7 +480,7 @@ export function FormSelectTrigger({
|
||||
<View style={styles.compactSelectValueContainer}>
|
||||
{showLabel ? <Text style={styles.compactSelectLabel}>{label}</Text> : null}
|
||||
{isLoading ? (
|
||||
<ActivityIndicator size="small" color={defaultTheme.colors.foregroundMuted} />
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<Text
|
||||
style={hasConcreteValue ? styles.compactSelectValue : styles.compactSelectPlaceholder}
|
||||
@@ -480,7 +491,7 @@ export function FormSelectTrigger({
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<ChevronDown size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
|
||||
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
@@ -518,6 +529,8 @@ export function AgentConfigRow({
|
||||
onSelectThinkingOption,
|
||||
disabled,
|
||||
}: AgentConfigRowProps): ReactElement {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
const providerOptions: ComboSelectOption[] = useMemo(
|
||||
() =>
|
||||
providerDefinitions.map((def) => ({
|
||||
@@ -576,7 +589,7 @@ export function AgentConfigRow({
|
||||
placeholder={providerOptions.length > 0 ? "Select..." : "No providers available"}
|
||||
disabled={disabled || providerOptions.length === 0}
|
||||
onSelect={onSelectProvider}
|
||||
icon={<Bot size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />}
|
||||
icon={<Bot size={theme.iconSize.md} color={theme.colors.foregroundMuted} />}
|
||||
showLabel={false}
|
||||
testID="draft-provider-select"
|
||||
/>
|
||||
@@ -591,9 +604,7 @@ export function AgentConfigRow({
|
||||
disabled={disabled}
|
||||
isLoading={isModelLoading}
|
||||
onSelect={onSelectModel}
|
||||
icon={
|
||||
<Brain size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
|
||||
}
|
||||
icon={<Brain size={theme.iconSize.md} color={theme.colors.foregroundMuted} />}
|
||||
showLabel={false}
|
||||
testID="draft-model-select"
|
||||
/>
|
||||
@@ -607,7 +618,7 @@ export function AgentConfigRow({
|
||||
placeholder="Default"
|
||||
disabled={disabled || modeOptions.length === 0}
|
||||
onSelect={onSelectMode}
|
||||
icon={<ModeIcon size={defaultTheme.iconSize.md} color={modeIconColor} />}
|
||||
icon={<ModeIcon size={theme.iconSize.md} color={modeIconColor} />}
|
||||
showLabel={false}
|
||||
testID="draft-mode-select"
|
||||
/>
|
||||
@@ -622,9 +633,7 @@ export function AgentConfigRow({
|
||||
placeholder="Select..."
|
||||
disabled={disabled}
|
||||
onSelect={onSelectThinkingOption}
|
||||
icon={
|
||||
<Brain size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
|
||||
}
|
||||
icon={<Brain size={theme.iconSize.md} color={theme.colors.foregroundMuted} />}
|
||||
showLabel={false}
|
||||
/>
|
||||
</View>
|
||||
@@ -969,6 +978,8 @@ export function GitOptionsSection({
|
||||
attachWorktreeError,
|
||||
onSelectWorktreePath,
|
||||
}: GitOptionsSectionProps): ReactElement {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
const isLoading = status === "loading";
|
||||
const isCreateMode = worktreeMode === "create";
|
||||
const isAttachMode = worktreeMode === "attach";
|
||||
@@ -1103,7 +1114,7 @@ export function GitOptionsSection({
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
placeholder="branch name"
|
||||
placeholderTextColor={defaultTheme.colors.foregroundMuted}
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
onSubmitEditing={handleConfirmEdit}
|
||||
/>
|
||||
<Pressable
|
||||
@@ -1111,19 +1122,16 @@ export function GitOptionsSection({
|
||||
hitSlop={8}
|
||||
style={styles.baseBranchIconButton}
|
||||
>
|
||||
<Check
|
||||
size={defaultTheme.iconSize.md}
|
||||
color={defaultTheme.colors.palette.green[500]}
|
||||
/>
|
||||
<Check size={theme.iconSize.md} color={theme.colors.palette.green[500]} />
|
||||
</Pressable>
|
||||
<Pressable onPress={handleCancelEdit} hitSlop={8} style={styles.baseBranchIconButton}>
|
||||
<X size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
|
||||
<X size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
) : (
|
||||
<Pressable onPress={handleStartEdit} style={styles.baseBranchValueRow}>
|
||||
<Text style={styles.baseBranchValue}>{displayBranch}</Text>
|
||||
<Pencil size={defaultTheme.iconSize.sm} color={defaultTheme.colors.foregroundMuted} />
|
||||
<Pencil size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
@@ -1171,14 +1179,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
bottomSheetBackground: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderTopLeftRadius: theme.borderRadius["2xl"],
|
||||
borderTopRightRadius: theme.borderRadius["2xl"],
|
||||
},
|
||||
bottomSheetHandle: {
|
||||
backgroundColor: theme.colors.palette.zinc[600],
|
||||
},
|
||||
bottomSheetHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
@@ -1220,7 +1220,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
dropdownSheetTitle: {
|
||||
fontSize: theme.fontSize.lg,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
color: theme.colors.foreground,
|
||||
textAlign: "center",
|
||||
},
|
||||
dropdownSheetScrollContent: {
|
||||
|
||||
@@ -59,6 +59,51 @@ describe("submitAgentInput", () => {
|
||||
expect(clearDraft).toHaveBeenCalledWith("sent");
|
||||
});
|
||||
|
||||
it("preserves the composer before an in-flight submit resolves when requested", async () => {
|
||||
const deferred = createDeferredPromise<void>();
|
||||
const attachments = [{ id: "img-1" }];
|
||||
const queueMessage = vi.fn();
|
||||
const submitMessage = vi.fn(async () => {
|
||||
await deferred.promise;
|
||||
});
|
||||
const clearDraft = vi.fn();
|
||||
const setUserInput = vi.fn();
|
||||
const setAttachments = vi.fn();
|
||||
const setSendError = vi.fn();
|
||||
const setIsProcessing = vi.fn();
|
||||
|
||||
const submitPromise = submitAgentInput({
|
||||
message: " keep me ",
|
||||
attachments,
|
||||
submitBehavior: "preserve-and-lock",
|
||||
isAgentRunning: false,
|
||||
canSubmit: true,
|
||||
queueMessage,
|
||||
submitMessage,
|
||||
clearDraft,
|
||||
setUserInput,
|
||||
setAttachments,
|
||||
setSendError,
|
||||
setIsProcessing,
|
||||
});
|
||||
|
||||
expect(queueMessage).not.toHaveBeenCalled();
|
||||
expect(submitMessage).toHaveBeenCalledWith({
|
||||
message: "keep me",
|
||||
attachments,
|
||||
});
|
||||
expect(setUserInput).not.toHaveBeenCalled();
|
||||
expect(setAttachments).not.toHaveBeenCalled();
|
||||
expect(setSendError).toHaveBeenCalledWith(null);
|
||||
expect(setIsProcessing).toHaveBeenCalledWith(true);
|
||||
expect(clearDraft).not.toHaveBeenCalled();
|
||||
|
||||
deferred.resolve();
|
||||
|
||||
await expect(submitPromise).resolves.toBe("submitted");
|
||||
expect(clearDraft).toHaveBeenCalledWith("sent");
|
||||
});
|
||||
|
||||
it("queues while the agent is running and clears the composer immediately", async () => {
|
||||
const queueMessage = vi.fn();
|
||||
const submitMessage = vi.fn();
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface AgentInputSubmitActionInput<TAttachment> {
|
||||
attachments: TAttachment[];
|
||||
hasExternalContent?: boolean;
|
||||
allowEmptySubmit?: boolean;
|
||||
submitBehavior?: "clear" | "preserve-and-lock";
|
||||
forceSend?: boolean;
|
||||
isAgentRunning: boolean;
|
||||
canSubmit: boolean;
|
||||
@@ -23,6 +24,7 @@ export async function submitAgentInput<TAttachment>(
|
||||
): Promise<AgentInputSubmitResult> {
|
||||
const trimmedMessage = input.message.trim();
|
||||
const attachments = input.attachments;
|
||||
const shouldClearOnSubmit = input.submitBehavior !== "preserve-and-lock";
|
||||
|
||||
if (
|
||||
!trimmedMessage &&
|
||||
@@ -39,14 +41,18 @@ export async function submitAgentInput<TAttachment>(
|
||||
|
||||
if (input.isAgentRunning && !input.forceSend) {
|
||||
input.queueMessage({ message: trimmedMessage, attachments });
|
||||
input.setUserInput("");
|
||||
input.setAttachments([]);
|
||||
if (shouldClearOnSubmit) {
|
||||
input.setUserInput("");
|
||||
input.setAttachments([]);
|
||||
}
|
||||
return "queued";
|
||||
}
|
||||
|
||||
// Clear immediately so optimistic stream updates and composer state stay in sync.
|
||||
input.setUserInput("");
|
||||
input.setAttachments([]);
|
||||
if (shouldClearOnSubmit) {
|
||||
input.setUserInput("");
|
||||
input.setAttachments([]);
|
||||
}
|
||||
input.setSendError(null);
|
||||
input.setIsProcessing(true);
|
||||
|
||||
@@ -57,8 +63,10 @@ export async function submitAgentInput<TAttachment>(
|
||||
return "submitted";
|
||||
} catch (error) {
|
||||
input.onSubmitError?.(error);
|
||||
input.setUserInput(trimmedMessage);
|
||||
input.setAttachments(attachments);
|
||||
if (shouldClearOnSubmit) {
|
||||
input.setUserInput(trimmedMessage);
|
||||
input.setAttachments(attachments);
|
||||
}
|
||||
input.setSendError(error instanceof Error ? error.message : "Failed to send message");
|
||||
input.setIsProcessing(false);
|
||||
return "failed";
|
||||
|
||||
@@ -52,6 +52,8 @@ import {
|
||||
resolveAgentModelSelection,
|
||||
} from "@/components/agent-status-bar.utils";
|
||||
import { isWeb as platformIsWeb } from "@/constants/platform";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
|
||||
type StatusOption = {
|
||||
id: string;
|
||||
@@ -90,7 +92,7 @@ type ControlledAgentStatusBarProps = {
|
||||
|
||||
export interface DraftAgentStatusBarProps {
|
||||
providerDefinitions: AgentProviderDefinition[];
|
||||
selectedProvider: AgentProvider;
|
||||
selectedProvider: AgentProvider | null;
|
||||
onSelectProvider: (provider: AgentProvider) => void;
|
||||
modeOptions: AgentMode[];
|
||||
selectedMode: string;
|
||||
@@ -253,7 +255,8 @@ function ControlledStatusBar({
|
||||
: undefined;
|
||||
const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null;
|
||||
const modeIconColor = getModeIconColor(modeVisuals?.colorTier, theme.colors.palette);
|
||||
const ProviderIcon = getProviderIcon(provider);
|
||||
const hasSelectedProvider = provider.trim().length > 0;
|
||||
const ProviderIcon = hasSelectedProvider ? getProviderIcon(provider) : null;
|
||||
|
||||
const hasAnyControl =
|
||||
Boolean(providerOptions?.length) ||
|
||||
@@ -424,12 +427,7 @@ function ControlledStatusBar({
|
||||
|
||||
{thinkingOptions && thinkingOptions.length > 0 ? (
|
||||
<>
|
||||
<Tooltip
|
||||
key={`thinking-${openSelector === "thinking" ? "open" : "closed"}`}
|
||||
delayDuration={0}
|
||||
enabledOnDesktop
|
||||
enabledOnMobile={false}
|
||||
>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
<Pressable
|
||||
ref={thinkingAnchorRef}
|
||||
@@ -470,12 +468,7 @@ function ControlledStatusBar({
|
||||
|
||||
{modeOptions && modeOptions.length > 0 ? (
|
||||
<>
|
||||
<Tooltip
|
||||
key={`mode-${openSelector === "mode" ? "open" : "closed"}`}
|
||||
delayDuration={0}
|
||||
enabledOnDesktop
|
||||
enabledOnMobile={false}
|
||||
>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
<Pressable
|
||||
ref={modeAnchorRef}
|
||||
@@ -628,7 +621,9 @@ function ControlledStatusBar({
|
||||
accessibilityLabel="Agent preferences"
|
||||
testID="agent-preferences-button"
|
||||
>
|
||||
<ProviderIcon size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
|
||||
{ProviderIcon ? (
|
||||
<ProviderIcon size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
|
||||
) : null}
|
||||
<Text style={styles.prefsButtonText} numberOfLines={1}>
|
||||
{displayModel}
|
||||
</Text>
|
||||
@@ -638,7 +633,6 @@ function ControlledStatusBar({
|
||||
title="Preferences"
|
||||
visible={prefsOpen}
|
||||
onClose={() => setPrefsOpen(false)}
|
||||
stackBehavior="replace"
|
||||
testID="agent-preferences-sheet"
|
||||
>
|
||||
{canSelectModel ? (
|
||||
@@ -671,7 +665,12 @@ function ControlledStatusBar({
|
||||
pointerEvents="none"
|
||||
testID="agent-preferences-model"
|
||||
>
|
||||
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
{ProviderIcon ? (
|
||||
<ProviderIcon
|
||||
size={theme.iconSize.md}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
) : null}
|
||||
<Text style={styles.sheetSelectText}>{selectedModelLabel}</Text>
|
||||
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
@@ -873,23 +872,23 @@ export const AgentStatusBar = memo(function AgentStatusBar({
|
||||
(a, b) => a === b || JSON.stringify(a) === JSON.stringify(b),
|
||||
);
|
||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||
const toast = useToast();
|
||||
|
||||
const {
|
||||
entries: snapshotEntries,
|
||||
isLoading: snapshotIsLoading,
|
||||
isFetching: snapshotIsFetching,
|
||||
invalidate: invalidateSnapshot,
|
||||
refetchIfStale: refetchSnapshotIfStale,
|
||||
} = useProvidersSnapshot(serverId, agent?.cwd);
|
||||
|
||||
const snapshotModels = useMemo(() => {
|
||||
const snapshotSelectedEntry = useMemo(() => {
|
||||
if (!snapshotEntries || !agent?.provider) {
|
||||
return null;
|
||||
}
|
||||
const entry = snapshotEntries.find((e) => e.provider === agent.provider);
|
||||
return entry?.models ?? null;
|
||||
return snapshotEntries.find((e) => e.provider === agent.provider) ?? null;
|
||||
}, [snapshotEntries, agent?.provider]);
|
||||
|
||||
const models = snapshotModels;
|
||||
const models = snapshotSelectedEntry?.models ?? null;
|
||||
const selectedProviderIsLoading = snapshotSelectedEntry?.status === "loading";
|
||||
|
||||
const agentProviderDefinitions = useMemo(() => {
|
||||
const definition = agent?.provider
|
||||
@@ -900,11 +899,11 @@ export const AgentStatusBar = memo(function AgentStatusBar({
|
||||
|
||||
const agentProviderModels = useMemo(() => {
|
||||
const map = new Map<string, AgentModelDefinition[]>();
|
||||
if (agent?.provider && snapshotModels) {
|
||||
map.set(agent.provider, snapshotModels);
|
||||
if (agent?.provider && models) {
|
||||
map.set(agent.provider, models);
|
||||
}
|
||||
return map;
|
||||
}, [agent?.provider, snapshotModels]);
|
||||
}, [agent?.provider, models]);
|
||||
|
||||
const displayMode =
|
||||
availableModes.find((mode) => mode.id === agent?.currentModeId)?.label ||
|
||||
@@ -964,6 +963,7 @@ export const AgentStatusBar = memo(function AgentStatusBar({
|
||||
}
|
||||
void client.setAgentMode(agentId, modeId).catch((error) => {
|
||||
console.warn("[AgentStatusBar] setAgentMode failed", error);
|
||||
toast.error(toErrorMessage(error));
|
||||
});
|
||||
}}
|
||||
modelOptions={modelOptions}
|
||||
@@ -985,6 +985,7 @@ export const AgentStatusBar = memo(function AgentStatusBar({
|
||||
});
|
||||
void client.setAgentModel(agentId, modelId).catch((error) => {
|
||||
console.warn("[AgentStatusBar] setAgentModel failed", error);
|
||||
toast.error(toErrorMessage(error));
|
||||
});
|
||||
}}
|
||||
favoriteKeys={favoriteKeys}
|
||||
@@ -1020,6 +1021,7 @@ export const AgentStatusBar = memo(function AgentStatusBar({
|
||||
}
|
||||
void client.setAgentThinkingOption(agentId, thinkingOptionId).catch((error) => {
|
||||
console.warn("[AgentStatusBar] setAgentThinkingOption failed", error);
|
||||
toast.error(toErrorMessage(error));
|
||||
});
|
||||
}}
|
||||
features={agent.features}
|
||||
@@ -1042,10 +1044,11 @@ export const AgentStatusBar = memo(function AgentStatusBar({
|
||||
});
|
||||
void client.setAgentFeature(agentId, featureId, value).catch((error) => {
|
||||
console.warn("[AgentStatusBar] setAgentFeature failed", error);
|
||||
toast.error(toErrorMessage(error));
|
||||
});
|
||||
}}
|
||||
isModelLoading={snapshotIsLoading || snapshotIsFetching}
|
||||
onModelSelectorOpen={invalidateSnapshot}
|
||||
isModelLoading={snapshotIsLoading || selectedProviderIsLoading}
|
||||
onModelSelectorOpen={() => refetchSnapshotIfStale(agent?.provider)}
|
||||
onDropdownClose={onDropdownClose}
|
||||
disabled={!client}
|
||||
/>
|
||||
@@ -1101,6 +1104,7 @@ export function DraftAgentStatusBar({
|
||||
const effectiveSelectedMode = selectedMode || mappedModeOptions[0]?.id || "";
|
||||
const effectiveSelectedThinkingOption =
|
||||
selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined;
|
||||
const hasSelectedProvider = selectedProvider !== null;
|
||||
|
||||
if (platformIsWeb) {
|
||||
return (
|
||||
@@ -1108,7 +1112,7 @@ export function DraftAgentStatusBar({
|
||||
<CombinedModelSelector
|
||||
providerDefinitions={providerDefinitions}
|
||||
allProviderModels={allProviderModels}
|
||||
selectedProvider={selectedProvider}
|
||||
selectedProvider={selectedProvider ?? ""}
|
||||
selectedModel={selectedModel}
|
||||
onSelect={onSelectProviderAndModel}
|
||||
favoriteKeys={favoriteKeys}
|
||||
@@ -1124,20 +1128,22 @@ export function DraftAgentStatusBar({
|
||||
onOpen={onModelSelectorOpen}
|
||||
onClose={onDropdownClose}
|
||||
/>
|
||||
<ControlledStatusBar
|
||||
provider={selectedProvider}
|
||||
providerDefinitions={providerDefinitions}
|
||||
modeOptions={mappedModeOptions}
|
||||
selectedModeId={effectiveSelectedMode}
|
||||
onSelectMode={onSelectMode}
|
||||
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
|
||||
selectedThinkingOptionId={effectiveSelectedThinkingOption}
|
||||
onSelectThinkingOption={onSelectThinkingOption}
|
||||
features={features}
|
||||
onSetFeature={onSetFeature}
|
||||
onDropdownClose={onDropdownClose}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{selectedProvider ? (
|
||||
<ControlledStatusBar
|
||||
provider={selectedProvider}
|
||||
providerDefinitions={providerDefinitions}
|
||||
modeOptions={mappedModeOptions}
|
||||
selectedModeId={effectiveSelectedMode}
|
||||
onSelectMode={onSelectMode}
|
||||
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
|
||||
selectedThinkingOptionId={effectiveSelectedThinkingOption}
|
||||
onSelectThinkingOption={onSelectThinkingOption}
|
||||
features={features}
|
||||
onSetFeature={onSetFeature}
|
||||
onDropdownClose={onDropdownClose}
|
||||
disabled={disabled}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1150,10 +1156,10 @@ export function DraftAgentStatusBar({
|
||||
return (
|
||||
<>
|
||||
<ControlledStatusBar
|
||||
provider={selectedProvider}
|
||||
provider={selectedProvider ?? ""}
|
||||
providerDefinitions={providerDefinitions}
|
||||
allProviderModels={allProviderModels}
|
||||
modeOptions={mappedModeOptions}
|
||||
modeOptions={hasSelectedProvider ? mappedModeOptions : undefined}
|
||||
selectedModeId={effectiveSelectedMode}
|
||||
onSelectMode={onSelectMode}
|
||||
modelOptions={modelOptions}
|
||||
|
||||
@@ -125,7 +125,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
|
||||
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
|
||||
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
||||
|
||||
// Get serverId (fallback to agent's serverId if not provided)
|
||||
@@ -199,17 +199,21 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
setCurrentPath: false,
|
||||
});
|
||||
|
||||
setExplorerTabForCheckout({
|
||||
const checkout = {
|
||||
serverId: resolvedServerId,
|
||||
cwd: agent.cwd,
|
||||
isGit: agent.projectPlacement?.checkout?.isGit ?? true,
|
||||
tab: "files",
|
||||
};
|
||||
setExplorerTabForCheckout({ ...checkout, tab: "files" });
|
||||
openFileExplorerForCheckout({
|
||||
isCompact: isMobile,
|
||||
checkout,
|
||||
});
|
||||
openFileExplorer();
|
||||
},
|
||||
[
|
||||
agent.cwd,
|
||||
openFileExplorer,
|
||||
isMobile,
|
||||
openFileExplorerForCheckout,
|
||||
requestDirectoryListing,
|
||||
resolvedServerId,
|
||||
router,
|
||||
|
||||
@@ -10,6 +10,7 @@ interface AttachmentPillProps {
|
||||
onRemove: () => void;
|
||||
openAccessibilityLabel: string;
|
||||
removeAccessibilityLabel: string;
|
||||
disabled?: boolean;
|
||||
testID?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
@@ -19,6 +20,7 @@ export function AttachmentPill({
|
||||
onRemove,
|
||||
openAccessibilityLabel,
|
||||
removeAccessibilityLabel,
|
||||
disabled = false,
|
||||
testID,
|
||||
children,
|
||||
}: AttachmentPillProps) {
|
||||
@@ -33,6 +35,7 @@ export function AttachmentPill({
|
||||
<Pressable
|
||||
testID={testID}
|
||||
onPress={onOpen}
|
||||
disabled={disabled}
|
||||
onHoverIn={() => setIsBodyHovered(true)}
|
||||
onHoverOut={() => setIsBodyHovered(false)}
|
||||
accessibilityRole="button"
|
||||
@@ -43,6 +46,7 @@ export function AttachmentPill({
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={onRemove}
|
||||
disabled={disabled}
|
||||
onHoverIn={() => setIsCloseHovered(true)}
|
||||
onHoverOut={() => setIsCloseHovered(false)}
|
||||
hitSlop={8}
|
||||
|
||||
@@ -45,10 +45,10 @@ export function BranchSwitcher({
|
||||
});
|
||||
|
||||
const titleContent = (
|
||||
<>
|
||||
<View style={styles.titleRow}>
|
||||
{isGitCheckout ? <GitBranch size={14} color={theme.colors.foregroundMuted} /> : null}
|
||||
<ScreenTitle testID="workspace-header-title">{title}</ScreenTitle>
|
||||
</>
|
||||
</View>
|
||||
);
|
||||
|
||||
if (!currentBranchName) {
|
||||
@@ -105,6 +105,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
minWidth: 0,
|
||||
marginLeft: {
|
||||
xs: -theme.spacing[2],
|
||||
md: 0,
|
||||
@@ -116,9 +117,15 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
branchSwitcherTriggerHovered: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
minWidth: 0,
|
||||
overflow: "hidden",
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -568,10 +568,14 @@ export function CombinedModelSelector({
|
||||
[onSelect],
|
||||
);
|
||||
|
||||
const ProviderIcon = getProviderIcon(selectedProvider);
|
||||
const hasSelectedProvider = selectedProvider.trim().length > 0;
|
||||
const ProviderIcon = hasSelectedProvider ? getProviderIcon(selectedProvider) : null;
|
||||
|
||||
const selectedModelLabel = useMemo(() => {
|
||||
if (!selectedModel) {
|
||||
if (!hasSelectedProvider) {
|
||||
return "Select model";
|
||||
}
|
||||
return isLoading ? "Loading..." : "Select model";
|
||||
}
|
||||
const models = allProviderModels.get(selectedProvider);
|
||||
@@ -580,7 +584,7 @@ export function CombinedModelSelector({
|
||||
}
|
||||
const model = models.find((entry) => entry.id === selectedModel);
|
||||
return model?.label ?? resolveDefaultModelLabel(models);
|
||||
}, [allProviderModels, isLoading, selectedModel, selectedProvider]);
|
||||
}, [allProviderModels, hasSelectedProvider, isLoading, selectedModel, selectedProvider]);
|
||||
|
||||
const desktopFixedHeight = useMemo(() => {
|
||||
if (view.kind !== "provider") {
|
||||
@@ -643,7 +647,9 @@ export function CombinedModelSelector({
|
||||
})
|
||||
) : (
|
||||
<>
|
||||
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
{ProviderIcon ? (
|
||||
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
) : null}
|
||||
<Text style={styles.triggerText} numberOfLines={1} ellipsizeMode="tail">
|
||||
{triggerLabel}
|
||||
</Text>
|
||||
@@ -657,7 +663,6 @@ export function CombinedModelSelector({
|
||||
onSelect={() => {}}
|
||||
open={isOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
stackBehavior="push"
|
||||
anchorRef={anchorRef}
|
||||
desktopPlacement="top-start"
|
||||
desktopMinWidth={360}
|
||||
|
||||
1
packages/app/src/components/composer-height-mirror.d.ts
vendored
Normal file
1
packages/app/src/components/composer-height-mirror.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./composer-height-mirror.native";
|
||||
13
packages/app/src/components/composer-height-mirror.native.ts
Normal file
13
packages/app/src/components/composer-height-mirror.native.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { RefObject } from "react";
|
||||
|
||||
interface Args {
|
||||
value: string;
|
||||
textareaRef: RefObject<unknown>;
|
||||
minHeight: number;
|
||||
maxHeight: number;
|
||||
onHeight: (height: number) => void;
|
||||
}
|
||||
|
||||
export function useComposerHeightMirror(_args: Args): void {
|
||||
// No-op on native: onContentSizeChange drives height natively.
|
||||
}
|
||||
106
packages/app/src/components/composer-height-mirror.web.ts
Normal file
106
packages/app/src/components/composer-height-mirror.web.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
|
||||
import type { RefObject } from "react";
|
||||
|
||||
interface Args {
|
||||
value: string;
|
||||
textareaRef: RefObject<HTMLElement | null>;
|
||||
minHeight: number;
|
||||
maxHeight: number;
|
||||
onHeight: (height: number) => void;
|
||||
}
|
||||
|
||||
const COPIED_STYLES = [
|
||||
"fontFamily",
|
||||
"fontSize",
|
||||
"fontWeight",
|
||||
"fontStyle",
|
||||
"fontVariant",
|
||||
"lineHeight",
|
||||
"letterSpacing",
|
||||
"wordSpacing",
|
||||
"textTransform",
|
||||
"textIndent",
|
||||
"whiteSpace",
|
||||
"wordWrap",
|
||||
"overflowWrap",
|
||||
"wordBreak",
|
||||
"tabSize",
|
||||
"paddingTop",
|
||||
"paddingRight",
|
||||
"paddingBottom",
|
||||
"paddingLeft",
|
||||
] as const;
|
||||
|
||||
export function useComposerHeightMirror({
|
||||
value,
|
||||
textareaRef,
|
||||
minHeight,
|
||||
maxHeight,
|
||||
onHeight,
|
||||
}: Args): void {
|
||||
const paramsRef = useRef({ value, minHeight, maxHeight, onHeight });
|
||||
paramsRef.current = { value, minHeight, maxHeight, onHeight };
|
||||
|
||||
const mirrorRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined") return;
|
||||
const mirror = document.createElement("textarea");
|
||||
mirror.setAttribute("aria-hidden", "true");
|
||||
mirror.setAttribute("tabindex", "-1");
|
||||
mirror.readOnly = true;
|
||||
mirror.rows = 1;
|
||||
const style = mirror.style;
|
||||
style.position = "absolute";
|
||||
style.top = "0";
|
||||
style.left = "0";
|
||||
style.visibility = "hidden";
|
||||
style.pointerEvents = "none";
|
||||
style.overflow = "hidden";
|
||||
style.border = "0";
|
||||
style.margin = "0";
|
||||
style.resize = "none";
|
||||
style.zIndex = "-1";
|
||||
style.boxSizing = "border-box";
|
||||
document.body.appendChild(mirror);
|
||||
mirrorRef.current = mirror;
|
||||
return () => {
|
||||
mirror.remove();
|
||||
mirrorRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const measure = useCallback(() => {
|
||||
const mirror = mirrorRef.current;
|
||||
const source = textareaRef.current;
|
||||
if (!mirror || !source || typeof window === "undefined") return;
|
||||
if (!(source instanceof HTMLElement)) return;
|
||||
|
||||
const cs = window.getComputedStyle(source);
|
||||
const ms = mirror.style;
|
||||
for (const prop of COPIED_STYLES) {
|
||||
ms[prop] = cs[prop];
|
||||
}
|
||||
ms.width = `${source.clientWidth}px`;
|
||||
|
||||
const { value, minHeight, maxHeight, onHeight } = paramsRef.current;
|
||||
// Trailing newline is collapsed by textarea measurement — pad with a space.
|
||||
mirror.value = value.endsWith("\n") ? `${value} ` : value;
|
||||
|
||||
const next = Math.max(minHeight, Math.min(maxHeight, mirror.scrollHeight));
|
||||
onHeight(next);
|
||||
}, [textareaRef]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
measure();
|
||||
}, [value, measure]);
|
||||
|
||||
useEffect(() => {
|
||||
const source = textareaRef.current;
|
||||
if (!source || !(source instanceof HTMLElement)) return;
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
const observer = new ResizeObserver(() => measure());
|
||||
observer.observe(source);
|
||||
return () => observer.disconnect();
|
||||
}, [textareaRef, measure]);
|
||||
}
|
||||
@@ -20,10 +20,12 @@ const {
|
||||
deleteAttachmentsMock,
|
||||
encodeImagesMock,
|
||||
openExternalUrlMock,
|
||||
markScrollInvestigationRenderMock,
|
||||
mockSessionState,
|
||||
setAgentStreamTailMock,
|
||||
setAgentStreamHeadMock,
|
||||
setQueuedMessagesMock,
|
||||
agentDirectoryStatusMock,
|
||||
} = vi.hoisted(() => {
|
||||
const theme = {
|
||||
spacing: { 1: 4, 2: 8, 3: 12, 4: 16, 6: 24, 8: 32 },
|
||||
@@ -102,6 +104,17 @@ const {
|
||||
string,
|
||||
{
|
||||
agents: Map<string, { status: string; lastUsage: null }>;
|
||||
serverInfo: {
|
||||
serverId: string;
|
||||
hostname: string | null;
|
||||
version: string | null;
|
||||
capabilities?: {
|
||||
voice?: {
|
||||
dictation: { enabled: boolean; reason: string };
|
||||
voice: { enabled: boolean; reason: string };
|
||||
};
|
||||
};
|
||||
} | null;
|
||||
queuedMessages: Map<string, unknown[]>;
|
||||
agentStreamHead: Map<string, unknown[]>;
|
||||
agentStreamTail: Map<string, unknown[]>;
|
||||
@@ -114,6 +127,17 @@ const {
|
||||
sessions: {
|
||||
server: {
|
||||
agents: new Map([["agent", { status: "idle", lastUsage: null }]]),
|
||||
serverInfo: {
|
||||
serverId: "server",
|
||||
hostname: "test",
|
||||
version: "0.0.0",
|
||||
capabilities: {
|
||||
voice: {
|
||||
dictation: { enabled: true, reason: "" },
|
||||
voice: { enabled: true, reason: "" },
|
||||
},
|
||||
},
|
||||
},
|
||||
queuedMessages: new Map(),
|
||||
agentStreamHead: new Map(),
|
||||
agentStreamTail: new Map(),
|
||||
@@ -135,6 +159,8 @@ const {
|
||||
);
|
||||
mockSessionState.setAgentStreamTail = setAgentStreamTailMock;
|
||||
mockSessionState.setAgentStreamHead = setAgentStreamHeadMock;
|
||||
const markScrollInvestigationRenderMock = vi.fn();
|
||||
const agentDirectoryStatusMock = vi.fn(() => "ready");
|
||||
|
||||
return {
|
||||
theme,
|
||||
@@ -147,10 +173,12 @@ const {
|
||||
deleteAttachmentsMock: vi.fn(async () => {}),
|
||||
encodeImagesMock: vi.fn(async (images: AttachmentMetadata[]) => images),
|
||||
openExternalUrlMock: vi.fn(async () => {}),
|
||||
markScrollInvestigationRenderMock,
|
||||
mockSessionState,
|
||||
setAgentStreamTailMock,
|
||||
setAgentStreamHeadMock,
|
||||
setQueuedMessagesMock,
|
||||
agentDirectoryStatusMock,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -177,6 +205,7 @@ vi.mock("lucide-react-native", () => {
|
||||
React.createElement("span", { ...props, "data-icon": name });
|
||||
return {
|
||||
ArrowUp: createIcon("ArrowUp"),
|
||||
CornerDownLeft: createIcon("CornerDownLeft"),
|
||||
Square: createIcon("Square"),
|
||||
Pencil: createIcon("Pencil"),
|
||||
AudioLines: createIcon("AudioLines"),
|
||||
@@ -216,7 +245,7 @@ vi.mock("react-native-safe-area-context", () => ({
|
||||
vi.mock("@/runtime/host-runtime", () => ({
|
||||
useHostRuntimeClient: () => mockClient,
|
||||
useHostRuntimeIsConnected: () => true,
|
||||
useHostRuntimeAgentDirectoryStatus: () => "ready",
|
||||
useHostRuntimeAgentDirectoryStatus: () => agentDirectoryStatusMock(),
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/session-store", () => {
|
||||
@@ -312,7 +341,7 @@ vi.mock("@/contexts/toast-context", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/scroll-jank-investigation", () => ({
|
||||
markScrollInvestigationRender: vi.fn(),
|
||||
markScrollInvestigationRender: markScrollInvestigationRenderMock,
|
||||
markScrollInvestigationEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -359,6 +388,20 @@ vi.mock("@/hooks/use-dictation", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/server-info-capabilities", () => ({
|
||||
getVoiceReadinessState: ({
|
||||
serverInfo,
|
||||
mode,
|
||||
}: {
|
||||
serverInfo: {
|
||||
capabilities?: {
|
||||
voice?: {
|
||||
dictation?: { enabled: boolean; reason: string };
|
||||
voice?: { enabled: boolean; reason: string };
|
||||
};
|
||||
};
|
||||
} | null;
|
||||
mode: "dictation" | "voice";
|
||||
}) => serverInfo?.capabilities?.voice?.[mode] ?? null,
|
||||
resolveVoiceUnavailableMessage: () => null,
|
||||
}));
|
||||
|
||||
@@ -546,9 +589,23 @@ beforeEach(() => {
|
||||
deleteAttachmentsMock.mockClear();
|
||||
encodeImagesMock.mockClear();
|
||||
openExternalUrlMock.mockClear();
|
||||
markScrollInvestigationRenderMock.mockClear();
|
||||
setAgentStreamTailMock.mockClear();
|
||||
setAgentStreamHeadMock.mockClear();
|
||||
setQueuedMessagesMock.mockClear();
|
||||
agentDirectoryStatusMock.mockReset();
|
||||
agentDirectoryStatusMock.mockReturnValue("ready");
|
||||
mockSessionState.sessions.server.serverInfo = {
|
||||
serverId: "server",
|
||||
hostname: "test",
|
||||
version: "0.0.0",
|
||||
capabilities: {
|
||||
voice: {
|
||||
dictation: { enabled: true, reason: "" },
|
||||
voice: { enabled: true, reason: "" },
|
||||
},
|
||||
},
|
||||
};
|
||||
mockSessionState.sessions.server.agentStreamHead = new Map();
|
||||
mockSessionState.sessions.server.agentStreamTail = new Map();
|
||||
mockSessionState.sessions.server.queuedMessages = new Map();
|
||||
@@ -579,9 +636,13 @@ function imageAttachment(id: string): AttachmentMetadata {
|
||||
function ComposerHarness({
|
||||
initialText = "",
|
||||
initialAttachments = [],
|
||||
isSubmitLoading = false,
|
||||
submitBehavior,
|
||||
}: {
|
||||
initialText?: string;
|
||||
initialAttachments?: ComposerAttachment[];
|
||||
isSubmitLoading?: boolean;
|
||||
submitBehavior?: "clear" | "preserve-and-lock";
|
||||
}) {
|
||||
const [text, setText] = useState(initialText);
|
||||
const [attachments, setAttachments] = useState(initialAttachments);
|
||||
@@ -603,6 +664,8 @@ function ComposerHarness({
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
isSubmitLoading={isSubmitLoading}
|
||||
submitBehavior={submitBehavior}
|
||||
cwd="/repo"
|
||||
clearDraft={vi.fn()}
|
||||
/>
|
||||
@@ -611,7 +674,12 @@ function ComposerHarness({
|
||||
}
|
||||
|
||||
function renderComposer(
|
||||
input: { initialText?: string; initialAttachments?: ComposerAttachment[] } = {},
|
||||
input: {
|
||||
initialText?: string;
|
||||
initialAttachments?: ComposerAttachment[];
|
||||
isSubmitLoading?: boolean;
|
||||
submitBehavior?: "clear" | "preserve-and-lock";
|
||||
} = {},
|
||||
) {
|
||||
act(() => {
|
||||
root?.render(<ComposerHarness {...input} />);
|
||||
@@ -651,6 +719,12 @@ function queryAllAttachmentMenuItems(): NodeListOf<HTMLElement> {
|
||||
return document.querySelectorAll('[data-testid^="message-input-attachment-menu-item-"]');
|
||||
}
|
||||
|
||||
function countMessageInputRenders(): number {
|
||||
return markScrollInvestigationRenderMock.mock.calls.filter(
|
||||
([componentId]) => componentId === "MessageInput:server:agent",
|
||||
).length;
|
||||
}
|
||||
|
||||
describe("Composer attachments", () => {
|
||||
it("opens a Plus menu with image and GitHub attachment actions", () => {
|
||||
renderComposer();
|
||||
@@ -835,6 +909,64 @@ describe("Composer attachments", () => {
|
||||
expect(latestAttachments).toEqual([{ kind: "image", metadata: image }]);
|
||||
});
|
||||
|
||||
it("does not re-render MessageInput when opening the attachment lightbox", () => {
|
||||
const image = imageAttachment("img-lightbox-render");
|
||||
renderComposer({ initialAttachments: [{ kind: "image", metadata: image }] });
|
||||
const renderCountBeforeLightbox = countMessageInputRenders();
|
||||
|
||||
click(queryByTestId("composer-image-attachment-pill")!);
|
||||
|
||||
expect(queryByTestId("attachment-lightbox-image")).not.toBeNull();
|
||||
expect(countMessageInputRenders()).toBe(renderCountBeforeLightbox);
|
||||
});
|
||||
|
||||
it("still re-renders MessageInput when submit loading semantics change", () => {
|
||||
renderComposer({ initialText: "pending submit", isSubmitLoading: false });
|
||||
const renderCountBeforeLoading = countMessageInputRenders();
|
||||
|
||||
renderComposer({ initialText: "pending submit", isSubmitLoading: true });
|
||||
|
||||
expect(countMessageInputRenders()).toBe(renderCountBeforeLoading + 1);
|
||||
expect(document.querySelector('[aria-label="Send message"]')).toHaveProperty("disabled", true);
|
||||
});
|
||||
|
||||
it("enables dictation from server capabilities before the agent directory finishes loading", () => {
|
||||
agentDirectoryStatusMock.mockReturnValue("initial_loading");
|
||||
|
||||
renderComposer();
|
||||
|
||||
expect(document.querySelector('[aria-label="Start dictation"]')).toHaveProperty(
|
||||
"disabled",
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("locks the preserved draft while submit loading", () => {
|
||||
renderComposer({
|
||||
initialText: "keep this prompt",
|
||||
initialAttachments: [{ kind: "github_pr", item: prItem }],
|
||||
isSubmitLoading: true,
|
||||
submitBehavior: "preserve-and-lock",
|
||||
});
|
||||
|
||||
const textInput = document.querySelector('[aria-label="Message agent..."]');
|
||||
const attachButton = queryByTestId("message-input-attach-button");
|
||||
const pill = queryByTestId("composer-github-attachment-pill");
|
||||
const removeButton = document.querySelector(`[aria-label="Remove PR #${prItem.number}"]`);
|
||||
|
||||
expect(textInput).toHaveProperty("readOnly", true);
|
||||
expect(textInput).toHaveProperty("value", "keep this prompt");
|
||||
expect(attachButton).toHaveProperty("disabled", true);
|
||||
expect(pill).not.toBeNull();
|
||||
expect(removeButton).not.toBeNull();
|
||||
|
||||
click(pill!);
|
||||
click(removeButton!);
|
||||
|
||||
expect(openExternalUrlMock).not.toHaveBeenCalled();
|
||||
expect(latestAttachments).toEqual([{ kind: "github_pr", item: prItem }]);
|
||||
});
|
||||
|
||||
it("closes the image lightbox when its close button is pressed", () => {
|
||||
const image = imageAttachment("img-close");
|
||||
renderComposer({ initialAttachments: [{ kind: "image", metadata: image }] });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { View, Pressable, Text, ActivityIndicator, Image } from "react-native";
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||
import { useState, useEffect, useRef, useCallback, useMemo, memo } from "react";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
Paperclip,
|
||||
} from "lucide-react-native";
|
||||
import Animated from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||
import { generateMessageId, type StreamItem } from "@/types/stream";
|
||||
@@ -33,7 +32,7 @@ import {
|
||||
type MessageInputRef,
|
||||
type AttachmentMenuItem,
|
||||
} from "./message-input";
|
||||
import { Theme } from "@/styles/theme";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
import { focusWithRetries } from "@/utils/web-focus";
|
||||
@@ -70,6 +69,7 @@ import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachm
|
||||
import { AttachmentPill } from "@/components/attachment-pill";
|
||||
import { AttachmentLightbox } from "@/components/attachment-lightbox";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { useIsDictationReady } from "@/hooks/use-is-dictation-ready";
|
||||
|
||||
type QueuedMessage = {
|
||||
id: string;
|
||||
@@ -100,8 +100,10 @@ interface ComposerProps {
|
||||
allowEmptySubmit?: boolean;
|
||||
/** Optional accessibility label for the primary submit button. */
|
||||
submitButtonAccessibilityLabel?: string;
|
||||
submitIcon?: "arrow" | "return";
|
||||
/** Externally controlled loading state. When true, disables the submit button. */
|
||||
isSubmitLoading?: boolean;
|
||||
submitBehavior?: "clear" | "preserve-and-lock";
|
||||
/** When true, blurs the input immediately when submitting. */
|
||||
blurOnSubmit?: boolean;
|
||||
value: string;
|
||||
@@ -132,6 +134,7 @@ interface ComposerProps {
|
||||
const EMPTY_ARRAY: readonly QueuedMessage[] = [];
|
||||
const DESKTOP_MESSAGE_PLACEHOLDER = "Message the agent, tag @files, or use /commands and /skills";
|
||||
const MOBILE_MESSAGE_PLACEHOLDER = "Message, @files, /commands";
|
||||
const StableMessageInput = memo(MessageInput);
|
||||
|
||||
export function Composer({
|
||||
agentId,
|
||||
@@ -141,7 +144,9 @@ export function Composer({
|
||||
hasExternalContent = false,
|
||||
allowEmptySubmit = false,
|
||||
submitButtonAccessibilityLabel,
|
||||
submitIcon = "arrow",
|
||||
isSubmitLoading = false,
|
||||
submitBehavior = "clear",
|
||||
blurOnSubmit = false,
|
||||
value,
|
||||
onChangeText,
|
||||
@@ -163,19 +168,20 @@ export function Composer({
|
||||
markScrollInvestigationRender(`Composer:${serverId}:${agentId}`);
|
||||
const { theme } = useUnistyles();
|
||||
const buttonIconSize = isWeb ? theme.iconSize.md : theme.iconSize.lg;
|
||||
const insets = useSafeAreaInsets();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const agentDirectoryStatus = useHostRuntimeAgentDirectoryStatus(serverId);
|
||||
const toast = useToast();
|
||||
const toastErrorRef = useRef(toast.error);
|
||||
toastErrorRef.current = toast.error;
|
||||
const voice = useVoiceOptional();
|
||||
const voiceToggleKeys = useShortcutKeys("voice-toggle");
|
||||
const dictationCancelKeys = useShortcutKeys("dictation-cancel");
|
||||
const isDictationReady =
|
||||
isConnected &&
|
||||
(agentDirectoryStatus === "ready" ||
|
||||
agentDirectoryStatus === "revalidating" ||
|
||||
agentDirectoryStatus === "error_after_ready");
|
||||
const isDictationReady = useIsDictationReady({
|
||||
serverId,
|
||||
isConnected,
|
||||
agentDirectoryStatus,
|
||||
});
|
||||
|
||||
const { settings: appSettings } = useAppSettings();
|
||||
|
||||
@@ -218,6 +224,7 @@ export function Composer({
|
||||
const [lightboxMetadata, setLightboxMetadata] = useState<AttachmentMetadata | null>(null);
|
||||
const attachButtonRef = useRef<View | null>(null);
|
||||
const messageInputRef = useRef<MessageInputRef>(null);
|
||||
const isComposerLocked = submitBehavior === "preserve-and-lock" && isSubmitLoading;
|
||||
const keyboardHandlerIdRef = useRef(
|
||||
`message-input:${serverId}:${agentId}:${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
@@ -233,6 +240,8 @@ export function Composer({
|
||||
messageInputRef.current?.focus();
|
||||
},
|
||||
});
|
||||
const autocompleteOnKeyPressRef = useRef(autocomplete.onKeyPress);
|
||||
autocompleteOnKeyPressRef.current = autocomplete.onKeyPress;
|
||||
|
||||
// Clear send error when user edits the input
|
||||
useEffect(() => {
|
||||
@@ -370,66 +379,82 @@ export function Composer({
|
||||
[agentId, serverId, setQueuedMessages],
|
||||
);
|
||||
|
||||
function queueMessage(message: string, attachments: ComposerAttachment[]) {
|
||||
const trimmedMessage = message.trim();
|
||||
if (!trimmedMessage && attachments.length === 0) return;
|
||||
const queueMessage = useCallback(
|
||||
(message: string, attachments: ComposerAttachment[]) => {
|
||||
const trimmedMessage = message.trim();
|
||||
if (!trimmedMessage && attachments.length === 0) return;
|
||||
|
||||
const newItem = {
|
||||
id: generateMessageId(),
|
||||
text: trimmedMessage,
|
||||
attachments,
|
||||
};
|
||||
const newItem = {
|
||||
id: generateMessageId(),
|
||||
text: trimmedMessage,
|
||||
attachments,
|
||||
};
|
||||
|
||||
setQueuedMessages(serverId, (prev: Map<string, QueuedMessage[]>) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, [...(prev.get(agentId) ?? []), newItem]);
|
||||
return next;
|
||||
});
|
||||
setQueuedMessages(serverId, (prev: Map<string, QueuedMessage[]>) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, [...(prev.get(agentId) ?? []), newItem]);
|
||||
return next;
|
||||
});
|
||||
|
||||
setUserInput("");
|
||||
setSelectedAttachments([]);
|
||||
}
|
||||
setUserInput("");
|
||||
setSelectedAttachments([]);
|
||||
},
|
||||
[agentId, serverId, setQueuedMessages, setSelectedAttachments, setUserInput],
|
||||
);
|
||||
|
||||
async function sendMessageWithContent(
|
||||
message: string,
|
||||
attachments: ComposerAttachment[],
|
||||
forceSend?: boolean,
|
||||
) {
|
||||
await submitAgentInput({
|
||||
message,
|
||||
attachments,
|
||||
hasExternalContent,
|
||||
const sendMessageWithContent = useCallback(
|
||||
async (message: string, attachments: ComposerAttachment[], forceSend?: boolean) => {
|
||||
await submitAgentInput({
|
||||
message,
|
||||
attachments,
|
||||
hasExternalContent,
|
||||
allowEmptySubmit,
|
||||
forceSend,
|
||||
submitBehavior,
|
||||
isAgentRunning,
|
||||
// Parent-managed submits are still valid submit paths even when the
|
||||
// transport is disconnected, because the parent decides the failure mode.
|
||||
canSubmit: Boolean(sendAgentMessageRef.current || onSubmitMessageRef.current),
|
||||
queueMessage: ({ message, attachments }) => {
|
||||
queueMessage(message, attachments);
|
||||
},
|
||||
submitMessage: async ({ message, attachments }) => {
|
||||
await submitMessage(message, attachments);
|
||||
},
|
||||
clearDraft,
|
||||
setUserInput,
|
||||
setAttachments: (nextAttachments) => {
|
||||
setSelectedAttachments(nextAttachments);
|
||||
},
|
||||
setSendError,
|
||||
setIsProcessing,
|
||||
onSubmitError: (error) => {
|
||||
console.error("[AgentInput] Failed to send message:", error);
|
||||
},
|
||||
});
|
||||
},
|
||||
[
|
||||
allowEmptySubmit,
|
||||
forceSend,
|
||||
isAgentRunning: agentState.status === "running",
|
||||
// Parent-managed submits are still valid submit paths even when the
|
||||
// transport is disconnected, because the parent decides the failure mode.
|
||||
canSubmit: Boolean(sendAgentMessageRef.current || onSubmitMessageRef.current),
|
||||
queueMessage: ({ message, attachments }) => {
|
||||
queueMessage(message, attachments);
|
||||
},
|
||||
submitMessage: async ({ message, attachments }) => {
|
||||
await submitMessage(message, attachments);
|
||||
},
|
||||
clearDraft,
|
||||
hasExternalContent,
|
||||
isAgentRunning,
|
||||
queueMessage,
|
||||
setSelectedAttachments,
|
||||
setUserInput,
|
||||
setAttachments: (nextAttachments) => {
|
||||
setSelectedAttachments(nextAttachments);
|
||||
},
|
||||
setSendError,
|
||||
setIsProcessing,
|
||||
onSubmitError: (error) => {
|
||||
console.error("[AgentInput] Failed to send message:", error);
|
||||
},
|
||||
});
|
||||
}
|
||||
submitBehavior,
|
||||
submitMessage,
|
||||
],
|
||||
);
|
||||
|
||||
function handleSubmit(payload: MessagePayload) {
|
||||
if (blurOnSubmit) {
|
||||
messageInputRef.current?.blur();
|
||||
}
|
||||
void sendMessageWithContent(payload.text, payload.attachments, payload.forceSend);
|
||||
}
|
||||
const handleSubmit = useCallback(
|
||||
(payload: MessagePayload) => {
|
||||
if (blurOnSubmit) {
|
||||
messageInputRef.current?.blur();
|
||||
}
|
||||
void sendMessageWithContent(payload.text, payload.attachments, payload.forceSend);
|
||||
},
|
||||
[blurOnSubmit, sendMessageWithContent],
|
||||
);
|
||||
|
||||
const handlePickImage = useCallback(async () => {
|
||||
const result = await pickImages();
|
||||
@@ -481,6 +506,18 @@ export function Composer({
|
||||
}
|
||||
}, [isAgentRunning, isConnected]);
|
||||
|
||||
const handleCancelAgent = useCallback(() => {
|
||||
if (!isAgentRunning || isCancellingAgent) {
|
||||
return;
|
||||
}
|
||||
if (!isConnected || !client) {
|
||||
return;
|
||||
}
|
||||
setIsCancellingAgent(true);
|
||||
void client.cancelAgent(agentIdRef.current);
|
||||
messageInputRef.current?.focus();
|
||||
}, [client, isAgentRunning, isCancellingAgent, isConnected]);
|
||||
|
||||
const handleKeyboardAction = useCallback(
|
||||
(action: KeyboardActionDefinition): boolean => {
|
||||
if (!isPaneFocused) {
|
||||
@@ -547,18 +584,6 @@ export function Composer({
|
||||
mode: "translate",
|
||||
});
|
||||
|
||||
function handleCancelAgent() {
|
||||
if (!isAgentRunning || isCancellingAgent) {
|
||||
return;
|
||||
}
|
||||
if (!isConnected || !client) {
|
||||
return;
|
||||
}
|
||||
setIsCancellingAgent(true);
|
||||
void client.cancelAgent(agentIdRef.current);
|
||||
messageInputRef.current?.focus();
|
||||
}
|
||||
|
||||
const isVoiceModeForAgent = voice?.isVoiceModeForAgent(serverId, agentId) ?? false;
|
||||
|
||||
const handleToggleRealtimeVoice = useCallback(() => {
|
||||
@@ -576,10 +601,10 @@ export function Composer({
|
||||
const message =
|
||||
error instanceof Error ? error.message : typeof error === "string" ? error : null;
|
||||
if (message && message.trim().length > 0) {
|
||||
toast.error(message);
|
||||
toastErrorRef.current(message);
|
||||
}
|
||||
});
|
||||
}, [agentId, hasAgent, isConnected, serverId, toast, voice]);
|
||||
}, [agentId, hasAgent, isConnected, serverId, voice]);
|
||||
|
||||
function handleEditQueuedMessage(id: string) {
|
||||
const item = queuedMessages.find((q) => q.id === id);
|
||||
@@ -606,9 +631,12 @@ export function Composer({
|
||||
}
|
||||
}
|
||||
|
||||
const handleQueue = useCallback((payload: MessagePayload) => {
|
||||
queueMessage(payload.text, payload.attachments);
|
||||
}, []);
|
||||
const handleQueue = useCallback(
|
||||
(payload: MessagePayload) => {
|
||||
queueMessage(payload.text, payload.attachments);
|
||||
},
|
||||
[queueMessage],
|
||||
);
|
||||
|
||||
const hasSendableContent = userInput.trim().length > 0 || selectedAttachments.length > 0;
|
||||
|
||||
@@ -627,91 +655,109 @@ export function Composer({
|
||||
return true;
|
||||
}
|
||||
|
||||
return autocomplete.onKeyPress(event);
|
||||
return autocompleteOnKeyPressRef.current(event);
|
||||
},
|
||||
[hasSendableContent, isAgentRunning, isCancellingAgent, isConnected, handleCancelAgent],
|
||||
);
|
||||
|
||||
const cancelButton = useMemo(
|
||||
() =>
|
||||
isAgentRunning && !hasSendableContent && !isProcessing ? (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
onPress={handleCancelAgent}
|
||||
disabled={!isConnected || isCancellingAgent}
|
||||
accessibilityLabel={isCancellingAgent ? "Canceling agent" : "Stop agent"}
|
||||
accessibilityRole="button"
|
||||
style={[
|
||||
styles.cancelButton as any,
|
||||
(!isConnected || isCancellingAgent ? styles.buttonDisabled : undefined) as any,
|
||||
]}
|
||||
>
|
||||
{isCancellingAgent ? (
|
||||
<ActivityIndicator size="small" color="white" />
|
||||
) : (
|
||||
<Square size={buttonIconSize} color="white" fill="white" />
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>Interrupt</Text>
|
||||
{dictationCancelKeys ? (
|
||||
<Shortcut chord={dictationCancelKeys} style={styles.tooltipShortcut} />
|
||||
) : null}
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null,
|
||||
[
|
||||
autocomplete,
|
||||
buttonIconSize,
|
||||
dictationCancelKeys,
|
||||
handleCancelAgent,
|
||||
hasSendableContent,
|
||||
isAgentRunning,
|
||||
isCancellingAgent,
|
||||
isConnected,
|
||||
handleCancelAgent,
|
||||
isProcessing,
|
||||
],
|
||||
);
|
||||
|
||||
const cancelButton =
|
||||
isAgentRunning && !hasSendableContent && !isProcessing ? (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
onPress={handleCancelAgent}
|
||||
disabled={!isConnected || isCancellingAgent}
|
||||
accessibilityLabel={isCancellingAgent ? "Canceling agent" : "Stop agent"}
|
||||
accessibilityRole="button"
|
||||
style={[
|
||||
styles.cancelButton as any,
|
||||
(!isConnected || isCancellingAgent ? styles.buttonDisabled : undefined) as any,
|
||||
]}
|
||||
>
|
||||
{isCancellingAgent ? (
|
||||
<ActivityIndicator size="small" color="white" />
|
||||
) : (
|
||||
<Square size={buttonIconSize} color="white" fill="white" />
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>Interrupt</Text>
|
||||
{dictationCancelKeys ? (
|
||||
<Shortcut chord={dictationCancelKeys} style={styles.tooltipShortcut} />
|
||||
) : null}
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null;
|
||||
|
||||
const showVoiceModeButton = !isVoiceModeForAgent && hasAgent;
|
||||
const rightContent =
|
||||
showVoiceModeButton || cancelButton ? (
|
||||
<View style={styles.rightControls}>
|
||||
{showVoiceModeButton ? (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
onPress={handleToggleRealtimeVoice}
|
||||
disabled={!isConnected || voice?.isVoiceSwitching}
|
||||
accessibilityLabel="Enable Voice mode"
|
||||
accessibilityRole="button"
|
||||
style={({ hovered }) => [
|
||||
styles.realtimeVoiceButton as any,
|
||||
(hovered ? styles.iconButtonHovered : undefined) as any,
|
||||
(!isConnected || voice?.isVoiceSwitching
|
||||
? styles.buttonDisabled
|
||||
: undefined) as any,
|
||||
]}
|
||||
>
|
||||
{({ hovered }) =>
|
||||
voice?.isVoiceSwitching ? (
|
||||
<ActivityIndicator size="small" color="white" />
|
||||
) : (
|
||||
<AudioLines
|
||||
size={buttonIconSize}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>Voice mode</Text>
|
||||
{voiceToggleKeys ? (
|
||||
<Shortcut chord={voiceToggleKeys} style={styles.tooltipShortcut} />
|
||||
) : null}
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{cancelButton}
|
||||
</View>
|
||||
) : null;
|
||||
const rightContent = useMemo(
|
||||
() =>
|
||||
showVoiceModeButton || cancelButton ? (
|
||||
<View style={styles.rightControls}>
|
||||
{showVoiceModeButton ? (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
onPress={handleToggleRealtimeVoice}
|
||||
disabled={!isConnected || voice?.isVoiceSwitching}
|
||||
accessibilityLabel="Enable Voice mode"
|
||||
accessibilityRole="button"
|
||||
style={({ hovered }) => [
|
||||
styles.realtimeVoiceButton as any,
|
||||
(hovered ? styles.iconButtonHovered : undefined) as any,
|
||||
(!isConnected || voice?.isVoiceSwitching
|
||||
? styles.buttonDisabled
|
||||
: undefined) as any,
|
||||
]}
|
||||
>
|
||||
{({ hovered }) =>
|
||||
voice?.isVoiceSwitching ? (
|
||||
<ActivityIndicator size="small" color="white" />
|
||||
) : (
|
||||
<AudioLines
|
||||
size={buttonIconSize}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>Voice mode</Text>
|
||||
{voiceToggleKeys ? (
|
||||
<Shortcut chord={voiceToggleKeys} style={styles.tooltipShortcut} />
|
||||
) : null}
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{cancelButton}
|
||||
</View>
|
||||
) : null,
|
||||
[
|
||||
buttonIconSize,
|
||||
cancelButton,
|
||||
handleToggleRealtimeVoice,
|
||||
isConnected,
|
||||
showVoiceModeButton,
|
||||
theme.colors.foreground,
|
||||
theme.colors.foregroundMuted,
|
||||
voice,
|
||||
voiceToggleKeys,
|
||||
],
|
||||
);
|
||||
|
||||
const hasContextWindowMeter =
|
||||
typeof agentState.contextWindowMaxTokens === "number" &&
|
||||
@@ -719,15 +765,18 @@ export function Composer({
|
||||
const contextWindowMaxTokens = hasContextWindowMeter ? agentState.contextWindowMaxTokens : null;
|
||||
const contextWindowUsedTokens = hasContextWindowMeter ? agentState.contextWindowUsedTokens : null;
|
||||
|
||||
const beforeVoiceContent = (
|
||||
<View style={styles.contextWindowMeterSlot}>
|
||||
{contextWindowMaxTokens !== null && contextWindowUsedTokens !== null ? (
|
||||
<ContextWindowMeter
|
||||
maxTokens={contextWindowMaxTokens}
|
||||
usedTokens={contextWindowUsedTokens}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
const beforeVoiceContent = useMemo(
|
||||
() => (
|
||||
<View style={styles.contextWindowMeterSlot}>
|
||||
{contextWindowMaxTokens !== null && contextWindowUsedTokens !== null ? (
|
||||
<ContextWindowMeter
|
||||
maxTokens={contextWindowMaxTokens}
|
||||
usedTokens={contextWindowUsedTokens}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
),
|
||||
[contextWindowMaxTokens, contextWindowUsedTokens],
|
||||
);
|
||||
|
||||
const githubSearchQueryTrimmed = githubSearchQuery.trim();
|
||||
@@ -802,20 +851,39 @@ export function Composer({
|
||||
[setSelectedAttachments, setGithubSearchQuery, setIsGithubPickerOpen],
|
||||
);
|
||||
|
||||
const leftContent =
|
||||
resolveStatusControlMode(statusControls) === "draft" && statusControls ? (
|
||||
<DraftAgentStatusBar {...statusControls} />
|
||||
) : (
|
||||
<AgentStatusBar agentId={agentId} serverId={serverId} onDropdownClose={focusInput} />
|
||||
);
|
||||
const leftContent = useMemo(
|
||||
() =>
|
||||
resolveStatusControlMode(statusControls) === "draft" && statusControls ? (
|
||||
<DraftAgentStatusBar {...statusControls} />
|
||||
) : (
|
||||
<AgentStatusBar agentId={agentId} serverId={serverId} onDropdownClose={focusInput} />
|
||||
),
|
||||
[agentId, focusInput, serverId, statusControls],
|
||||
);
|
||||
|
||||
const handleAttachButtonRef = useCallback((node: View | null) => {
|
||||
attachButtonRef.current = node;
|
||||
}, []);
|
||||
|
||||
const handleSelectionChange = useCallback((selection: { start: number; end: number }) => {
|
||||
setCursorIndex(selection.start);
|
||||
}, []);
|
||||
|
||||
const handleFocusChange = useCallback(
|
||||
(focused: boolean) => {
|
||||
setIsMessageInputFocused(focused);
|
||||
if (focused) {
|
||||
onAttentionInputFocus?.();
|
||||
}
|
||||
},
|
||||
[onAttentionInputFocus],
|
||||
);
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[styles.container, { paddingBottom: insets.bottom }, keyboardAnimatedStyle]}
|
||||
>
|
||||
<Animated.View style={[styles.container, keyboardAnimatedStyle]}>
|
||||
<AttachmentLightbox metadata={lightboxMetadata} onClose={() => setLightboxMetadata(null)} />
|
||||
{/* Input area */}
|
||||
<View style={styles.inputAreaContainer}>
|
||||
<View style={[styles.inputAreaContainer, isComposerLocked && styles.inputAreaLocked]}>
|
||||
<View style={styles.inputAreaContent}>
|
||||
{/* Queue list */}
|
||||
{queuedMessages.length > 0 && (
|
||||
@@ -874,6 +942,7 @@ export function Composer({
|
||||
onRemove={() => handleRemoveAttachment(index)}
|
||||
openAccessibilityLabel="Open image attachment"
|
||||
removeAccessibilityLabel="Remove image attachment"
|
||||
disabled={isComposerLocked}
|
||||
>
|
||||
<ImageAttachmentThumbnail image={attachment.metadata} />
|
||||
</AttachmentPill>
|
||||
@@ -890,6 +959,7 @@ export function Composer({
|
||||
onRemove={() => handleRemoveAttachment(index)}
|
||||
openAccessibilityLabel={`Open ${kindLabel} #${item.number}`}
|
||||
removeAccessibilityLabel={`Remove ${kindLabel} #${item.number}`}
|
||||
disabled={isComposerLocked}
|
||||
>
|
||||
<View style={styles.githubPillBody}>
|
||||
<View style={styles.githubPillIcon}>
|
||||
@@ -916,7 +986,7 @@ export function Composer({
|
||||
) : null}
|
||||
|
||||
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
|
||||
<MessageInput
|
||||
<StableMessageInput
|
||||
ref={messageInputRef}
|
||||
value={userInput}
|
||||
onChangeText={setUserInput}
|
||||
@@ -924,14 +994,13 @@ export function Composer({
|
||||
hasExternalContent={hasExternalContent}
|
||||
allowEmptySubmit={allowEmptySubmit}
|
||||
submitButtonAccessibilityLabel={submitButtonAccessibilityLabel}
|
||||
submitIcon={submitIcon}
|
||||
isSubmitDisabled={isProcessing || isSubmitLoading}
|
||||
isSubmitLoading={isProcessing || isSubmitLoading}
|
||||
attachments={selectedAttachments}
|
||||
cwd={cwd}
|
||||
attachmentMenuItems={attachmentMenuItems}
|
||||
onAttachButtonRef={(node) => {
|
||||
attachButtonRef.current = node;
|
||||
}}
|
||||
onAttachButtonRef={handleAttachButtonRef}
|
||||
onAddImages={addImages}
|
||||
client={client}
|
||||
isReadyForDictation={isDictationReady}
|
||||
@@ -950,15 +1019,8 @@ export function Composer({
|
||||
onQueue={handleQueue}
|
||||
onSubmitLoadingPress={isAgentRunning ? handleCancelAgent : undefined}
|
||||
onKeyPress={handleCommandKeyPress}
|
||||
onSelectionChange={(selection) => {
|
||||
setCursorIndex(selection.start);
|
||||
}}
|
||||
onFocusChange={(focused) => {
|
||||
setIsMessageInputFocused(focused);
|
||||
if (focused) {
|
||||
onAttentionInputFocus?.();
|
||||
}
|
||||
}}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onFocusChange={handleFocusChange}
|
||||
onHeightChange={onComposerHeightChange}
|
||||
inputWrapperStyle={inputWrapperStyle}
|
||||
/>
|
||||
@@ -1043,6 +1105,9 @@ const styles = StyleSheet.create(((theme: Theme) => ({
|
||||
overflow: "visible",
|
||||
padding: theme.spacing[4],
|
||||
},
|
||||
inputAreaLocked: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
inputAreaContent: {
|
||||
width: "100%",
|
||||
maxWidth: MAX_CONTENT_WIDTH,
|
||||
|
||||
@@ -6,11 +6,20 @@ interface DiffStatProps {
|
||||
deletions: number;
|
||||
}
|
||||
|
||||
const compactFormatter = new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
|
||||
export function formatDiffCount(value: number): string {
|
||||
return compactFormatter.format(value).toLowerCase();
|
||||
}
|
||||
|
||||
export function DiffStat({ additions, deletions }: DiffStatProps) {
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.additions}>+{additions.toLocaleString()}</Text>
|
||||
<Text style={styles.deletions}>-{deletions.toLocaleString()}</Text>
|
||||
<Text style={styles.additions}>+{formatDiffCount(additions)}</Text>
|
||||
<Text style={styles.deletions}>-{formatDiffCount(deletions)}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -25,11 +34,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
additions: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.palette.green[400],
|
||||
color: theme.colors.diffAddition,
|
||||
},
|
||||
deletions: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.palette.red[500],
|
||||
color: theme.colors.diffDeletion,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -142,7 +142,7 @@ export function DraggableList<T>({
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
distance: 6,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
|
||||
@@ -12,8 +12,12 @@ import Animated, { useAnimatedStyle, useSharedValue, runOnJS } from "react-nativ
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { X } from "lucide-react-native";
|
||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||
import { PrPane } from "./pr-pane";
|
||||
import { usePrPaneData } from "@/hooks/use-pr-pane-data";
|
||||
import {
|
||||
usePanelStore,
|
||||
selectIsFileExplorerOpen,
|
||||
MIN_EXPLORER_SIDEBAR_WIDTH,
|
||||
MAX_EXPLORER_SIDEBAR_WIDTH,
|
||||
type ExplorerTab,
|
||||
@@ -49,9 +53,9 @@ export function ExplorerSidebar({
|
||||
const isScreenFocused = useIsFocused();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
||||
const closeToAgent = usePanelStore((state) => state.closeToAgent);
|
||||
const isOpen = usePanelStore((state) => selectIsFileExplorerOpen(state, { isCompact: isMobile }));
|
||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||
const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer);
|
||||
const explorerTab = usePanelStore((state) => state.explorerTab);
|
||||
const explorerWidth = usePanelStore((state) => state.explorerWidth);
|
||||
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
||||
@@ -78,9 +82,6 @@ export function ExplorerSidebar({
|
||||
}
|
||||
}, [explorerWidth, isMobile, setExplorerWidth, viewportWidth]);
|
||||
|
||||
// Derive isOpen from the unified panel state
|
||||
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
||||
|
||||
const {
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
@@ -101,18 +102,20 @@ export function ExplorerSidebar({
|
||||
logExplorerSidebar("handleClose", {
|
||||
reason,
|
||||
isOpen,
|
||||
mobileView,
|
||||
desktopFileExplorerOpen,
|
||||
});
|
||||
closeToAgent();
|
||||
if (isMobile) {
|
||||
showMobileAgent();
|
||||
return;
|
||||
}
|
||||
closeDesktopFileExplorer();
|
||||
},
|
||||
[closeToAgent, desktopFileExplorerOpen, isOpen, mobileView],
|
||||
[closeDesktopFileExplorer, isMobile, isOpen, showMobileAgent],
|
||||
);
|
||||
|
||||
const handleCloseFromGesture = useCallback(() => {
|
||||
gestureAnimatingRef.current = true;
|
||||
closeToAgent();
|
||||
}, [closeToAgent, gestureAnimatingRef]);
|
||||
showMobileAgent();
|
||||
}, [gestureAnimatingRef, showMobileAgent]);
|
||||
|
||||
const enableSidebarCloseGesture = isMobile && isOpen;
|
||||
|
||||
@@ -289,6 +292,7 @@ export function ExplorerSidebar({
|
||||
workspaceRoot={workspaceRoot}
|
||||
isGit={isGit}
|
||||
isMobile={isMobile}
|
||||
isOpen={isOpen}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
</Animated.View>
|
||||
@@ -321,6 +325,7 @@ export function ExplorerSidebar({
|
||||
workspaceRoot={workspaceRoot}
|
||||
isGit={isGit}
|
||||
isMobile={false}
|
||||
isOpen={isOpen}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
</View>
|
||||
@@ -337,6 +342,7 @@ interface SidebarContentProps {
|
||||
workspaceRoot: string;
|
||||
isGit: boolean;
|
||||
isMobile: boolean;
|
||||
isOpen: boolean;
|
||||
onOpenFile?: (filePath: string) => void;
|
||||
}
|
||||
|
||||
@@ -349,11 +355,24 @@ function SidebarContent({
|
||||
workspaceRoot,
|
||||
isGit,
|
||||
isMobile,
|
||||
isOpen,
|
||||
onOpenFile,
|
||||
}: SidebarContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const padding = useWindowControlsPadding("explorerSidebar");
|
||||
const resolvedTab: ExplorerTab = !isGit && activeTab === "changes" ? "files" : activeTab;
|
||||
const canQueryPullRequest = isGit && Boolean(workspaceRoot);
|
||||
const prPane = usePrPaneData({
|
||||
serverId,
|
||||
cwd: workspaceRoot,
|
||||
enabled: canQueryPullRequest && isOpen,
|
||||
timelineEnabled: activeTab === "pr" && canQueryPullRequest && isOpen,
|
||||
});
|
||||
const hasPullRequest = prPane.prNumber !== null;
|
||||
const requestedTab: ExplorerTab =
|
||||
!isGit && (activeTab === "changes" || activeTab === "pr") ? "files" : activeTab;
|
||||
const resolvedTab: ExplorerTab =
|
||||
requestedTab === "pr" && !hasPullRequest ? "changes" : requestedTab;
|
||||
const prTabLabel = prPane.prNumber === null ? "" : `#${prPane.prNumber}`;
|
||||
|
||||
return (
|
||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||
@@ -381,6 +400,23 @@ function SidebarContent({
|
||||
Files
|
||||
</Text>
|
||||
</Pressable>
|
||||
{isGit && hasPullRequest && (
|
||||
<Pressable
|
||||
testID="explorer-tab-pr"
|
||||
style={[styles.tab, resolvedTab === "pr" && styles.tabActive]}
|
||||
onPress={() => onTabPress("pr")}
|
||||
>
|
||||
<GitHubIcon
|
||||
size={13}
|
||||
color={
|
||||
resolvedTab === "pr" ? theme.colors.foreground : theme.colors.foregroundMuted
|
||||
}
|
||||
/>
|
||||
<Text style={[styles.tabText, resolvedTab === "pr" && styles.tabTextActive]}>
|
||||
{prTabLabel}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.headerRightSection}>
|
||||
{isMobile && (
|
||||
@@ -409,6 +445,7 @@ function SidebarContent({
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
)}
|
||||
{resolvedTab === "pr" && prPane.data && <PrPane data={prPane.data} />}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -8,18 +8,8 @@ import {
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Gesture } from "react-native-gesture-handler";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import Animated, {
|
||||
cancelAnimation,
|
||||
Easing,
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withRepeat,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
@@ -34,6 +24,7 @@ import {
|
||||
X,
|
||||
} from "lucide-react-native";
|
||||
import { getFileIconSvg } from "@/components/material-file-icons";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
@@ -195,35 +186,6 @@ export function FileExplorerPane({
|
||||
});
|
||||
}, [hasWorkspaceScope, requestDirectoryListing, workspaceStateKey]);
|
||||
|
||||
// Expand ancestor directories when a file is selected (e.g., from an inline path click)
|
||||
useEffect(() => {
|
||||
if (!selectedEntryPath || !workspaceStateKey) {
|
||||
return;
|
||||
}
|
||||
const parentDir = getParentDirectory(selectedEntryPath);
|
||||
const ancestors = getAncestorDirectories(parentDir);
|
||||
const newPaths = ancestors.filter((path) => !expandedPaths.has(path));
|
||||
if (newPaths.length === 0) {
|
||||
return;
|
||||
}
|
||||
setExpandedPathsForWorkspace(workspaceStateKey, [...Array.from(expandedPaths), ...newPaths]);
|
||||
newPaths.forEach((path) => {
|
||||
if (!directories.has(path)) {
|
||||
void requestDirectoryListing(path, {
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [
|
||||
directories,
|
||||
workspaceStateKey,
|
||||
expandedPaths,
|
||||
requestDirectoryListing,
|
||||
selectedEntryPath,
|
||||
setExpandedPathsForWorkspace,
|
||||
]);
|
||||
|
||||
const handleToggleDirectory = useCallback(
|
||||
(entry: ExplorerEntry) => {
|
||||
if (!workspaceStateKey) {
|
||||
@@ -341,48 +303,6 @@ export function FileExplorerPane({
|
||||
const handleRefresh = useCallback(() => {
|
||||
void refetchExplorer();
|
||||
}, [refetchExplorer]);
|
||||
const refreshIconRotation = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRefreshFetching) {
|
||||
refreshIconRotation.value = 0;
|
||||
refreshIconRotation.value = withRepeat(
|
||||
withTiming(360, {
|
||||
duration: 700,
|
||||
easing: Easing.linear,
|
||||
}),
|
||||
-1,
|
||||
false,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
cancelAnimation(refreshIconRotation);
|
||||
const remainder = refreshIconRotation.value % 360;
|
||||
if (Math.abs(remainder) < 0.001) {
|
||||
refreshIconRotation.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const remaining = 360 - remainder;
|
||||
const duration = Math.max(80, Math.round((remaining / 360) * 700));
|
||||
refreshIconRotation.value = withTiming(
|
||||
360,
|
||||
{
|
||||
duration,
|
||||
easing: Easing.linear,
|
||||
},
|
||||
(finished) => {
|
||||
if (finished) {
|
||||
refreshIconRotation.value = 0;
|
||||
}
|
||||
},
|
||||
);
|
||||
}, [isRefreshFetching, refreshIconRotation]);
|
||||
|
||||
const refreshIconAnimatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ rotate: `${refreshIconRotation.value}deg` }],
|
||||
}));
|
||||
|
||||
const currentSortLabel = SORT_OPTIONS.find((opt) => opt.value === sortOption)?.label ?? "Name";
|
||||
|
||||
@@ -592,11 +512,15 @@ export function FileExplorerPane({
|
||||
(hovered || pressed) && styles.iconButtonHovered,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Refresh files"
|
||||
accessibilityLabel={isRefreshFetching ? "Refreshing files" : "Refresh files"}
|
||||
>
|
||||
<Animated.View style={[styles.refreshIcon, refreshIconAnimatedStyle]}>
|
||||
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Animated.View>
|
||||
<View style={styles.refreshIcon}>
|
||||
{isRefreshFetching ? (
|
||||
<LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
<FlatList
|
||||
@@ -682,35 +606,6 @@ function buildTreeRows({
|
||||
return rows;
|
||||
}
|
||||
|
||||
function getParentDirectory(path: string): string {
|
||||
const normalized = path.replace(/\/+$/, "");
|
||||
if (!normalized || normalized === ".") {
|
||||
return ".";
|
||||
}
|
||||
const lastSlash = normalized.lastIndexOf("/");
|
||||
if (lastSlash === -1) {
|
||||
return ".";
|
||||
}
|
||||
const dir = normalized.slice(0, lastSlash);
|
||||
return dir.length > 0 ? dir : ".";
|
||||
}
|
||||
|
||||
function getAncestorDirectories(directory: string): string[] {
|
||||
const trimmed = directory.replace(/^\.\/+/, "").replace(/\/+$/, "");
|
||||
if (!trimmed || trimmed === ".") {
|
||||
return ["."];
|
||||
}
|
||||
|
||||
const parts = trimmed.split("/").filter(Boolean);
|
||||
const ancestors: string[] = ["."];
|
||||
let acc = "";
|
||||
for (const part of parts) {
|
||||
acc = acc ? `${acc}/${part}` : part;
|
||||
ancestors.push(acc);
|
||||
}
|
||||
return ancestors;
|
||||
}
|
||||
|
||||
function getErrorRecoveryPath(state: AgentFileExplorerState | undefined): string {
|
||||
if (!state) {
|
||||
return ".";
|
||||
|
||||
@@ -16,9 +16,10 @@ import type { GitAction, GitActions } from "@/components/git-actions-policy";
|
||||
|
||||
interface GitActionsSplitButtonProps {
|
||||
gitActions: GitActions;
|
||||
hideLabels?: boolean;
|
||||
}
|
||||
|
||||
export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps) {
|
||||
export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSplitButtonProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const toast = useToast();
|
||||
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
|
||||
@@ -68,9 +69,11 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
|
||||
) : (
|
||||
<View style={styles.splitButtonContent}>
|
||||
{gitActions.primary.icon}
|
||||
<Text style={styles.splitButtonText}>
|
||||
{getActionDisplayLabel(gitActions.primary)}
|
||||
</Text>
|
||||
{!hideLabels && (
|
||||
<Text style={styles.splitButtonText}>
|
||||
{getActionDisplayLabel(gitActions.primary)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
@@ -661,10 +661,8 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
const {
|
||||
status,
|
||||
isLoading: isStatusLoading,
|
||||
isFetching: isStatusFetching,
|
||||
isError: isStatusError,
|
||||
error: statusError,
|
||||
refresh: refreshStatus,
|
||||
} = useCheckoutStatusQuery({ serverId, cwd });
|
||||
const gitStatus = status && status.isGit ? status : null;
|
||||
const isGit = Boolean(gitStatus);
|
||||
@@ -683,10 +681,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
files,
|
||||
payloadError: diffPayloadError,
|
||||
isLoading: isDiffLoading,
|
||||
isFetching: isDiffFetching,
|
||||
isError: isDiffError,
|
||||
error: diffError,
|
||||
refresh: refreshDiff,
|
||||
} = useCheckoutDiffQuery({
|
||||
serverId,
|
||||
cwd,
|
||||
@@ -699,14 +693,11 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
status: prStatus,
|
||||
githubFeaturesEnabled,
|
||||
payloadError: prPayloadError,
|
||||
refresh: refreshPrStatus,
|
||||
} = useCheckoutPrStatusQuery({
|
||||
serverId,
|
||||
cwd,
|
||||
enabled: isGit,
|
||||
});
|
||||
// Track user-initiated refresh to avoid iOS RefreshControl animation on background fetches
|
||||
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
||||
const normalizedWorkspaceRoot = useMemo(() => cwd.trim(), [cwd]);
|
||||
const workspaceStateKey = useMemo(
|
||||
() =>
|
||||
@@ -732,13 +723,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
const headerHeightByPathRef = useRef<Record<string, number>>({});
|
||||
const bodyHeightByPathRef = useRef<Record<string, number>>({});
|
||||
const defaultHeaderHeightRef = useRef<number>(44);
|
||||
const handleRefresh = useCallback(() => {
|
||||
setIsManualRefresh(true);
|
||||
void refreshDiff();
|
||||
void refreshStatus();
|
||||
void refreshPrStatus();
|
||||
}, [refreshDiff, refreshStatus, refreshPrStatus]);
|
||||
|
||||
const shipDefaultStorageKey = useMemo(() => {
|
||||
if (!gitStatus?.repoRoot) {
|
||||
return null;
|
||||
@@ -901,13 +885,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
}
|
||||
}, [allExpanded, files, setDiffExpandedPathsForWorkspace, workspaceStateKey]);
|
||||
|
||||
// Reset manual refresh flag when fetch completes
|
||||
useEffect(() => {
|
||||
if (!(isDiffFetching || isStatusFetching) && isManualRefresh) {
|
||||
setIsManualRefresh(false);
|
||||
}
|
||||
}, [isDiffFetching, isStatusFetching, isManualRefresh]);
|
||||
|
||||
// Clear diff mode override when auto mode changes (e.g., after commit)
|
||||
useEffect(() => {
|
||||
setDiffModeOverride(null);
|
||||
@@ -1092,9 +1069,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
);
|
||||
|
||||
const hasChanges = files.length > 0;
|
||||
const diffErrorMessage =
|
||||
diffPayloadError?.message ??
|
||||
(isDiffError && diffError instanceof Error ? diffError.message : null);
|
||||
const diffErrorMessage = diffPayloadError?.message ?? null;
|
||||
const prErrorMessage = githubFeaturesEnabled ? (prPayloadError?.message ?? null) : null;
|
||||
const branchLabel =
|
||||
gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD"
|
||||
@@ -1199,8 +1174,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
onContentSizeChange={scrollbar.onContentSizeChange}
|
||||
scrollEventThrottle={16}
|
||||
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||
onRefresh={handleRefresh}
|
||||
refreshing={isManualRefresh && isDiffFetching}
|
||||
// Mixed-height rows (header + potentially very large body) are prone to clipping artifacts.
|
||||
// Keep a larger render window and disable clipping to avoid bodies disappearing mid-scroll.
|
||||
removeClippedSubviews={false}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { PanelLeft } from "lucide-react-native";
|
||||
import { ScreenHeader } from "./screen-header";
|
||||
import { ScreenTitle } from "./screen-title";
|
||||
import { HeaderToggleButton } from "./header-toggle-button";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
|
||||
@@ -46,18 +46,16 @@ export function SidebarMenuToggle({
|
||||
}: SidebarMenuToggleProps = {}) {
|
||||
const { theme } = useUnistyles();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
|
||||
const isOpen = usePanelStore((state) => selectIsAgentListOpen(state, { isCompact: isMobile }));
|
||||
const toggleAgentListForLayout = usePanelStore((state) => state.toggleAgentListForLayout);
|
||||
const toggleShortcutKeys = getShortcutOs() === "mac" ? ["mod", "B"] : ["mod", "."];
|
||||
|
||||
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
|
||||
const menuIconColor =
|
||||
!isMobile && isOpen ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||
|
||||
return (
|
||||
<HeaderToggleButton
|
||||
onPress={toggleAgentList}
|
||||
onPress={() => toggleAgentListForLayout({ isCompact: isMobile })}
|
||||
tooltipLabel="Toggle sidebar"
|
||||
tooltipKeys={toggleShortcutKeys}
|
||||
tooltipSide={tooltipSide}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { LayoutChangeEvent } from "react-native";
|
||||
import { View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
@@ -17,6 +18,7 @@ interface ScreenHeaderProps {
|
||||
leftStyle?: StyleProp<ViewStyle>;
|
||||
rightStyle?: StyleProp<ViewStyle>;
|
||||
borderless?: boolean;
|
||||
onRowLayout?: (event: LayoutChangeEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,6 +31,7 @@ export function ScreenHeader({
|
||||
leftStyle,
|
||||
rightStyle,
|
||||
borderless,
|
||||
onRowLayout,
|
||||
}: ScreenHeaderProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
@@ -42,6 +45,7 @@ export function ScreenHeader({
|
||||
<View style={styles.header}>
|
||||
<View style={[styles.inner, { paddingTop: insets.top + topPadding }]}>
|
||||
<View
|
||||
onLayout={onRowLayout}
|
||||
style={[
|
||||
styles.row,
|
||||
{
|
||||
@@ -84,11 +88,13 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
minWidth: 0,
|
||||
},
|
||||
right: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
flexShrink: 0,
|
||||
},
|
||||
borderless: {
|
||||
borderBottomColor: "transparent",
|
||||
|
||||
@@ -25,6 +25,7 @@ export function ScreenTitle({ children, numberOfLines = 1, testID, style }: Scre
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
text: {
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: {
|
||||
xs: "400",
|
||||
|
||||
248
packages/app/src/components/left-sidebar.test.tsx
Normal file
248
packages/app/src/components/left-sidebar.test.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import React from "react";
|
||||
import { act } from "@testing-library/react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { panelState, useSidebarWorkspacesListMock, theme } = vi.hoisted(() => {
|
||||
const panelState = {
|
||||
isOpen: false,
|
||||
showMobileAgent: vi.fn(),
|
||||
};
|
||||
|
||||
return {
|
||||
panelState,
|
||||
useSidebarWorkspacesListMock: vi.fn(),
|
||||
theme: {
|
||||
spacing: { 0: 0, 0.5: 2, 1: 4, 1.5: 6, 2: 8, 3: 12, 4: 16, 5: 20 },
|
||||
iconSize: { sm: 14, md: 18, lg: 22 },
|
||||
borderWidth: { 1: 1 },
|
||||
borderRadius: { sm: 4, md: 6, lg: 8, full: 999 },
|
||||
fontSize: { xs: 11, sm: 13, base: 15 },
|
||||
fontWeight: { normal: "400", medium: "500", semibold: "600" },
|
||||
colors: {
|
||||
surfaceSidebar: "#111",
|
||||
surface1: "#111",
|
||||
surface2: "#222",
|
||||
surface3: "#333",
|
||||
surface4: "#444",
|
||||
foreground: "#fff",
|
||||
foregroundMuted: "#aaa",
|
||||
border: "#555",
|
||||
borderAccent: "#666",
|
||||
accent: "#0a84ff",
|
||||
accentForeground: "#fff",
|
||||
palette: {
|
||||
green: { 400: "#30d158" },
|
||||
amber: { 500: "#ffd60a" },
|
||||
red: { 500: "#ff453a" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("react-native-unistyles", () => ({
|
||||
StyleSheet: {
|
||||
absoluteFillObject: {},
|
||||
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
|
||||
},
|
||||
useUnistyles: () => ({ theme }),
|
||||
}));
|
||||
|
||||
vi.mock("react-native-reanimated", () => ({
|
||||
default: {
|
||||
View: "div",
|
||||
},
|
||||
Extrapolation: { CLAMP: "clamp" },
|
||||
interpolate: () => 0,
|
||||
runOnJS: (fn: (...args: unknown[]) => unknown) => fn,
|
||||
useAnimatedStyle: (factory: () => unknown) => factory(),
|
||||
useSharedValue: (value: unknown) => ({ value }),
|
||||
}));
|
||||
|
||||
vi.mock("react-native-gesture-handler", () => {
|
||||
const chain = {
|
||||
enabled: () => chain,
|
||||
hitSlop: () => chain,
|
||||
manualActivation: () => chain,
|
||||
onTouchesDown: () => chain,
|
||||
onTouchesMove: () => chain,
|
||||
onStart: () => chain,
|
||||
onUpdate: () => chain,
|
||||
onEnd: () => chain,
|
||||
onFinalize: () => chain,
|
||||
withRef: () => chain,
|
||||
};
|
||||
return {
|
||||
Gesture: { Pan: () => chain },
|
||||
GestureDetector: ({ children }: { children: React.ReactNode }) => children,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("lucide-react-native", () => {
|
||||
const createIcon = (name: string) => (props: Record<string, unknown>) =>
|
||||
React.createElement("span", { ...props, "data-icon": name });
|
||||
return {
|
||||
MessagesSquare: createIcon("MessagesSquare"),
|
||||
Plus: createIcon("Plus"),
|
||||
Settings: createIcon("Settings"),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("expo-router", () => ({
|
||||
router: { push: vi.fn() },
|
||||
usePathname: () => "/hosts/srv",
|
||||
}));
|
||||
|
||||
vi.mock("react-native-safe-area-context", () => ({
|
||||
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
|
||||
}));
|
||||
|
||||
vi.mock("@/constants/layout", () => ({
|
||||
useIsCompactFormFactor: () => true,
|
||||
}));
|
||||
|
||||
vi.mock("@/constants/platform", () => ({
|
||||
isWeb: true,
|
||||
isNative: false,
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/panel-store", () => ({
|
||||
MIN_SIDEBAR_WIDTH: 260,
|
||||
MAX_SIDEBAR_WIDTH: 420,
|
||||
selectIsAgentListOpen: (state: typeof panelState) => state.isOpen,
|
||||
usePanelStore: (selector: (state: typeof panelState) => unknown) => selector(panelState),
|
||||
}));
|
||||
|
||||
vi.mock("@/runtime/host-runtime", () => ({
|
||||
useHosts: () => [{ serverId: "srv", label: "Local" }],
|
||||
useHostRuntimeSnapshot: () => ({ connectionStatus: "online" }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-sidebar-workspaces-list", () => ({
|
||||
useSidebarWorkspacesList: useSidebarWorkspacesListMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-sidebar-shortcut-model", () => ({
|
||||
useSidebarShortcutModel: () => ({
|
||||
collapsedProjectKeys: new Set<string>(),
|
||||
shortcutIndexByWorkspaceKey: new Map<string, number>(),
|
||||
toggleProjectCollapsed: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/contexts/sidebar-animation-context", () => ({
|
||||
useSidebarAnimation: () => ({
|
||||
translateX: { value: 0 },
|
||||
backdropOpacity: { value: 0 },
|
||||
windowWidth: 390,
|
||||
animateToOpen: vi.fn(),
|
||||
animateToClose: vi.fn(),
|
||||
isGesturing: { value: false },
|
||||
gestureAnimatingRef: { current: false },
|
||||
closeGestureRef: { current: undefined },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-shortcut-keys", () => ({
|
||||
useShortcutKeys: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/desktop-window", () => ({
|
||||
useWindowControlsPadding: () => ({ top: 0 }),
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/host-routes", () => ({
|
||||
buildHostSessionsRoute: (serverId: string) => `/hosts/${serverId}/sessions`,
|
||||
buildSettingsRoute: () => "/settings",
|
||||
mapPathnameToServer: (_pathname: string, serverId: string) => `/hosts/${serverId}`,
|
||||
parseServerIdFromPathname: () => "srv",
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-open-project-picker", () => ({
|
||||
useOpenProjectPicker: () => vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/sidebar/sidebar-header-row", () => ({
|
||||
SidebarHeaderRow: ({ label }: { label: string }) => React.createElement("div", null, label),
|
||||
}));
|
||||
|
||||
vi.mock("./sidebar-workspace-list", () => ({
|
||||
SidebarWorkspaceList: ({ projects }: { projects: Array<{ projectName: string }> }) =>
|
||||
React.createElement(
|
||||
"div",
|
||||
{ "data-testid": "sidebar-workspace-list" },
|
||||
projects.map((project) => project.projectName).join(","),
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./sidebar-agent-list-skeleton", () => ({
|
||||
SidebarAgentListSkeleton: () => React.createElement("div", null, "Loading"),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/tooltip", () => ({
|
||||
Tooltip: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement("div", null, children),
|
||||
TooltipContent: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement("div", null, children),
|
||||
TooltipTrigger: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement("div", null, children),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/shortcut", () => ({
|
||||
Shortcut: () => React.createElement("span", null),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/combobox", () => ({
|
||||
Combobox: () => null,
|
||||
ComboboxItem: ({ label }: { label: string }) => React.createElement("div", null, label),
|
||||
}));
|
||||
|
||||
vi.stubGlobal("React", React);
|
||||
|
||||
import { LeftSidebar } from "./left-sidebar";
|
||||
|
||||
describe("LeftSidebar", () => {
|
||||
let root: Root | null = null;
|
||||
let container: HTMLElement | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
panelState.isOpen = false;
|
||||
panelState.showMobileAgent.mockReset();
|
||||
useSidebarWorkspacesListMock.mockReset();
|
||||
useSidebarWorkspacesListMock.mockReturnValue({
|
||||
projects: [{ projectKey: "project-1", projectName: "Project 1", workspaces: [] }],
|
||||
isInitialLoad: false,
|
||||
isRevalidating: false,
|
||||
refreshAll: vi.fn(),
|
||||
});
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
root = null;
|
||||
container?.remove();
|
||||
container = null;
|
||||
});
|
||||
|
||||
it("keeps the mobile workspace list subscribed while the sidebar is hidden", async () => {
|
||||
await act(async () => {
|
||||
root?.render(<LeftSidebar />);
|
||||
});
|
||||
|
||||
expect(useSidebarWorkspacesListMock).toHaveBeenLastCalledWith({
|
||||
serverId: "srv",
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -32,7 +32,12 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { router, usePathname } from "expo-router";
|
||||
import { usePanelStore, MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH } from "@/stores/panel-store";
|
||||
import {
|
||||
usePanelStore,
|
||||
selectIsAgentListOpen,
|
||||
MIN_SIDEBAR_WIDTH,
|
||||
MAX_SIDEBAR_WIDTH,
|
||||
} from "@/stores/panel-store";
|
||||
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
|
||||
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
|
||||
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
||||
@@ -52,10 +57,10 @@ import {
|
||||
buildHostSessionsRoute,
|
||||
buildSettingsRoute,
|
||||
mapPathnameToServer,
|
||||
parseServerIdFromPathname,
|
||||
} from "@/utils/host-routes";
|
||||
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { resolveActiveHost } from "@/utils/active-host";
|
||||
|
||||
const MIN_CHAT_WIDTH = 400;
|
||||
|
||||
@@ -116,24 +121,16 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isCompactLayout = useIsCompactFormFactor();
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||
const closeToAgent = usePanelStore((state) => state.closeToAgent);
|
||||
const isOpen = usePanelStore((state) =>
|
||||
selectIsAgentListOpen(state, { isCompact: isCompactLayout }),
|
||||
);
|
||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||
const pathname = usePathname();
|
||||
const daemons = useHosts();
|
||||
const activeServerIdFromPath = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
|
||||
const activeDaemon = useMemo(() => {
|
||||
if (daemons.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (activeServerIdFromPath) {
|
||||
const routeMatch = daemons.find((entry) => entry.serverId === activeServerIdFromPath);
|
||||
if (routeMatch) {
|
||||
return routeMatch;
|
||||
}
|
||||
}
|
||||
return daemons[0] ?? null;
|
||||
}, [activeServerIdFromPath, daemons]);
|
||||
const activeDaemon = useMemo(
|
||||
() => resolveActiveHost({ hosts: daemons, pathname }),
|
||||
[daemons, pathname],
|
||||
);
|
||||
const activeServerId = activeDaemon?.serverId ?? null;
|
||||
const activeHostLabel = useMemo(() => {
|
||||
if (!activeDaemon) return "No host";
|
||||
@@ -183,14 +180,12 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
const hostTriggerRef = useRef<View | null>(null);
|
||||
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false);
|
||||
|
||||
const isOpen = isCompactLayout ? mobileView === "agent-list" : desktopAgentListOpen;
|
||||
|
||||
const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarWorkspacesList({
|
||||
serverId: activeServerId,
|
||||
enabled: isOpen,
|
||||
enabled: isCompactLayout || isOpen,
|
||||
});
|
||||
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } =
|
||||
useSidebarShortcutModel(projects);
|
||||
useSidebarShortcutModel({ projects, isInitialLoad });
|
||||
|
||||
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
||||
|
||||
@@ -208,18 +203,18 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
const openProjectPicker = useOpenProjectPicker(activeServerId);
|
||||
|
||||
const handleOpenProjectMobile = useCallback(() => {
|
||||
closeToAgent();
|
||||
showMobileAgent();
|
||||
void openProjectPicker();
|
||||
}, [closeToAgent, openProjectPicker]);
|
||||
}, [showMobileAgent, openProjectPicker]);
|
||||
|
||||
const handleOpenProjectDesktop = useCallback(() => {
|
||||
void openProjectPicker();
|
||||
}, [openProjectPicker]);
|
||||
|
||||
const handleSettingsMobile = useCallback(() => {
|
||||
closeToAgent();
|
||||
showMobileAgent();
|
||||
router.push(buildSettingsRoute());
|
||||
}, [closeToAgent]);
|
||||
}, [showMobileAgent]);
|
||||
|
||||
const handleSettingsDesktop = useCallback(() => {
|
||||
router.push(buildSettingsRoute());
|
||||
@@ -272,7 +267,7 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
insetsTop={insets.top}
|
||||
insetsBottom={insets.bottom}
|
||||
isOpen={isOpen}
|
||||
closeToAgent={closeToAgent}
|
||||
closeToAgent={showMobileAgent}
|
||||
handleOpenProject={handleOpenProjectMobile}
|
||||
handleSettings={handleSettingsMobile}
|
||||
handleViewMoreNavigate={handleViewMoreNavigate}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import React from "react";
|
||||
import React, { createRef } from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { MessageInput, type AttachmentMenuItem } from "./message-input";
|
||||
import { MessageInput, type AttachmentMenuItem, type MessageInputRef } from "./message-input";
|
||||
|
||||
const { startDictationMock, cancelDictationMock, confirmDictationMock } = vi.hoisted(() => ({
|
||||
startDictationMock: vi.fn(),
|
||||
cancelDictationMock: vi.fn(),
|
||||
confirmDictationMock: vi.fn(),
|
||||
}));
|
||||
|
||||
const { theme } = vi.hoisted(() => ({
|
||||
theme: {
|
||||
@@ -49,9 +55,10 @@ vi.mock("lucide-react-native", () => {
|
||||
const createIcon = (name: string) => (props: Record<string, unknown>) =>
|
||||
React.createElement("span", { ...props, "data-icon": name });
|
||||
return {
|
||||
ArrowUp: createIcon("ArrowUp"),
|
||||
Mic: createIcon("Mic"),
|
||||
MicOff: createIcon("MicOff"),
|
||||
ArrowUp: createIcon("ArrowUp"),
|
||||
CornerDownLeft: createIcon("CornerDownLeft"),
|
||||
Plus: createIcon("Plus"),
|
||||
Square: createIcon("Square"),
|
||||
};
|
||||
@@ -84,9 +91,9 @@ vi.mock("@/hooks/use-dictation", () => ({
|
||||
duration: 0,
|
||||
error: null,
|
||||
status: "idle",
|
||||
startDictation: vi.fn(),
|
||||
cancelDictation: vi.fn(),
|
||||
confirmDictation: vi.fn(),
|
||||
startDictation: startDictationMock,
|
||||
cancelDictation: cancelDictationMock,
|
||||
confirmDictation: confirmDictationMock,
|
||||
retryFailedDictation: vi.fn(),
|
||||
discardFailedDictation: vi.fn(),
|
||||
}),
|
||||
@@ -213,6 +220,9 @@ beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
startDictationMock.mockReset();
|
||||
cancelDictationMock.mockReset();
|
||||
confirmDictationMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -226,18 +236,27 @@ afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function renderMessageInput(menuItems: AttachmentMenuItem[]) {
|
||||
interface RenderMessageInputOptions {
|
||||
value?: string;
|
||||
submitIcon?: "arrow" | "return";
|
||||
}
|
||||
|
||||
function renderMessageInput(
|
||||
menuItems: AttachmentMenuItem[],
|
||||
{ value = "", submitIcon }: RenderMessageInputOptions = {},
|
||||
) {
|
||||
act(() => {
|
||||
root?.render(
|
||||
<MessageInput
|
||||
value=""
|
||||
value={value}
|
||||
onChangeText={vi.fn()}
|
||||
onSubmit={vi.fn()}
|
||||
attachments={[]}
|
||||
cwd="/repo"
|
||||
attachmentMenuItems={menuItems}
|
||||
client={{ isConnected: true } as never}
|
||||
isAgentRunning
|
||||
isAgentRunning={false}
|
||||
submitIcon={submitIcon}
|
||||
onQueue={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
@@ -302,4 +321,70 @@ describe("MessageInput attachments", () => {
|
||||
|
||||
expect(queryAllByAriaLabel("Queue message")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("uses ArrowUp by default and CornerDownLeft when return submit icon is requested", () => {
|
||||
renderMessageInput([], { value: "Send this" });
|
||||
|
||||
expect(document.querySelectorAll('[data-icon="ArrowUp"]')).toHaveLength(1);
|
||||
expect(document.querySelectorAll('[data-icon="CornerDownLeft"]')).toHaveLength(0);
|
||||
|
||||
renderMessageInput([], { value: "Create this", submitIcon: "return" });
|
||||
|
||||
expect(document.querySelectorAll('[data-icon="ArrowUp"]')).toHaveLength(0);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
forwardRef,
|
||||
} from "react";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { Mic, MicOff, ArrowUp, Plus, Square } from "lucide-react-native";
|
||||
import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native";
|
||||
import Animated, { useSharedValue, useAnimatedStyle, withTiming } from "react-native-reanimated";
|
||||
import { useDictation } from "@/hooks/use-dictation";
|
||||
import { DictationOverlay } from "./dictation-controls";
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
markScrollInvestigationRender,
|
||||
} from "@/utils/scroll-jank-investigation";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useComposerHeightMirror } from "./composer-height-mirror";
|
||||
|
||||
export type ImageAttachment = AttachmentMetadata;
|
||||
|
||||
@@ -85,6 +86,7 @@ export interface MessageInputProps {
|
||||
allowEmptySubmit?: boolean;
|
||||
/** Optional accessibility label for the primary submit button. */
|
||||
submitButtonAccessibilityLabel?: string;
|
||||
submitIcon?: "arrow" | "return";
|
||||
isSubmitDisabled?: boolean;
|
||||
isSubmitLoading?: boolean;
|
||||
attachments: ComposerAttachment[];
|
||||
@@ -217,6 +219,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
hasExternalContent = false,
|
||||
allowEmptySubmit = false,
|
||||
submitButtonAccessibilityLabel,
|
||||
submitIcon = "arrow",
|
||||
isSubmitDisabled = false,
|
||||
isSubmitLoading = false,
|
||||
attachments,
|
||||
@@ -328,7 +331,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
},
|
||||
}));
|
||||
const inputHeightRef = useRef(MIN_INPUT_HEIGHT);
|
||||
const baselineInputHeightRef = useRef<number | null>(null);
|
||||
const overlayTransition = useSharedValue(0);
|
||||
const sendAfterTranscriptRef = useRef(false);
|
||||
const valueRef = useRef(value);
|
||||
@@ -398,12 +400,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
} else {
|
||||
onChangeText(nextValue);
|
||||
}
|
||||
|
||||
if (isWeb && typeof requestAnimationFrame === "function") {
|
||||
requestAnimationFrame(() => {
|
||||
measureWebInputHeight("dictation");
|
||||
});
|
||||
}
|
||||
},
|
||||
[onChangeText, onSubmit, onQueue, attachments, cwd, isAgentRunning, defaultSendBehavior],
|
||||
);
|
||||
@@ -484,11 +480,15 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
toast.error(dictationUnavailableMessage);
|
||||
return;
|
||||
}
|
||||
if (!canStartDictation()) {
|
||||
isDictatingRef.current = false;
|
||||
return;
|
||||
}
|
||||
// Keep hotkey toggling deterministic between the async start call and the
|
||||
// state-ref sync effect, so a rapid second toggle routes to confirm.
|
||||
isDictatingRef.current = true;
|
||||
await startDictation();
|
||||
}, [dictationUnavailableMessage, startDictation, toast]);
|
||||
}, [canStartDictation, dictationUnavailableMessage, startDictation, toast]);
|
||||
|
||||
// Animate overlay
|
||||
useEffect(() => {
|
||||
@@ -830,76 +830,27 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
};
|
||||
}, [getWebTextArea]);
|
||||
|
||||
function measureWebInputHeight(source: string): boolean {
|
||||
if (!isWeb) return false;
|
||||
const textarea = getWebTextArea();
|
||||
if (!textarea || typeof textarea.scrollHeight !== "number") return false;
|
||||
const scrollHeight = textarea.scrollHeight ?? 0;
|
||||
|
||||
if (baselineInputHeightRef.current === null && scrollHeight > 0) {
|
||||
baselineInputHeightRef.current = scrollHeight;
|
||||
logWebStickyBottom("composer_baseline_measured", {
|
||||
source,
|
||||
baseline: scrollHeight,
|
||||
});
|
||||
}
|
||||
|
||||
const baseline = baselineInputHeightRef.current ?? MIN_INPUT_HEIGHT;
|
||||
const rawTarget = scrollHeight > 0 ? scrollHeight : baseline;
|
||||
const bounded = Math.max(MIN_INPUT_HEIGHT, Math.min(MAX_INPUT_HEIGHT, rawTarget));
|
||||
|
||||
const previousHeight = inputHeightRef.current;
|
||||
if (Math.abs(previousHeight - bounded) >= 1) {
|
||||
inputHeightRef.current = bounded;
|
||||
setInputHeight(bounded);
|
||||
onHeightChange?.(bounded);
|
||||
logWebStickyBottom("composer_height_changed", {
|
||||
source,
|
||||
previousHeight,
|
||||
nextHeight: bounded,
|
||||
scrollHeight,
|
||||
clientHeight: textarea.clientHeight ?? null,
|
||||
offsetHeight: textarea.offsetHeight ?? null,
|
||||
baseline,
|
||||
rawTarget,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function setBoundedInputHeight(nextHeight: number) {
|
||||
const bounded = Math.max(MIN_INPUT_HEIGHT, Math.min(MAX_INPUT_HEIGHT, nextHeight));
|
||||
if (Math.abs(inputHeightRef.current - bounded) < 1) return;
|
||||
const previousHeight = inputHeightRef.current;
|
||||
inputHeightRef.current = bounded;
|
||||
setInputHeight(bounded);
|
||||
onHeightChange?.(bounded);
|
||||
logWebStickyBottom("composer_height_changed_native", {
|
||||
previousHeight,
|
||||
nextHeight: bounded,
|
||||
});
|
||||
}
|
||||
|
||||
useComposerHeightMirror({
|
||||
value,
|
||||
textareaRef: webTextareaRef,
|
||||
minHeight: MIN_INPUT_HEIGHT,
|
||||
maxHeight: MAX_INPUT_HEIGHT,
|
||||
onHeight: setBoundedInputHeight,
|
||||
});
|
||||
|
||||
function handleContentSizeChange(
|
||||
event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>,
|
||||
) {
|
||||
const contentHeight = event.nativeEvent.contentSize.height;
|
||||
if (isWeb) {
|
||||
logWebStickyBottom("composer_content_size_change", {
|
||||
reportedHeight: contentHeight,
|
||||
});
|
||||
if (baselineInputHeightRef.current === null && contentHeight > 0) {
|
||||
baselineInputHeightRef.current = contentHeight;
|
||||
logWebStickyBottom("composer_baseline_measured", {
|
||||
source: "contentSizeChange",
|
||||
baseline: contentHeight,
|
||||
});
|
||||
}
|
||||
setBoundedInputHeight(contentHeight);
|
||||
return;
|
||||
}
|
||||
setBoundedInputHeight(contentHeight);
|
||||
if (isWeb) return;
|
||||
setBoundedInputHeight(event.nativeEvent.contentSize.height);
|
||||
}
|
||||
|
||||
function handleSelectionChange(event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) {
|
||||
@@ -1180,6 +1131,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
>
|
||||
{isSubmitLoading ? (
|
||||
<ActivityIndicator size="small" color="white" />
|
||||
) : submitIcon === "return" ? (
|
||||
<CornerDownLeft size={buttonIconSize} color="white" />
|
||||
) : (
|
||||
<ArrowUp size={buttonIconSize} color="white" />
|
||||
)}
|
||||
|
||||
@@ -53,7 +53,6 @@ import Animated, {
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
|
||||
import { theme } from "@/styles/theme";
|
||||
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
||||
import { Colors, Fonts } from "@/constants/theme";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
@@ -119,6 +118,26 @@ function useDisableOuterSpacing(disableOuterSpacing: boolean | undefined) {
|
||||
|
||||
const WEB_TOOLCALL_SHIMMER_KEYFRAME_ID = "paseo-toolcall-shimmer-keyframes";
|
||||
const WEB_TOOLCALL_SHIMMER_ANIMATION_NAME = "paseo-toolcall-shimmer";
|
||||
const MARKDOWN_ALLOWED_IMAGE_HANDLERS = [
|
||||
"data:image/png;base64",
|
||||
"data:image/gif;base64",
|
||||
"data:image/jpeg;base64",
|
||||
"https://",
|
||||
"http://",
|
||||
] as const;
|
||||
const MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM = <Text key="dotdotdot">...</Text>;
|
||||
|
||||
type MarkdownWithStableRendererProps = {
|
||||
children: ReactNode;
|
||||
style: ReturnType<typeof createMarkdownStyles>;
|
||||
rules: RenderRules;
|
||||
markdownit: MarkdownIt;
|
||||
onLinkPress: (url: string) => boolean;
|
||||
allowedImageHandlers: readonly string[];
|
||||
topLevelMaxExceededItem: ReactNode;
|
||||
};
|
||||
|
||||
const MarkdownWithStableRenderer = Markdown as ComponentType<MarkdownWithStableRendererProps>;
|
||||
const WEB_TOOLCALL_SHIMMER_KEYFRAME_CSS = `
|
||||
@keyframes ${WEB_TOOLCALL_SHIMMER_ANIMATION_NAME} {
|
||||
0% {
|
||||
@@ -341,6 +360,7 @@ export const UserMessage = memo(function UserMessage({
|
||||
isLastInGroup = true,
|
||||
disableOuterSpacing,
|
||||
}: UserMessageProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const [messageHovered, setMessageHovered] = useState(false);
|
||||
const [copyButtonHovered, setCopyButtonHovered] = useState(false);
|
||||
@@ -947,9 +967,16 @@ const MemoizedMarkdownBlock = React.memo(function MemoizedMarkdownBlock({
|
||||
onLinkPress,
|
||||
}: MemoizedMarkdownBlockProps) {
|
||||
return (
|
||||
<Markdown style={styles} rules={rules} markdownit={parser} onLinkPress={onLinkPress}>
|
||||
<MarkdownWithStableRenderer
|
||||
style={styles}
|
||||
rules={rules}
|
||||
markdownit={parser}
|
||||
onLinkPress={onLinkPress}
|
||||
allowedImageHandlers={MARKDOWN_ALLOWED_IMAGE_HANDLERS}
|
||||
topLevelMaxExceededItem={MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM}
|
||||
>
|
||||
{text}
|
||||
</Markdown>
|
||||
</MarkdownWithStableRenderer>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -962,10 +989,10 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
client,
|
||||
disableOuterSpacing,
|
||||
}: AssistantMessageProps) {
|
||||
const { theme, rt } = useUnistyles();
|
||||
const { theme } = useUnistyles();
|
||||
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
|
||||
|
||||
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [rt.themeName]);
|
||||
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
|
||||
|
||||
const markdownParser = useMemo(() => {
|
||||
const parser = MarkdownIt({ typographer: true, linkify: true });
|
||||
|
||||
169
packages/app/src/components/pr-pane.test.tsx
Normal file
169
packages/app/src/components/pr-pane.test.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PrPaneData } from "@/utils/pr-pane-data";
|
||||
import { PrPane } from "./pr-pane";
|
||||
|
||||
const { theme } = vi.hoisted(() => ({
|
||||
theme: {
|
||||
spacing: { 1: 4, 2: 8, 3: 12, 4: 16 },
|
||||
fontSize: { xs: 11, sm: 13, base: 15 },
|
||||
fontWeight: { normal: "400" },
|
||||
colors: {
|
||||
surfaceSidebar: "#0b0b0b",
|
||||
surfaceSidebarHover: "#151515",
|
||||
border: "#222",
|
||||
foreground: "#fff",
|
||||
foregroundMuted: "#aaa",
|
||||
statusSuccess: "#30d158",
|
||||
statusDanger: "#ff453a",
|
||||
statusWarning: "#f2c945",
|
||||
statusMerged: "#a371f7",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("react-native-unistyles", () => ({
|
||||
StyleSheet: {
|
||||
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
|
||||
},
|
||||
useUnistyles: () => ({ theme }),
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react-native", () => {
|
||||
const createIcon = (name: string) => (props: Record<string, unknown>) =>
|
||||
React.createElement("span", { ...props, "data-icon": name });
|
||||
return {
|
||||
ChevronDown: createIcon("ChevronDown"),
|
||||
ChevronRight: createIcon("ChevronRight"),
|
||||
CircleCheck: createIcon("CircleCheck"),
|
||||
CircleDot: createIcon("CircleDot"),
|
||||
CircleSlash: createIcon("CircleSlash"),
|
||||
CircleX: createIcon("CircleX"),
|
||||
ExternalLink: createIcon("ExternalLink"),
|
||||
GitMerge: createIcon("GitMerge"),
|
||||
GitPullRequest: createIcon("GitPullRequest"),
|
||||
GitPullRequestClosed: createIcon("GitPullRequestClosed"),
|
||||
GitPullRequestDraft: createIcon("GitPullRequestDraft"),
|
||||
MessageSquare: createIcon("MessageSquare"),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/utils/open-external-url", () => ({
|
||||
openExternalUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
let root: Root | null = null;
|
||||
let container: HTMLElement | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>");
|
||||
vi.stubGlobal("React", React);
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
vi.stubGlobal("window", dom.window);
|
||||
vi.stubGlobal("document", dom.window.document);
|
||||
vi.stubGlobal("HTMLElement", dom.window.HTMLElement);
|
||||
vi.stubGlobal("Node", dom.window.Node);
|
||||
vi.stubGlobal("navigator", dom.window.navigator);
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
root = null;
|
||||
container = null;
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function makeData(overrides: Partial<PrPaneData> = {}): PrPaneData {
|
||||
return {
|
||||
number: 1284,
|
||||
title: "fix(server,app): coalesce tool call stream events",
|
||||
state: "open",
|
||||
url: "https://github.com/getpaseo/paseo/pull/1284",
|
||||
reviewDecision: "changes_requested",
|
||||
awaitingReviewers: [],
|
||||
checks: [
|
||||
{ name: "typecheck", workflow: "CI", status: "success", duration: "1m", url: "#" },
|
||||
{ name: "test", workflow: "CI", status: "failure", duration: "2m", url: "#" },
|
||||
],
|
||||
activity: [
|
||||
{
|
||||
kind: "review",
|
||||
author: "alicek",
|
||||
avatarColor: "#8b5cf6",
|
||||
reviewState: "approved",
|
||||
body: "LGTM",
|
||||
age: "2h ago",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
kind: "comment",
|
||||
author: "bmartin",
|
||||
avatarColor: "#f97316",
|
||||
body: "Worth a benchmark before/after.",
|
||||
age: "3h ago",
|
||||
url: "#",
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function render(data: PrPaneData) {
|
||||
act(() => {
|
||||
root?.render(<PrPane data={data} />);
|
||||
});
|
||||
}
|
||||
|
||||
describe("PrPane", () => {
|
||||
it("renders the PR title and 'Open' state label from the data prop", () => {
|
||||
render(makeData());
|
||||
|
||||
expect(container?.textContent).toContain("fix(server,app): coalesce tool call stream events");
|
||||
expect(container?.textContent).toContain("Open");
|
||||
});
|
||||
|
||||
it("renders the correct state label and icon for each PR state", () => {
|
||||
render(makeData({ state: "draft" }));
|
||||
expect(container?.textContent).toContain("Draft");
|
||||
expect(container?.querySelector('[data-icon="GitPullRequestDraft"]')).not.toBeNull();
|
||||
|
||||
render(makeData({ state: "merged" }));
|
||||
expect(container?.textContent).toContain("Merged");
|
||||
expect(container?.querySelector('[data-icon="GitMerge"]')).not.toBeNull();
|
||||
|
||||
render(makeData({ state: "closed" }));
|
||||
expect(container?.textContent).toContain("Closed");
|
||||
expect(container?.querySelector('[data-icon="GitPullRequestClosed"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("derives check summary counts from the data prop", () => {
|
||||
render(makeData());
|
||||
const text = container?.textContent ?? "";
|
||||
|
||||
expect(text).toContain("typecheck");
|
||||
expect(text).toContain("test");
|
||||
// 1 passed, 1 failed, 0 pending — so pending pill is hidden.
|
||||
expect(text).toMatch(/1.*1/);
|
||||
});
|
||||
|
||||
it("derives review summary counts and renders activity rows", () => {
|
||||
render(makeData());
|
||||
const text = container?.textContent ?? "";
|
||||
|
||||
expect(text).toContain("alicek");
|
||||
expect(text).toContain("Approved");
|
||||
expect(text).toContain("bmartin");
|
||||
expect(text).toContain("Commented");
|
||||
});
|
||||
});
|
||||
397
packages/app/src/components/pr-pane.tsx
Normal file
397
packages/app/src/components/pr-pane.tsx
Normal file
@@ -0,0 +1,397 @@
|
||||
import { useState } from "react";
|
||||
import { Pressable, ScrollView, Text, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CircleCheck,
|
||||
CircleDot,
|
||||
CircleSlash,
|
||||
CircleX,
|
||||
ExternalLink,
|
||||
GitMerge,
|
||||
GitPullRequest,
|
||||
GitPullRequestClosed,
|
||||
GitPullRequestDraft,
|
||||
MessageSquare,
|
||||
} from "lucide-react-native";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import type {
|
||||
CheckStatus,
|
||||
PrPaneActivity,
|
||||
PrPaneCheck,
|
||||
PrPaneData,
|
||||
PrState,
|
||||
} from "@/utils/pr-pane-data";
|
||||
|
||||
export function PrPane({ data }: { data: PrPaneData }) {
|
||||
const { theme } = useUnistyles();
|
||||
const [checksOpen, setChecksOpen] = useState(true);
|
||||
const [reviewsOpen, setReviewsOpen] = useState(true);
|
||||
|
||||
const passed = data.checks.filter((c) => c.status === "success").length;
|
||||
const failed = data.checks.filter((c) => c.status === "failure").length;
|
||||
const pending = data.checks.filter((c) => c.status === "pending").length;
|
||||
|
||||
const approvals = data.activity.filter(
|
||||
(a) => a.kind === "review" && a.reviewState === "approved",
|
||||
).length;
|
||||
const changesRequested = data.activity.filter(
|
||||
(a) => a.kind === "review" && a.reviewState === "changes_requested",
|
||||
).length;
|
||||
const commentCount = data.activity.filter(
|
||||
(a) => a.kind === "comment" || (a.kind === "review" && a.reviewState === "commented"),
|
||||
).length;
|
||||
|
||||
const stateColor = getStateColor(data.state, theme);
|
||||
const StateIcon = getStateIcon(data.state);
|
||||
const stateLabel = getStateLabel(data.state);
|
||||
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
<Pressable onPress={() => void openExternalUrl(data.url)} style={styles.header}>
|
||||
{({ hovered }) => (
|
||||
<>
|
||||
<View style={styles.stateLine}>
|
||||
<StateIcon size={14} color={stateColor} />
|
||||
<Text style={[styles.stateLabel, { color: stateColor }]}>{stateLabel}</Text>
|
||||
</View>
|
||||
<Text style={styles.title} numberOfLines={3}>
|
||||
{data.title}
|
||||
{hovered ? (
|
||||
<Text>
|
||||
{" "}
|
||||
<ExternalLink size={12} color={theme.colors.foregroundMuted} />
|
||||
</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
<Section
|
||||
title="Checks"
|
||||
open={checksOpen}
|
||||
onToggle={() => setChecksOpen((o) => !o)}
|
||||
summary={
|
||||
<>
|
||||
<SummaryPill
|
||||
count={passed}
|
||||
color={theme.colors.statusSuccess}
|
||||
icon={<CircleCheck size={12} color={theme.colors.statusSuccess} />}
|
||||
/>
|
||||
<SummaryPill
|
||||
count={failed}
|
||||
color={theme.colors.statusDanger}
|
||||
icon={<CircleX size={12} color={theme.colors.statusDanger} />}
|
||||
/>
|
||||
<SummaryPill
|
||||
count={pending}
|
||||
color={theme.colors.statusWarning}
|
||||
icon={<CircleDot size={12} color={theme.colors.statusWarning} />}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{data.checks.map((check, idx) => (
|
||||
<CheckRow key={`${check.name}-${idx}`} check={check} />
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
<Section
|
||||
title="Reviews"
|
||||
open={reviewsOpen}
|
||||
onToggle={() => setReviewsOpen((o) => !o)}
|
||||
summary={
|
||||
<>
|
||||
<SummaryPill
|
||||
count={approvals}
|
||||
color={theme.colors.statusSuccess}
|
||||
icon={<CircleCheck size={12} color={theme.colors.statusSuccess} />}
|
||||
/>
|
||||
<SummaryPill
|
||||
count={changesRequested}
|
||||
color={theme.colors.statusDanger}
|
||||
icon={<CircleX size={12} color={theme.colors.statusDanger} />}
|
||||
/>
|
||||
<SummaryPill
|
||||
count={commentCount}
|
||||
color={theme.colors.foregroundMuted}
|
||||
icon={<MessageSquare size={11} color={theme.colors.foregroundMuted} />}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{data.activity.map((item, idx) => (
|
||||
<ActivityRow key={`${item.author}-${idx}`} item={item} />
|
||||
))}
|
||||
</Section>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface SectionProps {
|
||||
title: string;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
summary: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function Section({ title, open, onToggle, summary, children }: SectionProps) {
|
||||
const { theme } = useUnistyles();
|
||||
return (
|
||||
<View style={open ? styles.sectionOpen : undefined}>
|
||||
<Pressable style={styles.sectionHeader} onPress={onToggle}>
|
||||
{open ? (
|
||||
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<ChevronRight size={14} color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
<Text style={styles.sectionTitle}>{title}</Text>
|
||||
<View style={styles.summaryWrap}>{summary}</View>
|
||||
</Pressable>
|
||||
{open && (
|
||||
<ScrollView
|
||||
style={styles.sectionBody}
|
||||
contentContainerStyle={styles.sectionBodyContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryPill({
|
||||
count,
|
||||
color,
|
||||
icon,
|
||||
}: {
|
||||
count: number;
|
||||
color: string;
|
||||
icon: React.ReactNode;
|
||||
}) {
|
||||
if (count === 0) return null;
|
||||
return (
|
||||
<View style={styles.summaryPill}>
|
||||
{icon}
|
||||
<Text style={[styles.summaryPillText, { color }]}>{count}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckRow({ check }: { check: PrPaneCheck }) {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => void openExternalUrl(check.url)}
|
||||
style={({ hovered }) => [styles.row, hovered && styles.hoverable]}
|
||||
>
|
||||
<CheckStatusIcon status={check.status} />
|
||||
<Text style={styles.rowTitle} numberOfLines={1}>
|
||||
{check.name}
|
||||
</Text>
|
||||
{check.workflow && (
|
||||
<Text style={styles.rowMetaMid} numberOfLines={1}>
|
||||
{check.workflow}
|
||||
</Text>
|
||||
)}
|
||||
{check.duration && <Text style={styles.rowMeta}>{check.duration}</Text>}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckStatusIcon({ status }: { status: CheckStatus }) {
|
||||
const { theme } = useUnistyles();
|
||||
if (status === "success") return <CircleCheck size={14} color={theme.colors.statusSuccess} />;
|
||||
if (status === "failure") return <CircleX size={14} color={theme.colors.statusDanger} />;
|
||||
if (status === "pending") return <CircleDot size={14} color={theme.colors.statusWarning} />;
|
||||
return <CircleSlash size={14} color={theme.colors.foregroundMuted} />;
|
||||
}
|
||||
|
||||
function ActivityRow({ item }: { item: PrPaneActivity }) {
|
||||
const verb = getActivityVerb(item);
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => void openExternalUrl(item.url)}
|
||||
style={({ hovered }) => [styles.activityRow, hovered && styles.hoverable]}
|
||||
>
|
||||
<View style={[styles.avatar, { backgroundColor: item.avatarColor }]}>
|
||||
<Text style={styles.avatarText}>{item.author.slice(0, 1).toUpperCase()}</Text>
|
||||
</View>
|
||||
<View style={styles.activityMain}>
|
||||
<View style={styles.activityHeader}>
|
||||
<Text style={styles.rowTitle} numberOfLines={1}>
|
||||
{item.author}
|
||||
</Text>
|
||||
<Text style={styles.rowMetaMid}>{verb}</Text>
|
||||
<Text style={styles.rowMeta}>{item.age}</Text>
|
||||
</View>
|
||||
<Text style={styles.rowBody} numberOfLines={2}>
|
||||
{item.body}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function getActivityVerb(item: PrPaneActivity): string {
|
||||
if (item.kind === "comment") return "Commented";
|
||||
if (item.reviewState === "approved") return "Approved";
|
||||
if (item.reviewState === "changes_requested") return "Requested changes";
|
||||
return "Reviewed";
|
||||
}
|
||||
|
||||
function getStateColor(state: PrState, theme: ReturnType<typeof useUnistyles>["theme"]): string {
|
||||
if (state === "open") return theme.colors.statusSuccess;
|
||||
if (state === "draft") return theme.colors.foregroundMuted;
|
||||
if (state === "merged") return theme.colors.statusMerged;
|
||||
return theme.colors.statusDanger;
|
||||
}
|
||||
|
||||
function getStateIcon(state: PrState) {
|
||||
if (state === "draft") return GitPullRequestDraft;
|
||||
if (state === "merged") return GitMerge;
|
||||
if (state === "closed") return GitPullRequestClosed;
|
||||
return GitPullRequest;
|
||||
}
|
||||
|
||||
function getStateLabel(state: PrState): string {
|
||||
if (state === "draft") return "Draft";
|
||||
if (state === "merged") return "Merged";
|
||||
if (state === "closed") return "Closed";
|
||||
return "Open";
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
},
|
||||
hoverable: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "column",
|
||||
gap: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[4],
|
||||
},
|
||||
stateLine: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
stateLabel: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
title: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.foreground,
|
||||
lineHeight: 19,
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
backgroundColor: theme.colors.border,
|
||||
},
|
||||
sectionOpen: {
|
||||
flexShrink: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
sectionBody: {
|
||||
flexShrink: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
sectionBodyContent: {
|
||||
paddingBottom: theme.spacing[3],
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
summaryWrap: {
|
||||
marginLeft: "auto",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
summaryPill: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 3,
|
||||
},
|
||||
summaryPillText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
},
|
||||
activityRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
},
|
||||
activityMain: { flex: 1, minWidth: 0, gap: 2 },
|
||||
activityHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
rowTitle: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.foreground,
|
||||
flexShrink: 1,
|
||||
},
|
||||
rowMeta: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
marginLeft: "auto",
|
||||
},
|
||||
rowMetaMid: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
flexShrink: 1,
|
||||
},
|
||||
rowBody: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
lineHeight: 16,
|
||||
},
|
||||
avatar: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 10,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginTop: 1,
|
||||
},
|
||||
avatarText: {
|
||||
fontSize: 10,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: "#fff",
|
||||
},
|
||||
}));
|
||||
@@ -3,35 +3,25 @@ import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-nativ
|
||||
import { Folder } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { usePathname } from "expo-router";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useHosts, useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useRecommendedProjectPaths } from "@/stores/session-store-hooks";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useOpenProject } from "@/hooks/use-open-project";
|
||||
import { parseServerIdFromPathname } from "@/utils/host-routes";
|
||||
import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { useActiveServerId } from "@/hooks/use-active-server-id";
|
||||
|
||||
export function ProjectPickerModal() {
|
||||
const { theme } = useUnistyles();
|
||||
const pathname = usePathname();
|
||||
const daemons = useHosts();
|
||||
const serverId = useActiveServerId();
|
||||
|
||||
const open = useKeyboardShortcutsStore((s) => s.projectPickerOpen);
|
||||
const setOpen = useKeyboardShortcutsStore((s) => s.setProjectPickerOpen);
|
||||
|
||||
const serverId = useMemo(() => {
|
||||
const fromPath = parseServerIdFromPathname(pathname);
|
||||
if (fromPath) return fromPath;
|
||||
return daemons[0]?.serverId ?? null;
|
||||
}, [pathname, daemons]);
|
||||
|
||||
const client = useHostRuntimeClient(serverId ?? "");
|
||||
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
||||
const workspaces = useSessionStore((state) =>
|
||||
serverId ? state.sessions[serverId]?.workspaces : undefined,
|
||||
);
|
||||
const recommendedPaths = useRecommendedProjectPaths(serverId);
|
||||
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
@@ -39,13 +29,6 @@ export function ProjectPickerModal() {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const openProject = useOpenProject(serverId);
|
||||
|
||||
const recommendedPaths = useMemo(() => {
|
||||
if (!workspaces) return [];
|
||||
return Array.from(workspaces.values())
|
||||
.map((workspace) => workspace.projectRootPath)
|
||||
.filter((path) => path.length > 0);
|
||||
}, [workspaces]);
|
||||
|
||||
const directorySuggestionsQuery = useQuery({
|
||||
queryKey: ["project-picker-directory-suggestions", serverId, query],
|
||||
queryFn: async () => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { AlertCircle, Search } from "lucide-react-native";
|
||||
import { AlertCircle, RotateCw, Search } from "lucide-react-native";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
|
||||
import { SpinningRefreshIcon } from "@/components/spinning-refresh-icon";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
@@ -82,8 +82,12 @@ export function ProviderDiagnosticSheet({
|
||||
);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
void refresh([provider as AgentProvider]);
|
||||
void fetchDiagnostic({ keepCurrent: true });
|
||||
if (!provider) {
|
||||
return;
|
||||
}
|
||||
void Promise.all([refresh([provider as AgentProvider]), fetchDiagnostic()]).catch((err) => {
|
||||
setDiagnostic(err instanceof Error ? err.message : "Failed to refresh provider");
|
||||
});
|
||||
}, [fetchDiagnostic, provider, refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -157,13 +161,15 @@ export function ProviderDiagnosticSheet({
|
||||
refreshInFlight ? sheetStyles.disabled : null,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Refresh ${providerLabel}`}
|
||||
accessibilityLabel={
|
||||
refreshInFlight ? `Refreshing ${providerLabel}` : `Refresh ${providerLabel}`
|
||||
}
|
||||
>
|
||||
<SpinningRefreshIcon
|
||||
spinning={refreshInFlight}
|
||||
size={theme.iconSize.sm}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
{refreshInFlight ? (
|
||||
<LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
</Pressable>
|
||||
}
|
||||
>
|
||||
|
||||
453
packages/app/src/components/sidebar-workspace-list.test.tsx
Normal file
453
packages/app/src/components/sidebar-workspace-list.test.tsx
Normal file
@@ -0,0 +1,453 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { act } from "@testing-library/react";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import type { WorkspaceScriptPayload } from "@server/shared/messages";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import React from "react";
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
vi.hoisted(() => {
|
||||
(globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
|
||||
});
|
||||
|
||||
import {
|
||||
createSidebarWorkspaceEntry,
|
||||
type SidebarProjectEntry,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { useSidebarWorkspacesList } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { patchWorkspaceScripts } from "@/contexts/session-workspace-scripts";
|
||||
import {
|
||||
getHostRuntimeStore,
|
||||
type HostRuntimeController,
|
||||
type HostRuntimeSnapshot,
|
||||
} from "@/runtime/host-runtime";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||
import {
|
||||
activateNavigationWorkspaceSelection,
|
||||
syncNavigationActiveWorkspace,
|
||||
useIsNavigationProjectActive,
|
||||
useIsNavigationWorkspaceSelected,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||
default: {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const SERVER_ID = "sidebar-render-count";
|
||||
|
||||
type RenderCounts = {
|
||||
frame: number;
|
||||
headers: Record<string, number>;
|
||||
rows: Record<string, number>;
|
||||
projectSelection: Record<string, number>;
|
||||
rowSelection: Record<string, number>;
|
||||
};
|
||||
|
||||
const runningScript: WorkspaceScriptPayload = {
|
||||
scriptName: "web",
|
||||
type: "service",
|
||||
hostname: "web.paseo.localhost",
|
||||
port: 3000,
|
||||
proxyUrl: "http://web.paseo.localhost:6767",
|
||||
lifecycle: "running",
|
||||
health: "healthy",
|
||||
exitCode: null,
|
||||
};
|
||||
|
||||
function workspace(input: {
|
||||
id: string;
|
||||
projectId: string;
|
||||
projectDisplayName: string;
|
||||
name: string;
|
||||
status?: WorkspaceDescriptor["status"];
|
||||
scripts?: WorkspaceDescriptor["scripts"];
|
||||
}): WorkspaceDescriptor {
|
||||
return {
|
||||
id: input.id,
|
||||
projectId: input.projectId,
|
||||
projectDisplayName: input.projectDisplayName,
|
||||
projectRootPath: `/repo/${input.projectId}`,
|
||||
workspaceDirectory: `/repo/${input.projectId}/${input.id}`,
|
||||
projectKind: "git",
|
||||
workspaceKind: input.name === "main" ? "local_checkout" : "worktree",
|
||||
name: input.name,
|
||||
status: input.status ?? "done",
|
||||
diffStat: null,
|
||||
scripts: input.scripts ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function createWorkspaces(): WorkspaceDescriptor[] {
|
||||
return [
|
||||
workspace({
|
||||
id: "a-main",
|
||||
projectId: "project-a",
|
||||
projectDisplayName: "Project A",
|
||||
name: "main",
|
||||
scripts: [runningScript],
|
||||
}),
|
||||
workspace({
|
||||
id: "a-one",
|
||||
projectId: "project-a",
|
||||
projectDisplayName: "Project A",
|
||||
name: "one",
|
||||
}),
|
||||
workspace({
|
||||
id: "a-two",
|
||||
projectId: "project-a",
|
||||
projectDisplayName: "Project A",
|
||||
name: "two",
|
||||
}),
|
||||
workspace({
|
||||
id: "b-main",
|
||||
projectId: "project-b",
|
||||
projectDisplayName: "Project B",
|
||||
name: "main",
|
||||
}),
|
||||
workspace({
|
||||
id: "b-one",
|
||||
projectId: "project-b",
|
||||
projectDisplayName: "Project B",
|
||||
name: "one",
|
||||
}),
|
||||
workspace({
|
||||
id: "b-two",
|
||||
projectId: "project-b",
|
||||
projectDisplayName: "Project B",
|
||||
name: "two",
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function makeHost(): HostProfile {
|
||||
const now = "2026-04-19T00:00:00.000Z";
|
||||
return {
|
||||
serverId: SERVER_ID,
|
||||
label: "Render Count Host",
|
||||
lifecycle: {},
|
||||
connections: [],
|
||||
preferredConnectionId: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
function initializeSidebarState(workspaces: WorkspaceDescriptor[]): void {
|
||||
act(() => {
|
||||
getHostRuntimeStore().syncHosts([makeHost()]);
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
|
||||
useSessionStore
|
||||
.getState()
|
||||
.setWorkspaces(SERVER_ID, new Map(workspaces.map((entry) => [entry.id, entry])));
|
||||
useSessionStore.getState().setHasHydratedWorkspaces(SERVER_ID, true);
|
||||
useSidebarOrderStore.setState({
|
||||
projectOrderByServerId: {
|
||||
[SERVER_ID]: ["project-a", "project-b"],
|
||||
},
|
||||
workspaceOrderByServerAndProject: {
|
||||
[`${SERVER_ID}::project-a`]: [
|
||||
`${SERVER_ID}:a-main`,
|
||||
`${SERVER_ID}:a-one`,
|
||||
`${SERVER_ID}:a-two`,
|
||||
],
|
||||
[`${SERVER_ID}::project-b`]: [
|
||||
`${SERVER_ID}:b-main`,
|
||||
`${SERVER_ID}:b-one`,
|
||||
`${SERVER_ID}:b-two`,
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function resetCounts(counts: RenderCounts): void {
|
||||
counts.frame = 0;
|
||||
counts.headers = {};
|
||||
counts.rows = {};
|
||||
counts.projectSelection = {};
|
||||
counts.rowSelection = {};
|
||||
}
|
||||
|
||||
function incrementRecord(record: Record<string, number>, key: string): void {
|
||||
record[key] = (record[key] ?? 0) + 1;
|
||||
}
|
||||
|
||||
function ProjectHeaderProbe({
|
||||
project,
|
||||
counts,
|
||||
}: {
|
||||
project: SidebarProjectEntry;
|
||||
counts: RenderCounts;
|
||||
}): null {
|
||||
incrementRecord(counts.headers, project.projectKey);
|
||||
return null;
|
||||
}
|
||||
|
||||
function WorkspaceRowProbe({
|
||||
serverId,
|
||||
workspaceId,
|
||||
counts,
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
counts: RenderCounts;
|
||||
}): null {
|
||||
const workspace = useWorkspaceFields(serverId, workspaceId, (entry) =>
|
||||
createSidebarWorkspaceEntry({ serverId, workspace: entry }),
|
||||
);
|
||||
if (workspace) {
|
||||
incrementRecord(counts.rows, workspace.workspaceId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function ProjectActiveProbe({
|
||||
serverId,
|
||||
project,
|
||||
counts,
|
||||
}: {
|
||||
serverId: string;
|
||||
project: SidebarProjectEntry;
|
||||
counts: RenderCounts;
|
||||
}): null {
|
||||
useIsNavigationProjectActive({
|
||||
serverId,
|
||||
workspaceIds: project.workspaces.map((workspace) => workspace.workspaceId),
|
||||
});
|
||||
incrementRecord(counts.projectSelection, project.projectKey);
|
||||
return null;
|
||||
}
|
||||
|
||||
function WorkspaceSelectionProbe({
|
||||
serverId,
|
||||
workspaceId,
|
||||
counts,
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
counts: RenderCounts;
|
||||
}): null {
|
||||
useIsNavigationWorkspaceSelected({ serverId, workspaceId });
|
||||
incrementRecord(counts.rowSelection, workspaceId);
|
||||
return null;
|
||||
}
|
||||
|
||||
function SidebarFrameProbe({ counts }: { counts: RenderCounts }): ReactElement {
|
||||
counts.frame += 1;
|
||||
const { projects } = useSidebarWorkspacesList({ serverId: SERVER_ID });
|
||||
|
||||
return (
|
||||
<>
|
||||
{projects.map((project) => (
|
||||
<div key={project.projectKey}>
|
||||
<ProjectHeaderProbe project={project} counts={counts} />
|
||||
<ProjectActiveProbe serverId={SERVER_ID} project={project} counts={counts} />
|
||||
{project.workspaces.map((workspace) => (
|
||||
<React.Fragment key={workspace.workspaceKey}>
|
||||
<WorkspaceRowProbe
|
||||
serverId={workspace.serverId}
|
||||
workspaceId={workspace.workspaceId}
|
||||
counts={counts}
|
||||
/>
|
||||
<WorkspaceSelectionProbe
|
||||
serverId={workspace.serverId}
|
||||
workspaceId={workspace.workspaceId}
|
||||
counts={counts}
|
||||
/>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function getHostController(): HostRuntimeController {
|
||||
const controllers = (
|
||||
getHostRuntimeStore() as unknown as {
|
||||
controllers: Map<string, HostRuntimeController>;
|
||||
}
|
||||
).controllers;
|
||||
const controller = controllers.get(SERVER_ID);
|
||||
if (!controller) {
|
||||
throw new Error("Host runtime controller was not initialized");
|
||||
}
|
||||
return controller;
|
||||
}
|
||||
|
||||
function updateControllerSnapshot(
|
||||
patch: Partial<Omit<HostRuntimeSnapshot, "serverId" | "clientGeneration">>,
|
||||
): void {
|
||||
(
|
||||
getHostController() as unknown as {
|
||||
updateSnapshot: (
|
||||
patch: Partial<Omit<HostRuntimeSnapshot, "serverId" | "clientGeneration">>,
|
||||
) => void;
|
||||
}
|
||||
).updateSnapshot(patch);
|
||||
}
|
||||
|
||||
async function renderProbe(counts: RenderCounts): Promise<{ root: Root; container: HTMLElement }> {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<SidebarFrameProbe counts={counts} />);
|
||||
});
|
||||
resetCounts(counts);
|
||||
return { root, container };
|
||||
}
|
||||
|
||||
describe("sidebar workspace render isolation", () => {
|
||||
let root: Root | null = null;
|
||||
let container: HTMLElement | null = null;
|
||||
|
||||
beforeEach(async () => {
|
||||
initializeSidebarState(createWorkspaces());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
root = null;
|
||||
container?.remove();
|
||||
container = null;
|
||||
act(() => {
|
||||
syncNavigationActiveWorkspace({ current: null });
|
||||
getHostRuntimeStore().syncHosts([]);
|
||||
useSessionStore.getState().clearSession(SERVER_ID);
|
||||
useSidebarOrderStore.setState({
|
||||
projectOrderByServerId: {},
|
||||
workspaceOrderByServerAndProject: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("re-renders only the changed workspace row for a status update", async () => {
|
||||
const counts: RenderCounts = {
|
||||
frame: 0,
|
||||
headers: {},
|
||||
rows: {},
|
||||
projectSelection: {},
|
||||
rowSelection: {},
|
||||
};
|
||||
({ root, container } = await renderProbe(counts));
|
||||
|
||||
act(() => {
|
||||
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [
|
||||
{
|
||||
...createWorkspaces()[1]!,
|
||||
status: "running",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
expect(counts.frame).toBe(0);
|
||||
expect(counts.headers).toEqual({});
|
||||
expect(counts.rows).toEqual({ "a-one": 1 });
|
||||
});
|
||||
|
||||
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: {},
|
||||
projectSelection: {},
|
||||
rowSelection: {},
|
||||
};
|
||||
({ root, container } = await renderProbe(counts));
|
||||
|
||||
act(() => {
|
||||
const probeByConnectionId = getHostController().getSnapshot().probeByConnectionId;
|
||||
updateControllerSnapshot({
|
||||
probeByConnectionId: new Map(probeByConnectionId),
|
||||
});
|
||||
});
|
||||
|
||||
expect(counts).toEqual({
|
||||
frame: 0,
|
||||
headers: {},
|
||||
rows: {},
|
||||
projectSelection: {},
|
||||
rowSelection: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not re-render for a deep-equal scripts patch", async () => {
|
||||
const counts: RenderCounts = {
|
||||
frame: 0,
|
||||
headers: {},
|
||||
rows: {},
|
||||
projectSelection: {},
|
||||
rowSelection: {},
|
||||
};
|
||||
({ root, container } = await renderProbe(counts));
|
||||
|
||||
act(() => {
|
||||
useSessionStore.getState().setWorkspaces(SERVER_ID, (current) =>
|
||||
patchWorkspaceScripts(current, {
|
||||
workspaceId: "a-main",
|
||||
scripts: [{ ...runningScript }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
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 {
|
||||
Archive,
|
||||
ArrowUpRight,
|
||||
CircleAlert,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
@@ -56,6 +55,7 @@ import {
|
||||
} from "@/utils/host-routes";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import {
|
||||
createSidebarWorkspaceEntry,
|
||||
type SidebarProjectEntry,
|
||||
type SidebarWorkspaceEntry,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
@@ -91,9 +91,17 @@ import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import { type PrHint, useWorkspacePrHint } from "@/hooks/use-checkout-pr-status-query";
|
||||
import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model";
|
||||
import { useNavigationActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
|
||||
import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation";
|
||||
import {
|
||||
useIsNavigationProjectActive,
|
||||
useIsNavigationWorkspaceSelected,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
import {
|
||||
normalizeWorkspaceDescriptor,
|
||||
useSessionStore,
|
||||
type WorkspaceDescriptor,
|
||||
} from "@/stores/session-store";
|
||||
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import {
|
||||
requireWorkspaceExecutionDirectory,
|
||||
@@ -114,7 +122,6 @@ function toProjectIconDataUri(icon: { mimeType: string; data: string } | null):
|
||||
const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) => workspace.workspaceKey;
|
||||
|
||||
const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey;
|
||||
const EMPTY_WORKSPACES = new Map();
|
||||
const WORKSPACE_STATUS_DOT_WIDTH = 14;
|
||||
const DEFAULT_STATUS_DOT_SIZE = 7;
|
||||
const EMPHASIZED_STATUS_DOT_SIZE = 9;
|
||||
@@ -194,10 +201,24 @@ interface WorkspaceRowInnerProps {
|
||||
archiveShortcutKeys?: ShortcutKey[][] | null;
|
||||
}
|
||||
|
||||
function useSidebarWorkspaceEntry(
|
||||
serverId: string | null,
|
||||
workspaceId: string | null,
|
||||
): SidebarWorkspaceEntry | null {
|
||||
const projectWorkspaceEntry = useCallback(
|
||||
(workspace: WorkspaceDescriptor): SidebarWorkspaceEntry =>
|
||||
createSidebarWorkspaceEntry({ serverId: serverId ?? "", workspace }),
|
||||
[serverId],
|
||||
);
|
||||
|
||||
return useWorkspaceFields(serverId, workspaceId, projectWorkspaceEntry);
|
||||
}
|
||||
|
||||
export function PrBadge({ hint }: { hint: PrHint }) {
|
||||
const { theme } = useUnistyles();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const activeColor = isHovered ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||
const iconColor = getWorkspacePrIconColor(theme, hint.state);
|
||||
|
||||
const handlePressIn = useCallback((event: GestureResponderEvent) => {
|
||||
event.stopPropagation();
|
||||
@@ -222,11 +243,14 @@ export function PrBadge({ hint }: { hint: PrHint }) {
|
||||
onHoverOut={() => setIsHovered(false)}
|
||||
style={({ pressed }) => [prBadgeStyles.badge, pressed && prBadgeStyles.badgePressed]}
|
||||
>
|
||||
<GitPullRequest size={12} color={activeColor} />
|
||||
{isHovered ? (
|
||||
<ExternalLink size={12} color={activeColor} />
|
||||
) : (
|
||||
<GitPullRequest size={12} color={iconColor} />
|
||||
)}
|
||||
<Text style={[prBadgeStyles.text, { color: activeColor }]} numberOfLines={1}>
|
||||
#{hint.number}
|
||||
</Text>
|
||||
<ArrowUpRight size={10} color={activeColor} style={{ opacity: isHovered ? 1 : 0 }} />
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
@@ -797,17 +821,6 @@ function ProjectHeaderRow({
|
||||
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
||||
const toast = useToast();
|
||||
|
||||
useKeyboardActionHandler({
|
||||
handlerId: `worktree-new-${project.projectKey}`,
|
||||
actions: ["worktree.new"],
|
||||
enabled: isProjectActive && canCreateWorktree && Boolean(serverId),
|
||||
priority: 0,
|
||||
handle: () => {
|
||||
handleBeginWorkspaceSetup();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
const interaction = useLongPressDragInteraction({
|
||||
drag,
|
||||
menuController,
|
||||
@@ -823,12 +836,7 @@ function ProjectHeaderRow({
|
||||
|
||||
const rowChildren = (
|
||||
<>
|
||||
<View
|
||||
{...(dragHandleProps?.attributes as any)}
|
||||
{...(dragHandleProps?.listeners as any)}
|
||||
ref={dragHandleProps?.setActivatorNodeRef as any}
|
||||
style={styles.projectRowLeft}
|
||||
>
|
||||
<View style={styles.projectRowLeft}>
|
||||
<ProjectLeadingVisual
|
||||
displayName={displayName}
|
||||
iconDataUri={iconDataUri}
|
||||
@@ -905,7 +913,13 @@ function ProjectHeaderRow({
|
||||
|
||||
if (menuController) {
|
||||
return (
|
||||
<View onPointerEnter={() => setIsHovered(true)} onPointerLeave={() => setIsHovered(false)}>
|
||||
<View
|
||||
{...(dragHandleProps?.attributes as any)}
|
||||
{...(dragHandleProps?.listeners as any)}
|
||||
ref={dragHandleProps?.setActivatorNodeRef as any}
|
||||
onPointerEnter={() => setIsHovered(true)}
|
||||
onPointerLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<ContextMenuTrigger
|
||||
enabledOnMobile={false}
|
||||
style={({ pressed }) => [
|
||||
@@ -928,7 +942,13 @@ function ProjectHeaderRow({
|
||||
}
|
||||
|
||||
return (
|
||||
<View onPointerEnter={() => setIsHovered(true)} onPointerLeave={() => setIsHovered(false)}>
|
||||
<View
|
||||
{...(dragHandleProps?.attributes as any)}
|
||||
{...(dragHandleProps?.listeners as any)}
|
||||
ref={dragHandleProps?.setActivatorNodeRef as any}
|
||||
onPointerEnter={() => setIsHovered(true)}
|
||||
onPointerLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.projectRow,
|
||||
@@ -1003,6 +1023,9 @@ function WorkspaceRowInner({
|
||||
return (
|
||||
<WorkspaceHoverCard workspace={workspace} prHint={prHint} isDragging={isDragging}>
|
||||
<View
|
||||
{...(dragHandleProps?.attributes as any)}
|
||||
{...(dragHandleProps?.listeners as any)}
|
||||
ref={dragHandleProps?.setActivatorNodeRef as any}
|
||||
style={styles.workspaceRowContainer}
|
||||
onPointerEnter={() => setIsHovered(true)}
|
||||
onPointerLeave={() => setIsHovered(false)}
|
||||
@@ -1026,12 +1049,7 @@ function WorkspaceRowInner({
|
||||
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
||||
>
|
||||
<View style={styles.workspaceRowMain}>
|
||||
<View
|
||||
{...(dragHandleProps?.attributes as any)}
|
||||
{...(dragHandleProps?.listeners as any)}
|
||||
ref={dragHandleProps?.setActivatorNodeRef as any}
|
||||
style={styles.workspaceRowLeft}
|
||||
>
|
||||
<View style={styles.workspaceRowLeft}>
|
||||
<WorkspaceStatusIndicator
|
||||
bucket={workspace.statusBucket}
|
||||
workspaceKind={workspace.workspaceKind}
|
||||
@@ -1126,8 +1144,8 @@ function WorkspaceRowInner({
|
||||
</View>
|
||||
{prHint ? (
|
||||
<View style={styles.workspacePrBadgeRow}>
|
||||
<ChecksBadge checks={prHint.checks} />
|
||||
<PrBadge hint={prHint} />
|
||||
<ChecksBadge checks={prHint.checks} />
|
||||
</View>
|
||||
) : null}
|
||||
</Pressable>
|
||||
@@ -1160,11 +1178,7 @@ function WorkspaceRowWithMenu({
|
||||
isCreating?: boolean;
|
||||
}) {
|
||||
const toast = useToast();
|
||||
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection();
|
||||
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
|
||||
const sessionWorkspaces = useSessionStore(
|
||||
(state) => state.sessions[workspace.serverId]?.workspaces ?? EMPTY_WORKSPACES,
|
||||
);
|
||||
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
|
||||
const workspaceDirectory = resolveWorkspaceExecutionDirectory({
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
@@ -1181,21 +1195,11 @@ function WorkspaceRowWithMenu({
|
||||
const isWorktree = workspace.workspaceKind === "worktree";
|
||||
const isArchiving = isWorktree ? archiveStatus === "pending" : isArchivingWorkspace;
|
||||
const redirectAfterArchive = useCallback(() => {
|
||||
if (
|
||||
activeWorkspaceSelection?.serverId !== workspace.serverId ||
|
||||
activeWorkspaceSelection.workspaceId !== workspace.workspaceId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.replace(
|
||||
buildWorkspaceArchiveRedirectRoute({
|
||||
serverId: workspace.serverId,
|
||||
archivedWorkspaceId: workspace.workspaceId,
|
||||
workspaces: sessionWorkspaces.values(),
|
||||
}),
|
||||
);
|
||||
}, [activeWorkspaceSelection, sessionWorkspaces, workspace.serverId, workspace.workspaceId]);
|
||||
redirectIfArchivingActiveWorkspace({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
});
|
||||
}, [workspace.serverId, workspace.workspaceId]);
|
||||
|
||||
const handleArchiveWorktree = useCallback(() => {
|
||||
if (isArchiving) {
|
||||
@@ -1388,27 +1392,13 @@ function NonGitProjectRowWithMenuContent({
|
||||
}) {
|
||||
const toast = useToast();
|
||||
const contextMenu = useContextMenu();
|
||||
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection();
|
||||
const sessionWorkspaces = useSessionStore(
|
||||
(state) => state.sessions[workspace.serverId]?.workspaces ?? EMPTY_WORKSPACES,
|
||||
);
|
||||
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
|
||||
const redirectAfterArchive = useCallback(() => {
|
||||
if (
|
||||
activeWorkspaceSelection?.serverId !== workspace.serverId ||
|
||||
activeWorkspaceSelection.workspaceId !== workspace.workspaceId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.replace(
|
||||
buildWorkspaceArchiveRedirectRoute({
|
||||
serverId: workspace.serverId,
|
||||
archivedWorkspaceId: workspace.workspaceId,
|
||||
workspaces: sessionWorkspaces.values(),
|
||||
}) as any,
|
||||
);
|
||||
}, [activeWorkspaceSelection, sessionWorkspaces, workspace.serverId, workspace.workspaceId]);
|
||||
redirectIfArchivingActiveWorkspace({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
});
|
||||
}, [workspace.serverId, workspace.workspaceId]);
|
||||
|
||||
const handleArchiveWorkspace = useCallback(() => {
|
||||
if (isArchivingWorkspace) {
|
||||
@@ -1535,6 +1525,7 @@ function FlattenedProjectRow({
|
||||
isProjectActive = false,
|
||||
onRemoveProject,
|
||||
removeProjectStatus,
|
||||
selectionEnabled,
|
||||
}: {
|
||||
project: SidebarProjectEntry;
|
||||
displayName: string;
|
||||
@@ -1552,15 +1543,27 @@ function FlattenedProjectRow({
|
||||
isProjectActive?: boolean;
|
||||
onRemoveProject?: () => void;
|
||||
removeProjectStatus?: "idle" | "pending";
|
||||
selectionEnabled: boolean;
|
||||
}) {
|
||||
const workspace = useSidebarWorkspaceEntry(serverId, rowModel.workspace.workspaceId);
|
||||
const selected = useIsNavigationWorkspaceSelected({
|
||||
serverId,
|
||||
workspaceId: rowModel.workspace.workspaceId,
|
||||
enabled: selectionEnabled,
|
||||
});
|
||||
|
||||
if (!workspace) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (project.projectKind === "directory") {
|
||||
return (
|
||||
<NonGitProjectRowWithMenu
|
||||
project={project}
|
||||
displayName={displayName}
|
||||
iconDataUri={iconDataUri}
|
||||
workspace={rowModel.workspace}
|
||||
selected={rowModel.selected}
|
||||
workspace={workspace}
|
||||
selected={selected}
|
||||
onPress={onPress}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
@@ -1576,8 +1579,8 @@ function FlattenedProjectRow({
|
||||
project={project}
|
||||
displayName={displayName}
|
||||
iconDataUri={iconDataUri}
|
||||
workspace={rowModel.workspace}
|
||||
selected={rowModel.selected}
|
||||
workspace={workspace}
|
||||
selected={selected}
|
||||
chevron={rowModel.chevron}
|
||||
onPress={onPress}
|
||||
serverId={serverId}
|
||||
@@ -1599,7 +1602,6 @@ function FlattenedProjectRow({
|
||||
|
||||
function WorkspaceRow({
|
||||
workspace,
|
||||
selected,
|
||||
shortcutNumber,
|
||||
showShortcutBadge,
|
||||
onPress,
|
||||
@@ -1608,9 +1610,9 @@ function WorkspaceRow({
|
||||
dragHandleProps,
|
||||
canCopyBranchName,
|
||||
isCreating = false,
|
||||
selectionEnabled,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
selected: boolean;
|
||||
shortcutNumber: number | null;
|
||||
showShortcutBadge: boolean;
|
||||
onPress: () => void;
|
||||
@@ -1619,10 +1621,22 @@ function WorkspaceRow({
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
canCopyBranchName: boolean;
|
||||
isCreating?: boolean;
|
||||
selectionEnabled: boolean;
|
||||
}) {
|
||||
const hydratedWorkspace = useSidebarWorkspaceEntry(workspace.serverId, workspace.workspaceId);
|
||||
const selected = useIsNavigationWorkspaceSelected({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
enabled: selectionEnabled,
|
||||
});
|
||||
|
||||
if (!hydratedWorkspace) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkspaceRowWithMenu
|
||||
workspace={workspace}
|
||||
workspace={hydratedWorkspace}
|
||||
selected={selected}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
@@ -1642,7 +1656,7 @@ function ProjectBlock({
|
||||
displayName,
|
||||
iconDataUri,
|
||||
serverId,
|
||||
activeWorkspaceSelection,
|
||||
selectionEnabled,
|
||||
showShortcutBadges,
|
||||
shortcutIndexByWorkspaceKey,
|
||||
parentGestureRef,
|
||||
@@ -1650,6 +1664,7 @@ function ProjectBlock({
|
||||
onWorkspacePress,
|
||||
onWorkspaceReorder,
|
||||
onWorktreeCreated,
|
||||
currentPathname,
|
||||
drag,
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
@@ -1661,7 +1676,7 @@ function ProjectBlock({
|
||||
displayName: string;
|
||||
iconDataUri: string | null;
|
||||
serverId: string | null;
|
||||
activeWorkspaceSelection: { serverId: string; workspaceId: string } | null;
|
||||
selectionEnabled: boolean;
|
||||
showShortcutBadges: boolean;
|
||||
shortcutIndexByWorkspaceKey: Map<string, number>;
|
||||
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
||||
@@ -1669,6 +1684,7 @@ function ProjectBlock({
|
||||
onWorkspacePress?: () => void;
|
||||
onWorkspaceReorder: (projectKey: string, workspaces: SidebarWorkspaceEntry[]) => void;
|
||||
onWorktreeCreated?: (workspaceId: string) => void;
|
||||
currentPathname: string | null;
|
||||
drag: () => void;
|
||||
isDragging: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
@@ -1680,18 +1696,19 @@ function ProjectBlock({
|
||||
buildSidebarProjectRowModel({
|
||||
project,
|
||||
collapsed,
|
||||
serverId,
|
||||
activeWorkspaceSelection,
|
||||
}),
|
||||
[activeWorkspaceSelection, collapsed, project, serverId],
|
||||
[collapsed, project],
|
||||
);
|
||||
|
||||
const isProjectActive = useMemo(() => {
|
||||
if (!serverId || !activeWorkspaceSelection || activeWorkspaceSelection.serverId !== serverId) {
|
||||
return false;
|
||||
}
|
||||
return project.workspaces.some((w) => w.workspaceId === activeWorkspaceSelection.workspaceId);
|
||||
}, [serverId, activeWorkspaceSelection, project.workspaces]);
|
||||
const projectWorkspaceIds = useMemo(
|
||||
() => project.workspaces.map((workspace) => workspace.workspaceId),
|
||||
[project.workspaces],
|
||||
);
|
||||
const isProjectActive = useIsNavigationProjectActive({
|
||||
serverId,
|
||||
workspaceIds: projectWorkspaceIds,
|
||||
enabled: selectionEnabled,
|
||||
});
|
||||
|
||||
const renderWorkspaceRow = useCallback(
|
||||
(
|
||||
@@ -1702,25 +1719,20 @@ function ProjectBlock({
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
},
|
||||
) => {
|
||||
const isSelected =
|
||||
Boolean(serverId) &&
|
||||
activeWorkspaceSelection?.serverId === serverId &&
|
||||
activeWorkspaceSelection.workspaceId === item.workspaceId;
|
||||
|
||||
return (
|
||||
<WorkspaceRow
|
||||
workspace={item}
|
||||
selected={isSelected}
|
||||
shortcutNumber={shortcutIndexByWorkspaceKey.get(item.workspaceKey) ?? null}
|
||||
showShortcutBadge={showShortcutBadges}
|
||||
canCopyBranchName={project.projectKind === "git"}
|
||||
isCreating={creatingWorkspaceIds.has(item.workspaceId)}
|
||||
selectionEnabled={selectionEnabled}
|
||||
onPress={() => {
|
||||
if (!serverId) {
|
||||
return;
|
||||
}
|
||||
onWorkspacePress?.();
|
||||
navigateToWorkspace(serverId, item.workspaceId);
|
||||
navigateToWorkspace(serverId, item.workspaceId, { currentPathname });
|
||||
}}
|
||||
drag={input?.drag ?? (() => {})}
|
||||
isDragging={input?.isDragging ?? false}
|
||||
@@ -1729,11 +1741,12 @@ function ProjectBlock({
|
||||
);
|
||||
},
|
||||
[
|
||||
activeWorkspaceSelection,
|
||||
project.projectKind,
|
||||
creatingWorkspaceIds,
|
||||
currentPathname,
|
||||
onWorkspacePress,
|
||||
serverId,
|
||||
selectionEnabled,
|
||||
shortcutIndexByWorkspaceKey,
|
||||
showShortcutBadges,
|
||||
],
|
||||
@@ -1820,7 +1833,9 @@ function ProjectBlock({
|
||||
return;
|
||||
}
|
||||
onWorkspacePress?.();
|
||||
navigateToWorkspace(serverId, rowModel.workspace.workspaceId);
|
||||
navigateToWorkspace(serverId, rowModel.workspace.workspaceId, {
|
||||
currentPathname,
|
||||
});
|
||||
}}
|
||||
serverId={serverId}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
@@ -1833,6 +1848,7 @@ function ProjectBlock({
|
||||
isProjectActive={isProjectActive}
|
||||
onRemoveProject={handleRemoveProject}
|
||||
removeProjectStatus={isRemovingProject ? "pending" : "idle"}
|
||||
selectionEnabled={selectionEnabled}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
@@ -1891,9 +1907,7 @@ export function SidebarWorkspaceList({
|
||||
listFooterComponent,
|
||||
parentGestureRef,
|
||||
}: SidebarWorkspaceListProps) {
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const pathname = usePathname();
|
||||
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection();
|
||||
const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set());
|
||||
const creatingWorkspaceTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
|
||||
new Map(),
|
||||
@@ -1909,7 +1923,7 @@ export function SidebarWorkspaceList({
|
||||
() => Boolean(pathname && parseHostWorkspaceRouteFromPathname(pathname)),
|
||||
[pathname],
|
||||
);
|
||||
const effectiveActiveWorkspaceSelection = isWorkspaceRoute ? activeWorkspaceSelection : null;
|
||||
const selectionEnabled = isWorkspaceRoute;
|
||||
|
||||
const projectIconRequests = useMemo(() => {
|
||||
if (!serverId) {
|
||||
@@ -2114,7 +2128,7 @@ export function SidebarWorkspaceList({
|
||||
displayName={item.projectName}
|
||||
iconDataUri={projectIconByProjectKey.get(item.projectKey) ?? null}
|
||||
serverId={serverId}
|
||||
activeWorkspaceSelection={effectiveActiveWorkspaceSelection}
|
||||
selectionEnabled={selectionEnabled}
|
||||
showShortcutBadges={showShortcutBadges}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
parentGestureRef={parentGestureRef}
|
||||
@@ -2122,6 +2136,7 @@ export function SidebarWorkspaceList({
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
onWorkspaceReorder={handleWorkspaceReorder}
|
||||
onWorktreeCreated={handleWorktreeCreated}
|
||||
currentPathname={pathname}
|
||||
drag={drag}
|
||||
isDragging={isActive}
|
||||
dragHandleProps={dragHandleProps}
|
||||
@@ -2132,13 +2147,14 @@ export function SidebarWorkspaceList({
|
||||
},
|
||||
[
|
||||
collapsedProjectKeys,
|
||||
effectiveActiveWorkspaceSelection,
|
||||
handleWorktreeCreated,
|
||||
handleWorkspaceReorder,
|
||||
onWorkspacePress,
|
||||
onToggleProjectCollapsed,
|
||||
parentGestureRef,
|
||||
pathname,
|
||||
projectIconByProjectKey,
|
||||
selectionEnabled,
|
||||
serverId,
|
||||
shortcutIndexByWorkspaceKey,
|
||||
showShortcutBadges,
|
||||
@@ -2251,6 +2267,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[2],
|
||||
userSelect: "none",
|
||||
},
|
||||
projectRowHovered: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
@@ -2394,6 +2411,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "stretch",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[1],
|
||||
userSelect: "none",
|
||||
},
|
||||
workspaceRowMain: {
|
||||
flexDirection: "row",
|
||||
@@ -2494,22 +2512,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.xs,
|
||||
flexShrink: 0,
|
||||
},
|
||||
diffStatRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
flexShrink: 0,
|
||||
},
|
||||
diffStatAdditions: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.diffAddition,
|
||||
},
|
||||
diffStatDeletions: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.diffDeletion,
|
||||
},
|
||||
kebabButton: {
|
||||
padding: 2,
|
||||
borderRadius: 4,
|
||||
|
||||
@@ -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;
|
||||
normalizedServerId: string;
|
||||
normalizedWorkspaceId: string;
|
||||
isWorkspaceFocused: boolean;
|
||||
uiTabs: WorkspaceTab[];
|
||||
hoveredCloseTabKey: string | null;
|
||||
setHoveredTabKey: Dispatch<SetStateAction<string | null>>;
|
||||
@@ -93,7 +94,6 @@ interface SplitContainerProps {
|
||||
onCreateTerminalTab: (input: { paneId?: string }) => void;
|
||||
buildPaneContentModel: (input: {
|
||||
paneId: string;
|
||||
isPaneFocused: boolean;
|
||||
tab: WorkspaceTabDescriptor;
|
||||
}) => WorkspacePaneContentModel;
|
||||
onFocusPane: (paneId: string) => void;
|
||||
@@ -158,11 +158,11 @@ interface SplitPaneViewProps
|
||||
interface MountedTabSlotProps {
|
||||
tabDescriptor: WorkspaceTabDescriptor;
|
||||
isVisible: boolean;
|
||||
isWorkspaceFocused: boolean;
|
||||
isPaneFocused: boolean;
|
||||
paneId: string;
|
||||
buildPaneContentModel: (input: {
|
||||
paneId: string;
|
||||
isPaneFocused: boolean;
|
||||
tab: WorkspaceTabDescriptor;
|
||||
}) => WorkspacePaneContentModel;
|
||||
}
|
||||
@@ -170,6 +170,7 @@ interface MountedTabSlotProps {
|
||||
const MountedTabSlot = memo(function MountedTabSlot({
|
||||
tabDescriptor,
|
||||
isVisible,
|
||||
isWorkspaceFocused,
|
||||
isPaneFocused,
|
||||
paneId,
|
||||
buildPaneContentModel,
|
||||
@@ -178,15 +179,18 @@ const MountedTabSlot = memo(function MountedTabSlot({
|
||||
() =>
|
||||
buildPaneContentModel({
|
||||
paneId,
|
||||
isPaneFocused,
|
||||
tab: tabDescriptor,
|
||||
}),
|
||||
[buildPaneContentModel, isPaneFocused, paneId, tabDescriptor],
|
||||
[buildPaneContentModel, paneId, tabDescriptor],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={{ display: isVisible ? "flex" : "none", flex: 1 }}>
|
||||
<WorkspacePaneContent content={content} />
|
||||
<WorkspacePaneContent
|
||||
content={content}
|
||||
isWorkspaceFocused={isWorkspaceFocused}
|
||||
isPaneFocused={isPaneFocused}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
@@ -241,6 +245,7 @@ export function SplitContainer({
|
||||
workspaceKey,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
isWorkspaceFocused,
|
||||
uiTabs,
|
||||
hoveredCloseTabKey,
|
||||
setHoveredTabKey,
|
||||
@@ -511,6 +516,7 @@ export function SplitContainer({
|
||||
focusedPaneId={layout.focusedPaneId}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
isWorkspaceFocused={isWorkspaceFocused}
|
||||
hoveredCloseTabKey={hoveredCloseTabKey}
|
||||
setHoveredTabKey={setHoveredTabKey}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
@@ -633,6 +639,7 @@ function SplitNodeView({
|
||||
focusedPaneId,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
isWorkspaceFocused,
|
||||
hoveredCloseTabKey,
|
||||
setHoveredTabKey,
|
||||
setHoveredCloseTabKey,
|
||||
@@ -668,6 +675,7 @@ function SplitNodeView({
|
||||
isFocused={node.pane.id === focusedPaneId}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
isWorkspaceFocused={isWorkspaceFocused}
|
||||
hoveredCloseTabKey={hoveredCloseTabKey}
|
||||
setHoveredTabKey={setHoveredTabKey}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
@@ -718,6 +726,7 @@ function SplitNodeView({
|
||||
focusedPaneId={focusedPaneId}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
isWorkspaceFocused={isWorkspaceFocused}
|
||||
hoveredCloseTabKey={hoveredCloseTabKey}
|
||||
setHoveredTabKey={setHoveredTabKey}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
@@ -767,6 +776,7 @@ function SplitPaneView({
|
||||
isFocused,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
isWorkspaceFocused,
|
||||
hoveredCloseTabKey,
|
||||
setHoveredTabKey,
|
||||
setHoveredCloseTabKey,
|
||||
@@ -916,6 +926,7 @@ function SplitPaneView({
|
||||
key={tabId}
|
||||
tabDescriptor={tabDescriptor}
|
||||
isVisible={tabId === activeTabDescriptor?.tabId}
|
||||
isWorkspaceFocused={isWorkspaceFocused}
|
||||
isPaneFocused={isFocused && tabId === activeTabDescriptor?.tabId}
|
||||
paneId={pane.id}
|
||||
buildPaneContentModel={buildPaneContentModel}
|
||||
|
||||
128
packages/app/src/components/stream-strategy-web.test.tsx
Normal file
128
packages/app/src/components/stream-strategy-web.test.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { StreamSegmentRenderers, StreamViewportHandle } from "./stream-strategy";
|
||||
import { createWebStreamStrategy } from "./stream-strategy-web";
|
||||
|
||||
function userMessage(index: number): StreamItem {
|
||||
return {
|
||||
kind: "user_message",
|
||||
id: `message-${index}`,
|
||||
text: `Message ${index}`,
|
||||
timestamp: new Date(`2026-04-20T00:00:${String(index % 60).padStart(2, "0")}.000Z`),
|
||||
};
|
||||
}
|
||||
|
||||
function createRenderers(onRowRender: () => void): StreamSegmentRenderers {
|
||||
return {
|
||||
renderHistoryVirtualizedRow: (item) => {
|
||||
onRowRender();
|
||||
return <div style={{ height: 24 }}>{item.id}</div>;
|
||||
},
|
||||
renderHistoryMountedRow: (item) => <div>{item.id}</div>,
|
||||
renderLiveHeadRow: (item) => <div>{item.id}</div>,
|
||||
renderLiveAuxiliary: () => null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("createWebStreamStrategy", () => {
|
||||
let root: Root | null = null;
|
||||
let container: HTMLDivElement | null = null;
|
||||
let originalScrollTo: HTMLElement["scrollTo"] | undefined;
|
||||
let originalOffsetHeight: PropertyDescriptor | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, "ResizeObserver", {
|
||||
value: class ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
originalScrollTo = HTMLElement.prototype.scrollTo;
|
||||
HTMLElement.prototype.scrollTo = vi.fn();
|
||||
originalOffsetHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetHeight");
|
||||
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
|
||||
configurable: true,
|
||||
get() {
|
||||
return 24;
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
root = null;
|
||||
container?.remove();
|
||||
container = null;
|
||||
if (originalScrollTo) {
|
||||
HTMLElement.prototype.scrollTo = originalScrollTo;
|
||||
} else {
|
||||
Reflect.deleteProperty(HTMLElement.prototype, "scrollTo");
|
||||
}
|
||||
if (originalOffsetHeight) {
|
||||
Object.defineProperty(HTMLElement.prototype, "offsetHeight", originalOffsetHeight);
|
||||
} else {
|
||||
Reflect.deleteProperty(HTMLElement.prototype, "offsetHeight");
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("mounts virtualized history without recursive row measurement updates", () => {
|
||||
const rowRenderCount = vi.fn();
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||
const viewportRef = React.createRef<StreamViewportHandle>();
|
||||
const historyVirtualized = Array.from({ length: 16 }, (_, index) => userMessage(index));
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root?.render(
|
||||
<>
|
||||
{strategy.render({
|
||||
agentId: "agent",
|
||||
segments: {
|
||||
historyVirtualized,
|
||||
historyMounted: [],
|
||||
liveHead: [],
|
||||
},
|
||||
boundary: {
|
||||
hasVirtualizedHistory: true,
|
||||
hasMountedHistory: false,
|
||||
hasLiveHead: false,
|
||||
historyToHeadGap: 0,
|
||||
},
|
||||
renderers: createRenderers(rowRenderCount),
|
||||
listEmptyComponent: null,
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest: null,
|
||||
isAuthoritativeHistoryReady: true,
|
||||
onNearBottomChange: vi.fn(),
|
||||
scrollEnabled: true,
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
})}
|
||||
</>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(rowRenderCount.mock.calls.length).toBeGreaterThan(0);
|
||||
expect(rowRenderCount.mock.calls.length).toBeLessThanOrEqual(historyVirtualized.length);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import {
|
||||
import React, {
|
||||
Fragment,
|
||||
type CSSProperties,
|
||||
useCallback,
|
||||
@@ -126,6 +126,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const lastTouchClientYRef = useRef<number | null>(null);
|
||||
const pendingAutoScrollFrameRef = useRef<number | null>(null);
|
||||
const pendingAutoScrollTimeoutRef = useRef<number | null>(null);
|
||||
const pendingVirtualRowMeasureFramesRef = useRef(new Map<Element, number>());
|
||||
const showDesktopWebScrollbar = !isMobileBreakpoint;
|
||||
const scrollbarOverlay = useWebElementScrollbar(scrollContainerRef, {
|
||||
enabled: showDesktopWebScrollbar,
|
||||
@@ -181,6 +182,38 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const virtualRows = rowVirtualizer.getVirtualItems();
|
||||
const virtualTotalSize = rowVirtualizer.getTotalSize();
|
||||
|
||||
const measureVirtualizedRowElement = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
if (!node) {
|
||||
rowVirtualizer.measureElement(null);
|
||||
return;
|
||||
}
|
||||
const pendingFrames = pendingVirtualRowMeasureFramesRef.current;
|
||||
const existingFrame = pendingFrames.get(node);
|
||||
if (existingFrame !== undefined) {
|
||||
window.cancelAnimationFrame(existingFrame);
|
||||
}
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
pendingFrames.delete(node);
|
||||
if (node.isConnected) {
|
||||
rowVirtualizer.measureElement(node);
|
||||
}
|
||||
});
|
||||
pendingFrames.set(node, frame);
|
||||
},
|
||||
[rowVirtualizer],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const pendingFrames = pendingVirtualRowMeasureFramesRef.current;
|
||||
return () => {
|
||||
for (const frame of pendingFrames.values()) {
|
||||
window.cancelAnimationFrame(frame);
|
||||
}
|
||||
pendingFrames.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const cancelPendingStickToBottom = useCallback(() => {
|
||||
const pendingFrame = pendingAutoScrollFrameRef.current;
|
||||
if (pendingFrame !== null) {
|
||||
@@ -696,7 +729,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
data-index={virtualRow.index}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
ref={measureVirtualizedRowElement}
|
||||
style={renderVirtualRowStyle(virtualRow.start)}
|
||||
>
|
||||
{renderHistoryVirtualizedRow(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useFocusEffect, useIsFocused } from "@react-navigation/native";
|
||||
import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
|
||||
import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
@@ -27,7 +26,9 @@ interface TerminalPaneProps {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
terminalId: string;
|
||||
isWorkspaceFocused: boolean;
|
||||
isPaneFocused: boolean;
|
||||
onOpenFileExplorer: () => void;
|
||||
}
|
||||
|
||||
const TERMINAL_REFIT_DELAYS_MS = [0, 48, 144, 320];
|
||||
@@ -82,15 +83,20 @@ function terminalScopeKey(input: { serverId: string; cwd: string }): string {
|
||||
return `${input.serverId}:${input.cwd}`;
|
||||
}
|
||||
|
||||
export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: TerminalPaneProps) {
|
||||
const isScreenFocused = useIsFocused();
|
||||
export function TerminalPane({
|
||||
serverId,
|
||||
cwd,
|
||||
terminalId,
|
||||
isWorkspaceFocused,
|
||||
isPaneFocused,
|
||||
onOpenFileExplorer,
|
||||
}: TerminalPaneProps) {
|
||||
const isAppVisible = useAppVisible();
|
||||
const { theme } = useUnistyles();
|
||||
const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme.colors.terminal]);
|
||||
const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme]);
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const openAgentList = usePanelStore((state) => state.openAgentList);
|
||||
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
|
||||
const showMobileAgentList = usePanelStore((state) => state.showMobileAgentList);
|
||||
const swipeGesturesEnabled = isMobile && mobileView === "agent";
|
||||
const { shift: keyboardShift, style: keyboardPaddingStyle } = useKeyboardShiftStyle({
|
||||
mode: "padding",
|
||||
@@ -131,7 +137,7 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobile || !isScreenFocused || !isPaneFocused || !terminalId) {
|
||||
if (isMobile || !isWorkspaceFocused || !isPaneFocused || !terminalId) {
|
||||
lastAutoFocusKeyRef.current = null;
|
||||
return;
|
||||
}
|
||||
@@ -143,14 +149,14 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
|
||||
|
||||
lastAutoFocusKeyRef.current = nextFocusKey;
|
||||
requestTerminalFocus();
|
||||
}, [isMobile, isPaneFocused, isScreenFocused, requestTerminalFocus, scopeKey, terminalId]);
|
||||
}, [isMobile, isPaneFocused, isWorkspaceFocused, requestTerminalFocus, scopeKey, terminalId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPaneFocused && isScreenFocused && isAppVisible && terminalId) {
|
||||
if (isPaneFocused && isWorkspaceFocused && isAppVisible && terminalId) {
|
||||
lastReportedSizeRef.current = null;
|
||||
requestTerminalReflow();
|
||||
}
|
||||
}, [isAppVisible, isPaneFocused, isScreenFocused, requestTerminalReflow, terminalId]);
|
||||
}, [isAppVisible, isPaneFocused, isWorkspaceFocused, requestTerminalReflow, terminalId]);
|
||||
|
||||
const clearKeyboardRefitTimeouts = useCallback(() => {
|
||||
if (keyboardRefitTimeoutsRef.current.length === 0) {
|
||||
@@ -187,29 +193,27 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
|
||||
[pulseKeyboardRefits],
|
||||
);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (!terminalId) {
|
||||
return;
|
||||
}
|
||||
// Navigation transitions can temporarily report stale dimensions.
|
||||
// Pulse forced refits so xterm fills the pane when returning to an agent.
|
||||
const timeoutHandles = TERMINAL_REFIT_DELAYS_MS.map((delayMs) =>
|
||||
setTimeout(() => {
|
||||
requestTerminalReflow();
|
||||
}, delayMs),
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!isWorkspaceFocused || !terminalId) {
|
||||
return;
|
||||
}
|
||||
// Focus transitions can temporarily report stale dimensions.
|
||||
// Pulse forced refits so xterm fills the pane when returning to a terminal.
|
||||
const timeoutHandles = TERMINAL_REFIT_DELAYS_MS.map((delayMs) =>
|
||||
setTimeout(() => {
|
||||
requestTerminalReflow();
|
||||
}, delayMs),
|
||||
);
|
||||
|
||||
return () => {
|
||||
for (const handle of timeoutHandles) {
|
||||
clearTimeout(handle);
|
||||
}
|
||||
};
|
||||
}, [requestTerminalReflow, terminalId]),
|
||||
);
|
||||
return () => {
|
||||
for (const handle of timeoutHandles) {
|
||||
clearTimeout(handle);
|
||||
}
|
||||
};
|
||||
}, [isWorkspaceFocused, requestTerminalReflow, terminalId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !isConnected || !isScreenFocused) {
|
||||
if (!client || !isConnected || !isWorkspaceFocused) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -232,7 +236,7 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
|
||||
});
|
||||
setModifiers({ ...EMPTY_MODIFIERS });
|
||||
});
|
||||
}, [client, isConnected, workspaceTerminalSession.snapshots]);
|
||||
}, [client, isConnected, isWorkspaceFocused, workspaceTerminalSession.snapshots]);
|
||||
|
||||
useEffect(() => {
|
||||
lastReportedSizeRef.current = null;
|
||||
@@ -257,14 +261,14 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
|
||||
client,
|
||||
getPreferredSize: () => lastReportedSizeRef.current,
|
||||
onOutput: ({ terminalId, text }) => {
|
||||
if (!isScreenFocused || terminalIdRef.current !== terminalId) {
|
||||
if (!isWorkspaceFocused || terminalIdRef.current !== terminalId) {
|
||||
return;
|
||||
}
|
||||
emulatorRef.current?.writeOutput(text);
|
||||
},
|
||||
onSnapshot: ({ terminalId, state }) => {
|
||||
workspaceTerminalSession.snapshots.set({ terminalId, state });
|
||||
if (!isScreenFocused || terminalIdRef.current !== terminalId) {
|
||||
if (!isWorkspaceFocused || terminalIdRef.current !== terminalId) {
|
||||
return;
|
||||
}
|
||||
emulatorRef.current?.renderSnapshot(state);
|
||||
@@ -274,7 +278,7 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
|
||||
|
||||
streamControllerRef.current = controller;
|
||||
controller.setTerminal({
|
||||
terminalId: isScreenFocused ? terminalIdRef.current : null,
|
||||
terminalId: isWorkspaceFocused ? terminalIdRef.current : null,
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -287,17 +291,17 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
|
||||
client,
|
||||
handleStreamControllerStatus,
|
||||
isConnected,
|
||||
isScreenFocused,
|
||||
isWorkspaceFocused,
|
||||
workspaceTerminalSession.snapshots,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
pendingTerminalInputRef.current = [];
|
||||
const nextTerminalId = isScreenFocused ? terminalId : null;
|
||||
const nextTerminalId = isWorkspaceFocused ? terminalId : null;
|
||||
streamControllerRef.current?.setTerminal({
|
||||
terminalId: nextTerminalId,
|
||||
});
|
||||
}, [isScreenFocused, terminalId]);
|
||||
}, [isWorkspaceFocused, terminalId]);
|
||||
|
||||
const enqueuePendingTerminalInput = useCallback((entry: PendingTerminalInput) => {
|
||||
const queue = pendingTerminalInputRef.current;
|
||||
@@ -477,7 +481,7 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
|
||||
!client ||
|
||||
!terminalId ||
|
||||
!isPaneFocused ||
|
||||
!isScreenFocused ||
|
||||
!isWorkspaceFocused ||
|
||||
!isAppVisible ||
|
||||
rows <= 0 ||
|
||||
cols <= 0
|
||||
@@ -550,7 +554,7 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
|
||||
return (
|
||||
<Animated.View style={[styles.container, keyboardPaddingStyle]}>
|
||||
<View style={styles.outputContainer}>
|
||||
{isScreenFocused ? (
|
||||
{isWorkspaceFocused ? (
|
||||
<View style={styles.terminalGestureContainer}>
|
||||
<TerminalEmulator
|
||||
ref={emulatorRef}
|
||||
@@ -573,13 +577,13 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
|
||||
if (!swipeGesturesEnabled) {
|
||||
return;
|
||||
}
|
||||
openAgentList();
|
||||
showMobileAgentList();
|
||||
}}
|
||||
onSwipeLeft={() => {
|
||||
if (!swipeGesturesEnabled) {
|
||||
return;
|
||||
}
|
||||
openFileExplorer();
|
||||
onOpenFileExplorer();
|
||||
}}
|
||||
onInput={handleTerminalData}
|
||||
onResize={handleTerminalResize}
|
||||
@@ -594,7 +598,7 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
|
||||
<View style={styles.terminalGestureContainer} />
|
||||
)}
|
||||
|
||||
{isAttaching && isScreenFocused ? (
|
||||
{isAttaching && isWorkspaceFocused ? (
|
||||
<View style={styles.attachOverlay} pointerEvents="none" testID="terminal-attach-loading">
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
|
||||
@@ -3,13 +3,16 @@ import { View, Text, Pressable } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import Animated from "react-native-reanimated";
|
||||
import {
|
||||
BottomSheetModal,
|
||||
BottomSheetScrollView,
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetBackgroundProps,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { X } from "lucide-react-native";
|
||||
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
||||
import {
|
||||
IsolatedBottomSheetModal,
|
||||
type IsolatedBottomSheetModalRef,
|
||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
|
||||
import { ToolCallDetailsContent } from "./tool-call-details";
|
||||
|
||||
@@ -59,18 +62,26 @@ interface ToolCallSheetProviderProps {
|
||||
}
|
||||
|
||||
export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps) {
|
||||
const bottomSheetRef = useRef<BottomSheetModal>(null);
|
||||
const { theme } = useUnistyles();
|
||||
const bottomSheetRef = useRef<IsolatedBottomSheetModalRef>(null);
|
||||
const hasPresentedRef = useRef(false);
|
||||
const [sheetData, setSheetData] = React.useState<ToolCallSheetData | null>(null);
|
||||
|
||||
const snapPoints = useMemo(() => ["60%", "95%"], []);
|
||||
|
||||
const openToolCall = useCallback((data: ToolCallSheetData) => {
|
||||
setSheetData(data);
|
||||
if (hasPresentedRef.current) {
|
||||
requestAnimationFrame(() => bottomSheetRef.current?.snapToIndex(0));
|
||||
return;
|
||||
}
|
||||
|
||||
hasPresentedRef.current = true;
|
||||
bottomSheetRef.current?.present();
|
||||
}, []);
|
||||
|
||||
const closeToolCall = useCallback(() => {
|
||||
bottomSheetRef.current?.dismiss();
|
||||
bottomSheetRef.current?.close();
|
||||
}, []);
|
||||
|
||||
const handleSheetChange = useCallback((index: number) => {
|
||||
@@ -94,20 +105,19 @@ export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps)
|
||||
return (
|
||||
<ToolCallSheetContext.Provider value={contextValue}>
|
||||
{children}
|
||||
<BottomSheetModal
|
||||
<IsolatedBottomSheetModal
|
||||
ref={bottomSheetRef}
|
||||
snapPoints={snapPoints}
|
||||
index={0}
|
||||
stackBehavior="replace"
|
||||
enableDynamicSizing={false}
|
||||
onChange={handleSheetChange}
|
||||
backdropComponent={renderBackdrop}
|
||||
enablePanDownToClose
|
||||
backgroundStyle={styles.sheetBackground}
|
||||
handleIndicatorStyle={styles.handleIndicator}
|
||||
backgroundComponent={CustomSheetBackground}
|
||||
handleIndicatorStyle={{ backgroundColor: theme.colors.palette.zinc[600] }}
|
||||
>
|
||||
{sheetData && <ToolCallSheetContent data={sheetData} onClose={closeToolCall} />}
|
||||
</BottomSheetModal>
|
||||
</IsolatedBottomSheetModal>
|
||||
</ToolCallSheetContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -120,6 +130,7 @@ interface ToolCallSheetContentProps {
|
||||
}
|
||||
|
||||
function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { toolName, displayName, detail, errorText, showLoadingSkeleton } = data;
|
||||
|
||||
const IconComponent = resolveToolCallIcon(toolName, detail);
|
||||
@@ -129,13 +140,13 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<View style={styles.headerLeft}>
|
||||
<IconComponent size={20} color={styles.headerIcon.color} />
|
||||
<IconComponent size={20} color={theme.colors.foreground} />
|
||||
<Text style={styles.headerTitle} numberOfLines={1}>
|
||||
{displayName}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable onPress={onClose} style={styles.closeButton}>
|
||||
<X size={20} color={styles.closeIcon.color} />
|
||||
<X size={20} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
@@ -155,12 +166,6 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
|
||||
// ----- Styles -----
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
sheetBackground: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
handleIndicator: {
|
||||
backgroundColor: theme.colors.palette.zinc[600],
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
@@ -180,9 +185,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
gap: theme.spacing[2],
|
||||
flex: 1,
|
||||
},
|
||||
headerIcon: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: theme.fontSize.lg,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
@@ -192,9 +194,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
closeButton: {
|
||||
padding: theme.spacing[2],
|
||||
},
|
||||
closeIcon: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef } from "react";
|
||||
import { ScrollView, Text, View, Pressable, type LayoutChangeEvent } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { File, Folder } from "lucide-react-native";
|
||||
import { Theme } from "@/styles/theme";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { getAutocompleteScrollOffset } from "./autocomplete-utils";
|
||||
|
||||
export interface AutocompleteOption {
|
||||
|
||||
@@ -88,6 +88,39 @@ describe("filterAndRankComboboxOptions", () => {
|
||||
it("returns empty when nothing matches", () => {
|
||||
expect(filterAndRankComboboxOptions(options, "zzz")).toEqual([]);
|
||||
});
|
||||
|
||||
it("ranks word-boundary matches above mid-word substring matches", () => {
|
||||
const items = [
|
||||
{ id: "happy", label: "happy" },
|
||||
{ id: "a/py", label: "a/py" },
|
||||
];
|
||||
const result = filterAndRankComboboxOptions(items, "py");
|
||||
expect(result.map((o) => o.id)).toEqual(["a/py", "happy"]);
|
||||
});
|
||||
|
||||
it("interleaves mixed branches and PRs by match quality", () => {
|
||||
const items = [
|
||||
{ id: "branch:feat/api-login", label: "feat/api-login" },
|
||||
{ id: "branch:feat/pi-direct-sdk", label: "feat/pi-direct-sdk" },
|
||||
{ id: "github-pr:202", label: "#202 feat(server): replace Pi ACP with direct SDK provider" },
|
||||
{ id: "github-pr:355", label: "#355 feat: add LaTeX math formula rendering" },
|
||||
];
|
||||
const result = filterAndRankComboboxOptions(items, "pi");
|
||||
expect(result.map((o) => o.id)).toEqual([
|
||||
"branch:feat/pi-direct-sdk",
|
||||
"github-pr:202",
|
||||
"branch:feat/api-login",
|
||||
]);
|
||||
});
|
||||
|
||||
it("ranks PR-number matches via word-boundary on #", () => {
|
||||
const items = [
|
||||
{ id: "github-pr:202", label: "#202 some title" },
|
||||
{ id: "github-pr:1202", label: "#1202 another title" },
|
||||
];
|
||||
const result = filterAndRankComboboxOptions(items, "202");
|
||||
expect(result.map((o) => o.id)).toEqual(["github-pr:202", "github-pr:1202"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("combobox above-search ordering", () => {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { compareMatchScores, type MatchScore, scoreMatch } from "../../utils/score-match";
|
||||
|
||||
export type ComboboxOptionKind = "directory" | "file";
|
||||
|
||||
export interface ComboboxOptionModel {
|
||||
@@ -7,6 +9,23 @@ export interface ComboboxOptionModel {
|
||||
kind?: ComboboxOptionKind;
|
||||
}
|
||||
|
||||
const DESCRIPTION_FALLBACK_TIER = 99;
|
||||
|
||||
function scoreOption(opt: ComboboxOptionModel, search: string): MatchScore | null {
|
||||
const labelScore = scoreMatch(search, opt.label);
|
||||
const idScore = scoreMatch(search, opt.id);
|
||||
let best: MatchScore | null = null;
|
||||
if (labelScore) best = labelScore;
|
||||
if (idScore && (!best || compareMatchScores(idScore, best) < 0)) {
|
||||
best = idScore;
|
||||
}
|
||||
if (best) return best;
|
||||
if (opt.description && opt.description.toLowerCase().includes(search)) {
|
||||
return { tier: DESCRIPTION_FALLBACK_TIER, offset: 0 };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface BuildVisibleComboboxOptionsInput {
|
||||
options: ComboboxOptionModel[];
|
||||
searchQuery: string;
|
||||
@@ -40,21 +59,17 @@ export function filterAndRankComboboxOptions(
|
||||
search: string,
|
||||
): ComboboxOptionModel[] {
|
||||
if (!search) return options;
|
||||
return options
|
||||
.filter(
|
||||
(opt) =>
|
||||
opt.label.toLowerCase().includes(search) ||
|
||||
opt.id.toLowerCase().includes(search) ||
|
||||
opt.description?.toLowerCase().includes(search),
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const aPrefix =
|
||||
a.label.toLowerCase().startsWith(search) || a.id.toLowerCase().startsWith(search);
|
||||
const bPrefix =
|
||||
b.label.toLowerCase().startsWith(search) || b.id.toLowerCase().startsWith(search);
|
||||
if (aPrefix !== bPrefix) return aPrefix ? -1 : 1;
|
||||
return 0;
|
||||
});
|
||||
const scored: { opt: ComboboxOptionModel; score: MatchScore }[] = [];
|
||||
for (const opt of options) {
|
||||
const score = scoreOption(opt, search);
|
||||
if (score) scored.push({ opt, score });
|
||||
}
|
||||
scored.sort((a, b) => {
|
||||
const cmp = compareMatchScores(a.score, b.score);
|
||||
if (cmp !== 0) return cmp;
|
||||
return a.opt.label.localeCompare(b.opt.label);
|
||||
});
|
||||
return scored.map((entry) => entry.opt);
|
||||
}
|
||||
|
||||
export function buildVisibleComboboxOptions(
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import {
|
||||
BottomSheetModal,
|
||||
BottomSheetScrollView,
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetTextInput,
|
||||
@@ -38,6 +37,10 @@ import {
|
||||
} from "./combobox-options";
|
||||
import type { ComboboxOptionModel } from "./combobox-options";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import {
|
||||
IsolatedBottomSheetModal,
|
||||
useIsolatedBottomSheetVisibility,
|
||||
} from "./isolated-bottom-sheet-modal";
|
||||
|
||||
const IS_WEB = isWeb;
|
||||
|
||||
@@ -66,8 +69,6 @@ export interface ComboboxProps {
|
||||
title?: string;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
enableDismissOnClose?: boolean;
|
||||
stackBehavior?: "push" | "switch" | "replace";
|
||||
desktopPlacement?: "top-start" | "bottom-start";
|
||||
/**
|
||||
* Prevents an initial frame at 0,0 by hiding desktop content until floating
|
||||
@@ -101,7 +102,21 @@ function toNumericStyleValue(value: unknown): number | null {
|
||||
}
|
||||
|
||||
function ComboboxSheetBackground({ style }: BottomSheetBackgroundProps) {
|
||||
return <Animated.View pointerEvents="none" style={[style, styles.bottomSheetBackground]} />;
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
pointerEvents="none"
|
||||
style={[
|
||||
style,
|
||||
{
|
||||
backgroundColor: theme.colors.surface0,
|
||||
borderTopLeftRadius: theme.borderRadius["2xl"],
|
||||
borderTopRightRadius: theme.borderRadius["2xl"],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export interface SearchInputProps {
|
||||
@@ -257,8 +272,6 @@ export function Combobox({
|
||||
title = "Select",
|
||||
open,
|
||||
onOpenChange,
|
||||
enableDismissOnClose,
|
||||
stackBehavior,
|
||||
desktopPlacement = "top-start",
|
||||
desktopPreventInitialFlash = true,
|
||||
desktopMinWidth,
|
||||
@@ -268,12 +281,12 @@ export function Combobox({
|
||||
anchorRef,
|
||||
children,
|
||||
}: ComboboxProps): ReactElement {
|
||||
const { theme } = useUnistyles();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const titleColor = theme.colors.foreground;
|
||||
const effectiveOptionsPosition = isMobile ? "below-search" : optionsPosition;
|
||||
const isDesktopAboveSearch = !isMobile && isWeb && effectiveOptionsPosition === "above-search";
|
||||
const { height: windowHeight, width: windowWidth } = useWindowDimensions();
|
||||
const bottomSheetRef = useRef<BottomSheetModal>(null);
|
||||
const hasPresentedBottomSheetRef = useRef(false);
|
||||
const snapPoints = useMemo(() => ["60%", "90%"], []);
|
||||
const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>(
|
||||
null,
|
||||
@@ -459,32 +472,11 @@ export function Combobox({
|
||||
}
|
||||
: floatingStyles;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile) return;
|
||||
if (isOpen) {
|
||||
if (enableDismissOnClose === false && hasPresentedBottomSheetRef.current) {
|
||||
bottomSheetRef.current?.snapToIndex(0);
|
||||
} else {
|
||||
hasPresentedBottomSheetRef.current = true;
|
||||
bottomSheetRef.current?.present();
|
||||
}
|
||||
} else {
|
||||
if (enableDismissOnClose === false) {
|
||||
bottomSheetRef.current?.close();
|
||||
} else {
|
||||
bottomSheetRef.current?.dismiss();
|
||||
}
|
||||
}
|
||||
}, [enableDismissOnClose, isOpen, isMobile]);
|
||||
|
||||
const handleSheetChange = useCallback(
|
||||
(index: number) => {
|
||||
if (index === -1) {
|
||||
handleClose();
|
||||
}
|
||||
},
|
||||
[handleClose],
|
||||
);
|
||||
const { sheetRef: bottomSheetRef, handleSheetChange } = useIsolatedBottomSheetVisibility({
|
||||
visible: isOpen,
|
||||
isEnabled: isMobile,
|
||||
onClose: handleClose,
|
||||
});
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||
@@ -703,7 +695,7 @@ export function Combobox({
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<BottomSheetModal
|
||||
<IsolatedBottomSheetModal
|
||||
ref={bottomSheetRef}
|
||||
snapPoints={snapPoints}
|
||||
index={0}
|
||||
@@ -711,15 +703,15 @@ export function Combobox({
|
||||
onChange={handleSheetChange}
|
||||
backdropComponent={renderBackdrop}
|
||||
enablePanDownToClose
|
||||
enableDismissOnClose={enableDismissOnClose}
|
||||
stackBehavior={stackBehavior}
|
||||
backgroundComponent={ComboboxSheetBackground}
|
||||
handleIndicatorStyle={styles.bottomSheetHandle}
|
||||
handleIndicatorStyle={{ backgroundColor: theme.colors.palette.zinc[600] }}
|
||||
keyboardBehavior="extend"
|
||||
keyboardBlurBehavior="restore"
|
||||
>
|
||||
<View style={styles.bottomSheetHeader}>
|
||||
<Text style={styles.comboboxTitle}>{title}</Text>
|
||||
<Text key={titleColor} style={[styles.comboboxTitle, { color: titleColor }]}>
|
||||
{title}
|
||||
</Text>
|
||||
</View>
|
||||
{stickyHeader}
|
||||
{!children && searchable ? searchInput : null}
|
||||
@@ -730,7 +722,7 @@ export function Combobox({
|
||||
>
|
||||
{content}
|
||||
</BottomSheetScrollView>
|
||||
</BottomSheetModal>
|
||||
</IsolatedBottomSheetModal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -909,14 +901,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
bottomSheetBackground: {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
borderTopLeftRadius: theme.borderRadius["2xl"],
|
||||
borderTopRightRadius: theme.borderRadius["2xl"],
|
||||
},
|
||||
bottomSheetHandle: {
|
||||
backgroundColor: theme.colors.palette.zinc[600],
|
||||
},
|
||||
bottomSheetHeader: {
|
||||
paddingHorizontal: theme.spacing[6],
|
||||
paddingBottom: theme.spacing[2],
|
||||
@@ -924,7 +908,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
comboboxTitle: {
|
||||
fontSize: theme.fontSize.lg,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foreground,
|
||||
textAlign: "left",
|
||||
},
|
||||
comboboxScrollContent: {
|
||||
|
||||
@@ -30,8 +30,12 @@ import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { Check, CheckCircle } from "lucide-react-native";
|
||||
import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||
import { BottomSheetBackdrop, BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import {
|
||||
IsolatedBottomSheetModal,
|
||||
useIsolatedBottomSheetVisibility,
|
||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import { isWeb, isNative } from "@/constants/platform";
|
||||
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||
|
||||
@@ -349,11 +353,11 @@ export function ContextMenuContent({
|
||||
testID?: string;
|
||||
}>): ReactElement | null {
|
||||
const context = useContextMenuContext("ContextMenuContent");
|
||||
const { theme } = useUnistyles();
|
||||
const webScrollbarStyle = useWebScrollbarStyle();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const useMobileSheet = isMobile && mobileMode === "sheet";
|
||||
const { open, setOpen, triggerRef, anchorRect } = context;
|
||||
const bottomSheetRef = useRef<BottomSheetModal>(null);
|
||||
const sheetSnapPoints = useMemo(() => ["30%", "55%"], []);
|
||||
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
|
||||
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
|
||||
@@ -363,23 +367,11 @@ export function ContextMenuContent({
|
||||
setOpen(false);
|
||||
}, [setOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!useMobileSheet) return;
|
||||
if (open) {
|
||||
bottomSheetRef.current?.present();
|
||||
return;
|
||||
}
|
||||
bottomSheetRef.current?.dismiss();
|
||||
}, [open, useMobileSheet]);
|
||||
|
||||
const handleSheetChange = useCallback(
|
||||
(index: number) => {
|
||||
if (index === -1) {
|
||||
handleClose();
|
||||
}
|
||||
},
|
||||
[handleClose],
|
||||
);
|
||||
const { sheetRef: bottomSheetRef, handleSheetChange } = useIsolatedBottomSheetVisibility({
|
||||
visible: open,
|
||||
isEnabled: useMobileSheet,
|
||||
onClose: handleClose,
|
||||
});
|
||||
|
||||
const renderSheetBackdrop = useCallback(
|
||||
(props: ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||
@@ -467,7 +459,7 @@ export function ContextMenuContent({
|
||||
if (useMobileSheet) {
|
||||
return (
|
||||
<ContextMenuContext.Provider value={context}>
|
||||
<BottomSheetModal
|
||||
<IsolatedBottomSheetModal
|
||||
ref={bottomSheetRef}
|
||||
index={0}
|
||||
snapPoints={sheetSnapPoints}
|
||||
@@ -475,8 +467,14 @@ export function ContextMenuContent({
|
||||
onChange={handleSheetChange}
|
||||
backdropComponent={renderSheetBackdrop}
|
||||
enablePanDownToClose
|
||||
backgroundStyle={styles.sheetBackground}
|
||||
handleIndicatorStyle={styles.sheetHandle}
|
||||
backgroundStyle={[
|
||||
styles.sheetBackground,
|
||||
{
|
||||
backgroundColor: theme.colors.surface0,
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
]}
|
||||
handleIndicatorStyle={[styles.sheetHandle, { backgroundColor: theme.colors.surface2 }]}
|
||||
keyboardBehavior="extend"
|
||||
keyboardBlurBehavior="restore"
|
||||
>
|
||||
@@ -488,7 +486,7 @@ export function ContextMenuContent({
|
||||
>
|
||||
{children}
|
||||
</BottomSheetScrollView>
|
||||
</BottomSheetModal>
|
||||
</IsolatedBottomSheetModal>
|
||||
</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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
106
packages/app/src/components/ui/isolated-bottom-sheet-modal.tsx
Normal file
106
packages/app/src/components/ui/isolated-bottom-sheet-modal.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
BottomSheetModal as GorhomBottomSheetModal,
|
||||
BottomSheetModalProvider,
|
||||
type BottomSheetModalProps,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { Portal } from "@gorhom/portal";
|
||||
import React, { createContext, useContext } from "react";
|
||||
import { forwardRef, useCallback, useEffect, useRef } from "react";
|
||||
import type { ElementRef } from "react";
|
||||
|
||||
type GorhomBottomSheetModalMethods = ElementRef<typeof GorhomBottomSheetModal>;
|
||||
|
||||
type IsolatedBottomSheetModalProps = Omit<
|
||||
BottomSheetModalProps,
|
||||
"enableDismissOnClose" | "stackBehavior"
|
||||
>;
|
||||
|
||||
export type IsolatedBottomSheetModalRef = GorhomBottomSheetModalMethods;
|
||||
|
||||
const IsolatedBottomSheetScopeContext = createContext(false);
|
||||
|
||||
export const IsolatedBottomSheetModal = forwardRef<
|
||||
IsolatedBottomSheetModalRef,
|
||||
IsolatedBottomSheetModalProps
|
||||
>(function IsolatedBottomSheetModal(props, ref) {
|
||||
const isNestedSheet = useContext(IsolatedBottomSheetScopeContext);
|
||||
const { children, ...bottomSheetProps } = props;
|
||||
const scopedChildren =
|
||||
typeof children === "function" ? (
|
||||
(input: { data?: unknown }) => (
|
||||
<IsolatedBottomSheetScopeContext.Provider value={true}>
|
||||
{children(input) as React.ReactNode}
|
||||
</IsolatedBottomSheetScopeContext.Provider>
|
||||
)
|
||||
) : (
|
||||
<IsolatedBottomSheetScopeContext.Provider value={true}>
|
||||
{children}
|
||||
</IsolatedBottomSheetScopeContext.Provider>
|
||||
);
|
||||
const modal = (
|
||||
<GorhomBottomSheetModal
|
||||
{...bottomSheetProps}
|
||||
ref={ref}
|
||||
enableDismissOnClose={false}
|
||||
stackBehavior={isNestedSheet ? "push" : "replace"}
|
||||
>
|
||||
{scopedChildren}
|
||||
</GorhomBottomSheetModal>
|
||||
);
|
||||
|
||||
if (isNestedSheet) {
|
||||
return modal;
|
||||
}
|
||||
|
||||
return (
|
||||
<Portal hostName="root">
|
||||
<BottomSheetModalProvider>{modal}</BottomSheetModalProvider>
|
||||
</Portal>
|
||||
);
|
||||
});
|
||||
|
||||
export function useIsolatedBottomSheetVisibility({
|
||||
visible,
|
||||
isEnabled,
|
||||
onClose,
|
||||
}: {
|
||||
visible: boolean;
|
||||
isEnabled?: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const sheetRef = useRef<IsolatedBottomSheetModalRef>(null);
|
||||
const hasPresentedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEnabled === false) return;
|
||||
|
||||
if (visible) {
|
||||
if (hasPresentedRef.current) {
|
||||
sheetRef.current?.snapToIndex(0);
|
||||
return;
|
||||
}
|
||||
|
||||
hasPresentedRef.current = true;
|
||||
sheetRef.current?.present();
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasPresentedRef.current) {
|
||||
sheetRef.current?.close();
|
||||
}
|
||||
}, [isEnabled, visible]);
|
||||
|
||||
const handleSheetChange = useCallback(
|
||||
(index: number) => {
|
||||
if (index === -1 && visible) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose, visible],
|
||||
);
|
||||
|
||||
return {
|
||||
sheetRef,
|
||||
handleSheetChange,
|
||||
};
|
||||
}
|
||||
10
packages/app/src/components/ui/loading-spinner.tsx
Normal file
10
packages/app/src/components/ui/loading-spinner.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { ActivityIndicator, type ActivityIndicatorProps } from "react-native";
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
color: string;
|
||||
size?: ActivityIndicatorProps["size"];
|
||||
}
|
||||
|
||||
export function LoadingSpinner({ color, size = "small" }: LoadingSpinnerProps) {
|
||||
return <ActivityIndicator size={size} color={color} />;
|
||||
}
|
||||
@@ -35,12 +35,13 @@ export function SegmentedControl<T extends string>({
|
||||
testID,
|
||||
}: SegmentedControlProps<T>) {
|
||||
const { theme } = useUnistyles();
|
||||
const containerSizeStyle = size === "sm" ? styles.containerSm : styles.containerMd;
|
||||
const segmentSizeStyle = size === "sm" ? styles.segmentSm : styles.segmentMd;
|
||||
const labelSizeStyle = size === "sm" ? styles.labelSm : styles.labelMd;
|
||||
const iconSize = size === "sm" ? theme.iconSize.sm : theme.iconSize.md;
|
||||
|
||||
return (
|
||||
<View style={[styles.container, style]} testID={testID}>
|
||||
<View style={[styles.container, containerSizeStyle, style]} testID={testID}>
|
||||
{options.map((option) => {
|
||||
const isSelected = option.value === value;
|
||||
const iconColor = isSelected ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||
@@ -89,39 +90,46 @@ export function SegmentedControl<T extends string>({
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
alignItems: "stretch",
|
||||
maxWidth: "100%",
|
||||
gap: 4,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
containerSm: {
|
||||
padding: 2,
|
||||
},
|
||||
containerMd: {
|
||||
padding: 3,
|
||||
},
|
||||
segment: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
borderWidth: 1,
|
||||
borderColor: "transparent",
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
segmentSm: {
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
},
|
||||
segmentMd: {
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[6],
|
||||
},
|
||||
segmentSelected: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 2,
|
||||
elevation: 1,
|
||||
},
|
||||
segmentHover: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
segmentPressed: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
segmentDisabled: {
|
||||
opacity: theme.opacity[50],
|
||||
@@ -135,10 +143,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
labelSm: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
labelMd: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
labelSelected: {
|
||||
color: theme.colors.foreground,
|
||||
|
||||
116
packages/app/src/components/ui/tooltip.test.tsx
Normal file
116
packages/app/src/components/ui/tooltip.test.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { Pressable, Text } from "react-native";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Tooltip, TooltipTrigger } from "./tooltip";
|
||||
|
||||
vi.mock("@/constants/platform", () => ({
|
||||
isWeb: true,
|
||||
isNative: false,
|
||||
}));
|
||||
|
||||
vi.mock("@/constants/layout", () => ({
|
||||
useIsCompactFormFactor: () => false,
|
||||
}));
|
||||
|
||||
vi.mock("@gorhom/portal", () => ({
|
||||
Portal: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
vi.mock("@gorhom/bottom-sheet", () => ({
|
||||
useBottomSheetModalInternal: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("react-native-reanimated", () => ({
|
||||
default: {
|
||||
View: "div",
|
||||
},
|
||||
FadeIn: {},
|
||||
FadeOut: {},
|
||||
}));
|
||||
|
||||
vi.mock("react-native-unistyles", () => ({
|
||||
StyleSheet: {
|
||||
create: (styles: unknown) => styles,
|
||||
},
|
||||
}));
|
||||
|
||||
let root: Root | null = null;
|
||||
let container: HTMLElement | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>");
|
||||
vi.stubGlobal("React", React);
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
vi.stubGlobal("window", dom.window);
|
||||
vi.stubGlobal("document", dom.window.document);
|
||||
vi.stubGlobal("HTMLElement", dom.window.HTMLElement);
|
||||
vi.stubGlobal("Node", dom.window.Node);
|
||||
vi.stubGlobal("navigator", dom.window.navigator);
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
root = null;
|
||||
container = null;
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function renderTrigger({
|
||||
childDisabled,
|
||||
onPress,
|
||||
}: {
|
||||
childDisabled: boolean;
|
||||
onPress: () => void;
|
||||
}): void {
|
||||
act(() => {
|
||||
root?.render(
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable disabled={childDisabled} onPress={onPress} testID="trigger">
|
||||
<Text>Send</Text>
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
</Tooltip>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function pressTrigger(): void {
|
||||
const trigger = container?.querySelector('[data-testid="trigger"]');
|
||||
expect(trigger).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
trigger?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
describe("TooltipTrigger", () => {
|
||||
it("keeps an asChild trigger disabled when the child is disabled", () => {
|
||||
const onPress = vi.fn();
|
||||
|
||||
renderTrigger({ childDisabled: true, onPress });
|
||||
pressTrigger();
|
||||
|
||||
expect(onPress).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps an asChild trigger interactive when the child is not disabled", () => {
|
||||
const onPress = vi.fn();
|
||||
|
||||
renderTrigger({ childDisabled: false, onPress });
|
||||
pressTrigger();
|
||||
|
||||
expect(onPress).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -60,6 +60,28 @@ function useTooltipContext(componentName: string): TooltipContextValue {
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// Tooltips should open on hover or keyboard focus, not when focus is restored
|
||||
// programmatically (e.g. when a Modal closes and returns focus to its opener).
|
||||
// Track the last input modality on web so TooltipTrigger can ignore focus
|
||||
// events that weren't keyboard-driven. Native has no equivalent scenario.
|
||||
let lastInputWasKeyboard = false;
|
||||
if (isWeb && typeof window !== "undefined") {
|
||||
const markKeyboard = () => {
|
||||
lastInputWasKeyboard = true;
|
||||
};
|
||||
const markPointer = () => {
|
||||
lastInputWasKeyboard = false;
|
||||
};
|
||||
window.addEventListener("keydown", markKeyboard, true);
|
||||
window.addEventListener("mousedown", markPointer, true);
|
||||
window.addEventListener("pointerdown", markPointer, true);
|
||||
window.addEventListener("touchstart", markPointer, true);
|
||||
}
|
||||
|
||||
function shouldOpenOnFocus(): boolean {
|
||||
return !isWeb || lastInputWasKeyboard;
|
||||
}
|
||||
|
||||
function composeEventHandlers<E>(
|
||||
original?: (event: E) => void,
|
||||
injected?: (event: E) => void,
|
||||
@@ -304,6 +326,7 @@ export function TooltipTrigger({
|
||||
(e: any) => {
|
||||
onFocus?.(e);
|
||||
if (!ctx.enabled || disabled) return;
|
||||
if (!shouldOpenOnFocus()) return;
|
||||
clearOpenTimer();
|
||||
ctx.setOpen(true);
|
||||
},
|
||||
@@ -363,6 +386,7 @@ export function TooltipTrigger({
|
||||
const mergedProps = {
|
||||
...childProps,
|
||||
...triggerProps,
|
||||
disabled: childProps.disabled || disabled,
|
||||
onHoverIn: composeEventHandlers(childProps.onHoverIn, handleHoverIn),
|
||||
onHoverOut: composeEventHandlers(childProps.onHoverOut, handleHoverOut),
|
||||
onFocus: composeEventHandlers(childProps.onFocus, handleFocus),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
||||
import { Pressable, Text, View, ScrollView } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { QrCode, Link2, ClipboardPaste, ExternalLink, Settings } from "lucide-react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
@@ -21,8 +21,6 @@ import { PaseoLogo } from "@/components/icons/paseo-logo";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { isWeb, isNative } from "@/constants/platform";
|
||||
|
||||
const ThemedScrollView = withUnistyles(ScrollView);
|
||||
|
||||
type WelcomeAction = {
|
||||
key: "scan-qr" | "direct-connection" | "paste-pairing-link";
|
||||
label: string;
|
||||
@@ -33,13 +31,15 @@ type WelcomeAction = {
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
scrollView: {
|
||||
root: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
container: {
|
||||
flexGrow: 1,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
padding: theme.spacing[6],
|
||||
paddingBottom: 0,
|
||||
alignItems: "center",
|
||||
@@ -309,97 +309,101 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
const showHostList = hosts.length > 0 && !anyOnlineServerId;
|
||||
|
||||
return (
|
||||
<ThemedScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={[
|
||||
styles.container,
|
||||
{ paddingBottom: theme.spacing[6] + insets.bottom },
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
testID="welcome-screen"
|
||||
>
|
||||
<View style={styles.content}>
|
||||
<PaseoLogo size={96} />
|
||||
<View style={styles.copyBlock}>
|
||||
<Text style={styles.title}>Welcome to Paseo</Text>
|
||||
{showHostList ? (
|
||||
<Text style={styles.subtitle}>Connecting to your hosts…</Text>
|
||||
) : (
|
||||
<>
|
||||
<Text style={styles.subtitle}>Connect your computer to get started</Text>
|
||||
{isNative ? (
|
||||
<Pressable
|
||||
style={styles.setupLink}
|
||||
onPress={() => openExternalUrl("https://paseo.sh")}
|
||||
>
|
||||
<Text style={styles.setupLinkText}>paseo.sh</Text>
|
||||
<ExternalLink size={14} color={theme.colors.accent} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.actions}>
|
||||
{actions.map((action) => {
|
||||
const Icon = action.icon;
|
||||
return (
|
||||
<Pressable
|
||||
key={action.key}
|
||||
style={[styles.actionButton, action.primary ? styles.actionButtonPrimary : null]}
|
||||
onPress={action.onPress}
|
||||
testID={action.testID}
|
||||
>
|
||||
<Icon
|
||||
size={18}
|
||||
color={action.primary ? theme.colors.accentForeground : theme.colors.foreground}
|
||||
/>
|
||||
<Text style={[styles.actionText, action.primary ? styles.actionTextPrimary : null]}>
|
||||
{action.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
{showHostList && (
|
||||
<View style={styles.hostList}>
|
||||
{hosts.map((host) => (
|
||||
<HostStatusRow key={host.serverId} serverId={host.serverId} label={host.label} />
|
||||
))}
|
||||
<View style={styles.root}>
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={[
|
||||
styles.container,
|
||||
{ paddingBottom: theme.spacing[6] + insets.bottom },
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
testID="welcome-screen"
|
||||
>
|
||||
<View style={styles.content}>
|
||||
<PaseoLogo size={96} />
|
||||
<View style={styles.copyBlock}>
|
||||
<Text style={styles.title}>Welcome to Paseo</Text>
|
||||
{showHostList ? (
|
||||
<Text style={styles.subtitle}>Connecting to your hosts…</Text>
|
||||
) : (
|
||||
<>
|
||||
<Text style={styles.subtitle}>Connect your computer to get started</Text>
|
||||
{isNative ? (
|
||||
<Pressable
|
||||
style={styles.setupLink}
|
||||
onPress={() => openExternalUrl("https://paseo.sh")}
|
||||
>
|
||||
<Text style={styles.setupLinkText}>paseo.sh</Text>
|
||||
<ExternalLink size={14} color={theme.colors.accent} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={Settings}
|
||||
onPress={() => router.push("/settings")}
|
||||
style={styles.settingsButton}
|
||||
testID="welcome-open-settings"
|
||||
>
|
||||
Settings
|
||||
</Button>
|
||||
</View>
|
||||
<Text style={styles.versionLabel}>{appVersionText}</Text>
|
||||
<View style={styles.actions}>
|
||||
{actions.map((action) => {
|
||||
const Icon = action.icon;
|
||||
return (
|
||||
<Pressable
|
||||
key={action.key}
|
||||
style={[styles.actionButton, action.primary ? styles.actionButtonPrimary : null]}
|
||||
onPress={action.onPress}
|
||||
testID={action.testID}
|
||||
>
|
||||
<Icon
|
||||
size={18}
|
||||
color={action.primary ? theme.colors.accentForeground : theme.colors.foreground}
|
||||
/>
|
||||
<Text
|
||||
style={[styles.actionText, action.primary ? styles.actionTextPrimary : null]}
|
||||
>
|
||||
{action.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
<AddHostModal
|
||||
visible={isDirectOpen}
|
||||
onClose={() => setIsDirectOpen(false)}
|
||||
onSaved={({ profile, serverId }) => {
|
||||
onHostAdded?.(profile);
|
||||
finishOnboarding(serverId);
|
||||
}}
|
||||
/>
|
||||
{showHostList && (
|
||||
<View style={styles.hostList}>
|
||||
{hosts.map((host) => (
|
||||
<HostStatusRow key={host.serverId} serverId={host.serverId} label={host.label} />
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<PairLinkModal
|
||||
visible={isPasteLinkOpen}
|
||||
onClose={() => setIsPasteLinkOpen(false)}
|
||||
onSaved={({ profile, serverId }) => {
|
||||
onHostAdded?.(profile);
|
||||
finishOnboarding(serverId);
|
||||
}}
|
||||
/>
|
||||
</ThemedScrollView>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={Settings}
|
||||
onPress={() => router.push("/settings")}
|
||||
style={styles.settingsButton}
|
||||
testID="welcome-open-settings"
|
||||
>
|
||||
Settings
|
||||
</Button>
|
||||
</View>
|
||||
<Text style={styles.versionLabel}>{appVersionText}</Text>
|
||||
|
||||
<AddHostModal
|
||||
visible={isDirectOpen}
|
||||
onClose={() => setIsDirectOpen(false)}
|
||||
onSaved={({ profile, serverId }) => {
|
||||
onHostAdded?.(profile);
|
||||
finishOnboarding(serverId);
|
||||
}}
|
||||
/>
|
||||
|
||||
<PairLinkModal
|
||||
visible={isPasteLinkOpen}
|
||||
onClose={() => setIsPasteLinkOpen(false)}
|
||||
onSaved={({ profile, serverId }) => {
|
||||
onHostAdded?.(profile);
|
||||
finishOnboarding(serverId);
|
||||
}}
|
||||
/>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"
|
||||
import type { PrHint } from "@/hooks/use-checkout-pr-status-query";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { PrBadge } from "@/components/sidebar-workspace-list";
|
||||
import { useHoverSafeZone } from "@/hooks/use-hover-safe-zone";
|
||||
|
||||
interface Rect {
|
||||
x: number;
|
||||
@@ -99,10 +100,10 @@ function WorkspaceHoverCardDesktop({
|
||||
children,
|
||||
}: PropsWithChildren<WorkspaceHoverCardProps>): ReactElement {
|
||||
const triggerRef = useRef<View>(null);
|
||||
const contentRef = useRef<View>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const graceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const triggerHoveredRef = useRef(false);
|
||||
const contentHoveredRef = useRef(false);
|
||||
|
||||
const hasContent = prHint !== null || !!workspace.diffStat;
|
||||
|
||||
@@ -114,14 +115,12 @@ function WorkspaceHoverCardDesktop({
|
||||
}, []);
|
||||
|
||||
const scheduleClose = useCallback(() => {
|
||||
clearGraceTimer();
|
||||
if (graceTimerRef.current) return;
|
||||
graceTimerRef.current = setTimeout(() => {
|
||||
if (!triggerHoveredRef.current && !contentHoveredRef.current) {
|
||||
setOpen(false);
|
||||
}
|
||||
graceTimerRef.current = null;
|
||||
setOpen(false);
|
||||
}, HOVER_GRACE_MS);
|
||||
}, [clearGraceTimer]);
|
||||
}, []);
|
||||
|
||||
const handleTriggerEnter = useCallback(() => {
|
||||
triggerHoveredRef.current = true;
|
||||
@@ -133,18 +132,18 @@ function WorkspaceHoverCardDesktop({
|
||||
|
||||
const handleTriggerLeave = useCallback(() => {
|
||||
triggerHoveredRef.current = false;
|
||||
scheduleClose();
|
||||
}, [scheduleClose]);
|
||||
}, []);
|
||||
|
||||
const handleContentEnter = useCallback(() => {
|
||||
contentHoveredRef.current = true;
|
||||
clearGraceTimer();
|
||||
}, [clearGraceTimer]);
|
||||
|
||||
const handleContentLeave = useCallback(() => {
|
||||
contentHoveredRef.current = false;
|
||||
scheduleClose();
|
||||
}, [scheduleClose]);
|
||||
// While open, the safe zone covers trigger + content + the bridge between
|
||||
// them. Close only fires when the pointer leaves the safe zone; re-entering
|
||||
// it (including the bridge) cancels the pending close.
|
||||
useHoverSafeZone({
|
||||
enabled: open,
|
||||
triggerRef,
|
||||
contentRef,
|
||||
onEnterSafeZone: clearGraceTimer,
|
||||
onLeaveSafeZone: scheduleClose,
|
||||
});
|
||||
|
||||
// Close when drag starts
|
||||
useEffect(() => {
|
||||
@@ -182,8 +181,7 @@ function WorkspaceHoverCardDesktop({
|
||||
workspace={workspace}
|
||||
prHint={prHint}
|
||||
triggerRef={triggerRef}
|
||||
onContentEnter={handleContentEnter}
|
||||
onContentLeave={handleContentLeave}
|
||||
contentRef={contentRef}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
@@ -194,14 +192,12 @@ function WorkspaceHoverCardContent({
|
||||
workspace,
|
||||
prHint,
|
||||
triggerRef,
|
||||
onContentEnter,
|
||||
onContentLeave,
|
||||
contentRef,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
prHint: PrHint | null;
|
||||
triggerRef: React.RefObject<View | null>;
|
||||
onContentEnter: () => void;
|
||||
onContentLeave: () => void;
|
||||
contentRef: React.RefObject<View | null>;
|
||||
}): ReactElement | null {
|
||||
const { theme } = useUnistyles();
|
||||
const bottomSheetInternal = useBottomSheetModalInternal(true);
|
||||
@@ -250,12 +246,11 @@ function WorkspaceHoverCardContent({
|
||||
<Portal hostName={bottomSheetInternal?.hostName}>
|
||||
<View pointerEvents="box-none" style={styles.portalOverlay}>
|
||||
<Animated.View
|
||||
ref={contentRef}
|
||||
entering={FadeIn.duration(80)}
|
||||
exiting={FadeOut.duration(80)}
|
||||
collapsable={false}
|
||||
onLayout={handleLayout}
|
||||
onPointerEnter={onContentEnter}
|
||||
onPointerLeave={onContentLeave}
|
||||
accessibilityRole="menu"
|
||||
accessibilityLabel="Workspace scripts"
|
||||
testID="workspace-hover-card"
|
||||
@@ -311,14 +306,24 @@ function WorkspaceHoverCardContent({
|
||||
badgeLabel = `${checks.length} passed`;
|
||||
}
|
||||
|
||||
const iconColor = hovered
|
||||
? theme.colors.foreground
|
||||
: theme.colors.foregroundMuted;
|
||||
return (
|
||||
<>
|
||||
{hovered ? (
|
||||
<ExternalLink size={12} color={theme.colors.foregroundMuted} />
|
||||
<ExternalLink size={12} color={iconColor} />
|
||||
) : (
|
||||
<GitHubIcon size={12} color={theme.colors.foregroundMuted} />
|
||||
<GitHubIcon size={12} color={iconColor} />
|
||||
)}
|
||||
<Text style={styles.checksSummaryLabel}>Checks</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.checksSummaryLabel,
|
||||
hovered && styles.checksSummaryLabelHovered,
|
||||
]}
|
||||
>
|
||||
Checks
|
||||
</Text>
|
||||
<View style={styles.checksSummaryCounts}>
|
||||
<View style={[styles.checksDot, { backgroundColor: badgeColor }]} />
|
||||
<Text style={[styles.checksStatusText, { color: badgeColor }]}>
|
||||
@@ -400,6 +405,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
checksSummaryLabelHovered: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
checksSummaryCounts: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -179,6 +179,9 @@ export function WorkspaceSetupDialog() {
|
||||
if (!composerState) {
|
||||
throw new Error("Workspace setup composer state is required");
|
||||
}
|
||||
if (!composerState.selectedProvider) {
|
||||
throw new Error("Select a model");
|
||||
}
|
||||
|
||||
const wirePayload = splitComposerAttachmentsForSubmit(attachments);
|
||||
const encodedImages = await encodeImages(wirePayload.images);
|
||||
|
||||
@@ -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:
|
||||
// isWeb → DOM APIs (document, window, <div>, addEventListener)
|
||||
// isNative → Native-only APIs (Haptics, StatusBar, push tokens, camera)
|
||||
// isDev → Development-only diagnostics and instrumentation
|
||||
// isElectron → Desktop wrapper features (file dialogs, titlebar, updates)
|
||||
//
|
||||
// For layout decisions, use useIsCompactFormFactor() from constants/layout.ts.
|
||||
@@ -22,6 +23,9 @@ export const isWeb = Platform.OS === "web";
|
||||
/** iOS or Android — the JS runtime is React Native. */
|
||||
export const isNative = Platform.OS !== "web";
|
||||
|
||||
/** Development build/runtime — true in Metro dev bundles, false in production. */
|
||||
export const isDev = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Electron detection (cached — only caches `true`, keeps checking if false
|
||||
// because the desktop bridge may load after initial module evaluation)
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { createContext, useContext, useEffect, useRef, type ReactNode } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useWindowDimensions } from "react-native";
|
||||
import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated";
|
||||
import { type GestureType } from "react-native-gesture-handler";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
|
||||
import {
|
||||
getRightSidebarAnimationTargets,
|
||||
shouldSyncSidebarAnimation,
|
||||
@@ -30,11 +38,9 @@ const ExplorerSidebarAnimationContext = createContext<ExplorerSidebarAnimationCo
|
||||
export function ExplorerSidebarAnimationProvider({ children }: { children: ReactNode }) {
|
||||
const { width: windowWidth } = useWindowDimensions();
|
||||
const isCompactLayout = useIsCompactFormFactor();
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
||||
|
||||
// Derive isOpen from the unified panel state
|
||||
const isOpen = isCompactLayout ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
||||
const isOpen = usePanelStore((state) =>
|
||||
selectIsFileExplorerOpen(state, { isCompact: isCompactLayout }),
|
||||
);
|
||||
|
||||
// Right sidebar: closed = +windowWidth (off-screen right), open = 0
|
||||
const initialTargets = getRightSidebarAnimationTargets({ isOpen, windowWidth });
|
||||
@@ -93,7 +99,7 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
|
||||
backdropOpacity.value = targets.backdropOpacity;
|
||||
}, [isOpen, translateX, backdropOpacity, windowWidth, isGesturing]);
|
||||
|
||||
const animateToOpen = () => {
|
||||
const animateToOpen = useCallback(() => {
|
||||
"worklet";
|
||||
translateX.value = withTiming(0, {
|
||||
duration: ANIMATION_DURATION,
|
||||
@@ -103,9 +109,9 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
|
||||
duration: ANIMATION_DURATION,
|
||||
easing: ANIMATION_EASING,
|
||||
});
|
||||
};
|
||||
}, [translateX, backdropOpacity]);
|
||||
|
||||
const animateToClose = () => {
|
||||
const animateToClose = useCallback(() => {
|
||||
"worklet";
|
||||
translateX.value = withTiming(windowWidth, {
|
||||
duration: ANIMATION_DURATION,
|
||||
@@ -115,22 +121,25 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
|
||||
duration: ANIMATION_DURATION,
|
||||
easing: ANIMATION_EASING,
|
||||
});
|
||||
};
|
||||
}, [translateX, backdropOpacity, windowWidth]);
|
||||
|
||||
const value = useMemo<ExplorerSidebarAnimationContextValue>(
|
||||
() => ({
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
windowWidth,
|
||||
animateToOpen,
|
||||
animateToClose,
|
||||
isGesturing,
|
||||
gestureAnimatingRef,
|
||||
openGestureRef,
|
||||
closeGestureRef,
|
||||
}),
|
||||
[translateX, backdropOpacity, windowWidth, animateToOpen, animateToClose, isGesturing],
|
||||
);
|
||||
|
||||
return (
|
||||
<ExplorerSidebarAnimationContext.Provider
|
||||
value={{
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
windowWidth,
|
||||
animateToOpen,
|
||||
animateToClose,
|
||||
isGesturing,
|
||||
gestureAnimatingRef,
|
||||
openGestureRef,
|
||||
closeGestureRef,
|
||||
}}
|
||||
>
|
||||
<ExplorerSidebarAnimationContext.Provider value={value}>
|
||||
{children}
|
||||
</ExplorerSidebarAnimationContext.Provider>
|
||||
);
|
||||
|
||||
@@ -26,9 +26,9 @@ function workspace(input: {
|
||||
const runningScript: WorkspaceScriptPayload = {
|
||||
scriptName: "web",
|
||||
type: "service",
|
||||
hostname: "main.web.localhost",
|
||||
hostname: "web.paseo.localhost",
|
||||
port: 3000,
|
||||
proxyUrl: "http://main.web.localhost:6767",
|
||||
proxyUrl: "http://web.paseo.localhost:6767",
|
||||
lifecycle: "running",
|
||||
health: "healthy",
|
||||
exitCode: null,
|
||||
|
||||
@@ -34,7 +34,6 @@ import {
|
||||
type Agent,
|
||||
type SessionState,
|
||||
type WorkspaceDescriptor,
|
||||
mergeWorkspaceSnapshotWithExisting,
|
||||
normalizeWorkspaceDescriptor,
|
||||
} from "@/stores/session-store";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
@@ -57,6 +56,8 @@ import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachm
|
||||
import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking";
|
||||
import { patchWorkspaceScripts } from "@/contexts/session-workspace-scripts";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
|
||||
// Re-export types from session-store and draft-store for backward compatibility
|
||||
export type { DraftInput } from "@/stores/draft-store";
|
||||
@@ -258,6 +259,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
const voiceAudioEngine = useVoiceAudioEngineOptional();
|
||||
const queryClient = useQueryClient();
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const toast = useToast();
|
||||
|
||||
// Zustand store actions
|
||||
const initializeSession = useSessionStore((state) => state.initializeSession);
|
||||
@@ -345,7 +347,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
}
|
||||
|
||||
const workspaces = new Map<string, WorkspaceDescriptor>();
|
||||
const existingWorkspaces = useSessionStore.getState().sessions[serverId]?.workspaces;
|
||||
let cursor: string | null = null;
|
||||
let includeSubscribe = options?.subscribe ?? false;
|
||||
|
||||
@@ -361,13 +362,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
|
||||
for (const entry of payload.entries) {
|
||||
const workspace = normalizeWorkspaceDescriptor(entry);
|
||||
workspaces.set(
|
||||
workspace.id,
|
||||
mergeWorkspaceSnapshotWithExisting({
|
||||
incoming: workspace,
|
||||
existing: existingWorkspaces?.get(workspace.id),
|
||||
}),
|
||||
);
|
||||
workspaces.set(workspace.id, workspace);
|
||||
}
|
||||
|
||||
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
|
||||
@@ -1769,9 +1764,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
}
|
||||
void client.setAgentMode(agentId, modeId).catch((error) => {
|
||||
console.error("[Session] Failed to set agent mode:", error);
|
||||
toast.error(toErrorMessage(error));
|
||||
});
|
||||
},
|
||||
[client],
|
||||
[client, toast],
|
||||
);
|
||||
|
||||
const setAgentModel = useCallback(
|
||||
@@ -1782,9 +1778,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
}
|
||||
void client.setAgentModel(agentId, modelId).catch((error) => {
|
||||
console.error("[Session] Failed to set agent model:", error);
|
||||
toast.error(toErrorMessage(error));
|
||||
});
|
||||
},
|
||||
[client],
|
||||
[client, toast],
|
||||
);
|
||||
|
||||
const setAgentThinkingOption = useCallback(
|
||||
@@ -1795,9 +1792,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
}
|
||||
void client.setAgentThinkingOption(agentId, thinkingOptionId).catch((error) => {
|
||||
console.error("[Session] Failed to set agent thinking option:", error);
|
||||
toast.error(toErrorMessage(error));
|
||||
});
|
||||
},
|
||||
[client],
|
||||
[client, toast],
|
||||
);
|
||||
|
||||
const respondToPermission = useCallback(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import equal from "fast-deep-equal";
|
||||
import type { ScriptStatusUpdateMessage } from "@server/shared/messages";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-execution";
|
||||
@@ -19,6 +20,10 @@ export function patchWorkspaceScripts(
|
||||
return workspaces;
|
||||
}
|
||||
|
||||
if (equal(existing.scripts, update.scripts)) {
|
||||
return workspaces;
|
||||
}
|
||||
|
||||
const next = new Map(workspaces);
|
||||
next.set(workspaceKey, {
|
||||
...existing,
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useWindowDimensions } from "react-native";
|
||||
import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated";
|
||||
import { type GestureType } from "react-native-gesture-handler";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
|
||||
import {
|
||||
getLeftSidebarAnimationTargets,
|
||||
shouldSyncSidebarAnimation,
|
||||
@@ -37,11 +37,9 @@ const SidebarAnimationContext = createContext<SidebarAnimationContextValue | nul
|
||||
export function SidebarAnimationProvider({ children }: { children: ReactNode }) {
|
||||
const { width: windowWidth } = useWindowDimensions();
|
||||
const isCompactLayout = useIsCompactFormFactor();
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||
|
||||
// Derive isOpen from the unified panel state
|
||||
const isOpen = isCompactLayout ? mobileView === "agent-list" : desktopAgentListOpen;
|
||||
const isOpen = usePanelStore((state) =>
|
||||
selectIsAgentListOpen(state, { isCompact: isCompactLayout }),
|
||||
);
|
||||
|
||||
// Initialize based on current state
|
||||
const initialTargets = getLeftSidebarAnimationTargets({ isOpen, windowWidth });
|
||||
|
||||
80
packages/app/src/desktop/electron/idle.test.ts
Normal file
80
packages/app/src/desktop/electron/idle.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { invokeDesktopCommandMock } = vi.hoisted(() => ({
|
||||
invokeDesktopCommandMock: vi.fn<() => Promise<unknown>>(async () => 12_000),
|
||||
}));
|
||||
|
||||
vi.mock("@/desktop/electron/invoke", () => ({
|
||||
invokeDesktopCommand: invokeDesktopCommandMock,
|
||||
}));
|
||||
|
||||
import { getDesktopSystemIdleTimeMs } from "./idle";
|
||||
|
||||
describe("getDesktopSystemIdleTimeMs", () => {
|
||||
afterEach(() => {
|
||||
invokeDesktopCommandMock.mockReset();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("invokes the desktop idle command and returns the millisecond value", async () => {
|
||||
invokeDesktopCommandMock.mockResolvedValueOnce(4_200);
|
||||
|
||||
const idleTimeMs = await getDesktopSystemIdleTimeMs();
|
||||
|
||||
expect(invokeDesktopCommandMock).toHaveBeenCalledWith("desktop_get_system_idle_time");
|
||||
expect(idleTimeMs).toBe(4_200);
|
||||
});
|
||||
|
||||
it("returns null and logs once when IPC rejects", async () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const error = new Error("ipc failed");
|
||||
invokeDesktopCommandMock.mockRejectedValueOnce(error);
|
||||
|
||||
const idleTimeMs = await getDesktopSystemIdleTimeMs();
|
||||
|
||||
expect(idleTimeMs).toBeNull();
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(warn).toHaveBeenCalledWith("[DesktopIdle] Failed to read system idle time", error);
|
||||
});
|
||||
|
||||
it("returns null and logs once when IPC returns null", async () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
invokeDesktopCommandMock.mockResolvedValueOnce(null);
|
||||
|
||||
const idleTimeMs = await getDesktopSystemIdleTimeMs();
|
||||
|
||||
expect(idleTimeMs).toBeNull();
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(warn).toHaveBeenCalledWith("[DesktopIdle] Invalid system idle time", null);
|
||||
});
|
||||
|
||||
it("returns null and logs once when IPC returns NaN", async () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
invokeDesktopCommandMock.mockResolvedValueOnce(Number.NaN);
|
||||
|
||||
const idleTimeMs = await getDesktopSystemIdleTimeMs();
|
||||
|
||||
expect(idleTimeMs).toBeNull();
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(warn).toHaveBeenCalledWith("[DesktopIdle] Invalid system idle time", Number.NaN);
|
||||
});
|
||||
|
||||
it("returns null and logs once when IPC returns a negative value", async () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
invokeDesktopCommandMock.mockResolvedValueOnce(-1);
|
||||
|
||||
const idleTimeMs = await getDesktopSystemIdleTimeMs();
|
||||
|
||||
expect(idleTimeMs).toBeNull();
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(warn).toHaveBeenCalledWith("[DesktopIdle] Invalid system idle time", -1);
|
||||
});
|
||||
|
||||
it("returns 0 when IPC returns zero", async () => {
|
||||
invokeDesktopCommandMock.mockResolvedValueOnce(0);
|
||||
|
||||
const idleTimeMs = await getDesktopSystemIdleTimeMs();
|
||||
|
||||
expect(idleTimeMs).toBe(0);
|
||||
});
|
||||
});
|
||||
21
packages/app/src/desktop/electron/idle.ts
Normal file
21
packages/app/src/desktop/electron/idle.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { invokeDesktopCommand } from "@/desktop/electron/invoke";
|
||||
|
||||
const DESKTOP_SYSTEM_IDLE_COMMAND = "desktop_get_system_idle_time";
|
||||
|
||||
function isValidIdleTimeMs(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
||||
}
|
||||
|
||||
export async function getDesktopSystemIdleTimeMs(): Promise<number | null> {
|
||||
try {
|
||||
const idleTimeMs = await invokeDesktopCommand<unknown>(DESKTOP_SYSTEM_IDLE_COMMAND);
|
||||
if (!isValidIdleTimeMs(idleTimeMs)) {
|
||||
console.warn("[DesktopIdle] Invalid system idle time", idleTimeMs);
|
||||
return null;
|
||||
}
|
||||
return idleTimeMs;
|
||||
} catch (error) {
|
||||
console.warn("[DesktopIdle] Failed to read system idle time", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,8 @@ export interface DesktopAppUpdateInstallResult {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type DesktopReleaseChannel = "stable" | "beta";
|
||||
|
||||
export interface LocalDaemonUpdateResult {
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
@@ -69,8 +71,12 @@ export async function getLocalDaemonVersion(): Promise<LocalDaemonVersionResult>
|
||||
return parseLocalDaemonVersionResult(result);
|
||||
}
|
||||
|
||||
export async function checkDesktopAppUpdate(): Promise<DesktopAppUpdateCheckResult> {
|
||||
const result = await invokeDesktopCommand<unknown>("check_app_update");
|
||||
export async function checkDesktopAppUpdate({
|
||||
releaseChannel,
|
||||
}: {
|
||||
releaseChannel: DesktopReleaseChannel;
|
||||
}): Promise<DesktopAppUpdateCheckResult> {
|
||||
const result = await invokeDesktopCommand<unknown>("check_app_update", { releaseChannel });
|
||||
if (!isRecord(result)) {
|
||||
throw new Error("Unexpected response while checking desktop updates.");
|
||||
}
|
||||
@@ -85,8 +91,12 @@ export async function checkDesktopAppUpdate(): Promise<DesktopAppUpdateCheckResu
|
||||
};
|
||||
}
|
||||
|
||||
export async function installDesktopAppUpdate(): Promise<DesktopAppUpdateInstallResult> {
|
||||
const result = await invokeDesktopCommand<unknown>("install_app_update");
|
||||
export async function installDesktopAppUpdate({
|
||||
releaseChannel,
|
||||
}: {
|
||||
releaseChannel: DesktopReleaseChannel;
|
||||
}): Promise<DesktopAppUpdateInstallResult> {
|
||||
const result = await invokeDesktopCommand<unknown>("install_app_update", { releaseChannel });
|
||||
if (!isRecord(result)) {
|
||||
throw new Error("Unexpected response while installing desktop update.");
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type DesktopAppUpdateCheckResult,
|
||||
type DesktopAppUpdateInstallResult,
|
||||
} from "@/desktop/updates/desktop-updates";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
|
||||
export type DesktopAppUpdateStatus =
|
||||
| "idle"
|
||||
@@ -83,6 +84,8 @@ function formatStatusText(input: {
|
||||
|
||||
export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
|
||||
const isDesktopApp = shouldShowDesktopUpdateSection();
|
||||
const { settings } = useAppSettings();
|
||||
const releaseChannel = settings.releaseChannel;
|
||||
const requestVersionRef = useRef(0);
|
||||
const [status, setStatus] = useState<DesktopAppUpdateStatus>("idle");
|
||||
const [availableUpdate, setAvailableUpdate] = useState<DesktopAppUpdateCheckResult | null>(null);
|
||||
@@ -105,7 +108,7 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const result = await checkDesktopAppUpdate();
|
||||
const result = await checkDesktopAppUpdate({ releaseChannel });
|
||||
if (requestVersion !== requestVersionRef.current) {
|
||||
return result;
|
||||
}
|
||||
@@ -140,9 +143,17 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[isDesktopApp],
|
||||
[isDesktopApp, releaseChannel],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDesktopApp) {
|
||||
return;
|
||||
}
|
||||
|
||||
void checkForUpdates({ silent: true });
|
||||
}, [checkForUpdates, isDesktopApp]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDesktopApp || status !== "pending") {
|
||||
return undefined;
|
||||
@@ -166,7 +177,7 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const result = await installDesktopAppUpdate();
|
||||
const result = await installDesktopAppUpdate({ releaseChannel });
|
||||
setLastCheckedAt(Date.now());
|
||||
|
||||
if (result.installed) {
|
||||
@@ -186,7 +197,7 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
|
||||
setErrorMessage(message);
|
||||
return null;
|
||||
}
|
||||
}, [isDesktopApp]);
|
||||
}, [isDesktopApp, releaseChannel]);
|
||||
|
||||
return {
|
||||
isDesktopApp,
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
export type CheckoutStatusRevalidationParams = {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
isOpen: boolean;
|
||||
explorerTab: string;
|
||||
};
|
||||
|
||||
export function checkoutStatusRevalidationKey(
|
||||
params: CheckoutStatusRevalidationParams,
|
||||
): string | null {
|
||||
if (!params.cwd) return null;
|
||||
if (!params.isOpen) return null;
|
||||
if (params.explorerTab !== "changes") return null;
|
||||
return `${params.serverId}:${params.cwd}`;
|
||||
}
|
||||
|
||||
export function nextCheckoutStatusRefetchDecision(
|
||||
prevKey: string | null,
|
||||
nextKey: string | null,
|
||||
): { nextSeenKey: string | null; shouldRefetch: boolean } {
|
||||
if (!nextKey) return { nextSeenKey: null, shouldRefetch: false };
|
||||
if (prevKey === nextKey) return { nextSeenKey: prevKey, shouldRefetch: false };
|
||||
return { nextSeenKey: nextKey, shouldRefetch: true };
|
||||
}
|
||||
15
packages/app/src/hooks/use-active-server-id.ts
Normal file
15
packages/app/src/hooks/use-active-server-id.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useMemo } from "react";
|
||||
import { usePathname } from "expo-router";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { resolveActiveHost } from "@/utils/active-host";
|
||||
|
||||
export function useActiveHost() {
|
||||
const pathname = usePathname();
|
||||
const hosts = useHosts();
|
||||
|
||||
return useMemo(() => resolveActiveHost({ hosts, pathname }), [hosts, pathname]);
|
||||
}
|
||||
|
||||
export function useActiveServerId(): string | null {
|
||||
return useActiveHost()?.serverId ?? null;
|
||||
}
|
||||
58
packages/app/src/hooks/use-active-worktree-new-action.ts
Normal file
58
packages/app/src/hooks/use-active-worktree-new-action.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useCallback } from "react";
|
||||
import { router } from "expo-router";
|
||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import type { KeyboardActionId } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import { useNavigationActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { buildHostNewWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
|
||||
|
||||
const WORKTREE_NEW_ACTIONS: readonly KeyboardActionId[] = ["worktree.new"];
|
||||
|
||||
export function useActiveWorktreeNewAction() {
|
||||
const selection = useNavigationActiveWorkspaceSelection();
|
||||
const serverId = selection?.serverId ?? null;
|
||||
const workspaceId = selection?.workspaceId ?? null;
|
||||
|
||||
const workingDir = useSessionStore((state) => {
|
||||
if (!serverId || !workspaceId) {
|
||||
return null;
|
||||
}
|
||||
const workspace = state.sessions[serverId]?.workspaces?.get(workspaceId);
|
||||
if (!workspace || workspace.projectKind !== "git") {
|
||||
return null;
|
||||
}
|
||||
return workspace.projectRootPath;
|
||||
});
|
||||
|
||||
const displayName = useSessionStore((state) => {
|
||||
if (!serverId || !workspaceId) {
|
||||
return null;
|
||||
}
|
||||
const workspace = state.sessions[serverId]?.workspaces?.get(workspaceId);
|
||||
if (!workspace || workspace.projectKind !== "git") {
|
||||
return null;
|
||||
}
|
||||
return workspace.projectDisplayName || projectDisplayNameFromProjectId(workspace.projectId);
|
||||
});
|
||||
|
||||
const handle = useCallback(() => {
|
||||
if (!serverId || !workingDir) {
|
||||
return false;
|
||||
}
|
||||
router.navigate(
|
||||
buildHostNewWorkspaceRoute(serverId, workingDir, {
|
||||
displayName: displayName ?? undefined,
|
||||
}) as never,
|
||||
);
|
||||
return true;
|
||||
}, [serverId, workingDir, displayName]);
|
||||
|
||||
useKeyboardActionHandler({
|
||||
handlerId: "worktree-new-active",
|
||||
actions: WORKTREE_NEW_ACTIONS,
|
||||
enabled: serverId !== null && workingDir !== null,
|
||||
priority: 0,
|
||||
handle,
|
||||
});
|
||||
}
|
||||
209
packages/app/src/hooks/use-agent-form-state.live.test.ts
Normal file
209
packages/app/src/hooks/use-agent-form-state.live.test.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AgentProvider, ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
|
||||
import type { FormPreferences } from "./use-form-preferences";
|
||||
import { useAgentFormState } from "./use-agent-form-state";
|
||||
|
||||
const { mocks } = vi.hoisted(() => ({
|
||||
mocks: {
|
||||
preferences: {} as FormPreferences,
|
||||
isPreferencesLoading: false,
|
||||
snapshotEntries: [] as ProviderSnapshotEntry[],
|
||||
updatePreferences: vi.fn(),
|
||||
refreshSnapshot: vi.fn(),
|
||||
refetchSnapshotIfStale: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/runtime/host-runtime", () => ({
|
||||
useHosts: () => [{ serverId: "host-1" }],
|
||||
}));
|
||||
|
||||
vi.mock("./use-form-preferences", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./use-form-preferences")>();
|
||||
return {
|
||||
...actual,
|
||||
useFormPreferences: () => ({
|
||||
preferences: mocks.preferences,
|
||||
isLoading: mocks.isPreferencesLoading,
|
||||
updatePreferences: mocks.updatePreferences,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./use-providers-snapshot", () => ({
|
||||
useProvidersSnapshot: () => ({
|
||||
entries: mocks.snapshotEntries,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isRefreshing: false,
|
||||
error: null,
|
||||
supportsSnapshot: true,
|
||||
refresh: mocks.refreshSnapshot,
|
||||
refetchIfStale: mocks.refetchSnapshotIfStale,
|
||||
}),
|
||||
}));
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
||||
value: true,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
describe("useAgentFormState live preference hydration", () => {
|
||||
beforeEach(() => {
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
|
||||
url: "http://localhost",
|
||||
});
|
||||
|
||||
Object.defineProperty(globalThis, "document", {
|
||||
value: dom.window.document,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
value: dom.window,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
value: dom.window.navigator,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
mocks.preferences = {};
|
||||
mocks.isPreferencesLoading = false;
|
||||
mocks.snapshotEntries = [
|
||||
{
|
||||
provider: "codex",
|
||||
status: "ready",
|
||||
label: "Codex",
|
||||
description: "Codex test provider",
|
||||
defaultModeId: "auto",
|
||||
modes: [
|
||||
{ id: "auto", label: "Auto", icon: "ShieldAlert", colorTier: "moderate" },
|
||||
{
|
||||
id: "full-access",
|
||||
label: "Full Access",
|
||||
icon: "ShieldAlert",
|
||||
colorTier: "dangerous",
|
||||
},
|
||||
],
|
||||
models: [
|
||||
{
|
||||
provider: "codex",
|
||||
id: "gpt-5.4",
|
||||
label: "gpt-5.4",
|
||||
isDefault: true,
|
||||
defaultThinkingOptionId: "low",
|
||||
thinkingOptions: [
|
||||
{ id: "low", label: "Low" },
|
||||
{ id: "xhigh", label: "XHigh" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
provider: "claude",
|
||||
status: "ready",
|
||||
label: "Claude",
|
||||
description: "Claude test provider",
|
||||
defaultModeId: "default",
|
||||
modes: [{ id: "default", label: "Default", icon: "ShieldCheck", colorTier: "safe" }],
|
||||
models: [
|
||||
{
|
||||
provider: "claude",
|
||||
id: "claude-sonnet-4-6",
|
||||
label: "Sonnet 4.6",
|
||||
isDefault: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
mocks.updatePreferences.mockReset();
|
||||
mocks.refreshSnapshot.mockReset();
|
||||
mocks.refetchSnapshotIfStale.mockReset();
|
||||
});
|
||||
|
||||
it("hydrates from stored preferences once and ignores later preference writes from other composers", async () => {
|
||||
mocks.preferences = {
|
||||
provider: "codex",
|
||||
providerPreferences: {
|
||||
codex: {
|
||||
model: "gpt-5.4",
|
||||
mode: "full-access",
|
||||
thinkingByModel: {
|
||||
"gpt-5.4": "xhigh",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { result, rerender } = renderHook(() =>
|
||||
useAgentFormState({
|
||||
initialServerId: "host-1",
|
||||
isVisible: true,
|
||||
onlineServerIds: ["host-1"],
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedProvider).toBe("codex");
|
||||
expect(result.current.selectedModel).toBe("gpt-5.4");
|
||||
expect(result.current.selectedMode).toBe("full-access");
|
||||
expect(result.current.selectedThinkingOptionId).toBe("xhigh");
|
||||
});
|
||||
|
||||
mocks.preferences = {
|
||||
provider: "claude",
|
||||
providerPreferences: {
|
||||
claude: {
|
||||
model: "claude-sonnet-4-6",
|
||||
mode: "default",
|
||||
},
|
||||
codex: {
|
||||
model: "gpt-5.4",
|
||||
mode: "auto",
|
||||
thinkingByModel: {
|
||||
"gpt-5.4": "low",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
rerender();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedProvider).toBe("codex");
|
||||
expect(result.current.selectedModel).toBe("gpt-5.4");
|
||||
expect(result.current.selectedMode).toBe("full-access");
|
||||
expect(result.current.selectedThinkingOptionId).toBe("xhigh");
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the latest preferences when a separate composer hydrates later", async () => {
|
||||
mocks.preferences = {
|
||||
provider: "claude",
|
||||
providerPreferences: {
|
||||
claude: {
|
||||
model: "claude-sonnet-4-6",
|
||||
mode: "default",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAgentFormState({
|
||||
initialServerId: "host-1",
|
||||
isVisible: true,
|
||||
onlineServerIds: ["host-1"],
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedProvider).toBe("claude" as AgentProvider);
|
||||
expect(result.current.selectedModel).toBe("claude-sonnet-4-6");
|
||||
expect(result.current.selectedMode).toBe("default");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -146,6 +146,112 @@ describe("useAgentFormState", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("__private__.mergeSelectedComposerPreferences", () => {
|
||||
it("stores the selected model for the selected provider", () => {
|
||||
expect(
|
||||
__private__.mergeSelectedComposerPreferences({
|
||||
preferences: {},
|
||||
provider: "codex",
|
||||
updates: {
|
||||
model: "gpt-5.4",
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
provider: "codex",
|
||||
providerPreferences: {
|
||||
codex: {
|
||||
model: "gpt-5.4",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves existing provider preferences when the selected model changes", () => {
|
||||
expect(
|
||||
__private__.mergeSelectedComposerPreferences({
|
||||
preferences: {
|
||||
provider: "claude",
|
||||
providerPreferences: {
|
||||
codex: {
|
||||
mode: "full-access",
|
||||
thinkingByModel: {
|
||||
"gpt-5.4-mini": "medium",
|
||||
},
|
||||
featureValues: {
|
||||
fast_mode: true,
|
||||
},
|
||||
},
|
||||
claude: {
|
||||
model: "claude-sonnet-4-6",
|
||||
},
|
||||
},
|
||||
favoriteModels: [{ provider: "codex", modelId: "gpt-5.4-mini" }],
|
||||
},
|
||||
provider: "codex",
|
||||
updates: {
|
||||
model: "gpt-5.4",
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
provider: "codex",
|
||||
providerPreferences: {
|
||||
codex: {
|
||||
model: "gpt-5.4",
|
||||
mode: "full-access",
|
||||
thinkingByModel: {
|
||||
"gpt-5.4-mini": "medium",
|
||||
},
|
||||
featureValues: {
|
||||
fast_mode: true,
|
||||
},
|
||||
},
|
||||
claude: {
|
||||
model: "claude-sonnet-4-6",
|
||||
},
|
||||
},
|
||||
favoriteModels: [{ provider: "codex", modelId: "gpt-5.4-mini" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("stores mode and thinking preferences without dropping the selected model", () => {
|
||||
expect(
|
||||
__private__.mergeSelectedComposerPreferences({
|
||||
preferences: {
|
||||
provider: "codex",
|
||||
providerPreferences: {
|
||||
codex: {
|
||||
model: "gpt-5.4",
|
||||
mode: "auto",
|
||||
thinkingByModel: {
|
||||
"gpt-5.4-mini": "low",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: "codex",
|
||||
updates: {
|
||||
mode: "full-access",
|
||||
thinkingByModel: {
|
||||
"gpt-5.4": "xhigh",
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
provider: "codex",
|
||||
providerPreferences: {
|
||||
codex: {
|
||||
model: "gpt-5.4",
|
||||
mode: "full-access",
|
||||
thinkingByModel: {
|
||||
"gpt-5.4-mini": "low",
|
||||
"gpt-5.4": "xhigh",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("__private__.resolveFormState", () => {
|
||||
const codexModels: AgentModelDefinition[] = [
|
||||
{
|
||||
@@ -161,6 +267,37 @@ describe("useAgentFormState", () => {
|
||||
},
|
||||
];
|
||||
|
||||
it("keeps provider, mode, and model unset on first open without preferences or explicit values", () => {
|
||||
const resolved = __private__.resolveFormState(
|
||||
undefined,
|
||||
{},
|
||||
null,
|
||||
{
|
||||
serverId: false,
|
||||
provider: false,
|
||||
modeId: false,
|
||||
model: false,
|
||||
thinkingOptionId: false,
|
||||
workingDir: false,
|
||||
},
|
||||
{
|
||||
serverId: null,
|
||||
provider: null,
|
||||
modeId: "",
|
||||
model: "",
|
||||
thinkingOptionId: "",
|
||||
workingDir: "",
|
||||
},
|
||||
new Set<string>(),
|
||||
makeProviderMap(TEST_CLAUDE_DEFINITION, TEST_CODEX_DEFINITION),
|
||||
);
|
||||
|
||||
expect(resolved.provider).toBeNull();
|
||||
expect(resolved.modeId).toBe("");
|
||||
expect(resolved.model).toBe("");
|
||||
expect(resolved.thinkingOptionId).toBe("");
|
||||
});
|
||||
|
||||
it("does not auto-select a model on fresh drafts without preferences", () => {
|
||||
const resolved = __private__.resolveFormState(
|
||||
undefined,
|
||||
@@ -374,7 +511,7 @@ describe("useAgentFormState", () => {
|
||||
expect(resolved.thinkingOptionId).toBe("low");
|
||||
});
|
||||
|
||||
it("resolves provider only from allowed provider map", () => {
|
||||
it("clears an invalid provider instead of falling back to the first allowed provider", () => {
|
||||
const resolved = __private__.resolveFormState(
|
||||
undefined,
|
||||
{ provider: "codex" },
|
||||
@@ -399,7 +536,121 @@ describe("useAgentFormState", () => {
|
||||
claudeProviderMap,
|
||||
);
|
||||
|
||||
expect(resolved.provider).toBe("claude");
|
||||
expect(resolved.provider).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves a user-selected provider and model while that provider is loading during refresh", () => {
|
||||
const loadingEntries: ProviderSnapshotEntry[] = [
|
||||
{
|
||||
provider: "codex",
|
||||
status: "loading",
|
||||
label: TEST_CODEX_DEFINITION.label,
|
||||
description: TEST_CODEX_DEFINITION.description,
|
||||
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
|
||||
modes: TEST_CODEX_DEFINITION.modes,
|
||||
},
|
||||
{
|
||||
provider: "claude",
|
||||
status: "ready",
|
||||
label: TEST_CLAUDE_DEFINITION.label,
|
||||
description: TEST_CLAUDE_DEFINITION.description,
|
||||
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
|
||||
modes: TEST_CLAUDE_DEFINITION.modes,
|
||||
models: [{ provider: "claude", id: "default", label: "Default", isDefault: true }],
|
||||
},
|
||||
];
|
||||
const providerDefinitions = buildProviderDefinitions(loadingEntries);
|
||||
const resolvableProviderMap = __private__.buildProviderDefinitionMapForStatuses({
|
||||
snapshotEntries: loadingEntries,
|
||||
providerDefinitions,
|
||||
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready", "loading"]),
|
||||
});
|
||||
|
||||
const resolved = __private__.resolveFormState(
|
||||
undefined,
|
||||
{},
|
||||
null,
|
||||
{
|
||||
serverId: false,
|
||||
provider: true,
|
||||
modeId: true,
|
||||
model: true,
|
||||
thinkingOptionId: true,
|
||||
workingDir: false,
|
||||
},
|
||||
{
|
||||
serverId: null,
|
||||
provider: "codex",
|
||||
modeId: "full-access",
|
||||
model: "gpt-5.3-codex",
|
||||
thinkingOptionId: "xhigh",
|
||||
workingDir: "",
|
||||
},
|
||||
new Set<string>(),
|
||||
resolvableProviderMap,
|
||||
);
|
||||
|
||||
expect(resolved.provider).toBe("codex");
|
||||
expect(resolved.modeId).toBe("full-access");
|
||||
expect(resolved.model).toBe("gpt-5.3-codex");
|
||||
expect(resolved.thinkingOptionId).toBe("xhigh");
|
||||
});
|
||||
|
||||
it("clears a user-selected provider when the refreshed snapshot marks it unavailable", () => {
|
||||
const unavailableEntries: ProviderSnapshotEntry[] = [
|
||||
{
|
||||
provider: "codex",
|
||||
status: "unavailable",
|
||||
label: TEST_CODEX_DEFINITION.label,
|
||||
description: TEST_CODEX_DEFINITION.description,
|
||||
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
|
||||
modes: TEST_CODEX_DEFINITION.modes,
|
||||
},
|
||||
{
|
||||
provider: "claude",
|
||||
status: "ready",
|
||||
label: TEST_CLAUDE_DEFINITION.label,
|
||||
description: TEST_CLAUDE_DEFINITION.description,
|
||||
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
|
||||
modes: TEST_CLAUDE_DEFINITION.modes,
|
||||
models: [{ provider: "claude", id: "default", label: "Default", isDefault: true }],
|
||||
},
|
||||
];
|
||||
const providerDefinitions = buildProviderDefinitions(unavailableEntries);
|
||||
const resolvableProviderMap = __private__.buildProviderDefinitionMapForStatuses({
|
||||
snapshotEntries: unavailableEntries,
|
||||
providerDefinitions,
|
||||
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready", "loading"]),
|
||||
});
|
||||
|
||||
const resolved = __private__.resolveFormState(
|
||||
undefined,
|
||||
{},
|
||||
null,
|
||||
{
|
||||
serverId: false,
|
||||
provider: true,
|
||||
modeId: false,
|
||||
model: false,
|
||||
thinkingOptionId: false,
|
||||
workingDir: false,
|
||||
},
|
||||
{
|
||||
serverId: null,
|
||||
provider: "codex",
|
||||
modeId: "full-access",
|
||||
model: "gpt-5.3-codex",
|
||||
thinkingOptionId: "xhigh",
|
||||
workingDir: "",
|
||||
},
|
||||
new Set<string>(),
|
||||
resolvableProviderMap,
|
||||
);
|
||||
|
||||
expect(resolved.provider).toBeNull();
|
||||
expect(resolved.modeId).toBe("");
|
||||
expect(resolved.model).toBe("");
|
||||
expect(resolved.thinkingOptionId).toBe("");
|
||||
});
|
||||
|
||||
it("does not force fallback provider when allowed provider map is empty", () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user