mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-26 03:03:04 +00:00
Compare commits
162
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1c786e70b | ||
|
|
de57f301f9 | ||
|
|
95c773ce6d | ||
|
|
c6680b3001 | ||
|
|
484e453401 | ||
|
|
4b867c2f41 | ||
|
|
368ce0339a | ||
|
|
8e63274ce6 | ||
|
|
d80c524e0e | ||
|
|
89efb007f9 | ||
|
|
407b4b68b0 | ||
|
|
637109eab0 | ||
|
|
3a0161d051 | ||
|
|
027b3839fc | ||
|
|
c3589bdc84 | ||
|
|
a5e6cc2d2b | ||
|
|
713c90ea42 | ||
|
|
e51fdada51 | ||
|
|
dd98ae9359 | ||
|
|
274a7fd217 | ||
|
|
a582fccef0 | ||
|
|
019bf65790 | ||
|
|
9218f70df3 | ||
|
|
fd05bf8bff | ||
|
|
9e3d8f0ec5 | ||
|
|
43b9123c5c | ||
|
|
b6afc66131 | ||
|
|
7993dc02dc | ||
|
|
6601db2673 | ||
|
|
e4badd95f4 | ||
|
|
02fc354686 | ||
|
|
aef54d5180 | ||
|
|
3c5bfd6e3d | ||
|
|
f422dabf1e | ||
|
|
2706d5e934 | ||
|
|
6b210df471 | ||
|
|
9a39ba4be0 | ||
|
|
fc86ce2a5f | ||
|
|
6edf19c7c6 | ||
|
|
80c319dc49 | ||
|
|
77c8c91ab5 | ||
|
|
6503245493 | ||
|
|
184902dd0d | ||
|
|
42cdabdf57 | ||
|
|
247983ee87 | ||
|
|
7b9cc3d423 | ||
|
|
8ee3c6ae1d | ||
|
|
244c40a62f | ||
|
|
3fe63d5a2c | ||
|
|
1582028902 | ||
|
|
7e7cc1ad56 | ||
|
|
27ddfb1ea7 | ||
|
|
807cce6ad9 | ||
|
|
73b28a7f60 | ||
|
|
8dad423635 | ||
|
|
abaa29e73c | ||
|
|
ae2a301cb6 | ||
|
|
006d57d546 | ||
|
|
a201e5a853 | ||
|
|
c402db8b1c | ||
|
|
6a03873d81 | ||
|
|
39db3ea5ff | ||
|
|
79bf5ac3c7 | ||
|
|
051507717c | ||
|
|
f3cb19529c | ||
|
|
cea97c83b3 | ||
|
|
3e2faab7f8 | ||
|
|
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 |
@@ -132,6 +132,9 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm install
|
run: npm install
|
||||||
|
|
||||||
|
- name: Install Playwright browsers
|
||||||
|
run: npx playwright install --with-deps chromium
|
||||||
|
|
||||||
- name: Build highlight dependency
|
- name: Build highlight dependency
|
||||||
run: npm run build --workspace=@getpaseo/highlight
|
run: npm run build --workspace=@getpaseo/highlight
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ on:
|
|||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
- '!v*-rc.*'
|
- '!v*-beta.*'
|
||||||
- 'app-v*'
|
- 'app-v*'
|
||||||
- '!app-v*-rc.*'
|
- '!app-v*-beta.*'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|||||||
@@ -66,7 +66,9 @@ jobs:
|
|||||||
--repo "${{ github.repository }}" \
|
--repo "${{ github.repository }}" \
|
||||||
--title "Paseo $RELEASE_TAG" \
|
--title "Paseo $RELEASE_TAG" \
|
||||||
--generate-notes \
|
--generate-notes \
|
||||||
$prerelease_flag
|
$prerelease_flag || {
|
||||||
|
echo "Release creation raced with another workflow; continuing."
|
||||||
|
}
|
||||||
fi
|
fi
|
||||||
|
|
||||||
publish-macos:
|
publish-macos:
|
||||||
@@ -145,6 +147,7 @@ jobs:
|
|||||||
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
|
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
|
||||||
publish_mode="always"
|
publish_mode="always"
|
||||||
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||||
|
publish_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --mac --${{ matrix.electron_arch }} "${publish_args[@]}"
|
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --mac --${{ matrix.electron_arch }} "${publish_args[@]}"
|
||||||
@@ -154,7 +157,7 @@ jobs:
|
|||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: mac-manifest-${{ matrix.electron_arch }}
|
name: mac-manifest-${{ matrix.electron_arch }}
|
||||||
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/latest-mac.yml
|
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/${{ env.RELEASE_CHANNEL }}-mac.yml
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|
||||||
finalize-mac-manifest:
|
finalize-mac-manifest:
|
||||||
@@ -227,8 +230,9 @@ jobs:
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
const arm64Text = fs.readFileSync('mac-manifest-arm64/latest-mac.yml', 'utf8');
|
const manifestName = `${process.env.RELEASE_CHANNEL}-mac.yml`;
|
||||||
const x64Text = fs.readFileSync('mac-manifest-x64/latest-mac.yml', 'utf8');
|
const arm64Text = fs.readFileSync(`mac-manifest-arm64/${manifestName}`, 'utf8');
|
||||||
|
const x64Text = fs.readFileSync(`mac-manifest-x64/${manifestName}`, 'utf8');
|
||||||
|
|
||||||
const arm64 = parseManifest(arm64Text);
|
const arm64 = parseManifest(arm64Text);
|
||||||
const x64 = parseManifest(x64Text);
|
const x64 = parseManifest(x64Text);
|
||||||
@@ -243,7 +247,7 @@ jobs:
|
|||||||
};
|
};
|
||||||
|
|
||||||
const output = toYaml(merged);
|
const output = toYaml(merged);
|
||||||
fs.writeFileSync('latest-mac.yml', output);
|
fs.writeFileSync(manifestName, output);
|
||||||
console.log('Merged manifest:\n' + output);
|
console.log('Merged manifest:\n' + output);
|
||||||
NODE
|
NODE
|
||||||
|
|
||||||
@@ -251,7 +255,7 @@ jobs:
|
|||||||
if: env.IS_SMOKE_TAG != 'true'
|
if: env.IS_SMOKE_TAG != 'true'
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: gh release upload "$RELEASE_TAG" latest-mac.yml --clobber --repo "${{ github.repository }}"
|
run: gh release upload "$RELEASE_TAG" "$RELEASE_CHANNEL-mac.yml" --clobber --repo "${{ github.repository }}"
|
||||||
|
|
||||||
publish-linux:
|
publish-linux:
|
||||||
needs: [create-release]
|
needs: [create-release]
|
||||||
@@ -316,6 +320,7 @@ jobs:
|
|||||||
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
|
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
|
||||||
publish_mode="always"
|
publish_mode="always"
|
||||||
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||||
|
publish_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --linux --x64 "${publish_args[@]}"
|
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --linux --x64 "${publish_args[@]}"
|
||||||
@@ -391,6 +396,7 @@ jobs:
|
|||||||
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
|
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
|
||||||
publish_mode="always"
|
publish_mode="always"
|
||||||
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||||
|
publish_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --win --x64 "${publish_args[@]}"
|
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --win --x64 "${publish_args[@]}"
|
||||||
|
|||||||
@@ -54,9 +54,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
create_if_missing="false"
|
create_if_missing="false"
|
||||||
if [ "$EVENT_NAME" = "push" ] && [[ "$REF" == refs/tags/v* ]]; then
|
if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "${INPUT_CREATE_IF_MISSING:-false}" = "true" ]; then
|
||||||
create_if_missing="true"
|
|
||||||
elif [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "${INPUT_CREATE_IF_MISSING:-false}" = "true" ]; then
|
|
||||||
create_if_missing="true"
|
create_if_missing="true"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,54 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.1.60 - 2026-04-22
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Scripts and services per worktree — define named commands in `paseo.json`, and long-running services get supervised with their own ports and nice proxy URLs like `http://web.my-app.localhost:6767`. See the [worktrees guide](https://paseo.sh/docs/worktrees).
|
||||||
|
- Launch scripts and services for a worktree directly from the workspace header.
|
||||||
|
- New Setup tab in every workspace showing setup, teardown, and script progress live.
|
||||||
|
- GitHub checks and PR reviews in the explorer sidebar, with a hover card for the full breakdown.
|
||||||
|
- New worktree creation flow lets you pick a base branch or check out an existing GitHub pull request.
|
||||||
|
- Attach GitHub issues and pull requests to an agent as part of its prompt context.
|
||||||
|
- Pull request pane in the workspace sidebar.
|
||||||
|
- Redesigned Settings screen with modular section navigation.
|
||||||
|
- Per-host provider configuration — set providers, models, and credentials independently on each remote host.
|
||||||
|
- Direct Pi integration replaces the ACP bridge, with faster streaming and fewer hiccups.
|
||||||
|
- Beta release channel — opt in from Settings to receive beta desktop builds before they are promoted to stable.
|
||||||
|
- New-workspace picker ranks branches by recency with fast search.
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
- Workspace and tab switching are dramatically faster on desktop and mobile — you can keep many workspaces open in parallel without lag.
|
||||||
|
- Agent streams render more smoothly during heavy tool output.
|
||||||
|
- App startup routes through a stable connection and lands on the right screen without flicker.
|
||||||
|
- Provider refresh is reliable and no longer stalls on transient failures.
|
||||||
|
- Git and GitHub state stay in sync with local changes like commits, branch switches, and pushes.
|
||||||
|
- Composer attachments redesigned with a cleaner pill layout and an image lightbox.
|
||||||
|
- In-app notifications route to whichever surface you're actually looking at.
|
||||||
|
- Keyboard shortcuts keep working while Settings is open.
|
||||||
|
- Escape reliably interrupts the active agent.
|
||||||
|
- Checking out a pull request from a fork lands on an owner-prefixed branch so multiple forks don't collide.
|
||||||
|
- `paseo ls` defaults to active agents; pass `-a` to include archived.
|
||||||
|
- GitHub branch and PR picker loads faster — queries are deferred until the picker opens.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Composer textarea shrinks back down after sending on web.
|
||||||
|
- New workspace drafts clear after submit instead of sticking around.
|
||||||
|
- Replacing a running agent cleans up the previous one without leaving it behind.
|
||||||
|
- Agent notifications no longer get swallowed by a backgrounded focused client.
|
||||||
|
- Removed workspace folders disappear from the workspace list again.
|
||||||
|
- Codex keeps fast mode after you approve a plan. ([#526](https://github.com/getpaseo/paseo/pull/526) by [@therainisme](https://github.com/therainisme))
|
||||||
|
- Workspace tab focus is preserved across page refreshes.
|
||||||
|
- Settings screen no longer pushes its header down with extra spacing.
|
||||||
|
- Branch switcher title no longer overflows on narrow rows.
|
||||||
|
- iOS image picker no longer leaves the screen unresponsive after cancelling.
|
||||||
|
- Archiving a worktree recovers cleanly if a previous attempt was interrupted.
|
||||||
|
- Images in agent messages with `~`-prefixed paths load instead of spinning forever.
|
||||||
|
- Tool call blocks expand correctly on mobile while an agent is still streaming.
|
||||||
|
- Timeline no longer stutters when catch-up and projected ranges overlap.
|
||||||
|
- Codex no longer flashes idle when a replacement turn is in progress.
|
||||||
|
- Branch state recovers correctly when a rebase is in progress.
|
||||||
|
- Workspace hover card no longer clips near screen edges.
|
||||||
|
|
||||||
## 0.1.59 - 2026-04-16
|
## 0.1.59 - 2026-04-16
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+1
-1
@@ -51,7 +51,7 @@ Stable tag pushes like `v0.1.0` trigger:
|
|||||||
- `packages/app/.eas/workflows/release-mobile.yml` on Expo servers (iOS + Android build + submit)
|
- `packages/app/.eas/workflows/release-mobile.yml` on Expo servers (iOS + Android build + submit)
|
||||||
- `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release)
|
- `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release)
|
||||||
|
|
||||||
Release candidate tags like `v0.1.1-rc.1` only trigger the GitHub APK workflow. They publish a GitHub prerelease APK for testing and do not submit to the stores.
|
Beta tags like `v0.1.1-beta.1` only trigger the GitHub APK workflow. They publish a GitHub prerelease APK for testing and do not submit to the stores.
|
||||||
|
|
||||||
### Useful commands
|
### Useful commands
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ PASEO_HOME=~/.paseo-blue npm run dev
|
|||||||
```
|
```
|
||||||
|
|
||||||
- `PASEO_HOME` — path for runtime state (agents, sockets, etc.). Defaults to `~/.paseo`.
|
- `PASEO_HOME` — path for runtime state (agents, sockets, etc.). Defaults to `~/.paseo`.
|
||||||
|
- In git worktrees, `npm run dev` derives a stable home like `~/.paseo-<worktree-name>`.
|
||||||
|
On first run, it seeds that home from `~/.paseo` by copying agent/project JSON metadata
|
||||||
|
and `config.json`; actual checkout/worktree directories are not copied.
|
||||||
|
- `PASEO_DEV_SEED_HOME=/path/to/home npm run dev` seeds from a different source home.
|
||||||
|
- `PASEO_DEV_RESET_HOME=1 npm run dev` clears and reseeds the derived worktree home.
|
||||||
|
|
||||||
### Default ports
|
### Default ports
|
||||||
|
|
||||||
@@ -54,6 +59,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`.
|
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.
|
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
|
## Build sync gotchas
|
||||||
|
|
||||||
### Relay → Daemon
|
### Relay → Daemon
|
||||||
|
|||||||
@@ -110,6 +110,33 @@ See `image-picker-repro.yaml` for an example.
|
|||||||
|
|
||||||
**Prefer direct connection over relay pairing for local E2E.** Relay needs a 400+ character pairing URL typed into an input; direct needs `127.0.0.1:6767`. The daemon listens on 6767 and the simulator can reach it directly.
|
**Prefer direct connection over relay pairing for local E2E.** Relay needs a 400+ character pairing URL typed into an input; direct needs `127.0.0.1:6767`. The daemon listens on 6767 and the simulator can reach it directly.
|
||||||
|
|
||||||
|
### New Workspace Creation
|
||||||
|
|
||||||
|
The Android workspace-creation regression has a dedicated harness:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash packages/app/maestro/test-workspace-create-android-crash.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
For a short recording that starts after launch/connection/sidebar setup:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash packages/app/maestro/record-workspace-create-android-focus.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The flow details are documented in `packages/app/maestro/README.md`. The important rule is that a valid new-workspace assertion must prove the redirect completed: select a real model, tap `Create`, wait for `workspace-header-title`, wait for `message-input-root`, assert `New workspace` is gone, and assert the Android redbox strings are absent. Waiting for the composer alone is too weak because it can still be the `/new` route after a validation error.
|
||||||
|
|
||||||
|
New workspace scenarios should compose the reusable subflows in `packages/app/maestro/flows/`:
|
||||||
|
|
||||||
|
- `android-dev-client.yaml`
|
||||||
|
- `connect-direct-if-welcome.yaml`
|
||||||
|
- `open-prepared-project-sidebar.yaml`
|
||||||
|
- `new-workspace-open-from-sidebar.yaml`
|
||||||
|
- `new-workspace-select-codex-gpt54.yaml`
|
||||||
|
- `new-workspace-submit-and-assert-created.yaml`
|
||||||
|
|
||||||
|
The workspace-create shell scripts render those subflows into a temp directory before running Maestro, which keeps nested `runFlow` paths and `${PASEO_MAESTRO_*}` placeholders working together.
|
||||||
|
|
||||||
### Inputs that Maestro types into
|
### Inputs that Maestro types into
|
||||||
|
|
||||||
Maestro `inputText` fires one character at a time. React Native's **controlled** `TextInput` re-renders per keystroke; if a controlled input's state update lags or re-mounts mid-type, characters are dropped silently — the final value on screen is a truncated/scrambled version of what was "typed."
|
Maestro `inputText` fires one character at a time. React Native's **controlled** `TextInput` re-renders per keystroke; if a controlled input's state update lags or re-mounts mid-type, characters are dropped silently — the final value on screen is a truncated/scrambled version of what was "typed."
|
||||||
|
|||||||
+28
-25
@@ -7,7 +7,7 @@ All workspaces share one version and release together.
|
|||||||
There are two supported ways to ship from `main`:
|
There are two supported ways to ship from `main`:
|
||||||
|
|
||||||
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
|
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
|
||||||
2. **Release candidate flow**: you want public test builds first, but you are not ready for the website, npm, or production mobile release flows to move yet.
|
2. **Beta flow**: you want public test builds first, but you are not ready for the website, npm, or production mobile release flows to move yet.
|
||||||
|
|
||||||
## Standard release (patch)
|
## Standard release (patch)
|
||||||
|
|
||||||
@@ -37,31 +37,32 @@ npm run release:publish # Publish to npm
|
|||||||
npm run release:push # Push HEAD + tag (triggers CI workflows)
|
npm run release:push # Push HEAD + tag (triggers CI workflows)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Release candidate flow
|
## Beta flow
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run release:rc:patch # Bump to X.Y.Z-rc.1, push commit + tag
|
npm run release:beta:patch # Bump to X.Y.Z-beta.1, push commit + tag
|
||||||
# ... test desktop and APK prerelease assets from GitHub Releases ...
|
# ... test desktop and APK prerelease assets from GitHub Releases ...
|
||||||
npm run release:rc:next # Optional: cut X.Y.Z-rc.2, rc.3, ...
|
npm run release:beta:next # Optional: cut X.Y.Z-beta.2, beta.3, ...
|
||||||
npm run release:promote # Promote X.Y.Z-rc.N to stable X.Y.Z
|
npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z
|
||||||
```
|
```
|
||||||
|
|
||||||
- RC tags are published GitHub prereleases like `v0.1.41-rc.1`
|
- Beta tags are published GitHub prereleases like `v0.1.41-beta.1`
|
||||||
- RCs publish desktop assets and APKs for testing, but they do not publish npm packages and do not trigger the production web/mobile release flows
|
- Betas publish desktop assets and APKs for testing, but they do not publish npm packages and do not trigger the production web/mobile release flows
|
||||||
- `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the RC tag
|
- `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the beta tag
|
||||||
- Desktop assets now come from the Electron package at `packages/desktop`
|
- Desktop assets now come from the Electron package at `packages/desktop`
|
||||||
- **Do NOT create a changelog entry for RCs.** The changelog remains stable-only. RC release notes are generated automatically so the website stays pinned to the latest published stable release.
|
- Beta releases use Electron's `beta` update channel. Users on the stable channel only receive stable releases; users on the beta channel receive beta releases and the final stable release when it is published.
|
||||||
|
- **Do create a changelog entry for betas.** The beta entry is temporary and gets updated in place until promotion.
|
||||||
|
|
||||||
Use the RC path when you need to:
|
Use the beta path when you need to:
|
||||||
|
|
||||||
- test a build manually in a Linux or Windows VM
|
- test a build manually in a Linux or Windows VM
|
||||||
- send a build to a user who is hitting a specific problem
|
- send a build to a user who is hitting a specific problem
|
||||||
- iterate on `rc.1`, `rc.2`, `rc.3`, and so on before deciding to ship broadly
|
- iterate on `beta.1`, `beta.2`, `beta.3`, and so on before deciding to ship broadly
|
||||||
|
|
||||||
## Website behavior
|
## Website behavior
|
||||||
|
|
||||||
- The website download page points to GitHub's latest published **stable** release.
|
- The website download page points to GitHub's latest published **stable** release.
|
||||||
- Published RC prereleases are public on GitHub Releases, but they do **not** become the website download target.
|
- Published beta prereleases are public on GitHub Releases, but they do **not** become the website download target.
|
||||||
- The website only moves when you publish the final stable release tag like `v0.1.41`.
|
- The website only moves when you publish the final stable release tag like `v0.1.41`.
|
||||||
|
|
||||||
## Fixing a failed release build
|
## Fixing a failed release build
|
||||||
@@ -88,13 +89,13 @@ git tag -f desktop-windows-v0.1.28 HEAD && git push origin desktop-windows-v0.1.
|
|||||||
# Android APK
|
# Android APK
|
||||||
git tag -f android-v0.1.28 HEAD && git push origin android-v0.1.28 --force
|
git tag -f android-v0.1.28 HEAD && git push origin android-v0.1.28 --force
|
||||||
|
|
||||||
# RC
|
# Beta
|
||||||
git tag -f v0.1.29-rc.2 HEAD && git push origin v0.1.29-rc.2 --force
|
git tag -f v0.1.29-beta.2 HEAD && git push origin v0.1.29-beta.2 --force
|
||||||
```
|
```
|
||||||
|
|
||||||
This ensures the checkout ref matches the actual code on `main` with the fix included.
|
This ensures the checkout ref matches the actual code on `main` with the fix included.
|
||||||
|
|
||||||
- `vX.Y.Z` or `vX.Y.Z-rc.N` rebuilds the full tagged release
|
- `vX.Y.Z` or `vX.Y.Z-beta.N` rebuilds the full tagged release
|
||||||
- `desktop-vX.Y.Z` rebuilds desktop for all desktop platforms only
|
- `desktop-vX.Y.Z` rebuilds desktop for all desktop platforms only
|
||||||
- `desktop-macos-vX.Y.Z`, `desktop-linux-vX.Y.Z`, and `desktop-windows-vX.Y.Z` rebuild only that desktop platform
|
- `desktop-macos-vX.Y.Z`, `desktop-linux-vX.Y.Z`, and `desktop-windows-vX.Y.Z` rebuild only that desktop platform
|
||||||
- `android-vX.Y.Z` rebuilds the Android APK release only
|
- `android-vX.Y.Z` rebuilds the Android APK release only
|
||||||
@@ -105,24 +106,26 @@ This ensures the checkout ref matches the actual code on `main` with the fix inc
|
|||||||
- `release:prepare` refreshes workspace `node_modules` links to prevent stale types
|
- `release:prepare` refreshes workspace `node_modules` links to prevent stale types
|
||||||
- `npm run dev:desktop` and `npm run build:desktop` target the Electron desktop package in `packages/desktop`
|
- `npm run dev:desktop` and `npm run build:desktop` target the Electron desktop package in `packages/desktop`
|
||||||
- If `release:publish` partially fails, re-run it — npm skips already-published versions
|
- If `release:publish` partially fails, re-run it — npm skips already-published versions
|
||||||
- The website uses GitHub's latest published release API for download links, so published RC prereleases do not replace the stable download target.
|
- The website uses GitHub's latest published release API for download links, so published beta prereleases do not replace the stable download target.
|
||||||
|
|
||||||
## Changelog format
|
## Changelog format
|
||||||
|
|
||||||
Stable release notes depend on the changelog heading format. The heading **must** be strictly followed:
|
Release notes depend on the changelog heading format. The heading **must** be strictly followed:
|
||||||
|
|
||||||
```
|
```
|
||||||
## X.Y.Z - YYYY-MM-DD
|
## X.Y.Z - YYYY-MM-DD
|
||||||
|
## X.Y.Z-beta.N - YYYY-MM-DD
|
||||||
```
|
```
|
||||||
|
|
||||||
No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to extract the version. A malformed heading will break download links on the homepage.
|
No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to extract the version. A malformed heading will break download links on the homepage.
|
||||||
|
|
||||||
## Changelog policy
|
## Changelog policy
|
||||||
|
|
||||||
- `CHANGELOG.md` is for **final stable releases only**.
|
- `CHANGELOG.md` includes stable releases and the current beta line.
|
||||||
- Do not add or edit changelog entries while iterating on RCs.
|
- The first beta inserts a top entry like `## 0.1.60-beta.1 - YYYY-MM-DD`.
|
||||||
- Write the proper changelog entry when you are cutting the final stable release that comes after the RC cycle.
|
- The next beta updates that same top entry in place, for example from `0.1.60-beta.1` to `0.1.60-beta.2`.
|
||||||
- Between stable releases, keep changelog work out of the repo until the final release is ready.
|
- Stable promotion updates that same entry in place, for example from `0.1.60-beta.2` to `0.1.60`.
|
||||||
|
- Do not create duplicate entries for each beta on the same version line.
|
||||||
|
|
||||||
## Changelog ownership
|
## Changelog ownership
|
||||||
|
|
||||||
@@ -178,7 +181,7 @@ Entries within each section (Added, Improved, Fixed) are ordered by user impact:
|
|||||||
|
|
||||||
## Pre-release sanity check
|
## Pre-release sanity check
|
||||||
|
|
||||||
Before cutting any release (RC or stable), run a Codex review of the diff as a last line of defence against shipping bugs.
|
Before cutting any release (beta or stable), run a Codex review of the diff as a last line of defence against shipping bugs.
|
||||||
|
|
||||||
Load the `paseo` skill and launch a **Codex 5.4** agent with a prompt like:
|
Load the `paseo` skill and launch a **Codex 5.4** agent with a prompt like:
|
||||||
|
|
||||||
@@ -196,10 +199,10 @@ The agent's job is a deep sanity check, not a full code review. If it flags anyt
|
|||||||
|
|
||||||
The changelog always covers **stable-to-HEAD**:
|
The changelog always covers **stable-to-HEAD**:
|
||||||
|
|
||||||
- **RC release**: the diff and release notes cover `latest stable tag → HEAD`. RC release notes are auto-generated and not added to `CHANGELOG.md`.
|
- **Beta release**: the diff and release notes cover `latest stable tag -> HEAD`. The current beta changelog entry is updated in place.
|
||||||
- **Stable release**: the diff and changelog entry cover `latest stable tag → HEAD`. Any intermediate RCs are skipped — the changelog captures the full delta from the previous stable release, not just what changed since the last RC.
|
- **Stable release**: the same changelog entry is promoted in place. It still captures the full delta from the previous stable release, not just what changed since the last beta.
|
||||||
|
|
||||||
In other words, RCs are checkpoints along the way; the changelog only records the final jump from one stable version to the next.
|
In other words, betas are checkpoints along the way; the changelog entry remains the single record for the final jump from one stable version to the next.
|
||||||
|
|
||||||
## Completion checklist
|
## Completion checklist
|
||||||
|
|
||||||
|
|||||||
+71
-1
@@ -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.
|
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
|
## 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.
|
Use this sparingly. It works because React re-renders the prop, but it gives up the main Unistyles native-update path for that value.
|
||||||
|
|
||||||
|
## `withUnistyles` And The `> *` Child-Selector Leak
|
||||||
|
|
||||||
|
`withUnistyles` on a component with a theme-dependent `style` prop works by wrapping the component in a `<div style={{display: 'contents'}} className={hash}>` and emitting the style under a `.hash > *` child selector so the styles cascade onto the wrapped component. This is how auto-mapping for `style` and `contentContainerStyle` works on web.
|
||||||
|
|
||||||
|
The sharp edge: Unistyles hashes styles by value. If `withUnistyles` receives a style whose value is **identical** to a style used elsewhere in the app on a plain `View`, both usages get the same hash — and both CSS rules (the element rule and the `> *` child rule) are emitted under the same class name. The `> *` rule then leaks onto the direct children of every `View` that shares the hash.
|
||||||
|
|
||||||
|
Concrete regression we hit: `welcome-screen.tsx` had `const ThemedScrollView = withUnistyles(ScrollView)` with `style={{ flex: 1, backgroundColor: theme.colors.surface0 }}`. `agent-panel.tsx` had `root` and `container` styles with the exact same value. All three collided on class `unistyles_j2k2iilhfz`, so the browser stylesheet contained:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.unistyles_j2k2iilhfz { flex: 1 1 0%; background-color: var(--colors-surface0); }
|
||||||
|
.unistyles_j2k2iilhfz > * { flex: 1 1 0%; background-color: var(--colors-surface0); }
|
||||||
|
```
|
||||||
|
|
||||||
|
The child-selector rule forced `flex:1` and `background-color: surface0` onto the Composer's outer `Animated.View` (a direct child of `container`), stretching it to fill remaining space and leaving a large empty gap between the composer UI and the bottom of the screen. It also painted a `surface0` band behind the scroll-to-bottom button. The bug only appeared in the browser — Electron skips `WelcomeScreen` after pairing, so the `> *` rule was never injected there.
|
||||||
|
|
||||||
|
Symptoms to watch for:
|
||||||
|
|
||||||
|
- A sibling of a themed panel-background `View` stretches unexpectedly on web only.
|
||||||
|
- Random direct children of a `{ flex: 1, backgroundColor: surface0 }` `View` pick up an unexpected background.
|
||||||
|
- DevTools shows a `.unistyles_xxx > *` rule you did not write.
|
||||||
|
|
||||||
|
Quick confirmation in DevTools console:
|
||||||
|
|
||||||
|
```js
|
||||||
|
[...document.styleSheets].flatMap(s => [...(s.cssRules || [])])
|
||||||
|
.map(r => r.cssText).filter(t => t.includes("unistyles") && t.includes("> *"));
|
||||||
|
```
|
||||||
|
|
||||||
|
Any match beyond benign `r-pointerEvents-* > *` rules from react-native-web is a leak.
|
||||||
|
|
||||||
|
Avoid the bug by preferring the wrapper-`View` pattern from the previous section whenever possible: put `{ flex: 1, backgroundColor: surface0 }` on a plain `View` and give the `ScrollView` a theme-free `style`/`contentContainerStyle`. That keeps `withUnistyles` off the hot path and avoids the hash collision. Only reach for `withUnistyles(ScrollView)` when a wrapper view is genuinely awkward, and when you do, give the wrapped style a distinctive shape (extra key, different layout) so it does not hash-collide with a common panel background used elsewhere.
|
||||||
|
|
||||||
## Hidden Sheet Content
|
## Hidden Sheet Content
|
||||||
|
|
||||||
`@gorhom/bottom-sheet` can keep `BottomSheetModal` content mounted while the sheet is hidden. That matters during Paseo's startup theme transition: a header node can be created under the initial adaptive theme, stay hidden, then appear later with stale native style values even though surrounding content has re-rendered correctly.
|
`@gorhom/bottom-sheet` can keep `BottomSheetModal` content mounted while the sheet is hidden. That matters during Paseo's startup theme transition: a header node can be created under the initial adaptive theme, stay hidden, then appear later with stale native style values even though surrounding content has re-rendered correctly.
|
||||||
@@ -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.
|
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
|
## 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.
|
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.
|
||||||
|
|||||||
+1
-1
@@ -42,7 +42,7 @@ buildNpmPackage rec {
|
|||||||
|
|
||||||
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
||||||
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
||||||
npmDepsHash = "sha256-Q8FCZ5cQs45ZL0XeaRzgZXmROmywT0YI/1Rqdlpbt/w=";
|
npmDepsHash = "sha256-p7luf7/eeQR5seXerJd42x7gY73qqOecStNZN/3wIlk=";
|
||||||
|
|
||||||
# Prevent onnxruntime-node's install script from running during automatic
|
# Prevent onnxruntime-node's install script from running during automatic
|
||||||
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
||||||
|
|||||||
Generated
+2915
-78
File diff suppressed because it is too large
Load Diff
+9
-9
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.59",
|
"version": "0.1.60",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/expo-two-way-audio",
|
"packages/expo-two-way-audio",
|
||||||
@@ -47,19 +47,19 @@
|
|||||||
"version:all:patch": "node scripts/set-release-version.mjs --mode patch",
|
"version:all:patch": "node scripts/set-release-version.mjs --mode patch",
|
||||||
"version:all:minor": "node scripts/set-release-version.mjs --mode minor",
|
"version:all:minor": "node scripts/set-release-version.mjs --mode minor",
|
||||||
"version:all:major": "node scripts/set-release-version.mjs --mode major",
|
"version:all:major": "node scripts/set-release-version.mjs --mode major",
|
||||||
"version:all:rc:patch": "node scripts/set-release-version.mjs --mode rc-patch",
|
"version:all:beta:patch": "node scripts/set-release-version.mjs --mode beta-patch",
|
||||||
"version:all:rc:minor": "node scripts/set-release-version.mjs --mode rc-minor",
|
"version:all:beta:minor": "node scripts/set-release-version.mjs --mode beta-minor",
|
||||||
"version:all:rc:major": "node scripts/set-release-version.mjs --mode rc-major",
|
"version:all:beta:major": "node scripts/set-release-version.mjs --mode beta-major",
|
||||||
"version:all:rc:next": "node scripts/set-release-version.mjs --mode rc-next",
|
"version:all:beta:next": "node scripts/set-release-version.mjs --mode beta-next",
|
||||||
"version:all:promote": "node scripts/set-release-version.mjs --mode promote",
|
"version:all:promote": "node scripts/set-release-version.mjs --mode promote",
|
||||||
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
|
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
|
||||||
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
|
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
|
||||||
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
|
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
|
||||||
"release:push": "node scripts/push-current-release-tag.mjs",
|
"release:push": "node scripts/push-current-release-tag.mjs",
|
||||||
"release:rc:patch": "npm run release:check && npm run version:all:rc:patch && npm run release:push",
|
"release:beta:patch": "npm run release:check && npm run version:all:beta:patch && npm run release:push",
|
||||||
"release:rc:minor": "npm run release:check && npm run version:all:rc:minor && npm run release:push",
|
"release:beta:minor": "npm run release:check && npm run version:all:beta:minor && npm run release:push",
|
||||||
"release:rc:major": "npm run release:check && npm run version:all:rc:major && npm run release:push",
|
"release:beta:major": "npm run release:check && npm run version:all:beta:major && npm run release:push",
|
||||||
"release:rc:next": "npm run release:check && npm run version:all:rc:next && npm run release:push",
|
"release:beta:next": "npm run release:check && npm run version:all:beta:next && npm run release:push",
|
||||||
"release:promote": "npm run release:check && npm run version:all:promote && npm run release:publish && npm run release:push",
|
"release:promote": "npm run release:check && npm run version:all:promote && npm run release:publish && npm run release:push",
|
||||||
"release:patch": "npm run release:check && npm run version:all:patch && npm run release:publish && npm run release:push",
|
"release:patch": "npm run release:check && npm run version:all:patch && npm run release:publish && npm run release:push",
|
||||||
"release:minor": "npm run release:check && npm run version:all:minor && npm run release:publish && npm run release:push",
|
"release:minor": "npm run release:check && npm run version:all:minor && npm run release:publish && npm run release:push",
|
||||||
|
|||||||
@@ -4,8 +4,12 @@ import { createTempGitRepo } from "./helpers/workspace";
|
|||||||
import {
|
import {
|
||||||
archiveAgentFromDaemon,
|
archiveAgentFromDaemon,
|
||||||
archiveAgentFromSessions,
|
archiveAgentFromSessions,
|
||||||
|
clickSessionRow,
|
||||||
|
closeWorkspaceAgentTab,
|
||||||
connectArchiveTabDaemonClient,
|
connectArchiveTabDaemonClient,
|
||||||
createIdleAgent,
|
createIdleAgent,
|
||||||
|
expectArchivedAgentFocused,
|
||||||
|
expectSessionRowArchived,
|
||||||
expectSessionRowVisible,
|
expectSessionRowVisible,
|
||||||
expectWorkspaceArchiveOutcome,
|
expectWorkspaceArchiveOutcome,
|
||||||
expectWorkspaceTabHidden,
|
expectWorkspaceTabHidden,
|
||||||
@@ -100,4 +104,26 @@ test.describe("Archive tab reconciliation", () => {
|
|||||||
await passivePage.close();
|
await passivePage.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("clicking an archived session reopens its closed tab focused", async ({ page }) => {
|
||||||
|
const archived = await createIdleAgent(client, {
|
||||||
|
cwd: tempRepo.path,
|
||||||
|
title: `reopen-archived-${randomUUID().slice(0, 8)}`,
|
||||||
|
});
|
||||||
|
const surviving = await createIdleAgent(client, {
|
||||||
|
cwd: tempRepo.path,
|
||||||
|
title: `reopen-control-${randomUUID().slice(0, 8)}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await resetSeededPageState(page);
|
||||||
|
await openWorkspaceWithAgents(page, [archived, surviving]);
|
||||||
|
await closeWorkspaceAgentTab(page, archived.id);
|
||||||
|
await archiveAgentFromDaemon(client, archived.id);
|
||||||
|
await openSessions(page);
|
||||||
|
await expectSessionRowArchived(page, archived.title);
|
||||||
|
|
||||||
|
await clickSessionRow(page, archived.title);
|
||||||
|
|
||||||
|
await expectArchivedAgentFocused(page, archived.id);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,11 +1,12 @@
|
|||||||
import { spawn, type ChildProcess, execFileSync, execSync } from "node:child_process";
|
import { spawn, type ChildProcess, execFileSync, execSync } from "node:child_process";
|
||||||
import { existsSync } from "node:fs";
|
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 { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import net from "node:net";
|
import net from "node:net";
|
||||||
import { Buffer } from "node:buffer";
|
import { Buffer } from "node:buffer";
|
||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
|
import { forkPaseoHomeMetadata, resolvePaseoHomePath } from "./helpers/paseo-home-fork";
|
||||||
|
|
||||||
type WaitForServerOptions = {
|
type WaitForServerOptions = {
|
||||||
host?: string;
|
host?: string;
|
||||||
@@ -182,8 +183,20 @@ async function isOpenAiApiKeyUsable(apiKey: string | undefined): Promise<boolean
|
|||||||
let daemonProcess: ChildProcess | null = null;
|
let daemonProcess: ChildProcess | null = null;
|
||||||
let metroProcess: ChildProcess | null = null;
|
let metroProcess: ChildProcess | null = null;
|
||||||
let paseoHome: string | null = null;
|
let paseoHome: string | null = null;
|
||||||
|
let fakeGhBinDir: string | null = null;
|
||||||
let relayProcess: ChildProcess | null = null;
|
let relayProcess: ChildProcess | null = null;
|
||||||
|
|
||||||
|
function resolveOptionalPaseoHomeEnv(value: string | undefined): string | null {
|
||||||
|
const trimmed = value?.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (trimmed === "current") {
|
||||||
|
return resolvePaseoHomePath("~/.paseo");
|
||||||
|
}
|
||||||
|
return resolvePaseoHomePath(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
type OfferPayload = {
|
type OfferPayload = {
|
||||||
v: 2;
|
v: 2;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
@@ -191,6 +204,52 @@ type OfferPayload = {
|
|||||||
relay: { endpoint: string };
|
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] === "pr" && args[1] === "view" && args[2] === "--json" && args[3]) {
|
||||||
|
console.error("no pull requests found for branch");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
function stripAnsi(input: string): string {
|
||||||
return input.replace(/\u001b\[[0-9;]*m/g, "");
|
return input.replace(/\u001b\[[0-9;]*m/g, "");
|
||||||
}
|
}
|
||||||
@@ -280,11 +339,36 @@ export default async function globalSetup() {
|
|||||||
const port = await getAvailablePort();
|
const port = await getAvailablePort();
|
||||||
let relayPort = 0;
|
let relayPort = 0;
|
||||||
const metroPort = await getAvailablePort();
|
const metroPort = await getAvailablePort();
|
||||||
paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-e2e-home-"));
|
const requestedPaseoHome = resolveOptionalPaseoHomeEnv(process.env.E2E_PASEO_HOME);
|
||||||
|
const shouldRemovePaseoHome = !requestedPaseoHome && process.env.E2E_KEEP_PASEO_HOME !== "1";
|
||||||
|
paseoHome = requestedPaseoHome ?? (await mkdtemp(path.join(tmpdir(), "paseo-e2e-home-")));
|
||||||
|
fakeGhBinDir = await createFakeGhBin();
|
||||||
let relayLineBuffer = createLineBuffer();
|
let relayLineBuffer = createLineBuffer();
|
||||||
const metroLineBuffer = createLineBuffer();
|
const metroLineBuffer = createLineBuffer();
|
||||||
const daemonLineBuffer = createLineBuffer();
|
const daemonLineBuffer = createLineBuffer();
|
||||||
|
|
||||||
|
const forkSourceHome = resolveOptionalPaseoHomeEnv(process.env.E2E_FORK_PASEO_HOME_FROM);
|
||||||
|
if (forkSourceHome) {
|
||||||
|
const forkResult = await forkPaseoHomeMetadata({
|
||||||
|
sourceHome: forkSourceHome,
|
||||||
|
targetHome: paseoHome,
|
||||||
|
});
|
||||||
|
process.env.E2E_FORK_SOURCE_PASEO_HOME = forkResult.sourceHome;
|
||||||
|
process.env.E2E_FORK_TARGET_PASEO_HOME = forkResult.targetHome;
|
||||||
|
process.env.E2E_FORK_COPIED_FILES = String(forkResult.copiedFiles);
|
||||||
|
process.env.E2E_FORK_COPIED_BYTES = String(forkResult.copiedBytes);
|
||||||
|
console.log(
|
||||||
|
`[e2e] Forked Paseo metadata from ${forkResult.sourceHome} to ${forkResult.targetHome} ` +
|
||||||
|
`(${forkResult.agentFiles} agent files, ${forkResult.projectFiles} project registry files, ` +
|
||||||
|
`${forkResult.copiedBytes} bytes)`,
|
||||||
|
);
|
||||||
|
if (forkResult.skippedMissing.length > 0) {
|
||||||
|
console.warn(
|
||||||
|
`[e2e] Paseo metadata fork skipped missing paths: ${forkResult.skippedMissing.join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const cleanup = async () => {
|
const cleanup = async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
stopProcess(daemonProcess),
|
stopProcess(daemonProcess),
|
||||||
@@ -294,9 +378,15 @@ export default async function globalSetup() {
|
|||||||
daemonProcess = null;
|
daemonProcess = null;
|
||||||
metroProcess = null;
|
metroProcess = null;
|
||||||
relayProcess = null;
|
relayProcess = null;
|
||||||
if (paseoHome) {
|
if (paseoHome && shouldRemovePaseoHome) {
|
||||||
await rm(paseoHome, { recursive: true, force: true });
|
await rm(paseoHome, { recursive: true, force: true });
|
||||||
paseoHome = null;
|
paseoHome = null;
|
||||||
|
} else if (paseoHome) {
|
||||||
|
console.log(`[e2e] Preserving PASEO_HOME: ${paseoHome}`);
|
||||||
|
}
|
||||||
|
if (fakeGhBinDir) {
|
||||||
|
await rm(fakeGhBinDir, { recursive: true, force: true });
|
||||||
|
fakeGhBinDir = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -482,6 +572,7 @@ export default async function globalSetup() {
|
|||||||
cwd: serverDir,
|
cwd: serverDir,
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
|
PATH: `${fakeGhBinDir}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||||
PASEO_HOME: paseoHome,
|
PASEO_HOME: paseoHome,
|
||||||
PASEO_SERVER_ID: "srv_e2e_test_daemon",
|
PASEO_SERVER_ID: "srv_e2e_test_daemon",
|
||||||
PASEO_LISTEN: `0.0.0.0:${port}`,
|
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> {
|
export async function expectWorkspaceTabVisible(page: Page, agentId: string): Promise<void> {
|
||||||
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`).first()).toBeVisible({
|
await expect(
|
||||||
timeout: 30_000,
|
page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }).first(),
|
||||||
});
|
).toBeVisible({ timeout: 30_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function expectWorkspaceTabHidden(page: Page, agentId: string): Promise<void> {
|
export async function expectWorkspaceTabHidden(page: Page, agentId: string): Promise<void> {
|
||||||
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`)).toHaveCount(0, {
|
await expect(
|
||||||
|
page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }),
|
||||||
|
).toHaveCount(0, {
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -229,6 +231,24 @@ export async function expectWorkspaceArchiveOutcome(
|
|||||||
await expectWorkspaceTabVisible(page, input.survivingAgentId);
|
await expectWorkspaceTabVisible(page, input.survivingAgentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function closeWorkspaceAgentTab(page: Page, agentId: string): Promise<void> {
|
||||||
|
const closeButton = page.getByTestId(`workspace-agent-close-${agentId}`).filter({
|
||||||
|
visible: true,
|
||||||
|
});
|
||||||
|
await expect(closeButton.first()).toBeVisible({ timeout: 30_000 });
|
||||||
|
await closeButton.first().click();
|
||||||
|
await expectWorkspaceTabHidden(page, agentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectArchivedAgentFocused(page: Page, agentId: string): Promise<void> {
|
||||||
|
await expectWorkspaceTabVisible(page, agentId);
|
||||||
|
await expect(
|
||||||
|
page.getByText("This agent is archived").filter({ visible: true }).first(),
|
||||||
|
).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function reloadWorkspace(page: Page, workspaceId: string): Promise<void> {
|
export async function reloadWorkspace(page: Page, workspaceId: string): Promise<void> {
|
||||||
const serverId = getServerId();
|
const serverId = getServerId();
|
||||||
await page.goto(buildHostWorkspaceRoute(serverId, workspaceId));
|
await page.goto(buildHostWorkspaceRoute(serverId, workspaceId));
|
||||||
@@ -259,6 +279,12 @@ export async function expectSessionRowArchived(page: Page, title: string): Promi
|
|||||||
await expect(getSessionRowByTitle(page, title)).toContainText("Archived", { timeout: 30_000 });
|
await expect(getSessionRowByTitle(page, title)).toContainText("Archived", { timeout: 30_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function clickSessionRow(page: Page, title: string): Promise<void> {
|
||||||
|
const row = getSessionRowByTitle(page, title);
|
||||||
|
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||||
|
await row.click();
|
||||||
|
}
|
||||||
|
|
||||||
export async function archiveAgentFromSessions(
|
export async function archiveAgentFromSessions(
|
||||||
page: Page,
|
page: Page,
|
||||||
input: { agentId: string; title: string },
|
input: { agentId: string; title: string },
|
||||||
|
|||||||
@@ -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. */
|
/** Wait for the workspace tab bar to be visible. */
|
||||||
export async function waitForTabBar(page: Page): Promise<void> {
|
export async function waitForTabBar(page: Page): Promise<void> {
|
||||||
await expect(page.getByTestId("workspace-tabs-row").first()).toBeVisible({
|
await expect(
|
||||||
|
page.getByTestId("workspace-tabs-row").filter({ visible: true }).first(),
|
||||||
|
).toBeVisible({
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Return all tab test IDs currently in the tab bar. */
|
/** Return all tab test IDs currently in the tab bar. */
|
||||||
export async function getTabTestIds(page: Page): Promise<string[]> {
|
export async function getTabTestIds(page: Page): Promise<string[]> {
|
||||||
const tabs = page.locator(
|
const tabs = page
|
||||||
'[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])',
|
.locator('[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])')
|
||||||
);
|
.filter({ visible: true });
|
||||||
const count = await tabs.count();
|
const count = await tabs.count();
|
||||||
const ids: string[] = [];
|
const ids: string[] = [];
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
@@ -55,6 +57,7 @@ export async function getActiveTabTestId(page: Page): Promise<string | null> {
|
|||||||
.locator(
|
.locator(
|
||||||
'[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])[aria-selected="true"]',
|
'[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])[aria-selected="true"]',
|
||||||
)
|
)
|
||||||
|
.filter({ visible: true })
|
||||||
.first();
|
.first();
|
||||||
if (await activeTab.isVisible().catch(() => false)) {
|
if (await activeTab.isVisible().catch(() => false)) {
|
||||||
return activeTab.getAttribute("data-testid");
|
return activeTab.getAttribute("data-testid");
|
||||||
@@ -67,14 +70,14 @@ export async function getActiveTabTestId(page: Page): Promise<string | null> {
|
|||||||
|
|
||||||
/** Click the new agent tab button in the tab bar. Creates a draft/chat tab directly. */
|
/** Click the new agent tab button in the tab bar. Creates a draft/chat tab directly. */
|
||||||
export async function clickNewTabButton(page: Page): Promise<void> {
|
export async function clickNewTabButton(page: Page): Promise<void> {
|
||||||
const button = page.getByTestId("workspace-new-agent-tab");
|
const button = page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first();
|
||||||
await expect(button).toBeVisible({ timeout: 10_000 });
|
await expect(button).toBeVisible({ timeout: 10_000 });
|
||||||
await button.click();
|
await button.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Click the new terminal button in the workspace tab bar. Creates a terminal tab directly. */
|
/** Click the new terminal button in the workspace tab bar. Creates a terminal tab directly. */
|
||||||
export async function clickNewTerminalButton(page: Page): Promise<void> {
|
export async function clickNewTerminalButton(page: Page): Promise<void> {
|
||||||
const button = page.getByTestId("workspace-new-terminal");
|
const button = page.getByTestId("workspace-new-terminal").filter({ visible: true }).first();
|
||||||
await expect(button).toBeVisible({ timeout: 10_000 });
|
await expect(button).toBeVisible({ timeout: 10_000 });
|
||||||
await button.click();
|
await button.click();
|
||||||
}
|
}
|
||||||
@@ -94,26 +97,30 @@ export async function waitForLauncherPanel(page: Page): Promise<void> {
|
|||||||
|
|
||||||
/** Assert the new agent tab button is visible in the tab bar. */
|
/** Assert the new agent tab button is visible in the tab bar. */
|
||||||
export async function assertNewChatTileVisible(page: Page): Promise<void> {
|
export async function assertNewChatTileVisible(page: Page): Promise<void> {
|
||||||
await expect(page.getByTestId("workspace-new-agent-tab").first()).toBeVisible();
|
await expect(
|
||||||
|
page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first(),
|
||||||
|
).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Assert the new terminal button is visible in the tab bar. */
|
/** Assert the new terminal button is visible in the tab bar. */
|
||||||
export async function assertTerminalTileVisible(page: Page): Promise<void> {
|
export async function assertTerminalTileVisible(page: Page): Promise<void> {
|
||||||
await expect(page.getByTestId("workspace-new-terminal").first()).toBeVisible();
|
await expect(
|
||||||
|
page.getByTestId("workspace-new-terminal").filter({ visible: true }).first(),
|
||||||
|
).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Tab creation actions ─────────────────────────────────────────────────
|
// ─── Tab creation actions ─────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Click the new agent tab button to create a draft/chat tab. */
|
/** Click the new agent tab button to create a draft/chat tab. */
|
||||||
export async function clickNewChat(page: Page): Promise<void> {
|
export async function clickNewChat(page: Page): Promise<void> {
|
||||||
const button = page.getByTestId("workspace-new-agent-tab");
|
const button = page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first();
|
||||||
await expect(button).toBeVisible({ timeout: 10_000 });
|
await expect(button).toBeVisible({ timeout: 10_000 });
|
||||||
await button.click();
|
await button.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Click the new terminal button to create a terminal tab. */
|
/** Click the new terminal button to create a terminal tab. */
|
||||||
export async function clickTerminal(page: Page): Promise<void> {
|
export async function clickTerminal(page: Page): Promise<void> {
|
||||||
const button = page.getByTestId("workspace-new-terminal");
|
const button = page.getByTestId("workspace-new-terminal").filter({ visible: true }).first();
|
||||||
await expect(button).toBeVisible({ timeout: 10_000 });
|
await expect(button).toBeVisible({ timeout: 10_000 });
|
||||||
await button.click();
|
await button.click();
|
||||||
}
|
}
|
||||||
@@ -131,13 +138,14 @@ export async function waitForTabWithTitle(
|
|||||||
page
|
page
|
||||||
.locator('[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])')
|
.locator('[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])')
|
||||||
.filter({ hasText: matcher })
|
.filter({ hasText: matcher })
|
||||||
|
.filter({ visible: true })
|
||||||
.first(),
|
.first(),
|
||||||
).toBeVisible({ timeout });
|
).toBeVisible({ timeout });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Assert the new agent tab button is visible in the tab bar. */
|
/** Assert the new agent tab button is visible in the tab bar. */
|
||||||
export async function assertSingleNewTabButton(page: Page): Promise<void> {
|
export async function assertSingleNewTabButton(page: Page): Promise<void> {
|
||||||
const buttons = page.getByTestId("workspace-new-agent-tab");
|
const buttons = page.getByTestId("workspace-new-agent-tab").filter({ visible: true });
|
||||||
const count = await buttons.count();
|
const count = await buttons.count();
|
||||||
expect(count).toBeGreaterThanOrEqual(1);
|
expect(count).toBeGreaterThanOrEqual(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ export async function createWorktreeViaDaemon(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clickNewWorkspaceButton(
|
export async function openNewWorkspaceComposer(
|
||||||
page: Page,
|
page: Page,
|
||||||
input: { projectKey: string; projectDisplayName: string },
|
input: { projectKey: string; projectDisplayName: string },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -168,7 +168,13 @@ export async function clickNewWorkspaceButton(
|
|||||||
await expect(page).toHaveURL(/\/h\/[^/]+\/new(?:\?.*)?$/, {
|
await expect(page).toHaveURL(/\/h\/[^/]+\/new(?:\?.*)?$/, {
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clickNewWorkspaceButton(
|
||||||
|
page: Page,
|
||||||
|
input: { projectKey: string; projectDisplayName: string },
|
||||||
|
): Promise<void> {
|
||||||
|
await openNewWorkspaceComposer(page, input);
|
||||||
const createButton = page
|
const createButton = page
|
||||||
.getByTestId("message-input-root")
|
.getByTestId("message-input-root")
|
||||||
.getByRole("button", { name: "Create" });
|
.getByRole("button", { name: "Create" });
|
||||||
@@ -176,6 +182,44 @@ export async function clickNewWorkspaceButton(
|
|||||||
await createButton.click();
|
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(
|
export async function assertNewWorkspaceSidebarAndHeader(
|
||||||
page: Page,
|
page: Page,
|
||||||
input: { serverId: string; previousWorkspaceId: string; projectDisplayName: string },
|
input: { serverId: string; previousWorkspaceId: string; projectDisplayName: string },
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { copyFile, mkdir, readdir, rm, stat } from "node:fs/promises";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export type PaseoHomeMetadataForkResult = {
|
||||||
|
sourceHome: string;
|
||||||
|
targetHome: string;
|
||||||
|
agentFiles: number;
|
||||||
|
agentBytes: number;
|
||||||
|
projectFiles: number;
|
||||||
|
projectBytes: number;
|
||||||
|
copiedFiles: number;
|
||||||
|
copiedBytes: number;
|
||||||
|
skippedMissing: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type CopyStats = {
|
||||||
|
files: number;
|
||||||
|
bytes: number;
|
||||||
|
skippedMissing: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function resolvePaseoHomePath(value: string): string {
|
||||||
|
if (value === "~") {
|
||||||
|
return homedir();
|
||||||
|
}
|
||||||
|
if (value.startsWith("~/")) {
|
||||||
|
return path.join(homedir(), value.slice(2));
|
||||||
|
}
|
||||||
|
return path.resolve(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyJsonTree(sourceDir: string, targetDir: string): Promise<CopyStats> {
|
||||||
|
if (!existsSync(sourceDir)) {
|
||||||
|
return { files: 0, bytes: 0, skippedMissing: [sourceDir] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats: CopyStats = { files: 0, bytes: 0, skippedMissing: [] };
|
||||||
|
const entries = await readdir(sourceDir, { withFileTypes: true });
|
||||||
|
await mkdir(targetDir, { recursive: true });
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const sourcePath = path.join(sourceDir, entry.name);
|
||||||
|
const targetPath = path.join(targetDir, entry.name);
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
const nested = await copyJsonTree(sourcePath, targetPath);
|
||||||
|
stats.files += nested.files;
|
||||||
|
stats.bytes += nested.bytes;
|
||||||
|
stats.skippedMissing.push(...nested.skippedMissing);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!entry.isFile() || !entry.name.endsWith(".json")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await mkdir(path.dirname(targetPath), { recursive: true });
|
||||||
|
await copyFile(sourcePath, targetPath);
|
||||||
|
const fileStat = await stat(sourcePath);
|
||||||
|
stats.files += 1;
|
||||||
|
stats.bytes += fileStat.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyProjectRegistryFiles(
|
||||||
|
sourceHome: string,
|
||||||
|
targetHome: string,
|
||||||
|
): Promise<CopyStats> {
|
||||||
|
const stats: CopyStats = { files: 0, bytes: 0, skippedMissing: [] };
|
||||||
|
const sourceProjectsDir = path.join(sourceHome, "projects");
|
||||||
|
const targetProjectsDir = path.join(targetHome, "projects");
|
||||||
|
await mkdir(targetProjectsDir, { recursive: true });
|
||||||
|
|
||||||
|
for (const fileName of ["projects.json", "workspaces.json"]) {
|
||||||
|
const sourcePath = path.join(sourceProjectsDir, fileName);
|
||||||
|
const targetPath = path.join(targetProjectsDir, fileName);
|
||||||
|
if (!existsSync(sourcePath)) {
|
||||||
|
stats.skippedMissing.push(sourcePath);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await copyFile(sourcePath, targetPath);
|
||||||
|
const fileStat = await stat(sourcePath);
|
||||||
|
stats.files += 1;
|
||||||
|
stats.bytes += fileStat.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function forkPaseoHomeMetadata(input: {
|
||||||
|
sourceHome: string;
|
||||||
|
targetHome: string;
|
||||||
|
}): Promise<PaseoHomeMetadataForkResult> {
|
||||||
|
const sourceHome = resolvePaseoHomePath(input.sourceHome);
|
||||||
|
const targetHome = resolvePaseoHomePath(input.targetHome);
|
||||||
|
|
||||||
|
if (sourceHome === targetHome) {
|
||||||
|
throw new Error("Refusing to fork Paseo metadata onto the same PASEO_HOME.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await mkdir(targetHome, { recursive: true });
|
||||||
|
|
||||||
|
// Reset only the copied metadata surface. In particular, do not copy or remove
|
||||||
|
// worktrees here: forked workspace records should continue to point at the
|
||||||
|
// original checkout/worktree paths from the source home.
|
||||||
|
await rm(path.join(targetHome, "agents"), { recursive: true, force: true });
|
||||||
|
await rm(path.join(targetHome, "projects", "projects.json"), { force: true });
|
||||||
|
await rm(path.join(targetHome, "projects", "workspaces.json"), { force: true });
|
||||||
|
|
||||||
|
const agents = await copyJsonTree(
|
||||||
|
path.join(sourceHome, "agents"),
|
||||||
|
path.join(targetHome, "agents"),
|
||||||
|
);
|
||||||
|
const projects = await copyProjectRegistryFiles(sourceHome, targetHome);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sourceHome,
|
||||||
|
targetHome,
|
||||||
|
agentFiles: agents.files,
|
||||||
|
agentBytes: agents.bytes,
|
||||||
|
projectFiles: projects.files,
|
||||||
|
projectBytes: projects.bytes,
|
||||||
|
copiedFiles: agents.files + projects.files,
|
||||||
|
copiedBytes: agents.bytes + projects.bytes,
|
||||||
|
skippedMissing: [...agents.skippedMissing, ...projects.skippedMissing],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import { expect, type Page } from "../fixtures";
|
||||||
|
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
|
||||||
|
|
||||||
|
const DISABLE_DEFAULT_SEED_ONCE_KEY = "@paseo:e2e-disable-default-seed-once";
|
||||||
|
const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce";
|
||||||
|
const REGISTRY_KEY = "@paseo:daemon-registry";
|
||||||
|
const E2E_KEY = "@paseo:e2e";
|
||||||
|
|
||||||
|
type SavedHostInput = {
|
||||||
|
serverId: string;
|
||||||
|
label: string;
|
||||||
|
endpoint: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function startupScenario(page: Page) {
|
||||||
|
return new StartupScenario(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
class StartupScenario {
|
||||||
|
private readonly page: Page;
|
||||||
|
private savedHosts: SavedHostInput[] = [];
|
||||||
|
private desktopBridge = false;
|
||||||
|
private blockedEndpointPorts = new Set<string>();
|
||||||
|
private viewport: { width: number; height: number } | null = null;
|
||||||
|
|
||||||
|
constructor(page: Page) {
|
||||||
|
this.page = page;
|
||||||
|
}
|
||||||
|
|
||||||
|
withMobileViewport(): this {
|
||||||
|
this.viewport = { width: 390, height: 844 };
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
withSavedHost(input: SavedHostInput): this {
|
||||||
|
this.savedHosts.push(input);
|
||||||
|
const port = input.endpoint.match(/:(\d+)$/)?.[1];
|
||||||
|
if (port) {
|
||||||
|
this.blockedEndpointPorts.add(port);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
withPendingDesktopDaemon(): this {
|
||||||
|
this.desktopBridge = true;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
withBlockedPort(port: string): this {
|
||||||
|
this.blockedEndpointPorts.add(port);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
async openRoot(): Promise<StartupAssertions> {
|
||||||
|
await this.prepare();
|
||||||
|
await this.page.goto("/");
|
||||||
|
return new StartupAssertions(this.page);
|
||||||
|
}
|
||||||
|
|
||||||
|
async openHostWorkspace(input: {
|
||||||
|
serverId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
}): Promise<StartupAssertions> {
|
||||||
|
await this.prepare();
|
||||||
|
await this.page.goto(
|
||||||
|
`/h/${encodeURIComponent(input.serverId)}/workspace/${encodeURIComponent(input.workspaceId)}`,
|
||||||
|
);
|
||||||
|
return new StartupAssertions(this.page);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async prepare(): Promise<void> {
|
||||||
|
if (this.viewport) {
|
||||||
|
await this.page.setViewportSize(this.viewport);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.desktopBridge) {
|
||||||
|
await installPendingDesktopBridge(this.page);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const port of this.blockedEndpointPorts) {
|
||||||
|
await this.page.routeWebSocket(new RegExp(`:${escapeRegex(port)}\\b`), async (ws) => {
|
||||||
|
await ws.close({ code: 1008, reason: "Blocked unreachable startup test host." });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.savedHosts.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Let the shared fixture create its seed nonce, then opt out of that seed for
|
||||||
|
// the next navigation so this scenario owns the stored host registry.
|
||||||
|
await this.page.goto("/");
|
||||||
|
const nowIso = new Date().toISOString();
|
||||||
|
const registry = this.savedHosts.map((host) =>
|
||||||
|
buildStoredHost({
|
||||||
|
serverId: host.serverId,
|
||||||
|
endpoint: host.endpoint,
|
||||||
|
label: host.label,
|
||||||
|
nowIso,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const firstHost = registry[0];
|
||||||
|
if (!firstHost) {
|
||||||
|
throw new Error("Expected at least one startup test host.");
|
||||||
|
}
|
||||||
|
const createAgentPreferences = buildStoredCreateAgentPreferences(firstHost.serverId);
|
||||||
|
|
||||||
|
await this.page.evaluate(
|
||||||
|
({ keys, registry, createAgentPreferences }) => {
|
||||||
|
const nonce = localStorage.getItem(keys.seedNonce);
|
||||||
|
if (!nonce) {
|
||||||
|
throw new Error("Expected e2e seed nonce before overriding startup registry.");
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem(keys.e2e, "1");
|
||||||
|
localStorage.setItem(keys.registry, JSON.stringify(registry));
|
||||||
|
localStorage.setItem(
|
||||||
|
"@paseo:create-agent-preferences",
|
||||||
|
JSON.stringify(createAgentPreferences),
|
||||||
|
);
|
||||||
|
localStorage.setItem(keys.disableDefaultSeedOnce, nonce);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keys: {
|
||||||
|
disableDefaultSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY,
|
||||||
|
e2e: E2E_KEY,
|
||||||
|
registry: REGISTRY_KEY,
|
||||||
|
seedNonce: SEED_NONCE_KEY,
|
||||||
|
},
|
||||||
|
registry,
|
||||||
|
createAgentPreferences,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class StartupAssertions {
|
||||||
|
private readonly page: Page;
|
||||||
|
|
||||||
|
constructor(page: Page) {
|
||||||
|
this.page = page;
|
||||||
|
}
|
||||||
|
|
||||||
|
async expectsReconnectWelcome(): Promise<this> {
|
||||||
|
await expect(this.page.getByTestId("welcome-screen")).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(this.page.getByText("Connecting…", { exact: true })).toBeVisible();
|
||||||
|
await expect(this.page.getByTestId("welcome-open-settings")).toBeVisible();
|
||||||
|
await expect(this.page.getByTestId("welcome-direct-connection")).toBeVisible();
|
||||||
|
await expect(this.page.getByTestId("welcome-paste-pairing-link")).toBeVisible();
|
||||||
|
await expect(this.page.getByTestId("welcome-scan-qr")).toHaveCount(0);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
async expectsNoSavedHostStatus(input: { label: string }): Promise<this> {
|
||||||
|
await expect(this.page.getByText(input.label, { exact: true })).toHaveCount(0);
|
||||||
|
await expect(this.page.getByText("Connection error", { exact: true })).toHaveCount(0);
|
||||||
|
await expect(this.page.getByText("Offline", { exact: true })).toHaveCount(0);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
async expectsNoLocalServerStartupCopy(): Promise<this> {
|
||||||
|
await expect(this.page.getByText("Starting local server...", { exact: true })).toHaveCount(0);
|
||||||
|
await expect(this.page.getByText("Connecting to local server...", { exact: true })).toHaveCount(
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
async expectsDesktopDaemonStartup(): Promise<this> {
|
||||||
|
await expect(this.page.getByText("Starting local server...", { exact: true })).toBeVisible({
|
||||||
|
timeout: 15_000,
|
||||||
|
});
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
async expectsSidebarHidden(): Promise<this> {
|
||||||
|
await expect(this.page.locator('[data-testid="sidebar-settings"]:visible')).toHaveCount(0);
|
||||||
|
await expect(this.page.locator('[data-testid="sidebar-project-list"]:visible')).toHaveCount(0);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
async expectsNoUndefinedRoute(): Promise<this> {
|
||||||
|
await expect(this.page).not.toHaveURL(/\/h\/undefined\/workspace\/undefined/);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installPendingDesktopBridge(page: Page): Promise<void> {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = {
|
||||||
|
platform: "darwin",
|
||||||
|
invoke: async (command: string) => {
|
||||||
|
if (command === "start_desktop_daemon") {
|
||||||
|
await new Promise(() => {
|
||||||
|
// Keep the daemon in the startup phase until the test ends.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (command === "desktop_daemon_status") {
|
||||||
|
return {
|
||||||
|
serverId: "srv_desktop_pending",
|
||||||
|
status: "starting",
|
||||||
|
listen: null,
|
||||||
|
hostname: null,
|
||||||
|
pid: null,
|
||||||
|
home: "",
|
||||||
|
version: null,
|
||||||
|
desktopManaged: true,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (command === "desktop_daemon_logs") {
|
||||||
|
return { logPath: "", contents: "" };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
getPendingOpenProject: async () => null,
|
||||||
|
events: { on: async () => () => undefined },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStoredHost(input: {
|
||||||
|
serverId: string;
|
||||||
|
endpoint: string;
|
||||||
|
label: string;
|
||||||
|
nowIso: string;
|
||||||
|
}) {
|
||||||
|
return buildSeededHost(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStoredCreateAgentPreferences(serverId: string) {
|
||||||
|
return buildCreateAgentPreferences(serverId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegex(value: string): string {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import type { Page } from "@playwright/test";
|
||||||
|
import { createTempGitRepo } from "./workspace";
|
||||||
|
import {
|
||||||
|
connectTerminalClient,
|
||||||
|
navigateToTerminal,
|
||||||
|
setupDeterministicPrompt,
|
||||||
|
type TerminalPerfDaemonClient,
|
||||||
|
} from "./terminal-perf";
|
||||||
|
|
||||||
|
type TempRepo = {
|
||||||
|
path: string;
|
||||||
|
cleanup: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TerminalInstance = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
cwd: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class TerminalE2EHarness {
|
||||||
|
readonly client: TerminalPerfDaemonClient;
|
||||||
|
readonly tempRepo: TempRepo;
|
||||||
|
readonly workspaceId: string;
|
||||||
|
|
||||||
|
private constructor(input: {
|
||||||
|
client: TerminalPerfDaemonClient;
|
||||||
|
tempRepo: TempRepo;
|
||||||
|
workspaceId: string;
|
||||||
|
}) {
|
||||||
|
this.client = input.client;
|
||||||
|
this.tempRepo = input.tempRepo;
|
||||||
|
this.workspaceId = input.workspaceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async create(input: { tempPrefix: string }): Promise<TerminalE2EHarness> {
|
||||||
|
const tempRepo = await createTempGitRepo(input.tempPrefix);
|
||||||
|
const client = await connectTerminalClient();
|
||||||
|
const seedResult = await client.openProject(tempRepo.path);
|
||||||
|
if (!seedResult.workspace) {
|
||||||
|
await client.close().catch(() => {});
|
||||||
|
await tempRepo.cleanup().catch(() => {});
|
||||||
|
throw new Error(seedResult.error ?? "Failed to seed workspace");
|
||||||
|
}
|
||||||
|
return new TerminalE2EHarness({
|
||||||
|
client,
|
||||||
|
tempRepo,
|
||||||
|
workspaceId: seedResult.workspace.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async cleanup(): Promise<void> {
|
||||||
|
await this.client.close().catch(() => {});
|
||||||
|
await this.tempRepo.cleanup().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createTerminal(input: { name: string }): Promise<TerminalInstance> {
|
||||||
|
const result = await this.client.createTerminal(this.tempRepo.path, input.name);
|
||||||
|
if (!result.terminal) {
|
||||||
|
throw new Error(`Failed to create terminal: ${result.error}`);
|
||||||
|
}
|
||||||
|
return result.terminal;
|
||||||
|
}
|
||||||
|
|
||||||
|
async killTerminal(terminalId: string): Promise<void> {
|
||||||
|
await this.client.killTerminal(terminalId).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async openTerminal(page: Page, input: { terminalId: string }): Promise<void> {
|
||||||
|
await navigateToTerminal(page, {
|
||||||
|
workspaceId: this.workspaceId,
|
||||||
|
terminalId: input.terminalId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
terminalSurface(page: Page) {
|
||||||
|
return page.locator('[data-testid="terminal-surface"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
async setupPrompt(page: Page, sentinel?: string): Promise<void> {
|
||||||
|
await setupDeterministicPrompt(page, sentinel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function withTerminalInApp<T>(
|
||||||
|
page: Page,
|
||||||
|
harness: TerminalE2EHarness,
|
||||||
|
input: { name: string },
|
||||||
|
fn: (terminal: TerminalInstance) => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
const terminal = await harness.createTerminal({ name: input.name });
|
||||||
|
try {
|
||||||
|
await harness.openTerminal(page, { terminalId: terminal.id });
|
||||||
|
return await fn(terminal);
|
||||||
|
} finally {
|
||||||
|
await harness.killTerminal(terminal.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,13 @@ export type TerminalPerfDaemonClient = {
|
|||||||
terminal: { id: string; name: string; cwd: string } | null;
|
terminal: { id: string; name: string; cwd: string } | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}>;
|
}>;
|
||||||
|
createAgent(options: {
|
||||||
|
provider: string;
|
||||||
|
cwd: string;
|
||||||
|
title?: string;
|
||||||
|
modeId?: string;
|
||||||
|
}): Promise<{ id: string; status: string }>;
|
||||||
|
sendAgentMessage(agentId: string, text: string): Promise<void>;
|
||||||
subscribeTerminal(
|
subscribeTerminal(
|
||||||
terminalId: string,
|
terminalId: string,
|
||||||
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
|
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
|
||||||
|
|||||||
@@ -0,0 +1,680 @@
|
|||||||
|
import type { Page } from "@playwright/test";
|
||||||
|
|
||||||
|
export type TerminalRenderProbeSnapshot = {
|
||||||
|
setCount: number;
|
||||||
|
unsetCount: number;
|
||||||
|
writeCount: number;
|
||||||
|
resetWrites: number;
|
||||||
|
clearScreenWrites: number;
|
||||||
|
altEnterWrites: number;
|
||||||
|
altExitWrites: number;
|
||||||
|
events: TerminalRenderProbeEvent[];
|
||||||
|
frames: TerminalFrame[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TerminalRenderProbeEvent = {
|
||||||
|
at: number;
|
||||||
|
type: "set" | "unset" | "reset-write" | "clear-write" | "alt-enter-write" | "alt-exit-write";
|
||||||
|
preview?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TerminalFrame = {
|
||||||
|
at: number;
|
||||||
|
rowCount: number;
|
||||||
|
nonEmptyRows: number;
|
||||||
|
firstNonEmptyRow: number | null;
|
||||||
|
text: string;
|
||||||
|
topText: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TerminalRenderProbeSummary = Omit<TerminalRenderProbeSnapshot, "frames"> & {
|
||||||
|
frameCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TerminalKeystrokeStressReport = {
|
||||||
|
inputTextLength: number;
|
||||||
|
keydownCount: number;
|
||||||
|
inputFrameCount: number;
|
||||||
|
outputFrameCount: number;
|
||||||
|
textMessageFrameCount: number;
|
||||||
|
textMessagePayloadBytes: number;
|
||||||
|
largeTextMessageCount: number;
|
||||||
|
largestTextMessageBytes: number;
|
||||||
|
agentStreamTextMessageCount: number;
|
||||||
|
agentStreamTextMessagePayloadBytes: number;
|
||||||
|
largeAgentStreamTextMessageCount: number;
|
||||||
|
largestAgentStreamTextMessageBytes: number;
|
||||||
|
appEventCount: number;
|
||||||
|
appEventCounts: Record<string, number>;
|
||||||
|
runtimeMaxQueueDepth: number;
|
||||||
|
xtermWriteCount: number;
|
||||||
|
inputFramePayloadBytes: number;
|
||||||
|
outputFramePayloadBytes: number;
|
||||||
|
keydownToInputFrameMs: LatencyStats | null;
|
||||||
|
inputFrameToOutputFrameMs: LatencyStats | null;
|
||||||
|
appBinaryReceivedToFrameDecodedMs: LatencyStats | null;
|
||||||
|
appFrameDecodedToTerminalEmitMs: LatencyStats | null;
|
||||||
|
appTerminalEmitListenerDurationMs: LatencyStats | null;
|
||||||
|
appTerminalEmitToStreamControllerOutputMs: LatencyStats | null;
|
||||||
|
appStreamControllerDecodeToOnOutputMs: LatencyStats | null;
|
||||||
|
appStreamControllerToEmulatorWriteMs: LatencyStats | null;
|
||||||
|
appEmulatorWriteToRuntimeEnqueuedMs: LatencyStats | null;
|
||||||
|
appRuntimeEnqueuedToOperationStartMs: LatencyStats | null;
|
||||||
|
appRuntimeOperationStartToXtermWriteMs: LatencyStats | null;
|
||||||
|
appRuntimeXtermWriteToCommitMs: LatencyStats | null;
|
||||||
|
appBinaryReceivedToRuntimeEnqueuedMs: LatencyStats | null;
|
||||||
|
appBinaryReceivedToRuntimeOperationStartMs: LatencyStats | null;
|
||||||
|
appBinaryReceivedToXtermCommitMs: LatencyStats | null;
|
||||||
|
outputFrameToXtermWriteMs: LatencyStats | null;
|
||||||
|
xtermWriteDurationMs: LatencyStats | null;
|
||||||
|
keydownToXtermCommitMs: LatencyStats | null;
|
||||||
|
firstKeydownAt: number | null;
|
||||||
|
lastXtermCommitAt: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LatencyStats = {
|
||||||
|
count: number;
|
||||||
|
minMs: number;
|
||||||
|
p50Ms: number;
|
||||||
|
p95Ms: number;
|
||||||
|
maxMs: number;
|
||||||
|
avgMs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function installTerminalRenderProbe(page: Page): Promise<void> {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
type ProbeState = {
|
||||||
|
term: any;
|
||||||
|
setCount: number;
|
||||||
|
unsetCount: number;
|
||||||
|
writeCount: number;
|
||||||
|
resetWrites: number;
|
||||||
|
clearScreenWrites: number;
|
||||||
|
altEnterWrites: number;
|
||||||
|
altExitWrites: number;
|
||||||
|
events: TerminalRenderProbeEvent[];
|
||||||
|
frames: TerminalFrame[];
|
||||||
|
rafId: number | null;
|
||||||
|
sampleUntil: number;
|
||||||
|
reset: () => void;
|
||||||
|
snapshot: () => TerminalRenderProbeSnapshot;
|
||||||
|
startSampling: (durationMs: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const win = window as any;
|
||||||
|
const existingDescriptor = Object.getOwnPropertyDescriptor(win, "__paseoTerminal");
|
||||||
|
const getExisting = () =>
|
||||||
|
existingDescriptor?.get ? existingDescriptor.get.call(win) : existingDescriptor?.value;
|
||||||
|
|
||||||
|
const probe: ProbeState = {
|
||||||
|
term: getExisting(),
|
||||||
|
setCount: 0,
|
||||||
|
unsetCount: 0,
|
||||||
|
writeCount: 0,
|
||||||
|
resetWrites: 0,
|
||||||
|
clearScreenWrites: 0,
|
||||||
|
altEnterWrites: 0,
|
||||||
|
altExitWrites: 0,
|
||||||
|
events: [],
|
||||||
|
frames: [],
|
||||||
|
rafId: null,
|
||||||
|
sampleUntil: 0,
|
||||||
|
reset() {
|
||||||
|
this.setCount = 0;
|
||||||
|
this.unsetCount = 0;
|
||||||
|
this.writeCount = 0;
|
||||||
|
this.resetWrites = 0;
|
||||||
|
this.clearScreenWrites = 0;
|
||||||
|
this.altEnterWrites = 0;
|
||||||
|
this.altExitWrites = 0;
|
||||||
|
this.events = [];
|
||||||
|
this.frames = [];
|
||||||
|
},
|
||||||
|
snapshot() {
|
||||||
|
return {
|
||||||
|
setCount: this.setCount,
|
||||||
|
unsetCount: this.unsetCount,
|
||||||
|
writeCount: this.writeCount,
|
||||||
|
resetWrites: this.resetWrites,
|
||||||
|
clearScreenWrites: this.clearScreenWrites,
|
||||||
|
altEnterWrites: this.altEnterWrites,
|
||||||
|
altExitWrites: this.altExitWrites,
|
||||||
|
events: this.events,
|
||||||
|
frames: this.frames,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
startSampling(durationMs: number) {
|
||||||
|
this.sampleUntil = performance.now() + durationMs;
|
||||||
|
if (this.rafId !== null) {
|
||||||
|
cancelAnimationFrame(this.rafId);
|
||||||
|
}
|
||||||
|
const sample = () => {
|
||||||
|
const rows = Array.from(document.querySelectorAll(".xterm-rows > div")).map(
|
||||||
|
(row) => row.textContent ?? "",
|
||||||
|
);
|
||||||
|
const nonEmptyRows = rows.filter((row) => row.trim().length > 0);
|
||||||
|
const firstNonEmptyRow = rows.findIndex((row) => row.trim().length > 0);
|
||||||
|
this.frames.push({
|
||||||
|
at: performance.now(),
|
||||||
|
rowCount: rows.length,
|
||||||
|
nonEmptyRows: nonEmptyRows.length,
|
||||||
|
firstNonEmptyRow: firstNonEmptyRow === -1 ? null : firstNonEmptyRow,
|
||||||
|
text: rows.join("\n"),
|
||||||
|
topText: rows.slice(0, 3).join("\n"),
|
||||||
|
});
|
||||||
|
if (performance.now() < this.sampleUntil) {
|
||||||
|
this.rafId = requestAnimationFrame(sample);
|
||||||
|
} else {
|
||||||
|
this.rafId = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.rafId = requestAnimationFrame(sample);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.defineProperty(win, "__terminalRenderProbe", {
|
||||||
|
configurable: true,
|
||||||
|
value: probe,
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(win, "__paseoTerminal", {
|
||||||
|
configurable: true,
|
||||||
|
get() {
|
||||||
|
return probe.term;
|
||||||
|
},
|
||||||
|
set(next: any) {
|
||||||
|
if (next === undefined) {
|
||||||
|
probe.unsetCount += 1;
|
||||||
|
probe.events.push({ at: performance.now(), type: "unset" });
|
||||||
|
probe.term = next;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
probe.setCount += 1;
|
||||||
|
probe.events.push({ at: performance.now(), type: "set" });
|
||||||
|
probe.term = next;
|
||||||
|
|
||||||
|
if (next?.write && !next.__paseoRenderProbeWriteWrapped) {
|
||||||
|
const originalWrite = next.write.bind(next);
|
||||||
|
next.write = (data: string | Uint8Array, callback?: () => void) => {
|
||||||
|
const text =
|
||||||
|
typeof data === "string" ? data : new TextDecoder().decode(data as Uint8Array);
|
||||||
|
probe.writeCount += 1;
|
||||||
|
const preview = text
|
||||||
|
.replace(/\u001b/g, "\\x1b")
|
||||||
|
.replace(/\r/g, "\\r")
|
||||||
|
.replace(/\n/g, "\\n")
|
||||||
|
.slice(0, 160);
|
||||||
|
if (text.includes("\u001bc")) {
|
||||||
|
probe.resetWrites += 1;
|
||||||
|
probe.events.push({ at: performance.now(), type: "reset-write", preview });
|
||||||
|
}
|
||||||
|
if (text.includes("\u001b[2J")) {
|
||||||
|
probe.clearScreenWrites += 1;
|
||||||
|
probe.events.push({ at: performance.now(), type: "clear-write", preview });
|
||||||
|
}
|
||||||
|
if (text.includes("\u001b[?1049h")) {
|
||||||
|
probe.altEnterWrites += 1;
|
||||||
|
probe.events.push({ at: performance.now(), type: "alt-enter-write", preview });
|
||||||
|
}
|
||||||
|
if (text.includes("\u001b[?1049l")) {
|
||||||
|
probe.altExitWrites += 1;
|
||||||
|
probe.events.push({ at: performance.now(), type: "alt-exit-write", preview });
|
||||||
|
}
|
||||||
|
return originalWrite(data, callback);
|
||||||
|
};
|
||||||
|
next.__paseoRenderProbeWriteWrapped = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetTerminalRenderProbe(page: Page): Promise<void> {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
(window as any).__terminalRenderProbe.reset();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startTerminalFrameSampling(page: Page, durationMs = 2500): Promise<void> {
|
||||||
|
await page.evaluate((ms) => {
|
||||||
|
(window as any).__terminalRenderProbe.startSampling(ms);
|
||||||
|
}, durationMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readTerminalRenderProbe(page: Page): Promise<TerminalRenderProbeSnapshot> {
|
||||||
|
return page.evaluate(() => (window as any).__terminalRenderProbe.snapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function terminalVisibleText(page: Page): Promise<string> {
|
||||||
|
return page.evaluate(() =>
|
||||||
|
Array.from(document.querySelectorAll(".xterm-rows > div"))
|
||||||
|
.map((row) => row.textContent ?? "")
|
||||||
|
.join("\n"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeTerminalRenderProbe(
|
||||||
|
probe: TerminalRenderProbeSnapshot,
|
||||||
|
): TerminalRenderProbeSummary {
|
||||||
|
return {
|
||||||
|
setCount: probe.setCount,
|
||||||
|
unsetCount: probe.unsetCount,
|
||||||
|
writeCount: probe.writeCount,
|
||||||
|
resetWrites: probe.resetWrites,
|
||||||
|
clearScreenWrites: probe.clearScreenWrites,
|
||||||
|
altEnterWrites: probe.altEnterWrites,
|
||||||
|
altExitWrites: probe.altExitWrites,
|
||||||
|
events: probe.events,
|
||||||
|
frameCount: probe.frames.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function installTerminalKeystrokeStressProbe(page: Page): Promise<void> {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
type TimedTextEvent = {
|
||||||
|
at: number;
|
||||||
|
text: string;
|
||||||
|
bytes: number;
|
||||||
|
};
|
||||||
|
type TimedTextMessageEvent = {
|
||||||
|
at: number;
|
||||||
|
bytes: number;
|
||||||
|
kind: string | null;
|
||||||
|
};
|
||||||
|
type XtermWriteEvent = {
|
||||||
|
at: number;
|
||||||
|
committedAt: number | null;
|
||||||
|
text: string;
|
||||||
|
bytes: number;
|
||||||
|
};
|
||||||
|
type AppProbeEvent = {
|
||||||
|
type: string;
|
||||||
|
at: number;
|
||||||
|
bytes?: number;
|
||||||
|
queueDepth?: number;
|
||||||
|
};
|
||||||
|
type StressProbeState = {
|
||||||
|
keydowns: Array<{ at: number; key: string }>;
|
||||||
|
inputFrames: TimedTextEvent[];
|
||||||
|
outputFrames: TimedTextEvent[];
|
||||||
|
textMessageFrames: TimedTextMessageEvent[];
|
||||||
|
xtermWrites: XtermWriteEvent[];
|
||||||
|
appEvents: AppProbeEvent[];
|
||||||
|
reset: () => void;
|
||||||
|
report: (inputText: string) => TerminalKeystrokeStressReport;
|
||||||
|
};
|
||||||
|
|
||||||
|
const INPUT_OPCODE = 0x02;
|
||||||
|
const OUTPUT_OPCODE = 0x01;
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
|
||||||
|
function bytesFrom(data: unknown): Uint8Array | null {
|
||||||
|
if (data instanceof Uint8Array) return data;
|
||||||
|
if (data instanceof ArrayBuffer) return new Uint8Array(data);
|
||||||
|
if (ArrayBuffer.isView(data)) {
|
||||||
|
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventDataBytes(data: unknown): Promise<Uint8Array | null> {
|
||||||
|
const bytes = bytesFrom(data);
|
||||||
|
if (bytes) return Promise.resolve(bytes);
|
||||||
|
if (typeof Blob !== "undefined" && data instanceof Blob) {
|
||||||
|
return data.arrayBuffer().then((buffer) => new Uint8Array(buffer));
|
||||||
|
}
|
||||||
|
return Promise.resolve(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function frameText(bytes: Uint8Array): string {
|
||||||
|
return decoder.decode(bytes.slice(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventDataText(data: unknown): Promise<string | null> {
|
||||||
|
if (typeof data === "string") {
|
||||||
|
return Promise.resolve(data);
|
||||||
|
}
|
||||||
|
if (typeof Blob !== "undefined" && data instanceof Blob) {
|
||||||
|
return data.text();
|
||||||
|
}
|
||||||
|
return Promise.resolve(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function textMessageKind(text: string): string | null {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text) as {
|
||||||
|
type?: unknown;
|
||||||
|
message?: {
|
||||||
|
type?: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
if (typeof parsed.type !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof parsed.message?.type === "string") {
|
||||||
|
return `${parsed.type}:${parsed.message.type}`;
|
||||||
|
}
|
||||||
|
return parsed.type;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarize(values: number[]): LatencyStats | null {
|
||||||
|
if (values.length === 0) return null;
|
||||||
|
const sorted = [...values].sort((a, b) => a - b);
|
||||||
|
const percentile = (p: number) => {
|
||||||
|
const index = Math.ceil((p / 100) * sorted.length) - 1;
|
||||||
|
return sorted[Math.max(0, Math.min(sorted.length - 1, index))];
|
||||||
|
};
|
||||||
|
const total = values.reduce((sum, value) => sum + value, 0);
|
||||||
|
const round2 = (value: number) => Math.round(value * 100) / 100;
|
||||||
|
return {
|
||||||
|
count: values.length,
|
||||||
|
minMs: round2(sorted[0] ?? 0),
|
||||||
|
p50Ms: round2(percentile(50)),
|
||||||
|
p95Ms: round2(percentile(95)),
|
||||||
|
maxMs: round2(sorted[sorted.length - 1] ?? 0),
|
||||||
|
avgMs: round2(total / values.length),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstAtOrAfter<T extends { at: number }>(events: T[], at: number): T | null {
|
||||||
|
return events.find((event) => event.at >= at) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstCommitAtOrAfter(events: XtermWriteEvent[], at: number): XtermWriteEvent | null {
|
||||||
|
return (
|
||||||
|
events.find((event) => typeof event.committedAt === "number" && event.committedAt >= at) ??
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function countByType(events: AppProbeEvent[]): Record<string, number> {
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
for (const event of events) {
|
||||||
|
counts[event.type] = (counts[event.type] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appEventsOf(type: string, events: AppProbeEvent[]): AppProbeEvent[] {
|
||||||
|
return events.filter((event) => event.type === type);
|
||||||
|
}
|
||||||
|
|
||||||
|
function latencyByIndex(from: AppProbeEvent[], to: AppProbeEvent[]): number[] {
|
||||||
|
const count = Math.min(from.length, to.length);
|
||||||
|
const values: number[] = [];
|
||||||
|
for (let index = 0; index < count; index += 1) {
|
||||||
|
values.push(to[index]!.at - from[index]!.at);
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
const probe: StressProbeState = {
|
||||||
|
keydowns: [],
|
||||||
|
inputFrames: [],
|
||||||
|
outputFrames: [],
|
||||||
|
textMessageFrames: [],
|
||||||
|
xtermWrites: [],
|
||||||
|
appEvents: [],
|
||||||
|
reset() {
|
||||||
|
this.keydowns = [];
|
||||||
|
this.inputFrames = [];
|
||||||
|
this.outputFrames = [];
|
||||||
|
this.textMessageFrames = [];
|
||||||
|
this.xtermWrites = [];
|
||||||
|
this.appEvents = [];
|
||||||
|
},
|
||||||
|
report(inputText: string) {
|
||||||
|
const binaryReceived = appEventsOf("daemon-client-binary-received", this.appEvents);
|
||||||
|
const frameDecoded = appEventsOf("daemon-client-frame-decoded", this.appEvents);
|
||||||
|
const terminalEmit = appEventsOf("daemon-client-terminal-emit", this.appEvents);
|
||||||
|
const terminalEmitted = appEventsOf("daemon-client-terminal-emitted", this.appEvents);
|
||||||
|
const streamControllerOutput = appEventsOf("stream-controller-output", this.appEvents);
|
||||||
|
const streamControllerOnOutput = appEventsOf("stream-controller-on-output", this.appEvents);
|
||||||
|
const emulatorWriteOutput = appEventsOf("terminal-emulator-write-output", this.appEvents);
|
||||||
|
const runtimeWriteEnqueued = appEventsOf("runtime-write-enqueued", this.appEvents);
|
||||||
|
const runtimeOperationStart = appEventsOf("runtime-operation-start", this.appEvents);
|
||||||
|
const runtimeXtermWrite = appEventsOf("runtime-xterm-write", this.appEvents);
|
||||||
|
const runtimeXtermCommitted = appEventsOf("runtime-xterm-committed", this.appEvents);
|
||||||
|
const keydownToInputFrame = this.keydowns
|
||||||
|
.map((keydown) => firstAtOrAfter(this.inputFrames, keydown.at)?.at ?? null)
|
||||||
|
.filter((at): at is number => at !== null)
|
||||||
|
.map((at, index) => at - this.keydowns[index]!.at);
|
||||||
|
const inputFrameToOutputFrame = this.inputFrames
|
||||||
|
.map((input) => {
|
||||||
|
const output = firstAtOrAfter(this.outputFrames, input.at);
|
||||||
|
return output ? output.at - input.at : null;
|
||||||
|
})
|
||||||
|
.filter((value): value is number => value !== null);
|
||||||
|
const outputFrameToXtermWrite = this.outputFrames
|
||||||
|
.map((output) => {
|
||||||
|
const write = firstAtOrAfter(this.xtermWrites, output.at);
|
||||||
|
return write ? write.at - output.at : null;
|
||||||
|
})
|
||||||
|
.filter((value): value is number => value !== null);
|
||||||
|
const xtermWriteDurations = this.xtermWrites
|
||||||
|
.map((write) => (write.committedAt === null ? null : write.committedAt - write.at))
|
||||||
|
.filter((value): value is number => value !== null);
|
||||||
|
const keydownToXtermCommit = this.keydowns
|
||||||
|
.map((keydown) => {
|
||||||
|
const write = firstCommitAtOrAfter(this.xtermWrites, keydown.at);
|
||||||
|
return write?.committedAt ? write.committedAt - keydown.at : null;
|
||||||
|
})
|
||||||
|
.filter((value): value is number => value !== null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
inputTextLength: inputText.length,
|
||||||
|
keydownCount: this.keydowns.length,
|
||||||
|
inputFrameCount: this.inputFrames.length,
|
||||||
|
outputFrameCount: this.outputFrames.length,
|
||||||
|
textMessageFrameCount: this.textMessageFrames.length,
|
||||||
|
textMessagePayloadBytes: this.textMessageFrames.reduce(
|
||||||
|
(sum, frame) => sum + frame.bytes,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
largeTextMessageCount: this.textMessageFrames.filter((frame) => frame.bytes >= 50_000)
|
||||||
|
.length,
|
||||||
|
largestTextMessageBytes: Math.max(
|
||||||
|
0,
|
||||||
|
...this.textMessageFrames.map((frame) => frame.bytes),
|
||||||
|
),
|
||||||
|
agentStreamTextMessageCount: this.textMessageFrames.filter(
|
||||||
|
(frame) => frame.kind === "session:agent_stream",
|
||||||
|
).length,
|
||||||
|
agentStreamTextMessagePayloadBytes: this.textMessageFrames
|
||||||
|
.filter((frame) => frame.kind === "session:agent_stream")
|
||||||
|
.reduce((sum, frame) => sum + frame.bytes, 0),
|
||||||
|
largeAgentStreamTextMessageCount: this.textMessageFrames.filter(
|
||||||
|
(frame) => frame.kind === "session:agent_stream" && frame.bytes >= 50_000,
|
||||||
|
).length,
|
||||||
|
largestAgentStreamTextMessageBytes: Math.max(
|
||||||
|
0,
|
||||||
|
...this.textMessageFrames
|
||||||
|
.filter((frame) => frame.kind === "session:agent_stream")
|
||||||
|
.map((frame) => frame.bytes),
|
||||||
|
),
|
||||||
|
appEventCount: this.appEvents.length,
|
||||||
|
appEventCounts: countByType(this.appEvents),
|
||||||
|
runtimeMaxQueueDepth: Math.max(
|
||||||
|
0,
|
||||||
|
...this.appEvents
|
||||||
|
.map((event) => event.queueDepth)
|
||||||
|
.filter((value): value is number => typeof value === "number"),
|
||||||
|
),
|
||||||
|
xtermWriteCount: this.xtermWrites.length,
|
||||||
|
inputFramePayloadBytes: this.inputFrames.reduce((sum, frame) => sum + frame.bytes, 0),
|
||||||
|
outputFramePayloadBytes: this.outputFrames.reduce((sum, frame) => sum + frame.bytes, 0),
|
||||||
|
keydownToInputFrameMs: summarize(keydownToInputFrame),
|
||||||
|
inputFrameToOutputFrameMs: summarize(inputFrameToOutputFrame),
|
||||||
|
appBinaryReceivedToFrameDecodedMs: summarize(
|
||||||
|
latencyByIndex(binaryReceived, frameDecoded),
|
||||||
|
),
|
||||||
|
appFrameDecodedToTerminalEmitMs: summarize(latencyByIndex(frameDecoded, terminalEmit)),
|
||||||
|
appTerminalEmitListenerDurationMs: summarize(
|
||||||
|
latencyByIndex(terminalEmit, terminalEmitted),
|
||||||
|
),
|
||||||
|
appTerminalEmitToStreamControllerOutputMs: summarize(
|
||||||
|
latencyByIndex(terminalEmit, streamControllerOutput),
|
||||||
|
),
|
||||||
|
appStreamControllerDecodeToOnOutputMs: summarize(
|
||||||
|
latencyByIndex(streamControllerOutput, streamControllerOnOutput),
|
||||||
|
),
|
||||||
|
appStreamControllerToEmulatorWriteMs: summarize(
|
||||||
|
latencyByIndex(streamControllerOnOutput, emulatorWriteOutput),
|
||||||
|
),
|
||||||
|
appEmulatorWriteToRuntimeEnqueuedMs: summarize(
|
||||||
|
latencyByIndex(emulatorWriteOutput, runtimeWriteEnqueued),
|
||||||
|
),
|
||||||
|
appRuntimeEnqueuedToOperationStartMs: summarize(
|
||||||
|
latencyByIndex(runtimeWriteEnqueued, runtimeOperationStart),
|
||||||
|
),
|
||||||
|
appRuntimeOperationStartToXtermWriteMs: summarize(
|
||||||
|
latencyByIndex(runtimeOperationStart, runtimeXtermWrite),
|
||||||
|
),
|
||||||
|
appRuntimeXtermWriteToCommitMs: summarize(
|
||||||
|
latencyByIndex(runtimeXtermWrite, runtimeXtermCommitted),
|
||||||
|
),
|
||||||
|
appBinaryReceivedToRuntimeEnqueuedMs: summarize(
|
||||||
|
latencyByIndex(binaryReceived, runtimeWriteEnqueued),
|
||||||
|
),
|
||||||
|
appBinaryReceivedToRuntimeOperationStartMs: summarize(
|
||||||
|
latencyByIndex(binaryReceived, runtimeOperationStart),
|
||||||
|
),
|
||||||
|
appBinaryReceivedToXtermCommitMs: summarize(
|
||||||
|
latencyByIndex(binaryReceived, runtimeXtermCommitted),
|
||||||
|
),
|
||||||
|
outputFrameToXtermWriteMs: summarize(outputFrameToXtermWrite),
|
||||||
|
xtermWriteDurationMs: summarize(xtermWriteDurations),
|
||||||
|
keydownToXtermCommitMs: summarize(keydownToXtermCommit),
|
||||||
|
firstKeydownAt: this.keydowns[0]?.at ?? null,
|
||||||
|
lastXtermCommitAt:
|
||||||
|
this.xtermWrites
|
||||||
|
.map((write) => write.committedAt)
|
||||||
|
.filter((at): at is number => typeof at === "number")
|
||||||
|
.at(-1) ?? null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.defineProperty(window, "__terminalKeystrokeStressProbe", {
|
||||||
|
configurable: true,
|
||||||
|
value: probe,
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener(
|
||||||
|
"keydown",
|
||||||
|
(event) => {
|
||||||
|
if (event.key.length === 1) {
|
||||||
|
probe.keydowns.push({ at: performance.now(), key: event.key });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const NativeWebSocket = window.WebSocket;
|
||||||
|
class InstrumentedWebSocket extends NativeWebSocket {
|
||||||
|
constructor(url: string | URL, protocols?: string | string[]) {
|
||||||
|
if (protocols === undefined) {
|
||||||
|
super(url);
|
||||||
|
} else {
|
||||||
|
super(url, protocols);
|
||||||
|
}
|
||||||
|
super.addEventListener("message", (event) => {
|
||||||
|
void eventDataBytes(event.data).then((bytes) => {
|
||||||
|
if (!bytes || bytes.byteLength < 2 || bytes[0] !== OUTPUT_OPCODE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
probe.outputFrames.push({
|
||||||
|
at: performance.now(),
|
||||||
|
text: frameText(bytes),
|
||||||
|
bytes: bytes.byteLength - 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
void eventDataText(event.data).then((text) => {
|
||||||
|
if (text === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
probe.textMessageFrames.push({
|
||||||
|
at: performance.now(),
|
||||||
|
bytes: new TextEncoder().encode(text).byteLength,
|
||||||
|
kind: textMessageKind(text),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void {
|
||||||
|
const bytes = bytesFrom(data);
|
||||||
|
if (bytes && bytes.byteLength >= 2 && bytes[0] === INPUT_OPCODE) {
|
||||||
|
probe.inputFrames.push({
|
||||||
|
at: performance.now(),
|
||||||
|
text: frameText(bytes),
|
||||||
|
bytes: bytes.byteLength - 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
super.send(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.defineProperty(InstrumentedWebSocket, "CONNECTING", {
|
||||||
|
value: NativeWebSocket.CONNECTING,
|
||||||
|
});
|
||||||
|
Object.defineProperty(InstrumentedWebSocket, "OPEN", { value: NativeWebSocket.OPEN });
|
||||||
|
Object.defineProperty(InstrumentedWebSocket, "CLOSING", { value: NativeWebSocket.CLOSING });
|
||||||
|
Object.defineProperty(InstrumentedWebSocket, "CLOSED", { value: NativeWebSocket.CLOSED });
|
||||||
|
window.WebSocket = InstrumentedWebSocket as typeof WebSocket;
|
||||||
|
|
||||||
|
const existingDescriptor = Object.getOwnPropertyDescriptor(window, "__paseoTerminal");
|
||||||
|
const getExisting = () =>
|
||||||
|
existingDescriptor?.get ? existingDescriptor.get.call(window) : existingDescriptor?.value;
|
||||||
|
|
||||||
|
let terminal = getExisting();
|
||||||
|
Object.defineProperty(window, "__paseoTerminal", {
|
||||||
|
configurable: true,
|
||||||
|
get() {
|
||||||
|
return terminal;
|
||||||
|
},
|
||||||
|
set(next: any) {
|
||||||
|
terminal = next;
|
||||||
|
if (next?.write && !next.__paseoKeystrokeProbeWriteWrapped) {
|
||||||
|
const originalWrite = next.write.bind(next);
|
||||||
|
next.write = (data: string | Uint8Array, callback?: () => void) => {
|
||||||
|
const text =
|
||||||
|
typeof data === "string" ? data : new TextDecoder().decode(data as Uint8Array);
|
||||||
|
const event: XtermWriteEvent = {
|
||||||
|
at: performance.now(),
|
||||||
|
committedAt: null,
|
||||||
|
text,
|
||||||
|
bytes: text.length,
|
||||||
|
};
|
||||||
|
probe.xtermWrites.push(event);
|
||||||
|
return originalWrite(data, () => {
|
||||||
|
event.committedAt = performance.now();
|
||||||
|
callback?.();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
next.__paseoKeystrokeProbeWriteWrapped = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetTerminalKeystrokeStressProbe(page: Page): Promise<void> {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
(window as any).__terminalKeystrokeStressProbe.reset();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readTerminalKeystrokeStressReport(
|
||||||
|
page: Page,
|
||||||
|
inputText: string,
|
||||||
|
): Promise<TerminalKeystrokeStressReport> {
|
||||||
|
return page.evaluate(
|
||||||
|
(text) => (window as any).__terminalKeystrokeStressProbe.report(text),
|
||||||
|
inputText,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,18 +13,30 @@ export async function getWorkspaceTabTestIds(page: Page): Promise<string[]> {
|
|||||||
return ids;
|
return ids;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function visibleTestId(page: Page, testId: string) {
|
||||||
|
return page.getByTestId(testId).filter({ visible: true });
|
||||||
|
}
|
||||||
|
|
||||||
export async function waitForWorkspaceTabsVisible(page: Page): Promise<void> {
|
export async function waitForWorkspaceTabsVisible(page: Page): Promise<void> {
|
||||||
await expect(page.getByTestId("workspace-tabs-row").first()).toBeVisible({
|
await expect(visibleTestId(page, "workspace-tabs-row").first()).toBeVisible({
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
await expect(page.getByTestId("workspace-new-agent-tab").first()).toBeVisible({
|
await expect(visibleTestId(page, "workspace-new-agent-tab").first()).toBeVisible({
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getVisibleWorkspaceAgentTabIds(page: Page): Promise<string[]> {
|
export async function getVisibleWorkspaceAgentTabIds(page: Page): Promise<string[]> {
|
||||||
const allTabIds = await getWorkspaceTabTestIds(page);
|
const tabs = page.locator('[data-testid^="workspace-tab-agent_"]').filter({ visible: true });
|
||||||
return allTabIds.filter((id) => id.startsWith("workspace-tab-agent_"));
|
const count = await tabs.count();
|
||||||
|
const ids: string[] = [];
|
||||||
|
for (let index = 0; index < count; index += 1) {
|
||||||
|
const testId = await tabs.nth(index).getAttribute("data-testid");
|
||||||
|
if (testId && !ids.includes(testId)) {
|
||||||
|
ids.push(testId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function expectOnlyWorkspaceAgentTabsVisible(
|
export async function expectOnlyWorkspaceAgentTabsVisible(
|
||||||
@@ -38,7 +50,7 @@ export async function expectOnlyWorkspaceAgentTabsVisible(
|
|||||||
expect(unexpected).toEqual([]);
|
expect(unexpected).toEqual([]);
|
||||||
expect(visible.length).toBe(expected.size);
|
expect(visible.length).toBe(expected.size);
|
||||||
for (const expectedId of expectedAgentIds) {
|
for (const expectedId of expectedAgentIds) {
|
||||||
await expect(page.getByTestId(`workspace-tab-agent_${expectedId}`)).toBeVisible({
|
await expect(visibleTestId(page, `workspace-tab-agent_${expectedId}`).first()).toBeVisible({
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,8 +103,8 @@ export async function expectWorkspaceHeader(
|
|||||||
page: Page,
|
page: Page,
|
||||||
input: { title: string; subtitle: string },
|
input: { title: string; subtitle: string },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const titleLocator = page.getByTestId("workspace-header-title");
|
const titleLocator = page.getByTestId("workspace-header-title").filter({ visible: true });
|
||||||
const subtitleLocator = page.getByTestId("workspace-header-subtitle");
|
const subtitleLocator = page.getByTestId("workspace-header-subtitle").filter({ visible: true });
|
||||||
|
|
||||||
await expect(titleLocator.first()).toHaveText(input.title, {
|
await expect(titleLocator.first()).toHaveText(input.title, {
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import path from "node:path";
|
|||||||
|
|
||||||
type TempRepo = {
|
type TempRepo = {
|
||||||
path: string;
|
path: string;
|
||||||
|
branchHeads: Record<string, string>;
|
||||||
cleanup: () => Promise<void>;
|
cleanup: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ export const createTempGitRepo = async (
|
|||||||
withRemote?: boolean;
|
withRemote?: boolean;
|
||||||
paseoConfig?: Record<string, unknown>;
|
paseoConfig?: Record<string, unknown>;
|
||||||
files?: Array<{ path: string; content: string }>;
|
files?: Array<{ path: string; content: string }>;
|
||||||
|
branches?: string[];
|
||||||
},
|
},
|
||||||
): Promise<TempRepo> => {
|
): Promise<TempRepo> => {
|
||||||
// Keep E2E repo paths short so terminal prompt + typed commands stay visible without zsh clipping.
|
// 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" });
|
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) {
|
if (withRemote) {
|
||||||
// Deterministic local remote to avoid relying on external auth/network in e2e.
|
// Deterministic local remote to avoid relying on external auth/network in e2e.
|
||||||
const remoteDir = path.join(repoPath, "remote.git");
|
const remoteDir = path.join(repoPath, "remote.git");
|
||||||
await mkdir(remoteDir, { recursive: true });
|
await mkdir(remoteDir, { recursive: true });
|
||||||
execSync(`git init --bare -b main ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
|
execSync(`git init --bare -b main ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
|
||||||
execSync(`git remote add origin ${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 {
|
return {
|
||||||
path: repoPath,
|
path: repoPath,
|
||||||
|
branchHeads,
|
||||||
cleanup: async () => {
|
cleanup: async () => {
|
||||||
await rm(repoPath, { recursive: true, force: true });
|
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,5 +1,7 @@
|
|||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||||
import { expect, test } from "./fixtures";
|
import { expect, test, type Page } from "./fixtures";
|
||||||
import { gotoAppShell } from "./helpers/app";
|
import { gotoAppShell } from "./helpers/app";
|
||||||
import {
|
import {
|
||||||
archiveWorkspaceFromDaemon,
|
archiveWorkspaceFromDaemon,
|
||||||
@@ -8,9 +10,15 @@ import {
|
|||||||
clickNewWorkspaceButton,
|
clickNewWorkspaceButton,
|
||||||
connectNewWorkspaceDaemonClient,
|
connectNewWorkspaceDaemonClient,
|
||||||
createWorktreeViaDaemon,
|
createWorktreeViaDaemon,
|
||||||
|
expectComposerGithubAttachmentPill,
|
||||||
|
expectStartingRefPickerTriggerPr,
|
||||||
|
openNewWorkspaceComposer,
|
||||||
|
openStartingRefPicker,
|
||||||
openProjectViaDaemon,
|
openProjectViaDaemon,
|
||||||
|
selectBranchInPicker,
|
||||||
|
selectGitHubPrInPicker,
|
||||||
} from "./helpers/new-workspace";
|
} from "./helpers/new-workspace";
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
import { createTempGitRepo, readWorktreeBranchInfo } from "./helpers/workspace";
|
||||||
import {
|
import {
|
||||||
expectSidebarWorkspaceSelected,
|
expectSidebarWorkspaceSelected,
|
||||||
expectWorkspaceHeader,
|
expectWorkspaceHeader,
|
||||||
@@ -20,6 +28,108 @@ import {
|
|||||||
workspaceLabelFromPath,
|
workspaceLabelFromPath,
|
||||||
} from "./helpers/workspace-ui";
|
} from "./helpers/workspace-ui";
|
||||||
|
|
||||||
|
type WebSocketMessage = string | Buffer;
|
||||||
|
|
||||||
|
function parseWebSocketJson(message: WebSocketMessage): unknown {
|
||||||
|
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
|
||||||
|
try {
|
||||||
|
return JSON.parse(rawMessage);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
|
||||||
|
const envelope = parseWebSocketJson(message);
|
||||||
|
if (!envelope || typeof envelope !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
|
||||||
|
if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof maybeEnvelope.message !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return maybeEnvelope.message as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStringField(input: Record<string, unknown>, key: string): string | null {
|
||||||
|
const value = input[key];
|
||||||
|
return typeof value === "string" ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function delayBrowserAgentCreatedStatus(page: Page) {
|
||||||
|
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||||
|
if (!daemonPort) {
|
||||||
|
throw new Error("E2E_DAEMON_PORT is not set.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const daemonPortPattern = new RegExp(`:${daemonPort.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
|
||||||
|
const createRequestIds = new Set<string>();
|
||||||
|
const delayedForwards: Array<() => void> = [];
|
||||||
|
let releaseRequested = false;
|
||||||
|
let resolveCreateRequest: (() => void) | null = null;
|
||||||
|
let resolveDelayedCreatedStatus: (() => void) | null = null;
|
||||||
|
const createRequestSeen = new Promise<void>((resolve) => {
|
||||||
|
resolveCreateRequest = resolve;
|
||||||
|
});
|
||||||
|
const delayedCreatedStatusSeen = new Promise<void>((resolve) => {
|
||||||
|
resolveDelayedCreatedStatus = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.routeWebSocket(daemonPortPattern, (ws) => {
|
||||||
|
const server = ws.connectToServer();
|
||||||
|
|
||||||
|
ws.onMessage((message) => {
|
||||||
|
const sessionMessage = getSessionMessage(message);
|
||||||
|
if (sessionMessage?.type === "create_agent_request") {
|
||||||
|
const requestId = getStringField(sessionMessage, "requestId");
|
||||||
|
if (requestId) {
|
||||||
|
createRequestIds.add(requestId);
|
||||||
|
resolveCreateRequest?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
server.send(message);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.onMessage((message) => {
|
||||||
|
const sessionMessage = getSessionMessage(message);
|
||||||
|
const payload =
|
||||||
|
sessionMessage?.type === "status" && typeof sessionMessage.payload === "object"
|
||||||
|
? (sessionMessage.payload as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
const requestId = payload ? getStringField(payload, "requestId") : null;
|
||||||
|
|
||||||
|
if (payload?.status === "agent_created" && requestId && createRequestIds.has(requestId)) {
|
||||||
|
resolveDelayedCreatedStatus?.();
|
||||||
|
if (releaseRequested) {
|
||||||
|
ws.send(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
delayedForwards.push(() => ws.send(message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.send(message);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
release() {
|
||||||
|
releaseRequested = true;
|
||||||
|
for (const forward of delayedForwards.splice(0)) {
|
||||||
|
forward();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
waitForCreateRequest: () => createRequestSeen,
|
||||||
|
waitForDelayedCreatedStatus: () => delayedCreatedStatusSeen,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
test.describe("New workspace flow", () => {
|
test.describe("New workspace flow", () => {
|
||||||
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||||
const localWorkspaceIds = new Set<string>();
|
const localWorkspaceIds = new Set<string>();
|
||||||
@@ -183,7 +293,7 @@ test.describe("New workspace flow", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("clicking new workspace redirects, renders header, shows sidebar row, and keeps one draft tab", async ({
|
test("clicking new workspace redirects, renders header, shows sidebar row, and keeps one agent tab", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
@@ -240,7 +350,12 @@ test.describe("New workspace flow", () => {
|
|||||||
subtitle: openedProject.projectDisplayName,
|
subtitle: openedProject.projectDisplayName,
|
||||||
});
|
});
|
||||||
|
|
||||||
const agentTabs = page.locator('[data-testid^="workspace-tab-agent_"]');
|
const activeWorkspaceDeckEntry = page
|
||||||
|
.getByTestId(`workspace-deck-entry-${serverId}:${createdWorkspace.workspaceId}`)
|
||||||
|
.filter({ visible: true });
|
||||||
|
await expect(activeWorkspaceDeckEntry).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
const agentTabs = activeWorkspaceDeckEntry.locator('[data-testid^="workspace-tab-agent_"]');
|
||||||
await expect(agentTabs).toHaveCount(1, { timeout: 30_000 });
|
await expect(agentTabs).toHaveCount(1, { timeout: 30_000 });
|
||||||
|
|
||||||
// Workspace setup may auto-open a setup tab that steals focus,
|
// Workspace setup may auto-open a setup tab that steals focus,
|
||||||
@@ -249,7 +364,172 @@ test.describe("New workspace flow", () => {
|
|||||||
await agentTabs.first().click();
|
await agentTabs.first().click();
|
||||||
|
|
||||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||||
await expect(composer).toBeEditable({ timeout: 30_000 });
|
await expect(composer).toBeVisible({ timeout: 30_000 });
|
||||||
|
} finally {
|
||||||
|
await tempRepo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("redirects to the optimistic draft tab before agent creation resolves", 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-optimistic-");
|
||||||
|
const agentCreatedDelay = await delayBrowserAgentCreatedStatus(page);
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createButton = page
|
||||||
|
.getByTestId("message-input-root")
|
||||||
|
.getByRole("button", { name: "Create" });
|
||||||
|
await expect(createButton).toBeVisible({ timeout: 30_000 });
|
||||||
|
await createButton.click();
|
||||||
|
|
||||||
|
await agentCreatedDelay.waitForCreateRequest();
|
||||||
|
await agentCreatedDelay.waitForDelayedCreatedStatus();
|
||||||
|
|
||||||
|
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||||
|
serverId,
|
||||||
|
previousWorkspaceId: openedProject.workspaceId,
|
||||||
|
projectDisplayName: openedProject.projectDisplayName,
|
||||||
|
});
|
||||||
|
createdWorktreeIds.add(createdWorkspace.workspaceId);
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(
|
||||||
|
buildHostWorkspaceRoute(serverId, createdWorkspace.workspaceId),
|
||||||
|
{
|
||||||
|
timeout: 30_000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeWorkspaceDeckEntry = page
|
||||||
|
.getByTestId(`workspace-deck-entry-${serverId}:${createdWorkspace.workspaceId}`)
|
||||||
|
.filter({ visible: true });
|
||||||
|
await expect(activeWorkspaceDeckEntry).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
const draftTabs = activeWorkspaceDeckEntry.locator('[data-testid^="workspace-tab-draft_"]');
|
||||||
|
await expect(draftTabs).toHaveCount(1, { timeout: 30_000 });
|
||||||
|
await expect(
|
||||||
|
activeWorkspaceDeckEntry.locator('[data-testid^="workspace-tab-agent_"]'),
|
||||||
|
).toHaveCount(0);
|
||||||
|
|
||||||
|
agentCreatedDelay.release();
|
||||||
|
await expect(
|
||||||
|
activeWorkspaceDeckEntry.locator('[data-testid^="workspace-tab-agent_"]'),
|
||||||
|
).toHaveCount(1, { timeout: 30_000 });
|
||||||
|
} finally {
|
||||||
|
agentCreatedDelay.release();
|
||||||
|
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 {
|
} finally {
|
||||||
await tempRepo.cleanup();
|
await tempRepo.cleanup();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ async function openHostPage(page: Page, serverId: string) {
|
|||||||
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
|
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.describe("Settings host page", () => {
|
||||||
test("host page shows seeded label, connection endpoint, inject MCP toggle, and all action rows", async ({
|
test("host page shows seeded label, connection endpoint, inject MCP toggle, and all action rows", async ({
|
||||||
page,
|
page,
|
||||||
@@ -34,16 +40,8 @@ test.describe("Settings host page", () => {
|
|||||||
await openSettings(page);
|
await openSettings(page);
|
||||||
await openHostPage(page, serverId);
|
await openHostPage(page, serverId);
|
||||||
|
|
||||||
// Label renders as a title with a pencil edit affordance; the input is hidden until edit.
|
// Label renders in the detail header with a pencil edit affordance; the input is hidden until edit.
|
||||||
await expect(page.getByTestId("host-page-label-card")).toBeVisible();
|
await expectHostLabelHeader(page);
|
||||||
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);
|
|
||||||
|
|
||||||
// Connections is its own section with a "Connections" heading and the seeded endpoint row.
|
// Connections is its own section with a "Connections" heading and the seeded endpoint row.
|
||||||
const connectionsCard = page.getByTestId("host-page-connections-card");
|
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 page.goto(`/settings/hosts/${encodeURIComponent(serverId)}`);
|
||||||
|
|
||||||
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
|
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();
|
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();
|
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 }) => {
|
test("/settings renders only the sidebar list (no section content)", async ({ page }) => {
|
||||||
await openCompactSettingsRoot(page);
|
await openCompactSettingsRoot(page);
|
||||||
|
|
||||||
@@ -110,12 +119,12 @@ test.describe("Settings — compact master-detail", () => {
|
|||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
|
||||||
// Section detail content is NOT rendered at the root.
|
// Section detail content is NOT rendered at the root.
|
||||||
await expect(page.getByText("Theme", { exact: true })).toHaveCount(0);
|
await expectCompactSettingsRootList(page);
|
||||||
await expect(page.getByRole("button", { name: "Play test" })).toHaveCount(0);
|
|
||||||
await expect(page.getByTestId("host-page-label-card")).toHaveCount(0);
|
|
||||||
|
|
||||||
// Root shows the menu header, not a back button.
|
const rootBackButton = page.getByRole("button", { name: "Back", exact: true });
|
||||||
await expect(page.getByRole("button", { name: "Back", exact: true })).toHaveCount(0);
|
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 }) => {
|
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 expect(page).toHaveURL(/\/settings\/about$/);
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Back", exact: true }).click();
|
await page.getByRole("button", { name: "Back", exact: true }).click();
|
||||||
await expect(page).toHaveURL(/\/settings$/);
|
await expectCompactSettingsRootList(page);
|
||||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
await expect(page.getByRole("button", { name: "Back", exact: true })).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: "Back", exact: true })).toHaveCount(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("tapping a host entry pushes /settings/hosts/[serverId]", async ({ page }) => {
|
test("tapping a host entry pushes /settings/hosts/[serverId]", async ({ page }) => {
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||||
|
import { expect, test, type Page } from "./fixtures";
|
||||||
|
import {
|
||||||
|
archiveAgentFromDaemon,
|
||||||
|
connectArchiveTabDaemonClient,
|
||||||
|
createIdleAgent,
|
||||||
|
openWorkspaceWithAgents,
|
||||||
|
} from "./helpers/archive-tab";
|
||||||
|
import { getActiveTabTestId, waitForTabBar } from "./helpers/launcher";
|
||||||
|
import { createTempGitRepo } from "./helpers/workspace";
|
||||||
|
|
||||||
|
function getServerId(): string {
|
||||||
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
|
if (!serverId) {
|
||||||
|
throw new Error("E2E_SERVER_ID is not set.");
|
||||||
|
}
|
||||||
|
return serverId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pressSettingsToggleShortcut(page: Page) {
|
||||||
|
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||||
|
await page.keyboard.press(`${modifier}+Comma`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectSendBehavior(page: Page, expected: "interrupt" | "queue") {
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const raw = await page.evaluate(() => localStorage.getItem("@paseo:app-settings"));
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (JSON.parse(raw) as { sendBehavior?: string }).sendBehavior ?? null;
|
||||||
|
})
|
||||||
|
.toBe(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openAgentRouteAndExpectFocused(input: {
|
||||||
|
page: Page;
|
||||||
|
serverId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
agentId: string;
|
||||||
|
}) {
|
||||||
|
const expectedActiveTabId = `workspace-tab-agent_${input.agentId}`;
|
||||||
|
await input.page.goto(
|
||||||
|
buildHostAgentDetailRoute(input.serverId, input.agentId, input.workspaceId),
|
||||||
|
);
|
||||||
|
await input.page.waitForURL(
|
||||||
|
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
);
|
||||||
|
await waitForTabBar(input.page);
|
||||||
|
await expect(
|
||||||
|
input.page.getByTestId(expectedActiveTabId).filter({ visible: true }),
|
||||||
|
).toHaveAttribute("aria-selected", "true");
|
||||||
|
await expect(getActiveTabTestId(input.page)).resolves.toBe(expectedActiveTabId);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("Settings toggle tab regression", () => {
|
||||||
|
test.describe.configure({ timeout: 180_000 });
|
||||||
|
|
||||||
|
test("toggling settings after changing a setting returns to the same workspace tab", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const serverId = getServerId();
|
||||||
|
const client = await connectArchiveTabDaemonClient();
|
||||||
|
const repo = await createTempGitRepo("settings-toggle-tab-");
|
||||||
|
const agentIds: string[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const firstAgent = await createIdleAgent(client, {
|
||||||
|
cwd: repo.path,
|
||||||
|
title: `settings-toggle-a-${Date.now()}`,
|
||||||
|
});
|
||||||
|
const secondAgent = await createIdleAgent(client, {
|
||||||
|
cwd: repo.path,
|
||||||
|
title: `settings-toggle-b-${Date.now()}`,
|
||||||
|
});
|
||||||
|
agentIds.push(firstAgent.id, secondAgent.id);
|
||||||
|
|
||||||
|
await openWorkspaceWithAgents(page, [firstAgent, secondAgent]);
|
||||||
|
await waitForTabBar(page);
|
||||||
|
|
||||||
|
const expectedActiveTabId = `workspace-tab-agent_${secondAgent.id}`;
|
||||||
|
await expect(page.getByTestId(expectedActiveTabId).filter({ visible: true })).toHaveAttribute(
|
||||||
|
"aria-selected",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
await expect(getActiveTabTestId(page)).resolves.toBe(expectedActiveTabId);
|
||||||
|
|
||||||
|
await pressSettingsToggleShortcut(page);
|
||||||
|
await expect(page).toHaveURL(/\/settings\/general$/);
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Queue", exact: true }).click();
|
||||||
|
await expectSendBehavior(page, "queue");
|
||||||
|
await page.getByRole("button", { name: "Interrupt", exact: true }).click();
|
||||||
|
await expectSendBehavior(page, "interrupt");
|
||||||
|
|
||||||
|
await pressSettingsToggleShortcut(page);
|
||||||
|
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, repo.path));
|
||||||
|
await waitForTabBar(page);
|
||||||
|
await expect(page.getByTestId(expectedActiveTabId).filter({ visible: true })).toHaveAttribute(
|
||||||
|
"aria-selected",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
await expect(getActiveTabTestId(page)).resolves.toBe(expectedActiveTabId);
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await waitForTabBar(page);
|
||||||
|
await expect(page.getByTestId(expectedActiveTabId).filter({ visible: true })).toHaveAttribute(
|
||||||
|
"aria-selected",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
await expect(getActiveTabTestId(page)).resolves.toBe(expectedActiveTabId);
|
||||||
|
} finally {
|
||||||
|
for (const agentId of agentIds) {
|
||||||
|
await archiveAgentFromDaemon(client, agentId).catch(() => undefined);
|
||||||
|
}
|
||||||
|
await client.close().catch(() => undefined);
|
||||||
|
await repo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refresh after navigating between agent routes keeps the latest agent focused", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const serverId = getServerId();
|
||||||
|
const client = await connectArchiveTabDaemonClient();
|
||||||
|
const repo = await createTempGitRepo("agent-route-refresh-");
|
||||||
|
const agentIds: string[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const firstAgent = await createIdleAgent(client, {
|
||||||
|
cwd: repo.path,
|
||||||
|
title: `agent-route-refresh-a-${Date.now()}`,
|
||||||
|
});
|
||||||
|
const secondAgent = await createIdleAgent(client, {
|
||||||
|
cwd: repo.path,
|
||||||
|
title: `agent-route-refresh-b-${Date.now()}`,
|
||||||
|
});
|
||||||
|
agentIds.push(firstAgent.id, secondAgent.id);
|
||||||
|
|
||||||
|
await openAgentRouteAndExpectFocused({
|
||||||
|
page,
|
||||||
|
serverId,
|
||||||
|
workspaceId: repo.path,
|
||||||
|
agentId: firstAgent.id,
|
||||||
|
});
|
||||||
|
await openAgentRouteAndExpectFocused({
|
||||||
|
page,
|
||||||
|
serverId,
|
||||||
|
workspaceId: repo.path,
|
||||||
|
agentId: secondAgent.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const expectedActiveTabId = `workspace-tab-agent_${secondAgent.id}`;
|
||||||
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||||
|
await page.reload();
|
||||||
|
await waitForTabBar(page);
|
||||||
|
await expect(
|
||||||
|
page.getByTestId(expectedActiveTabId).filter({ visible: true }),
|
||||||
|
).toHaveAttribute("aria-selected", "true");
|
||||||
|
await expect(getActiveTabTestId(page)).resolves.toBe(expectedActiveTabId);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
for (const agentId of agentIds) {
|
||||||
|
await archiveAgentFromDaemon(client, agentId).catch(() => undefined);
|
||||||
|
}
|
||||||
|
await client.close().catch(() => undefined);
|
||||||
|
await repo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { test } from "./fixtures";
|
||||||
|
import { startupScenario } from "./helpers/startup-dsl";
|
||||||
|
|
||||||
|
function getE2EDaemonPort(): string {
|
||||||
|
const port = process.env.E2E_DAEMON_PORT;
|
||||||
|
if (!port) {
|
||||||
|
throw new Error("E2E_DAEMON_PORT is not set.");
|
||||||
|
}
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getE2EServerId(): string {
|
||||||
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
|
if (!serverId) {
|
||||||
|
throw new Error("E2E_SERVER_ID is not set.");
|
||||||
|
}
|
||||||
|
return serverId;
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("Startup loading presentation", () => {
|
||||||
|
test("mobile reconnect keeps connection recovery actions visible", async ({ page }) => {
|
||||||
|
const startup = await startupScenario(page)
|
||||||
|
.withMobileViewport()
|
||||||
|
.withSavedHost({
|
||||||
|
serverId: "srv_unreachable_mobile",
|
||||||
|
label: "Dev",
|
||||||
|
endpoint: "127.0.0.1:45678",
|
||||||
|
})
|
||||||
|
.openRoot();
|
||||||
|
|
||||||
|
await startup.expectsReconnectWelcome();
|
||||||
|
await startup.expectsNoSavedHostStatus({ label: "Dev" });
|
||||||
|
await startup.expectsNoLocalServerStartupCopy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("desktop daemon bootstrap keeps the local server startup copy desktop-only", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const startup = await startupScenario(page)
|
||||||
|
.withPendingDesktopDaemon()
|
||||||
|
.withBlockedPort(getE2EDaemonPort())
|
||||||
|
.openRoot();
|
||||||
|
|
||||||
|
await startup.expectsDesktopDaemonStartup();
|
||||||
|
await startup.expectsSidebarHidden();
|
||||||
|
await startup.expectsNoUndefinedRoute();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("host-route refresh does not render route chrome around the bootstrap splash", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const serverId = getE2EServerId();
|
||||||
|
const startup = await startupScenario(page)
|
||||||
|
.withPendingDesktopDaemon()
|
||||||
|
.withBlockedPort(getE2EDaemonPort())
|
||||||
|
.openHostWorkspace({
|
||||||
|
serverId,
|
||||||
|
workspaceId: "workspace-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
await startup.expectsDesktopDaemonStartup();
|
||||||
|
await startup.expectsSidebarHidden();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,537 @@
|
|||||||
|
import { Buffer } from "node:buffer";
|
||||||
|
import type { CDPSession, Page, TestInfo } from "@playwright/test";
|
||||||
|
import { expect, test } from "./fixtures";
|
||||||
|
import { gotoAppShell } from "./helpers/app";
|
||||||
|
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||||
|
|
||||||
|
type WireDirection = "sent" | "received";
|
||||||
|
type WirePhase = "startup" | "workspace_clicks";
|
||||||
|
|
||||||
|
type ParsedWireMessage = {
|
||||||
|
type: string | null;
|
||||||
|
requestId: string | null;
|
||||||
|
entryCount: number | null;
|
||||||
|
hasMore: boolean | null;
|
||||||
|
providerEntries: ProviderSnapshotWireEntry[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WireFrameRecord = ParsedWireMessage & {
|
||||||
|
phase: WirePhase;
|
||||||
|
direction: WireDirection;
|
||||||
|
bytes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProviderSnapshotWireEntry = {
|
||||||
|
provider: string;
|
||||||
|
status: string | null;
|
||||||
|
modelCount: number;
|
||||||
|
modeCount: number;
|
||||||
|
bytes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WebSocketFrameEvent = {
|
||||||
|
requestId: string;
|
||||||
|
response: {
|
||||||
|
opcode: number;
|
||||||
|
payloadData: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type WireSummary = {
|
||||||
|
totalFrames: number;
|
||||||
|
totalBytes: number;
|
||||||
|
byDirection: Record<WireDirection, { frames: number; bytes: number }>;
|
||||||
|
byPhase: Record<
|
||||||
|
WirePhase,
|
||||||
|
{
|
||||||
|
frames: number;
|
||||||
|
bytes: number;
|
||||||
|
byType: Array<{ type: string; frames: number; bytes: number }>;
|
||||||
|
rpcCounts: Array<{ requestType: string; count: number }>;
|
||||||
|
rpcs: Array<{ requestType: string; requestId: string; responseType: string | null }>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
fetchPages: Array<{
|
||||||
|
phase: WirePhase;
|
||||||
|
type: string;
|
||||||
|
requestId: string | null;
|
||||||
|
entries: number | null;
|
||||||
|
hasMore: boolean | null;
|
||||||
|
bytes: number;
|
||||||
|
}>;
|
||||||
|
clickedWorkspaces: Array<{ testId: string; frames: number; bytes: number }>;
|
||||||
|
providerSnapshots: Array<{
|
||||||
|
phase: WirePhase;
|
||||||
|
type: string;
|
||||||
|
requestId: string | null;
|
||||||
|
totalModels: number;
|
||||||
|
totalModes: number;
|
||||||
|
bytes: number;
|
||||||
|
providers: ProviderSnapshotWireEntry[];
|
||||||
|
}>;
|
||||||
|
providerSnapshotTotals: Array<{
|
||||||
|
phase: WirePhase;
|
||||||
|
provider: string;
|
||||||
|
frames: number;
|
||||||
|
bytes: number;
|
||||||
|
maxModels: number;
|
||||||
|
maxModes: number;
|
||||||
|
statuses: string[];
|
||||||
|
}>;
|
||||||
|
fork: {
|
||||||
|
sourceHome: string | null;
|
||||||
|
targetHome: string | null;
|
||||||
|
copiedFiles: number | null;
|
||||||
|
copiedBytes: number | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
class WireMonitor {
|
||||||
|
private phase: WirePhase = "startup";
|
||||||
|
private session: CDPSession | null = null;
|
||||||
|
readonly records: WireFrameRecord[] = [];
|
||||||
|
|
||||||
|
async start(page: Page): Promise<void> {
|
||||||
|
this.session = await page.context().newCDPSession(page);
|
||||||
|
await this.session.send("Network.enable");
|
||||||
|
this.session.on("Network.webSocketFrameSent", (event: WebSocketFrameEvent) => {
|
||||||
|
this.record("sent", event);
|
||||||
|
});
|
||||||
|
this.session.on("Network.webSocketFrameReceived", (event: WebSocketFrameEvent) => {
|
||||||
|
this.record("received", event);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setPhase(phase: WirePhase): void {
|
||||||
|
this.phase = phase;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasCompletedStartupFetches(): boolean {
|
||||||
|
return (
|
||||||
|
this.records.some(
|
||||||
|
(record) =>
|
||||||
|
record.direction === "received" &&
|
||||||
|
record.type === "fetch_agents_response" &&
|
||||||
|
record.hasMore === false,
|
||||||
|
) &&
|
||||||
|
this.records.some(
|
||||||
|
(record) =>
|
||||||
|
record.direction === "received" &&
|
||||||
|
record.type === "fetch_workspaces_response" &&
|
||||||
|
record.hasMore === false,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
summarize(clickedWorkspaces: WireSummary["clickedWorkspaces"]): WireSummary {
|
||||||
|
return {
|
||||||
|
totalFrames: this.records.length,
|
||||||
|
totalBytes: sumBytes(this.records),
|
||||||
|
byDirection: {
|
||||||
|
sent: summarizeDirection(this.records, "sent"),
|
||||||
|
received: summarizeDirection(this.records, "received"),
|
||||||
|
},
|
||||||
|
byPhase: {
|
||||||
|
startup: summarizePhase(this.records, "startup"),
|
||||||
|
workspace_clicks: summarizePhase(this.records, "workspace_clicks"),
|
||||||
|
},
|
||||||
|
fetchPages: this.records
|
||||||
|
.filter(
|
||||||
|
(record) =>
|
||||||
|
record.direction === "received" &&
|
||||||
|
(record.type === "fetch_agents_response" ||
|
||||||
|
record.type === "fetch_workspaces_response"),
|
||||||
|
)
|
||||||
|
.map((record) => ({
|
||||||
|
phase: record.phase,
|
||||||
|
type: record.type ?? "unknown",
|
||||||
|
requestId: record.requestId,
|
||||||
|
entries: record.entryCount,
|
||||||
|
hasMore: record.hasMore,
|
||||||
|
bytes: record.bytes,
|
||||||
|
})),
|
||||||
|
clickedWorkspaces,
|
||||||
|
providerSnapshots: this.records
|
||||||
|
.filter((record) => record.direction === "received" && record.providerEntries)
|
||||||
|
.map((record) => ({
|
||||||
|
phase: record.phase,
|
||||||
|
type: record.type ?? "unknown",
|
||||||
|
requestId: record.requestId,
|
||||||
|
totalModels: sumProviderModels(record.providerEntries ?? []),
|
||||||
|
totalModes: sumProviderModes(record.providerEntries ?? []),
|
||||||
|
bytes: record.bytes,
|
||||||
|
providers: record.providerEntries ?? [],
|
||||||
|
})),
|
||||||
|
providerSnapshotTotals: summarizeProviderSnapshots(this.records),
|
||||||
|
fork: {
|
||||||
|
sourceHome: process.env.E2E_FORK_SOURCE_PASEO_HOME ?? null,
|
||||||
|
targetHome: process.env.E2E_FORK_TARGET_PASEO_HOME ?? null,
|
||||||
|
copiedFiles: parseOptionalNumber(process.env.E2E_FORK_COPIED_FILES),
|
||||||
|
copiedBytes: parseOptionalNumber(process.env.E2E_FORK_COPIED_BYTES),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private record(direction: WireDirection, event: WebSocketFrameEvent): void {
|
||||||
|
if (event.response.opcode !== 1) {
|
||||||
|
this.records.push({
|
||||||
|
phase: this.phase,
|
||||||
|
direction,
|
||||||
|
bytes: Buffer.byteLength(event.response.payloadData),
|
||||||
|
type: `opcode:${event.response.opcode}`,
|
||||||
|
requestId: null,
|
||||||
|
entryCount: null,
|
||||||
|
hasMore: null,
|
||||||
|
providerEntries: null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.records.push({
|
||||||
|
phase: this.phase,
|
||||||
|
direction,
|
||||||
|
bytes: Buffer.byteLength(event.response.payloadData, "utf8"),
|
||||||
|
...parseWireMessage(event.response.payloadData),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOptionalNumber(value: string | undefined): number | null {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWireMessage(payloadData: string): ParsedWireMessage {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(payloadData) as unknown;
|
||||||
|
if (!parsed || typeof parsed !== "object") {
|
||||||
|
return emptyParsedWireMessage();
|
||||||
|
}
|
||||||
|
const envelope = parsed as {
|
||||||
|
type?: unknown;
|
||||||
|
message?: unknown;
|
||||||
|
};
|
||||||
|
const unwrapped =
|
||||||
|
envelope.type === "session" && envelope.message && typeof envelope.message === "object"
|
||||||
|
? envelope.message
|
||||||
|
: parsed;
|
||||||
|
const message = unwrapped as {
|
||||||
|
type?: unknown;
|
||||||
|
requestId?: unknown;
|
||||||
|
payload?: {
|
||||||
|
requestId?: unknown;
|
||||||
|
entries?: unknown;
|
||||||
|
agents?: unknown;
|
||||||
|
pageInfo?: { hasMore?: unknown };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const entries = readMessageEntries(message.payload);
|
||||||
|
const hasMore = readMessageHasMore(message.payload);
|
||||||
|
const messageType = typeof message.type === "string" ? message.type : null;
|
||||||
|
const providerEntries = isProviderSnapshotMessage(messageType)
|
||||||
|
? parseProviderSnapshotEntries(message.payload)
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
type: messageType,
|
||||||
|
requestId: readMessageRequestId(message),
|
||||||
|
entryCount: entries ? entries.length : null,
|
||||||
|
hasMore,
|
||||||
|
providerEntries,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return emptyParsedWireMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readMessageRequestId(message: {
|
||||||
|
requestId?: unknown;
|
||||||
|
payload?: { requestId?: unknown };
|
||||||
|
}): string | null {
|
||||||
|
if (typeof message.requestId === "string") {
|
||||||
|
return message.requestId;
|
||||||
|
}
|
||||||
|
if (typeof message.payload?.requestId === "string") {
|
||||||
|
return message.payload.requestId;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isProviderSnapshotMessage(messageType: string | null): boolean {
|
||||||
|
return (
|
||||||
|
messageType === "get_providers_snapshot_response" || messageType === "providers_snapshot_update"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readMessageEntries(
|
||||||
|
payload:
|
||||||
|
| {
|
||||||
|
entries?: unknown;
|
||||||
|
agents?: unknown;
|
||||||
|
}
|
||||||
|
| undefined,
|
||||||
|
): unknown[] | null {
|
||||||
|
if (Array.isArray(payload?.entries)) {
|
||||||
|
return payload.entries;
|
||||||
|
}
|
||||||
|
if (Array.isArray(payload?.agents)) {
|
||||||
|
return payload.agents;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readMessageHasMore(
|
||||||
|
payload: { pageInfo?: { hasMore?: unknown } } | undefined,
|
||||||
|
): boolean | null {
|
||||||
|
return typeof payload?.pageInfo?.hasMore === "boolean" ? payload.pageInfo.hasMore : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyParsedWireMessage(): ParsedWireMessage {
|
||||||
|
return {
|
||||||
|
type: null,
|
||||||
|
requestId: null,
|
||||||
|
entryCount: null,
|
||||||
|
hasMore: null,
|
||||||
|
providerEntries: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseProviderSnapshotEntries(payload: unknown): ProviderSnapshotWireEntry[] | null {
|
||||||
|
if (!payload || typeof payload !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const entries = (payload as { entries?: unknown }).entries;
|
||||||
|
if (!Array.isArray(entries)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerEntries: ProviderSnapshotWireEntry[] = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry || typeof entry !== "object") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const provider = (entry as { provider?: unknown }).provider;
|
||||||
|
if (typeof provider !== "string") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const models = (entry as { models?: unknown }).models;
|
||||||
|
const modes = (entry as { modes?: unknown }).modes;
|
||||||
|
const status = (entry as { status?: unknown }).status;
|
||||||
|
providerEntries.push({
|
||||||
|
provider,
|
||||||
|
status: typeof status === "string" ? status : null,
|
||||||
|
modelCount: Array.isArray(models) ? models.length : 0,
|
||||||
|
modeCount: Array.isArray(modes) ? modes.length : 0,
|
||||||
|
bytes: Buffer.byteLength(JSON.stringify(entry), "utf8"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return providerEntries.length > 0 ? providerEntries : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sumBytes(records: WireFrameRecord[]): number {
|
||||||
|
return records.reduce((sum, record) => sum + record.bytes, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeDirection(records: WireFrameRecord[], direction: WireDirection) {
|
||||||
|
const selected = records.filter((record) => record.direction === direction);
|
||||||
|
return {
|
||||||
|
frames: selected.length,
|
||||||
|
bytes: sumBytes(selected),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizePhase(records: WireFrameRecord[], phase: WirePhase) {
|
||||||
|
const selected = records.filter((record) => record.phase === phase);
|
||||||
|
return {
|
||||||
|
frames: selected.length,
|
||||||
|
bytes: sumBytes(selected),
|
||||||
|
byType: summarizeByType(selected),
|
||||||
|
rpcCounts: summarizeRpcCounts(selected),
|
||||||
|
rpcs: summarizeRpcs(selected),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeByType(records: WireFrameRecord[]): Array<{
|
||||||
|
type: string;
|
||||||
|
frames: number;
|
||||||
|
bytes: number;
|
||||||
|
}> {
|
||||||
|
const byType = new Map<string, { frames: number; bytes: number }>();
|
||||||
|
for (const record of records) {
|
||||||
|
const type = record.type ?? "unknown";
|
||||||
|
const current = byType.get(type) ?? { frames: 0, bytes: 0 };
|
||||||
|
current.frames += 1;
|
||||||
|
current.bytes += record.bytes;
|
||||||
|
byType.set(type, current);
|
||||||
|
}
|
||||||
|
return [...byType.entries()]
|
||||||
|
.map(([type, value]) => ({ type, ...value }))
|
||||||
|
.sort((left, right) => right.bytes - left.bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeRpcs(records: WireFrameRecord[]): WireSummary["byPhase"][WirePhase]["rpcs"] {
|
||||||
|
const responsesByRequestId = new Map<string, string>();
|
||||||
|
for (const record of records) {
|
||||||
|
if (record.direction !== "received" || !record.requestId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
responsesByRequestId.set(record.requestId, record.type ?? "unknown");
|
||||||
|
}
|
||||||
|
|
||||||
|
return records
|
||||||
|
.filter((record) => record.direction === "sent" && record.requestId && record.type)
|
||||||
|
.map((record) => ({
|
||||||
|
requestType: record.type ?? "unknown",
|
||||||
|
requestId: record.requestId ?? "",
|
||||||
|
responseType: responsesByRequestId.get(record.requestId ?? "") ?? null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeRpcCounts(
|
||||||
|
records: WireFrameRecord[],
|
||||||
|
): Array<{ requestType: string; count: number }> {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const record of records) {
|
||||||
|
if (record.direction !== "sent" || !record.type || !record.requestId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
counts.set(record.type, (counts.get(record.type) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
return [...counts.entries()]
|
||||||
|
.map(([requestType, count]) => ({ requestType, count }))
|
||||||
|
.sort(
|
||||||
|
(left, right) =>
|
||||||
|
right.count - left.count || left.requestType.localeCompare(right.requestType),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sumProviderModels(entries: ProviderSnapshotWireEntry[]): number {
|
||||||
|
return entries.reduce((sum, entry) => sum + entry.modelCount, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sumProviderModes(entries: ProviderSnapshotWireEntry[]): number {
|
||||||
|
return entries.reduce((sum, entry) => sum + entry.modeCount, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeProviderSnapshots(
|
||||||
|
records: WireFrameRecord[],
|
||||||
|
): WireSummary["providerSnapshotTotals"] {
|
||||||
|
const byProvider = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
phase: WirePhase;
|
||||||
|
provider: string;
|
||||||
|
frames: number;
|
||||||
|
bytes: number;
|
||||||
|
maxModels: number;
|
||||||
|
maxModes: number;
|
||||||
|
statuses: Set<string>;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (const record of records) {
|
||||||
|
if (record.direction !== "received" || !record.providerEntries) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const entry of record.providerEntries) {
|
||||||
|
const key = `${record.phase}:${entry.provider}`;
|
||||||
|
const current = byProvider.get(key) ?? {
|
||||||
|
phase: record.phase,
|
||||||
|
provider: entry.provider,
|
||||||
|
frames: 0,
|
||||||
|
bytes: 0,
|
||||||
|
maxModels: 0,
|
||||||
|
maxModes: 0,
|
||||||
|
statuses: new Set<string>(),
|
||||||
|
};
|
||||||
|
current.frames += 1;
|
||||||
|
current.bytes += entry.bytes;
|
||||||
|
current.maxModels = Math.max(current.maxModels, entry.modelCount);
|
||||||
|
current.maxModes = Math.max(current.maxModes, entry.modeCount);
|
||||||
|
if (entry.status) {
|
||||||
|
current.statuses.add(entry.status);
|
||||||
|
}
|
||||||
|
byProvider.set(key, current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...byProvider.values()]
|
||||||
|
.map((entry) => ({
|
||||||
|
phase: entry.phase,
|
||||||
|
provider: entry.provider,
|
||||||
|
frames: entry.frames,
|
||||||
|
bytes: entry.bytes,
|
||||||
|
maxModels: entry.maxModels,
|
||||||
|
maxModes: entry.maxModes,
|
||||||
|
statuses: [...entry.statuses].sort(),
|
||||||
|
}))
|
||||||
|
.sort((left, right) => right.bytes - left.bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attachSummary(testInfo: TestInfo, summary: WireSummary): Promise<void> {
|
||||||
|
await testInfo.attach("startup-wire-metrics.json", {
|
||||||
|
body: JSON.stringify(summary, null, 2),
|
||||||
|
contentType: "application/json",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("ad hoc startup wire metrics", () => {
|
||||||
|
test.skip(
|
||||||
|
process.env.E2E_WIRE_METRICS !== "1",
|
||||||
|
"Set E2E_WIRE_METRICS=1 to run this ad hoc measurement.",
|
||||||
|
);
|
||||||
|
|
||||||
|
test("measures startup hydration and workspace navigation websocket traffic", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
test.setTimeout(180_000);
|
||||||
|
|
||||||
|
const monitor = new WireMonitor();
|
||||||
|
await monitor.start(page);
|
||||||
|
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await waitForSidebarHydration(page, 120_000);
|
||||||
|
await expect.poll(() => monitor.hasCompletedStartupFetches(), { timeout: 120_000 }).toBe(true);
|
||||||
|
await page.waitForTimeout(1_000);
|
||||||
|
|
||||||
|
const workspaceTestIds = await page
|
||||||
|
.locator('[data-testid^="sidebar-workspace-row-"]:visible')
|
||||||
|
.evaluateAll((elements) =>
|
||||||
|
elements
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((element) => element.getAttribute("data-testid"))
|
||||||
|
.filter((value): value is string => Boolean(value)),
|
||||||
|
);
|
||||||
|
|
||||||
|
monitor.setPhase("workspace_clicks");
|
||||||
|
const clickedWorkspaces: WireSummary["clickedWorkspaces"] = [];
|
||||||
|
for (const testId of workspaceTestIds) {
|
||||||
|
const row = page.getByTestId(testId);
|
||||||
|
if (!(await row.isVisible().catch(() => false))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const beforeFrames = monitor.records.length;
|
||||||
|
const beforeBytes = sumBytes(monitor.records);
|
||||||
|
await row.click();
|
||||||
|
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||||
|
await page.waitForTimeout(1_000);
|
||||||
|
clickedWorkspaces.push({
|
||||||
|
testId,
|
||||||
|
frames: monitor.records.length - beforeFrames,
|
||||||
|
bytes: sumBytes(monitor.records) - beforeBytes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = monitor.summarize(clickedWorkspaces);
|
||||||
|
await attachSummary(testInfo, summary);
|
||||||
|
console.log("PASEO_STARTUP_WIRE_METRICS_BEGIN");
|
||||||
|
console.log(JSON.stringify(summary, null, 2));
|
||||||
|
console.log("PASEO_STARTUP_WIRE_METRICS_END");
|
||||||
|
|
||||||
|
expect(summary.byPhase.startup.byType.length).toBeGreaterThan(0);
|
||||||
|
expect(clickedWorkspaces.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import type { Page } from "@playwright/test";
|
||||||
|
import { test, expect } from "./fixtures";
|
||||||
|
import { TerminalE2EHarness, withTerminalInApp } from "./helpers/terminal-dsl";
|
||||||
|
import {
|
||||||
|
installTerminalRenderProbe,
|
||||||
|
readTerminalRenderProbe,
|
||||||
|
resetTerminalRenderProbe,
|
||||||
|
startTerminalFrameSampling,
|
||||||
|
summarizeTerminalRenderProbe,
|
||||||
|
} from "./helpers/terminal-probes";
|
||||||
|
import { getTerminalBufferText, waitForTerminalContent } from "./helpers/terminal-perf";
|
||||||
|
|
||||||
|
async function waitForAlternateScreenExit(page: Page, afterAlt: string, timeout: number) {
|
||||||
|
let lastBufferText = "";
|
||||||
|
let lastProbe = await readTerminalRenderProbe(page);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
lastBufferText = await getTerminalBufferText(page);
|
||||||
|
lastProbe = await readTerminalRenderProbe(page);
|
||||||
|
return (
|
||||||
|
lastProbe.altEnterWrites > 0 &&
|
||||||
|
lastProbe.altExitWrites > 0 &&
|
||||||
|
lastBufferText.includes(afterAlt)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
intervals: [50],
|
||||||
|
message: `wait for alternate-screen exit and ${afterAlt} output`,
|
||||||
|
timeout,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
throw new Error(
|
||||||
|
`Timed out waiting for alternate-screen exit: ${message}\n${JSON.stringify(
|
||||||
|
{
|
||||||
|
afterAlt,
|
||||||
|
probe: summarizeTerminalRenderProbe(lastProbe),
|
||||||
|
bufferTextTail: lastBufferText.slice(-500),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lastProbe;
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("Terminal alternate-screen transitions", () => {
|
||||||
|
let harness: TerminalE2EHarness;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
harness = await TerminalE2EHarness.create({ tempPrefix: "terminal-alt-" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await harness?.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("restores the normal screen after full-screen alternate buffer exit without remounting", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
|
||||||
|
await installTerminalRenderProbe(page);
|
||||||
|
|
||||||
|
await withTerminalInApp(page, harness, { name: "alternate-screen" }, async () => {
|
||||||
|
await harness.setupPrompt(page);
|
||||||
|
|
||||||
|
const terminal = harness.terminalSurface(page);
|
||||||
|
const historyReady = `HISTORY_READY_${Date.now()}`;
|
||||||
|
await terminal.pressSequentially(
|
||||||
|
`for i in $(seq 1 80); do echo HISTORY_$i; done; echo ${historyReady}\n`,
|
||||||
|
{ delay: 0 },
|
||||||
|
);
|
||||||
|
await waitForTerminalContent(page, (text) => text.includes(historyReady), 10_000);
|
||||||
|
|
||||||
|
await resetTerminalRenderProbe(page);
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
const settledProbe = await readTerminalRenderProbe(page);
|
||||||
|
expect(settledProbe.resetWrites, "terminal should be idle before alternate-screen act").toBe(
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
await resetTerminalRenderProbe(page);
|
||||||
|
|
||||||
|
const afterAlt = `AFTER_ALT_${Date.now()}`;
|
||||||
|
await startTerminalFrameSampling(page);
|
||||||
|
await terminal.pressSequentially(
|
||||||
|
`printf '\\033[?1049h\\033[2J\\033[HALT_SCREEN_TOP\\n'; sleep 0.25; printf '\\033[?1049l'; echo ${afterAlt}\n`,
|
||||||
|
{ delay: 0 },
|
||||||
|
);
|
||||||
|
const probe = await waitForAlternateScreenExit(page, afterAlt, 10_000);
|
||||||
|
const probeSummary = summarizeTerminalRenderProbe(probe);
|
||||||
|
|
||||||
|
await testInfo.attach("alternate-screen-probe", {
|
||||||
|
body: JSON.stringify({ summary: probeSummary, probe }, null, 2),
|
||||||
|
contentType: "application/json",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(probe.setCount, "terminal instance should not be replaced after attach").toBe(0);
|
||||||
|
expect(probe.unsetCount, "terminal instance should not be unset after attach").toBe(0);
|
||||||
|
expect(
|
||||||
|
probe.altEnterWrites,
|
||||||
|
"test command should enter the alternate screen",
|
||||||
|
).toBeGreaterThan(0);
|
||||||
|
expect(probe.altExitWrites, "test command should exit the alternate screen").toBeGreaterThan(
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
expect(probe.resetWrites, "alternate-screen exit should not replay a snapshot reset").toBe(0);
|
||||||
|
|
||||||
|
const finalBufferText = await getTerminalBufferText(page);
|
||||||
|
expect(finalBufferText).toContain(historyReady);
|
||||||
|
expect(finalBufferText).toContain(afterAlt);
|
||||||
|
|
||||||
|
const suspiciousFrames = probe.frames.filter(
|
||||||
|
(frame) =>
|
||||||
|
frame.text.includes("$") &&
|
||||||
|
!frame.text.includes(historyReady) &&
|
||||||
|
!frame.text.includes(afterAlt) &&
|
||||||
|
frame.nonEmptyRows <= 2 &&
|
||||||
|
(frame.firstNonEmptyRow ?? Number.POSITIVE_INFINITY) <= 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
suspiciousFrames,
|
||||||
|
"normal-screen restore should not flash to a mostly blank prompt-at-top frame",
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
import { performance } from "node:perf_hooks";
|
||||||
|
import type { Page, TestInfo } from "@playwright/test";
|
||||||
|
import { test, expect } from "./fixtures";
|
||||||
|
import { TerminalE2EHarness, type TerminalInstance } from "./helpers/terminal-dsl";
|
||||||
|
import {
|
||||||
|
installTerminalKeystrokeStressProbe,
|
||||||
|
readTerminalKeystrokeStressReport,
|
||||||
|
resetTerminalKeystrokeStressProbe,
|
||||||
|
type LatencyStats,
|
||||||
|
} from "./helpers/terminal-probes";
|
||||||
|
import { waitForTerminalContent } from "./helpers/terminal-perf";
|
||||||
|
|
||||||
|
const INPUT_TEXT = buildStressText(600);
|
||||||
|
const BIG_DIFF_BYTES = 256_000;
|
||||||
|
const SMALL_AGENT_STREAM_UPDATES = 1000;
|
||||||
|
const STRESS_TIMEOUT_MS = 15_000;
|
||||||
|
const RUN_MANUAL_TERMINAL_PERF = process.env.PASEO_TERMINAL_PERF_E2E === "1";
|
||||||
|
const terminalPerfDescribe = RUN_MANUAL_TERMINAL_PERF ? test.describe : test.describe.skip;
|
||||||
|
|
||||||
|
type DaemonEchoReport = {
|
||||||
|
inputTextLength: number;
|
||||||
|
inputFrameCount: number;
|
||||||
|
outputEventCount: number;
|
||||||
|
echoedBytes: number;
|
||||||
|
sendToOutputMs: LatencyStats;
|
||||||
|
firstSendAt: number;
|
||||||
|
lastOutputAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
terminalPerfDescribe("Terminal keystroke stress", () => {
|
||||||
|
let harness: TerminalE2EHarness;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
harness = await TerminalE2EHarness.create({ tempPrefix: "terminal-key-stress-" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await harness?.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("logs daemon-only and app keystroke echo latency under burst input", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
test.setTimeout(75_000);
|
||||||
|
|
||||||
|
const daemonTerminal = await harness.createTerminal({ name: "daemon-keystroke-baseline" });
|
||||||
|
try {
|
||||||
|
const daemonReport = await measureDaemonBurstEcho(harness, daemonTerminal, INPUT_TEXT);
|
||||||
|
await attachJson(testInfo, "daemon-keystroke-baseline", daemonReport);
|
||||||
|
console.log("[terminal-stress-daemon]", JSON.stringify(daemonReport));
|
||||||
|
|
||||||
|
expect(daemonReport.echoedBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length);
|
||||||
|
} finally {
|
||||||
|
await harness.killTerminal(daemonTerminal.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
await installTerminalKeystrokeStressProbe(page);
|
||||||
|
const appBaselineReport = await measureAppBurstEcho({
|
||||||
|
page,
|
||||||
|
harness,
|
||||||
|
terminalName: "app-keystroke-stress",
|
||||||
|
});
|
||||||
|
await attachJson(testInfo, "app-keystroke-stress", appBaselineReport);
|
||||||
|
console.log("[terminal-stress-app]", JSON.stringify(appBaselineReport));
|
||||||
|
|
||||||
|
expect(appBaselineReport.keydownCount).toBeGreaterThanOrEqual(INPUT_TEXT.length);
|
||||||
|
expect(appBaselineReport.inputFramePayloadBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length);
|
||||||
|
expect(appBaselineReport.outputFramePayloadBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length);
|
||||||
|
expect(appBaselineReport.keydownToXtermCommitMs?.count ?? 0).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const appSmallChunksReport = await measureAppBurstEcho({
|
||||||
|
page,
|
||||||
|
harness,
|
||||||
|
terminalName: "app-keystroke-stress-small-agent-chunks",
|
||||||
|
agentStreamUpdateCount: SMALL_AGENT_STREAM_UPDATES,
|
||||||
|
});
|
||||||
|
await attachJson(testInfo, "app-keystroke-stress-small-agent-chunks", appSmallChunksReport);
|
||||||
|
console.log("[terminal-stress-app-small-agent-chunks]", JSON.stringify(appSmallChunksReport));
|
||||||
|
|
||||||
|
expect(appSmallChunksReport.keydownCount).toBeGreaterThanOrEqual(INPUT_TEXT.length);
|
||||||
|
expect(appSmallChunksReport.inputFramePayloadBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length);
|
||||||
|
expect(appSmallChunksReport.outputFramePayloadBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length);
|
||||||
|
expect(appSmallChunksReport.keydownToXtermCommitMs?.count ?? 0).toBeGreaterThan(0);
|
||||||
|
expect(appSmallChunksReport.agentStreamTextMessageCount).toBeGreaterThanOrEqual(
|
||||||
|
SMALL_AGENT_STREAM_UPDATES,
|
||||||
|
);
|
||||||
|
|
||||||
|
const appBigDiffReport = await measureAppBurstEcho({
|
||||||
|
page,
|
||||||
|
harness,
|
||||||
|
terminalName: "app-keystroke-stress-big-diff",
|
||||||
|
bigDiffBytes: BIG_DIFF_BYTES,
|
||||||
|
});
|
||||||
|
await attachJson(testInfo, "app-keystroke-stress-big-diff", appBigDiffReport);
|
||||||
|
console.log("[terminal-stress-app-big-diff]", JSON.stringify(appBigDiffReport));
|
||||||
|
|
||||||
|
expect(appBigDiffReport.keydownCount).toBeGreaterThanOrEqual(INPUT_TEXT.length);
|
||||||
|
expect(appBigDiffReport.inputFramePayloadBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length);
|
||||||
|
expect(appBigDiffReport.outputFramePayloadBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length);
|
||||||
|
expect(appBigDiffReport.keydownToXtermCommitMs?.count ?? 0).toBeGreaterThan(0);
|
||||||
|
expect(appBigDiffReport.largeAgentStreamTextMessageCount).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(appBigDiffReport.largestAgentStreamTextMessageBytes).toBeGreaterThanOrEqual(
|
||||||
|
BIG_DIFF_BYTES,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function measureAppBurstEcho(input: {
|
||||||
|
page: Page;
|
||||||
|
harness: TerminalE2EHarness;
|
||||||
|
terminalName: string;
|
||||||
|
bigDiffBytes?: number;
|
||||||
|
agentStreamUpdateCount?: number;
|
||||||
|
}) {
|
||||||
|
const appTerminal = await input.harness.createTerminal({ name: input.terminalName });
|
||||||
|
try {
|
||||||
|
await input.harness.openTerminal(input.page, { terminalId: appTerminal.id });
|
||||||
|
await input.harness.setupPrompt(input.page);
|
||||||
|
|
||||||
|
const agent =
|
||||||
|
input.bigDiffBytes === undefined && input.agentStreamUpdateCount === undefined
|
||||||
|
? null
|
||||||
|
: await input.harness.client.createAgent({
|
||||||
|
provider: "mock",
|
||||||
|
cwd: input.harness.tempRepo.path,
|
||||||
|
title: "Large WebSocket payload",
|
||||||
|
modeId: "load-test",
|
||||||
|
});
|
||||||
|
|
||||||
|
const bigDiffBytes = input.bigDiffBytes;
|
||||||
|
const agentStreamUpdateCount = input.agentStreamUpdateCount;
|
||||||
|
const startAgentLoad =
|
||||||
|
agent === null
|
||||||
|
? null
|
||||||
|
: () => {
|
||||||
|
if (bigDiffBytes !== undefined) {
|
||||||
|
return emitLargeDiffAgentPayload(input.harness, {
|
||||||
|
agentId: agent.id,
|
||||||
|
bytes: bigDiffBytes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (agentStreamUpdateCount !== undefined) {
|
||||||
|
return emitRapidAgentStreamUpdates(input.harness, {
|
||||||
|
agentId: agent.id,
|
||||||
|
count: agentStreamUpdateCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
const terminal = input.harness.terminalSurface(input.page);
|
||||||
|
await terminal.press("Control+c");
|
||||||
|
await input.page.waitForTimeout(200);
|
||||||
|
await resetTerminalKeystrokeStressProbe(input.page);
|
||||||
|
|
||||||
|
const activeAgentLoadPromise = startAgentLoad === null ? Promise.resolve() : startAgentLoad();
|
||||||
|
|
||||||
|
await terminal.pressSequentially(INPUT_TEXT, { delay: 0 });
|
||||||
|
await waitForAppStressEcho(input.page, INPUT_TEXT);
|
||||||
|
await waitForAppProbePayload(input.page, INPUT_TEXT.length);
|
||||||
|
if (input.bigDiffBytes !== undefined) {
|
||||||
|
await waitForLargeAgentStreamMessage(input.page, input.bigDiffBytes);
|
||||||
|
}
|
||||||
|
if (input.agentStreamUpdateCount !== undefined) {
|
||||||
|
await waitForAgentStreamMessages(input.page, input.agentStreamUpdateCount);
|
||||||
|
}
|
||||||
|
await activeAgentLoadPromise;
|
||||||
|
|
||||||
|
return readTerminalKeystrokeStressReport(input.page, INPUT_TEXT);
|
||||||
|
} finally {
|
||||||
|
await input.harness.killTerminal(appTerminal.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function emitRapidAgentStreamUpdates(
|
||||||
|
harness: TerminalE2EHarness,
|
||||||
|
input: { agentId: string; count: number },
|
||||||
|
): Promise<void> {
|
||||||
|
await harness.client.sendAgentMessage(input.agentId, `emit ${input.count} agent stream updates`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function emitLargeDiffAgentPayload(
|
||||||
|
harness: TerminalE2EHarness,
|
||||||
|
input: { agentId: string; bytes: number },
|
||||||
|
): Promise<void> {
|
||||||
|
await harness.client.sendAgentMessage(
|
||||||
|
input.agentId,
|
||||||
|
`emit ${input.bytes} byte large diff agent stream update`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function measureDaemonBurstEcho(
|
||||||
|
harness: TerminalE2EHarness,
|
||||||
|
terminal: TerminalInstance,
|
||||||
|
inputText: string,
|
||||||
|
): Promise<DaemonEchoReport> {
|
||||||
|
await harness.client.subscribeTerminal(terminal.id);
|
||||||
|
|
||||||
|
const outputTimesByByte: number[] = [];
|
||||||
|
let outputEventCount = 0;
|
||||||
|
let echoedBytes = 0;
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
const unsubscribe = harness.client.onTerminalStreamEvent((event) => {
|
||||||
|
if (event.terminalId !== terminal.id || event.type !== "output" || !event.data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
outputEventCount += 1;
|
||||||
|
const text = decoder.decode(event.data);
|
||||||
|
const now = performance.now();
|
||||||
|
const previousEchoedBytes = echoedBytes;
|
||||||
|
echoedBytes += text.length;
|
||||||
|
for (let index = previousEchoedBytes; index < echoedBytes; index += 1) {
|
||||||
|
outputTimesByByte[index] = now;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||||
|
echoedBytes = 0;
|
||||||
|
outputTimesByByte.length = 0;
|
||||||
|
|
||||||
|
const sendTimes: number[] = [];
|
||||||
|
for (const char of inputText) {
|
||||||
|
sendTimes.push(performance.now());
|
||||||
|
harness.client.sendTerminalInput(terminal.id, {
|
||||||
|
type: "input",
|
||||||
|
data: char,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await waitForDaemonEchoBytes({
|
||||||
|
getEchoedBytes: () => echoedBytes,
|
||||||
|
expectedBytes: inputText.length,
|
||||||
|
timeoutMs: STRESS_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
|
||||||
|
const latencies = sendTimes.map((sentAt, index) => outputTimesByByte[index]! - sentAt);
|
||||||
|
return {
|
||||||
|
inputTextLength: inputText.length,
|
||||||
|
inputFrameCount: sendTimes.length,
|
||||||
|
outputEventCount,
|
||||||
|
echoedBytes,
|
||||||
|
sendToOutputMs: summarizeLatency(latencies),
|
||||||
|
firstSendAt: sendTimes[0] ?? 0,
|
||||||
|
lastOutputAt: outputTimesByByte[inputText.length - 1] ?? 0,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
unsubscribe();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForDaemonEchoBytes(input: {
|
||||||
|
getEchoedBytes: () => number;
|
||||||
|
expectedBytes: number;
|
||||||
|
timeoutMs: number;
|
||||||
|
}): Promise<void> {
|
||||||
|
const deadline = Date.now() + input.timeoutMs;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (input.getEchoedBytes() >= input.expectedBytes) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`Timed out waiting for daemon echo bytes: ${input.getEchoedBytes()}/${input.expectedBytes}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForAppStressEcho(page: Page, text: string): Promise<void> {
|
||||||
|
const tail = text.slice(-80);
|
||||||
|
await waitForTerminalContent(page, (content) => content.includes(tail), STRESS_TIMEOUT_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForAppProbePayload(page: Page, expectedBytes: number): Promise<void> {
|
||||||
|
const deadline = Date.now() + STRESS_TIMEOUT_MS;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const report = await readTerminalKeystrokeStressReport(page, INPUT_TEXT);
|
||||||
|
if (report.outputFramePayloadBytes >= expectedBytes) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForLargeAgentStreamMessage(page: Page, expectedBytes: number): Promise<void> {
|
||||||
|
const deadline = Date.now() + STRESS_TIMEOUT_MS;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const report = await readTerminalKeystrokeStressReport(page, INPUT_TEXT);
|
||||||
|
if (report.largestAgentStreamTextMessageBytes >= expectedBytes) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(25);
|
||||||
|
}
|
||||||
|
const report = await readTerminalKeystrokeStressReport(page, INPUT_TEXT);
|
||||||
|
throw new Error(
|
||||||
|
`Timed out waiting for large agent_stream message: largest=${report.largestAgentStreamTextMessageBytes}, expected=${expectedBytes}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForAgentStreamMessages(page: Page, expectedCount: number): Promise<void> {
|
||||||
|
const deadline = Date.now() + STRESS_TIMEOUT_MS;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const report = await readTerminalKeystrokeStressReport(page, INPUT_TEXT);
|
||||||
|
if (report.agentStreamTextMessageCount >= expectedCount) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(25);
|
||||||
|
}
|
||||||
|
const report = await readTerminalKeystrokeStressReport(page, INPUT_TEXT);
|
||||||
|
throw new Error(
|
||||||
|
`Timed out waiting for agent_stream messages: count=${report.agentStreamTextMessageCount}, expected=${expectedCount}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attachJson(testInfo: TestInfo, name: string, value: unknown): Promise<void> {
|
||||||
|
await testInfo.attach(name, {
|
||||||
|
body: JSON.stringify(value, null, 2),
|
||||||
|
contentType: "application/json",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStressText(length: number): string {
|
||||||
|
const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||||
|
let output = "";
|
||||||
|
while (output.length < length) {
|
||||||
|
output += alphabet;
|
||||||
|
}
|
||||||
|
return output.slice(0, length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeLatency(values: number[]): LatencyStats {
|
||||||
|
const sorted = [...values].sort((a, b) => a - b);
|
||||||
|
const percentile = (p: number) => {
|
||||||
|
const index = Math.ceil((p / 100) * sorted.length) - 1;
|
||||||
|
return sorted[Math.max(0, Math.min(sorted.length - 1, index))] ?? 0;
|
||||||
|
};
|
||||||
|
const total = values.reduce((sum, value) => sum + value, 0);
|
||||||
|
return {
|
||||||
|
count: values.length,
|
||||||
|
minMs: round2(sorted[0] ?? 0),
|
||||||
|
p50Ms: round2(percentile(50)),
|
||||||
|
p95Ms: round2(percentile(95)),
|
||||||
|
maxMs: round2(sorted[sorted.length - 1] ?? 0),
|
||||||
|
avgMs: round2(total / values.length),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function round2(value: number): number {
|
||||||
|
return Math.round(value * 100) / 100;
|
||||||
|
}
|
||||||
@@ -16,8 +16,10 @@ const LINE_COUNT = 50_000;
|
|||||||
const THROUGHPUT_BUDGET_MS = 30_000;
|
const THROUGHPUT_BUDGET_MS = 30_000;
|
||||||
const KEYSTROKE_SAMPLE_COUNT = 20;
|
const KEYSTROKE_SAMPLE_COUNT = 20;
|
||||||
const KEYSTROKE_P95_BUDGET_MS = 150;
|
const KEYSTROKE_P95_BUDGET_MS = 150;
|
||||||
|
const RUN_MANUAL_TERMINAL_PERF = process.env.PASEO_TERMINAL_PERF_E2E === "1";
|
||||||
|
const terminalPerfDescribe = RUN_MANUAL_TERMINAL_PERF ? test.describe : test.describe.skip;
|
||||||
|
|
||||||
test.describe("Terminal wire performance", () => {
|
terminalPerfDescribe("Terminal wire performance", () => {
|
||||||
let client: TerminalPerfDaemonClient;
|
let client: TerminalPerfDaemonClient;
|
||||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||||
let workspaceId: string;
|
let workspaceId: string;
|
||||||
|
|||||||
@@ -96,6 +96,14 @@ test.describe("Workspace navigation regression", () => {
|
|||||||
`workspace-tab-agent_${firstAgent.id}`,
|
`workspace-tab-agent_${firstAgent.id}`,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const firstDeckEntry = page.getByTestId(
|
||||||
|
`workspace-deck-entry-${serverId}:${firstWorkspace.workspaceId}`,
|
||||||
|
);
|
||||||
|
const secondDeckEntry = page.getByTestId(
|
||||||
|
`workspace-deck-entry-${serverId}:${secondWorkspace.workspaceId}`,
|
||||||
|
);
|
||||||
|
await expect(firstDeckEntry).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
await switchWorkspaceViaSidebar({
|
await switchWorkspaceViaSidebar({
|
||||||
page,
|
page,
|
||||||
serverId,
|
serverId,
|
||||||
@@ -126,27 +134,74 @@ test.describe("Workspace navigation regression", () => {
|
|||||||
await expect(getVisibleWorkspaceAgentTabIds(page)).resolves.toEqual([
|
await expect(getVisibleWorkspaceAgentTabIds(page)).resolves.toEqual([
|
||||||
`workspace-tab-agent_${secondAgent.id}`,
|
`workspace-tab-agent_${secondAgent.id}`,
|
||||||
]);
|
]);
|
||||||
|
await expect(firstDeckEntry).toBeAttached();
|
||||||
|
await expect(firstDeckEntry).toBeHidden();
|
||||||
|
await expect(secondDeckEntry).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(2);
|
||||||
|
|
||||||
|
await page.evaluate(
|
||||||
|
({ agentId, serverId }) => {
|
||||||
|
globalThis.dispatchEvent(
|
||||||
|
new CustomEvent("paseo:web-notification-click", {
|
||||||
|
detail: {
|
||||||
|
data: {
|
||||||
|
serverId,
|
||||||
|
agentId,
|
||||||
|
reason: "finished",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cancelable: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ agentId: secondAgent.id, serverId },
|
||||||
|
);
|
||||||
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await expect(secondDeckEntry).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expectWorkspaceTabVisible(page, secondAgent.id);
|
||||||
|
await expectWorkspaceTabHidden(page, firstAgent.id);
|
||||||
|
await expectOnlyWorkspaceAgentTabsVisible(page, [secondAgent.id]);
|
||||||
|
await expect(firstDeckEntry).toBeAttached();
|
||||||
|
await expect(firstDeckEntry).toBeHidden();
|
||||||
|
await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(2);
|
||||||
|
|
||||||
|
await switchWorkspaceViaSidebar({
|
||||||
|
page,
|
||||||
|
serverId,
|
||||||
|
targetWorkspacePath: firstWorkspace.workspaceId,
|
||||||
|
});
|
||||||
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId), {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await expect(firstDeckEntry).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(secondDeckEntry).toBeAttached();
|
||||||
|
await expect(secondDeckEntry).toBeHidden();
|
||||||
|
await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(2);
|
||||||
|
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await waitForSidebarHydration(page);
|
await waitForSidebarHydration(page);
|
||||||
await waitForWorkspaceTabsVisible(page);
|
await waitForWorkspaceTabsVisible(page);
|
||||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), {
|
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId), {
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
await expectSidebarWorkspaceSelected({
|
await expectSidebarWorkspaceSelected({
|
||||||
page,
|
page,
|
||||||
serverId,
|
serverId,
|
||||||
workspaceId: secondWorkspace.workspaceId,
|
workspaceId: firstWorkspace.workspaceId,
|
||||||
});
|
});
|
||||||
await expectWorkspaceHeader(page, {
|
await expectWorkspaceHeader(page, {
|
||||||
title: secondWorkspace.workspaceName,
|
title: firstWorkspace.workspaceName,
|
||||||
subtitle: secondWorkspace.projectDisplayName,
|
subtitle: firstWorkspace.projectDisplayName,
|
||||||
});
|
});
|
||||||
await expectWorkspaceTabVisible(page, secondAgent.id);
|
await expectWorkspaceTabVisible(page, firstAgent.id);
|
||||||
await expectWorkspaceTabHidden(page, firstAgent.id);
|
await expectWorkspaceTabHidden(page, secondAgent.id);
|
||||||
await expectOnlyWorkspaceAgentTabsVisible(page, [secondAgent.id]);
|
await expectOnlyWorkspaceAgentTabsVisible(page, [firstAgent.id]);
|
||||||
await expect(getVisibleWorkspaceAgentTabIds(page)).resolves.toEqual([
|
await expect(getVisibleWorkspaceAgentTabIds(page)).resolves.toEqual([
|
||||||
`workspace-tab-agent_${secondAgent.id}`,
|
`workspace-tab-agent_${firstAgent.id}`,
|
||||||
]);
|
]);
|
||||||
} finally {
|
} finally {
|
||||||
for (const agentId of agentIds) {
|
for (const agentId of agentIds) {
|
||||||
|
|||||||
@@ -19,6 +19,18 @@ function getServerId(): string {
|
|||||||
return serverId;
|
return serverId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WorkspaceScriptStarter = {
|
||||||
|
startWorkspaceScript(
|
||||||
|
workspaceId: string,
|
||||||
|
scriptName: string,
|
||||||
|
): Promise<{
|
||||||
|
workspaceId: string;
|
||||||
|
scriptName: string;
|
||||||
|
terminalId: string | null;
|
||||||
|
error: string | null;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
/** Click the sidebar row for a workspace (by ID) and wait for navigation. */
|
/** Click the sidebar row for a workspace (by ID) and wait for navigation. */
|
||||||
async function navigateToWorkspaceViaSidebar(
|
async function navigateToWorkspaceViaSidebar(
|
||||||
page: import("@playwright/test").Page,
|
page: import("@playwright/test").Page,
|
||||||
@@ -94,7 +106,7 @@ test.describe("Workspace setup streaming", () => {
|
|||||||
await navigateToWorkspaceViaSidebar(page, workspace.id);
|
await navigateToWorkspaceViaSidebar(page, workspace.id);
|
||||||
|
|
||||||
await waitForWorkspaceTabsVisible(page);
|
await waitForWorkspaceTabsVisible(page);
|
||||||
await page.getByTestId("workspace-new-agent-tab").first().click();
|
await page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first().click();
|
||||||
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
|
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
@@ -226,7 +238,7 @@ test.describe("Workspace setup streaming", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("launches script terminals after setup completes", async ({ page }) => {
|
test("launches script terminals from the workspace scripts menu", async ({ page }) => {
|
||||||
test.setTimeout(90_000);
|
test.setTimeout(90_000);
|
||||||
const client = await connectWorkspaceSetupClient();
|
const client = await connectWorkspaceSetupClient();
|
||||||
const repo = await createTempGitRepo("setup-svc-ui-", {
|
const repo = await createTempGitRepo("setup-svc-ui-", {
|
||||||
@@ -263,12 +275,15 @@ test.describe("Workspace setup streaming", () => {
|
|||||||
|
|
||||||
await waitForWorkspaceTabsVisible(page);
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
|
||||||
// Wait for the script terminal tab to appear in the tabs bar.
|
await expect(page.locator('[data-testid^="workspace-tab-terminal_"]')).toHaveCount(0);
|
||||||
// The tab title shows the command, not the script name.
|
await page.getByTestId("workspace-scripts-button").click();
|
||||||
|
await expect(page.getByTestId("workspace-scripts-menu")).toBeVisible({ timeout: 10_000 });
|
||||||
|
await page.getByTestId("workspace-scripts-start-web").click();
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
|
||||||
const terminalTab = page.locator('[data-testid^="workspace-tab-terminal_"]').first();
|
const terminalTab = page.locator('[data-testid^="workspace-tab-terminal_"]').first();
|
||||||
await expect(terminalTab).toBeVisible({ timeout: 30_000 });
|
await expect(terminalTab).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
// Click the script terminal tab
|
|
||||||
await terminalTab.click();
|
await terminalTab.click();
|
||||||
|
|
||||||
// Verify the terminal surface rendered
|
// Verify the terminal surface rendered
|
||||||
@@ -294,7 +309,7 @@ test.describe("Workspace setup streaming", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("launches workspace scripts after setup completes", async () => {
|
test("launches workspace scripts through an explicit daemon request", async () => {
|
||||||
const client = await connectWorkspaceSetupClient();
|
const client = await connectWorkspaceSetupClient();
|
||||||
const repo = await createTempGitRepo("setup-scripts-", {
|
const repo = await createTempGitRepo("setup-scripts-", {
|
||||||
paseoConfig: {
|
paseoConfig: {
|
||||||
@@ -303,7 +318,7 @@ test.describe("Workspace setup streaming", () => {
|
|||||||
},
|
},
|
||||||
scripts: {
|
scripts: {
|
||||||
editor: {
|
editor: {
|
||||||
command: "npm run dev",
|
command: "node -e \"console.log('editor ready'); setInterval(() => {}, 1000)\"",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -325,9 +340,19 @@ test.describe("Workspace setup streaming", () => {
|
|||||||
throw new Error(result.error ?? "Failed to create workspace");
|
throw new Error(result.error ?? "Failed to create workspace");
|
||||||
}
|
}
|
||||||
const workspaceDir = result.workspace.workspaceDirectory;
|
const workspaceDir = result.workspace.workspaceDirectory;
|
||||||
|
const workspaceId = String(result.workspace.id);
|
||||||
|
|
||||||
await completed;
|
await completed;
|
||||||
|
|
||||||
|
const scriptClient = client as typeof client & WorkspaceScriptStarter;
|
||||||
|
const startResult = await scriptClient.startWorkspaceScript(workspaceId, "editor");
|
||||||
|
expect(startResult).toMatchObject({
|
||||||
|
workspaceId,
|
||||||
|
scriptName: "editor",
|
||||||
|
terminalId: expect.any(String),
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
const terminals = await client.listTerminals(workspaceDir);
|
const terminals = await client.listTerminals(workspaceDir);
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Maestro Flows
|
||||||
|
|
||||||
|
This directory contains local mobile UI flows. Keep flows small enough that a
|
||||||
|
failure screenshot proves the intended behavior, not just that the app launched.
|
||||||
|
|
||||||
|
## New Workspace Android Flow
|
||||||
|
|
||||||
|
Use these files when debugging or extending workspace creation on Android:
|
||||||
|
|
||||||
|
- `test-workspace-create-android-crash.sh` runs the full regression harness.
|
||||||
|
- `workspace-create-android-crash.yaml` is the full Maestro flow used by the
|
||||||
|
harness.
|
||||||
|
- `record-workspace-create-android-focus.sh` records only the focused repro
|
||||||
|
window after setup.
|
||||||
|
- `workspace-create-android-ready-sidebar.yaml` stages the app with the Android
|
||||||
|
sidebar open and a prepared project visible.
|
||||||
|
- `workspace-create-android-create-focused.yaml` starts from that staged sidebar
|
||||||
|
and performs the actual workspace creation.
|
||||||
|
|
||||||
|
The reusable pieces live in `flows/`:
|
||||||
|
|
||||||
|
- `flows/android-dev-client.yaml` handles Expo dev launcher/dev menu screens.
|
||||||
|
- `flows/connect-direct-if-welcome.yaml` connects to the local daemon only when
|
||||||
|
the welcome screen is visible.
|
||||||
|
- `flows/open-prepared-project-sidebar.yaml` waits for the home screen, opens
|
||||||
|
the compact Android sidebar, and waits for the prepared project.
|
||||||
|
- `flows/new-workspace-open-from-sidebar.yaml` taps the project row's
|
||||||
|
new-workspace action and waits for `/new`.
|
||||||
|
- `flows/new-workspace-select-codex-gpt54.yaml` selects a real provider/model.
|
||||||
|
- `flows/new-workspace-submit-and-assert-created.yaml` taps `Create` and proves
|
||||||
|
the app landed on the created workspace.
|
||||||
|
|
||||||
|
Compose new workspace scenarios out of these primitives instead of copying the
|
||||||
|
old full flow. The shell scripts render the top-level flows and every `flows/*.yaml`
|
||||||
|
file into the same temp directory, so nested `runFlow: flows/...` paths keep
|
||||||
|
working with `${PASEO_MAESTRO_*}` placeholders.
|
||||||
|
|
||||||
|
The flow is intentionally strict. It must:
|
||||||
|
|
||||||
|
1. Open a prepared project from the daemon.
|
||||||
|
2. Tap the project row's new-workspace action.
|
||||||
|
3. Select an actual provider/model before tapping `Create`.
|
||||||
|
4. Tap `Create`.
|
||||||
|
5. Assert the app lands on a workspace header and the draft composer.
|
||||||
|
6. Assert `New workspace`, `Select a model`, and the Android redbox text are not
|
||||||
|
visible.
|
||||||
|
7. For the shell harness, grep logcat for `failed to insert view` and
|
||||||
|
`specified child already has a parent`.
|
||||||
|
|
||||||
|
Do not weaken this flow to only wait for `message-input-root`. That can pass on
|
||||||
|
the wrong route. The header assertion and the `New workspace` negative assertion
|
||||||
|
are what prove the redirect actually completed.
|
||||||
|
|
||||||
|
The scripts assume a development build with package id `sh.paseo.debug`, an
|
||||||
|
already-running local daemon on `127.0.0.1:6767`, and a connected Android device
|
||||||
|
or emulator. They call `adb reverse tcp:6767 tcp:6767`; they do not restart the
|
||||||
|
daemon.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash packages/app/maestro/test-workspace-create-android-crash.sh
|
||||||
|
bash packages/app/maestro/record-workspace-create-android-focus.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional environment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PASEO_MAESTRO_APP_ID=sh.paseo.debug
|
||||||
|
PASEO_MAESTRO_DIRECT_ENDPOINT=127.0.0.1:6767
|
||||||
|
PASEO_MAESTRO_DAEMON_WS_URL=ws://127.0.0.1:6767/ws
|
||||||
|
PASEO_MAESTRO_PROJECT_PATH=/path/to/git/repo
|
||||||
|
```
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# NOTE: Render through the workspace-create shell scripts before running when
|
||||||
|
# using ${PASEO_MAESTRO_APP_ID}. Maestro does not substitute environment vars.
|
||||||
|
appId: ${PASEO_MAESTRO_APP_ID}
|
||||||
|
---
|
||||||
|
# Handle Expo dev launcher/dev menu screens when testing Android development
|
||||||
|
# builds. Release builds no-op through these optional/conditional steps.
|
||||||
|
|
||||||
|
- tapOn:
|
||||||
|
text: ".*(localhost|127\\.0\\.0\\.1|10\\.0\\.2\\.2|192\\.168\\.).*"
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "DEVELOPMENT SERVERS"
|
||||||
|
commands:
|
||||||
|
# Expo dev launcher text matching is flaky on Android here; tap the first
|
||||||
|
# development server row by coordinate as the fallback.
|
||||||
|
- tapOn:
|
||||||
|
point: "50%,8%"
|
||||||
|
|
||||||
|
- waitForAnimationToEnd
|
||||||
|
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Continue"
|
||||||
|
commands:
|
||||||
|
- tapOn: "Continue"
|
||||||
|
|
||||||
|
- tapOn:
|
||||||
|
text: "Continue"
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
- waitForAnimationToEnd
|
||||||
|
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Performance monitor"
|
||||||
|
commands:
|
||||||
|
# Close the Expo dev menu if it opens as a bottom sheet.
|
||||||
|
- tapOn:
|
||||||
|
point: "89%,9%"
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# NOTE: Render through the workspace-create shell scripts before running when
|
||||||
|
# using ${PASEO_MAESTRO_*}. Maestro does not substitute environment vars.
|
||||||
|
appId: ${PASEO_MAESTRO_APP_ID}
|
||||||
|
---
|
||||||
|
# Connect to the local daemon only when the app is on the welcome screen.
|
||||||
|
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible:
|
||||||
|
id: "welcome-screen"
|
||||||
|
commands:
|
||||||
|
- tapOn:
|
||||||
|
id: "welcome-direct-connection"
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "add-host-modal"
|
||||||
|
timeout: 10000
|
||||||
|
|
||||||
|
- tapOn:
|
||||||
|
id: "direct-host-input"
|
||||||
|
- eraseText
|
||||||
|
- inputText: ${PASEO_MAESTRO_DIRECT_ENDPOINT}
|
||||||
|
|
||||||
|
- assertVisible:
|
||||||
|
id: "direct-host-input"
|
||||||
|
text: ${PASEO_MAESTRO_DIRECT_ENDPOINT}
|
||||||
|
|
||||||
|
- tapOn:
|
||||||
|
id: "direct-host-submit"
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# NOTE: Render through the workspace-create shell scripts before running when
|
||||||
|
# using ${PASEO_MAESTRO_*}. Maestro does not substitute environment vars.
|
||||||
|
appId: ${PASEO_MAESTRO_APP_ID}
|
||||||
|
---
|
||||||
|
# Start from an open Android sidebar with ${PASEO_MAESTRO_PROJECT_NAME}
|
||||||
|
# visible. Ends on the New Workspace screen.
|
||||||
|
|
||||||
|
- assertVisible:
|
||||||
|
id: "sidebar-project-list"
|
||||||
|
|
||||||
|
- assertVisible: ${PASEO_MAESTRO_PROJECT_NAME}
|
||||||
|
|
||||||
|
- tapOn: "Create a new workspace for ${PASEO_MAESTRO_PROJECT_NAME}"
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "New workspace"
|
||||||
|
timeout: 30000
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# NOTE: Render through the workspace-create shell scripts before running when
|
||||||
|
# using ${PASEO_MAESTRO_APP_ID}. Maestro does not substitute environment vars.
|
||||||
|
appId: ${PASEO_MAESTRO_APP_ID}
|
||||||
|
---
|
||||||
|
# Select a real provider/model on the New Workspace screen. Do not skip this
|
||||||
|
# in redirect tests: without a selected model, Create only shows validation and
|
||||||
|
# never exercises workspace creation.
|
||||||
|
|
||||||
|
- tapOn:
|
||||||
|
id: "agent-preferences-button"
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "agent-preferences-sheet"
|
||||||
|
timeout: 10000
|
||||||
|
|
||||||
|
- tapOn:
|
||||||
|
id: "combined-model-selector"
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Select model"
|
||||||
|
timeout: 30000
|
||||||
|
|
||||||
|
- tapOn: "Codex"
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "GPT-5.4"
|
||||||
|
timeout: 30000
|
||||||
|
|
||||||
|
- tapOn: "GPT-5.4"
|
||||||
|
|
||||||
|
- tapOn: "Close"
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "GPT-5.4"
|
||||||
|
timeout: 10000
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# NOTE: Render through the workspace-create shell scripts before running when
|
||||||
|
# using ${PASEO_MAESTRO_APP_ID}. Maestro does not substitute environment vars.
|
||||||
|
appId: ${PASEO_MAESTRO_APP_ID}
|
||||||
|
---
|
||||||
|
# Start on the New Workspace screen after model selection. Taps Create and
|
||||||
|
# proves the redirect completed into a workspace draft-agent tab.
|
||||||
|
|
||||||
|
- tapOn: "Create"
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "workspace-header-title"
|
||||||
|
timeout: 60000
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "message-input-root"
|
||||||
|
timeout: 30000
|
||||||
|
|
||||||
|
- assertNotVisible: "New workspace"
|
||||||
|
- assertNotVisible: "Select a model"
|
||||||
|
- assertNotVisible: "There was a problem loading the project."
|
||||||
|
- assertNotVisible: "The specified child already has a parent"
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# NOTE: Render through the workspace-create shell scripts before running when
|
||||||
|
# using ${PASEO_MAESTRO_*}. Maestro does not substitute environment vars.
|
||||||
|
appId: ${PASEO_MAESTRO_APP_ID}
|
||||||
|
---
|
||||||
|
# Start from the connected home screen and leave the Android sidebar open with
|
||||||
|
# the prepared project visible.
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "What shall we build today?"
|
||||||
|
timeout: 60000
|
||||||
|
|
||||||
|
- tapOn:
|
||||||
|
id: "menu-button"
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "sidebar-project-list"
|
||||||
|
timeout: 10000
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ${PASEO_MAESTRO_PROJECT_NAME}
|
||||||
|
timeout: 30000
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Records only the Android workspace-creation repro window.
|
||||||
|
#
|
||||||
|
# The setup Maestro flow gets the app to the open sidebar with a prepared
|
||||||
|
# project visible. Recording starts after that, then the focused flow taps the
|
||||||
|
# new-workspace button, selects a provider/model, taps Create, and asserts the
|
||||||
|
# app lands on the created workspace rather than remaining on /new.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
|
||||||
|
FLOW_TEMPLATE_DIR="$REPO_ROOT/packages/app/maestro"
|
||||||
|
SETUP_TEMPLATE="$REPO_ROOT/packages/app/maestro/workspace-create-android-ready-sidebar.yaml"
|
||||||
|
FOCUS_TEMPLATE="$REPO_ROOT/packages/app/maestro/workspace-create-android-create-focused.yaml"
|
||||||
|
OUT_DIR="/tmp/paseo-workspace-create-android-focus-$(date +%s)"
|
||||||
|
VIDEO_DIR="/tmp/paseo-maestro-videos"
|
||||||
|
DEVICE_VIDEO="/sdcard/paseo-maestro-workspace-create-focused.mp4"
|
||||||
|
LOCAL_VIDEO="$VIDEO_DIR/paseo-maestro-workspace-create-focused.mp4"
|
||||||
|
|
||||||
|
export PASEO_MAESTRO_APP_ID="${PASEO_MAESTRO_APP_ID:-sh.paseo.debug}"
|
||||||
|
export PASEO_MAESTRO_DIRECT_ENDPOINT="${PASEO_MAESTRO_DIRECT_ENDPOINT:-127.0.0.1:6767}"
|
||||||
|
export PASEO_MAESTRO_DAEMON_WS_URL="${PASEO_MAESTRO_DAEMON_WS_URL:-ws://127.0.0.1:6767/ws}"
|
||||||
|
|
||||||
|
require_command() {
|
||||||
|
if ! command -v "$1" >/dev/null 2>&1; then
|
||||||
|
echo "Missing required command: $1" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
render_flow() {
|
||||||
|
local source="$1"
|
||||||
|
local target="$2"
|
||||||
|
mkdir -p "$(dirname "$target")"
|
||||||
|
perl -0pe '
|
||||||
|
s/\$\{PASEO_MAESTRO_APP_ID\}/$ENV{PASEO_MAESTRO_APP_ID}/g;
|
||||||
|
s/\$\{PASEO_MAESTRO_DIRECT_ENDPOINT\}/$ENV{PASEO_MAESTRO_DIRECT_ENDPOINT}/g;
|
||||||
|
s/\$\{PASEO_MAESTRO_PROJECT_NAME\}/$ENV{PASEO_MAESTRO_PROJECT_NAME}/g;
|
||||||
|
' "$source" > "$target"
|
||||||
|
}
|
||||||
|
|
||||||
|
render_flow_tree() {
|
||||||
|
mkdir -p "$OUT_DIR/flows"
|
||||||
|
render_flow "$SETUP_TEMPLATE" "$SETUP_FLOW"
|
||||||
|
render_flow "$FOCUS_TEMPLATE" "$FOCUS_FLOW"
|
||||||
|
for source in "$FLOW_TEMPLATE_DIR"/flows/*.yaml; do
|
||||||
|
render_flow "$source" "$OUT_DIR/flows/$(basename "$source")"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
require_command adb
|
||||||
|
require_command git
|
||||||
|
require_command maestro
|
||||||
|
require_command node
|
||||||
|
require_command perl
|
||||||
|
|
||||||
|
mkdir -p "$OUT_DIR" "$VIDEO_DIR"
|
||||||
|
|
||||||
|
if [ -z "${PASEO_MAESTRO_PROJECT_PATH:-}" ]; then
|
||||||
|
PROJECT_PARENT="$(mktemp -d /tmp/paseo-maestro-project-XXXXXX)"
|
||||||
|
PROJECT_BASENAME="aaa-workspace-create-android-$(basename "$PROJECT_PARENT")"
|
||||||
|
export PASEO_MAESTRO_PROJECT_PATH="$PROJECT_PARENT/$PROJECT_BASENAME"
|
||||||
|
mkdir -p "$PASEO_MAESTRO_PROJECT_PATH"
|
||||||
|
git -C "$PASEO_MAESTRO_PROJECT_PATH" init >/dev/null
|
||||||
|
git -C "$PASEO_MAESTRO_PROJECT_PATH" checkout -b main >/dev/null 2>&1 || true
|
||||||
|
git -C "$PASEO_MAESTRO_PROJECT_PATH" config user.name "Paseo Maestro"
|
||||||
|
git -C "$PASEO_MAESTRO_PROJECT_PATH" config user.email "maestro@getpaseo.local"
|
||||||
|
printf "# Workspace create Android focused recording\n" > "$PASEO_MAESTRO_PROJECT_PATH/README.md"
|
||||||
|
git -C "$PASEO_MAESTRO_PROJECT_PATH" add README.md
|
||||||
|
git -C "$PASEO_MAESTRO_PROJECT_PATH" commit -m "Initial commit" >/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
export PASEO_MAESTRO_PROJECT_NAME="${PASEO_MAESTRO_PROJECT_NAME:-$(basename "$PASEO_MAESTRO_PROJECT_PATH")}"
|
||||||
|
|
||||||
|
SETUP_FLOW="$OUT_DIR/workspace-create-android-ready-sidebar.rendered.yaml"
|
||||||
|
FOCUS_FLOW="$OUT_DIR/workspace-create-android-create-focused.rendered.yaml"
|
||||||
|
render_flow_tree
|
||||||
|
|
||||||
|
echo "=== Focused Android Workspace Create Recording ==="
|
||||||
|
echo "Output dir: $OUT_DIR"
|
||||||
|
echo "Video: $LOCAL_VIDEO"
|
||||||
|
echo "Project: $PASEO_MAESTRO_PROJECT_PATH"
|
||||||
|
echo "Project name: $PASEO_MAESTRO_PROJECT_NAME"
|
||||||
|
|
||||||
|
adb reverse tcp:6767 tcp:6767 >/dev/null
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Opening project in daemon..."
|
||||||
|
REPO_ROOT="$REPO_ROOT" node --input-type=module <<'NODE'
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import WebSocket from "ws";
|
||||||
|
|
||||||
|
const repoRoot = process.env.REPO_ROOT;
|
||||||
|
const projectPath = process.env.PASEO_MAESTRO_PROJECT_PATH;
|
||||||
|
const daemonUrl = process.env.PASEO_MAESTRO_DAEMON_WS_URL;
|
||||||
|
if (!repoRoot || !projectPath || !daemonUrl) {
|
||||||
|
throw new Error("Missing required environment for daemon project setup.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const moduleUrl = pathToFileURL(`${repoRoot}/packages/server/dist/server/server/exports.js`).href;
|
||||||
|
const { DaemonClient } = await import(moduleUrl);
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: daemonUrl,
|
||||||
|
clientId: `maestro-workspace-create-focus-${Date.now()}`,
|
||||||
|
clientType: "cli",
|
||||||
|
webSocketFactory: (url, options) => new WebSocket(url, { headers: options?.headers }),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
const payload = await client.openProject(projectPath);
|
||||||
|
if (payload.error || !payload.workspace) {
|
||||||
|
throw new Error(payload.error ?? "openProject returned no workspace");
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
JSON.stringify({
|
||||||
|
workspaceId: payload.workspace.id,
|
||||||
|
projectDisplayName: payload.workspace.projectDisplayName,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await client.close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
NODE
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Staging app at open sidebar..."
|
||||||
|
(cd "$OUT_DIR" && maestro test "$SETUP_FLOW") 2>&1 | tee "$OUT_DIR/setup.log"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Recording focused create flow..."
|
||||||
|
adb shell rm -f "$DEVICE_VIDEO" >/dev/null 2>&1 || true
|
||||||
|
adb shell screenrecord --time-limit 90 "$DEVICE_VIDEO" &
|
||||||
|
SCREENRECORD_PID=$!
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
set +e
|
||||||
|
(cd "$OUT_DIR" && maestro test "$FOCUS_FLOW") 2>&1 | tee "$OUT_DIR/focus.log"
|
||||||
|
FOCUS_STATUS=${PIPESTATUS[0]}
|
||||||
|
set -e
|
||||||
|
|
||||||
|
kill -INT "$SCREENRECORD_PID" >/dev/null 2>&1 || true
|
||||||
|
wait "$SCREENRECORD_PID" >/dev/null 2>&1 || true
|
||||||
|
adb shell pkill -INT screenrecord >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
adb pull "$DEVICE_VIDEO" "$LOCAL_VIDEO" >/dev/null
|
||||||
|
ls -lh "$LOCAL_VIDEO"
|
||||||
|
|
||||||
|
if [ "$FOCUS_STATUS" -ne 0 ]; then
|
||||||
|
echo "Focused Maestro flow failed. Artifacts: $OUT_DIR" >&2
|
||||||
|
exit "$FOCUS_STATUS"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Focused recording complete."
|
||||||
|
echo "Artifacts: $OUT_DIR"
|
||||||
+187
@@ -0,0 +1,187 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Android Maestro harness for the workspace-creation redirect crash.
|
||||||
|
#
|
||||||
|
# Starts from a clean app state, connects the Android app to the local daemon,
|
||||||
|
# opens a prepared git project, creates a workspace through the UI, and captures
|
||||||
|
# adb logcat around the redirect window.
|
||||||
|
#
|
||||||
|
# This harness is deliberately stronger than "composer is visible": it selects
|
||||||
|
# a model, taps Create, asserts the workspace header, asserts the New Workspace
|
||||||
|
# route is gone, and fails if logcat contains the Android Fabric view-parent
|
||||||
|
# crash signature.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# bash packages/app/maestro/test-workspace-create-android-crash.sh
|
||||||
|
#
|
||||||
|
# Optional environment:
|
||||||
|
# PASEO_MAESTRO_APP_ID=sh.paseo.debug
|
||||||
|
# PASEO_MAESTRO_DIRECT_ENDPOINT=127.0.0.1:6767
|
||||||
|
# PASEO_MAESTRO_DAEMON_WS_URL=ws://127.0.0.1:6767/ws
|
||||||
|
# PASEO_MAESTRO_PROJECT_PATH=/path/to/git/repo
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
|
||||||
|
FLOW_TEMPLATE="$REPO_ROOT/packages/app/maestro/workspace-create-android-crash.yaml"
|
||||||
|
FLOW_TEMPLATE_DIR="$REPO_ROOT/packages/app/maestro"
|
||||||
|
OUT_DIR="/tmp/paseo-workspace-create-android-$(date +%s)"
|
||||||
|
SERVER_EXPORTS="$REPO_ROOT/packages/server/dist/server/server/exports.js"
|
||||||
|
|
||||||
|
export PASEO_MAESTRO_APP_ID="${PASEO_MAESTRO_APP_ID:-sh.paseo.debug}"
|
||||||
|
export PASEO_MAESTRO_DIRECT_ENDPOINT="${PASEO_MAESTRO_DIRECT_ENDPOINT:-127.0.0.1:6767}"
|
||||||
|
export PASEO_MAESTRO_DAEMON_WS_URL="${PASEO_MAESTRO_DAEMON_WS_URL:-ws://127.0.0.1:6767/ws}"
|
||||||
|
|
||||||
|
require_command() {
|
||||||
|
if ! command -v "$1" >/dev/null 2>&1; then
|
||||||
|
echo "Missing required command: $1" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
require_command adb
|
||||||
|
require_command git
|
||||||
|
require_command maestro
|
||||||
|
require_command node
|
||||||
|
require_command perl
|
||||||
|
|
||||||
|
render_flow() {
|
||||||
|
local source="$1"
|
||||||
|
local target="$2"
|
||||||
|
mkdir -p "$(dirname "$target")"
|
||||||
|
perl -0pe '
|
||||||
|
s/\$\{PASEO_MAESTRO_APP_ID\}/$ENV{PASEO_MAESTRO_APP_ID}/g;
|
||||||
|
s/\$\{PASEO_MAESTRO_DIRECT_ENDPOINT\}/$ENV{PASEO_MAESTRO_DIRECT_ENDPOINT}/g;
|
||||||
|
s/\$\{PASEO_MAESTRO_PROJECT_NAME\}/$ENV{PASEO_MAESTRO_PROJECT_NAME}/g;
|
||||||
|
' "$source" > "$target"
|
||||||
|
}
|
||||||
|
|
||||||
|
render_flow_tree() {
|
||||||
|
mkdir -p "$OUT_DIR/flows"
|
||||||
|
render_flow "$FLOW_TEMPLATE" "$FLOW"
|
||||||
|
for source in "$FLOW_TEMPLATE_DIR"/flows/*.yaml; do
|
||||||
|
render_flow "$source" "$OUT_DIR/flows/$(basename "$source")"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ ! -f "$SERVER_EXPORTS" ]; then
|
||||||
|
echo "Missing server build artifact: $SERVER_EXPORTS" >&2
|
||||||
|
echo "Run: npm run build --workspace=@getpaseo/server" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$OUT_DIR"
|
||||||
|
|
||||||
|
if [ -z "${PASEO_MAESTRO_PROJECT_PATH:-}" ]; then
|
||||||
|
PROJECT_PARENT="$(mktemp -d /tmp/paseo-maestro-project-XXXXXX)"
|
||||||
|
PROJECT_BASENAME="aaa-workspace-create-android-$(basename "$PROJECT_PARENT")"
|
||||||
|
export PASEO_MAESTRO_PROJECT_PATH="$PROJECT_PARENT/$PROJECT_BASENAME"
|
||||||
|
mkdir -p "$PASEO_MAESTRO_PROJECT_PATH"
|
||||||
|
git -C "$PASEO_MAESTRO_PROJECT_PATH" init >/dev/null
|
||||||
|
git -C "$PASEO_MAESTRO_PROJECT_PATH" checkout -b main >/dev/null 2>&1 || true
|
||||||
|
git -C "$PASEO_MAESTRO_PROJECT_PATH" config user.name "Paseo Maestro"
|
||||||
|
git -C "$PASEO_MAESTRO_PROJECT_PATH" config user.email "maestro@getpaseo.local"
|
||||||
|
printf "# Workspace create Android repro\n" > "$PASEO_MAESTRO_PROJECT_PATH/README.md"
|
||||||
|
git -C "$PASEO_MAESTRO_PROJECT_PATH" add README.md
|
||||||
|
git -C "$PASEO_MAESTRO_PROJECT_PATH" commit -m "Initial commit" >/dev/null
|
||||||
|
else
|
||||||
|
PROJECT_PARENT=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
export PASEO_MAESTRO_PROJECT_NAME="${PASEO_MAESTRO_PROJECT_NAME:-$(basename "$PASEO_MAESTRO_PROJECT_PATH")}"
|
||||||
|
|
||||||
|
echo "=== Workspace Create Android Crash Harness ==="
|
||||||
|
echo "Output dir: $OUT_DIR"
|
||||||
|
echo "App id: $PASEO_MAESTRO_APP_ID"
|
||||||
|
echo "Android direct endpoint: $PASEO_MAESTRO_DIRECT_ENDPOINT"
|
||||||
|
echo "Daemon websocket: $PASEO_MAESTRO_DAEMON_WS_URL"
|
||||||
|
echo "Project: $PASEO_MAESTRO_PROJECT_PATH"
|
||||||
|
echo "Project name: $PASEO_MAESTRO_PROJECT_NAME"
|
||||||
|
|
||||||
|
FLOW="$OUT_DIR/workspace-create-android-crash.rendered.yaml"
|
||||||
|
render_flow_tree
|
||||||
|
echo "Rendered flow: $FLOW"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Preparing Android port reverse..."
|
||||||
|
adb reverse tcp:6767 tcp:6767 >/dev/null
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Opening project in daemon..."
|
||||||
|
REPO_ROOT="$REPO_ROOT" node --input-type=module <<'NODE'
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import WebSocket from "ws";
|
||||||
|
|
||||||
|
const repoRoot = process.env.REPO_ROOT;
|
||||||
|
const projectPath = process.env.PASEO_MAESTRO_PROJECT_PATH;
|
||||||
|
const daemonUrl = process.env.PASEO_MAESTRO_DAEMON_WS_URL;
|
||||||
|
if (!repoRoot || !projectPath || !daemonUrl) {
|
||||||
|
throw new Error("Missing required environment for daemon project setup.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const moduleUrl = pathToFileURL(`${repoRoot}/packages/server/dist/server/server/exports.js`).href;
|
||||||
|
const { DaemonClient } = await import(moduleUrl);
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: daemonUrl,
|
||||||
|
clientId: `maestro-workspace-create-${Date.now()}`,
|
||||||
|
clientType: "cli",
|
||||||
|
webSocketFactory: (url, options) => new WebSocket(url, { headers: options?.headers }),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
const payload = await client.openProject(projectPath);
|
||||||
|
if (payload.error || !payload.workspace) {
|
||||||
|
throw new Error(payload.error ?? "openProject returned no workspace");
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
JSON.stringify({
|
||||||
|
workspaceId: payload.workspace.id,
|
||||||
|
projectId: payload.workspace.projectId,
|
||||||
|
projectDisplayName: payload.workspace.projectDisplayName,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await client.close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
NODE
|
||||||
|
|
||||||
|
LOGCAT_PID=""
|
||||||
|
cleanup() {
|
||||||
|
if [ -n "$LOGCAT_PID" ]; then
|
||||||
|
kill "$LOGCAT_PID" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Capturing Android logcat..."
|
||||||
|
adb logcat -c || true
|
||||||
|
adb logcat -v time > "$OUT_DIR/logcat.txt" &
|
||||||
|
LOGCAT_PID="$!"
|
||||||
|
|
||||||
|
echo "Running Maestro flow..."
|
||||||
|
set +e
|
||||||
|
(cd "$OUT_DIR" && maestro test "$FLOW") 2>&1 | tee "$OUT_DIR/maestro.log"
|
||||||
|
MAESTRO_STATUS=${PIPESTATUS[0]}
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cleanup
|
||||||
|
LOGCAT_PID=""
|
||||||
|
|
||||||
|
if [ "$MAESTRO_STATUS" -ne 0 ]; then
|
||||||
|
adb exec-out screencap -p > "$OUT_DIR/failure-state.png" 2>/dev/null || true
|
||||||
|
echo ""
|
||||||
|
echo "Maestro failed. Artifacts: $OUT_DIR" >&2
|
||||||
|
exit "$MAESTRO_STATUS"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if grep -E "failed to insert view|specified child already has a parent" "$OUT_DIR/logcat.txt" >/dev/null; then
|
||||||
|
adb exec-out screencap -p > "$OUT_DIR/failure-state.png" 2>/dev/null || true
|
||||||
|
echo ""
|
||||||
|
echo "Android native view crash signature found in logcat. Artifacts: $OUT_DIR" >&2
|
||||||
|
grep -n -E "failed to insert view|specified child already has a parent" "$OUT_DIR/logcat.txt" >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "PASS: workspace creation flow completed without the Android view-parent crash signature."
|
||||||
|
echo "Artifacts: $OUT_DIR"
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
appId: ${PASEO_MAESTRO_APP_ID}
|
||||||
|
---
|
||||||
|
# Android regression/reproduction flow for creating a workspace from an existing project.
|
||||||
|
# The shell wrapper prepares the daemon/project and captures logcat around this flow.
|
||||||
|
|
||||||
|
- launchApp:
|
||||||
|
clearState: true
|
||||||
|
|
||||||
|
- runFlow: flows/android-dev-client.yaml
|
||||||
|
- runFlow: flows/connect-direct-if-welcome.yaml
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "What shall we build today?"
|
||||||
|
timeout: 60000
|
||||||
|
|
||||||
|
- takeScreenshot: 01-connected
|
||||||
|
|
||||||
|
- runFlow: flows/open-prepared-project-sidebar.yaml
|
||||||
|
- runFlow: flows/new-workspace-open-from-sidebar.yaml
|
||||||
|
|
||||||
|
- takeScreenshot: 02-new-workspace
|
||||||
|
|
||||||
|
- runFlow: flows/new-workspace-select-codex-gpt54.yaml
|
||||||
|
- runFlow: flows/new-workspace-submit-and-assert-created.yaml
|
||||||
|
|
||||||
|
- takeScreenshot: 03-created-workspace
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
appId: ${PASEO_MAESTRO_APP_ID}
|
||||||
|
---
|
||||||
|
# Focused repro window. Assumes `workspace-create-android-ready-sidebar.yaml`
|
||||||
|
# already left the sidebar open with the prepared project visible.
|
||||||
|
|
||||||
|
- runFlow: flows/new-workspace-open-from-sidebar.yaml
|
||||||
|
- runFlow: flows/new-workspace-select-codex-gpt54.yaml
|
||||||
|
- runFlow: flows/new-workspace-submit-and-assert-created.yaml
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
appId: ${PASEO_MAESTRO_APP_ID}
|
||||||
|
---
|
||||||
|
# Setup-only flow. It stops with the Android sidebar open and the prepared
|
||||||
|
# project visible, so focused recordings can start at the actual repro window.
|
||||||
|
|
||||||
|
- launchApp:
|
||||||
|
clearState: true
|
||||||
|
|
||||||
|
- runFlow: flows/android-dev-client.yaml
|
||||||
|
- runFlow: flows/connect-direct-if-welcome.yaml
|
||||||
|
- runFlow: flows/open-prepared-project-sidebar.yaml
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"version": "0.1.59",
|
"version": "0.1.60",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "APP_VARIANT=development expo start",
|
||||||
"reset-project": "node ./scripts/reset-project.js",
|
"reset-project": "node ./scripts/reset-project.js",
|
||||||
"build:workspace-deps": "npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/expo-two-way-audio",
|
"build:workspace-deps": "npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/expo-two-way-audio",
|
||||||
"eas-build-post-install": "npm run build:workspace-deps",
|
"eas-build-post-install": "npm run build:workspace-deps",
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
"lint": "expo lint",
|
"lint": "expo lint",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
"test:browser": "vitest run --project browser",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"test:e2e:ui": "playwright test --ui",
|
"test:e2e:ui": "playwright test --ui",
|
||||||
"build": "npm run build:web",
|
"build": "npm run build:web",
|
||||||
@@ -31,9 +32,9 @@
|
|||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@expo/vector-icons": "^15.0.2",
|
"@expo/vector-icons": "^15.0.2",
|
||||||
"@floating-ui/react-native": "^0.10.7",
|
"@floating-ui/react-native": "^0.10.7",
|
||||||
"@getpaseo/expo-two-way-audio": "0.1.59",
|
"@getpaseo/expo-two-way-audio": "0.1.60",
|
||||||
"@getpaseo/highlight": "0.1.59",
|
"@getpaseo/highlight": "0.1.60",
|
||||||
"@getpaseo/server": "0.1.59",
|
"@getpaseo/server": "0.1.60",
|
||||||
"@gorhom/bottom-sheet": "^5.2.6",
|
"@gorhom/bottom-sheet": "^5.2.6",
|
||||||
"@gorhom/portal": "^1.0.14",
|
"@gorhom/portal": "^1.0.14",
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
@@ -79,6 +80,7 @@
|
|||||||
"expo-system-ui": "~6.0.7",
|
"expo-system-ui": "~6.0.7",
|
||||||
"expo-updates": "~29.0.12",
|
"expo-updates": "~29.0.12",
|
||||||
"expo-web-browser": "~15.0.8",
|
"expo-web-browser": "~15.0.8",
|
||||||
|
"fast-deep-equal": "^3.1.3",
|
||||||
"lucide-react-native": "^0.546.0",
|
"lucide-react-native": "^0.546.0",
|
||||||
"mnemonic-id": "^3.2.7",
|
"mnemonic-id": "^3.2.7",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
@@ -102,13 +104,16 @@
|
|||||||
"react-native-web": "~0.21.0",
|
"react-native-web": "~0.21.0",
|
||||||
"react-native-webview": "^13.16.0",
|
"react-native-webview": "^13.16.0",
|
||||||
"react-native-worklets": "0.5.1",
|
"react-native-worklets": "0.5.1",
|
||||||
|
"use-sync-external-store": "^1.6.0",
|
||||||
"zod": "^3.23.8",
|
"zod": "^3.23.8",
|
||||||
"zustand": "^5.0.9"
|
"zustand": "^5.0.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.56.1",
|
"@playwright/test": "^1.56.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/react": "~19.2.0",
|
"@types/react": "~19.2.0",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
|
"@vitest/browser": "^3.2.4",
|
||||||
"eas-cli": "^16.24.1",
|
"eas-cli": "^16.24.1",
|
||||||
"eslint": "^9.25.0",
|
"eslint": "^9.25.0",
|
||||||
"eslint-config-expo": "~10.0.0",
|
"eslint-config-expo": "~10.0.0",
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||||
import { KeyboardProvider } from "react-native-keyboard-controller";
|
import { KeyboardProvider } from "react-native-keyboard-controller";
|
||||||
import { GestureHandlerRootView, Gesture, GestureDetector } from "react-native-gesture-handler";
|
import { GestureHandlerRootView, Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
|
||||||
import { PortalProvider } from "@gorhom/portal";
|
import { PortalProvider } from "@gorhom/portal";
|
||||||
import { VoiceProvider } from "@/contexts/voice-context";
|
import { VoiceProvider } from "@/contexts/voice-context";
|
||||||
import { useAppSettings } from "@/hooks/use-settings";
|
import { useAppSettings } from "@/hooks/use-settings";
|
||||||
@@ -29,6 +28,7 @@ import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
|||||||
import { loadSettingsFromStorage } from "@/hooks/use-settings";
|
import { loadSettingsFromStorage } from "@/hooks/use-settings";
|
||||||
import { useColorScheme } from "@/hooks/use-color-scheme";
|
import { useColorScheme } from "@/hooks/use-color-scheme";
|
||||||
import { useOpenProject } from "@/hooks/use-open-project";
|
import { useOpenProject } from "@/hooks/use-open-project";
|
||||||
|
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||||
import { SessionProvider } from "@/contexts/session-context";
|
import { SessionProvider } from "@/contexts/session-context";
|
||||||
import type { HostProfile } from "@/types/host-connection";
|
import type { HostProfile } from "@/types/host-connection";
|
||||||
import {
|
import {
|
||||||
@@ -63,8 +63,13 @@ import { CommandCenter } from "@/components/command-center";
|
|||||||
import { ProjectPickerModal } from "@/components/project-picker-modal";
|
import { ProjectPickerModal } from "@/components/project-picker-modal";
|
||||||
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
||||||
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
|
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
|
||||||
|
import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber";
|
||||||
|
import { resolveActiveHost } from "@/utils/active-host";
|
||||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||||
|
import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action";
|
||||||
|
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
||||||
import { queryClient } from "@/query/query-client";
|
import { queryClient } from "@/query/query-client";
|
||||||
|
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
|
||||||
import {
|
import {
|
||||||
WEB_NOTIFICATION_CLICK_EVENT,
|
WEB_NOTIFICATION_CLICK_EVENT,
|
||||||
type WebNotificationClickDetail,
|
type WebNotificationClickDetail,
|
||||||
@@ -73,7 +78,7 @@ import {
|
|||||||
import { listenToDesktopEvent } from "@/desktop/electron/events";
|
import { listenToDesktopEvent } from "@/desktop/electron/events";
|
||||||
import { getDesktopHost } from "@/desktop/host";
|
import { getDesktopHost } from "@/desktop/host";
|
||||||
import { updateDesktopWindowControls } from "@/desktop/electron/window";
|
import { updateDesktopWindowControls } from "@/desktop/electron/window";
|
||||||
import { buildNotificationRoute } from "@/utils/notification-routing";
|
import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing";
|
||||||
import {
|
import {
|
||||||
buildHostRootRoute,
|
buildHostRootRoute,
|
||||||
mapPathnameToServer,
|
mapPathnameToServer,
|
||||||
@@ -82,7 +87,14 @@ import {
|
|||||||
parseWorkspaceOpenIntent,
|
parseWorkspaceOpenIntent,
|
||||||
decodeWorkspaceIdFromPathSegment,
|
decodeWorkspaceIdFromPathSegment,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||||
|
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
|
||||||
|
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
|
import {
|
||||||
|
addBrowserActiveWorkspaceLocationListener,
|
||||||
|
syncNavigationActiveWorkspace,
|
||||||
|
} from "@/stores/navigation-active-workspace-store";
|
||||||
import { isWeb, isNative } from "@/constants/platform";
|
import { isWeb, isNative } from "@/constants/platform";
|
||||||
|
|
||||||
polyfillCrypto();
|
polyfillCrypto();
|
||||||
@@ -117,7 +129,36 @@ const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
|
|||||||
|
|
||||||
function PushNotificationRouter() {
|
function PushNotificationRouter() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
const lastHandledIdRef = useRef<string | null>(null);
|
const lastHandledIdRef = useRef<string | null>(null);
|
||||||
|
const openNotification = useStableEvent((data: Record<string, unknown> | undefined) => {
|
||||||
|
const target = resolveNotificationTarget(data);
|
||||||
|
const serverId = target.serverId;
|
||||||
|
const agentId = target.agentId;
|
||||||
|
if (serverId && agentId) {
|
||||||
|
const session = useSessionStore.getState().sessions[serverId];
|
||||||
|
const agent = session?.agents.get(agentId);
|
||||||
|
const workspaceId =
|
||||||
|
target.workspaceId ??
|
||||||
|
resolveWorkspaceIdByExecutionDirectory({
|
||||||
|
workspaces: session?.workspaces.values(),
|
||||||
|
workspaceDirectory: agent?.cwd,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (workspaceId) {
|
||||||
|
prepareWorkspaceTab({
|
||||||
|
serverId,
|
||||||
|
workspaceId,
|
||||||
|
target: { kind: "agent", agentId },
|
||||||
|
pin: true,
|
||||||
|
});
|
||||||
|
navigateToWorkspace(serverId, workspaceId, { currentPathname: pathname });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
router.navigate(buildNotificationRoute(data));
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isWeb) {
|
if (isWeb) {
|
||||||
@@ -138,7 +179,7 @@ function PushNotificationRouter() {
|
|||||||
(payload as { data?: unknown }).data !== null
|
(payload as { data?: unknown }).data !== null
|
||||||
? (payload as { data: Record<string, unknown> }).data
|
? (payload as { data: Record<string, unknown> }).data
|
||||||
: undefined;
|
: undefined;
|
||||||
router.push(buildNotificationRoute(data) as any);
|
openNotification(data);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -158,7 +199,7 @@ function PushNotificationRouter() {
|
|||||||
const openFromWebClick = (event: Event) => {
|
const openFromWebClick = (event: Event) => {
|
||||||
const customEvent = event as CustomEvent<WebNotificationClickDetail>;
|
const customEvent = event as CustomEvent<WebNotificationClickDetail>;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
router.push(buildNotificationRoute(customEvent.detail?.data) as any);
|
openNotification(customEvent.detail?.data);
|
||||||
};
|
};
|
||||||
|
|
||||||
target.addEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener);
|
target.addEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener);
|
||||||
@@ -191,7 +232,7 @@ function PushNotificationRouter() {
|
|||||||
const data = response.notification.request.content.data as
|
const data = response.notification.request.content.data as
|
||||||
| Record<string, unknown>
|
| Record<string, unknown>
|
||||||
| undefined;
|
| undefined;
|
||||||
router.push(buildNotificationRoute(data) as any);
|
openNotification(data);
|
||||||
};
|
};
|
||||||
|
|
||||||
const subscription = Notifications.addNotificationResponseReceivedListener(openFromResponse);
|
const subscription = Notifications.addNotificationResponseReceivedListener(openFromResponse);
|
||||||
@@ -205,7 +246,7 @@ function PushNotificationRouter() {
|
|||||||
return () => {
|
return () => {
|
||||||
subscription.remove();
|
subscription.remove();
|
||||||
};
|
};
|
||||||
}, [router]);
|
}, [openNotification]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -312,7 +353,9 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
void store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon });
|
setPhase("connecting");
|
||||||
|
setError(null);
|
||||||
|
await store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon });
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setPhase("online");
|
setPhase("online");
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -387,13 +430,13 @@ function AppContainer({
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
const { settings, updateSettings } = useAppSettings();
|
const { settings, updateSettings } = useAppSettings();
|
||||||
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
|
const toggleMobileAgentList = usePanelStore((state) => state.toggleMobileAgentList);
|
||||||
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
|
const toggleDesktopAgentList = usePanelStore((state) => state.toggleDesktopAgentList);
|
||||||
const toggleBothSidebars = usePanelStore((state) => state.toggleBothSidebars);
|
const openDesktopAgentList = usePanelStore((state) => state.openDesktopAgentList);
|
||||||
|
const closeDesktopAgentList = usePanelStore((state) => state.closeDesktopAgentList);
|
||||||
|
const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer);
|
||||||
const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode);
|
const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode);
|
||||||
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
|
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
|
||||||
const agentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
|
||||||
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
|
||||||
|
|
||||||
const cycleTheme = useCallback(() => {
|
const cycleTheme = useCallback(() => {
|
||||||
const currentIndex = THEME_CYCLE_ORDER.indexOf(settings.theme as ThemeName);
|
const currentIndex = THEME_CYCLE_ORDER.indexOf(settings.theme as ThemeName);
|
||||||
@@ -403,46 +446,44 @@ function AppContainer({
|
|||||||
|
|
||||||
const isCompactLayout = useIsCompactFormFactor();
|
const isCompactLayout = useIsCompactFormFactor();
|
||||||
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
|
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
|
||||||
|
const pathname = usePathname();
|
||||||
useEffect(() => {
|
const activeServerId = useMemo(
|
||||||
const bp = UnistylesRuntime.breakpoint;
|
() => resolveActiveHost({ hosts: daemons, pathname })?.serverId ?? null,
|
||||||
const screenW = UnistylesRuntime.screen.width;
|
[daemons, pathname],
|
||||||
const screenH = UnistylesRuntime.screen.height;
|
);
|
||||||
const isElectron = getIsElectronRuntime();
|
const toggleAgentList = isCompactLayout ? toggleMobileAgentList : toggleDesktopAgentList;
|
||||||
const windowW = isWeb ? window.innerWidth : undefined;
|
const toggleDesktopSidebars = useCallback(() => {
|
||||||
const windowH = isWeb ? window.innerHeight : undefined;
|
const { desktop } = usePanelStore.getState();
|
||||||
const dpr = isWeb ? window.devicePixelRatio : undefined;
|
toggleDesktopSidebarsWithCheckoutIntent({
|
||||||
const ua = isWeb ? navigator.userAgent : undefined;
|
isAgentListOpen: desktop.agentListOpen,
|
||||||
|
isFileExplorerOpen: desktop.fileExplorerOpen,
|
||||||
console.log(
|
openAgentList: openDesktopAgentList,
|
||||||
"[layout-debug]",
|
closeAgentList: closeDesktopAgentList,
|
||||||
JSON.stringify({
|
closeFileExplorer: closeDesktopFileExplorer,
|
||||||
breakpoint: bp,
|
toggleFocusedFileExplorer: () =>
|
||||||
isCompactLayout,
|
keyboardActionDispatcher.dispatch({
|
||||||
isElectron,
|
id: "sidebar.toggle.right",
|
||||||
chromeEnabled,
|
scope: "sidebar",
|
||||||
isFocusModeEnabled,
|
}),
|
||||||
agentListOpen,
|
});
|
||||||
sidebarWidth,
|
}, [closeDesktopAgentList, closeDesktopFileExplorer, openDesktopAgentList]);
|
||||||
sidebarRenderedInRow: !isCompactLayout && chromeEnabled && !isFocusModeEnabled,
|
// TODO: stop matching pathname here as a branch. `chromeEnabled` should not
|
||||||
unistylesScreen: { w: screenW, h: screenH },
|
// conflate workspace/project-specific chrome (sidebar, mobile gesture) with
|
||||||
window: { w: windowW, h: windowH },
|
// global concerns like keyboard shortcuts. Split those out so settings (and
|
||||||
devicePixelRatio: dpr,
|
// other non-workspace routes) don't need a special-case to keep shortcuts alive.
|
||||||
userAgent: ua,
|
const keyboardShortcutsEnabled = chromeEnabled || pathname.startsWith("/settings");
|
||||||
}),
|
|
||||||
);
|
|
||||||
}, [isCompactLayout, chromeEnabled, isFocusModeEnabled, agentListOpen, sidebarWidth]);
|
|
||||||
|
|
||||||
useKeyboardShortcuts({
|
useKeyboardShortcuts({
|
||||||
enabled: chromeEnabled,
|
enabled: keyboardShortcutsEnabled,
|
||||||
isMobile: isCompactLayout,
|
isMobile: isCompactLayout,
|
||||||
toggleAgentList,
|
toggleAgentList,
|
||||||
toggleFileExplorer,
|
toggleBothSidebars: toggleDesktopSidebars,
|
||||||
toggleBothSidebars,
|
|
||||||
toggleFocusMode,
|
toggleFocusMode,
|
||||||
cycleTheme,
|
cycleTheme,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useActiveWorktreeNewAction();
|
||||||
|
|
||||||
const containerStyle = useMemo(
|
const containerStyle = useMemo(
|
||||||
() => ({ flex: 1 as const, backgroundColor: theme.colors.surface0 }),
|
() => ({ flex: 1 as const, backgroundColor: theme.colors.surface0 }),
|
||||||
[theme.colors.surface0],
|
[theme.colors.surface0],
|
||||||
@@ -461,6 +502,10 @@ function AppContainer({
|
|||||||
<UpdateBanner />
|
<UpdateBanner />
|
||||||
<CommandCenter />
|
<CommandCenter />
|
||||||
<ProjectPickerModal />
|
<ProjectPickerModal />
|
||||||
|
<WorkspaceShortcutTargetsSubscriber
|
||||||
|
enabled={keyboardShortcutsEnabled}
|
||||||
|
serverId={activeServerId}
|
||||||
|
/>
|
||||||
<WorkspaceSetupDialog />
|
<WorkspaceSetupDialog />
|
||||||
<KeyboardShortcutsDialog />
|
<KeyboardShortcutsDialog />
|
||||||
</View>
|
</View>
|
||||||
@@ -481,7 +526,7 @@ function MobileGestureWrapper({
|
|||||||
chromeEnabled: boolean;
|
chromeEnabled: boolean;
|
||||||
}) {
|
}) {
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const mobileView = usePanelStore((state) => state.mobileView);
|
||||||
const openAgentList = usePanelStore((state) => state.openAgentList);
|
const showMobileAgentList = usePanelStore((state) => state.showMobileAgentList);
|
||||||
const horizontalScroll = useHorizontalScrollOptional();
|
const horizontalScroll = useHorizontalScrollOptional();
|
||||||
const {
|
const {
|
||||||
translateX,
|
translateX,
|
||||||
@@ -498,8 +543,8 @@ function MobileGestureWrapper({
|
|||||||
|
|
||||||
const handleGestureOpen = useCallback(() => {
|
const handleGestureOpen = useCallback(() => {
|
||||||
gestureAnimatingRef.current = true;
|
gestureAnimatingRef.current = true;
|
||||||
openAgentList();
|
showMobileAgentList();
|
||||||
}, [openAgentList, gestureAnimatingRef]);
|
}, [showMobileAgentList, gestureAnimatingRef]);
|
||||||
|
|
||||||
const openGesture = useMemo(
|
const openGesture = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -743,8 +788,10 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const params = useGlobalSearchParams<{ open?: string | string[] }>();
|
const params = useGlobalSearchParams<{ open?: string | string[] }>();
|
||||||
const hosts = useHosts();
|
const hosts = useHosts();
|
||||||
|
const storeReady = useStoreReady();
|
||||||
const activeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
|
const activeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
|
||||||
const shouldShowAppChrome = activeServerId !== null;
|
const shouldShowAppChrome =
|
||||||
|
storeReady && activeServerId !== null && hosts.some((host) => host.serverId === activeServerId);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeServerId || hosts.length === 0) {
|
if (!activeServerId || hosts.length === 0) {
|
||||||
@@ -800,26 +847,26 @@ function RootStack() {
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<Stack.Screen name="index" />
|
||||||
<Stack.Protected guard={storeReady}>
|
<Stack.Protected guard={storeReady}>
|
||||||
<Stack.Screen name="welcome" />
|
<Stack.Screen name="welcome" />
|
||||||
<Stack.Screen name="settings/index" />
|
<Stack.Screen name="settings/index" />
|
||||||
<Stack.Screen name="settings/[section]" />
|
<Stack.Screen name="settings/[section]" />
|
||||||
<Stack.Screen name="pair-scan" />
|
<Stack.Screen name="pair-scan" />
|
||||||
</Stack.Protected>
|
</Stack.Protected>
|
||||||
<Stack.Screen
|
{/*
|
||||||
name="h/[serverId]/workspace/[workspaceId]"
|
Do not add getId or dangerouslySingular back to the workspace route.
|
||||||
getId={({ params }) => {
|
Expo Router maps dangerouslySingular to React Navigation getId, and
|
||||||
const serverId = getRouteParamValue(params?.serverId);
|
getId repeatedly breaks Android native-stack/Fabric by reordering an
|
||||||
const workspaceId = getRouteParamValue(params?.workspaceId);
|
already-mounted workspace screen. Keep workspace identity/retention
|
||||||
return serverId && workspaceId ? `${serverId}:${workspaceId}` : undefined;
|
outside this route-level native-stack API.
|
||||||
}}
|
*/}
|
||||||
/>
|
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
|
||||||
<Stack.Screen name="h/[serverId]/agent/[agentId]" options={{ gestureEnabled: false }} />
|
<Stack.Screen name="h/[serverId]/agent/[agentId]" options={{ gestureEnabled: false }} />
|
||||||
<Stack.Screen name="h/[serverId]/index" />
|
<Stack.Screen name="h/[serverId]/index" />
|
||||||
<Stack.Screen name="h/[serverId]/sessions" />
|
<Stack.Screen name="h/[serverId]/sessions" />
|
||||||
<Stack.Screen name="h/[serverId]/open-project" />
|
<Stack.Screen name="h/[serverId]/open-project" />
|
||||||
<Stack.Screen name="h/[serverId]/settings" />
|
<Stack.Screen name="h/[serverId]/settings" />
|
||||||
<Stack.Screen name="index" />
|
|
||||||
<Stack.Screen name="settings/hosts/[serverId]" />
|
<Stack.Screen name="settings/hosts/[serverId]" />
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
@@ -830,6 +877,7 @@ function NavigationActiveWorkspaceObserver() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
syncNavigationActiveWorkspace(navigationRef);
|
syncNavigationActiveWorkspace(navigationRef);
|
||||||
|
const unsubscribeBrowserLocation = addBrowserActiveWorkspaceLocationListener();
|
||||||
const unsubscribeState = navigationRef.addListener("state", () => {
|
const unsubscribeState = navigationRef.addListener("state", () => {
|
||||||
syncNavigationActiveWorkspace(navigationRef);
|
syncNavigationActiveWorkspace(navigationRef);
|
||||||
});
|
});
|
||||||
@@ -837,6 +885,7 @@ function NavigationActiveWorkspaceObserver() {
|
|||||||
syncNavigationActiveWorkspace(navigationRef);
|
syncNavigationActiveWorkspace(navigationRef);
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
|
unsubscribeBrowserLocation();
|
||||||
unsubscribeState();
|
unsubscribeState();
|
||||||
unsubscribeReady();
|
unsubscribeReady();
|
||||||
};
|
};
|
||||||
@@ -855,23 +904,21 @@ export default function RootLayout() {
|
|||||||
<SafeAreaProvider>
|
<SafeAreaProvider>
|
||||||
<KeyboardProvider>
|
<KeyboardProvider>
|
||||||
<QueryProvider>
|
<QueryProvider>
|
||||||
<BottomSheetModalProvider>
|
<HostRuntimeBootstrapProvider>
|
||||||
<HostRuntimeBootstrapProvider>
|
<PushNotificationRouter />
|
||||||
<PushNotificationRouter />
|
<ToastProvider>
|
||||||
<ProvidersWrapper>
|
<ProvidersWrapper>
|
||||||
<SidebarAnimationProvider>
|
<SidebarAnimationProvider>
|
||||||
<HorizontalScrollProvider>
|
<HorizontalScrollProvider>
|
||||||
<ToastProvider>
|
<OpenProjectListener />
|
||||||
<OpenProjectListener />
|
<AppWithSidebar>
|
||||||
<AppWithSidebar>
|
<RootStack />
|
||||||
<RootStack />
|
</AppWithSidebar>
|
||||||
</AppWithSidebar>
|
|
||||||
</ToastProvider>
|
|
||||||
</HorizontalScrollProvider>
|
</HorizontalScrollProvider>
|
||||||
</SidebarAnimationProvider>
|
</SidebarAnimationProvider>
|
||||||
</ProvidersWrapper>
|
</ProvidersWrapper>
|
||||||
</HostRuntimeBootstrapProvider>
|
</ToastProvider>
|
||||||
</BottomSheetModalProvider>
|
</HostRuntimeBootstrapProvider>
|
||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
</KeyboardProvider>
|
</KeyboardProvider>
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useRef } from "react";
|
|||||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
import { useResolveWorkspaceIdByCwd } from "@/stores/session-store-hooks";
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||||
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||||
@@ -32,21 +33,10 @@ function HostAgentReadyRouteContent() {
|
|||||||
}
|
}
|
||||||
return state.sessions[serverId]?.agents?.get(agentId)?.cwd ?? null;
|
return state.sessions[serverId]?.agents?.get(agentId)?.cwd ?? null;
|
||||||
});
|
});
|
||||||
const sessionWorkspaces = useSessionStore((state) =>
|
|
||||||
serverId ? state.sessions[serverId]?.workspaces : undefined,
|
|
||||||
);
|
|
||||||
const hasHydratedWorkspaces = useSessionStore((state) =>
|
const hasHydratedWorkspaces = useSessionStore((state) =>
|
||||||
serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false,
|
serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false,
|
||||||
);
|
);
|
||||||
const resolvedWorkspaceId = useSessionStore((state) => {
|
const resolvedWorkspaceId = useResolveWorkspaceIdByCwd(serverId, agentCwd);
|
||||||
if (!serverId || !agentId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return resolveWorkspaceIdByExecutionDirectory({
|
|
||||||
workspaces: state.sessions[serverId]?.workspaces?.values(),
|
|
||||||
workspaceDirectory: state.sessions[serverId]?.agents?.get(agentId)?.cwd,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (redirectedRef.current) {
|
if (redirectedRef.current) {
|
||||||
@@ -102,8 +92,9 @@ function HostAgentReadyRouteContent() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const cwd = result?.agent?.cwd?.trim();
|
const cwd = result?.agent?.cwd?.trim();
|
||||||
|
const workspaces = useSessionStore.getState().sessions[serverId]?.workspaces;
|
||||||
const workspaceId = resolveWorkspaceIdByExecutionDirectory({
|
const workspaceId = resolveWorkspaceIdByExecutionDirectory({
|
||||||
workspaces: sessionWorkspaces?.values(),
|
workspaces: workspaces?.values(),
|
||||||
workspaceDirectory: cwd,
|
workspaceDirectory: cwd,
|
||||||
});
|
});
|
||||||
if (!workspaceId && !hasHydratedWorkspaces) {
|
if (!workspaceId && !hasHydratedWorkspaces) {
|
||||||
@@ -133,7 +124,7 @@ function HostAgentReadyRouteContent() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [agentId, client, hasHydratedWorkspaces, isConnected, router, serverId, sessionWorkspaces]);
|
}, [agentId, client, hasHydratedWorkspaces, isConnected, router, serverId]);
|
||||||
|
|
||||||
return null;
|
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 { useNavigation } from "@react-navigation/native";
|
||||||
|
import { StyleSheet, View } from "react-native";
|
||||||
import {
|
import {
|
||||||
useGlobalSearchParams,
|
useGlobalSearchParams,
|
||||||
useLocalSearchParams,
|
useLocalSearchParams,
|
||||||
@@ -7,8 +8,14 @@ import {
|
|||||||
useRootNavigationState,
|
useRootNavigationState,
|
||||||
} from "expo-router";
|
} from "expo-router";
|
||||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||||
|
import {
|
||||||
|
activateNavigationWorkspaceSelection,
|
||||||
|
type ActiveWorkspaceSelection,
|
||||||
|
useNavigationActiveWorkspaceSelection,
|
||||||
|
} from "@/stores/navigation-active-workspace-store";
|
||||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||||
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
||||||
|
import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store";
|
||||||
import {
|
import {
|
||||||
buildHostWorkspaceRoute,
|
buildHostWorkspaceRoute,
|
||||||
decodeWorkspaceIdFromPathSegment,
|
decodeWorkspaceIdFromPathSegment,
|
||||||
@@ -63,9 +70,11 @@ function clearConsumedOpenIntent(input: {
|
|||||||
serverId: string;
|
serverId: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
}) {
|
}) {
|
||||||
input.router.replace(buildHostWorkspaceRoute(input.serverId, input.workspaceId));
|
|
||||||
input.navigation.setParams({ open: undefined });
|
input.navigation.setParams({ open: undefined });
|
||||||
stripOpenSearchParamFromBrowserUrl();
|
if (isWeb) {
|
||||||
|
input.router.replace(buildHostWorkspaceRoute(input.serverId, input.workspaceId));
|
||||||
|
stripOpenSearchParamFromBrowserUrl();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HostWorkspaceLayout() {
|
export default function HostWorkspaceLayout() {
|
||||||
@@ -80,6 +89,7 @@ function HostWorkspaceLayoutContent() {
|
|||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const rootNavigationState = useRootNavigationState();
|
const rootNavigationState = useRootNavigationState();
|
||||||
|
const hasHydratedWorkspaceLayoutStore = useWorkspaceLayoutStoreHydrated();
|
||||||
const consumedIntentRef = useRef<string | null>(null);
|
const consumedIntentRef = useRef<string | null>(null);
|
||||||
const [intentConsumed, setIntentConsumed] = useState(false);
|
const [intentConsumed, setIntentConsumed] = useState(false);
|
||||||
const params = useLocalSearchParams<{
|
const params = useLocalSearchParams<{
|
||||||
@@ -95,6 +105,23 @@ function HostWorkspaceLayoutContent() {
|
|||||||
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
|
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
|
||||||
: "";
|
: "";
|
||||||
const openValue = getParamValue(globalParams.open);
|
const openValue = getParamValue(globalParams.open);
|
||||||
|
const routeWorkspaceSelection = useMemo(
|
||||||
|
() =>
|
||||||
|
serverId && workspaceId
|
||||||
|
? {
|
||||||
|
serverId,
|
||||||
|
workspaceId,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
[serverId, workspaceId],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!routeWorkspaceSelection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
activateNavigationWorkspaceSelection(routeWorkspaceSelection);
|
||||||
|
}, [routeWorkspaceSelection]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!openValue) {
|
if (!openValue) {
|
||||||
@@ -103,6 +130,9 @@ function HostWorkspaceLayoutContent() {
|
|||||||
if (!rootNavigationState?.key) {
|
if (!rootNavigationState?.key) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!hasHydratedWorkspaceLayoutStore) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const consumptionKey = `${serverId}:${workspaceId}:${openValue}`;
|
const consumptionKey = `${serverId}:${workspaceId}:${openValue}`;
|
||||||
if (consumedIntentRef.current === consumptionKey) {
|
if (consumedIntentRef.current === consumptionKey) {
|
||||||
@@ -138,17 +168,87 @@ function HostWorkspaceLayoutContent() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
setIntentConsumed(true);
|
setIntentConsumed(true);
|
||||||
}, [navigation, openValue, rootNavigationState?.key, router, serverId, workspaceId]);
|
}, [
|
||||||
|
hasHydratedWorkspaceLayoutStore,
|
||||||
|
navigation,
|
||||||
|
openValue,
|
||||||
|
rootNavigationState?.key,
|
||||||
|
router,
|
||||||
|
serverId,
|
||||||
|
workspaceId,
|
||||||
|
]);
|
||||||
|
|
||||||
if (openValue && !intentConsumed) {
|
if (openValue && (!intentConsumed || !hasHydratedWorkspaceLayoutStore)) {
|
||||||
|
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 null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WorkspaceScreen
|
<View style={styles.deck}>
|
||||||
key={`${serverId}:${workspaceId}`}
|
{mountedSelections.map((selection) => {
|
||||||
serverId={serverId}
|
const isActive = areWorkspaceSelectionsEqual(selection, activeSelection);
|
||||||
workspaceId={workspaceId}
|
return (
|
||||||
/>
|
<View
|
||||||
|
key={`${selection.serverId}:${selection.workspaceId}`}
|
||||||
|
style={isActive ? styles.activeDeckEntry : styles.inactiveDeckEntry}
|
||||||
|
testID={`workspace-deck-entry-${selection.serverId}:${selection.workspaceId}`}
|
||||||
|
>
|
||||||
|
<WorkspaceScreen
|
||||||
|
serverId={selection.serverId}
|
||||||
|
workspaceId={selection.workspaceId}
|
||||||
|
isRouteFocused={isActive}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
deck: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
activeDeckEntry: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
inactiveDeckEntry: {
|
||||||
|
display: "none",
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createLocalFileAttachmentStore } from "./local-file-attachment-store";
|
||||||
|
|
||||||
|
const fileSystemMock = vi.hoisted(() => ({
|
||||||
|
getInfoAsync: vi.fn(async (uri: string) => {
|
||||||
|
if (uri.endsWith("/preview-assets/")) {
|
||||||
|
return { exists: true, isDirectory: true };
|
||||||
|
}
|
||||||
|
return { exists: true, isDirectory: false, size: 4 };
|
||||||
|
}),
|
||||||
|
makeDirectoryAsync: vi.fn(async () => {}),
|
||||||
|
writeAsStringAsync: vi.fn(async () => {}),
|
||||||
|
copyAsync: vi.fn(async () => {}),
|
||||||
|
readAsStringAsync: vi.fn(async () => "AAECAw=="),
|
||||||
|
deleteAsync: vi.fn(async () => {}),
|
||||||
|
readDirectoryAsync: vi.fn(async () => []),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("expo-file-system/legacy", () => ({
|
||||||
|
cacheDirectory: "file:///cache/",
|
||||||
|
EncodingType: { Base64: "base64" },
|
||||||
|
getInfoAsync: fileSystemMock.getInfoAsync,
|
||||||
|
makeDirectoryAsync: fileSystemMock.makeDirectoryAsync,
|
||||||
|
writeAsStringAsync: fileSystemMock.writeAsStringAsync,
|
||||||
|
copyAsync: fileSystemMock.copyAsync,
|
||||||
|
readAsStringAsync: fileSystemMock.readAsStringAsync,
|
||||||
|
deleteAsync: fileSystemMock.deleteAsync,
|
||||||
|
readDirectoryAsync: fileSystemMock.readDirectoryAsync,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("local file attachment store", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
fileSystemMock.getInfoAsync.mockClear();
|
||||||
|
fileSystemMock.makeDirectoryAsync.mockClear();
|
||||||
|
fileSystemMock.writeAsStringAsync.mockClear();
|
||||||
|
fileSystemMock.copyAsync.mockClear();
|
||||||
|
fileSystemMock.readAsStringAsync.mockClear();
|
||||||
|
fileSystemMock.deleteAsync.mockClear();
|
||||||
|
fileSystemMock.readDirectoryAsync.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes raw base64 sources directly to the managed file path", async () => {
|
||||||
|
const store = createLocalFileAttachmentStore({
|
||||||
|
storageType: "native-file",
|
||||||
|
baseDirectoryName: "preview-assets",
|
||||||
|
resolvePreviewUrl: async (attachment) => `file://${attachment.storageKey}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const attachment = await store.save({
|
||||||
|
id: "preview_8_test",
|
||||||
|
mimeType: "image/png",
|
||||||
|
fileName: "result.png",
|
||||||
|
source: { kind: "base64", base64: "AAECAw==" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fileSystemMock.writeAsStringAsync).toHaveBeenCalledWith(
|
||||||
|
"file:///cache/preview-assets/preview_8_test.png",
|
||||||
|
"AAECAw==",
|
||||||
|
{ encoding: "base64" },
|
||||||
|
);
|
||||||
|
expect(attachment).toMatchObject({
|
||||||
|
id: "preview_8_test",
|
||||||
|
mimeType: "image/png",
|
||||||
|
storageType: "native-file",
|
||||||
|
storageKey: "/cache/preview-assets/preview_8_test.png",
|
||||||
|
fileName: "result.png",
|
||||||
|
byteSize: 4,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -72,6 +72,13 @@ async function writeFromSource(input: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (input.source.kind === "base64") {
|
||||||
|
await FileSystem.writeAsStringAsync(input.targetUri, input.source.base64, {
|
||||||
|
encoding: FileSystem.EncodingType.Base64,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const base64 = await blobToBase64(input.source.blob);
|
const base64 = await blobToBase64(input.source.blob);
|
||||||
await FileSystem.writeAsStringAsync(input.targetUri, base64, {
|
await FileSystem.writeAsStringAsync(input.targetUri, base64, {
|
||||||
encoding: FileSystem.EncodingType.Base64,
|
encoding: FileSystem.EncodingType.Base64,
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import type { AttachmentMetadata, AttachmentStore, SaveAttachmentInput } from "@/attachments/types";
|
||||||
|
import { __setAttachmentStoreForTests } from "./store";
|
||||||
|
import { encodeAttachmentsForSend, persistAttachmentFromBase64 } from "./service";
|
||||||
|
|
||||||
|
function createAttachment(input: Partial<AttachmentMetadata> = {}): AttachmentMetadata {
|
||||||
|
return {
|
||||||
|
id: input.id ?? "att_1",
|
||||||
|
mimeType: input.mimeType ?? "image/png",
|
||||||
|
storageType: input.storageType ?? "web-indexeddb",
|
||||||
|
storageKey: input.storageKey ?? "att_1",
|
||||||
|
fileName: input.fileName,
|
||||||
|
byteSize: input.byteSize,
|
||||||
|
createdAt: input.createdAt ?? 1700000000000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRecordingStore(): AttachmentStore & {
|
||||||
|
savedSources: SaveAttachmentInput[];
|
||||||
|
releasedUrls: string[];
|
||||||
|
} {
|
||||||
|
const savedSources: SaveAttachmentInput[] = [];
|
||||||
|
const releasedUrls: string[] = [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
storageType: "web-indexeddb",
|
||||||
|
savedSources,
|
||||||
|
releasedUrls,
|
||||||
|
async save(input) {
|
||||||
|
savedSources.push(input);
|
||||||
|
return createAttachment({
|
||||||
|
id: input.id,
|
||||||
|
mimeType: input.mimeType,
|
||||||
|
fileName: input.fileName,
|
||||||
|
byteSize: 4,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async encodeBase64({ attachment }) {
|
||||||
|
return `${attachment.id}:base64`;
|
||||||
|
},
|
||||||
|
async resolvePreviewUrl({ attachment }) {
|
||||||
|
return `blob:${attachment.id}`;
|
||||||
|
},
|
||||||
|
async releasePreviewUrl({ url }) {
|
||||||
|
releasedUrls.push(url);
|
||||||
|
},
|
||||||
|
async delete() {},
|
||||||
|
async garbageCollect() {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("attachment service", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
__setAttachmentStoreForTests(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists raw base64 without requiring a data URL wrapper", async () => {
|
||||||
|
const store = createRecordingStore();
|
||||||
|
__setAttachmentStoreForTests(store);
|
||||||
|
|
||||||
|
const attachment = await persistAttachmentFromBase64({
|
||||||
|
id: "att_base64",
|
||||||
|
base64: "AAECAw==",
|
||||||
|
mimeType: "image/png",
|
||||||
|
fileName: "image.png",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(attachment).toEqual({
|
||||||
|
id: "att_base64",
|
||||||
|
mimeType: "image/png",
|
||||||
|
storageType: "web-indexeddb",
|
||||||
|
storageKey: "att_1",
|
||||||
|
fileName: "image.png",
|
||||||
|
byteSize: 4,
|
||||||
|
createdAt: 1700000000000,
|
||||||
|
});
|
||||||
|
expect(store.savedSources).toEqual([
|
||||||
|
{
|
||||||
|
id: "att_base64",
|
||||||
|
mimeType: "image/png",
|
||||||
|
fileName: "image.png",
|
||||||
|
source: { kind: "base64", base64: "AAECAw==" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps provider send output byte-compatible", async () => {
|
||||||
|
const store = createRecordingStore();
|
||||||
|
__setAttachmentStoreForTests(store);
|
||||||
|
const attachment = createAttachment({ id: "att_send", mimeType: "image/jpeg" });
|
||||||
|
|
||||||
|
await expect(encodeAttachmentsForSend([attachment])).resolves.toEqual([
|
||||||
|
{ data: "att_send:base64", mimeType: "image/jpeg" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -31,6 +31,21 @@ export async function persistAttachmentFromDataUrl(input: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function persistAttachmentFromBase64(input: {
|
||||||
|
base64: string;
|
||||||
|
mimeType?: string;
|
||||||
|
fileName?: string | null;
|
||||||
|
id?: string;
|
||||||
|
}): Promise<AttachmentMetadata> {
|
||||||
|
const store = await getAttachmentStore();
|
||||||
|
return await store.save({
|
||||||
|
id: input.id,
|
||||||
|
mimeType: input.mimeType,
|
||||||
|
fileName: input.fileName,
|
||||||
|
source: { kind: "base64", base64: input.base64 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function persistAttachmentFromFileUri(input: {
|
export async function persistAttachmentFromFileUri(input: {
|
||||||
uri: string;
|
uri: string;
|
||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export type ComposerAttachment =
|
|||||||
| { kind: "github_pr"; item: GitHubSearchItem };
|
| { kind: "github_pr"; item: GitHubSearchItem };
|
||||||
|
|
||||||
export type AttachmentDataSource =
|
export type AttachmentDataSource =
|
||||||
|
| { kind: "base64"; base64: string }
|
||||||
| { kind: "blob"; blob: Blob }
|
| { kind: "blob"; blob: Blob }
|
||||||
| { kind: "data_url"; dataUrl: string }
|
| { kind: "data_url"; dataUrl: string }
|
||||||
| { kind: "file_uri"; uri: string };
|
| { kind: "file_uri"; uri: string };
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { pathToFileUri } from "./utils";
|
import { createImageSourceCacheKey, parseDataUrl, parseImageDataUrl, pathToFileUri } from "./utils";
|
||||||
|
|
||||||
describe("pathToFileUri", () => {
|
describe("pathToFileUri", () => {
|
||||||
it("converts POSIX absolute paths to file URIs", () => {
|
it("converts POSIX absolute paths to file URIs", () => {
|
||||||
@@ -22,3 +22,35 @@ describe("pathToFileUri", () => {
|
|||||||
expect(pathToFileUri("relative/path")).toBe("relative/path");
|
expect(pathToFileUri("relative/path")).toBe("relative/path");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("parseDataUrl", () => {
|
||||||
|
it("accepts base64 data URLs with media-type parameters", () => {
|
||||||
|
expect(parseDataUrl("data:image/png;charset=utf-8;name=preview;base64,AAECAw==")).toEqual({
|
||||||
|
mimeType: "image/png",
|
||||||
|
base64: "AAECAw==",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-base64 data URLs", () => {
|
||||||
|
expect(() => parseDataUrl("data:image/png,not-base64")).toThrow(
|
||||||
|
"Attachment data URL is not base64 encoded.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseImageDataUrl", () => {
|
||||||
|
it("returns a compact cache key for image data URLs", () => {
|
||||||
|
const dataUrl = `data:image/png;base64,${"a".repeat(512)}`;
|
||||||
|
|
||||||
|
expect(parseImageDataUrl(dataUrl)).toMatchObject({
|
||||||
|
mimeType: "image/png",
|
||||||
|
base64: "a".repeat(512),
|
||||||
|
});
|
||||||
|
expect(createImageSourceCacheKey(dataUrl)).toMatch(/^data-image:image\/png:512:/);
|
||||||
|
expect(createImageSourceCacheKey(dataUrl)).not.toContain("a".repeat(128));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores non-image data URLs", () => {
|
||||||
|
expect(parseImageDataUrl("data:text/plain;base64,SGVsbG8=")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -14,11 +14,17 @@ export function normalizeMimeType(input: string | undefined | null): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function parseDataUrl(dataUrl: string): { mimeType: string; base64: string } {
|
export function parseDataUrl(dataUrl: string): { mimeType: string; base64: string } {
|
||||||
const match = /^data:([^;,]+)?;base64,(.+)$/i.exec(dataUrl);
|
const match = /^data:([^,]*),([\s\S]+)$/i.exec(dataUrl.trim());
|
||||||
if (!match) {
|
if (!match) {
|
||||||
throw new Error("Malformed data URL for attachment.");
|
throw new Error("Malformed data URL for attachment.");
|
||||||
}
|
}
|
||||||
const [, mimeTypeRaw, base64] = match;
|
const metadata = match[1] ?? "";
|
||||||
|
const base64 = match[2]?.replace(/\s/g, "");
|
||||||
|
const [mimeTypeRaw, ...parameters] = metadata.split(";").map((part) => part.trim());
|
||||||
|
const isBase64 = parameters.some((part) => part.toLowerCase() === "base64");
|
||||||
|
if (!isBase64) {
|
||||||
|
throw new Error("Attachment data URL is not base64 encoded.");
|
||||||
|
}
|
||||||
if (!base64) {
|
if (!base64) {
|
||||||
throw new Error("Attachment data URL is missing base64 payload.");
|
throw new Error("Attachment data URL is missing base64 payload.");
|
||||||
}
|
}
|
||||||
@@ -28,6 +34,61 @@ export function parseDataUrl(dataUrl: string): { mimeType: string; base64: strin
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hashString(value: string): string {
|
||||||
|
let hash = 2166136261;
|
||||||
|
for (let index = 0; index < value.length; index += 1) {
|
||||||
|
hash ^= value.charCodeAt(index);
|
||||||
|
hash = Math.imul(hash, 16777619);
|
||||||
|
}
|
||||||
|
return (hash >>> 0).toString(36);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseImageDataUrl(
|
||||||
|
uri: string,
|
||||||
|
): { mimeType: string; base64: string; cacheKey: string } | null {
|
||||||
|
if (!uri.trim().toLowerCase().startsWith("data:image/")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = parseDataUrl(uri);
|
||||||
|
if (!parsed.mimeType.toLowerCase().startsWith("image/")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...parsed,
|
||||||
|
cacheKey: `data-image:${parsed.mimeType}:${parsed.base64.length}:${hashString(parsed.base64)}`,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createImageSourceCacheKey(source: string): string {
|
||||||
|
return parseImageDataUrl(source)?.cacheKey ?? source;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFileNameFromPath(path: string | null | undefined): string | null {
|
||||||
|
const trimmed = path?.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = trimmed.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||||
|
const fileName = normalized.split("/").pop()?.trim();
|
||||||
|
return fileName || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPreviewAttachmentId(input: {
|
||||||
|
base64: string;
|
||||||
|
mimeType: string;
|
||||||
|
path?: string | null;
|
||||||
|
}): string {
|
||||||
|
const path = input.path?.trim() ?? "";
|
||||||
|
const hash = hashString(`${input.mimeType}\0${path}\0${input.base64}`);
|
||||||
|
return `preview_${input.base64.length}_${hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
export async function blobToBase64(blob: Blob): Promise<string> {
|
export async function blobToBase64(blob: Blob): Promise<string> {
|
||||||
return await new Promise<string>((resolve, reject) => {
|
return await new Promise<string>((resolve, reject) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
|
|||||||
@@ -74,6 +74,15 @@ function runTx<T>(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function base64ToBlob(input: { base64: string; mimeType: string }): Blob {
|
||||||
|
const binary = atob(input.base64);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let index = 0; index < binary.length; index += 1) {
|
||||||
|
bytes[index] = binary.charCodeAt(index);
|
||||||
|
}
|
||||||
|
return new Blob([bytes], { type: input.mimeType });
|
||||||
|
}
|
||||||
|
|
||||||
async function sourceToBlob(input: SaveAttachmentInput): Promise<{ blob: Blob; mimeType: string }> {
|
async function sourceToBlob(input: SaveAttachmentInput): Promise<{ blob: Blob; mimeType: string }> {
|
||||||
const source = input.source;
|
const source = input.source;
|
||||||
if (source.kind === "blob") {
|
if (source.kind === "blob") {
|
||||||
@@ -96,6 +105,14 @@ async function sourceToBlob(input: SaveAttachmentInput): Promise<{ blob: Blob; m
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (source.kind === "base64") {
|
||||||
|
const mimeType = normalizeMimeType(input.mimeType);
|
||||||
|
return {
|
||||||
|
blob: base64ToBlob({ base64: source.base64, mimeType }),
|
||||||
|
mimeType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(source.uri);
|
const response = await fetch(source.uri);
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
const mimeType = normalizeMimeType(input.mimeType ?? blob.type);
|
const mimeType = normalizeMimeType(input.mimeType ?? blob.type);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react";
|
import { forwardRef, useCallback, useEffect, useMemo } from "react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
|
import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
|
||||||
@@ -7,7 +7,6 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
|||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root";
|
import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root";
|
||||||
import {
|
import {
|
||||||
BottomSheetModal,
|
|
||||||
BottomSheetBackdrop,
|
BottomSheetBackdrop,
|
||||||
BottomSheetScrollView,
|
BottomSheetScrollView,
|
||||||
BottomSheetTextInput,
|
BottomSheetTextInput,
|
||||||
@@ -16,6 +15,10 @@ import {
|
|||||||
import { X } from "lucide-react-native";
|
import { X } from "lucide-react-native";
|
||||||
import { FileDropZone } from "@/components/file-drop-zone";
|
import { FileDropZone } from "@/components/file-drop-zone";
|
||||||
import type { ImageAttachment } from "@/components/message-input";
|
import type { ImageAttachment } from "@/components/message-input";
|
||||||
|
import {
|
||||||
|
IsolatedBottomSheetModal,
|
||||||
|
useIsolatedBottomSheetVisibility,
|
||||||
|
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
type EscHandler = () => void;
|
type EscHandler = () => void;
|
||||||
@@ -109,9 +112,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
gap: theme.spacing[4],
|
gap: theme.spacing[4],
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
},
|
},
|
||||||
bottomSheetHandle: {
|
|
||||||
backgroundColor: theme.colors.surface2,
|
|
||||||
},
|
|
||||||
bottomSheetHeader: {
|
bottomSheetHeader: {
|
||||||
paddingHorizontal: theme.spacing[6],
|
paddingHorizontal: theme.spacing[6],
|
||||||
paddingTop: theme.spacing[4],
|
paddingTop: theme.spacing[4],
|
||||||
@@ -166,7 +166,6 @@ export interface AdaptiveModalSheetProps {
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
headerActions?: ReactNode;
|
headerActions?: ReactNode;
|
||||||
snapPoints?: string[];
|
snapPoints?: string[];
|
||||||
stackBehavior?: "push" | "switch" | "replace";
|
|
||||||
testID?: string;
|
testID?: string;
|
||||||
/** Override the max width of the desktop card. */
|
/** Override the max width of the desktop card. */
|
||||||
desktopMaxWidth?: number;
|
desktopMaxWidth?: number;
|
||||||
@@ -183,7 +182,6 @@ export function AdaptiveModalSheet({
|
|||||||
children,
|
children,
|
||||||
headerActions,
|
headerActions,
|
||||||
snapPoints,
|
snapPoints,
|
||||||
stackBehavior,
|
|
||||||
testID,
|
testID,
|
||||||
desktopMaxWidth,
|
desktopMaxWidth,
|
||||||
onFilesDropped,
|
onFilesDropped,
|
||||||
@@ -191,33 +189,13 @@ export function AdaptiveModalSheet({
|
|||||||
}: AdaptiveModalSheetProps) {
|
}: AdaptiveModalSheetProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
const sheetRef = useRef<BottomSheetModal>(null);
|
const titleColor = theme.colors.foreground;
|
||||||
const dismissingForVisibilityRef = useRef(false);
|
|
||||||
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
|
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
|
||||||
|
const { sheetRef, handleSheetChange } = useIsolatedBottomSheetVisibility({
|
||||||
useEffect(() => {
|
visible,
|
||||||
if (!isMobile) return;
|
isEnabled: isMobile,
|
||||||
if (visible) {
|
onClose,
|
||||||
dismissingForVisibilityRef.current = false;
|
});
|
||||||
sheetRef.current?.present();
|
|
||||||
} else {
|
|
||||||
dismissingForVisibilityRef.current = true;
|
|
||||||
sheetRef.current?.dismiss();
|
|
||||||
}
|
|
||||||
}, [visible, isMobile]);
|
|
||||||
|
|
||||||
const handleSheetChange = useCallback(
|
|
||||||
(index: number) => {
|
|
||||||
if (index === -1) {
|
|
||||||
if (dismissingForVisibilityRef.current) {
|
|
||||||
dismissingForVisibilityRef.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[onClose],
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
const renderBackdrop = useCallback(
|
||||||
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||||
@@ -233,7 +211,7 @@ export function AdaptiveModalSheet({
|
|||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
<BottomSheetModal
|
<IsolatedBottomSheetModal
|
||||||
ref={sheetRef}
|
ref={sheetRef}
|
||||||
snapPoints={resolvedSnapPoints}
|
snapPoints={resolvedSnapPoints}
|
||||||
index={0}
|
index={0}
|
||||||
@@ -241,16 +219,15 @@ export function AdaptiveModalSheet({
|
|||||||
onChange={handleSheetChange}
|
onChange={handleSheetChange}
|
||||||
backdropComponent={renderBackdrop}
|
backdropComponent={renderBackdrop}
|
||||||
enablePanDownToClose
|
enablePanDownToClose
|
||||||
stackBehavior={stackBehavior}
|
|
||||||
backgroundComponent={SheetBackground}
|
backgroundComponent={SheetBackground}
|
||||||
handleIndicatorStyle={styles.bottomSheetHandle}
|
handleIndicatorStyle={{ backgroundColor: theme.colors.surface2 }}
|
||||||
keyboardBehavior="extend"
|
keyboardBehavior="extend"
|
||||||
keyboardBlurBehavior="restore"
|
keyboardBlurBehavior="restore"
|
||||||
accessible={false}
|
accessible={false}
|
||||||
>
|
>
|
||||||
<View style={styles.bottomSheetHeader} testID={testID}>
|
<View style={styles.bottomSheetHeader} testID={testID}>
|
||||||
<View style={styles.headerTitleGroup}>
|
<View style={styles.headerTitleGroup}>
|
||||||
<Text style={[styles.title, { color: theme.colors.foreground }]} numberOfLines={1}>
|
<Text key={titleColor} style={[styles.title, { color: titleColor }]} numberOfLines={1}>
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
{subtitle}
|
{subtitle}
|
||||||
@@ -271,7 +248,7 @@ export function AdaptiveModalSheet({
|
|||||||
) : (
|
) : (
|
||||||
<View style={styles.bottomSheetStaticContent}>{children}</View>
|
<View style={styles.bottomSheetStaticContent}>{children}</View>
|
||||||
)}
|
)}
|
||||||
</BottomSheetModal>
|
</IsolatedBottomSheetModal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,7 +256,7 @@ export function AdaptiveModalSheet({
|
|||||||
<>
|
<>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<View style={styles.headerTitleGroup}>
|
<View style={styles.headerTitleGroup}>
|
||||||
<Text style={[styles.title, { color: theme.colors.foreground }]} numberOfLines={1}>
|
<Text key={titleColor} style={[styles.title, { color: titleColor }]} numberOfLines={1}>
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
{subtitle}
|
{subtitle}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { View, Text, Pressable, TextInput, ActivityIndicator } from "react-nativ
|
|||||||
import type { StyleProp, ViewStyle, TextProps } from "react-native";
|
import type { StyleProp, ViewStyle, TextProps } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import {
|
import {
|
||||||
BottomSheetModal,
|
|
||||||
BottomSheetScrollView,
|
BottomSheetScrollView,
|
||||||
BottomSheetBackdrop,
|
BottomSheetBackdrop,
|
||||||
BottomSheetBackgroundProps,
|
BottomSheetBackgroundProps,
|
||||||
@@ -22,7 +21,6 @@ import {
|
|||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
ShieldOff,
|
ShieldOff,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { theme as defaultTheme } from "@/styles/theme";
|
|
||||||
import type {
|
import type {
|
||||||
AgentMode,
|
AgentMode,
|
||||||
AgentModelDefinition,
|
AgentModelDefinition,
|
||||||
@@ -31,6 +29,10 @@ import type {
|
|||||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||||
import { getModeVisuals, type AgentModeIcon } from "@server/server/agent/provider-manifest";
|
import { getModeVisuals, type AgentModeIcon } from "@server/server/agent/provider-manifest";
|
||||||
import { Combobox, ComboboxItem, ComboboxEmpty } from "@/components/ui/combobox";
|
import { Combobox, ComboboxItem, ComboboxEmpty } from "@/components/ui/combobox";
|
||||||
|
import {
|
||||||
|
IsolatedBottomSheetModal,
|
||||||
|
useIsolatedBottomSheetVisibility,
|
||||||
|
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||||
import { baseColors } from "@/styles/theme";
|
import { baseColors } from "@/styles/theme";
|
||||||
import { isNative } from "@/constants/platform";
|
import { isNative } from "@/constants/platform";
|
||||||
|
|
||||||
@@ -86,6 +88,8 @@ export function DropdownField({
|
|||||||
renderTrigger,
|
renderTrigger,
|
||||||
testID,
|
testID,
|
||||||
}: DropdownFieldProps): ReactElement {
|
}: DropdownFieldProps): ReactElement {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
|
||||||
if (renderTrigger) {
|
if (renderTrigger) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -115,7 +119,7 @@ export function DropdownField({
|
|||||||
<Text style={value ? styles.dropdownValue : styles.dropdownPlaceholder} numberOfLines={1}>
|
<Text style={value ? styles.dropdownValue : styles.dropdownPlaceholder} numberOfLines={1}>
|
||||||
{value || placeholder}
|
{value || placeholder}
|
||||||
</Text>
|
</Text>
|
||||||
<ChevronDown size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
|
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
||||||
{warningMessage ? <Text style={styles.warningText}>{warningMessage}</Text> : null}
|
{warningMessage ? <Text style={styles.warningText}>{warningMessage}</Text> : null}
|
||||||
@@ -151,6 +155,8 @@ export function SelectField({
|
|||||||
valueEllipsizeMode,
|
valueEllipsizeMode,
|
||||||
testID,
|
testID,
|
||||||
}: SelectFieldProps): ReactElement {
|
}: SelectFieldProps): ReactElement {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
|
||||||
const getWebKey = useCallback((event: unknown): string | null => {
|
const getWebKey = useCallback((event: unknown): string | null => {
|
||||||
if (!event || typeof event !== "object") return null;
|
if (!event || typeof event !== "object") return null;
|
||||||
const eventWithNative = event as { nativeEvent?: unknown; key?: unknown };
|
const eventWithNative = event as { nativeEvent?: unknown; key?: unknown };
|
||||||
@@ -210,7 +216,7 @@ export function SelectField({
|
|||||||
{value || placeholder || "Select..."}
|
{value || placeholder || "Select..."}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<ChevronRight size={defaultTheme.iconSize.lg} color={defaultTheme.colors.foregroundMuted} />
|
<ChevronRight size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
||||||
{warningMessage ? <Text style={styles.warningText}>{warningMessage}</Text> : null}
|
{warningMessage ? <Text style={styles.warningText}>{warningMessage}</Text> : null}
|
||||||
@@ -229,7 +235,21 @@ interface DropdownSheetProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function DropdownSheetBackground({ style }: BottomSheetBackgroundProps) {
|
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({
|
export function DropdownSheet({
|
||||||
@@ -238,31 +258,18 @@ export function DropdownSheet({
|
|||||||
onClose,
|
onClose,
|
||||||
children,
|
children,
|
||||||
}: DropdownSheetProps): ReactElement {
|
}: DropdownSheetProps): ReactElement {
|
||||||
const bottomSheetRef = useRef<BottomSheetModal>(null);
|
const { theme } = useUnistyles();
|
||||||
|
const titleColor = theme.colors.foreground;
|
||||||
const snapPoints = useMemo(() => ["60%", "90%"], []);
|
const snapPoints = useMemo(() => ["60%", "90%"], []);
|
||||||
|
const { sheetRef, handleSheetChange } = useIsolatedBottomSheetVisibility({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
});
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
bottomSheetRef.current?.dismiss();
|
|
||||||
onClose();
|
onClose();
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (visible) {
|
|
||||||
bottomSheetRef.current?.present();
|
|
||||||
} else {
|
|
||||||
bottomSheetRef.current?.dismiss();
|
|
||||||
}
|
|
||||||
}, [visible]);
|
|
||||||
|
|
||||||
const handleSheetChange = useCallback(
|
|
||||||
(index: number) => {
|
|
||||||
if (index === -1) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[onClose],
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
const renderBackdrop = useCallback(
|
||||||
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||||
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
|
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
|
||||||
@@ -271,8 +278,8 @@ export function DropdownSheet({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BottomSheetModal
|
<IsolatedBottomSheetModal
|
||||||
ref={bottomSheetRef}
|
ref={sheetRef}
|
||||||
snapPoints={snapPoints}
|
snapPoints={snapPoints}
|
||||||
index={0}
|
index={0}
|
||||||
enableDynamicSizing={false}
|
enableDynamicSizing={false}
|
||||||
@@ -280,12 +287,14 @@ export function DropdownSheet({
|
|||||||
backdropComponent={renderBackdrop}
|
backdropComponent={renderBackdrop}
|
||||||
enablePanDownToClose
|
enablePanDownToClose
|
||||||
backgroundComponent={DropdownSheetBackground}
|
backgroundComponent={DropdownSheetBackground}
|
||||||
handleIndicatorStyle={styles.bottomSheetHandle}
|
handleIndicatorStyle={{ backgroundColor: theme.colors.palette.zinc[600] }}
|
||||||
keyboardBehavior="extend"
|
keyboardBehavior="extend"
|
||||||
keyboardBlurBehavior="restore"
|
keyboardBlurBehavior="restore"
|
||||||
>
|
>
|
||||||
<View style={styles.bottomSheetHeader}>
|
<View style={styles.bottomSheetHeader}>
|
||||||
<Text style={styles.dropdownSheetTitle}>{title}</Text>
|
<Text key={titleColor} style={[styles.dropdownSheetTitle, { color: titleColor }]}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
<Pressable
|
<Pressable
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel="Close sheet"
|
accessibilityLabel="Close sheet"
|
||||||
@@ -293,7 +302,7 @@ export function DropdownSheet({
|
|||||||
hitSlop={10}
|
hitSlop={10}
|
||||||
testID="dropdown-sheet-close"
|
testID="dropdown-sheet-close"
|
||||||
>
|
>
|
||||||
<X size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
|
<X size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
<BottomSheetScrollView
|
<BottomSheetScrollView
|
||||||
@@ -303,7 +312,7 @@ export function DropdownSheet({
|
|||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</BottomSheetScrollView>
|
</BottomSheetScrollView>
|
||||||
</BottomSheetModal>
|
</IsolatedBottomSheetModal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,6 +422,8 @@ export function FormSelectTrigger({
|
|||||||
valueEllipsizeMode,
|
valueEllipsizeMode,
|
||||||
testID,
|
testID,
|
||||||
}: CompactSelectFieldProps): ReactElement {
|
}: CompactSelectFieldProps): ReactElement {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
|
||||||
const getWebKey = useCallback((event: unknown): string | null => {
|
const getWebKey = useCallback((event: unknown): string | null => {
|
||||||
if (!event || typeof event !== "object") return null;
|
if (!event || typeof event !== "object") return null;
|
||||||
const eventWithNative = event as { nativeEvent?: unknown; key?: unknown };
|
const eventWithNative = event as { nativeEvent?: unknown; key?: unknown };
|
||||||
@@ -469,7 +480,7 @@ export function FormSelectTrigger({
|
|||||||
<View style={styles.compactSelectValueContainer}>
|
<View style={styles.compactSelectValueContainer}>
|
||||||
{showLabel ? <Text style={styles.compactSelectLabel}>{label}</Text> : null}
|
{showLabel ? <Text style={styles.compactSelectLabel}>{label}</Text> : null}
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<ActivityIndicator size="small" color={defaultTheme.colors.foregroundMuted} />
|
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||||
) : (
|
) : (
|
||||||
<Text
|
<Text
|
||||||
style={hasConcreteValue ? styles.compactSelectValue : styles.compactSelectPlaceholder}
|
style={hasConcreteValue ? styles.compactSelectValue : styles.compactSelectPlaceholder}
|
||||||
@@ -480,7 +491,7 @@ export function FormSelectTrigger({
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
<ChevronDown size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
|
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -518,6 +529,8 @@ export function AgentConfigRow({
|
|||||||
onSelectThinkingOption,
|
onSelectThinkingOption,
|
||||||
disabled,
|
disabled,
|
||||||
}: AgentConfigRowProps): ReactElement {
|
}: AgentConfigRowProps): ReactElement {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
|
||||||
const providerOptions: ComboSelectOption[] = useMemo(
|
const providerOptions: ComboSelectOption[] = useMemo(
|
||||||
() =>
|
() =>
|
||||||
providerDefinitions.map((def) => ({
|
providerDefinitions.map((def) => ({
|
||||||
@@ -576,7 +589,7 @@ export function AgentConfigRow({
|
|||||||
placeholder={providerOptions.length > 0 ? "Select..." : "No providers available"}
|
placeholder={providerOptions.length > 0 ? "Select..." : "No providers available"}
|
||||||
disabled={disabled || providerOptions.length === 0}
|
disabled={disabled || providerOptions.length === 0}
|
||||||
onSelect={onSelectProvider}
|
onSelect={onSelectProvider}
|
||||||
icon={<Bot size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />}
|
icon={<Bot size={theme.iconSize.md} color={theme.colors.foregroundMuted} />}
|
||||||
showLabel={false}
|
showLabel={false}
|
||||||
testID="draft-provider-select"
|
testID="draft-provider-select"
|
||||||
/>
|
/>
|
||||||
@@ -591,9 +604,7 @@ export function AgentConfigRow({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
isLoading={isModelLoading}
|
isLoading={isModelLoading}
|
||||||
onSelect={onSelectModel}
|
onSelect={onSelectModel}
|
||||||
icon={
|
icon={<Brain size={theme.iconSize.md} color={theme.colors.foregroundMuted} />}
|
||||||
<Brain size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
|
|
||||||
}
|
|
||||||
showLabel={false}
|
showLabel={false}
|
||||||
testID="draft-model-select"
|
testID="draft-model-select"
|
||||||
/>
|
/>
|
||||||
@@ -607,7 +618,7 @@ export function AgentConfigRow({
|
|||||||
placeholder="Default"
|
placeholder="Default"
|
||||||
disabled={disabled || modeOptions.length === 0}
|
disabled={disabled || modeOptions.length === 0}
|
||||||
onSelect={onSelectMode}
|
onSelect={onSelectMode}
|
||||||
icon={<ModeIcon size={defaultTheme.iconSize.md} color={modeIconColor} />}
|
icon={<ModeIcon size={theme.iconSize.md} color={modeIconColor} />}
|
||||||
showLabel={false}
|
showLabel={false}
|
||||||
testID="draft-mode-select"
|
testID="draft-mode-select"
|
||||||
/>
|
/>
|
||||||
@@ -622,9 +633,7 @@ export function AgentConfigRow({
|
|||||||
placeholder="Select..."
|
placeholder="Select..."
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onSelect={onSelectThinkingOption}
|
onSelect={onSelectThinkingOption}
|
||||||
icon={
|
icon={<Brain size={theme.iconSize.md} color={theme.colors.foregroundMuted} />}
|
||||||
<Brain size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
|
|
||||||
}
|
|
||||||
showLabel={false}
|
showLabel={false}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -969,6 +978,8 @@ export function GitOptionsSection({
|
|||||||
attachWorktreeError,
|
attachWorktreeError,
|
||||||
onSelectWorktreePath,
|
onSelectWorktreePath,
|
||||||
}: GitOptionsSectionProps): ReactElement {
|
}: GitOptionsSectionProps): ReactElement {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
|
||||||
const isLoading = status === "loading";
|
const isLoading = status === "loading";
|
||||||
const isCreateMode = worktreeMode === "create";
|
const isCreateMode = worktreeMode === "create";
|
||||||
const isAttachMode = worktreeMode === "attach";
|
const isAttachMode = worktreeMode === "attach";
|
||||||
@@ -1103,7 +1114,7 @@ export function GitOptionsSection({
|
|||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
placeholder="branch name"
|
placeholder="branch name"
|
||||||
placeholderTextColor={defaultTheme.colors.foregroundMuted}
|
placeholderTextColor={theme.colors.foregroundMuted}
|
||||||
onSubmitEditing={handleConfirmEdit}
|
onSubmitEditing={handleConfirmEdit}
|
||||||
/>
|
/>
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -1111,19 +1122,16 @@ export function GitOptionsSection({
|
|||||||
hitSlop={8}
|
hitSlop={8}
|
||||||
style={styles.baseBranchIconButton}
|
style={styles.baseBranchIconButton}
|
||||||
>
|
>
|
||||||
<Check
|
<Check size={theme.iconSize.md} color={theme.colors.palette.green[500]} />
|
||||||
size={defaultTheme.iconSize.md}
|
|
||||||
color={defaultTheme.colors.palette.green[500]}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable onPress={handleCancelEdit} hitSlop={8} style={styles.baseBranchIconButton}>
|
<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>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<Pressable onPress={handleStartEdit} style={styles.baseBranchValueRow}>
|
<Pressable onPress={handleStartEdit} style={styles.baseBranchValueRow}>
|
||||||
<Text style={styles.baseBranchValue}>{displayBranch}</Text>
|
<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>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -1171,14 +1179,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.base,
|
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: {
|
bottomSheetHeader: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -1220,7 +1220,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
dropdownSheetTitle: {
|
dropdownSheetTitle: {
|
||||||
fontSize: theme.fontSize.lg,
|
fontSize: theme.fontSize.lg,
|
||||||
fontWeight: theme.fontWeight.semibold,
|
fontWeight: theme.fontWeight.semibold,
|
||||||
color: theme.colors.foreground,
|
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
},
|
},
|
||||||
dropdownSheetScrollContent: {
|
dropdownSheetScrollContent: {
|
||||||
|
|||||||
@@ -59,6 +59,51 @@ describe("submitAgentInput", () => {
|
|||||||
expect(clearDraft).toHaveBeenCalledWith("sent");
|
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 () => {
|
it("queues while the agent is running and clears the composer immediately", async () => {
|
||||||
const queueMessage = vi.fn();
|
const queueMessage = vi.fn();
|
||||||
const submitMessage = vi.fn();
|
const submitMessage = vi.fn();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export interface AgentInputSubmitActionInput<TAttachment> {
|
|||||||
attachments: TAttachment[];
|
attachments: TAttachment[];
|
||||||
hasExternalContent?: boolean;
|
hasExternalContent?: boolean;
|
||||||
allowEmptySubmit?: boolean;
|
allowEmptySubmit?: boolean;
|
||||||
|
submitBehavior?: "clear" | "preserve-and-lock";
|
||||||
forceSend?: boolean;
|
forceSend?: boolean;
|
||||||
isAgentRunning: boolean;
|
isAgentRunning: boolean;
|
||||||
canSubmit: boolean;
|
canSubmit: boolean;
|
||||||
@@ -23,6 +24,7 @@ export async function submitAgentInput<TAttachment>(
|
|||||||
): Promise<AgentInputSubmitResult> {
|
): Promise<AgentInputSubmitResult> {
|
||||||
const trimmedMessage = input.message.trim();
|
const trimmedMessage = input.message.trim();
|
||||||
const attachments = input.attachments;
|
const attachments = input.attachments;
|
||||||
|
const shouldClearOnSubmit = input.submitBehavior !== "preserve-and-lock";
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!trimmedMessage &&
|
!trimmedMessage &&
|
||||||
@@ -39,14 +41,18 @@ export async function submitAgentInput<TAttachment>(
|
|||||||
|
|
||||||
if (input.isAgentRunning && !input.forceSend) {
|
if (input.isAgentRunning && !input.forceSend) {
|
||||||
input.queueMessage({ message: trimmedMessage, attachments });
|
input.queueMessage({ message: trimmedMessage, attachments });
|
||||||
input.setUserInput("");
|
if (shouldClearOnSubmit) {
|
||||||
input.setAttachments([]);
|
input.setUserInput("");
|
||||||
|
input.setAttachments([]);
|
||||||
|
}
|
||||||
return "queued";
|
return "queued";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear immediately so optimistic stream updates and composer state stay in sync.
|
// Clear immediately so optimistic stream updates and composer state stay in sync.
|
||||||
input.setUserInput("");
|
if (shouldClearOnSubmit) {
|
||||||
input.setAttachments([]);
|
input.setUserInput("");
|
||||||
|
input.setAttachments([]);
|
||||||
|
}
|
||||||
input.setSendError(null);
|
input.setSendError(null);
|
||||||
input.setIsProcessing(true);
|
input.setIsProcessing(true);
|
||||||
|
|
||||||
@@ -57,8 +63,10 @@ export async function submitAgentInput<TAttachment>(
|
|||||||
return "submitted";
|
return "submitted";
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
input.onSubmitError?.(error);
|
input.onSubmitError?.(error);
|
||||||
input.setUserInput(trimmedMessage);
|
if (shouldClearOnSubmit) {
|
||||||
input.setAttachments(attachments);
|
input.setUserInput(trimmedMessage);
|
||||||
|
input.setAttachments(attachments);
|
||||||
|
}
|
||||||
input.setSendError(error instanceof Error ? error.message : "Failed to send message");
|
input.setSendError(error instanceof Error ? error.message : "Failed to send message");
|
||||||
input.setIsProcessing(false);
|
input.setIsProcessing(false);
|
||||||
return "failed";
|
return "failed";
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import { getProviderIcon } from "@/components/provider-icons";
|
|||||||
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
||||||
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
|
import type { Agent } from "@/stores/session-store";
|
||||||
|
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||||
|
|
||||||
interface AgentListProps {
|
interface AgentListProps {
|
||||||
agents: AggregatedAgent[];
|
agents: AggregatedAgent[];
|
||||||
@@ -37,6 +39,62 @@ type FlatListItem =
|
|||||||
| { type: "header"; key: string; title: string }
|
| { type: "header"; key: string; title: string }
|
||||||
| { type: "agent"; key: string; agent: AggregatedAgent };
|
| { type: "agent"; key: string; agent: AggregatedAgent };
|
||||||
|
|
||||||
|
function buildHistoricalAgentDetail(agent: AggregatedAgent): Agent {
|
||||||
|
return {
|
||||||
|
serverId: agent.serverId,
|
||||||
|
id: agent.id,
|
||||||
|
provider: agent.provider,
|
||||||
|
status: agent.status,
|
||||||
|
createdAt: agent.createdAt,
|
||||||
|
updatedAt: agent.lastActivityAt,
|
||||||
|
lastUserMessageAt: null,
|
||||||
|
lastActivityAt: agent.lastActivityAt,
|
||||||
|
capabilities: {
|
||||||
|
supportsStreaming: false,
|
||||||
|
supportsSessionPersistence: false,
|
||||||
|
supportsDynamicModes: false,
|
||||||
|
supportsMcpServers: false,
|
||||||
|
supportsReasoningStream: false,
|
||||||
|
supportsToolInvocations: false,
|
||||||
|
},
|
||||||
|
currentModeId: null,
|
||||||
|
availableModes: [],
|
||||||
|
pendingPermissions: [],
|
||||||
|
persistence: null,
|
||||||
|
runtimeInfo: {
|
||||||
|
provider: agent.provider,
|
||||||
|
sessionId: null,
|
||||||
|
},
|
||||||
|
title: agent.title,
|
||||||
|
cwd: agent.cwd,
|
||||||
|
model: null,
|
||||||
|
thinkingOptionId: null,
|
||||||
|
requiresAttention: agent.requiresAttention,
|
||||||
|
attentionReason: agent.attentionReason,
|
||||||
|
attentionTimestamp: agent.attentionTimestamp,
|
||||||
|
archivedAt: agent.archivedAt,
|
||||||
|
labels: agent.labels,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rememberArchivedAgentDetail(agent: AggregatedAgent) {
|
||||||
|
if (!agent.archivedAt) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
useSessionStore.getState().setAgentDetails(agent.serverId, (previous) => {
|
||||||
|
const existing = previous.get(agent.id);
|
||||||
|
const next = new Map(previous);
|
||||||
|
next.set(agent.id, {
|
||||||
|
...buildHistoricalAgentDetail(agent),
|
||||||
|
...existing,
|
||||||
|
archivedAt: existing?.archivedAt ?? agent.archivedAt,
|
||||||
|
cwd: existing?.cwd ?? agent.cwd,
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function deriveDateSectionLabel(lastActivityAt: Date): string {
|
function deriveDateSectionLabel(lastActivityAt: Date): string {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
@@ -223,6 +281,7 @@ export function AgentList({
|
|||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null);
|
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null);
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
|
const { archiveAgent } = useArchiveAgent();
|
||||||
|
|
||||||
const actionClient = useSessionStore((state) =>
|
const actionClient = useSessionStore((state) =>
|
||||||
actionAgent?.serverId ? (state.sessions[actionAgent.serverId]?.client ?? null) : null,
|
actionAgent?.serverId ? (state.sessions[actionAgent.serverId]?.client ?? null) : null,
|
||||||
@@ -251,6 +310,7 @@ export function AgentList({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rememberArchivedAgentDetail(agent);
|
||||||
const route = prepareWorkspaceTab({
|
const route = prepareWorkspaceTab({
|
||||||
serverId,
|
serverId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -262,20 +322,23 @@ export function AgentList({
|
|||||||
[isActionSheetVisible, onAgentSelect],
|
[isActionSheetVisible, onAgentSelect],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleAgentLongPress = useCallback((agent: AggregatedAgent) => {
|
const handleAgentLongPress = useCallback(
|
||||||
const isRunning = agent.status === "running" || agent.status === "initializing";
|
(agent: AggregatedAgent) => {
|
||||||
if (isRunning) {
|
const isRunning = agent.status === "running" || agent.status === "initializing";
|
||||||
setActionAgent(agent);
|
if (isRunning) {
|
||||||
return;
|
setActionAgent(agent);
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const client = useSessionStore.getState().sessions[agent.serverId]?.client ?? null;
|
const client = useSessionStore.getState().sessions[agent.serverId]?.client ?? null;
|
||||||
if (!client) {
|
if (!client) {
|
||||||
setActionAgent(agent);
|
setActionAgent(agent);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void client.archiveAgent(agent.id);
|
void archiveAgent({ serverId: agent.serverId, agentId: agent.id }).catch(() => {});
|
||||||
}, []);
|
},
|
||||||
|
[archiveAgent],
|
||||||
|
);
|
||||||
|
|
||||||
const handleCloseActionSheet = useCallback(() => {
|
const handleCloseActionSheet = useCallback(() => {
|
||||||
setActionAgent(null);
|
setActionAgent(null);
|
||||||
@@ -286,9 +349,9 @@ export function AgentList({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Timeout errors are swallowed — the daemon will still process the archive
|
// Timeout errors are swallowed — the daemon will still process the archive
|
||||||
void actionClient.archiveAgent(actionAgent.id).catch(() => {});
|
void archiveAgent({ serverId: actionAgent.serverId, agentId: actionAgent.id }).catch(() => {});
|
||||||
setActionAgent(null);
|
setActionAgent(null);
|
||||||
}, [actionAgent, actionClient]);
|
}, [actionAgent, actionClient, archiveAgent]);
|
||||||
|
|
||||||
const flatItems = useMemo((): FlatListItem[] => {
|
const flatItems = useMemo((): FlatListItem[] => {
|
||||||
const order = ["Today", "Yesterday", "This week", "This month", "Older"] as const;
|
const order = ["Today", "Yesterday", "This week", "This month", "Older"] as const;
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ import {
|
|||||||
resolveAgentModelSelection,
|
resolveAgentModelSelection,
|
||||||
} from "@/components/agent-status-bar.utils";
|
} from "@/components/agent-status-bar.utils";
|
||||||
import { isWeb as platformIsWeb } from "@/constants/platform";
|
import { isWeb as platformIsWeb } from "@/constants/platform";
|
||||||
|
import { useToast } from "@/contexts/toast-context";
|
||||||
|
import { toErrorMessage } from "@/utils/error-messages";
|
||||||
|
|
||||||
type StatusOption = {
|
type StatusOption = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -90,7 +92,7 @@ type ControlledAgentStatusBarProps = {
|
|||||||
|
|
||||||
export interface DraftAgentStatusBarProps {
|
export interface DraftAgentStatusBarProps {
|
||||||
providerDefinitions: AgentProviderDefinition[];
|
providerDefinitions: AgentProviderDefinition[];
|
||||||
selectedProvider: AgentProvider;
|
selectedProvider: AgentProvider | null;
|
||||||
onSelectProvider: (provider: AgentProvider) => void;
|
onSelectProvider: (provider: AgentProvider) => void;
|
||||||
modeOptions: AgentMode[];
|
modeOptions: AgentMode[];
|
||||||
selectedMode: string;
|
selectedMode: string;
|
||||||
@@ -253,7 +255,8 @@ function ControlledStatusBar({
|
|||||||
: undefined;
|
: undefined;
|
||||||
const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null;
|
const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null;
|
||||||
const modeIconColor = getModeIconColor(modeVisuals?.colorTier, theme.colors.palette);
|
const modeIconColor = getModeIconColor(modeVisuals?.colorTier, theme.colors.palette);
|
||||||
const ProviderIcon = getProviderIcon(provider);
|
const hasSelectedProvider = provider.trim().length > 0;
|
||||||
|
const ProviderIcon = hasSelectedProvider ? getProviderIcon(provider) : null;
|
||||||
|
|
||||||
const hasAnyControl =
|
const hasAnyControl =
|
||||||
Boolean(providerOptions?.length) ||
|
Boolean(providerOptions?.length) ||
|
||||||
@@ -424,12 +427,7 @@ function ControlledStatusBar({
|
|||||||
|
|
||||||
{thinkingOptions && thinkingOptions.length > 0 ? (
|
{thinkingOptions && thinkingOptions.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<Tooltip
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||||
key={`thinking-${openSelector === "thinking" ? "open" : "closed"}`}
|
|
||||||
delayDuration={0}
|
|
||||||
enabledOnDesktop
|
|
||||||
enabledOnMobile={false}
|
|
||||||
>
|
|
||||||
<TooltipTrigger asChild triggerRefProp="ref">
|
<TooltipTrigger asChild triggerRefProp="ref">
|
||||||
<Pressable
|
<Pressable
|
||||||
ref={thinkingAnchorRef}
|
ref={thinkingAnchorRef}
|
||||||
@@ -470,12 +468,7 @@ function ControlledStatusBar({
|
|||||||
|
|
||||||
{modeOptions && modeOptions.length > 0 ? (
|
{modeOptions && modeOptions.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<Tooltip
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||||
key={`mode-${openSelector === "mode" ? "open" : "closed"}`}
|
|
||||||
delayDuration={0}
|
|
||||||
enabledOnDesktop
|
|
||||||
enabledOnMobile={false}
|
|
||||||
>
|
|
||||||
<TooltipTrigger asChild triggerRefProp="ref">
|
<TooltipTrigger asChild triggerRefProp="ref">
|
||||||
<Pressable
|
<Pressable
|
||||||
ref={modeAnchorRef}
|
ref={modeAnchorRef}
|
||||||
@@ -628,7 +621,9 @@ function ControlledStatusBar({
|
|||||||
accessibilityLabel="Agent preferences"
|
accessibilityLabel="Agent preferences"
|
||||||
testID="agent-preferences-button"
|
testID="agent-preferences-button"
|
||||||
>
|
>
|
||||||
<ProviderIcon size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
|
{ProviderIcon ? (
|
||||||
|
<ProviderIcon size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
|
||||||
|
) : null}
|
||||||
<Text style={styles.prefsButtonText} numberOfLines={1}>
|
<Text style={styles.prefsButtonText} numberOfLines={1}>
|
||||||
{displayModel}
|
{displayModel}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -638,7 +633,6 @@ function ControlledStatusBar({
|
|||||||
title="Preferences"
|
title="Preferences"
|
||||||
visible={prefsOpen}
|
visible={prefsOpen}
|
||||||
onClose={() => setPrefsOpen(false)}
|
onClose={() => setPrefsOpen(false)}
|
||||||
stackBehavior="replace"
|
|
||||||
testID="agent-preferences-sheet"
|
testID="agent-preferences-sheet"
|
||||||
>
|
>
|
||||||
{canSelectModel ? (
|
{canSelectModel ? (
|
||||||
@@ -671,7 +665,12 @@ function ControlledStatusBar({
|
|||||||
pointerEvents="none"
|
pointerEvents="none"
|
||||||
testID="agent-preferences-model"
|
testID="agent-preferences-model"
|
||||||
>
|
>
|
||||||
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
{ProviderIcon ? (
|
||||||
|
<ProviderIcon
|
||||||
|
size={theme.iconSize.md}
|
||||||
|
color={theme.colors.foregroundMuted}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<Text style={styles.sheetSelectText}>{selectedModelLabel}</Text>
|
<Text style={styles.sheetSelectText}>{selectedModelLabel}</Text>
|
||||||
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
</View>
|
</View>
|
||||||
@@ -873,23 +872,23 @@ export const AgentStatusBar = memo(function AgentStatusBar({
|
|||||||
(a, b) => a === b || JSON.stringify(a) === JSON.stringify(b),
|
(a, b) => a === b || JSON.stringify(a) === JSON.stringify(b),
|
||||||
);
|
);
|
||||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
entries: snapshotEntries,
|
entries: snapshotEntries,
|
||||||
isLoading: snapshotIsLoading,
|
isLoading: snapshotIsLoading,
|
||||||
isFetching: snapshotIsFetching,
|
refetchIfStale: refetchSnapshotIfStale,
|
||||||
invalidate: invalidateSnapshot,
|
} = useProvidersSnapshot(serverId);
|
||||||
} = useProvidersSnapshot(serverId, agent?.cwd);
|
|
||||||
|
|
||||||
const snapshotModels = useMemo(() => {
|
const snapshotSelectedEntry = useMemo(() => {
|
||||||
if (!snapshotEntries || !agent?.provider) {
|
if (!snapshotEntries || !agent?.provider) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const entry = snapshotEntries.find((e) => e.provider === agent.provider);
|
return snapshotEntries.find((e) => e.provider === agent.provider) ?? null;
|
||||||
return entry?.models ?? null;
|
|
||||||
}, [snapshotEntries, agent?.provider]);
|
}, [snapshotEntries, agent?.provider]);
|
||||||
|
|
||||||
const models = snapshotModels;
|
const models = snapshotSelectedEntry?.models ?? null;
|
||||||
|
const selectedProviderIsLoading = snapshotSelectedEntry?.status === "loading";
|
||||||
|
|
||||||
const agentProviderDefinitions = useMemo(() => {
|
const agentProviderDefinitions = useMemo(() => {
|
||||||
const definition = agent?.provider
|
const definition = agent?.provider
|
||||||
@@ -900,11 +899,11 @@ export const AgentStatusBar = memo(function AgentStatusBar({
|
|||||||
|
|
||||||
const agentProviderModels = useMemo(() => {
|
const agentProviderModels = useMemo(() => {
|
||||||
const map = new Map<string, AgentModelDefinition[]>();
|
const map = new Map<string, AgentModelDefinition[]>();
|
||||||
if (agent?.provider && snapshotModels) {
|
if (agent?.provider && models) {
|
||||||
map.set(agent.provider, snapshotModels);
|
map.set(agent.provider, models);
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}, [agent?.provider, snapshotModels]);
|
}, [agent?.provider, models]);
|
||||||
|
|
||||||
const displayMode =
|
const displayMode =
|
||||||
availableModes.find((mode) => mode.id === agent?.currentModeId)?.label ||
|
availableModes.find((mode) => mode.id === agent?.currentModeId)?.label ||
|
||||||
@@ -964,6 +963,7 @@ export const AgentStatusBar = memo(function AgentStatusBar({
|
|||||||
}
|
}
|
||||||
void client.setAgentMode(agentId, modeId).catch((error) => {
|
void client.setAgentMode(agentId, modeId).catch((error) => {
|
||||||
console.warn("[AgentStatusBar] setAgentMode failed", error);
|
console.warn("[AgentStatusBar] setAgentMode failed", error);
|
||||||
|
toast.error(toErrorMessage(error));
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
modelOptions={modelOptions}
|
modelOptions={modelOptions}
|
||||||
@@ -985,6 +985,7 @@ export const AgentStatusBar = memo(function AgentStatusBar({
|
|||||||
});
|
});
|
||||||
void client.setAgentModel(agentId, modelId).catch((error) => {
|
void client.setAgentModel(agentId, modelId).catch((error) => {
|
||||||
console.warn("[AgentStatusBar] setAgentModel failed", error);
|
console.warn("[AgentStatusBar] setAgentModel failed", error);
|
||||||
|
toast.error(toErrorMessage(error));
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
favoriteKeys={favoriteKeys}
|
favoriteKeys={favoriteKeys}
|
||||||
@@ -1020,6 +1021,7 @@ export const AgentStatusBar = memo(function AgentStatusBar({
|
|||||||
}
|
}
|
||||||
void client.setAgentThinkingOption(agentId, thinkingOptionId).catch((error) => {
|
void client.setAgentThinkingOption(agentId, thinkingOptionId).catch((error) => {
|
||||||
console.warn("[AgentStatusBar] setAgentThinkingOption failed", error);
|
console.warn("[AgentStatusBar] setAgentThinkingOption failed", error);
|
||||||
|
toast.error(toErrorMessage(error));
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
features={agent.features}
|
features={agent.features}
|
||||||
@@ -1042,10 +1044,11 @@ export const AgentStatusBar = memo(function AgentStatusBar({
|
|||||||
});
|
});
|
||||||
void client.setAgentFeature(agentId, featureId, value).catch((error) => {
|
void client.setAgentFeature(agentId, featureId, value).catch((error) => {
|
||||||
console.warn("[AgentStatusBar] setAgentFeature failed", error);
|
console.warn("[AgentStatusBar] setAgentFeature failed", error);
|
||||||
|
toast.error(toErrorMessage(error));
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
isModelLoading={snapshotIsLoading || snapshotIsFetching}
|
isModelLoading={snapshotIsLoading || selectedProviderIsLoading}
|
||||||
onModelSelectorOpen={invalidateSnapshot}
|
onModelSelectorOpen={() => refetchSnapshotIfStale(agent?.provider)}
|
||||||
onDropdownClose={onDropdownClose}
|
onDropdownClose={onDropdownClose}
|
||||||
disabled={!client}
|
disabled={!client}
|
||||||
/>
|
/>
|
||||||
@@ -1101,6 +1104,7 @@ export function DraftAgentStatusBar({
|
|||||||
const effectiveSelectedMode = selectedMode || mappedModeOptions[0]?.id || "";
|
const effectiveSelectedMode = selectedMode || mappedModeOptions[0]?.id || "";
|
||||||
const effectiveSelectedThinkingOption =
|
const effectiveSelectedThinkingOption =
|
||||||
selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined;
|
selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined;
|
||||||
|
const hasSelectedProvider = selectedProvider !== null;
|
||||||
|
|
||||||
if (platformIsWeb) {
|
if (platformIsWeb) {
|
||||||
return (
|
return (
|
||||||
@@ -1108,7 +1112,7 @@ export function DraftAgentStatusBar({
|
|||||||
<CombinedModelSelector
|
<CombinedModelSelector
|
||||||
providerDefinitions={providerDefinitions}
|
providerDefinitions={providerDefinitions}
|
||||||
allProviderModels={allProviderModels}
|
allProviderModels={allProviderModels}
|
||||||
selectedProvider={selectedProvider}
|
selectedProvider={selectedProvider ?? ""}
|
||||||
selectedModel={selectedModel}
|
selectedModel={selectedModel}
|
||||||
onSelect={onSelectProviderAndModel}
|
onSelect={onSelectProviderAndModel}
|
||||||
favoriteKeys={favoriteKeys}
|
favoriteKeys={favoriteKeys}
|
||||||
@@ -1124,20 +1128,22 @@ export function DraftAgentStatusBar({
|
|||||||
onOpen={onModelSelectorOpen}
|
onOpen={onModelSelectorOpen}
|
||||||
onClose={onDropdownClose}
|
onClose={onDropdownClose}
|
||||||
/>
|
/>
|
||||||
<ControlledStatusBar
|
{selectedProvider ? (
|
||||||
provider={selectedProvider}
|
<ControlledStatusBar
|
||||||
providerDefinitions={providerDefinitions}
|
provider={selectedProvider}
|
||||||
modeOptions={mappedModeOptions}
|
providerDefinitions={providerDefinitions}
|
||||||
selectedModeId={effectiveSelectedMode}
|
modeOptions={mappedModeOptions}
|
||||||
onSelectMode={onSelectMode}
|
selectedModeId={effectiveSelectedMode}
|
||||||
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
|
onSelectMode={onSelectMode}
|
||||||
selectedThinkingOptionId={effectiveSelectedThinkingOption}
|
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
|
||||||
onSelectThinkingOption={onSelectThinkingOption}
|
selectedThinkingOptionId={effectiveSelectedThinkingOption}
|
||||||
features={features}
|
onSelectThinkingOption={onSelectThinkingOption}
|
||||||
onSetFeature={onSetFeature}
|
features={features}
|
||||||
onDropdownClose={onDropdownClose}
|
onSetFeature={onSetFeature}
|
||||||
disabled={disabled}
|
onDropdownClose={onDropdownClose}
|
||||||
/>
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1150,10 +1156,10 @@ export function DraftAgentStatusBar({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ControlledStatusBar
|
<ControlledStatusBar
|
||||||
provider={selectedProvider}
|
provider={selectedProvider ?? ""}
|
||||||
providerDefinitions={providerDefinitions}
|
providerDefinitions={providerDefinitions}
|
||||||
allProviderModels={allProviderModels}
|
allProviderModels={allProviderModels}
|
||||||
modeOptions={mappedModeOptions}
|
modeOptions={hasSelectedProvider ? mappedModeOptions : undefined}
|
||||||
selectedModeId={effectiveSelectedMode}
|
selectedModeId={effectiveSelectedMode}
|
||||||
onSelectMode={onSelectMode}
|
onSelectMode={onSelectMode}
|
||||||
modelOptions={modelOptions}
|
modelOptions={modelOptions}
|
||||||
|
|||||||
@@ -79,6 +79,43 @@ import { isWeb } from "@/constants/platform";
|
|||||||
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
|
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
|
||||||
const isToolSequenceItem = (item?: StreamItem) =>
|
const isToolSequenceItem = (item?: StreamItem) =>
|
||||||
item?.kind === "tool_call" || item?.kind === "thought" || item?.kind === "todo_list";
|
item?.kind === "tool_call" || item?.kind === "thought" || item?.kind === "todo_list";
|
||||||
|
|
||||||
|
const isSameAssistantBlockGroup = (params: {
|
||||||
|
item: StreamItem | null | undefined;
|
||||||
|
other: StreamItem | null | undefined;
|
||||||
|
}) =>
|
||||||
|
params.item?.kind === "assistant_message" &&
|
||||||
|
params.other?.kind === "assistant_message" &&
|
||||||
|
params.item.blockGroupId !== undefined &&
|
||||||
|
params.item.blockGroupId === params.other.blockGroupId;
|
||||||
|
|
||||||
|
const getAssistantBlockSpacing = (params: {
|
||||||
|
item: StreamItem;
|
||||||
|
aboveItem: StreamItem | null | undefined;
|
||||||
|
belowItem: StreamItem | null | undefined;
|
||||||
|
}): "default" | "compactTop" | "compactBottom" | "compactBoth" => {
|
||||||
|
if (params.item.kind !== "assistant_message") {
|
||||||
|
return "default";
|
||||||
|
}
|
||||||
|
const compactTop = isSameAssistantBlockGroup({
|
||||||
|
item: params.item,
|
||||||
|
other: params.aboveItem,
|
||||||
|
});
|
||||||
|
const compactBottom = isSameAssistantBlockGroup({
|
||||||
|
item: params.item,
|
||||||
|
other: params.belowItem,
|
||||||
|
});
|
||||||
|
if (compactTop && compactBottom) {
|
||||||
|
return "compactBoth";
|
||||||
|
}
|
||||||
|
if (compactTop) {
|
||||||
|
return "compactTop";
|
||||||
|
}
|
||||||
|
if (compactBottom) {
|
||||||
|
return "compactBottom";
|
||||||
|
}
|
||||||
|
return "default";
|
||||||
|
};
|
||||||
export interface AgentStreamViewHandle {
|
export interface AgentStreamViewHandle {
|
||||||
scrollToBottom(reason?: BottomAnchorLocalRequest["reason"]): void;
|
scrollToBottom(reason?: BottomAnchorLocalRequest["reason"]): void;
|
||||||
prepareForViewportChange(): void;
|
prepareForViewportChange(): void;
|
||||||
@@ -125,7 +162,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState<Set<string>>(
|
const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState<Set<string>>(
|
||||||
new Set(),
|
new Set(),
|
||||||
);
|
);
|
||||||
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
|
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
|
||||||
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
||||||
|
|
||||||
// Get serverId (fallback to agent's serverId if not provided)
|
// Get serverId (fallback to agent's serverId if not provided)
|
||||||
@@ -199,17 +236,21 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
setCurrentPath: false,
|
setCurrentPath: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
setExplorerTabForCheckout({
|
const checkout = {
|
||||||
serverId: resolvedServerId,
|
serverId: resolvedServerId,
|
||||||
cwd: agent.cwd,
|
cwd: agent.cwd,
|
||||||
isGit: agent.projectPlacement?.checkout?.isGit ?? true,
|
isGit: agent.projectPlacement?.checkout?.isGit ?? true,
|
||||||
tab: "files",
|
};
|
||||||
|
setExplorerTabForCheckout({ ...checkout, tab: "files" });
|
||||||
|
openFileExplorerForCheckout({
|
||||||
|
isCompact: isMobile,
|
||||||
|
checkout,
|
||||||
});
|
});
|
||||||
openFileExplorer();
|
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
agent.cwd,
|
agent.cwd,
|
||||||
openFileExplorer,
|
isMobile,
|
||||||
|
openFileExplorerForCheckout,
|
||||||
requestDirectoryListing,
|
requestDirectoryListing,
|
||||||
resolvedServerId,
|
resolvedServerId,
|
||||||
router,
|
router,
|
||||||
@@ -245,6 +286,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tightGap = theme.spacing[1]; // 4px
|
const tightGap = theme.spacing[1]; // 4px
|
||||||
|
const assistantBlockGap = theme.spacing[3]; // 12px
|
||||||
const looseGap = theme.spacing[4]; // 16px
|
const looseGap = theme.spacing[4]; // 16px
|
||||||
|
|
||||||
const getGapBetween = useCallback(
|
const getGapBetween = useCallback(
|
||||||
@@ -257,26 +299,23 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
return tightGap;
|
return tightGap;
|
||||||
}
|
}
|
||||||
if (isToolSequenceItem(item) && isToolSequenceItem(belowItem)) {
|
if (isToolSequenceItem(item) && isToolSequenceItem(belowItem)) {
|
||||||
return tightGap;
|
return 0;
|
||||||
}
|
}
|
||||||
if (item.kind === "user_message" && isToolSequenceItem(belowItem)) {
|
if (item.kind === "user_message" && isToolSequenceItem(belowItem)) {
|
||||||
return looseGap;
|
return looseGap;
|
||||||
}
|
}
|
||||||
if (
|
if (item.kind === "assistant_message" && isToolSequenceItem(belowItem)) {
|
||||||
(item.kind === "user_message" || item.kind === "assistant_message") &&
|
|
||||||
isToolSequenceItem(belowItem)
|
|
||||||
) {
|
|
||||||
return tightGap;
|
|
||||||
}
|
|
||||||
if (item.kind === "todo_list" && isToolSequenceItem(belowItem)) {
|
|
||||||
return tightGap;
|
return tightGap;
|
||||||
}
|
}
|
||||||
if (isToolSequenceItem(item) && belowItem.kind === "assistant_message") {
|
if (isToolSequenceItem(item) && belowItem.kind === "assistant_message") {
|
||||||
return tightGap;
|
return looseGap;
|
||||||
|
}
|
||||||
|
if (isSameAssistantBlockGroup({ item, other: belowItem })) {
|
||||||
|
return assistantBlockGap;
|
||||||
}
|
}
|
||||||
return looseGap;
|
return looseGap;
|
||||||
},
|
},
|
||||||
[looseGap, tightGap],
|
[assistantBlockGap, looseGap, tightGap],
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderStreamItemContent = useCallback(
|
const renderStreamItemContent = useCallback(
|
||||||
@@ -331,7 +370,27 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
case "assistant_message":
|
case "assistant_message": {
|
||||||
|
const aboveItem =
|
||||||
|
getStreamNeighborItem({
|
||||||
|
strategy: streamRenderStrategy,
|
||||||
|
items,
|
||||||
|
index,
|
||||||
|
relation: "above",
|
||||||
|
}) ??
|
||||||
|
seamAboveItem ??
|
||||||
|
undefined;
|
||||||
|
const belowItem = getStreamNeighborItem({
|
||||||
|
strategy: streamRenderStrategy,
|
||||||
|
items,
|
||||||
|
index,
|
||||||
|
relation: "below",
|
||||||
|
});
|
||||||
|
const spacing = getAssistantBlockSpacing({
|
||||||
|
item,
|
||||||
|
aboveItem,
|
||||||
|
belowItem,
|
||||||
|
});
|
||||||
return (
|
return (
|
||||||
<AssistantMessage
|
<AssistantMessage
|
||||||
message={item.text}
|
message={item.text}
|
||||||
@@ -340,8 +399,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
workspaceRoot={workspaceRoot}
|
workspaceRoot={workspaceRoot}
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
client={client}
|
client={client}
|
||||||
|
spacing={spacing}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
case "thought": {
|
case "thought": {
|
||||||
const nextItem = getStreamNeighborItem({
|
const nextItem = getStreamNeighborItem({
|
||||||
strategy: streamRenderStrategy,
|
strategy: streamRenderStrategy,
|
||||||
@@ -481,21 +542,27 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
);
|
);
|
||||||
|
|
||||||
const showWorkingIndicator = agent.status === "running";
|
const showWorkingIndicator = agent.status === "running";
|
||||||
const renderModel = useMemo<AgentStreamRenderModel>(() => {
|
const pendingPermissionsNode = useMemo(
|
||||||
const pendingPermissionsNode =
|
() =>
|
||||||
pendingPermissionItems.length > 0 ? (
|
pendingPermissionItems.length > 0 ? (
|
||||||
<View style={stylesheet.permissionsContainer}>
|
<View style={stylesheet.permissionsContainer}>
|
||||||
{pendingPermissionItems.map((permission) => (
|
{pendingPermissionItems.map((permission) => (
|
||||||
<PermissionRequestCard key={permission.key} permission={permission} client={client} />
|
<PermissionRequestCard key={permission.key} permission={permission} client={client} />
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
) : null;
|
) : null,
|
||||||
const workingIndicatorNode = showWorkingIndicator ? (
|
[client, pendingPermissionItems],
|
||||||
<View style={stylesheet.bottomBarWrapper}>
|
);
|
||||||
<WorkingIndicator />
|
const workingIndicatorNode = useMemo(
|
||||||
</View>
|
() =>
|
||||||
) : null;
|
showWorkingIndicator ? (
|
||||||
|
<View style={stylesheet.bottomBarWrapper}>
|
||||||
|
<WorkingIndicator />
|
||||||
|
</View>
|
||||||
|
) : null,
|
||||||
|
[showWorkingIndicator],
|
||||||
|
);
|
||||||
|
const renderModel = useMemo<AgentStreamRenderModel>(() => {
|
||||||
return {
|
return {
|
||||||
...baseRenderModel,
|
...baseRenderModel,
|
||||||
boundary: {
|
boundary: {
|
||||||
@@ -510,7 +577,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
workingIndicator: workingIndicatorNode,
|
workingIndicator: workingIndicatorNode,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}, [baseRenderModel, client, getGapBetween, pendingPermissionItems, showWorkingIndicator]);
|
}, [baseRenderModel, getGapBetween, pendingPermissionsNode, workingIndicatorNode]);
|
||||||
|
|
||||||
const listEmptyComponent = useMemo(() => {
|
const listEmptyComponent = useMemo(() => {
|
||||||
if (
|
if (
|
||||||
@@ -575,7 +642,11 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
stylesheet.listHeaderContent,
|
stylesheet.listHeaderContent,
|
||||||
boundary.hasLiveHead ? { paddingTop: tightGap } : null,
|
boundary.hasLiveHead
|
||||||
|
? streamRenderStrategy.getFlatListInverted()
|
||||||
|
? { paddingBottom: looseGap }
|
||||||
|
: { paddingTop: looseGap }
|
||||||
|
: null,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{auxiliary.pendingPermissions}
|
{auxiliary.pendingPermissions}
|
||||||
@@ -583,7 +654,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}, [auxiliary.pendingPermissions, auxiliary.workingIndicator, boundary.hasLiveHead, tightGap]);
|
}, [
|
||||||
|
auxiliary.pendingPermissions,
|
||||||
|
auxiliary.workingIndicator,
|
||||||
|
boundary.hasLiveHead,
|
||||||
|
looseGap,
|
||||||
|
streamRenderStrategy,
|
||||||
|
]);
|
||||||
|
|
||||||
const renderers = useMemo<StreamSegmentRenderers>(
|
const renderers = useMemo<StreamSegmentRenderers>(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -992,6 +1069,7 @@ const stylesheet = StyleSheet.create((theme) => ({
|
|||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "flex-start",
|
justifyContent: "flex-start",
|
||||||
|
marginTop: theme.spacing[4],
|
||||||
paddingLeft: 3,
|
paddingLeft: 3,
|
||||||
paddingRight: 3,
|
paddingRight: 3,
|
||||||
paddingTop: theme.spacing[3],
|
paddingTop: theme.spacing[3],
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ interface AttachmentPillProps {
|
|||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
openAccessibilityLabel: string;
|
openAccessibilityLabel: string;
|
||||||
removeAccessibilityLabel: string;
|
removeAccessibilityLabel: string;
|
||||||
|
disabled?: boolean;
|
||||||
testID?: string;
|
testID?: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
@@ -19,6 +20,7 @@ export function AttachmentPill({
|
|||||||
onRemove,
|
onRemove,
|
||||||
openAccessibilityLabel,
|
openAccessibilityLabel,
|
||||||
removeAccessibilityLabel,
|
removeAccessibilityLabel,
|
||||||
|
disabled = false,
|
||||||
testID,
|
testID,
|
||||||
children,
|
children,
|
||||||
}: AttachmentPillProps) {
|
}: AttachmentPillProps) {
|
||||||
@@ -33,6 +35,7 @@ export function AttachmentPill({
|
|||||||
<Pressable
|
<Pressable
|
||||||
testID={testID}
|
testID={testID}
|
||||||
onPress={onOpen}
|
onPress={onOpen}
|
||||||
|
disabled={disabled}
|
||||||
onHoverIn={() => setIsBodyHovered(true)}
|
onHoverIn={() => setIsBodyHovered(true)}
|
||||||
onHoverOut={() => setIsBodyHovered(false)}
|
onHoverOut={() => setIsBodyHovered(false)}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
@@ -43,6 +46,7 @@ export function AttachmentPill({
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onRemove}
|
onPress={onRemove}
|
||||||
|
disabled={disabled}
|
||||||
onHoverIn={() => setIsCloseHovered(true)}
|
onHoverIn={() => setIsCloseHovered(true)}
|
||||||
onHoverOut={() => setIsCloseHovered(false)}
|
onHoverOut={() => setIsCloseHovered(false)}
|
||||||
hitSlop={8}
|
hitSlop={8}
|
||||||
|
|||||||
@@ -45,10 +45,10 @@ export function BranchSwitcher({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const titleContent = (
|
const titleContent = (
|
||||||
<>
|
<View style={styles.titleRow}>
|
||||||
{isGitCheckout ? <GitBranch size={14} color={theme.colors.foregroundMuted} /> : null}
|
{isGitCheckout ? <GitBranch size={14} color={theme.colors.foregroundMuted} /> : null}
|
||||||
<ScreenTitle testID="workspace-header-title">{title}</ScreenTitle>
|
<ScreenTitle testID="workspace-header-title">{title}</ScreenTitle>
|
||||||
</>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!currentBranchName) {
|
if (!currentBranchName) {
|
||||||
@@ -105,6 +105,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[1],
|
gap: theme.spacing[1],
|
||||||
|
minWidth: 0,
|
||||||
marginLeft: {
|
marginLeft: {
|
||||||
xs: -theme.spacing[2],
|
xs: -theme.spacing[2],
|
||||||
md: 0,
|
md: 0,
|
||||||
@@ -116,9 +117,15 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
paddingHorizontal: theme.spacing[2],
|
paddingHorizontal: theme.spacing[2],
|
||||||
borderRadius: theme.borderRadius.md,
|
borderRadius: theme.borderRadius.md,
|
||||||
flexShrink: 1,
|
flexShrink: 1,
|
||||||
minWidth: 0,
|
|
||||||
},
|
},
|
||||||
branchSwitcherTriggerHovered: {
|
branchSwitcherTriggerHovered: {
|
||||||
backgroundColor: theme.colors.surface1,
|
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],
|
[onSelect],
|
||||||
);
|
);
|
||||||
|
|
||||||
const ProviderIcon = getProviderIcon(selectedProvider);
|
const hasSelectedProvider = selectedProvider.trim().length > 0;
|
||||||
|
const ProviderIcon = hasSelectedProvider ? getProviderIcon(selectedProvider) : null;
|
||||||
|
|
||||||
const selectedModelLabel = useMemo(() => {
|
const selectedModelLabel = useMemo(() => {
|
||||||
if (!selectedModel) {
|
if (!selectedModel) {
|
||||||
|
if (!hasSelectedProvider) {
|
||||||
|
return "Select model";
|
||||||
|
}
|
||||||
return isLoading ? "Loading..." : "Select model";
|
return isLoading ? "Loading..." : "Select model";
|
||||||
}
|
}
|
||||||
const models = allProviderModels.get(selectedProvider);
|
const models = allProviderModels.get(selectedProvider);
|
||||||
@@ -580,7 +584,7 @@ export function CombinedModelSelector({
|
|||||||
}
|
}
|
||||||
const model = models.find((entry) => entry.id === selectedModel);
|
const model = models.find((entry) => entry.id === selectedModel);
|
||||||
return model?.label ?? resolveDefaultModelLabel(models);
|
return model?.label ?? resolveDefaultModelLabel(models);
|
||||||
}, [allProviderModels, isLoading, selectedModel, selectedProvider]);
|
}, [allProviderModels, hasSelectedProvider, isLoading, selectedModel, selectedProvider]);
|
||||||
|
|
||||||
const desktopFixedHeight = useMemo(() => {
|
const desktopFixedHeight = useMemo(() => {
|
||||||
if (view.kind !== "provider") {
|
if (view.kind !== "provider") {
|
||||||
@@ -643,7 +647,9 @@ export function CombinedModelSelector({
|
|||||||
})
|
})
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
{ProviderIcon ? (
|
||||||
|
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
|
) : null}
|
||||||
<Text style={styles.triggerText} numberOfLines={1} ellipsizeMode="tail">
|
<Text style={styles.triggerText} numberOfLines={1} ellipsizeMode="tail">
|
||||||
{triggerLabel}
|
{triggerLabel}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -657,7 +663,6 @@ export function CombinedModelSelector({
|
|||||||
onSelect={() => {}}
|
onSelect={() => {}}
|
||||||
open={isOpen}
|
open={isOpen}
|
||||||
onOpenChange={handleOpenChange}
|
onOpenChange={handleOpenChange}
|
||||||
stackBehavior="push"
|
|
||||||
anchorRef={anchorRef}
|
anchorRef={anchorRef}
|
||||||
desktopPlacement="top-start"
|
desktopPlacement="top-start"
|
||||||
desktopMinWidth={360}
|
desktopMinWidth={360}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./composer-height-mirror.native";
|
||||||
@@ -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.
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ import type { GitHubSearchItem } from "@server/shared/messages";
|
|||||||
import { Composer } from "./composer";
|
import { Composer } from "./composer";
|
||||||
import { splitComposerAttachmentsForSubmit } from "./composer-attachments";
|
import { splitComposerAttachmentsForSubmit } from "./composer-attachments";
|
||||||
|
|
||||||
|
const keyboardActionHandlerMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
const {
|
const {
|
||||||
theme,
|
theme,
|
||||||
imageMetadata,
|
imageMetadata,
|
||||||
@@ -20,10 +22,12 @@ const {
|
|||||||
deleteAttachmentsMock,
|
deleteAttachmentsMock,
|
||||||
encodeImagesMock,
|
encodeImagesMock,
|
||||||
openExternalUrlMock,
|
openExternalUrlMock,
|
||||||
|
markScrollInvestigationRenderMock,
|
||||||
mockSessionState,
|
mockSessionState,
|
||||||
setAgentStreamTailMock,
|
setAgentStreamTailMock,
|
||||||
setAgentStreamHeadMock,
|
setAgentStreamHeadMock,
|
||||||
setQueuedMessagesMock,
|
setQueuedMessagesMock,
|
||||||
|
agentDirectoryStatusMock,
|
||||||
} = vi.hoisted(() => {
|
} = vi.hoisted(() => {
|
||||||
const theme = {
|
const theme = {
|
||||||
spacing: { 1: 4, 2: 8, 3: 12, 4: 16, 6: 24, 8: 32 },
|
spacing: { 1: 4, 2: 8, 3: 12, 4: 16, 6: 24, 8: 32 },
|
||||||
@@ -102,6 +106,17 @@ const {
|
|||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
agents: Map<string, { status: string; lastUsage: null }>;
|
agents: Map<string, { status: string; lastUsage: null }>;
|
||||||
|
serverInfo: {
|
||||||
|
serverId: string;
|
||||||
|
hostname: string | null;
|
||||||
|
version: string | null;
|
||||||
|
capabilities?: {
|
||||||
|
voice?: {
|
||||||
|
dictation: { enabled: boolean; reason: string };
|
||||||
|
voice: { enabled: boolean; reason: string };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
} | null;
|
||||||
queuedMessages: Map<string, unknown[]>;
|
queuedMessages: Map<string, unknown[]>;
|
||||||
agentStreamHead: Map<string, unknown[]>;
|
agentStreamHead: Map<string, unknown[]>;
|
||||||
agentStreamTail: Map<string, unknown[]>;
|
agentStreamTail: Map<string, unknown[]>;
|
||||||
@@ -114,6 +129,17 @@ const {
|
|||||||
sessions: {
|
sessions: {
|
||||||
server: {
|
server: {
|
||||||
agents: new Map([["agent", { status: "idle", lastUsage: null }]]),
|
agents: new Map([["agent", { status: "idle", lastUsage: null }]]),
|
||||||
|
serverInfo: {
|
||||||
|
serverId: "server",
|
||||||
|
hostname: "test",
|
||||||
|
version: "0.0.0",
|
||||||
|
capabilities: {
|
||||||
|
voice: {
|
||||||
|
dictation: { enabled: true, reason: "" },
|
||||||
|
voice: { enabled: true, reason: "" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
queuedMessages: new Map(),
|
queuedMessages: new Map(),
|
||||||
agentStreamHead: new Map(),
|
agentStreamHead: new Map(),
|
||||||
agentStreamTail: new Map(),
|
agentStreamTail: new Map(),
|
||||||
@@ -135,6 +161,8 @@ const {
|
|||||||
);
|
);
|
||||||
mockSessionState.setAgentStreamTail = setAgentStreamTailMock;
|
mockSessionState.setAgentStreamTail = setAgentStreamTailMock;
|
||||||
mockSessionState.setAgentStreamHead = setAgentStreamHeadMock;
|
mockSessionState.setAgentStreamHead = setAgentStreamHeadMock;
|
||||||
|
const markScrollInvestigationRenderMock = vi.fn();
|
||||||
|
const agentDirectoryStatusMock = vi.fn(() => "ready");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
theme,
|
theme,
|
||||||
@@ -147,10 +175,12 @@ const {
|
|||||||
deleteAttachmentsMock: vi.fn(async () => {}),
|
deleteAttachmentsMock: vi.fn(async () => {}),
|
||||||
encodeImagesMock: vi.fn(async (images: AttachmentMetadata[]) => images),
|
encodeImagesMock: vi.fn(async (images: AttachmentMetadata[]) => images),
|
||||||
openExternalUrlMock: vi.fn(async () => {}),
|
openExternalUrlMock: vi.fn(async () => {}),
|
||||||
|
markScrollInvestigationRenderMock,
|
||||||
mockSessionState,
|
mockSessionState,
|
||||||
setAgentStreamTailMock,
|
setAgentStreamTailMock,
|
||||||
setAgentStreamHeadMock,
|
setAgentStreamHeadMock,
|
||||||
setQueuedMessagesMock,
|
setQueuedMessagesMock,
|
||||||
|
agentDirectoryStatusMock,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -177,6 +207,7 @@ vi.mock("lucide-react-native", () => {
|
|||||||
React.createElement("span", { ...props, "data-icon": name });
|
React.createElement("span", { ...props, "data-icon": name });
|
||||||
return {
|
return {
|
||||||
ArrowUp: createIcon("ArrowUp"),
|
ArrowUp: createIcon("ArrowUp"),
|
||||||
|
CornerDownLeft: createIcon("CornerDownLeft"),
|
||||||
Square: createIcon("Square"),
|
Square: createIcon("Square"),
|
||||||
Pencil: createIcon("Pencil"),
|
Pencil: createIcon("Pencil"),
|
||||||
AudioLines: createIcon("AudioLines"),
|
AudioLines: createIcon("AudioLines"),
|
||||||
@@ -216,7 +247,7 @@ vi.mock("react-native-safe-area-context", () => ({
|
|||||||
vi.mock("@/runtime/host-runtime", () => ({
|
vi.mock("@/runtime/host-runtime", () => ({
|
||||||
useHostRuntimeClient: () => mockClient,
|
useHostRuntimeClient: () => mockClient,
|
||||||
useHostRuntimeIsConnected: () => true,
|
useHostRuntimeIsConnected: () => true,
|
||||||
useHostRuntimeAgentDirectoryStatus: () => "ready",
|
useHostRuntimeAgentDirectoryStatus: () => agentDirectoryStatusMock(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/stores/session-store", () => {
|
vi.mock("@/stores/session-store", () => {
|
||||||
@@ -296,7 +327,9 @@ vi.mock("@/hooks/use-shortcut-keys", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/hooks/use-keyboard-action-handler", () => ({
|
vi.mock("@/hooks/use-keyboard-action-handler", () => ({
|
||||||
useKeyboardActionHandler: () => {},
|
useKeyboardActionHandler: (input: unknown) => {
|
||||||
|
keyboardActionHandlerMock(input);
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/hooks/use-keyboard-shift-style", () => ({
|
vi.mock("@/hooks/use-keyboard-shift-style", () => ({
|
||||||
@@ -311,8 +344,8 @@ vi.mock("@/contexts/toast-context", () => ({
|
|||||||
useToast: () => ({ error: vi.fn() }),
|
useToast: () => ({ error: vi.fn() }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/utils/scroll-jank-investigation", () => ({
|
vi.mock("@/utils/scroll-jank", () => ({
|
||||||
markScrollInvestigationRender: vi.fn(),
|
markScrollInvestigationRender: markScrollInvestigationRenderMock,
|
||||||
markScrollInvestigationEvent: vi.fn(),
|
markScrollInvestigationEvent: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -359,6 +392,20 @@ vi.mock("@/hooks/use-dictation", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/utils/server-info-capabilities", () => ({
|
vi.mock("@/utils/server-info-capabilities", () => ({
|
||||||
|
getVoiceReadinessState: ({
|
||||||
|
serverInfo,
|
||||||
|
mode,
|
||||||
|
}: {
|
||||||
|
serverInfo: {
|
||||||
|
capabilities?: {
|
||||||
|
voice?: {
|
||||||
|
dictation?: { enabled: boolean; reason: string };
|
||||||
|
voice?: { enabled: boolean; reason: string };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
} | null;
|
||||||
|
mode: "dictation" | "voice";
|
||||||
|
}) => serverInfo?.capabilities?.voice?.[mode] ?? null,
|
||||||
resolveVoiceUnavailableMessage: () => null,
|
resolveVoiceUnavailableMessage: () => null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -532,6 +579,10 @@ beforeEach(() => {
|
|||||||
vi.stubGlobal("Node", dom.window.Node);
|
vi.stubGlobal("Node", dom.window.Node);
|
||||||
vi.stubGlobal("navigator", dom.window.navigator);
|
vi.stubGlobal("navigator", dom.window.navigator);
|
||||||
vi.stubGlobal("Blob", dom.window.Blob);
|
vi.stubGlobal("Blob", dom.window.Blob);
|
||||||
|
Object.assign(dom.window.HTMLElement.prototype, {
|
||||||
|
attachEvent: vi.fn(),
|
||||||
|
detachEvent: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
container = document.createElement("div");
|
container = document.createElement("div");
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
@@ -546,9 +597,27 @@ beforeEach(() => {
|
|||||||
deleteAttachmentsMock.mockClear();
|
deleteAttachmentsMock.mockClear();
|
||||||
encodeImagesMock.mockClear();
|
encodeImagesMock.mockClear();
|
||||||
openExternalUrlMock.mockClear();
|
openExternalUrlMock.mockClear();
|
||||||
|
markScrollInvestigationRenderMock.mockClear();
|
||||||
setAgentStreamTailMock.mockClear();
|
setAgentStreamTailMock.mockClear();
|
||||||
setAgentStreamHeadMock.mockClear();
|
setAgentStreamHeadMock.mockClear();
|
||||||
setQueuedMessagesMock.mockClear();
|
setQueuedMessagesMock.mockClear();
|
||||||
|
keyboardActionHandlerMock.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.agents = new Map([
|
||||||
|
["agent", { status: "idle", lastUsage: null }],
|
||||||
|
]);
|
||||||
mockSessionState.sessions.server.agentStreamHead = new Map();
|
mockSessionState.sessions.server.agentStreamHead = new Map();
|
||||||
mockSessionState.sessions.server.agentStreamTail = new Map();
|
mockSessionState.sessions.server.agentStreamTail = new Map();
|
||||||
mockSessionState.sessions.server.queuedMessages = new Map();
|
mockSessionState.sessions.server.queuedMessages = new Map();
|
||||||
@@ -579,9 +648,13 @@ function imageAttachment(id: string): AttachmentMetadata {
|
|||||||
function ComposerHarness({
|
function ComposerHarness({
|
||||||
initialText = "",
|
initialText = "",
|
||||||
initialAttachments = [],
|
initialAttachments = [],
|
||||||
|
isSubmitLoading = false,
|
||||||
|
submitBehavior,
|
||||||
}: {
|
}: {
|
||||||
initialText?: string;
|
initialText?: string;
|
||||||
initialAttachments?: ComposerAttachment[];
|
initialAttachments?: ComposerAttachment[];
|
||||||
|
isSubmitLoading?: boolean;
|
||||||
|
submitBehavior?: "clear" | "preserve-and-lock";
|
||||||
}) {
|
}) {
|
||||||
const [text, setText] = useState(initialText);
|
const [text, setText] = useState(initialText);
|
||||||
const [attachments, setAttachments] = useState(initialAttachments);
|
const [attachments, setAttachments] = useState(initialAttachments);
|
||||||
@@ -603,6 +676,8 @@ function ComposerHarness({
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
isSubmitLoading={isSubmitLoading}
|
||||||
|
submitBehavior={submitBehavior}
|
||||||
cwd="/repo"
|
cwd="/repo"
|
||||||
clearDraft={vi.fn()}
|
clearDraft={vi.fn()}
|
||||||
/>
|
/>
|
||||||
@@ -611,7 +686,12 @@ function ComposerHarness({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderComposer(
|
function renderComposer(
|
||||||
input: { initialText?: string; initialAttachments?: ComposerAttachment[] } = {},
|
input: {
|
||||||
|
initialText?: string;
|
||||||
|
initialAttachments?: ComposerAttachment[];
|
||||||
|
isSubmitLoading?: boolean;
|
||||||
|
submitBehavior?: "clear" | "preserve-and-lock";
|
||||||
|
} = {},
|
||||||
) {
|
) {
|
||||||
act(() => {
|
act(() => {
|
||||||
root?.render(<ComposerHarness {...input} />);
|
root?.render(<ComposerHarness {...input} />);
|
||||||
@@ -651,6 +731,61 @@ function queryAllAttachmentMenuItems(): NodeListOf<HTMLElement> {
|
|||||||
return document.querySelectorAll('[data-testid^="message-input-attachment-menu-item-"]');
|
return document.querySelectorAll('[data-testid^="message-input-attachment-menu-item-"]');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dispatchAgentInterrupt() {
|
||||||
|
act(() => {
|
||||||
|
const registeredHandler = keyboardActionHandlerMock.mock.calls.at(-1)?.[0];
|
||||||
|
registeredHandler?.handle({ id: "agent.interrupt", scope: "global" });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function countMessageInputRenders(): number {
|
||||||
|
return markScrollInvestigationRenderMock.mock.calls.filter(
|
||||||
|
([componentId]) => componentId === "MessageInput:server:agent",
|
||||||
|
).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Composer keyboard shortcuts", () => {
|
||||||
|
it("interrupts a running agent without clearing a filled draft", async () => {
|
||||||
|
mockSessionState.sessions.server.agents = new Map([
|
||||||
|
["agent", { status: "running", lastUsage: null }],
|
||||||
|
]);
|
||||||
|
|
||||||
|
renderComposer({ initialText: "keep this prompt" });
|
||||||
|
await flushAsyncWork();
|
||||||
|
|
||||||
|
dispatchAgentInterrupt();
|
||||||
|
|
||||||
|
expect(mockClient.cancelAgent).toHaveBeenCalledWith("agent");
|
||||||
|
expect(document.querySelector('[aria-label="Message agent..."]')).toHaveProperty(
|
||||||
|
"value",
|
||||||
|
"keep this prompt",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("interrupts a running agent when the message input is unfocused", async () => {
|
||||||
|
mockSessionState.sessions.server.agents = new Map([
|
||||||
|
["agent", { status: "running", lastUsage: null }],
|
||||||
|
]);
|
||||||
|
|
||||||
|
renderComposer();
|
||||||
|
await flushAsyncWork();
|
||||||
|
|
||||||
|
const input = document.querySelector('[aria-label="Message agent..."]') as HTMLElement | null;
|
||||||
|
input?.blur();
|
||||||
|
dispatchAgentInterrupt();
|
||||||
|
|
||||||
|
expect(mockClient.cancelAgent).toHaveBeenCalledWith("agent");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not interrupt when the agent is idle", () => {
|
||||||
|
renderComposer();
|
||||||
|
|
||||||
|
dispatchAgentInterrupt();
|
||||||
|
|
||||||
|
expect(mockClient.cancelAgent).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("Composer attachments", () => {
|
describe("Composer attachments", () => {
|
||||||
it("opens a Plus menu with image and GitHub attachment actions", () => {
|
it("opens a Plus menu with image and GitHub attachment actions", () => {
|
||||||
renderComposer();
|
renderComposer();
|
||||||
@@ -699,6 +834,23 @@ describe("Composer attachments", () => {
|
|||||||
expect(combobox.dataset.anchor).toBe("attached");
|
expect(combobox.dataset.anchor).toBe("attached");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("lazily searches GitHub only after the GitHub combobox opens", async () => {
|
||||||
|
renderComposer();
|
||||||
|
await flushAsyncWork();
|
||||||
|
|
||||||
|
expect(mockClient.searchGitHub).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
click(queryByTestId("message-input-attach-button")!);
|
||||||
|
click(queryByTestId("message-input-attachment-menu-item-github")!);
|
||||||
|
await findByTestId("composer-github-combobox");
|
||||||
|
|
||||||
|
expect(mockClient.searchGitHub).toHaveBeenCalledWith({
|
||||||
|
cwd: "/repo",
|
||||||
|
query: "",
|
||||||
|
limit: 20,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("closes the GitHub combobox after selecting an item", async () => {
|
it("closes the GitHub combobox after selecting an item", async () => {
|
||||||
renderComposer();
|
renderComposer();
|
||||||
|
|
||||||
@@ -835,6 +987,64 @@ describe("Composer attachments", () => {
|
|||||||
expect(latestAttachments).toEqual([{ kind: "image", metadata: image }]);
|
expect(latestAttachments).toEqual([{ kind: "image", metadata: image }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not re-render MessageInput when opening the attachment lightbox", () => {
|
||||||
|
const image = imageAttachment("img-lightbox-render");
|
||||||
|
renderComposer({ initialAttachments: [{ kind: "image", metadata: image }] });
|
||||||
|
const renderCountBeforeLightbox = countMessageInputRenders();
|
||||||
|
|
||||||
|
click(queryByTestId("composer-image-attachment-pill")!);
|
||||||
|
|
||||||
|
expect(queryByTestId("attachment-lightbox-image")).not.toBeNull();
|
||||||
|
expect(countMessageInputRenders()).toBe(renderCountBeforeLightbox);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still re-renders MessageInput when submit loading semantics change", () => {
|
||||||
|
renderComposer({ initialText: "pending submit", isSubmitLoading: false });
|
||||||
|
const renderCountBeforeLoading = countMessageInputRenders();
|
||||||
|
|
||||||
|
renderComposer({ initialText: "pending submit", isSubmitLoading: true });
|
||||||
|
|
||||||
|
expect(countMessageInputRenders()).toBe(renderCountBeforeLoading + 1);
|
||||||
|
expect(document.querySelector('[aria-label="Send message"]')).toHaveProperty("disabled", true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enables dictation from server capabilities before the agent directory finishes loading", () => {
|
||||||
|
agentDirectoryStatusMock.mockReturnValue("initial_loading");
|
||||||
|
|
||||||
|
renderComposer();
|
||||||
|
|
||||||
|
expect(document.querySelector('[aria-label="Start dictation"]')).toHaveProperty(
|
||||||
|
"disabled",
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("locks the preserved draft while submit loading", () => {
|
||||||
|
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", () => {
|
it("closes the image lightbox when its close button is pressed", () => {
|
||||||
const image = imageAttachment("img-close");
|
const image = imageAttachment("img-close");
|
||||||
renderComposer({ initialAttachments: [{ kind: "image", metadata: image }] });
|
renderComposer({ initialAttachments: [{ kind: "image", metadata: image }] });
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { View, Pressable, Text, ActivityIndicator, Image } from "react-native";
|
import { View, Pressable, Text, ActivityIndicator, Image } from "react-native";
|
||||||
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
import { useState, useEffect, useRef, useCallback, useMemo, memo } from "react";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { useShallow } from "zustand/shallow";
|
import { useShallow } from "zustand/shallow";
|
||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
Paperclip,
|
Paperclip,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import Animated from "react-native-reanimated";
|
import Animated from "react-native-reanimated";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
|
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||||
import { generateMessageId, type StreamItem } from "@/types/stream";
|
import { generateMessageId, type StreamItem } from "@/types/stream";
|
||||||
@@ -33,7 +32,7 @@ import {
|
|||||||
type MessageInputRef,
|
type MessageInputRef,
|
||||||
type AttachmentMenuItem,
|
type AttachmentMenuItem,
|
||||||
} from "./message-input";
|
} from "./message-input";
|
||||||
import { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
||||||
import { encodeImages } from "@/utils/encode-images";
|
import { encodeImages } from "@/utils/encode-images";
|
||||||
import { focusWithRetries } from "@/utils/web-focus";
|
import { focusWithRetries } from "@/utils/web-focus";
|
||||||
@@ -55,7 +54,7 @@ import {
|
|||||||
persistAttachmentFromFileUri,
|
persistAttachmentFromFileUri,
|
||||||
} from "@/attachments/service";
|
} from "@/attachments/service";
|
||||||
import { resolveStatusControlMode } from "@/components/composer.status-controls";
|
import { resolveStatusControlMode } from "@/components/composer.status-controls";
|
||||||
import { markScrollInvestigationRender } from "@/utils/scroll-jank-investigation";
|
import { markScrollInvestigationRender } from "@/utils/scroll-jank";
|
||||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||||
@@ -70,6 +69,7 @@ import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachm
|
|||||||
import { AttachmentPill } from "@/components/attachment-pill";
|
import { AttachmentPill } from "@/components/attachment-pill";
|
||||||
import { AttachmentLightbox } from "@/components/attachment-lightbox";
|
import { AttachmentLightbox } from "@/components/attachment-lightbox";
|
||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
|
import { useIsDictationReady } from "@/hooks/use-is-dictation-ready";
|
||||||
|
|
||||||
type QueuedMessage = {
|
type QueuedMessage = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -100,8 +100,10 @@ interface ComposerProps {
|
|||||||
allowEmptySubmit?: boolean;
|
allowEmptySubmit?: boolean;
|
||||||
/** Optional accessibility label for the primary submit button. */
|
/** Optional accessibility label for the primary submit button. */
|
||||||
submitButtonAccessibilityLabel?: string;
|
submitButtonAccessibilityLabel?: string;
|
||||||
|
submitIcon?: "arrow" | "return";
|
||||||
/** Externally controlled loading state. When true, disables the submit button. */
|
/** Externally controlled loading state. When true, disables the submit button. */
|
||||||
isSubmitLoading?: boolean;
|
isSubmitLoading?: boolean;
|
||||||
|
submitBehavior?: "clear" | "preserve-and-lock";
|
||||||
/** When true, blurs the input immediately when submitting. */
|
/** When true, blurs the input immediately when submitting. */
|
||||||
blurOnSubmit?: boolean;
|
blurOnSubmit?: boolean;
|
||||||
value: string;
|
value: string;
|
||||||
@@ -132,6 +134,7 @@ interface ComposerProps {
|
|||||||
const EMPTY_ARRAY: readonly QueuedMessage[] = [];
|
const EMPTY_ARRAY: readonly QueuedMessage[] = [];
|
||||||
const DESKTOP_MESSAGE_PLACEHOLDER = "Message the agent, tag @files, or use /commands and /skills";
|
const DESKTOP_MESSAGE_PLACEHOLDER = "Message the agent, tag @files, or use /commands and /skills";
|
||||||
const MOBILE_MESSAGE_PLACEHOLDER = "Message, @files, /commands";
|
const MOBILE_MESSAGE_PLACEHOLDER = "Message, @files, /commands";
|
||||||
|
const StableMessageInput = memo(MessageInput);
|
||||||
|
|
||||||
export function Composer({
|
export function Composer({
|
||||||
agentId,
|
agentId,
|
||||||
@@ -141,7 +144,9 @@ export function Composer({
|
|||||||
hasExternalContent = false,
|
hasExternalContent = false,
|
||||||
allowEmptySubmit = false,
|
allowEmptySubmit = false,
|
||||||
submitButtonAccessibilityLabel,
|
submitButtonAccessibilityLabel,
|
||||||
|
submitIcon = "arrow",
|
||||||
isSubmitLoading = false,
|
isSubmitLoading = false,
|
||||||
|
submitBehavior = "clear",
|
||||||
blurOnSubmit = false,
|
blurOnSubmit = false,
|
||||||
value,
|
value,
|
||||||
onChangeText,
|
onChangeText,
|
||||||
@@ -163,19 +168,20 @@ export function Composer({
|
|||||||
markScrollInvestigationRender(`Composer:${serverId}:${agentId}`);
|
markScrollInvestigationRender(`Composer:${serverId}:${agentId}`);
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const buttonIconSize = isWeb ? theme.iconSize.md : theme.iconSize.lg;
|
const buttonIconSize = isWeb ? theme.iconSize.md : theme.iconSize.lg;
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const client = useHostRuntimeClient(serverId);
|
const client = useHostRuntimeClient(serverId);
|
||||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||||
const agentDirectoryStatus = useHostRuntimeAgentDirectoryStatus(serverId);
|
const agentDirectoryStatus = useHostRuntimeAgentDirectoryStatus(serverId);
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
const toastErrorRef = useRef(toast.error);
|
||||||
|
toastErrorRef.current = toast.error;
|
||||||
const voice = useVoiceOptional();
|
const voice = useVoiceOptional();
|
||||||
const voiceToggleKeys = useShortcutKeys("voice-toggle");
|
const voiceToggleKeys = useShortcutKeys("voice-toggle");
|
||||||
const dictationCancelKeys = useShortcutKeys("dictation-cancel");
|
const agentInterruptKeys = useShortcutKeys("agent-interrupt");
|
||||||
const isDictationReady =
|
const isDictationReady = useIsDictationReady({
|
||||||
isConnected &&
|
serverId,
|
||||||
(agentDirectoryStatus === "ready" ||
|
isConnected,
|
||||||
agentDirectoryStatus === "revalidating" ||
|
agentDirectoryStatus,
|
||||||
agentDirectoryStatus === "error_after_ready");
|
});
|
||||||
|
|
||||||
const { settings: appSettings } = useAppSettings();
|
const { settings: appSettings } = useAppSettings();
|
||||||
|
|
||||||
@@ -218,6 +224,7 @@ export function Composer({
|
|||||||
const [lightboxMetadata, setLightboxMetadata] = useState<AttachmentMetadata | null>(null);
|
const [lightboxMetadata, setLightboxMetadata] = useState<AttachmentMetadata | null>(null);
|
||||||
const attachButtonRef = useRef<View | null>(null);
|
const attachButtonRef = useRef<View | null>(null);
|
||||||
const messageInputRef = useRef<MessageInputRef>(null);
|
const messageInputRef = useRef<MessageInputRef>(null);
|
||||||
|
const isComposerLocked = submitBehavior === "preserve-and-lock" && isSubmitLoading;
|
||||||
const keyboardHandlerIdRef = useRef(
|
const keyboardHandlerIdRef = useRef(
|
||||||
`message-input:${serverId}:${agentId}:${Math.random().toString(36).slice(2)}`,
|
`message-input:${serverId}:${agentId}:${Math.random().toString(36).slice(2)}`,
|
||||||
);
|
);
|
||||||
@@ -233,6 +240,8 @@ export function Composer({
|
|||||||
messageInputRef.current?.focus();
|
messageInputRef.current?.focus();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const autocompleteOnKeyPressRef = useRef(autocomplete.onKeyPress);
|
||||||
|
autocompleteOnKeyPressRef.current = autocomplete.onKeyPress;
|
||||||
|
|
||||||
// Clear send error when user edits the input
|
// Clear send error when user edits the input
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -370,66 +379,82 @@ export function Composer({
|
|||||||
[agentId, serverId, setQueuedMessages],
|
[agentId, serverId, setQueuedMessages],
|
||||||
);
|
);
|
||||||
|
|
||||||
function queueMessage(message: string, attachments: ComposerAttachment[]) {
|
const queueMessage = useCallback(
|
||||||
const trimmedMessage = message.trim();
|
(message: string, attachments: ComposerAttachment[]) => {
|
||||||
if (!trimmedMessage && attachments.length === 0) return;
|
const trimmedMessage = message.trim();
|
||||||
|
if (!trimmedMessage && attachments.length === 0) return;
|
||||||
|
|
||||||
const newItem = {
|
const newItem = {
|
||||||
id: generateMessageId(),
|
id: generateMessageId(),
|
||||||
text: trimmedMessage,
|
text: trimmedMessage,
|
||||||
attachments,
|
attachments,
|
||||||
};
|
};
|
||||||
|
|
||||||
setQueuedMessages(serverId, (prev: Map<string, QueuedMessage[]>) => {
|
setQueuedMessages(serverId, (prev: Map<string, QueuedMessage[]>) => {
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
next.set(agentId, [...(prev.get(agentId) ?? []), newItem]);
|
next.set(agentId, [...(prev.get(agentId) ?? []), newItem]);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|
||||||
setUserInput("");
|
setUserInput("");
|
||||||
setSelectedAttachments([]);
|
setSelectedAttachments([]);
|
||||||
}
|
},
|
||||||
|
[agentId, serverId, setQueuedMessages, setSelectedAttachments, setUserInput],
|
||||||
|
);
|
||||||
|
|
||||||
async function sendMessageWithContent(
|
const sendMessageWithContent = useCallback(
|
||||||
message: string,
|
async (message: string, attachments: ComposerAttachment[], forceSend?: boolean) => {
|
||||||
attachments: ComposerAttachment[],
|
await submitAgentInput({
|
||||||
forceSend?: boolean,
|
message,
|
||||||
) {
|
attachments,
|
||||||
await submitAgentInput({
|
hasExternalContent,
|
||||||
message,
|
allowEmptySubmit,
|
||||||
attachments,
|
forceSend,
|
||||||
hasExternalContent,
|
submitBehavior,
|
||||||
|
isAgentRunning,
|
||||||
|
// Parent-managed submits are still valid submit paths even when the
|
||||||
|
// transport is disconnected, because the parent decides the failure mode.
|
||||||
|
canSubmit: Boolean(sendAgentMessageRef.current || onSubmitMessageRef.current),
|
||||||
|
queueMessage: ({ message, attachments }) => {
|
||||||
|
queueMessage(message, attachments);
|
||||||
|
},
|
||||||
|
submitMessage: async ({ message, attachments }) => {
|
||||||
|
await submitMessage(message, attachments);
|
||||||
|
},
|
||||||
|
clearDraft,
|
||||||
|
setUserInput,
|
||||||
|
setAttachments: (nextAttachments) => {
|
||||||
|
setSelectedAttachments(nextAttachments);
|
||||||
|
},
|
||||||
|
setSendError,
|
||||||
|
setIsProcessing,
|
||||||
|
onSubmitError: (error) => {
|
||||||
|
console.error("[AgentInput] Failed to send message:", error);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[
|
||||||
allowEmptySubmit,
|
allowEmptySubmit,
|
||||||
forceSend,
|
|
||||||
isAgentRunning: agentState.status === "running",
|
|
||||||
// Parent-managed submits are still valid submit paths even when the
|
|
||||||
// transport is disconnected, because the parent decides the failure mode.
|
|
||||||
canSubmit: Boolean(sendAgentMessageRef.current || onSubmitMessageRef.current),
|
|
||||||
queueMessage: ({ message, attachments }) => {
|
|
||||||
queueMessage(message, attachments);
|
|
||||||
},
|
|
||||||
submitMessage: async ({ message, attachments }) => {
|
|
||||||
await submitMessage(message, attachments);
|
|
||||||
},
|
|
||||||
clearDraft,
|
clearDraft,
|
||||||
|
hasExternalContent,
|
||||||
|
isAgentRunning,
|
||||||
|
queueMessage,
|
||||||
|
setSelectedAttachments,
|
||||||
setUserInput,
|
setUserInput,
|
||||||
setAttachments: (nextAttachments) => {
|
submitBehavior,
|
||||||
setSelectedAttachments(nextAttachments);
|
submitMessage,
|
||||||
},
|
],
|
||||||
setSendError,
|
);
|
||||||
setIsProcessing,
|
|
||||||
onSubmitError: (error) => {
|
|
||||||
console.error("[AgentInput] Failed to send message:", error);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSubmit(payload: MessagePayload) {
|
const handleSubmit = useCallback(
|
||||||
if (blurOnSubmit) {
|
(payload: MessagePayload) => {
|
||||||
messageInputRef.current?.blur();
|
if (blurOnSubmit) {
|
||||||
}
|
messageInputRef.current?.blur();
|
||||||
void sendMessageWithContent(payload.text, payload.attachments, payload.forceSend);
|
}
|
||||||
}
|
void sendMessageWithContent(payload.text, payload.attachments, payload.forceSend);
|
||||||
|
},
|
||||||
|
[blurOnSubmit, sendMessageWithContent],
|
||||||
|
);
|
||||||
|
|
||||||
const handlePickImage = useCallback(async () => {
|
const handlePickImage = useCallback(async () => {
|
||||||
const result = await pickImages();
|
const result = await pickImages();
|
||||||
@@ -481,6 +506,18 @@ export function Composer({
|
|||||||
}
|
}
|
||||||
}, [isAgentRunning, isConnected]);
|
}, [isAgentRunning, isConnected]);
|
||||||
|
|
||||||
|
const handleCancelAgent = useCallback(() => {
|
||||||
|
if (!isAgentRunning || isCancellingAgent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isConnected || !client) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsCancellingAgent(true);
|
||||||
|
void client.cancelAgent(agentIdRef.current);
|
||||||
|
messageInputRef.current?.focus();
|
||||||
|
}, [client, isAgentRunning, isCancellingAgent, isConnected]);
|
||||||
|
|
||||||
const handleKeyboardAction = useCallback(
|
const handleKeyboardAction = useCallback(
|
||||||
(action: KeyboardActionDefinition): boolean => {
|
(action: KeyboardActionDefinition): boolean => {
|
||||||
if (!isPaneFocused) {
|
if (!isPaneFocused) {
|
||||||
@@ -488,6 +525,15 @@ export function Composer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch (action.id) {
|
switch (action.id) {
|
||||||
|
case "agent.interrupt":
|
||||||
|
if (messageInputRef.current?.runKeyboardAction("dictation-cancel")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!isAgentRunning || isCancellingAgent || !isConnected) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
handleCancelAgent();
|
||||||
|
return true;
|
||||||
case "message-input.send":
|
case "message-input.send":
|
||||||
return messageInputRef.current?.runKeyboardAction("send") ?? false;
|
return messageInputRef.current?.runKeyboardAction("send") ?? false;
|
||||||
case "message-input.dictation-confirm":
|
case "message-input.dictation-confirm":
|
||||||
@@ -523,12 +569,13 @@ export function Composer({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isPaneFocused],
|
[handleCancelAgent, isAgentRunning, isCancellingAgent, isConnected, isPaneFocused],
|
||||||
);
|
);
|
||||||
|
|
||||||
useKeyboardActionHandler({
|
useKeyboardActionHandler({
|
||||||
handlerId: keyboardHandlerIdRef.current,
|
handlerId: keyboardHandlerIdRef.current,
|
||||||
actions: [
|
actions: [
|
||||||
|
"agent.interrupt",
|
||||||
"message-input.focus",
|
"message-input.focus",
|
||||||
"message-input.send",
|
"message-input.send",
|
||||||
"message-input.dictation-toggle",
|
"message-input.dictation-toggle",
|
||||||
@@ -547,18 +594,6 @@ export function Composer({
|
|||||||
mode: "translate",
|
mode: "translate",
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleCancelAgent() {
|
|
||||||
if (!isAgentRunning || isCancellingAgent) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!isConnected || !client) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsCancellingAgent(true);
|
|
||||||
void client.cancelAgent(agentIdRef.current);
|
|
||||||
messageInputRef.current?.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
const isVoiceModeForAgent = voice?.isVoiceModeForAgent(serverId, agentId) ?? false;
|
const isVoiceModeForAgent = voice?.isVoiceModeForAgent(serverId, agentId) ?? false;
|
||||||
|
|
||||||
const handleToggleRealtimeVoice = useCallback(() => {
|
const handleToggleRealtimeVoice = useCallback(() => {
|
||||||
@@ -576,10 +611,10 @@ export function Composer({
|
|||||||
const message =
|
const message =
|
||||||
error instanceof Error ? error.message : typeof error === "string" ? error : null;
|
error instanceof Error ? error.message : typeof error === "string" ? error : null;
|
||||||
if (message && message.trim().length > 0) {
|
if (message && message.trim().length > 0) {
|
||||||
toast.error(message);
|
toastErrorRef.current(message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [agentId, hasAgent, isConnected, serverId, toast, voice]);
|
}, [agentId, hasAgent, isConnected, serverId, voice]);
|
||||||
|
|
||||||
function handleEditQueuedMessage(id: string) {
|
function handleEditQueuedMessage(id: string) {
|
||||||
const item = queuedMessages.find((q) => q.id === id);
|
const item = queuedMessages.find((q) => q.id === id);
|
||||||
@@ -606,112 +641,121 @@ export function Composer({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleQueue = useCallback((payload: MessagePayload) => {
|
const handleQueue = useCallback(
|
||||||
queueMessage(payload.text, payload.attachments);
|
(payload: MessagePayload) => {
|
||||||
}, []);
|
queueMessage(payload.text, payload.attachments);
|
||||||
|
},
|
||||||
|
[queueMessage],
|
||||||
|
);
|
||||||
|
|
||||||
const hasSendableContent = userInput.trim().length > 0 || selectedAttachments.length > 0;
|
const hasSendableContent = userInput.trim().length > 0 || selectedAttachments.length > 0;
|
||||||
|
|
||||||
// Handle keyboard navigation for command autocomplete and stop action.
|
// Handle keyboard navigation for command autocomplete.
|
||||||
const handleCommandKeyPress = useCallback(
|
const handleCommandKeyPress = useCallback(
|
||||||
(event: { key: string; preventDefault: () => void }) => {
|
(event: { key: string; preventDefault: () => void }) => {
|
||||||
if (
|
return autocompleteOnKeyPressRef.current(event);
|
||||||
event.key === "Escape" &&
|
|
||||||
isAgentRunning &&
|
|
||||||
!hasSendableContent &&
|
|
||||||
!isCancellingAgent &&
|
|
||||||
isConnected
|
|
||||||
) {
|
|
||||||
event.preventDefault();
|
|
||||||
handleCancelAgent();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return autocomplete.onKeyPress(event);
|
|
||||||
},
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
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>
|
||||||
|
{agentInterruptKeys ? (
|
||||||
|
<Shortcut chord={agentInterruptKeys} style={styles.tooltipShortcut} />
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
) : null,
|
||||||
[
|
[
|
||||||
autocomplete,
|
agentInterruptKeys,
|
||||||
|
buttonIconSize,
|
||||||
|
handleCancelAgent,
|
||||||
hasSendableContent,
|
hasSendableContent,
|
||||||
isAgentRunning,
|
isAgentRunning,
|
||||||
isCancellingAgent,
|
isCancellingAgent,
|
||||||
isConnected,
|
isConnected,
|
||||||
handleCancelAgent,
|
isProcessing,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const cancelButton =
|
|
||||||
isAgentRunning && !hasSendableContent && !isProcessing ? (
|
|
||||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
|
||||||
<TooltipTrigger
|
|
||||||
onPress={handleCancelAgent}
|
|
||||||
disabled={!isConnected || isCancellingAgent}
|
|
||||||
accessibilityLabel={isCancellingAgent ? "Canceling agent" : "Stop agent"}
|
|
||||||
accessibilityRole="button"
|
|
||||||
style={[
|
|
||||||
styles.cancelButton as any,
|
|
||||||
(!isConnected || isCancellingAgent ? styles.buttonDisabled : undefined) as any,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{isCancellingAgent ? (
|
|
||||||
<ActivityIndicator size="small" color="white" />
|
|
||||||
) : (
|
|
||||||
<Square size={buttonIconSize} color="white" fill="white" />
|
|
||||||
)}
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top" align="center" offset={8}>
|
|
||||||
<View style={styles.tooltipRow}>
|
|
||||||
<Text style={styles.tooltipText}>Interrupt</Text>
|
|
||||||
{dictationCancelKeys ? (
|
|
||||||
<Shortcut chord={dictationCancelKeys} style={styles.tooltipShortcut} />
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
) : null;
|
|
||||||
|
|
||||||
const showVoiceModeButton = !isVoiceModeForAgent && hasAgent;
|
const showVoiceModeButton = !isVoiceModeForAgent && hasAgent;
|
||||||
const rightContent =
|
const rightContent = useMemo(
|
||||||
showVoiceModeButton || cancelButton ? (
|
() =>
|
||||||
<View style={styles.rightControls}>
|
showVoiceModeButton || cancelButton ? (
|
||||||
{showVoiceModeButton ? (
|
<View style={styles.rightControls}>
|
||||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
{showVoiceModeButton ? (
|
||||||
<TooltipTrigger
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||||
onPress={handleToggleRealtimeVoice}
|
<TooltipTrigger
|
||||||
disabled={!isConnected || voice?.isVoiceSwitching}
|
onPress={handleToggleRealtimeVoice}
|
||||||
accessibilityLabel="Enable Voice mode"
|
disabled={!isConnected || voice?.isVoiceSwitching}
|
||||||
accessibilityRole="button"
|
accessibilityLabel="Enable Voice mode"
|
||||||
style={({ hovered }) => [
|
accessibilityRole="button"
|
||||||
styles.realtimeVoiceButton as any,
|
style={({ hovered }) => [
|
||||||
(hovered ? styles.iconButtonHovered : undefined) as any,
|
styles.realtimeVoiceButton as any,
|
||||||
(!isConnected || voice?.isVoiceSwitching
|
(hovered ? styles.iconButtonHovered : undefined) as any,
|
||||||
? styles.buttonDisabled
|
(!isConnected || voice?.isVoiceSwitching
|
||||||
: undefined) as any,
|
? styles.buttonDisabled
|
||||||
]}
|
: undefined) as any,
|
||||||
>
|
]}
|
||||||
{({ hovered }) =>
|
>
|
||||||
voice?.isVoiceSwitching ? (
|
{({ hovered }) =>
|
||||||
<ActivityIndicator size="small" color="white" />
|
voice?.isVoiceSwitching ? (
|
||||||
) : (
|
<ActivityIndicator size="small" color="white" />
|
||||||
<AudioLines
|
) : (
|
||||||
size={buttonIconSize}
|
<AudioLines
|
||||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
size={buttonIconSize}
|
||||||
/>
|
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||||
)
|
/>
|
||||||
}
|
)
|
||||||
</TooltipTrigger>
|
}
|
||||||
<TooltipContent side="top" align="center" offset={8}>
|
</TooltipTrigger>
|
||||||
<View style={styles.tooltipRow}>
|
<TooltipContent side="top" align="center" offset={8}>
|
||||||
<Text style={styles.tooltipText}>Voice mode</Text>
|
<View style={styles.tooltipRow}>
|
||||||
{voiceToggleKeys ? (
|
<Text style={styles.tooltipText}>Voice mode</Text>
|
||||||
<Shortcut chord={voiceToggleKeys} style={styles.tooltipShortcut} />
|
{voiceToggleKeys ? (
|
||||||
) : null}
|
<Shortcut chord={voiceToggleKeys} style={styles.tooltipShortcut} />
|
||||||
</View>
|
) : null}
|
||||||
</TooltipContent>
|
</View>
|
||||||
</Tooltip>
|
</TooltipContent>
|
||||||
) : null}
|
</Tooltip>
|
||||||
{cancelButton}
|
) : null}
|
||||||
</View>
|
{cancelButton}
|
||||||
) : null;
|
</View>
|
||||||
|
) : null,
|
||||||
|
[
|
||||||
|
buttonIconSize,
|
||||||
|
cancelButton,
|
||||||
|
handleToggleRealtimeVoice,
|
||||||
|
isConnected,
|
||||||
|
showVoiceModeButton,
|
||||||
|
theme.colors.foreground,
|
||||||
|
theme.colors.foregroundMuted,
|
||||||
|
voice,
|
||||||
|
voiceToggleKeys,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
const hasContextWindowMeter =
|
const hasContextWindowMeter =
|
||||||
typeof agentState.contextWindowMaxTokens === "number" &&
|
typeof agentState.contextWindowMaxTokens === "number" &&
|
||||||
@@ -719,15 +763,18 @@ export function Composer({
|
|||||||
const contextWindowMaxTokens = hasContextWindowMeter ? agentState.contextWindowMaxTokens : null;
|
const contextWindowMaxTokens = hasContextWindowMeter ? agentState.contextWindowMaxTokens : null;
|
||||||
const contextWindowUsedTokens = hasContextWindowMeter ? agentState.contextWindowUsedTokens : null;
|
const contextWindowUsedTokens = hasContextWindowMeter ? agentState.contextWindowUsedTokens : null;
|
||||||
|
|
||||||
const beforeVoiceContent = (
|
const beforeVoiceContent = useMemo(
|
||||||
<View style={styles.contextWindowMeterSlot}>
|
() => (
|
||||||
{contextWindowMaxTokens !== null && contextWindowUsedTokens !== null ? (
|
<View style={styles.contextWindowMeterSlot}>
|
||||||
<ContextWindowMeter
|
{contextWindowMaxTokens !== null && contextWindowUsedTokens !== null ? (
|
||||||
maxTokens={contextWindowMaxTokens}
|
<ContextWindowMeter
|
||||||
usedTokens={contextWindowUsedTokens}
|
maxTokens={contextWindowMaxTokens}
|
||||||
/>
|
usedTokens={contextWindowUsedTokens}
|
||||||
) : null}
|
/>
|
||||||
</View>
|
) : null}
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
[contextWindowMaxTokens, contextWindowUsedTokens],
|
||||||
);
|
);
|
||||||
|
|
||||||
const githubSearchQueryTrimmed = githubSearchQuery.trim();
|
const githubSearchQueryTrimmed = githubSearchQuery.trim();
|
||||||
@@ -743,7 +790,7 @@ export function Composer({
|
|||||||
limit: 20,
|
limit: 20,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
enabled: isConnected && !!client && cwd.trim().length > 0,
|
enabled: isGithubPickerOpen && isConnected && !!client && cwd.trim().length > 0,
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -802,20 +849,39 @@ export function Composer({
|
|||||||
[setSelectedAttachments, setGithubSearchQuery, setIsGithubPickerOpen],
|
[setSelectedAttachments, setGithubSearchQuery, setIsGithubPickerOpen],
|
||||||
);
|
);
|
||||||
|
|
||||||
const leftContent =
|
const leftContent = useMemo(
|
||||||
resolveStatusControlMode(statusControls) === "draft" && statusControls ? (
|
() =>
|
||||||
<DraftAgentStatusBar {...statusControls} />
|
resolveStatusControlMode(statusControls) === "draft" && statusControls ? (
|
||||||
) : (
|
<DraftAgentStatusBar {...statusControls} />
|
||||||
<AgentStatusBar agentId={agentId} serverId={serverId} onDropdownClose={focusInput} />
|
) : (
|
||||||
);
|
<AgentStatusBar agentId={agentId} serverId={serverId} onDropdownClose={focusInput} />
|
||||||
|
),
|
||||||
|
[agentId, focusInput, serverId, statusControls],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAttachButtonRef = useCallback((node: View | null) => {
|
||||||
|
attachButtonRef.current = node;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSelectionChange = useCallback((selection: { start: number; end: number }) => {
|
||||||
|
setCursorIndex(selection.start);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFocusChange = useCallback(
|
||||||
|
(focused: boolean) => {
|
||||||
|
setIsMessageInputFocused(focused);
|
||||||
|
if (focused) {
|
||||||
|
onAttentionInputFocus?.();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onAttentionInputFocus],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View
|
<Animated.View style={[styles.container, keyboardAnimatedStyle]}>
|
||||||
style={[styles.container, { paddingBottom: insets.bottom }, keyboardAnimatedStyle]}
|
|
||||||
>
|
|
||||||
<AttachmentLightbox metadata={lightboxMetadata} onClose={() => setLightboxMetadata(null)} />
|
<AttachmentLightbox metadata={lightboxMetadata} onClose={() => setLightboxMetadata(null)} />
|
||||||
{/* Input area */}
|
{/* Input area */}
|
||||||
<View style={styles.inputAreaContainer}>
|
<View style={[styles.inputAreaContainer, isComposerLocked && styles.inputAreaLocked]}>
|
||||||
<View style={styles.inputAreaContent}>
|
<View style={styles.inputAreaContent}>
|
||||||
{/* Queue list */}
|
{/* Queue list */}
|
||||||
{queuedMessages.length > 0 && (
|
{queuedMessages.length > 0 && (
|
||||||
@@ -874,6 +940,7 @@ export function Composer({
|
|||||||
onRemove={() => handleRemoveAttachment(index)}
|
onRemove={() => handleRemoveAttachment(index)}
|
||||||
openAccessibilityLabel="Open image attachment"
|
openAccessibilityLabel="Open image attachment"
|
||||||
removeAccessibilityLabel="Remove image attachment"
|
removeAccessibilityLabel="Remove image attachment"
|
||||||
|
disabled={isComposerLocked}
|
||||||
>
|
>
|
||||||
<ImageAttachmentThumbnail image={attachment.metadata} />
|
<ImageAttachmentThumbnail image={attachment.metadata} />
|
||||||
</AttachmentPill>
|
</AttachmentPill>
|
||||||
@@ -890,6 +957,7 @@ export function Composer({
|
|||||||
onRemove={() => handleRemoveAttachment(index)}
|
onRemove={() => handleRemoveAttachment(index)}
|
||||||
openAccessibilityLabel={`Open ${kindLabel} #${item.number}`}
|
openAccessibilityLabel={`Open ${kindLabel} #${item.number}`}
|
||||||
removeAccessibilityLabel={`Remove ${kindLabel} #${item.number}`}
|
removeAccessibilityLabel={`Remove ${kindLabel} #${item.number}`}
|
||||||
|
disabled={isComposerLocked}
|
||||||
>
|
>
|
||||||
<View style={styles.githubPillBody}>
|
<View style={styles.githubPillBody}>
|
||||||
<View style={styles.githubPillIcon}>
|
<View style={styles.githubPillIcon}>
|
||||||
@@ -916,7 +984,7 @@ export function Composer({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
|
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
|
||||||
<MessageInput
|
<StableMessageInput
|
||||||
ref={messageInputRef}
|
ref={messageInputRef}
|
||||||
value={userInput}
|
value={userInput}
|
||||||
onChangeText={setUserInput}
|
onChangeText={setUserInput}
|
||||||
@@ -924,14 +992,13 @@ export function Composer({
|
|||||||
hasExternalContent={hasExternalContent}
|
hasExternalContent={hasExternalContent}
|
||||||
allowEmptySubmit={allowEmptySubmit}
|
allowEmptySubmit={allowEmptySubmit}
|
||||||
submitButtonAccessibilityLabel={submitButtonAccessibilityLabel}
|
submitButtonAccessibilityLabel={submitButtonAccessibilityLabel}
|
||||||
|
submitIcon={submitIcon}
|
||||||
isSubmitDisabled={isProcessing || isSubmitLoading}
|
isSubmitDisabled={isProcessing || isSubmitLoading}
|
||||||
isSubmitLoading={isProcessing || isSubmitLoading}
|
isSubmitLoading={isProcessing || isSubmitLoading}
|
||||||
attachments={selectedAttachments}
|
attachments={selectedAttachments}
|
||||||
cwd={cwd}
|
cwd={cwd}
|
||||||
attachmentMenuItems={attachmentMenuItems}
|
attachmentMenuItems={attachmentMenuItems}
|
||||||
onAttachButtonRef={(node) => {
|
onAttachButtonRef={handleAttachButtonRef}
|
||||||
attachButtonRef.current = node;
|
|
||||||
}}
|
|
||||||
onAddImages={addImages}
|
onAddImages={addImages}
|
||||||
client={client}
|
client={client}
|
||||||
isReadyForDictation={isDictationReady}
|
isReadyForDictation={isDictationReady}
|
||||||
@@ -950,15 +1017,8 @@ export function Composer({
|
|||||||
onQueue={handleQueue}
|
onQueue={handleQueue}
|
||||||
onSubmitLoadingPress={isAgentRunning ? handleCancelAgent : undefined}
|
onSubmitLoadingPress={isAgentRunning ? handleCancelAgent : undefined}
|
||||||
onKeyPress={handleCommandKeyPress}
|
onKeyPress={handleCommandKeyPress}
|
||||||
onSelectionChange={(selection) => {
|
onSelectionChange={handleSelectionChange}
|
||||||
setCursorIndex(selection.start);
|
onFocusChange={handleFocusChange}
|
||||||
}}
|
|
||||||
onFocusChange={(focused) => {
|
|
||||||
setIsMessageInputFocused(focused);
|
|
||||||
if (focused) {
|
|
||||||
onAttentionInputFocus?.();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onHeightChange={onComposerHeightChange}
|
onHeightChange={onComposerHeightChange}
|
||||||
inputWrapperStyle={inputWrapperStyle}
|
inputWrapperStyle={inputWrapperStyle}
|
||||||
/>
|
/>
|
||||||
@@ -1043,6 +1103,9 @@ const styles = StyleSheet.create(((theme: Theme) => ({
|
|||||||
overflow: "visible",
|
overflow: "visible",
|
||||||
padding: theme.spacing[4],
|
padding: theme.spacing[4],
|
||||||
},
|
},
|
||||||
|
inputAreaLocked: {
|
||||||
|
opacity: 0.6,
|
||||||
|
},
|
||||||
inputAreaContent: {
|
inputAreaContent: {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
maxWidth: MAX_CONTENT_WIDTH,
|
maxWidth: MAX_CONTENT_WIDTH,
|
||||||
|
|||||||
@@ -6,11 +6,20 @@ interface DiffStatProps {
|
|||||||
deletions: number;
|
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) {
|
export function DiffStat({ additions, deletions }: DiffStatProps) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
<Text style={styles.additions}>+{additions.toLocaleString()}</Text>
|
<Text style={styles.additions}>+{formatDiffCount(additions)}</Text>
|
||||||
<Text style={styles.deletions}>-{deletions.toLocaleString()}</Text>
|
<Text style={styles.deletions}>-{formatDiffCount(deletions)}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -25,11 +34,11 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
additions: {
|
additions: {
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
fontWeight: theme.fontWeight.normal,
|
fontWeight: theme.fontWeight.normal,
|
||||||
color: theme.colors.palette.green[400],
|
color: theme.colors.diffAddition,
|
||||||
},
|
},
|
||||||
deletions: {
|
deletions: {
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
fontWeight: theme.fontWeight.normal,
|
fontWeight: theme.fontWeight.normal,
|
||||||
color: theme.colors.palette.red[500],
|
color: theme.colors.diffDeletion,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
/**
|
||||||
|
* @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 { DraggableList } from "./draggable-list.web";
|
||||||
|
|
||||||
|
type DndContextProps = {
|
||||||
|
onDragStart?: (event: { active: { id: string } }) => void;
|
||||||
|
onDragCancel?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let latestDndContextProps: DndContextProps | null = null;
|
||||||
|
|
||||||
|
vi.mock("@dnd-kit/core", () => ({
|
||||||
|
DndContext: ({ children, ...props }: React.PropsWithChildren<DndContextProps>) => {
|
||||||
|
latestDndContextProps = props;
|
||||||
|
return <div>{children}</div>;
|
||||||
|
},
|
||||||
|
closestCenter: vi.fn(),
|
||||||
|
KeyboardSensor: vi.fn(),
|
||||||
|
PointerSensor: vi.fn(),
|
||||||
|
useSensor: vi.fn(() => ({})),
|
||||||
|
useSensors: vi.fn(() => []),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@dnd-kit/sortable", () => ({
|
||||||
|
SortableContext: ({ children }: React.PropsWithChildren) => <>{children}</>,
|
||||||
|
arrayMove: <T,>(items: T[], from: number, to: number) => {
|
||||||
|
const next = [...items];
|
||||||
|
const [item] = next.splice(from, 1);
|
||||||
|
if (item !== undefined) {
|
||||||
|
next.splice(to, 0, item);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
},
|
||||||
|
sortableKeyboardCoordinates: vi.fn(),
|
||||||
|
useSortable: () => ({
|
||||||
|
attributes: {},
|
||||||
|
listeners: {},
|
||||||
|
setNodeRef: vi.fn(),
|
||||||
|
setActivatorNodeRef: vi.fn(),
|
||||||
|
transform: null,
|
||||||
|
transition: undefined,
|
||||||
|
isDragging: false,
|
||||||
|
}),
|
||||||
|
verticalListSortingStrategy: {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./use-web-scrollbar", () => ({
|
||||||
|
useWebScrollViewScrollbar: () => ({
|
||||||
|
onLayout: vi.fn(),
|
||||||
|
onContentSizeChange: vi.fn(),
|
||||||
|
onScroll: vi.fn(),
|
||||||
|
overlay: null,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
let root: Root | null = null;
|
||||||
|
let container: HTMLElement | null = null;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal("React", React);
|
||||||
|
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||||
|
vi.stubGlobal(
|
||||||
|
"ResizeObserver",
|
||||||
|
class ResizeObserver {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
latestDndContextProps = null;
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (root) {
|
||||||
|
act(() => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
root = null;
|
||||||
|
container?.remove();
|
||||||
|
container = null;
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderList(): void {
|
||||||
|
act(() => {
|
||||||
|
root?.render(
|
||||||
|
<DraggableList
|
||||||
|
data={["alpha", "beta"]}
|
||||||
|
keyExtractor={(item) => item}
|
||||||
|
onDragEnd={vi.fn()}
|
||||||
|
renderItem={({ item, isActive }) => (
|
||||||
|
<div data-active={String(isActive)} data-testid={`item-${item}`}>
|
||||||
|
{item}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
scrollEnabled={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getItemActiveState(item: string): string | null {
|
||||||
|
return (
|
||||||
|
container?.querySelector(`[data-testid="item-${item}"]`)?.getAttribute("data-active") ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("DraggableList web", () => {
|
||||||
|
it("clears active drag state when a drag is cancelled", () => {
|
||||||
|
renderList();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
latestDndContextProps?.onDragStart?.({ active: { id: "alpha" } });
|
||||||
|
});
|
||||||
|
expect(getItemActiveState("alpha")).toBe("true");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
latestDndContextProps?.onDragCancel?.();
|
||||||
|
});
|
||||||
|
expect(getItemActiveState("alpha")).toBe("false");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -142,7 +142,7 @@ export function DraggableList<T>({
|
|||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, {
|
useSensor(PointerSensor, {
|
||||||
activationConstraint: {
|
activationConstraint: {
|
||||||
distance: 8,
|
distance: 6,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
useSensor(KeyboardSensor, {
|
useSensor(KeyboardSensor, {
|
||||||
@@ -159,12 +159,16 @@ export function DraggableList<T>({
|
|||||||
[data, onDragBegin],
|
[data, onDragBegin],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const clearDragState = useCallback(() => {
|
||||||
|
setActiveId(null);
|
||||||
|
setDragItems(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleDragEnd = useCallback(
|
const handleDragEnd = useCallback(
|
||||||
(event: DragEndEvent) => {
|
(event: DragEndEvent) => {
|
||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
|
|
||||||
setActiveId(null);
|
clearDragState();
|
||||||
setDragItems(null);
|
|
||||||
|
|
||||||
if (over && active.id !== over.id) {
|
if (over && active.id !== over.id) {
|
||||||
const oldIndex = items.findIndex((item, i) => keyExtractor(item, i) === active.id);
|
const oldIndex = items.findIndex((item, i) => keyExtractor(item, i) === active.id);
|
||||||
@@ -176,7 +180,7 @@ export function DraggableList<T>({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[items, keyExtractor, onDragEnd],
|
[clearDragState, items, keyExtractor, onDragEnd],
|
||||||
);
|
);
|
||||||
|
|
||||||
const ids = items.map((item, index) => keyExtractor(item, index));
|
const ids = items.map((item, index) => keyExtractor(item, index));
|
||||||
@@ -207,6 +211,7 @@ export function DraggableList<T>({
|
|||||||
collisionDetection={closestCenter}
|
collisionDetection={closestCenter}
|
||||||
modifiers={[restrictToVerticalAxis]}
|
modifiers={[restrictToVerticalAxis]}
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
|
onDragCancel={clearDragState}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
>
|
>
|
||||||
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
|
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
|
||||||
@@ -237,6 +242,7 @@ export function DraggableList<T>({
|
|||||||
collisionDetection={closestCenter}
|
collisionDetection={closestCenter}
|
||||||
modifiers={[restrictToVerticalAxis]}
|
modifiers={[restrictToVerticalAxis]}
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
|
onDragCancel={clearDragState}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
>
|
>
|
||||||
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
|
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
|
||||||
|
|||||||
@@ -12,8 +12,12 @@ import Animated, { useAnimatedStyle, useSharedValue, runOnJS } from "react-nativ
|
|||||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { X } from "lucide-react-native";
|
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 {
|
import {
|
||||||
usePanelStore,
|
usePanelStore,
|
||||||
|
selectIsFileExplorerOpen,
|
||||||
MIN_EXPLORER_SIDEBAR_WIDTH,
|
MIN_EXPLORER_SIDEBAR_WIDTH,
|
||||||
MAX_EXPLORER_SIDEBAR_WIDTH,
|
MAX_EXPLORER_SIDEBAR_WIDTH,
|
||||||
type ExplorerTab,
|
type ExplorerTab,
|
||||||
@@ -49,9 +53,9 @@ export function ExplorerSidebar({
|
|||||||
const isScreenFocused = useIsFocused();
|
const isScreenFocused = useIsFocused();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const isOpen = usePanelStore((state) => selectIsFileExplorerOpen(state, { isCompact: isMobile }));
|
||||||
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||||
const closeToAgent = usePanelStore((state) => state.closeToAgent);
|
const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer);
|
||||||
const explorerTab = usePanelStore((state) => state.explorerTab);
|
const explorerTab = usePanelStore((state) => state.explorerTab);
|
||||||
const explorerWidth = usePanelStore((state) => state.explorerWidth);
|
const explorerWidth = usePanelStore((state) => state.explorerWidth);
|
||||||
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
||||||
@@ -78,9 +82,6 @@ export function ExplorerSidebar({
|
|||||||
}
|
}
|
||||||
}, [explorerWidth, isMobile, setExplorerWidth, viewportWidth]);
|
}, [explorerWidth, isMobile, setExplorerWidth, viewportWidth]);
|
||||||
|
|
||||||
// Derive isOpen from the unified panel state
|
|
||||||
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
translateX,
|
translateX,
|
||||||
backdropOpacity,
|
backdropOpacity,
|
||||||
@@ -101,18 +102,20 @@ export function ExplorerSidebar({
|
|||||||
logExplorerSidebar("handleClose", {
|
logExplorerSidebar("handleClose", {
|
||||||
reason,
|
reason,
|
||||||
isOpen,
|
isOpen,
|
||||||
mobileView,
|
|
||||||
desktopFileExplorerOpen,
|
|
||||||
});
|
});
|
||||||
closeToAgent();
|
if (isMobile) {
|
||||||
|
showMobileAgent();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closeDesktopFileExplorer();
|
||||||
},
|
},
|
||||||
[closeToAgent, desktopFileExplorerOpen, isOpen, mobileView],
|
[closeDesktopFileExplorer, isMobile, isOpen, showMobileAgent],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCloseFromGesture = useCallback(() => {
|
const handleCloseFromGesture = useCallback(() => {
|
||||||
gestureAnimatingRef.current = true;
|
gestureAnimatingRef.current = true;
|
||||||
closeToAgent();
|
showMobileAgent();
|
||||||
}, [closeToAgent, gestureAnimatingRef]);
|
}, [gestureAnimatingRef, showMobileAgent]);
|
||||||
|
|
||||||
const enableSidebarCloseGesture = isMobile && isOpen;
|
const enableSidebarCloseGesture = isMobile && isOpen;
|
||||||
|
|
||||||
@@ -289,6 +292,7 @@ export function ExplorerSidebar({
|
|||||||
workspaceRoot={workspaceRoot}
|
workspaceRoot={workspaceRoot}
|
||||||
isGit={isGit}
|
isGit={isGit}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
|
isOpen={isOpen}
|
||||||
onOpenFile={onOpenFile}
|
onOpenFile={onOpenFile}
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
@@ -321,6 +325,7 @@ export function ExplorerSidebar({
|
|||||||
workspaceRoot={workspaceRoot}
|
workspaceRoot={workspaceRoot}
|
||||||
isGit={isGit}
|
isGit={isGit}
|
||||||
isMobile={false}
|
isMobile={false}
|
||||||
|
isOpen={isOpen}
|
||||||
onOpenFile={onOpenFile}
|
onOpenFile={onOpenFile}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -337,6 +342,7 @@ interface SidebarContentProps {
|
|||||||
workspaceRoot: string;
|
workspaceRoot: string;
|
||||||
isGit: boolean;
|
isGit: boolean;
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
|
isOpen: boolean;
|
||||||
onOpenFile?: (filePath: string) => void;
|
onOpenFile?: (filePath: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,11 +355,24 @@ function SidebarContent({
|
|||||||
workspaceRoot,
|
workspaceRoot,
|
||||||
isGit,
|
isGit,
|
||||||
isMobile,
|
isMobile,
|
||||||
|
isOpen,
|
||||||
onOpenFile,
|
onOpenFile,
|
||||||
}: SidebarContentProps) {
|
}: SidebarContentProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const padding = useWindowControlsPadding("explorerSidebar");
|
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 (
|
return (
|
||||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||||
@@ -381,6 +400,23 @@ function SidebarContent({
|
|||||||
Files
|
Files
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</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>
|
||||||
<View style={styles.headerRightSection}>
|
<View style={styles.headerRightSection}>
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
@@ -409,6 +445,7 @@ function SidebarContent({
|
|||||||
onOpenFile={onOpenFile}
|
onOpenFile={onOpenFile}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{resolvedTab === "pr" && prPane.data && <PrPane data={prPane.data} />}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,18 +8,8 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Gesture } from "react-native-gesture-handler";
|
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import Animated, {
|
|
||||||
cancelAnimation,
|
|
||||||
Easing,
|
|
||||||
runOnJS,
|
|
||||||
useAnimatedStyle,
|
|
||||||
useSharedValue,
|
|
||||||
withRepeat,
|
|
||||||
withTiming,
|
|
||||||
} from "react-native-reanimated";
|
|
||||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||||
import { Fonts } from "@/constants/theme";
|
import { Fonts } from "@/constants/theme";
|
||||||
import * as Clipboard from "expo-clipboard";
|
import * as Clipboard from "expo-clipboard";
|
||||||
@@ -34,6 +24,7 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { getFileIconSvg } from "@/components/material-file-icons";
|
import { getFileIconSvg } from "@/components/material-file-icons";
|
||||||
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||||
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
|
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
|
||||||
import { useHosts } from "@/runtime/host-runtime";
|
import { useHosts } from "@/runtime/host-runtime";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
@@ -195,35 +186,6 @@ export function FileExplorerPane({
|
|||||||
});
|
});
|
||||||
}, [hasWorkspaceScope, requestDirectoryListing, workspaceStateKey]);
|
}, [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(
|
const handleToggleDirectory = useCallback(
|
||||||
(entry: ExplorerEntry) => {
|
(entry: ExplorerEntry) => {
|
||||||
if (!workspaceStateKey) {
|
if (!workspaceStateKey) {
|
||||||
@@ -341,48 +303,6 @@ export function FileExplorerPane({
|
|||||||
const handleRefresh = useCallback(() => {
|
const handleRefresh = useCallback(() => {
|
||||||
void refetchExplorer();
|
void refetchExplorer();
|
||||||
}, [refetchExplorer]);
|
}, [refetchExplorer]);
|
||||||
const refreshIconRotation = useSharedValue(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isRefreshFetching) {
|
|
||||||
refreshIconRotation.value = 0;
|
|
||||||
refreshIconRotation.value = withRepeat(
|
|
||||||
withTiming(360, {
|
|
||||||
duration: 700,
|
|
||||||
easing: Easing.linear,
|
|
||||||
}),
|
|
||||||
-1,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
cancelAnimation(refreshIconRotation);
|
|
||||||
const remainder = refreshIconRotation.value % 360;
|
|
||||||
if (Math.abs(remainder) < 0.001) {
|
|
||||||
refreshIconRotation.value = 0;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const remaining = 360 - remainder;
|
|
||||||
const duration = Math.max(80, Math.round((remaining / 360) * 700));
|
|
||||||
refreshIconRotation.value = withTiming(
|
|
||||||
360,
|
|
||||||
{
|
|
||||||
duration,
|
|
||||||
easing: Easing.linear,
|
|
||||||
},
|
|
||||||
(finished) => {
|
|
||||||
if (finished) {
|
|
||||||
refreshIconRotation.value = 0;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}, [isRefreshFetching, refreshIconRotation]);
|
|
||||||
|
|
||||||
const refreshIconAnimatedStyle = useAnimatedStyle(() => ({
|
|
||||||
transform: [{ rotate: `${refreshIconRotation.value}deg` }],
|
|
||||||
}));
|
|
||||||
|
|
||||||
const currentSortLabel = SORT_OPTIONS.find((opt) => opt.value === sortOption)?.label ?? "Name";
|
const currentSortLabel = SORT_OPTIONS.find((opt) => opt.value === sortOption)?.label ?? "Name";
|
||||||
|
|
||||||
@@ -592,11 +512,15 @@ export function FileExplorerPane({
|
|||||||
(hovered || pressed) && styles.iconButtonHovered,
|
(hovered || pressed) && styles.iconButtonHovered,
|
||||||
]}
|
]}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel="Refresh files"
|
accessibilityLabel={isRefreshFetching ? "Refreshing files" : "Refresh files"}
|
||||||
>
|
>
|
||||||
<Animated.View style={[styles.refreshIcon, refreshIconAnimatedStyle]}>
|
<View style={styles.refreshIcon}>
|
||||||
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
{isRefreshFetching ? (
|
||||||
</Animated.View>
|
<LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
|
) : (
|
||||||
|
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
<FlatList
|
<FlatList
|
||||||
@@ -682,35 +606,6 @@ function buildTreeRows({
|
|||||||
return rows;
|
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 {
|
function getErrorRecoveryPath(state: AgentFileExplorerState | undefined): string {
|
||||||
if (!state) {
|
if (!state) {
|
||||||
return ".";
|
return ".";
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ import { lineNumberGutterWidth } from "@/components/code-insets";
|
|||||||
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
|
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
||||||
|
import type { AttachmentMetadata } from "@/attachments/types";
|
||||||
|
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||||
|
import { persistAttachmentFromBase64 } from "@/attachments/service";
|
||||||
|
import { createPreviewAttachmentId, getFileNameFromPath } from "@/attachments/utils";
|
||||||
|
|
||||||
interface CodeLineProps {
|
interface CodeLineProps {
|
||||||
tokens: HighlightToken[];
|
tokens: HighlightToken[];
|
||||||
@@ -40,6 +44,7 @@ interface FilePreviewBodyProps {
|
|||||||
showDesktopWebScrollbar: boolean;
|
showDesktopWebScrollbar: boolean;
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
filePath: string;
|
filePath: string;
|
||||||
|
imagePreviewUri: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||||
@@ -60,6 +65,32 @@ function formatFileSize({ size }: { size: number }): string {
|
|||||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createFilePanePreview(file: ExplorerFile | null): Promise<{
|
||||||
|
file: ExplorerFile | null;
|
||||||
|
imageAttachment: AttachmentMetadata | null;
|
||||||
|
}> {
|
||||||
|
if (!file || file.kind !== "image" || !file.content) {
|
||||||
|
return { file, imageAttachment: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { content: _content, ...imageFile } = file;
|
||||||
|
const imageAttachment = await persistAttachmentFromBase64({
|
||||||
|
id: createPreviewAttachmentId({
|
||||||
|
base64: file.content,
|
||||||
|
mimeType: file.mimeType ?? "image/png",
|
||||||
|
path: file.path,
|
||||||
|
}),
|
||||||
|
base64: file.content,
|
||||||
|
mimeType: file.mimeType,
|
||||||
|
fileName: getFileNameFromPath(file.path),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
file: imageFile,
|
||||||
|
imageAttachment,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const CodeLine = React.memo(function CodeLine({
|
const CodeLine = React.memo(function CodeLine({
|
||||||
tokens,
|
tokens,
|
||||||
lineNumber,
|
lineNumber,
|
||||||
@@ -116,6 +147,7 @@ function FilePreviewBody({
|
|||||||
showDesktopWebScrollbar,
|
showDesktopWebScrollbar,
|
||||||
isMobile,
|
isMobile,
|
||||||
filePath,
|
filePath,
|
||||||
|
imagePreviewUri,
|
||||||
}: FilePreviewBodyProps) {
|
}: FilePreviewBodyProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isDark = theme.colorScheme === "dark";
|
const isDark = theme.colorScheme === "dark";
|
||||||
@@ -230,7 +262,16 @@ function FilePreviewBody({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (preview.kind === "image" && preview.content) {
|
if (preview.kind === "image") {
|
||||||
|
if (!imagePreviewUri) {
|
||||||
|
return (
|
||||||
|
<View style={styles.centerState}>
|
||||||
|
<ActivityIndicator size="small" />
|
||||||
|
<Text style={styles.loadingText}>Loading file…</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.previewScrollContainer}>
|
<View style={styles.previewScrollContainer}>
|
||||||
<RNScrollView
|
<RNScrollView
|
||||||
@@ -245,7 +286,7 @@ function FilePreviewBody({
|
|||||||
>
|
>
|
||||||
<RNImage
|
<RNImage
|
||||||
source={{
|
source={{
|
||||||
uri: `data:${preview.mimeType ?? "image/png"};base64,${preview.content}`,
|
uri: imagePreviewUri,
|
||||||
}}
|
}}
|
||||||
style={styles.previewImage}
|
style={styles.previewImage}
|
||||||
resizeMode="contain"
|
resizeMode="contain"
|
||||||
@@ -292,11 +333,17 @@ export function FilePane({
|
|||||||
normalizedFilePath,
|
normalizedFilePath,
|
||||||
"file",
|
"file",
|
||||||
);
|
);
|
||||||
return { file: payload.file ?? null, error: payload.error ?? null };
|
const preview = await createFilePanePreview(payload.file ?? null);
|
||||||
|
return {
|
||||||
|
file: preview.file,
|
||||||
|
imageAttachment: preview.imageAttachment,
|
||||||
|
error: payload.error ?? null,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
staleTime: 5_000,
|
staleTime: 5_000,
|
||||||
refetchOnMount: true,
|
refetchOnMount: true,
|
||||||
});
|
});
|
||||||
|
const imagePreviewUri = useAttachmentPreviewUrl(query.data?.imageAttachment ?? null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container} testID="workspace-file-pane">
|
<View style={styles.container} testID="workspace-file-pane">
|
||||||
@@ -312,6 +359,7 @@ export function FilePane({
|
|||||||
showDesktopWebScrollbar={showDesktopWebScrollbar}
|
showDesktopWebScrollbar={showDesktopWebScrollbar}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
filePath={filePath}
|
filePath={filePath}
|
||||||
|
imagePreviewUri={imagePreviewUri}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,9 +16,10 @@ import type { GitAction, GitActions } from "@/components/git-actions-policy";
|
|||||||
|
|
||||||
interface GitActionsSplitButtonProps {
|
interface GitActionsSplitButtonProps {
|
||||||
gitActions: GitActions;
|
gitActions: GitActions;
|
||||||
|
hideLabels?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps) {
|
export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSplitButtonProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
|
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
|
||||||
@@ -68,9 +69,11 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
|
|||||||
) : (
|
) : (
|
||||||
<View style={styles.splitButtonContent}>
|
<View style={styles.splitButtonContent}>
|
||||||
{gitActions.primary.icon}
|
{gitActions.primary.icon}
|
||||||
<Text style={styles.splitButtonText}>
|
{!hideLabels && (
|
||||||
{getActionDisplayLabel(gitActions.primary)}
|
<Text style={styles.splitButtonText}>
|
||||||
</Text>
|
{getActionDisplayLabel(gitActions.primary)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|||||||
@@ -661,10 +661,8 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
const {
|
const {
|
||||||
status,
|
status,
|
||||||
isLoading: isStatusLoading,
|
isLoading: isStatusLoading,
|
||||||
isFetching: isStatusFetching,
|
|
||||||
isError: isStatusError,
|
isError: isStatusError,
|
||||||
error: statusError,
|
error: statusError,
|
||||||
refresh: refreshStatus,
|
|
||||||
} = useCheckoutStatusQuery({ serverId, cwd });
|
} = useCheckoutStatusQuery({ serverId, cwd });
|
||||||
const gitStatus = status && status.isGit ? status : null;
|
const gitStatus = status && status.isGit ? status : null;
|
||||||
const isGit = Boolean(gitStatus);
|
const isGit = Boolean(gitStatus);
|
||||||
@@ -683,10 +681,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
files,
|
files,
|
||||||
payloadError: diffPayloadError,
|
payloadError: diffPayloadError,
|
||||||
isLoading: isDiffLoading,
|
isLoading: isDiffLoading,
|
||||||
isFetching: isDiffFetching,
|
|
||||||
isError: isDiffError,
|
|
||||||
error: diffError,
|
|
||||||
refresh: refreshDiff,
|
|
||||||
} = useCheckoutDiffQuery({
|
} = useCheckoutDiffQuery({
|
||||||
serverId,
|
serverId,
|
||||||
cwd,
|
cwd,
|
||||||
@@ -699,14 +693,11 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
status: prStatus,
|
status: prStatus,
|
||||||
githubFeaturesEnabled,
|
githubFeaturesEnabled,
|
||||||
payloadError: prPayloadError,
|
payloadError: prPayloadError,
|
||||||
refresh: refreshPrStatus,
|
|
||||||
} = useCheckoutPrStatusQuery({
|
} = useCheckoutPrStatusQuery({
|
||||||
serverId,
|
serverId,
|
||||||
cwd,
|
cwd,
|
||||||
enabled: isGit,
|
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 normalizedWorkspaceRoot = useMemo(() => cwd.trim(), [cwd]);
|
||||||
const workspaceStateKey = useMemo(
|
const workspaceStateKey = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -732,13 +723,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
const headerHeightByPathRef = useRef<Record<string, number>>({});
|
const headerHeightByPathRef = useRef<Record<string, number>>({});
|
||||||
const bodyHeightByPathRef = useRef<Record<string, number>>({});
|
const bodyHeightByPathRef = useRef<Record<string, number>>({});
|
||||||
const defaultHeaderHeightRef = useRef<number>(44);
|
const defaultHeaderHeightRef = useRef<number>(44);
|
||||||
const handleRefresh = useCallback(() => {
|
|
||||||
setIsManualRefresh(true);
|
|
||||||
void refreshDiff();
|
|
||||||
void refreshStatus();
|
|
||||||
void refreshPrStatus();
|
|
||||||
}, [refreshDiff, refreshStatus, refreshPrStatus]);
|
|
||||||
|
|
||||||
const shipDefaultStorageKey = useMemo(() => {
|
const shipDefaultStorageKey = useMemo(() => {
|
||||||
if (!gitStatus?.repoRoot) {
|
if (!gitStatus?.repoRoot) {
|
||||||
return null;
|
return null;
|
||||||
@@ -901,13 +885,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
}
|
}
|
||||||
}, [allExpanded, files, setDiffExpandedPathsForWorkspace, workspaceStateKey]);
|
}, [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)
|
// Clear diff mode override when auto mode changes (e.g., after commit)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDiffModeOverride(null);
|
setDiffModeOverride(null);
|
||||||
@@ -1092,9 +1069,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
);
|
);
|
||||||
|
|
||||||
const hasChanges = files.length > 0;
|
const hasChanges = files.length > 0;
|
||||||
const diffErrorMessage =
|
const diffErrorMessage = diffPayloadError?.message ?? null;
|
||||||
diffPayloadError?.message ??
|
|
||||||
(isDiffError && diffError instanceof Error ? diffError.message : null);
|
|
||||||
const prErrorMessage = githubFeaturesEnabled ? (prPayloadError?.message ?? null) : null;
|
const prErrorMessage = githubFeaturesEnabled ? (prPayloadError?.message ?? null) : null;
|
||||||
const branchLabel =
|
const branchLabel =
|
||||||
gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD"
|
gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD"
|
||||||
@@ -1199,8 +1174,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
onContentSizeChange={scrollbar.onContentSizeChange}
|
onContentSizeChange={scrollbar.onContentSizeChange}
|
||||||
scrollEventThrottle={16}
|
scrollEventThrottle={16}
|
||||||
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||||
onRefresh={handleRefresh}
|
|
||||||
refreshing={isManualRefresh && isDiffFetching}
|
|
||||||
// Mixed-height rows (header + potentially very large body) are prone to clipping artifacts.
|
// 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.
|
// Keep a larger render window and disable clipping to avoid bodies disappearing mid-scroll.
|
||||||
removeClippedSubviews={false}
|
removeClippedSubviews={false}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { PanelLeft } from "lucide-react-native";
|
|||||||
import { ScreenHeader } from "./screen-header";
|
import { ScreenHeader } from "./screen-header";
|
||||||
import { ScreenTitle } from "./screen-title";
|
import { ScreenTitle } from "./screen-title";
|
||||||
import { HeaderToggleButton } from "./header-toggle-button";
|
import { HeaderToggleButton } from "./header-toggle-button";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||||
|
|
||||||
@@ -46,18 +46,16 @@ export function SidebarMenuToggle({
|
|||||||
}: SidebarMenuToggleProps = {}) {
|
}: SidebarMenuToggleProps = {}) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const isOpen = usePanelStore((state) => selectIsAgentListOpen(state, { isCompact: isMobile }));
|
||||||
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
const toggleAgentListForLayout = usePanelStore((state) => state.toggleAgentListForLayout);
|
||||||
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
|
|
||||||
const toggleShortcutKeys = getShortcutOs() === "mac" ? ["mod", "B"] : ["mod", "."];
|
const toggleShortcutKeys = getShortcutOs() === "mac" ? ["mod", "B"] : ["mod", "."];
|
||||||
|
|
||||||
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
|
|
||||||
const menuIconColor =
|
const menuIconColor =
|
||||||
!isMobile && isOpen ? theme.colors.foreground : theme.colors.foregroundMuted;
|
!isMobile && isOpen ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HeaderToggleButton
|
<HeaderToggleButton
|
||||||
onPress={toggleAgentList}
|
onPress={() => toggleAgentListForLayout({ isCompact: isMobile })}
|
||||||
tooltipLabel="Toggle sidebar"
|
tooltipLabel="Toggle sidebar"
|
||||||
tooltipKeys={toggleShortcutKeys}
|
tooltipKeys={toggleShortcutKeys}
|
||||||
tooltipSide={tooltipSide}
|
tooltipSide={tooltipSide}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import type { LayoutChangeEvent } from "react-native";
|
||||||
import { View, type StyleProp, type ViewStyle } from "react-native";
|
import { View, type StyleProp, type ViewStyle } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
@@ -17,6 +18,8 @@ interface ScreenHeaderProps {
|
|||||||
leftStyle?: StyleProp<ViewStyle>;
|
leftStyle?: StyleProp<ViewStyle>;
|
||||||
rightStyle?: StyleProp<ViewStyle>;
|
rightStyle?: StyleProp<ViewStyle>;
|
||||||
borderless?: boolean;
|
borderless?: boolean;
|
||||||
|
windowControlsPaddingRole?: "header" | "detailHeader";
|
||||||
|
onRowLayout?: (event: LayoutChangeEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,11 +32,13 @@ export function ScreenHeader({
|
|||||||
leftStyle,
|
leftStyle,
|
||||||
rightStyle,
|
rightStyle,
|
||||||
borderless,
|
borderless,
|
||||||
|
windowControlsPaddingRole = "header",
|
||||||
|
onRowLayout,
|
||||||
}: ScreenHeaderProps) {
|
}: ScreenHeaderProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
const padding = useWindowControlsPadding("header");
|
const padding = useWindowControlsPadding(windowControlsPaddingRole);
|
||||||
// Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets
|
// Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets
|
||||||
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
|
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
|
||||||
const baseHorizontalPadding = theme.spacing[2];
|
const baseHorizontalPadding = theme.spacing[2];
|
||||||
@@ -42,6 +47,7 @@ export function ScreenHeader({
|
|||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<View style={[styles.inner, { paddingTop: insets.top + topPadding }]}>
|
<View style={[styles.inner, { paddingTop: insets.top + topPadding }]}>
|
||||||
<View
|
<View
|
||||||
|
onLayout={onRowLayout}
|
||||||
style={[
|
style={[
|
||||||
styles.row,
|
styles.row,
|
||||||
{
|
{
|
||||||
@@ -84,11 +90,13 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
|
minWidth: 0,
|
||||||
},
|
},
|
||||||
right: {
|
right: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
|
flexShrink: 0,
|
||||||
},
|
},
|
||||||
borderless: {
|
borderless: {
|
||||||
borderBottomColor: "transparent",
|
borderBottomColor: "transparent",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export function ScreenTitle({ children, numberOfLines = 1, testID, style }: Scre
|
|||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
text: {
|
text: {
|
||||||
flexShrink: 1,
|
flexShrink: 1,
|
||||||
|
minWidth: 0,
|
||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.base,
|
||||||
fontWeight: {
|
fontWeight: {
|
||||||
xs: "400",
|
xs: "400",
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { useHostRuntimeBootstrapState, useStoreReady } from "@/app/_layout";
|
import { useHostRuntimeBootstrapState, useStoreReady } from "@/app/_layout";
|
||||||
|
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||||
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
|
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
|
||||||
|
|
||||||
export function HostRouteBootstrapBoundary({ children }: { children: ReactNode }) {
|
export function HostRouteBootstrapBoundary({ children }: { children: ReactNode }) {
|
||||||
const storeReady = useStoreReady();
|
const storeReady = useStoreReady();
|
||||||
const bootstrapState = useHostRuntimeBootstrapState();
|
const bootstrapState = useHostRuntimeBootstrapState();
|
||||||
|
const isDesktop = shouldUseDesktopDaemon();
|
||||||
|
|
||||||
if (!storeReady) {
|
if (!storeReady) {
|
||||||
return <StartupSplashScreen bootstrapState={bootstrapState} />;
|
return <StartupSplashScreen bootstrapState={isDesktop ? bootstrapState : undefined} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*/
|
||||||
|
import React from "react";
|
||||||
|
import { act } from "@testing-library/react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const { panelState, useSidebarWorkspacesListMock, theme } = vi.hoisted(() => {
|
||||||
|
const panelState = {
|
||||||
|
isOpen: false,
|
||||||
|
showMobileAgent: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
panelState,
|
||||||
|
useSidebarWorkspacesListMock: vi.fn(),
|
||||||
|
theme: {
|
||||||
|
spacing: { 0: 0, 0.5: 2, 1: 4, 1.5: 6, 2: 8, 3: 12, 4: 16, 5: 20 },
|
||||||
|
iconSize: { sm: 14, md: 18, lg: 22 },
|
||||||
|
borderWidth: { 1: 1 },
|
||||||
|
borderRadius: { sm: 4, md: 6, lg: 8, full: 999 },
|
||||||
|
fontSize: { xs: 11, sm: 13, base: 15 },
|
||||||
|
fontWeight: { normal: "400", medium: "500", semibold: "600" },
|
||||||
|
colors: {
|
||||||
|
surfaceSidebar: "#111",
|
||||||
|
surface1: "#111",
|
||||||
|
surface2: "#222",
|
||||||
|
surface3: "#333",
|
||||||
|
surface4: "#444",
|
||||||
|
foreground: "#fff",
|
||||||
|
foregroundMuted: "#aaa",
|
||||||
|
border: "#555",
|
||||||
|
borderAccent: "#666",
|
||||||
|
accent: "#0a84ff",
|
||||||
|
accentForeground: "#fff",
|
||||||
|
palette: {
|
||||||
|
green: { 400: "#30d158" },
|
||||||
|
amber: { 500: "#ffd60a" },
|
||||||
|
red: { 500: "#ff453a" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("react-native-unistyles", () => ({
|
||||||
|
StyleSheet: {
|
||||||
|
absoluteFillObject: {},
|
||||||
|
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
|
||||||
|
},
|
||||||
|
useUnistyles: () => ({ theme }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-native-reanimated", () => ({
|
||||||
|
default: {
|
||||||
|
View: "div",
|
||||||
|
},
|
||||||
|
Extrapolation: { CLAMP: "clamp" },
|
||||||
|
interpolate: () => 0,
|
||||||
|
runOnJS: (fn: (...args: unknown[]) => unknown) => fn,
|
||||||
|
useAnimatedStyle: (factory: () => unknown) => factory(),
|
||||||
|
useSharedValue: (value: unknown) => ({ value }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-native-gesture-handler", () => {
|
||||||
|
const chain = {
|
||||||
|
enabled: () => chain,
|
||||||
|
hitSlop: () => chain,
|
||||||
|
manualActivation: () => chain,
|
||||||
|
onTouchesDown: () => chain,
|
||||||
|
onTouchesMove: () => chain,
|
||||||
|
onStart: () => chain,
|
||||||
|
onUpdate: () => chain,
|
||||||
|
onEnd: () => chain,
|
||||||
|
onFinalize: () => chain,
|
||||||
|
withRef: () => chain,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
Gesture: { Pan: () => chain },
|
||||||
|
GestureDetector: ({ children }: { children: React.ReactNode }) => children,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("lucide-react-native", () => {
|
||||||
|
const createIcon = (name: string) => (props: Record<string, unknown>) =>
|
||||||
|
React.createElement("span", { ...props, "data-icon": name });
|
||||||
|
return {
|
||||||
|
MessagesSquare: createIcon("MessagesSquare"),
|
||||||
|
Plus: createIcon("Plus"),
|
||||||
|
Settings: createIcon("Settings"),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("expo-router", () => ({
|
||||||
|
router: { push: vi.fn() },
|
||||||
|
usePathname: () => "/hosts/srv",
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-native-safe-area-context", () => ({
|
||||||
|
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/constants/layout", () => ({
|
||||||
|
useIsCompactFormFactor: () => true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/constants/platform", () => ({
|
||||||
|
isWeb: true,
|
||||||
|
isNative: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/stores/panel-store", () => ({
|
||||||
|
MIN_SIDEBAR_WIDTH: 260,
|
||||||
|
MAX_SIDEBAR_WIDTH: 420,
|
||||||
|
selectIsAgentListOpen: (state: typeof panelState) => state.isOpen,
|
||||||
|
usePanelStore: (selector: (state: typeof panelState) => unknown) => selector(panelState),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/runtime/host-runtime", () => ({
|
||||||
|
useHosts: () => [{ serverId: "srv", label: "Local" }],
|
||||||
|
useHostRuntimeSnapshot: () => ({ connectionStatus: "online" }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-sidebar-workspaces-list", () => ({
|
||||||
|
useSidebarWorkspacesList: useSidebarWorkspacesListMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-sidebar-shortcut-model", () => ({
|
||||||
|
useSidebarShortcutModel: () => ({
|
||||||
|
collapsedProjectKeys: new Set<string>(),
|
||||||
|
shortcutIndexByWorkspaceKey: new Map<string, number>(),
|
||||||
|
toggleProjectCollapsed: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/contexts/sidebar-animation-context", () => ({
|
||||||
|
useSidebarAnimation: () => ({
|
||||||
|
translateX: { value: 0 },
|
||||||
|
backdropOpacity: { value: 0 },
|
||||||
|
windowWidth: 390,
|
||||||
|
animateToOpen: vi.fn(),
|
||||||
|
animateToClose: vi.fn(),
|
||||||
|
isGesturing: { value: false },
|
||||||
|
gestureAnimatingRef: { current: false },
|
||||||
|
closeGestureRef: { current: undefined },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-shortcut-keys", () => ({
|
||||||
|
useShortcutKeys: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/utils/desktop-window", () => ({
|
||||||
|
useWindowControlsPadding: () => ({ top: 0 }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/utils/host-routes", () => ({
|
||||||
|
buildHostSessionsRoute: (serverId: string) => `/hosts/${serverId}/sessions`,
|
||||||
|
buildSettingsRoute: () => "/settings",
|
||||||
|
mapPathnameToServer: (_pathname: string, serverId: string) => `/hosts/${serverId}`,
|
||||||
|
parseServerIdFromPathname: () => "srv",
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-open-project-picker", () => ({
|
||||||
|
useOpenProjectPicker: () => vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/sidebar/sidebar-header-row", () => ({
|
||||||
|
SidebarHeaderRow: ({ label }: { label: string }) => React.createElement("div", null, label),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./sidebar-workspace-list", () => ({
|
||||||
|
SidebarWorkspaceList: ({ projects }: { projects: Array<{ projectName: string }> }) =>
|
||||||
|
React.createElement(
|
||||||
|
"div",
|
||||||
|
{ "data-testid": "sidebar-workspace-list" },
|
||||||
|
projects.map((project) => project.projectName).join(","),
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./sidebar-agent-list-skeleton", () => ({
|
||||||
|
SidebarAgentListSkeleton: () => React.createElement("div", null, "Loading"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/ui/tooltip", () => ({
|
||||||
|
Tooltip: ({ children }: { children: React.ReactNode }) =>
|
||||||
|
React.createElement("div", null, children),
|
||||||
|
TooltipContent: ({ children }: { children: React.ReactNode }) =>
|
||||||
|
React.createElement("div", null, children),
|
||||||
|
TooltipTrigger: ({ children }: { children: React.ReactNode }) =>
|
||||||
|
React.createElement("div", null, children),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/ui/shortcut", () => ({
|
||||||
|
Shortcut: () => React.createElement("span", null),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/ui/combobox", () => ({
|
||||||
|
Combobox: () => null,
|
||||||
|
ComboboxItem: ({ label }: { label: string }) => React.createElement("div", null, label),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.stubGlobal("React", React);
|
||||||
|
|
||||||
|
import { LeftSidebar } from "./left-sidebar";
|
||||||
|
|
||||||
|
describe("LeftSidebar", () => {
|
||||||
|
let root: Root | null = null;
|
||||||
|
let container: HTMLElement | null = null;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
panelState.isOpen = false;
|
||||||
|
panelState.showMobileAgent.mockReset();
|
||||||
|
useSidebarWorkspacesListMock.mockReset();
|
||||||
|
useSidebarWorkspacesListMock.mockReturnValue({
|
||||||
|
projects: [{ projectKey: "project-1", projectName: "Project 1", workspaces: [] }],
|
||||||
|
isInitialLoad: false,
|
||||||
|
isRevalidating: false,
|
||||||
|
refreshAll: vi.fn(),
|
||||||
|
});
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (root) {
|
||||||
|
act(() => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
root = null;
|
||||||
|
container?.remove();
|
||||||
|
container = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the mobile workspace list subscribed while the sidebar is hidden", async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(<LeftSidebar />);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(useSidebarWorkspacesListMock).toHaveBeenLastCalledWith({
|
||||||
|
serverId: "srv",
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -32,7 +32,12 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
|
|||||||
import { Shortcut } from "@/components/ui/shortcut";
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||||
import { router, usePathname } from "expo-router";
|
import { router, usePathname } from "expo-router";
|
||||||
import { usePanelStore, MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH } from "@/stores/panel-store";
|
import {
|
||||||
|
usePanelStore,
|
||||||
|
selectIsAgentListOpen,
|
||||||
|
MIN_SIDEBAR_WIDTH,
|
||||||
|
MAX_SIDEBAR_WIDTH,
|
||||||
|
} from "@/stores/panel-store";
|
||||||
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
|
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
|
||||||
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
|
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
|
||||||
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
||||||
@@ -52,10 +57,10 @@ import {
|
|||||||
buildHostSessionsRoute,
|
buildHostSessionsRoute,
|
||||||
buildSettingsRoute,
|
buildSettingsRoute,
|
||||||
mapPathnameToServer,
|
mapPathnameToServer,
|
||||||
parseServerIdFromPathname,
|
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
|
import { resolveActiveHost } from "@/utils/active-host";
|
||||||
|
|
||||||
const MIN_CHAT_WIDTH = 400;
|
const MIN_CHAT_WIDTH = 400;
|
||||||
|
|
||||||
@@ -116,24 +121,16 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const isCompactLayout = useIsCompactFormFactor();
|
const isCompactLayout = useIsCompactFormFactor();
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const isOpen = usePanelStore((state) =>
|
||||||
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
selectIsAgentListOpen(state, { isCompact: isCompactLayout }),
|
||||||
const closeToAgent = usePanelStore((state) => state.closeToAgent);
|
);
|
||||||
|
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
const activeServerIdFromPath = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
|
const activeDaemon = useMemo(
|
||||||
const activeDaemon = useMemo(() => {
|
() => resolveActiveHost({ hosts: daemons, pathname }),
|
||||||
if (daemons.length === 0) {
|
[daemons, pathname],
|
||||||
return null;
|
);
|
||||||
}
|
|
||||||
if (activeServerIdFromPath) {
|
|
||||||
const routeMatch = daemons.find((entry) => entry.serverId === activeServerIdFromPath);
|
|
||||||
if (routeMatch) {
|
|
||||||
return routeMatch;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return daemons[0] ?? null;
|
|
||||||
}, [activeServerIdFromPath, daemons]);
|
|
||||||
const activeServerId = activeDaemon?.serverId ?? null;
|
const activeServerId = activeDaemon?.serverId ?? null;
|
||||||
const activeHostLabel = useMemo(() => {
|
const activeHostLabel = useMemo(() => {
|
||||||
if (!activeDaemon) return "No host";
|
if (!activeDaemon) return "No host";
|
||||||
@@ -183,14 +180,12 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|||||||
const hostTriggerRef = useRef<View | null>(null);
|
const hostTriggerRef = useRef<View | null>(null);
|
||||||
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false);
|
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false);
|
||||||
|
|
||||||
const isOpen = isCompactLayout ? mobileView === "agent-list" : desktopAgentListOpen;
|
|
||||||
|
|
||||||
const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarWorkspacesList({
|
const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarWorkspacesList({
|
||||||
serverId: activeServerId,
|
serverId: activeServerId,
|
||||||
enabled: isOpen,
|
enabled: isCompactLayout || isOpen,
|
||||||
});
|
});
|
||||||
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } =
|
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } =
|
||||||
useSidebarShortcutModel(projects);
|
useSidebarShortcutModel({ projects, isInitialLoad });
|
||||||
|
|
||||||
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
||||||
|
|
||||||
@@ -208,18 +203,18 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|||||||
const openProjectPicker = useOpenProjectPicker(activeServerId);
|
const openProjectPicker = useOpenProjectPicker(activeServerId);
|
||||||
|
|
||||||
const handleOpenProjectMobile = useCallback(() => {
|
const handleOpenProjectMobile = useCallback(() => {
|
||||||
closeToAgent();
|
showMobileAgent();
|
||||||
void openProjectPicker();
|
void openProjectPicker();
|
||||||
}, [closeToAgent, openProjectPicker]);
|
}, [showMobileAgent, openProjectPicker]);
|
||||||
|
|
||||||
const handleOpenProjectDesktop = useCallback(() => {
|
const handleOpenProjectDesktop = useCallback(() => {
|
||||||
void openProjectPicker();
|
void openProjectPicker();
|
||||||
}, [openProjectPicker]);
|
}, [openProjectPicker]);
|
||||||
|
|
||||||
const handleSettingsMobile = useCallback(() => {
|
const handleSettingsMobile = useCallback(() => {
|
||||||
closeToAgent();
|
showMobileAgent();
|
||||||
router.push(buildSettingsRoute());
|
router.push(buildSettingsRoute());
|
||||||
}, [closeToAgent]);
|
}, [showMobileAgent]);
|
||||||
|
|
||||||
const handleSettingsDesktop = useCallback(() => {
|
const handleSettingsDesktop = useCallback(() => {
|
||||||
router.push(buildSettingsRoute());
|
router.push(buildSettingsRoute());
|
||||||
@@ -272,7 +267,7 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|||||||
insetsTop={insets.top}
|
insetsTop={insets.top}
|
||||||
insetsBottom={insets.bottom}
|
insetsBottom={insets.bottom}
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
closeToAgent={closeToAgent}
|
closeToAgent={showMobileAgent}
|
||||||
handleOpenProject={handleOpenProjectMobile}
|
handleOpenProject={handleOpenProjectMobile}
|
||||||
handleSettings={handleSettingsMobile}
|
handleSettings={handleSettingsMobile}
|
||||||
handleViewMoreNavigate={handleViewMoreNavigate}
|
handleViewMoreNavigate={handleViewMoreNavigate}
|
||||||
@@ -384,6 +379,10 @@ function MobileSidebar({
|
|||||||
windowWidth,
|
windowWidth,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const handleWorkspacePress = useCallback(() => {
|
||||||
|
closeToAgent();
|
||||||
|
}, [closeToAgent]);
|
||||||
|
|
||||||
const closeGesture = useMemo(
|
const closeGesture = useMemo(
|
||||||
() =>
|
() =>
|
||||||
Gesture.Pan()
|
Gesture.Pan()
|
||||||
@@ -518,7 +517,7 @@ function MobileSidebar({
|
|||||||
projects={projects}
|
projects={projects}
|
||||||
isRefreshing={isManualRefresh && isRevalidating}
|
isRefreshing={isManualRefresh && isRevalidating}
|
||||||
onRefresh={handleRefresh}
|
onRefresh={handleRefresh}
|
||||||
onWorkspacePress={() => closeToAgent()}
|
onWorkspacePress={handleWorkspacePress}
|
||||||
onAddProject={handleOpenProject}
|
onAddProject={handleOpenProject}
|
||||||
parentGestureRef={closeGestureRef}
|
parentGestureRef={closeGestureRef}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import React from "react";
|
import React, { createRef } from "react";
|
||||||
import { act } from "react";
|
import { act } from "react";
|
||||||
import { createRoot, type Root } from "react-dom/client";
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
import { JSDOM } from "jsdom";
|
import { JSDOM } from "jsdom";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { MessageInput, type AttachmentMenuItem } from "./message-input";
|
import { MessageInput, type AttachmentMenuItem, type MessageInputRef } from "./message-input";
|
||||||
|
|
||||||
|
const { startDictationMock, cancelDictationMock, confirmDictationMock } = vi.hoisted(() => ({
|
||||||
|
startDictationMock: vi.fn(),
|
||||||
|
cancelDictationMock: vi.fn(),
|
||||||
|
confirmDictationMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
const { theme } = vi.hoisted(() => ({
|
const { theme } = vi.hoisted(() => ({
|
||||||
theme: {
|
theme: {
|
||||||
@@ -49,9 +55,10 @@ vi.mock("lucide-react-native", () => {
|
|||||||
const createIcon = (name: string) => (props: Record<string, unknown>) =>
|
const createIcon = (name: string) => (props: Record<string, unknown>) =>
|
||||||
React.createElement("span", { ...props, "data-icon": name });
|
React.createElement("span", { ...props, "data-icon": name });
|
||||||
return {
|
return {
|
||||||
|
ArrowUp: createIcon("ArrowUp"),
|
||||||
Mic: createIcon("Mic"),
|
Mic: createIcon("Mic"),
|
||||||
MicOff: createIcon("MicOff"),
|
MicOff: createIcon("MicOff"),
|
||||||
ArrowUp: createIcon("ArrowUp"),
|
CornerDownLeft: createIcon("CornerDownLeft"),
|
||||||
Plus: createIcon("Plus"),
|
Plus: createIcon("Plus"),
|
||||||
Square: createIcon("Square"),
|
Square: createIcon("Square"),
|
||||||
};
|
};
|
||||||
@@ -84,9 +91,9 @@ vi.mock("@/hooks/use-dictation", () => ({
|
|||||||
duration: 0,
|
duration: 0,
|
||||||
error: null,
|
error: null,
|
||||||
status: "idle",
|
status: "idle",
|
||||||
startDictation: vi.fn(),
|
startDictation: startDictationMock,
|
||||||
cancelDictation: vi.fn(),
|
cancelDictation: cancelDictationMock,
|
||||||
confirmDictation: vi.fn(),
|
confirmDictation: confirmDictationMock,
|
||||||
retryFailedDictation: vi.fn(),
|
retryFailedDictation: vi.fn(),
|
||||||
discardFailedDictation: vi.fn(),
|
discardFailedDictation: vi.fn(),
|
||||||
}),
|
}),
|
||||||
@@ -213,6 +220,9 @@ beforeEach(() => {
|
|||||||
container = document.createElement("div");
|
container = document.createElement("div");
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
root = createRoot(container);
|
root = createRoot(container);
|
||||||
|
startDictationMock.mockReset();
|
||||||
|
cancelDictationMock.mockReset();
|
||||||
|
confirmDictationMock.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -226,18 +236,27 @@ afterEach(() => {
|
|||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
function renderMessageInput(menuItems: AttachmentMenuItem[]) {
|
interface RenderMessageInputOptions {
|
||||||
|
value?: string;
|
||||||
|
submitIcon?: "arrow" | "return";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMessageInput(
|
||||||
|
menuItems: AttachmentMenuItem[],
|
||||||
|
{ value = "", submitIcon }: RenderMessageInputOptions = {},
|
||||||
|
) {
|
||||||
act(() => {
|
act(() => {
|
||||||
root?.render(
|
root?.render(
|
||||||
<MessageInput
|
<MessageInput
|
||||||
value=""
|
value={value}
|
||||||
onChangeText={vi.fn()}
|
onChangeText={vi.fn()}
|
||||||
onSubmit={vi.fn()}
|
onSubmit={vi.fn()}
|
||||||
attachments={[]}
|
attachments={[]}
|
||||||
cwd="/repo"
|
cwd="/repo"
|
||||||
attachmentMenuItems={menuItems}
|
attachmentMenuItems={menuItems}
|
||||||
client={{ isConnected: true } as never}
|
client={{ isConnected: true } as never}
|
||||||
isAgentRunning
|
isAgentRunning={false}
|
||||||
|
submitIcon={submitIcon}
|
||||||
onQueue={vi.fn()}
|
onQueue={vi.fn()}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
@@ -302,4 +321,70 @@ describe("MessageInput attachments", () => {
|
|||||||
|
|
||||||
expect(queryAllByAriaLabel("Queue message")).toHaveLength(0);
|
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,
|
forwardRef,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
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 Animated, { useSharedValue, useAnimatedStyle, withTiming } from "react-native-reanimated";
|
||||||
import { useDictation } from "@/hooks/use-dictation";
|
import { useDictation } from "@/hooks/use-dictation";
|
||||||
import { DictationOverlay } from "./dictation-controls";
|
import { DictationOverlay } from "./dictation-controls";
|
||||||
@@ -51,11 +51,9 @@ import { formatShortcut } from "@/utils/format-shortcut";
|
|||||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||||
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
|
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
|
||||||
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
|
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
|
||||||
import {
|
import { markScrollInvestigationEvent, markScrollInvestigationRender } from "@/utils/scroll-jank";
|
||||||
markScrollInvestigationEvent,
|
|
||||||
markScrollInvestigationRender,
|
|
||||||
} from "@/utils/scroll-jank-investigation";
|
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
|
import { useComposerHeightMirror } from "./composer-height-mirror";
|
||||||
|
|
||||||
export type ImageAttachment = AttachmentMetadata;
|
export type ImageAttachment = AttachmentMetadata;
|
||||||
|
|
||||||
@@ -85,6 +83,7 @@ export interface MessageInputProps {
|
|||||||
allowEmptySubmit?: boolean;
|
allowEmptySubmit?: boolean;
|
||||||
/** Optional accessibility label for the primary submit button. */
|
/** Optional accessibility label for the primary submit button. */
|
||||||
submitButtonAccessibilityLabel?: string;
|
submitButtonAccessibilityLabel?: string;
|
||||||
|
submitIcon?: "arrow" | "return";
|
||||||
isSubmitDisabled?: boolean;
|
isSubmitDisabled?: boolean;
|
||||||
isSubmitLoading?: boolean;
|
isSubmitLoading?: boolean;
|
||||||
attachments: ComposerAttachment[];
|
attachments: ComposerAttachment[];
|
||||||
@@ -217,6 +216,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
hasExternalContent = false,
|
hasExternalContent = false,
|
||||||
allowEmptySubmit = false,
|
allowEmptySubmit = false,
|
||||||
submitButtonAccessibilityLabel,
|
submitButtonAccessibilityLabel,
|
||||||
|
submitIcon = "arrow",
|
||||||
isSubmitDisabled = false,
|
isSubmitDisabled = false,
|
||||||
isSubmitLoading = false,
|
isSubmitLoading = false,
|
||||||
attachments,
|
attachments,
|
||||||
@@ -304,8 +304,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
if (action === "dictation-cancel") {
|
if (action === "dictation-cancel") {
|
||||||
if (isDictatingRef.current) {
|
if (isDictatingRef.current) {
|
||||||
cancelDictation();
|
cancelDictation();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
return true;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === "dictation-toggle") {
|
if (action === "dictation-toggle") {
|
||||||
@@ -328,7 +329,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
const inputHeightRef = useRef(MIN_INPUT_HEIGHT);
|
const inputHeightRef = useRef(MIN_INPUT_HEIGHT);
|
||||||
const baselineInputHeightRef = useRef<number | null>(null);
|
|
||||||
const overlayTransition = useSharedValue(0);
|
const overlayTransition = useSharedValue(0);
|
||||||
const sendAfterTranscriptRef = useRef(false);
|
const sendAfterTranscriptRef = useRef(false);
|
||||||
const valueRef = useRef(value);
|
const valueRef = useRef(value);
|
||||||
@@ -398,12 +398,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
} else {
|
} else {
|
||||||
onChangeText(nextValue);
|
onChangeText(nextValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isWeb && typeof requestAnimationFrame === "function") {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
measureWebInputHeight("dictation");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[onChangeText, onSubmit, onQueue, attachments, cwd, isAgentRunning, defaultSendBehavior],
|
[onChangeText, onSubmit, onQueue, attachments, cwd, isAgentRunning, defaultSendBehavior],
|
||||||
);
|
);
|
||||||
@@ -484,11 +478,15 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
toast.error(dictationUnavailableMessage);
|
toast.error(dictationUnavailableMessage);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!canStartDictation()) {
|
||||||
|
isDictatingRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Keep hotkey toggling deterministic between the async start call and the
|
// Keep hotkey toggling deterministic between the async start call and the
|
||||||
// state-ref sync effect, so a rapid second toggle routes to confirm.
|
// state-ref sync effect, so a rapid second toggle routes to confirm.
|
||||||
isDictatingRef.current = true;
|
isDictatingRef.current = true;
|
||||||
await startDictation();
|
await startDictation();
|
||||||
}, [dictationUnavailableMessage, startDictation, toast]);
|
}, [canStartDictation, dictationUnavailableMessage, startDictation, toast]);
|
||||||
|
|
||||||
// Animate overlay
|
// Animate overlay
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -830,76 +828,27 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
};
|
};
|
||||||
}, [getWebTextArea]);
|
}, [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) {
|
function setBoundedInputHeight(nextHeight: number) {
|
||||||
const bounded = Math.max(MIN_INPUT_HEIGHT, Math.min(MAX_INPUT_HEIGHT, nextHeight));
|
const bounded = Math.max(MIN_INPUT_HEIGHT, Math.min(MAX_INPUT_HEIGHT, nextHeight));
|
||||||
if (Math.abs(inputHeightRef.current - bounded) < 1) return;
|
if (Math.abs(inputHeightRef.current - bounded) < 1) return;
|
||||||
const previousHeight = inputHeightRef.current;
|
|
||||||
inputHeightRef.current = bounded;
|
inputHeightRef.current = bounded;
|
||||||
setInputHeight(bounded);
|
setInputHeight(bounded);
|
||||||
onHeightChange?.(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(
|
function handleContentSizeChange(
|
||||||
event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>,
|
event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>,
|
||||||
) {
|
) {
|
||||||
const contentHeight = event.nativeEvent.contentSize.height;
|
if (isWeb) return;
|
||||||
if (isWeb) {
|
setBoundedInputHeight(event.nativeEvent.contentSize.height);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSelectionChange(event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) {
|
function handleSelectionChange(event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) {
|
||||||
@@ -1180,6 +1129,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
>
|
>
|
||||||
{isSubmitLoading ? (
|
{isSubmitLoading ? (
|
||||||
<ActivityIndicator size="small" color="white" />
|
<ActivityIndicator size="small" color="white" />
|
||||||
|
) : submitIcon === "return" ? (
|
||||||
|
<CornerDownLeft size={buttonIconSize} color="white" />
|
||||||
) : (
|
) : (
|
||||||
<ArrowUp size={buttonIconSize} color="white" />
|
<ArrowUp size={buttonIconSize} color="white" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ import Animated, {
|
|||||||
withTiming,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
|
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
|
||||||
import { theme } from "@/styles/theme";
|
|
||||||
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
||||||
import { Colors, Fonts } from "@/constants/theme";
|
import { Colors, Fonts } from "@/constants/theme";
|
||||||
import * as Clipboard from "expo-clipboard";
|
import * as Clipboard from "expo-clipboard";
|
||||||
@@ -72,18 +71,24 @@ import {
|
|||||||
} from "@/utils/inline-path";
|
} from "@/utils/inline-path";
|
||||||
import { getMarkdownListMarker } from "@/utils/markdown-list";
|
import { getMarkdownListMarker } from "@/utils/markdown-list";
|
||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
import { markScrollInvestigationEvent } from "@/utils/scroll-jank-investigation";
|
import { markScrollInvestigationEvent } from "@/utils/scroll-jank";
|
||||||
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
|
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
|
||||||
import {
|
import {
|
||||||
getAssistantImageMetadata,
|
getAssistantImageMetadata,
|
||||||
setAssistantImageMetadata,
|
setAssistantImageMetadata,
|
||||||
} from "@/utils/assistant-image-metadata";
|
} from "@/utils/assistant-image-metadata";
|
||||||
import { resolveAssistantImageSource } from "@/utils/assistant-image-source";
|
import { resolveAssistantImageSource } from "@/utils/assistant-image-source";
|
||||||
|
import {
|
||||||
|
createPreviewAttachmentId,
|
||||||
|
getFileNameFromPath,
|
||||||
|
parseImageDataUrl,
|
||||||
|
} from "@/attachments/utils";
|
||||||
export type { InlinePathTarget } from "@/utils/inline-path";
|
export type { InlinePathTarget } from "@/utils/inline-path";
|
||||||
import { PlanCard } from "./plan-card";
|
import { PlanCard } from "./plan-card";
|
||||||
import { useToolCallSheet } from "./tool-call-sheet";
|
import { useToolCallSheet } from "./tool-call-sheet";
|
||||||
import { ToolCallDetailsContent } from "./tool-call-details";
|
import { ToolCallDetailsContent } from "./tool-call-details";
|
||||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||||
|
import { persistAttachmentFromBase64, persistAttachmentFromDataUrl } from "@/attachments/service";
|
||||||
import type { DaemonClient } from "@server/client/daemon-client";
|
import type { DaemonClient } from "@server/client/daemon-client";
|
||||||
import { isWeb, isNative } from "@/constants/platform";
|
import { isWeb, isNative } from "@/constants/platform";
|
||||||
|
|
||||||
@@ -119,6 +124,26 @@ function useDisableOuterSpacing(disableOuterSpacing: boolean | undefined) {
|
|||||||
|
|
||||||
const WEB_TOOLCALL_SHIMMER_KEYFRAME_ID = "paseo-toolcall-shimmer-keyframes";
|
const WEB_TOOLCALL_SHIMMER_KEYFRAME_ID = "paseo-toolcall-shimmer-keyframes";
|
||||||
const WEB_TOOLCALL_SHIMMER_ANIMATION_NAME = "paseo-toolcall-shimmer";
|
const WEB_TOOLCALL_SHIMMER_ANIMATION_NAME = "paseo-toolcall-shimmer";
|
||||||
|
const MARKDOWN_ALLOWED_IMAGE_HANDLERS = [
|
||||||
|
"data:image/png;base64",
|
||||||
|
"data:image/gif;base64",
|
||||||
|
"data:image/jpeg;base64",
|
||||||
|
"https://",
|
||||||
|
"http://",
|
||||||
|
] as const;
|
||||||
|
const MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM = <Text key="dotdotdot">...</Text>;
|
||||||
|
|
||||||
|
type MarkdownWithStableRendererProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
style: ReturnType<typeof createMarkdownStyles>;
|
||||||
|
rules: RenderRules;
|
||||||
|
markdownit: MarkdownIt;
|
||||||
|
onLinkPress: (url: string) => boolean;
|
||||||
|
allowedImageHandlers: readonly string[];
|
||||||
|
topLevelMaxExceededItem: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MarkdownWithStableRenderer = Markdown as ComponentType<MarkdownWithStableRendererProps>;
|
||||||
const WEB_TOOLCALL_SHIMMER_KEYFRAME_CSS = `
|
const WEB_TOOLCALL_SHIMMER_KEYFRAME_CSS = `
|
||||||
@keyframes ${WEB_TOOLCALL_SHIMMER_ANIMATION_NAME} {
|
@keyframes ${WEB_TOOLCALL_SHIMMER_ANIMATION_NAME} {
|
||||||
0% {
|
0% {
|
||||||
@@ -264,6 +289,7 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
|
|||||||
content: {
|
content: {
|
||||||
alignItems: "flex-end",
|
alignItems: "flex-end",
|
||||||
maxWidth: "100%",
|
maxWidth: "100%",
|
||||||
|
cursor: "auto",
|
||||||
},
|
},
|
||||||
containerSpacing: {
|
containerSpacing: {
|
||||||
marginBottom: theme.spacing[1],
|
marginBottom: theme.spacing[1],
|
||||||
@@ -341,6 +367,7 @@ export const UserMessage = memo(function UserMessage({
|
|||||||
isLastInGroup = true,
|
isLastInGroup = true,
|
||||||
disableOuterSpacing,
|
disableOuterSpacing,
|
||||||
}: UserMessageProps) {
|
}: UserMessageProps) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
const isCompact = useIsCompactFormFactor();
|
const isCompact = useIsCompactFormFactor();
|
||||||
const [messageHovered, setMessageHovered] = useState(false);
|
const [messageHovered, setMessageHovered] = useState(false);
|
||||||
const [copyButtonHovered, setCopyButtonHovered] = useState(false);
|
const [copyButtonHovered, setCopyButtonHovered] = useState(false);
|
||||||
@@ -412,6 +439,7 @@ interface AssistantMessageProps {
|
|||||||
serverId?: string;
|
serverId?: string;
|
||||||
client?: DaemonClient | null;
|
client?: DaemonClient | null;
|
||||||
disableOuterSpacing?: boolean;
|
disableOuterSpacing?: boolean;
|
||||||
|
spacing?: "default" | "compactTop" | "compactBottom" | "compactBoth";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
|
export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
|
||||||
@@ -419,6 +447,12 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
|
|||||||
paddingHorizontal: theme.spacing[2],
|
paddingHorizontal: theme.spacing[2],
|
||||||
paddingVertical: theme.spacing[3],
|
paddingVertical: theme.spacing[3],
|
||||||
},
|
},
|
||||||
|
containerCompactTop: {
|
||||||
|
paddingTop: 0,
|
||||||
|
},
|
||||||
|
containerCompactBottom: {
|
||||||
|
paddingBottom: 0,
|
||||||
|
},
|
||||||
containerSpacing: {
|
containerSpacing: {
|
||||||
marginBottom: theme.spacing[4],
|
marginBottom: theme.spacing[4],
|
||||||
},
|
},
|
||||||
@@ -567,6 +601,7 @@ function AssistantMarkdownImage({
|
|||||||
() => resolveAssistantImageSource({ source, workspaceRoot }),
|
() => resolveAssistantImageSource({ source, workspaceRoot }),
|
||||||
[source, workspaceRoot],
|
[source, workspaceRoot],
|
||||||
);
|
);
|
||||||
|
const dataImage = useMemo(() => parseImageDataUrl(source), [source]);
|
||||||
const containerStyle = useMemo<StyleProp<ViewStyle>>(
|
const containerStyle = useMemo<StyleProp<ViewStyle>>(
|
||||||
() => ({
|
() => ({
|
||||||
marginTop: hasLeadingContent ? theme.spacing[4] : 0,
|
marginTop: hasLeadingContent ? theme.spacing[4] : 0,
|
||||||
@@ -593,16 +628,44 @@ function AssistantMarkdownImage({
|
|||||||
if (payload.error) {
|
if (payload.error) {
|
||||||
throw new Error(payload.error);
|
throw new Error(payload.error);
|
||||||
}
|
}
|
||||||
if (!payload.file || payload.file.kind !== "image" || !payload.file.content) {
|
const file = payload.file;
|
||||||
|
if (!file || file.kind !== "image" || !file.content) {
|
||||||
throw new Error("Image preview unavailable.");
|
throw new Error("Image preview unavailable.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return `data:${payload.file.mimeType ?? "image/png"};base64,${payload.file.content}`;
|
return await persistAttachmentFromBase64({
|
||||||
|
id: createPreviewAttachmentId({
|
||||||
|
base64: file.content,
|
||||||
|
mimeType: file.mimeType ?? "image/png",
|
||||||
|
path: file.path || resolution.path,
|
||||||
|
}),
|
||||||
|
base64: file.content,
|
||||||
|
mimeType: file.mimeType,
|
||||||
|
fileName: getFileNameFromPath(file.path || resolution.path),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const dataImageQuery = useQuery({
|
||||||
|
queryKey: ["assistantMarkdownDataImage", dataImage?.cacheKey ?? null],
|
||||||
|
enabled: dataImage !== null,
|
||||||
|
staleTime: 30_000,
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!dataImage) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await persistAttachmentFromDataUrl({
|
||||||
|
id: createPreviewAttachmentId(dataImage),
|
||||||
|
dataUrl: source,
|
||||||
|
mimeType: dataImage.mimeType,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const directUri = resolution?.kind === "direct" ? resolution.uri : null;
|
const fileAssetUri = useAttachmentPreviewUrl(query.data);
|
||||||
const resolvedUri = directUri ?? query.data ?? null;
|
const dataImageAssetUri = useAttachmentPreviewUrl(dataImageQuery.data);
|
||||||
|
const directUri = resolution?.kind === "direct" && !dataImage ? resolution.uri : null;
|
||||||
|
const resolvedUri = directUri ?? dataImageAssetUri ?? fileAssetUri ?? null;
|
||||||
|
|
||||||
if (resolvedUri) {
|
if (resolvedUri) {
|
||||||
return (
|
return (
|
||||||
@@ -617,7 +680,7 @@ function AssistantMarkdownImage({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (query.isLoading) {
|
if (query.isLoading || dataImageQuery.isLoading) {
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
@@ -640,7 +703,11 @@ function AssistantMarkdownImage({
|
|||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Text style={assistantMessageStylesheet.imageErrorText}>
|
<Text style={assistantMessageStylesheet.imageErrorText}>
|
||||||
{query.error instanceof Error ? query.error.message : "Unable to load image preview."}
|
{query.error instanceof Error
|
||||||
|
? query.error.message
|
||||||
|
: dataImageQuery.error instanceof Error
|
||||||
|
? dataImageQuery.error.message
|
||||||
|
: "Unable to load image preview."}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -931,6 +998,116 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
interface NativeExpandableBadgeShimmerProps {
|
||||||
|
label: string;
|
||||||
|
secondaryLabel?: string;
|
||||||
|
rowWidth: number;
|
||||||
|
rowHeight: number;
|
||||||
|
peakWidth: number;
|
||||||
|
durationSeconds: number;
|
||||||
|
gradientId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NativeExpandableBadgeShimmer = memo(function NativeExpandableBadgeShimmer({
|
||||||
|
label,
|
||||||
|
secondaryLabel,
|
||||||
|
rowWidth,
|
||||||
|
rowHeight,
|
||||||
|
peakWidth,
|
||||||
|
durationSeconds,
|
||||||
|
gradientId,
|
||||||
|
}: NativeExpandableBadgeShimmerProps) {
|
||||||
|
const shimmerTranslateX = useSharedValue(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const startPosition = -peakWidth;
|
||||||
|
const endPosition = rowWidth + peakWidth;
|
||||||
|
shimmerTranslateX.value = startPosition;
|
||||||
|
shimmerTranslateX.value = withRepeat(
|
||||||
|
withTiming(endPosition, {
|
||||||
|
duration: durationSeconds * 1000,
|
||||||
|
easing: Easing.linear,
|
||||||
|
}),
|
||||||
|
-1,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
cancelAnimation(shimmerTranslateX);
|
||||||
|
};
|
||||||
|
}, [durationSeconds, peakWidth, rowWidth, shimmerTranslateX]);
|
||||||
|
|
||||||
|
const nativeShimmerPeakStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ translateX: shimmerTranslateX.value }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const nativeShimmerTrackStyle = useMemo(
|
||||||
|
() => [expandableBadgeStylesheet.nativeShimmerTrack, { width: rowWidth, height: rowHeight }],
|
||||||
|
[rowHeight, rowWidth],
|
||||||
|
);
|
||||||
|
|
||||||
|
const nativeShimmerMaskStyle = useMemo(
|
||||||
|
() => [expandableBadgeStylesheet.shimmerMaskRow, { width: rowWidth, height: rowHeight }],
|
||||||
|
[rowHeight, rowWidth],
|
||||||
|
);
|
||||||
|
|
||||||
|
const nativeLabelMaskStyle = useMemo(
|
||||||
|
() => [expandableBadgeStylesheet.label, { color: "#000000", opacity: 1 }],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const nativeSecondaryMaskStyle = useMemo(
|
||||||
|
() => [expandableBadgeStylesheet.secondaryLabel, { color: "#000000", opacity: 1 }],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const nativeShimmerPeakCombinedStyle = useMemo(
|
||||||
|
() => [
|
||||||
|
expandableBadgeStylesheet.nativeShimmerPeak,
|
||||||
|
nativeShimmerPeakStyle,
|
||||||
|
{ width: peakWidth, height: rowHeight },
|
||||||
|
],
|
||||||
|
[nativeShimmerPeakStyle, peakWidth, rowHeight],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={expandableBadgeStylesheet.shimmerOverlay} pointerEvents="none">
|
||||||
|
<MaskedView
|
||||||
|
pointerEvents="none"
|
||||||
|
style={nativeShimmerTrackStyle}
|
||||||
|
maskElement={
|
||||||
|
<View pointerEvents="none" style={nativeShimmerMaskStyle}>
|
||||||
|
<Text style={nativeLabelMaskStyle} numberOfLines={1}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
{secondaryLabel ? (
|
||||||
|
<Text style={nativeSecondaryMaskStyle} numberOfLines={1}>
|
||||||
|
{secondaryLabel}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<View style={expandableBadgeStylesheet.spacer} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<View pointerEvents="none" style={nativeShimmerTrackStyle}>
|
||||||
|
<Animated.View pointerEvents="none" style={nativeShimmerPeakCombinedStyle}>
|
||||||
|
<Svg width="100%" height="100%" preserveAspectRatio="none">
|
||||||
|
<Defs>
|
||||||
|
<SvgLinearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<Stop offset="0%" stopColor="#ffffff" stopOpacity={0} />
|
||||||
|
<Stop offset="50%" stopColor="#ffffff" stopOpacity={1} />
|
||||||
|
<Stop offset="100%" stopColor="#ffffff" stopOpacity={0} />
|
||||||
|
</SvgLinearGradient>
|
||||||
|
</Defs>
|
||||||
|
<Rect x="0" y="0" width="100%" height="100%" fill={`url(#${gradientId})`} />
|
||||||
|
</Svg>
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
</MaskedView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
interface MemoizedMarkdownBlockProps {
|
interface MemoizedMarkdownBlockProps {
|
||||||
text: string;
|
text: string;
|
||||||
styles: ReturnType<typeof createMarkdownStyles>;
|
styles: ReturnType<typeof createMarkdownStyles>;
|
||||||
@@ -947,9 +1124,16 @@ const MemoizedMarkdownBlock = React.memo(function MemoizedMarkdownBlock({
|
|||||||
onLinkPress,
|
onLinkPress,
|
||||||
}: MemoizedMarkdownBlockProps) {
|
}: MemoizedMarkdownBlockProps) {
|
||||||
return (
|
return (
|
||||||
<Markdown style={styles} rules={rules} markdownit={parser} onLinkPress={onLinkPress}>
|
<MarkdownWithStableRenderer
|
||||||
|
style={styles}
|
||||||
|
rules={rules}
|
||||||
|
markdownit={parser}
|
||||||
|
onLinkPress={onLinkPress}
|
||||||
|
allowedImageHandlers={MARKDOWN_ALLOWED_IMAGE_HANDLERS}
|
||||||
|
topLevelMaxExceededItem={MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM}
|
||||||
|
>
|
||||||
{text}
|
{text}
|
||||||
</Markdown>
|
</MarkdownWithStableRenderer>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -961,11 +1145,14 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
serverId,
|
serverId,
|
||||||
client,
|
client,
|
||||||
disableOuterSpacing,
|
disableOuterSpacing,
|
||||||
|
spacing = "default",
|
||||||
}: AssistantMessageProps) {
|
}: AssistantMessageProps) {
|
||||||
const { theme, rt } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
|
const resolvedDisableOuterSpacing = useDisableOuterSpacing(
|
||||||
|
disableOuterSpacing ?? spacing !== "default",
|
||||||
|
);
|
||||||
|
|
||||||
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [rt.themeName]);
|
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
|
||||||
|
|
||||||
const markdownParser = useMemo(() => {
|
const markdownParser = useMemo(() => {
|
||||||
const parser = MarkdownIt({ typographer: true, linkify: true });
|
const parser = MarkdownIt({ typographer: true, linkify: true });
|
||||||
@@ -1164,6 +1351,10 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
testID="assistant-message"
|
testID="assistant-message"
|
||||||
style={[
|
style={[
|
||||||
assistantMessageStylesheet.container,
|
assistantMessageStylesheet.container,
|
||||||
|
(spacing === "compactTop" || spacing === "compactBoth") &&
|
||||||
|
assistantMessageStylesheet.containerCompactTop,
|
||||||
|
(spacing === "compactBottom" || spacing === "compactBoth") &&
|
||||||
|
assistantMessageStylesheet.containerCompactBottom,
|
||||||
!resolvedDisableOuterSpacing && assistantMessageStylesheet.containerSpacing,
|
!resolvedDisableOuterSpacing && assistantMessageStylesheet.containerSpacing,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
@@ -1642,7 +1833,6 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
|||||||
const [labelWidth, setLabelWidth] = useState(0);
|
const [labelWidth, setLabelWidth] = useState(0);
|
||||||
const [secondaryOffsetX, setSecondaryOffsetX] = useState(0);
|
const [secondaryOffsetX, setSecondaryOffsetX] = useState(0);
|
||||||
const [secondaryWidth, setSecondaryWidth] = useState(0);
|
const [secondaryWidth, setSecondaryWidth] = useState(0);
|
||||||
const shimmerTranslateX = useSharedValue(0);
|
|
||||||
|
|
||||||
const totalShimmerChars = label.trim().length + (secondaryLabel?.trim().length ?? 0);
|
const totalShimmerChars = label.trim().length + (secondaryLabel?.trim().length ?? 0);
|
||||||
const shortTextDurationAdjustment = totalShimmerChars <= 12 ? 0.25 : 0;
|
const shortTextDurationAdjustment = totalShimmerChars <= 12 ? 0.25 : 0;
|
||||||
@@ -1710,28 +1900,6 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
|||||||
ensureWebToolCallShimmerKeyframes();
|
ensureWebToolCallShimmerKeyframes();
|
||||||
}, [isWebShimmer]);
|
}, [isWebShimmer]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isNativeShimmer) {
|
|
||||||
cancelAnimation(shimmerTranslateX);
|
|
||||||
shimmerTranslateX.value = -nativeShimmerPeakWidth;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const startPosition = -nativeShimmerPeakWidth;
|
|
||||||
const endPosition = labelRowWidth + nativeShimmerPeakWidth;
|
|
||||||
shimmerTranslateX.value = startPosition;
|
|
||||||
shimmerTranslateX.value = withRepeat(
|
|
||||||
withTiming(endPosition, {
|
|
||||||
duration: shimmerDuration * 1000,
|
|
||||||
easing: Easing.linear,
|
|
||||||
}),
|
|
||||||
-1,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
return () => {
|
|
||||||
cancelAnimation(shimmerTranslateX);
|
|
||||||
};
|
|
||||||
}, [isNativeShimmer, labelRowWidth, nativeShimmerPeakWidth, shimmerDuration, shimmerTranslateX]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isNative || !isExpanded || !hasDetailContent) {
|
if (isNative || !isExpanded || !hasDetailContent) {
|
||||||
return;
|
return;
|
||||||
@@ -1756,10 +1924,6 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
|||||||
};
|
};
|
||||||
}, [hasDetailContent, isExpanded, wheelInvestigationComponentId]);
|
}, [hasDetailContent, isExpanded, wheelInvestigationComponentId]);
|
||||||
|
|
||||||
const nativeShimmerPeakStyle = useAnimatedStyle(() => ({
|
|
||||||
transform: [{ translateX: shimmerTranslateX.value }],
|
|
||||||
}));
|
|
||||||
|
|
||||||
const shimmerGradient =
|
const shimmerGradient =
|
||||||
"linear-gradient(90deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.45) 24%, #ffffff 40%, #ffffff 60%, rgba(255, 255, 255, 0.45) 76%, rgba(255, 255, 255, 0) 100%)";
|
"linear-gradient(90deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.45) 24%, #ffffff 40%, #ffffff 60%, rgba(255, 255, 255, 0.45) 76%, rgba(255, 255, 255, 0) 100%)";
|
||||||
|
|
||||||
@@ -1859,41 +2023,6 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
|||||||
[shimmerSecondaryStyle],
|
[shimmerSecondaryStyle],
|
||||||
);
|
);
|
||||||
|
|
||||||
const nativeShimmerTrackStyle = useMemo(
|
|
||||||
() => [
|
|
||||||
expandableBadgeStylesheet.nativeShimmerTrack,
|
|
||||||
{ width: labelRowWidth, height: labelRowHeight },
|
|
||||||
],
|
|
||||||
[labelRowHeight, labelRowWidth],
|
|
||||||
);
|
|
||||||
|
|
||||||
const nativeShimmerMaskStyle = useMemo(
|
|
||||||
() => [
|
|
||||||
expandableBadgeStylesheet.shimmerMaskRow,
|
|
||||||
{ width: labelRowWidth, height: labelRowHeight },
|
|
||||||
],
|
|
||||||
[labelRowHeight, labelRowWidth],
|
|
||||||
);
|
|
||||||
|
|
||||||
const nativeLabelMaskStyle = useMemo(
|
|
||||||
() => [expandableBadgeStylesheet.label, { color: "#000000", opacity: 1 }],
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const nativeSecondaryMaskStyle = useMemo(
|
|
||||||
() => [expandableBadgeStylesheet.secondaryLabel, { color: "#000000", opacity: 1 }],
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const nativeShimmerPeakCombinedStyle = useMemo(
|
|
||||||
() => [
|
|
||||||
expandableBadgeStylesheet.nativeShimmerPeak,
|
|
||||||
nativeShimmerPeakStyle,
|
|
||||||
{ width: nativeShimmerPeakWidth, height: labelRowHeight },
|
|
||||||
],
|
|
||||||
[labelRowHeight, nativeShimmerPeakStyle, nativeShimmerPeakWidth],
|
|
||||||
);
|
|
||||||
|
|
||||||
const chevronStyle = useMemo(
|
const chevronStyle = useMemo(
|
||||||
() => [
|
() => [
|
||||||
expandableBadgeStylesheet.chevron,
|
expandableBadgeStylesheet.chevron,
|
||||||
@@ -1975,52 +2104,15 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
|||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
{isNativeShimmer ? (
|
{isNativeShimmer ? (
|
||||||
<View style={expandableBadgeStylesheet.shimmerOverlay} pointerEvents="none">
|
<NativeExpandableBadgeShimmer
|
||||||
<MaskedView
|
label={label}
|
||||||
style={nativeShimmerTrackStyle}
|
secondaryLabel={secondaryLabel}
|
||||||
maskElement={
|
rowWidth={labelRowWidth}
|
||||||
<View style={nativeShimmerMaskStyle}>
|
rowHeight={labelRowHeight}
|
||||||
<Text style={nativeLabelMaskStyle} numberOfLines={1}>
|
peakWidth={nativeShimmerPeakWidth}
|
||||||
{label}
|
durationSeconds={shimmerDuration}
|
||||||
</Text>
|
gradientId={nativeGradientIdRef.current}
|
||||||
{secondaryLabel ? (
|
/>
|
||||||
<Text style={nativeSecondaryMaskStyle} numberOfLines={1}>
|
|
||||||
{secondaryLabel}
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<View style={expandableBadgeStylesheet.spacer} />
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<View style={nativeShimmerTrackStyle}>
|
|
||||||
<Animated.View style={nativeShimmerPeakCombinedStyle}>
|
|
||||||
<Svg width="100%" height="100%" preserveAspectRatio="none">
|
|
||||||
<Defs>
|
|
||||||
<SvgLinearGradient
|
|
||||||
id={nativeGradientIdRef.current}
|
|
||||||
x1="0%"
|
|
||||||
y1="0%"
|
|
||||||
x2="100%"
|
|
||||||
y2="0%"
|
|
||||||
>
|
|
||||||
<Stop offset="0%" stopColor="#ffffff" stopOpacity={0} />
|
|
||||||
<Stop offset="50%" stopColor="#ffffff" stopOpacity={1} />
|
|
||||||
<Stop offset="100%" stopColor="#ffffff" stopOpacity={0} />
|
|
||||||
</SvgLinearGradient>
|
|
||||||
</Defs>
|
|
||||||
<Rect
|
|
||||||
x="0"
|
|
||||||
y="0"
|
|
||||||
width="100%"
|
|
||||||
height="100%"
|
|
||||||
fill={`url(#${nativeGradientIdRef.current})`}
|
|
||||||
/>
|
|
||||||
</Svg>
|
|
||||||
</Animated.View>
|
|
||||||
</View>
|
|
||||||
</MaskedView>
|
|
||||||
</View>
|
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
{isInteractive && isHovered ? (
|
{isInteractive && isHovered ? (
|
||||||
@@ -2053,10 +2145,9 @@ function areExpandableBadgePropsEqual(previous: ExpandableBadgeProps, next: Expa
|
|||||||
if (previous.isLastInSequence !== next.isLastInSequence) return false;
|
if (previous.isLastInSequence !== next.isLastInSequence) return false;
|
||||||
if (previous.disableOuterSpacing !== next.disableOuterSpacing) return false;
|
if (previous.disableOuterSpacing !== next.disableOuterSpacing) return false;
|
||||||
if (previous.testID !== next.testID) return false;
|
if (previous.testID !== next.testID) return false;
|
||||||
if (Boolean(previous.onToggle) !== Boolean(next.onToggle)) return false;
|
if (previous.onToggle !== next.onToggle) return false;
|
||||||
if (previous.isExpanded && previous.renderDetails !== next.renderDetails) {
|
if (previous.onDetailHoverChange !== next.onDetailHoverChange) return false;
|
||||||
return false;
|
if (previous.renderDetails !== next.renderDetails) return false;
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 { Folder } from "lucide-react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { usePathname } from "expo-router";
|
|
||||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||||
import { shortenPath } from "@/utils/shorten-path";
|
import { shortenPath } from "@/utils/shorten-path";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useRecommendedProjectPaths } from "@/stores/session-store-hooks";
|
||||||
import { useHosts, useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import { useOpenProject } from "@/hooks/use-open-project";
|
import { useOpenProject } from "@/hooks/use-open-project";
|
||||||
import { parseServerIdFromPathname } from "@/utils/host-routes";
|
|
||||||
import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions";
|
import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions";
|
||||||
import { isNative } from "@/constants/platform";
|
import { isNative } from "@/constants/platform";
|
||||||
|
import { useActiveServerId } from "@/hooks/use-active-server-id";
|
||||||
|
|
||||||
export function ProjectPickerModal() {
|
export function ProjectPickerModal() {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const pathname = usePathname();
|
const serverId = useActiveServerId();
|
||||||
const daemons = useHosts();
|
|
||||||
|
|
||||||
const open = useKeyboardShortcutsStore((s) => s.projectPickerOpen);
|
const open = useKeyboardShortcutsStore((s) => s.projectPickerOpen);
|
||||||
const setOpen = useKeyboardShortcutsStore((s) => s.setProjectPickerOpen);
|
const setOpen = useKeyboardShortcutsStore((s) => s.setProjectPickerOpen);
|
||||||
|
|
||||||
const serverId = useMemo(() => {
|
|
||||||
const fromPath = parseServerIdFromPathname(pathname);
|
|
||||||
if (fromPath) return fromPath;
|
|
||||||
return daemons[0]?.serverId ?? null;
|
|
||||||
}, [pathname, daemons]);
|
|
||||||
|
|
||||||
const client = useHostRuntimeClient(serverId ?? "");
|
const client = useHostRuntimeClient(serverId ?? "");
|
||||||
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
||||||
const workspaces = useSessionStore((state) =>
|
const recommendedPaths = useRecommendedProjectPaths(serverId);
|
||||||
serverId ? state.sessions[serverId]?.workspaces : undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
const inputRef = useRef<TextInput>(null);
|
const inputRef = useRef<TextInput>(null);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
@@ -39,13 +29,6 @@ export function ProjectPickerModal() {
|
|||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const openProject = useOpenProject(serverId);
|
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({
|
const directorySuggestionsQuery = useQuery({
|
||||||
queryKey: ["project-picker-directory-suggestions", serverId, query],
|
queryKey: ["project-picker-directory-suggestions", serverId, query],
|
||||||
queryFn: async () => {
|
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 { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
|
import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
|
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
|
||||||
import { SpinningRefreshIcon } from "@/components/spinning-refresh-icon";
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
import { Fonts } from "@/constants/theme";
|
import { Fonts } from "@/constants/theme";
|
||||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||||
@@ -82,8 +82,12 @@ export function ProviderDiagnosticSheet({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleRefresh = useCallback(() => {
|
const handleRefresh = useCallback(() => {
|
||||||
void refresh([provider as AgentProvider]);
|
if (!provider) {
|
||||||
void fetchDiagnostic({ keepCurrent: true });
|
return;
|
||||||
|
}
|
||||||
|
void Promise.all([refresh([provider as AgentProvider]), fetchDiagnostic()]).catch((err) => {
|
||||||
|
setDiagnostic(err instanceof Error ? err.message : "Failed to refresh provider");
|
||||||
|
});
|
||||||
}, [fetchDiagnostic, provider, refresh]);
|
}, [fetchDiagnostic, provider, refresh]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -157,13 +161,15 @@ export function ProviderDiagnosticSheet({
|
|||||||
refreshInFlight ? sheetStyles.disabled : null,
|
refreshInFlight ? sheetStyles.disabled : null,
|
||||||
]}
|
]}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={`Refresh ${providerLabel}`}
|
accessibilityLabel={
|
||||||
|
refreshInFlight ? `Refreshing ${providerLabel}` : `Refresh ${providerLabel}`
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<SpinningRefreshIcon
|
{refreshInFlight ? (
|
||||||
spinning={refreshInFlight}
|
<LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
size={theme.iconSize.sm}
|
) : (
|
||||||
color={theme.colors.foregroundMuted}
|
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
/>
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,454 @@
|
|||||||
|
/**
|
||||||
|
* @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,
|
||||||
|
terminalId: 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user