mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
169 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e969f42c68 | ||
|
|
876c08ebc2 | ||
|
|
8ba5df358a | ||
|
|
771acd11a1 | ||
|
|
d4735edd45 | ||
|
|
e36784e510 | ||
|
|
a237285808 | ||
|
|
ee6d8072ed | ||
|
|
c8d259b1df | ||
|
|
2da56634a1 | ||
|
|
27be8c3b1e | ||
|
|
988c432ded | ||
|
|
dce4e668f1 | ||
|
|
2c69008a4a | ||
|
|
19452c2742 | ||
|
|
956828fa55 | ||
|
|
b873523eab | ||
|
|
b862f7b72d | ||
|
|
af0f629073 | ||
|
|
a4f433195d | ||
|
|
12ea75ab65 | ||
|
|
a6f24508e8 | ||
|
|
d562fd40c0 | ||
|
|
cded2c52ab | ||
|
|
df99442123 | ||
|
|
b9b1601bb8 | ||
|
|
5efac0dfaf | ||
|
|
ea41dbc8e7 | ||
|
|
c9ca1697e5 | ||
|
|
ed3ed78ec3 | ||
|
|
37aaa7b155 | ||
|
|
4b4e55a246 | ||
|
|
d6977352aa | ||
|
|
87145f1e6a | ||
|
|
7f9dafab68 | ||
|
|
07720f1d99 | ||
|
|
c0e63f76da | ||
|
|
93f4b84b8b | ||
|
|
804d5d2487 | ||
|
|
babc724220 | ||
|
|
138c774297 | ||
|
|
50ea5a76fb | ||
|
|
54282409bb | ||
|
|
f5b66f4745 | ||
|
|
907074a454 | ||
|
|
1f764d418c | ||
|
|
e2248015aa | ||
|
|
1e5b809b70 | ||
|
|
d79ea8b15c | ||
|
|
c60ea67a9b | ||
|
|
4819011c97 | ||
|
|
9be754cdbf | ||
|
|
35ed62ad0f | ||
|
|
d705d68f2f | ||
|
|
43a1f46047 | ||
|
|
1911946ed1 | ||
|
|
1572c95b4d | ||
|
|
cc80d05e49 | ||
|
|
83b681cebb | ||
|
|
57f8c7615c | ||
|
|
d9bcf2a439 | ||
|
|
051868c74c | ||
|
|
ffee4a472a | ||
|
|
00a2eec5f6 | ||
|
|
859d234543 | ||
|
|
207bdd1fae | ||
|
|
d102a8fd64 | ||
|
|
fcbdb09796 | ||
|
|
59f3f4737a | ||
|
|
c80c3e0256 | ||
|
|
ad1cb4c200 | ||
|
|
68ab7b81a1 | ||
|
|
f1cf4ebe60 | ||
|
|
0a452fad04 | ||
|
|
eb618638d2 | ||
|
|
7b8a2fbcef | ||
|
|
928169a4f2 | ||
|
|
0af666e3ac | ||
|
|
d41808efe1 | ||
|
|
aec28c8b67 | ||
|
|
3b5daa1451 | ||
|
|
f6579dbeb4 | ||
|
|
ab40786882 | ||
|
|
5f065c2685 | ||
|
|
6b50ace2bd | ||
|
|
c46c03158e | ||
|
|
7395fc6fd1 | ||
|
|
c35d649f6d | ||
|
|
60e833245d | ||
|
|
f424503234 | ||
|
|
1d42542514 | ||
|
|
d1314a4d5d | ||
|
|
48c3308c31 | ||
|
|
ba149330b1 | ||
|
|
6731879931 | ||
|
|
b15159caeb | ||
|
|
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 |
15
.github/workflows/android-apk-release.yml
vendored
15
.github/workflows/android-apk-release.yml
vendored
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
- "android-v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
@@ -16,7 +17,7 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
|
||||
SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
|
||||
|
||||
jobs:
|
||||
publish-android-apk:
|
||||
@@ -31,6 +32,18 @@ jobs:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||
|
||||
- name: Resolve release tag
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source_tag="${SOURCE_TAG}"
|
||||
if [[ "$source_tag" =~ ^(android-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
|
||||
release_tag="v${BASH_REMATCH[2]}"
|
||||
else
|
||||
release_tag="$source_tag"
|
||||
fi
|
||||
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
|
||||
505
.github/workflows/desktop-release.yml
vendored
505
.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
|
||||
@@ -75,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:
|
||||
@@ -162,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 }}
|
||||
EP_GH_IGNORE_TIME: true
|
||||
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
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'))) }}
|
||||
@@ -267,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'
|
||||
@@ -359,97 +224,31 @@ 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 Linux Tauri release
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
id: linux_tauri
|
||||
continue-on-error: true
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
NO_STRIP: "1"
|
||||
APPIMAGE_EXTRACT_AND_RUN: "1"
|
||||
- name: Build desktop release
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm run tauri --workspace=@getpaseo/desktop build -- --bundles appimage
|
||||
|
||||
- name: Attempt manual Linux AppImage fallback
|
||||
if: env.IS_SMOKE_TAG != 'true' && steps.linux_tauri.outcome == 'failure'
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
appimage_dir="packages/desktop/src-tauri/target/release/bundle/appimage"
|
||||
appdir_path="$appimage_dir/Paseo.AppDir"
|
||||
canonical_appimage="$appimage_dir/Paseo_${DESKTOP_VERSION}_amd64.AppImage"
|
||||
existing_appimage="$(find "$appimage_dir" -maxdepth 1 -type f -name '*.AppImage' | head -n 1)"
|
||||
if [ -n "$existing_appimage" ]; then
|
||||
if [ "$existing_appimage" != "$canonical_appimage" ]; then
|
||||
mv "$existing_appimage" "$canonical_appimage"
|
||||
fi
|
||||
if [ ! -f "$canonical_appimage.sig" ]; then
|
||||
npx tauri signer sign "$canonical_appimage"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
if [ ! -d "$appdir_path" ]; then
|
||||
echo "::error::AppDir was not generated at $appdir_path"
|
||||
exit 1
|
||||
fi
|
||||
cp --remove-destination "$appdir_path/usr/share/applications/Paseo.desktop" "$appdir_path/Paseo.desktop"
|
||||
cp --remove-destination "$appdir_path/Paseo.png" "$appdir_path/.DirIcon"
|
||||
env | sort | grep -E '^(APPIMAGE|DESKTOP_VERSION|NO_STRIP|RELEASE_TAG|SOURCE_TAG|TAURI_)' || true
|
||||
tools_dir="$(mktemp -d)"
|
||||
curl -fsSL https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage -o "$tools_dir/appimagetool-x86_64.AppImage"
|
||||
chmod +x "$tools_dir/appimagetool-x86_64.AppImage"
|
||||
ARCH=x86_64 APPIMAGE_EXTRACT_AND_RUN=1 "$tools_dir/appimagetool-x86_64.AppImage" "$appdir_path" "$canonical_appimage"
|
||||
npx tauri signer sign "$canonical_appimage"
|
||||
|
||||
- name: Fail Linux release when AppImage bundling fails
|
||||
if: env.IS_SMOKE_TAG != 'true' && steps.linux_tauri.outcome == 'failure'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
assets=(
|
||||
packages/desktop/src-tauri/target/release/bundle/appimage/*.AppImage
|
||||
packages/desktop/src-tauri/target/release/bundle/appimage/*.AppImage.sig
|
||||
)
|
||||
if [ "${#assets[@]}" -eq 0 ]; then
|
||||
echo "::error::Linux AppImage assets are still missing after the manual fallback."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload Linux release assets
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
EP_GH_IGNORE_TIME: true
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
assets=(
|
||||
packages/desktop/src-tauri/target/release/bundle/appimage/*.AppImage
|
||||
packages/desktop/src-tauri/target/release/bundle/appimage/*.AppImage.sig
|
||||
)
|
||||
if [ "${#assets[@]}" -eq 0 ]; then
|
||||
echo "::error::No Linux AppImage assets were produced."
|
||||
exit 1
|
||||
publish_mode="never"
|
||||
publish_args=()
|
||||
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
|
||||
publish_mode="always"
|
||||
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||
fi
|
||||
printf 'Uploading Linux assets:\n%s\n' "${assets[@]}"
|
||||
gh release upload "$RELEASE_TAG" "${assets[@]}" --repo "${{ github.repository }}" --clobber
|
||||
|
||||
- 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'))) }}
|
||||
@@ -485,75 +284,46 @@ 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
|
||||
- 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 workspace dependencies
|
||||
run: npm run build:workspace-deps --workspace=@getpaseo/app
|
||||
|
||||
- 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: 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
|
||||
npx expo export --platform web
|
||||
working-directory: packages/app
|
||||
|
||||
- name: Detect existing GitHub release state
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
@@ -563,31 +333,28 @@ 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 }}
|
||||
EP_GH_IGNORE_TIME: true
|
||||
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
.gitignore
vendored
3
.gitignore
vendored
@@ -46,6 +46,9 @@ test-results/
|
||||
# Vercel
|
||||
.vercel/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
|
||||
# Misc
|
||||
*.pem
|
||||
.vercel
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
|
||||
# Build outputs
|
||||
dist
|
||||
.next
|
||||
.expo
|
||||
build
|
||||
*.tsbuildinfo
|
||||
|
||||
# Coverage
|
||||
coverage
|
||||
|
||||
# Lock files
|
||||
*.lock
|
||||
package-lock.json
|
||||
|
||||
# Generated
|
||||
android
|
||||
ios
|
||||
.turbo
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5",
|
||||
"tabWidth": 2,
|
||||
"printWidth": 100
|
||||
}
|
||||
62
CHANGELOG.md
62
CHANGELOG.md
@@ -1,5 +1,61 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.33 - 2026-03-23
|
||||
|
||||
### Fixed
|
||||
- Fixed the desktop app failing to reopen after closing on macOS — the daemon and agent processes were registering with Launch Services as instances of the main app, blocking subsequent launches.
|
||||
- Fixed dictation not working in the packaged desktop app — the microphone entitlement was missing from the hardened runtime configuration.
|
||||
- Fixed leaked Claude Code child processes when agents were closed — the SDK query stream was not being properly shut down.
|
||||
- The notification test button now surfaces errors instead of failing silently.
|
||||
|
||||
## 0.1.32 - 2026-03-23
|
||||
|
||||
### Added
|
||||
- Fully rebindable keyboard shortcuts with chord support — all shortcuts are now declarative with proper Cmd (Mac) vs Ctrl (Windows/Linux) separation.
|
||||
- Migrated the desktop app from Tauri to Electron, with macOS notarization, code signing, and Linux Wayland support.
|
||||
- Added line numbers and word-wrap toggle to file previews.
|
||||
- Added an archived agent callout with an unarchive button so you can restore agents directly from the chat view.
|
||||
- Added workspace kind indicators in the sidebar (e.g. worktree vs standalone).
|
||||
- Expanded diff syntax highlighting to cover more languages.
|
||||
- Added status bar tooltips for project and agent status.
|
||||
|
||||
### Improved
|
||||
- Redesigned the mobile tab switcher as a compact header row with quick access to new agents and terminals.
|
||||
- Streamlined workspace creation — worktrees are now created inline with a single action instead of a multi-step flow.
|
||||
- Agent history now streams from disk on reconnect, so you see past messages immediately instead of a blank screen.
|
||||
- Automatic cleanup of stale workspaces: deleted worktree directories and fully-archived workspaces are pruned automatically.
|
||||
- After archiving a workspace, the app now redirects to the next available workspace instead of leaving you on a dead screen.
|
||||
- Reopening an archived agent tab now keeps it open instead of collapsing back to archived state.
|
||||
- Reduced unnecessary re-renders across the workspace screen, sidebar, and agent list for smoother scrolling and interaction.
|
||||
- Agent list no longer refreshes in the background when the screen is unfocused, saving resources.
|
||||
- Desktop key repeat now works correctly on macOS.
|
||||
- Desktop notifications on macOS are more reliable.
|
||||
- Daemon startup no longer blocks on model downloads.
|
||||
- Better error messages from the daemon — RPC errors now include the actual underlying details.
|
||||
|
||||
### Fixed
|
||||
- Fixed user messages appearing as assistant output in the timeline when messages contained structured content blocks.
|
||||
- Fixed archived workspace routing so navigating to an archived session no longer breaks the app.
|
||||
- Fixed Linux AppImage failing to launch on Wayland-only desktops.
|
||||
- Fixed desktop window drag coordinates being applied when they shouldn't be.
|
||||
|
||||
## 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
|
||||
@@ -149,7 +205,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
|
||||
@@ -171,7 +227,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
|
||||
@@ -210,7 +266,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
|
||||
|
||||
@@ -44,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`)
|
||||
|
||||
|
||||
32
biome.json
Normal file
32
biome.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"includes": ["**", "!*.lock"]
|
||||
},
|
||||
"formatter": {
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 100
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double",
|
||||
"trailingCommas": "all",
|
||||
"semicolons": "always"
|
||||
}
|
||||
},
|
||||
"css": {
|
||||
"parser": {
|
||||
"cssModules": true,
|
||||
"tailwindDirectives": true
|
||||
}
|
||||
},
|
||||
"linter": {
|
||||
"enabled": false
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
74
docs/PRODUCT.md
Normal file
74
docs/PRODUCT.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# Product
|
||||
|
||||
What Paseo is, who it's for, and where it's going.
|
||||
|
||||
## What is Paseo
|
||||
|
||||
Paseo is a next-generation development environment built around agents. One interface to run, monitor, and interact with coding agents across desktop, mobile, terminal, and web.
|
||||
|
||||
The development workflow is shifting from manually editing files to orchestrating agents that do the editing. Paseo is built for that workflow.
|
||||
|
||||
## Core philosophy
|
||||
|
||||
Freedom and flexibility. Every design decision follows from this:
|
||||
|
||||
- **Multi-provider** — Use any coding agent harness. Pick the right model for each job, switch freely as the landscape shifts. No vendor-lock in.
|
||||
- **Cross-device** — Desktop, mobile, web, CLI. Start work at your desk, check progress from your phone, script from the terminal.
|
||||
- **Self-hosted** — The daemon runs on your machine. Your code, your keys, your environment. No inference markup, no cloud dependency.
|
||||
- **Respectful** - No telemetry, no forced cloud, no forced accounts
|
||||
- **Open source** — AGPL-3.0. Users can inspect, fork, and contribute.
|
||||
- **BYOK** — Bring your own keys. Use your subsidized plans and first-party provider pricing. Paseo adds zero cost on top.
|
||||
|
||||
## How it works
|
||||
|
||||
### Projects and workspaces
|
||||
|
||||
Projects are grouped in the sidebar, detected automatically from your filesystem and tagged by git remote when available.
|
||||
|
||||
Each project opens as a workspace. For git projects, the default workspace is the main checkout. Users can create additional workspaces, which are isolated copies (git worktrees) where agents work without affecting main.
|
||||
|
||||
### Inside a workspace
|
||||
|
||||
A workspace is a flexible canvas:
|
||||
- Launch multiple agents side by side in split panes
|
||||
- Open terminals alongside agents
|
||||
- Mix and match providers within the same workspace
|
||||
|
||||
### The daemon
|
||||
|
||||
Paseo is a client-server system. The daemon (Node.js) runs on your machine, manages agent processes, and streams output in real time over WebSocket. Clients connect to the daemon — locally or remotely.
|
||||
|
||||
This architecture means:
|
||||
- The daemon can run on any machine: laptop, VM, remote server
|
||||
- Multiple clients can connect simultaneously
|
||||
- Agents keep running when you close the app
|
||||
|
||||
## Target user
|
||||
|
||||
Anyone who builds software:
|
||||
|
||||
- Care about owning their tools and their data
|
||||
- Use multiple AI providers and want to switch freely
|
||||
- Run agents on real tasks across real projects
|
||||
- Want to work from multiple devices
|
||||
|
||||
## What compounds over time
|
||||
|
||||
- **Trust** — Showing up daily, shipping in public, being open source. Earned slowly, lost quickly.
|
||||
- **Community contributions** — Code, packaging, skills, agent configs. Contributors become advocates.
|
||||
- **Ecosystem** — Skills, integrations, shared configs. Community-built content that makes the platform more valuable.
|
||||
|
||||
## Strategic bets
|
||||
|
||||
1. **Models commoditize.** Value moves to the orchestration layer. The best model changes monthly — the workflow layer stays.
|
||||
2. **Multi-provider wins.** No single provider stays on top. Developers want the best model for each task.
|
||||
3. **The daemon as infrastructure.** Server/client architecture enables deployment anywhere.
|
||||
4. **Open source outlasts funding.** Open source communities are resilient. Contributors become advocates.
|
||||
|
||||
## Current state (March 2026)
|
||||
|
||||
- Desktop (Electron), mobile (iOS/Android), web, CLI
|
||||
- Providers: Claude Code (Agent SDK), Codex (app-server), OpenCode
|
||||
- Daily releases
|
||||
- Community contributions starting (packaging, bug fixes)
|
||||
- Key UX: split panes, keybinding customization, workspace model
|
||||
@@ -31,11 +31,36 @@ 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.
|
||||
|
||||
**NEVER use `workflow_dispatch` to retry release builds.** The `workflow_dispatch` trigger runs the workflow file from the default branch but checks out the code at the tag ref (`ref: ${{ inputs.tag }}`). This means build fixes committed to `main` won't be picked up — the old broken code at the tag gets built again.
|
||||
|
||||
To retry a failed workflow, **always push a retry tag** on the commit you want to build:
|
||||
|
||||
```bash
|
||||
# Desktop (all platforms)
|
||||
git tag -f desktop-v0.1.28 HEAD && git push origin desktop-v0.1.28 --force
|
||||
|
||||
# Desktop (single platform)
|
||||
git tag -f desktop-macos-v0.1.28 HEAD && git push origin desktop-macos-v0.1.28 --force
|
||||
git tag -f desktop-linux-v0.1.28 HEAD && git push origin desktop-linux-v0.1.28 --force
|
||||
git tag -f desktop-windows-v0.1.28 HEAD && git push origin desktop-windows-v0.1.28 --force
|
||||
|
||||
# Android APK
|
||||
git tag -f android-v0.1.28 HEAD && git push origin android-v0.1.28 --force
|
||||
```
|
||||
|
||||
This ensures the checkout ref matches the actual code on `main` with the fix included.
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
4226
package-lock.json
generated
4226
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
23
package.json
23
package.json
@@ -1,9 +1,10 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.28",
|
||||
"version": "0.1.33",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/expo-two-way-audio",
|
||||
"packages/highlight",
|
||||
"packages/server",
|
||||
"packages/app",
|
||||
"packages/relay",
|
||||
@@ -13,17 +14,18 @@
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "./scripts/dev.sh",
|
||||
"dev:server": "NODE_ENV=development tsx packages/server/scripts/daemon-runner.ts --dev",
|
||||
"dev:server": "npm run dev --workspace=@getpaseo/server",
|
||||
"dev:app": "npm run start --workspace=@getpaseo/app",
|
||||
"dev:website": "npm run dev --workspace=@getpaseo/website",
|
||||
"postinstall": "node scripts/postinstall-patches.mjs",
|
||||
"build": "npm run build --workspaces --if-present",
|
||||
"build:daemon": "npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli",
|
||||
"build:highlight": "npm run build --workspace=@getpaseo/highlight",
|
||||
"build:daemon": "npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli",
|
||||
"typecheck": "npm run typecheck --workspaces --if-present",
|
||||
"typecheck:daemon": "npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli",
|
||||
"test": "npm run test --workspaces --if-present",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"format": "biome format --write .",
|
||||
"format:check": "biome format .",
|
||||
"start": "npm run start --workspace=@getpaseo/server",
|
||||
"android": "npm run android --workspace=@getpaseo/app",
|
||||
"android:development": "npm run android:development --workspace=@getpaseo/app",
|
||||
@@ -42,9 +44,9 @@
|
||||
"version:all:patch": "npm version patch --include-workspace-root --message \"chore(release): cut %s\"",
|
||||
"version:all:minor": "npm version minor --include-workspace-root --message \"chore(release): cut %s\"",
|
||||
"version:all:major": "npm version major --include-workspace-root --message \"chore(release): cut %s\"",
|
||||
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
|
||||
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
|
||||
"release:publish": "npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
|
||||
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
|
||||
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
|
||||
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
|
||||
"release:push": "node scripts/push-current-release-tag.mjs",
|
||||
"draft-release:push": "node scripts/push-current-release-tag.mjs --draft-release",
|
||||
"draft-release:patch": "npm run version:all:patch && npm run release:check && npm run draft-release:push",
|
||||
@@ -56,8 +58,8 @@
|
||||
"release:major": "npm run version:all:major && npm run release:check && npm run release:publish && npm run release:push"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.8",
|
||||
"concurrently": "^9.2.1",
|
||||
"prettier": "^3.5.3",
|
||||
"get-port-cli": "^3.0.0",
|
||||
"knip": "^5.82.1",
|
||||
"patch-package": "^8.0.1",
|
||||
@@ -82,6 +84,7 @@
|
||||
"react-dom": "19.1.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11"
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@modelcontextprotocol/sdk": "^1.27.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,8 +65,7 @@ export default {
|
||||
ios: {
|
||||
supportsTablet: true,
|
||||
infoPlist: {
|
||||
NSMicrophoneUsageDescription:
|
||||
"This app needs access to the microphone for voice commands.",
|
||||
NSMicrophoneUsageDescription: "This app needs access to the microphone for voice commands.",
|
||||
ITSAppUsesNonExemptEncryption: false,
|
||||
},
|
||||
bundleIdentifier: variant.packageId,
|
||||
@@ -92,9 +91,7 @@ export default {
|
||||
"android.permission.CAMERA",
|
||||
],
|
||||
package: variant.packageId,
|
||||
...(variant.googleServicesFile
|
||||
? { googleServicesFile: variant.googleServicesFile }
|
||||
: {}),
|
||||
...(variant.googleServicesFile ? { googleServicesFile: variant.googleServicesFile } : {}),
|
||||
},
|
||||
web: {
|
||||
output: "single",
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import { test as base, expect, type Page } from '@playwright/test';
|
||||
import {
|
||||
buildCreateAgentPreferences,
|
||||
buildSeededHost,
|
||||
} from './helpers/daemon-registry';
|
||||
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({
|
||||
baseURL: async ({}, use) => {
|
||||
const metroPort = process.env.E2E_METRO_PORT;
|
||||
if (!metroPort) {
|
||||
throw new Error('E2E_METRO_PORT not set - globalSetup must run first');
|
||||
throw new Error("E2E_METRO_PORT not set - globalSetup must run first");
|
||||
}
|
||||
await use(`http://localhost:${metroPort}`);
|
||||
},
|
||||
@@ -22,19 +19,19 @@ test.beforeEach(async ({ page }) => {
|
||||
const metroPort = process.env.E2E_METRO_PORT;
|
||||
if (!daemonPort) {
|
||||
throw new Error(
|
||||
'E2E_DAEMON_PORT is not set. Refusing to run e2e against the default daemon (e.g. localhost:6767). ' +
|
||||
'Ensure Playwright `globalSetup` starts the e2e daemon and exports E2E_DAEMON_PORT.'
|
||||
"E2E_DAEMON_PORT is not set. Refusing to run e2e against the default daemon (e.g. localhost:6767). " +
|
||||
"Ensure Playwright `globalSetup` starts the e2e daemon and exports E2E_DAEMON_PORT.",
|
||||
);
|
||||
}
|
||||
if (daemonPort === '6767') {
|
||||
if (daemonPort === "6767") {
|
||||
throw new Error(
|
||||
'E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon. ' +
|
||||
'Fix Playwright globalSetup to start an isolated test daemon and export its port.'
|
||||
"E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon. " +
|
||||
"Fix Playwright globalSetup to start an isolated test daemon and export its port.",
|
||||
);
|
||||
}
|
||||
if (!metroPort) {
|
||||
throw new Error(
|
||||
'E2E_METRO_PORT is not set. Ensure Playwright `globalSetup` starts Metro and exports E2E_METRO_PORT.'
|
||||
"E2E_METRO_PORT is not set. Ensure Playwright `globalSetup` starts Metro and exports E2E_METRO_PORT.",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,17 +39,17 @@ test.beforeEach(async ({ page }) => {
|
||||
// This blocks both HTTP and WS attempts to :6767 (before any navigation).
|
||||
await page.route(/:(6767)\b/, (route) => route.abort());
|
||||
await page.routeWebSocket(/:(6767)\b/, async (ws) => {
|
||||
await ws.close({ code: 1008, reason: 'Blocked connection to localhost:6767 during e2e.' });
|
||||
await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." });
|
||||
});
|
||||
|
||||
const entries: string[] = [];
|
||||
consoleEntries.set(page, entries);
|
||||
|
||||
page.on('console', (message) => {
|
||||
page.on("console", (message) => {
|
||||
entries.push(`[console:${message.type()}] ${message.text()}`);
|
||||
});
|
||||
|
||||
page.on('pageerror', (error) => {
|
||||
page.on("pageerror", (error) => {
|
||||
entries.push(`[pageerror] ${error.message}`);
|
||||
});
|
||||
|
||||
@@ -60,7 +57,7 @@ test.beforeEach(async ({ page }) => {
|
||||
const seedNonce = Math.random().toString(36).slice(2);
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error('E2E_SERVER_ID is not set - expected from Playwright globalSetup.');
|
||||
throw new Error("E2E_SERVER_ID is not set - expected from Playwright globalSetup.");
|
||||
}
|
||||
const testDaemon = buildSeededHost({
|
||||
serverId,
|
||||
@@ -74,7 +71,7 @@ test.beforeEach(async ({ page }) => {
|
||||
// `addInitScript` runs on every navigation (including reloads). Some tests intentionally
|
||||
// override storage and reload; they can opt out of seeding for the *next* navigation by
|
||||
// setting this flag before the reload.
|
||||
const disableOnceKey = '@paseo:e2e-disable-default-seed-once';
|
||||
const disableOnceKey = "@paseo:e2e-disable-default-seed-once";
|
||||
const disableValue = localStorage.getItem(disableOnceKey);
|
||||
if (disableValue) {
|
||||
localStorage.removeItem(disableOnceKey);
|
||||
@@ -83,15 +80,15 @@ test.beforeEach(async ({ page }) => {
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.setItem('@paseo:e2e', '1');
|
||||
localStorage.setItem('@paseo:e2e-seed-nonce', seedNonce);
|
||||
localStorage.setItem("@paseo:e2e", "1");
|
||||
localStorage.setItem("@paseo:e2e-seed-nonce", seedNonce);
|
||||
|
||||
// Hard-reset anything that could point to a developer's real daemon.
|
||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
|
||||
localStorage.removeItem('@paseo:settings');
|
||||
localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(preferences));
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
|
||||
localStorage.removeItem("@paseo:settings");
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||
},
|
||||
{ daemon: testDaemon, preferences: createAgentPreferences, seedNonce }
|
||||
{ daemon: testDaemon, preferences: createAgentPreferences, seedNonce },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -105,9 +102,9 @@ test.afterEach(async ({ page }, testInfo) => {
|
||||
return;
|
||||
}
|
||||
|
||||
await testInfo.attach('browser-console', {
|
||||
body: entries.join('\n'),
|
||||
contentType: 'text/plain',
|
||||
await testInfo.attach("browser-console", {
|
||||
body: entries.join("\n"),
|
||||
contentType: "text/plain",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { spawn, type ChildProcess, execSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import net from 'node:net';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import dotenv from 'dotenv';
|
||||
import { spawn, type ChildProcess, execSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import net from "node:net";
|
||||
import { Buffer } from "node:buffer";
|
||||
import dotenv from "dotenv";
|
||||
|
||||
type WaitForServerOptions = {
|
||||
host?: string;
|
||||
@@ -18,11 +18,11 @@ type WaitForServerOptions = {
|
||||
async function getAvailablePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.once('error', reject);
|
||||
server.once("error", reject);
|
||||
server.listen(0, () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
server.close(() => reject(new Error('Failed to acquire port')));
|
||||
if (!address || typeof address === "string") {
|
||||
server.close(() => reject(new Error("Failed to acquire port")));
|
||||
return;
|
||||
}
|
||||
server.close(() => resolve(address.port));
|
||||
@@ -40,18 +40,18 @@ function createLineBuffer(maxLines = 120): { add: (line: string) => void; dump:
|
||||
}
|
||||
},
|
||||
dump() {
|
||||
return lines.join('\n');
|
||||
return lines.join("\n");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function formatRecentOutput(getRecentOutput?: () => string): string {
|
||||
if (!getRecentOutput) {
|
||||
return '';
|
||||
return "";
|
||||
}
|
||||
const output = getRecentOutput().trim();
|
||||
if (!output) {
|
||||
return '';
|
||||
return "";
|
||||
}
|
||||
return `\nRecent output:\n${output}`;
|
||||
}
|
||||
@@ -61,21 +61,15 @@ function sleep(ms: number): Promise<void> {
|
||||
}
|
||||
|
||||
async function waitForServer(port: number, options: WaitForServerOptions): Promise<void> {
|
||||
const {
|
||||
host = '127.0.0.1',
|
||||
timeoutMs = 15000,
|
||||
label,
|
||||
childProcess,
|
||||
getRecentOutput,
|
||||
} = options;
|
||||
const { host = "127.0.0.1", timeoutMs = 15000, label, childProcess, getRecentOutput } = options;
|
||||
const start = Date.now();
|
||||
let lastConnectionError: unknown = null;
|
||||
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (childProcess && childProcess.exitCode !== null) {
|
||||
const signal = childProcess.signalCode ? `, signal ${childProcess.signalCode}` : '';
|
||||
const signal = childProcess.signalCode ? `, signal ${childProcess.signalCode}` : "";
|
||||
throw new Error(
|
||||
`${label} exited before listening on ${host}:${port} (exit code ${childProcess.exitCode}${signal}).${formatRecentOutput(getRecentOutput)}`
|
||||
`${label} exited before listening on ${host}:${port} (exit code ${childProcess.exitCode}${signal}).${formatRecentOutput(getRecentOutput)}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -89,7 +83,7 @@ async function waitForServer(port: number, options: WaitForServerOptions): Promi
|
||||
socket.destroy();
|
||||
reject(new Error(`Connection timed out to ${host}:${port}`));
|
||||
});
|
||||
socket.on('error', reject);
|
||||
socket.on("error", reject);
|
||||
});
|
||||
return;
|
||||
} catch (error) {
|
||||
@@ -99,9 +93,11 @@ async function waitForServer(port: number, options: WaitForServerOptions): Promi
|
||||
}
|
||||
|
||||
const reason =
|
||||
lastConnectionError instanceof Error ? ` Last connection error: ${lastConnectionError.message}` : '';
|
||||
lastConnectionError instanceof Error
|
||||
? ` Last connection error: ${lastConnectionError.message}`
|
||||
: "";
|
||||
throw new Error(
|
||||
`${label} did not start on ${host}:${port} within ${timeoutMs}ms.${reason}${formatRecentOutput(getRecentOutput)}`
|
||||
`${label} did not start on ${host}:${port} within ${timeoutMs}ms.${reason}${formatRecentOutput(getRecentOutput)}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -126,15 +122,15 @@ async function stopProcess(child: ChildProcess | null): Promise<void> {
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
return;
|
||||
}
|
||||
child.kill('SIGTERM');
|
||||
child.kill("SIGTERM");
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
child.kill('SIGKILL');
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
resolve();
|
||||
}, 5000);
|
||||
child.once('exit', () => {
|
||||
child.once("exit", () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
@@ -144,7 +140,7 @@ async function stopProcess(child: ChildProcess | null): Promise<void> {
|
||||
function summarizeOpenAiErrorBody(body: string): string {
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed) {
|
||||
return 'empty response body';
|
||||
return "empty response body";
|
||||
}
|
||||
if (trimmed.length <= 240) {
|
||||
return trimmed;
|
||||
@@ -159,8 +155,8 @@ async function isOpenAiApiKeyUsable(apiKey: string | undefined): Promise<boolean
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('https://api.openai.com/v1/models?limit=1', {
|
||||
method: 'GET',
|
||||
const response = await fetch("https://api.openai.com/v1/models?limit=1", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`,
|
||||
},
|
||||
@@ -170,14 +166,14 @@ async function isOpenAiApiKeyUsable(apiKey: string | undefined): Promise<boolean
|
||||
}
|
||||
const body = await response.text();
|
||||
console.warn(
|
||||
`[e2e] OPENAI_API_KEY probe failed (${response.status}): ${summarizeOpenAiErrorBody(body)}`
|
||||
`[e2e] OPENAI_API_KEY probe failed (${response.status}): ${summarizeOpenAiErrorBody(body)}`,
|
||||
);
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[e2e] OPENAI_API_KEY probe request failed: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
@@ -196,42 +192,42 @@ type OfferPayload = {
|
||||
};
|
||||
|
||||
function stripAnsi(input: string): string {
|
||||
return input.replace(/\u001b\[[0-9;]*m/g, '');
|
||||
return input.replace(/\u001b\[[0-9;]*m/g, "");
|
||||
}
|
||||
|
||||
function ensureRelayBuildArtifact(repoRoot: string): void {
|
||||
const relayDistEntry = path.join(repoRoot, 'packages/relay/dist/e2ee.js');
|
||||
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', {
|
||||
console.log("[e2e] Building @getpaseo/relay for daemon startup");
|
||||
execSync("npm run build --workspace=@getpaseo/relay", {
|
||||
cwd: repoRoot,
|
||||
stdio: 'inherit',
|
||||
stdio: "inherit",
|
||||
});
|
||||
}
|
||||
|
||||
function decodeOfferFromFragmentUrl(url: string): OfferPayload {
|
||||
const marker = '#offer=';
|
||||
const marker = "#offer=";
|
||||
const idx = url.indexOf(marker);
|
||||
if (idx === -1) {
|
||||
throw new Error(`missing ${marker} fragment: ${url}`);
|
||||
}
|
||||
const encoded = url.slice(idx + marker.length);
|
||||
const json = Buffer.from(encoded, 'base64url').toString('utf8');
|
||||
const json = Buffer.from(encoded, "base64url").toString("utf8");
|
||||
const offer = JSON.parse(json) as Partial<OfferPayload>;
|
||||
if (offer.v !== 2) throw new Error('offer.v missing/invalid');
|
||||
if (!offer.serverId) throw new Error('offer.serverId missing');
|
||||
if (!offer.daemonPublicKeyB64) throw new Error('offer.daemonPublicKeyB64 missing');
|
||||
if (!offer.relay?.endpoint) throw new Error('offer.relay.endpoint missing');
|
||||
if (offer.v !== 2) throw new Error("offer.v missing/invalid");
|
||||
if (!offer.serverId) throw new Error("offer.serverId missing");
|
||||
if (!offer.daemonPublicKeyB64) throw new Error("offer.daemonPublicKeyB64 missing");
|
||||
if (!offer.relay?.endpoint) throw new Error("offer.relay.endpoint missing");
|
||||
return offer as OfferPayload;
|
||||
}
|
||||
|
||||
export default async function globalSetup() {
|
||||
const repoRoot = path.resolve(__dirname, '../../..');
|
||||
const repoRoot = path.resolve(__dirname, "../../..");
|
||||
ensureRelayBuildArtifact(repoRoot);
|
||||
const envTestPath = path.join(repoRoot, '.env.test');
|
||||
const envTestPath = path.join(repoRoot, ".env.test");
|
||||
if (existsSync(envTestPath)) {
|
||||
dotenv.config({ path: envTestPath });
|
||||
}
|
||||
@@ -239,13 +235,17 @@ export default async function globalSetup() {
|
||||
const port = await getAvailablePort();
|
||||
let relayPort = 0;
|
||||
const metroPort = await getAvailablePort();
|
||||
paseoHome = await mkdtemp(path.join(tmpdir(), 'paseo-e2e-home-'));
|
||||
paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-e2e-home-"));
|
||||
let relayLineBuffer = createLineBuffer();
|
||||
const metroLineBuffer = createLineBuffer();
|
||||
const daemonLineBuffer = createLineBuffer();
|
||||
|
||||
const cleanup = async () => {
|
||||
await Promise.all([stopProcess(daemonProcess), stopProcess(metroProcess), stopProcess(relayProcess)]);
|
||||
await Promise.all([
|
||||
stopProcess(daemonProcess),
|
||||
stopProcess(metroProcess),
|
||||
stopProcess(relayProcess),
|
||||
]);
|
||||
daemonProcess = null;
|
||||
metroProcess = null;
|
||||
relayProcess = null;
|
||||
@@ -256,24 +256,30 @@ export default async function globalSetup() {
|
||||
};
|
||||
|
||||
const openAiUsable = await isOpenAiApiKeyUsable(process.env.OPENAI_API_KEY);
|
||||
const defaultLocalModelsDir = path.join(process.env.HOME ?? '', '.paseo', 'models', 'local-speech');
|
||||
const hasDefaultLocalModelsDir = defaultLocalModelsDir.trim().length > 0 && existsSync(defaultLocalModelsDir);
|
||||
const dictationProvider = openAiUsable ? 'openai' : 'local';
|
||||
const defaultLocalModelsDir = path.join(
|
||||
process.env.HOME ?? "",
|
||||
".paseo",
|
||||
"models",
|
||||
"local-speech",
|
||||
);
|
||||
const hasDefaultLocalModelsDir =
|
||||
defaultLocalModelsDir.trim().length > 0 && existsSync(defaultLocalModelsDir);
|
||||
const dictationProvider = openAiUsable ? "openai" : "local";
|
||||
|
||||
if (dictationProvider === 'local' && !hasDefaultLocalModelsDir) {
|
||||
if (dictationProvider === "local" && !hasDefaultLocalModelsDir) {
|
||||
throw new Error(
|
||||
'OpenAI key is not usable and local speech models are unavailable at ~/.paseo/models/local-speech. ' +
|
||||
'Either provide a valid OPENAI_API_KEY or install local speech models before running app e2e tests.'
|
||||
"OpenAI key is not usable and local speech models are unavailable at ~/.paseo/models/local-speech. " +
|
||||
"Either provide a valid OPENAI_API_KEY or install local speech models before running app e2e tests.",
|
||||
);
|
||||
}
|
||||
|
||||
const localModelsDir = dictationProvider === 'local' ? defaultLocalModelsDir : null;
|
||||
const localModelsDir = dictationProvider === "local" ? defaultLocalModelsDir : null;
|
||||
console.log(
|
||||
`[e2e] Dictation STT provider: ${dictationProvider}${openAiUsable ? '' : ' (OpenAI probe failed)'}`
|
||||
`[e2e] Dictation STT provider: ${dictationProvider}${openAiUsable ? "" : " (OpenAI probe failed)"}`,
|
||||
);
|
||||
|
||||
try {
|
||||
const relayDir = path.resolve(__dirname, '..', '..', 'relay');
|
||||
const relayDir = path.resolve(__dirname, "..", "..", "relay");
|
||||
const maxRelayStartupAttempts = 5;
|
||||
let relayStarted = false;
|
||||
let lastRelayStartupError: unknown = null;
|
||||
@@ -285,18 +291,21 @@ export default async function globalSetup() {
|
||||
let relayReadyForSelectedPort = false;
|
||||
|
||||
relayProcess = spawn(
|
||||
'npx',
|
||||
['wrangler', 'dev', '--local', '--ip', '127.0.0.1', '--port', String(relayPort)],
|
||||
"npx",
|
||||
["wrangler", "dev", "--local", "--ip", "127.0.0.1", "--port", String(relayPort)],
|
||||
{
|
||||
cwd: relayDir,
|
||||
env: { ...process.env },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: false,
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
relayProcess.stdout?.on('data', (data: Buffer) => {
|
||||
const lines = data.toString().split('\n').filter((line) => line.trim());
|
||||
relayProcess.stdout?.on("data", (data: Buffer) => {
|
||||
const lines = data
|
||||
.toString()
|
||||
.split("\n")
|
||||
.filter((line) => line.trim());
|
||||
for (const line of lines) {
|
||||
relayLineBuffer.add(`[stdout] ${line}`);
|
||||
const failure = parseRelayStartupFailure(line);
|
||||
@@ -311,8 +320,11 @@ export default async function globalSetup() {
|
||||
console.log(`[relay] ${line}`);
|
||||
}
|
||||
});
|
||||
relayProcess.stderr?.on('data', (data: Buffer) => {
|
||||
const lines = data.toString().split('\n').filter((line) => line.trim());
|
||||
relayProcess.stderr?.on("data", (data: Buffer) => {
|
||||
const lines = data
|
||||
.toString()
|
||||
.split("\n")
|
||||
.filter((line) => line.trim());
|
||||
for (const line of lines) {
|
||||
relayLineBuffer.add(`[stderr] ${line}`);
|
||||
const failure = parseRelayStartupFailure(line);
|
||||
@@ -330,7 +342,7 @@ export default async function globalSetup() {
|
||||
|
||||
try {
|
||||
await waitForServer(relayPort, {
|
||||
label: 'Relay dev server',
|
||||
label: "Relay dev server",
|
||||
timeoutMs: 30000,
|
||||
childProcess: relayProcess,
|
||||
getRecentOutput: relayLineBuffer.dump,
|
||||
@@ -353,15 +365,15 @@ export default async function globalSetup() {
|
||||
if (!relayReadyForSelectedPort) {
|
||||
throw new Error(
|
||||
`Relay process did not report ready for selected port ${relayPort}.${formatRecentOutput(
|
||||
relayLineBuffer.dump
|
||||
)}`
|
||||
relayLineBuffer.dump,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
if (relayProcess.exitCode !== null || relayProcess.signalCode !== null) {
|
||||
throw new Error(
|
||||
`Relay process exited before startup completed (exit code ${relayProcess.exitCode}, signal ${relayProcess.signalCode}).${formatRecentOutput(
|
||||
relayLineBuffer.dump
|
||||
)}`
|
||||
relayLineBuffer.dump,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -380,40 +392,46 @@ export default async function globalSetup() {
|
||||
? lastRelayStartupError.message
|
||||
: String(lastRelayStartupError);
|
||||
throw new Error(
|
||||
`Failed to start relay dev server after ${maxRelayStartupAttempts} attempts. ${message}`
|
||||
`Failed to start relay dev server after ${maxRelayStartupAttempts} attempts. ${message}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Start Metro bundler on dynamic port
|
||||
const appDir = path.resolve(__dirname, '..');
|
||||
metroProcess = spawn('npx', ['expo', 'start', '--web', '--port', String(metroPort)], {
|
||||
const appDir = path.resolve(__dirname, "..");
|
||||
metroProcess = spawn("npx", ["expo", "start", "--web", "--port", String(metroPort)], {
|
||||
cwd: appDir,
|
||||
env: {
|
||||
...process.env,
|
||||
BROWSER: 'none', // Don't auto-open browser
|
||||
BROWSER: "none", // Don't auto-open browser
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: false,
|
||||
});
|
||||
|
||||
metroProcess.stdout?.on('data', (data: Buffer) => {
|
||||
const lines = data.toString().split('\n').filter((line) => line.trim());
|
||||
metroProcess.stdout?.on("data", (data: Buffer) => {
|
||||
const lines = data
|
||||
.toString()
|
||||
.split("\n")
|
||||
.filter((line) => line.trim());
|
||||
for (const line of lines) {
|
||||
metroLineBuffer.add(`[stdout] ${line}`);
|
||||
console.log(`[metro] ${line}`);
|
||||
}
|
||||
});
|
||||
|
||||
metroProcess.stderr?.on('data', (data: Buffer) => {
|
||||
const lines = data.toString().split('\n').filter((line) => line.trim());
|
||||
metroProcess.stderr?.on("data", (data: Buffer) => {
|
||||
const lines = data
|
||||
.toString()
|
||||
.split("\n")
|
||||
.filter((line) => line.trim());
|
||||
for (const line of lines) {
|
||||
metroLineBuffer.add(`[stderr] ${line}`);
|
||||
console.error(`[metro] ${line}`);
|
||||
}
|
||||
});
|
||||
|
||||
const serverDir = path.resolve(__dirname, '../../..', 'packages/server');
|
||||
const tsxBin = execSync('which tsx').toString().trim();
|
||||
const serverDir = path.resolve(__dirname, "../../..", "packages/server");
|
||||
const tsxBin = execSync("which tsx").toString().trim();
|
||||
|
||||
let offerPayload: OfferPayload | null = null;
|
||||
let offerResolve: (() => void) | null = null;
|
||||
@@ -421,33 +439,33 @@ export default async function globalSetup() {
|
||||
offerResolve = resolve;
|
||||
});
|
||||
|
||||
daemonProcess = spawn(tsxBin, ['src/server/index.ts'], {
|
||||
daemonProcess = spawn(tsxBin, ["src/server/index.ts"], {
|
||||
cwd: serverDir,
|
||||
env: {
|
||||
...process.env,
|
||||
PASEO_HOME: paseoHome,
|
||||
PASEO_SERVER_ID: 'srv_e2e_test_daemon',
|
||||
PASEO_SERVER_ID: "srv_e2e_test_daemon",
|
||||
PASEO_LISTEN: `0.0.0.0:${port}`,
|
||||
PASEO_RELAY_ENDPOINT: `127.0.0.1:${relayPort}`,
|
||||
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
|
||||
// Use OpenAI speech providers in e2e to avoid local model bootstrapping delays.
|
||||
PASEO_DICTATION_ENABLED: '1',
|
||||
PASEO_VOICE_MODE_ENABLED: '1',
|
||||
PASEO_DICTATION_ENABLED: "1",
|
||||
PASEO_VOICE_MODE_ENABLED: "1",
|
||||
PASEO_DICTATION_STT_PROVIDER: dictationProvider,
|
||||
PASEO_VOICE_STT_PROVIDER: 'openai',
|
||||
PASEO_VOICE_TTS_PROVIDER: 'openai',
|
||||
PASEO_VOICE_STT_PROVIDER: "openai",
|
||||
PASEO_VOICE_TTS_PROVIDER: "openai",
|
||||
...(localModelsDir ? { PASEO_LOCAL_MODELS_DIR: localModelsDir } : {}),
|
||||
NODE_ENV: 'development',
|
||||
NODE_ENV: "development",
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: false,
|
||||
});
|
||||
|
||||
let stdoutBuffer = '';
|
||||
daemonProcess.stdout?.on('data', (data: Buffer) => {
|
||||
stdoutBuffer += data.toString('utf8');
|
||||
const lines = stdoutBuffer.split('\n');
|
||||
stdoutBuffer = lines.pop() ?? '';
|
||||
let stdoutBuffer = "";
|
||||
daemonProcess.stdout?.on("data", (data: Buffer) => {
|
||||
stdoutBuffer += data.toString("utf8");
|
||||
const lines = stdoutBuffer.split("\n");
|
||||
stdoutBuffer = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
@@ -456,13 +474,13 @@ export default async function globalSetup() {
|
||||
const clean = stripAnsi(trimmed);
|
||||
try {
|
||||
const obj = JSON.parse(clean) as { msg?: string; url?: string };
|
||||
if (obj.msg === 'pairing_offer' && typeof obj.url === 'string') {
|
||||
if (obj.msg === "pairing_offer" && typeof obj.url === "string") {
|
||||
offerPayload = decodeOfferFromFragmentUrl(obj.url);
|
||||
offerResolve?.();
|
||||
}
|
||||
} catch {
|
||||
const match = clean.match(/https?:\/\/[^\s"]+#offer=[A-Za-z0-9_-]+/);
|
||||
if (match && clean.includes('pairing_offer')) {
|
||||
if (match && clean.includes("pairing_offer")) {
|
||||
try {
|
||||
offerPayload = decodeOfferFromFragmentUrl(match[0]);
|
||||
offerResolve?.();
|
||||
@@ -476,8 +494,11 @@ export default async function globalSetup() {
|
||||
}
|
||||
});
|
||||
|
||||
daemonProcess.stderr?.on('data', (data: Buffer) => {
|
||||
const lines = data.toString().split('\n').filter((line) => line.trim());
|
||||
daemonProcess.stderr?.on("data", (data: Buffer) => {
|
||||
const lines = data
|
||||
.toString()
|
||||
.split("\n")
|
||||
.filter((line) => line.trim());
|
||||
for (const line of lines) {
|
||||
daemonLineBuffer.add(`[stderr] ${line}`);
|
||||
console.error(`[daemon] ${line}`);
|
||||
@@ -487,12 +508,12 @@ export default async function globalSetup() {
|
||||
// Wait for both daemon and Metro to be ready
|
||||
await Promise.all([
|
||||
waitForServer(port, {
|
||||
label: 'Paseo daemon',
|
||||
label: "Paseo daemon",
|
||||
childProcess: daemonProcess,
|
||||
getRecentOutput: daemonLineBuffer.dump,
|
||||
}),
|
||||
waitForServer(metroPort, {
|
||||
label: 'Metro web server',
|
||||
label: "Metro web server",
|
||||
timeoutMs: 120000, // Metro can take longer to start
|
||||
childProcess: metroProcess,
|
||||
getRecentOutput: metroLineBuffer.dump,
|
||||
@@ -503,11 +524,11 @@ export default async function globalSetup() {
|
||||
await Promise.race([
|
||||
offerPromise,
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Timed out waiting for pairing_offer log')), 15000)
|
||||
setTimeout(() => reject(new Error("Timed out waiting for pairing_offer log")), 15000),
|
||||
),
|
||||
]);
|
||||
if (!offerPayload) {
|
||||
throw new Error('pairing_offer was not parsed from daemon logs');
|
||||
throw new Error("pairing_offer was not parsed from daemon logs");
|
||||
}
|
||||
const offer = offerPayload as OfferPayload;
|
||||
|
||||
@@ -516,11 +537,13 @@ export default async function globalSetup() {
|
||||
process.env.E2E_SERVER_ID = offer.serverId;
|
||||
process.env.E2E_RELAY_DAEMON_PUBLIC_KEY = offer.daemonPublicKeyB64;
|
||||
process.env.E2E_METRO_PORT = String(metroPort);
|
||||
console.log(`[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, home: ${paseoHome}`);
|
||||
console.log(
|
||||
`[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, home: ${paseoHome}`,
|
||||
);
|
||||
|
||||
return async () => {
|
||||
await cleanup();
|
||||
console.log('[e2e] Test daemon stopped');
|
||||
console.log("[e2e] Test daemon stopped");
|
||||
};
|
||||
} catch (error) {
|
||||
await cleanup();
|
||||
|
||||
@@ -2,10 +2,7 @@ import { expect, type Page } from "@playwright/test";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
buildHostWorkspaceAgentRoute,
|
||||
buildHostWorkspaceRoute,
|
||||
} from "../../src/utils/host-routes";
|
||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||
|
||||
const NEAR_BOTTOM_THRESHOLD_PX = 72;
|
||||
|
||||
@@ -37,10 +34,7 @@ export type DaemonClientInstance = {
|
||||
initialPrompt: string;
|
||||
}): Promise<{ id: string }>;
|
||||
sendAgentMessage(agentId: string, text: string): Promise<void>;
|
||||
waitForFinish(
|
||||
agentId: string,
|
||||
timeout?: number
|
||||
): Promise<{ status: string }>;
|
||||
waitForFinish(agentId: string, timeout?: number): Promise<{ status: string }>;
|
||||
};
|
||||
|
||||
function getDaemonWsUrl(): string {
|
||||
@@ -89,14 +83,16 @@ export function createReplyTurn(label: string): {
|
||||
};
|
||||
}
|
||||
|
||||
async function loadDaemonClientConstructor(): Promise<new (config: {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
}) => DaemonClientInstance> {
|
||||
async function loadDaemonClientConstructor(): Promise<
|
||||
new (config: {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
}) => DaemonClientInstance
|
||||
> {
|
||||
const repoRoot = path.resolve(process.cwd(), "../..");
|
||||
const moduleUrl = pathToFileURL(
|
||||
path.join(repoRoot, "packages/server/dist/server/server/exports.js")
|
||||
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
|
||||
).href;
|
||||
const mod = (await import(moduleUrl)) as {
|
||||
DaemonClient: new (config: {
|
||||
@@ -141,7 +137,7 @@ export async function seedBottomAnchorAgent(input: {
|
||||
const initialFinish = await input.client.waitForFinish(created.id, 120000);
|
||||
if (initialFinish.status !== "idle") {
|
||||
throw new Error(
|
||||
`Expected seeded agent ${created.id} to become idle after initial prompt, got ${initialFinish.status}.`
|
||||
`Expected seeded agent ${created.id} to become idle after initial prompt, got ${initialFinish.status}.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -153,7 +149,7 @@ export async function seedBottomAnchorAgent(input: {
|
||||
const finish = await input.client.waitForFinish(created.id, 120000);
|
||||
if (finish.status !== "idle") {
|
||||
throw new Error(
|
||||
`Expected seeded agent ${created.id} to become idle after turn ${index}, got ${finish.status}.`
|
||||
`Expected seeded agent ${created.id} to become idle after turn ${index}, got ${finish.status}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -162,7 +158,7 @@ export async function seedBottomAnchorAgent(input: {
|
||||
id: created.id,
|
||||
title,
|
||||
expectedTailText,
|
||||
url: buildHostWorkspaceAgentRoute(getServerId(), input.cwd, created.id),
|
||||
url: `${buildHostWorkspaceRoute(getServerId(), input.cwd)}?open=${encodeURIComponent(`agent:${created.id}`)}`,
|
||||
workspaceUrl: buildHostWorkspaceRoute(getServerId(), input.cwd),
|
||||
};
|
||||
}
|
||||
@@ -186,18 +182,13 @@ export async function readScrollMetrics(page: Page): Promise<ScrollMetrics> {
|
||||
const scrollElement =
|
||||
candidates.sort(
|
||||
(left, right) =>
|
||||
right.scrollHeight -
|
||||
right.clientHeight -
|
||||
(left.scrollHeight - left.clientHeight)
|
||||
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);
|
||||
const viewportHeight = Math.max(0, scrollElement.clientHeight);
|
||||
const distanceFromBottom = Math.max(
|
||||
0,
|
||||
contentHeight - (offsetY + viewportHeight)
|
||||
);
|
||||
const distanceFromBottom = Math.max(0, contentHeight - (offsetY + viewportHeight));
|
||||
|
||||
return {
|
||||
offsetY,
|
||||
@@ -221,7 +212,7 @@ export async function scrollUpFromBottom(page: Page, pixels: number): Promise<vo
|
||||
deltaY: -step,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
}),
|
||||
);
|
||||
scrollContainer.scrollTop = Math.max(0, scrollContainer.scrollTop - step);
|
||||
scrollContainer.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||
@@ -270,7 +261,7 @@ export async function expectDetachedFromBottom(page: Page): Promise<void> {
|
||||
|
||||
export async function waitForContentGrowth(
|
||||
page: Page,
|
||||
previousContentHeight: number
|
||||
previousContentHeight: number,
|
||||
): Promise<ScrollMetrics> {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
@@ -283,8 +274,8 @@ export async function waitForContentGrowth(
|
||||
|
||||
export async function getChatContainerKey(page: Page): Promise<string | null> {
|
||||
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;
|
||||
});
|
||||
const nativeId = (element as HTMLElement).id;
|
||||
const prefix = "agent-chat-scroll-";
|
||||
return nativeId.startsWith(prefix) ? nativeId.slice(prefix.length) : null;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
import {
|
||||
buildCreateAgentPreferences,
|
||||
buildSeededHost,
|
||||
} from './daemon-registry';
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function getE2EDaemonPort(): string {
|
||||
const port = process.env.E2E_DAEMON_PORT;
|
||||
if (!port) {
|
||||
throw new Error('E2E_DAEMON_PORT is not set (expected from Playwright globalSetup).');
|
||||
throw new Error("E2E_DAEMON_PORT is not set (expected from Playwright globalSetup).");
|
||||
}
|
||||
if (port === '6767') {
|
||||
throw new Error('E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon.');
|
||||
if (port === "6767") {
|
||||
throw new Error(
|
||||
"E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon.",
|
||||
);
|
||||
}
|
||||
return port;
|
||||
}
|
||||
@@ -24,25 +23,38 @@ async function ensureE2EStorageSeeded(page: Page): Promise<void> {
|
||||
const expectedEndpoint = `127.0.0.1:${port}`;
|
||||
const expectedServerId = process.env.E2E_SERVER_ID;
|
||||
if (!expectedServerId) {
|
||||
throw new Error('E2E_SERVER_ID is not set (expected from Playwright globalSetup).');
|
||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||
}
|
||||
|
||||
const needsReset = await page.evaluate(({ expectedEndpoint, expectedServerId }) => {
|
||||
const raw = localStorage.getItem('@paseo:daemon-registry');
|
||||
if (!raw) return true;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed) || parsed.length !== 1) return true;
|
||||
const entry = parsed[0] as any;
|
||||
if (entry?.serverId !== expectedServerId) return true;
|
||||
const connections = entry?.connections;
|
||||
if (!Array.isArray(connections)) return true;
|
||||
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;
|
||||
}
|
||||
}, { expectedEndpoint, expectedServerId });
|
||||
const needsReset = await page.evaluate(
|
||||
({ expectedEndpoint, expectedServerId }) => {
|
||||
const raw = localStorage.getItem("@paseo:daemon-registry");
|
||||
if (!raw) return true;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed) || parsed.length !== 1) return true;
|
||||
const entry = parsed[0] as any;
|
||||
if (entry?.serverId !== expectedServerId) return true;
|
||||
const connections = entry?.connections;
|
||||
if (!Array.isArray(connections)) return true;
|
||||
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;
|
||||
}
|
||||
},
|
||||
{ expectedEndpoint, expectedServerId },
|
||||
);
|
||||
|
||||
if (!needsReset) {
|
||||
return;
|
||||
@@ -57,12 +69,12 @@ async function ensureE2EStorageSeeded(page: Page): Promise<void> {
|
||||
const preferences = buildCreateAgentPreferences(expectedServerId);
|
||||
await page.evaluate(
|
||||
({ daemon, preferences }) => {
|
||||
localStorage.setItem('@paseo:e2e', '1');
|
||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
|
||||
localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(preferences));
|
||||
localStorage.removeItem('@paseo:settings');
|
||||
localStorage.setItem("@paseo:e2e", "1");
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||
localStorage.removeItem("@paseo:settings");
|
||||
},
|
||||
{ daemon, preferences }
|
||||
{ daemon, preferences },
|
||||
);
|
||||
|
||||
await page.reload();
|
||||
@@ -73,81 +85,98 @@ async function assertE2EUsesSeededTestDaemon(page: Page): Promise<void> {
|
||||
const expectedEndpoint = `127.0.0.1:${port}`;
|
||||
const expectedServerId = process.env.E2E_SERVER_ID;
|
||||
if (!expectedServerId) {
|
||||
throw new Error('E2E_SERVER_ID is not set (expected from Playwright globalSetup).');
|
||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||
}
|
||||
|
||||
const snapshot = await page.evaluate(() => {
|
||||
const registryRaw = localStorage.getItem('@paseo:daemon-registry');
|
||||
const prefsRaw = localStorage.getItem('@paseo:create-agent-preferences');
|
||||
const registryRaw = localStorage.getItem("@paseo:daemon-registry");
|
||||
const prefsRaw = localStorage.getItem("@paseo:create-agent-preferences");
|
||||
return { registryRaw, prefsRaw };
|
||||
});
|
||||
|
||||
if (!snapshot.registryRaw) {
|
||||
throw new Error('E2E expected @paseo:daemon-registry to be set before app load.');
|
||||
throw new Error("E2E expected @paseo:daemon-registry to be set before app load.");
|
||||
}
|
||||
|
||||
let registry: any;
|
||||
try {
|
||||
registry = JSON.parse(snapshot.registryRaw);
|
||||
} catch {
|
||||
throw new Error('E2E expected @paseo:daemon-registry to be valid JSON.');
|
||||
throw new Error("E2E expected @paseo:daemon-registry to be valid JSON.");
|
||||
}
|
||||
|
||||
if (!Array.isArray(registry) || registry.length !== 1) {
|
||||
throw new Error(
|
||||
`E2E expected @paseo:daemon-registry to contain exactly 1 daemon (got ${Array.isArray(registry) ? registry.length : 'non-array'}).`
|
||||
`E2E expected @paseo:daemon-registry to contain exactly 1 daemon (got ${Array.isArray(registry) ? registry.length : "non-array"}).`,
|
||||
);
|
||||
}
|
||||
|
||||
const daemon = registry[0];
|
||||
if (typeof daemon?.serverId !== 'string' || daemon.serverId.length === 0) {
|
||||
throw new Error(`E2E expected seeded daemon to have a string serverId (got ${String(daemon?.serverId)}).`);
|
||||
if (typeof daemon?.serverId !== "string" || daemon.serverId.length === 0) {
|
||||
throw new Error(
|
||||
`E2E expected seeded daemon to have a string serverId (got ${String(daemon?.serverId)}).`,
|
||||
);
|
||||
}
|
||||
if (daemon.serverId !== expectedServerId) {
|
||||
throw new Error(`E2E expected seeded daemon serverId to be ${expectedServerId} (got ${daemon.serverId}).`);
|
||||
throw new Error(
|
||||
`E2E expected seeded daemon serverId to be ${expectedServerId} (got ${daemon.serverId}).`,
|
||||
);
|
||||
}
|
||||
|
||||
const connections: unknown = daemon?.connections;
|
||||
if (
|
||||
!Array.isArray(connections) ||
|
||||
!connections.some((c: any) => c?.type === 'directTcp' && c?.endpoint === expectedEndpoint)
|
||||
!connections.some((c: any) => c?.type === "directTcp" && c?.endpoint === expectedEndpoint)
|
||||
) {
|
||||
throw new Error(
|
||||
`E2E expected seeded daemon connections to include directTcp ${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 === 'directTcp' && typeof c?.endpoint === 'string' && /:6767\b/.test(c.endpoint))) {
|
||||
throw new Error(`E2E detected a daemon endpoint pointing at :6767 (${JSON.stringify(connections)}).`);
|
||||
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)}).`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!snapshot.prefsRaw) {
|
||||
throw new Error('E2E expected @paseo:create-agent-preferences to be set before app load.');
|
||||
throw new Error("E2E expected @paseo:create-agent-preferences to be set before app load.");
|
||||
}
|
||||
try {
|
||||
const prefs = JSON.parse(snapshot.prefsRaw) as any;
|
||||
if (prefs?.serverId !== daemon.serverId) {
|
||||
throw new Error(
|
||||
`E2E expected create-agent-preferences.serverId to match seeded daemon serverId (${daemon.serverId}) (got ${String(prefs?.serverId)}).`
|
||||
`E2E expected create-agent-preferences.serverId to match seeded daemon serverId (${daemon.serverId}) (got ${String(prefs?.serverId)}).`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) throw error;
|
||||
throw new Error('E2E expected @paseo:create-agent-preferences to be valid JSON.');
|
||||
throw new Error("E2E expected @paseo:create-agent-preferences to be valid JSON.");
|
||||
}
|
||||
}
|
||||
|
||||
export const gotoAppShell = async (page: Page) => {
|
||||
await page.goto('/');
|
||||
await page.goto("/");
|
||||
await ensureE2EStorageSeeded(page);
|
||||
};
|
||||
|
||||
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();
|
||||
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 expect
|
||||
.poll(
|
||||
@@ -155,7 +184,7 @@ export const gotoHome = async (page: Page) => {
|
||||
(await addProjectCta.isVisible().catch(() => false)) ||
|
||||
(await addProjectSidebar.isVisible().catch(() => false)) ||
|
||||
(await newAgentButton.isVisible().catch(() => false)),
|
||||
{ timeout: 10000 }
|
||||
{ timeout: 10000 },
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
@@ -173,7 +202,7 @@ export const gotoHome = async (page: Page) => {
|
||||
export const openSettings = async (page: Page) => {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error('E2E_SERVER_ID is not set (expected from Playwright globalSetup).');
|
||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||
}
|
||||
|
||||
// Navigate through the real app control so route changes stay aligned with UI behavior.
|
||||
@@ -189,23 +218,19 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
|
||||
.first();
|
||||
await expect(workingDirectorySelect).toBeVisible({ timeout: 30000 });
|
||||
|
||||
const legacyInput = page.getByRole('textbox', { name: '/path/to/project' }).first();
|
||||
const directorySearchInput = page.getByRole('textbox', { name: /search directories/i }).first();
|
||||
const worktreePicker = page.getByTestId('worktree-attach-picker');
|
||||
const worktreeSheetTitle = page.getByText('Select worktree', { exact: true }).first();
|
||||
const legacyInput = page.getByRole("textbox", { name: "/path/to/project" }).first();
|
||||
const directorySearchInput = page.getByRole("textbox", { name: /search directories/i }).first();
|
||||
const worktreePicker = page.getByTestId("worktree-attach-picker");
|
||||
const worktreeSheetTitle = page.getByText("Select worktree", { exact: true }).first();
|
||||
const closeBottomSheet = async () => {
|
||||
const bottomSheetBackdrop = page
|
||||
.getByRole('button', { name: 'Bottom sheet backdrop' })
|
||||
.first();
|
||||
const bottomSheetHandle = page
|
||||
.getByRole('slider', { name: 'Bottom sheet handle' })
|
||||
.first();
|
||||
const bottomSheetBackdrop = page.getByRole("button", { name: "Bottom sheet backdrop" }).first();
|
||||
const bottomSheetHandle = page.getByRole("slider", { name: "Bottom sheet handle" }).first();
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
if (!(await bottomSheetBackdrop.isVisible())) {
|
||||
return;
|
||||
}
|
||||
await bottomSheetBackdrop.click({ force: true });
|
||||
await page.keyboard.press('Escape').catch(() => undefined);
|
||||
await page.keyboard.press("Escape").catch(() => undefined);
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
if (await bottomSheetBackdrop.isVisible()) {
|
||||
@@ -225,7 +250,7 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
|
||||
if (!(await worktreeSheetTitle.isVisible()) && !(await worktreePicker.isVisible())) {
|
||||
return;
|
||||
}
|
||||
const attachToggle = page.getByTestId('worktree-attach-toggle');
|
||||
const attachToggle = page.getByTestId("worktree-attach-toggle");
|
||||
if (await attachToggle.isVisible()) {
|
||||
await attachToggle.click({ force: true });
|
||||
await page.waitForTimeout(200);
|
||||
@@ -246,26 +271,23 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
|
||||
await closeBottomSheet();
|
||||
await workingDirectorySelect.click({ force: true });
|
||||
}
|
||||
await expect
|
||||
.poll(async () => pickerInputVisible(), { timeout: 10000 })
|
||||
.toBe(true);
|
||||
await expect.poll(async () => pickerInputVisible(), { timeout: 10000 }).toBe(true);
|
||||
}
|
||||
|
||||
const trimmedDirectory = directory.replace(/\/+$/, '');
|
||||
const activeInput =
|
||||
(await directorySearchInput.isVisible().catch(() => false))
|
||||
? directorySearchInput
|
||||
: legacyInput;
|
||||
const trimmedDirectory = directory.replace(/\/+$/, "");
|
||||
const activeInput = (await directorySearchInput.isVisible().catch(() => false))
|
||||
? directorySearchInput
|
||||
: legacyInput;
|
||||
|
||||
await activeInput.fill(trimmedDirectory);
|
||||
|
||||
if (activeInput === directorySearchInput) {
|
||||
// Combobox custom rows can be either plain path labels or prefixed labels.
|
||||
const plainOption = page
|
||||
.getByText(new RegExp(`^${escapeRegex(trimmedDirectory)}$`, 'i'))
|
||||
.getByText(new RegExp(`^${escapeRegex(trimmedDirectory)}$`, "i"))
|
||||
.first();
|
||||
const prefixedUseOption = page
|
||||
.getByText(new RegExp(`^Use "${escapeRegex(trimmedDirectory)}"$`, 'i'))
|
||||
.getByText(new RegExp(`^Use "${escapeRegex(trimmedDirectory)}"$`, "i"))
|
||||
.first();
|
||||
|
||||
if (await plainOption.isVisible().catch(() => false)) {
|
||||
@@ -274,33 +296,38 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
|
||||
await prefixedUseOption.click({ force: true });
|
||||
} else {
|
||||
// Fallback: accept highlighted option (directory suggestion).
|
||||
await activeInput.press('Enter');
|
||||
await activeInput.press("Enter");
|
||||
}
|
||||
} else {
|
||||
// Legacy path picker fallback.
|
||||
await activeInput.press('Enter');
|
||||
await activeInput.press("Enter");
|
||||
}
|
||||
|
||||
// Wait for picker to close.
|
||||
await expect(activeInput).not.toBeVisible({ timeout: 10000 });
|
||||
|
||||
const directoryCandidates = new Set<string>([trimmedDirectory]);
|
||||
if (trimmedDirectory.startsWith('/var/')) {
|
||||
if (trimmedDirectory.startsWith("/var/")) {
|
||||
directoryCandidates.add(`/private${trimmedDirectory}`);
|
||||
}
|
||||
if (trimmedDirectory.startsWith('/private/var/')) {
|
||||
directoryCandidates.add(trimmedDirectory.replace(/^\/private/, ''));
|
||||
if (trimmedDirectory.startsWith("/private/var/")) {
|
||||
directoryCandidates.add(trimmedDirectory.replace(/^\/private/, ""));
|
||||
}
|
||||
const basename = trimmedDirectory.split('/').filter(Boolean).pop() ?? trimmedDirectory;
|
||||
const basename = trimmedDirectory.split("/").filter(Boolean).pop() ?? trimmedDirectory;
|
||||
|
||||
await expect.poll(async () => {
|
||||
const text = await workingDirectorySelect.innerText().catch(() => '');
|
||||
if (text.includes(basename)) return true;
|
||||
for (const candidate of directoryCandidates) {
|
||||
if (text.includes(candidate)) return true;
|
||||
}
|
||||
return false;
|
||||
}, { timeout: 30000 }).toBe(true);
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const text = await workingDirectorySelect.innerText().catch(() => "");
|
||||
if (text.includes(basename)) return true;
|
||||
for (const candidate of directoryCandidates) {
|
||||
if (text.includes(candidate)) return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ timeout: 30000 },
|
||||
)
|
||||
.toBe(true);
|
||||
};
|
||||
|
||||
export const ensureHostSelected = async (page: Page) => {
|
||||
@@ -318,19 +345,21 @@ export const ensureHostSelected = async (page: Page) => {
|
||||
}
|
||||
|
||||
const fix = await page.evaluate(() => {
|
||||
const registryRaw = localStorage.getItem('@paseo:daemon-registry');
|
||||
const prefsRaw = localStorage.getItem('@paseo:create-agent-preferences');
|
||||
if (!registryRaw || !prefsRaw) return { ok: false, reason: 'missing storage' } as const;
|
||||
const registryRaw = localStorage.getItem("@paseo:daemon-registry");
|
||||
const prefsRaw = localStorage.getItem("@paseo:create-agent-preferences");
|
||||
if (!registryRaw || !prefsRaw) return { ok: false, reason: "missing storage" } as const;
|
||||
const registry = JSON.parse(registryRaw) as any[];
|
||||
const prefs = JSON.parse(prefsRaw) as any;
|
||||
if (!Array.isArray(registry) || registry.length !== 1) return { ok: false, reason: 'registry shape' } as const;
|
||||
if (!Array.isArray(registry) || registry.length !== 1)
|
||||
return { ok: false, reason: "registry shape" } as const;
|
||||
const serverId = registry[0]?.serverId;
|
||||
if (typeof serverId !== 'string' || serverId.length === 0) return { ok: false, reason: 'missing serverId' } as const;
|
||||
if (typeof serverId !== "string" || serverId.length === 0)
|
||||
return { ok: false, reason: "missing serverId" } as const;
|
||||
prefs.serverId = serverId;
|
||||
localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(prefs));
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(prefs));
|
||||
// Prevent the fixture's init-script from overwriting the corrected prefs on reload.
|
||||
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
|
||||
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
|
||||
const nonce = localStorage.getItem("@paseo:e2e-seed-nonce") ?? "1";
|
||||
localStorage.setItem("@paseo:e2e-disable-default-seed-once", nonce);
|
||||
return { ok: true } as const;
|
||||
});
|
||||
|
||||
@@ -342,20 +371,22 @@ export const ensureHostSelected = async (page: Page) => {
|
||||
await assertE2EUsesSeededTestDaemon(page);
|
||||
}
|
||||
|
||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
||||
const input = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await expect(input).toBeVisible();
|
||||
|
||||
if (await input.isEditable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectHostLabel = page.getByText('Select host', { exact: true });
|
||||
const selectHostLabel = page.getByText("Select host", { exact: true });
|
||||
if (await selectHostLabel.isVisible()) {
|
||||
await selectHostLabel.click();
|
||||
|
||||
// E2E safety: we enforce a single seeded daemon, so the option should be unambiguous.
|
||||
const localhostOption = page.getByText('localhost', { exact: true }).first();
|
||||
const daemonIdOption = page.getByText(process.env.E2E_SERVER_ID ?? 'srv_e2e_test_daemon', { exact: true }).first();
|
||||
const localhostOption = page.getByText("localhost", { exact: true }).first();
|
||||
const daemonIdOption = page
|
||||
.getByText(process.env.E2E_SERVER_ID ?? "srv_e2e_test_daemon", { exact: true })
|
||||
.first();
|
||||
|
||||
if (await localhostOption.isVisible()) {
|
||||
await localhostOption.click();
|
||||
@@ -369,11 +400,11 @@ export const ensureHostSelected = async (page: Page) => {
|
||||
};
|
||||
|
||||
export const createAgent = async (page: Page, message: string) => {
|
||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
||||
const input = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await expect(input).toBeEditable();
|
||||
await preferFastThinkingOption(page);
|
||||
await input.fill(message);
|
||||
await input.press('Enter');
|
||||
await input.press("Enter");
|
||||
|
||||
// The composer may remain on the draft screen briefly while the initial run starts,
|
||||
// so assert the user-visible result instead of forcing one route shape here.
|
||||
@@ -385,21 +416,23 @@ export const createAgent = async (page: Page, message: string) => {
|
||||
|
||||
async function preferFastThinkingOption(page: Page): Promise<void> {
|
||||
const providerTrigger = page
|
||||
.locator('[data-testid="agent-provider-selector"]:visible, [data-testid="draft-provider-select"]:visible')
|
||||
.locator(
|
||||
'[data-testid="agent-provider-selector"]:visible, [data-testid="draft-provider-select"]:visible',
|
||||
)
|
||||
.first();
|
||||
if (await providerTrigger.isVisible().catch(() => false)) {
|
||||
const providerText = ((await providerTrigger.innerText().catch(() => '')) ?? '').trim();
|
||||
const providerText = ((await providerTrigger.innerText().catch(() => "")) ?? "").trim();
|
||||
if (!/codex/i.test(providerText)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const thinkingTrigger = page.getByTestId('agent-thinking-selector').first();
|
||||
const thinkingTrigger = page.getByTestId("agent-thinking-selector").first();
|
||||
if (!(await thinkingTrigger.isVisible().catch(() => false))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentThinkingLabel = ((await thinkingTrigger.innerText().catch(() => '')) ?? '')
|
||||
const currentThinkingLabel = ((await thinkingTrigger.innerText().catch(() => "")) ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (/\b(low|minimal|off)\b/.test(currentThinkingLabel)) {
|
||||
@@ -407,16 +440,16 @@ async function preferFastThinkingOption(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
await thinkingTrigger.click();
|
||||
const menu = page.getByTestId('agent-thinking-menu').first();
|
||||
const menu = page.getByTestId("agent-thinking-menu").first();
|
||||
if (!(await menu.isVisible().catch(() => false))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const preferredLabels = ['low', 'minimal', 'off', 'medium'];
|
||||
const preferredLabels = ["low", "minimal", "off", "medium"];
|
||||
let selected = false;
|
||||
for (const label of preferredLabels) {
|
||||
const option = menu
|
||||
.getByRole('button', { name: new RegExp(`^${escapeRegex(label)}$`, 'i') })
|
||||
.getByRole("button", { name: new RegExp(`^${escapeRegex(label)}$`, "i") })
|
||||
.first();
|
||||
if (await option.isVisible().catch(() => false)) {
|
||||
await option.click({ force: true });
|
||||
@@ -426,11 +459,11 @@ async function preferFastThinkingOption(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
if (!selected) {
|
||||
const options = menu.getByRole('button');
|
||||
const options = menu.getByRole("button");
|
||||
const count = await options.count();
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const option = options.nth(index);
|
||||
const label = ((await option.innerText().catch(() => '')) ?? '').trim();
|
||||
const label = ((await option.innerText().catch(() => "")) ?? "").trim();
|
||||
if (!label) {
|
||||
continue;
|
||||
}
|
||||
@@ -444,7 +477,7 @@ async function preferFastThinkingOption(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
if (!selected) {
|
||||
await page.keyboard.press('Escape').catch(() => undefined);
|
||||
await page.keyboard.press("Escape").catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -462,15 +495,17 @@ export interface AgentConfig {
|
||||
export const selectProvider = async (page: Page, provider: string) => {
|
||||
const normalizedProvider = provider.trim();
|
||||
if (!normalizedProvider) {
|
||||
throw new Error('Provider must be a non-empty string.');
|
||||
throw new Error("Provider must be a non-empty string.");
|
||||
}
|
||||
|
||||
const providerTrigger = page
|
||||
.locator('[data-testid="agent-provider-selector"]:visible, [data-testid="draft-provider-select"]:visible')
|
||||
.locator(
|
||||
'[data-testid="agent-provider-selector"]:visible, [data-testid="draft-provider-select"]:visible',
|
||||
)
|
||||
.first();
|
||||
if (
|
||||
await providerTrigger
|
||||
.getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, 'i'))
|
||||
.getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, "i"))
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
@@ -481,20 +516,18 @@ export const selectProvider = async (page: Page, provider: string) => {
|
||||
if (await providerTrigger.isVisible().catch(() => false)) {
|
||||
await providerTrigger.click();
|
||||
} else {
|
||||
const providerLabel = page.getByText('PROVIDER', { exact: true }).first();
|
||||
const providerLabel = page.getByText("PROVIDER", { exact: true }).first();
|
||||
await expect(providerLabel).toBeVisible();
|
||||
await providerLabel.click();
|
||||
}
|
||||
|
||||
const dialog = page.getByRole('dialog').last();
|
||||
const searchInput = dialog.getByRole('textbox', { name: /search provider/i }).first();
|
||||
const dialog = page.getByRole("dialog").last();
|
||||
const searchInput = dialog.getByRole("textbox", { name: /search provider/i }).first();
|
||||
if (await searchInput.isVisible().catch(() => false)) {
|
||||
await searchInput.fill(normalizedProvider);
|
||||
}
|
||||
|
||||
const option = dialog
|
||||
.getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, 'i'))
|
||||
.first();
|
||||
const option = dialog.getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, "i")).first();
|
||||
await expect(option).toBeVisible();
|
||||
await option.click();
|
||||
};
|
||||
@@ -502,15 +535,17 @@ export const selectProvider = async (page: Page, provider: string) => {
|
||||
export const selectModel = async (page: Page, model: string) => {
|
||||
const normalizedModel = model.trim();
|
||||
if (!normalizedModel) {
|
||||
throw new Error('Model must be a non-empty string.');
|
||||
throw new Error("Model must be a non-empty string.");
|
||||
}
|
||||
|
||||
const modelTrigger = page
|
||||
.locator('[data-testid="agent-model-selector"]:visible, [data-testid="draft-model-select"]:visible')
|
||||
.locator(
|
||||
'[data-testid="agent-model-selector"]:visible, [data-testid="draft-model-select"]:visible',
|
||||
)
|
||||
.first();
|
||||
if (
|
||||
await modelTrigger
|
||||
.getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, 'i'))
|
||||
.getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, "i"))
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
@@ -521,21 +556,21 @@ export const selectModel = async (page: Page, model: string) => {
|
||||
if (await modelTrigger.isVisible().catch(() => false)) {
|
||||
await modelTrigger.click();
|
||||
} else {
|
||||
const modelLabel = page.getByText('MODEL', { exact: true }).first();
|
||||
const modelLabel = page.getByText("MODEL", { exact: true }).first();
|
||||
await expect(modelLabel).toBeVisible();
|
||||
await modelLabel.click();
|
||||
}
|
||||
|
||||
// Wait for the model dropdown to open
|
||||
const searchInput = page.getByRole('textbox', { name: /search model/i });
|
||||
const searchInput = page.getByRole("textbox", { name: /search model/i });
|
||||
await expect(searchInput).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Type to search/filter models
|
||||
await searchInput.fill(normalizedModel);
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
const dialog = page.getByRole("dialog");
|
||||
const exactOption = dialog
|
||||
.getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, 'i'))
|
||||
.getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, "i"))
|
||||
.first();
|
||||
const exactVisible = await exactOption.isVisible().catch(() => false);
|
||||
if (exactVisible) {
|
||||
@@ -543,39 +578,39 @@ export const selectModel = async (page: Page, model: string) => {
|
||||
} else {
|
||||
// Modern labels include version suffixes (for example "Haiku 4.5"), so
|
||||
// select the first filtered result using keyboard confirm.
|
||||
await searchInput.press('Enter');
|
||||
await searchInput.press("Enter");
|
||||
}
|
||||
|
||||
// Wait for dropdown to close
|
||||
if (await searchInput.isVisible().catch(() => false)) {
|
||||
await page.keyboard.press('Escape').catch(() => undefined);
|
||||
await page.keyboard.press("Escape").catch(() => undefined);
|
||||
}
|
||||
await expect(searchInput).not.toBeVisible({ timeout: 5000 });
|
||||
};
|
||||
|
||||
export const selectMode = async (page: Page, mode: string) => {
|
||||
const modeTrigger = page
|
||||
.locator('[data-testid="agent-mode-selector"]:visible, [data-testid="draft-mode-select"]:visible')
|
||||
.locator(
|
||||
'[data-testid="agent-mode-selector"]:visible, [data-testid="draft-mode-select"]:visible',
|
||||
)
|
||||
.first();
|
||||
if (await modeTrigger.isVisible().catch(() => false)) {
|
||||
await modeTrigger.click();
|
||||
} else {
|
||||
const modeLabel = page.getByText('MODE', { exact: true }).first();
|
||||
const modeLabel = page.getByText("MODE", { exact: true }).first();
|
||||
await expect(modeLabel).toBeVisible();
|
||||
await modeLabel.click();
|
||||
}
|
||||
|
||||
// Wait for the mode dropdown to open
|
||||
const searchInput = page.getByRole('textbox', { name: /search mode/i });
|
||||
const searchInput = page.getByRole("textbox", { name: /search mode/i });
|
||||
await expect(searchInput).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Type to filter modes
|
||||
await searchInput.fill(mode);
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
const option = dialog
|
||||
.getByText(new RegExp(`^${escapeRegex(mode)}$`, 'i'))
|
||||
.first();
|
||||
const dialog = page.getByRole("dialog");
|
||||
const option = dialog.getByText(new RegExp(`^${escapeRegex(mode)}$`, "i")).first();
|
||||
await expect(option).toBeVisible();
|
||||
await option.click({ force: true });
|
||||
|
||||
@@ -605,7 +640,7 @@ export const createAgentWithConfig = async (page: Page, config: AgentConfig) =>
|
||||
|
||||
export const createAgentInRepo = async (
|
||||
page: Page,
|
||||
config: Pick<AgentConfig, 'directory' | 'prompt'>
|
||||
config: Pick<AgentConfig, "directory" | "prompt">,
|
||||
) => {
|
||||
await gotoHome(page);
|
||||
await ensureHostSelected(page);
|
||||
@@ -614,25 +649,25 @@ export const createAgentInRepo = async (
|
||||
};
|
||||
|
||||
export const waitForPermissionPrompt = async (page: Page, timeout = 30000) => {
|
||||
const promptText = page.getByTestId('permission-request-question').first();
|
||||
const promptText = page.getByTestId("permission-request-question").first();
|
||||
await expect(promptText).toBeVisible({ timeout });
|
||||
};
|
||||
|
||||
export const allowPermission = async (page: Page) => {
|
||||
const acceptButton = page.getByTestId('permission-request-accept').first();
|
||||
const acceptButton = page.getByTestId("permission-request-accept").first();
|
||||
await expect(acceptButton).toBeVisible({ timeout: 5000 });
|
||||
await acceptButton.click();
|
||||
};
|
||||
|
||||
export const denyPermission = async (page: Page) => {
|
||||
const denyButton = page.getByTestId('permission-request-deny').first();
|
||||
const denyButton = page.getByTestId("permission-request-deny").first();
|
||||
await expect(denyButton).toBeVisible({ timeout: 5000 });
|
||||
await denyButton.click();
|
||||
};
|
||||
|
||||
export async function waitForAgentFinishUI(page: Page, timeout = 30000) {
|
||||
// Wait for the stop button to disappear
|
||||
const stopButton = page.getByRole('button', { name: /stop|cancel/i });
|
||||
const stopButton = page.getByRole("button", { name: /stop|cancel/i });
|
||||
|
||||
// First, let's debug what's happening - wait a bit to see the state
|
||||
await page.waitForTimeout(2000);
|
||||
@@ -649,9 +684,11 @@ export async function waitForAgentFinishUI(page: Page, timeout = 30000) {
|
||||
const toolCallResult = page.getByText(/permission.*denied|denied|blocked/i);
|
||||
|
||||
// Wait for the tool call result to appear
|
||||
await expect(toolCallResult).toBeVisible({ timeout: 10000 }).catch(() => {
|
||||
// If no specific message, just wait for the button to disappear
|
||||
});
|
||||
await expect(toolCallResult)
|
||||
.toBeVisible({ timeout: 10000 })
|
||||
.catch(() => {
|
||||
// If no specific message, just wait for the button to disappear
|
||||
});
|
||||
|
||||
// Now wait for the stop button to disappear
|
||||
await expect(stopButton).not.toBeVisible({ timeout });
|
||||
|
||||
@@ -38,7 +38,7 @@ export async function ensureWorkspaceAgentPaneVisible(page: Page): Promise<void>
|
||||
|
||||
export async function sampleWorkspaceTabIds(
|
||||
page: Page,
|
||||
options: { durationMs?: number; intervalMs?: number } = {}
|
||||
options: { durationMs?: number; intervalMs?: number } = {},
|
||||
): Promise<string[][]> {
|
||||
const durationMs = options.durationMs ?? 2_500;
|
||||
const intervalMs = options.intervalMs ?? 50;
|
||||
|
||||
@@ -1,38 +1,38 @@
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
import { buildHostWorkspaceRoute } from '@/utils/host-routes';
|
||||
import { gotoHome } from './app';
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { gotoHome } from "./app";
|
||||
|
||||
export async function openNewAgentComposer(page: Page): Promise<void> {
|
||||
await gotoHome(page);
|
||||
}
|
||||
|
||||
export function workspaceLabelFromPath(value: string): string {
|
||||
const normalized = value.replace(/\\/g, '/').replace(/\/+$/, '');
|
||||
const parts = normalized.split('/').filter(Boolean);
|
||||
const normalized = value.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
const parts = normalized.split("/").filter(Boolean);
|
||||
return parts[parts.length - 1] ?? normalized;
|
||||
}
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function candidateWorkspaceIds(inputPath: string): string[] {
|
||||
const trimmed = inputPath.replace(/\/+$/, '');
|
||||
const trimmed = inputPath.replace(/\/+$/, "");
|
||||
const candidates = new Set<string>([trimmed]);
|
||||
if (trimmed.startsWith('/var/')) {
|
||||
if (trimmed.startsWith("/var/")) {
|
||||
candidates.add(`/private${trimmed}`);
|
||||
}
|
||||
if (trimmed.startsWith('/private/var/')) {
|
||||
candidates.add(trimmed.replace(/^\/private/, ''));
|
||||
if (trimmed.startsWith("/private/var/")) {
|
||||
candidates.add(trimmed.replace(/^\/private/, ""));
|
||||
}
|
||||
return Array.from(candidates);
|
||||
}
|
||||
|
||||
function workspaceRowLocator(page: Page, serverId: string, workspacePath: string) {
|
||||
const ids = candidateWorkspaceIds(workspacePath).map(
|
||||
(id) => `[data-testid="sidebar-workspace-row-${serverId}:${id}"]`
|
||||
(id) => `[data-testid="sidebar-workspace-row-${serverId}:${id}"]`,
|
||||
);
|
||||
return page.locator(ids.join(',')).first();
|
||||
return page.locator(ids.join(",")).first();
|
||||
}
|
||||
|
||||
export async function switchWorkspaceViaSidebar(input: {
|
||||
@@ -52,10 +52,10 @@ export async function switchWorkspaceViaSidebar(input: {
|
||||
|
||||
export async function expectWorkspaceHeader(
|
||||
page: Page,
|
||||
input: { title: string; subtitle: string }
|
||||
input: { title: string; subtitle: string },
|
||||
): Promise<void> {
|
||||
const titleLocator = page.getByTestId('workspace-header-title');
|
||||
const subtitleLocator = page.getByTestId('workspace-header-subtitle');
|
||||
const titleLocator = page.getByTestId("workspace-header-title");
|
||||
const subtitleLocator = page.getByTestId("workspace-header-subtitle");
|
||||
|
||||
await expect(titleLocator.first()).toHaveText(input.title, {
|
||||
timeout: 30_000,
|
||||
@@ -66,9 +66,9 @@ export async function expectWorkspaceHeader(
|
||||
}
|
||||
|
||||
export async function seedWorkspaceActivity(page: Page, marker: string): Promise<void> {
|
||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
||||
const input = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
await input.fill(marker);
|
||||
await input.press('Enter');
|
||||
await input.press("Enter");
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
import { mkdtemp, writeFile, rm, mkdir } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { execSync } from "node:child_process";
|
||||
import { mkdtemp, writeFile, rm, mkdir } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
type TempRepo = {
|
||||
path: string;
|
||||
@@ -9,29 +9,29 @@ type TempRepo = {
|
||||
};
|
||||
|
||||
export const createTempGitRepo = async (
|
||||
prefix = 'paseo-e2e-',
|
||||
options?: { withRemote?: boolean }
|
||||
prefix = "paseo-e2e-",
|
||||
options?: { withRemote?: boolean },
|
||||
): Promise<TempRepo> => {
|
||||
// Keep E2E repo paths short so terminal prompt + typed commands stay visible without zsh clipping.
|
||||
const tempRoot = process.platform === 'win32' ? tmpdir() : '/tmp';
|
||||
const tempRoot = process.platform === "win32" ? tmpdir() : "/tmp";
|
||||
const repoPath = await mkdtemp(path.join(tempRoot, prefix));
|
||||
const withRemote = options?.withRemote ?? false;
|
||||
|
||||
execSync('git init -b main', { cwd: repoPath, stdio: 'ignore' });
|
||||
execSync('git config user.email "e2e@paseo.test"', { cwd: repoPath, stdio: 'ignore' });
|
||||
execSync('git config user.name "Paseo E2E"', { cwd: repoPath, stdio: 'ignore' });
|
||||
execSync('git config commit.gpgsign false', { cwd: repoPath, stdio: 'ignore' });
|
||||
await writeFile(path.join(repoPath, 'README.md'), '# Temp Repo\n');
|
||||
execSync('git add README.md', { cwd: repoPath, stdio: 'ignore' });
|
||||
execSync('git commit -m "Initial commit"', { cwd: repoPath, stdio: 'ignore' });
|
||||
execSync("git init -b main", { cwd: repoPath, stdio: "ignore" });
|
||||
execSync('git config user.email "e2e@paseo.test"', { cwd: repoPath, stdio: "ignore" });
|
||||
execSync('git config user.name "Paseo E2E"', { cwd: repoPath, stdio: "ignore" });
|
||||
execSync("git config commit.gpgsign false", { cwd: repoPath, stdio: "ignore" });
|
||||
await writeFile(path.join(repoPath, "README.md"), "# Temp Repo\n");
|
||||
execSync("git add README.md", { cwd: repoPath, stdio: "ignore" });
|
||||
execSync('git commit -m "Initial commit"', { cwd: repoPath, stdio: "ignore" });
|
||||
|
||||
if (withRemote) {
|
||||
// Deterministic local remote to avoid relying on external auth/network in e2e.
|
||||
const remoteDir = path.join(repoPath, 'remote.git');
|
||||
const remoteDir = path.join(repoPath, "remote.git");
|
||||
await mkdir(remoteDir, { recursive: true });
|
||||
execSync(`git init --bare -b main ${remoteDir}`, { cwd: repoPath, stdio: 'ignore' });
|
||||
execSync(`git remote add origin ${remoteDir}`, { cwd: repoPath, stdio: 'ignore' });
|
||||
execSync('git push -u origin main', { cwd: repoPath, stdio: 'ignore' });
|
||||
execSync(`git init --bare -b main ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
|
||||
execSync(`git remote add origin ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
|
||||
execSync("git push -u origin main", { cwd: repoPath, stdio: "ignore" });
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// https://docs.expo.dev/guides/using-eslint/
|
||||
const { defineConfig } = require('eslint/config');
|
||||
const expoConfig = require('eslint-config-expo/flat');
|
||||
const { defineConfig } = require("eslint/config");
|
||||
const expoConfig = require("eslint-config-expo/flat");
|
||||
|
||||
module.exports = defineConfig([
|
||||
expoConfig,
|
||||
{
|
||||
ignores: ['dist/*'],
|
||||
ignores: ["dist/*"],
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -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,11 +1,13 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"main": "index.ts",
|
||||
"version": "0.1.28",
|
||||
"version": "0.1.33",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"reset-project": "node ./scripts/reset-project.js",
|
||||
"build:workspace-deps": "npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/expo-two-way-audio",
|
||||
"eas-build-post-install": "npm run build:workspace-deps",
|
||||
"android": "npm run android:development",
|
||||
"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",
|
||||
@@ -14,35 +16,26 @@
|
||||
"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",
|
||||
"test:e2e": "playwright test",
|
||||
"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",
|
||||
"build:web": "npm run build:workspace-deps && expo export --platform web",
|
||||
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/expo-two-way-audio": "0.1.28",
|
||||
"@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.28",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.33",
|
||||
"@getpaseo/highlight": "0.1.33",
|
||||
"@getpaseo/server": "0.1.33",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/css": "^1.3.0",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@lezer/html": "^1.3.13",
|
||||
"@lezer/javascript": "^1.5.4",
|
||||
"@lezer/json": "^1.0.3",
|
||||
"@lezer/markdown": "^1.6.2",
|
||||
"@lezer/python": "^1.1.18",
|
||||
"@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",
|
||||
@@ -51,10 +44,13 @@
|
||||
"@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-clipboard": "^0.2.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-image": "^0.9.0",
|
||||
"@xterm/addon-ligatures": "^0.10.0",
|
||||
"@xterm/addon-search": "^0.16.0",
|
||||
"@xterm/addon-unicode11": "^0.9.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"base64-js": "^1.5.1",
|
||||
@@ -83,7 +79,6 @@
|
||||
"expo-system-ui": "~6.0.7",
|
||||
"expo-updates": "~29.0.12",
|
||||
"expo-web-browser": "~15.0.8",
|
||||
"lezer-elixir": "^1.1.2",
|
||||
"lucide-react-native": "^0.546.0",
|
||||
"mnemonic-id": "^3.2.7",
|
||||
"react": "19.1.4",
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
// E2E_METRO_PORT is set dynamically by global-setup.ts after finding a free port
|
||||
// This allows multiple test runs in parallel across different worktrees
|
||||
const baseURL = process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? '8081'}`;
|
||||
const baseURL =
|
||||
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,
|
||||
@@ -16,17 +17,17 @@ export default defineConfig({
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
retries: process.env.CI ? 1 : 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 Chrome',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
name: "Desktop Chrome",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
// Note: Metro is started by global-setup.ts on a dynamic port to allow parallel test runs
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
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",
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
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",
|
||||
|
||||
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>
|
||||
@@ -91,7 +91,7 @@ const moveDirectories = async (userInput) => {
|
||||
userInput === "y"
|
||||
? `\n3. Delete the /${exampleDir} directory when you're done referencing it.`
|
||||
: ""
|
||||
}`
|
||||
}`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`❌ Error during script execution: ${error.message}`);
|
||||
@@ -108,5 +108,5 @@ rl.question(
|
||||
console.log("❌ Invalid input. Please enter 'Y' or 'N'.");
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,36 +1,36 @@
|
||||
export const WELCOME_ROUTE = '/welcome'
|
||||
export const WELCOME_ROUTE = "/welcome";
|
||||
|
||||
export function shouldWaitOnStartupRace(input: {
|
||||
onlineServerId: string | null
|
||||
hasTimedOut: boolean
|
||||
isDesktopStartupRace: boolean
|
||||
daemonCount: number
|
||||
pathname: string
|
||||
onlineServerId: string | null;
|
||||
hasTimedOut: boolean;
|
||||
isDesktopStartupRace: boolean;
|
||||
daemonCount: number;
|
||||
pathname: string;
|
||||
}): boolean {
|
||||
if (input.onlineServerId) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
if (input.pathname === WELCOME_ROUTE) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
if (input.hasTimedOut) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
return input.isDesktopStartupRace || input.daemonCount > 0
|
||||
return input.isDesktopStartupRace || input.daemonCount > 0;
|
||||
}
|
||||
|
||||
export function shouldRedirectToWelcome(input: {
|
||||
onlineServerId: string | null
|
||||
hasTimedOut: boolean
|
||||
pathname: string
|
||||
isDesktopStartupRace: boolean
|
||||
daemonCount: number
|
||||
onlineServerId: string | null;
|
||||
hasTimedOut: boolean;
|
||||
pathname: string;
|
||||
isDesktopStartupRace: boolean;
|
||||
daemonCount: number;
|
||||
}): boolean {
|
||||
if (input.onlineServerId || !input.hasTimedOut) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
if (input.pathname !== '/' && input.pathname !== '') {
|
||||
return false
|
||||
if (input.pathname !== "/" && input.pathname !== "") {
|
||||
return false;
|
||||
}
|
||||
return input.isDesktopStartupRace || input.daemonCount > 0
|
||||
return input.isDesktopStartupRace || input.daemonCount > 0;
|
||||
}
|
||||
|
||||
@@ -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,11 +53,10 @@ 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";
|
||||
import { listenToDesktopNotificationClicks } from "@/desktop/notifications/desktop-notifications";
|
||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { queryClient } from "@/query/query-client";
|
||||
import {
|
||||
@@ -58,18 +64,18 @@ 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);
|
||||
|
||||
function PushNotificationRouter() {
|
||||
@@ -78,33 +84,37 @@ function PushNotificationRouter() {
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS === "web") {
|
||||
if (getTauri()) {
|
||||
let removeDesktopNotificationListener: (() => void) | null = null;
|
||||
let cancelled = false;
|
||||
|
||||
if (getIsDesktop()) {
|
||||
void ensureOsNotificationPermission();
|
||||
|
||||
let disposed = false;
|
||||
let unlisten: (() => void) | null = null;
|
||||
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 listenToDesktopNotificationClicks((payload) => {
|
||||
router.push(buildNotificationRoute(payload.data) as any);
|
||||
})
|
||||
.then((cleanup) => {
|
||||
if (disposed) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
unlisten = cleanup;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
"[OSNotifications][Desktop] Failed to register notification click listener",
|
||||
error
|
||||
);
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
unlisten?.();
|
||||
};
|
||||
void Promise.resolve(unlistenResult).then((unlisten) => {
|
||||
if (typeof unlisten !== "function") {
|
||||
return;
|
||||
}
|
||||
if (cancelled) {
|
||||
unlisten();
|
||||
return;
|
||||
}
|
||||
removeDesktopNotificationListener = unlisten;
|
||||
});
|
||||
}
|
||||
|
||||
const target = globalThis as unknown as EventTarget;
|
||||
@@ -114,15 +124,12 @@ function PushNotificationRouter() {
|
||||
router.push(buildNotificationRoute(customEvent.detail?.data) as any);
|
||||
};
|
||||
|
||||
target.addEventListener(
|
||||
WEB_NOTIFICATION_CLICK_EVENT,
|
||||
openFromWebClick as EventListener
|
||||
);
|
||||
target.addEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener);
|
||||
|
||||
return () => {
|
||||
target.removeEventListener(
|
||||
WEB_NOTIFICATION_CLICK_EVENT,
|
||||
openFromWebClick as EventListener
|
||||
);
|
||||
cancelled = true;
|
||||
removeDesktopNotificationListener?.();
|
||||
target.removeEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -150,8 +157,7 @@ function PushNotificationRouter() {
|
||||
router.push(buildNotificationRoute(data) as any);
|
||||
};
|
||||
|
||||
const subscription =
|
||||
Notifications.addNotificationResponseReceivedListener(openFromResponse);
|
||||
const subscription = Notifications.addNotificationResponseReceivedListener(openFromResponse);
|
||||
|
||||
void Notifications.getLastNotificationResponseAsync().then((response) => {
|
||||
if (response) {
|
||||
@@ -168,18 +174,14 @@ function PushNotificationRouter() {
|
||||
}
|
||||
|
||||
function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
|
||||
const { client } = useHostRuntimeSession(daemon.serverId);
|
||||
const client = useHostRuntimeClient(daemon.serverId);
|
||||
|
||||
if (!client) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SessionProvider
|
||||
key={daemon.serverId}
|
||||
serverId={daemon.serverId}
|
||||
client={client}
|
||||
>
|
||||
<SessionProvider key={daemon.serverId} serverId={daemon.serverId} client={client}>
|
||||
{null}
|
||||
</SessionProvider>
|
||||
);
|
||||
@@ -244,6 +246,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;
|
||||
@@ -257,23 +262,14 @@ 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 toggleBothSidebars = usePanelStore((state) => state.toggleBothSidebars);
|
||||
const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode);
|
||||
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
|
||||
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
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,
|
||||
@@ -281,27 +277,57 @@ function AppContainer({
|
||||
toggleAgentList,
|
||||
selectedAgentId,
|
||||
toggleFileExplorer,
|
||||
toggleBothSidebars,
|
||||
toggleFocusMode,
|
||||
});
|
||||
const {
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
windowWidth,
|
||||
animateToOpen,
|
||||
animateToClose,
|
||||
isGesturing,
|
||||
} = useSidebarAnimation();
|
||||
|
||||
// Track initial touch position for manual activation
|
||||
const containerStyle = useMemo(
|
||||
() => ({ flex: 1 as const, backgroundColor: theme.colors.surface0 }),
|
||||
[theme.colors.surface0],
|
||||
);
|
||||
|
||||
const content = (
|
||||
<View style={containerStyle}>
|
||||
<View style={rowStyle}>
|
||||
{!isMobile && chromeEnabled && !isFocusModeEnabled && <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, windowWidth, animateToOpen, animateToClose, isGesturing } =
|
||||
useSidebarAnimation();
|
||||
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];
|
||||
@@ -315,13 +341,11 @@ 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();
|
||||
}
|
||||
@@ -330,19 +354,17 @@ function AppContainer({
|
||||
isGesturing.value = true;
|
||||
})
|
||||
.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(
|
||||
newTranslateX,
|
||||
[-windowWidth, 0],
|
||||
[0, 1],
|
||||
Extrapolation.CLAMP
|
||||
Extrapolation.CLAMP,
|
||||
);
|
||||
})
|
||||
.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;
|
||||
if (shouldOpen) {
|
||||
animateToOpen();
|
||||
@@ -362,36 +384,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>
|
||||
);
|
||||
}
|
||||
@@ -421,6 +422,7 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
|
||||
<VoiceProvider>
|
||||
<OfferLinkListener upsertDaemonFromOfferUrl={upsertConnectionFromOfferUrl} />
|
||||
<HostSessionManager />
|
||||
<FaviconStatusSync />
|
||||
{children}
|
||||
</VoiceProvider>
|
||||
);
|
||||
@@ -451,7 +453,9 @@ function OfferLinkListener({
|
||||
});
|
||||
};
|
||||
|
||||
void Linking.getInitialURL().then(handleUrl).catch(() => undefined);
|
||||
void Linking.getInitialURL()
|
||||
.then(handleUrl)
|
||||
.catch(() => undefined);
|
||||
|
||||
const subscription = Linking.addEventListener("url", (event) => {
|
||||
handleUrl(event.url);
|
||||
@@ -467,12 +471,23 @@ function OfferLinkListener({
|
||||
}
|
||||
|
||||
function AppWithSidebar({ children }: { children: ReactNode }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const params = useGlobalSearchParams<{ open?: string | string[] }>();
|
||||
useFaviconStatus();
|
||||
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
|
||||
const selectedAgentKey = useMemo(() => {
|
||||
@@ -499,6 +514,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
|
||||
@@ -552,14 +592,13 @@ function MissingDaemonView() {
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<GestureHandlerRootView
|
||||
style={{ flex: 1, backgroundColor: darkTheme.colors.surface0 }}
|
||||
>
|
||||
<GestureHandlerRootView style={{ flex: 1, backgroundColor: darkTheme.colors.surface0 }}>
|
||||
<NavigationActiveWorkspaceObserver />
|
||||
<PortalProvider>
|
||||
<SafeAreaProvider>
|
||||
<KeyboardProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<QueryProvider>
|
||||
<QueryProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<HostRuntimeBootstrapProvider>
|
||||
<PushNotificationRouter />
|
||||
<ProvidersWrapper>
|
||||
@@ -584,8 +623,7 @@ export default function RootLayout() {
|
||||
options={{ gestureEnabled: false }}
|
||||
/>
|
||||
<Stack.Screen name="h/[serverId]/index" />
|
||||
<Stack.Screen name="h/[serverId]/agents" />
|
||||
<Stack.Screen name="h/[serverId]/new-agent" />
|
||||
<Stack.Screen name="h/[serverId]/sessions" />
|
||||
<Stack.Screen name="h/[serverId]/open-project" />
|
||||
<Stack.Screen name="h/[serverId]/settings" />
|
||||
<Stack.Screen name="pair-scan" />
|
||||
@@ -596,8 +634,8 @@ export default function RootLayout() {
|
||||
</SidebarAnimationProvider>
|
||||
</ProvidersWrapper>
|
||||
</HostRuntimeBootstrapProvider>
|
||||
</QueryProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</QueryProvider>
|
||||
</KeyboardProvider>
|
||||
</SafeAreaProvider>
|
||||
</PortalProvider>
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useHostRuntimeSession } from "@/runtime/host-runtime";
|
||||
import {
|
||||
buildHostRootRoute,
|
||||
buildHostWorkspaceAgentRoute,
|
||||
} from "@/utils/host-routes";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
|
||||
export default function HostAgentReadyRoute() {
|
||||
const router = useRouter();
|
||||
@@ -16,7 +14,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;
|
||||
@@ -38,7 +37,11 @@ export default function HostAgentReadyRoute() {
|
||||
if (normalizedCwd) {
|
||||
redirectedRef.current = true;
|
||||
router.replace(
|
||||
buildHostWorkspaceAgentRoute(serverId, normalizedCwd, agentId) as any
|
||||
prepareWorkspaceTab({
|
||||
serverId,
|
||||
workspaceId: normalizedCwd,
|
||||
target: { kind: "agent", agentId },
|
||||
}) as any,
|
||||
);
|
||||
}
|
||||
}, [agentCwd, agentId, router, serverId]);
|
||||
@@ -77,7 +80,13 @@ export default function HostAgentReadyRoute() {
|
||||
const cwd = result?.agent?.cwd?.trim();
|
||||
redirectedRef.current = true;
|
||||
if (cwd) {
|
||||
router.replace(buildHostWorkspaceAgentRoute(serverId, cwd, agentId) as any);
|
||||
router.replace(
|
||||
prepareWorkspaceTab({
|
||||
serverId,
|
||||
workspaceId: cwd,
|
||||
target: { kind: "agent", agentId },
|
||||
}) as any,
|
||||
);
|
||||
return;
|
||||
}
|
||||
router.replace(buildHostRootRoute(serverId) as any);
|
||||
|
||||
@@ -5,9 +5,9 @@ import { useFormPreferences } from "@/hooks/use-form-preferences";
|
||||
import {
|
||||
buildHostOpenProjectRoute,
|
||||
buildHostRootRoute,
|
||||
buildHostWorkspaceAgentRoute,
|
||||
buildHostWorkspaceRoute,
|
||||
} from "@/utils/host-routes";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
|
||||
const HOST_ROOT_REDIRECT_DELAY_MS = 300;
|
||||
|
||||
@@ -17,11 +17,11 @@ export default function HostIndexRoute() {
|
||||
const params = useLocalSearchParams<{ serverId?: string }>();
|
||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||
const { isLoading: preferencesLoading } = useFormPreferences();
|
||||
const sessionAgents = useSessionStore(
|
||||
(state) => (serverId ? state.sessions[serverId]?.agents : undefined)
|
||||
const sessionAgents = useSessionStore((state) =>
|
||||
serverId ? state.sessions[serverId]?.agents : undefined,
|
||||
);
|
||||
const sessionWorkspaces = useSessionStore(
|
||||
(state) => (serverId ? state.sessions[serverId]?.workspaces : undefined)
|
||||
const sessionWorkspaces = useSessionStore((state) =>
|
||||
serverId ? state.sessions[serverId]?.workspaces : undefined,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -44,12 +44,10 @@ export default function HostIndexRoute() {
|
||||
? Array.from(sessionAgents.values()).filter((agent) => !agent.archivedAt)
|
||||
: [];
|
||||
visibleAgents.sort(
|
||||
(left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime()
|
||||
(left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime(),
|
||||
);
|
||||
|
||||
const visibleWorkspaces = sessionWorkspaces
|
||||
? Array.from(sessionWorkspaces.values())
|
||||
: [];
|
||||
const visibleWorkspaces = sessionWorkspaces ? Array.from(sessionWorkspaces.values()) : [];
|
||||
visibleWorkspaces.sort((left, right) => {
|
||||
const leftTime = left.activityAt?.getTime() ?? Number.NEGATIVE_INFINITY;
|
||||
const rightTime = right.activityAt?.getTime() ?? Number.NEGATIVE_INFINITY;
|
||||
@@ -59,11 +57,11 @@ export default function HostIndexRoute() {
|
||||
const primaryAgent = visibleAgents[0];
|
||||
if (primaryAgent?.cwd?.trim()) {
|
||||
router.replace(
|
||||
buildHostWorkspaceAgentRoute(
|
||||
prepareWorkspaceTab({
|
||||
serverId,
|
||||
primaryAgent.cwd.trim(),
|
||||
primaryAgent.id
|
||||
) as any
|
||||
workspaceId: primaryAgent.cwd.trim(),
|
||||
target: { kind: "agent", agentId: primaryAgent.id },
|
||||
}) as any,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -78,14 +76,7 @@ export default function HostIndexRoute() {
|
||||
}, HOST_ROOT_REDIRECT_DELAY_MS);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [
|
||||
pathname,
|
||||
preferencesLoading,
|
||||
router,
|
||||
serverId,
|
||||
sessionAgents,
|
||||
sessionWorkspaces,
|
||||
]);
|
||||
}, [pathname, preferencesLoading, router, serverId, sessionAgents, sessionWorkspaces]);
|
||||
|
||||
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,9 +1,9 @@
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { AgentsScreen } from "@/screens/agents-screen";
|
||||
import { SessionsScreen } from "@/screens/sessions-screen";
|
||||
|
||||
export default function HostAgentsRoute() {
|
||||
const params = useLocalSearchParams<{ serverId?: string }>();
|
||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||
|
||||
return <AgentsScreen serverId={serverId} />;
|
||||
return <SessionsScreen serverId={serverId} />;
|
||||
}
|
||||
@@ -1,25 +1,89 @@
|
||||
import { useGlobalSearchParams, usePathname } from 'expo-router'
|
||||
import { WorkspaceScreen } from '@/screens/workspace/workspace-screen'
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useGlobalSearchParams, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
||||
import {
|
||||
parseHostWorkspaceRouteFromPathname,
|
||||
buildHostWorkspaceRoute,
|
||||
decodeWorkspaceIdFromPathSegment,
|
||||
parseWorkspaceOpenIntent,
|
||||
} from '@/utils/host-routes'
|
||||
type WorkspaceOpenIntent,
|
||||
} from "@/utils/host-routes";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
|
||||
function getParamValue(value: string | string[] | undefined): string {
|
||||
if (typeof value === "string") {
|
||||
return value.trim();
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const firstValue = value[0];
|
||||
return typeof firstValue === "string" ? firstValue.trim() : "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function getOpenIntentTarget(openIntent: WorkspaceOpenIntent): WorkspaceTabTarget {
|
||||
if (openIntent.kind === "agent") {
|
||||
return { kind: "agent", agentId: openIntent.agentId };
|
||||
}
|
||||
if (openIntent.kind === "terminal") {
|
||||
return { kind: "terminal", terminalId: openIntent.terminalId };
|
||||
}
|
||||
if (openIntent.kind === "file") {
|
||||
return { kind: "file", path: openIntent.path };
|
||||
}
|
||||
return { kind: "draft", draftId: openIntent.draftId };
|
||||
}
|
||||
|
||||
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 openIntent = parseWorkspaceOpenIntent(openValue)
|
||||
const router = useRouter();
|
||||
const consumedIntentRef = useRef<string | null>(null);
|
||||
const params = useLocalSearchParams<{
|
||||
serverId?: string | string[];
|
||||
workspaceId?: string | string[];
|
||||
}>();
|
||||
const globalParams = useGlobalSearchParams<{
|
||||
open?: string | string[];
|
||||
}>();
|
||||
const serverId = getParamValue(params.serverId);
|
||||
const workspaceValue = getParamValue(params.workspaceId);
|
||||
const workspaceId = workspaceValue
|
||||
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
|
||||
: "";
|
||||
const openValue = getParamValue(globalParams.open);
|
||||
|
||||
useEffect(() => {
|
||||
if (!openValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
const consumptionKey = `${serverId}:${workspaceId}:${openValue}`;
|
||||
if (consumedIntentRef.current === consumptionKey) {
|
||||
return;
|
||||
}
|
||||
consumedIntentRef.current = consumptionKey;
|
||||
|
||||
const openIntent = parseWorkspaceOpenIntent(openValue);
|
||||
const route = openIntent
|
||||
? prepareWorkspaceTab({
|
||||
serverId,
|
||||
workspaceId,
|
||||
target: getOpenIntentTarget(openIntent),
|
||||
pin: openIntent.kind === "agent",
|
||||
})
|
||||
: buildHostWorkspaceRoute(serverId, workspaceId);
|
||||
|
||||
router.replace(route as any);
|
||||
}, [openValue, router, serverId, workspaceId]);
|
||||
|
||||
if (openValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkspaceScreen
|
||||
key={`${serverId}:${workspaceId}`}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
openIntent={openIntent}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,82 +1,82 @@
|
||||
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 { buildHostRootRoute } from '@/utils/host-routes'
|
||||
import { StartupSplashScreen } from '@/screens/startup-splash-screen'
|
||||
import { WelcomeScreen } from '@/components/welcome-screen'
|
||||
import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runtime'
|
||||
import { useEffect, useSyncExternalStore, useState } from "react";
|
||||
import { usePathname, useRouter } from "expo-router";
|
||||
import { useHosts } from "@/runtime/host-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";
|
||||
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
|
||||
import {
|
||||
shouldRedirectToWelcome,
|
||||
shouldWaitOnStartupRace,
|
||||
WELCOME_ROUTE,
|
||||
} from '@/app-support/index-startup'
|
||||
} from "@/app-support/index-startup";
|
||||
|
||||
const STARTUP_TIMEOUT_MS = 30_000
|
||||
const STARTUP_TIMEOUT_MS = 30_000;
|
||||
function useAnyHostOnline(serverIds: string[]): string | null {
|
||||
const runtime = getHostRuntimeStore()
|
||||
const runtime = getHostRuntimeStore();
|
||||
return useSyncExternalStore(
|
||||
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
||||
() => {
|
||||
let firstOnlineServerId: string | null = null
|
||||
let firstOnlineAt: string | null = null
|
||||
let firstOnlineServerId: string | null = null;
|
||||
let firstOnlineAt: string | null = null;
|
||||
for (const serverId of serverIds) {
|
||||
const snapshot = runtime.getSnapshot(serverId)
|
||||
const lastOnlineAt = snapshot?.lastOnlineAt ?? null
|
||||
const snapshot = runtime.getSnapshot(serverId);
|
||||
const lastOnlineAt = snapshot?.lastOnlineAt ?? null;
|
||||
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
|
||||
continue
|
||||
continue;
|
||||
}
|
||||
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
|
||||
firstOnlineAt = lastOnlineAt
|
||||
firstOnlineServerId = serverId
|
||||
firstOnlineAt = lastOnlineAt;
|
||||
firstOnlineServerId = serverId;
|
||||
}
|
||||
}
|
||||
return firstOnlineServerId
|
||||
return firstOnlineServerId;
|
||||
},
|
||||
() => {
|
||||
let firstOnlineServerId: string | null = null
|
||||
let firstOnlineAt: string | null = null
|
||||
let firstOnlineServerId: string | null = null;
|
||||
let firstOnlineAt: string | null = null;
|
||||
for (const serverId of serverIds) {
|
||||
const snapshot = runtime.getSnapshot(serverId)
|
||||
const lastOnlineAt = snapshot?.lastOnlineAt ?? null
|
||||
const snapshot = runtime.getSnapshot(serverId);
|
||||
const lastOnlineAt = snapshot?.lastOnlineAt ?? null;
|
||||
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
|
||||
continue
|
||||
continue;
|
||||
}
|
||||
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
|
||||
firstOnlineAt = lastOnlineAt
|
||||
firstOnlineServerId = serverId
|
||||
firstOnlineAt = lastOnlineAt;
|
||||
firstOnlineServerId = serverId;
|
||||
}
|
||||
}
|
||||
return firstOnlineServerId
|
||||
}
|
||||
)
|
||||
return firstOnlineServerId;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export default function Index() {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const daemons = useHosts()
|
||||
const [hasTimedOut, setHasTimedOut] = useState(false)
|
||||
const isDesktopStartupRace = shouldUseManagedDesktopDaemon()
|
||||
const onlineServerId = useAnyHostOnline(daemons.map((daemon) => daemon.serverId))
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const daemons = useHosts();
|
||||
const [hasTimedOut, setHasTimedOut] = useState(false);
|
||||
const isDesktopStartupRace = shouldUseDesktopDaemon();
|
||||
const onlineServerId = useAnyHostOnline(daemons.map((daemon) => daemon.serverId));
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setHasTimedOut(true)
|
||||
}, STARTUP_TIMEOUT_MS)
|
||||
setHasTimedOut(true);
|
||||
}, STARTUP_TIMEOUT_MS);
|
||||
return () => {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [])
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onlineServerId) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
if (pathname !== '/' && pathname !== '') {
|
||||
return
|
||||
if (pathname !== "/" && pathname !== "") {
|
||||
return;
|
||||
}
|
||||
router.replace(buildHostRootRoute(onlineServerId) as any)
|
||||
}, [onlineServerId, pathname, router])
|
||||
router.replace(buildHostRootRoute(onlineServerId) as any);
|
||||
}, [onlineServerId, pathname, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -88,10 +88,10 @@ export default function Index() {
|
||||
daemonCount: daemons.length,
|
||||
})
|
||||
) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
router.replace(WELCOME_ROUTE as any)
|
||||
}, [daemons.length, hasTimedOut, isDesktopStartupRace, onlineServerId, pathname, router])
|
||||
router.replace(WELCOME_ROUTE as any);
|
||||
}, [daemons.length, hasTimedOut, isDesktopStartupRace, onlineServerId, pathname, router]);
|
||||
|
||||
if (
|
||||
shouldWaitOnStartupRace({
|
||||
@@ -102,12 +102,12 @@ export default function Index() {
|
||||
pathname,
|
||||
})
|
||||
) {
|
||||
return <StartupSplashScreen />
|
||||
return <StartupSplashScreen />;
|
||||
}
|
||||
|
||||
if (!onlineServerId) {
|
||||
return <WelcomeScreen />
|
||||
return <WelcomeScreen />;
|
||||
}
|
||||
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -11,10 +11,7 @@ import { NameHostModal } from "@/components/name-host-modal";
|
||||
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
|
||||
import { connectToDaemon } from "@/utils/test-daemon-connection";
|
||||
import { ConnectionOfferSchema } from "@server/shared/connection-offer";
|
||||
import {
|
||||
buildHostRootRoute,
|
||||
buildHostSettingsRoute,
|
||||
} from "@/utils/host-routes";
|
||||
import { buildHostRootRoute, buildHostSettingsRoute } from "@/utils/host-routes";
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
@@ -148,8 +145,7 @@ export default function PairScanScreen() {
|
||||
targetServerId?: string;
|
||||
}>();
|
||||
const source = typeof params.source === "string" ? params.source : "settings";
|
||||
const sourceServerId =
|
||||
typeof params.sourceServerId === "string" ? params.sourceServerId : null;
|
||||
const sourceServerId = typeof params.sourceServerId === "string" ? params.sourceServerId : null;
|
||||
const targetServerId = typeof params.targetServerId === "string" ? params.targetServerId : null;
|
||||
const daemons = useHosts();
|
||||
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl, renameHost } = useHostMutations();
|
||||
@@ -157,15 +153,22 @@ export default function PairScanScreen() {
|
||||
const [permission, requestPermission] = useCameraPermissions();
|
||||
const [isPairing, setIsPairing] = useState(false);
|
||||
const lastScannedRef = useRef<string | null>(null);
|
||||
const [pendingNameHost, setPendingNameHost] = useState<{ serverId: string; hostname: string | null } | null>(null);
|
||||
const [pendingNameHost, setPendingNameHost] = useState<{
|
||||
serverId: string;
|
||||
hostname: string | null;
|
||||
} | null>(null);
|
||||
const pendingNameHostname = useSessionStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
if (!pendingNameHost) return null;
|
||||
return state.sessions[pendingNameHost.serverId]?.serverInfo?.hostname ?? pendingNameHost.hostname ?? null;
|
||||
return (
|
||||
state.sessions[pendingNameHost.serverId]?.serverInfo?.hostname ??
|
||||
pendingNameHost.hostname ??
|
||||
null
|
||||
);
|
||||
},
|
||||
[pendingNameHost]
|
||||
)
|
||||
[pendingNameHost],
|
||||
),
|
||||
);
|
||||
|
||||
const returnToSource = useCallback(
|
||||
@@ -190,7 +193,7 @@ export default function PairScanScreen() {
|
||||
router.replace(buildHostSettingsRoute(settingsServerId) as any);
|
||||
}
|
||||
},
|
||||
[router, source, sourceServerId, targetServerId]
|
||||
[router, source, sourceServerId, targetServerId],
|
||||
);
|
||||
|
||||
const closeToSource = useCallback(() => {
|
||||
@@ -238,7 +241,10 @@ export default function PairScanScreen() {
|
||||
|
||||
if (targetServerId && offer.serverId !== targetServerId) {
|
||||
lastScannedRef.current = null;
|
||||
Alert.alert("Wrong daemon", `That QR code belongs to ${offer.serverId}, not ${targetServerId}.`);
|
||||
Alert.alert(
|
||||
"Wrong daemon",
|
||||
`That QR code belongs to ${offer.serverId}, not ${targetServerId}.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -270,7 +276,7 @@ export default function PairScanScreen() {
|
||||
setIsPairing(false);
|
||||
}
|
||||
},
|
||||
[daemons, isPairing, pendingNameHost, returnToSource, targetServerId, upsertDaemonFromOfferUrl]
|
||||
[daemons, isPairing, pendingNameHost, returnToSource, targetServerId, upsertDaemonFromOfferUrl],
|
||||
);
|
||||
|
||||
if (Platform.OS === "web") {
|
||||
@@ -334,10 +340,7 @@ export default function PairScanScreen() {
|
||||
<Text style={styles.permissionBody}>
|
||||
Allow camera access to scan the pairing QR code from your daemon.
|
||||
</Text>
|
||||
<Pressable
|
||||
style={styles.permissionButton}
|
||||
onPress={() => void requestPermission()}
|
||||
>
|
||||
<Pressable style={styles.permissionButton} onPress={() => void requestPermission()}>
|
||||
<Text style={styles.permissionButtonText}>Grant permission</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
@@ -356,9 +359,7 @@ export default function PairScanScreen() {
|
||||
<View style={[styles.corner, styles.cornerBL]} />
|
||||
<View style={[styles.corner, styles.cornerBR]} />
|
||||
</View>
|
||||
<Text style={styles.helperText}>
|
||||
Point your camera at the pairing QR code.
|
||||
</Text>
|
||||
<Text style={styles.helperText}>Point your camera at the pairing QR code.</Text>
|
||||
{isPairing ? (
|
||||
<Text style={[styles.helperText, { color: theme.colors.foreground }]}>
|
||||
Pairing…
|
||||
|
||||
@@ -18,9 +18,7 @@ export default function LegacySettingsRoute() {
|
||||
return null;
|
||||
}
|
||||
if (preferences.serverId) {
|
||||
const match = daemons.find(
|
||||
(daemon) => daemon.serverId === preferences.serverId
|
||||
);
|
||||
const match = daemons.find((daemon) => daemon.serverId === preferences.serverId);
|
||||
if (match) {
|
||||
return match.serverId;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { WelcomeScreen } from '@/components/welcome-screen'
|
||||
import { WelcomeScreen } from "@/components/welcome-screen";
|
||||
|
||||
export default function WelcomeRoute() {
|
||||
return <WelcomeScreen />
|
||||
return <WelcomeScreen />;
|
||||
}
|
||||
|
||||
@@ -86,10 +86,7 @@ export function createLocalFileAttachmentStore(params: {
|
||||
storageType: Extract<AttachmentStorageType, "desktop-file" | "native-file">;
|
||||
baseDirectoryName: string;
|
||||
resolvePreviewUrl: (attachment: AttachmentMetadata) => Promise<string>;
|
||||
releasePreviewUrl?: (input: {
|
||||
attachment: AttachmentMetadata;
|
||||
url: string;
|
||||
}) => Promise<void>;
|
||||
releasePreviewUrl?: (input: { attachment: AttachmentMetadata; url: string }) => Promise<void>;
|
||||
}): AttachmentStore {
|
||||
const baseDirectory = FileSystem.cacheDirectory
|
||||
? `${FileSystem.cacheDirectory}${params.baseDirectoryName}/`
|
||||
@@ -201,7 +198,7 @@ export function createLocalFileAttachmentStore(params: {
|
||||
await FileSystem.deleteAsync(`${baseDirectory}${entryName}`, {
|
||||
idempotent: true,
|
||||
});
|
||||
})
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -47,7 +47,7 @@ export async function persistAttachmentFromFileUri(input: {
|
||||
}
|
||||
|
||||
export async function encodeAttachmentsForSend(
|
||||
attachments: readonly AttachmentMetadata[] | undefined
|
||||
attachments: readonly AttachmentMetadata[] | undefined,
|
||||
): Promise<Array<{ data: string; mimeType: string }> | undefined> {
|
||||
if (!attachments || attachments.length === 0) {
|
||||
return undefined;
|
||||
@@ -69,18 +69,16 @@ export async function encodeAttachmentsForSend(
|
||||
});
|
||||
return null;
|
||||
}
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
const valid = encoded.filter(
|
||||
(entry): entry is { data: string; mimeType: string } => entry !== null
|
||||
(entry): entry is { data: string; mimeType: string } => entry !== null,
|
||||
);
|
||||
return valid.length > 0 ? valid : undefined;
|
||||
}
|
||||
|
||||
export async function resolveAttachmentPreviewUrl(
|
||||
attachment: AttachmentMetadata
|
||||
): Promise<string> {
|
||||
export async function resolveAttachmentPreviewUrl(attachment: AttachmentMetadata): Promise<string> {
|
||||
const store = await getAttachmentStore();
|
||||
return await store.resolvePreviewUrl({ attachment });
|
||||
}
|
||||
@@ -97,7 +95,7 @@ export async function releaseAttachmentPreviewUrl(input: {
|
||||
}
|
||||
|
||||
export async function deleteAttachments(
|
||||
attachments: readonly AttachmentMetadata[] | undefined
|
||||
attachments: readonly AttachmentMetadata[] | undefined,
|
||||
): Promise<void> {
|
||||
if (!attachments || attachments.length === 0) {
|
||||
return;
|
||||
@@ -113,7 +111,7 @@ export async function deleteAttachments(
|
||||
error,
|
||||
});
|
||||
}
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,27 +1,23 @@
|
||||
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()) {
|
||||
if (isDesktop()) {
|
||||
const { createDesktopAttachmentStore } = await import(
|
||||
"../desktop/attachments/desktop-attachment-store"
|
||||
);
|
||||
return createDesktopAttachmentStore();
|
||||
}
|
||||
|
||||
const { createIndexedDbAttachmentStore } = await import(
|
||||
"./web/indexeddb-attachment-store"
|
||||
);
|
||||
const { createIndexedDbAttachmentStore } = await import("./web/indexeddb-attachment-store");
|
||||
return createIndexedDbAttachmentStore();
|
||||
}
|
||||
|
||||
const { createNativeFileAttachmentStore } = await import(
|
||||
"./native/native-file-attachment-store"
|
||||
);
|
||||
const { createNativeFileAttachmentStore } = await import("./native/native-file-attachment-store");
|
||||
return createNativeFileAttachmentStore();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import {
|
||||
releaseAttachmentPreviewUrl,
|
||||
resolveAttachmentPreviewUrl,
|
||||
} from "@/attachments/service";
|
||||
import { releaseAttachmentPreviewUrl, resolveAttachmentPreviewUrl } from "@/attachments/service";
|
||||
|
||||
export function useAttachmentPreviewUrl(
|
||||
attachment: AttachmentMetadata | null | undefined
|
||||
attachment: AttachmentMetadata | null | undefined,
|
||||
): string | null {
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
const activeAttachmentRef = useRef<AttachmentMetadata | null>(null);
|
||||
@@ -52,12 +49,7 @@ export function useAttachmentPreviewUrl(
|
||||
url: currentUrl,
|
||||
});
|
||||
};
|
||||
}, [
|
||||
attachment?.id,
|
||||
attachment?.storageType,
|
||||
attachment?.storageKey,
|
||||
attachment?.mimeType,
|
||||
]);
|
||||
}, [attachment?.id, attachment?.storageType, attachment?.storageKey, attachment?.mimeType]);
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ function openAttachmentDb(): Promise<IDBDatabase> {
|
||||
function runTx<T>(
|
||||
db: IDBDatabase,
|
||||
mode: IDBTransactionMode,
|
||||
run: (store: IDBObjectStore) => IDBRequest<T>
|
||||
run: (store: IDBObjectStore) => IDBRequest<T>,
|
||||
): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(STORE_NAME, mode);
|
||||
@@ -78,7 +78,10 @@ async function sourceToBlob(input: SaveAttachmentInput): Promise<{ blob: Blob; m
|
||||
const source = input.source;
|
||||
if (source.kind === "blob") {
|
||||
const mimeType = normalizeMimeType(input.mimeType ?? source.blob.type);
|
||||
const blob = source.blob.type === mimeType ? source.blob : source.blob.slice(0, source.blob.size, mimeType);
|
||||
const blob =
|
||||
source.blob.type === mimeType
|
||||
? source.blob
|
||||
: source.blob.slice(0, source.blob.size, mimeType);
|
||||
return { blob, mimeType };
|
||||
}
|
||||
|
||||
@@ -104,7 +107,7 @@ async function sourceToBlob(input: SaveAttachmentInput): Promise<{ blob: Blob; m
|
||||
|
||||
async function loadBlob(db: IDBDatabase, id: string): Promise<Blob> {
|
||||
const record = await runTx<StoredBlobRecord | undefined>(db, "readonly", (store) =>
|
||||
store.get(id)
|
||||
store.get(id),
|
||||
);
|
||||
if (!record?.blob) {
|
||||
throw new Error(`Attachment ${id} was not found in IndexedDB.`);
|
||||
@@ -125,7 +128,7 @@ export function createIndexedDbAttachmentStore(): AttachmentStore {
|
||||
|
||||
try {
|
||||
await runTx(db, "readwrite", (store) =>
|
||||
store.put({ id, blob, createdAt, fileName } satisfies StoredBlobRecord)
|
||||
store.put({ id, blob, createdAt, fileName } satisfies StoredBlobRecord),
|
||||
);
|
||||
} finally {
|
||||
db.close();
|
||||
@@ -184,7 +187,9 @@ export function createIndexedDbAttachmentStore(): AttachmentStore {
|
||||
const cursorRequest = store.openCursor();
|
||||
|
||||
cursorRequest.onerror = () => {
|
||||
reject(cursorRequest.error ?? new Error("Failed to iterate IndexedDB attachment store."));
|
||||
reject(
|
||||
cursorRequest.error ?? new Error("Failed to iterate IndexedDB attachment store."),
|
||||
);
|
||||
};
|
||||
|
||||
cursorRequest.onsuccess = () => {
|
||||
|
||||
@@ -68,16 +68,16 @@ const styles = StyleSheet.create((theme) => ({
|
||||
backButtonText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: '600',
|
||||
textAlign: 'center',
|
||||
fontWeight: "600",
|
||||
textAlign: "center",
|
||||
},
|
||||
scrollView: {
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[3],
|
||||
},
|
||||
processItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
@@ -104,7 +104,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
processText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: '500',
|
||||
fontWeight: "500",
|
||||
},
|
||||
processTextActive: {
|
||||
color: theme.colors.primaryForeground,
|
||||
@@ -147,20 +147,21 @@ export function ActiveProcesses({
|
||||
onPress={onSelectOrchestrator}
|
||||
style={({ pressed }) => [
|
||||
styles.processItem,
|
||||
viewMode === 'orchestrator' ? styles.processItemActive : styles.processItemInactive,
|
||||
viewMode === "orchestrator" ? styles.processItemActive : styles.processItemInactive,
|
||||
pressed && { opacity: 0.7 },
|
||||
]}
|
||||
>
|
||||
<View style={styles.agentIcon} />
|
||||
<Text style={[
|
||||
styles.processText,
|
||||
viewMode === 'orchestrator' && styles.processTextActive,
|
||||
]}>Orchestrator</Text>
|
||||
<Text
|
||||
style={[styles.processText, viewMode === "orchestrator" && styles.processTextActive]}
|
||||
>
|
||||
Orchestrator
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
{/* Agent pills */}
|
||||
{agents.map((agent) => {
|
||||
const isActive = viewMode === 'agent' && activeAgentId === agent.id;
|
||||
const isActive = viewMode === "agent" && activeAgentId === agent.id;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
@@ -174,15 +175,21 @@ export function ActiveProcesses({
|
||||
>
|
||||
<View style={styles.agentIcon} />
|
||||
|
||||
<Text style={[
|
||||
styles.processText,
|
||||
isActive && styles.processTextActive,
|
||||
]}>{agent.id.substring(0, 8)}</Text>
|
||||
<Text style={[styles.processText, isActive && styles.processTextActive]}>
|
||||
{agent.id.substring(0, 8)}
|
||||
</Text>
|
||||
|
||||
<View style={[styles.statusDot, { backgroundColor: getAgentStatusColor(agent.status) }]} />
|
||||
<View
|
||||
style={[styles.statusDot, { backgroundColor: getAgentStatusColor(agent.status) }]}
|
||||
/>
|
||||
|
||||
{agent.currentModeId && (
|
||||
<View style={[styles.modeIndicator, { backgroundColor: getModeColor(agent.currentModeId) }]} />
|
||||
<View
|
||||
style={[
|
||||
styles.modeIndicator,
|
||||
{ backgroundColor: getModeColor(agent.currentModeId) },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Modal, Platform, Pressable, ScrollView, Text, TextInput, View } from "react-native";
|
||||
import type { TextInputProps } from "react-native";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root";
|
||||
@@ -109,6 +101,7 @@ export interface AdaptiveModalSheetProps {
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
snapPoints?: string[];
|
||||
stackBehavior?: "push" | "switch" | "replace";
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
@@ -118,11 +111,11 @@ export function AdaptiveModalSheet({
|
||||
onClose,
|
||||
children,
|
||||
snapPoints,
|
||||
stackBehavior,
|
||||
testID,
|
||||
}: AdaptiveModalSheetProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const sheetRef = useRef<BottomSheetModal>(null);
|
||||
const dismissingForVisibilityRef = useRef(false);
|
||||
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
|
||||
@@ -148,19 +141,14 @@ export function AdaptiveModalSheet({
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose]
|
||||
[onClose],
|
||||
);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
opacity={0.45}
|
||||
/>
|
||||
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
|
||||
),
|
||||
[]
|
||||
[],
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
@@ -173,6 +161,7 @@ export function AdaptiveModalSheet({
|
||||
onChange={handleSheetChange}
|
||||
backdropComponent={renderBackdrop}
|
||||
enablePanDownToClose
|
||||
stackBehavior={stackBehavior}
|
||||
backgroundComponent={SheetBackground}
|
||||
handleIndicatorStyle={styles.bottomSheetHandle}
|
||||
keyboardBehavior="extend"
|
||||
@@ -180,11 +169,7 @@ export function AdaptiveModalSheet({
|
||||
>
|
||||
<View style={styles.bottomSheetHeader}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
<Pressable
|
||||
accessibilityLabel="Close"
|
||||
style={styles.closeButton}
|
||||
onPress={onClose}
|
||||
>
|
||||
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
|
||||
<X size={16} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
@@ -209,11 +194,7 @@ export function AdaptiveModalSheet({
|
||||
<View style={styles.desktopCard}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
<Pressable
|
||||
accessibilityLabel="Close"
|
||||
style={styles.closeButton}
|
||||
onPress={onClose}
|
||||
>
|
||||
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
|
||||
<X size={16} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
@@ -254,13 +235,12 @@ export function AdaptiveModalSheet({
|
||||
*/
|
||||
export const AdaptiveTextInput = forwardRef<TextInput, TextInputProps>(
|
||||
function AdaptiveTextInput(props, ref) {
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
|
||||
if (isMobile) {
|
||||
return <BottomSheetTextInput ref={ref as any} {...props} />;
|
||||
}
|
||||
|
||||
return <TextInput ref={ref} {...props} />;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -60,8 +60,17 @@ export function AddHostMethodModal({
|
||||
}, [onPasteLink]);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet title="Add connection" visible={visible} onClose={onClose} testID="add-host-method-modal">
|
||||
<Pressable style={styles.option} onPress={handleDirect} accessibilityLabel="Direct connection">
|
||||
<AdaptiveModalSheet
|
||||
title="Add connection"
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
testID="add-host-method-modal"
|
||||
>
|
||||
<Pressable
|
||||
style={styles.option}
|
||||
onPress={handleDirect}
|
||||
accessibilityLabel="Direct connection"
|
||||
>
|
||||
<Link2 size={18} color={theme.colors.foreground} />
|
||||
<View style={styles.optionBody}>
|
||||
<Text style={styles.optionText}>Direct connection</Text>
|
||||
@@ -79,7 +88,11 @@ export function AddHostMethodModal({
|
||||
</Pressable>
|
||||
) : null}
|
||||
|
||||
<Pressable style={styles.option} onPress={handlePaste} accessibilityLabel="Paste pairing link">
|
||||
<Pressable
|
||||
style={styles.option}
|
||||
onPress={handlePaste}
|
||||
accessibilityLabel="Paste pairing link"
|
||||
>
|
||||
<ClipboardPaste size={18} color={theme.colors.foreground} />
|
||||
<View style={styles.optionBody}>
|
||||
<Text style={styles.optionText}>Paste pairing link</Text>
|
||||
|
||||
@@ -60,8 +60,8 @@ function formatTechnicalTransportDetails(details: Array<string | null>): string
|
||||
.map((value) => normalizeTransportMessage(value))
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => value.length > 0)
|
||||
)
|
||||
.filter((value) => value.length > 0),
|
||||
),
|
||||
);
|
||||
|
||||
if (unique.length === 0) return null;
|
||||
@@ -78,7 +78,10 @@ function formatTechnicalTransportDetails(details: Array<string | null>): string
|
||||
return unique.join(" — ");
|
||||
}
|
||||
|
||||
function buildConnectionFailureCopy(endpoint: string, error: unknown): { title: string; detail: string | null; raw: string | null } {
|
||||
function buildConnectionFailureCopy(
|
||||
endpoint: string,
|
||||
error: unknown,
|
||||
): { title: string; detail: string | null; raw: string | null } {
|
||||
const title = `We failed to connect to ${endpoint}.`;
|
||||
|
||||
const raw = (() => {
|
||||
@@ -109,8 +112,13 @@ function buildConnectionFailureCopy(endpoint: string, error: unknown): { title:
|
||||
detail = "Host not found. Check the hostname and try again.";
|
||||
} else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) {
|
||||
detail = "Host is unreachable. Check your network and firewall.";
|
||||
} else if (rawLower.includes("certificate") || rawLower.includes("tls") || rawLower.includes("ssl")) {
|
||||
detail = "TLS error. Direct connections use an unencrypted local connection. Use relay for remote access.";
|
||||
} else if (
|
||||
rawLower.includes("certificate") ||
|
||||
rawLower.includes("tls") ||
|
||||
rawLower.includes("ssl")
|
||||
) {
|
||||
detail =
|
||||
"TLS error. Direct connections use an unencrypted local connection. Use relay for remote access.";
|
||||
} else if (raw) {
|
||||
detail = "Unable to connect. Check the host/port and that the daemon is reachable.";
|
||||
} else {
|
||||
@@ -125,15 +133,25 @@ export interface AddHostModalProps {
|
||||
onClose: () => void;
|
||||
targetServerId?: string;
|
||||
onCancel?: () => void;
|
||||
onSaved?: (result: { profile: HostProfile; serverId: string; hostname: string | null; isNewHost: boolean }) => void;
|
||||
onSaved?: (result: {
|
||||
profile: HostProfile;
|
||||
serverId: string;
|
||||
hostname: string | null;
|
||||
isNewHost: boolean;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export function AddHostModal({ visible, onClose, onCancel, onSaved, targetServerId }: AddHostModalProps) {
|
||||
export function AddHostModal({
|
||||
visible,
|
||||
onClose,
|
||||
onCancel,
|
||||
onSaved,
|
||||
targetServerId,
|
||||
}: AddHostModalProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const daemons = useHosts();
|
||||
const { upsertDirectConnection } = useHostMutations();
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
|
||||
const hostInputRef = useRef<TextInput>(null);
|
||||
|
||||
@@ -220,10 +238,24 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved, targetServer
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [daemons, endpointRaw, handleClose, isMobile, isSaving, onSaved, targetServerId, upsertDirectConnection]);
|
||||
}, [
|
||||
daemons,
|
||||
endpointRaw,
|
||||
handleClose,
|
||||
isMobile,
|
||||
isSaving,
|
||||
onSaved,
|
||||
targetServerId,
|
||||
upsertDirectConnection,
|
||||
]);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet title="Direct connection" visible={visible} onClose={handleClose} testID="add-host-modal">
|
||||
<AdaptiveModalSheet
|
||||
title="Direct connection"
|
||||
visible={visible}
|
||||
onClose={handleClose}
|
||||
testID="add-host-modal"
|
||||
>
|
||||
<Text style={styles.helper}>Enter the address of a Paseo server.</Text>
|
||||
|
||||
<View style={styles.field}>
|
||||
|
||||
@@ -1,76 +1,77 @@
|
||||
import { useState } from 'react';
|
||||
import { View, Text, Pressable } from 'react-native';
|
||||
import { StyleSheet } from 'react-native-unistyles';
|
||||
import { useState } from "react";
|
||||
import { View, Text, Pressable } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import type { AgentActivity, GroupedTextMessage, MergedToolCall, SessionUpdate } from '@/types/agent-activity';
|
||||
import type {
|
||||
AgentActivity,
|
||||
GroupedTextMessage,
|
||||
MergedToolCall,
|
||||
SessionUpdate,
|
||||
} from "@/types/agent-activity";
|
||||
|
||||
interface AgentActivityItemProps {
|
||||
item: GroupedTextMessage | MergedToolCall | AgentActivity;
|
||||
}
|
||||
|
||||
function formatTimestamp(date: Date): string {
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: true,
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function getToolIcon(toolKind?: string): string {
|
||||
switch (toolKind) {
|
||||
case 'read':
|
||||
return '📖';
|
||||
case 'edit':
|
||||
return '✏️';
|
||||
case 'delete':
|
||||
return '🗑️';
|
||||
case 'move':
|
||||
return '📦';
|
||||
case 'search':
|
||||
return '🔍';
|
||||
case 'execute':
|
||||
return '▶️';
|
||||
case 'think':
|
||||
return '💭';
|
||||
case 'fetch':
|
||||
return '🌐';
|
||||
case 'switch_mode':
|
||||
return '🔄';
|
||||
case "read":
|
||||
return "📖";
|
||||
case "edit":
|
||||
return "✏️";
|
||||
case "delete":
|
||||
return "🗑️";
|
||||
case "move":
|
||||
return "📦";
|
||||
case "search":
|
||||
return "🔍";
|
||||
case "execute":
|
||||
return "▶️";
|
||||
case "think":
|
||||
return "💭";
|
||||
case "fetch":
|
||||
return "🌐";
|
||||
case "switch_mode":
|
||||
return "🔄";
|
||||
default:
|
||||
return '🔧';
|
||||
return "🔧";
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusColor(status?: string): string {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return '#9ca3af';
|
||||
case 'in_progress':
|
||||
return '#fbbf24';
|
||||
case 'completed':
|
||||
return '#22c55e';
|
||||
case 'failed':
|
||||
return '#ef4444';
|
||||
case "pending":
|
||||
return "#9ca3af";
|
||||
case "in_progress":
|
||||
return "#fbbf24";
|
||||
case "completed":
|
||||
return "#22c55e";
|
||||
case "failed":
|
||||
return "#ef4444";
|
||||
default:
|
||||
return '#6b7280';
|
||||
return "#6b7280";
|
||||
}
|
||||
}
|
||||
|
||||
function GroupedTextItem({ item }: { item: GroupedTextMessage }) {
|
||||
const isThought = item.messageType === 'thought';
|
||||
const isThought = item.messageType === "thought";
|
||||
|
||||
return (
|
||||
<View style={[stylesheet.card, isThought && stylesheet.thoughtCard]}>
|
||||
<Text style={[stylesheet.timestamp, isThought && stylesheet.thoughtTimestamp]}>
|
||||
{formatTimestamp(item.startTimestamp)}
|
||||
</Text>
|
||||
{isThought && (
|
||||
<Text style={stylesheet.thoughtLabel}>💭 Thinking</Text>
|
||||
)}
|
||||
<Text style={[stylesheet.text, isThought && stylesheet.thoughtText]}>
|
||||
{item.text}
|
||||
</Text>
|
||||
{isThought && <Text style={stylesheet.thoughtLabel}>💭 Thinking</Text>}
|
||||
<Text style={[stylesheet.text, isThought && stylesheet.thoughtText]}>{item.text}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -80,28 +81,20 @@ function MergedToolCallItem({ item }: { item: MergedToolCall }) {
|
||||
|
||||
return (
|
||||
<View style={stylesheet.toolCard}>
|
||||
<Pressable
|
||||
onPress={() => setIsExpanded(!isExpanded)}
|
||||
style={stylesheet.toolHeader}
|
||||
>
|
||||
<Pressable onPress={() => setIsExpanded(!isExpanded)} style={stylesheet.toolHeader}>
|
||||
<View style={stylesheet.toolHeaderLeft}>
|
||||
<Text style={stylesheet.timestamp}>
|
||||
{formatTimestamp(item.startTimestamp)}
|
||||
</Text>
|
||||
<Text style={stylesheet.timestamp}>{formatTimestamp(item.startTimestamp)}</Text>
|
||||
<View style={stylesheet.toolTitleRow}>
|
||||
<Text style={stylesheet.toolIcon}>{getToolIcon(item.toolKind)}</Text>
|
||||
<Text style={stylesheet.toolTitle}>{item.title}</Text>
|
||||
<View
|
||||
style={[
|
||||
stylesheet.statusBadge,
|
||||
{ backgroundColor: getStatusColor(item.status) },
|
||||
]}
|
||||
style={[stylesheet.statusBadge, { backgroundColor: getStatusColor(item.status) }]}
|
||||
>
|
||||
<Text style={stylesheet.statusText}>{item.status}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={stylesheet.expandIcon}>{isExpanded ? '▼' : '▶'}</Text>
|
||||
<Text style={stylesheet.expandIcon}>{isExpanded ? "▼" : "▶"}</Text>
|
||||
</Pressable>
|
||||
|
||||
{isExpanded && (
|
||||
@@ -109,23 +102,17 @@ function MergedToolCallItem({ item }: { item: MergedToolCall }) {
|
||||
{item.input && (
|
||||
<View style={stylesheet.section}>
|
||||
<Text style={stylesheet.sectionTitle}>Input:</Text>
|
||||
<Text style={stylesheet.code}>
|
||||
{JSON.stringify(item.input, null, 2)}
|
||||
</Text>
|
||||
<Text style={stylesheet.code}>{JSON.stringify(item.input, null, 2)}</Text>
|
||||
</View>
|
||||
)}
|
||||
{item.output && (
|
||||
<View style={stylesheet.section}>
|
||||
<Text style={stylesheet.sectionTitle}>Output:</Text>
|
||||
<Text style={stylesheet.code}>
|
||||
{JSON.stringify(item.output, null, 2)}
|
||||
</Text>
|
||||
<Text style={stylesheet.code}>{JSON.stringify(item.output, null, 2)}</Text>
|
||||
</View>
|
||||
)}
|
||||
{!item.input && !item.output && (
|
||||
<Text style={stylesheet.emptyText}>
|
||||
No details available
|
||||
</Text>
|
||||
<Text style={stylesheet.emptyText}>No details available</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
@@ -136,36 +123,26 @@ function MergedToolCallItem({ item }: { item: MergedToolCall }) {
|
||||
function PlanItem({ update, timestamp }: { update: SessionUpdate; timestamp: Date }) {
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
|
||||
if (update.kind !== 'plan') {
|
||||
if (update.kind !== "plan") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={stylesheet.planCard}>
|
||||
<Pressable
|
||||
onPress={() => setIsExpanded(!isExpanded)}
|
||||
style={stylesheet.planHeader}
|
||||
>
|
||||
<Pressable onPress={() => setIsExpanded(!isExpanded)} style={stylesheet.planHeader}>
|
||||
<View style={stylesheet.planHeaderLeft}>
|
||||
<Text style={stylesheet.timestamp}>{formatTimestamp(timestamp)}</Text>
|
||||
<Text style={stylesheet.planTitle}>
|
||||
📋 Tasks ({update.entries.length})
|
||||
</Text>
|
||||
<Text style={stylesheet.planTitle}>📋 Tasks ({update.entries.length})</Text>
|
||||
</View>
|
||||
<Text style={stylesheet.expandIcon}>{isExpanded ? '▼' : '▶'}</Text>
|
||||
<Text style={stylesheet.expandIcon}>{isExpanded ? "▼" : "▶"}</Text>
|
||||
</Pressable>
|
||||
|
||||
{isExpanded && (
|
||||
<View style={stylesheet.planContent}>
|
||||
{update.entries.map((entry, idx) => (
|
||||
<View key={idx} style={stylesheet.planEntry}>
|
||||
<Text
|
||||
style={[
|
||||
stylesheet.planEntryStatus,
|
||||
{ color: getStatusColor(entry.status) },
|
||||
]}
|
||||
>
|
||||
{entry.status === 'completed' ? '✓' : entry.status === 'in_progress' ? '⏳' : '○'}
|
||||
<Text style={[stylesheet.planEntryStatus, { color: getStatusColor(entry.status) }]}>
|
||||
{entry.status === "completed" ? "✓" : entry.status === "in_progress" ? "⏳" : "○"}
|
||||
</Text>
|
||||
<Text style={stylesheet.planEntryText}>{entry.content}</Text>
|
||||
</View>
|
||||
@@ -181,10 +158,7 @@ function UnknownActivityItem({ update, timestamp }: { update: SessionUpdate; tim
|
||||
|
||||
return (
|
||||
<View style={stylesheet.unknownCard}>
|
||||
<Pressable
|
||||
onPress={() => setShowDrawer(!showDrawer)}
|
||||
style={stylesheet.unknownHeader}
|
||||
>
|
||||
<Pressable onPress={() => setShowDrawer(!showDrawer)} style={stylesheet.unknownHeader}>
|
||||
<Text style={stylesheet.timestamp}>{formatTimestamp(timestamp)}</Text>
|
||||
<View style={stylesheet.unknownBadge}>
|
||||
<Text style={stylesheet.unknownBadgeText}>{update.kind}</Text>
|
||||
@@ -193,9 +167,7 @@ function UnknownActivityItem({ update, timestamp }: { update: SessionUpdate; tim
|
||||
|
||||
{showDrawer && (
|
||||
<View style={stylesheet.drawerContent}>
|
||||
<Text style={stylesheet.code}>
|
||||
{JSON.stringify(update, null, 2)}
|
||||
</Text>
|
||||
<Text style={stylesheet.code}>{JSON.stringify(update, null, 2)}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@@ -204,12 +176,12 @@ function UnknownActivityItem({ update, timestamp }: { update: SessionUpdate; tim
|
||||
|
||||
export function AgentActivityItem({ item }: AgentActivityItemProps) {
|
||||
// Grouped text message
|
||||
if ('kind' in item && item.kind === 'grouped_text') {
|
||||
if ("kind" in item && item.kind === "grouped_text") {
|
||||
return <GroupedTextItem item={item} />;
|
||||
}
|
||||
|
||||
// Merged tool call
|
||||
if ('kind' in item && item.kind === 'merged_tool_call') {
|
||||
if ("kind" in item && item.kind === "merged_tool_call") {
|
||||
return <MergedToolCallItem item={item} />;
|
||||
}
|
||||
|
||||
@@ -218,12 +190,12 @@ export function AgentActivityItem({ item }: AgentActivityItemProps) {
|
||||
const update = activity.update;
|
||||
|
||||
// Tasks
|
||||
if (update.kind === 'plan') {
|
||||
if (update.kind === "plan") {
|
||||
return <PlanItem update={update} timestamp={activity.timestamp} />;
|
||||
}
|
||||
|
||||
// Available commands update
|
||||
if (update.kind === 'available_commands_update') {
|
||||
if (update.kind === "available_commands_update") {
|
||||
return (
|
||||
<View style={stylesheet.card}>
|
||||
<Text style={stylesheet.timestamp}>{formatTimestamp(activity.timestamp)}</Text>
|
||||
@@ -235,13 +207,11 @@ export function AgentActivityItem({ item }: AgentActivityItemProps) {
|
||||
}
|
||||
|
||||
// Current mode update
|
||||
if (update.kind === 'current_mode_update') {
|
||||
if (update.kind === "current_mode_update") {
|
||||
return (
|
||||
<View style={stylesheet.card}>
|
||||
<Text style={stylesheet.timestamp}>{formatTimestamp(activity.timestamp)}</Text>
|
||||
<Text style={stylesheet.infoText}>
|
||||
Mode changed to: {update.currentModeId}
|
||||
</Text>
|
||||
<Text style={stylesheet.infoText}>Mode changed to: {update.currentModeId}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -284,26 +254,26 @@ const stylesheet = StyleSheet.create((theme) => ({
|
||||
},
|
||||
thoughtText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontStyle: 'italic',
|
||||
fontStyle: "italic",
|
||||
},
|
||||
toolCard: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
marginBottom: theme.spacing[2],
|
||||
overflow: 'hidden',
|
||||
overflow: "hidden",
|
||||
},
|
||||
toolHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: theme.spacing[3],
|
||||
},
|
||||
toolHeaderLeft: {
|
||||
flex: 1,
|
||||
},
|
||||
toolTitleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
marginTop: theme.spacing[1],
|
||||
},
|
||||
@@ -355,18 +325,18 @@ const stylesheet = StyleSheet.create((theme) => ({
|
||||
emptyText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontStyle: 'italic',
|
||||
fontStyle: "italic",
|
||||
},
|
||||
planCard: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
marginBottom: theme.spacing[2],
|
||||
overflow: 'hidden',
|
||||
overflow: "hidden",
|
||||
},
|
||||
planHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: theme.spacing[3],
|
||||
},
|
||||
planHeaderLeft: {
|
||||
@@ -384,8 +354,8 @@ const stylesheet = StyleSheet.create((theme) => ({
|
||||
padding: theme.spacing[3],
|
||||
},
|
||||
planEntry: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
gap: theme.spacing[2],
|
||||
marginBottom: theme.spacing[2],
|
||||
},
|
||||
@@ -406,7 +376,7 @@ const stylesheet = StyleSheet.create((theme) => ({
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
marginBottom: theme.spacing[2],
|
||||
overflow: 'hidden',
|
||||
overflow: "hidden",
|
||||
},
|
||||
unknownHeader: {
|
||||
padding: theme.spacing[3],
|
||||
@@ -417,7 +387,7 @@ const stylesheet = StyleSheet.create((theme) => ({
|
||||
paddingVertical: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
marginTop: theme.spacing[2],
|
||||
alignSelf: 'flex-start',
|
||||
alignSelf: "flex-start",
|
||||
},
|
||||
unknownBadgeText: {
|
||||
color: theme.colors.foreground,
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Pressable,
|
||||
TextInput,
|
||||
ActivityIndicator,
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import { View, Text, Pressable, TextInput, ActivityIndicator, Platform } from "react-native";
|
||||
import type { StyleProp, ViewStyle, TextProps } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
@@ -17,7 +10,18 @@ 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 +29,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;
|
||||
@@ -91,19 +111,14 @@ export function DropdownField({
|
||||
testID={testID}
|
||||
style={[styles.dropdownControl, disabled && styles.dropdownControlDisabled]}
|
||||
>
|
||||
<Text
|
||||
style={value ? styles.dropdownValue : styles.dropdownPlaceholder}
|
||||
numberOfLines={1}
|
||||
>
|
||||
<Text style={value ? styles.dropdownValue : styles.dropdownPlaceholder} numberOfLines={1}>
|
||||
{value || placeholder}
|
||||
</Text>
|
||||
<ChevronDown size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
||||
{warningMessage ? <Text style={styles.warningText}>{warningMessage}</Text> : null}
|
||||
{!errorMessage && helperText ? (
|
||||
<Text style={styles.helperText}>{helperText}</Text>
|
||||
) : null}
|
||||
{!errorMessage && helperText ? <Text style={styles.helperText}>{helperText}</Text> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -160,7 +175,7 @@ export function SelectField({
|
||||
onPress();
|
||||
}
|
||||
},
|
||||
[getWebKey, onPress, preventWebDefault]
|
||||
[getWebKey, onPress, preventWebDefault],
|
||||
);
|
||||
|
||||
const normalizedValue = (value ?? "").trim();
|
||||
@@ -168,9 +183,7 @@ export function SelectField({
|
||||
const hasConcreteValue =
|
||||
normalizedValue.length > 0 &&
|
||||
(normalizedPlaceholder.length === 0 || normalizedValue !== normalizedPlaceholder);
|
||||
const displayText = hasConcreteValue
|
||||
? normalizedValue
|
||||
: (normalizedPlaceholder || "Select...");
|
||||
const displayText = hasConcreteValue ? normalizedValue : normalizedPlaceholder || "Select...";
|
||||
|
||||
return (
|
||||
<View style={styles.selectFieldContainer}>
|
||||
@@ -215,12 +228,7 @@ interface DropdownSheetProps {
|
||||
}
|
||||
|
||||
function DropdownSheetBackground({ style }: BottomSheetBackgroundProps) {
|
||||
return (
|
||||
<Animated.View
|
||||
pointerEvents="none"
|
||||
style={[style, styles.bottomSheetBackground]}
|
||||
/>
|
||||
);
|
||||
return <Animated.View pointerEvents="none" style={[style, styles.bottomSheetBackground]} />;
|
||||
}
|
||||
|
||||
export function DropdownSheet({
|
||||
@@ -251,19 +259,14 @@ export function DropdownSheet({
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose]
|
||||
[onClose],
|
||||
);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
opacity={0.45}
|
||||
/>
|
||||
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
|
||||
),
|
||||
[]
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -434,16 +437,14 @@ export function FormSelectTrigger({
|
||||
onPress();
|
||||
}
|
||||
},
|
||||
[getWebKey, onPress, preventWebDefault]
|
||||
[getWebKey, onPress, preventWebDefault],
|
||||
);
|
||||
const normalizedValue = (value ?? "").trim();
|
||||
const normalizedPlaceholder = (placeholder ?? "").trim();
|
||||
const hasConcreteValue =
|
||||
normalizedValue.length > 0 &&
|
||||
(normalizedPlaceholder.length === 0 || normalizedValue !== normalizedPlaceholder);
|
||||
const displayText = hasConcreteValue
|
||||
? normalizedValue
|
||||
: (normalizedPlaceholder || "Select...");
|
||||
const displayText = hasConcreteValue ? normalizedValue : normalizedPlaceholder || "Select...";
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
@@ -465,9 +466,7 @@ export function FormSelectTrigger({
|
||||
>
|
||||
{icon ? <View style={styles.compactSelectLeading}>{icon}</View> : null}
|
||||
<View style={styles.compactSelectValueContainer}>
|
||||
{showLabel ? (
|
||||
<Text style={styles.compactSelectLabel}>{label}</Text>
|
||||
) : null}
|
||||
{showLabel ? <Text style={styles.compactSelectLabel}>{label}</Text> : null}
|
||||
{isLoading ? (
|
||||
<ActivityIndicator size="small" color={defaultTheme.colors.foregroundMuted} />
|
||||
) : (
|
||||
@@ -524,7 +523,7 @@ export function AgentConfigRow({
|
||||
id: def.id,
|
||||
label: def.label,
|
||||
})),
|
||||
[providerDefinitions]
|
||||
[providerDefinitions],
|
||||
);
|
||||
|
||||
const modeSelectOptions: ComboSelectOption[] = useMemo(() => {
|
||||
@@ -538,9 +537,7 @@ export function AgentConfigRow({
|
||||
}, [modeOptions]);
|
||||
|
||||
const modelSelectOptions: ComboSelectOption[] = useMemo(() => {
|
||||
const opts: ComboSelectOption[] = [
|
||||
{ id: "", label: "Auto" },
|
||||
];
|
||||
const opts: ComboSelectOption[] = [{ id: "", label: "Auto" }];
|
||||
for (const model of models) {
|
||||
opts.push({
|
||||
id: model.id,
|
||||
@@ -556,13 +553,17 @@ export function AgentConfigRow({
|
||||
id: option.id,
|
||||
label: option.label,
|
||||
})),
|
||||
[thinkingOptions]
|
||||
[thinkingOptions],
|
||||
);
|
||||
|
||||
const effectiveSelectedMode = selectedMode || (modeOptions.length > 0 ? modeOptions[0]?.id : "");
|
||||
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}>
|
||||
@@ -589,7 +590,9 @@ export function AgentConfigRow({
|
||||
disabled={disabled}
|
||||
isLoading={isModelLoading}
|
||||
onSelect={onSelectModel}
|
||||
icon={<Brain size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />}
|
||||
icon={
|
||||
<Brain size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
|
||||
}
|
||||
showLabel={false}
|
||||
testID="draft-model-select"
|
||||
/>
|
||||
@@ -603,7 +606,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"
|
||||
/>
|
||||
@@ -618,7 +621,9 @@ export function AgentConfigRow({
|
||||
placeholder="Select..."
|
||||
disabled={disabled}
|
||||
onSelect={onSelectThinkingOption}
|
||||
icon={<Brain size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />}
|
||||
icon={
|
||||
<Brain size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
|
||||
}
|
||||
showLabel={false}
|
||||
/>
|
||||
</View>
|
||||
@@ -644,7 +649,7 @@ export function AssistantDropdown({
|
||||
const anchorRef = useRef<View>(null);
|
||||
|
||||
const selectedDefinition = providerDefinitions.find(
|
||||
(definition) => definition.id === selectedProvider
|
||||
(definition) => definition.id === selectedProvider,
|
||||
);
|
||||
|
||||
const options = useMemo(
|
||||
@@ -653,7 +658,7 @@ export function AssistantDropdown({
|
||||
id: def.id,
|
||||
label: def.label,
|
||||
})),
|
||||
[providerDefinitions]
|
||||
[providerDefinitions],
|
||||
);
|
||||
|
||||
const handleOpen = useCallback(() => setIsOpen(true), []);
|
||||
@@ -700,9 +705,9 @@ export function PermissionsDropdown({
|
||||
|
||||
const hasOptions = modeOptions.length > 0;
|
||||
const selectedModeLabel = hasOptions
|
||||
? modeOptions.find((mode) => mode.id === selectedMode)?.label ??
|
||||
? (modeOptions.find((mode) => mode.id === selectedMode)?.label ??
|
||||
modeOptions[0]?.label ??
|
||||
"Default"
|
||||
"Default")
|
||||
: "Automatic";
|
||||
|
||||
const options = useMemo(
|
||||
@@ -712,7 +717,7 @@ export function PermissionsDropdown({
|
||||
label: mode.label,
|
||||
description: mode.description,
|
||||
})),
|
||||
[modeOptions]
|
||||
[modeOptions],
|
||||
);
|
||||
|
||||
const handleOpen = useCallback(() => {
|
||||
@@ -731,9 +736,7 @@ export function PermissionsDropdown({
|
||||
onPress={handleOpen}
|
||||
disabled={disabled || !hasOptions}
|
||||
helperText={
|
||||
hasOptions
|
||||
? undefined
|
||||
: "This assistant does not expose selectable permissions."
|
||||
hasOptions ? undefined : "This assistant does not expose selectable permissions."
|
||||
}
|
||||
controlRef={anchorRef}
|
||||
/>
|
||||
@@ -775,7 +778,7 @@ export function ModelDropdown({
|
||||
const anchorRef = useRef<View>(null);
|
||||
|
||||
const selectedLabel = selectedModel
|
||||
? models.find((model) => model.id === selectedModel)?.label ?? selectedModel
|
||||
? (models.find((model) => model.id === selectedModel)?.label ?? selectedModel)
|
||||
: "Automatic";
|
||||
const placeholder = isLoading && models.length === 0 ? "Loading..." : "Automatic";
|
||||
const helperText = error
|
||||
@@ -814,7 +817,7 @@ export function ModelDropdown({
|
||||
onSelect(id);
|
||||
}
|
||||
},
|
||||
[onClear, onSelect]
|
||||
[onClear, onSelect],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -862,7 +865,7 @@ export function WorkingDirectoryDropdown({
|
||||
|
||||
const options = useMemo(
|
||||
() => suggestedPaths.map((path) => ({ id: path, label: path })),
|
||||
[suggestedPaths]
|
||||
[suggestedPaths],
|
||||
);
|
||||
|
||||
const handleOpen = useCallback(() => setIsOpen(true), []);
|
||||
@@ -937,9 +940,7 @@ export function ToggleRow({
|
||||
</View>
|
||||
<View style={styles.toggleTextContainer}>
|
||||
<Text style={styles.toggleLabel}>{label}</Text>
|
||||
{description ? (
|
||||
<Text style={styles.helperText}>{description}</Text>
|
||||
) : null}
|
||||
{description ? <Text style={styles.helperText}>{description}</Text> : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
@@ -1032,9 +1033,7 @@ export function GitOptionsSection({
|
||||
<View style={styles.gitOptionsContainer}>
|
||||
<Pressable
|
||||
testID="worktree-create-toggle"
|
||||
onPress={() =>
|
||||
onWorktreeModeChange(isCreateMode ? "none" : "create")
|
||||
}
|
||||
onPress={() => onWorktreeModeChange(isCreateMode ? "none" : "create")}
|
||||
disabled={isLoading}
|
||||
style={[styles.worktreeToggle, isLoading && styles.worktreeToggleDisabled]}
|
||||
>
|
||||
@@ -1057,9 +1056,7 @@ export function GitOptionsSection({
|
||||
|
||||
<Pressable
|
||||
testID="worktree-attach-toggle"
|
||||
onPress={() =>
|
||||
onWorktreeModeChange(isAttachMode ? "none" : "attach")
|
||||
}
|
||||
onPress={() => onWorktreeModeChange(isAttachMode ? "none" : "attach")}
|
||||
disabled={isLoading}
|
||||
style={[styles.worktreeToggle, isLoading && styles.worktreeToggleDisabled]}
|
||||
>
|
||||
@@ -1123,8 +1120,15 @@ export function GitOptionsSection({
|
||||
placeholderTextColor={defaultTheme.colors.foregroundMuted}
|
||||
onSubmitEditing={handleConfirmEdit}
|
||||
/>
|
||||
<Pressable onPress={handleConfirmEdit} hitSlop={8} style={styles.baseBranchIconButton}>
|
||||
<Check size={defaultTheme.iconSize.md} color={defaultTheme.colors.palette.green[500]} />
|
||||
<Pressable
|
||||
onPress={handleConfirmEdit}
|
||||
hitSlop={8}
|
||||
style={styles.baseBranchIconButton}
|
||||
>
|
||||
<Check
|
||||
size={defaultTheme.iconSize.md}
|
||||
color={defaultTheme.colors.palette.green[500]}
|
||||
/>
|
||||
</Pressable>
|
||||
<Pressable onPress={handleCancelEdit} hitSlop={8} style={styles.baseBranchIconButton}>
|
||||
<X size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
|
||||
@@ -1139,17 +1143,11 @@ export function GitOptionsSection({
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{baseBranchError ? (
|
||||
<Text style={styles.errorText}>{baseBranchError}</Text>
|
||||
) : null}
|
||||
{baseBranchError ? <Text style={styles.errorText}>{baseBranchError}</Text> : null}
|
||||
|
||||
{repoError ? (
|
||||
<Text style={styles.errorText}>{repoError}</Text>
|
||||
) : null}
|
||||
{repoError ? <Text style={styles.errorText}>{repoError}</Text> : null}
|
||||
|
||||
{gitValidationError ? (
|
||||
<Text style={styles.errorText}>{gitValidationError}</Text>
|
||||
) : null}
|
||||
{gitValidationError ? <Text style={styles.errorText}>{gitValidationError}</Text> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { shouldSkipDraftPersist } from "./agent-input-area.draft-persist-guard";
|
||||
|
||||
describe("shouldSkipDraftPersist", () => {
|
||||
it("blocks persist while hydrate for current uncontrolled generation is incomplete", () => {
|
||||
expect(
|
||||
shouldSkipDraftPersist({
|
||||
isControlled: false,
|
||||
currentGeneration: 2,
|
||||
hydratedGeneration: 1,
|
||||
isCurrentGeneration: true,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("allows persist after hydrate completes for current generation", () => {
|
||||
expect(
|
||||
shouldSkipDraftPersist({
|
||||
isControlled: false,
|
||||
currentGeneration: 3,
|
||||
hydratedGeneration: 3,
|
||||
isCurrentGeneration: true,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks persist for stale generations", () => {
|
||||
expect(
|
||||
shouldSkipDraftPersist({
|
||||
isControlled: false,
|
||||
currentGeneration: 4,
|
||||
hydratedGeneration: 4,
|
||||
isCurrentGeneration: false,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not block controlled draft persistence", () => {
|
||||
expect(
|
||||
shouldSkipDraftPersist({
|
||||
isControlled: true,
|
||||
currentGeneration: 0,
|
||||
hydratedGeneration: 0,
|
||||
isCurrentGeneration: true,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
export function shouldSkipDraftPersist(input: {
|
||||
isControlled: boolean;
|
||||
currentGeneration: number;
|
||||
hydratedGeneration: number;
|
||||
isCurrentGeneration: boolean;
|
||||
}): boolean {
|
||||
if (input.isControlled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (input.currentGeneration <= 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!input.isCurrentGeneration) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return input.hydratedGeneration !== input.currentGeneration;
|
||||
}
|
||||
@@ -1,28 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveStatusControlMode } from './agent-input-area.status-controls'
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveStatusControlMode } from "./agent-input-area.status-controls";
|
||||
|
||||
describe('resolveStatusControlMode', () => {
|
||||
it('uses ready mode when no controlled status controls are provided', () => {
|
||||
expect(resolveStatusControlMode(undefined)).toBe('ready')
|
||||
})
|
||||
describe("resolveStatusControlMode", () => {
|
||||
it("uses ready mode when no controlled status controls are provided", () => {
|
||||
expect(resolveStatusControlMode(undefined)).toBe("ready");
|
||||
});
|
||||
|
||||
it('uses draft mode when controlled status controls are provided', () => {
|
||||
it("uses draft mode when controlled status controls are provided", () => {
|
||||
expect(
|
||||
resolveStatusControlMode({
|
||||
providerDefinitions: [],
|
||||
selectedProvider: 'codex',
|
||||
selectedProvider: "codex",
|
||||
onSelectProvider: () => undefined,
|
||||
modeOptions: [],
|
||||
selectedMode: '',
|
||||
selectedMode: "",
|
||||
onSelectMode: () => undefined,
|
||||
models: [],
|
||||
selectedModel: '',
|
||||
selectedModel: "",
|
||||
onSelectModel: () => undefined,
|
||||
isModelLoading: false,
|
||||
allProviderModels: new Map(),
|
||||
isAllModelsLoading: false,
|
||||
onSelectProviderAndModel: () => undefined,
|
||||
thinkingOptions: [],
|
||||
selectedThinkingOptionId: '',
|
||||
selectedThinkingOptionId: "",
|
||||
onSelectThinkingOption: () => undefined,
|
||||
})
|
||||
).toBe('draft')
|
||||
})
|
||||
})
|
||||
}),
|
||||
).toBe("draft");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { DraftAgentStatusBarProps } from './agent-status-bar'
|
||||
import type { DraftAgentStatusBarProps } from "./agent-status-bar";
|
||||
|
||||
export function resolveStatusControlMode(statusControls?: DraftAgentStatusBarProps) {
|
||||
return statusControls ? 'draft' : 'ready'
|
||||
return statusControls ? "draft" : "ready";
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
140
packages/app/src/components/agent-input-submit.test.ts
Normal file
140
packages/app/src/components/agent-input-submit.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { submitAgentInput } from "./agent-input-submit";
|
||||
|
||||
function createDeferredPromise<T>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((nextResolve, nextReject) => {
|
||||
resolve = nextResolve;
|
||||
reject = nextReject;
|
||||
});
|
||||
return {
|
||||
promise,
|
||||
resolve,
|
||||
reject,
|
||||
};
|
||||
}
|
||||
|
||||
describe("submitAgentInput", () => {
|
||||
it("clears the composer before an in-flight submit resolves", async () => {
|
||||
const deferred = createDeferredPromise<void>();
|
||||
const queueMessage = vi.fn();
|
||||
const submitMessage = vi.fn(async () => {
|
||||
await deferred.promise;
|
||||
});
|
||||
const clearDraft = vi.fn();
|
||||
const setUserInput = vi.fn();
|
||||
const setSelectedImages = vi.fn();
|
||||
const setSendError = vi.fn();
|
||||
const setIsProcessing = vi.fn();
|
||||
|
||||
const submitPromise = submitAgentInput({
|
||||
message: " hello world ",
|
||||
isAgentRunning: false,
|
||||
canSubmit: true,
|
||||
queueMessage,
|
||||
submitMessage,
|
||||
clearDraft,
|
||||
setUserInput,
|
||||
setSelectedImages,
|
||||
setSendError,
|
||||
setIsProcessing,
|
||||
});
|
||||
|
||||
expect(queueMessage).not.toHaveBeenCalled();
|
||||
expect(submitMessage).toHaveBeenCalledWith({
|
||||
message: "hello world",
|
||||
imageAttachments: undefined,
|
||||
});
|
||||
expect(setUserInput).toHaveBeenCalledWith("");
|
||||
expect(setSelectedImages).toHaveBeenCalledWith([]);
|
||||
expect(setSendError).toHaveBeenCalledWith(null);
|
||||
expect(setIsProcessing).toHaveBeenCalledWith(true);
|
||||
expect(clearDraft).not.toHaveBeenCalled();
|
||||
|
||||
deferred.resolve();
|
||||
|
||||
await expect(submitPromise).resolves.toBe("submitted");
|
||||
expect(clearDraft).toHaveBeenCalledWith("sent");
|
||||
});
|
||||
|
||||
it("queues while the agent is running and clears the composer immediately", async () => {
|
||||
const queueMessage = vi.fn();
|
||||
const submitMessage = vi.fn();
|
||||
const clearDraft = vi.fn();
|
||||
const setUserInput = vi.fn();
|
||||
const setSelectedImages = vi.fn();
|
||||
const setSendError = vi.fn();
|
||||
const setIsProcessing = vi.fn();
|
||||
|
||||
await expect(
|
||||
submitAgentInput({
|
||||
message: " queued message ",
|
||||
imageAttachments: [{ id: "img-1" }],
|
||||
isAgentRunning: true,
|
||||
canSubmit: true,
|
||||
queueMessage,
|
||||
submitMessage,
|
||||
clearDraft,
|
||||
setUserInput,
|
||||
setSelectedImages,
|
||||
setSendError,
|
||||
setIsProcessing,
|
||||
}),
|
||||
).resolves.toBe("queued");
|
||||
|
||||
expect(queueMessage).toHaveBeenCalledWith({
|
||||
message: "queued message",
|
||||
imageAttachments: [{ id: "img-1" }],
|
||||
});
|
||||
expect(submitMessage).not.toHaveBeenCalled();
|
||||
expect(setUserInput).toHaveBeenCalledWith("");
|
||||
expect(setSelectedImages).toHaveBeenCalledWith([]);
|
||||
expect(setSendError).not.toHaveBeenCalled();
|
||||
expect(setIsProcessing).not.toHaveBeenCalled();
|
||||
expect(clearDraft).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restores the composer when submit fails", async () => {
|
||||
const submitError = new Error("No host selected");
|
||||
const queueMessage = vi.fn();
|
||||
const submitMessage = vi.fn(async () => {
|
||||
throw submitError;
|
||||
});
|
||||
const clearDraft = vi.fn();
|
||||
const setUserInput = vi.fn();
|
||||
const setSelectedImages = vi.fn();
|
||||
const setSendError = vi.fn();
|
||||
const setIsProcessing = vi.fn();
|
||||
const onSubmitError = vi.fn();
|
||||
const imageAttachments = [{ id: "img-1" }];
|
||||
|
||||
await expect(
|
||||
submitAgentInput({
|
||||
message: " hello world ",
|
||||
imageAttachments,
|
||||
isAgentRunning: false,
|
||||
canSubmit: true,
|
||||
queueMessage,
|
||||
submitMessage,
|
||||
clearDraft,
|
||||
setUserInput,
|
||||
setSelectedImages,
|
||||
setSendError,
|
||||
setIsProcessing,
|
||||
onSubmitError,
|
||||
}),
|
||||
).resolves.toBe("failed");
|
||||
|
||||
expect(onSubmitError).toHaveBeenCalledWith(submitError);
|
||||
expect(setUserInput).toHaveBeenNthCalledWith(1, "");
|
||||
expect(setUserInput).toHaveBeenNthCalledWith(2, "hello world");
|
||||
expect(setSelectedImages).toHaveBeenNthCalledWith(1, []);
|
||||
expect(setSelectedImages).toHaveBeenNthCalledWith(2, imageAttachments);
|
||||
expect(setSendError).toHaveBeenNthCalledWith(1, null);
|
||||
expect(setSendError).toHaveBeenNthCalledWith(2, "No host selected");
|
||||
expect(setIsProcessing).toHaveBeenNthCalledWith(1, true);
|
||||
expect(setIsProcessing).toHaveBeenNthCalledWith(2, false);
|
||||
expect(clearDraft).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
58
packages/app/src/components/agent-input-submit.ts
Normal file
58
packages/app/src/components/agent-input-submit.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
export type AgentInputSubmitResult = "noop" | "queued" | "submitted" | "failed";
|
||||
|
||||
export interface AgentInputSubmitActionInput<TImage> {
|
||||
message: string;
|
||||
imageAttachments?: TImage[];
|
||||
forceSend?: boolean;
|
||||
isAgentRunning: boolean;
|
||||
canSubmit: boolean;
|
||||
queueMessage: (input: { message: string; imageAttachments?: TImage[] }) => void;
|
||||
submitMessage: (input: { message: string; imageAttachments?: TImage[] }) => Promise<void>;
|
||||
clearDraft: (lifecycle: "sent" | "abandoned") => void;
|
||||
setUserInput: (text: string) => void;
|
||||
setSelectedImages: (images: TImage[]) => void;
|
||||
setSendError: (message: string | null) => void;
|
||||
setIsProcessing: (isProcessing: boolean) => void;
|
||||
onSubmitError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export async function submitAgentInput<TImage>(
|
||||
input: AgentInputSubmitActionInput<TImage>,
|
||||
): Promise<AgentInputSubmitResult> {
|
||||
const trimmedMessage = input.message.trim();
|
||||
const imageAttachments = input.imageAttachments;
|
||||
|
||||
if (!trimmedMessage && !imageAttachments?.length) {
|
||||
return "noop";
|
||||
}
|
||||
|
||||
if (!input.canSubmit) {
|
||||
return "noop";
|
||||
}
|
||||
|
||||
if (input.isAgentRunning && !input.forceSend) {
|
||||
input.queueMessage({ message: trimmedMessage, imageAttachments });
|
||||
input.setUserInput("");
|
||||
input.setSelectedImages([]);
|
||||
return "queued";
|
||||
}
|
||||
|
||||
// Clear immediately so optimistic stream updates and composer state stay in sync.
|
||||
input.setUserInput("");
|
||||
input.setSelectedImages([]);
|
||||
input.setSendError(null);
|
||||
input.setIsProcessing(true);
|
||||
|
||||
try {
|
||||
await input.submitMessage({ message: trimmedMessage, imageAttachments });
|
||||
input.clearDraft("sent");
|
||||
return "submitted";
|
||||
} catch (error) {
|
||||
input.onSubmitError?.(error);
|
||||
input.setUserInput(trimmedMessage);
|
||||
input.setSelectedImages(imageAttachments ?? []);
|
||||
input.setSendError(error instanceof Error ? error.message : "Failed to send message");
|
||||
input.setIsProcessing(false);
|
||||
return "failed";
|
||||
}
|
||||
}
|
||||
@@ -6,106 +6,107 @@ import {
|
||||
RefreshControl,
|
||||
FlatList,
|
||||
type ListRenderItem,
|
||||
} 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 { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
|
||||
import { formatTimeAgo } from '@/utils/time'
|
||||
import { shortenPath } from '@/utils/shorten-path'
|
||||
import { type AggregatedAgent } from '@/hooks/use-aggregated-agents'
|
||||
import { useSessionStore } from '@/stores/session-store'
|
||||
import { AgentStatusDot } from '@/components/agent-status-dot'
|
||||
import { buildHostWorkspaceAgentRoute } from '@/utils/host-routes'
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useCallback, useMemo, useState, type ReactElement } from "react";
|
||||
import { router } from "expo-router";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { Archive } from "lucide-react-native";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
|
||||
interface AgentListProps {
|
||||
agents: AggregatedAgent[]
|
||||
showCheckoutInfo?: boolean
|
||||
isRefreshing?: boolean
|
||||
onRefresh?: () => void
|
||||
selectedAgentId?: string
|
||||
onAgentSelect?: () => void
|
||||
listFooterComponent?: ReactElement | null
|
||||
showAttentionIndicator?: boolean
|
||||
agents: AggregatedAgent[];
|
||||
showCheckoutInfo?: boolean;
|
||||
isRefreshing?: boolean;
|
||||
onRefresh?: () => void;
|
||||
selectedAgentId?: string;
|
||||
onAgentSelect?: () => void;
|
||||
listFooterComponent?: ReactElement | null;
|
||||
showAttentionIndicator?: boolean;
|
||||
}
|
||||
|
||||
interface AgentListSection {
|
||||
key: string
|
||||
title: string
|
||||
data: AggregatedAgent[]
|
||||
}
|
||||
type FlatListItem =
|
||||
| { type: "header"; key: string; title: string }
|
||||
| { type: "agent"; key: string; agent: AggregatedAgent };
|
||||
|
||||
function deriveDateSectionLabel(lastActivityAt: Date): string {
|
||||
const now = new Date()
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const yesterdayStart = new Date(todayStart.getTime() - 24 * 60 * 60 * 1000)
|
||||
const now = new Date();
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const yesterdayStart = new Date(todayStart.getTime() - 24 * 60 * 60 * 1000);
|
||||
const activityStart = new Date(
|
||||
lastActivityAt.getFullYear(),
|
||||
lastActivityAt.getMonth(),
|
||||
lastActivityAt.getDate()
|
||||
)
|
||||
lastActivityAt.getDate(),
|
||||
);
|
||||
|
||||
if (activityStart.getTime() >= todayStart.getTime()) {
|
||||
return 'Today'
|
||||
return "Today";
|
||||
}
|
||||
if (activityStart.getTime() >= yesterdayStart.getTime()) {
|
||||
return 'Yesterday'
|
||||
return "Yesterday";
|
||||
}
|
||||
|
||||
const diffTime = todayStart.getTime() - activityStart.getTime()
|
||||
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24))
|
||||
const diffTime = todayStart.getTime() - activityStart.getTime();
|
||||
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
|
||||
if (diffDays <= 7) {
|
||||
return 'This week'
|
||||
return "This week";
|
||||
}
|
||||
if (diffDays <= 30) {
|
||||
return 'This month'
|
||||
return "This month";
|
||||
}
|
||||
return 'Older'
|
||||
return "Older";
|
||||
}
|
||||
|
||||
function formatStatusLabel(status: AggregatedAgent['status']): string {
|
||||
function formatStatusLabel(status: AggregatedAgent["status"]): string {
|
||||
switch (status) {
|
||||
case 'initializing':
|
||||
return 'Starting'
|
||||
case 'idle':
|
||||
return 'Idle'
|
||||
case 'running':
|
||||
return 'Running'
|
||||
case 'error':
|
||||
return 'Error'
|
||||
case 'closed':
|
||||
return 'Closed'
|
||||
case "initializing":
|
||||
return "Starting";
|
||||
case "idle":
|
||||
return "Idle";
|
||||
case "running":
|
||||
return "Running";
|
||||
case "error":
|
||||
return "Error";
|
||||
case "closed":
|
||||
return "Closed";
|
||||
default:
|
||||
return status
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
function SessionBadge({
|
||||
label,
|
||||
tone = 'neutral',
|
||||
icon,
|
||||
tone = "neutral",
|
||||
}: {
|
||||
label: string
|
||||
tone?: 'neutral' | 'warning' | 'danger'
|
||||
label: string;
|
||||
icon?: ReactElement;
|
||||
tone?: "neutral" | "warning" | "danger";
|
||||
}) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.badge,
|
||||
tone === 'warning' && styles.badgeWarning,
|
||||
tone === 'danger' && styles.badgeDanger,
|
||||
tone === "warning" && styles.badgeWarning,
|
||||
tone === "danger" && styles.badgeDanger,
|
||||
]}
|
||||
>
|
||||
{icon}
|
||||
<Text
|
||||
style={[
|
||||
styles.badgeText,
|
||||
tone === 'warning' && styles.badgeTextWarning,
|
||||
tone === 'danger' && styles.badgeTextDanger,
|
||||
tone === "warning" && styles.badgeTextWarning,
|
||||
tone === "danger" && styles.badgeTextDanger,
|
||||
]}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SessionRow({
|
||||
@@ -116,18 +117,19 @@ function SessionRow({
|
||||
onPress,
|
||||
onLongPress,
|
||||
}: {
|
||||
agent: AggregatedAgent
|
||||
isMobile: boolean
|
||||
selectedAgentId?: string
|
||||
showAttentionIndicator: boolean
|
||||
onPress: (agent: AggregatedAgent) => void
|
||||
onLongPress: (agent: AggregatedAgent) => void
|
||||
agent: AggregatedAgent;
|
||||
isMobile: boolean;
|
||||
selectedAgentId?: string;
|
||||
showAttentionIndicator: boolean;
|
||||
onPress: (agent: AggregatedAgent) => void;
|
||||
onLongPress: (agent: AggregatedAgent) => void;
|
||||
}) {
|
||||
const timeAgo = formatTimeAgo(agent.lastActivityAt)
|
||||
const agentKey = `${agent.serverId}:${agent.id}`
|
||||
const isSelected = selectedAgentId === agentKey
|
||||
const statusLabel = formatStatusLabel(agent.status)
|
||||
const projectPath = shortenPath(agent.cwd)
|
||||
const { theme } = useUnistyles();
|
||||
const timeAgo = formatTimeAgo(agent.lastActivityAt);
|
||||
const agentKey = `${agent.serverId}:${agent.id}`;
|
||||
const isSelected = selectedAgentId === agentKey;
|
||||
const statusLabel = formatStatusLabel(agent.status);
|
||||
const projectPath = shortenPath(agent.cwd);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
@@ -141,18 +143,20 @@ function SessionRow({
|
||||
onLongPress={() => onLongPress(agent)}
|
||||
testID={`agent-row-${agent.serverId}-${agent.id}`}
|
||||
>
|
||||
<View style={styles.rowLeading}>
|
||||
<AgentStatusDot status={agent.status} requiresAttention={agent.requiresAttention} />
|
||||
</View>
|
||||
<View style={styles.rowContent}>
|
||||
<View style={styles.rowTitleRow}>
|
||||
<Text
|
||||
style={[styles.sessionTitle, isSelected && styles.sessionTitleHighlighted]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{agent.title || 'New session'}
|
||||
{agent.title || "New session"}
|
||||
</Text>
|
||||
{agent.archivedAt ? <SessionBadge label="Archived" /> : null}
|
||||
{agent.archivedAt ? (
|
||||
<SessionBadge
|
||||
label="Archived"
|
||||
icon={<Archive size={theme.fontSize.xs} color={theme.colors.foregroundMuted} />}
|
||||
/>
|
||||
) : null}
|
||||
{(agent.pendingPermissionCount ?? 0) > 0 ? (
|
||||
<SessionBadge label={`${agent.pendingPermissionCount} pending`} tone="warning" />
|
||||
) : null}
|
||||
@@ -195,49 +199,7 @@ function SessionRow({
|
||||
</View>
|
||||
) : null}
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionTableSection({
|
||||
section,
|
||||
isMobile,
|
||||
selectedAgentId,
|
||||
showAttentionIndicator,
|
||||
onAgentPress,
|
||||
onAgentLongPress,
|
||||
}: {
|
||||
section: AgentListSection
|
||||
isMobile: boolean
|
||||
selectedAgentId?: string
|
||||
showAttentionIndicator: boolean
|
||||
onAgentPress: (agent: AggregatedAgent) => void
|
||||
onAgentLongPress: (agent: AggregatedAgent) => void
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.sectionBlock}>
|
||||
<View style={styles.sectionHeading}>
|
||||
<Text style={styles.sectionTitle}>{section.title}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.listCard}>
|
||||
{section.data.map((agent, index) => (
|
||||
<View
|
||||
key={`${agent.serverId}:${agent.id}`}
|
||||
style={index > 0 ? styles.rowDivider : undefined}
|
||||
>
|
||||
<SessionRow
|
||||
agent={agent}
|
||||
isMobile={isMobile}
|
||||
selectedAgentId={selectedAgentId}
|
||||
showAttentionIndicator={showAttentionIndicator}
|
||||
onPress={onAgentPress}
|
||||
onLongPress={onAgentLongPress}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentList({
|
||||
@@ -249,99 +211,113 @@ export function AgentList({
|
||||
listFooterComponent,
|
||||
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'
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null);
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
|
||||
const actionClient = useSessionStore((state) =>
|
||||
actionAgent?.serverId ? (state.sessions[actionAgent.serverId]?.client ?? null) : null
|
||||
)
|
||||
actionAgent?.serverId ? (state.sessions[actionAgent.serverId]?.client ?? null) : null,
|
||||
);
|
||||
|
||||
const isActionSheetVisible = actionAgent !== null
|
||||
const isActionDaemonUnavailable = Boolean(actionAgent?.serverId && !actionClient)
|
||||
const isActionSheetVisible = actionAgent !== null;
|
||||
const isActionDaemonUnavailable = Boolean(actionAgent?.serverId && !actionClient);
|
||||
|
||||
const handleAgentPress = useCallback(
|
||||
(agent: AggregatedAgent) => {
|
||||
if (isActionSheetVisible) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const serverId = agent.serverId
|
||||
const agentId = agent.id
|
||||
const shouldReplace = pathname.startsWith('/h/')
|
||||
const navigate = shouldReplace ? router.replace : router.push
|
||||
const serverId = agent.serverId;
|
||||
const agentId = agent.id;
|
||||
|
||||
onAgentSelect?.()
|
||||
onAgentSelect?.();
|
||||
|
||||
const route: Href = buildHostWorkspaceAgentRoute(serverId, agent.cwd, agentId) as Href
|
||||
navigate(route)
|
||||
const route = prepareWorkspaceTab({
|
||||
serverId,
|
||||
workspaceId: agent.cwd,
|
||||
target: { kind: "agent", agentId },
|
||||
pin: Boolean(agent.archivedAt),
|
||||
});
|
||||
router.navigate(route as any);
|
||||
},
|
||||
[isActionSheetVisible, pathname, onAgentSelect]
|
||||
)
|
||||
[isActionSheetVisible, onAgentSelect],
|
||||
);
|
||||
|
||||
const handleAgentLongPress = useCallback((agent: AggregatedAgent) => {
|
||||
setActionAgent(agent)
|
||||
}, [])
|
||||
setActionAgent(agent);
|
||||
}, []);
|
||||
|
||||
const handleCloseActionSheet = useCallback(() => {
|
||||
setActionAgent(null)
|
||||
}, [])
|
||||
setActionAgent(null);
|
||||
}, []);
|
||||
|
||||
const handleArchiveAgent = useCallback(() => {
|
||||
if (!actionAgent || !actionClient) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
void actionClient.archiveAgent(actionAgent.id)
|
||||
setActionAgent(null)
|
||||
}, [actionAgent, actionClient])
|
||||
void actionClient.archiveAgent(actionAgent.id);
|
||||
setActionAgent(null);
|
||||
}, [actionAgent, actionClient]);
|
||||
|
||||
const sections = useMemo((): AgentListSection[] => {
|
||||
const order = ['Today', 'Yesterday', 'This week', 'This month', 'Older'] as const
|
||||
const buckets = new Map<string, AggregatedAgent[]>()
|
||||
const flatItems = useMemo((): FlatListItem[] => {
|
||||
const order = ["Today", "Yesterday", "This week", "This month", "Older"] as const;
|
||||
const buckets = new Map<string, AggregatedAgent[]>();
|
||||
for (const agent of agents) {
|
||||
const label = deriveDateSectionLabel(agent.lastActivityAt)
|
||||
const existing = buckets.get(label) ?? []
|
||||
existing.push(agent)
|
||||
buckets.set(label, existing)
|
||||
const label = deriveDateSectionLabel(agent.lastActivityAt);
|
||||
const existing = buckets.get(label) ?? [];
|
||||
existing.push(agent);
|
||||
buckets.set(label, existing);
|
||||
}
|
||||
|
||||
const result: AgentListSection[] = []
|
||||
const result: FlatListItem[] = [];
|
||||
for (const label of order) {
|
||||
const data = buckets.get(label)
|
||||
const data = buckets.get(label);
|
||||
if (!data || data.length === 0) {
|
||||
continue
|
||||
continue;
|
||||
}
|
||||
result.push({ type: "header", key: `header:${label}`, title: label });
|
||||
for (const agent of data) {
|
||||
result.push({ type: "agent", key: `${agent.serverId}:${agent.id}`, agent });
|
||||
}
|
||||
result.push({ key: `date:${label}`, title: label, data })
|
||||
}
|
||||
return result
|
||||
}, [agents])
|
||||
return result;
|
||||
}, [agents]);
|
||||
|
||||
const renderSection: ListRenderItem<AgentListSection> = useCallback(
|
||||
({ item: section }) => (
|
||||
<SessionTableSection
|
||||
section={section}
|
||||
isMobile={isMobile}
|
||||
selectedAgentId={selectedAgentId}
|
||||
showAttentionIndicator={showAttentionIndicator}
|
||||
onAgentPress={handleAgentPress}
|
||||
onAgentLongPress={handleAgentLongPress}
|
||||
/>
|
||||
),
|
||||
[handleAgentLongPress, handleAgentPress, isMobile, selectedAgentId, showAttentionIndicator]
|
||||
)
|
||||
const renderItem: ListRenderItem<FlatListItem> = useCallback(
|
||||
({ item }) => {
|
||||
if (item.type === "header") {
|
||||
return (
|
||||
<View style={styles.sectionHeading}>
|
||||
<Text style={styles.sectionTitle}>{item.title}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<SessionRow
|
||||
agent={item.agent}
|
||||
isMobile={isMobile}
|
||||
selectedAgentId={selectedAgentId}
|
||||
showAttentionIndicator={showAttentionIndicator}
|
||||
onPress={handleAgentPress}
|
||||
onLongPress={handleAgentLongPress}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[handleAgentLongPress, handleAgentPress, isMobile, selectedAgentId, showAttentionIndicator],
|
||||
);
|
||||
|
||||
const keyExtractor = useCallback((section: AgentListSection) => section.key, [])
|
||||
const keyExtractor = useCallback((item: FlatListItem) => item.key, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FlatList
|
||||
data={sections}
|
||||
data={flatItems}
|
||||
style={styles.list}
|
||||
contentContainerStyle={styles.listContent}
|
||||
keyExtractor={keyExtractor}
|
||||
renderItem={renderSection}
|
||||
renderItem={renderItem}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
ListFooterComponent={listFooterComponent}
|
||||
@@ -373,7 +349,7 @@ export function AgentList({
|
||||
>
|
||||
<View style={styles.sheetHandle} />
|
||||
<Text style={styles.sheetTitle}>
|
||||
{isActionDaemonUnavailable ? 'Host offline' : 'Archive this session?'}
|
||||
{isActionDaemonUnavailable ? "Host offline" : "Archive this session?"}
|
||||
</Text>
|
||||
<View style={styles.sheetButtonRow}>
|
||||
<Pressable
|
||||
@@ -403,7 +379,7 @@ export function AgentList({
|
||||
</View>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
@@ -416,18 +392,16 @@ const styles = StyleSheet.create((theme) => ({
|
||||
xs: theme.spacing[3],
|
||||
md: theme.spacing[6],
|
||||
},
|
||||
paddingTop: theme.spacing[2],
|
||||
paddingTop: theme.spacing[4],
|
||||
paddingBottom: theme.spacing[6],
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
sectionBlock: {
|
||||
marginTop: theme.spacing[2],
|
||||
},
|
||||
sectionHeading: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: theme.spacing[2],
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
marginBottom: theme.spacing[2],
|
||||
},
|
||||
sectionTitle: {
|
||||
@@ -435,26 +409,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
listCard: {
|
||||
overflow: {
|
||||
xs: 'hidden' as const,
|
||||
md: 'visible' as const,
|
||||
},
|
||||
borderRadius: {
|
||||
xs: theme.borderRadius.lg,
|
||||
md: 0,
|
||||
},
|
||||
},
|
||||
rowDivider: {
|
||||
borderTopWidth: {
|
||||
xs: StyleSheet.hairlineWidth,
|
||||
md: 0,
|
||||
},
|
||||
borderTopColor: theme.colors.border,
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: {
|
||||
@@ -466,23 +423,20 @@ const styles = StyleSheet.create((theme) => ({
|
||||
md: 0,
|
||||
},
|
||||
},
|
||||
rowLeading: {
|
||||
marginRight: theme.spacing[3],
|
||||
},
|
||||
rowContent: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
rowTitleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
rowMetaRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
gap: theme.spacing[1],
|
||||
marginTop: 2,
|
||||
},
|
||||
@@ -501,7 +455,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
sessionTitle: {
|
||||
flexShrink: 1,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: '500',
|
||||
fontWeight: "400",
|
||||
color: theme.colors.foreground,
|
||||
opacity: 0.86,
|
||||
},
|
||||
@@ -509,7 +463,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
opacity: 1,
|
||||
},
|
||||
sessionMetaText: {
|
||||
maxWidth: '100%',
|
||||
maxWidth: "100%",
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
@@ -531,19 +485,22 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
flexShrink: 0,
|
||||
width: 72,
|
||||
textAlign: 'right' as const,
|
||||
textAlign: "right" as const,
|
||||
},
|
||||
badge: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
badgeWarning: {
|
||||
backgroundColor: 'rgba(245, 158, 11, 0.12)',
|
||||
backgroundColor: "rgba(245, 158, 11, 0.12)",
|
||||
},
|
||||
badgeDanger: {
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.14)',
|
||||
backgroundColor: "rgba(239, 68, 68, 0.14)",
|
||||
},
|
||||
badgeText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
@@ -558,26 +515,26 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
sheetOverlay: {
|
||||
flex: 1,
|
||||
justifyContent: 'flex-end',
|
||||
justifyContent: "flex-end",
|
||||
},
|
||||
sheetBackdrop: {
|
||||
position: 'absolute',
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.35)',
|
||||
backgroundColor: "rgba(0,0,0,0.35)",
|
||||
},
|
||||
sheetContainer: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderTopLeftRadius: theme.borderRadius['2xl'],
|
||||
borderTopRightRadius: theme.borderRadius['2xl'],
|
||||
borderTopLeftRadius: theme.borderRadius["2xl"],
|
||||
borderTopRightRadius: theme.borderRadius["2xl"],
|
||||
paddingHorizontal: theme.spacing[6],
|
||||
paddingTop: theme.spacing[4],
|
||||
gap: theme.spacing[4],
|
||||
},
|
||||
sheetHandle: {
|
||||
alignSelf: 'center',
|
||||
alignSelf: "center",
|
||||
width: 40,
|
||||
height: 4,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
@@ -588,18 +545,18 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.lg,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
color: theme.colors.foreground,
|
||||
textAlign: 'center',
|
||||
textAlign: "center",
|
||||
},
|
||||
sheetButtonRow: {
|
||||
flexDirection: 'row',
|
||||
flexDirection: "row",
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
sheetButton: {
|
||||
flex: 1,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
paddingVertical: theme.spacing[4],
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
sheetArchiveButton: {
|
||||
backgroundColor: theme.colors.primary,
|
||||
@@ -620,4 +577,4 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
}))
|
||||
}));
|
||||
|
||||
@@ -1,60 +1,72 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { normalizeModelId, resolveAgentModelSelection } from './agent-status-bar.utils'
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getStatusSelectorHint,
|
||||
normalizeModelId,
|
||||
resolveAgentModelSelection,
|
||||
} from "./agent-status-bar.utils";
|
||||
|
||||
describe('normalizeModelId', () => {
|
||||
it('treats empty and default values as unset', () => {
|
||||
expect(normalizeModelId('')).toBeNull()
|
||||
expect(normalizeModelId(' default ')).toBeNull()
|
||||
expect(normalizeModelId(undefined)).toBeNull()
|
||||
})
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
it('returns trimmed model ids', () => {
|
||||
expect(normalizeModelId(' gpt-5.1-codex ')).toBe('gpt-5.1-codex')
|
||||
})
|
||||
})
|
||||
describe("normalizeModelId", () => {
|
||||
it("treats empty and default values as unset", () => {
|
||||
expect(normalizeModelId("")).toBeNull();
|
||||
expect(normalizeModelId(" default ")).toBeNull();
|
||||
expect(normalizeModelId(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
describe('resolveAgentModelSelection', () => {
|
||||
it('prefers runtime model over configured model', () => {
|
||||
it("returns trimmed model ids", () => {
|
||||
expect(normalizeModelId(" gpt-5.1-codex ")).toBe("gpt-5.1-codex");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAgentModelSelection", () => {
|
||||
it("prefers runtime model over configured model", () => {
|
||||
const selection = resolveAgentModelSelection({
|
||||
models: [
|
||||
{
|
||||
id: 'a',
|
||||
provider: 'codex',
|
||||
label: 'Model A',
|
||||
thinkingOptions: [{ id: 'low', label: 'Low' }],
|
||||
defaultThinkingOptionId: 'low',
|
||||
id: "a",
|
||||
provider: "codex",
|
||||
label: "Model A",
|
||||
thinkingOptions: [{ id: "low", label: "Low" }],
|
||||
defaultThinkingOptionId: "low",
|
||||
},
|
||||
],
|
||||
runtimeModelId: 'a',
|
||||
configuredModelId: 'b',
|
||||
runtimeModelId: "a",
|
||||
configuredModelId: "b",
|
||||
explicitThinkingOptionId: null,
|
||||
})
|
||||
});
|
||||
|
||||
expect(selection.activeModelId).toBe('a')
|
||||
expect(selection.displayModel).toBe('Model A')
|
||||
expect(selection.selectedThinkingId).toBe('low')
|
||||
})
|
||||
expect(selection.activeModelId).toBe("a");
|
||||
expect(selection.displayModel).toBe("Model A");
|
||||
expect(selection.selectedThinkingId).toBe("low");
|
||||
});
|
||||
|
||||
it('uses explicit thinking option when provided', () => {
|
||||
it("uses explicit thinking option when provided", () => {
|
||||
const selection = resolveAgentModelSelection({
|
||||
models: [
|
||||
{
|
||||
id: 'a',
|
||||
provider: 'codex',
|
||||
label: 'Model A',
|
||||
id: "a",
|
||||
provider: "codex",
|
||||
label: "Model A",
|
||||
thinkingOptions: [
|
||||
{ id: 'low', label: 'Low' },
|
||||
{ id: 'high', label: 'High' },
|
||||
{ id: "low", label: "Low" },
|
||||
{ id: "high", label: "High" },
|
||||
],
|
||||
defaultThinkingOptionId: 'low',
|
||||
defaultThinkingOptionId: "low",
|
||||
},
|
||||
],
|
||||
runtimeModelId: 'a',
|
||||
runtimeModelId: "a",
|
||||
configuredModelId: null,
|
||||
explicitThinkingOptionId: 'high',
|
||||
})
|
||||
explicitThinkingOptionId: "high",
|
||||
});
|
||||
|
||||
expect(selection.selectedThinkingId).toBe('high')
|
||||
expect(selection.displayThinking).toBe('High')
|
||||
})
|
||||
})
|
||||
expect(selection.selectedThinkingId).toBe("high");
|
||||
expect(selection.displayThinking).toBe("High");
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,38 +1,54 @@
|
||||
import type { AgentModelDefinition } from '@server/server/agent/agent-sdk-types'
|
||||
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') {
|
||||
return null
|
||||
const normalized = typeof modelId === "string" ? modelId.trim() : "";
|
||||
if (!normalized || normalized.toLowerCase() === "default") {
|
||||
return null;
|
||||
}
|
||||
return normalized
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function resolveAgentModelSelection(input: {
|
||||
models: AgentModelDefinition[] | null
|
||||
runtimeModelId: string | null | undefined
|
||||
configuredModelId: string | null | undefined
|
||||
explicitThinkingOptionId: string | null | undefined
|
||||
models: AgentModelDefinition[] | null;
|
||||
runtimeModelId: string | null | undefined;
|
||||
configuredModelId: string | null | undefined;
|
||||
explicitThinkingOptionId: string | null | undefined;
|
||||
}) {
|
||||
const { models, runtimeModelId, configuredModelId, explicitThinkingOptionId } = input
|
||||
const normalizedRuntimeModelId = normalizeModelId(runtimeModelId)
|
||||
const normalizedConfiguredModelId = normalizeModelId(configuredModelId)
|
||||
const preferredModelId = normalizedRuntimeModelId ?? normalizedConfiguredModelId
|
||||
const { models, runtimeModelId, configuredModelId, explicitThinkingOptionId } = input;
|
||||
const normalizedRuntimeModelId = normalizeModelId(runtimeModelId);
|
||||
const normalizedConfiguredModelId = normalizeModelId(configuredModelId);
|
||||
const preferredModelId = normalizedRuntimeModelId ?? normalizedConfiguredModelId;
|
||||
const selectedModel =
|
||||
models && preferredModelId ? models.find((model) => model.id === preferredModelId) ?? null : null
|
||||
models && preferredModelId
|
||||
? (models.find((model) => model.id === preferredModelId) ?? null)
|
||||
: null;
|
||||
|
||||
const activeModelId = selectedModel?.id ?? preferredModelId ?? null
|
||||
const displayModel = selectedModel?.label ?? preferredModelId ?? 'Auto'
|
||||
const activeModelId = selectedModel?.id ?? preferredModelId ?? null;
|
||||
const displayModel = selectedModel?.label ?? preferredModelId ?? "Auto";
|
||||
|
||||
const thinkingOptions = selectedModel?.thinkingOptions ?? null
|
||||
const thinkingOptions = selectedModel?.thinkingOptions ?? null;
|
||||
const selectedThinkingId =
|
||||
explicitThinkingOptionId && explicitThinkingOptionId !== 'default'
|
||||
explicitThinkingOptionId && explicitThinkingOptionId !== "default"
|
||||
? explicitThinkingOptionId
|
||||
: selectedModel?.defaultThinkingOptionId ?? null
|
||||
const selectedThinking = thinkingOptions?.find((option) => option.id === selectedThinkingId) ?? null
|
||||
: (selectedModel?.defaultThinkingOptionId ?? null);
|
||||
const selectedThinking =
|
||||
thinkingOptions?.find((option) => option.id === selectedThinkingId) ?? null;
|
||||
const displayThinking =
|
||||
selectedThinking?.label ??
|
||||
(selectedThinkingId === 'default' ? 'Model default' : selectedThinkingId ?? 'auto')
|
||||
(selectedThinkingId === "default" ? "Model default" : (selectedThinkingId ?? "auto"));
|
||||
|
||||
return {
|
||||
selectedModel,
|
||||
@@ -41,5 +57,5 @@ export function resolveAgentModelSelection(input: {
|
||||
thinkingOptions,
|
||||
selectedThinkingId,
|
||||
displayThinking,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@ function splitOrderedTail(params: {
|
||||
}
|
||||
|
||||
export function buildAgentStreamRenderModel(
|
||||
input: BuildAgentStreamRenderModelInput
|
||||
input: BuildAgentStreamRenderModelInput,
|
||||
): AgentStreamRenderModel {
|
||||
const strategy = resolveStreamRenderStrategy({
|
||||
platform: input.platform === "web" ? "web" : "native",
|
||||
|
||||
@@ -78,7 +78,7 @@ describe("resolveStreamRenderStrategy", () => {
|
||||
resolveBottomAnchorTransportBehavior({
|
||||
strategy,
|
||||
isViewportSettling: true,
|
||||
})
|
||||
}),
|
||||
).toEqual({
|
||||
verificationDelayFrames: 4,
|
||||
verificationRetryMode: "recheck",
|
||||
@@ -95,7 +95,7 @@ describe("resolveStreamRenderStrategy", () => {
|
||||
resolveBottomAnchorTransportBehavior({
|
||||
strategy,
|
||||
isViewportSettling: true,
|
||||
})
|
||||
}),
|
||||
).toEqual({
|
||||
verificationDelayFrames: 0,
|
||||
verificationRetryMode: "rescroll",
|
||||
@@ -172,7 +172,7 @@ describe("neighbor and traversal semantics", () => {
|
||||
strategy: forward,
|
||||
items: chronological,
|
||||
startIndex: forwardStartIndex,
|
||||
})
|
||||
}),
|
||||
).toBe("assistant-1\n\nassistant-2");
|
||||
|
||||
const inverted = resolveStreamRenderStrategy({
|
||||
@@ -189,7 +189,7 @@ describe("neighbor and traversal semantics", () => {
|
||||
strategy: inverted,
|
||||
items: invertedItems,
|
||||
startIndex: invertedStartIndex,
|
||||
})
|
||||
}),
|
||||
).toBe("assistant-1\n\nassistant-2");
|
||||
});
|
||||
|
||||
@@ -206,7 +206,7 @@ describe("neighbor and traversal semantics", () => {
|
||||
items,
|
||||
index: 0,
|
||||
relation: "above",
|
||||
})
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
getStreamNeighborItem({
|
||||
@@ -214,7 +214,7 @@ describe("neighbor and traversal semantics", () => {
|
||||
items,
|
||||
index: 0,
|
||||
relation: "below",
|
||||
})
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -233,7 +233,7 @@ describe("scroll/bottom calculations", () => {
|
||||
viewportHeight: 300,
|
||||
contentHeight: 1000,
|
||||
threshold: 24,
|
||||
})
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isNearBottomForStreamRenderStrategy({
|
||||
@@ -242,7 +242,7 @@ describe("scroll/bottom calculations", () => {
|
||||
viewportHeight: 300,
|
||||
contentHeight: 1000,
|
||||
threshold: 24,
|
||||
})
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@@ -259,7 +259,7 @@ describe("scroll/bottom calculations", () => {
|
||||
viewportHeight: 300,
|
||||
contentHeight: 1000,
|
||||
threshold: 24,
|
||||
})
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isNearBottomForStreamRenderStrategy({
|
||||
@@ -268,14 +268,14 @@ describe("scroll/bottom calculations", () => {
|
||||
viewportHeight: 300,
|
||||
contentHeight: 1000,
|
||||
threshold: 24,
|
||||
})
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
getBottomOffsetForStreamRenderStrategy({
|
||||
strategy,
|
||||
viewportHeight: 300,
|
||||
contentHeight: 1000,
|
||||
})
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
@@ -290,7 +290,7 @@ describe("scroll/bottom calculations", () => {
|
||||
strategy,
|
||||
viewportHeight: 320,
|
||||
contentHeight: 1000,
|
||||
})
|
||||
}),
|
||||
).toBe(680);
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -62,7 +62,7 @@ describe("findMountedWindowStart", () => {
|
||||
findMountedWindowStart({
|
||||
items,
|
||||
minMountedCount: 50,
|
||||
})
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
@@ -79,7 +79,7 @@ describe("findMountedWindowStart", () => {
|
||||
findMountedWindowStart({
|
||||
items,
|
||||
minMountedCount: 50,
|
||||
})
|
||||
}),
|
||||
).toBe(39);
|
||||
});
|
||||
});
|
||||
@@ -136,20 +136,16 @@ describe("web virtualization test overrides", () => {
|
||||
__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: unknown;
|
||||
__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS?: unknown;
|
||||
};
|
||||
const previousThreshold =
|
||||
globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD;
|
||||
const previousMounted =
|
||||
globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS;
|
||||
const previousThreshold = globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD;
|
||||
const previousMounted = globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS;
|
||||
|
||||
try {
|
||||
delete globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD;
|
||||
delete globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS;
|
||||
expect(getWebPartialVirtualizationThreshold()).toBe(
|
||||
DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD
|
||||
);
|
||||
expect(getWebMountedRecentStreamItems()).toBe(
|
||||
DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS
|
||||
DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD,
|
||||
);
|
||||
expect(getWebMountedRecentStreamItems()).toBe(DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS);
|
||||
|
||||
globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 6;
|
||||
globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS = 4;
|
||||
@@ -159,14 +155,12 @@ describe("web virtualization test overrides", () => {
|
||||
if (previousThreshold === undefined) {
|
||||
delete globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD;
|
||||
} else {
|
||||
globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD =
|
||||
previousThreshold;
|
||||
globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = previousThreshold;
|
||||
}
|
||||
if (previousMounted === undefined) {
|
||||
delete globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS;
|
||||
} else {
|
||||
globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS =
|
||||
previousMounted;
|
||||
globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS = previousMounted;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -18,16 +18,14 @@ function readPositiveIntegerOverride(value: unknown): number | null {
|
||||
|
||||
export function getWebPartialVirtualizationThreshold(): number {
|
||||
const override = readPositiveIntegerOverride(
|
||||
(globalThis as BottomAnchorE2ETestGlobals)
|
||||
.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD
|
||||
(globalThis as BottomAnchorE2ETestGlobals).__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD,
|
||||
);
|
||||
return override ?? DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD;
|
||||
}
|
||||
|
||||
export function getWebMountedRecentStreamItems(): number {
|
||||
const override = readPositiveIntegerOverride(
|
||||
(globalThis as BottomAnchorE2ETestGlobals)
|
||||
.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS
|
||||
(globalThis as BottomAnchorE2ETestGlobals).__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS,
|
||||
);
|
||||
return override ?? DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS;
|
||||
}
|
||||
|
||||
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>;
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
Pressable,
|
||||
Modal,
|
||||
} from "react-native";
|
||||
import { View, Text, ScrollView, Pressable, Modal } from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
@@ -185,15 +179,8 @@ export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.headerActions}>
|
||||
<View
|
||||
style={[
|
||||
styles.badge,
|
||||
typeBadgeStyles[artifact.type],
|
||||
]}
|
||||
>
|
||||
<Text style={styles.badgeText}>
|
||||
{artifact.type.toUpperCase()}
|
||||
</Text>
|
||||
<View style={[styles.badge, typeBadgeStyles[artifact.type]]}>
|
||||
<Text style={styles.badgeText}>{artifact.type.toUpperCase()}</Text>
|
||||
</View>
|
||||
<Pressable onPress={onClose} style={styles.closeButton}>
|
||||
<Text style={styles.closeButtonText}>×</Text>
|
||||
@@ -208,12 +195,8 @@ export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
|
||||
>
|
||||
{artifact.type === "image" ? (
|
||||
<View style={styles.imagePlaceholder}>
|
||||
<Text style={styles.imagePlaceholderText}>
|
||||
Image viewing not yet implemented
|
||||
</Text>
|
||||
<Text style={styles.imagePlaceholderSubtext}>
|
||||
Base64 image data received
|
||||
</Text>
|
||||
<Text style={styles.imagePlaceholderText}>Image viewing not yet implemented</Text>
|
||||
<Text style={styles.imagePlaceholderSubtext}>Base64 image data received</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.codeContainer}>
|
||||
@@ -244,9 +227,7 @@ export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
|
||||
</View>
|
||||
<View style={styles.metadataRow}>
|
||||
<Text style={styles.metadataLabel}>Size:</Text>
|
||||
<Text style={styles.metadataValue}>
|
||||
{content.length.toLocaleString()} characters
|
||||
</Text>
|
||||
<Text style={styles.metadataValue}>{content.length.toLocaleString()} characters</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -49,7 +49,11 @@ function formatDuration(duration?: number): string | null {
|
||||
return `${seconds.toFixed(seconds >= 10 ? 0 : 1)} s`;
|
||||
}
|
||||
|
||||
export function AudioDebugNotice({ info, onDismiss, title = "Dictation Debug" }: AudioDebugNoticeProps) {
|
||||
export function AudioDebugNotice({
|
||||
info,
|
||||
onDismiss,
|
||||
title = "Dictation Debug",
|
||||
}: AudioDebugNoticeProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
@@ -115,11 +119,12 @@ export function AudioDebugNotice({ info, onDismiss, title = "Dictation Debug" }:
|
||||
</View>
|
||||
|
||||
{info.debugRecordingPath ? (
|
||||
<Pressable style={styles.pathRow} onPress={handleCopyPath} accessibilityLabel="Copy raw audio path">
|
||||
<Text
|
||||
numberOfLines={2}
|
||||
style={[styles.pathText, { color: theme.colors.foreground }]}
|
||||
>
|
||||
<Pressable
|
||||
style={styles.pathRow}
|
||||
onPress={handleCopyPath}
|
||||
accessibilityLabel="Copy raw audio path"
|
||||
>
|
||||
<Text numberOfLines={2} style={[styles.pathText, { color: theme.colors.foreground }]}>
|
||||
{info.debugRecordingPath}
|
||||
</Text>
|
||||
<View style={[styles.copyPill, { backgroundColor: theme.colors.primary }]}>
|
||||
@@ -137,9 +142,7 @@ export function AudioDebugNotice({ info, onDismiss, title = "Dictation Debug" }:
|
||||
)}
|
||||
|
||||
{stats ? (
|
||||
<Text style={[styles.stats, { color: theme.colors.foregroundMuted }]}>
|
||||
{stats}
|
||||
</Text>
|
||||
<Text style={[styles.stats, { color: theme.colors.foregroundMuted }]}>{stats}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
/**
|
||||
* Compute the pixel width for a line-number gutter based on the highest
|
||||
* line number that will be displayed. Minimum width accommodates 2 digits.
|
||||
*/
|
||||
export function lineNumberGutterWidth(maxLineNumber: number): number {
|
||||
const digits = Math.max(2, String(maxLineNumber).length);
|
||||
return digits * 8 + 12;
|
||||
}
|
||||
|
||||
export function getCodeInsets(theme: any) {
|
||||
const padding =
|
||||
typeof theme.spacing?.[3] === "number"
|
||||
|
||||
375
packages/app/src/components/combined-model-selector.tsx
Normal file
375
packages/app/src/components/combined-model-selector.tsx
Normal file
@@ -0,0 +1,375 @@
|
||||
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,
|
||||
},
|
||||
}));
|
||||
@@ -1,13 +1,5 @@
|
||||
import {
|
||||
Modal,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import { memo, useEffect, useMemo, useRef, type ReactNode } from "react";
|
||||
import { Modal, Pressable, ScrollView, Text, TextInput, View, Platform } from "react-native";
|
||||
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";
|
||||
@@ -54,31 +46,28 @@ const CommandCenterRow = memo(function CommandCenterRow({
|
||||
|
||||
export function CommandCenter() {
|
||||
const { theme } = useUnistyles();
|
||||
const {
|
||||
open,
|
||||
inputRef,
|
||||
query,
|
||||
setQuery,
|
||||
activeIndex,
|
||||
items,
|
||||
handleClose,
|
||||
handleSelectItem,
|
||||
} = useCommandCenter();
|
||||
const { open, inputRef, query, setQuery, activeIndex, items, handleClose, handleSelectItem } =
|
||||
useCommandCenter();
|
||||
|
||||
const rowRefs = useRef<Map<number, View>>(new Map());
|
||||
const resultsRef = useRef<ScrollView>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
const row = rowRefs.current.get(activeIndex);
|
||||
if (!row || typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
const scrollNode =
|
||||
(resultsRef.current as
|
||||
| (ScrollView & {
|
||||
getScrollableNode?: () => HTMLElement | null;
|
||||
})
|
||||
| null)?.getScrollableNode?.() ?? null;
|
||||
(
|
||||
resultsRef.current as
|
||||
| (ScrollView & {
|
||||
getScrollableNode?: () => HTMLElement | null;
|
||||
})
|
||||
| null
|
||||
)?.getScrollableNode?.() ?? null;
|
||||
const rowEl = row as unknown as HTMLElement;
|
||||
|
||||
if (!scrollNode) {
|
||||
@@ -99,32 +88,24 @@ 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
|
||||
visible={open}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={handleClose}
|
||||
>
|
||||
<Modal visible={open} transparent animationType="fade" onRequestClose={handleClose}>
|
||||
<View style={styles.overlay}>
|
||||
<Pressable style={styles.backdrop} onPress={handleClose} />
|
||||
|
||||
<View
|
||||
testID="command-center-panel"
|
||||
style={[styles.panel, { borderColor: theme.colors.border, backgroundColor: theme.colors.surface0 }]}
|
||||
style={[
|
||||
styles.panel,
|
||||
{ borderColor: theme.colors.border, backgroundColor: theme.colors.surface0 },
|
||||
]}
|
||||
>
|
||||
<View style={[styles.header, { borderBottomColor: theme.colors.border }]}>
|
||||
<TextInput
|
||||
@@ -134,10 +115,7 @@ export function CommandCenter() {
|
||||
onChangeText={setQuery}
|
||||
placeholder="Type a command or search agents..."
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
style={[
|
||||
styles.input,
|
||||
{ color: theme.colors.foreground },
|
||||
]}
|
||||
style={[styles.input, { color: theme.colors.foreground }]}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
@@ -167,11 +145,7 @@ export function CommandCenter() {
|
||||
const action = item.action;
|
||||
const actionIcon =
|
||||
action.icon === "plus" ? (
|
||||
<Plus
|
||||
size={16}
|
||||
strokeWidth={2.4}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
<Plus size={16} strokeWidth={2.4} color={theme.colors.foregroundMuted} />
|
||||
) : action.icon === "settings" ? (
|
||||
<Settings
|
||||
size={16}
|
||||
@@ -204,7 +178,7 @@ export function CommandCenter() {
|
||||
</View>
|
||||
</View>
|
||||
{action.shortcutKeys ? (
|
||||
<Shortcut keys={action.shortcutKeys} style={styles.rowShortcut} />
|
||||
<Shortcut chord={action.shortcutKeys} style={styles.rowShortcut} />
|
||||
) : null}
|
||||
</View>
|
||||
</CommandCenterRow>
|
||||
|
||||
@@ -40,18 +40,8 @@ export function ConnectionStatus({ isConnected }: ConnectionStatusProps) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.row}>
|
||||
<View
|
||||
style={[
|
||||
styles.dot,
|
||||
isConnected ? styles.dotConnected : styles.dotDisconnected,
|
||||
]}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.text,
|
||||
isConnected ? styles.textConnected : styles.textDisconnected,
|
||||
]}
|
||||
>
|
||||
<View style={[styles.dot, isConnected ? styles.dotConnected : styles.dotDisconnected]} />
|
||||
<Text style={[styles.text, isConnected ? styles.textConnected : styles.textDisconnected]}>
|
||||
{isConnected ? "Connected" : "Disconnected"}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -24,9 +24,7 @@ interface DictationControlsProps {
|
||||
function formatDuration(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins.toString().padStart(2, "0")}:${secs
|
||||
.toString()
|
||||
.padStart(2, "0")}`;
|
||||
return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function DictationControls({
|
||||
@@ -66,12 +64,7 @@ export function DictationControls({
|
||||
return (
|
||||
<View style={styles.activeContainer}>
|
||||
<View style={styles.meterWrapper}>
|
||||
<VolumeMeter
|
||||
volume={volume}
|
||||
isMuted={false}
|
||||
isSpeaking={false}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
<VolumeMeter volume={volume} isMuted={false} isSpeaking={false} orientation="horizontal" />
|
||||
</View>
|
||||
<Text style={[styles.timerText, { color: theme.colors.foreground }]}>
|
||||
{formatDuration(duration)}
|
||||
@@ -152,12 +145,7 @@ export function DictationOverlay({
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
overlayStyles.container,
|
||||
{ backgroundColor: theme.colors.accent },
|
||||
]}
|
||||
>
|
||||
<View style={[overlayStyles.container, { backgroundColor: theme.colors.accent }]}>
|
||||
<Pressable
|
||||
onPress={handleCancel}
|
||||
disabled={actionsDisabled && !isFailed}
|
||||
@@ -180,12 +168,7 @@ export function DictationOverlay({
|
||||
orientation="horizontal"
|
||||
color={theme.colors.palette.white}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
overlayStyles.timerText,
|
||||
{ color: theme.colors.palette.white },
|
||||
]}
|
||||
>
|
||||
<Text style={[overlayStyles.timerText, { color: theme.colors.palette.white }]}>
|
||||
{formatDuration(duration)}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -205,26 +188,16 @@ export function DictationOverlay({
|
||||
<View style={overlayStyles.actionButtonsContainer}>
|
||||
{actionsDisabled ? (
|
||||
<View style={overlayStyles.loadingContainer}>
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={theme.colors.palette.white}
|
||||
/>
|
||||
<ActivityIndicator size="small" color={theme.colors.palette.white} />
|
||||
</View>
|
||||
) : isFailed ? (
|
||||
<Pressable
|
||||
onPress={onRetry}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Retry dictation"
|
||||
style={[
|
||||
overlayStyles.actionButton,
|
||||
{ backgroundColor: theme.colors.palette.white },
|
||||
]}
|
||||
style={[overlayStyles.actionButton, { backgroundColor: theme.colors.palette.white }]}
|
||||
>
|
||||
<RefreshCcw
|
||||
size={theme.iconSize.lg}
|
||||
color={theme.colors.accent}
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
<RefreshCcw size={theme.iconSize.lg} color={theme.colors.accent} strokeWidth={2.5} />
|
||||
</Pressable>
|
||||
) : (
|
||||
<>
|
||||
@@ -232,10 +205,7 @@ export function DictationOverlay({
|
||||
onPress={onAccept}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Insert transcription"
|
||||
style={[
|
||||
overlayStyles.actionButton,
|
||||
{ backgroundColor: "rgba(255, 255, 255, 0.25)" },
|
||||
]}
|
||||
style={[overlayStyles.actionButton, { backgroundColor: "rgba(255, 255, 255, 0.25)" }]}
|
||||
>
|
||||
<Pencil
|
||||
size={theme.iconSize.lg}
|
||||
@@ -247,16 +217,9 @@ export function DictationOverlay({
|
||||
onPress={onAcceptAndSend}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Insert transcription and send"
|
||||
style={[
|
||||
overlayStyles.actionButton,
|
||||
{ backgroundColor: theme.colors.palette.white },
|
||||
]}
|
||||
style={[overlayStyles.actionButton, { backgroundColor: theme.colors.palette.white }]}
|
||||
>
|
||||
<ArrowUp
|
||||
size={theme.iconSize.lg}
|
||||
color={theme.colors.accent}
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
<ArrowUp size={theme.iconSize.lg} color={theme.colors.accent} strokeWidth={2.5} />
|
||||
</Pressable>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { View, Text, Pressable } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { AlertTriangle, CheckCircle2, Info, RefreshCcw, RotateCcw, WifiOff, X } from "lucide-react-native";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Info,
|
||||
RefreshCcw,
|
||||
RotateCcw,
|
||||
WifiOff,
|
||||
X,
|
||||
} from "lucide-react-native";
|
||||
|
||||
export type DictationToastVariant = "info" | "success" | "warning" | "error";
|
||||
|
||||
@@ -53,7 +61,8 @@ export function DictationStatusNotice({
|
||||
})();
|
||||
|
||||
const foregroundColor = variant === "info" ? theme.colors.foreground : theme.colors.palette.white;
|
||||
const secondaryColor = variant === "info" ? theme.colors.foregroundMuted : theme.colors.palette.white;
|
||||
const secondaryColor =
|
||||
variant === "info" ? theme.colors.foregroundMuted : theme.colors.palette.white;
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -83,11 +92,7 @@ export function DictationStatusNotice({
|
||||
|
||||
{(meta || (actionLabel && onAction)) && (
|
||||
<View style={styles.actionsRow}>
|
||||
{meta ? (
|
||||
<Text style={[styles.meta, { color: secondaryColor }]}>{meta}</Text>
|
||||
) : (
|
||||
<View />
|
||||
)}
|
||||
{meta ? <Text style={[styles.meta, { color: secondaryColor }]}>{meta}</Text> : <View />}
|
||||
{actionLabel && onAction ? (
|
||||
<Pressable
|
||||
style={[
|
||||
|
||||
85
packages/app/src/components/diff-scroll.tsx
Normal file
85
packages/app/src/components/diff-scroll.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useState, useCallback, useEffect, useId, useRef } from "react";
|
||||
import {
|
||||
type LayoutChangeEvent,
|
||||
type NativeSyntheticEvent,
|
||||
type NativeScrollEvent,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler";
|
||||
import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context";
|
||||
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
||||
|
||||
interface DiffScrollProps {
|
||||
children: React.ReactNode;
|
||||
scrollViewWidth: number;
|
||||
onScrollViewWidthChange: (width: number) => void;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
contentContainerStyle?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
export function DiffScroll({
|
||||
children,
|
||||
scrollViewWidth,
|
||||
onScrollViewWidthChange,
|
||||
style,
|
||||
contentContainerStyle,
|
||||
}: DiffScrollProps) {
|
||||
const [isAtLeftEdge, setIsAtLeftEdge] = useState(true);
|
||||
const horizontalScroll = useHorizontalScrollOptional();
|
||||
const scrollId = useId();
|
||||
const scrollViewRef = useRef<ScrollViewType>(null);
|
||||
|
||||
// Get the close gesture ref from animation context (may not be available outside sidebar)
|
||||
let closeGestureRef: React.MutableRefObject<any> | undefined;
|
||||
try {
|
||||
const animation = useExplorerSidebarAnimation();
|
||||
closeGestureRef = animation.closeGestureRef;
|
||||
} catch {
|
||||
// Not inside ExplorerSidebarAnimationProvider, which is fine
|
||||
}
|
||||
|
||||
// Register/unregister scroll offset tracking
|
||||
useEffect(() => {
|
||||
if (!horizontalScroll) return;
|
||||
// Start at 0 (not scrolled)
|
||||
horizontalScroll.registerScrollOffset(scrollId, 0);
|
||||
return () => {
|
||||
horizontalScroll.unregisterScrollOffset(scrollId);
|
||||
};
|
||||
}, [horizontalScroll, scrollId]);
|
||||
|
||||
const handleScroll = useCallback(
|
||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
const offsetX = event.nativeEvent.contentOffset.x;
|
||||
// Track if we're at the left edge (with small threshold for float precision)
|
||||
setIsAtLeftEdge(offsetX <= 1);
|
||||
if (horizontalScroll) {
|
||||
horizontalScroll.registerScrollOffset(scrollId, offsetX);
|
||||
}
|
||||
},
|
||||
[horizontalScroll, scrollId],
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
bounces={false}
|
||||
style={style}
|
||||
contentContainerStyle={contentContainerStyle}
|
||||
onScroll={handleScroll}
|
||||
scrollEventThrottle={16}
|
||||
onLayout={(e: LayoutChangeEvent) => onScrollViewWidthChange(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}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
29
packages/app/src/components/diff-scroll.web.tsx
Normal file
29
packages/app/src/components/diff-scroll.web.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { ScrollView, type LayoutChangeEvent, type StyleProp, type ViewStyle } from "react-native";
|
||||
|
||||
interface DiffScrollProps {
|
||||
children: React.ReactNode;
|
||||
scrollViewWidth: number;
|
||||
onScrollViewWidthChange: (width: number) => void;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
contentContainerStyle?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
export function DiffScroll({
|
||||
children,
|
||||
onScrollViewWidthChange,
|
||||
style,
|
||||
contentContainerStyle,
|
||||
}: DiffScrollProps) {
|
||||
return (
|
||||
<ScrollView
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
style={style}
|
||||
contentContainerStyle={contentContainerStyle}
|
||||
onLayout={(e: LayoutChangeEvent) => onScrollViewWidthChange(e.nativeEvent.layout.width)}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -71,7 +71,8 @@ export function DiffViewer({
|
||||
key={segIdx}
|
||||
style={[
|
||||
line.type === "add" ? styles.addText : styles.removeText,
|
||||
segment.changed && (line.type === "add" ? styles.addHighlight : styles.removeHighlight),
|
||||
segment.changed &&
|
||||
(line.type === "add" ? styles.addHighlight : styles.removeHighlight),
|
||||
]}
|
||||
>
|
||||
{segment.text}
|
||||
|
||||
@@ -41,7 +41,10 @@ export function DownloadToast() {
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { bottom: theme.spacing[4] + insets.bottom }]} pointerEvents="box-none">
|
||||
<View
|
||||
style={[styles.container, { bottom: theme.spacing[4] + insets.bottom }]}
|
||||
pointerEvents="box-none"
|
||||
>
|
||||
<View style={styles.toast}>
|
||||
{activeDownload.status === "downloading" ? (
|
||||
<ActivityIndicator size="small" color={theme.colors.foreground} />
|
||||
@@ -61,7 +64,7 @@ export function DownloadToast() {
|
||||
: "Starting..."
|
||||
: activeDownload.status === "complete"
|
||||
? "Download complete"
|
||||
: activeDownload.message ?? "Download failed"}
|
||||
: (activeDownload.message ?? "Download failed")}
|
||||
</Text>
|
||||
{activeDownload.status === "downloading" && activeDownload.progress && (
|
||||
<View style={styles.progressBar}>
|
||||
|
||||
@@ -5,10 +5,7 @@ import DraggableFlatList, {
|
||||
type RenderItemParams,
|
||||
} from "react-native-draggable-flatlist";
|
||||
import { useUnistyles } from "react-native-unistyles";
|
||||
import type {
|
||||
DraggableListProps,
|
||||
DraggableRenderItemInfo,
|
||||
} from "./draggable-list.types";
|
||||
import type { DraggableListProps, DraggableRenderItemInfo } from "./draggable-list.types";
|
||||
|
||||
export type { DraggableListProps, DraggableRenderItemInfo };
|
||||
|
||||
@@ -53,7 +50,7 @@ export function DraggableList<T>({
|
||||
};
|
||||
return renderItem(info);
|
||||
},
|
||||
[renderItem]
|
||||
[renderItem],
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
@@ -61,7 +58,7 @@ export function DraggableList<T>({
|
||||
setIsDragging(false);
|
||||
onDragEnd(newData);
|
||||
},
|
||||
[onDragEnd]
|
||||
[onDragEnd],
|
||||
);
|
||||
|
||||
const handleDragBegin = useCallback(() => {
|
||||
@@ -74,12 +71,11 @@ export function DraggableList<T>({
|
||||
}, []);
|
||||
|
||||
const showRefreshControl = Boolean(onRefresh) && (!isDragging || Boolean(refreshing));
|
||||
const resolvedContainerStyle =
|
||||
containerStyle ?? (scrollEnabled ? { flex: 1 } : undefined);
|
||||
const resolvedContainerStyle = containerStyle ?? (scrollEnabled ? { flex: 1 } : undefined);
|
||||
const shouldShowRefreshControl = showRefreshControl && !nestable;
|
||||
const ListComponent: typeof DraggableFlatList = (nestable
|
||||
? (NestableDraggableFlatList as any)
|
||||
: DraggableFlatList) as any;
|
||||
const ListComponent: typeof DraggableFlatList = (
|
||||
nestable ? (NestableDraggableFlatList as any) : DraggableFlatList
|
||||
) as any;
|
||||
|
||||
return (
|
||||
<ListComponent
|
||||
|
||||
@@ -19,14 +19,8 @@ import {
|
||||
arrayMove,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import type {
|
||||
DraggableListProps,
|
||||
DraggableRenderItemInfo,
|
||||
} from "./draggable-list.types";
|
||||
import {
|
||||
WebDesktopScrollbarOverlay,
|
||||
useWebDesktopScrollbarMetrics,
|
||||
} from "./web-desktop-scrollbar";
|
||||
import type { DraggableListProps, DraggableRenderItemInfo } from "./draggable-list.types";
|
||||
import { WebDesktopScrollbarOverlay, useWebDesktopScrollbarMetrics } from "./web-desktop-scrollbar";
|
||||
|
||||
export type { DraggableListProps, DraggableRenderItemInfo };
|
||||
|
||||
@@ -79,7 +73,7 @@ function SortableItem<T>({
|
||||
// the "ghost" stretches. Keep the dragged item's size stable by zeroing
|
||||
// out the dnd-kit scaling component.
|
||||
const baseTransform = CSS.Transform.toString(
|
||||
transform && isDragging ? { ...transform, scaleX: 1, scaleY: 1 } : transform
|
||||
transform && isDragging ? { ...transform, scaleX: 1, scaleY: 1 } : transform,
|
||||
);
|
||||
const scaleTransform = isDragging ? "scale(1.02)" : "";
|
||||
const combinedTransform = [baseTransform, scaleTransform].filter(Boolean).join(" ");
|
||||
@@ -100,9 +94,7 @@ function SortableItem<T>({
|
||||
? {
|
||||
attributes: attributes as unknown as Record<string, unknown>,
|
||||
listeners: listeners as unknown as Record<string, unknown>,
|
||||
setActivatorNodeRef: setActivatorNodeRef as unknown as (
|
||||
node: unknown
|
||||
) => void,
|
||||
setActivatorNodeRef: setActivatorNodeRef as unknown as (node: unknown) => void,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
@@ -152,7 +144,7 @@ export function DraggableList<T>({
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
@@ -161,7 +153,7 @@ export function DraggableList<T>({
|
||||
setActiveId(String(event.active.id));
|
||||
onDragBegin?.();
|
||||
},
|
||||
[data, onDragBegin]
|
||||
[data, onDragBegin],
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
@@ -172,12 +164,8 @@ export function DraggableList<T>({
|
||||
setDragItems(null);
|
||||
|
||||
if (over && active.id !== over.id) {
|
||||
const oldIndex = items.findIndex(
|
||||
(item, i) => keyExtractor(item, i) === active.id
|
||||
);
|
||||
const newIndex = items.findIndex(
|
||||
(item, i) => keyExtractor(item, i) === over.id
|
||||
);
|
||||
const oldIndex = items.findIndex((item, i) => keyExtractor(item, i) === active.id);
|
||||
const newIndex = items.findIndex((item, i) => keyExtractor(item, i) === over.id);
|
||||
|
||||
if (oldIndex >= 0 && newIndex >= 0 && oldIndex !== newIndex) {
|
||||
const newItems = arrayMove(items, oldIndex, newIndex);
|
||||
@@ -185,7 +173,7 @@ export function DraggableList<T>({
|
||||
}
|
||||
}
|
||||
},
|
||||
[items, keyExtractor, onDragEnd]
|
||||
[items, keyExtractor, onDragEnd],
|
||||
);
|
||||
|
||||
const ids = items.map((item, index) => keyExtractor(item, index));
|
||||
@@ -204,9 +192,7 @@ export function DraggableList<T>({
|
||||
testID={testID}
|
||||
style={style}
|
||||
contentContainerStyle={contentContainerStyle}
|
||||
showsVerticalScrollIndicator={
|
||||
showCustomScrollbar ? false : showsVerticalScrollIndicator
|
||||
}
|
||||
showsVerticalScrollIndicator={showCustomScrollbar ? false : showsVerticalScrollIndicator}
|
||||
onLayout={showCustomScrollbar ? scrollbarMetrics.onLayout : undefined}
|
||||
onContentSizeChange={
|
||||
showCustomScrollbar ? scrollbarMetrics.onContentSizeChange : undefined
|
||||
|
||||
@@ -21,10 +21,7 @@ export function EmptyState({ onCreateAgent, onImportAgent }: EmptyStateProps) {
|
||||
<Text style={styles.primaryButtonText}>New agent</Text>
|
||||
</Pressable>
|
||||
{hasImportCta ? (
|
||||
<Pressable
|
||||
onPress={onImportAgent}
|
||||
style={[styles.button, styles.secondaryButton]}
|
||||
>
|
||||
<Pressable onPress={onImportAgent} style={[styles.button, styles.secondaryButton]}>
|
||||
<Download size={20} color={styles.secondaryButtonText.color} />
|
||||
<Text style={styles.secondaryButtonText}>Import agent</Text>
|
||||
</Pressable>
|
||||
|
||||
@@ -2,11 +2,7 @@ import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { View, Text, Pressable, Platform, useWindowDimensions } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useIsFocused } from "@react-navigation/native";
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
runOnJS,
|
||||
} from "react-native-reanimated";
|
||||
import Animated, { useAnimatedStyle, useSharedValue, runOnJS } from "react-native-reanimated";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { X } from "lucide-react-native";
|
||||
@@ -23,8 +19,7 @@ import { FileExplorerPane } from "./file-explorer-pane";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
|
||||
const MIN_CHAT_WIDTH = 400;
|
||||
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {
|
||||
}
|
||||
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
|
||||
|
||||
interface ExplorerSidebarProps {
|
||||
serverId: string;
|
||||
@@ -44,8 +39,7 @@ export function ExplorerSidebar({
|
||||
const { theme } = useUnistyles();
|
||||
const isScreenFocused = useIsFocused();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
||||
const closeToAgent = usePanelStore((state) => state.closeToAgent);
|
||||
@@ -68,7 +62,7 @@ export function ExplorerSidebar({
|
||||
}
|
||||
const maxWidth = Math.max(
|
||||
MIN_EXPLORER_SIDEBAR_WIDTH,
|
||||
Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH)
|
||||
Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH),
|
||||
);
|
||||
if (explorerWidth > maxWidth) {
|
||||
setExplorerWidth(maxWidth);
|
||||
@@ -102,7 +96,7 @@ export function ExplorerSidebar({
|
||||
});
|
||||
closeToAgent();
|
||||
},
|
||||
[closeToAgent, desktopFileExplorerOpen, isOpen, mobileView]
|
||||
[closeToAgent, desktopFileExplorerOpen, isOpen, mobileView],
|
||||
);
|
||||
|
||||
const enableSidebarCloseGesture = isMobile && isOpen;
|
||||
@@ -111,7 +105,7 @@ export function ExplorerSidebar({
|
||||
(tab: ExplorerTab) => {
|
||||
setExplorerTabForCheckout({ serverId, cwd: workspaceRoot, isGit, tab });
|
||||
},
|
||||
[isGit, serverId, setExplorerTabForCheckout, workspaceRoot]
|
||||
[isGit, serverId, setExplorerTabForCheckout, workspaceRoot],
|
||||
);
|
||||
|
||||
// Swipe gesture to close (swipe right on mobile)
|
||||
@@ -170,8 +164,7 @@ export function ExplorerSidebar({
|
||||
})
|
||||
.onEnd((event) => {
|
||||
isGesturing.value = false;
|
||||
const shouldClose =
|
||||
event.translationX > windowWidth / 3 || event.velocityX > 500;
|
||||
const shouldClose = event.translationX > windowWidth / 3 || event.velocityX > 500;
|
||||
runOnJS(logExplorerSidebar)("closeGestureEnd", {
|
||||
translationX: event.translationX,
|
||||
velocityX: event.velocityX,
|
||||
@@ -200,7 +193,7 @@ export function ExplorerSidebar({
|
||||
closeGestureRef,
|
||||
closeTouchStartX,
|
||||
closeTouchStartY,
|
||||
]
|
||||
],
|
||||
);
|
||||
|
||||
// Desktop resize gesture (drag left edge)
|
||||
@@ -218,18 +211,15 @@ export function ExplorerSidebar({
|
||||
const newWidth = startWidthRef.current - event.translationX;
|
||||
const maxWidth = Math.max(
|
||||
MIN_EXPLORER_SIDEBAR_WIDTH,
|
||||
Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH)
|
||||
);
|
||||
const clampedWidth = Math.max(
|
||||
MIN_EXPLORER_SIDEBAR_WIDTH,
|
||||
Math.min(maxWidth, newWidth)
|
||||
Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH),
|
||||
);
|
||||
const clampedWidth = Math.max(MIN_EXPLORER_SIDEBAR_WIDTH, Math.min(maxWidth, newWidth));
|
||||
resizeWidth.value = clampedWidth;
|
||||
})
|
||||
.onEnd(() => {
|
||||
runOnJS(setExplorerWidth)(resizeWidth.value);
|
||||
}),
|
||||
[isMobile, explorerWidth, resizeWidth, setExplorerWidth, viewportWidth]
|
||||
[isMobile, explorerWidth, resizeWidth, setExplorerWidth, viewportWidth],
|
||||
);
|
||||
|
||||
const sidebarAnimatedStyle = useAnimatedStyle(() => ({
|
||||
@@ -299,16 +289,11 @@ export function ExplorerSidebar({
|
||||
}
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[styles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }]}
|
||||
>
|
||||
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }]}>
|
||||
{/* Resize handle - absolutely positioned over left border */}
|
||||
<GestureDetector gesture={resizeGesture}>
|
||||
<View
|
||||
style={[
|
||||
styles.resizeHandle,
|
||||
Platform.OS === "web" && ({ cursor: "col-resize" } as any),
|
||||
]}
|
||||
style={[styles.resizeHandle, Platform.OS === "web" && ({ cursor: "col-resize" } as any)]}
|
||||
/>
|
||||
</GestureDetector>
|
||||
|
||||
@@ -351,8 +336,7 @@ function SidebarContent({
|
||||
onOpenFile,
|
||||
}: SidebarContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const resolvedTab: ExplorerTab =
|
||||
!isGit && activeTab === "changes" ? "files" : activeTab;
|
||||
const resolvedTab: ExplorerTab = !isGit && activeTab === "changes" ? "files" : activeTab;
|
||||
|
||||
return (
|
||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||
@@ -365,12 +349,7 @@ function SidebarContent({
|
||||
style={[styles.tab, resolvedTab === "changes" && styles.tabActive]}
|
||||
onPress={() => onTabPress("changes")}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabText,
|
||||
resolvedTab === "changes" && styles.tabTextActive,
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.tabText, resolvedTab === "changes" && styles.tabTextActive]}>
|
||||
Changes
|
||||
</Text>
|
||||
</Pressable>
|
||||
@@ -380,12 +359,7 @@ function SidebarContent({
|
||||
style={[styles.tab, resolvedTab === "files" && styles.tabActive]}
|
||||
onPress={() => onTabPress("files")}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabText,
|
||||
resolvedTab === "files" && styles.tabTextActive,
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.tabText, resolvedTab === "files" && styles.tabTextActive]}>
|
||||
Files
|
||||
</Text>
|
||||
</Pressable>
|
||||
@@ -479,7 +453,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
tabActive: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
tabText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { View, Text, Platform } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
withTiming,
|
||||
useSharedValue,
|
||||
} from "react-native-reanimated";
|
||||
import Animated, { useAnimatedStyle, withTiming, useSharedValue } from "react-native-reanimated";
|
||||
import { useEffect } from "react";
|
||||
import { Upload } from "lucide-react-native";
|
||||
import { useFileDropZone } from "@/hooks/use-file-drop-zone";
|
||||
@@ -18,11 +14,7 @@ interface FileDropZoneProps {
|
||||
|
||||
const IS_WEB = Platform.OS === "web";
|
||||
|
||||
export function FileDropZone({
|
||||
children,
|
||||
onFilesDropped,
|
||||
disabled = false,
|
||||
}: FileDropZoneProps) {
|
||||
export function FileDropZone({ children, onFilesDropped, disabled = false }: FileDropZoneProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { isDragging, containerRef } = useFileDropZone({
|
||||
onFilesDropped,
|
||||
|
||||
@@ -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 {
|
||||
@@ -37,10 +38,7 @@ import {
|
||||
RotateCw,
|
||||
X,
|
||||
} from "lucide-react-native";
|
||||
import type {
|
||||
AgentFileExplorerState,
|
||||
ExplorerEntry,
|
||||
} from "@/stores/session-store";
|
||||
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useDownloadStore } from "@/stores/download-store";
|
||||
@@ -53,10 +51,7 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
||||
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
|
||||
import {
|
||||
usePanelStore,
|
||||
type SortOption,
|
||||
} from "@/stores/panel-store";
|
||||
import { usePanelStore, type SortOption } from "@/stores/panel-store";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
|
||||
import {
|
||||
@@ -101,36 +96,32 @@ export function FileExplorerPane({
|
||||
onOpenFile,
|
||||
}: FileExplorerPaneProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
|
||||
|
||||
const daemons = useHosts();
|
||||
const daemonProfile = useMemo(
|
||||
() => daemons.find((daemon) => daemon.serverId === serverId),
|
||||
[daemons, serverId]
|
||||
);
|
||||
const normalizedWorkspaceRoot = useMemo(
|
||||
() => workspaceRoot.trim(),
|
||||
[workspaceRoot]
|
||||
[daemons, serverId],
|
||||
);
|
||||
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
|
||||
const workspaceStateKey = useMemo(
|
||||
() =>
|
||||
buildWorkspaceExplorerStateKey({
|
||||
workspaceId,
|
||||
workspaceRoot: normalizedWorkspaceRoot,
|
||||
}),
|
||||
[normalizedWorkspaceRoot, workspaceId]
|
||||
[normalizedWorkspaceRoot, workspaceId],
|
||||
);
|
||||
const workspaceScopeId = useMemo(
|
||||
() => workspaceId?.trim() || normalizedWorkspaceRoot,
|
||||
[normalizedWorkspaceRoot, workspaceId]
|
||||
[normalizedWorkspaceRoot, workspaceId],
|
||||
);
|
||||
const hasWorkspaceScope = Boolean(workspaceStateKey && normalizedWorkspaceRoot);
|
||||
const explorerState = useSessionStore((state) =>
|
||||
workspaceStateKey && state.sessions[serverId]
|
||||
? state.sessions[serverId]?.fileExplorer.get(workspaceStateKey)
|
||||
: undefined
|
||||
: undefined,
|
||||
);
|
||||
|
||||
const {
|
||||
@@ -155,9 +146,9 @@ export function FileExplorerPane({
|
||||
const isDirectoryLoading = useCallback(
|
||||
(path: string) =>
|
||||
Boolean(
|
||||
isExplorerLoading && pendingRequest?.mode === "list" && pendingRequest?.path === path
|
||||
isExplorerLoading && pendingRequest?.mode === "list" && pendingRequest?.path === path,
|
||||
),
|
||||
[isExplorerLoading, pendingRequest?.mode, pendingRequest?.path]
|
||||
[isExplorerLoading, pendingRequest?.mode, pendingRequest?.path],
|
||||
);
|
||||
|
||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => new Set(["."]));
|
||||
@@ -234,7 +225,7 @@ export function FileExplorerPane({
|
||||
});
|
||||
}
|
||||
},
|
||||
[directories, expandedPaths, hasWorkspaceScope, requestDirectoryListing]
|
||||
[directories, expandedPaths, hasWorkspaceScope, requestDirectoryListing],
|
||||
);
|
||||
|
||||
const handleOpenFile = useCallback(
|
||||
@@ -245,7 +236,7 @@ export function FileExplorerPane({
|
||||
selectExplorerEntry(entry.path);
|
||||
onOpenFile?.(entry.path);
|
||||
},
|
||||
[hasWorkspaceScope, onOpenFile, selectExplorerEntry]
|
||||
[hasWorkspaceScope, onOpenFile, selectExplorerEntry],
|
||||
);
|
||||
|
||||
const handleEntryPress = useCallback(
|
||||
@@ -256,7 +247,7 @@ export function FileExplorerPane({
|
||||
}
|
||||
handleOpenFile(entry);
|
||||
},
|
||||
[handleOpenFile, handleToggleDirectory]
|
||||
[handleOpenFile, handleToggleDirectory],
|
||||
);
|
||||
|
||||
const handleCopyPath = useCallback(
|
||||
@@ -265,10 +256,10 @@ export function FileExplorerPane({
|
||||
buildAbsoluteExplorerPath({
|
||||
workspaceRoot: normalizedWorkspaceRoot,
|
||||
entryPath: path,
|
||||
})
|
||||
}),
|
||||
);
|
||||
},
|
||||
[normalizedWorkspaceRoot]
|
||||
[normalizedWorkspaceRoot],
|
||||
);
|
||||
|
||||
const startDownload = useDownloadStore((state) => state.startDownload);
|
||||
@@ -287,13 +278,7 @@ export function FileExplorerPane({
|
||||
requestFileDownloadToken: (targetPath) => requestFileDownloadToken(targetPath),
|
||||
});
|
||||
},
|
||||
[
|
||||
daemonProfile,
|
||||
requestFileDownloadToken,
|
||||
serverId,
|
||||
startDownload,
|
||||
workspaceScopeId,
|
||||
]
|
||||
[daemonProfile, requestFileDownloadToken, serverId, startDownload, workspaceScopeId],
|
||||
);
|
||||
|
||||
const handleSortCycle = useCallback(() => {
|
||||
@@ -319,7 +304,7 @@ export function FileExplorerPane({
|
||||
requestDirectoryListing(path, {
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
})
|
||||
}),
|
||||
),
|
||||
]);
|
||||
return null;
|
||||
@@ -341,7 +326,7 @@ export function FileExplorerPane({
|
||||
easing: Easing.linear,
|
||||
}),
|
||||
-1,
|
||||
false
|
||||
false,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -365,7 +350,7 @@ export function FileExplorerPane({
|
||||
if (finished) {
|
||||
refreshIconRotation.value = 0;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}, [isRefreshFetching, refreshIconRotation]);
|
||||
|
||||
@@ -393,10 +378,7 @@ export function FileExplorerPane({
|
||||
!directories.has(".") &&
|
||||
Boolean(isExplorerLoading && pendingRequest?.mode === "list" && pendingRequest?.path === ".");
|
||||
const showBackFromError = Boolean(error && selectedEntryPath);
|
||||
const errorRecoveryPath = useMemo(
|
||||
() => getErrorRecoveryPath(explorerState),
|
||||
[explorerState]
|
||||
);
|
||||
const errorRecoveryPath = useMemo(() => getErrorRecoveryPath(explorerState), [explorerState]);
|
||||
|
||||
const renderTreeRow = useCallback(
|
||||
({ item }: ListRenderItemInfo<TreeRow>) => {
|
||||
@@ -494,7 +476,7 @@ export function FileExplorerPane({
|
||||
selectedEntryPath,
|
||||
theme.colors,
|
||||
theme.spacing,
|
||||
]
|
||||
],
|
||||
);
|
||||
|
||||
const handleBackFromError = useCallback(() => {
|
||||
@@ -514,7 +496,7 @@ export function FileExplorerPane({
|
||||
treeScrollbarMetrics.onScroll(event);
|
||||
}
|
||||
},
|
||||
[showDesktopWebScrollbar, treeScrollbarMetrics]
|
||||
[showDesktopWebScrollbar, treeScrollbarMetrics],
|
||||
);
|
||||
|
||||
const handleTreeListLayout = useCallback(
|
||||
@@ -523,7 +505,7 @@ export function FileExplorerPane({
|
||||
treeScrollbarMetrics.onLayout(event);
|
||||
}
|
||||
},
|
||||
[showDesktopWebScrollbar, treeScrollbarMetrics]
|
||||
[showDesktopWebScrollbar, treeScrollbarMetrics],
|
||||
);
|
||||
|
||||
if (!hasWorkspaceScope) {
|
||||
@@ -535,9 +517,7 @@ export function FileExplorerPane({
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={styles.container}
|
||||
>
|
||||
<View style={styles.container}>
|
||||
{error ? (
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
@@ -581,13 +561,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}>
|
||||
@@ -632,16 +611,7 @@ export function FileExplorerPane({
|
||||
|
||||
type EntryDisplayKind = "directory" | "image" | "text" | "other";
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set([
|
||||
"png",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"gif",
|
||||
"bmp",
|
||||
"svg",
|
||||
"webp",
|
||||
"ico",
|
||||
]);
|
||||
const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "bmp", "svg", "webp", "ico"]);
|
||||
|
||||
const TEXT_EXTENSIONS = new Set([
|
||||
"txt",
|
||||
@@ -684,7 +654,7 @@ const TEXT_EXTENSIONS = new Set([
|
||||
|
||||
function renderEntryIcon(
|
||||
kind: EntryDisplayKind,
|
||||
colors: { foreground: string; primary: string; directoryOpen?: boolean }
|
||||
colors: { foreground: string; primary: string; directoryOpen?: boolean },
|
||||
) {
|
||||
const color = colors.foreground;
|
||||
switch (kind) {
|
||||
@@ -783,7 +753,7 @@ function buildTreeRows({
|
||||
sortOption,
|
||||
path: entry.path,
|
||||
depth: depth + 1,
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -875,7 +845,7 @@ 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,
|
||||
@@ -898,7 +868,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexShrink: 0,
|
||||
},
|
||||
sortButton: {
|
||||
height: 32,
|
||||
height: 28,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
@@ -1025,8 +995,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 +1004,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,4 +1,4 @@
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import React, { useCallback, useMemo, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
@@ -11,13 +11,37 @@ import {
|
||||
type NativeScrollEvent,
|
||||
type NativeSyntheticEvent,
|
||||
} from "react-native";
|
||||
import { StyleSheet, UnistylesRuntime } from "react-native-unistyles";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
|
||||
import {
|
||||
WebDesktopScrollbarOverlay,
|
||||
useWebDesktopScrollbarMetrics,
|
||||
} from "@/components/web-desktop-scrollbar";
|
||||
import {
|
||||
highlightCode,
|
||||
darkHighlightColors,
|
||||
lightHighlightColors,
|
||||
type HighlightToken,
|
||||
type HighlightStyle,
|
||||
} from "@getpaseo/highlight";
|
||||
import { lineNumberGutterWidth } from "@/components/code-insets";
|
||||
|
||||
interface CodeLineProps {
|
||||
tokens: HighlightToken[];
|
||||
lineNumber: number;
|
||||
gutterWidth: number;
|
||||
colorMap: Record<HighlightStyle, string>;
|
||||
baseColor: string;
|
||||
}
|
||||
|
||||
interface FilePreviewBodyProps {
|
||||
preview: ExplorerFile | null;
|
||||
isLoading: boolean;
|
||||
showDesktopWebScrollbar: boolean;
|
||||
isMobile: boolean;
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string") {
|
||||
@@ -37,28 +61,92 @@ function formatFileSize({ size }: { size: number }): string {
|
||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
const CodeLine = React.memo(function CodeLine({
|
||||
tokens,
|
||||
lineNumber,
|
||||
gutterWidth,
|
||||
colorMap,
|
||||
baseColor,
|
||||
}: CodeLineProps) {
|
||||
return (
|
||||
<View style={codeLineStyles.line}>
|
||||
<View style={[codeLineStyles.gutter, { width: gutterWidth }]}>
|
||||
<Text style={[codeLineStyles.gutterText, { color: baseColor }]}>{String(lineNumber)}</Text>
|
||||
</View>
|
||||
<Text style={codeLineStyles.lineText}>
|
||||
{tokens.map((token, index) => (
|
||||
<Text
|
||||
key={index}
|
||||
style={{ color: token.style ? (colorMap[token.style] ?? baseColor) : baseColor }}
|
||||
>
|
||||
{token.text}
|
||||
</Text>
|
||||
))}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
const codeLineStyles = StyleSheet.create((theme) => ({
|
||||
line: {
|
||||
flexDirection: "row",
|
||||
},
|
||||
gutter: {
|
||||
alignItems: "flex-end",
|
||||
paddingRight: theme.spacing[3],
|
||||
flexShrink: 0,
|
||||
},
|
||||
gutterText: {
|
||||
fontFamily: Fonts.mono,
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: theme.fontSize.sm * 1.45,
|
||||
opacity: 0.4,
|
||||
userSelect: "none",
|
||||
},
|
||||
lineText: {
|
||||
fontFamily: Fonts.mono,
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: theme.fontSize.sm * 1.45,
|
||||
flex: 1,
|
||||
},
|
||||
}));
|
||||
|
||||
function FilePreviewBody({
|
||||
preview,
|
||||
isLoading,
|
||||
showDesktopWebScrollbar,
|
||||
isMobile,
|
||||
}: {
|
||||
preview: ExplorerFile | null;
|
||||
isLoading: boolean;
|
||||
showDesktopWebScrollbar: boolean;
|
||||
isMobile: boolean;
|
||||
}) {
|
||||
filePath,
|
||||
}: FilePreviewBodyProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isDark = theme.colors.surface0 === "#18181c";
|
||||
const colorMap = isDark ? darkHighlightColors : lightHighlightColors;
|
||||
const baseColor = isDark ? "#c9d1d9" : "#24292f";
|
||||
|
||||
const enablePreviewDesktopScrollbar = showDesktopWebScrollbar;
|
||||
const previewScrollRef = useRef<RNScrollView>(null);
|
||||
const previewScrollbarMetrics = useWebDesktopScrollbarMetrics();
|
||||
|
||||
const highlightedLines = useMemo(() => {
|
||||
if (!preview || preview.kind !== "text") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return highlightCode(preview.content ?? "", filePath);
|
||||
}, [preview?.kind, preview?.content, filePath]);
|
||||
|
||||
const gutterWidth = useMemo(() => {
|
||||
if (!highlightedLines) return 0;
|
||||
return lineNumberGutterWidth(highlightedLines.length);
|
||||
}, [highlightedLines]);
|
||||
|
||||
const handlePreviewScroll = useCallback(
|
||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
if (enablePreviewDesktopScrollbar) {
|
||||
previewScrollbarMetrics.onScroll(event);
|
||||
}
|
||||
},
|
||||
[enablePreviewDesktopScrollbar, previewScrollbarMetrics]
|
||||
[enablePreviewDesktopScrollbar, previewScrollbarMetrics],
|
||||
);
|
||||
|
||||
const handlePreviewLayout = useCallback(
|
||||
@@ -67,7 +155,7 @@ function FilePreviewBody({
|
||||
previewScrollbarMetrics.onLayout(event);
|
||||
}
|
||||
},
|
||||
[enablePreviewDesktopScrollbar, previewScrollbarMetrics]
|
||||
[enablePreviewDesktopScrollbar, previewScrollbarMetrics],
|
||||
);
|
||||
|
||||
if (isLoading && !preview) {
|
||||
@@ -88,6 +176,22 @@ function FilePreviewBody({
|
||||
}
|
||||
|
||||
if (preview.kind === "text") {
|
||||
const lines = highlightedLines ?? [[{ text: preview.content ?? "", style: null }]];
|
||||
const codeLines = (
|
||||
<View>
|
||||
{lines.map((tokens, index) => (
|
||||
<CodeLine
|
||||
key={index}
|
||||
tokens={tokens}
|
||||
lineNumber={index + 1}
|
||||
gutterWidth={gutterWidth}
|
||||
colorMap={colorMap}
|
||||
baseColor={baseColor}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.previewScrollContainer}>
|
||||
<RNScrollView
|
||||
@@ -96,17 +200,13 @@ function FilePreviewBody({
|
||||
onLayout={enablePreviewDesktopScrollbar ? handlePreviewLayout : undefined}
|
||||
onScroll={enablePreviewDesktopScrollbar ? handlePreviewScroll : undefined}
|
||||
onContentSizeChange={
|
||||
enablePreviewDesktopScrollbar
|
||||
? previewScrollbarMetrics.onContentSizeChange
|
||||
: undefined
|
||||
enablePreviewDesktopScrollbar ? previewScrollbarMetrics.onContentSizeChange : undefined
|
||||
}
|
||||
scrollEventThrottle={enablePreviewDesktopScrollbar ? 16 : undefined}
|
||||
showsVerticalScrollIndicator={!enablePreviewDesktopScrollbar}
|
||||
>
|
||||
{isMobile ? (
|
||||
<View style={styles.previewCodeScrollContent}>
|
||||
<Text style={styles.codeText}>{preview.content}</Text>
|
||||
</View>
|
||||
<View style={styles.previewCodeScrollContent}>{codeLines}</View>
|
||||
) : (
|
||||
<RNScrollView
|
||||
horizontal
|
||||
@@ -114,7 +214,7 @@ function FilePreviewBody({
|
||||
showsHorizontalScrollIndicator
|
||||
contentContainerStyle={styles.previewCodeScrollContent}
|
||||
>
|
||||
<Text style={styles.codeText}>{preview.content}</Text>
|
||||
{codeLines}
|
||||
</RNScrollView>
|
||||
)}
|
||||
</RNScrollView>
|
||||
@@ -139,9 +239,7 @@ function FilePreviewBody({
|
||||
onLayout={enablePreviewDesktopScrollbar ? handlePreviewLayout : undefined}
|
||||
onScroll={enablePreviewDesktopScrollbar ? handlePreviewScroll : undefined}
|
||||
onContentSizeChange={
|
||||
enablePreviewDesktopScrollbar
|
||||
? previewScrollbarMetrics.onContentSizeChange
|
||||
: undefined
|
||||
enablePreviewDesktopScrollbar ? previewScrollbarMetrics.onContentSizeChange : undefined
|
||||
}
|
||||
scrollEventThrottle={enablePreviewDesktopScrollbar ? 16 : undefined}
|
||||
showsVerticalScrollIndicator={!enablePreviewDesktopScrollbar}
|
||||
@@ -182,8 +280,7 @@ export function FilePane({
|
||||
workspaceRoot: string;
|
||||
filePath: string;
|
||||
}) {
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
|
||||
|
||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||
@@ -200,7 +297,7 @@ export function FilePane({
|
||||
const payload = await client.exploreFileSystem(
|
||||
normalizedWorkspaceRoot,
|
||||
normalizedFilePath,
|
||||
"file"
|
||||
"file",
|
||||
);
|
||||
return { file: payload.file ?? null, error: payload.error ?? null };
|
||||
},
|
||||
@@ -220,6 +317,7 @@ export function FilePane({
|
||||
isLoading={query.isFetching}
|
||||
showDesktopWebScrollbar={showDesktopWebScrollbar}
|
||||
isMobile={isMobile}
|
||||
filePath={filePath}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
@@ -268,12 +366,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
previewCodeScrollContent: {
|
||||
padding: theme.spacing[4],
|
||||
},
|
||||
codeText: {
|
||||
fontFamily: Fonts.mono,
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: theme.fontSize.sm * 1.45,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
previewImageScrollContent: {
|
||||
flexGrow: 1,
|
||||
padding: theme.spacing[4],
|
||||
|
||||
@@ -2,9 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildGitActions, type BuildGitActionsInput } from "./git-actions-policy";
|
||||
|
||||
function createInput(
|
||||
overrides: Partial<BuildGitActionsInput> = {}
|
||||
): BuildGitActionsInput {
|
||||
function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitActionsInput {
|
||||
return {
|
||||
isGit: true,
|
||||
githubFeaturesEnabled: true,
|
||||
@@ -68,7 +66,7 @@ describe("git-actions-policy", () => {
|
||||
aheadCount: 3,
|
||||
aheadOfOrigin: 2,
|
||||
shipDefault: "pr",
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
expect(noPrActions.primary).toBeNull();
|
||||
@@ -117,18 +115,20 @@ describe("git-actions-policy", () => {
|
||||
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);
|
||||
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");
|
||||
|
||||
|
||||
@@ -54,12 +54,7 @@ export interface BuildGitActionsInput {
|
||||
runtime: Record<GitActionId, GitActionRuntimeState>;
|
||||
}
|
||||
|
||||
const SECONDARY_ACTION_IDS: GitActionId[] = [
|
||||
"merge-branch",
|
||||
"pr",
|
||||
"merge-from-base",
|
||||
"push",
|
||||
];
|
||||
const SECONDARY_ACTION_IDS: GitActionId[] = ["merge-branch", "pr", "merge-from-base", "push"];
|
||||
|
||||
export function buildGitActions(input: BuildGitActionsInput): GitActions {
|
||||
if (!input.isGit) {
|
||||
@@ -134,7 +129,7 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
|
||||
});
|
||||
|
||||
const primaryActionId = getPrimaryActionId(input);
|
||||
const primary = primaryActionId ? allActions.get(primaryActionId) ?? null : null;
|
||||
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")!);
|
||||
@@ -189,10 +184,7 @@ function buildPrAction(input: BuildGitActionsInput): GitAction {
|
||||
label: "Create PR",
|
||||
pendingLabel: "Creating PR...",
|
||||
successLabel: "PR Created",
|
||||
disabled:
|
||||
input.runtime.pr.disabled ||
|
||||
!input.githubFeaturesEnabled ||
|
||||
input.aheadCount === 0,
|
||||
disabled: input.runtime.pr.disabled || !input.githubFeaturesEnabled || input.aheadCount === 0,
|
||||
status: input.runtime.pr.status,
|
||||
description: getCreatePrDescription(input),
|
||||
icon: input.runtime.pr.icon,
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import type { GitAction, GitActions } from "@/components/git-actions-policy";
|
||||
|
||||
interface GitActionsSplitButtonProps {
|
||||
@@ -17,6 +19,7 @@ interface GitActionsSplitButtonProps {
|
||||
|
||||
export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
|
||||
|
||||
const getActionDisplayLabel = useCallback((action: GitAction): string => {
|
||||
if (action.status === "pending") return action.pendingLabel;
|
||||
@@ -49,7 +52,9 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
|
||||
) : (
|
||||
<View style={styles.splitButtonContent}>
|
||||
{gitActions.primary.icon}
|
||||
<Text style={styles.splitButtonText}>{getActionDisplayLabel(gitActions.primary)}</Text>
|
||||
<Text style={styles.splitButtonText}>
|
||||
{getActionDisplayLabel(gitActions.primary)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
@@ -75,6 +80,11 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
|
||||
<DropdownMenuItem
|
||||
testID={`changes-menu-${action.id}`}
|
||||
leading={action.icon}
|
||||
trailing={
|
||||
action.id === "archive-worktree" && archiveShortcutKeys
|
||||
? <Shortcut chord={archiveShortcutKeys} />
|
||||
: undefined
|
||||
}
|
||||
disabled={action.disabled}
|
||||
status={action.status}
|
||||
pendingLabel={action.pendingLabel}
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { useState, useCallback, useEffect, useId, useMemo, useRef, memo, type ReactElement } from "react";
|
||||
import {
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
memo,
|
||||
type ReactElement,
|
||||
} from "react";
|
||||
import { useRouter } from "expo-router";
|
||||
import {
|
||||
View,
|
||||
@@ -11,13 +19,11 @@ import {
|
||||
type NativeSyntheticEvent,
|
||||
type NativeScrollEvent,
|
||||
} from "react-native";
|
||||
import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import {
|
||||
Archive,
|
||||
ChevronDown,
|
||||
|
||||
GitBranch,
|
||||
GitCommitHorizontal,
|
||||
GitMerge,
|
||||
@@ -25,6 +31,7 @@ import {
|
||||
ListChevronsUpDown,
|
||||
RefreshCcw,
|
||||
Upload,
|
||||
WrapText,
|
||||
} from "lucide-react-native";
|
||||
import { useCheckoutGitActionsStore } from "@/stores/checkout-git-actions-store";
|
||||
import {
|
||||
@@ -35,8 +42,13 @@ import {
|
||||
} from "@/hooks/use-checkout-diff-query";
|
||||
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 { DiffScroll } from "./diff-scroll";
|
||||
import {
|
||||
darkHighlightColors,
|
||||
lightHighlightColors,
|
||||
type HighlightStyle as HighlightStyleKey,
|
||||
} from "@getpaseo/highlight";
|
||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import { shouldAnchorHeaderBeforeCollapse } from "@/utils/git-diff-scroll";
|
||||
import {
|
||||
@@ -46,11 +58,10 @@ 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 { buildGitActions, type GitActions } from "@/components/git-actions-policy";
|
||||
import { lineNumberGutterWidth } from "@/components/code-insets";
|
||||
import {
|
||||
WebDesktopScrollbarOverlay,
|
||||
useWebDesktopScrollbarMetrics,
|
||||
@@ -73,53 +84,6 @@ interface HighlightedTextProps {
|
||||
lineType: "add" | "remove" | "context" | "header";
|
||||
}
|
||||
|
||||
// GitHub syntax highlight colors for dark/light modes
|
||||
const darkHighlightColors: Record<HighlightStyle, string> = {
|
||||
keyword: "#ff7b72",
|
||||
comment: "#8b949e",
|
||||
string: "#a5d6ff",
|
||||
number: "#79c0ff",
|
||||
literal: "#79c0ff",
|
||||
function: "#d2a8ff",
|
||||
definition: "#d2a8ff",
|
||||
class: "#ffa657",
|
||||
type: "#ff7b72",
|
||||
tag: "#7ee787",
|
||||
attribute: "#79c0ff",
|
||||
property: "#79c0ff",
|
||||
variable: "#c9d1d9",
|
||||
operator: "#79c0ff",
|
||||
punctuation: "#c9d1d9",
|
||||
regexp: "#a5d6ff",
|
||||
escape: "#79c0ff",
|
||||
meta: "#8b949e",
|
||||
heading: "#79c0ff",
|
||||
link: "#a5d6ff",
|
||||
};
|
||||
|
||||
const lightHighlightColors: Record<HighlightStyle, string> = {
|
||||
keyword: "#cf222e",
|
||||
comment: "#6e7781",
|
||||
string: "#0a3069",
|
||||
number: "#0550ae",
|
||||
literal: "#0550ae",
|
||||
function: "#8250df",
|
||||
definition: "#8250df",
|
||||
class: "#953800",
|
||||
type: "#cf222e",
|
||||
tag: "#116329",
|
||||
attribute: "#0550ae",
|
||||
property: "#0550ae",
|
||||
variable: "#24292f",
|
||||
operator: "#0550ae",
|
||||
punctuation: "#24292f",
|
||||
regexp: "#0a3069",
|
||||
escape: "#0550ae",
|
||||
meta: "#6e7781",
|
||||
heading: "#0550ae",
|
||||
link: "#0a3069",
|
||||
};
|
||||
|
||||
function HighlightedText({ tokens, lineType }: HighlightedTextProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isDark = theme.colors.surface0 === "#18181c";
|
||||
@@ -129,7 +93,7 @@ function HighlightedText({ tokens, lineType }: HighlightedTextProps) {
|
||||
const baseColor = isDark ? "#c9d1d9" : "#24292f";
|
||||
if (!style) return baseColor;
|
||||
const colors = isDark ? darkHighlightColors : lightHighlightColors;
|
||||
return colors[style] ?? baseColor;
|
||||
return colors[style as HighlightStyleKey] ?? baseColor;
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -143,7 +107,6 @@ function HighlightedText({ tokens, lineType }: HighlightedTextProps) {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
interface DiffFileSectionProps {
|
||||
file: ParsedDiffFile;
|
||||
isExpanded: boolean;
|
||||
@@ -152,7 +115,15 @@ 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,12 +134,19 @@ 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}
|
||||
baseStyle={null}
|
||||
lineType={line.type}
|
||||
/>
|
||||
<HighlightedText tokens={line.tokens} baseStyle={null} lineType={line.type} />
|
||||
) : (
|
||||
<Text
|
||||
style={[
|
||||
@@ -204,10 +182,7 @@ const DiffFileHeader = memo(function DiffFileHeader({
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.fileSectionHeaderContainer,
|
||||
!isExpanded && styles.fileSectionBorder,
|
||||
]}
|
||||
style={[styles.fileSectionHeaderContainer, isExpanded && styles.fileSectionHeaderExpanded]}
|
||||
onLayout={(event) => {
|
||||
layoutYRef.current = event.nativeEvent.layout.y;
|
||||
onHeaderHeightChange?.(file.path, event.nativeEvent.layout.height);
|
||||
@@ -216,10 +191,7 @@ const DiffFileHeader = memo(function DiffFileHeader({
|
||||
>
|
||||
<Pressable
|
||||
testID={testID ? `${testID}-toggle` : undefined}
|
||||
style={({ pressed }) => [
|
||||
styles.fileHeader,
|
||||
pressed && styles.fileHeaderPressed,
|
||||
]}
|
||||
style={({ pressed }) => [styles.fileHeader, pressed && styles.fileHeaderPressed]}
|
||||
// Android: prevent parent pan/scroll gestures from canceling the tap release.
|
||||
cancelable={false}
|
||||
onPressIn={(event) => {
|
||||
@@ -253,9 +225,7 @@ const DiffFileHeader = memo(function DiffFileHeader({
|
||||
<View style={styles.fileHeaderLeft}>
|
||||
<Text style={styles.fileName}>{file.path.split("/").pop()}</Text>
|
||||
<Text style={styles.fileDir} numberOfLines={1}>
|
||||
{file.path.includes("/")
|
||||
? ` ${file.path.slice(0, file.path.lastIndexOf("/"))}`
|
||||
: ""}
|
||||
{file.path.includes("/") ? ` ${file.path.slice(0, file.path.lastIndexOf("/"))}` : ""}
|
||||
</Text>
|
||||
{file.isNew && (
|
||||
<View style={styles.newBadge}>
|
||||
@@ -279,49 +249,16 @@ const DiffFileHeader = memo(function DiffFileHeader({
|
||||
|
||||
function DiffFileBody({
|
||||
file,
|
||||
wrapLines,
|
||||
onBodyHeightChange,
|
||||
testID,
|
||||
}: {
|
||||
file: ParsedDiffFile;
|
||||
wrapLines: boolean;
|
||||
onBodyHeightChange?: (path: string, height: number) => void;
|
||||
testID?: string;
|
||||
}) {
|
||||
const [scrollViewWidth, setScrollViewWidth] = useState(0);
|
||||
const [isAtLeftEdge, setIsAtLeftEdge] = useState(true);
|
||||
const horizontalScroll = useHorizontalScrollOptional();
|
||||
const scrollId = useId();
|
||||
const scrollViewRef = useRef<ScrollViewType>(null);
|
||||
|
||||
// Get the close gesture ref from animation context (may not be available outside sidebar)
|
||||
let closeGestureRef: React.MutableRefObject<any> | undefined;
|
||||
try {
|
||||
const animation = useExplorerSidebarAnimation();
|
||||
closeGestureRef = animation.closeGestureRef;
|
||||
} catch {
|
||||
// Not inside ExplorerSidebarAnimationProvider, which is fine
|
||||
}
|
||||
|
||||
// Register/unregister scroll offset tracking
|
||||
useEffect(() => {
|
||||
if (!horizontalScroll) return;
|
||||
// Start at 0 (not scrolled)
|
||||
horizontalScroll.registerScrollOffset(scrollId, 0);
|
||||
return () => {
|
||||
horizontalScroll.unregisterScrollOffset(scrollId);
|
||||
};
|
||||
}, [horizontalScroll, scrollId]);
|
||||
|
||||
const handleScroll = useCallback(
|
||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
const offsetX = event.nativeEvent.contentOffset.x;
|
||||
// Track if we're at the left edge (with small threshold for float precision)
|
||||
setIsAtLeftEdge(offsetX <= 1);
|
||||
if (horizontalScroll) {
|
||||
horizontalScroll.registerScrollOffset(scrollId, offsetX);
|
||||
}
|
||||
},
|
||||
[horizontalScroll, scrollId]
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -331,39 +268,78 @@ 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 gutterWidth = lineNumberGutterWidth(maxLineNo);
|
||||
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 (
|
||||
<DiffScroll
|
||||
scrollViewWidth={scrollViewWidth}
|
||||
onScrollViewWidthChange={setScrollViewWidth}
|
||||
style={styles.diffContent}
|
||||
contentContainerStyle={styles.diffContentInner}
|
||||
>
|
||||
<View
|
||||
style={[styles.linesContainer, scrollViewWidth > 0 && { minWidth: scrollViewWidth }]}
|
||||
>
|
||||
{linesContent}
|
||||
</View>
|
||||
</DiffScroll>
|
||||
);
|
||||
})()}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -381,16 +357,37 @@ type DiffFlatItem =
|
||||
|
||||
export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDiffPaneProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
|
||||
const router = useRouter();
|
||||
const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
|
||||
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
|
||||
const { status, isLoading: isStatusLoading, isFetching: isStatusFetching, isError: isStatusError, error: statusError, refresh: refreshStatus } =
|
||||
useCheckoutStatusQuery({ serverId, cwd });
|
||||
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;
|
||||
const isGit = Boolean(gitStatus);
|
||||
const notGit = status !== null && !status.isGit && !status.error;
|
||||
@@ -481,7 +478,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
// Ignore persistence failures; default will reset to "merge".
|
||||
}
|
||||
},
|
||||
[shipDefaultStorageKey]
|
||||
[shipDefaultStorageKey],
|
||||
);
|
||||
|
||||
const { flatItems, stickyHeaderIndices } = useMemo(() => {
|
||||
@@ -523,7 +520,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
diffScrollbarMetrics.onScroll(event);
|
||||
}
|
||||
},
|
||||
[diffScrollbarMetrics, showDesktopWebScrollbar]
|
||||
[diffScrollbarMetrics, showDesktopWebScrollbar],
|
||||
);
|
||||
|
||||
const handleDiffListLayout = useCallback(
|
||||
@@ -537,7 +534,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
diffScrollbarMetrics.onLayout(event);
|
||||
}
|
||||
},
|
||||
[diffScrollbarMetrics, showDesktopWebScrollbar]
|
||||
[diffScrollbarMetrics, showDesktopWebScrollbar],
|
||||
);
|
||||
|
||||
const computeHeaderOffset = useCallback(
|
||||
@@ -555,7 +552,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
}
|
||||
return Math.max(0, offset);
|
||||
},
|
||||
[expandedByPath, files]
|
||||
[expandedByPath, files],
|
||||
);
|
||||
|
||||
const handleToggleExpanded = useCallback(
|
||||
@@ -589,7 +586,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
[path]: nextExpanded,
|
||||
}));
|
||||
},
|
||||
[computeHeaderOffset, expandedByPath]
|
||||
[computeHeaderOffset, expandedByPath],
|
||||
);
|
||||
|
||||
const allExpanded = useMemo(() => {
|
||||
@@ -622,22 +619,22 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
}, [autoDiffMode]);
|
||||
|
||||
const commitStatus = useCheckoutGitActionsStore((state) =>
|
||||
state.getStatus({ serverId, cwd, actionId: "commit" })
|
||||
state.getStatus({ serverId, cwd, actionId: "commit" }),
|
||||
);
|
||||
const pushStatus = useCheckoutGitActionsStore((state) =>
|
||||
state.getStatus({ serverId, cwd, actionId: "push" })
|
||||
state.getStatus({ serverId, cwd, actionId: "push" }),
|
||||
);
|
||||
const prCreateStatus = useCheckoutGitActionsStore((state) =>
|
||||
state.getStatus({ serverId, cwd, actionId: "create-pr" })
|
||||
state.getStatus({ serverId, cwd, actionId: "create-pr" }),
|
||||
);
|
||||
const mergeStatus = useCheckoutGitActionsStore((state) =>
|
||||
state.getStatus({ serverId, cwd, actionId: "merge-branch" })
|
||||
state.getStatus({ serverId, cwd, actionId: "merge-branch" }),
|
||||
);
|
||||
const mergeFromBaseStatus = useCheckoutGitActionsStore((state) =>
|
||||
state.getStatus({ serverId, cwd, actionId: "merge-from-base" })
|
||||
state.getStatus({ serverId, cwd, actionId: "merge-from-base" }),
|
||||
);
|
||||
const archiveStatus = useCheckoutGitActionsStore((state) =>
|
||||
state.getStatus({ serverId, cwd, actionId: "archive-worktree" })
|
||||
state.getStatus({ serverId, cwd, actionId: "archive-worktree" }),
|
||||
);
|
||||
|
||||
const runCommit = useCheckoutGitActionsStore((state) => state.commit);
|
||||
@@ -735,24 +732,25 @@ 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(
|
||||
(item: DiffFlatItem) => `${item.type}-${item.file.path}`,
|
||||
[]
|
||||
[],
|
||||
);
|
||||
|
||||
const hasChanges = files.length > 0;
|
||||
const diffErrorMessage =
|
||||
diffPayloadError?.message ??
|
||||
(isDiffError && diffError instanceof Error ? diffError.message : null);
|
||||
const prErrorMessage = githubFeaturesEnabled ? prPayloadError?.message ?? null : null;
|
||||
const prErrorMessage = githubFeaturesEnabled ? (prPayloadError?.message ?? null) : null;
|
||||
const branchLabel =
|
||||
gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD"
|
||||
? gitStatus.currentBranch
|
||||
@@ -772,9 +770,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
if (!branchLabel || !baseRefLabel) {
|
||||
return undefined;
|
||||
}
|
||||
return branchLabel === baseRefLabel
|
||||
? undefined
|
||||
: `${branchLabel} -> ${baseRefLabel}`;
|
||||
return branchLabel === baseRefLabel ? undefined : `${branchLabel} -> ${baseRefLabel}`;
|
||||
}, [baseRefLabel, branchLabel]);
|
||||
const hasPullRequest = Boolean(prStatus?.url);
|
||||
const hasRemote = gitStatus?.hasRemote ?? false;
|
||||
@@ -789,14 +785,10 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
|
||||
const commitDisabled = actionsDisabled || commitStatus === "pending";
|
||||
const prDisabled = actionsDisabled || prCreateStatus === "pending";
|
||||
const mergeDisabled =
|
||||
actionsDisabled || mergeStatus === "pending";
|
||||
const mergeFromBaseDisabled =
|
||||
actionsDisabled || mergeFromBaseStatus === "pending";
|
||||
const pushDisabled =
|
||||
actionsDisabled || pushStatus === "pending";
|
||||
const archiveDisabled =
|
||||
actionsDisabled || archiveStatus === "pending";
|
||||
const mergeDisabled = actionsDisabled || mergeStatus === "pending";
|
||||
const mergeFromBaseDisabled = actionsDisabled || mergeFromBaseStatus === "pending";
|
||||
const pushDisabled = actionsDisabled || pushStatus === "pending";
|
||||
const archiveDisabled = actionsDisabled || archiveStatus === "pending";
|
||||
|
||||
let bodyContent: ReactElement;
|
||||
|
||||
@@ -854,9 +846,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
onLayout={handleDiffListLayout}
|
||||
onScroll={handleDiffListScroll}
|
||||
onContentSizeChange={
|
||||
showDesktopWebScrollbar
|
||||
? diffScrollbarMetrics.onContentSizeChange
|
||||
: undefined
|
||||
showDesktopWebScrollbar ? diffScrollbarMetrics.onContentSizeChange : undefined
|
||||
}
|
||||
scrollEventThrottle={16}
|
||||
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||
@@ -948,11 +938,38 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
},
|
||||
});
|
||||
}, [
|
||||
isGit, hasRemote, hasPullRequest, prStatus?.url, aheadCount, isPaseoOwnedWorktree, isOnBaseBranch, githubFeaturesEnabled,
|
||||
hasUncommittedChanges, aheadOfOrigin, behindOfOrigin, shipDefault, baseRefLabel, shouldPromoteArchive,
|
||||
commitDisabled, pushDisabled, prDisabled, mergeDisabled, mergeFromBaseDisabled, archiveDisabled,
|
||||
commitStatus, pushStatus, prCreateStatus, mergeStatus, mergeFromBaseStatus, archiveStatus,
|
||||
handleCommit, handlePush, handleCreatePr, handleMergeBranch, handleMergeFromBase, handleArchiveWorktree,
|
||||
isGit,
|
||||
hasRemote,
|
||||
hasPullRequest,
|
||||
prStatus?.url,
|
||||
aheadCount,
|
||||
isPaseoOwnedWorktree,
|
||||
isOnBaseBranch,
|
||||
githubFeaturesEnabled,
|
||||
hasUncommittedChanges,
|
||||
aheadOfOrigin,
|
||||
behindOfOrigin,
|
||||
shipDefault,
|
||||
baseRefLabel,
|
||||
shouldPromoteArchive,
|
||||
commitDisabled,
|
||||
pushDisabled,
|
||||
prDisabled,
|
||||
mergeDisabled,
|
||||
mergeFromBaseDisabled,
|
||||
archiveDisabled,
|
||||
commitStatus,
|
||||
pushStatus,
|
||||
prCreateStatus,
|
||||
mergeStatus,
|
||||
mergeFromBaseStatus,
|
||||
archiveStatus,
|
||||
handleCommit,
|
||||
handlePush,
|
||||
handleCreatePr,
|
||||
handleMergeBranch,
|
||||
handleMergeFromBase,
|
||||
handleArchiveWorktree,
|
||||
theme.colors.foregroundMuted,
|
||||
]);
|
||||
|
||||
@@ -968,9 +985,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
{branchLabel}
|
||||
</Text>
|
||||
</View>
|
||||
{isGit ? (
|
||||
<GitActionsSplitButton gitActions={gitActions} />
|
||||
) : null}
|
||||
{isGit ? <GitActionsSplitButton gitActions={gitActions} /> : null}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
@@ -988,16 +1003,12 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Diff mode"
|
||||
>
|
||||
<Text style={styles.diffStatusText}>
|
||||
<Text style={styles.diffStatusText} numberOfLines={1}>
|
||||
{diffMode === "uncommitted" ? "Uncommitted" : "Committed"}
|
||||
</Text>
|
||||
<ChevronDown size={12} color={theme.colors.foregroundMuted} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
width={260}
|
||||
testID="changes-diff-status-menu"
|
||||
>
|
||||
<DropdownMenuContent align="start" width={260} testID="changes-diff-status-menu">
|
||||
<DropdownMenuItem
|
||||
testID="changes-diff-mode-uncommitted"
|
||||
selected={diffMode === "uncommitted"}
|
||||
@@ -1017,28 +1028,61 @@ 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>
|
||||
) : null}
|
||||
|
||||
{actionError ? <Text style={styles.actionErrorText}>{actionError}</Text> : null}
|
||||
{prErrorMessage ? (
|
||||
<Text style={styles.actionErrorText}>{prErrorMessage}</Text>
|
||||
) : null}
|
||||
{prErrorMessage ? <Text style={styles.actionErrorText}>{prErrorMessage}</Text> : null}
|
||||
|
||||
<View style={styles.diffContainer}>
|
||||
{bodyContent}
|
||||
@@ -1086,11 +1130,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",
|
||||
@@ -1099,13 +1144,18 @@ const styles = StyleSheet.create((theme) => ({
|
||||
diffModeTrigger: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[1],
|
||||
// Align text with header branch icon (at spacing[3] from edge, minus our horizontal padding)
|
||||
marginLeft: theme.spacing[3] - theme.spacing[1],
|
||||
marginVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
paddingVertical: theme.spacing[1],
|
||||
height: {
|
||||
xs: 28,
|
||||
sm: 28,
|
||||
md: 24,
|
||||
},
|
||||
borderRadius: theme.borderRadius.base,
|
||||
flexShrink: 0,
|
||||
},
|
||||
diffModeTriggerHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
@@ -1118,19 +1168,43 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
diffStatusText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
lineHeight: theme.fontSize.xs * 1.25,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
diffStatusIconHidden: {
|
||||
opacity: 0,
|
||||
},
|
||||
diffStatusButtons: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: {
|
||||
xs: theme.spacing[1],
|
||||
sm: theme.spacing[1],
|
||||
md: 0,
|
||||
},
|
||||
},
|
||||
expandAllButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[1],
|
||||
marginVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
paddingVertical: theme.spacing[1],
|
||||
minWidth: {
|
||||
xs: 32,
|
||||
sm: 32,
|
||||
md: 24,
|
||||
},
|
||||
height: {
|
||||
xs: 32,
|
||||
sm: 32,
|
||||
md: 24,
|
||||
},
|
||||
paddingHorizontal: {
|
||||
xs: theme.spacing[2],
|
||||
sm: theme.spacing[2],
|
||||
md: theme.spacing[1],
|
||||
},
|
||||
borderRadius: theme.borderRadius.base,
|
||||
flexShrink: 0,
|
||||
},
|
||||
actionErrorText: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
@@ -1190,6 +1264,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
fileSectionHeaderContainer: {
|
||||
overflow: "hidden",
|
||||
},
|
||||
fileSectionHeaderExpanded: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
fileSectionBodyContainer: {
|
||||
@@ -1208,7 +1284,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,
|
||||
},
|
||||
@@ -1286,13 +1361,39 @@ 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,
|
||||
userSelect: "none",
|
||||
},
|
||||
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,
|
||||
userSelect: "text",
|
||||
},
|
||||
addLineContainer: {
|
||||
backgroundColor: "rgba(46, 160, 67, 0.15)", // GitHub green
|
||||
@@ -1330,4 +1431,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontStyle: "italic",
|
||||
},
|
||||
tooltipText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -11,21 +11,14 @@ interface BackHeaderProps {
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
export function BackHeader({
|
||||
title,
|
||||
rightContent,
|
||||
onBack,
|
||||
}: BackHeaderProps) {
|
||||
export function BackHeader({ title, rightContent, onBack }: BackHeaderProps) {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
return (
|
||||
<ScreenHeader
|
||||
left={
|
||||
<>
|
||||
<Pressable
|
||||
onPress={onBack ?? (() => router.back())}
|
||||
style={styles.backButton}
|
||||
>
|
||||
<Pressable onPress={onBack ?? (() => router.back())} style={styles.backButton}>
|
||||
<ArrowLeft size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
{title && (
|
||||
|
||||
@@ -29,11 +29,7 @@ function MobileMenuIcon({ color }: { color: string }) {
|
||||
<View style={[styles.mobileMenuLine, { backgroundColor: color }]} />
|
||||
<View style={[styles.mobileMenuLine, { backgroundColor: color }]} />
|
||||
<View
|
||||
style={[
|
||||
styles.mobileMenuLine,
|
||||
styles.mobileMenuLineShort,
|
||||
{ backgroundColor: color },
|
||||
]}
|
||||
style={[styles.mobileMenuLine, styles.mobileMenuLineShort, { backgroundColor: color }]}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
@@ -46,17 +42,15 @@ export function SidebarMenuToggle({
|
||||
nativeID = "menu-button",
|
||||
}: SidebarMenuToggleProps = {}) {
|
||||
const { theme } = useUnistyles();
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
|
||||
const toggleShortcutKeys = getShortcutOs() === "mac" ? ["mod", "B"] : ["mod", "."];
|
||||
|
||||
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
|
||||
const menuIconColor = !isMobile && isOpen
|
||||
? theme.colors.foreground
|
||||
: theme.colors.foregroundMuted;
|
||||
const menuIconColor =
|
||||
!isMobile && isOpen ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||
|
||||
return (
|
||||
<HeaderToggleButton
|
||||
@@ -81,10 +75,7 @@ export function SidebarMenuToggle({
|
||||
);
|
||||
}
|
||||
|
||||
export function MenuHeader({
|
||||
title,
|
||||
rightContent,
|
||||
}: MenuHeaderProps) {
|
||||
export function MenuHeader({ title, rightContent }: MenuHeaderProps) {
|
||||
return (
|
||||
<ScreenHeader
|
||||
left={
|
||||
|
||||
@@ -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 {
|
||||
@@ -22,12 +22,7 @@ interface ScreenHeaderProps {
|
||||
* Shared frame for the home/back headers so we only maintain padding, border,
|
||||
* and safe-area logic in one place.
|
||||
*/
|
||||
export function ScreenHeader({
|
||||
left,
|
||||
right,
|
||||
leftStyle,
|
||||
rightStyle,
|
||||
}: ScreenHeaderProps) {
|
||||
export function ScreenHeader({ left, right, leftStyle, rightStyle }: ScreenHeaderProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
@@ -37,18 +32,18 @@ export function ScreenHeader({
|
||||
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
|
||||
const baseHorizontalPadding = theme.spacing[2];
|
||||
const collapsedSidebarTrafficLightInset =
|
||||
!isMobile && !desktopAgentListOpen && getIsTauriMac()
|
||||
? trafficLightPadding.left
|
||||
: 0;
|
||||
!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>
|
||||
|
||||
@@ -25,10 +25,34 @@ export function SourceControlPanelIcon({
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{/* Plus */}
|
||||
<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" />
|
||||
<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={strokeWidth} 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,15 +13,15 @@ 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 (
|
||||
<AdaptiveModalSheet
|
||||
title="Keyboard shortcuts"
|
||||
title="Shortcuts"
|
||||
visible={open}
|
||||
onClose={() => setOpen(false)}
|
||||
testID="keyboard-shortcuts-dialog"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user