mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
159 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec8c51a014 | ||
|
|
84d3aa1962 | ||
|
|
0bc693f157 | ||
|
|
5ae7a0c9c2 | ||
|
|
e6bc752b68 | ||
|
|
78fc1fe862 | ||
|
|
84500cdcae | ||
|
|
480af26b57 | ||
|
|
2003f308f3 | ||
|
|
a042fbbe43 | ||
|
|
ee5577c2da | ||
|
|
24945a9498 | ||
|
|
f6f689d570 | ||
|
|
7703625e76 | ||
|
|
17b81fb132 | ||
|
|
111576e2ca | ||
|
|
862cc43db7 | ||
|
|
48924626df | ||
|
|
72ea9b7a72 | ||
|
|
e0f9b33d23 | ||
|
|
c7fe944e73 | ||
|
|
584f5ce05e | ||
|
|
614c085310 | ||
|
|
866aeb8ad9 | ||
|
|
23aaecd99a | ||
|
|
77fb74c188 | ||
|
|
70fb5f5bfd | ||
|
|
26c07b671f | ||
|
|
c9f2e01131 | ||
|
|
f4f3e4204d | ||
|
|
d7d1e2d169 | ||
|
|
6ab97c579e | ||
|
|
3d4ac57bd0 | ||
|
|
efb3df8233 | ||
|
|
a952112910 | ||
|
|
20fa1a3a3b | ||
|
|
7609ccee4c | ||
|
|
9065dcef54 | ||
|
|
4ab307b10f | ||
|
|
675d825f6b | ||
|
|
1328e0cc05 | ||
|
|
f0ba64f23b | ||
|
|
22c27dd583 | ||
|
|
35da664a09 | ||
|
|
1323cb13c4 | ||
|
|
3287f2d00b | ||
|
|
d15a1451b7 | ||
|
|
9d370a18b8 | ||
|
|
865e25b3a9 | ||
|
|
82ab598426 | ||
|
|
09fae62888 | ||
|
|
7e0a220fde | ||
|
|
ed9a15c0bd | ||
|
|
b763d4358e | ||
|
|
5f2d4ac122 | ||
|
|
3bdc90a661 | ||
|
|
b5212a69c9 | ||
|
|
0bc903fa21 | ||
|
|
e2f20f0e24 | ||
|
|
30a225ce9f | ||
|
|
30dd54a318 | ||
|
|
abc8ad3fd4 | ||
|
|
4897627943 | ||
|
|
18533ef52b | ||
|
|
faa9c9c491 | ||
|
|
11f6494c20 | ||
|
|
9364da3414 | ||
|
|
ed5bc3091e | ||
|
|
8ff51eb176 | ||
|
|
90fb5e1f33 | ||
|
|
cda08ec033 | ||
|
|
21c585abec | ||
|
|
a1d0492d8d | ||
|
|
745f2e2768 | ||
|
|
ef55fecfe2 | ||
|
|
cc675da49c | ||
|
|
a9d5502fad | ||
|
|
849f49b622 | ||
|
|
d89f1ea663 | ||
|
|
5701543746 | ||
|
|
57db31c662 | ||
|
|
eadca4a96b | ||
|
|
4a111099c4 | ||
|
|
6f34699e4b | ||
|
|
aa6c521394 | ||
|
|
e80db346da | ||
|
|
26c7f3f5f3 | ||
|
|
0057ee7b72 | ||
|
|
0a3e55734b | ||
|
|
0b88383b01 | ||
|
|
a364edde17 | ||
|
|
22c6df3f85 | ||
|
|
93a3a742ac | ||
|
|
0abca5e0c7 | ||
|
|
f275474fec | ||
|
|
f12dc6127b | ||
|
|
e526f375c8 | ||
|
|
3ae1102d13 | ||
|
|
d1632d80e3 | ||
|
|
cad4110e24 | ||
|
|
fc7369384b | ||
|
|
c45b414476 | ||
|
|
73bd0c1dba | ||
|
|
502dd2d2e2 | ||
|
|
80a2f8052c | ||
|
|
a495138374 | ||
|
|
7f69166e97 | ||
|
|
2268790656 | ||
|
|
3a02d1cdd6 | ||
|
|
2e560677e7 | ||
|
|
b4a6b098a4 | ||
|
|
12f8c0db96 | ||
|
|
d39c95e0e7 | ||
|
|
f74031b50d | ||
|
|
8e67f5a707 | ||
|
|
a194a901b4 | ||
|
|
049edbfcaf | ||
|
|
7c18616160 | ||
|
|
70191ceaa8 | ||
|
|
99bac0fd27 | ||
|
|
e8b55eccab | ||
|
|
40ebd1b730 | ||
|
|
b5102e7d7e | ||
|
|
f3036ad2c9 | ||
|
|
4a01b9ec90 | ||
|
|
2f13806a2c | ||
|
|
5f5599a1a8 | ||
|
|
f2680563bd | ||
|
|
79d658243d | ||
|
|
3994fa65c0 | ||
|
|
10671cf999 | ||
|
|
40b9b4672e | ||
|
|
44fc4bfeff | ||
|
|
402f3e32ae | ||
|
|
436a595839 | ||
|
|
8a360281c6 | ||
|
|
730f34753a | ||
|
|
013f573cff | ||
|
|
ee8a81d2e1 | ||
|
|
bed03be332 | ||
|
|
debe4b543b | ||
|
|
1c601e09b6 | ||
|
|
ba54aae206 | ||
|
|
36961f83bf | ||
|
|
6db99c03cb | ||
|
|
999d100464 | ||
|
|
9190d86148 | ||
|
|
4b5cb5a3e2 | ||
|
|
847e1d2c60 | ||
|
|
970df6478b | ||
|
|
3371f17606 | ||
|
|
0f65fe894c | ||
|
|
2ac27a3e3e | ||
|
|
defcc54af2 | ||
|
|
fe84c5c9a3 | ||
|
|
2ff8041e15 | ||
|
|
1d3e551e7f | ||
|
|
79795b6f49 | ||
|
|
16da3300e3 |
1
.github/workflows/deploy-app.yml
vendored
1
.github/workflows/deploy-app.yml
vendored
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- 'app-v*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
||||
481
.github/workflows/desktop-release.yml
vendored
481
.github/workflows/desktop-release.yml
vendored
@@ -3,21 +3,21 @@ name: Desktop Release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
- "desktop-v*"
|
||||
- "desktop-macos-v*"
|
||||
- "desktop-linux-v*"
|
||||
- "desktop-windows-v*"
|
||||
- 'v*'
|
||||
- 'desktop-v*'
|
||||
- 'desktop-macos-v*'
|
||||
- 'desktop-linux-v*'
|
||||
- 'desktop-windows-v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Existing tag to build (e.g. v0.1.0)"
|
||||
description: 'Existing tag to build (e.g. v0.1.0)'
|
||||
required: true
|
||||
type: string
|
||||
platform:
|
||||
description: "Optional desktop platform to build."
|
||||
description: 'Optional desktop platform to build.'
|
||||
required: false
|
||||
default: "all"
|
||||
default: 'all'
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
@@ -31,6 +31,8 @@ concurrency:
|
||||
|
||||
env:
|
||||
SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
|
||||
DESKTOP_WORKSPACE: '@getpaseo/desktop'
|
||||
DESKTOP_PACKAGE_PATH: 'packages/desktop'
|
||||
|
||||
jobs:
|
||||
publish-macos:
|
||||
@@ -40,9 +42,9 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- runner: macos-14
|
||||
rust_target: aarch64-apple-darwin
|
||||
electron_arch: arm64
|
||||
- runner: macos-15-intel
|
||||
rust_target: x86_64-apple-darwin
|
||||
electron_arch: x64
|
||||
permissions:
|
||||
contents: write
|
||||
packages: read
|
||||
@@ -59,13 +61,11 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source_tag="${SOURCE_TAG}"
|
||||
release_tag="$source_tag"
|
||||
for prefix in desktop-windows-v desktop-linux-v desktop-macos-v desktop-v; do
|
||||
if [[ "$source_tag" == ${prefix}* ]]; then
|
||||
release_tag="v${source_tag#${prefix}}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ "$source_tag" =~ ^(desktop-(windows|linux|macos)-|desktop-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
|
||||
release_tag="v${BASH_REMATCH[3]}"
|
||||
else
|
||||
release_tag="$source_tag"
|
||||
fi
|
||||
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
|
||||
|
||||
version="${release_tag#v}"
|
||||
@@ -77,85 +77,39 @@ jobs:
|
||||
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Set desktop version from tag
|
||||
shell: bash
|
||||
run: |
|
||||
node <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const version = process.env.DESKTOP_VERSION;
|
||||
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
|
||||
console.log(`Setting desktop version to ${version}`);
|
||||
|
||||
const tauriConfPath = path.join('packages', 'desktop', 'src-tauri', 'tauri.conf.json');
|
||||
const tauriConfText = fs.readFileSync(tauriConfPath, 'utf8');
|
||||
const tauriRe = /("version"\s*:\s*")([^"]+)(")/;
|
||||
if (!tauriRe.test(tauriConfText)) throw new Error('Failed to find version in tauri.conf.json');
|
||||
fs.writeFileSync(tauriConfPath, tauriConfText.replace(tauriRe, `$1${version}$3`));
|
||||
|
||||
const cargoTomlPath = path.join('packages', 'desktop', 'src-tauri', 'Cargo.toml');
|
||||
const lines = fs.readFileSync(cargoTomlPath, 'utf8').split(/\r?\n/);
|
||||
let inPackage = false, updated = false;
|
||||
const result = lines.map((line) => {
|
||||
if (/^\[package\]\s*$/.test(line)) inPackage = true;
|
||||
else if (inPackage && /^\[/.test(line)) inPackage = false;
|
||||
if (inPackage && /^version\s*=\s*".*"\s*$/.test(line)) {
|
||||
updated = true;
|
||||
return `version = "${version}"`;
|
||||
}
|
||||
return line;
|
||||
});
|
||||
if (!updated) throw new Error('Failed to update Cargo.toml version');
|
||||
fs.writeFileSync(cargoTomlPath, result.join('\n') + '\n');
|
||||
NODE
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: package-lock.json
|
||||
registry-url: "https://npm.pkg.github.com"
|
||||
scope: "@boudra"
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.rust_target }}
|
||||
|
||||
- name: Restore Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: desktop-release-macos-${{ matrix.rust_target }}
|
||||
workspaces: |
|
||||
.
|
||||
packages/desktop/src-tauri -> target
|
||||
registry-url: 'https://npm.pkg.github.com'
|
||||
scope: '@boudra'
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: npm ci
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build web app for Tauri
|
||||
- name: Set desktop package version from tag
|
||||
shell: bash
|
||||
run: |
|
||||
node <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const version = process.env.DESKTOP_VERSION;
|
||||
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
|
||||
|
||||
const packageJsonPath = path.join(process.env.DESKTOP_PACKAGE_PATH, 'package.json');
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
packageJson.version = version;
|
||||
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||
NODE
|
||||
|
||||
- name: Build web app for desktop
|
||||
run: npm run build:web --workspace=@getpaseo/app
|
||||
|
||||
- name: Build managed runtime
|
||||
run: npm run prepare:managed-runtime --workspace=@getpaseo/desktop
|
||||
|
||||
- name: Validate managed runtime bundle
|
||||
run: npm run validate:managed-runtime --workspace=@getpaseo/desktop
|
||||
|
||||
- name: Import Apple code-signing certificate
|
||||
uses: apple-actions/import-codesign-certs@v3
|
||||
with:
|
||||
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
|
||||
- name: Sign bundled managed runtime
|
||||
env:
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
run: node ./packages/desktop/scripts/sign-managed-runtime-macos.mjs
|
||||
|
||||
- name: Detect existing GitHub release state
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
env:
|
||||
@@ -164,76 +118,36 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if release_draft="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
|
||||
:
|
||||
if [[ "$release_draft" == "true" ]]; then
|
||||
release_type="draft"
|
||||
else
|
||||
release_type="release"
|
||||
fi
|
||||
else
|
||||
release_draft="false"
|
||||
release_type="release"
|
||||
fi
|
||||
echo "RELEASE_DRAFT=$release_draft" >> "$GITHUB_ENV"
|
||||
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build and publish macOS Tauri release
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
id: tauri_build
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
with:
|
||||
projectPath: packages/desktop
|
||||
tagName: ${{ env.RELEASE_TAG }}
|
||||
releaseName: Paseo ${{ env.RELEASE_TAG }}
|
||||
releaseBody: See the assets to download and install this version.
|
||||
releaseDraft: ${{ env.RELEASE_DRAFT }}
|
||||
prerelease: false
|
||||
args: --target ${{ matrix.rust_target }}
|
||||
|
||||
- name: Notarize and re-upload DMG
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build desktop release
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
CSC_NAME: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
artifacts='${{ steps.tauri_build.outputs.artifactPaths }}'
|
||||
dmg_path=$(echo "$artifacts" | jq -r '.[] | select(endswith(".dmg"))')
|
||||
if [ -z "$dmg_path" ]; then
|
||||
echo "::error::No DMG found in tauri build artifacts"
|
||||
exit 1
|
||||
publish_mode="never"
|
||||
publish_args=()
|
||||
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
|
||||
publish_mode="always"
|
||||
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||
fi
|
||||
echo "DMG: $dmg_path"
|
||||
|
||||
echo "Signing DMG..."
|
||||
codesign --force --sign "$APPLE_SIGNING_IDENTITY" --timestamp "$dmg_path"
|
||||
|
||||
echo "Submitting DMG for notarization..."
|
||||
xcrun notarytool submit "$dmg_path" \
|
||||
--apple-id "$APPLE_ID" \
|
||||
--password "$APPLE_PASSWORD" \
|
||||
--team-id "$APPLE_TEAM_ID" \
|
||||
--wait
|
||||
|
||||
echo "Stapling notarization ticket..."
|
||||
xcrun stapler staple "$dmg_path"
|
||||
|
||||
echo "Verifying..."
|
||||
spctl --assess --type install --verbose "$dmg_path"
|
||||
|
||||
echo "Replacing release asset with notarized DMG..."
|
||||
gh release upload "$RELEASE_TAG" "$dmg_path" --repo "${{ github.repository }}" --clobber
|
||||
|
||||
- name: Build macOS app (smoke only)
|
||||
if: env.IS_SMOKE_TAG == 'true'
|
||||
run: npm run tauri --workspace=@getpaseo/desktop build -- --target ${{ matrix.rust_target }} --no-bundle
|
||||
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --mac --${{ matrix.electron_arch }} "${publish_args[@]}"
|
||||
|
||||
publish-linux:
|
||||
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'linux')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-linux-v'))) }}
|
||||
@@ -253,13 +167,11 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source_tag="${SOURCE_TAG}"
|
||||
release_tag="$source_tag"
|
||||
for prefix in desktop-windows-v desktop-linux-v desktop-macos-v desktop-v; do
|
||||
if [[ "$source_tag" == ${prefix}* ]]; then
|
||||
release_tag="v${source_tag#${prefix}}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ "$source_tag" =~ ^(desktop-(windows|linux|macos)-|desktop-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
|
||||
release_tag="v${BASH_REMATCH[3]}"
|
||||
else
|
||||
release_tag="$source_tag"
|
||||
fi
|
||||
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
|
||||
|
||||
version="${release_tag#v}"
|
||||
@@ -271,89 +183,38 @@ jobs:
|
||||
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Set desktop version from tag
|
||||
shell: bash
|
||||
run: |
|
||||
node <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const version = process.env.DESKTOP_VERSION;
|
||||
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
|
||||
console.log(`Setting desktop version to ${version}`);
|
||||
|
||||
const tauriConfPath = path.join('packages', 'desktop', 'src-tauri', 'tauri.conf.json');
|
||||
const tauriConfText = fs.readFileSync(tauriConfPath, 'utf8');
|
||||
const tauriRe = /("version"\s*:\s*")([^"]+)(")/;
|
||||
if (!tauriRe.test(tauriConfText)) throw new Error('Failed to find version in tauri.conf.json');
|
||||
fs.writeFileSync(tauriConfPath, tauriConfText.replace(tauriRe, `$1${version}$3`));
|
||||
|
||||
const cargoTomlPath = path.join('packages', 'desktop', 'src-tauri', 'Cargo.toml');
|
||||
const lines = fs.readFileSync(cargoTomlPath, 'utf8').split(/\r?\n/);
|
||||
let inPackage = false, updated = false;
|
||||
const result = lines.map((line) => {
|
||||
if (/^\[package\]\s*$/.test(line)) inPackage = true;
|
||||
else if (inPackage && /^\[/.test(line)) inPackage = false;
|
||||
if (inPackage && /^version\s*=\s*".*"\s*$/.test(line)) {
|
||||
updated = true;
|
||||
return `version = "${version}"`;
|
||||
}
|
||||
return line;
|
||||
});
|
||||
if (!updated) throw new Error('Failed to update Cargo.toml version');
|
||||
fs.writeFileSync(cargoTomlPath, result.join('\n') + '\n');
|
||||
NODE
|
||||
|
||||
- name: Install Linux packaging dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libappindicator3-dev librsvg2-dev patchelf libfuse2
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: package-lock.json
|
||||
registry-url: "https://npm.pkg.github.com"
|
||||
scope: "@boudra"
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Restore Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: desktop-release-linux
|
||||
workspaces: |
|
||||
.
|
||||
packages/desktop/src-tauri -> target
|
||||
registry-url: 'https://npm.pkg.github.com'
|
||||
scope: '@boudra'
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: npm ci
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build web app for Tauri
|
||||
run: npm run build:web --workspace=@getpaseo/app
|
||||
|
||||
- name: Build managed runtime
|
||||
run: npm run prepare:managed-runtime --workspace=@getpaseo/desktop
|
||||
|
||||
- name: Validate managed runtime bundle
|
||||
run: npm run validate:managed-runtime --workspace=@getpaseo/desktop
|
||||
|
||||
- name: Strip CUDA dependencies from onnxruntime
|
||||
- name: Set desktop package version from tag
|
||||
shell: bash
|
||||
run: |
|
||||
find packages/desktop/src-tauri/resources/managed-runtime -path '*/onnxruntime-node/bin/*' \( -name '*cuda*' -o -name '*tensorrt*' \) -delete || true
|
||||
# Remove CUDA shared library references from onnxruntime .so files so linuxdeploy
|
||||
# doesn't try to bundle them (they're optional runtime deps, not needed for CPU inference)
|
||||
for f in $(find packages/desktop/src-tauri/resources/managed-runtime -path '*/onnxruntime-node/bin/*' \( -name '*.so' -o -name '*.so.*' \)); do
|
||||
for lib in $(patchelf --print-needed "$f" 2>/dev/null | grep -iE 'cublas|cudnn|cudart|cufft|curand|cusolver|cusparse|nccl|nvrtc|tensorrt|nvinfer'); do
|
||||
echo "Removing needed $lib from $f"
|
||||
patchelf --remove-needed "$lib" "$f"
|
||||
done
|
||||
done
|
||||
node <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const version = process.env.DESKTOP_VERSION;
|
||||
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
|
||||
|
||||
const packageJsonPath = path.join(process.env.DESKTOP_PACKAGE_PATH, 'package.json');
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
packageJson.version = version;
|
||||
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||
NODE
|
||||
|
||||
- name: Build web app for desktop
|
||||
run: npm run build:web --workspace=@getpaseo/app
|
||||
|
||||
- name: Detect existing GitHub release state
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
@@ -363,33 +224,30 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if release_draft="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
|
||||
:
|
||||
if [[ "$release_draft" == "true" ]]; then
|
||||
release_type="draft"
|
||||
else
|
||||
release_type="release"
|
||||
fi
|
||||
else
|
||||
release_draft="false"
|
||||
release_type="release"
|
||||
fi
|
||||
echo "RELEASE_DRAFT=$release_draft" >> "$GITHUB_ENV"
|
||||
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build and publish Linux Tauri release
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
- name: Build desktop release
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
NO_STRIP: "true"
|
||||
APPIMAGE_EXTRACT_AND_RUN: "1"
|
||||
with:
|
||||
projectPath: packages/desktop
|
||||
tagName: ${{ env.RELEASE_TAG }}
|
||||
releaseName: Paseo ${{ env.RELEASE_TAG }}
|
||||
releaseBody: See the assets to download and install this version.
|
||||
releaseDraft: ${{ env.RELEASE_DRAFT }}
|
||||
prerelease: false
|
||||
args: --bundles appimage
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
publish_mode="never"
|
||||
publish_args=()
|
||||
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
|
||||
publish_mode="always"
|
||||
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||
fi
|
||||
|
||||
- name: Build Linux app (smoke only)
|
||||
if: env.IS_SMOKE_TAG == 'true'
|
||||
run: npm run tauri --workspace=@getpaseo/desktop build -- --no-bundle
|
||||
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --linux --x64 "${publish_args[@]}"
|
||||
|
||||
publish-windows:
|
||||
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'windows')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-windows-v'))) }}
|
||||
@@ -409,13 +267,11 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source_tag="${SOURCE_TAG}"
|
||||
release_tag="$source_tag"
|
||||
for prefix in desktop-windows-v desktop-linux-v desktop-macos-v desktop-v; do
|
||||
if [[ "$source_tag" == ${prefix}* ]]; then
|
||||
release_tag="v${source_tag#${prefix}}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ "$source_tag" =~ ^(desktop-(windows|linux|macos)-|desktop-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
|
||||
release_tag="v${BASH_REMATCH[3]}"
|
||||
else
|
||||
release_tag="$source_tag"
|
||||
fi
|
||||
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
|
||||
|
||||
version="${release_tag#v}"
|
||||
@@ -427,71 +283,42 @@ jobs:
|
||||
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Set desktop version from tag
|
||||
shell: bash
|
||||
run: |
|
||||
node <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const version = process.env.DESKTOP_VERSION;
|
||||
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
|
||||
console.log(`Setting desktop version to ${version}`);
|
||||
|
||||
const tauriConfPath = path.join('packages', 'desktop', 'src-tauri', 'tauri.conf.json');
|
||||
const tauriConfText = fs.readFileSync(tauriConfPath, 'utf8');
|
||||
const tauriRe = /("version"\s*:\s*")([^"]+)(")/;
|
||||
if (!tauriRe.test(tauriConfText)) throw new Error('Failed to find version in tauri.conf.json');
|
||||
fs.writeFileSync(tauriConfPath, tauriConfText.replace(tauriRe, `$1${version}$3`));
|
||||
|
||||
const cargoTomlPath = path.join('packages', 'desktop', 'src-tauri', 'Cargo.toml');
|
||||
const lines = fs.readFileSync(cargoTomlPath, 'utf8').split(/\r?\n/);
|
||||
let inPackage = false, updated = false;
|
||||
const result = lines.map((line) => {
|
||||
if (/^\[package\]\s*$/.test(line)) inPackage = true;
|
||||
else if (inPackage && /^\[/.test(line)) inPackage = false;
|
||||
if (inPackage && /^version\s*=\s*".*"\s*$/.test(line)) {
|
||||
updated = true;
|
||||
return `version = "${version}"`;
|
||||
}
|
||||
return line;
|
||||
});
|
||||
if (!updated) throw new Error('Failed to update Cargo.toml version');
|
||||
fs.writeFileSync(cargoTomlPath, result.join('\n') + '\n');
|
||||
NODE
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: package-lock.json
|
||||
registry-url: "https://npm.pkg.github.com"
|
||||
scope: "@boudra"
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Restore Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: desktop-release-windows
|
||||
workspaces: |
|
||||
.
|
||||
packages/desktop/src-tauri -> target
|
||||
registry-url: 'https://npm.pkg.github.com'
|
||||
scope: '@boudra'
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: npm ci
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build web app for Tauri
|
||||
run: npm run build:web --workspace=@getpaseo/app
|
||||
- name: Set desktop package version from tag
|
||||
shell: bash
|
||||
run: |
|
||||
node <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
- name: Build managed runtime
|
||||
run: npm run prepare:managed-runtime --workspace=@getpaseo/desktop
|
||||
const version = process.env.DESKTOP_VERSION;
|
||||
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
|
||||
|
||||
- name: Validate managed runtime bundle
|
||||
run: npm run validate:managed-runtime --workspace=@getpaseo/desktop
|
||||
const packageJsonPath = path.join(process.env.DESKTOP_PACKAGE_PATH, 'package.json');
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
packageJson.version = version;
|
||||
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||
NODE
|
||||
|
||||
- name: Build web app for desktop
|
||||
shell: pwsh
|
||||
run: |
|
||||
$patchPath = (Get-Item "$env:GITHUB_WORKSPACE/scripts/metro-config-windows-loader-patch.cjs").FullName
|
||||
$env:NODE_OPTIONS = "--require=$patchPath"
|
||||
npm run build:web --workspace=@getpaseo/app
|
||||
|
||||
- name: Detect existing GitHub release state
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
@@ -501,31 +328,27 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if release_draft="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
|
||||
:
|
||||
if [[ "$release_draft" == "true" ]]; then
|
||||
release_type="draft"
|
||||
else
|
||||
release_type="release"
|
||||
fi
|
||||
else
|
||||
release_draft="false"
|
||||
release_type="release"
|
||||
fi
|
||||
echo "RELEASE_DRAFT=$release_draft" >> "$GITHUB_ENV"
|
||||
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build and publish Windows Tauri release
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
- name: Build desktop release
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
with:
|
||||
projectPath: packages/desktop
|
||||
tagName: ${{ env.RELEASE_TAG }}
|
||||
releaseName: Paseo ${{ env.RELEASE_TAG }}
|
||||
releaseBody: See the assets to download and install this version.
|
||||
releaseDraft: ${{ env.RELEASE_DRAFT }}
|
||||
prerelease: false
|
||||
args: --bundles nsis
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
publish_mode="never"
|
||||
publish_args=()
|
||||
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
|
||||
publish_mode="always"
|
||||
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||
fi
|
||||
|
||||
- name: Build Windows app (smoke only)
|
||||
if: env.IS_SMOKE_TAG == 'true'
|
||||
run: npm run tauri --workspace=@getpaseo/desktop build -- --no-bundle
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --win --x64 "${publish_args[@]}"
|
||||
|
||||
3
.github/workflows/server-ci.yml
vendored
3
.github/workflows/server-ci.yml
vendored
@@ -44,3 +44,6 @@ jobs:
|
||||
|
||||
- name: Test
|
||||
run: npm run test --workspace=@getpaseo/server
|
||||
env:
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -46,6 +46,9 @@ test-results/
|
||||
# Vercel
|
||||
.vercel/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
|
||||
# Misc
|
||||
*.pem
|
||||
.vercel
|
||||
|
||||
54
CHANGELOG.md
54
CHANGELOG.md
@@ -1,5 +1,53 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.30 - 2026-03-19
|
||||
|
||||
### Added
|
||||
- Added terminal tabs, split pane controls, and drop previews for workspace layouts.
|
||||
- Added a combined model selector and agent mode visuals across key UI surfaces.
|
||||
- Added Open Graph metadata improvements for richer website sharing previews.
|
||||
|
||||
### Improved
|
||||
- Improved workspace navigation with better active-workspace tracking and keyboard-driven pane interactions.
|
||||
- Improved terminal scrollbar behavior, pane focus handling, and status bar/message input spacing.
|
||||
- Improved project picker path display and general workspace UI polish.
|
||||
|
||||
### Fixed
|
||||
- Fixed agent startup reliability by tightening PATH resolution and surfacing missing provider binaries in status.
|
||||
- Fixed workspace route syncing, drag hit areas, and git diff panel header styling regressions.
|
||||
- Fixed website mobile horizontal scrolling and ensured the workspace audio module builds during EAS installs.
|
||||
|
||||
## 0.1.28 - 2026-03-15
|
||||
|
||||
### Added
|
||||
- Added OpenCode build and plan modes.
|
||||
- Added website landing pages for Claude Code, Codex, and OpenCode.
|
||||
|
||||
### Improved
|
||||
- Improved the git action menu for more reliable repository actions.
|
||||
- Improved the mobile settings screen, workspace header actions, and welcome screen presentation.
|
||||
- Updated the website hero copy and added a sponsor callout section.
|
||||
|
||||
### Fixed
|
||||
- Fixed assistant file links so they open the correct workspace files from chat.
|
||||
|
||||
## 0.1.27 - 2026-03-13
|
||||
|
||||
### Added
|
||||
- Added voice runtime with new audio engine architecture for voice interactions.
|
||||
- Added Grep tool support in Claude tool-call mapping.
|
||||
- Added ability to open workspace files directly from agent chat messages.
|
||||
- Added desktop notifications via a custom native bridge.
|
||||
|
||||
### Improved
|
||||
- Improved image picker, markdown rendering, and UI interactions.
|
||||
- Improved shell environment detection using shell-env.
|
||||
|
||||
### Fixed
|
||||
- Fixed platform-specific markdown link rendering.
|
||||
- Fixed Linux AppImage CLI resource paths.
|
||||
- Fixed Codex replacement stream being killed by stale turn notifications.
|
||||
|
||||
## 0.1.26 - 2026-03-12
|
||||
|
||||
### Added
|
||||
@@ -118,7 +166,7 @@
|
||||
- Redesigned the website get-started experience into a clearer two-step flow.
|
||||
- Simplified website GitHub navigation and changelog headings.
|
||||
- Improved app draft/new-agent UX with clearer working directory placeholder and empty-state messaging.
|
||||
- Enabled drag interactions in previously unhandled areas on the desktop (Tauri) draft screen.
|
||||
- Enabled drag interactions in previously unhandled areas on the desktop draft screen.
|
||||
- Hid empty filter groups in the left sidebar.
|
||||
|
||||
### Fixed
|
||||
@@ -140,7 +188,7 @@
|
||||
- Improved new worktree-agent defaults by prefilling CWD to the main repository.
|
||||
- Improved desktop command autocomplete behavior to match combobox interactions.
|
||||
- Improved git sync UX by simplifying sync labels and only showing Sync when a branch diverges from origin.
|
||||
- Improved desktop settings and permissions UX in Tauri.
|
||||
- Improved desktop settings and permissions UX on desktop.
|
||||
- Improved scrollbar visibility, drag interactions, tracking, and animation timing on web/desktop.
|
||||
|
||||
### Fixed
|
||||
@@ -179,7 +227,7 @@
|
||||
- Fixed stuck "send while running" recovery across app and server session handling.
|
||||
- Fixed Claude session identity preservation when reloading existing agents.
|
||||
- Fixed combobox option behavior and related interactions.
|
||||
- Fixed Tauri file-drop listener cleanup to avoid uncaught unlisten errors.
|
||||
- Fixed desktop file-drop listener cleanup to avoid uncaught unlisten errors.
|
||||
- Fixed web tool-detail wheel event routing at scroll edges.
|
||||
|
||||
## 0.1.7 - 2026-02-16
|
||||
|
||||
@@ -12,7 +12,7 @@ This is an npm workspace monorepo:
|
||||
- `packages/app` — Mobile + web client (Expo)
|
||||
- `packages/cli` — Docker-style CLI (`paseo run/ls/logs/wait`)
|
||||
- `packages/relay` — E2E encrypted relay for remote access
|
||||
- `packages/desktop` — Tauri desktop wrapper
|
||||
- `packages/desktop` — Electron desktop wrapper
|
||||
- `packages/website` — Marketing site (paseo.sh)
|
||||
|
||||
## Documentation
|
||||
|
||||
22
README.md
22
README.md
@@ -4,7 +4,7 @@
|
||||
|
||||
<h1 align="center">Paseo</h1>
|
||||
|
||||
<p align="center">Manage coding agents from your phone and desktop.</p>
|
||||
<p align="center">One interface for all your coding agents.</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://paseo.sh/paseo-mockup.png" alt="Paseo app screenshot" width="100%">
|
||||
@@ -12,19 +12,27 @@
|
||||
|
||||
---
|
||||
|
||||
> [!WARNING]
|
||||
> **Early development** — Features may break or change without notice. Use at your own risk.
|
||||
Run agents in parallel on your own machines. Ship from your phone or your desk.
|
||||
|
||||
Paseo is a self-hosted daemon for Claude Code, Codex, and OpenCode. Agents run on your machine with your full dev environment. Connect from phone, desktop, or web.
|
||||
- **Self-hosted** — Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills.
|
||||
- **Multi-provider** — Claude Code, Codex, and OpenCode through the same interface. Pick the right model for each job.
|
||||
- **Voice control** — Dictate tasks or talk through problems in voice mode. Hands-free when you need it.
|
||||
- **Cross-device** — iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal.
|
||||
|
||||
## Getting Started
|
||||
|
||||
Download the desktop app from [paseo.sh](https://paseo.sh) or the [GitHub releases page](https://github.com/getpaseo/paseo/releases) — it bundles the daemon so there's nothing else to install.
|
||||
|
||||
### Headless / server mode
|
||||
|
||||
To run the daemon on a remote or headless machine:
|
||||
|
||||
```bash
|
||||
npm install -g @getpaseo/cli
|
||||
paseo
|
||||
```
|
||||
|
||||
Then open the app and connect to your daemon.
|
||||
Then connect from the desktop app, mobile app, or CLI.
|
||||
|
||||
For full setup and configuration, see:
|
||||
- [Docs](https://paseo.sh/docs)
|
||||
@@ -36,7 +44,7 @@ Quick monorepo package map:
|
||||
- `packages/server`: Paseo daemon (agent process orchestration, WebSocket API, MCP server)
|
||||
- `packages/app`: Expo client (iOS, Android, web)
|
||||
- `packages/cli`: `paseo` CLI for daemon and agent workflows
|
||||
- `packages/desktop`: Tauri desktop app
|
||||
- `packages/desktop`: Electron desktop app
|
||||
- `packages/relay`: Relay package for remote connectivity
|
||||
- `packages/website`: Marketing site and documentation (`paseo.sh`)
|
||||
|
||||
@@ -57,4 +65,4 @@ npm run typecheck
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
AGPL-3.0
|
||||
|
||||
@@ -20,7 +20,7 @@ From repo root:
|
||||
```bash
|
||||
npm run android:development # Debug build
|
||||
npm run android:production # Release build
|
||||
npm run android:clean # Clean native project
|
||||
npm run android:clear # Remove generated Android project
|
||||
```
|
||||
|
||||
Or from `packages/app`:
|
||||
@@ -34,8 +34,8 @@ APP_VARIANT=development npx expo run:android --variant=debug
|
||||
APP_VARIANT=production npx expo prebuild --platform android --non-interactive
|
||||
APP_VARIANT=production npx expo run:android --variant=release
|
||||
|
||||
# Clean
|
||||
npx expo prebuild --platform android --clean --non-interactive
|
||||
# Clear generated Android project
|
||||
rm -rf android
|
||||
```
|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -9,7 +9,7 @@ Your code never leaves your machine. Paseo is local-first.
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ Mobile App │ │ CLI │ │ Desktop App │
|
||||
│ (Expo) │ │ (Commander) │ │ (Tauri) │
|
||||
│ (Expo) │ │ (Commander) │ │ (Electron) │
|
||||
└──────┬───────┘ └──────┬──────┘ └──────┬──────┘
|
||||
│ │ │
|
||||
│ WebSocket │ WebSocket │ Managed subprocess
|
||||
@@ -90,9 +90,9 @@ Enables remote access when the daemon is behind a firewall.
|
||||
|
||||
See [SECURITY.md](../SECURITY.md) for the full threat model.
|
||||
|
||||
### `packages/desktop` — Desktop app (Tauri)
|
||||
### `packages/desktop` — Desktop app (Electron)
|
||||
|
||||
Tauri wrapper for macOS, Linux, and Windows.
|
||||
Electron wrapper for macOS, Linux, and Windows.
|
||||
|
||||
- Can spawn the daemon as a managed subprocess
|
||||
- Native file access for workspace integration
|
||||
@@ -180,5 +180,5 @@ $PASEO_HOME/
|
||||
## Deployment models
|
||||
|
||||
1. **Local daemon** (default): `paseo daemon start` on `127.0.0.1:6767`
|
||||
2. **Managed desktop**: Tauri app spawns daemon as subprocess
|
||||
2. **Managed desktop**: Electron app spawns daemon as subprocess
|
||||
3. **Remote + relay**: Daemon behind firewall, relay bridges with E2E encryption
|
||||
|
||||
@@ -31,11 +31,30 @@ npm run release:finalize # Publish npm, promote draft to published
|
||||
- `draft-release:patch` creates the GitHub Release as a draft so desktop assets, APK uploads, and synced notes attach to it
|
||||
- `release:finalize` publishes npm and promotes the same draft release
|
||||
- Use the same semver tag for both; don't cut a second tag
|
||||
- Desktop assets now come from the Electron package at `packages/desktop`
|
||||
|
||||
## Fixing a failed release build
|
||||
|
||||
**NEVER bump the version to fix a build problem.** New versions are reserved for meaningful product changes (features, fixes, improvements). Build/CI failures are fixed on the current version.
|
||||
|
||||
To retry a failed workflow for an existing tag:
|
||||
|
||||
1. **Retry via `workflow_dispatch`** — all release workflows support `workflow_dispatch` with a `tag` input:
|
||||
```bash
|
||||
gh workflow run "Desktop Release" -f tag=v0.1.28 # all platforms
|
||||
gh workflow run "Desktop Release" -f tag=v0.1.28 -f platform=macos # single platform
|
||||
gh workflow run "Android APK Release" -f tag=v0.1.28
|
||||
gh workflow run "Deploy App" # no tag input needed
|
||||
```
|
||||
2. **Platform-specific retry tags** (desktop only) — push a tag like `desktop-macos-v0.1.28` to rebuild just that platform against the release tag's code
|
||||
|
||||
If the fix requires a code change (e.g. a broken build script), commit the fix to `main` and use `workflow_dispatch` pointing at the existing tag — the workflow checks out the tag ref, but for build-tooling fixes you may need to point it at `main` or cherry-pick the fix onto the tag.
|
||||
|
||||
## Notes
|
||||
|
||||
- `version:all:*` bumps root + syncs workspace versions and `@getpaseo/*` dependency versions
|
||||
- `release:prepare` refreshes workspace `node_modules` links to prevent stale types
|
||||
- `npm run dev:desktop` and `npm run build:desktop` target the Electron desktop package in `packages/desktop`
|
||||
- If `release:publish` partially fails, re-run it — npm skips already-published versions
|
||||
- Website Mac download CTA URL derives from `packages/website/package.json` version at build time
|
||||
|
||||
|
||||
7679
package-lock.json
generated
7679
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.26",
|
||||
"version": "0.1.31",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/expo-two-way-audio",
|
||||
"packages/server",
|
||||
"packages/app",
|
||||
"packages/relay",
|
||||
@@ -28,6 +29,7 @@
|
||||
"android:development": "npm run android:development --workspace=@getpaseo/app",
|
||||
"android:production": "npm run android:production --workspace=@getpaseo/app",
|
||||
"android:release": "npm run android:production --workspace=@getpaseo/app",
|
||||
"android:clear": "npm run android:clear --workspace=@getpaseo/app",
|
||||
"android:clean": "npm run android:clean --workspace=@getpaseo/app",
|
||||
"ios": "npm run ios --workspace=@getpaseo/app",
|
||||
"web": "npm run web --workspace=@getpaseo/app",
|
||||
@@ -54,11 +56,12 @@
|
||||
"release:major": "npm run version:all:major && npm run release:check && npm run release:publish && npm run release:push"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.2.1",
|
||||
"prettier": "^3.5.3",
|
||||
"get-port-cli": "^3.0.0",
|
||||
"knip": "^5.82.1",
|
||||
"patch-package": "^8.0.1",
|
||||
"react": "19.1.4",
|
||||
"react-dom": "19.1.4",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
||||
|
||||
@@ -100,6 +100,9 @@ export default {
|
||||
output: "single",
|
||||
favicon: "./assets/images/favicon.png",
|
||||
},
|
||||
autolinking: {
|
||||
searchPaths: ["../../node_modules", "./node_modules"],
|
||||
},
|
||||
plugins: [
|
||||
"expo-router",
|
||||
[
|
||||
@@ -143,6 +146,7 @@ export default {
|
||||
experiments: {
|
||||
typedRoutes: true,
|
||||
reactCompiler: true,
|
||||
autolinkingModuleResolution: true,
|
||||
},
|
||||
extra: {
|
||||
router: {},
|
||||
|
||||
BIN
packages/app/assets/audio/thinking-tone.wav
Normal file
BIN
packages/app/assets/audio/thinking-tone.wav
Normal file
Binary file not shown.
@@ -1,183 +0,0 @@
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
connectDaemonClient,
|
||||
createReplyTurn,
|
||||
expectDetachedFromBottom,
|
||||
expectNearBottom,
|
||||
getChatContainerKey,
|
||||
readScrollMetrics,
|
||||
scrollUpFromBottom,
|
||||
seedBottomAnchorAgent,
|
||||
waitForAgentReady,
|
||||
waitForContentGrowth,
|
||||
} from "./helpers/agent-bottom-anchor";
|
||||
|
||||
test.describe.configure({ timeout: 180000 });
|
||||
|
||||
async function openWorkspaceAgentTab(page: Page, agentId: string) {
|
||||
const tab = page.getByTestId(`workspace-tab-agent_${agentId}`).first();
|
||||
await expect(tab).toBeVisible({ timeout: 30000 });
|
||||
await tab.click();
|
||||
}
|
||||
|
||||
test("direct load and refresh land at the bottom for history-backed chats", async ({
|
||||
page,
|
||||
}) => {
|
||||
const repo = await createTempGitRepo("paseo-e2e-bottom-anchor-direct-");
|
||||
const client = await connectDaemonClient();
|
||||
|
||||
try {
|
||||
const agent = await seedBottomAnchorAgent({
|
||||
client,
|
||||
cwd: repo.path,
|
||||
title: `bottom-anchor-direct-${Date.now()}`,
|
||||
turnCount: 4,
|
||||
});
|
||||
|
||||
await page.goto(agent.url, { waitUntil: "domcontentloaded" });
|
||||
await openWorkspaceAgentTab(page, agent.id);
|
||||
await waitForAgentReady(page, agent.expectedTailText);
|
||||
await expectNearBottom(page);
|
||||
|
||||
await page.reload({ waitUntil: "commit" });
|
||||
await openWorkspaceAgentTab(page, agent.id);
|
||||
await waitForAgentReady(page, agent.expectedTailText);
|
||||
await expectNearBottom(page);
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("revisiting a loaded chat restores bottom anchoring", async ({
|
||||
page,
|
||||
}) => {
|
||||
const repo = await createTempGitRepo("paseo-e2e-bottom-anchor-switch-");
|
||||
const client = await connectDaemonClient();
|
||||
|
||||
try {
|
||||
const agent = await seedBottomAnchorAgent({
|
||||
client,
|
||||
cwd: repo.path,
|
||||
title: `bottom-anchor-switch-${Date.now()}`,
|
||||
turnCount: 4,
|
||||
});
|
||||
|
||||
await page.goto(agent.url, { waitUntil: "domcontentloaded" });
|
||||
await openWorkspaceAgentTab(page, agent.id);
|
||||
await waitForAgentReady(page, agent.expectedTailText);
|
||||
await expectNearBottom(page);
|
||||
|
||||
await page.getByTestId("sidebar-new-agent").first().click();
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
await openWorkspaceAgentTab(page, agent.id);
|
||||
await waitForAgentReady(page, agent.expectedTailText);
|
||||
await expectNearBottom(page);
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("sticky mode stays pinned through composer growth and viewport resize, but detached mode does not fight streamed updates", async ({
|
||||
page,
|
||||
}) => {
|
||||
const repo = await createTempGitRepo("paseo-e2e-bottom-anchor-sticky-");
|
||||
const client = await connectDaemonClient();
|
||||
|
||||
try {
|
||||
const agent = await seedBottomAnchorAgent({
|
||||
client,
|
||||
cwd: repo.path,
|
||||
title: `bottom-anchor-sticky-${Date.now()}`,
|
||||
turnCount: 10,
|
||||
});
|
||||
|
||||
await page.setViewportSize({ width: 1320, height: 920 });
|
||||
await page.goto(agent.url, { waitUntil: "domcontentloaded" });
|
||||
await openWorkspaceAgentTab(page, agent.id);
|
||||
await waitForAgentReady(page, agent.expectedTailText);
|
||||
await expectNearBottom(page);
|
||||
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
await composer.click();
|
||||
for (let index = 0; index < 6; index += 1) {
|
||||
await composer.pressSequentially(`composer growth line ${index + 1}`);
|
||||
if (index < 5) {
|
||||
await page.keyboard.press("Shift+Enter");
|
||||
}
|
||||
}
|
||||
await expectNearBottom(page);
|
||||
await expect(page.getByTestId("scroll-to-bottom-button")).toHaveCount(0);
|
||||
|
||||
await page.setViewportSize({ width: 820, height: 760 });
|
||||
await expectNearBottom(page);
|
||||
|
||||
await scrollUpFromBottom(page, 720);
|
||||
await expectDetachedFromBottom(page);
|
||||
const beforeExternalUpdate = await readScrollMetrics(page);
|
||||
|
||||
const externalTurn = createReplyTurn(`external-stream-${Date.now()}`);
|
||||
await client.sendAgentMessage(agent.id, externalTurn.message);
|
||||
await waitForContentGrowth(page, beforeExternalUpdate.contentHeight);
|
||||
const finish = await client.waitForFinish(agent.id, 120000);
|
||||
expect(finish.status).toBe("idle");
|
||||
await expectDetachedFromBottom(page);
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("web partial virtualization keeps bottom anchoring stable across direct load, refresh, and resize", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript(() => {
|
||||
(window as typeof window & {
|
||||
__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: number;
|
||||
__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS?: number;
|
||||
}).__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 6;
|
||||
(window as typeof window & {
|
||||
__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: number;
|
||||
__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS?: number;
|
||||
}).__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS = 4;
|
||||
});
|
||||
|
||||
const repo = await createTempGitRepo("paseo-e2e-bottom-anchor-virtualized-");
|
||||
const client = await connectDaemonClient();
|
||||
|
||||
try {
|
||||
const agent = await seedBottomAnchorAgent({
|
||||
client,
|
||||
cwd: repo.path,
|
||||
title: `bottom-anchor-virtualized-${Date.now()}`,
|
||||
turnCount: 4,
|
||||
});
|
||||
|
||||
await page.goto(agent.url, { waitUntil: "domcontentloaded" });
|
||||
await openWorkspaceAgentTab(page, agent.id);
|
||||
await waitForAgentReady(page, agent.expectedTailText);
|
||||
await expect
|
||||
.poll(async () => await getChatContainerKey(page))
|
||||
.toBe("web-partial-virtualized");
|
||||
await expectNearBottom(page);
|
||||
|
||||
await page.reload({ waitUntil: "commit" });
|
||||
await openWorkspaceAgentTab(page, agent.id);
|
||||
await waitForAgentReady(page, agent.expectedTailText);
|
||||
await expect
|
||||
.poll(async () => await getChatContainerKey(page))
|
||||
.toBe("web-partial-virtualized");
|
||||
await expectNearBottom(page);
|
||||
|
||||
await page.setViewportSize({ width: 780, height: 720 });
|
||||
await expectNearBottom(page);
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from "./helpers/app";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
|
||||
test("agent details sheet shows IDs and copy toast", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const repo = await createTempGitRepo();
|
||||
const prompt = "Respond with exactly: Hello";
|
||||
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
await createAgent(page, prompt);
|
||||
|
||||
await page.getByTestId("agent-overflow-menu").click();
|
||||
await expect(page.getByTestId("agent-details-sheet")).toBeVisible();
|
||||
|
||||
await expect(page.getByTestId("agent-details-agent-id")).toBeVisible();
|
||||
await expect(page.getByTestId("agent-details-agent-id-value")).not.toHaveText(
|
||||
"Not available"
|
||||
);
|
||||
|
||||
await expect(page.getByTestId("agent-details-persistence-session-id")).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("agent-details-persistence-session-id-value")
|
||||
).not.toHaveText("Not available", { timeout: 90_000 });
|
||||
|
||||
await page.getByTestId("agent-details-agent-id").click();
|
||||
await expect(page.getByTestId("app-toast")).toBeVisible();
|
||||
await expect(page.getByTestId("app-toast-message")).toHaveText(/copied/i);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,197 +0,0 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
const SERVER_ID =
|
||||
process.env.PLAYWRIGHT_REPRO_SERVER_ID ?? "srv_ETXtcjYRGrCI";
|
||||
const AGENT_ID =
|
||||
process.env.PLAYWRIGHT_REPRO_AGENT_ID ??
|
||||
"3533e6c3-c0b3-4310-b85c-eb07cbb501a0";
|
||||
const APP_BASE_URL = process.env.PLAYWRIGHT_REPRO_BASE_URL ?? "http://localhost:8081";
|
||||
const DAEMON_ENDPOINT =
|
||||
process.env.PLAYWRIGHT_REPRO_DAEMON_ENDPOINT ?? "127.0.0.1:6767";
|
||||
const SUBMIT_TEXT = process.env.PLAYWRIGHT_REPRO_MESSAGE ?? "hello";
|
||||
const AGENT_URL = `${APP_BASE_URL}/h/${SERVER_ID}/agent/${AGENT_ID}`;
|
||||
const NEAR_BOTTOM_THRESHOLD_PX = 64;
|
||||
|
||||
type ScrollMetrics = {
|
||||
offsetY: number;
|
||||
contentHeight: number;
|
||||
viewportHeight: number;
|
||||
distanceFromBottom: number;
|
||||
};
|
||||
|
||||
test.use({ browserName: "firefox" });
|
||||
|
||||
function seedDaemonRegistryScript(params: {
|
||||
serverId: string;
|
||||
endpoint: string;
|
||||
nowIso: string;
|
||||
}) {
|
||||
const daemon = {
|
||||
serverId: params.serverId,
|
||||
label: "localhost",
|
||||
connections: [
|
||||
{
|
||||
id: `direct:${params.endpoint}`,
|
||||
type: "direct",
|
||||
endpoint: params.endpoint,
|
||||
},
|
||||
],
|
||||
preferredConnectionId: `direct:${params.endpoint}`,
|
||||
createdAt: params.nowIso,
|
||||
updatedAt: params.nowIso,
|
||||
};
|
||||
|
||||
localStorage.setItem("@paseo:e2e", "1");
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
|
||||
localStorage.setItem(
|
||||
"@paseo:create-agent-preferences",
|
||||
JSON.stringify({
|
||||
serverId: params.serverId,
|
||||
provider: "codex",
|
||||
providerPreferences: {
|
||||
claude: { model: "haiku" },
|
||||
codex: { model: "gpt-5.1-codex-mini", thinkingOptionId: "low" },
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function readScrollMetrics(page: Page): Promise<ScrollMetrics> {
|
||||
return page.getByTestId("agent-chat-scroll").evaluate((root: Element) => {
|
||||
const rootElement = root as HTMLElement;
|
||||
const candidates = [rootElement, ...Array.from(rootElement.querySelectorAll("*"))];
|
||||
const scrollElement =
|
||||
candidates.find(
|
||||
(element) =>
|
||||
element instanceof HTMLElement &&
|
||||
element.scrollHeight - element.clientHeight > 1
|
||||
) ?? rootElement;
|
||||
|
||||
const offsetY = Math.max(0, scrollElement.scrollTop);
|
||||
const contentHeight = Math.max(0, scrollElement.scrollHeight);
|
||||
const viewportHeight = Math.max(0, scrollElement.clientHeight);
|
||||
const distanceFromBottom = Math.max(
|
||||
0,
|
||||
contentHeight - (offsetY + viewportHeight)
|
||||
);
|
||||
|
||||
return {
|
||||
offsetY,
|
||||
contentHeight,
|
||||
viewportHeight,
|
||||
distanceFromBottom,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function scrollUpFromBottom(
|
||||
page: Page,
|
||||
pixels: number
|
||||
): Promise<void> {
|
||||
await page.getByTestId("agent-chat-scroll").evaluate(
|
||||
(root: Element, amount: number) => {
|
||||
const rootElement = root as HTMLElement;
|
||||
const candidates = [
|
||||
rootElement,
|
||||
...Array.from(rootElement.querySelectorAll("*")),
|
||||
];
|
||||
const scrollElement =
|
||||
candidates.find(
|
||||
(element) =>
|
||||
element instanceof HTMLElement &&
|
||||
element.scrollHeight - element.clientHeight > 1
|
||||
) ?? rootElement;
|
||||
|
||||
const bottomOffset = Math.max(
|
||||
0,
|
||||
scrollElement.scrollHeight - scrollElement.clientHeight
|
||||
);
|
||||
scrollElement.scrollTop = Math.max(0, bottomOffset - amount);
|
||||
},
|
||||
pixels
|
||||
);
|
||||
}
|
||||
|
||||
test("repro: submit while scrolled up should stay anchored to bottom (Firefox)", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const userAgent = await page.evaluate(() => navigator.userAgent);
|
||||
expect(userAgent).toContain("Firefox");
|
||||
|
||||
await page.addInitScript(seedDaemonRegistryScript, {
|
||||
serverId: SERVER_ID,
|
||||
endpoint: DAEMON_ENDPOINT,
|
||||
nowIso: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await page.goto(AGENT_URL, { waitUntil: "domcontentloaded" });
|
||||
await expect(page.getByTestId("agent-chat-scroll")).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
// Require enough history so the repro actually scrolls away from bottom.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const metrics = await readScrollMetrics(page);
|
||||
return Math.max(0, metrics.contentHeight - metrics.viewportHeight);
|
||||
})
|
||||
.toBeGreaterThan(300);
|
||||
|
||||
await scrollUpFromBottom(page, 900);
|
||||
await page.waitForTimeout(250);
|
||||
const beforeSubmit = await readScrollMetrics(page);
|
||||
const beforePath = testInfo.outputPath("before-submit.png");
|
||||
await page.screenshot({ path: beforePath, fullPage: true });
|
||||
await testInfo.attach("before-submit", {
|
||||
path: beforePath,
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
await page.getByRole("textbox", { name: "Message agent..." }).fill(SUBMIT_TEXT);
|
||||
await page.getByRole("textbox", { name: "Message agent..." }).press("Enter");
|
||||
await page.waitForTimeout(1200);
|
||||
|
||||
const afterSubmit = await readScrollMetrics(page);
|
||||
const afterPath = testInfo.outputPath("after-submit.png");
|
||||
await page.screenshot({ path: afterPath, fullPage: true });
|
||||
await testInfo.attach("after-submit", {
|
||||
path: afterPath,
|
||||
contentType: "image/png",
|
||||
});
|
||||
await testInfo.attach("firefox-scroll-metrics", {
|
||||
body: JSON.stringify(
|
||||
{
|
||||
url: AGENT_URL,
|
||||
submitText: SUBMIT_TEXT,
|
||||
thresholdPx: NEAR_BOTTOM_THRESHOLD_PX,
|
||||
beforeScreenshot: beforePath,
|
||||
afterScreenshot: afterPath,
|
||||
beforeSubmit,
|
||||
afterSubmit,
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
contentType: "application/json",
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[firefox-scroll-repro] ${JSON.stringify(
|
||||
{
|
||||
submitText: SUBMIT_TEXT,
|
||||
beforeSubmit,
|
||||
afterSubmit,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}`
|
||||
);
|
||||
|
||||
expect(beforeSubmit.distanceFromBottom).toBeGreaterThan(NEAR_BOTTOM_THRESHOLD_PX);
|
||||
expect(afterSubmit.distanceFromBottom).toBeLessThanOrEqual(
|
||||
NEAR_BOTTOM_THRESHOLD_PX
|
||||
);
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
|
||||
test('agent timeline hydrates after reload via fetch_agent_timeline_request', async ({ page }) => {
|
||||
const repo = await createTempGitRepo();
|
||||
const prompt = 'Respond with exactly: TIMELINE_HYDRATION_OK';
|
||||
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
await createAgent(page, prompt);
|
||||
await expect(page.getByText(prompt, { exact: true }).first()).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
await page.reload({ waitUntil: 'commit' });
|
||||
await expect(page).toHaveURL(/\/agent\//);
|
||||
await expect(page.getByTestId('agent-loading')).toHaveCount(0, { timeout: 30000 });
|
||||
await expect(page.getByText(prompt, { exact: true }).first()).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,440 +0,0 @@
|
||||
import path from 'node:path';
|
||||
import { appendFile, mkdtemp, rm, writeFile, realpath } from 'node:fs/promises';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { test, expect, type Page } from './fixtures';
|
||||
import {
|
||||
createAgent,
|
||||
ensureHostSelected,
|
||||
gotoHome,
|
||||
setWorkingDirectory,
|
||||
} from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
|
||||
test.describe.configure({ mode: 'serial', timeout: 120000 });
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function getChangesScope(page: Page) {
|
||||
return page.locator('[data-testid="explorer-content-area"]:visible').first();
|
||||
}
|
||||
|
||||
function getChangesHeader(page: Page) {
|
||||
return getChangesScope(page).getByTestId('changes-header');
|
||||
}
|
||||
|
||||
async function ensureExplorerTabsVisible(page: Page) {
|
||||
const changesTab = page.getByTestId('explorer-tab-changes').first();
|
||||
if (await changesTab.isVisible().catch(() => false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const toggle = page
|
||||
.getByRole('button', { name: /open explorer|close explorer|toggle explorer/i })
|
||||
.first();
|
||||
await expect(toggle).toBeVisible({ timeout: 10000 });
|
||||
for (let attempt = 0; attempt < 4; attempt += 1) {
|
||||
if (await changesTab.isVisible().catch(() => false)) {
|
||||
return;
|
||||
}
|
||||
await toggle.click();
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
await expect(changesTab).toBeVisible({ timeout: 30000 });
|
||||
}
|
||||
|
||||
async function selectChangesView(page: Page, view: 'working' | 'base') {
|
||||
// Defensive: close any open dropdown menus (their backdrops intercept clicks).
|
||||
const primaryBackdrop = page.getByTestId('changes-primary-cta-menu-backdrop');
|
||||
if (await primaryBackdrop.isVisible().catch(() => false)) {
|
||||
await primaryBackdrop.click({ force: true });
|
||||
await expect(primaryBackdrop).toHaveCount(0);
|
||||
}
|
||||
const overflowBackdrop = page.getByTestId('changes-overflow-content-backdrop');
|
||||
if (await overflowBackdrop.isVisible().catch(() => false)) {
|
||||
await overflowBackdrop.click({ force: true });
|
||||
await expect(overflowBackdrop).toHaveCount(0);
|
||||
}
|
||||
const diffModeBackdrop = page.getByTestId('changes-diff-status-menu-backdrop');
|
||||
if (await diffModeBackdrop.isVisible().catch(() => false)) {
|
||||
await diffModeBackdrop.click({ force: true });
|
||||
await expect(diffModeBackdrop).toHaveCount(0);
|
||||
}
|
||||
|
||||
const scope = getChangesScope(page);
|
||||
const modeToggle = scope.getByTestId('changes-diff-status').first();
|
||||
const expected = view === 'working' ? 'Uncommitted' : 'Committed';
|
||||
if (!(await modeToggle.isVisible().catch(() => false))) {
|
||||
return;
|
||||
}
|
||||
const current = ((await modeToggle.innerText().catch(() => '')) ?? '').trim();
|
||||
if (current !== expected) {
|
||||
await modeToggle.click();
|
||||
const menu = page.getByTestId('changes-diff-status-menu');
|
||||
await expect(menu).toBeVisible({ timeout: 10000 });
|
||||
const optionTestId =
|
||||
view === 'working' ? 'changes-diff-mode-uncommitted' : 'changes-diff-mode-committed';
|
||||
await page.getByTestId(optionTestId).click({ force: true });
|
||||
}
|
||||
await expect(modeToggle).toContainText(expected, { timeout: 10000 });
|
||||
}
|
||||
|
||||
async function openChangesPrimaryMenu(page: Page) {
|
||||
const scope = getChangesScope(page);
|
||||
const caret = scope.getByTestId('changes-primary-cta-caret').first();
|
||||
await expect(caret).toBeVisible();
|
||||
await caret.click();
|
||||
// Menu content is rendered via a portal, so don't scope it to the explorer content area.
|
||||
await expect(page.getByTestId('changes-primary-cta-menu')).toBeVisible();
|
||||
}
|
||||
|
||||
async function openChangesPanel(page: Page, options?: { expectGit?: boolean }) {
|
||||
await ensureExplorerTabsVisible(page);
|
||||
const changesHeader = getChangesHeader(page);
|
||||
if (!(await changesHeader.isVisible())) {
|
||||
await page.getByTestId('explorer-tab-changes').first().click();
|
||||
}
|
||||
await expect(changesHeader).toBeVisible({ timeout: 30000 });
|
||||
if (options?.expectGit === false) {
|
||||
return;
|
||||
}
|
||||
const changesScope = getChangesScope(page);
|
||||
await expect(changesScope.getByTestId('changes-not-git')).toHaveCount(0, {
|
||||
timeout: 30000,
|
||||
});
|
||||
await expect(changesScope.getByTestId('changes-branch')).not.toHaveText('Not a git repository', {
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForAgentTurnToSettle(page: Page, timeout = 90000) {
|
||||
const stopButton = page.getByRole('button', { name: /stop agent|stop/i }).first();
|
||||
if (!(await stopButton.isVisible().catch(() => false))) {
|
||||
return;
|
||||
}
|
||||
await expect(stopButton).not.toBeVisible({ timeout });
|
||||
}
|
||||
|
||||
async function createAgentAndWait(page: Page, message: string) {
|
||||
await createAgent(page, message);
|
||||
}
|
||||
|
||||
async function selectAttachWorktree(page: Page, branchName: string) {
|
||||
const trigger = page.getByTestId('worktree-select-trigger').first();
|
||||
await expect(trigger).toBeVisible({ timeout: 30000 });
|
||||
await trigger.click();
|
||||
|
||||
const menu = page.getByTestId('combobox-desktop-container').first();
|
||||
await expect(menu).toBeVisible({ timeout: 10000 });
|
||||
const searchInput = page.getByRole('textbox', { name: /search worktrees/i }).first();
|
||||
if (await searchInput.isVisible().catch(() => false)) {
|
||||
await searchInput.fill(branchName);
|
||||
}
|
||||
|
||||
const preferredOption = menu
|
||||
.getByText(new RegExp(`^${escapeRegex(branchName)}$`, 'i'))
|
||||
.first();
|
||||
await expect(preferredOption).toBeVisible({ timeout: 10000 });
|
||||
await preferredOption.click({ force: true });
|
||||
await expect(menu).toHaveCount(0);
|
||||
await expect(trigger).toContainText(branchName, { timeout: 30000 });
|
||||
}
|
||||
|
||||
async function enableCreateWorktree(page: Page) {
|
||||
const trigger = page.getByTestId('worktree-select-trigger').first();
|
||||
await expect(trigger).toBeVisible({ timeout: 30000 });
|
||||
|
||||
const currentValue = ((await trigger.innerText().catch(() => '')) ?? '').trim();
|
||||
if (/Create new worktree/i.test(currentValue)) {
|
||||
await expect(page.getByTestId('worktree-base-branch-trigger')).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await trigger.click();
|
||||
const menu = page.getByTestId('combobox-desktop-container').first();
|
||||
await expect(menu).toBeVisible({ timeout: 10000 });
|
||||
const createOption = menu.getByText('Create new worktree', { exact: true }).first();
|
||||
await expect(createOption).toBeVisible({ timeout: 10000 });
|
||||
await createOption.click({ force: true });
|
||||
await expect(menu).toHaveCount(0);
|
||||
await expect(trigger).toContainText('Create new worktree', { timeout: 30000 });
|
||||
await expect(page.getByTestId('worktree-base-branch-trigger')).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshUncommittedMode(page: Page) {
|
||||
await selectChangesView(page, 'base');
|
||||
await selectChangesView(page, 'working');
|
||||
}
|
||||
|
||||
async function refreshChangesTab(page: Page) {
|
||||
await ensureExplorerTabsVisible(page);
|
||||
await page.getByTestId('explorer-tab-files').first().click();
|
||||
await page.getByTestId('explorer-tab-changes').first().click();
|
||||
}
|
||||
|
||||
function normalizeTmpPath(value: string) {
|
||||
if (value.startsWith('/var/')) {
|
||||
return `/private${value}`;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
type GitWorktreeEntry = {
|
||||
worktreePath: string;
|
||||
branchRef: string | null;
|
||||
};
|
||||
|
||||
function parseGitWorktreeList(raw: string): GitWorktreeEntry[] {
|
||||
const blocks = raw
|
||||
.split(/\n\s*\n/g)
|
||||
.map((block) => block.trim())
|
||||
.filter((block) => block.length > 0);
|
||||
|
||||
const entries: GitWorktreeEntry[] = [];
|
||||
for (const block of blocks) {
|
||||
const worktreeMatch = block.match(/^worktree (.+)$/m);
|
||||
if (!worktreeMatch) {
|
||||
continue;
|
||||
}
|
||||
const branchMatch = block.match(/^branch (.+)$/m);
|
||||
entries.push({
|
||||
worktreePath: worktreeMatch[1].trim(),
|
||||
branchRef: branchMatch ? branchMatch[1].trim() : null,
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function waitForCreatedWorktree(repoPath: string, timeoutMs = 30000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const normalizedRepoPath = normalizeTmpPath(repoPath);
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const output = execSync('git worktree list --porcelain', {
|
||||
cwd: repoPath,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
const entries = parseGitWorktreeList(output);
|
||||
const candidate = entries.find((entry) => {
|
||||
const normalizedWorktreePath = normalizeTmpPath(entry.worktreePath);
|
||||
if (normalizedWorktreePath === normalizedRepoPath) {
|
||||
return false;
|
||||
}
|
||||
if (!entry.branchRef) {
|
||||
return false;
|
||||
}
|
||||
return !/\/main$/i.test(entry.branchRef);
|
||||
});
|
||||
|
||||
if (candidate) {
|
||||
const branchName = candidate.branchRef?.split('/').filter(Boolean).pop();
|
||||
if (branchName) {
|
||||
return {
|
||||
worktreePath: candidate.worktreePath,
|
||||
branchName,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore transient git worktree read errors while polling.
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for a non-main worktree under ${repoPath}`);
|
||||
}
|
||||
|
||||
test('checkout-first Changes panel ship loop', async ({ page }) => {
|
||||
const repo = await createTempGitRepo('paseo-e2e-', { withRemote: true });
|
||||
const nonGitDir = await mkdtemp(path.join(tmpdir(), 'paseo-e2e-non-git-'));
|
||||
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
|
||||
await enableCreateWorktree(page);
|
||||
await createAgentAndWait(page, 'Respond with exactly: READY');
|
||||
await waitForAgentTurnToSettle(page);
|
||||
|
||||
await openChangesPanel(page);
|
||||
const branchLabelLocator = getChangesScope(page).getByTestId('changes-branch');
|
||||
await expect
|
||||
.poll(async () => (await branchLabelLocator.innerText()).trim(), { timeout: 30000 })
|
||||
.not.toBe('Unknown');
|
||||
const branchNameFromUi = (await branchLabelLocator.innerText()).trim();
|
||||
expect(branchNameFromUi.length).toBeGreaterThan(0);
|
||||
|
||||
const { worktreePath: firstCwd, branchName: worktreeBranch } = await waitForCreatedWorktree(
|
||||
repo.path
|
||||
);
|
||||
expect(worktreeBranch.length).toBeGreaterThan(0);
|
||||
const [resolvedCwd, resolvedRepo] = await Promise.all([
|
||||
realpath(firstCwd).catch(() => firstCwd),
|
||||
realpath(repo.path).catch(() => repo.path),
|
||||
]);
|
||||
const normalizedRepo = normalizeTmpPath(resolvedRepo);
|
||||
const normalizedCwd = normalizeTmpPath(resolvedCwd);
|
||||
const expectedMarker = `${path.sep}worktrees${path.sep}`;
|
||||
expect(normalizedCwd.includes(expectedMarker)).toBeTruthy();
|
||||
|
||||
await page.getByTestId('sidebar-new-agent').click();
|
||||
await expect(page).toHaveURL(/\/h\/[^/]+\/agent(\?|$)/);
|
||||
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
await selectAttachWorktree(page, worktreeBranch);
|
||||
await createAgentAndWait(page, 'Respond with exactly: READY2');
|
||||
await waitForAgentTurnToSettle(page);
|
||||
await openChangesPanel(page);
|
||||
|
||||
const readmePath = path.join(firstCwd, 'README.md');
|
||||
await appendFile(readmePath, '\nFirst change\n');
|
||||
|
||||
await refreshUncommittedMode(page);
|
||||
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
await getChangesScope(page).getByTestId('diff-file-0-toggle').first().click();
|
||||
await expect(page.getByText('First change')).toBeVisible();
|
||||
const primaryCta = getChangesScope(page).getByTestId('changes-primary-cta').first();
|
||||
await expect(primaryCta).toBeVisible();
|
||||
await expect(primaryCta).toContainText('Commit');
|
||||
|
||||
await primaryCta.click();
|
||||
await expect
|
||||
.poll(() => {
|
||||
try {
|
||||
return execSync('git status --porcelain', {
|
||||
cwd: firstCwd,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
|
||||
}).trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, { timeout: 30000 })
|
||||
.toBe('');
|
||||
await openChangesPanel(page);
|
||||
await selectChangesView(page, 'working');
|
||||
await expect(getChangesScope(page).getByText('No uncommitted changes')).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
await expect(getChangesScope(page).getByTestId('changes-primary-cta')).not.toContainText('Commit');
|
||||
|
||||
await selectChangesView(page, 'base');
|
||||
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
// Push once from the menu so the branch has an origin/<branch> ref.
|
||||
await openChangesPrimaryMenu(page);
|
||||
await page.getByTestId('changes-menu-push').click();
|
||||
await expect
|
||||
.poll(() => {
|
||||
try {
|
||||
execSync(`git show-ref --verify --quiet refs/remotes/origin/${worktreeBranch}`, { cwd: firstCwd });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, { timeout: 30000 })
|
||||
.toBe(true);
|
||||
|
||||
const notesPath = path.join(firstCwd, 'notes.txt');
|
||||
await writeFile(notesPath, 'Second change\n');
|
||||
|
||||
await refreshUncommittedMode(page);
|
||||
await refreshChangesTab(page);
|
||||
await expect(getChangesScope(page).getByText('notes.txt', { exact: true })).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toHaveCount(0);
|
||||
await expect(getChangesScope(page).getByTestId('changes-primary-cta')).toContainText('Commit');
|
||||
|
||||
await selectChangesView(page, 'base');
|
||||
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
await getChangesScope(page).getByTestId('changes-primary-cta').click();
|
||||
await expect
|
||||
.poll(() => {
|
||||
try {
|
||||
return execSync('git status --porcelain', {
|
||||
cwd: firstCwd,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
|
||||
}).trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, { timeout: 30000 })
|
||||
.toBe('');
|
||||
await openChangesPanel(page);
|
||||
await selectChangesView(page, 'working');
|
||||
await expect(getChangesScope(page).getByText('No uncommitted changes')).toBeVisible({ timeout: 30000 });
|
||||
await expect(getChangesScope(page).getByTestId('changes-primary-cta')).not.toContainText('Commit');
|
||||
|
||||
await selectChangesView(page, 'base');
|
||||
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
await expect(getChangesScope(page).getByText('notes.txt', { exact: true })).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
// Push is now the primary action (origin/<branch> exists and we're ahead of it).
|
||||
const pushPrimary = getChangesScope(page).getByTestId('changes-primary-cta').first();
|
||||
await expect(pushPrimary).toContainText(/push/i, { timeout: 30000 });
|
||||
await pushPrimary.click();
|
||||
// Regression check: the primary CTA stays in place while pushing.
|
||||
await expect(pushPrimary).toBeVisible();
|
||||
await page.waitForTimeout(50);
|
||||
await expect(pushPrimary).toBeVisible();
|
||||
|
||||
await expect
|
||||
.poll(() => {
|
||||
try {
|
||||
const count = execSync(
|
||||
`git rev-list --count origin/${worktreeBranch}..${worktreeBranch}`,
|
||||
{ cwd: firstCwd, encoding: 'utf8' }
|
||||
).trim();
|
||||
return Number.parseInt(count, 10);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, { timeout: 30000 })
|
||||
.toBe(0);
|
||||
|
||||
// Merge to base in the main worktree (worktree branches can't always check out base refs in-place).
|
||||
// This avoids UI flakiness around ship actions while still validating the diff panel end-to-end.
|
||||
execSync("git checkout main", { cwd: repo.path });
|
||||
execSync(`git -c commit.gpgsign=false merge --no-edit ${worktreeBranch}`, { cwd: repo.path });
|
||||
execSync("git push", { cwd: repo.path });
|
||||
|
||||
await selectChangesView(page, 'base');
|
||||
await expect(getChangesScope(page).getByTestId('changes-diff-status')).toContainText(
|
||||
'Committed',
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
await refreshChangesTab(page);
|
||||
|
||||
// Post-ship UI behavior is implementation-dependent (archive can be promoted into
|
||||
// primary flow or hidden behind menu variants), so continue from a fresh draft.
|
||||
await page.getByTestId('sidebar-new-agent').click();
|
||||
await expect(page).toHaveURL(/\/h\/[^/]+\/agent(?:\?|$)/, { timeout: 30000 });
|
||||
|
||||
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeEditable();
|
||||
} finally {
|
||||
await rm(nonGitDir, { recursive: true, force: true });
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { createAgentInRepo } from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
|
||||
test('create agent in a temp repo', async ({ page }) => {
|
||||
const repo = await createTempGitRepo();
|
||||
const prompt = "Respond with exactly: Hello";
|
||||
|
||||
try {
|
||||
await createAgentInRepo(page, { directory: repo.path, prompt });
|
||||
|
||||
// Verify user message is shown in the stream
|
||||
await expect(page.getByText(prompt, { exact: true })).toBeVisible();
|
||||
|
||||
// Verify we used the seeded fast model (do not fall back to other defaults).
|
||||
const modelPicker = page.getByRole("button", { name: /select agent model/i }).first();
|
||||
await expect(modelPicker).toBeVisible({ timeout: 30000 });
|
||||
await expect(modelPicker).toContainText(/gpt-5\.1-codex-mini/i);
|
||||
|
||||
// Verify the assistant response is rendered.
|
||||
await expect(page.getByText("Hello", { exact: true }).first()).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { gotoHome, openSettings } from './helpers/app';
|
||||
|
||||
test('daemon is connected in settings', async ({ page }) => {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!daemonPort) {
|
||||
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
|
||||
}
|
||||
if (!serverId) {
|
||||
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
|
||||
}
|
||||
|
||||
await gotoHome(page);
|
||||
await openSettings(page);
|
||||
|
||||
await expect(page.getByText(`127.0.0.1:${daemonPort}`)).toBeVisible();
|
||||
await expect(page.getByTestId(`daemon-card-${serverId}`).getByText('Online', { exact: true })).toBeVisible();
|
||||
});
|
||||
@@ -1,64 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
|
||||
test('deleting an agent persists after reload', async ({ page }) => {
|
||||
const repo = await createTempGitRepo();
|
||||
const nonce = Math.random().toString(36).slice(2, 10);
|
||||
const prompt = `respond-ready-${nonce}`;
|
||||
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
|
||||
// Create agent (via message input) so it shows up in the sidebar list.
|
||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
||||
await expect(input).toBeEditable();
|
||||
await input.fill(prompt);
|
||||
await input.press('Enter');
|
||||
await expect(page).toHaveURL(/\/h\/[^/]+\/agent\/[^/?#]+(?:\?|$)/, {
|
||||
timeout: 30000,
|
||||
});
|
||||
// Wait for the initial turn to complete so the agent can be archived (web uses a hover action).
|
||||
const stopOrCancel = page.getByRole('button', { name: /Stop agent|Canceling agent/ });
|
||||
await stopOrCancel.first().waitFor({ state: 'visible', timeout: 30000 }).catch(() => undefined);
|
||||
await expect(stopOrCancel).toHaveCount(0, { timeout: 120000 });
|
||||
|
||||
const match =
|
||||
page.url().match(/\/h\/([^/]+)\/agent\/([^/?#]+)/) ??
|
||||
page.url().match(/\/agent\/([^/]+)\/([^/?#]+)/);
|
||||
if (!match) {
|
||||
throw new Error(`Expected /h/:serverId/agent/:agentId URL, got ${page.url()}`);
|
||||
}
|
||||
const serverId = decodeURIComponent(match[1]);
|
||||
const agentId = decodeURIComponent(match[2]);
|
||||
|
||||
// Return home and delete via long-press in the agent list.
|
||||
await gotoHome(page);
|
||||
const rowTestId = `agent-row-${serverId}-${agentId}`;
|
||||
const agentRow = page.getByTestId(rowTestId).first();
|
||||
await expect(agentRow).toBeVisible({ timeout: 30000 });
|
||||
|
||||
// Web UX: hover shows a quick-archive icon. First click enters confirm state; second click archives.
|
||||
await agentRow.hover();
|
||||
const quickArchive = page.getByTestId(`agent-archive-${serverId}-${agentId}`).first();
|
||||
await expect(quickArchive).toBeVisible({ timeout: 10000 });
|
||||
await quickArchive.click({ force: true });
|
||||
const confirmArchive = page
|
||||
.getByTestId(`agent-archive-confirm-${serverId}-${agentId}`)
|
||||
.first();
|
||||
await expect(confirmArchive).toBeVisible({ timeout: 10000 });
|
||||
await confirmArchive.click({ force: true });
|
||||
|
||||
// Ensure deletion finished before reload (avoids races).
|
||||
await expect(page.getByTestId(rowTestId)).toHaveCount(0, { timeout: 30000 });
|
||||
|
||||
// A full reload should not bring the agent back.
|
||||
await page.reload();
|
||||
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeVisible();
|
||||
await expect(page.getByTestId(rowTestId)).toHaveCount(0, { timeout: 30000 });
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,342 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import {
|
||||
createAgent,
|
||||
createAgentInRepo,
|
||||
ensureHostSelected,
|
||||
gotoHome,
|
||||
openSettings,
|
||||
setWorkingDirectory,
|
||||
} from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
buildHostWorkspaceAgentRoute,
|
||||
buildHostWorkspaceRoute,
|
||||
} from '@/utils/host-routes';
|
||||
import {
|
||||
ensureWorkspaceAgentPaneVisible,
|
||||
waitForWorkspaceTabsVisible,
|
||||
} from './helpers/workspace-tabs';
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
async function addFakeMicrophone(page: Page) {
|
||||
const fixturePath = path.resolve(__dirname, 'fixtures', 'recording.wav');
|
||||
const base64Audio = (await readFile(fixturePath)).toString('base64');
|
||||
const mimeType = 'audio/wav';
|
||||
|
||||
return page.addInitScript(({ base64Audio, mimeType }) => {
|
||||
const mic = {
|
||||
active: 0,
|
||||
getUserMediaCalls: 0,
|
||||
stopCalls: 0,
|
||||
lastRecorder: null as null | { state: string },
|
||||
};
|
||||
(window as any).__mic = mic;
|
||||
|
||||
(window as any).isSecureContext = true;
|
||||
|
||||
const nav = navigator as any;
|
||||
if (!nav.mediaDevices) {
|
||||
nav.mediaDevices = {};
|
||||
}
|
||||
nav.mediaDevices.getUserMedia = async () => {
|
||||
mic.getUserMediaCalls += 1;
|
||||
mic.active += 1;
|
||||
const track = {
|
||||
stop: () => {
|
||||
mic.stopCalls += 1;
|
||||
mic.active = Math.max(0, mic.active - 1);
|
||||
},
|
||||
};
|
||||
return {
|
||||
getTracks: () => [track],
|
||||
};
|
||||
};
|
||||
|
||||
const AudioContextCtor =
|
||||
(window as any).AudioContext ?? (window as any).webkitAudioContext;
|
||||
if (AudioContextCtor?.prototype?.createMediaStreamSource) {
|
||||
const nativeCreateMediaStreamSource =
|
||||
AudioContextCtor.prototype.createMediaStreamSource;
|
||||
AudioContextCtor.prototype.createMediaStreamSource =
|
||||
function patchedCreateMediaStreamSource(stream: unknown) {
|
||||
const isFakeStream =
|
||||
!!stream &&
|
||||
typeof (stream as { getTracks?: unknown }).getTracks === 'function' &&
|
||||
typeof (stream as { id?: unknown }).id === 'undefined';
|
||||
if (isFakeStream) {
|
||||
throw new Error('Force recorder fallback for fake microphone stream');
|
||||
}
|
||||
return nativeCreateMediaStreamSource.call(this, stream);
|
||||
};
|
||||
}
|
||||
|
||||
const blobFromBase64 = (base64: string, mimeType: string): Blob => {
|
||||
const binaryString = atob(base64);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return new Blob([bytes], { type: mimeType });
|
||||
};
|
||||
|
||||
class FakeMediaRecorder extends EventTarget {
|
||||
public static isTypeSupported() {
|
||||
return true;
|
||||
}
|
||||
|
||||
public state: 'inactive' | 'recording' = 'inactive';
|
||||
public mimeType: string;
|
||||
public ondataavailable: ((event: { data: Blob }) => void) | null = null;
|
||||
public onerror: ((event: unknown) => void) | null = null;
|
||||
|
||||
constructor(_stream: unknown, options?: MediaRecorderOptions) {
|
||||
super();
|
||||
this.mimeType = options?.mimeType ?? 'audio/webm';
|
||||
mic.lastRecorder = this;
|
||||
}
|
||||
|
||||
public start() {
|
||||
this.state = 'recording';
|
||||
}
|
||||
|
||||
public stop() {
|
||||
if (this.state !== 'recording') {
|
||||
throw new Error('Not recording');
|
||||
}
|
||||
this.state = 'inactive';
|
||||
try {
|
||||
this.ondataavailable?.({
|
||||
data: blobFromBase64(base64Audio, mimeType),
|
||||
});
|
||||
} catch (err) {
|
||||
this.onerror?.(err);
|
||||
}
|
||||
this.dispatchEvent(new Event('stop'));
|
||||
}
|
||||
}
|
||||
|
||||
(window as any).MediaRecorder = FakeMediaRecorder;
|
||||
}, { base64Audio, mimeType });
|
||||
}
|
||||
|
||||
async function expectComposerReady(page: Page) {
|
||||
await expect(page.getByRole('textbox', { name: 'Message agent...' }).first()).toBeEditable();
|
||||
}
|
||||
|
||||
async function expectDictationStarted(page: Page) {
|
||||
await expect
|
||||
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
|
||||
.toBe(1);
|
||||
}
|
||||
|
||||
async function expectDictationStopped(page: Page) {
|
||||
await expect
|
||||
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
|
||||
.toBe(0);
|
||||
}
|
||||
|
||||
test('dictation hotkey works on a workspace agent tab', async ({ page }) => {
|
||||
await addFakeMicrophone(page);
|
||||
|
||||
const repo = await createTempGitRepo();
|
||||
try {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error('E2E_SERVER_ID is not set.');
|
||||
}
|
||||
|
||||
await createAgentInRepo(page, {
|
||||
directory: repo.path,
|
||||
prompt: 'Respond with exactly: Hello',
|
||||
});
|
||||
await page.goto(buildHostWorkspaceRoute(serverId, repo.path));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await ensureWorkspaceAgentPaneVisible(page);
|
||||
await expect(page).toHaveURL(/\/workspace\//);
|
||||
await expectComposerReady(page);
|
||||
|
||||
await page.keyboard.press('Control+d');
|
||||
await expectDictationStarted(page);
|
||||
|
||||
const calls = await page.evaluate(() => (window as any).__mic.getUserMediaCalls as number);
|
||||
expect(calls).toBe(1);
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expectDictationStopped(page);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('dictation hotkey works on a workspace draft tab', async ({ page }) => {
|
||||
await addFakeMicrophone(page);
|
||||
|
||||
const repo = await createTempGitRepo();
|
||||
try {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error('E2E_SERVER_ID is not set.');
|
||||
}
|
||||
|
||||
await createAgentInRepo(page, {
|
||||
directory: repo.path,
|
||||
prompt: 'Respond with exactly: Hello',
|
||||
});
|
||||
await page.goto(buildHostWorkspaceRoute(serverId, repo.path));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await ensureWorkspaceAgentPaneVisible(page);
|
||||
|
||||
await page.getByTestId('workspace-new-agent-tab').first().click();
|
||||
await expectComposerReady(page);
|
||||
|
||||
await page.keyboard.press('Control+d');
|
||||
await expectDictationStarted(page);
|
||||
|
||||
const calls = await page.evaluate(() => (window as any).__mic.getUserMediaCalls as number);
|
||||
expect(calls).toBe(1);
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expectDictationStopped(page);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('dictation hotkeys do not trigger on background screens', async ({ page }) => {
|
||||
await addFakeMicrophone(page);
|
||||
|
||||
const repo = await createTempGitRepo();
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await ensureHostSelected(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await createAgent(page, 'Respond with exactly: Hello');
|
||||
await expect(page).toHaveURL(/\/workspace\//);
|
||||
await expectComposerReady(page);
|
||||
|
||||
await openSettings(page);
|
||||
await page.keyboard.press('Control+d');
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
const calls = await page.evaluate(() => (window as any).__mic.getUserMediaCalls as number);
|
||||
const active = await page.evaluate(() => (window as any).__mic.active as number);
|
||||
|
||||
expect(calls).toBe(0);
|
||||
expect(active).toBe(0);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('dictation transcribes fixture via real STT', async ({ page }) => {
|
||||
await addFakeMicrophone(page);
|
||||
|
||||
const repo = await createTempGitRepo();
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await ensureHostSelected(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await createAgent(page, 'Respond with exactly: Hello');
|
||||
await expect(page).toHaveURL(/\/workspace\//);
|
||||
await expectComposerReady(page);
|
||||
|
||||
await page.keyboard.press('Control+d');
|
||||
await expectDictationStarted(page);
|
||||
|
||||
const initialCopyMessageCount = await page
|
||||
.getByRole('button', { name: 'Copy message' })
|
||||
.count();
|
||||
|
||||
await page.keyboard.press('Control+d');
|
||||
await expectDictationStopped(page);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => page.getByRole('button', { name: 'Copy message' }).count(),
|
||||
{ timeout: 60_000 }
|
||||
)
|
||||
.toBeGreaterThan(initialCopyMessageCount);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('cancel stops mic even if recorder is already inactive', async ({ page }) => {
|
||||
await addFakeMicrophone(page);
|
||||
|
||||
const repo = await createTempGitRepo();
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await ensureHostSelected(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await createAgent(page, 'Respond with exactly: Hello');
|
||||
|
||||
await expect(page).toHaveURL(/\/workspace\//);
|
||||
await expectComposerReady(page);
|
||||
|
||||
await page.keyboard.press('Control+d');
|
||||
await expectDictationStarted(page);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const mic = (window as any).__mic as { lastRecorder: null | { state: string } };
|
||||
if (mic.lastRecorder) {
|
||||
mic.lastRecorder.state = 'inactive';
|
||||
}
|
||||
});
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expectDictationStopped(page);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('dictation confirm+send does not dispatch after navigating away', async ({ page }) => {
|
||||
await addFakeMicrophone(page);
|
||||
|
||||
const repo = await createTempGitRepo();
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
await createAgent(page, 'Respond with exactly: Hello');
|
||||
|
||||
await expect(page).toHaveURL(/\/workspace\//);
|
||||
const match = page.url().match(/\/h\/([^/]+)\/workspace\/[^?]+(?:\?open=agent%3A|\\?open=agent:)([^/?#&]+)/);
|
||||
if (!match) {
|
||||
throw new Error(`Expected workspace agent URL, got ${page.url()}`);
|
||||
}
|
||||
const serverId = decodeURIComponent(match[1]!);
|
||||
const agentId = decodeURIComponent(match[2]!);
|
||||
await expectComposerReady(page);
|
||||
const initialCopyMessageCount = await page.getByRole('button', { name: 'Copy message' }).count();
|
||||
|
||||
await page.keyboard.press('Control+d');
|
||||
await expectDictationStarted(page);
|
||||
|
||||
await page.keyboard.press('Control+d');
|
||||
|
||||
const newAgentButton = page.getByTestId('sidebar-new-agent');
|
||||
await expect(newAgentButton).toBeVisible();
|
||||
await newAgentButton.click();
|
||||
await expect(page).toHaveURL(/\/h\/[^/]+\/new-agent(\?|$)/);
|
||||
|
||||
await page.waitForTimeout(10_000);
|
||||
|
||||
await page.goto(buildHostWorkspaceAgentRoute(serverId, repo.path, agentId));
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`/h/${escapeRegex(serverId)}/workspace/[^?]+\\?open=agent(?:%3A|:)${escapeRegex(agentId)}(?:$|&)`)
|
||||
);
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Copy message' })).toHaveCount(initialCopyMessageCount);
|
||||
await expect(page.getByTestId('agent-chat-scroll').getByText(/this is a voice note\./i)).toHaveCount(0);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { ensureHostSelected, gotoHome, setWorkingDirectory } from "./helpers/app";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
|
||||
test("draft enables explorer after selecting a working directory", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("paseo-e2e-draft-explorer-");
|
||||
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await ensureHostSelected(page);
|
||||
|
||||
const newAgentButton = page.getByTestId("sidebar-new-agent").first();
|
||||
await expect(newAgentButton).toBeVisible({ timeout: 30000 });
|
||||
await newAgentButton.click();
|
||||
await expect(page).toHaveURL(/\/h\/[^/]+\/agent(\?|$)/, { timeout: 30000 });
|
||||
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
|
||||
const toggle = page
|
||||
.getByRole("button", {
|
||||
name: /open explorer|close explorer|toggle explorer/i,
|
||||
})
|
||||
.first();
|
||||
await expect(toggle).toBeVisible({ timeout: 30000 });
|
||||
|
||||
await toggle.click();
|
||||
await expect(
|
||||
page.locator('[data-testid="explorer-header"]:visible').first()
|
||||
).toBeVisible({ timeout: 30000 });
|
||||
|
||||
const terminalsTab = page
|
||||
.locator('[data-testid="explorer-tab-terminals"]:visible')
|
||||
.first();
|
||||
await expect(terminalsTab).toBeVisible({ timeout: 30000 });
|
||||
await terminalsTab.click();
|
||||
await expect(
|
||||
page.locator('[data-testid="terminal-surface"]:visible').first()
|
||||
).toBeVisible({ timeout: 30000 });
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,4 +1,8 @@
|
||||
import { test as base, expect, type Page } from '@playwright/test';
|
||||
import {
|
||||
buildCreateAgentPreferences,
|
||||
buildSeededHost,
|
||||
} from './helpers/daemon-registry';
|
||||
|
||||
// Extend base test to provide dynamic baseURL from global-setup
|
||||
const test = base.extend({
|
||||
@@ -58,31 +62,12 @@ test.beforeEach(async ({ page }) => {
|
||||
if (!serverId) {
|
||||
throw new Error('E2E_SERVER_ID is not set - expected from Playwright globalSetup.');
|
||||
}
|
||||
const testDaemon = {
|
||||
const testDaemon = buildSeededHost({
|
||||
serverId,
|
||||
label: 'localhost',
|
||||
connections: [
|
||||
{
|
||||
id: `direct:127.0.0.1:${daemonPort}`,
|
||||
type: 'direct',
|
||||
endpoint: `127.0.0.1:${daemonPort}`,
|
||||
},
|
||||
],
|
||||
preferredConnectionId: `direct:127.0.0.1:${daemonPort}`,
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
|
||||
const createAgentPreferences = {
|
||||
// Ensure create flow never uses a remembered host from the developer's real app.
|
||||
serverId: testDaemon.serverId,
|
||||
// Keep e2e fast/cheap by default.
|
||||
provider: 'codex',
|
||||
providerPreferences: {
|
||||
claude: { model: 'haiku' },
|
||||
codex: { model: 'gpt-5.1-codex-mini', thinkingOptionId: 'low' },
|
||||
},
|
||||
};
|
||||
endpoint: `127.0.0.1:${daemonPort}`,
|
||||
nowIso,
|
||||
});
|
||||
const createAgentPreferences = buildCreateAgentPreferences(testDaemon.serverId);
|
||||
|
||||
await page.addInitScript(
|
||||
({ daemon, preferences, seedNonce }) => {
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
import path from 'node:path';
|
||||
import { appendFile } from 'node:fs/promises';
|
||||
import { test, expect, type Page } from './fixtures';
|
||||
import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
|
||||
test.describe.configure({ timeout: 90000 });
|
||||
|
||||
function getChangesScope(page: Page) {
|
||||
return page.locator('[data-testid="explorer-content-area"]:visible').first();
|
||||
}
|
||||
|
||||
async function ensureExplorerTabsVisible(page: Page) {
|
||||
const changesTab = page.getByTestId('explorer-tab-changes').first();
|
||||
if (await changesTab.isVisible().catch(() => false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const toggle = page
|
||||
.getByRole('button', { name: /open explorer|close explorer|toggle explorer/i })
|
||||
.first();
|
||||
await expect(toggle).toBeVisible({ timeout: 10000 });
|
||||
for (let attempt = 0; attempt < 4; attempt += 1) {
|
||||
if (await changesTab.isVisible().catch(() => false)) {
|
||||
return;
|
||||
}
|
||||
await toggle.click();
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
await expect(changesTab).toBeVisible({ timeout: 30000 });
|
||||
}
|
||||
|
||||
async function openChangesPanel(page: Page) {
|
||||
await ensureExplorerTabsVisible(page);
|
||||
const changesHeader = getChangesScope(page).getByTestId('changes-header');
|
||||
if (!(await changesHeader.isVisible())) {
|
||||
await page.getByTestId('explorer-tab-changes').first().click();
|
||||
}
|
||||
await expect(changesHeader).toBeVisible();
|
||||
}
|
||||
|
||||
async function refreshUncommittedMode(page: Page) {
|
||||
const scope = getChangesScope(page);
|
||||
const toggle = scope.getByTestId('changes-diff-status').first();
|
||||
await expect(toggle).toBeVisible({ timeout: 30000 });
|
||||
|
||||
const diffModeBackdrop = page.getByTestId('changes-diff-status-menu-backdrop');
|
||||
if (await diffModeBackdrop.isVisible().catch(() => false)) {
|
||||
await diffModeBackdrop.click({ force: true });
|
||||
await expect(diffModeBackdrop).toHaveCount(0);
|
||||
}
|
||||
|
||||
const currentLabel = (await toggle.innerText()).trim();
|
||||
await toggle.click({ force: true });
|
||||
await expect(page.getByTestId('changes-diff-status-menu')).toBeVisible({ timeout: 10000 });
|
||||
const firstTarget = currentLabel === 'Uncommitted' ? 'changes-diff-mode-committed' : 'changes-diff-mode-uncommitted';
|
||||
await page.getByTestId(firstTarget).click({ force: true });
|
||||
await expect.poll(async () => (await toggle.innerText()).trim()).not.toBe(currentLabel);
|
||||
|
||||
const nextLabel = (await toggle.innerText()).trim();
|
||||
await toggle.click({ force: true });
|
||||
await expect(page.getByTestId('changes-diff-status-menu')).toBeVisible({ timeout: 10000 });
|
||||
const secondTarget = nextLabel === 'Uncommitted' ? 'changes-diff-mode-committed' : 'changes-diff-mode-uncommitted';
|
||||
await page.getByTestId(secondTarget).click({ force: true });
|
||||
await expect.poll(async () => (await toggle.innerText()).trim()).not.toBe(nextLabel);
|
||||
}
|
||||
|
||||
async function createAgentAndWait(page: Page, message: string) {
|
||||
await createAgent(page, message);
|
||||
}
|
||||
|
||||
test('keeps file header sticky while scrolling within a long diff', async ({ page }) => {
|
||||
const repo = await createTempGitRepo('paseo-e2e-sticky-');
|
||||
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
await createAgentAndWait(page, 'Respond with exactly: READY');
|
||||
|
||||
await openChangesPanel(page);
|
||||
|
||||
const readmePath = path.join(repo.path, 'README.md');
|
||||
const lines = Array.from({ length: 400 }, (_, idx) => `Sticky header line ${idx}\n`).join('');
|
||||
await appendFile(readmePath, `\n${lines}`);
|
||||
|
||||
await refreshUncommittedMode(page);
|
||||
|
||||
const scope = getChangesScope(page);
|
||||
await expect(scope.getByText('README.md', { exact: true })).toBeVisible({ timeout: 30000 });
|
||||
|
||||
const fileToggle = scope.getByTestId('diff-file-0-toggle').first();
|
||||
await fileToggle.click();
|
||||
|
||||
const markerLine = scope.getByText('Sticky header line 250').first();
|
||||
await expect(markerLine).toBeVisible({ timeout: 30000 });
|
||||
|
||||
const scroll = scope.getByTestId('git-diff-scroll').first();
|
||||
await expect(scroll).toBeVisible();
|
||||
|
||||
await expect.poll(async () => {
|
||||
return await scroll.evaluate((el) => (el.scrollHeight ?? 0) > (el.clientHeight ?? 0));
|
||||
}).toBe(true);
|
||||
|
||||
await scroll.hover();
|
||||
for (let i = 0; i < 12; i++) {
|
||||
await page.mouse.wheel(0, 700);
|
||||
}
|
||||
|
||||
await expect.poll(async () => {
|
||||
return await scroll.evaluate((el) => el.scrollTop ?? 0);
|
||||
}).toBeGreaterThan(0);
|
||||
|
||||
await expect(scope.getByText('Sticky header line 390').first()).toBeVisible({ timeout: 30000 });
|
||||
await expect(fileToggle).toBeVisible();
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
import { test, expect } from './fixtures'
|
||||
import { createTempGitRepo } from './helpers/workspace'
|
||||
import { ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app'
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
test('global draft create uses input status controls and preserves optimistic flow', async ({ page }) => {
|
||||
const repo = await createTempGitRepo('paseo-e2e-global-draft-')
|
||||
const prompt = `global draft prompt ${Date.now()}`
|
||||
|
||||
try {
|
||||
await gotoHome(page)
|
||||
await ensureHostSelected(page)
|
||||
await setWorkingDirectory(page, repo.path)
|
||||
|
||||
await expect(page.getByTestId('working-directory-select').first()).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
const providerSelector = page.getByTestId('agent-provider-selector').first()
|
||||
const modeSelector = page.getByTestId('agent-mode-selector').first()
|
||||
const modelSelector = page.getByTestId('agent-model-selector').first()
|
||||
const thinkingSelector = page.getByTestId('agent-thinking-selector').first()
|
||||
|
||||
await expect(providerSelector).toBeVisible({ timeout: 30_000 })
|
||||
await expect(modeSelector).toBeVisible({ timeout: 30_000 })
|
||||
await expect(modelSelector).toBeVisible({ timeout: 30_000 })
|
||||
await expect(thinkingSelector).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
const selectedMode = ((await modeSelector.innerText()) ?? '').trim()
|
||||
const selectedModel = ((await modelSelector.innerText()) ?? '').trim()
|
||||
const selectedThinking = ((await thinkingSelector.innerText()) ?? '').trim()
|
||||
|
||||
const composer = page.getByRole('textbox', { name: 'Message agent...' }).first()
|
||||
await composer.fill(prompt)
|
||||
await composer.press('Enter')
|
||||
|
||||
await expect(page.getByText(prompt, { exact: true }).first()).toBeVisible({ timeout: 5_000 })
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 })
|
||||
|
||||
await expect(modelSelector).toContainText(new RegExp(escapeRegex(selectedModel), 'i'))
|
||||
await expect(modeSelector).toContainText(new RegExp(escapeRegex(selectedMode), 'i'))
|
||||
await expect(thinkingSelector).toContainText(new RegExp(escapeRegex(selectedThinking), 'i'))
|
||||
} finally {
|
||||
await repo.cleanup()
|
||||
}
|
||||
})
|
||||
@@ -1,219 +0,0 @@
|
||||
import { test, expect } from './fixtures'
|
||||
import type { Page } from '@playwright/test'
|
||||
import { ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app'
|
||||
import { createTempGitRepo } from './helpers/workspace'
|
||||
import { getWorkspaceTabTestIds } from './helpers/workspace-tabs'
|
||||
|
||||
function parseWorkspaceContextFromUrl(rawUrl: string): { workspaceToken: string | null; agentId: string | null } {
|
||||
const url = new URL(rawUrl)
|
||||
const pathMatch = url.pathname.match(/\/workspace\/([^/?#]+)(?:\/agent\/([^/?#]+))?/) ?? null
|
||||
const workspaceToken = pathMatch?.[1] ?? null
|
||||
let agentId = pathMatch?.[2] ?? null
|
||||
|
||||
if (!agentId) {
|
||||
const open = url.searchParams.get('open')
|
||||
const openMatch = open?.match(/^agent:(.+)$/) ?? null
|
||||
if (openMatch?.[1]) {
|
||||
agentId = openMatch[1]
|
||||
}
|
||||
}
|
||||
|
||||
return { workspaceToken, agentId }
|
||||
}
|
||||
|
||||
function getOpenAgentIdFromUrl(rawUrl: string): string | null {
|
||||
const url = new URL(rawUrl)
|
||||
const open = url.searchParams.get('open')
|
||||
const openMatch = open?.match(/^agent:(.+)$/) ?? null
|
||||
return openMatch?.[1] ?? null
|
||||
}
|
||||
|
||||
async function preferSlowerThinkingOption(page: Page): Promise<void> {
|
||||
await page.keyboard.press('Escape').catch(() => undefined)
|
||||
const thinkingTrigger = page.getByTestId('agent-thinking-selector').first()
|
||||
if (!(await thinkingTrigger.isVisible().catch(() => false))) {
|
||||
return
|
||||
}
|
||||
await thinkingTrigger.click({ force: true })
|
||||
const menu = page.getByTestId('agent-thinking-menu').first()
|
||||
if (!(await menu.isVisible().catch(() => false))) {
|
||||
return
|
||||
}
|
||||
|
||||
const preferred = ['high', 'max', 'deep', 'long', 'medium']
|
||||
for (const label of preferred) {
|
||||
const option = menu.getByRole('button', { name: new RegExp(`^${label}$`, 'i') }).first()
|
||||
if (await option.isVisible().catch(() => false)) {
|
||||
await option.click({ force: true })
|
||||
await expect(menu).not.toBeVisible({ timeout: 5_000 })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const options = menu.getByRole('button')
|
||||
const count = await options.count()
|
||||
if (count > 0) {
|
||||
await options.last().click({ force: true })
|
||||
}
|
||||
await expect(menu).not.toBeVisible({ timeout: 5_000 })
|
||||
}
|
||||
|
||||
async function isAnyStopVisible(page: Page): Promise<boolean> {
|
||||
const candidates = page.getByRole('button', { name: /stop|cancel/i })
|
||||
const count = await candidates.count()
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
if (await candidates.nth(index).isVisible().catch(() => false)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function isRunningControlVisible(page: Page): Promise<boolean> {
|
||||
if (await isAnyStopVisible(page)) {
|
||||
return true
|
||||
}
|
||||
const interruptSendButton = page.getByRole('button', { name: /interrupt agent|send and interrupt/i })
|
||||
const count = await interruptSendButton.count()
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
if (await interruptSendButton.nth(index).isVisible().catch(() => false)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function hasVisibleText(page: Page, matcher: RegExp | string): Promise<boolean> {
|
||||
const locator = page.getByText(matcher).first()
|
||||
const matches = page.getByText(matcher)
|
||||
const count = await matches.count()
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
if (await matches.nth(index).isVisible().catch(() => false)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return await locator.isVisible().catch(() => false)
|
||||
}
|
||||
|
||||
test('global draft create in existing workspace redirects to that workspace with created agent tab and settles to idle', async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(360_000)
|
||||
const serverId = process.env.E2E_SERVER_ID
|
||||
if (!serverId) {
|
||||
throw new Error('E2E_SERVER_ID is not set.')
|
||||
}
|
||||
|
||||
const repo = await createTempGitRepo('paseo-e2e-global-existing-workspace-')
|
||||
const id = `${Date.now()}`
|
||||
const seedPrompt = `hello seed-${id}. please reply exactly: ACK_SEED_${id}`
|
||||
const secondPrompt = `hello second-${id}. please reply exactly: ACK_SECOND_${id}`
|
||||
const lifecyclePrompt = `hello lifecycle-${id}. wait 8 seconds, then reply exactly: ACK_LIFECYCLE_${id}`
|
||||
const seedToken = `ACK_SEED_${id}`
|
||||
const secondToken = `ACK_SECOND_${id}`
|
||||
expect(seedPrompt).not.toBe(secondPrompt)
|
||||
expect(seedToken).not.toBe(secondToken)
|
||||
|
||||
try {
|
||||
await gotoHome(page)
|
||||
await ensureHostSelected(page)
|
||||
await page.goto(`/h/${serverId}/new-agent`)
|
||||
await expect(page.locator('[data-testid="working-directory-select"]:visible').first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
})
|
||||
|
||||
// 1) Seed workspace with an existing agent.
|
||||
await setWorkingDirectory(page, repo.path)
|
||||
const seedComposer = page.getByRole('textbox', { name: 'Message agent...' }).first()
|
||||
await seedComposer.fill(seedPrompt)
|
||||
await seedComposer.press('Enter')
|
||||
|
||||
await expect(page.getByText(seedPrompt, { exact: true }).first()).toBeVisible({ timeout: 30_000 })
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 60_000 })
|
||||
const seededContext = parseWorkspaceContextFromUrl(page.url())
|
||||
if (!seededContext.workspaceToken || !seededContext.agentId) {
|
||||
throw new Error(`Expected seeded workspace token and agent id in URL, got: ${page.url()}`)
|
||||
}
|
||||
const seededAgentId = seededContext.agentId
|
||||
expect(getOpenAgentIdFromUrl(page.url())).toBe(seededAgentId)
|
||||
await expect(page.getByText(new RegExp(seedToken)).first()).toBeVisible({ timeout: 180_000 })
|
||||
|
||||
// 2) Use global New Agent entry and 3) select same workspace.
|
||||
await page.getByText('New agent', { exact: true }).first().click()
|
||||
await expect(page).toHaveURL(new RegExp(`/h/${serverId}/new-agent`), { timeout: 30_000 })
|
||||
await expect(page.locator('[data-testid="working-directory-select"]:visible').first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
})
|
||||
|
||||
await setWorkingDirectory(page, repo.path)
|
||||
await preferSlowerThinkingOption(page)
|
||||
await page.keyboard.press('Escape').catch(() => undefined)
|
||||
|
||||
// 4) Create second agent from global draft.
|
||||
const composer = page.getByRole('textbox', { name: 'Message agent...' }).first()
|
||||
await composer.fill(secondPrompt)
|
||||
await composer.press('Enter')
|
||||
await page.waitForTimeout(500)
|
||||
if (page.url().includes('/new-agent')) {
|
||||
const sendButton = page.getByRole('button', { name: /send message/i }).first()
|
||||
await expect(sendButton).toBeVisible({ timeout: 10_000 })
|
||||
await expect(sendButton).toBeEnabled({ timeout: 10_000 })
|
||||
await sendButton.click({ force: true })
|
||||
}
|
||||
|
||||
// 5) Assert redirect workspace context + created agent tab is active/open.
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 60_000 })
|
||||
const createdContext = parseWorkspaceContextFromUrl(page.url())
|
||||
expect(createdContext.workspaceToken).toBe(seededContext.workspaceToken)
|
||||
if (!createdContext.agentId) {
|
||||
throw new Error(`Expected created agent id in URL, got: ${page.url()}`)
|
||||
}
|
||||
const createdAgentId = createdContext.agentId
|
||||
expect(createdAgentId).toBeTruthy()
|
||||
expect(createdAgentId).not.toBe(seededAgentId)
|
||||
expect(getOpenAgentIdFromUrl(page.url())).toBe(createdAgentId)
|
||||
expect(getOpenAgentIdFromUrl(page.url())).not.toBe(seededAgentId)
|
||||
|
||||
const createdTabTestId = `workspace-tab-agent_${createdAgentId}`
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const finalTabIds = await getWorkspaceTabTestIds(page)
|
||||
return finalTabIds.includes(createdTabTestId)
|
||||
})
|
||||
.toBe(true)
|
||||
expect(getOpenAgentIdFromUrl(page.url())).toBe(createdAgentId)
|
||||
|
||||
// 6) Response assertions are scoped to created-agent active context.
|
||||
await expect
|
||||
.poll(async () => hasVisibleText(page, new RegExp(secondToken)), { timeout: 240_000 })
|
||||
.toBe(true)
|
||||
await expect(page.getByText(seedToken, { exact: true }).first()).not.toBeVisible({ timeout: 30_000 })
|
||||
|
||||
// 7) Lifecycle assertions on created agent only: running first, then idle.
|
||||
const lifecycleComposer = page.getByRole('textbox', { name: 'Message agent...' }).first()
|
||||
await lifecycleComposer.fill(lifecyclePrompt)
|
||||
await lifecycleComposer.press('Enter')
|
||||
await expect
|
||||
.poll(async () => hasVisibleText(page, lifecyclePrompt), { timeout: 30_000 })
|
||||
.toBe(true)
|
||||
|
||||
let sawRunning = false
|
||||
for (let attempt = 0; attempt < 1200; attempt += 1) {
|
||||
if (await isRunningControlVisible(page)) {
|
||||
sawRunning = true
|
||||
break
|
||||
}
|
||||
await page.waitForTimeout(50)
|
||||
}
|
||||
expect(sawRunning).toBe(true)
|
||||
|
||||
await expect
|
||||
.poll(async () => (await isRunningControlVisible(page)) === false, { timeout: 240_000 })
|
||||
.toBe(true)
|
||||
const idleComposer = page.getByRole('textbox', { name: 'Message agent...' }).first()
|
||||
await expect(idleComposer).toBeVisible({ timeout: 30_000 })
|
||||
await expect(idleComposer).toBeEditable({ timeout: 30_000 })
|
||||
} finally {
|
||||
await repo.cleanup()
|
||||
}
|
||||
})
|
||||
@@ -199,6 +199,19 @@ function stripAnsi(input: string): string {
|
||||
return input.replace(/\u001b\[[0-9;]*m/g, '');
|
||||
}
|
||||
|
||||
function ensureRelayBuildArtifact(repoRoot: string): void {
|
||||
const relayDistEntry = path.join(repoRoot, 'packages/relay/dist/e2ee.js');
|
||||
if (existsSync(relayDistEntry)) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[e2e] Building @getpaseo/relay for daemon startup');
|
||||
execSync('npm run build --workspace=@getpaseo/relay', {
|
||||
cwd: repoRoot,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
}
|
||||
|
||||
function decodeOfferFromFragmentUrl(url: string): OfferPayload {
|
||||
const marker = '#offer=';
|
||||
const idx = url.indexOf(marker);
|
||||
@@ -217,6 +230,7 @@ function decodeOfferFromFragmentUrl(url: string): OfferPayload {
|
||||
|
||||
export default async function globalSetup() {
|
||||
const repoRoot = path.resolve(__dirname, '../../..');
|
||||
ensureRelayBuildArtifact(repoRoot);
|
||||
const envTestPath = path.join(repoRoot, '.env.test');
|
||||
if (existsSync(envTestPath)) {
|
||||
dotenv.config({ path: envTestPath });
|
||||
|
||||
@@ -66,17 +66,17 @@ function buildReplyBlock(label: string, lineCount = 14): string {
|
||||
}).join("\n");
|
||||
}
|
||||
|
||||
function buildProtocolMessage(label: string): string {
|
||||
function buildProtocolMessage(label: string, lineCount = 14): string {
|
||||
return [
|
||||
"For every message in this chat, reply with exactly the text after the final line `REPLY:`.",
|
||||
"Do not add extra words, bullets, markdown fences, or tool calls.",
|
||||
"REPLY:",
|
||||
buildReplyBlock(label),
|
||||
buildReplyBlock(label, lineCount),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildReplyMessage(label: string): string {
|
||||
return ["REPLY:", buildReplyBlock(label)].join("\n");
|
||||
function buildReplyMessage(label: string, lineCount = 14): string {
|
||||
return ["REPLY:", buildReplyBlock(label, lineCount)].join("\n");
|
||||
}
|
||||
|
||||
export function createReplyTurn(label: string): {
|
||||
@@ -124,9 +124,11 @@ export async function seedBottomAnchorAgent(input: {
|
||||
cwd: string;
|
||||
title?: string;
|
||||
turnCount?: number;
|
||||
lineCount?: number;
|
||||
}): Promise<SeededAgent> {
|
||||
const title = input.title ?? `bottom-anchor-${Date.now()}`;
|
||||
const turnCount = Math.max(3, input.turnCount ?? 5);
|
||||
const lineCount = Math.max(14, input.lineCount ?? 14);
|
||||
const created = await input.client.createAgent({
|
||||
provider: "codex",
|
||||
model: "gpt-5.1-codex-mini",
|
||||
@@ -134,7 +136,7 @@ export async function seedBottomAnchorAgent(input: {
|
||||
modeId: "full-access",
|
||||
cwd: input.cwd,
|
||||
title,
|
||||
initialPrompt: buildProtocolMessage(`${title}-turn-00`),
|
||||
initialPrompt: buildProtocolMessage(`${title}-turn-00`, lineCount),
|
||||
});
|
||||
const initialFinish = await input.client.waitForFinish(created.id, 120000);
|
||||
if (initialFinish.status !== "idle") {
|
||||
@@ -143,11 +145,11 @@ export async function seedBottomAnchorAgent(input: {
|
||||
);
|
||||
}
|
||||
|
||||
let expectedTailText = buildReplyBlock(`${title}-turn-00`);
|
||||
let expectedTailText = buildReplyBlock(`${title}-turn-00`, lineCount);
|
||||
for (let index = 1; index < turnCount; index += 1) {
|
||||
const label = `${title}-turn-${index.toString().padStart(2, "0")}`;
|
||||
expectedTailText = buildReplyBlock(label);
|
||||
await input.client.sendAgentMessage(created.id, buildReplyMessage(label));
|
||||
expectedTailText = buildReplyBlock(label, lineCount);
|
||||
await input.client.sendAgentMessage(created.id, buildReplyMessage(label, lineCount));
|
||||
const finish = await input.client.waitForFinish(created.id, 120000);
|
||||
if (finish.status !== "idle") {
|
||||
throw new Error(
|
||||
@@ -165,16 +167,29 @@ export async function seedBottomAnchorAgent(input: {
|
||||
};
|
||||
}
|
||||
|
||||
function getVisibleChatScroll(page: Page) {
|
||||
return page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
}
|
||||
|
||||
export async function readScrollMetrics(page: Page): Promise<ScrollMetrics> {
|
||||
return page.getByTestId("agent-chat-scroll").evaluate((root: Element) => {
|
||||
const rootElement = root as HTMLElement;
|
||||
const candidates = [rootElement, ...Array.from(rootElement.querySelectorAll("*"))];
|
||||
return getVisibleChatScroll(page).evaluate((root: Element) => {
|
||||
const candidates = [root, ...Array.from(root.querySelectorAll("*"))]
|
||||
.filter((element): element is HTMLElement => element instanceof HTMLElement)
|
||||
.filter((element) => {
|
||||
const tagName = element.tagName.toLowerCase();
|
||||
const isEditable =
|
||||
tagName === "textarea" ||
|
||||
tagName === "input" ||
|
||||
element.getAttribute("contenteditable") === "true";
|
||||
return !isEditable && element.scrollHeight - element.clientHeight > 1;
|
||||
});
|
||||
const scrollElement =
|
||||
candidates.find(
|
||||
(element) =>
|
||||
element instanceof HTMLElement &&
|
||||
element.scrollHeight - element.clientHeight > 1
|
||||
) ?? rootElement;
|
||||
candidates.sort(
|
||||
(left, right) =>
|
||||
right.scrollHeight -
|
||||
right.clientHeight -
|
||||
(left.scrollHeight - left.clientHeight)
|
||||
)[0] ?? (root as HTMLElement);
|
||||
|
||||
const offsetY = Math.max(0, scrollElement.scrollTop);
|
||||
const contentHeight = Math.max(0, scrollElement.scrollHeight);
|
||||
@@ -194,29 +209,33 @@ export async function readScrollMetrics(page: Page): Promise<ScrollMetrics> {
|
||||
}
|
||||
|
||||
export async function scrollUpFromBottom(page: Page, pixels: number): Promise<void> {
|
||||
await page.getByTestId("agent-chat-scroll").evaluate(
|
||||
(root: Element, amount: number) => {
|
||||
const rootElement = root as HTMLElement;
|
||||
const candidates = [rootElement, ...Array.from(rootElement.querySelectorAll("*"))];
|
||||
const scrollElement =
|
||||
candidates.find(
|
||||
(element) =>
|
||||
element instanceof HTMLElement &&
|
||||
element.scrollHeight - element.clientHeight > 1
|
||||
) ?? rootElement;
|
||||
|
||||
const bottomOffset = Math.max(
|
||||
0,
|
||||
scrollElement.scrollHeight - scrollElement.clientHeight
|
||||
const scrollViewport = getVisibleChatScroll(page);
|
||||
await expect(scrollViewport).toHaveCount(1, { timeout: 30000 });
|
||||
let remaining = Math.max(0, pixels);
|
||||
while (remaining > 0) {
|
||||
const delta = Math.min(240, remaining);
|
||||
await scrollViewport.evaluate((element: Element, step: number) => {
|
||||
const scrollContainer = element as HTMLElement;
|
||||
scrollContainer.dispatchEvent(
|
||||
new WheelEvent("wheel", {
|
||||
deltaY: -step,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
);
|
||||
scrollElement.scrollTop = Math.max(0, bottomOffset - amount);
|
||||
},
|
||||
pixels
|
||||
);
|
||||
scrollContainer.scrollTop = Math.max(0, scrollContainer.scrollTop - step);
|
||||
scrollContainer.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||
}, delta);
|
||||
remaining -= delta;
|
||||
|
||||
if ((await readScrollMetrics(page)).distanceFromBottom > NEAR_BOTTOM_THRESHOLD_PX) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForAgentReady(page: Page, expectedTailText?: string): Promise<void> {
|
||||
await expect(page.getByTestId("agent-chat-scroll")).toBeVisible({ timeout: 60000 });
|
||||
await expect(getVisibleChatScroll(page)).toBeVisible({ timeout: 60000 });
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
|
||||
timeout: 60000,
|
||||
});
|
||||
@@ -263,9 +282,7 @@ export async function waitForContentGrowth(
|
||||
}
|
||||
|
||||
export async function getChatContainerKey(page: Page): Promise<string | null> {
|
||||
return page
|
||||
.getByTestId("agent-chat-scroll")
|
||||
.evaluate((element) => {
|
||||
return getVisibleChatScroll(page).evaluate((element) => {
|
||||
const nativeId = (element as HTMLElement).id;
|
||||
const prefix = "agent-chat-scroll-";
|
||||
return nativeId.startsWith(prefix) ? nativeId.slice(prefix.length) : null;
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
import {
|
||||
buildCreateAgentPreferences,
|
||||
buildSeededHost,
|
||||
} from './daemon-registry';
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
@@ -33,8 +37,8 @@ async function ensureE2EStorageSeeded(page: Page): Promise<void> {
|
||||
if (entry?.serverId !== expectedServerId) return true;
|
||||
const connections = entry?.connections;
|
||||
if (!Array.isArray(connections)) return true;
|
||||
if (connections.some((c: any) => c?.type === 'direct' && typeof c?.endpoint === 'string' && /:6767\b/.test(c.endpoint))) return true;
|
||||
return !connections.some((c: any) => c?.type === 'direct' && c?.endpoint === expectedEndpoint);
|
||||
if (connections.some((c: any) => c?.type === 'directTcp' && typeof c?.endpoint === 'string' && /:6767\b/.test(c.endpoint))) return true;
|
||||
return !connections.some((c: any) => c?.type === 'directTcp' && c?.endpoint === expectedEndpoint);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
@@ -45,42 +49,20 @@ async function ensureE2EStorageSeeded(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
const nowIso = new Date().toISOString();
|
||||
const daemon = buildSeededHost({
|
||||
serverId: expectedServerId,
|
||||
endpoint: expectedEndpoint,
|
||||
nowIso,
|
||||
});
|
||||
const preferences = buildCreateAgentPreferences(expectedServerId);
|
||||
await page.evaluate(
|
||||
({ expectedEndpoint, nowIso, expectedServerId }) => {
|
||||
({ daemon, preferences }) => {
|
||||
localStorage.setItem('@paseo:e2e', '1');
|
||||
localStorage.setItem(
|
||||
'@paseo:daemon-registry',
|
||||
JSON.stringify([
|
||||
{
|
||||
serverId: expectedServerId,
|
||||
label: 'localhost',
|
||||
connections: [
|
||||
{
|
||||
id: `direct:${expectedEndpoint}`,
|
||||
type: 'direct',
|
||||
endpoint: expectedEndpoint,
|
||||
},
|
||||
],
|
||||
preferredConnectionId: `direct:${expectedEndpoint}`,
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
},
|
||||
])
|
||||
);
|
||||
localStorage.setItem(
|
||||
'@paseo:create-agent-preferences',
|
||||
JSON.stringify({
|
||||
serverId: expectedServerId,
|
||||
provider: 'codex',
|
||||
providerPreferences: {
|
||||
claude: { model: 'haiku' },
|
||||
codex: { model: 'gpt-5.1-codex-mini', thinkingOptionId: 'low' },
|
||||
},
|
||||
})
|
||||
);
|
||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
|
||||
localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(preferences));
|
||||
localStorage.removeItem('@paseo:settings');
|
||||
},
|
||||
{ expectedEndpoint, nowIso, expectedServerId }
|
||||
{ daemon, preferences }
|
||||
);
|
||||
|
||||
await page.reload();
|
||||
@@ -128,13 +110,13 @@ async function assertE2EUsesSeededTestDaemon(page: Page): Promise<void> {
|
||||
const connections: unknown = daemon?.connections;
|
||||
if (
|
||||
!Array.isArray(connections) ||
|
||||
!connections.some((c: any) => c?.type === 'direct' && c?.endpoint === expectedEndpoint)
|
||||
!connections.some((c: any) => c?.type === 'directTcp' && c?.endpoint === expectedEndpoint)
|
||||
) {
|
||||
throw new Error(
|
||||
`E2E expected seeded daemon connections to include direct ${expectedEndpoint} (got ${JSON.stringify(connections)}).`
|
||||
`E2E expected seeded daemon connections to include directTcp ${expectedEndpoint} (got ${JSON.stringify(connections)}).`
|
||||
);
|
||||
}
|
||||
if (Array.isArray(connections) && connections.some((c: any) => c?.type === 'direct' && typeof c?.endpoint === 'string' && /:6767\b/.test(c.endpoint))) {
|
||||
if (Array.isArray(connections) && connections.some((c: any) => c?.type === 'directTcp' && typeof c?.endpoint === 'string' && /:6767\b/.test(c.endpoint))) {
|
||||
throw new Error(`E2E detected a daemon endpoint pointing at :6767 (${JSON.stringify(connections)}).`);
|
||||
}
|
||||
|
||||
@@ -154,14 +136,36 @@ async function assertE2EUsesSeededTestDaemon(page: Page): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export const gotoHome = async (page: Page) => {
|
||||
export const gotoAppShell = async (page: Page) => {
|
||||
await page.goto('/');
|
||||
await ensureE2EStorageSeeded(page);
|
||||
await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible();
|
||||
};
|
||||
|
||||
export const gotoHome = async (page: Page) => {
|
||||
await gotoAppShell(page);
|
||||
const composer = page.getByRole('textbox', { name: 'Message agent...' });
|
||||
if (!(await composer.first().isVisible().catch(() => false))) {
|
||||
const addProjectCta = page.getByText('Add a project', { exact: true }).first();
|
||||
const addProjectSidebar = page.getByText('Add project', { exact: true }).first();
|
||||
const newAgentButton = page.getByText('New agent', { exact: true }).first();
|
||||
await newAgentButton.click();
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await addProjectCta.isVisible().catch(() => false)) ||
|
||||
(await addProjectSidebar.isVisible().catch(() => false)) ||
|
||||
(await newAgentButton.isVisible().catch(() => false)),
|
||||
{ timeout: 10000 }
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
if (await addProjectCta.isVisible().catch(() => false)) {
|
||||
await addProjectCta.click();
|
||||
} else if (await addProjectSidebar.isVisible().catch(() => false)) {
|
||||
await addProjectSidebar.click();
|
||||
} else {
|
||||
await newAgentButton.click();
|
||||
}
|
||||
}
|
||||
await expect(composer.first()).toBeVisible({ timeout: 30000 });
|
||||
};
|
||||
|
||||
39
packages/app/e2e/helpers/daemon-registry.ts
Normal file
39
packages/app/e2e/helpers/daemon-registry.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export const TEST_HOST_LABEL = "localhost";
|
||||
|
||||
export const TEST_PROVIDER_PREFERENCES = {
|
||||
claude: { model: "haiku" },
|
||||
codex: { model: "gpt-5.1-codex-mini", thinkingOptionId: "low" },
|
||||
} as const;
|
||||
|
||||
export function buildDirectTcpConnection(endpoint: string) {
|
||||
return {
|
||||
id: `direct:${endpoint}`,
|
||||
type: "directTcp" as const,
|
||||
endpoint,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSeededHost(input: {
|
||||
serverId: string;
|
||||
endpoint: string;
|
||||
label?: string;
|
||||
nowIso: string;
|
||||
}) {
|
||||
const connection = buildDirectTcpConnection(input.endpoint);
|
||||
return {
|
||||
serverId: input.serverId,
|
||||
label: input.label ?? TEST_HOST_LABEL,
|
||||
connections: [connection],
|
||||
preferredConnectionId: connection.id,
|
||||
createdAt: input.nowIso,
|
||||
updatedAt: input.nowIso,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCreateAgentPreferences(serverId: string) {
|
||||
return {
|
||||
serverId,
|
||||
provider: "codex" as const,
|
||||
providerPreferences: TEST_PROVIDER_PREFERENCES,
|
||||
};
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
|
||||
test('no hosts shows welcome; direct connection adds host and lands on agent create', async ({ page }) => {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!daemonPort) {
|
||||
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
|
||||
}
|
||||
if (!serverId) {
|
||||
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
|
||||
}
|
||||
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([]));
|
||||
localStorage.removeItem('@paseo:create-agent-preferences');
|
||||
localStorage.removeItem('@paseo:settings');
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page.getByText('Welcome to Paseo', { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByText('Direct connection', { exact: true }).click();
|
||||
|
||||
await page.getByPlaceholder('host:6767').fill(`127.0.0.1:${daemonPort}`);
|
||||
|
||||
await page.getByText('Connect', { exact: true }).click();
|
||||
|
||||
// First-time connection prompts for an optional label.
|
||||
const nameModal = page.getByTestId('name-host-modal');
|
||||
const showedNameModal = await nameModal
|
||||
.waitFor({ state: 'visible', timeout: 5000 })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (showedNameModal) {
|
||||
await nameModal.getByTestId('name-host-skip').click();
|
||||
}
|
||||
|
||||
await expect(page.getByTestId('sidebar-new-agent')).toBeVisible();
|
||||
await expect(page.getByText(serverId, { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeEditable({
|
||||
timeout: 15000,
|
||||
});
|
||||
});
|
||||
@@ -1,119 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
|
||||
test('host removal removes the host from UI and persists after reload', async ({ page }) => {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
const seededServerId = process.env.E2E_SERVER_ID;
|
||||
if (!daemonPort) {
|
||||
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
|
||||
}
|
||||
if (!seededServerId) {
|
||||
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
|
||||
}
|
||||
|
||||
const extraPort = Number(daemonPort) + 1;
|
||||
const extraEndpoint = `127.0.0.1:${extraPort}`;
|
||||
const nowIso = new Date().toISOString();
|
||||
|
||||
const extraDaemon = {
|
||||
serverId: 'srv_e2e_extra_daemon',
|
||||
label: 'extra',
|
||||
connections: [
|
||||
{ id: `direct:${extraEndpoint}`, type: 'direct', endpoint: extraEndpoint },
|
||||
],
|
||||
preferredConnectionId: `direct:${extraEndpoint}`,
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
|
||||
const seededTestDaemon = {
|
||||
serverId: seededServerId,
|
||||
label: 'localhost',
|
||||
connections: [
|
||||
{ id: `direct:127.0.0.1:${daemonPort}`, type: 'direct', endpoint: `127.0.0.1:${daemonPort}` },
|
||||
],
|
||||
preferredConnectionId: `direct:127.0.0.1:${daemonPort}`,
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
|
||||
const seedOnceKey = `@paseo:e2e-host-removal-seeded:${Math.random().toString(36).slice(2)}`;
|
||||
|
||||
// Add a second host once (fixtures seed the primary host on every navigation).
|
||||
await page.addInitScript(
|
||||
({ daemon, seededTestDaemon, seedOnceKey }) => {
|
||||
if (localStorage.getItem(seedOnceKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const raw = localStorage.getItem('@paseo:daemon-registry');
|
||||
let parsed: any[] = [];
|
||||
if (raw) {
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
parsed = [];
|
||||
}
|
||||
}
|
||||
const list = Array.isArray(parsed) ? parsed : [];
|
||||
localStorage.setItem(seedOnceKey, '1');
|
||||
|
||||
const next = [...list];
|
||||
const hasSeeded = next.some((entry: any) => entry && entry.serverId === seededTestDaemon.serverId);
|
||||
if (!hasSeeded) {
|
||||
next.push(seededTestDaemon);
|
||||
}
|
||||
|
||||
const alreadyPresent = next.some((entry: any) => entry && entry.serverId === daemon.serverId);
|
||||
if (!alreadyPresent) {
|
||||
next.push(daemon);
|
||||
}
|
||||
|
||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify(next));
|
||||
},
|
||||
{ daemon: extraDaemon, seededTestDaemon, seedOnceKey }
|
||||
);
|
||||
|
||||
await page.goto('/settings');
|
||||
|
||||
await expect(page.getByText('extra', { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByText(extraEndpoint, { exact: true }).first()).toBeVisible();
|
||||
|
||||
const hostSettingsButton = page.getByTestId(`daemon-card-settings-${extraDaemon.serverId}`).first();
|
||||
await expect(hostSettingsButton).toBeVisible({ timeout: 10000 });
|
||||
await hostSettingsButton.click();
|
||||
|
||||
const hostDetailModal = page.getByTestId('host-detail-modal');
|
||||
await expect(hostDetailModal).toBeVisible({ timeout: 10000 });
|
||||
await hostDetailModal.getByText('Advanced', { exact: true }).click();
|
||||
await page.getByText('Remove host', { exact: true }).last().click();
|
||||
|
||||
await expect(page.getByTestId('remove-host-confirm-modal')).toBeVisible();
|
||||
await page.getByTestId('remove-host-confirm').click();
|
||||
|
||||
await expect(page.getByTestId(`daemon-card-${extraDaemon.serverId}`)).toHaveCount(0, {
|
||||
timeout: 30000,
|
||||
});
|
||||
await expect(page.getByText(extraEndpoint, { exact: true })).toHaveCount(0);
|
||||
await page.waitForFunction(
|
||||
(serverId) => {
|
||||
const raw = localStorage.getItem('@paseo:daemon-registry');
|
||||
if (!raw) return false;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) && !parsed.some((entry: any) => entry && entry.serverId === serverId);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
extraDaemon.serverId,
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
|
||||
// Prevent the fixture from overwriting storage on reload; verify persistence.
|
||||
await page.evaluate(() => {
|
||||
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
|
||||
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
|
||||
});
|
||||
await page.reload();
|
||||
await expect(page.getByText(extraEndpoint, { exact: true })).toHaveCount(0);
|
||||
});
|
||||
@@ -1,124 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { ensureHostSelected, gotoHome } from './helpers/app';
|
||||
|
||||
test('new agent auto-selects the previous host', async ({ page }) => {
|
||||
await gotoHome(page);
|
||||
await ensureHostSelected(page);
|
||||
|
||||
await gotoHome(page);
|
||||
|
||||
// The selected host should be restored after a full reload without manual selection.
|
||||
await expect(page.getByText('localhost', { exact: true }).first()).toBeVisible();
|
||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
||||
await expect(input).toBeEditable({ timeout: 30000 });
|
||||
});
|
||||
|
||||
test('new agent respects serverId in the URL', async ({ page }) => {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!daemonPort) {
|
||||
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
|
||||
}
|
||||
if (!serverId) {
|
||||
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
|
||||
}
|
||||
|
||||
// Ensure this test's storage is deterministic even under parallel load.
|
||||
const nowIso = new Date().toISOString();
|
||||
const testDaemon = {
|
||||
serverId,
|
||||
label: 'localhost',
|
||||
connections: [
|
||||
{
|
||||
id: `direct:127.0.0.1:${daemonPort}`,
|
||||
type: 'direct',
|
||||
endpoint: `127.0.0.1:${daemonPort}`,
|
||||
},
|
||||
],
|
||||
preferredConnectionId: `direct:127.0.0.1:${daemonPort}`,
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
const createAgentPreferences = {
|
||||
serverId: testDaemon.serverId,
|
||||
provider: 'codex',
|
||||
providerPreferences: {
|
||||
claude: { model: 'haiku' },
|
||||
codex: { model: 'gpt-5.1-codex-mini', thinkingOptionId: 'low' },
|
||||
},
|
||||
};
|
||||
|
||||
await page.goto('/settings');
|
||||
await page.evaluate(
|
||||
({ daemon, preferences }) => {
|
||||
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
|
||||
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
|
||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
|
||||
localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(preferences));
|
||||
localStorage.removeItem('@paseo:settings');
|
||||
},
|
||||
{ daemon: testDaemon, preferences: createAgentPreferences }
|
||||
);
|
||||
await page.reload();
|
||||
await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible({ timeout: 20000 });
|
||||
|
||||
await page.goto(`/?serverId=${encodeURIComponent(serverId)}`);
|
||||
await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible();
|
||||
|
||||
const newAgentButton = page.getByTestId('sidebar-new-agent').first();
|
||||
if (await newAgentButton.isVisible().catch(() => false)) {
|
||||
await newAgentButton.click();
|
||||
} else {
|
||||
await page.getByText('New agent', { exact: true }).first().click();
|
||||
}
|
||||
|
||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
||||
await expect(input).toBeEditable({ timeout: 30000 });
|
||||
});
|
||||
|
||||
test('new agent auto-selects first online host when no preference is stored', async ({ page }) => {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!daemonPort) {
|
||||
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
|
||||
}
|
||||
if (!serverId) {
|
||||
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
|
||||
}
|
||||
|
||||
const nowIso = new Date().toISOString();
|
||||
const testDaemon = {
|
||||
serverId,
|
||||
label: 'localhost',
|
||||
connections: [
|
||||
{
|
||||
id: `direct:127.0.0.1:${daemonPort}`,
|
||||
type: 'direct',
|
||||
endpoint: `127.0.0.1:${daemonPort}`,
|
||||
},
|
||||
],
|
||||
preferredConnectionId: `direct:127.0.0.1:${daemonPort}`,
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
|
||||
await gotoHome(page);
|
||||
await page.evaluate(
|
||||
({ daemon }) => {
|
||||
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
|
||||
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
|
||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
|
||||
localStorage.removeItem('@paseo:create-agent-preferences');
|
||||
localStorage.removeItem('@paseo:settings');
|
||||
},
|
||||
{ daemon: testDaemon }
|
||||
);
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible();
|
||||
|
||||
// Host should be auto-selected (no manual selection required).
|
||||
await expect(page.getByText('localhost', { exact: true }).first()).toBeVisible();
|
||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
||||
await expect(input).toBeEditable({ timeout: 30000 });
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { gotoHome } from "./helpers/app";
|
||||
|
||||
test("question mark opens keyboard shortcuts dialog", async ({ page }) => {
|
||||
await gotoHome(page);
|
||||
await page.getByTestId("menu-button").first().focus();
|
||||
|
||||
await page.keyboard.press("Shift+/");
|
||||
|
||||
const dialog = page.getByTestId("keyboard-shortcuts-dialog");
|
||||
const content = page.getByTestId("keyboard-shortcuts-dialog-content");
|
||||
|
||||
await expect(dialog).toBeVisible({ timeout: 10000 });
|
||||
await expect(content).toBeVisible({ timeout: 10000 });
|
||||
await expect(content).toContainText("Show keyboard shortcuts");
|
||||
await expect(content).toContainText("Toggle left sidebar");
|
||||
});
|
||||
@@ -1,85 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
|
||||
test('manual host add accepts host:port only and persists a direct connection', async ({ page }) => {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!daemonPort) {
|
||||
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
|
||||
}
|
||||
if (!serverId) {
|
||||
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
|
||||
}
|
||||
|
||||
// Override the default fixture seeding for this navigation (must run before app boot).
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([]));
|
||||
localStorage.removeItem('@paseo:settings');
|
||||
});
|
||||
await page.goto('/settings');
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
if (await page.getByText('Welcome to Paseo', { exact: true }).isVisible().catch(() => false)) {
|
||||
return 'welcome';
|
||||
}
|
||||
if (await page.getByText('+ Add connection', { exact: true }).isVisible().catch(() => false)) {
|
||||
return 'settings';
|
||||
}
|
||||
return '';
|
||||
},
|
||||
{ timeout: 15000 }
|
||||
)
|
||||
.not.toBe('');
|
||||
|
||||
const isWelcome = await page.getByText('Welcome to Paseo', { exact: true }).isVisible().catch(() => false);
|
||||
if (isWelcome) {
|
||||
await page.getByText('Direct connection', { exact: true }).first().click();
|
||||
} else {
|
||||
await page.getByText('+ Add connection', { exact: true }).click();
|
||||
await page.getByText('Direct connection', { exact: true }).click();
|
||||
}
|
||||
|
||||
const input = page.getByPlaceholder('host:6767');
|
||||
await expect(input).toBeVisible();
|
||||
await input.fill(`127.0.0.1:${daemonPort}`);
|
||||
|
||||
await page.getByText('Connect', { exact: true }).click();
|
||||
|
||||
const nameModal = page.getByTestId('name-host-modal');
|
||||
if (await nameModal.isVisible().catch(() => false)) {
|
||||
await nameModal.getByTestId('name-host-skip').click();
|
||||
}
|
||||
|
||||
await expect(page.getByTestId('sidebar-new-agent')).toBeVisible({ timeout: 30000 });
|
||||
|
||||
const settingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first();
|
||||
await expect(settingsButton).toBeVisible({ timeout: 10000 });
|
||||
await settingsButton.click();
|
||||
await expect(page.locator(`[data-testid="daemon-card-${serverId}"]:visible`).first()).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
await page.waitForFunction(
|
||||
({ port, serverId }) => {
|
||||
const raw = localStorage.getItem('@paseo:daemon-registry');
|
||||
if (!raw) return false;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed) || parsed.length !== 1) return false;
|
||||
const entry = parsed[0];
|
||||
return (
|
||||
entry?.serverId === serverId &&
|
||||
Array.isArray(entry?.connections) &&
|
||||
entry.connections.some(
|
||||
(conn: any) => conn?.type === 'direct' && conn?.endpoint === `127.0.0.1:${port}`
|
||||
)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ port: daemonPort, serverId },
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
});
|
||||
@@ -1,91 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { gotoHome, openSettings } from './helpers/app';
|
||||
|
||||
function encodeBase64Url(input: string): string {
|
||||
return Buffer.from(input, 'utf8')
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
test('pairing flow accepts #offer=ConnectionOfferV2 and stores relay-only host', async ({ page }) => {
|
||||
const relayPort = process.env.E2E_RELAY_PORT;
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
const daemonPublicKeyB64 = process.env.E2E_RELAY_DAEMON_PUBLIC_KEY;
|
||||
if (!relayPort || !serverId || !daemonPublicKeyB64) {
|
||||
throw new Error(
|
||||
'E2E_RELAY_PORT, E2E_SERVER_ID, or E2E_RELAY_DAEMON_PUBLIC_KEY is not set (expected from globalSetup).'
|
||||
);
|
||||
}
|
||||
|
||||
// Override the default fixture seeding for this test.
|
||||
await gotoHome(page);
|
||||
await openSettings(page);
|
||||
await page.evaluate(() => {
|
||||
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
|
||||
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
|
||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([]));
|
||||
localStorage.removeItem('@paseo:settings');
|
||||
});
|
||||
await page.goto('/');
|
||||
|
||||
const relayEndpoint = `127.0.0.1:${relayPort}`;
|
||||
|
||||
const offer = {
|
||||
v: 2 as const,
|
||||
serverId,
|
||||
daemonPublicKeyB64,
|
||||
relay: { endpoint: relayEndpoint },
|
||||
};
|
||||
|
||||
const offerUrl = `https://app.paseo.sh/#offer=${encodeBase64Url(JSON.stringify(offer))}`;
|
||||
|
||||
const welcomeTitle = page.getByText('Welcome to Paseo', { exact: true });
|
||||
if (await welcomeTitle.isVisible().catch(() => false)) {
|
||||
await page.getByTestId('welcome-paste-pairing-link').click();
|
||||
} else {
|
||||
await page.getByText('+ Add connection', { exact: true }).click();
|
||||
await page.getByText('Paste pairing link', { exact: true }).click();
|
||||
}
|
||||
|
||||
const input = page.getByPlaceholder('https://app.paseo.sh/#offer=...');
|
||||
await expect(input).toBeVisible();
|
||||
await input.fill(offerUrl);
|
||||
|
||||
await page.getByTestId('pair-link-submit').click();
|
||||
|
||||
const nameHostModal = page.getByTestId('name-host-modal');
|
||||
if (await nameHostModal.isVisible().catch(() => false)) {
|
||||
await nameHostModal.getByTestId('name-host-skip').click();
|
||||
}
|
||||
|
||||
await expect(page.getByTestId('sidebar-new-agent')).toBeVisible({ timeout: 30000 });
|
||||
|
||||
await page.waitForFunction(
|
||||
({ expected }) => {
|
||||
const raw = localStorage.getItem('@paseo:daemon-registry');
|
||||
if (!raw) return false;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed) || parsed.length !== 1) return false;
|
||||
const entry = parsed[0];
|
||||
const relayId = `relay:${expected.relay.endpoint}`;
|
||||
return (
|
||||
entry?.serverId === expected.serverId &&
|
||||
Array.isArray(entry?.connections) &&
|
||||
entry.connections.length === 1 &&
|
||||
entry.connections[0]?.id === relayId &&
|
||||
entry.connections[0]?.type === 'relay' &&
|
||||
entry.connections[0]?.relayEndpoint === expected.relay.endpoint &&
|
||||
entry.connections[0]?.daemonPublicKeyB64 === expected.daemonPublicKeyB64
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ expected: offer },
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
});
|
||||
@@ -1,53 +0,0 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { ensureHostSelected, gotoHome, setWorkingDirectory } from "./helpers/app";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
|
||||
test("pastes clipboard image into prompt attachments", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("paseo-e2e-paste-image-");
|
||||
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
|
||||
const input = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await expect(input).toBeEditable();
|
||||
await input.focus();
|
||||
|
||||
const result = await page.evaluate(() => {
|
||||
const active = document.activeElement;
|
||||
if (!(active instanceof HTMLTextAreaElement)) {
|
||||
return {
|
||||
pasted: false,
|
||||
elementTag: active ? active.tagName : null,
|
||||
defaultPrevented: false,
|
||||
};
|
||||
}
|
||||
|
||||
const file = new File([new Uint8Array([0, 1, 2, 3])], "paste.png", {
|
||||
type: "image/png",
|
||||
});
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(file);
|
||||
|
||||
const event = new ClipboardEvent("paste", {
|
||||
clipboardData: dataTransfer,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
active.dispatchEvent(event);
|
||||
|
||||
return {
|
||||
pasted: true,
|
||||
elementTag: active.tagName,
|
||||
defaultPrevented: event.defaultPrevented,
|
||||
};
|
||||
});
|
||||
|
||||
expect(result.pasted).toBe(true);
|
||||
expect(result.defaultPrevented).toBe(true);
|
||||
await expect(page.getByTestId("message-input-image-pill")).toHaveCount(1);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,92 +0,0 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { test, expect } from './fixtures';
|
||||
import {
|
||||
createAgentWithConfig,
|
||||
waitForPermissionPrompt,
|
||||
allowPermission,
|
||||
denyPermission,
|
||||
waitForAgentFinishUI,
|
||||
} from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
|
||||
const FILE_CONTENT = 'Hello from permission test';
|
||||
|
||||
function buildWriteCommand(filePath: string): string {
|
||||
return `bash -lc 'sleep 2; printf "${FILE_CONTENT}" > "${filePath}"'`;
|
||||
}
|
||||
|
||||
test.describe('permission prompts', () => {
|
||||
test('allow permission creates the file', async ({ page }) => {
|
||||
const repo = await createTempGitRepo();
|
||||
const uniqueFilename = `test-allow-${Date.now()}.txt`;
|
||||
const filePath = path.join(repo.path, uniqueFilename);
|
||||
const shellCommand = buildWriteCommand(filePath);
|
||||
const prompt = [
|
||||
`Use your shell tool to run exactly this command:`,
|
||||
shellCommand,
|
||||
`Do not write outside this exact path.`,
|
||||
].join(' ');
|
||||
|
||||
try {
|
||||
await createAgentWithConfig(page, {
|
||||
directory: repo.path,
|
||||
provider: 'claude',
|
||||
mode: 'Always Ask',
|
||||
prompt,
|
||||
});
|
||||
|
||||
await waitForPermissionPrompt(page, 30000);
|
||||
|
||||
await allowPermission(page);
|
||||
|
||||
// Wait for file to be created
|
||||
await expect
|
||||
.poll(() => existsSync(filePath), {
|
||||
message: `File ${filePath} should exist after allowing permission`,
|
||||
timeout: 30000,
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
// After allowing, the file should be created successfully
|
||||
// The tool call count might still be 1 if the UI updates quickly
|
||||
const fileContent = await readFile(filePath, 'utf-8');
|
||||
expect(fileContent.trim()).toBe(FILE_CONTENT);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('deny permission does not create the file', async ({ page }) => {
|
||||
const repo = await createTempGitRepo();
|
||||
const uniqueFilename = `test-deny-${Date.now()}.txt`;
|
||||
const filePath = path.join(repo.path, uniqueFilename);
|
||||
const shellCommand = buildWriteCommand(filePath);
|
||||
const prompt = [
|
||||
`Use your shell tool to run exactly this command:`,
|
||||
shellCommand,
|
||||
`Do not write outside this exact path.`,
|
||||
].join(' ');
|
||||
|
||||
try {
|
||||
await createAgentWithConfig(page, {
|
||||
directory: repo.path,
|
||||
provider: 'claude',
|
||||
mode: 'Always Ask',
|
||||
prompt,
|
||||
});
|
||||
|
||||
await waitForPermissionPrompt(page, 30000);
|
||||
|
||||
await denyPermission(page);
|
||||
await waitForAgentFinishUI(page, 30000);
|
||||
await expect(page.getByTestId('permission-request-question')).toHaveCount(0);
|
||||
|
||||
expect(existsSync(filePath)).toBe(false);
|
||||
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { gotoHome, ensureHostSelected, setWorkingDirectory } from './helpers/app';
|
||||
|
||||
test('preserves prompt text when trying to create agent with non-existent directory', async ({ page }) => {
|
||||
const nonExistentDir = '/non/existent/directory/that/does/not/exist';
|
||||
const promptText = `Test prompt that should be preserved ${Date.now()}`;
|
||||
|
||||
await gotoHome(page);
|
||||
await ensureHostSelected(page);
|
||||
await setWorkingDirectory(page, nonExistentDir);
|
||||
|
||||
// Enter prompt text
|
||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
||||
await expect(input).toBeEditable();
|
||||
await input.fill(promptText);
|
||||
|
||||
// Try to submit - this should fail with an error about the directory not existing
|
||||
await input.press('Enter');
|
||||
|
||||
// Verify error message is displayed (error includes the path)
|
||||
await expect(page.getByText(/Working directory does not exist/)).toBeVisible();
|
||||
|
||||
// Verify the prompt text is still preserved in the input
|
||||
await expect(input).toHaveValue(promptText);
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { gotoHome, openSettings } from './helpers/app';
|
||||
|
||||
test('connects via relay when direct endpoints fail', async ({ page }) => {
|
||||
const relayPort = process.env.E2E_RELAY_PORT;
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
const daemonPublicKeyB64 = process.env.E2E_RELAY_DAEMON_PUBLIC_KEY;
|
||||
if (!relayPort || !serverId || !daemonPublicKeyB64) {
|
||||
throw new Error(
|
||||
'E2E_RELAY_PORT, E2E_SERVER_ID, or E2E_RELAY_DAEMON_PUBLIC_KEY is not set (expected from globalSetup).'
|
||||
);
|
||||
}
|
||||
|
||||
const nowIso = new Date().toISOString();
|
||||
const relayEndpoint = `127.0.0.1:${relayPort}`;
|
||||
|
||||
const host = {
|
||||
serverId,
|
||||
label: 'relay-daemon',
|
||||
connections: [
|
||||
{ id: 'direct:127.0.0.1:9', type: 'direct', endpoint: '127.0.0.1:9' },
|
||||
{ id: `relay:${relayEndpoint}`, type: 'relay', relayEndpoint, daemonPublicKeyB64 },
|
||||
],
|
||||
preferredConnectionId: 'direct:127.0.0.1:9',
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
|
||||
// Override the default fixture seeding for this test.
|
||||
await gotoHome(page);
|
||||
await openSettings(page);
|
||||
await page.evaluate((daemon) => {
|
||||
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
|
||||
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
|
||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
|
||||
localStorage.removeItem('@paseo:settings');
|
||||
}, host);
|
||||
await page.reload();
|
||||
|
||||
// Should eventually connect through the relay connection.
|
||||
const card = page.getByTestId(`daemon-card-${serverId}`);
|
||||
await expect(card.getByText('Relay', { exact: true })).toBeVisible({ timeout: 20000 });
|
||||
await expect(card.getByText('Online', { exact: true })).toBeVisible({ timeout: 20000 });
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { gotoHome, openSettings } from './helpers/app';
|
||||
|
||||
test('relay connection stays stable across multiple tabs', async ({ page }) => {
|
||||
const relayPort = process.env.E2E_RELAY_PORT;
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
const daemonPublicKeyB64 = process.env.E2E_RELAY_DAEMON_PUBLIC_KEY;
|
||||
if (!relayPort || !serverId || !daemonPublicKeyB64) {
|
||||
throw new Error(
|
||||
'E2E_RELAY_PORT, E2E_SERVER_ID, or E2E_RELAY_DAEMON_PUBLIC_KEY is not set (expected from globalSetup).'
|
||||
);
|
||||
}
|
||||
|
||||
const nowIso = new Date().toISOString();
|
||||
const relayEndpoint = `127.0.0.1:${relayPort}`;
|
||||
|
||||
const host = {
|
||||
serverId,
|
||||
label: 'relay-daemon',
|
||||
connections: [
|
||||
{ id: 'direct:127.0.0.1:9', type: 'direct', endpoint: '127.0.0.1:9' },
|
||||
{ id: `relay:${relayEndpoint}`, type: 'relay', relayEndpoint, daemonPublicKeyB64 },
|
||||
],
|
||||
preferredConnectionId: 'direct:127.0.0.1:9',
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
|
||||
// Use relay by making the direct endpoint intentionally fail.
|
||||
await gotoHome(page);
|
||||
await openSettings(page);
|
||||
await page.evaluate((daemon) => {
|
||||
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
|
||||
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
|
||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
|
||||
localStorage.removeItem('@paseo:settings');
|
||||
}, host);
|
||||
await page.reload();
|
||||
|
||||
const card = page.getByTestId(`daemon-card-${serverId}`);
|
||||
await expect(card.getByText('Relay', { exact: true })).toBeVisible({ timeout: 20000 });
|
||||
await expect(card.getByText('Online', { exact: true })).toBeVisible({ timeout: 20000 });
|
||||
|
||||
// Open a second tab. It should be able to connect independently without forcing disconnect churn.
|
||||
const page2 = await page.context().newPage();
|
||||
await page2.route(/:(6767)\b/, (route) => route.abort());
|
||||
await page2.routeWebSocket(/:(6767)\b/, async (ws) => {
|
||||
await ws.close({ code: 1008, reason: 'Blocked connection to localhost:6767 during e2e.' });
|
||||
});
|
||||
await page2.goto('/');
|
||||
const settingsButton2 = page2.locator('[data-testid="sidebar-settings"]:visible').first();
|
||||
await expect(settingsButton2).toBeVisible({ timeout: 20000 });
|
||||
await settingsButton2.click();
|
||||
const card2 = page2.getByTestId(`daemon-card-${serverId}`);
|
||||
await expect(card2.getByText('Relay', { exact: true })).toBeVisible({ timeout: 20000 });
|
||||
await expect(card2.getByText('Online', { exact: true })).toBeVisible({ timeout: 20000 });
|
||||
|
||||
// Stability window: keep both tabs open and ensure they remain online.
|
||||
await page.waitForTimeout(30_000);
|
||||
await expect(card.getByText('Online', { exact: true })).toBeVisible();
|
||||
await expect(card2.getByText('Online', { exact: true })).toBeVisible();
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
|
||||
test('sidebar New Agent opens a fresh create screen', async ({ page }) => {
|
||||
const repoA = await createTempGitRepo();
|
||||
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await ensureHostSelected(page);
|
||||
|
||||
await setWorkingDirectory(page, repoA.path);
|
||||
await createAgent(page, 'Agent A: respond with exactly A');
|
||||
await expect(page).toHaveURL(/\/agent\//);
|
||||
|
||||
// Click sidebar New Agent and assert it re-opens the host draft route while
|
||||
// preserving working directory context from the selected agent.
|
||||
await page.getByTestId('sidebar-new-agent').click();
|
||||
await expect(page).toHaveURL(/\/h\/[^/]+\/agent(\?|$)/);
|
||||
const searchWorkingDir = await page.evaluate(() => {
|
||||
try {
|
||||
return new URL(window.location.href).searchParams.get('workingDir');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const normalizedCandidates = new Set<string>([repoA.path]);
|
||||
if (repoA.path.startsWith('/var/')) {
|
||||
normalizedCandidates.add(`/private${repoA.path}`);
|
||||
}
|
||||
if (repoA.path.startsWith('/private/var/')) {
|
||||
normalizedCandidates.add(repoA.path.replace(/^\/private/, ''));
|
||||
}
|
||||
expect(searchWorkingDir).not.toBeNull();
|
||||
expect(normalizedCandidates.has(searchWorkingDir ?? '')).toBe(true);
|
||||
} finally {
|
||||
await repoA.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,117 +0,0 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { gotoHome } from "./helpers/app";
|
||||
|
||||
test("project filter dropdown never appears visibly at 0,0 on open", async ({ page }) => {
|
||||
await gotoHome(page);
|
||||
|
||||
const trigger = page.getByText("Project", { exact: true }).first();
|
||||
await expect(trigger).toBeVisible();
|
||||
|
||||
await page.evaluate(() => {
|
||||
(window as any).__projectFilterFlashProbe = new Promise<{
|
||||
targetFound: boolean;
|
||||
visibleAtOrigin: boolean;
|
||||
records: Array<{ left: number; top: number; opacity: number }>;
|
||||
}>((resolve) => {
|
||||
let target: HTMLElement | null = null;
|
||||
const records: Array<{ left: number; top: number; opacity: number }> = [];
|
||||
|
||||
const capture = () => {
|
||||
if (!target) return;
|
||||
const style = getComputedStyle(target);
|
||||
records.push({
|
||||
left: Number.parseFloat(style.left || "0"),
|
||||
top: Number.parseFloat(style.top || "0"),
|
||||
opacity: Number.parseFloat(style.opacity || "1"),
|
||||
});
|
||||
};
|
||||
|
||||
const tryResolveTarget = (root: HTMLElement) => {
|
||||
const stack = [root, ...Array.from(root.querySelectorAll<HTMLElement>("*"))];
|
||||
for (const element of stack) {
|
||||
if (element.dataset?.testid === "combobox-desktop-container") {
|
||||
target = element;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const finish = () => {
|
||||
observer.disconnect();
|
||||
if (!target) {
|
||||
resolve({
|
||||
targetFound: false,
|
||||
visibleAtOrigin: false,
|
||||
records: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleAtOrigin = records.some(
|
||||
(entry) => entry.left <= 1 && entry.top <= 1 && entry.opacity > 0.01
|
||||
);
|
||||
|
||||
resolve({
|
||||
targetFound: true,
|
||||
visibleAtOrigin,
|
||||
records,
|
||||
});
|
||||
};
|
||||
|
||||
const sampleFrames = () => {
|
||||
capture();
|
||||
requestAnimationFrame(() => {
|
||||
capture();
|
||||
requestAnimationFrame(() => {
|
||||
capture();
|
||||
requestAnimationFrame(() => {
|
||||
capture();
|
||||
requestAnimationFrame(() => {
|
||||
capture();
|
||||
finish();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
for (const added of Array.from(mutation.addedNodes)) {
|
||||
if (!(added instanceof HTMLElement)) continue;
|
||||
if (tryResolveTarget(added)) {
|
||||
sampleFrames();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
mutation.type === "attributes" &&
|
||||
mutation.target instanceof HTMLElement &&
|
||||
!target &&
|
||||
tryResolveTarget(mutation.target)
|
||||
) {
|
||||
sampleFrames();
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["style"],
|
||||
});
|
||||
|
||||
setTimeout(() => finish(), 2500);
|
||||
});
|
||||
});
|
||||
|
||||
await trigger.click();
|
||||
const probe = await page.evaluate(() => (window as any).__projectFilterFlashProbe);
|
||||
|
||||
expect(probe.targetFound).toBe(true);
|
||||
expect(probe.visibleAtOrigin).toBe(false);
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { gotoHome, openSettings } from './helpers/app';
|
||||
|
||||
test('sidebar toggle shows tooltip on the right', async ({ page }) => {
|
||||
await gotoHome(page);
|
||||
await openSettings(page);
|
||||
|
||||
const menuButton = page.getByRole('button', { name: /menu/i }).first();
|
||||
await expect(menuButton).toBeVisible();
|
||||
|
||||
// Baseline: tooltip should appear on keyboard focus (a11y requirement).
|
||||
await menuButton.focus();
|
||||
|
||||
const tooltip = page.getByTestId('menu-button-tooltip');
|
||||
await expect(tooltip).toBeVisible();
|
||||
await expect(tooltip).toContainText('Toggle sidebar');
|
||||
await expect(tooltip).toContainText(/⌘B|Ctrl\+\./);
|
||||
await page.waitForTimeout(250);
|
||||
await expect(tooltip).toBeVisible();
|
||||
|
||||
// Tooltip should also appear on hover.
|
||||
await menuButton.blur();
|
||||
await expect(tooltip).toHaveCount(0);
|
||||
await menuButton.hover();
|
||||
await expect(tooltip).toBeVisible();
|
||||
|
||||
const triggerBox = await menuButton.boundingBox();
|
||||
const tooltipBox = await tooltip.boundingBox();
|
||||
expect(triggerBox).not.toBeNull();
|
||||
expect(tooltipBox).not.toBeNull();
|
||||
if (!triggerBox || !tooltipBox) return;
|
||||
|
||||
// side=right => tooltip starts to the right of the trigger.
|
||||
expect(tooltipBox.x).toBeGreaterThan(triggerBox.x + triggerBox.width - 1);
|
||||
// Keep it reasonably close (should be ~trigger.right + offset).
|
||||
const expectedX = triggerBox.x + triggerBox.width + 8;
|
||||
expect(Math.abs(tooltipBox.x - expectedX)).toBeLessThanOrEqual(12);
|
||||
expect(tooltipBox.width).toBeGreaterThanOrEqual(60);
|
||||
expect(tooltipBox.width).toBeLessThanOrEqual(500);
|
||||
expect(tooltipBox.height).toBeGreaterThanOrEqual(20);
|
||||
expect(tooltipBox.height).toBeLessThanOrEqual(80);
|
||||
|
||||
// align=center => centers should be roughly aligned (allow some clamping tolerance).
|
||||
const triggerCenterY = triggerBox.y + triggerBox.height / 2;
|
||||
const tooltipCenterY = tooltipBox.y + tooltipBox.height / 2;
|
||||
expect(Math.abs(triggerCenterY - tooltipCenterY)).toBeLessThanOrEqual(24);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,209 +0,0 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { setWorkingDirectory } from "./helpers/app";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
openNewAgentComposer,
|
||||
seedWorkspaceActivity,
|
||||
switchWorkspaceViaSidebar,
|
||||
} from "./helpers/workspace-ui";
|
||||
|
||||
function percentile(values: number[], p: number): number {
|
||||
if (values.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const rank = Math.ceil((p / 100) * sorted.length);
|
||||
const index = Math.min(sorted.length - 1, Math.max(0, rank - 1));
|
||||
return sorted[index] ?? 0;
|
||||
}
|
||||
|
||||
function summarize(values: number[]) {
|
||||
if (values.length === 0) {
|
||||
return {
|
||||
count: 0,
|
||||
minMs: 0,
|
||||
maxMs: 0,
|
||||
avgMs: 0,
|
||||
p95Ms: 0,
|
||||
p99Ms: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const sum = values.reduce((acc, value) => acc + value, 0);
|
||||
return {
|
||||
count: values.length,
|
||||
minMs: Math.min(...values),
|
||||
maxMs: Math.max(...values),
|
||||
avgMs: Math.round((sum / values.length) * 100) / 100,
|
||||
p95Ms: percentile(values, 95),
|
||||
p99Ms: percentile(values, 99),
|
||||
};
|
||||
}
|
||||
|
||||
function buildStressCommand(doneMarker: string): string {
|
||||
// Deterministic synthetic "TUI-like" redraw loop: alternate screen + cursor-home repaint.
|
||||
const markerFile = ".paseo-terminal-benchmark-marker";
|
||||
return [
|
||||
"i=1",
|
||||
"printf '\\033[?1049h\\033[2J'",
|
||||
"while [ $i -le 240 ]; do",
|
||||
"printf '\\033[H'",
|
||||
"r=1",
|
||||
"while [ $r -le 24 ]; do",
|
||||
"printf 'bench frame:%03d row:%02d ########################################\\n' \"$i\" \"$r\"",
|
||||
"r=$((r+1))",
|
||||
"done",
|
||||
"sleep 0.01",
|
||||
"i=$((i+1))",
|
||||
"done",
|
||||
`printf '\\033[?1049l\\n${doneMarker}\\n'`,
|
||||
`printf '${doneMarker}\\n' > '${markerFile}'`,
|
||||
].join("; ");
|
||||
}
|
||||
|
||||
async function markerFileContains(filePath: string, marker: string): Promise<boolean> {
|
||||
try {
|
||||
const text = await readFile(filePath, "utf8");
|
||||
return text.includes(marker);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleExplorerAndMeasureLatency(page: Page): Promise<number> {
|
||||
const toggle = page.getByTestId("workspace-explorer-toggle").first();
|
||||
await expect(toggle).toBeVisible({ timeout: 30_000 });
|
||||
const currentlyExpanded = (await toggle.getAttribute("aria-expanded")) === "true";
|
||||
const expected = currentlyExpanded ? "false" : "true";
|
||||
|
||||
const start = Date.now();
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", expected, { timeout: 15_000 });
|
||||
return Date.now() - start;
|
||||
}
|
||||
|
||||
test("workspace terminal responsiveness benchmark (report-only, single stress profile)", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(180_000);
|
||||
const repo = await createTempGitRepo("paseo-e2e-terminal-benchmark-");
|
||||
const markerFilePath = path.join(repo.path, ".paseo-terminal-benchmark-marker");
|
||||
|
||||
try {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
|
||||
await openNewAgentComposer(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await seedWorkspaceActivity(page, `terminal benchmark seed ${Date.now()}`);
|
||||
|
||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repo.path });
|
||||
await expect(page.getByTestId("workspace-new-terminal-tab").first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const newTerminalButton = page.getByTestId("workspace-new-terminal-tab").first();
|
||||
await expect(newTerminalButton).toBeVisible({ timeout: 30_000 });
|
||||
await newTerminalButton.click();
|
||||
|
||||
const surface = page.locator('[data-testid="terminal-surface"]:visible').first();
|
||||
await expect(surface).toBeVisible({ timeout: 60_000 });
|
||||
await surface.click({ force: true });
|
||||
|
||||
await page.evaluate(() => {
|
||||
const monitor = {
|
||||
samples: [] as number[],
|
||||
active: true,
|
||||
rafId: 0,
|
||||
lastTs: performance.now(),
|
||||
};
|
||||
|
||||
const tick = (ts: number) => {
|
||||
if (!monitor.active) {
|
||||
return;
|
||||
}
|
||||
monitor.samples.push(ts - monitor.lastTs);
|
||||
monitor.lastTs = ts;
|
||||
monitor.rafId = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
monitor.rafId = requestAnimationFrame(tick);
|
||||
|
||||
(window as { __PASEO_E2E_RAF_MONITOR__?: { stop: () => { samples: number[] } } }).__PASEO_E2E_RAF_MONITOR__ =
|
||||
{
|
||||
stop: () => {
|
||||
if (!monitor.active) {
|
||||
return { samples: monitor.samples };
|
||||
}
|
||||
monitor.active = false;
|
||||
cancelAnimationFrame(monitor.rafId);
|
||||
return { samples: monitor.samples };
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const doneMarker = `TERMINAL_BENCH_DONE_${Date.now()}`;
|
||||
const postMarker = `TERMINAL_BENCH_POST_${Date.now()}`;
|
||||
const stressCommand = buildStressCommand(doneMarker);
|
||||
await page.keyboard.type(stressCommand, { delay: 0 });
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
const interactionLatenciesMs: number[] = [];
|
||||
for (let attempt = 0; attempt < 18; attempt += 1) {
|
||||
const latency = await toggleExplorerAndMeasureLatency(page);
|
||||
interactionLatenciesMs.push(latency);
|
||||
}
|
||||
|
||||
const rafResult = await page.evaluate(() => {
|
||||
const handle = (
|
||||
window as { __PASEO_E2E_RAF_MONITOR__?: { stop: () => { samples: number[] } } }
|
||||
).__PASEO_E2E_RAF_MONITOR__;
|
||||
if (!handle || typeof handle.stop !== "function") {
|
||||
return { samples: [] as number[] };
|
||||
}
|
||||
return handle.stop();
|
||||
});
|
||||
|
||||
await surface.click({ force: true });
|
||||
await page.keyboard.type(`echo ${postMarker} >> .paseo-terminal-benchmark-marker`, { delay: 0 });
|
||||
await page.keyboard.press("Enter");
|
||||
await expect.poll(async () => await markerFileContains(markerFilePath, postMarker), {
|
||||
timeout: 120_000,
|
||||
}).toBe(true);
|
||||
|
||||
const frameGapsMs = (rafResult.samples ?? []).filter(
|
||||
(sample) => Number.isFinite(sample) && sample > 0
|
||||
);
|
||||
const report = {
|
||||
mode: "report-only",
|
||||
profile: "single-stress",
|
||||
generatedAt: new Date().toISOString(),
|
||||
workload: {
|
||||
frames: 240,
|
||||
rowsPerFrame: 24,
|
||||
frameSleepMs: 10,
|
||||
doneMarker,
|
||||
postMarker,
|
||||
doneMarkerObserved: await markerFileContains(markerFilePath, doneMarker),
|
||||
},
|
||||
frameGapMs: {
|
||||
...summarize(frameGapsMs),
|
||||
over100Ms: frameGapsMs.filter((gap) => gap > 100).length,
|
||||
over250Ms: frameGapsMs.filter((gap) => gap > 250).length,
|
||||
over500Ms: frameGapsMs.filter((gap) => gap > 500).length,
|
||||
},
|
||||
explorerToggleLatencyMs: summarize(interactionLatenciesMs),
|
||||
};
|
||||
|
||||
await testInfo.attach("terminal-responsiveness-report", {
|
||||
body: JSON.stringify(report, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,158 +0,0 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
import { gotoHome } from "./helpers/app";
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
test("working directory combobox stays visually stable while typing search", async ({ page }) => {
|
||||
await gotoHome(page);
|
||||
|
||||
const workingDirectorySelect = page
|
||||
.locator('[data-testid="working-directory-select"]:visible')
|
||||
.first();
|
||||
await expect(workingDirectorySelect).toBeVisible();
|
||||
await workingDirectorySelect.click({ force: true });
|
||||
|
||||
const searchInput = page.getByRole("textbox", { name: /search directories/i }).first();
|
||||
await expect(searchInput).toBeVisible();
|
||||
|
||||
await page.evaluate(() => {
|
||||
const trigger = document.querySelector('[data-testid="working-directory-select"]');
|
||||
const searchInput = document.querySelector('input[placeholder="Search directories..."]');
|
||||
const container = document.querySelector('[data-testid="combobox-desktop-container"]');
|
||||
if (!(trigger instanceof HTMLElement)) {
|
||||
throw new Error("Missing working-directory-select trigger.");
|
||||
}
|
||||
if (!(searchInput instanceof HTMLInputElement)) {
|
||||
throw new Error("Missing working directory search input.");
|
||||
}
|
||||
if (!(container instanceof HTMLElement)) {
|
||||
throw new Error("Missing combobox desktop container.");
|
||||
}
|
||||
|
||||
const state = {
|
||||
samples: 0,
|
||||
underTriggerSamples: 0,
|
||||
emptyWhileSearchingSamples: 0,
|
||||
minSearchDelta: Number.POSITIVE_INFINITY,
|
||||
maxSearchDelta: Number.NEGATIVE_INFINITY,
|
||||
minContainerDelta: Number.POSITIVE_INFINITY,
|
||||
maxContainerDelta: Number.NEGATIVE_INFINITY,
|
||||
logs: [] as Array<{
|
||||
reason: string;
|
||||
query: string;
|
||||
searchDelta: number;
|
||||
containerDelta: number;
|
||||
hasEmpty: boolean;
|
||||
containerTop: number;
|
||||
containerBottom: number;
|
||||
triggerTop: number;
|
||||
searchBottom: number;
|
||||
}>,
|
||||
};
|
||||
|
||||
const sample = (reason: string) => {
|
||||
if (!document.body.contains(trigger) || !document.body.contains(searchInput) || !document.body.contains(container)) {
|
||||
return;
|
||||
}
|
||||
const query = searchInput.value.trim();
|
||||
if (!query) {
|
||||
return;
|
||||
}
|
||||
|
||||
const triggerRect = trigger.getBoundingClientRect();
|
||||
const searchRect = searchInput.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const hasEmpty = Boolean(container.querySelector('[data-testid="combobox-empty-text"]'));
|
||||
const searchDelta = searchRect.bottom - triggerRect.top;
|
||||
const containerDelta = containerRect.bottom - triggerRect.top;
|
||||
|
||||
state.samples += 1;
|
||||
state.minSearchDelta = Math.min(state.minSearchDelta, searchDelta);
|
||||
state.maxSearchDelta = Math.max(state.maxSearchDelta, searchDelta);
|
||||
state.minContainerDelta = Math.min(state.minContainerDelta, containerDelta);
|
||||
state.maxContainerDelta = Math.max(state.maxContainerDelta, containerDelta);
|
||||
if (searchDelta > 2 || containerDelta > 2) {
|
||||
state.underTriggerSamples += 1;
|
||||
}
|
||||
if (hasEmpty) {
|
||||
state.emptyWhileSearchingSamples += 1;
|
||||
}
|
||||
if (state.logs.length < 80) {
|
||||
state.logs.push({
|
||||
reason,
|
||||
query,
|
||||
searchDelta,
|
||||
containerDelta,
|
||||
hasEmpty,
|
||||
containerTop: containerRect.top,
|
||||
containerBottom: containerRect.bottom,
|
||||
triggerTop: triggerRect.top,
|
||||
searchBottom: searchRect.bottom,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const mutationObserver = new MutationObserver(() => sample("mutation"));
|
||||
mutationObserver.observe(container, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
characterData: true,
|
||||
});
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => sample("resize"));
|
||||
resizeObserver.observe(container);
|
||||
resizeObserver.observe(searchInput);
|
||||
|
||||
let rafId = 0;
|
||||
const loop = () => {
|
||||
sample("raf");
|
||||
rafId = requestAnimationFrame(loop);
|
||||
};
|
||||
rafId = requestAnimationFrame(loop);
|
||||
|
||||
(window as any).__paseoComboboxObserver = {
|
||||
stop: () => {
|
||||
cancelAnimationFrame(rafId);
|
||||
mutationObserver.disconnect();
|
||||
resizeObserver.disconnect();
|
||||
sample("stop");
|
||||
return {
|
||||
...state,
|
||||
minSearchDelta: state.samples > 0 ? state.minSearchDelta : 0,
|
||||
maxSearchDelta: state.samples > 0 ? state.maxSearchDelta : 0,
|
||||
minContainerDelta: state.samples > 0 ? state.minContainerDelta : 0,
|
||||
maxContainerDelta: state.samples > 0 ? state.maxContainerDelta : 0,
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const queries = [
|
||||
"/tmp/paseo-stability-a",
|
||||
"/tmp/paseo-stability-ab",
|
||||
"/tmp/paseo-stability-abc",
|
||||
"/tmp/paseo-stability-longer-branch",
|
||||
"/tmp/paseo-stability-z",
|
||||
];
|
||||
|
||||
for (const query of queries) {
|
||||
await searchInput.fill("");
|
||||
await searchInput.type(query, { delay: 20 });
|
||||
const customOption = page.getByText(new RegExp(`^${escapeRegex(query)}$`)).first();
|
||||
await expect(customOption).toBeVisible();
|
||||
await page.waitForTimeout(100);
|
||||
}
|
||||
|
||||
const stats = await page.evaluate(() => (window as any).__paseoComboboxObserver.stop());
|
||||
const debug = JSON.stringify(stats.logs.slice(-10));
|
||||
|
||||
expect(stats.samples, debug).toBeGreaterThan(20);
|
||||
expect(stats.underTriggerSamples, debug).toBe(0);
|
||||
expect(stats.emptyWhileSearchingSamples, debug).toBe(0);
|
||||
expect(stats.maxSearchDelta - stats.minSearchDelta, debug).toBeLessThanOrEqual(3);
|
||||
expect(stats.maxContainerDelta - stats.minContainerDelta, debug).toBeLessThanOrEqual(3);
|
||||
expect(stats.maxContainerDelta, debug).toBeLessThanOrEqual(2);
|
||||
});
|
||||
@@ -1,107 +0,0 @@
|
||||
import { test, expect } from './fixtures'
|
||||
import { ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app'
|
||||
import { createTempGitRepo } from './helpers/workspace'
|
||||
import {
|
||||
ensureWorkspaceAgentPaneVisible,
|
||||
getWorkspaceTabTestIds,
|
||||
waitForWorkspaceTabsVisible,
|
||||
} from './helpers/workspace-tabs'
|
||||
import { switchWorkspaceViaSidebar } from './helpers/workspace-ui'
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
test('workspace draft tab uses input status controls with optimistic create and in-place transition', async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = process.env.E2E_SERVER_ID
|
||||
if (!serverId) {
|
||||
throw new Error('E2E_SERVER_ID is not set.')
|
||||
}
|
||||
|
||||
const repo = await createTempGitRepo('paseo-e2e-workspace-draft-')
|
||||
const seedPrompt = `seed workspace ${Date.now()}`
|
||||
const createPrompt = `workspace draft prompt ${Date.now()}`
|
||||
|
||||
try {
|
||||
await gotoHome(page)
|
||||
await ensureHostSelected(page)
|
||||
|
||||
// Force the setup onto the global draft surface. In slow runs the app can
|
||||
// still be on a previously opened agent/workspace view where the placement
|
||||
// form is not rendered.
|
||||
await page.goto(`/h/${serverId}/new-agent`)
|
||||
await expect(page.locator('[data-testid="working-directory-select"]:visible').first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
})
|
||||
|
||||
await setWorkingDirectory(page, repo.path)
|
||||
const seedComposer = page.getByRole('textbox', { name: 'Message agent...' }).first()
|
||||
await seedComposer.fill(seedPrompt)
|
||||
await page.getByRole('button', { name: /send message/i }).first().click()
|
||||
await expect(page.getByText(seedPrompt, { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
})
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 60_000 })
|
||||
|
||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repo.path })
|
||||
const workspaceRouteToken = page.url().match(/\/workspace\/([^/?#]+)/)?.[1] ?? null
|
||||
expect(workspaceRouteToken).toBeTruthy()
|
||||
await waitForWorkspaceTabsVisible(page)
|
||||
await ensureWorkspaceAgentPaneVisible(page)
|
||||
|
||||
const beforeDraftIds = await getWorkspaceTabTestIds(page)
|
||||
await page.getByTestId('workspace-new-agent-tab').first().click()
|
||||
await ensureWorkspaceAgentPaneVisible(page)
|
||||
|
||||
const withDraftIds = await getWorkspaceTabTestIds(page)
|
||||
const draftTabTestId = withDraftIds.find((id) => !beforeDraftIds.includes(id))
|
||||
if (!draftTabTestId) {
|
||||
throw new Error('Expected a draft workspace tab to be created.')
|
||||
}
|
||||
|
||||
const draftId = draftTabTestId.replace('workspace-tab-', '')
|
||||
const draftCloseButton = page.getByTestId(`workspace-draft-close-${draftId}`).first()
|
||||
await expect(draftCloseButton).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
await expect(page.getByTestId('working-directory-select').first()).not.toBeVisible()
|
||||
await expect(page.getByTestId('worktree-select-trigger').first()).not.toBeVisible()
|
||||
|
||||
const providerSelector = page.getByTestId('agent-provider-selector').first()
|
||||
const modeSelector = page.getByTestId('agent-mode-selector').first()
|
||||
const modelSelector = page.getByTestId('agent-model-selector').first()
|
||||
const thinkingSelector = page.getByTestId('agent-thinking-selector').first()
|
||||
|
||||
await expect(providerSelector).toBeVisible({ timeout: 30_000 })
|
||||
await expect(modeSelector).toBeVisible({ timeout: 30_000 })
|
||||
await expect(modelSelector).toBeVisible({ timeout: 30_000 })
|
||||
await expect(thinkingSelector).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
const selectedMode = ((await modeSelector.innerText()) ?? '').trim()
|
||||
const selectedModel = ((await modelSelector.innerText()) ?? '').trim()
|
||||
const selectedThinking = ((await thinkingSelector.innerText()) ?? '').trim()
|
||||
|
||||
const composer = page.getByRole('textbox', { name: 'Message agent...' }).first()
|
||||
await composer.fill(createPrompt)
|
||||
await composer.press('Enter')
|
||||
|
||||
await expect(page.getByText(createPrompt, { exact: true }).first()).toBeVisible({ timeout: 5_000 })
|
||||
await expect(draftCloseButton).not.toBeVisible({ timeout: 30_000 })
|
||||
|
||||
const finalIds = await getWorkspaceTabTestIds(page)
|
||||
expect(finalIds).toContain(draftTabTestId)
|
||||
|
||||
await expect(modelSelector).toContainText(new RegExp(escapeRegex(selectedModel), 'i'))
|
||||
await expect(modeSelector).toContainText(new RegExp(escapeRegex(selectedMode), 'i'))
|
||||
await expect(thinkingSelector).toContainText(new RegExp(escapeRegex(selectedThinking), 'i'))
|
||||
|
||||
const currentUrl = page.url()
|
||||
if (!workspaceRouteToken) {
|
||||
throw new Error('Expected workspace route token to be present.')
|
||||
}
|
||||
expect(currentUrl).toContain(`/workspace/${workspaceRouteToken}`)
|
||||
} finally {
|
||||
await repo.cleanup()
|
||||
}
|
||||
})
|
||||
@@ -1,125 +0,0 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { createAgentInRepo } from "./helpers/app";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
ensureWorkspaceAgentPaneVisible,
|
||||
getWorkspaceTabTestIds,
|
||||
sampleWorkspaceTabIds,
|
||||
waitForWorkspaceTabsVisible,
|
||||
} from "./helpers/workspace-tabs";
|
||||
import { switchWorkspaceViaSidebar } from "./helpers/workspace-ui";
|
||||
|
||||
async function expectComposerFocused(page: Page) {
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
await expect(composer).toBeEditable({ timeout: 30_000 });
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await composer.evaluate(
|
||||
(element) => document.activeElement === element
|
||||
);
|
||||
})
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
test("workspace draft submit retargets tab in place without transient extra tabs", async ({ page }) => {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
|
||||
const repo = await createTempGitRepo("paseo-e2e-draft-retarget-");
|
||||
const seedPrompt = `seed prompt ${Date.now()}`;
|
||||
const createPrompt = `retarget prompt ${Date.now()}`;
|
||||
|
||||
try {
|
||||
await createAgentInRepo(page, { directory: repo.path, prompt: seedPrompt });
|
||||
|
||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repo.path });
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await ensureWorkspaceAgentPaneVisible(page);
|
||||
|
||||
const beforeDraftIds = await getWorkspaceTabTestIds(page);
|
||||
await page.getByTestId("workspace-new-agent-tab").first().click();
|
||||
await ensureWorkspaceAgentPaneVisible(page);
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeEditable();
|
||||
|
||||
const withDraftIds = await getWorkspaceTabTestIds(page);
|
||||
expect(withDraftIds.length).toBe(beforeDraftIds.length + 1);
|
||||
const draftTabTestId = withDraftIds.find((id) => !beforeDraftIds.includes(id));
|
||||
expect(draftTabTestId).toBeTruthy();
|
||||
|
||||
const draftId = draftTabTestId!.replace("workspace-tab-", "");
|
||||
const draftCloseButton = page.getByTestId(`workspace-draft-close-${draftId}`).first();
|
||||
await expect(draftCloseButton).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const samplingPromise = sampleWorkspaceTabIds(page, { durationMs: 3_000, intervalMs: 40 });
|
||||
const input = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await input.fill(createPrompt);
|
||||
await input.press("Enter");
|
||||
await expect(page.getByText(createPrompt, { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const snapshots = await samplingPromise;
|
||||
const maxObservedCount = snapshots.reduce((max, ids) => Math.max(max, ids.length), 0);
|
||||
expect(maxObservedCount).toBe(withDraftIds.length);
|
||||
|
||||
const finalIds = await getWorkspaceTabTestIds(page);
|
||||
expect(finalIds.length).toBe(withDraftIds.length);
|
||||
expect(finalIds).toContain(draftTabTestId!);
|
||||
await expect(draftCloseButton).not.toBeVisible({ timeout: 30_000 });
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace agent tab switch focuses composer on desktop web", async ({ page }) => {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
|
||||
const repo = await createTempGitRepo("paseo-e2e-tab-focus-");
|
||||
const firstPrompt = `first tab prompt ${Date.now()}`;
|
||||
const secondPrompt = `second tab prompt ${Date.now()}`;
|
||||
|
||||
try {
|
||||
await createAgentInRepo(page, { directory: repo.path, prompt: firstPrompt });
|
||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repo.path });
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await ensureWorkspaceAgentPaneVisible(page);
|
||||
|
||||
const beforeSecondAgentIds = await getWorkspaceTabTestIds(page);
|
||||
await page.getByTestId("workspace-new-agent-tab").first().click();
|
||||
await expectComposerFocused(page);
|
||||
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
await composer.fill(secondPrompt);
|
||||
await composer.press("Enter");
|
||||
await expect(page.getByText(secondPrompt, { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const withSecondAgentIds = await getWorkspaceTabTestIds(page);
|
||||
const secondAgentTabTestId = withSecondAgentIds.find(
|
||||
(id) => !beforeSecondAgentIds.includes(id)
|
||||
);
|
||||
if (!secondAgentTabTestId) {
|
||||
throw new Error("Expected second agent tab to be created.");
|
||||
}
|
||||
|
||||
const firstAgentTabTestId = beforeSecondAgentIds[0];
|
||||
if (!firstAgentTabTestId) {
|
||||
throw new Error("Expected first agent tab to exist.");
|
||||
}
|
||||
|
||||
await page.getByTestId(firstAgentTabTestId).first().click();
|
||||
await expectComposerFocused(page);
|
||||
|
||||
await page.getByTestId(secondAgentTabTestId).first().click();
|
||||
await expectComposerFocused(page);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,165 +0,0 @@
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { createAgentInRepo } from "./helpers/app";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { switchWorkspaceViaSidebar } from "./helpers/workspace-ui";
|
||||
|
||||
async function openWorkspaceWithAgent(page: Page, workspacePath: string): Promise<void> {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
|
||||
await createAgentInRepo(page, {
|
||||
directory: workspacePath,
|
||||
prompt: `workspace header restore ${Date.now()}`,
|
||||
});
|
||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: workspacePath });
|
||||
await expect(page.getByTestId("workspace-new-agent-tab").first()).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
await expect(page.getByTestId("workspace-new-terminal-tab").first()).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
test("workspace new-tab buttons stay on-screen during horizontal scroll", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("paseo-e2e-workspace-new-tab-");
|
||||
|
||||
try {
|
||||
await openWorkspaceWithAgent(page, repo.path);
|
||||
|
||||
const agentButton = page.getByTestId("workspace-new-agent-tab").first();
|
||||
const terminalButton = page.getByTestId("workspace-new-terminal-tab").first();
|
||||
const tabsScroll = page.getByTestId("workspace-tabs-scroll").first();
|
||||
await expect(agentButton).toBeVisible({ timeout: 30000 });
|
||||
await expect(terminalButton).toBeVisible({ timeout: 30000 });
|
||||
await expect(tabsScroll).toBeVisible({ timeout: 30000 });
|
||||
|
||||
// Create enough terminal tabs to ensure the tabs row has overflow to scroll.
|
||||
const workspaceTabs = page.locator(
|
||||
'[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])'
|
||||
);
|
||||
const initialTabCount = await workspaceTabs.count();
|
||||
const targetTabCount = initialTabCount + 8;
|
||||
|
||||
for (let attempt = initialTabCount; attempt < targetTabCount; attempt += 1) {
|
||||
await expect(terminalButton).toBeEnabled({ timeout: 30000 });
|
||||
await terminalButton.click();
|
||||
await expect
|
||||
.poll(async () => await workspaceTabs.count(), { timeout: 30000 })
|
||||
.toBeGreaterThanOrEqual(attempt + 1);
|
||||
}
|
||||
|
||||
const agentBoundsBefore = await agentButton.boundingBox();
|
||||
const terminalBoundsBefore = await terminalButton.boundingBox();
|
||||
const viewport = page.viewportSize();
|
||||
|
||||
expect(agentBoundsBefore).not.toBeNull();
|
||||
expect(terminalBoundsBefore).not.toBeNull();
|
||||
expect(viewport).not.toBeNull();
|
||||
|
||||
if (!agentBoundsBefore || !terminalBoundsBefore || !viewport) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(agentBoundsBefore.x).toBeGreaterThanOrEqual(0);
|
||||
expect(agentBoundsBefore.y).toBeGreaterThanOrEqual(0);
|
||||
expect(agentBoundsBefore.x + agentBoundsBefore.width).toBeLessThanOrEqual(viewport.width);
|
||||
expect(agentBoundsBefore.y + agentBoundsBefore.height).toBeLessThanOrEqual(viewport.height);
|
||||
|
||||
expect(terminalBoundsBefore.x).toBeGreaterThanOrEqual(0);
|
||||
expect(terminalBoundsBefore.y).toBeGreaterThanOrEqual(0);
|
||||
expect(terminalBoundsBefore.x + terminalBoundsBefore.width).toBeLessThanOrEqual(viewport.width);
|
||||
expect(terminalBoundsBefore.y + terminalBoundsBefore.height).toBeLessThanOrEqual(viewport.height);
|
||||
|
||||
// Scroll tabs horizontally; the new-tab buttons should remain fixed on the right edge.
|
||||
await tabsScroll.evaluate((el) => {
|
||||
(el as HTMLElement).scrollLeft = (el as HTMLElement).scrollWidth;
|
||||
});
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
const agentBoundsAfter = await agentButton.boundingBox();
|
||||
const terminalBoundsAfter = await terminalButton.boundingBox();
|
||||
|
||||
expect(agentBoundsAfter).not.toBeNull();
|
||||
expect(terminalBoundsAfter).not.toBeNull();
|
||||
|
||||
if (!agentBoundsAfter || !terminalBoundsAfter) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(agentBoundsAfter.x).toBeGreaterThanOrEqual(0);
|
||||
expect(agentBoundsAfter.y).toBeGreaterThanOrEqual(0);
|
||||
expect(agentBoundsAfter.x + agentBoundsAfter.width).toBeLessThanOrEqual(viewport.width);
|
||||
expect(agentBoundsAfter.y + agentBoundsAfter.height).toBeLessThanOrEqual(viewport.height);
|
||||
|
||||
expect(terminalBoundsAfter.x).toBeGreaterThanOrEqual(0);
|
||||
expect(terminalBoundsAfter.y).toBeGreaterThanOrEqual(0);
|
||||
expect(terminalBoundsAfter.x + terminalBoundsAfter.width).toBeLessThanOrEqual(viewport.width);
|
||||
expect(terminalBoundsAfter.y + terminalBoundsAfter.height).toBeLessThanOrEqual(viewport.height);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace new-tab buttons sit immediately after tabs before overflow", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("paseo-e2e-workspace-new-tab-adjacent-");
|
||||
|
||||
try {
|
||||
await openWorkspaceWithAgent(page, repo.path);
|
||||
|
||||
const agentButton = page.getByTestId("workspace-new-agent-tab").first();
|
||||
const workspaceTabs = page.locator(
|
||||
'[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])'
|
||||
);
|
||||
|
||||
await expect(agentButton).toBeVisible({ timeout: 30000 });
|
||||
await expect(workspaceTabs).toHaveCount(1, { timeout: 30000 });
|
||||
|
||||
const lastTabBounds = await workspaceTabs.last().boundingBox();
|
||||
const agentBounds = await agentButton.boundingBox();
|
||||
|
||||
expect(lastTabBounds).not.toBeNull();
|
||||
expect(agentBounds).not.toBeNull();
|
||||
|
||||
if (!lastTabBounds || !agentBounds) {
|
||||
return;
|
||||
}
|
||||
|
||||
const horizontalGap = agentBounds.x - (lastTabBounds.x + lastTabBounds.width);
|
||||
|
||||
expect(horizontalGap).toBeGreaterThanOrEqual(0);
|
||||
expect(horizontalGap).toBeLessThanOrEqual(24);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace explorer toggle opens and closes explorer", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("paseo-e2e-workspace-explorer-toggle-");
|
||||
|
||||
try {
|
||||
await openWorkspaceWithAgent(page, repo.path);
|
||||
|
||||
const toggle = page.getByTestId("workspace-explorer-toggle").first();
|
||||
const explorerHeader = page.locator('[data-testid="explorer-header"]:visible').first();
|
||||
await expect(toggle).toBeVisible({ timeout: 30000 });
|
||||
|
||||
const initiallyExpanded = (await toggle.getAttribute("aria-expanded")) === "true";
|
||||
if (initiallyExpanded) {
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(explorerHeader).not.toBeVisible({ timeout: 10000 });
|
||||
}
|
||||
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||
await expect(explorerHeader).toBeVisible({ timeout: 10000 });
|
||||
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(explorerHeader).not.toBeVisible({ timeout: 10000 });
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,58 +0,0 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
import { test } from './fixtures';
|
||||
import { setWorkingDirectory } from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
import {
|
||||
expectWorkspaceHeader,
|
||||
openNewAgentComposer,
|
||||
seedWorkspaceActivity,
|
||||
switchWorkspaceViaSidebar,
|
||||
workspaceLabelFromPath,
|
||||
} from './helpers/workspace-ui';
|
||||
|
||||
test('sidebar workspace switch keeps visible content in sync with selected workspace', async ({ page }) => {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error('E2E_SERVER_ID is not set.');
|
||||
}
|
||||
|
||||
const repoA = await createTempGitRepo('paseo-e2e-sync-a-');
|
||||
const repoB = await createTempGitRepo('paseo-e2e-sync-b-');
|
||||
|
||||
const tokenA = `SYNC_A_${Date.now()}`;
|
||||
const tokenB = `SYNC_B_${Date.now()}`;
|
||||
|
||||
try {
|
||||
execSync('git checkout -b sync-a-branch', { cwd: repoA.path, stdio: 'ignore' });
|
||||
execSync('git checkout -b sync-b-branch', { cwd: repoB.path, stdio: 'ignore' });
|
||||
|
||||
await openNewAgentComposer(page);
|
||||
await setWorkingDirectory(page, repoA.path);
|
||||
await seedWorkspaceActivity(page, tokenA);
|
||||
|
||||
await openNewAgentComposer(page);
|
||||
await setWorkingDirectory(page, repoB.path);
|
||||
await seedWorkspaceActivity(page, tokenB);
|
||||
|
||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repoA.path });
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: 'sync-a-branch',
|
||||
subtitle: workspaceLabelFromPath(repoA.path),
|
||||
});
|
||||
|
||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repoB.path });
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: 'sync-b-branch',
|
||||
subtitle: workspaceLabelFromPath(repoB.path),
|
||||
});
|
||||
|
||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repoA.path });
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: 'sync-a-branch',
|
||||
subtitle: workspaceLabelFromPath(repoA.path),
|
||||
});
|
||||
} finally {
|
||||
await repoA.cleanup();
|
||||
await repoB.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -2,17 +2,10 @@
|
||||
import { polyfillCrypto } from "./src/polyfills/crypto";
|
||||
polyfillCrypto();
|
||||
|
||||
// Polyfill screen.orientation for WebKitGTK (Tauri Linux) which lacks the API
|
||||
// Polyfill screen.orientation for WebKitGTK desktop runtimes that lack the API.
|
||||
import { polyfillScreenOrientation } from "./src/polyfills/screen-orientation";
|
||||
polyfillScreenOrientation();
|
||||
|
||||
// Bridge console.log/warn/error to Tauri's log plugin so JS output appears in app.log
|
||||
if ((globalThis as { __TAURI__?: unknown }).__TAURI__) {
|
||||
import("@tauri-apps/plugin-log").then(({ attachConsole }) => {
|
||||
attachConsole();
|
||||
});
|
||||
}
|
||||
|
||||
// Configure Unistyles before Expo Router pulls in any components using StyleSheet.
|
||||
import "./src/styles/unistyles";
|
||||
import "expo-router/entry";
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
const { getDefaultConfig } = require("expo/metro-config");
|
||||
const exclusionList =
|
||||
require("@expo/metro/metro-config/defaults/exclusionList").default;
|
||||
const { resolve } = require("metro-resolver");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
@@ -18,8 +16,10 @@ const customWebPlatform = (process.env.PASEO_WEB_PLATFORM ?? "")
|
||||
const config = getDefaultConfig(projectRoot);
|
||||
const defaultResolveRequest = config.resolver.resolveRequest ?? resolve;
|
||||
const escapedAppSrcRoot = appSrcRoot
|
||||
.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&")
|
||||
.replace(/\//g, "[\\\\/]");
|
||||
.split(path.sep)
|
||||
.map((segment) => segment.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&"))
|
||||
.join("[\\\\/]");
|
||||
const pathSeparatorPattern = "[\\\\/]";
|
||||
|
||||
config.resolver.extraNodeModules = {
|
||||
...(config.resolver.extraNodeModules ?? {}),
|
||||
@@ -28,9 +28,9 @@ config.resolver.extraNodeModules = {
|
||||
"react/jsx-runtime": path.join(appNodeModulesRoot, "react/jsx-runtime"),
|
||||
"react/jsx-dev-runtime": path.join(appNodeModulesRoot, "react/jsx-dev-runtime"),
|
||||
};
|
||||
config.resolver.blockList = exclusionList([
|
||||
new RegExp(`^${escapedAppSrcRoot}[\\\\/].*\\.(test|spec)\\.(ts|tsx)$`),
|
||||
]);
|
||||
config.resolver.blockList = new RegExp(
|
||||
`(^${escapedAppSrcRoot}${pathSeparatorPattern}.*\\.(test|spec)\\.(ts|tsx)$|${pathSeparatorPattern}__tests__${pathSeparatorPattern}.*)$`,
|
||||
);
|
||||
|
||||
function isLocalModuleImport(moduleName) {
|
||||
return (
|
||||
@@ -1,21 +1,21 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"main": "index.ts",
|
||||
"version": "0.1.26",
|
||||
"version": "0.1.31",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"reset-project": "node ./scripts/reset-project.js",
|
||||
"build:workspace-deps": "npm run build --prefix ../expo-two-way-audio",
|
||||
"eas-build-post-install": "npm run build:workspace-deps",
|
||||
"android": "npm run android:development",
|
||||
"android:clear-autolinking-cache": "node -e \"require('node:fs').rmSync('android/build/generated/autolinking', { recursive: true, force: true })\"",
|
||||
"android:development": "npm run android:clear-autolinking-cache && APP_VARIANT=development expo prebuild --platform android --non-interactive && APP_VARIANT=development expo run:android --variant=debug",
|
||||
"android:production": "npm run android:clear-autolinking-cache && APP_VARIANT=production expo prebuild --platform android --non-interactive && APP_VARIANT=production expo run:android --variant=release",
|
||||
"android:development": "APP_VARIANT=development expo prebuild --platform android --non-interactive && APP_VARIANT=development expo run:android --variant=debug",
|
||||
"android:production": "APP_VARIANT=production expo prebuild --platform android --non-interactive && APP_VARIANT=production expo run:android --variant=release",
|
||||
"android:release": "npm run android:production",
|
||||
"android:clean": "expo prebuild --platform android --clean --non-interactive",
|
||||
"android:clear": "node -e \"require('node:fs').rmSync('android', { recursive: true, force: true })\"",
|
||||
"ios": "expo run:ios",
|
||||
"ios:release": "expo run:ios --configuration Release",
|
||||
"web": "expo start --web",
|
||||
"web:tauri": "PASEO_WEB_PLATFORM=tauri expo start --web",
|
||||
"lint": "expo lint",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
@@ -23,27 +23,33 @@
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"build": "npm run build:web",
|
||||
"build:web": "expo export --platform web",
|
||||
"build:web:tauri": "PASEO_WEB_PLATFORM=tauri expo export --platform web",
|
||||
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app"
|
||||
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main"
|
||||
},
|
||||
"dependencies": {
|
||||
"@boudra/expo-two-way-audio": "^0.1.3",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@getpaseo/server": "0.1.26",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.31",
|
||||
"@getpaseo/server": "0.1.31",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/cpp": "^1.1.5",
|
||||
"@lezer/css": "^1.3.0",
|
||||
"@lezer/go": "^1.0.1",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@lezer/html": "^1.3.13",
|
||||
"@lezer/java": "^1.1.3",
|
||||
"@lezer/javascript": "^1.5.4",
|
||||
"@lezer/json": "^1.0.3",
|
||||
"@lezer/markdown": "^1.6.2",
|
||||
"@lezer/php": "^1.0.5",
|
||||
"@lezer/python": "^1.1.18",
|
||||
"@lezer/rust": "^1.0.2",
|
||||
"@lezer/xml": "^1.0.6",
|
||||
"@lezer/yaml": "^1.0.4",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-native-masked-view/masked-view": "^0.3.2",
|
||||
"@react-native/normalize-colors": "^0.81.5",
|
||||
@@ -52,8 +58,6 @@
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
"@tanstack/react-query": "^5.90.11",
|
||||
"@tanstack/react-virtual": "^3.13.21",
|
||||
"@tauri-apps/api": "^2.9.1",
|
||||
"@tauri-apps/plugin-log": "^2.8.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-unicode11": "^0.9.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
const baseURL =
|
||||
process.env.E2E_BASE_URL ??
|
||||
`http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
|
||||
process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? '8081'}`
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
globalSetup: "./e2e/global-setup.ts",
|
||||
testDir: './e2e',
|
||||
globalSetup: './e2e/global-setup.ts',
|
||||
timeout: 60_000,
|
||||
expect: {
|
||||
timeout: 10_000,
|
||||
@@ -14,17 +13,17 @@ export default defineConfig({
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
retries: 0,
|
||||
reporter: [["list"]],
|
||||
reporter: [['list']],
|
||||
use: {
|
||||
baseURL,
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
video: "retain-on-failure",
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "Desktop Safari",
|
||||
use: { ...devices["Desktop Safari"] },
|
||||
name: 'Desktop Safari',
|
||||
use: { ...devices['Desktop Safari'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
})
|
||||
|
||||
54
packages/app/public/index.html
Normal file
54
packages/app/public/index.html
Normal file
@@ -0,0 +1,54 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="%LANG_ISO_CODE%">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, shrink-to-fit=no, viewport-fit=cover"
|
||||
/>
|
||||
<title>%WEB_TITLE%</title>
|
||||
<!-- The `react-native-web` recommended style reset: https://necolas.github.io/react-native-web/docs/setup/#root-element -->
|
||||
<style id="expo-reset">
|
||||
/* These styles make the body full-height */
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
/* These styles disable body scrolling if you are using <ScrollView> */
|
||||
body {
|
||||
overflow: hidden;
|
||||
}
|
||||
/* These styles make the root element full-height */
|
||||
#root {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
button,
|
||||
a,
|
||||
input,
|
||||
textarea,
|
||||
select,
|
||||
[role='button'],
|
||||
[role='link'],
|
||||
[role='textbox'],
|
||||
[role='combobox'],
|
||||
[role='tab'],
|
||||
[role='switch'],
|
||||
[role='checkbox'],
|
||||
[role='slider'],
|
||||
[role='menuitem'],
|
||||
[tabindex],
|
||||
[contenteditable='true'] {
|
||||
-webkit-app-region: no-drag !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,6 +1,12 @@
|
||||
import "@/styles/unistyles";
|
||||
import { polyfillCrypto } from "@/polyfills/crypto";
|
||||
import { Stack, useGlobalSearchParams, usePathname, useRouter } from "expo-router";
|
||||
import {
|
||||
Stack,
|
||||
useGlobalSearchParams,
|
||||
useNavigationContainerRef,
|
||||
usePathname,
|
||||
useRouter,
|
||||
} from "expo-router";
|
||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||
import { KeyboardProvider } from "react-native-keyboard-controller";
|
||||
import { GestureHandlerRootView, Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
@@ -17,7 +23,7 @@ import {
|
||||
getHostRuntimeStore,
|
||||
useHosts,
|
||||
useHostMutations,
|
||||
useHostRuntimeSession,
|
||||
useHostRuntimeClient,
|
||||
} from "@/runtime/host-runtime";
|
||||
import { SessionProvider } from "@/contexts/session-context";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
@@ -35,6 +41,7 @@ import * as Linking from "expo-linking";
|
||||
import * as Notifications from "expo-notifications";
|
||||
import { LeftSidebar } from "@/components/left-sidebar";
|
||||
import { DownloadToast } from "@/components/download-toast";
|
||||
import { UpdateBanner } from "@/desktop/updates/update-banner";
|
||||
import { ToastProvider } from "@/contexts/toast-context";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { runOnJS, interpolate, Extrapolation, useSharedValue } from "react-native-reanimated";
|
||||
@@ -46,7 +53,7 @@ import {
|
||||
HorizontalScrollProvider,
|
||||
useHorizontalScrollOptional,
|
||||
} from "@/contexts/horizontal-scroll-context";
|
||||
import { getIsTauri } from "@/constants/layout";
|
||||
import { getIsDesktop } from "@/constants/layout";
|
||||
import { CommandCenter } from "@/components/command-center";
|
||||
import { ProjectPickerModal } from "@/components/project-picker-modal";
|
||||
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
||||
@@ -57,59 +64,74 @@ import {
|
||||
type WebNotificationClickDetail,
|
||||
ensureOsNotificationPermission,
|
||||
} from "@/utils/os-notifications";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
import { buildNotificationRoute } from "@/utils/notification-routing";
|
||||
import {
|
||||
buildHostRootRoute,
|
||||
mapPathnameToServer,
|
||||
parseServerIdFromPathname,
|
||||
parseHostAgentRouteFromPathname,
|
||||
parseWorkspaceOpenIntent,
|
||||
} from "@/utils/host-routes";
|
||||
import { getTauri } from "@/utils/tauri";
|
||||
import { attachConsole } from "@/utils/tauri-attach-console";
|
||||
import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||
|
||||
polyfillCrypto();
|
||||
attachConsole();
|
||||
const HostRuntimeBootstrapContext = createContext(false);
|
||||
|
||||
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
|
||||
|
||||
function logLeftSidebarOpenGesture(
|
||||
event: string,
|
||||
details: Record<string, unknown>
|
||||
): void {
|
||||
if (!IS_DEV) {
|
||||
return;
|
||||
}
|
||||
console.log(`[LeftSidebarOpenGesture] ${event}`, details);
|
||||
}
|
||||
|
||||
function PushNotificationRouter() {
|
||||
const router = useRouter();
|
||||
const lastHandledIdRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS === "web") {
|
||||
if (getTauri()) {
|
||||
void ensureOsNotificationPermission().then((granted) => {
|
||||
console.log(
|
||||
"[OSNotifications][Tauri] Startup permission preflight result:",
|
||||
granted ? "granted" : "not-granted"
|
||||
);
|
||||
let removeDesktopNotificationListener: (() => void) | null = null;
|
||||
let cancelled = false;
|
||||
|
||||
if (getIsDesktop()) {
|
||||
void ensureOsNotificationPermission();
|
||||
|
||||
const unlistenResult = getDesktopHost()?.events?.on?.(
|
||||
"notification-click",
|
||||
(payload: unknown) => {
|
||||
const data =
|
||||
typeof payload === "object" &&
|
||||
payload !== null &&
|
||||
"data" in payload &&
|
||||
typeof (payload as { data?: unknown }).data === "object" &&
|
||||
(payload as { data?: unknown }).data !== null
|
||||
? ((payload as { data: Record<string, unknown> }).data)
|
||||
: undefined;
|
||||
router.push(buildNotificationRoute(data) as any);
|
||||
}
|
||||
);
|
||||
|
||||
void Promise.resolve(unlistenResult).then((unlisten) => {
|
||||
if (typeof unlisten !== "function") {
|
||||
return;
|
||||
}
|
||||
if (cancelled) {
|
||||
unlisten();
|
||||
return;
|
||||
}
|
||||
removeDesktopNotificationListener = unlisten;
|
||||
});
|
||||
}
|
||||
|
||||
const target = globalThis as unknown as EventTarget;
|
||||
const openFromWebClick = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<WebNotificationClickDetail>;
|
||||
const route = buildNotificationRoute(customEvent.detail?.data);
|
||||
event.preventDefault();
|
||||
router.push(route as any);
|
||||
router.push(buildNotificationRoute(customEvent.detail?.data) as any);
|
||||
};
|
||||
|
||||
target.addEventListener(
|
||||
WEB_NOTIFICATION_CLICK_EVENT,
|
||||
openFromWebClick as EventListener
|
||||
);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
removeDesktopNotificationListener?.();
|
||||
target.removeEventListener(
|
||||
WEB_NOTIFICATION_CLICK_EVENT,
|
||||
openFromWebClick as EventListener
|
||||
@@ -159,7 +181,7 @@ function PushNotificationRouter() {
|
||||
}
|
||||
|
||||
function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
|
||||
const { client } = useHostRuntimeSession(daemon.serverId);
|
||||
const client = useHostRuntimeClient(daemon.serverId);
|
||||
|
||||
if (!client) {
|
||||
return null;
|
||||
@@ -235,6 +257,9 @@ function QueryProvider({ children }: { children: ReactNode }) {
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
const rowStyle = { flex: 1, flexDirection: "row" } as const;
|
||||
const flexStyle = { flex: 1 } as const;
|
||||
|
||||
interface AppContainerProps {
|
||||
children: ReactNode;
|
||||
selectedAgentId?: string;
|
||||
@@ -248,23 +273,12 @@ function AppContainer({
|
||||
}: AppContainerProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const daemons = useHosts();
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||
const openAgentList = usePanelStore((state) => state.openAgentList);
|
||||
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
|
||||
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
|
||||
const horizontalScroll = useHorizontalScrollOptional();
|
||||
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
|
||||
const isOpen = chromeEnabled
|
||||
? isMobile
|
||||
? mobileView === "agent-list"
|
||||
: desktopAgentListOpen
|
||||
: false;
|
||||
const openGestureEnabled =
|
||||
chromeEnabled && isMobile && mobileView === "agent";
|
||||
|
||||
useKeyboardShortcuts({
|
||||
enabled: chromeEnabled,
|
||||
@@ -273,6 +287,50 @@ function AppContainer({
|
||||
selectedAgentId,
|
||||
toggleFileExplorer,
|
||||
});
|
||||
|
||||
const containerStyle = useMemo(
|
||||
() => ({ flex: 1 as const, backgroundColor: theme.colors.surface0 }),
|
||||
[theme.colors.surface0]
|
||||
);
|
||||
|
||||
const content = (
|
||||
<View style={containerStyle}>
|
||||
<View style={rowStyle}>
|
||||
{!isMobile && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
||||
<View style={flexStyle}>
|
||||
{children}
|
||||
</View>
|
||||
</View>
|
||||
{isMobile && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
||||
<DownloadToast />
|
||||
<UpdateBanner />
|
||||
<CommandCenter />
|
||||
<ProjectPickerModal />
|
||||
<KeyboardShortcutsDialog />
|
||||
</View>
|
||||
);
|
||||
|
||||
if (!isMobile) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return (
|
||||
<MobileGestureWrapper chromeEnabled={chromeEnabled}>
|
||||
{content}
|
||||
</MobileGestureWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileGestureWrapper({
|
||||
children,
|
||||
chromeEnabled,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
chromeEnabled: boolean;
|
||||
}) {
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const openAgentList = usePanelStore((state) => state.openAgentList);
|
||||
const horizontalScroll = useHorizontalScrollOptional();
|
||||
const {
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
@@ -281,18 +339,14 @@ function AppContainer({
|
||||
animateToClose,
|
||||
isGesturing,
|
||||
} = useSidebarAnimation();
|
||||
|
||||
// Track initial touch position for manual activation
|
||||
const touchStartX = useSharedValue(0);
|
||||
const openGestureEnabled = chromeEnabled && mobileView === "agent";
|
||||
|
||||
// Open gesture: swipe right from anywhere to open sidebar (interactive drag)
|
||||
// If any horizontal scroll is scrolled right, let the scroll view handle the gesture first
|
||||
const openGesture = useMemo(
|
||||
() =>
|
||||
Gesture.Pan()
|
||||
.enabled(openGestureEnabled)
|
||||
.manualActivation(true)
|
||||
// Fail if 10px vertical movement happens first (allow vertical scroll)
|
||||
.failOffsetY([-10, 10])
|
||||
.onTouchesDown((event) => {
|
||||
const touch = event.changedTouches[0];
|
||||
@@ -306,26 +360,19 @@ function AppContainer({
|
||||
|
||||
const deltaX = touch.absoluteX - touchStartX.value;
|
||||
|
||||
// If horizontal scroll is scrolled right, fail so ScrollView handles it
|
||||
if (horizontalScroll?.isAnyScrolledRight.value) {
|
||||
stateManager.fail();
|
||||
return;
|
||||
}
|
||||
|
||||
// Activate after 15px rightward movement
|
||||
if (deltaX > 15) {
|
||||
stateManager.activate();
|
||||
}
|
||||
})
|
||||
.onStart(() => {
|
||||
isGesturing.value = true;
|
||||
runOnJS(logLeftSidebarOpenGesture)("start", {
|
||||
mobileView,
|
||||
openGestureEnabled,
|
||||
});
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
// Start from closed position (-windowWidth) and move towards 0
|
||||
const newTranslateX = Math.min(0, -windowWidth + event.translationX);
|
||||
translateX.value = newTranslateX;
|
||||
backdropOpacity.value = interpolate(
|
||||
@@ -337,15 +384,7 @@ function AppContainer({
|
||||
})
|
||||
.onEnd((event) => {
|
||||
isGesturing.value = false;
|
||||
// Open if dragged more than 1/3 of sidebar or fast swipe
|
||||
const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500;
|
||||
runOnJS(logLeftSidebarOpenGesture)("end", {
|
||||
translationX: event.translationX,
|
||||
velocityX: event.velocityX,
|
||||
shouldOpen,
|
||||
mobileView,
|
||||
openGestureEnabled,
|
||||
});
|
||||
if (shouldOpen) {
|
||||
animateToOpen();
|
||||
runOnJS(openAgentList)();
|
||||
@@ -364,36 +403,15 @@ function AppContainer({
|
||||
animateToOpen,
|
||||
animateToClose,
|
||||
openAgentList,
|
||||
mobileView,
|
||||
isGesturing,
|
||||
horizontalScroll?.isAnyScrolledRight,
|
||||
touchStartX,
|
||||
]
|
||||
);
|
||||
|
||||
const content = (
|
||||
<View style={{ flex: 1, backgroundColor: theme.colors.surface0 }}>
|
||||
<View style={{ flex: 1, flexDirection: "row" }}>
|
||||
{!isMobile && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
||||
<View style={{ flex: 1 }}>
|
||||
{children}
|
||||
</View>
|
||||
</View>
|
||||
{isMobile && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
||||
<DownloadToast />
|
||||
<CommandCenter />
|
||||
<ProjectPickerModal />
|
||||
<KeyboardShortcutsDialog />
|
||||
</View>
|
||||
);
|
||||
|
||||
if (!isMobile) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return (
|
||||
<GestureDetector gesture={openGesture} touchAction="pan-y">
|
||||
{content}
|
||||
{children}
|
||||
</GestureDetector>
|
||||
);
|
||||
}
|
||||
@@ -422,6 +440,8 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<VoiceProvider>
|
||||
<OfferLinkListener upsertDaemonFromOfferUrl={upsertConnectionFromOfferUrl} />
|
||||
<HostSessionManager />
|
||||
<FaviconStatusSync />
|
||||
{children}
|
||||
</VoiceProvider>
|
||||
);
|
||||
@@ -468,10 +488,22 @@ function OfferLinkListener({
|
||||
}
|
||||
|
||||
function AppWithSidebar({ children }: { children: ReactNode }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const params = useGlobalSearchParams<{ open?: string | string[] }>();
|
||||
useFaviconStatus();
|
||||
const shouldShowAppChrome = pathname !== "/" && pathname !== "";
|
||||
const hosts = useHosts();
|
||||
const activeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
|
||||
const shouldShowAppChrome = activeServerId !== null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeServerId || hosts.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (hosts.some((host) => host.serverId === activeServerId)) {
|
||||
return;
|
||||
}
|
||||
router.replace(mapPathnameToServer(pathname, hosts[0]!.serverId) as any);
|
||||
}, [activeServerId, hosts, pathname, router]);
|
||||
|
||||
// Parse selectedAgentKey directly from pathname
|
||||
// useLocalSearchParams doesn't update when navigating between same-pattern routes
|
||||
@@ -499,6 +531,31 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
function FaviconStatusSync() {
|
||||
useFaviconStatus();
|
||||
return null;
|
||||
}
|
||||
|
||||
function NavigationActiveWorkspaceObserver() {
|
||||
const navigationRef = useNavigationContainerRef();
|
||||
|
||||
useEffect(() => {
|
||||
syncNavigationActiveWorkspace(navigationRef);
|
||||
const unsubscribeState = navigationRef.addListener("state", () => {
|
||||
syncNavigationActiveWorkspace(navigationRef);
|
||||
});
|
||||
const unsubscribeReady = navigationRef.addListener("ready" as never, () => {
|
||||
syncNavigationActiveWorkspace(navigationRef);
|
||||
});
|
||||
return () => {
|
||||
unsubscribeState();
|
||||
unsubscribeReady();
|
||||
};
|
||||
}, [navigationRef]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function LoadingView({ message }: { message?: string } = {}) {
|
||||
return (
|
||||
<View
|
||||
@@ -555,14 +612,14 @@ export default function RootLayout() {
|
||||
<GestureHandlerRootView
|
||||
style={{ flex: 1, backgroundColor: darkTheme.colors.surface0 }}
|
||||
>
|
||||
<NavigationActiveWorkspaceObserver />
|
||||
<PortalProvider>
|
||||
<SafeAreaProvider>
|
||||
<KeyboardProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<QueryProvider>
|
||||
<QueryProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<HostRuntimeBootstrapProvider>
|
||||
<PushNotificationRouter />
|
||||
<HostSessionManager />
|
||||
<ProvidersWrapper>
|
||||
<SidebarAnimationProvider>
|
||||
<HorizontalScrollProvider>
|
||||
@@ -579,14 +636,16 @@ export default function RootLayout() {
|
||||
>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="settings" />
|
||||
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
|
||||
<Stack.Screen
|
||||
name="h/[serverId]/workspace/[workspaceId]"
|
||||
options={{ freezeOnBlur: true }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="h/[serverId]/agent/[agentId]"
|
||||
options={{ gestureEnabled: false }}
|
||||
options={{ gestureEnabled: false, freezeOnBlur: true }}
|
||||
/>
|
||||
<Stack.Screen name="h/[serverId]/index" />
|
||||
<Stack.Screen name="h/[serverId]/agents" />
|
||||
<Stack.Screen name="h/[serverId]/new-agent" />
|
||||
<Stack.Screen name="h/[serverId]/open-project" />
|
||||
<Stack.Screen name="h/[serverId]/settings" />
|
||||
<Stack.Screen name="pair-scan" />
|
||||
@@ -597,8 +656,8 @@ export default function RootLayout() {
|
||||
</SidebarAnimationProvider>
|
||||
</ProvidersWrapper>
|
||||
</HostRuntimeBootstrapProvider>
|
||||
</QueryProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</QueryProvider>
|
||||
</KeyboardProvider>
|
||||
</SafeAreaProvider>
|
||||
</PortalProvider>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useHostRuntimeSession } from "@/runtime/host-runtime";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import {
|
||||
buildHostRootRoute,
|
||||
buildHostWorkspaceAgentRoute,
|
||||
@@ -16,7 +16,8 @@ export default function HostAgentReadyRoute() {
|
||||
const redirectedRef = useRef(false);
|
||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||
const agentId = typeof params.agentId === "string" ? params.agentId : "";
|
||||
const { client, isConnected } = useHostRuntimeSession(serverId);
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const agentCwd = useSessionStore((state) => {
|
||||
if (!serverId || !agentId) {
|
||||
return null;
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
|
||||
|
||||
export default function HostNewAgentRoute() {
|
||||
const params = useLocalSearchParams<{ serverId?: string }>();
|
||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||
|
||||
return <DraftAgentScreen forcedServerId={serverId} />;
|
||||
}
|
||||
@@ -1,25 +1,44 @@
|
||||
import { useGlobalSearchParams, usePathname } from 'expo-router'
|
||||
import { useCallback } from 'react'
|
||||
import { useGlobalSearchParams, useLocalSearchParams, useRouter } from 'expo-router'
|
||||
import { WorkspaceScreen } from '@/screens/workspace/workspace-screen'
|
||||
import {
|
||||
parseHostWorkspaceRouteFromPathname,
|
||||
buildHostWorkspaceRoute,
|
||||
decodeWorkspaceIdFromPathSegment,
|
||||
parseWorkspaceOpenIntent,
|
||||
} from '@/utils/host-routes'
|
||||
|
||||
export default function HostWorkspaceLayout() {
|
||||
const expoPathname = usePathname()
|
||||
const params = useGlobalSearchParams<{ open?: string | string[] }>()
|
||||
const activeRoute = parseHostWorkspaceRouteFromPathname(expoPathname)
|
||||
const serverId = activeRoute?.serverId ?? ''
|
||||
const workspaceId = activeRoute?.workspaceId ?? ''
|
||||
const openValue = Array.isArray(params.open) ? params.open[0] : params.open
|
||||
const router = useRouter()
|
||||
const params = useLocalSearchParams<{
|
||||
serverId?: string | string[]
|
||||
workspaceId?: string | string[]
|
||||
}>()
|
||||
const globalParams = useGlobalSearchParams<{
|
||||
open?: string | string[]
|
||||
}>()
|
||||
const serverValue = Array.isArray(params.serverId) ? params.serverId[0] : params.serverId
|
||||
const workspaceValue = Array.isArray(params.workspaceId)
|
||||
? params.workspaceId[0]
|
||||
: params.workspaceId
|
||||
const serverId = serverValue?.trim() ?? ''
|
||||
const workspaceId = workspaceValue ? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? '') : ''
|
||||
const openValue = Array.isArray(globalParams.open) ? globalParams.open[0] : globalParams.open
|
||||
const openIntent = parseWorkspaceOpenIntent(openValue)
|
||||
|
||||
const handleOpenIntentConsumed = useCallback(
|
||||
function handleOpenIntentConsumed() {
|
||||
router.replace(buildHostWorkspaceRoute(serverId, workspaceId) as any)
|
||||
},
|
||||
[router, serverId, workspaceId]
|
||||
)
|
||||
|
||||
return (
|
||||
<WorkspaceScreen
|
||||
key={`${serverId}:${workspaceId}`}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
openIntent={openIntent}
|
||||
onOpenIntentConsumed={handleOpenIntentConsumed}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useSyncExternalStore, useState } from 'react'
|
||||
import { usePathname, useRouter } from 'expo-router'
|
||||
import { useHosts } from '@/runtime/host-runtime'
|
||||
import { shouldUseManagedDesktopDaemon } from '@/desktop/managed-runtime/managed-runtime'
|
||||
import { shouldUseDesktopDaemon } from '@/desktop/daemon/desktop-daemon'
|
||||
import { buildHostRootRoute } from '@/utils/host-routes'
|
||||
import { StartupSplashScreen } from '@/screens/startup-splash-screen'
|
||||
import { WelcomeScreen } from '@/components/welcome-screen'
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
shouldRedirectToWelcome,
|
||||
shouldWaitOnStartupRace,
|
||||
WELCOME_ROUTE,
|
||||
} from './index-startup'
|
||||
} from '@/app-support/index-startup'
|
||||
|
||||
const STARTUP_TIMEOUT_MS = 30_000
|
||||
function useAnyHostOnline(serverIds: string[]): string | null {
|
||||
@@ -57,7 +57,7 @@ export default function Index() {
|
||||
const pathname = usePathname()
|
||||
const daemons = useHosts()
|
||||
const [hasTimedOut, setHasTimedOut] = useState(false)
|
||||
const isDesktopStartupRace = shouldUseManagedDesktopDaemon()
|
||||
const isDesktopStartupRace = shouldUseDesktopDaemon()
|
||||
const onlineServerId = useAnyHostOnline(daemons.map((daemon) => daemon.serverId))
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
|
||||
14
packages/app/src/attachments/store.test.ts
Normal file
14
packages/app/src/attachments/store.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { __setAttachmentStoreForTests, getAttachmentStore } from "./store";
|
||||
|
||||
describe("attachment store", () => {
|
||||
afterEach(() => {
|
||||
__setAttachmentStoreForTests(null);
|
||||
});
|
||||
|
||||
it("creates the default web attachment store without runtime module resolution errors", async () => {
|
||||
const store = await getAttachmentStore();
|
||||
|
||||
expect(store.storageType).toBe("web-indexeddb");
|
||||
});
|
||||
});
|
||||
@@ -1,26 +1,26 @@
|
||||
import { Platform } from "react-native";
|
||||
import { isTauriEnvironment } from "@/utils/tauri";
|
||||
import { isDesktop } from "@/desktop/host";
|
||||
import type { AttachmentStore } from "@/attachments/types";
|
||||
|
||||
let attachmentStorePromise: Promise<AttachmentStore> | null = null;
|
||||
|
||||
async function createAttachmentStore(): Promise<AttachmentStore> {
|
||||
if (Platform.OS === "web") {
|
||||
if (isTauriEnvironment()) {
|
||||
const { createDesktopAttachmentStore } = require(
|
||||
"@/desktop/attachments/desktop-attachment-store"
|
||||
if (isDesktop()) {
|
||||
const { createDesktopAttachmentStore } = await import(
|
||||
"../desktop/attachments/desktop-attachment-store"
|
||||
);
|
||||
return createDesktopAttachmentStore();
|
||||
}
|
||||
|
||||
const { createIndexedDbAttachmentStore } = require(
|
||||
"@/attachments/web/indexeddb-attachment-store"
|
||||
const { createIndexedDbAttachmentStore } = await import(
|
||||
"./web/indexeddb-attachment-store"
|
||||
);
|
||||
return createIndexedDbAttachmentStore();
|
||||
}
|
||||
|
||||
const { createNativeFileAttachmentStore } = require(
|
||||
"@/attachments/native/native-file-attachment-store"
|
||||
const { createNativeFileAttachmentStore } = await import(
|
||||
"./native/native-file-attachment-store"
|
||||
);
|
||||
return createNativeFileAttachmentStore();
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
BottomSheetBackgroundProps,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import Animated from "react-native-reanimated";
|
||||
import { ChevronDown, ChevronRight, Pencil, Check, X, Bot, Brain, Shield } from "lucide-react-native";
|
||||
import { ChevronDown, ChevronRight, Pencil, Check, X, Bot, Brain, ShieldCheck, ShieldAlert, ShieldOff } from "lucide-react-native";
|
||||
import { theme as defaultTheme } from "@/styles/theme";
|
||||
import type {
|
||||
AgentMode,
|
||||
@@ -25,7 +25,23 @@ import type {
|
||||
AgentProvider,
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||
import { getModeVisuals, type AgentModeIcon } from "@server/server/agent/provider-manifest";
|
||||
import { Combobox, ComboboxItem, ComboboxEmpty } from "@/components/ui/combobox";
|
||||
import { baseColors } from "@/styles/theme";
|
||||
|
||||
const MODE_ICON_MAP: Record<AgentModeIcon, typeof ShieldCheck> = {
|
||||
ShieldCheck,
|
||||
ShieldAlert,
|
||||
ShieldOff,
|
||||
};
|
||||
|
||||
const MODE_COLOR_MAP: Record<string, string> = {
|
||||
default: baseColors.blue[500],
|
||||
safe: baseColors.green[500],
|
||||
moderate: baseColors.amber[500],
|
||||
dangerous: baseColors.red[500],
|
||||
readonly: baseColors.purple[500],
|
||||
};
|
||||
|
||||
type DropdownTriggerRenderProps = {
|
||||
label: string;
|
||||
@@ -563,6 +579,10 @@ export function AgentConfigRow({
|
||||
const effectiveSelectedThinkingOption =
|
||||
selectedThinkingOptionId || thinkingSelectOptions[0]?.id || "";
|
||||
|
||||
const selectedModeVisuals = getModeVisuals(selectedProvider, effectiveSelectedMode);
|
||||
const ModeIcon = MODE_ICON_MAP[selectedModeVisuals?.icon ?? "ShieldCheck"];
|
||||
const modeIconColor = MODE_COLOR_MAP[selectedModeVisuals?.colorTier ?? "safe"];
|
||||
|
||||
return (
|
||||
<View style={styles.agentConfigRow}>
|
||||
<View style={styles.agentConfigColumn}>
|
||||
@@ -603,7 +623,7 @@ export function AgentConfigRow({
|
||||
placeholder="Default"
|
||||
disabled={disabled || modeOptions.length === 0}
|
||||
onSelect={onSelectMode}
|
||||
icon={<Shield size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />}
|
||||
icon={<ModeIcon size={defaultTheme.iconSize.md} color={modeIconColor} />}
|
||||
showLabel={false}
|
||||
testID="draft-mode-select"
|
||||
/>
|
||||
|
||||
@@ -19,6 +19,9 @@ describe('resolveStatusControlMode', () => {
|
||||
selectedModel: '',
|
||||
onSelectModel: () => undefined,
|
||||
isModelLoading: false,
|
||||
allProviderModels: new Map(),
|
||||
isAllModelsLoading: false,
|
||||
onSelectProviderAndModel: () => undefined,
|
||||
thinkingOptions: [],
|
||||
selectedThinkingOptionId: '',
|
||||
onSelectThinkingOption: () => undefined,
|
||||
|
||||
@@ -28,9 +28,10 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
|
||||
import { Shortcut } from '@/components/ui/shortcut'
|
||||
import { Autocomplete } from '@/components/ui/autocomplete'
|
||||
import { useAgentAutocomplete } from '@/hooks/use-agent-autocomplete'
|
||||
import { useHostRuntimeSession } from '@/runtime/host-runtime'
|
||||
import { useHostRuntimeAgentDirectoryStatus, useHostRuntimeClient, useHostRuntimeIsConnected } from '@/runtime/host-runtime'
|
||||
import {
|
||||
deleteAttachments,
|
||||
persistAttachmentFromBlob,
|
||||
persistAttachmentFromFileUri,
|
||||
} from '@/attachments/service'
|
||||
import { resolveStatusControlMode } from '@/components/agent-input-area.status-controls'
|
||||
@@ -96,17 +97,20 @@ export function AgentInputArea({
|
||||
}: AgentInputAreaProps) {
|
||||
markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`)
|
||||
const { theme } = useUnistyles()
|
||||
const buttonIconSize = Platform.OS === 'web' ? theme.iconSize.md : theme.iconSize.lg
|
||||
const insets = useSafeAreaInsets()
|
||||
const isScreenFocused = useIsFocused()
|
||||
|
||||
const { client, isConnected, snapshot } = useHostRuntimeSession(serverId)
|
||||
const client = useHostRuntimeClient(serverId)
|
||||
const isConnected = useHostRuntimeIsConnected(serverId)
|
||||
const agentDirectoryStatus = useHostRuntimeAgentDirectoryStatus(serverId)
|
||||
const toast = useToast()
|
||||
const voice = useVoiceOptional()
|
||||
const isDictationReady =
|
||||
isConnected &&
|
||||
(snapshot?.agentDirectoryStatus === 'ready' ||
|
||||
snapshot?.agentDirectoryStatus === 'revalidating' ||
|
||||
snapshot?.agentDirectoryStatus === 'error_after_ready')
|
||||
(agentDirectoryStatus === 'ready' ||
|
||||
agentDirectoryStatus === 'revalidating' ||
|
||||
agentDirectoryStatus === 'error_after_ready')
|
||||
|
||||
const agent = useSessionStore((state) => state.sessions[serverId]?.agents?.get(agentId))
|
||||
|
||||
@@ -390,16 +394,24 @@ export function AgentInputArea({
|
||||
|
||||
async function handlePickImage() {
|
||||
const result = await pickImages()
|
||||
if (!result?.assets?.length) {
|
||||
if (!result?.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const newImages = await Promise.all(
|
||||
result.assets.map(async (asset) => {
|
||||
result.map(async (pickedImage) => {
|
||||
if (pickedImage.source.kind === 'blob') {
|
||||
return await persistAttachmentFromBlob({
|
||||
blob: pickedImage.source.blob,
|
||||
mimeType: pickedImage.mimeType || 'image/jpeg',
|
||||
fileName: pickedImage.fileName ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
return await persistAttachmentFromFileUri({
|
||||
uri: asset.uri,
|
||||
mimeType: asset.mimeType || 'image/jpeg',
|
||||
fileName: asset.fileName ?? null,
|
||||
uri: pickedImage.source.uri,
|
||||
mimeType: pickedImage.mimeType || 'image/jpeg',
|
||||
fileName: pickedImage.fileName ?? null,
|
||||
})
|
||||
})
|
||||
)
|
||||
@@ -690,7 +702,7 @@ export function AgentInputArea({
|
||||
{isCancellingAgent ? (
|
||||
<ActivityIndicator size="small" color="white" />
|
||||
) : (
|
||||
<Square size={theme.iconSize.lg} color="white" fill="white" />
|
||||
<Square size={buttonIconSize} color="white" fill="white" />
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
@@ -711,15 +723,16 @@ export function AgentInputArea({
|
||||
disabled={!isConnected || voice?.isVoiceSwitching}
|
||||
accessibilityLabel="Enable Voice mode"
|
||||
accessibilityRole="button"
|
||||
style={[
|
||||
style={({ hovered }) => [
|
||||
styles.realtimeVoiceButton as any,
|
||||
(hovered ? styles.iconButtonHovered : undefined) as any,
|
||||
(!isConnected || voice?.isVoiceSwitching ? styles.buttonDisabled : undefined) as any,
|
||||
]}
|
||||
>
|
||||
{voice?.isVoiceSwitching ? (
|
||||
<ActivityIndicator size="small" color="white" />
|
||||
) : (
|
||||
<AudioLines size={theme.iconSize.lg} color={theme.colors.foreground} />
|
||||
<AudioLines size={buttonIconSize} color={theme.colors.foreground} />
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
@@ -876,8 +889,8 @@ const styles = StyleSheet.create(((theme: Theme) => ({
|
||||
zIndex: 30,
|
||||
},
|
||||
cancelButton: {
|
||||
width: 34,
|
||||
height: 34,
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.palette.red[600],
|
||||
alignItems: 'center',
|
||||
@@ -889,12 +902,9 @@ const styles = StyleSheet.create(((theme: Theme) => ({
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
realtimeVoiceButton: {
|
||||
width: 34,
|
||||
height: 34,
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
@@ -902,6 +912,9 @@ const styles = StyleSheet.create(((theme: Theme) => ({
|
||||
backgroundColor: theme.colors.palette.green[600],
|
||||
borderColor: theme.colors.palette.green[800],
|
||||
},
|
||||
iconButtonHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
tooltipRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useCallback, useMemo, useState, type ReactElement } from 'react'
|
||||
import { router, usePathname, type Href } from 'expo-router'
|
||||
import { router, type Href } from 'expo-router'
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
|
||||
import { formatTimeAgo } from '@/utils/time'
|
||||
import { shortenPath } from '@/utils/shorten-path'
|
||||
@@ -250,7 +250,6 @@ export function AgentList({
|
||||
showAttentionIndicator = true,
|
||||
}: AgentListProps) {
|
||||
const { theme } = useUnistyles()
|
||||
const pathname = usePathname()
|
||||
const insets = useSafeAreaInsets()
|
||||
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null)
|
||||
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
|
||||
@@ -270,15 +269,13 @@ export function AgentList({
|
||||
|
||||
const serverId = agent.serverId
|
||||
const agentId = agent.id
|
||||
const shouldReplace = pathname.startsWith('/h/')
|
||||
const navigate = shouldReplace ? router.replace : router.push
|
||||
|
||||
onAgentSelect?.()
|
||||
|
||||
const route: Href = buildHostWorkspaceAgentRoute(serverId, agent.cwd, agentId) as Href
|
||||
navigate(route)
|
||||
router.navigate(route)
|
||||
},
|
||||
[isActionSheetVisible, pathname, onAgentSelect]
|
||||
[isActionSheetVisible, onAgentSelect]
|
||||
)
|
||||
|
||||
const handleAgentLongPress = useCallback((agent: AggregatedAgent) => {
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { normalizeModelId, resolveAgentModelSelection } from './agent-status-bar.utils'
|
||||
import {
|
||||
getStatusSelectorHint,
|
||||
normalizeModelId,
|
||||
resolveAgentModelSelection,
|
||||
} from './agent-status-bar.utils'
|
||||
|
||||
describe('getStatusSelectorHint', () => {
|
||||
it('explains what each editable status control does', () => {
|
||||
expect(getStatusSelectorHint('thinking')).toBe('Thinking mode')
|
||||
expect(getStatusSelectorHint('model')).toBe('Change model')
|
||||
expect(getStatusSelectorHint('mode')).toBe('Change permission mode')
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeModelId', () => {
|
||||
it('treats empty and default values as unset', () => {
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { View, Text, Platform, Pressable } from 'react-native'
|
||||
import { StyleSheet, useUnistyles } from 'react-native-unistyles'
|
||||
import { Brain, ChevronDown, SlidersHorizontal } from 'lucide-react-native'
|
||||
import {
|
||||
Brain,
|
||||
ChevronDown,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
ShieldOff,
|
||||
|
||||
} from 'lucide-react-native'
|
||||
import { getProviderIcon } from '@/components/provider-icons'
|
||||
import { CombinedModelSelector } from '@/components/combined-model-selector'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useSessionStore } from '@/stores/session-store'
|
||||
import {
|
||||
@@ -10,22 +19,34 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Combobox, type ComboboxOption } from '@/components/ui/combobox'
|
||||
import { Combobox, ComboboxItem, type ComboboxOption } from '@/components/ui/combobox'
|
||||
import { AdaptiveModalSheet } from '@/components/adaptive-modal-sheet'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import type {
|
||||
AgentMode,
|
||||
AgentModelDefinition,
|
||||
AgentProvider,
|
||||
} from '@server/server/agent/agent-sdk-types'
|
||||
import type { AgentProviderDefinition } from '@server/server/agent/provider-manifest'
|
||||
import { normalizeModelId, resolveAgentModelSelection } from '@/components/agent-status-bar.utils'
|
||||
import {
|
||||
getModeVisuals,
|
||||
type AgentModeColorTier,
|
||||
type AgentModeIcon,
|
||||
} from '@server/server/agent/provider-manifest'
|
||||
import {
|
||||
getStatusSelectorHint,
|
||||
resolveAgentModelSelection,
|
||||
} from '@/components/agent-status-bar.utils'
|
||||
|
||||
type StatusOption = {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
type StatusSelector = 'provider' | 'mode' | 'model' | 'thinking'
|
||||
|
||||
type ControlledAgentStatusBarProps = {
|
||||
provider: string
|
||||
providerOptions?: StatusOption[]
|
||||
selectedProviderId?: string
|
||||
onSelectProvider?: (providerId: string) => void
|
||||
@@ -53,6 +74,9 @@ export interface DraftAgentStatusBarProps {
|
||||
selectedModel: string
|
||||
onSelectModel: (modelId: string) => void
|
||||
isModelLoading: boolean
|
||||
allProviderModels: Map<string, AgentModelDefinition[]>
|
||||
isAllModelsLoading: boolean
|
||||
onSelectProviderAndModel: (provider: AgentProvider, modelId: string) => void
|
||||
thinkingOptions: NonNullable<AgentModelDefinition['thinkingOptions']>
|
||||
selectedThinkingOptionId: string
|
||||
onSelectThinkingOption: (thinkingOptionId: string) => void
|
||||
@@ -72,7 +96,36 @@ function findOptionLabel(options: StatusOption[] | undefined, selectedId: string
|
||||
return selected?.label ?? fallback
|
||||
}
|
||||
|
||||
const MODE_ICONS = {
|
||||
ShieldCheck,
|
||||
ShieldAlert,
|
||||
ShieldOff,
|
||||
} as const
|
||||
|
||||
|
||||
function getModeIconColor(
|
||||
colorTier: AgentModeColorTier | undefined,
|
||||
palette: { blue: { 500: string }; green: { 500: string }; amber: { 500: string }; red: { 500: string }; purple: { 500: string } }
|
||||
): string {
|
||||
switch (colorTier) {
|
||||
case 'default':
|
||||
return palette.blue[500]
|
||||
case 'safe':
|
||||
return palette.green[500]
|
||||
case 'moderate':
|
||||
return palette.amber[500]
|
||||
case 'dangerous':
|
||||
return palette.red[500]
|
||||
case 'readonly':
|
||||
return palette.purple[500]
|
||||
default:
|
||||
return palette.blue[500]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function ControlledStatusBar({
|
||||
provider,
|
||||
providerOptions,
|
||||
selectedProviderId,
|
||||
onSelectProvider,
|
||||
@@ -91,7 +144,7 @@ function ControlledStatusBar({
|
||||
const { theme } = useUnistyles()
|
||||
const isWeb = Platform.OS === 'web'
|
||||
const [prefsOpen, setPrefsOpen] = useState(false)
|
||||
const [openSelector, setOpenSelector] = useState<'provider' | 'mode' | 'model' | 'thinking' | null>(null)
|
||||
const [openSelector, setOpenSelector] = useState<StatusSelector | null>(null)
|
||||
|
||||
const providerAnchorRef = useRef<View>(null)
|
||||
const modeAnchorRef = useRef<View>(null)
|
||||
@@ -113,6 +166,11 @@ function ControlledStatusBar({
|
||||
: findOptionLabel(modelOptions, selectedModelId, 'Auto')
|
||||
const displayThinking = findOptionLabel(thinkingOptions, selectedThinkingOptionId, 'auto')
|
||||
|
||||
const modeVisuals = selectedModeId ? getModeVisuals(provider, selectedModeId) : undefined
|
||||
const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null
|
||||
const modeIconColor = getModeIconColor(modeVisuals?.colorTier, theme.colors.palette)
|
||||
const ProviderIcon = getProviderIcon(provider)
|
||||
|
||||
const hasAnyControl =
|
||||
Boolean(providerOptions?.length) ||
|
||||
Boolean(modeOptions?.length) ||
|
||||
@@ -144,15 +202,39 @@ function ControlledStatusBar({
|
||||
[thinkingOptions]
|
||||
)
|
||||
|
||||
const renderModeOption = useCallback(
|
||||
({ option, selected, active, onPress }: { option: ComboboxOption; selected: boolean; active: boolean; onPress: () => void }) => {
|
||||
const visuals = getModeVisuals(provider, option.id)
|
||||
const IconComponent = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck
|
||||
return (
|
||||
<ComboboxItem
|
||||
label={option.label}
|
||||
selected={selected}
|
||||
active={active}
|
||||
onPress={onPress}
|
||||
leadingSlot={<IconComponent size={16} color={theme.colors.foreground} />}
|
||||
/>
|
||||
)
|
||||
},
|
||||
[provider, theme.colors.foreground]
|
||||
)
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(selector: 'provider' | 'mode' | 'model' | 'thinking') => (nextOpen: boolean) => {
|
||||
(selector: StatusSelector) => (nextOpen: boolean) => {
|
||||
setOpenSelector(nextOpen ? selector : null)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleSelectorPress = useCallback(
|
||||
(selector: StatusSelector) => {
|
||||
handleOpenChange(selector)(openSelector !== selector)
|
||||
},
|
||||
[handleOpenChange, openSelector]
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={[styles.container, isWeb && { marginBottom: -theme.spacing[1] }]}>
|
||||
<View style={styles.container}>
|
||||
{isWeb ? (
|
||||
<>
|
||||
{providerOptions && providerOptions.length > 0 ? (
|
||||
@@ -161,7 +243,7 @@ function ControlledStatusBar({
|
||||
ref={providerAnchorRef}
|
||||
collapsable={false}
|
||||
disabled={disabled || !canSelectProvider}
|
||||
onPress={() => setOpenSelector(openSelector === 'provider' ? null : 'provider')}
|
||||
onPress={() => handleSelectorPress('provider')}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.modeBadge,
|
||||
hovered && styles.modeBadgeHovered,
|
||||
@@ -190,24 +272,39 @@ function ControlledStatusBar({
|
||||
|
||||
{modeOptions && modeOptions.length > 0 ? (
|
||||
<>
|
||||
<Pressable
|
||||
ref={modeAnchorRef}
|
||||
collapsable={false}
|
||||
disabled={disabled || !canSelectMode}
|
||||
onPress={() => setOpenSelector(openSelector === 'mode' ? null : 'mode')}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.modeBadge,
|
||||
hovered && styles.modeBadgeHovered,
|
||||
(pressed || openSelector === 'mode') && styles.modeBadgePressed,
|
||||
(disabled || !canSelectMode) && styles.disabledBadge,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Select agent mode"
|
||||
testID="agent-mode-selector"
|
||||
<Tooltip
|
||||
key={`mode-${openSelector === 'mode' ? 'open' : 'closed'}`}
|
||||
delayDuration={0}
|
||||
enabledOnDesktop
|
||||
enabledOnMobile={false}
|
||||
>
|
||||
<Text style={styles.modeBadgeText}>{displayMode}</Text>
|
||||
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
<Pressable
|
||||
ref={modeAnchorRef}
|
||||
collapsable={false}
|
||||
disabled={disabled || !canSelectMode}
|
||||
onPress={() => handleSelectorPress('mode')}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.modeIconBadge,
|
||||
hovered && styles.modeBadgeHovered,
|
||||
(pressed || openSelector === 'mode') && styles.modeBadgePressed,
|
||||
(disabled || !canSelectMode) && styles.disabledBadge,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Select agent mode (${displayMode})`}
|
||||
testID="agent-mode-selector"
|
||||
>
|
||||
{ModeIconComponent ? (
|
||||
<ModeIconComponent size={theme.iconSize.md} color={modeIconColor} />
|
||||
) : (
|
||||
<ShieldCheck size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{getStatusSelectorHint('mode')}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Combobox
|
||||
options={comboboxModeOptions}
|
||||
value={selectedModeId ?? ''}
|
||||
@@ -217,64 +314,90 @@ function ControlledStatusBar({
|
||||
onOpenChange={handleOpenChange('mode')}
|
||||
anchorRef={modeAnchorRef}
|
||||
desktopPlacement="top-start"
|
||||
renderOption={renderModeOption}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Pressable
|
||||
ref={modelAnchorRef}
|
||||
collapsable={false}
|
||||
disabled={modelDisabled}
|
||||
onPress={() => setOpenSelector(openSelector === 'model' ? null : 'model')}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.modeBadge,
|
||||
hovered && styles.modeBadgeHovered,
|
||||
(pressed || openSelector === 'model') && styles.modeBadgePressed,
|
||||
modelDisabled && styles.disabledBadge,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Select agent model"
|
||||
testID="agent-model-selector"
|
||||
>
|
||||
<Text style={styles.modeBadgeText}>{displayModel}</Text>
|
||||
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
<Combobox
|
||||
options={comboboxModelOptions}
|
||||
value={selectedModelId ?? ''}
|
||||
onSelect={(id) => onSelectModel?.(id)}
|
||||
searchable={comboboxModelOptions.length > SEARCH_THRESHOLD}
|
||||
open={openSelector === 'model'}
|
||||
onOpenChange={handleOpenChange('model')}
|
||||
anchorRef={modelAnchorRef}
|
||||
desktopPlacement="top-start"
|
||||
/>
|
||||
{canSelectModel ? (
|
||||
<>
|
||||
<Tooltip
|
||||
key={`model-${openSelector === 'model' ? 'open' : 'closed'}`}
|
||||
delayDuration={0}
|
||||
enabledOnDesktop
|
||||
enabledOnMobile={false}
|
||||
>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
<Pressable
|
||||
ref={modelAnchorRef}
|
||||
collapsable={false}
|
||||
disabled={modelDisabled}
|
||||
onPress={() => handleSelectorPress('model')}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.modeBadge,
|
||||
hovered && styles.modeBadgeHovered,
|
||||
(pressed || openSelector === 'model') && styles.modeBadgePressed,
|
||||
modelDisabled && styles.disabledBadge,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Select agent model"
|
||||
testID="agent-model-selector"
|
||||
>
|
||||
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.modeBadgeText}>{displayModel}</Text>
|
||||
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{getStatusSelectorHint('model')}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Combobox
|
||||
options={comboboxModelOptions}
|
||||
value={selectedModelId ?? ''}
|
||||
onSelect={(id) => onSelectModel?.(id)}
|
||||
searchable={comboboxModelOptions.length > SEARCH_THRESHOLD}
|
||||
open={openSelector === 'model'}
|
||||
onOpenChange={handleOpenChange('model')}
|
||||
anchorRef={modelAnchorRef}
|
||||
desktopPlacement="top-start"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{thinkingOptions && thinkingOptions.length > 0 ? (
|
||||
<>
|
||||
<Pressable
|
||||
ref={thinkingAnchorRef}
|
||||
collapsable={false}
|
||||
disabled={disabled || !canSelectThinking}
|
||||
onPress={() => setOpenSelector(openSelector === 'thinking' ? null : 'thinking')}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.modeBadge,
|
||||
hovered && styles.modeBadgeHovered,
|
||||
(pressed || openSelector === 'thinking') && styles.modeBadgePressed,
|
||||
(disabled || !canSelectThinking) && styles.disabledBadge,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Select thinking option"
|
||||
testID="agent-thinking-selector"
|
||||
<Tooltip
|
||||
key={`thinking-${openSelector === 'thinking' ? 'open' : 'closed'}`}
|
||||
delayDuration={0}
|
||||
enabledOnDesktop
|
||||
enabledOnMobile={false}
|
||||
>
|
||||
<Brain
|
||||
size={theme.iconSize.xs}
|
||||
color={theme.colors.foregroundMuted}
|
||||
style={{ marginTop: 1 }}
|
||||
/>
|
||||
<Text style={styles.modeBadgeText}>{displayThinking}</Text>
|
||||
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
<Pressable
|
||||
ref={thinkingAnchorRef}
|
||||
collapsable={false}
|
||||
disabled={disabled || !canSelectThinking}
|
||||
onPress={() => handleSelectorPress('thinking')}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.modeBadge,
|
||||
hovered && styles.modeBadgeHovered,
|
||||
(pressed || openSelector === 'thinking') && styles.modeBadgePressed,
|
||||
(disabled || !canSelectThinking) && styles.disabledBadge,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Select thinking option (${displayThinking})`}
|
||||
testID="agent-thinking-selector"
|
||||
>
|
||||
<Brain size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.modeBadgeText}>{displayThinking}</Text>
|
||||
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{getStatusSelectorHint('thinking')}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Combobox
|
||||
options={comboboxThinkingOptions}
|
||||
value={selectedThinkingOptionId ?? ''}
|
||||
@@ -300,7 +423,8 @@ function ControlledStatusBar({
|
||||
accessibilityLabel="Agent preferences"
|
||||
testID="agent-preferences-button"
|
||||
>
|
||||
<SlidersHorizontal size={theme.iconSize.lg} color={theme.colors.foreground} />
|
||||
<ProviderIcon size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.prefsButtonText} numberOfLines={1}>{displayModel}</Text>
|
||||
</Pressable>
|
||||
|
||||
<AdaptiveModalSheet
|
||||
@@ -311,7 +435,10 @@ function ControlledStatusBar({
|
||||
>
|
||||
{providerOptions && providerOptions.length > 0 ? (
|
||||
<View style={styles.sheetSection}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenu
|
||||
open={openSelector === 'provider'}
|
||||
onOpenChange={handleOpenChange('provider')}
|
||||
>
|
||||
<DropdownMenuTrigger
|
||||
disabled={disabled || !canSelectProvider}
|
||||
style={({ pressed }) => [
|
||||
@@ -343,7 +470,10 @@ function ControlledStatusBar({
|
||||
|
||||
{modeOptions && modeOptions.length > 0 ? (
|
||||
<View style={styles.sheetSection}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenu
|
||||
open={openSelector === 'mode'}
|
||||
onOpenChange={handleOpenChange('mode')}
|
||||
>
|
||||
<DropdownMenuTrigger
|
||||
disabled={disabled || !canSelectMode}
|
||||
style={({ pressed }) => [
|
||||
@@ -355,17 +485,60 @@ function ControlledStatusBar({
|
||||
accessibilityLabel="Select agent mode"
|
||||
testID="agent-preferences-mode"
|
||||
>
|
||||
{ModeIconComponent ? (
|
||||
<ModeIconComponent size={theme.iconSize.md} color={modeIconColor} />
|
||||
) : null}
|
||||
<Text style={styles.sheetSelectText}>{displayMode}</Text>
|
||||
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="top" align="start">
|
||||
{modeOptions.map((mode) => (
|
||||
{modeOptions.map((mode) => {
|
||||
const visuals = getModeVisuals(provider, mode.id)
|
||||
const Icon = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={mode.id}
|
||||
selected={mode.id === selectedModeId}
|
||||
onSelect={() => onSelectMode?.(mode.id)}
|
||||
leading={<Icon size={16} color={theme.colors.foreground} />}
|
||||
>
|
||||
{mode.label}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{canSelectModel ? (
|
||||
<View style={styles.sheetSection}>
|
||||
<DropdownMenu
|
||||
open={openSelector === 'model'}
|
||||
onOpenChange={handleOpenChange('model')}
|
||||
>
|
||||
<DropdownMenuTrigger
|
||||
disabled={modelDisabled}
|
||||
style={({ pressed }) => [
|
||||
styles.sheetSelect,
|
||||
pressed && styles.sheetSelectPressed,
|
||||
modelDisabled && styles.disabledSheetSelect,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Select agent model"
|
||||
testID="agent-preferences-model"
|
||||
>
|
||||
<Text style={styles.sheetSelectText}>{displayModel}</Text>
|
||||
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="top" align="start">
|
||||
{(modelOptions ?? []).map((model) => (
|
||||
<DropdownMenuItem
|
||||
key={mode.id}
|
||||
selected={mode.id === selectedModeId}
|
||||
onSelect={() => onSelectMode?.(mode.id)}
|
||||
key={model.id}
|
||||
selected={model.id === selectedModelId}
|
||||
onSelect={() => onSelectModel?.(model.id)}
|
||||
>
|
||||
{mode.label}
|
||||
{model.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
@@ -373,39 +546,12 @@ function ControlledStatusBar({
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={styles.sheetSection}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
disabled={modelDisabled}
|
||||
style={({ pressed }) => [
|
||||
styles.sheetSelect,
|
||||
pressed && styles.sheetSelectPressed,
|
||||
modelDisabled && styles.disabledSheetSelect,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Select agent model"
|
||||
testID="agent-preferences-model"
|
||||
>
|
||||
<Text style={styles.sheetSelectText}>{displayModel}</Text>
|
||||
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="top" align="start">
|
||||
{(modelOptions ?? []).map((model) => (
|
||||
<DropdownMenuItem
|
||||
key={model.id}
|
||||
selected={model.id === selectedModelId}
|
||||
onSelect={() => onSelectModel?.(model.id)}
|
||||
>
|
||||
{model.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</View>
|
||||
|
||||
{thinkingOptions && thinkingOptions.length > 0 ? (
|
||||
<View style={styles.sheetSection}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenu
|
||||
open={openSelector === 'thinking'}
|
||||
onOpenChange={handleOpenChange('thinking')}
|
||||
>
|
||||
<DropdownMenuTrigger
|
||||
disabled={disabled || !canSelectThinking}
|
||||
style={({ pressed }) => [
|
||||
@@ -504,6 +650,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
||||
|
||||
return (
|
||||
<ControlledStatusBar
|
||||
provider={agent.provider}
|
||||
modeOptions={
|
||||
modeOptions.length > 0
|
||||
? modeOptions
|
||||
@@ -555,33 +702,26 @@ export function DraftAgentStatusBar({
|
||||
selectedModel,
|
||||
onSelectModel,
|
||||
isModelLoading,
|
||||
allProviderModels,
|
||||
isAllModelsLoading,
|
||||
onSelectProviderAndModel,
|
||||
thinkingOptions,
|
||||
selectedThinkingOptionId,
|
||||
onSelectThinkingOption,
|
||||
disabled = false,
|
||||
}: DraftAgentStatusBarProps) {
|
||||
const providerOptions = useMemo<StatusOption[]>(() => {
|
||||
return providerDefinitions.map((definition) => ({
|
||||
id: definition.id,
|
||||
label: definition.label,
|
||||
}))
|
||||
}, [providerDefinitions])
|
||||
const isWeb = Platform.OS === 'web'
|
||||
|
||||
const mappedModeOptions = useMemo<StatusOption[]>(() => {
|
||||
if (modeOptions.length === 0) {
|
||||
return [{ id: '', label: 'Default' }]
|
||||
}
|
||||
return modeOptions.map((mode) => ({ id: mode.id, label: mode.label }))
|
||||
return modeOptions.map((mode) => ({
|
||||
id: mode.id,
|
||||
label: mode.label,
|
||||
}))
|
||||
}, [modeOptions])
|
||||
|
||||
const modelOptions = useMemo<StatusOption[]>(() => {
|
||||
const options: StatusOption[] = [{ id: '', label: 'Auto' }]
|
||||
for (const model of models) {
|
||||
options.push({ id: model.id, label: model.label })
|
||||
}
|
||||
return options
|
||||
}, [models])
|
||||
|
||||
const mappedThinkingOptions = useMemo<StatusOption[]>(() => {
|
||||
return thinkingOptions.map((option) => ({ id: option.id, label: option.label }))
|
||||
}, [thinkingOptions])
|
||||
@@ -590,8 +730,45 @@ export function DraftAgentStatusBar({
|
||||
const effectiveSelectedThinkingOption =
|
||||
selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined
|
||||
|
||||
if (isWeb) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<CombinedModelSelector
|
||||
providerDefinitions={providerDefinitions}
|
||||
allProviderModels={allProviderModels}
|
||||
selectedProvider={selectedProvider}
|
||||
selectedModel={selectedModel}
|
||||
onSelect={onSelectProviderAndModel}
|
||||
isLoading={isAllModelsLoading}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<ControlledStatusBar
|
||||
provider={selectedProvider}
|
||||
modeOptions={mappedModeOptions}
|
||||
selectedModeId={effectiveSelectedMode}
|
||||
onSelectMode={onSelectMode}
|
||||
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
|
||||
selectedThinkingOptionId={effectiveSelectedThinkingOption}
|
||||
onSelectThinkingOption={onSelectThinkingOption}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const providerOptions = providerDefinitions.map((definition) => ({
|
||||
id: definition.id,
|
||||
label: definition.label,
|
||||
}))
|
||||
|
||||
const modelOptions: StatusOption[] = [{ id: '', label: 'Auto' }]
|
||||
for (const model of models) {
|
||||
modelOptions.push({ id: model.id, label: model.label })
|
||||
}
|
||||
|
||||
return (
|
||||
<ControlledStatusBar
|
||||
provider={selectedProvider}
|
||||
providerOptions={providerOptions}
|
||||
selectedProviderId={selectedProvider}
|
||||
onSelectProvider={(providerId) => onSelectProvider(providerId as AgentProvider)}
|
||||
@@ -613,18 +790,26 @@ export function DraftAgentStatusBar({
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
alignItems: 'flex-end',
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
modeBadge: {
|
||||
height: 28,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'transparent',
|
||||
gap: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius['2xl'],
|
||||
},
|
||||
modeIconBadge: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'transparent',
|
||||
borderRadius: theme.borderRadius.full,
|
||||
},
|
||||
modeBadgeHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
@@ -639,16 +824,28 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
tooltipText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: theme.fontSize.sm * 1.4,
|
||||
},
|
||||
prefsButton: {
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
height: 28,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius['2xl'],
|
||||
},
|
||||
prefsButtonPressed: {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
prefsButtonText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
flexShrink: 1,
|
||||
},
|
||||
sheetSection: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
import type { AgentModelDefinition } from '@server/server/agent/agent-sdk-types'
|
||||
|
||||
export type ExplainedStatusSelector = 'mode' | 'model' | 'thinking'
|
||||
|
||||
export function getStatusSelectorHint(selector: ExplainedStatusSelector): string {
|
||||
switch (selector) {
|
||||
case 'thinking':
|
||||
return 'Thinking mode'
|
||||
case 'model':
|
||||
return 'Change model'
|
||||
case 'mode':
|
||||
return 'Change permission mode'
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeModelId(modelId: string | null | undefined): string | null {
|
||||
const normalized = typeof modelId === 'string' ? modelId.trim() : ''
|
||||
if (!normalized || normalized.toLowerCase() === 'default') {
|
||||
|
||||
@@ -22,11 +22,10 @@ import Animated, {
|
||||
FadeIn,
|
||||
FadeOut,
|
||||
cancelAnimation,
|
||||
Easing,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withDelay,
|
||||
withRepeat,
|
||||
withSequence,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { Check, ChevronDown, X } from "lucide-react-native";
|
||||
@@ -69,6 +68,12 @@ import { createMarkdownStyles } from "@/styles/markdown-styles";
|
||||
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||
import { getMarkdownListMarker } from "@/utils/markdown-list";
|
||||
import { buildHostWorkspaceFileRoute } from "@/utils/host-routes";
|
||||
import { normalizeInlinePathTarget } from "@/utils/inline-path";
|
||||
import {
|
||||
getWorkingIndicatorDotStrength,
|
||||
WORKING_INDICATOR_CYCLE_MS,
|
||||
WORKING_INDICATOR_OFFSETS,
|
||||
} from "@/utils/working-indicator";
|
||||
|
||||
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
|
||||
const isToolSequenceItem = (item?: StreamItem) =>
|
||||
@@ -86,6 +91,7 @@ export interface AgentStreamViewProps {
|
||||
pendingPermissions: Map<string, PendingPermission>;
|
||||
routeBottomAnchorRequest?: BottomAnchorRouteRequest | null;
|
||||
isAuthoritativeHistoryReady?: boolean;
|
||||
onOpenWorkspaceFile?: (input: { filePath: string }) => void;
|
||||
}
|
||||
|
||||
export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamViewProps>(function AgentStreamView({
|
||||
@@ -96,6 +102,7 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
pendingPermissions,
|
||||
routeBottomAnchorRequest = null,
|
||||
isAuthoritativeHistoryReady = true,
|
||||
onOpenWorkspaceFile,
|
||||
}, ref) {
|
||||
const viewportRef = useRef<StreamViewportHandle | null>(null);
|
||||
const { theme } = useUnistyles();
|
||||
@@ -153,12 +160,17 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = normalizeInlinePath(target.path, agent.cwd);
|
||||
const normalized = normalizeInlinePathTarget(target.path, agent.cwd);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (normalized.file) {
|
||||
if (onOpenWorkspaceFile) {
|
||||
onOpenWorkspaceFile({ filePath: normalized.file });
|
||||
return;
|
||||
}
|
||||
|
||||
const route = buildHostWorkspaceFileRoute(
|
||||
resolvedServerId,
|
||||
workspaceId,
|
||||
@@ -188,6 +200,7 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
resolvedServerId,
|
||||
router,
|
||||
setExplorerTabForCheckout,
|
||||
onOpenWorkspaceFile,
|
||||
workspaceId,
|
||||
]
|
||||
);
|
||||
@@ -306,6 +319,7 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
message={item.text}
|
||||
timestamp={item.timestamp.getTime()}
|
||||
onInlinePathPress={handleInlinePathPress}
|
||||
workspaceRoot={workspaceRoot}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -648,147 +662,59 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
);
|
||||
});
|
||||
|
||||
function normalizeInlinePath(
|
||||
rawPath: string,
|
||||
cwd?: string
|
||||
): { directory: string; file?: string } | null {
|
||||
if (!rawPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedInput = normalizePathInput(rawPath);
|
||||
if (!normalizedInput) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let normalized = normalizedInput;
|
||||
const cwdRelative = resolvePathAgainstCwd(normalized, cwd);
|
||||
if (cwdRelative) {
|
||||
normalized = cwdRelative;
|
||||
}
|
||||
|
||||
if (normalized.startsWith("./")) {
|
||||
normalized = normalized.slice(2) || ".";
|
||||
}
|
||||
|
||||
if (!normalized.length) {
|
||||
normalized = ".";
|
||||
}
|
||||
|
||||
if (normalized === ".") {
|
||||
return { directory: "." };
|
||||
}
|
||||
|
||||
if (normalized.endsWith("/")) {
|
||||
const dir = normalized.replace(/\/+$/, "");
|
||||
return { directory: dir.length > 0 ? dir : "." };
|
||||
}
|
||||
|
||||
const lastSlash = normalized.lastIndexOf("/");
|
||||
const directory = lastSlash >= 0 ? normalized.slice(0, lastSlash) : ".";
|
||||
|
||||
return {
|
||||
directory: directory.length > 0 ? directory : ".",
|
||||
file: normalized,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePathInput(value: string | undefined): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = value
|
||||
.trim()
|
||||
.replace(/^['"`]/, "")
|
||||
.replace(/['"`]$/, "");
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trimmed.replace(/\\/g, "/").replace(/\/{2,}/g, "/");
|
||||
}
|
||||
|
||||
function resolvePathAgainstCwd(pathValue: string, cwd?: string): string | null {
|
||||
const normalizedCwd = normalizePathInput(cwd);
|
||||
if (
|
||||
!normalizedCwd ||
|
||||
!isAbsolutePath(pathValue) ||
|
||||
!isAbsolutePath(normalizedCwd)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedCwdBase = normalizedCwd.replace(/\/+$/, "") || "/";
|
||||
const comparePath = normalizePathForCompare(pathValue);
|
||||
const compareCwd = normalizePathForCompare(normalizedCwdBase);
|
||||
const prefix = normalizedCwdBase === "/" ? "/" : `${normalizedCwdBase}/`;
|
||||
const comparePrefix = normalizePathForCompare(prefix);
|
||||
|
||||
if (comparePath === compareCwd) {
|
||||
return ".";
|
||||
}
|
||||
|
||||
if (comparePath.startsWith(comparePrefix)) {
|
||||
return pathValue.slice(prefix.length) || ".";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizePathForCompare(value: string): string {
|
||||
return /^[A-Za-z]:/.test(value) ? value.toLowerCase() : value;
|
||||
}
|
||||
|
||||
function isAbsolutePath(value: string): boolean {
|
||||
return value.startsWith("/") || /^[A-Za-z]:\//.test(value);
|
||||
}
|
||||
|
||||
function WorkingIndicator() {
|
||||
const dotOne = useSharedValue(0);
|
||||
const dotTwo = useSharedValue(0);
|
||||
const dotThree = useSharedValue(0);
|
||||
const bounceDuration = 600;
|
||||
const bounceDelayOffset = 160;
|
||||
const progress = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
const sharedValues = [dotOne, dotTwo, dotThree];
|
||||
sharedValues.forEach((value, index) => {
|
||||
value.value = withDelay(
|
||||
index * bounceDelayOffset,
|
||||
withRepeat(
|
||||
withSequence(
|
||||
withTiming(1, { duration: bounceDuration }),
|
||||
withTiming(0, { duration: bounceDuration })
|
||||
),
|
||||
-1
|
||||
)
|
||||
);
|
||||
});
|
||||
progress.value = 0;
|
||||
progress.value = withRepeat(
|
||||
withTiming(1, {
|
||||
duration: WORKING_INDICATOR_CYCLE_MS,
|
||||
easing: Easing.linear,
|
||||
}),
|
||||
-1,
|
||||
false
|
||||
);
|
||||
|
||||
return () => {
|
||||
sharedValues.forEach((value) => {
|
||||
cancelAnimation(value);
|
||||
value.value = 0;
|
||||
});
|
||||
cancelAnimation(progress);
|
||||
progress.value = 0;
|
||||
};
|
||||
}, [dotOne, dotTwo, dotThree]);
|
||||
}, [progress]);
|
||||
|
||||
const translateDistance = -2;
|
||||
const dotOneStyle = useAnimatedStyle(() => ({
|
||||
opacity: 0.3 + dotOne.value * 0.7,
|
||||
transform: [{ translateY: dotOne.value * translateDistance }],
|
||||
}));
|
||||
const dotOneStyle = useAnimatedStyle(() => {
|
||||
const strength = getWorkingIndicatorDotStrength(
|
||||
progress.value,
|
||||
WORKING_INDICATOR_OFFSETS[0]
|
||||
);
|
||||
return {
|
||||
opacity: 0.3 + strength * 0.7,
|
||||
transform: [{ translateY: strength * translateDistance }],
|
||||
};
|
||||
});
|
||||
|
||||
const dotTwoStyle = useAnimatedStyle(() => ({
|
||||
opacity: 0.3 + dotTwo.value * 0.7,
|
||||
transform: [{ translateY: dotTwo.value * translateDistance }],
|
||||
}));
|
||||
const dotTwoStyle = useAnimatedStyle(() => {
|
||||
const strength = getWorkingIndicatorDotStrength(
|
||||
progress.value,
|
||||
WORKING_INDICATOR_OFFSETS[1]
|
||||
);
|
||||
return {
|
||||
opacity: 0.3 + strength * 0.7,
|
||||
transform: [{ translateY: strength * translateDistance }],
|
||||
};
|
||||
});
|
||||
|
||||
const dotThreeStyle = useAnimatedStyle(() => ({
|
||||
opacity: 0.3 + dotThree.value * 0.7,
|
||||
transform: [{ translateY: dotThree.value * translateDistance }],
|
||||
}));
|
||||
const dotThreeStyle = useAnimatedStyle(() => {
|
||||
const strength = getWorkingIndicatorDotStrength(
|
||||
progress.value,
|
||||
WORKING_INDICATOR_OFFSETS[2]
|
||||
);
|
||||
return {
|
||||
opacity: 0.3 + strength * 0.7,
|
||||
transform: [{ translateY: strength * translateDistance }],
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={stylesheet.workingIndicatorBubble}>
|
||||
|
||||
99
packages/app/src/components/archived-agent-callout.tsx
Normal file
99
packages/app/src/components/archived-agent-callout.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useState } from 'react'
|
||||
import { View, Text } from 'react-native'
|
||||
import { StyleSheet } from 'react-native-unistyles'
|
||||
import Animated from 'react-native-reanimated'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from '@/constants/layout'
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from '@/runtime/host-runtime'
|
||||
import { useKeyboardShiftStyle } from '@/hooks/use-keyboard-shift-style'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { Theme } from '@/styles/theme'
|
||||
|
||||
interface ArchivedAgentCalloutProps {
|
||||
serverId: string
|
||||
agentId: string
|
||||
}
|
||||
|
||||
export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCalloutProps) {
|
||||
const insets = useSafeAreaInsets()
|
||||
const client = useHostRuntimeClient(serverId)
|
||||
const isConnected = useHostRuntimeIsConnected(serverId)
|
||||
const [isUnarchiving, setIsUnarchiving] = useState(false)
|
||||
|
||||
const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({ mode: 'translate' })
|
||||
|
||||
async function handleUnarchive() {
|
||||
if (!client || !isConnected || isUnarchiving) return
|
||||
setIsUnarchiving(true)
|
||||
try {
|
||||
await client.refreshAgent(agentId)
|
||||
} catch (error) {
|
||||
console.error('[ArchivedAgentCallout] Failed to unarchive agent:', error)
|
||||
setIsUnarchiving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[styles.container, { paddingBottom: insets.bottom }, keyboardAnimatedStyle]}
|
||||
>
|
||||
<View style={styles.inputAreaContainer}>
|
||||
<View style={styles.inputAreaContent}>
|
||||
<View style={styles.callout}>
|
||||
<Text style={styles.calloutText}>This agent is archived</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onPress={handleUnarchive}
|
||||
disabled={!isConnected || isUnarchiving}
|
||||
>
|
||||
Unarchive
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create(((theme: Theme) => ({
|
||||
container: {
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
},
|
||||
inputAreaContainer: {
|
||||
position: 'relative',
|
||||
minHeight: FOOTER_HEIGHT,
|
||||
marginHorizontal: 'auto',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
overflow: 'visible',
|
||||
padding: theme.spacing[4],
|
||||
},
|
||||
inputAreaContent: {
|
||||
width: '100%',
|
||||
maxWidth: MAX_CONTENT_WIDTH,
|
||||
},
|
||||
callout: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: theme.spacing[3],
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.borderAccent,
|
||||
borderRadius: theme.borderRadius['2xl'],
|
||||
paddingVertical: {
|
||||
xs: theme.spacing[3],
|
||||
md: theme.spacing[4],
|
||||
},
|
||||
paddingHorizontal: {
|
||||
xs: theme.spacing[4],
|
||||
md: theme.spacing[6],
|
||||
},
|
||||
},
|
||||
calloutText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
})) as any) as Record<string, any>
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
Modal,
|
||||
} from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { useEffect } from "react";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
|
||||
@@ -154,16 +153,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
}));
|
||||
|
||||
export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
|
||||
useEffect(() => {
|
||||
if (!artifact) return;
|
||||
console.log(
|
||||
"[ArtifactDrawer] Showing artifact:",
|
||||
artifact.id,
|
||||
artifact.type,
|
||||
artifact.title
|
||||
);
|
||||
}, [artifact]);
|
||||
|
||||
if (!artifact) {
|
||||
return null;
|
||||
}
|
||||
|
||||
372
packages/app/src/components/combined-model-selector.tsx
Normal file
372
packages/app/src/components/combined-model-selector.tsx
Normal file
@@ -0,0 +1,372 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { View, Text, Pressable, Platform } from 'react-native'
|
||||
import { StyleSheet, useUnistyles } from 'react-native-unistyles'
|
||||
import { ArrowLeft, Check, ChevronDown, ChevronRight } from 'lucide-react-native'
|
||||
import type { AgentModelDefinition, AgentProvider } from '@server/server/agent/agent-sdk-types'
|
||||
import type { AgentProviderDefinition } from '@server/server/agent/provider-manifest'
|
||||
import { Combobox, ComboboxItem, SearchInput } from '@/components/ui/combobox'
|
||||
import { getProviderIcon } from '@/components/provider-icons'
|
||||
|
||||
const INLINE_MODEL_THRESHOLD = 8
|
||||
|
||||
type DrillDownView = { provider: string }
|
||||
|
||||
interface CombinedModelSelectorProps {
|
||||
providerDefinitions: AgentProviderDefinition[]
|
||||
allProviderModels: Map<string, AgentModelDefinition[]>
|
||||
selectedProvider: string
|
||||
selectedModel: string
|
||||
onSelect: (provider: AgentProvider, modelId: string) => void
|
||||
isLoading: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function CombinedModelSelector({
|
||||
providerDefinitions,
|
||||
allProviderModels,
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
onSelect,
|
||||
isLoading,
|
||||
disabled = false,
|
||||
}: CombinedModelSelectorProps) {
|
||||
const { theme } = useUnistyles()
|
||||
const anchorRef = useRef<View>(null)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [view, setView] = useState<'groups' | DrillDownView>('groups')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
const handleOpenChange = useCallback((open: boolean) => {
|
||||
setIsOpen(open)
|
||||
if (open) {
|
||||
const models = allProviderModels.get(selectedProvider)
|
||||
if (models && models.length > INLINE_MODEL_THRESHOLD) {
|
||||
setView({ provider: selectedProvider })
|
||||
}
|
||||
} else {
|
||||
setView('groups')
|
||||
setSearchQuery('')
|
||||
}
|
||||
}, [allProviderModels, selectedProvider])
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(provider: string, modelId: string) => {
|
||||
onSelect(provider as AgentProvider, modelId)
|
||||
setIsOpen(false)
|
||||
setView('groups')
|
||||
setSearchQuery('')
|
||||
},
|
||||
[onSelect]
|
||||
)
|
||||
|
||||
const ProviderIcon = getProviderIcon(selectedProvider)
|
||||
|
||||
const selectedModelLabel = useMemo(() => {
|
||||
const models = allProviderModels.get(selectedProvider)
|
||||
if (!models) return isLoading ? 'Loading...' : 'Auto'
|
||||
const model = models.find((m) => m.id === selectedModel)
|
||||
return model?.label ?? 'Auto'
|
||||
}, [allProviderModels, selectedProvider, selectedModel, isLoading])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Pressable
|
||||
ref={anchorRef}
|
||||
collapsable={false}
|
||||
disabled={disabled}
|
||||
onPress={() => handleOpenChange(!isOpen)}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.trigger,
|
||||
hovered && styles.triggerHovered,
|
||||
(pressed || isOpen) && styles.triggerPressed,
|
||||
disabled && styles.triggerDisabled,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Select model (${selectedModelLabel})`}
|
||||
testID="combined-model-selector"
|
||||
>
|
||||
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.triggerText}>{selectedModelLabel}</Text>
|
||||
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
<Combobox
|
||||
options={[]}
|
||||
value=""
|
||||
onSelect={() => {}}
|
||||
open={isOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
anchorRef={anchorRef}
|
||||
desktopPlacement="top-start"
|
||||
title="Select model"
|
||||
>
|
||||
{view === 'groups' ? (
|
||||
<GroupsView
|
||||
providerDefinitions={providerDefinitions}
|
||||
allProviderModels={allProviderModels}
|
||||
selectedProvider={selectedProvider}
|
||||
selectedModel={selectedModel}
|
||||
onSelect={handleSelect}
|
||||
onDrillDown={(provider) => {
|
||||
setView({ provider })
|
||||
setSearchQuery('')
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<DrillDownModelView
|
||||
provider={view.provider}
|
||||
providerDefinitions={providerDefinitions}
|
||||
models={allProviderModels.get(view.provider) ?? []}
|
||||
selectedProvider={selectedProvider}
|
||||
selectedModel={selectedModel}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
onSelect={handleSelect}
|
||||
onBack={() => {
|
||||
setView('groups')
|
||||
setSearchQuery('')
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Combobox>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function GroupsView({
|
||||
providerDefinitions,
|
||||
allProviderModels,
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
onSelect,
|
||||
onDrillDown,
|
||||
}: {
|
||||
providerDefinitions: AgentProviderDefinition[]
|
||||
allProviderModels: Map<string, AgentModelDefinition[]>
|
||||
selectedProvider: string
|
||||
selectedModel: string
|
||||
onSelect: (provider: string, modelId: string) => void
|
||||
onDrillDown: (provider: string) => void
|
||||
}) {
|
||||
const { theme } = useUnistyles()
|
||||
|
||||
return (
|
||||
<View>
|
||||
{providerDefinitions.map((def, index) => {
|
||||
const models = allProviderModels.get(def.id) ?? []
|
||||
const isInline = models.length <= INLINE_MODEL_THRESHOLD
|
||||
const ProvIcon = getProviderIcon(def.id)
|
||||
|
||||
return (
|
||||
<View key={def.id}>
|
||||
{index > 0 ? <View style={styles.separator} /> : null}
|
||||
|
||||
{isInline ? (
|
||||
<>
|
||||
<View style={styles.sectionHeading}>
|
||||
<ProvIcon size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.sectionHeadingText}>{def.label}</Text>
|
||||
</View>
|
||||
{models.map((model) => (
|
||||
<ComboboxItem
|
||||
key={model.id}
|
||||
label={model.label}
|
||||
selected={model.id === selectedModel && def.id === selectedProvider}
|
||||
onPress={() => onSelect(def.id, model.id)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<Pressable
|
||||
onPress={() => onDrillDown(def.id)}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.drillDownRow,
|
||||
hovered && styles.drillDownRowHovered,
|
||||
pressed && styles.drillDownRowPressed,
|
||||
]}
|
||||
>
|
||||
<ProvIcon size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.drillDownText}>{def.label}</Text>
|
||||
<View style={styles.drillDownTrailing}>
|
||||
<Text style={styles.drillDownCount}>{models.length}</Text>
|
||||
<ChevronRight size={14} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function DrillDownModelView({
|
||||
provider,
|
||||
providerDefinitions,
|
||||
models,
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
onSelect,
|
||||
onBack,
|
||||
}: {
|
||||
provider: string
|
||||
providerDefinitions: AgentProviderDefinition[]
|
||||
models: AgentModelDefinition[]
|
||||
selectedProvider: string
|
||||
selectedModel: string
|
||||
searchQuery: string
|
||||
onSearchChange: (query: string) => void
|
||||
onSelect: (provider: string, modelId: string) => void
|
||||
onBack: () => void
|
||||
}) {
|
||||
const { theme } = useUnistyles()
|
||||
const ProvIcon = getProviderIcon(provider)
|
||||
const providerLabel = providerDefinitions.find((d) => d.id === provider)?.label ?? provider
|
||||
|
||||
const filteredModels = useMemo(() => {
|
||||
if (!searchQuery.trim()) return models
|
||||
const q = searchQuery.toLowerCase()
|
||||
return models.filter(
|
||||
(m) => m.label.toLowerCase().includes(q) || m.id.toLowerCase().includes(q)
|
||||
)
|
||||
}, [models, searchQuery])
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Pressable
|
||||
onPress={onBack}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.backButton,
|
||||
hovered && styles.backButtonHovered,
|
||||
pressed && styles.backButtonPressed,
|
||||
]}
|
||||
>
|
||||
<ArrowLeft size={14} color={theme.colors.foregroundMuted} />
|
||||
<ProvIcon size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.backButtonText}>{providerLabel}</Text>
|
||||
</Pressable>
|
||||
|
||||
<SearchInput
|
||||
placeholder="Search models..."
|
||||
value={searchQuery}
|
||||
onChangeText={onSearchChange}
|
||||
autoFocus={Platform.OS === 'web'}
|
||||
/>
|
||||
|
||||
{filteredModels.map((model) => (
|
||||
<ComboboxItem
|
||||
key={model.id}
|
||||
label={model.label}
|
||||
description={model.description}
|
||||
selected={model.id === selectedModel && provider === selectedProvider}
|
||||
onPress={() => onSelect(provider, model.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{filteredModels.length === 0 ? (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={styles.emptyStateText}>No models match your search</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
trigger: {
|
||||
height: 28,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'transparent',
|
||||
gap: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius['2xl'],
|
||||
},
|
||||
triggerHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
triggerPressed: {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
triggerDisabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
triggerText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
separator: {
|
||||
height: 1,
|
||||
backgroundColor: theme.colors.border,
|
||||
marginVertical: theme.spacing[1],
|
||||
},
|
||||
sectionHeading: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[1],
|
||||
},
|
||||
sectionHeadingText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
drillDownRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
minHeight: 36,
|
||||
},
|
||||
drillDownRowHovered: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
drillDownRowPressed: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
drillDownText: {
|
||||
flex: 1,
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
drillDownTrailing: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
drillDownCount: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
backButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
backButtonHovered: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
backButtonPressed: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
backButtonText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
emptyState: {
|
||||
paddingVertical: theme.spacing[4],
|
||||
alignItems: 'center',
|
||||
},
|
||||
emptyStateText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
}))
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
View,
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import { memo, useEffect, useMemo, useRef, type ReactNode } from "react";
|
||||
import { memo, useEffect, useRef, type ReactNode } from "react";
|
||||
import { Plus, Settings } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useCommandCenter } from "@/hooks/use-command-center";
|
||||
@@ -69,6 +69,9 @@ export function CommandCenter() {
|
||||
const resultsRef = useRef<ScrollView>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
const row = rowRefs.current.get(activeIndex);
|
||||
if (!row || typeof document === "undefined") {
|
||||
return;
|
||||
@@ -99,18 +102,12 @@ export function CommandCenter() {
|
||||
if (rowBottom > visibleBottom) {
|
||||
scrollNode.scrollTop = rowBottom - scrollNode.clientHeight;
|
||||
}
|
||||
}, [activeIndex]);
|
||||
}, [activeIndex, open]);
|
||||
|
||||
if (Platform.OS !== "web") return null;
|
||||
if (Platform.OS !== "web" || !open) return null;
|
||||
|
||||
const actionItems = useMemo(
|
||||
() => items.filter((item) => item.kind === "action"),
|
||||
[items]
|
||||
);
|
||||
const agentItems = useMemo(
|
||||
() => items.filter((item) => item.kind === "agent"),
|
||||
[items]
|
||||
);
|
||||
const actionItems = items.filter((item) => item.kind === "action");
|
||||
const agentItems = items.filter((item) => item.kind === "agent");
|
||||
|
||||
return (
|
||||
<Modal
|
||||
|
||||
@@ -69,7 +69,6 @@ export function DictationControls({
|
||||
<VolumeMeter
|
||||
volume={volume}
|
||||
isMuted={false}
|
||||
isDetecting
|
||||
isSpeaking={false}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
@@ -177,7 +176,6 @@ export function DictationOverlay({
|
||||
<VolumeMeter
|
||||
volume={volume}
|
||||
isMuted={false}
|
||||
isDetecting
|
||||
isSpeaking={false}
|
||||
orientation="horizontal"
|
||||
color={theme.colors.palette.white}
|
||||
|
||||
@@ -23,13 +23,7 @@ import { FileExplorerPane } from "./file-explorer-pane";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
|
||||
const MIN_CHAT_WIDTH = 400;
|
||||
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
|
||||
|
||||
function logExplorerSidebar(event: string, details: Record<string, unknown>): void {
|
||||
if (!IS_DEV) {
|
||||
return;
|
||||
}
|
||||
console.log(`[ExplorerSidebar] ${event}`, details);
|
||||
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {
|
||||
}
|
||||
|
||||
interface ExplorerSidebarProps {
|
||||
@@ -485,7 +479,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
tabActive: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
tabText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
|
||||
@@ -23,6 +23,7 @@ import Animated, {
|
||||
withRepeat,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import {
|
||||
@@ -581,13 +582,12 @@ export function FileExplorerPane({
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.iconButton,
|
||||
(hovered || pressed) && styles.iconButtonHovered,
|
||||
pressed && styles.iconButtonPressed,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Refresh files"
|
||||
>
|
||||
<Animated.View style={[styles.refreshIcon, refreshIconAnimatedStyle]}>
|
||||
<RotateCw size={16} color={theme.colors.foregroundMuted} />
|
||||
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
<Pressable style={styles.sortButton} onPress={handleSortCycle}>
|
||||
@@ -838,7 +838,7 @@ function getErrorRecoveryPath(state: AgentFileExplorerState | undefined): string
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
},
|
||||
desktopSplit: {
|
||||
flex: 1,
|
||||
@@ -875,11 +875,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
height: 32 + theme.spacing[2] * 2,
|
||||
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
},
|
||||
paneHeaderLeft: {
|
||||
flex: 1,
|
||||
@@ -898,7 +898,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexShrink: 0,
|
||||
},
|
||||
sortButton: {
|
||||
height: 32,
|
||||
height: 28,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
@@ -1025,8 +1025,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
iconButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
@@ -1034,10 +1034,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
iconButtonHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
iconButtonPressed: {
|
||||
opacity: 0.8,
|
||||
transform: [{ scale: 0.96 }],
|
||||
},
|
||||
refreshIcon: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { shouldShowMergeFromBaseAction } from "./git-action-visibility";
|
||||
|
||||
describe("git-action-visibility", () => {
|
||||
describe("shouldShowMergeFromBaseAction", () => {
|
||||
it("shows on non-base branches", () => {
|
||||
expect(
|
||||
shouldShowMergeFromBaseAction({
|
||||
isOnBaseBranch: false,
|
||||
hasRemote: false,
|
||||
aheadOfOrigin: 0,
|
||||
behindOfOrigin: 0,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("hides on base branch when no remote exists", () => {
|
||||
expect(
|
||||
shouldShowMergeFromBaseAction({
|
||||
isOnBaseBranch: true,
|
||||
hasRemote: false,
|
||||
aheadOfOrigin: 0,
|
||||
behindOfOrigin: 0,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("hides on base branch when local is in sync with origin", () => {
|
||||
expect(
|
||||
shouldShowMergeFromBaseAction({
|
||||
isOnBaseBranch: true,
|
||||
hasRemote: true,
|
||||
aheadOfOrigin: 0,
|
||||
behindOfOrigin: 0,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("shows on base branch when ahead of origin", () => {
|
||||
expect(
|
||||
shouldShowMergeFromBaseAction({
|
||||
isOnBaseBranch: true,
|
||||
hasRemote: true,
|
||||
aheadOfOrigin: 1,
|
||||
behindOfOrigin: 0,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("shows on base branch when behind origin", () => {
|
||||
expect(
|
||||
shouldShowMergeFromBaseAction({
|
||||
isOnBaseBranch: true,
|
||||
hasRemote: true,
|
||||
aheadOfOrigin: 0,
|
||||
behindOfOrigin: 2,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,14 +0,0 @@
|
||||
export function shouldShowMergeFromBaseAction(input: {
|
||||
isOnBaseBranch: boolean;
|
||||
hasRemote: boolean;
|
||||
aheadOfOrigin: number;
|
||||
behindOfOrigin: number;
|
||||
}): boolean {
|
||||
if (!input.isOnBaseBranch) {
|
||||
return true;
|
||||
}
|
||||
if (!input.hasRemote) {
|
||||
return false;
|
||||
}
|
||||
return input.aheadOfOrigin > 0 || input.behindOfOrigin > 0;
|
||||
}
|
||||
149
packages/app/src/components/git-actions-policy.test.ts
Normal file
149
packages/app/src/components/git-actions-policy.test.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildGitActions, type BuildGitActionsInput } from "./git-actions-policy";
|
||||
|
||||
function createInput(
|
||||
overrides: Partial<BuildGitActionsInput> = {}
|
||||
): BuildGitActionsInput {
|
||||
return {
|
||||
isGit: true,
|
||||
githubFeaturesEnabled: true,
|
||||
hasPullRequest: false,
|
||||
pullRequestUrl: null,
|
||||
hasRemote: false,
|
||||
isPaseoOwnedWorktree: false,
|
||||
isOnBaseBranch: true,
|
||||
hasUncommittedChanges: false,
|
||||
baseRefAvailable: true,
|
||||
baseRefLabel: "main",
|
||||
aheadCount: 0,
|
||||
aheadOfOrigin: 0,
|
||||
behindOfOrigin: 0,
|
||||
shouldPromoteArchive: false,
|
||||
shipDefault: "merge",
|
||||
runtime: {
|
||||
commit: {
|
||||
disabled: false,
|
||||
status: "idle",
|
||||
handler: () => undefined,
|
||||
},
|
||||
push: {
|
||||
disabled: false,
|
||||
status: "idle",
|
||||
handler: () => undefined,
|
||||
},
|
||||
pr: {
|
||||
disabled: false,
|
||||
status: "idle",
|
||||
handler: () => undefined,
|
||||
},
|
||||
"merge-branch": {
|
||||
disabled: false,
|
||||
status: "idle",
|
||||
handler: () => undefined,
|
||||
},
|
||||
"merge-from-base": {
|
||||
disabled: false,
|
||||
status: "idle",
|
||||
handler: () => undefined,
|
||||
},
|
||||
"archive-worktree": {
|
||||
disabled: false,
|
||||
status: "idle",
|
||||
handler: () => undefined,
|
||||
},
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("git-actions-policy", () => {
|
||||
it("keeps the secondary menu order stable while the primary action changes", () => {
|
||||
const noPrActions = buildGitActions(createInput());
|
||||
const withPrActions = buildGitActions(
|
||||
createInput({
|
||||
hasRemote: true,
|
||||
hasPullRequest: true,
|
||||
pullRequestUrl: "https://example.com/pr/123",
|
||||
aheadCount: 3,
|
||||
aheadOfOrigin: 2,
|
||||
shipDefault: "pr",
|
||||
})
|
||||
);
|
||||
|
||||
expect(noPrActions.primary).toBeNull();
|
||||
expect(withPrActions.primary?.id).toBe("push");
|
||||
expect(noPrActions.secondary.map((action) => action.id)).toEqual([
|
||||
"merge-branch",
|
||||
"pr",
|
||||
"merge-from-base",
|
||||
"push",
|
||||
]);
|
||||
expect(withPrActions.secondary.map((action) => action.id)).toEqual([
|
||||
"merge-branch",
|
||||
"pr",
|
||||
"merge-from-base",
|
||||
"push",
|
||||
]);
|
||||
});
|
||||
|
||||
it("disables hidden-before actions with explanations instead", () => {
|
||||
const actions = buildGitActions(createInput());
|
||||
const actionById = new Map(actions.secondary.map((action) => [action.id, action]));
|
||||
|
||||
expect(actionById.get("push")).toMatchObject({
|
||||
disabled: true,
|
||||
description: "No remote configured",
|
||||
});
|
||||
expect(actionById.get("pr")).toMatchObject({
|
||||
label: "Create PR",
|
||||
disabled: true,
|
||||
description: "Branch has no commits ahead of main",
|
||||
});
|
||||
expect(actionById.get("merge-branch")).toMatchObject({
|
||||
disabled: true,
|
||||
description: "No commits to merge into main",
|
||||
});
|
||||
expect(actionById.get("merge-from-base")).toMatchObject({
|
||||
disabled: true,
|
||||
description: "No remote configured",
|
||||
});
|
||||
expect(actionById.has("archive-worktree")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the current primary action visible in the menu", () => {
|
||||
const actions = buildGitActions(
|
||||
createInput({
|
||||
hasRemote: true,
|
||||
hasPullRequest: true,
|
||||
pullRequestUrl: "https://example.com/pr/456",
|
||||
})
|
||||
);
|
||||
|
||||
expect(actions.primary?.id).toBe("pr");
|
||||
expect(actions.secondary.some((action) => action.id === "pr" && action.label === "View PR")).toBe(true);
|
||||
});
|
||||
|
||||
it("disables sync on the base branch when already up to date", () => {
|
||||
const actions = buildGitActions(
|
||||
createInput({
|
||||
hasRemote: true,
|
||||
})
|
||||
);
|
||||
const syncAction = actions.secondary.find((action) => action.id === "merge-from-base");
|
||||
|
||||
expect(syncAction).toMatchObject({
|
||||
label: "Sync",
|
||||
disabled: true,
|
||||
description: "Already up to date",
|
||||
});
|
||||
});
|
||||
|
||||
it("only shows archive worktree for paseo worktrees", () => {
|
||||
const hidden = buildGitActions(createInput());
|
||||
const shown = buildGitActions(createInput({ isPaseoOwnedWorktree: true }));
|
||||
|
||||
expect(hidden.secondary.some((action) => action.id === "archive-worktree")).toBe(false);
|
||||
expect(shown.secondary.some((action) => action.id === "archive-worktree")).toBe(true);
|
||||
});
|
||||
});
|
||||
256
packages/app/src/components/git-actions-policy.ts
Normal file
256
packages/app/src/components/git-actions-policy.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
import type { ActionStatus } from "@/components/ui/dropdown-menu";
|
||||
|
||||
export type GitActionId =
|
||||
| "commit"
|
||||
| "push"
|
||||
| "pr"
|
||||
| "merge-branch"
|
||||
| "merge-from-base"
|
||||
| "archive-worktree";
|
||||
|
||||
export interface GitAction {
|
||||
id: GitActionId;
|
||||
label: string;
|
||||
pendingLabel: string;
|
||||
successLabel: string;
|
||||
disabled: boolean;
|
||||
status: ActionStatus;
|
||||
description?: string;
|
||||
icon?: ReactElement;
|
||||
handler: () => void;
|
||||
}
|
||||
|
||||
export interface GitActions {
|
||||
primary: GitAction | null;
|
||||
secondary: GitAction[];
|
||||
menu: GitAction[];
|
||||
}
|
||||
|
||||
interface GitActionRuntimeState {
|
||||
disabled: boolean;
|
||||
status: ActionStatus;
|
||||
icon?: ReactElement;
|
||||
handler: () => void;
|
||||
}
|
||||
|
||||
export interface BuildGitActionsInput {
|
||||
isGit: boolean;
|
||||
githubFeaturesEnabled: boolean;
|
||||
hasPullRequest: boolean;
|
||||
pullRequestUrl: string | null;
|
||||
hasRemote: boolean;
|
||||
isPaseoOwnedWorktree: boolean;
|
||||
isOnBaseBranch: boolean;
|
||||
hasUncommittedChanges: boolean;
|
||||
baseRefAvailable: boolean;
|
||||
baseRefLabel: string;
|
||||
aheadCount: number;
|
||||
aheadOfOrigin: number;
|
||||
behindOfOrigin: number;
|
||||
shouldPromoteArchive: boolean;
|
||||
shipDefault: "merge" | "pr";
|
||||
runtime: Record<GitActionId, GitActionRuntimeState>;
|
||||
}
|
||||
|
||||
const SECONDARY_ACTION_IDS: GitActionId[] = [
|
||||
"merge-branch",
|
||||
"pr",
|
||||
"merge-from-base",
|
||||
"push",
|
||||
];
|
||||
|
||||
export function buildGitActions(input: BuildGitActionsInput): GitActions {
|
||||
if (!input.isGit) {
|
||||
return { primary: null, secondary: [], menu: [] };
|
||||
}
|
||||
|
||||
const allActions = new Map<GitActionId, GitAction>();
|
||||
|
||||
allActions.set("commit", {
|
||||
id: "commit",
|
||||
label: "Commit",
|
||||
pendingLabel: "Committing...",
|
||||
successLabel: "Committed",
|
||||
disabled: input.runtime.commit.disabled,
|
||||
status: input.runtime.commit.status,
|
||||
icon: input.runtime.commit.icon,
|
||||
handler: input.runtime.commit.handler,
|
||||
});
|
||||
|
||||
allActions.set("push", {
|
||||
id: "push",
|
||||
label: "Push",
|
||||
pendingLabel: "Pushing...",
|
||||
successLabel: "Pushed",
|
||||
disabled: input.runtime.push.disabled || !input.hasRemote,
|
||||
status: input.runtime.push.status,
|
||||
description: input.hasRemote ? undefined : "No remote configured",
|
||||
icon: input.runtime.push.icon,
|
||||
handler: input.runtime.push.handler,
|
||||
});
|
||||
|
||||
allActions.set("pr", buildPrAction(input));
|
||||
|
||||
allActions.set("merge-branch", {
|
||||
id: "merge-branch",
|
||||
label: `Merge into ${input.baseRefLabel}`,
|
||||
pendingLabel: "Merging...",
|
||||
successLabel: "Merged",
|
||||
disabled:
|
||||
input.runtime["merge-branch"].disabled ||
|
||||
!input.baseRefAvailable ||
|
||||
input.hasUncommittedChanges ||
|
||||
input.aheadCount === 0,
|
||||
status: input.runtime["merge-branch"].status,
|
||||
description: getMergeBranchDescription(input),
|
||||
icon: input.runtime["merge-branch"].icon,
|
||||
handler: input.runtime["merge-branch"].handler,
|
||||
});
|
||||
|
||||
allActions.set("merge-from-base", {
|
||||
id: "merge-from-base",
|
||||
label: input.isOnBaseBranch ? "Sync" : `Update from ${input.baseRefLabel}`,
|
||||
pendingLabel: "Updating...",
|
||||
successLabel: "Updated",
|
||||
disabled: input.runtime["merge-from-base"].disabled || !canMergeFromBase(input),
|
||||
status: input.runtime["merge-from-base"].status,
|
||||
description: getMergeFromBaseDescription(input),
|
||||
icon: input.runtime["merge-from-base"].icon,
|
||||
handler: input.runtime["merge-from-base"].handler,
|
||||
});
|
||||
|
||||
allActions.set("archive-worktree", {
|
||||
id: "archive-worktree",
|
||||
label: "Archive worktree",
|
||||
pendingLabel: "Archiving...",
|
||||
successLabel: "Archived",
|
||||
disabled: input.runtime["archive-worktree"].disabled || !input.isPaseoOwnedWorktree,
|
||||
status: input.runtime["archive-worktree"].status,
|
||||
description: input.isPaseoOwnedWorktree ? undefined : "Only available for Paseo worktrees",
|
||||
icon: input.runtime["archive-worktree"].icon,
|
||||
handler: input.runtime["archive-worktree"].handler,
|
||||
});
|
||||
|
||||
const primaryActionId = getPrimaryActionId(input);
|
||||
const primary = primaryActionId ? allActions.get(primaryActionId) ?? null : null;
|
||||
const secondary = SECONDARY_ACTION_IDS.map((id) => allActions.get(id)!);
|
||||
if (input.isPaseoOwnedWorktree) {
|
||||
secondary.push(allActions.get("archive-worktree")!);
|
||||
}
|
||||
|
||||
return { primary, secondary, menu: [] };
|
||||
}
|
||||
|
||||
function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
|
||||
if (input.shouldPromoteArchive && input.isPaseoOwnedWorktree) {
|
||||
return "archive-worktree";
|
||||
}
|
||||
if (input.hasUncommittedChanges) {
|
||||
return "commit";
|
||||
}
|
||||
if (input.aheadOfOrigin > 0 && input.hasRemote) {
|
||||
return "push";
|
||||
}
|
||||
if (input.githubFeaturesEnabled && input.hasPullRequest && input.pullRequestUrl) {
|
||||
return "pr";
|
||||
}
|
||||
if (
|
||||
input.isOnBaseBranch &&
|
||||
input.hasRemote &&
|
||||
(input.aheadOfOrigin > 0 || input.behindOfOrigin > 0)
|
||||
) {
|
||||
return "merge-from-base";
|
||||
}
|
||||
if (input.aheadCount > 0) {
|
||||
return input.shipDefault === "merge" ? "merge-branch" : "pr";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildPrAction(input: BuildGitActionsInput): GitAction {
|
||||
if (input.hasPullRequest && input.pullRequestUrl) {
|
||||
return {
|
||||
id: "pr",
|
||||
label: "View PR",
|
||||
pendingLabel: "View PR",
|
||||
successLabel: "View PR",
|
||||
disabled: input.runtime.pr.disabled || !input.githubFeaturesEnabled,
|
||||
status: input.runtime.pr.status,
|
||||
description: input.githubFeaturesEnabled ? undefined : "GitHub features unavailable",
|
||||
icon: input.runtime.pr.icon,
|
||||
handler: input.runtime.pr.handler,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: "pr",
|
||||
label: "Create PR",
|
||||
pendingLabel: "Creating PR...",
|
||||
successLabel: "PR Created",
|
||||
disabled:
|
||||
input.runtime.pr.disabled ||
|
||||
!input.githubFeaturesEnabled ||
|
||||
input.aheadCount === 0,
|
||||
status: input.runtime.pr.status,
|
||||
description: getCreatePrDescription(input),
|
||||
icon: input.runtime.pr.icon,
|
||||
handler: input.runtime.pr.handler,
|
||||
};
|
||||
}
|
||||
|
||||
function canMergeFromBase(input: BuildGitActionsInput): boolean {
|
||||
if (!input.baseRefAvailable || input.hasUncommittedChanges) {
|
||||
return false;
|
||||
}
|
||||
if (!input.isOnBaseBranch) {
|
||||
return true;
|
||||
}
|
||||
if (!input.hasRemote) {
|
||||
return false;
|
||||
}
|
||||
return input.aheadOfOrigin > 0 || input.behindOfOrigin > 0;
|
||||
}
|
||||
|
||||
function getCreatePrDescription(input: BuildGitActionsInput): string | undefined {
|
||||
if (!input.githubFeaturesEnabled) {
|
||||
return "GitHub features unavailable";
|
||||
}
|
||||
if (input.aheadCount === 0) {
|
||||
return `Branch has no commits ahead of ${input.baseRefLabel}`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getMergeBranchDescription(input: BuildGitActionsInput): string | undefined {
|
||||
if (!input.baseRefAvailable) {
|
||||
return "Base ref unavailable";
|
||||
}
|
||||
if (input.hasUncommittedChanges) {
|
||||
return "Requires clean working tree";
|
||||
}
|
||||
if (input.aheadCount === 0) {
|
||||
return `No commits to merge into ${input.baseRefLabel}`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getMergeFromBaseDescription(input: BuildGitActionsInput): string | undefined {
|
||||
if (!input.baseRefAvailable) {
|
||||
return "Base ref unavailable";
|
||||
}
|
||||
if (input.hasUncommittedChanges) {
|
||||
return "Requires clean working tree";
|
||||
}
|
||||
if (!input.isOnBaseBranch) {
|
||||
return undefined;
|
||||
}
|
||||
if (!input.hasRemote) {
|
||||
return "No remote configured";
|
||||
}
|
||||
if (input.aheadOfOrigin === 0 && input.behindOfOrigin === 0) {
|
||||
return "Already up to date";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import type { GitAction, GitActions } from "@/hooks/use-git-actions";
|
||||
import type { GitAction, GitActions } from "@/components/git-actions-policy";
|
||||
|
||||
interface GitActionsSplitButtonProps {
|
||||
gitActions: GitActions;
|
||||
@@ -79,7 +79,11 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
|
||||
status={action.status}
|
||||
pendingLabel={action.pendingLabel}
|
||||
successLabel={action.successLabel}
|
||||
closeOnSelect={action.status === "idle" && action.id === "view-pr"}
|
||||
closeOnSelect={
|
||||
action.status === "idle" &&
|
||||
action.id === "pr" &&
|
||||
action.label === "View PR"
|
||||
}
|
||||
description={action.description}
|
||||
onSelect={action.handler}
|
||||
>
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
ListChevronsUpDown,
|
||||
RefreshCcw,
|
||||
Upload,
|
||||
WrapText,
|
||||
} from "lucide-react-native";
|
||||
import { useCheckoutGitActionsStore } from "@/stores/checkout-git-actions-store";
|
||||
import {
|
||||
@@ -37,6 +38,7 @@ import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
|
||||
import { useCheckoutPrStatusQuery } from "@/hooks/use-checkout-pr-status-query";
|
||||
import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context";
|
||||
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import { shouldAnchorHeaderBeforeCollapse } from "@/utils/git-diff-scroll";
|
||||
import {
|
||||
@@ -46,20 +48,21 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||
import {
|
||||
buildGitActions,
|
||||
type GitActions,
|
||||
} from "@/components/git-actions-policy";
|
||||
import {
|
||||
WebDesktopScrollbarOverlay,
|
||||
useWebDesktopScrollbarMetrics,
|
||||
} from "@/components/web-desktop-scrollbar";
|
||||
import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { shouldShowMergeFromBaseAction } from "./git-action-visibility";
|
||||
|
||||
import { type GitActionId, type GitAction, type GitActions } from "@/hooks/use-git-actions";
|
||||
import { GitActionsSplitButton } from "@/components/git-actions-split-button";
|
||||
|
||||
// Re-export types from shared hook
|
||||
export type { GitActionId, GitAction, GitActions } from "@/hooks/use-git-actions";
|
||||
export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy";
|
||||
|
||||
function openURLInNewTab(url: string): void {
|
||||
void openExternalUrl(url);
|
||||
@@ -152,7 +155,7 @@ interface DiffFileSectionProps {
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
function DiffLineView({ line }: { line: DiffLine }) {
|
||||
function DiffLineView({ line, lineNumber, gutterWidth }: { line: DiffLine; lineNumber: number | null; gutterWidth: number }) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
@@ -163,6 +166,15 @@ function DiffLineView({ line }: { line: DiffLine }) {
|
||||
line.type === "context" && styles.contextLineContainer,
|
||||
]}
|
||||
>
|
||||
<View style={[styles.lineNumberGutter, { width: gutterWidth }]}>
|
||||
<Text style={[
|
||||
styles.lineNumberText,
|
||||
line.type === "add" && styles.addLineNumberText,
|
||||
line.type === "remove" && styles.removeLineNumberText,
|
||||
]}>
|
||||
{lineNumber != null ? String(lineNumber) : ""}
|
||||
</Text>
|
||||
</View>
|
||||
{line.tokens && line.type !== "header" ? (
|
||||
<HighlightedText
|
||||
tokens={line.tokens}
|
||||
@@ -206,7 +218,7 @@ const DiffFileHeader = memo(function DiffFileHeader({
|
||||
<View
|
||||
style={[
|
||||
styles.fileSectionHeaderContainer,
|
||||
!isExpanded && styles.fileSectionBorder,
|
||||
isExpanded && styles.fileSectionHeaderExpanded,
|
||||
]}
|
||||
onLayout={(event) => {
|
||||
layoutYRef.current = event.nativeEvent.layout.y;
|
||||
@@ -279,10 +291,12 @@ const DiffFileHeader = memo(function DiffFileHeader({
|
||||
|
||||
function DiffFileBody({
|
||||
file,
|
||||
wrapLines,
|
||||
onBodyHeightChange,
|
||||
testID,
|
||||
}: {
|
||||
file: ParsedDiffFile;
|
||||
wrapLines: boolean;
|
||||
onBodyHeightChange?: (path: string, height: number) => void;
|
||||
testID?: string;
|
||||
}) {
|
||||
@@ -331,39 +345,86 @@ function DiffFileBody({
|
||||
}}
|
||||
testID={testID}
|
||||
>
|
||||
{file.status === "too_large" || file.status === "binary" ? (
|
||||
<View style={styles.statusMessageContainer}>
|
||||
<Text style={styles.statusMessageText}>
|
||||
{file.status === "binary" ? "Binary file" : "Diff too large to display"}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
bounces={false}
|
||||
style={styles.diffContent}
|
||||
contentContainerStyle={styles.diffContentInner}
|
||||
onScroll={handleScroll}
|
||||
scrollEventThrottle={16}
|
||||
onLayout={(e) => setScrollViewWidth(e.nativeEvent.layout.width)}
|
||||
// When at left edge, wait for close gesture to fail before scrolling.
|
||||
// The close gesture fails quickly on leftward swipes (failOffsetX=-10),
|
||||
// so scrolling left works normally. On rightward swipes, close gesture
|
||||
// activates and closes the sidebar.
|
||||
waitFor={isAtLeftEdge && closeGestureRef?.current ? closeGestureRef : undefined}
|
||||
>
|
||||
<View style={[styles.linesContainer, scrollViewWidth > 0 && { minWidth: scrollViewWidth }]}>
|
||||
{file.hunks.map((hunk, hunkIndex) =>
|
||||
hunk.lines.map((line, lineIndex) => (
|
||||
<DiffLineView key={`${hunkIndex}-${lineIndex}`} line={line} />
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
)}
|
||||
{(() => {
|
||||
if (file.status === "too_large" || file.status === "binary") {
|
||||
return (
|
||||
<View style={styles.statusMessageContainer}>
|
||||
<Text style={styles.statusMessageText}>
|
||||
{file.status === "binary" ? "Binary file" : "Diff too large to display"}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const linesContent = (() => {
|
||||
let maxLineNo = 0;
|
||||
for (const hunk of file.hunks) {
|
||||
maxLineNo = Math.max(maxLineNo, hunk.oldStart + hunk.oldCount, hunk.newStart + hunk.newCount);
|
||||
}
|
||||
const digitCount = Math.max(1, String(maxLineNo).length);
|
||||
const gutterWidth = digitCount * 8 + 12;
|
||||
return file.hunks.map((hunk, hunkIndex) => {
|
||||
let oldLineNo = hunk.oldStart;
|
||||
let newLineNo = hunk.newStart;
|
||||
return hunk.lines.map((line, lineIndex) => {
|
||||
let lineNumber: number | null = null;
|
||||
if (line.type === "remove") {
|
||||
lineNumber = oldLineNo;
|
||||
oldLineNo++;
|
||||
} else if (line.type === "add") {
|
||||
lineNumber = newLineNo;
|
||||
newLineNo++;
|
||||
} else if (line.type === "context") {
|
||||
lineNumber = newLineNo;
|
||||
oldLineNo++;
|
||||
newLineNo++;
|
||||
}
|
||||
return (
|
||||
<DiffLineView
|
||||
key={`${hunkIndex}-${lineIndex}`}
|
||||
line={line}
|
||||
lineNumber={lineNumber}
|
||||
gutterWidth={gutterWidth}
|
||||
/>
|
||||
);
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
if (wrapLines) {
|
||||
return (
|
||||
<View style={styles.diffContent}>
|
||||
<View style={styles.linesContainer}>
|
||||
{linesContent}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
bounces={false}
|
||||
style={styles.diffContent}
|
||||
contentContainerStyle={styles.diffContentInner}
|
||||
onScroll={handleScroll}
|
||||
scrollEventThrottle={16}
|
||||
onLayout={(e) => setScrollViewWidth(e.nativeEvent.layout.width)}
|
||||
// When at left edge, wait for close gesture to fail before scrolling.
|
||||
// The close gesture fails quickly on leftward swipes (failOffsetX=-10),
|
||||
// so scrolling left works normally. On rightward swipes, close gesture
|
||||
// activates and closes the sidebar.
|
||||
waitFor={isAtLeftEdge && closeGestureRef?.current ? closeGestureRef : undefined}
|
||||
>
|
||||
<View style={[styles.linesContainer, scrollViewWidth > 0 && { minWidth: scrollViewWidth }]}>
|
||||
{linesContent}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
})()}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -389,6 +450,22 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
|
||||
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
|
||||
const [wrapLines, setWrapLines] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
AsyncStorage.getItem("diff-wrap-lines").then((value) => {
|
||||
if (value === "true") setWrapLines(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleToggleWrapLines = useCallback(() => {
|
||||
setWrapLines((prev) => {
|
||||
const next = !prev;
|
||||
AsyncStorage.setItem("diff-wrap-lines", String(next));
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const { status, isLoading: isStatusLoading, isFetching: isStatusFetching, isError: isStatusError, error: statusError, refresh: refreshStatus } =
|
||||
useCheckoutStatusQuery({ serverId, cwd });
|
||||
const gitStatus = status && status.isGit ? status : null;
|
||||
@@ -735,12 +812,13 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
return (
|
||||
<DiffFileBody
|
||||
file={item.file}
|
||||
wrapLines={wrapLines}
|
||||
onBodyHeightChange={handleBodyHeightChange}
|
||||
testID={`diff-file-${item.fileIndex}-body`}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[handleBodyHeightChange, handleHeaderHeightChange, handleToggleExpanded]
|
||||
[handleBodyHeightChange, handleHeaderHeightChange, handleToggleExpanded, wrapLines]
|
||||
);
|
||||
|
||||
const flatKeyExtractor = useCallback(
|
||||
@@ -790,19 +868,13 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
const commitDisabled = actionsDisabled || commitStatus === "pending";
|
||||
const prDisabled = actionsDisabled || prCreateStatus === "pending";
|
||||
const mergeDisabled =
|
||||
actionsDisabled || mergeStatus === "pending" || hasUncommittedChanges || !baseRef;
|
||||
actionsDisabled || mergeStatus === "pending";
|
||||
const mergeFromBaseDisabled =
|
||||
actionsDisabled ||
|
||||
mergeFromBaseStatus === "pending" ||
|
||||
hasUncommittedChanges ||
|
||||
!baseRef ||
|
||||
(isOnBaseBranch && !hasRemote);
|
||||
actionsDisabled || mergeFromBaseStatus === "pending";
|
||||
const pushDisabled =
|
||||
actionsDisabled || pushStatus === "pending" || !(gitStatus?.hasRemote ?? false);
|
||||
actionsDisabled || pushStatus === "pending";
|
||||
const archiveDisabled =
|
||||
actionsDisabled ||
|
||||
archiveStatus === "pending" ||
|
||||
!gitStatus?.isPaseoOwnedWorktree;
|
||||
actionsDisabled || archiveStatus === "pending";
|
||||
|
||||
let bodyContent: ReactElement;
|
||||
|
||||
@@ -892,184 +964,67 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
// ==========================================================================
|
||||
|
||||
const gitActions: GitActions = useMemo(() => {
|
||||
if (!isGit) {
|
||||
return { primary: null, secondary: [], menu: [] };
|
||||
}
|
||||
|
||||
// Build all possible actions
|
||||
const allActions = new Map<GitActionId, GitAction>();
|
||||
|
||||
// Commit - always available
|
||||
allActions.set("commit", {
|
||||
id: "commit",
|
||||
label: "Commit",
|
||||
pendingLabel: "Committing...",
|
||||
successLabel: "Committed",
|
||||
disabled: commitDisabled,
|
||||
status: commitStatus,
|
||||
icon: <GitCommitHorizontal size={16} color={theme.colors.foregroundMuted} />,
|
||||
handler: handleCommit,
|
||||
return buildGitActions({
|
||||
isGit,
|
||||
githubFeaturesEnabled,
|
||||
hasPullRequest,
|
||||
pullRequestUrl: prStatus?.url ?? null,
|
||||
hasRemote,
|
||||
isPaseoOwnedWorktree,
|
||||
isOnBaseBranch,
|
||||
hasUncommittedChanges,
|
||||
baseRefAvailable: Boolean(baseRef),
|
||||
baseRefLabel,
|
||||
aheadCount,
|
||||
aheadOfOrigin,
|
||||
behindOfOrigin,
|
||||
shouldPromoteArchive,
|
||||
shipDefault,
|
||||
runtime: {
|
||||
commit: {
|
||||
disabled: commitDisabled,
|
||||
status: commitStatus,
|
||||
icon: <GitCommitHorizontal size={16} color={theme.colors.foregroundMuted} />,
|
||||
handler: handleCommit,
|
||||
},
|
||||
push: {
|
||||
disabled: pushDisabled,
|
||||
status: pushStatus,
|
||||
icon: <Upload size={16} color={theme.colors.foregroundMuted} />,
|
||||
handler: handlePush,
|
||||
},
|
||||
pr: {
|
||||
disabled: prDisabled,
|
||||
status: hasPullRequest ? "idle" : prCreateStatus,
|
||||
icon: <GitHubIcon size={16} color={theme.colors.foregroundMuted} />,
|
||||
handler: () => {
|
||||
if (prStatus?.url) {
|
||||
openURLInNewTab(prStatus.url);
|
||||
return;
|
||||
}
|
||||
handleCreatePr();
|
||||
},
|
||||
},
|
||||
"merge-branch": {
|
||||
disabled: mergeDisabled,
|
||||
status: mergeStatus,
|
||||
icon: <GitMerge size={16} color={theme.colors.foregroundMuted} />,
|
||||
handler: handleMergeBranch,
|
||||
},
|
||||
"merge-from-base": {
|
||||
disabled: mergeFromBaseDisabled,
|
||||
status: mergeFromBaseStatus,
|
||||
icon: <RefreshCcw size={16} color={theme.colors.foregroundMuted} />,
|
||||
handler: handleMergeFromBase,
|
||||
},
|
||||
"archive-worktree": {
|
||||
disabled: archiveDisabled,
|
||||
status: archiveStatus,
|
||||
icon: <Archive size={16} color={theme.colors.foregroundMuted} />,
|
||||
handler: handleArchiveWorktree,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Push - when has remote
|
||||
if (hasRemote) {
|
||||
allActions.set("push", {
|
||||
id: "push",
|
||||
label: "Push",
|
||||
pendingLabel: "Pushing...",
|
||||
successLabel: "Pushed",
|
||||
disabled: pushDisabled,
|
||||
status: pushStatus,
|
||||
description: !hasRemote ? "No remote configured" : undefined,
|
||||
icon: <Upload size={16} color={theme.colors.foregroundMuted} />,
|
||||
handler: handlePush,
|
||||
});
|
||||
}
|
||||
|
||||
// View PR - when PR exists
|
||||
if (githubFeaturesEnabled && hasPullRequest && prStatus?.url) {
|
||||
const prUrl = prStatus.url;
|
||||
allActions.set("view-pr", {
|
||||
id: "view-pr",
|
||||
label: "View PR",
|
||||
pendingLabel: "View PR",
|
||||
successLabel: "View PR",
|
||||
disabled: false,
|
||||
status: "idle",
|
||||
icon: <GitHubIcon size={16} color={theme.colors.foregroundMuted} />,
|
||||
handler: () => openURLInNewTab(prUrl),
|
||||
});
|
||||
}
|
||||
|
||||
// Create PR - when ahead of base and no PR
|
||||
if (githubFeaturesEnabled && aheadCount > 0 && !hasPullRequest) {
|
||||
allActions.set("create-pr", {
|
||||
id: "create-pr",
|
||||
label: "Create PR",
|
||||
pendingLabel: "Creating PR...",
|
||||
successLabel: "PR Created",
|
||||
disabled: prDisabled,
|
||||
status: prCreateStatus,
|
||||
icon: <GitHubIcon size={16} color={theme.colors.foregroundMuted} />,
|
||||
handler: handleCreatePr,
|
||||
});
|
||||
}
|
||||
|
||||
// Merge branch - when ahead of base
|
||||
if (aheadCount > 0) {
|
||||
allActions.set("merge-branch", {
|
||||
id: "merge-branch",
|
||||
label: `Merge into ${baseRefLabel}`,
|
||||
pendingLabel: "Merging...",
|
||||
successLabel: "Merged",
|
||||
disabled: mergeDisabled,
|
||||
status: mergeStatus,
|
||||
description: hasUncommittedChanges ? "Requires clean working tree" : undefined,
|
||||
icon: <GitMerge size={16} color={theme.colors.foregroundMuted} />,
|
||||
handler: handleMergeBranch,
|
||||
});
|
||||
}
|
||||
|
||||
// Update/sync from base
|
||||
if (
|
||||
shouldShowMergeFromBaseAction({
|
||||
isOnBaseBranch,
|
||||
hasRemote,
|
||||
aheadOfOrigin,
|
||||
behindOfOrigin,
|
||||
})
|
||||
) {
|
||||
allActions.set("merge-from-base", {
|
||||
id: "merge-from-base",
|
||||
label: isOnBaseBranch ? "Sync" : `Update from ${baseRefLabel}`,
|
||||
pendingLabel: "Updating...",
|
||||
successLabel: "Updated",
|
||||
disabled: mergeFromBaseDisabled,
|
||||
status: mergeFromBaseStatus,
|
||||
description:
|
||||
hasUncommittedChanges
|
||||
? "Requires clean working tree"
|
||||
: isOnBaseBranch && !hasRemote
|
||||
? "No remote configured"
|
||||
: undefined,
|
||||
icon: <RefreshCcw size={16} color={theme.colors.foregroundMuted} />,
|
||||
handler: handleMergeFromBase,
|
||||
});
|
||||
}
|
||||
|
||||
// Archive worktree - only for Paseo worktrees
|
||||
if (isPaseoOwnedWorktree) {
|
||||
allActions.set("archive-worktree", {
|
||||
id: "archive-worktree",
|
||||
label: "Archive worktree",
|
||||
pendingLabel: "Archiving...",
|
||||
successLabel: "Archived",
|
||||
disabled: archiveDisabled,
|
||||
status: archiveStatus,
|
||||
icon: <Archive size={16} color={theme.colors.foregroundMuted} />,
|
||||
handler: handleArchiveWorktree,
|
||||
});
|
||||
}
|
||||
|
||||
// Select primary action (priority rules)
|
||||
let primaryActionId: GitActionId | null = null;
|
||||
|
||||
// Rule 0: Post-ship in worktree -> Archive
|
||||
if (shouldPromoteArchive && allActions.has("archive-worktree")) {
|
||||
primaryActionId = "archive-worktree";
|
||||
}
|
||||
// Rule 1: Uncommitted changes → Commit
|
||||
else if (hasUncommittedChanges) {
|
||||
primaryActionId = "commit";
|
||||
}
|
||||
// Rule 2: Ahead of origin → Push
|
||||
else if (aheadOfOrigin > 0 && allActions.has("push")) {
|
||||
primaryActionId = "push";
|
||||
}
|
||||
// Rule 3: Has PR → View PR
|
||||
else if (hasPullRequest) {
|
||||
primaryActionId = "view-pr";
|
||||
}
|
||||
// Rule 4: On base branch -> surface sync explicitly
|
||||
else if (isOnBaseBranch && allActions.has("merge-from-base")) {
|
||||
primaryActionId = "merge-from-base";
|
||||
}
|
||||
// Rule 5: Ahead of base → Ship action based on preference
|
||||
else if (aheadCount > 0) {
|
||||
const preferred: GitActionId = shipDefault === "merge" ? "merge-branch" : "create-pr";
|
||||
const fallback: GitActionId = shipDefault === "merge" ? "create-pr" : "merge-branch";
|
||||
|
||||
const preferredAction = allActions.get(preferred);
|
||||
const fallbackAction = allActions.get(fallback);
|
||||
|
||||
if (preferredAction && !preferredAction.disabled) {
|
||||
primaryActionId = preferred;
|
||||
} else if (fallbackAction && !fallbackAction.disabled) {
|
||||
primaryActionId = fallback;
|
||||
} else if (preferredAction) {
|
||||
primaryActionId = preferred;
|
||||
}
|
||||
}
|
||||
|
||||
const primary = primaryActionId ? allActions.get(primaryActionId) ?? null : null;
|
||||
|
||||
// Secondary actions: ship-related + merge from base + push (excluding primary)
|
||||
const secondaryIds: GitActionId[] = [
|
||||
"merge-branch",
|
||||
"create-pr",
|
||||
"view-pr",
|
||||
"merge-from-base",
|
||||
"push",
|
||||
"archive-worktree",
|
||||
];
|
||||
const secondary = secondaryIds
|
||||
.filter(id => id !== primaryActionId && allActions.has(id))
|
||||
.map(id => allActions.get(id)!);
|
||||
|
||||
// Menu actions: none for now (all actionable items are in primary/secondary)
|
||||
const menu: GitAction[] = [];
|
||||
|
||||
return { primary, secondary, menu };
|
||||
}, [
|
||||
isGit, hasRemote, hasPullRequest, prStatus?.url, aheadCount, isPaseoOwnedWorktree, isOnBaseBranch, githubFeaturesEnabled,
|
||||
hasUncommittedChanges, aheadOfOrigin, behindOfOrigin, shipDefault, baseRefLabel, shouldPromoteArchive,
|
||||
@@ -1140,19 +1095,48 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{files.length > 0 ? (
|
||||
<Pressable
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.expandAllButton,
|
||||
(hovered || pressed) && styles.diffStatusRowHovered,
|
||||
]}
|
||||
onPress={handleToggleExpandAll}
|
||||
>
|
||||
{allExpanded ? (
|
||||
<ListChevronsDownUp size={14} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<ListChevronsUpDown size={14} color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
</Pressable>
|
||||
<View style={styles.diffStatusButtons}>
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.expandAllButton,
|
||||
(hovered || pressed) && styles.diffStatusRowHovered,
|
||||
]}
|
||||
onPress={handleToggleWrapLines}
|
||||
>
|
||||
<WrapText size={isMobile ? 18 : 14} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<Text style={styles.tooltipText}>
|
||||
{wrapLines ? "Scroll long lines" : "Wrap long lines"}
|
||||
</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.expandAllButton,
|
||||
(hovered || pressed) && styles.diffStatusRowHovered,
|
||||
]}
|
||||
onPress={handleToggleExpandAll}
|
||||
>
|
||||
{allExpanded ? (
|
||||
<ListChevronsDownUp size={isMobile ? 18 : 14} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<ListChevronsUpDown size={isMobile ? 18 : 14} color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<Text style={styles.tooltipText}>
|
||||
{allExpanded ? "Collapse all files" : "Expand all files"}
|
||||
</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
@@ -1209,11 +1193,12 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexShrink: 1,
|
||||
},
|
||||
diffStatusContainer: {
|
||||
paddingVertical: 1.5,
|
||||
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
diffStatusInner: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
@@ -1246,13 +1231,30 @@ const styles = StyleSheet.create((theme) => ({
|
||||
diffStatusIconHidden: {
|
||||
opacity: 0,
|
||||
},
|
||||
diffStatusButtons: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: {
|
||||
xs: theme.spacing[1],
|
||||
sm: theme.spacing[1],
|
||||
md: 0,
|
||||
},
|
||||
},
|
||||
expandAllButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
marginVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: {
|
||||
xs: theme.spacing[2],
|
||||
sm: theme.spacing[2],
|
||||
md: theme.spacing[1],
|
||||
},
|
||||
paddingVertical: {
|
||||
xs: theme.spacing[2],
|
||||
sm: theme.spacing[2],
|
||||
md: theme.spacing[1],
|
||||
},
|
||||
borderRadius: theme.borderRadius.base,
|
||||
},
|
||||
actionErrorText: {
|
||||
@@ -1313,6 +1315,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
fileSectionHeaderContainer: {
|
||||
overflow: "hidden",
|
||||
},
|
||||
fileSectionHeaderExpanded: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
fileSectionBodyContainer: {
|
||||
@@ -1331,7 +1335,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingRight: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[2],
|
||||
gap: theme.spacing[1],
|
||||
backgroundColor: theme.colors.surface1,
|
||||
zIndex: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
@@ -1409,10 +1412,34 @@ const styles = StyleSheet.create((theme) => ({
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
diffLineContainer: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
flexDirection: "row",
|
||||
alignItems: "stretch",
|
||||
},
|
||||
lineNumberGutter: {
|
||||
borderRightWidth: theme.borderWidth[1],
|
||||
borderRightColor: theme.colors.border,
|
||||
marginRight: theme.spacing[2],
|
||||
alignSelf: "stretch",
|
||||
justifyContent: "center",
|
||||
},
|
||||
lineNumberText: {
|
||||
textAlign: "right",
|
||||
paddingRight: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1],
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontFamily: Fonts.mono,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
addLineNumberText: {
|
||||
color: theme.colors.palette.green[400],
|
||||
},
|
||||
removeLineNumberText: {
|
||||
color: theme.colors.palette.red[500],
|
||||
},
|
||||
diffLineText: {
|
||||
flex: 1,
|
||||
paddingRight: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[1],
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontFamily: Fonts.mono,
|
||||
color: theme.colors.foreground,
|
||||
@@ -1453,4 +1480,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontStyle: "italic",
|
||||
},
|
||||
tooltipText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -6,9 +6,9 @@ import {
|
||||
HEADER_INNER_HEIGHT,
|
||||
HEADER_INNER_HEIGHT_MOBILE,
|
||||
HEADER_TOP_PADDING_MOBILE,
|
||||
getIsTauriMac,
|
||||
getIsDesktopMac,
|
||||
} from "@/constants/layout";
|
||||
import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-window";
|
||||
import { useDesktopDragHandlers, useTrafficLightPadding } from "@/utils/desktop-window";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
|
||||
interface ScreenHeaderProps {
|
||||
@@ -37,18 +37,20 @@ export function ScreenHeader({
|
||||
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
|
||||
const baseHorizontalPadding = theme.spacing[2];
|
||||
const collapsedSidebarTrafficLightInset =
|
||||
!isMobile && !desktopAgentListOpen && getIsTauriMac()
|
||||
!isMobile && !desktopAgentListOpen && getIsDesktopMac()
|
||||
? trafficLightPadding.left
|
||||
: 0;
|
||||
|
||||
// On Tauri macOS, enable window dragging and double-click to maximize
|
||||
const dragHandlers = useTauriDragHandlers();
|
||||
const dragHandlers = useDesktopDragHandlers();
|
||||
|
||||
return (
|
||||
<View style={styles.header}>
|
||||
<View style={[styles.inner, { paddingTop: insets.top + topPadding }]}>
|
||||
<View
|
||||
style={[styles.row, { paddingLeft: baseHorizontalPadding + collapsedSidebarTrafficLightInset }]}
|
||||
style={[
|
||||
styles.row,
|
||||
{ paddingLeft: baseHorizontalPadding + collapsedSidebarTrafficLightInset },
|
||||
]}
|
||||
{...dragHandlers}
|
||||
>
|
||||
<View style={[styles.left, leftStyle]}>{left}</View>
|
||||
|
||||
@@ -3,11 +3,13 @@ import Svg, { Rect, Line } from "react-native-svg";
|
||||
interface SourceControlPanelIconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
strokeWidth?: number;
|
||||
}
|
||||
|
||||
export function SourceControlPanelIcon({
|
||||
size = 16,
|
||||
color = "currentColor",
|
||||
strokeWidth = 2,
|
||||
}: SourceControlPanelIconProps) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
@@ -18,15 +20,15 @@ export function SourceControlPanelIcon({
|
||||
height={18}
|
||||
rx={2}
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{/* Plus */}
|
||||
<Line x1={9} y1={9.5} x2={15} y2={9.5} stroke={color} strokeWidth={2} strokeLinecap="round" />
|
||||
<Line x1={12} y1={6.5} x2={12} y2={12.5} stroke={color} strokeWidth={2} strokeLinecap="round" />
|
||||
<Line x1={9} y1={9.5} x2={15} y2={9.5} stroke={color} strokeWidth={strokeWidth} strokeLinecap="round" />
|
||||
<Line x1={12} y1={6.5} x2={12} y2={12.5} stroke={color} strokeWidth={strokeWidth} strokeLinecap="round" />
|
||||
{/* Minus */}
|
||||
<Line x1={9} y1={16} x2={15} y2={16} stroke={color} strokeWidth={2} strokeLinecap="round" />
|
||||
<Line x1={9} y1={16} x2={15} y2={16} stroke={color} strokeWidth={strokeWidth} strokeLinecap="round" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { getIsTauri } from "@/constants/layout";
|
||||
import { getIsDesktop } from "@/constants/layout";
|
||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
@@ -13,10 +13,10 @@ export function KeyboardShortcutsDialog() {
|
||||
const setOpen = useKeyboardShortcutsStore((s) => s.setShortcutsDialogOpen);
|
||||
|
||||
const isMac = getShortcutOs() === "mac";
|
||||
const isTauri = getIsTauri();
|
||||
const isDesktopApp = getIsDesktop();
|
||||
const sections = useMemo(
|
||||
() => buildKeyboardShortcutHelpSections({ isMac, isTauri }),
|
||||
[isMac, isTauri]
|
||||
() => buildKeyboardShortcutHelpSections({ isMac, isDesktop: isDesktopApp }),
|
||||
[isDesktopApp, isMac]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
import { useCallback, useMemo, useState, useEffect, useRef, useSyncExternalStore } from 'react'
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
useSyncExternalStore,
|
||||
type Dispatch,
|
||||
type RefObject,
|
||||
type SetStateAction,
|
||||
} from 'react'
|
||||
import { View, Pressable, Text, Platform } from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import Animated, {
|
||||
@@ -11,13 +22,16 @@ import Animated, {
|
||||
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
|
||||
import { MessagesSquare, Plus, Settings } from 'lucide-react-native'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { Shortcut } from '@/components/ui/shortcut'
|
||||
import { router, usePathname } from 'expo-router'
|
||||
import { usePanelStore } from '@/stores/panel-store'
|
||||
import { SidebarWorkspaceList } from './sidebar-workspace-list'
|
||||
import { SidebarAgentListSkeleton } from './sidebar-agent-list-skeleton'
|
||||
import { useSidebarWorkspacesList } from '@/hooks/use-sidebar-workspaces-list'
|
||||
import { useSidebarShortcutModel } from '@/hooks/use-sidebar-shortcut-model'
|
||||
import { useSidebarWorkspacesList, type SidebarProjectEntry } from '@/hooks/use-sidebar-workspaces-list'
|
||||
import { useSidebarAnimation } from '@/contexts/sidebar-animation-context'
|
||||
import { useTauriDragHandlers, useTrafficLightPadding } from '@/utils/tauri-window'
|
||||
import { useDesktopDragHandlers, useTrafficLightPadding } from '@/utils/desktop-window'
|
||||
import { Combobox } from '@/components/ui/combobox'
|
||||
import { getHostRuntimeStore, useHosts } from '@/runtime/host-runtime'
|
||||
import { formatConnectionStatus } from '@/utils/daemons'
|
||||
@@ -28,23 +42,61 @@ import {
|
||||
mapPathnameToServer,
|
||||
parseServerIdFromPathname,
|
||||
} from '@/utils/host-routes'
|
||||
import { useKeyboardShortcutsStore } from '@/stores/keyboard-shortcuts-store'
|
||||
import { useOpenProjectPicker } from '@/hooks/use-open-project-picker'
|
||||
|
||||
const DESKTOP_SIDEBAR_WIDTH = 320
|
||||
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__)
|
||||
|
||||
function logLeftSidebarCloseGesture(event: string, details: Record<string, unknown>): void {
|
||||
if (!IS_DEV) {
|
||||
return
|
||||
}
|
||||
console.log(`[LeftSidebarCloseGesture] ${event}`, details)
|
||||
}
|
||||
type SidebarShortcutModel = ReturnType<typeof useSidebarShortcutModel>
|
||||
type SidebarTheme = ReturnType<typeof useUnistyles>['theme']
|
||||
|
||||
interface LeftSidebarProps {
|
||||
selectedAgentId?: string
|
||||
}
|
||||
|
||||
export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarProps) {
|
||||
interface HostOption {
|
||||
id: string
|
||||
label: string
|
||||
description: string
|
||||
}
|
||||
|
||||
interface SidebarSharedProps {
|
||||
theme: SidebarTheme
|
||||
activeServerId: string | null
|
||||
activeHostLabel: string
|
||||
activeHostStatusColor: string
|
||||
hostOptions: HostOption[]
|
||||
hostTriggerRef: RefObject<View | null>
|
||||
isHostPickerOpen: boolean
|
||||
setIsHostPickerOpen: Dispatch<SetStateAction<boolean>>
|
||||
projects: SidebarProjectEntry[]
|
||||
isInitialLoad: boolean
|
||||
isRevalidating: boolean
|
||||
isManualRefresh: boolean
|
||||
collapsedProjectKeys: SidebarShortcutModel['collapsedProjectKeys']
|
||||
shortcutIndexByWorkspaceKey: SidebarShortcutModel['shortcutIndexByWorkspaceKey']
|
||||
toggleProjectCollapsed: SidebarShortcutModel['toggleProjectCollapsed']
|
||||
setProjectCollapsed: SidebarShortcutModel['setProjectCollapsed']
|
||||
handleRefresh: () => void
|
||||
handleHostSelect: (nextServerId: string) => void
|
||||
handleOpenProject: () => void
|
||||
handleSettings: () => void
|
||||
}
|
||||
|
||||
interface MobileSidebarProps extends SidebarSharedProps {
|
||||
insetsTop: number
|
||||
insetsBottom: number
|
||||
isOpen: boolean
|
||||
closeToAgent: () => void
|
||||
handleViewMoreNavigate: () => void
|
||||
}
|
||||
|
||||
interface DesktopSidebarProps extends SidebarSharedProps {
|
||||
isOpen: boolean
|
||||
handleViewMore: () => void
|
||||
}
|
||||
|
||||
export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarProps) {
|
||||
void _selectedAgentId
|
||||
|
||||
const { theme } = useUnistyles()
|
||||
const insets = useSafeAreaInsets()
|
||||
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
|
||||
@@ -114,31 +166,18 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
||||
})),
|
||||
[daemons, runtime, runtimeConnectionStatusSignature]
|
||||
)
|
||||
const hostTriggerRef = useRef<View>(null)
|
||||
const hostTriggerRef = useRef<View | null>(null)
|
||||
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false)
|
||||
|
||||
// Derive isOpen from the unified panel state
|
||||
const isOpen = isMobile ? mobileView === 'agent-list' : desktopAgentListOpen
|
||||
|
||||
const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarWorkspacesList({
|
||||
serverId: activeServerId,
|
||||
enabled: isOpen,
|
||||
})
|
||||
const {
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
windowWidth,
|
||||
animateToOpen,
|
||||
animateToClose,
|
||||
isGesturing,
|
||||
closeGestureRef,
|
||||
} = useSidebarAnimation()
|
||||
const dragHandlers = useTauriDragHandlers()
|
||||
const trafficLightPadding = useTrafficLightPadding()
|
||||
const closeTouchStartX = useSharedValue(0)
|
||||
const closeTouchStartY = useSharedValue(0)
|
||||
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed, setProjectCollapsed } =
|
||||
useSidebarShortcutModel(projects)
|
||||
|
||||
// Track user-initiated refresh to avoid showing spinner on background revalidation
|
||||
const [isManualRefresh, setIsManualRefresh] = useState(false)
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
@@ -146,29 +185,23 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
||||
refreshAll()
|
||||
}, [refreshAll])
|
||||
|
||||
// Reset manual refresh flag when revalidation completes
|
||||
useEffect(() => {
|
||||
if (!isRevalidating && isManualRefresh) {
|
||||
setIsManualRefresh(false)
|
||||
}
|
||||
}, [isRevalidating, isManualRefresh])
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
closeToAgent()
|
||||
}, [closeToAgent])
|
||||
|
||||
const setProjectPickerOpen = useKeyboardShortcutsStore((s) => s.setProjectPickerOpen)
|
||||
const openProjectPicker = useOpenProjectPicker(activeServerId)
|
||||
|
||||
const handleOpenProjectMobile = useCallback(() => {
|
||||
closeToAgent()
|
||||
setProjectPickerOpen(true)
|
||||
}, [closeToAgent, setProjectPickerOpen])
|
||||
void openProjectPicker()
|
||||
}, [closeToAgent, openProjectPicker])
|
||||
|
||||
const handleOpenProjectDesktop = useCallback(() => {
|
||||
setProjectPickerOpen(true)
|
||||
}, [setProjectPickerOpen])
|
||||
void openProjectPicker()
|
||||
}, [openProjectPicker])
|
||||
|
||||
// Mobile: close sidebar and navigate
|
||||
const handleSettingsMobile = useCallback(() => {
|
||||
if (!activeServerId) {
|
||||
return
|
||||
@@ -177,7 +210,6 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
||||
router.push(buildHostSettingsRoute(activeServerId) as any)
|
||||
}, [activeServerId, closeToAgent])
|
||||
|
||||
// Desktop: just navigate, don't close
|
||||
const handleSettingsDesktop = useCallback(() => {
|
||||
if (!activeServerId) {
|
||||
return
|
||||
@@ -185,17 +217,12 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
||||
router.push(buildHostSettingsRoute(activeServerId) as any)
|
||||
}, [activeServerId])
|
||||
|
||||
const handleViewMore = useCallback(() => {
|
||||
const handleViewMoreNavigate = useCallback(() => {
|
||||
if (!activeServerId) {
|
||||
return
|
||||
}
|
||||
if (isMobile) {
|
||||
translateX.value = -windowWidth
|
||||
backdropOpacity.value = 0
|
||||
closeToAgent()
|
||||
}
|
||||
router.push(buildHostAgentsRoute(activeServerId) as any)
|
||||
}, [activeServerId, backdropOpacity, closeToAgent, isMobile, translateX, windowWidth])
|
||||
}, [activeServerId])
|
||||
|
||||
const handleHostSelect = useCallback(
|
||||
(nextServerId: string) => {
|
||||
@@ -209,83 +236,201 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
||||
[pathname]
|
||||
)
|
||||
|
||||
// Close gesture (swipe left to close when sidebar is open)
|
||||
const closeGesture = Gesture.Pan()
|
||||
.withRef(closeGestureRef)
|
||||
.enabled(isOpen)
|
||||
// Use manual activation so child views keep touch streams unless we detect
|
||||
// an intentional left-swipe close (mirrors explorer-sidebar pattern).
|
||||
.manualActivation(true)
|
||||
.onTouchesDown((event) => {
|
||||
const touch = event.changedTouches[0]
|
||||
if (!touch) {
|
||||
return
|
||||
}
|
||||
closeTouchStartX.value = touch.absoluteX
|
||||
closeTouchStartY.value = touch.absoluteY
|
||||
})
|
||||
.onTouchesMove((event, stateManager) => {
|
||||
const touch = event.changedTouches[0]
|
||||
if (!touch || event.numberOfTouches !== 1) {
|
||||
stateManager.fail()
|
||||
return
|
||||
}
|
||||
const sharedProps = {
|
||||
theme,
|
||||
activeServerId,
|
||||
activeHostLabel,
|
||||
activeHostStatusColor,
|
||||
hostOptions,
|
||||
hostTriggerRef,
|
||||
isHostPickerOpen,
|
||||
setIsHostPickerOpen,
|
||||
projects,
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
isManualRefresh,
|
||||
collapsedProjectKeys,
|
||||
shortcutIndexByWorkspaceKey,
|
||||
toggleProjectCollapsed,
|
||||
setProjectCollapsed,
|
||||
handleRefresh,
|
||||
handleHostSelect,
|
||||
}
|
||||
|
||||
const deltaX = touch.absoluteX - closeTouchStartX.value
|
||||
const deltaY = touch.absoluteY - closeTouchStartY.value
|
||||
const absDeltaX = Math.abs(deltaX)
|
||||
const absDeltaY = Math.abs(deltaY)
|
||||
if (isMobile) {
|
||||
return (
|
||||
<MobileSidebar
|
||||
{...sharedProps}
|
||||
insetsTop={insets.top}
|
||||
insetsBottom={insets.bottom}
|
||||
isOpen={isOpen}
|
||||
closeToAgent={closeToAgent}
|
||||
handleOpenProject={handleOpenProjectMobile}
|
||||
handleSettings={handleSettingsMobile}
|
||||
handleViewMoreNavigate={handleViewMoreNavigate}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Fail quickly on clear rightward or vertical intent so child views keep control.
|
||||
if (deltaX >= 10) {
|
||||
stateManager.fail()
|
||||
return
|
||||
}
|
||||
if (absDeltaY > 10 && absDeltaY > absDeltaX) {
|
||||
stateManager.fail()
|
||||
return
|
||||
}
|
||||
return (
|
||||
<DesktopSidebar
|
||||
{...sharedProps}
|
||||
isOpen={isOpen}
|
||||
handleOpenProject={handleOpenProjectDesktop}
|
||||
handleSettings={handleSettingsDesktop}
|
||||
handleViewMore={handleViewMoreNavigate}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
// Activate only on intentional leftward movement.
|
||||
if (deltaX <= -15 && absDeltaX > absDeltaY) {
|
||||
stateManager.activate()
|
||||
}
|
||||
})
|
||||
.onStart(() => {
|
||||
isGesturing.value = true
|
||||
runOnJS(logLeftSidebarCloseGesture)('start', { isOpen, isMobile })
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
if (!isMobile) return
|
||||
// Only allow swiping left (closing)
|
||||
const newTranslateX = Math.min(0, Math.max(-windowWidth, event.translationX))
|
||||
translateX.value = newTranslateX
|
||||
backdropOpacity.value = interpolate(
|
||||
newTranslateX,
|
||||
[-windowWidth, 0],
|
||||
[0, 1],
|
||||
Extrapolation.CLAMP
|
||||
)
|
||||
})
|
||||
.onEnd((event) => {
|
||||
isGesturing.value = false
|
||||
if (!isMobile) return
|
||||
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500
|
||||
runOnJS(logLeftSidebarCloseGesture)('end', {
|
||||
translationX: event.translationX,
|
||||
velocityX: event.velocityX,
|
||||
shouldClose,
|
||||
})
|
||||
if (shouldClose) {
|
||||
animateToClose()
|
||||
runOnJS(handleClose)()
|
||||
} else {
|
||||
animateToOpen()
|
||||
}
|
||||
})
|
||||
.onFinalize(() => {
|
||||
isGesturing.value = false
|
||||
})
|
||||
function MobileSidebar({
|
||||
theme,
|
||||
activeServerId,
|
||||
activeHostLabel,
|
||||
activeHostStatusColor,
|
||||
hostOptions,
|
||||
hostTriggerRef,
|
||||
isHostPickerOpen,
|
||||
setIsHostPickerOpen,
|
||||
projects,
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
isManualRefresh,
|
||||
collapsedProjectKeys,
|
||||
shortcutIndexByWorkspaceKey,
|
||||
toggleProjectCollapsed,
|
||||
setProjectCollapsed,
|
||||
handleRefresh,
|
||||
handleHostSelect,
|
||||
handleOpenProject,
|
||||
handleSettings,
|
||||
insetsTop,
|
||||
insetsBottom,
|
||||
isOpen,
|
||||
closeToAgent,
|
||||
handleViewMoreNavigate,
|
||||
}: MobileSidebarProps) {
|
||||
const {
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
windowWidth,
|
||||
animateToOpen,
|
||||
animateToClose,
|
||||
isGesturing,
|
||||
closeGestureRef,
|
||||
} = useSidebarAnimation()
|
||||
const closeTouchStartX = useSharedValue(0)
|
||||
const closeTouchStartY = useSharedValue(0)
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
closeToAgent()
|
||||
}, [closeToAgent])
|
||||
|
||||
const handleViewMore = useCallback(() => {
|
||||
if (!activeServerId) {
|
||||
return
|
||||
}
|
||||
translateX.value = -windowWidth
|
||||
backdropOpacity.value = 0
|
||||
closeToAgent()
|
||||
handleViewMoreNavigate()
|
||||
}, [
|
||||
activeServerId,
|
||||
backdropOpacity,
|
||||
closeToAgent,
|
||||
handleViewMoreNavigate,
|
||||
translateX,
|
||||
windowWidth,
|
||||
])
|
||||
|
||||
const closeGesture = useMemo(
|
||||
() =>
|
||||
Gesture.Pan()
|
||||
.withRef(closeGestureRef)
|
||||
.enabled(isOpen)
|
||||
.manualActivation(true)
|
||||
.onTouchesDown((event) => {
|
||||
const touch = event.changedTouches[0]
|
||||
if (!touch) {
|
||||
return
|
||||
}
|
||||
closeTouchStartX.value = touch.absoluteX
|
||||
closeTouchStartY.value = touch.absoluteY
|
||||
})
|
||||
.onTouchesMove((event, stateManager) => {
|
||||
const touch = event.changedTouches[0]
|
||||
if (!touch || event.numberOfTouches !== 1) {
|
||||
stateManager.fail()
|
||||
return
|
||||
}
|
||||
|
||||
const deltaX = touch.absoluteX - closeTouchStartX.value
|
||||
const deltaY = touch.absoluteY - closeTouchStartY.value
|
||||
const absDeltaX = Math.abs(deltaX)
|
||||
const absDeltaY = Math.abs(deltaY)
|
||||
|
||||
if (deltaX >= 10) {
|
||||
stateManager.fail()
|
||||
return
|
||||
}
|
||||
if (absDeltaY > 10 && absDeltaY > absDeltaX) {
|
||||
stateManager.fail()
|
||||
return
|
||||
}
|
||||
if (deltaX <= -15 && absDeltaX > absDeltaY) {
|
||||
stateManager.activate()
|
||||
}
|
||||
})
|
||||
.onStart(() => {
|
||||
isGesturing.value = true
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
const newTranslateX = Math.min(0, Math.max(-windowWidth, event.translationX))
|
||||
translateX.value = newTranslateX
|
||||
backdropOpacity.value = interpolate(
|
||||
newTranslateX,
|
||||
[-windowWidth, 0],
|
||||
[0, 1],
|
||||
Extrapolation.CLAMP
|
||||
)
|
||||
})
|
||||
.onEnd((event) => {
|
||||
isGesturing.value = false
|
||||
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500
|
||||
if (shouldClose) {
|
||||
animateToClose()
|
||||
runOnJS(handleClose)()
|
||||
} else {
|
||||
animateToOpen()
|
||||
}
|
||||
})
|
||||
.onFinalize(() => {
|
||||
isGesturing.value = false
|
||||
}),
|
||||
[
|
||||
isOpen,
|
||||
closeGestureRef,
|
||||
closeTouchStartX,
|
||||
closeTouchStartY,
|
||||
isGesturing,
|
||||
windowWidth,
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
animateToClose,
|
||||
animateToOpen,
|
||||
handleClose,
|
||||
]
|
||||
)
|
||||
|
||||
const mobileSidebarInsetStyle = useMemo(
|
||||
() => ({ width: windowWidth, paddingTop: insetsTop, paddingBottom: insetsBottom }),
|
||||
[windowWidth, insetsTop, insetsBottom]
|
||||
)
|
||||
|
||||
const hostStatusDotStyle = useMemo(
|
||||
() => [styles.hostStatusDot, { backgroundColor: activeHostStatusColor }],
|
||||
[activeHostStatusColor]
|
||||
)
|
||||
|
||||
const sidebarAnimatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateX: translateX.value }],
|
||||
@@ -296,147 +441,174 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
||||
pointerEvents: backdropOpacity.value > 0.01 ? 'auto' : 'none',
|
||||
}))
|
||||
|
||||
// Render mobile sidebar
|
||||
// On web, keep the overlay interactive only while the sidebar is open.
|
||||
// This preserves swipe/scroll behavior without blocking taps when closed.
|
||||
const overlayPointerEvents = Platform.OS === 'web' ? (isOpen ? 'auto' : 'none') : 'box-none'
|
||||
if (isMobile) {
|
||||
return (
|
||||
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
|
||||
{/* Backdrop */}
|
||||
<Animated.View style={[styles.backdrop, backdropAnimatedStyle]}>
|
||||
<Pressable style={styles.backdropPressable} onPress={handleClose} />
|
||||
</Animated.View>
|
||||
|
||||
<GestureDetector gesture={closeGesture} touchAction="pan-y">
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.mobileSidebar,
|
||||
{ width: windowWidth, paddingTop: insets.top, paddingBottom: insets.bottom },
|
||||
sidebarAnimatedStyle,
|
||||
]}
|
||||
pointerEvents="auto"
|
||||
>
|
||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||
{/* Header */}
|
||||
<View style={styles.sidebarHeader}>
|
||||
<View style={styles.sidebarHeaderRow}>
|
||||
<Pressable
|
||||
style={styles.newAgentButton}
|
||||
testID="sidebar-new-agent"
|
||||
onPress={handleOpenProjectMobile}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<>
|
||||
<Plus
|
||||
size={theme.iconSize.md}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.newAgentButtonText,
|
||||
hovered && styles.newAgentButtonTextHovered,
|
||||
]}
|
||||
>
|
||||
Add project
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
return (
|
||||
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
|
||||
<Animated.View style={[styles.backdrop, backdropAnimatedStyle]}>
|
||||
<Pressable style={styles.backdropPressable} onPress={handleClose} />
|
||||
</Animated.View>
|
||||
|
||||
{/* Middle: scrollable project/workspace tree */}
|
||||
{isInitialLoad ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
<SidebarWorkspaceList
|
||||
isOpen={isOpen}
|
||||
serverId={activeServerId}
|
||||
projects={projects}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
onWorkspacePress={closeToAgent}
|
||||
parentGestureRef={closeGestureRef}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<View style={styles.sidebarFooter}>
|
||||
<View style={styles.footerHostSlot}>
|
||||
<Pressable
|
||||
ref={hostTriggerRef}
|
||||
style={({ hovered = false }) => [
|
||||
styles.hostTrigger,
|
||||
hovered && styles.hostTriggerHovered,
|
||||
]}
|
||||
onPress={() => setIsHostPickerOpen(true)}
|
||||
disabled={hostOptions.length === 0}
|
||||
>
|
||||
<View
|
||||
style={[styles.hostStatusDot, { backgroundColor: activeHostStatusColor }]}
|
||||
/>
|
||||
<Text style={styles.hostTriggerText} numberOfLines={1}>
|
||||
{activeHostLabel}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<View style={styles.footerIconRow}>
|
||||
<Pressable
|
||||
style={styles.footerIconButton}
|
||||
testID="sidebar-all-agents"
|
||||
nativeID="sidebar-all-agents"
|
||||
collapsable={false}
|
||||
accessible
|
||||
accessibilityLabel="Sessions"
|
||||
accessibilityRole="button"
|
||||
onPress={handleViewMore}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<GestureDetector gesture={closeGesture} touchAction="pan-y">
|
||||
<Animated.View
|
||||
style={[styles.mobileSidebar, mobileSidebarInsetStyle, sidebarAnimatedStyle]}
|
||||
pointerEvents="auto"
|
||||
>
|
||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||
<View style={styles.sidebarHeader}>
|
||||
<View style={styles.sidebarHeaderRow}>
|
||||
<Pressable
|
||||
style={styles.newAgentButton}
|
||||
testID="sidebar-sessions"
|
||||
onPress={handleViewMore}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<>
|
||||
<MessagesSquare
|
||||
size={theme.iconSize.lg}
|
||||
size={theme.iconSize.md}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={styles.footerIconButton}
|
||||
testID="sidebar-settings"
|
||||
nativeID="sidebar-settings"
|
||||
collapsable={false}
|
||||
accessible
|
||||
accessibilityLabel="Settings"
|
||||
accessibilityRole="button"
|
||||
onPress={handleSettingsMobile}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Settings
|
||||
size={theme.iconSize.lg}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
<Combobox
|
||||
options={hostOptions}
|
||||
value={activeServerId ?? ''}
|
||||
onSelect={handleHostSelect}
|
||||
searchable={false}
|
||||
title="Switch host"
|
||||
searchPlaceholder="Search hosts..."
|
||||
open={isHostPickerOpen}
|
||||
onOpenChange={setIsHostPickerOpen}
|
||||
anchorRef={hostTriggerRef}
|
||||
/>
|
||||
<Text
|
||||
style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}
|
||||
>
|
||||
Sessions
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
// Desktop: no edge swipe, just show/hide based on isOpen
|
||||
{isInitialLoad ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
<SidebarWorkspaceList
|
||||
serverId={activeServerId}
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||
onSetProjectCollapsed={setProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
projects={projects}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
onWorkspacePress={closeToAgent}
|
||||
parentGestureRef={closeGestureRef}
|
||||
/>
|
||||
)}
|
||||
|
||||
<View style={styles.sidebarFooter}>
|
||||
<View style={styles.footerHostSlot}>
|
||||
<Pressable
|
||||
ref={hostTriggerRef}
|
||||
style={({ hovered = false }) => [
|
||||
styles.hostTrigger,
|
||||
hovered && styles.hostTriggerHovered,
|
||||
]}
|
||||
onPress={() => setIsHostPickerOpen(true)}
|
||||
disabled={hostOptions.length === 0}
|
||||
>
|
||||
<View style={hostStatusDotStyle} />
|
||||
<Text style={styles.hostTriggerText} numberOfLines={1}>
|
||||
{activeHostLabel}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<View style={styles.footerIconRow}>
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
style={styles.footerIconButton}
|
||||
testID="sidebar-add-project"
|
||||
nativeID="sidebar-add-project"
|
||||
collapsable={false}
|
||||
accessible
|
||||
accessibilityLabel="Add project"
|
||||
accessibilityRole="button"
|
||||
onPress={handleOpenProject}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Plus
|
||||
size={theme.iconSize.lg}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>Add project</Text>
|
||||
<Shortcut keys={['⌘', '⇧', 'O']} />
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Pressable
|
||||
style={styles.footerIconButton}
|
||||
testID="sidebar-settings"
|
||||
nativeID="sidebar-settings"
|
||||
collapsable={false}
|
||||
accessible
|
||||
accessibilityLabel="Settings"
|
||||
accessibilityRole="button"
|
||||
onPress={handleSettings}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Settings
|
||||
size={theme.iconSize.lg}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
<Combobox
|
||||
options={hostOptions}
|
||||
value={activeServerId ?? ''}
|
||||
onSelect={handleHostSelect}
|
||||
searchable={false}
|
||||
title="Switch host"
|
||||
searchPlaceholder="Search hosts..."
|
||||
open={isHostPickerOpen}
|
||||
onOpenChange={setIsHostPickerOpen}
|
||||
anchorRef={hostTriggerRef}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function DesktopSidebar({
|
||||
theme,
|
||||
activeServerId,
|
||||
activeHostLabel,
|
||||
activeHostStatusColor,
|
||||
hostOptions,
|
||||
hostTriggerRef,
|
||||
isHostPickerOpen,
|
||||
setIsHostPickerOpen,
|
||||
projects,
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
isManualRefresh,
|
||||
collapsedProjectKeys,
|
||||
shortcutIndexByWorkspaceKey,
|
||||
toggleProjectCollapsed,
|
||||
setProjectCollapsed,
|
||||
handleRefresh,
|
||||
handleHostSelect,
|
||||
handleOpenProject,
|
||||
handleSettings,
|
||||
isOpen,
|
||||
handleViewMore,
|
||||
}: DesktopSidebarProps) {
|
||||
const dragHandlers = useDesktopDragHandlers()
|
||||
const trafficLightPadding = useTrafficLightPadding()
|
||||
const hostStatusDotStyle = useMemo(
|
||||
() => [styles.hostStatusDot, { backgroundColor: activeHostStatusColor }],
|
||||
[activeHostStatusColor]
|
||||
)
|
||||
|
||||
if (!isOpen) {
|
||||
return null
|
||||
}
|
||||
@@ -450,19 +622,19 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
||||
<View style={styles.sidebarHeaderRow}>
|
||||
<Pressable
|
||||
style={styles.newAgentButton}
|
||||
testID="sidebar-new-agent"
|
||||
onPress={handleOpenProjectDesktop}
|
||||
testID="sidebar-sessions"
|
||||
onPress={handleViewMore}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<>
|
||||
<Plus
|
||||
<MessagesSquare
|
||||
size={theme.iconSize.md}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
<Text
|
||||
style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}
|
||||
>
|
||||
Add project
|
||||
Sessions
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
@@ -470,20 +642,21 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Middle: scrollable project/workspace tree */}
|
||||
{isInitialLoad ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
<SidebarWorkspaceList
|
||||
isOpen={isOpen}
|
||||
serverId={activeServerId}
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||
onSetProjectCollapsed={setProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
projects={projects}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<View style={styles.sidebarFooter}>
|
||||
<View style={styles.footerHostSlot}>
|
||||
<Pressable
|
||||
@@ -495,30 +668,40 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
||||
onPress={() => setIsHostPickerOpen(true)}
|
||||
disabled={hostOptions.length === 0}
|
||||
>
|
||||
<View style={[styles.hostStatusDot, { backgroundColor: activeHostStatusColor }]} />
|
||||
<View style={hostStatusDotStyle} />
|
||||
<Text style={styles.hostTriggerText} numberOfLines={1}>
|
||||
{activeHostLabel}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<View style={styles.footerIconRow}>
|
||||
<Pressable
|
||||
style={styles.footerIconButton}
|
||||
testID="sidebar-all-agents"
|
||||
nativeID="sidebar-all-agents"
|
||||
collapsable={false}
|
||||
accessible
|
||||
accessibilityLabel="Sessions"
|
||||
accessibilityRole="button"
|
||||
onPress={handleViewMore}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<MessagesSquare
|
||||
size={theme.iconSize.lg}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
style={styles.footerIconButton}
|
||||
testID="sidebar-add-project"
|
||||
nativeID="sidebar-add-project"
|
||||
collapsable={false}
|
||||
accessible
|
||||
accessibilityLabel="Add project"
|
||||
accessibilityRole="button"
|
||||
onPress={handleOpenProject}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Plus
|
||||
size={theme.iconSize.lg}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>Add project</Text>
|
||||
<Shortcut keys={['⌘', '⇧', 'O']} />
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Pressable
|
||||
style={styles.footerIconButton}
|
||||
testID="sidebar-settings"
|
||||
@@ -527,7 +710,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
||||
accessible
|
||||
accessibilityLabel="Settings"
|
||||
accessibilityRole="button"
|
||||
onPress={handleSettingsDesktop}
|
||||
onPress={handleSettings}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Settings
|
||||
@@ -601,7 +784,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
paddingRight: theme.spacing[1],
|
||||
paddingLeft: theme.spacing[3],
|
||||
flexShrink: 0,
|
||||
},
|
||||
newAgentButtonHovered: {},
|
||||
@@ -622,12 +806,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
hostTriggerHovered: {
|
||||
borderColor: theme.colors.borderAccent,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
hostStatusDot: {
|
||||
width: 8,
|
||||
@@ -697,4 +878,13 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
tooltipRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
tooltipText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.popoverForeground,
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -101,7 +101,6 @@ export interface MessageInputRef {
|
||||
const MIN_INPUT_HEIGHT = 30
|
||||
const MAX_INPUT_HEIGHT = 160
|
||||
const IS_WEB = Platform.OS === 'web'
|
||||
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__)
|
||||
|
||||
type WebTextInputKeyPressEvent = NativeSyntheticEvent<
|
||||
TextInputKeyPressEventData & {
|
||||
@@ -125,13 +124,10 @@ type TextAreaHandle = {
|
||||
}
|
||||
|
||||
function logWebStickyBottom(
|
||||
event: string,
|
||||
details: Record<string, unknown>
|
||||
_event: string,
|
||||
_details: Record<string, unknown>
|
||||
): void {
|
||||
if (!IS_DEV || !IS_WEB) {
|
||||
return
|
||||
}
|
||||
console.log('[WebStickyBottom]', event, details)
|
||||
// Intentionally disabled: this path is too noisy during voice debugging.
|
||||
}
|
||||
|
||||
function getDebugNow(): number | null {
|
||||
@@ -216,6 +212,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
ref
|
||||
) {
|
||||
const { theme } = useUnistyles()
|
||||
const buttonIconSize = IS_WEB ? theme.iconSize.md : theme.iconSize.lg
|
||||
const investigationComponentId = `MessageInput:${voiceServerId ?? 'unknown-server'}:${voiceAgentId ?? 'unknown-agent'}`
|
||||
markScrollInvestigationRender(investigationComponentId)
|
||||
const toast = useToast()
|
||||
@@ -407,6 +404,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
const showRealtimeOverlay = isRealtimeVoiceForCurrentAgent
|
||||
const showOverlay = showDictationOverlay || showRealtimeOverlay
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (isDictating || isDictationProcessing) {
|
||||
return
|
||||
@@ -947,9 +945,13 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
disabled={!isConnected || disabled}
|
||||
accessibilityLabel="Attach images"
|
||||
accessibilityRole="button"
|
||||
style={[styles.attachButton, (!isConnected || disabled) && styles.buttonDisabled]}
|
||||
style={({ hovered }) => [
|
||||
styles.attachButton,
|
||||
hovered && styles.iconButtonHovered,
|
||||
(!isConnected || disabled) && styles.buttonDisabled,
|
||||
]}
|
||||
>
|
||||
<Paperclip size={theme.iconSize.lg} color={theme.colors.foreground} />
|
||||
<Paperclip size={buttonIconSize} color={theme.colors.foreground} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>Attach images</Text>
|
||||
@@ -975,18 +977,19 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
? 'Stop dictation'
|
||||
: 'Start dictation'
|
||||
}
|
||||
style={[
|
||||
style={({ hovered }) => [
|
||||
styles.voiceButton,
|
||||
hovered && !isDictating && styles.iconButtonHovered,
|
||||
(!isDictationStartEnabled) && styles.buttonDisabled,
|
||||
isDictating && styles.voiceButtonRecording,
|
||||
]}
|
||||
>
|
||||
{isDictating ? (
|
||||
<Square size={theme.iconSize.lg} color="white" fill="white" />
|
||||
<Square size={buttonIconSize} color="white" fill="white" />
|
||||
) : isRealtimeVoiceForCurrentAgent && voice?.isMuted ? (
|
||||
<MicOff size={theme.iconSize.lg} color={theme.colors.foreground} />
|
||||
<MicOff size={buttonIconSize} color={theme.colors.foreground} />
|
||||
) : (
|
||||
<Mic size={theme.iconSize.lg} color={theme.colors.foreground} />
|
||||
<Mic size={buttonIconSize} color={theme.colors.foreground} />
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
@@ -1013,9 +1016,13 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
disabled={!isConnected || disabled}
|
||||
accessibilityLabel="Queue message"
|
||||
accessibilityRole="button"
|
||||
style={[styles.queueButton, (!isConnected || disabled) && styles.buttonDisabled]}
|
||||
style={({ hovered }) => [
|
||||
styles.queueButton,
|
||||
hovered && styles.iconButtonHovered,
|
||||
(!isConnected || disabled) && styles.buttonDisabled,
|
||||
]}
|
||||
>
|
||||
<Plus size={theme.iconSize.lg} color="white" />
|
||||
<Plus size={buttonIconSize} color="white" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<View style={styles.tooltipRow}>
|
||||
@@ -1037,7 +1044,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
{isSubmitLoading ? (
|
||||
<ActivityIndicator size="small" color="white" />
|
||||
) : (
|
||||
<ArrowUp size={theme.iconSize.lg} color="white" />
|
||||
<ArrowUp size={buttonIconSize} color="white" />
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
@@ -1070,10 +1077,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
/>
|
||||
) : showRealtimeOverlay && voice ? (
|
||||
<RealtimeVoiceOverlay
|
||||
volume={voice.volume}
|
||||
isMuted={voice.isMuted}
|
||||
isDetecting={voice.isDetecting}
|
||||
isSpeaking={voice.isSpeaking}
|
||||
isSwitching={voice.isVoiceSwitching}
|
||||
onToggleMute={voice.toggleMute}
|
||||
onStop={() => {
|
||||
@@ -1162,9 +1166,9 @@ const styles = StyleSheet.create(((theme: any) => ({
|
||||
textInput: {
|
||||
width: '100%',
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.lg,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
lineHeight: theme.fontSize.lg * 1.4,
|
||||
lineHeight: theme.fontSize.base * 1.4,
|
||||
...(IS_WEB
|
||||
? {
|
||||
outlineStyle: 'none' as const,
|
||||
@@ -1180,24 +1184,24 @@ const styles = StyleSheet.create(((theme: any) => ({
|
||||
},
|
||||
leftButtonGroup: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing[2],
|
||||
alignItems: 'flex-end',
|
||||
gap: Platform.OS === 'web' ? theme.spacing[2] : theme.spacing[1],
|
||||
},
|
||||
rightButtonGroup: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing[2],
|
||||
gap: Platform.OS === 'web' ? theme.spacing[2] : theme.spacing[1],
|
||||
},
|
||||
attachButton: {
|
||||
width: 34,
|
||||
height: 34,
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
voiceButton: {
|
||||
width: 34,
|
||||
height: 34,
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
@@ -1206,21 +1210,24 @@ const styles = StyleSheet.create(((theme: any) => ({
|
||||
backgroundColor: theme.colors.destructive,
|
||||
},
|
||||
queueButton: {
|
||||
width: 34,
|
||||
height: 34,
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
sendButton: {
|
||||
width: 34,
|
||||
height: 34,
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.accent,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
iconButtonHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
tooltipRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -18,6 +18,9 @@ import {
|
||||
useCallback,
|
||||
createContext,
|
||||
useContext,
|
||||
isValidElement,
|
||||
Children,
|
||||
cloneElement,
|
||||
} from "react";
|
||||
import type { ReactNode, ComponentType } from "react";
|
||||
import Markdown, { MarkdownIt } from "react-native-markdown-display";
|
||||
@@ -68,7 +71,15 @@ import {
|
||||
buildToolCallDisplayModel,
|
||||
} from "@/utils/tool-call-display";
|
||||
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
|
||||
import { parseInlinePathToken, type InlinePathTarget } from "@/utils/inline-path";
|
||||
import {
|
||||
hasMeaningfulToolCallDetail,
|
||||
isPendingToolCallDetail,
|
||||
} from "@/utils/tool-call-detail-state";
|
||||
import {
|
||||
parseAssistantFileLink,
|
||||
parseInlinePathToken,
|
||||
type InlinePathTarget,
|
||||
} from "@/utils/inline-path";
|
||||
import { getMarkdownListMarker } from "@/utils/markdown-list";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { markScrollInvestigationEvent } from "@/utils/scroll-jank-investigation";
|
||||
@@ -422,6 +433,7 @@ interface AssistantMessageProps {
|
||||
message: string;
|
||||
timestamp: number;
|
||||
onInlinePathPress?: (target: InlinePathTarget) => void;
|
||||
workspaceRoot?: string;
|
||||
disableOuterSpacing?: boolean;
|
||||
}
|
||||
|
||||
@@ -433,20 +445,6 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
containerSpacing: {
|
||||
marginBottom: theme.spacing[4],
|
||||
},
|
||||
// Used in custom markdownRules for inline code styling
|
||||
markdownCodeInline: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
color: theme.colors.foreground,
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: 2,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
fontFamily: Fonts.mono,
|
||||
fontSize: 13,
|
||||
},
|
||||
markdownCodeInlineLink: {
|
||||
color: theme.colors.primary,
|
||||
textDecorationLine: "underline",
|
||||
},
|
||||
// Used in custom markdownRules for path chip styling
|
||||
pathChip: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
@@ -463,6 +461,44 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
function MarkdownLink({
|
||||
href,
|
||||
style,
|
||||
onPress,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
style: any;
|
||||
onPress: (url: string) => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
if (Platform.OS !== "web") {
|
||||
return (
|
||||
<Text
|
||||
accessibilityRole="link"
|
||||
onPress={() => onPress(href)}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="link"
|
||||
onPress={() => onPress(href)}
|
||||
onHoverIn={() => setHovered(true)}
|
||||
onHoverOut={() => setHovered(false)}
|
||||
>
|
||||
<Text style={[style, hovered && { textDecorationLine: "underline" }]}>
|
||||
{children}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function getInlineCodeAutoLinkUrl(
|
||||
markdownParser: ReturnType<typeof MarkdownIt>,
|
||||
content: string
|
||||
@@ -487,11 +523,25 @@ function getInlineCodeAutoLinkUrl(
|
||||
return match.url;
|
||||
}
|
||||
|
||||
function nodeHasParentType(parent: unknown, type: string): boolean {
|
||||
if (Array.isArray(parent)) {
|
||||
return parent.some((entry) => entry?.type === type);
|
||||
}
|
||||
|
||||
return (
|
||||
typeof parent === "object" &&
|
||||
parent !== null &&
|
||||
"type" in parent &&
|
||||
(parent as { type?: string }).type === type
|
||||
);
|
||||
}
|
||||
|
||||
const turnCopyButtonStylesheet = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
alignSelf: "flex-start",
|
||||
padding: theme.spacing[2],
|
||||
paddingTop: 0,
|
||||
marginTop: theme.spacing[2],
|
||||
},
|
||||
iconColor: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
@@ -575,7 +625,7 @@ export const TurnCopyButton = memo(function TurnCopyButton({
|
||||
|
||||
const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
marginHorizontal: theme.spacing[2],
|
||||
marginHorizontal: -6,
|
||||
},
|
||||
containerSpacing: {
|
||||
marginBottom: theme.spacing[1],
|
||||
@@ -586,8 +636,7 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
|
||||
pressable: {
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderColor: "transparent",
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1],
|
||||
overflow: "hidden",
|
||||
@@ -611,16 +660,18 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
|
||||
borderRadius: 11,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginRight: theme.spacing[2],
|
||||
marginRight: theme.spacing[1],
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
label: {
|
||||
color: theme.colors.foreground,
|
||||
opacity: 0.88,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
flexShrink: 0,
|
||||
},
|
||||
labelActive: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
labelLoading: {
|
||||
color: theme.colors.foreground,
|
||||
opacity: 0.72,
|
||||
@@ -632,6 +683,9 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
marginLeft: theme.spacing[2],
|
||||
},
|
||||
secondaryLabelActive: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
shimmerText: {
|
||||
color: "transparent",
|
||||
fontSize: theme.fontSize.base,
|
||||
@@ -661,6 +715,8 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
|
||||
overflow: "hidden",
|
||||
},
|
||||
pressableExpanded: {
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderBottomLeftRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
},
|
||||
@@ -700,25 +756,45 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
message,
|
||||
timestamp,
|
||||
onInlinePathPress,
|
||||
workspaceRoot,
|
||||
disableOuterSpacing,
|
||||
}: AssistantMessageProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { theme, rt } = useUnistyles();
|
||||
const resolvedDisableOuterSpacing =
|
||||
useDisableOuterSpacing(disableOuterSpacing);
|
||||
|
||||
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
|
||||
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [rt.themeName]);
|
||||
|
||||
const markdownParser = useMemo(
|
||||
() => MarkdownIt({ typographer: true, linkify: true }),
|
||||
() => {
|
||||
const parser = MarkdownIt({ typographer: true, linkify: true });
|
||||
const defaultValidateLink = parser.validateLink.bind(parser);
|
||||
parser.validateLink = (url: string) => {
|
||||
if (url.trim().toLowerCase().startsWith("file://")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return defaultValidateLink(url);
|
||||
};
|
||||
return parser;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleLinkPress = useCallback((url: string) => {
|
||||
const fileTarget = onInlinePathPress
|
||||
? parseAssistantFileLink(url, { workspaceRoot })
|
||||
: null;
|
||||
if (fileTarget) {
|
||||
onInlinePathPress?.(fileTarget);
|
||||
return false;
|
||||
}
|
||||
|
||||
void openExternalUrl(url);
|
||||
// react-native-markdown-display opens the link itself when this returns true.
|
||||
// We already handled it above, so return false to avoid duplicate opens.
|
||||
return false;
|
||||
}, []);
|
||||
}, [onInlinePathPress, workspaceRoot]);
|
||||
|
||||
const markdownRules = useMemo(() => {
|
||||
return {
|
||||
@@ -775,12 +851,16 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
code_inline: (
|
||||
node: any,
|
||||
_children: ReactNode[],
|
||||
_parent: any,
|
||||
_styles: any,
|
||||
parent: any,
|
||||
styles: any,
|
||||
inheritedStyles: any = {}
|
||||
) => {
|
||||
const content = node.content ?? "";
|
||||
const parsed = onInlinePathPress
|
||||
const isLinkedInlineCode =
|
||||
nodeHasParentType(parent, "link") ||
|
||||
(!Array.isArray(parent) &&
|
||||
typeof parent?.attributes?.href === "string");
|
||||
const parsed = onInlinePathPress && !isLinkedInlineCode
|
||||
? parseInlinePathToken(content)
|
||||
: null;
|
||||
|
||||
@@ -803,30 +883,21 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
const inlineCodeLinkUrl = getInlineCodeAutoLinkUrl(markdownParser, content);
|
||||
if (inlineCodeLinkUrl) {
|
||||
return (
|
||||
<Text
|
||||
<MarkdownLink
|
||||
key={node.key}
|
||||
accessibilityRole="link"
|
||||
onPress={() => {
|
||||
handleLinkPress(inlineCodeLinkUrl);
|
||||
}}
|
||||
style={[
|
||||
inheritedStyles,
|
||||
assistantMessageStylesheet.markdownCodeInline,
|
||||
assistantMessageStylesheet.markdownCodeInlineLink,
|
||||
]}
|
||||
href={inlineCodeLinkUrl}
|
||||
style={[inheritedStyles, styles.code_inline, styles.link]}
|
||||
onPress={handleLinkPress}
|
||||
>
|
||||
{content}
|
||||
</Text>
|
||||
</MarkdownLink>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Text
|
||||
key={node.key}
|
||||
style={[
|
||||
inheritedStyles,
|
||||
assistantMessageStylesheet.markdownCodeInline,
|
||||
]}
|
||||
style={[inheritedStyles, styles.code_inline]}
|
||||
>
|
||||
{content}
|
||||
</Text>
|
||||
@@ -877,6 +948,41 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
</View>
|
||||
);
|
||||
},
|
||||
paragraph: (
|
||||
node: any,
|
||||
children: ReactNode[],
|
||||
parent: any,
|
||||
styles: any,
|
||||
) => {
|
||||
const isLastChild = parent[0]?.children?.at(-1)?.key === node.key;
|
||||
return (
|
||||
<View
|
||||
key={node.key}
|
||||
style={[styles.paragraph, isLastChild && { marginBottom: 0 }]}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
},
|
||||
link: (
|
||||
node: any,
|
||||
children: ReactNode[],
|
||||
_parent: any,
|
||||
styles: any,
|
||||
) => (
|
||||
<MarkdownLink
|
||||
key={node.key}
|
||||
href={node.attributes?.href ?? ""}
|
||||
style={styles.link}
|
||||
onPress={handleLinkPress}
|
||||
>
|
||||
{Children.map(children, (child) =>
|
||||
isValidElement(child)
|
||||
? cloneElement(child, { style: [(child.props as any).style, { color: styles.link.color }] } as any)
|
||||
: child
|
||||
)}
|
||||
</MarkdownLink>
|
||||
),
|
||||
};
|
||||
}, [handleLinkPress, markdownParser, onInlinePathPress]);
|
||||
|
||||
@@ -1511,12 +1617,23 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
[isExpanded, isInteractive]
|
||||
);
|
||||
|
||||
const isActive = isHovered || isExpanded;
|
||||
|
||||
const labelStyle = useMemo(
|
||||
() => [
|
||||
expandableBadgeStylesheet.label,
|
||||
isActive && expandableBadgeStylesheet.labelActive,
|
||||
isLoading && expandableBadgeStylesheet.labelLoading,
|
||||
],
|
||||
[isLoading]
|
||||
[isActive, isLoading]
|
||||
);
|
||||
|
||||
const secondaryLabelStyle = useMemo(
|
||||
() => [
|
||||
expandableBadgeStylesheet.secondaryLabel,
|
||||
isActive && expandableBadgeStylesheet.secondaryLabelActive,
|
||||
],
|
||||
[isActive]
|
||||
);
|
||||
|
||||
const shimmerLabelTextStyle = useMemo(
|
||||
@@ -1587,7 +1704,9 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
const IconComponent = icon;
|
||||
const iconColor = isError
|
||||
? theme.colors.destructive
|
||||
: theme.colors.mutedForeground;
|
||||
: isActive
|
||||
? theme.colors.foreground
|
||||
: theme.colors.mutedForeground;
|
||||
|
||||
let iconNode: ReactNode = null;
|
||||
if (isError) {
|
||||
@@ -1634,7 +1753,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
</Text>
|
||||
{secondaryLabel ? (
|
||||
<Text
|
||||
style={expandableBadgeStylesheet.secondaryLabel}
|
||||
style={secondaryLabelStyle}
|
||||
numberOfLines={1}
|
||||
onLayout={shouldMeasureWebShimmer ? handleSecondaryLayout : undefined}
|
||||
>
|
||||
@@ -1729,7 +1848,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
{isInteractive && isHovered ? (
|
||||
<ChevronRight
|
||||
size={14}
|
||||
color={theme.colors.foregroundMuted}
|
||||
color={theme.colors.foreground}
|
||||
style={chevronStyle}
|
||||
/>
|
||||
) : null}
|
||||
@@ -1844,29 +1963,42 @@ export const ToolCall = memo(function ToolCall({
|
||||
const summary = displayModel.summary;
|
||||
const errorText = displayModel.errorText;
|
||||
const IconComponent = resolveToolCallIcon(toolName, effectiveDetail);
|
||||
const isLoadingDetails = isPendingToolCallDetail({
|
||||
detail: effectiveDetail,
|
||||
status,
|
||||
error,
|
||||
});
|
||||
const secondaryLabel = summary;
|
||||
|
||||
// Check if there's any content to display
|
||||
const hasDetails =
|
||||
Boolean(error) ||
|
||||
(effectiveDetail
|
||||
? effectiveDetail.type !== "unknown" ||
|
||||
effectiveDetail.input !== null ||
|
||||
effectiveDetail.output !== null
|
||||
: false);
|
||||
hasMeaningfulToolCallDetail(effectiveDetail);
|
||||
const canOpenDetails = hasDetails || isLoadingDetails;
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
if (isMobile) {
|
||||
openToolCall({
|
||||
toolName,
|
||||
displayName,
|
||||
summary,
|
||||
summary: secondaryLabel,
|
||||
detail: effectiveDetail,
|
||||
errorText,
|
||||
showLoadingSkeleton: isLoadingDetails,
|
||||
});
|
||||
} else {
|
||||
setIsExpanded((prev) => !prev);
|
||||
}
|
||||
}, [isMobile, openToolCall, toolName, displayName, summary, effectiveDetail, errorText]);
|
||||
}, [
|
||||
isMobile,
|
||||
openToolCall,
|
||||
toolName,
|
||||
displayName,
|
||||
secondaryLabel,
|
||||
effectiveDetail,
|
||||
errorText,
|
||||
isLoadingDetails,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onInlineDetailsHoverChange || isMobile || isExpanded) {
|
||||
@@ -1903,19 +2035,20 @@ export const ToolCall = memo(function ToolCall({
|
||||
detail={effectiveDetail}
|
||||
errorText={errorText}
|
||||
maxHeight={400}
|
||||
showLoadingSkeleton={isLoadingDetails}
|
||||
/>
|
||||
);
|
||||
}, [isMobile, effectiveDetail, errorText]);
|
||||
}, [isMobile, effectiveDetail, errorText, isLoadingDetails]);
|
||||
|
||||
return (
|
||||
<ExpandableBadge
|
||||
testID="tool-call-badge"
|
||||
label={displayName}
|
||||
secondaryLabel={summary}
|
||||
secondaryLabel={secondaryLabel}
|
||||
icon={IconComponent}
|
||||
isExpanded={!isMobile && isExpanded}
|
||||
onToggle={hasDetails ? handleToggle : undefined}
|
||||
renderDetails={hasDetails && !isMobile ? renderDetails : undefined}
|
||||
onToggle={canOpenDetails ? handleToggle : undefined}
|
||||
renderDetails={canOpenDetails && !isMobile ? renderDetails : undefined}
|
||||
isLoading={status === "running" || status === "executing"}
|
||||
isError={status === "failed"}
|
||||
isLastInSequence={isLastInSequence}
|
||||
|
||||
@@ -11,21 +11,17 @@ import {
|
||||
import { Folder } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { router, usePathname } from "expo-router";
|
||||
import { usePathname } from "expo-router";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import {
|
||||
normalizeWorkspaceDescriptor,
|
||||
useSessionStore,
|
||||
} from "@/stores/session-store";
|
||||
import { useHosts, useHostRuntimeSession } from "@/runtime/host-runtime";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useHosts, useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useOpenProject } from "@/hooks/use-open-project";
|
||||
import { parseServerIdFromPathname } from "@/utils/host-routes";
|
||||
import { buildHostWorkspaceRouteWithOpenIntent } from "@/utils/host-routes";
|
||||
import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions";
|
||||
|
||||
export function ProjectPickerModal() {
|
||||
const { theme } = useUnistyles();
|
||||
const toast = useToast();
|
||||
const pathname = usePathname();
|
||||
const daemons = useHosts();
|
||||
|
||||
@@ -38,19 +34,17 @@ export function ProjectPickerModal() {
|
||||
return daemons[0]?.serverId ?? null;
|
||||
}, [pathname, daemons]);
|
||||
|
||||
const { client, isConnected } = useHostRuntimeSession(serverId ?? "");
|
||||
const client = useHostRuntimeClient(serverId ?? "");
|
||||
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
||||
const workspaces = useSessionStore((state) =>
|
||||
serverId ? state.sessions[serverId]?.workspaces : undefined
|
||||
);
|
||||
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
||||
const setHasHydratedWorkspaces = useSessionStore(
|
||||
(state) => state.setHasHydratedWorkspaces
|
||||
);
|
||||
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const openProject = useOpenProject(serverId);
|
||||
|
||||
const recommendedPaths = useMemo(() => {
|
||||
if (!workspaces) return [];
|
||||
@@ -101,31 +95,15 @@ export function ProjectPickerModal() {
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const payload = await client.openProject(trimmed);
|
||||
if (payload.error || !payload.workspace) {
|
||||
throw new Error(payload.error || "Failed to open project");
|
||||
const didOpenProject = await openProject(trimmed);
|
||||
if (didOpenProject) {
|
||||
setOpen(false);
|
||||
}
|
||||
mergeWorkspaces(serverId, [
|
||||
normalizeWorkspaceDescriptor(payload.workspace),
|
||||
]);
|
||||
setHasHydratedWorkspaces(serverId, true);
|
||||
setOpen(false);
|
||||
router.replace(
|
||||
buildHostWorkspaceRouteWithOpenIntent(
|
||||
serverId,
|
||||
payload.workspace.id,
|
||||
{ kind: "draft", draftId: "new" }
|
||||
) as any
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to open project"
|
||||
);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
},
|
||||
[client, mergeWorkspaces, serverId, setHasHydratedWorkspaces, setOpen, toast]
|
||||
[client, openProject, serverId, setOpen]
|
||||
);
|
||||
|
||||
const handleSubmitCustom = useCallback(() => {
|
||||
@@ -294,7 +272,7 @@ export function ProjectPickerModal() {
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{path}
|
||||
{shortenPath(path)}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
12
packages/app/src/components/provider-icons.ts
Normal file
12
packages/app/src/components/provider-icons.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Bot } from 'lucide-react-native'
|
||||
import { ClaudeIcon } from '@/components/icons/claude-icon'
|
||||
import { CodexIcon } from '@/components/icons/codex-icon'
|
||||
|
||||
const PROVIDER_ICONS: Record<string, typeof Bot> = {
|
||||
claude: ClaudeIcon as unknown as typeof Bot,
|
||||
codex: CodexIcon as unknown as typeof Bot,
|
||||
}
|
||||
|
||||
export function getProviderIcon(provider: string): typeof Bot {
|
||||
return PROVIDER_ICONS[provider] ?? Bot
|
||||
}
|
||||
@@ -2,13 +2,11 @@ import { ActivityIndicator, Pressable, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { Mic, MicOff, Square } from "lucide-react-native";
|
||||
import { FOOTER_HEIGHT } from "@/constants/layout";
|
||||
import { useVoiceTelemetry } from "@/contexts/voice-context";
|
||||
import { VolumeMeter } from "./volume-meter";
|
||||
|
||||
interface RealtimeVoiceOverlayProps {
|
||||
volume: number;
|
||||
isMuted: boolean;
|
||||
isDetecting: boolean;
|
||||
isSpeaking: boolean;
|
||||
isSwitching: boolean;
|
||||
onToggleMute: () => void;
|
||||
onStop: () => void;
|
||||
@@ -18,23 +16,19 @@ const OVERLAY_BUTTON_SIZE = 44;
|
||||
const OVERLAY_VERTICAL_PADDING = (FOOTER_HEIGHT - OVERLAY_BUTTON_SIZE) / 2;
|
||||
|
||||
export function RealtimeVoiceOverlay({
|
||||
volume,
|
||||
isMuted,
|
||||
isDetecting,
|
||||
isSpeaking,
|
||||
isSwitching,
|
||||
onToggleMute,
|
||||
onStop,
|
||||
}: RealtimeVoiceOverlayProps) {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
const { volume, isSpeaking } = useVoiceTelemetry();
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.meterContainer}>
|
||||
<VolumeMeter
|
||||
volume={volume}
|
||||
isMuted={isMuted}
|
||||
isDetecting={isDetecting}
|
||||
isSpeaking={isSpeaking}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
|
||||
190
packages/app/src/components/resize-handle.tsx
Normal file
190
packages/app/src/components/resize-handle.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
|
||||
export interface ResizeHandleProps {
|
||||
direction: "horizontal" | "vertical";
|
||||
groupId: string;
|
||||
index: number;
|
||||
sizes: number[];
|
||||
onResizeSplit: (groupId: string, sizes: number[]) => void;
|
||||
}
|
||||
|
||||
interface PointerState {
|
||||
containerSize: number;
|
||||
pointerStart: number;
|
||||
leftSize: number;
|
||||
rightSize: number;
|
||||
}
|
||||
|
||||
export function ResizeHandle({
|
||||
direction,
|
||||
groupId,
|
||||
index,
|
||||
sizes,
|
||||
onResizeSplit,
|
||||
}: ResizeHandleProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const pointerStateRef = useRef<PointerState | null>(null);
|
||||
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [active, setActive] = useState(false);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const highlighted = active || dragging;
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(event: any) => {
|
||||
const hitAreaElement = event.currentTarget as HTMLElement | null;
|
||||
const containerElement = hitAreaElement?.parentElement?.parentElement ?? null;
|
||||
if (!containerElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = containerElement.getBoundingClientRect();
|
||||
const containerSize = direction === "horizontal" ? rect.width : rect.height;
|
||||
if (containerSize <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDragging(true);
|
||||
|
||||
pointerStateRef.current = {
|
||||
containerSize,
|
||||
pointerStart: direction === "horizontal" ? event.clientX : event.clientY,
|
||||
leftSize: sizes[index] ?? 0,
|
||||
rightSize: sizes[index + 1] ?? 0,
|
||||
};
|
||||
|
||||
const previousCursor = document.body.style.cursor;
|
||||
const nextCursor = direction === "horizontal" ? "col-resize" : "row-resize";
|
||||
document.body.style.cursor = nextCursor;
|
||||
event.preventDefault();
|
||||
|
||||
function cleanup() {
|
||||
pointerStateRef.current = null;
|
||||
setDragging(false);
|
||||
document.body.style.cursor = previousCursor;
|
||||
window.removeEventListener("pointermove", handlePointerMove);
|
||||
window.removeEventListener("pointerup", handlePointerUp);
|
||||
}
|
||||
|
||||
function handlePointerMove(moveEvent: PointerEvent) {
|
||||
const pointerState = pointerStateRef.current;
|
||||
if (!pointerState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pointerCurrent =
|
||||
direction === "horizontal" ? moveEvent.clientX : moveEvent.clientY;
|
||||
const deltaRatio =
|
||||
(pointerCurrent - pointerState.pointerStart) / pointerState.containerSize;
|
||||
|
||||
const nextSizes = sizes.slice();
|
||||
nextSizes[index] = pointerState.leftSize + deltaRatio;
|
||||
nextSizes[index + 1] = pointerState.rightSize - deltaRatio;
|
||||
onResizeSplit(groupId, nextSizes);
|
||||
}
|
||||
|
||||
function handlePointerUp() {
|
||||
cleanup();
|
||||
}
|
||||
|
||||
window.addEventListener("pointermove", handlePointerMove);
|
||||
window.addEventListener("pointerup", handlePointerUp, { once: true });
|
||||
},
|
||||
[direction, groupId, index, onResizeSplit, sizes]
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.handle,
|
||||
direction === "horizontal" ? styles.handleHorizontal : styles.handleVertical,
|
||||
{ backgroundColor: theme.colors.border },
|
||||
]}
|
||||
>
|
||||
{highlighted && (
|
||||
<View
|
||||
pointerEvents="none"
|
||||
style={[
|
||||
styles.highlight,
|
||||
direction === "horizontal"
|
||||
? styles.highlightHorizontal
|
||||
: styles.highlightVertical,
|
||||
{ backgroundColor: theme.colors.accent },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<View
|
||||
role="separator"
|
||||
aria-orientation={direction === "horizontal" ? "vertical" : "horizontal"}
|
||||
style={[
|
||||
styles.hitArea,
|
||||
direction === "horizontal" ? styles.hitAreaHorizontal : styles.hitAreaVertical,
|
||||
{
|
||||
cursor: direction === "horizontal" ? "col-resize" : "row-resize",
|
||||
} as any,
|
||||
]}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerEnter={() => {
|
||||
hoverTimerRef.current = setTimeout(() => {
|
||||
setActive(true);
|
||||
}, 150);
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
if (hoverTimerRef.current) {
|
||||
clearTimeout(hoverTimerRef.current);
|
||||
hoverTimerRef.current = null;
|
||||
}
|
||||
setActive(false);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((_theme) => ({
|
||||
handle: {
|
||||
position: "relative",
|
||||
flexShrink: 0,
|
||||
},
|
||||
handleHorizontal: {
|
||||
width: 1,
|
||||
alignSelf: "stretch",
|
||||
},
|
||||
handleVertical: {
|
||||
height: 1,
|
||||
width: "100%",
|
||||
},
|
||||
highlight: {
|
||||
position: "absolute",
|
||||
zIndex: 5,
|
||||
},
|
||||
highlightHorizontal: {
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 3,
|
||||
left: -1,
|
||||
},
|
||||
highlightVertical: {
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 3,
|
||||
top: -1,
|
||||
},
|
||||
hitArea: {
|
||||
position: "absolute",
|
||||
zIndex: 10,
|
||||
},
|
||||
hitAreaHorizontal: {
|
||||
left: -5,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 10,
|
||||
},
|
||||
hitAreaVertical: {
|
||||
top: -5,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 10,
|
||||
},
|
||||
}));
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,9 @@ export function SortableInlineList<T>({
|
||||
onDragEnd?: (data: T[]) => void;
|
||||
useDragHandle?: boolean;
|
||||
disabled?: boolean;
|
||||
externalDndContext?: boolean;
|
||||
activeId?: string | null;
|
||||
getItemData?: (item: T, index: number) => Record<string, unknown>;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -33,6 +33,8 @@ function SortableItem<T>({
|
||||
activeId,
|
||||
useDragHandle,
|
||||
disabled,
|
||||
itemData,
|
||||
externalDndContext,
|
||||
}: {
|
||||
id: string;
|
||||
item: T;
|
||||
@@ -41,6 +43,8 @@ function SortableItem<T>({
|
||||
activeId: string | null;
|
||||
useDragHandle: boolean;
|
||||
disabled: boolean;
|
||||
itemData?: Record<string, unknown>;
|
||||
externalDndContext: boolean;
|
||||
}): ReactElement {
|
||||
const {
|
||||
attributes,
|
||||
@@ -50,24 +54,27 @@ function SortableItem<T>({
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id, disabled });
|
||||
} = useSortable({ id, disabled, data: itemData });
|
||||
|
||||
const drag = useCallback(() => {
|
||||
// dnd-kit handles drag initiation via listeners
|
||||
// This is a no-op but matches the mobile API
|
||||
}, []);
|
||||
|
||||
// See `draggable-list.web.tsx` for details on why we zero out dnd-kit scale.
|
||||
const baseTransform = CSS.Transform.toString(
|
||||
transform && isDragging ? { ...transform, scaleX: 1, scaleY: 1 } : transform
|
||||
);
|
||||
const scaleTransform = isDragging ? "scale(1.01)" : "";
|
||||
// External DnD contexts render their own insertion affordance, so keep the
|
||||
// tab row static and let the DragOverlay carry the moving chip.
|
||||
const baseTransform = externalDndContext
|
||||
? undefined
|
||||
: CSS.Transform.toString(
|
||||
transform && isDragging ? { ...transform, scaleX: 1, scaleY: 1 } : transform
|
||||
);
|
||||
const scaleTransform = !externalDndContext && isDragging ? "scale(1.01)" : "";
|
||||
const combinedTransform = [baseTransform, scaleTransform].filter(Boolean).join(" ");
|
||||
|
||||
const style = {
|
||||
transform: combinedTransform || undefined,
|
||||
transition,
|
||||
opacity: isDragging ? 0.9 : 1,
|
||||
opacity: externalDndContext && isDragging ? 0.3 : isDragging ? 0.9 : 1,
|
||||
zIndex: isDragging ? 1000 : 1,
|
||||
};
|
||||
|
||||
@@ -107,6 +114,9 @@ export function SortableInlineList<T>({
|
||||
disabled = false,
|
||||
activationDistance = 8,
|
||||
onDragBegin,
|
||||
externalDndContext = false,
|
||||
activeId: externalActiveId = null,
|
||||
getItemData,
|
||||
}: {
|
||||
data: T[];
|
||||
keyExtractor: (item: T, index: number) => string;
|
||||
@@ -116,10 +126,13 @@ export function SortableInlineList<T>({
|
||||
disabled?: boolean;
|
||||
activationDistance?: number;
|
||||
onDragBegin?: () => void;
|
||||
externalDndContext?: boolean;
|
||||
activeId?: string | null;
|
||||
getItemData?: (item: T, index: number) => Record<string, unknown>;
|
||||
}): ReactElement {
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [dragItems, setDragItems] = useState<T[] | null>(null);
|
||||
const items = dragItems ?? data;
|
||||
const items = externalDndContext ? data : dragItems ?? data;
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
@@ -175,6 +188,32 @@ export function SortableInlineList<T>({
|
||||
|
||||
const ids = items.map((item, index) => keyExtractor(item, index));
|
||||
|
||||
const renderedItems = (
|
||||
<SortableContext items={ids} strategy={horizontalListSortingStrategy}>
|
||||
{items.map((item, index) => {
|
||||
const id = keyExtractor(item, index);
|
||||
return (
|
||||
<SortableItem
|
||||
key={id}
|
||||
id={id}
|
||||
item={item}
|
||||
index={index}
|
||||
renderItem={renderItem}
|
||||
activeId={externalDndContext ? externalActiveId : activeId}
|
||||
useDragHandle={useDragHandle}
|
||||
disabled={disabled}
|
||||
itemData={getItemData?.(item, index)}
|
||||
externalDndContext={externalDndContext}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
);
|
||||
|
||||
if (externalDndContext) {
|
||||
return renderedItems;
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
@@ -183,23 +222,7 @@ export function SortableInlineList<T>({
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={ids} strategy={horizontalListSortingStrategy}>
|
||||
{items.map((item, index) => {
|
||||
const id = keyExtractor(item, index);
|
||||
return (
|
||||
<SortableItem
|
||||
key={id}
|
||||
id={id}
|
||||
item={item}
|
||||
index={index}
|
||||
renderItem={renderItem}
|
||||
activeId={activeId}
|
||||
useDragHandle={useDragHandle}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
{renderedItems}
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user