mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
148 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89a25276a5 | ||
|
|
244eed8696 | ||
|
|
dce7316931 | ||
|
|
d69addaad2 | ||
|
|
845cf68d38 | ||
|
|
2b17aa1a1d | ||
|
|
d32c196fd5 | ||
|
|
a0266e29e3 | ||
|
|
752d29c146 | ||
|
|
36660b3cc1 | ||
|
|
bf355aaaf3 | ||
|
|
98d91fd696 | ||
|
|
8dfc866d40 | ||
|
|
15e9569157 | ||
|
|
483dd7cb6d | ||
|
|
6a0e48c10c | ||
|
|
7a4be5233c | ||
|
|
965704da20 | ||
|
|
f760255d50 | ||
|
|
f3acdedfb1 | ||
|
|
cc45c3772f | ||
|
|
a3e271a1e7 | ||
|
|
7b22fc5c3f | ||
|
|
a15b52efc8 | ||
|
|
7f11b93e0f | ||
|
|
02d74777b2 | ||
|
|
cf148ba3af | ||
|
|
19b6aaa2f3 | ||
|
|
97737be91c | ||
|
|
e3d7dabb87 | ||
|
|
a90a7f454c | ||
|
|
8a60dc30d6 | ||
|
|
06f8722f25 | ||
|
|
e3552f6365 | ||
|
|
7c6eb2ad74 | ||
|
|
2721ce331a | ||
|
|
ca787271b3 | ||
|
|
87948e956a | ||
|
|
1e5e0f625d | ||
|
|
6f7b3db4fa | ||
|
|
93b5cc530c | ||
|
|
785124eb9f | ||
|
|
2a1ef17107 | ||
|
|
2d5d0dcacd | ||
|
|
faa5aaab6f | ||
|
|
03e1915316 | ||
|
|
b339c5e61d | ||
|
|
0c44e7db80 | ||
|
|
870d95f35e | ||
|
|
cfb9784ea9 | ||
|
|
484edb1e1e | ||
|
|
356faa0563 | ||
|
|
7d76da3249 | ||
|
|
1d1c7058f1 | ||
|
|
6b51088f39 | ||
|
|
efa6a6aed3 | ||
|
|
ba054b4dfb | ||
|
|
604b41db9b | ||
|
|
8dd94757d7 | ||
|
|
a99edcc0b2 | ||
|
|
1985bd6669 | ||
|
|
674573938a | ||
|
|
7821a8a8af | ||
|
|
226ece2bdd | ||
|
|
cec0b96adb | ||
|
|
9d64c3e01e | ||
|
|
bb300fa2f8 | ||
|
|
8a41c92488 | ||
|
|
5f76fbb222 | ||
|
|
d512932233 | ||
|
|
cb24822cac | ||
|
|
304d42d969 | ||
|
|
7f29baf915 | ||
|
|
74c99a827d | ||
|
|
5b270444d5 | ||
|
|
885b575d14 | ||
|
|
8371ea0c7d | ||
|
|
6dc49ccbc7 | ||
|
|
6f2b967599 | ||
|
|
bdfed2db4b | ||
|
|
21b9743c61 | ||
|
|
73fce89edd | ||
|
|
ee8e2ab691 | ||
|
|
26ea214d64 | ||
|
|
7578846c27 | ||
|
|
430ad54d77 | ||
|
|
f47ea7afe5 | ||
|
|
92991ffdb8 | ||
|
|
b21764fd40 | ||
|
|
4f1783fc76 | ||
|
|
b5b4e60916 | ||
|
|
952ff0dd72 | ||
|
|
111b9d95f4 | ||
|
|
6de7756952 | ||
|
|
3c066f5dd9 | ||
|
|
bf1cded8a1 | ||
|
|
e9b57a5ac6 | ||
|
|
cb92f39e73 | ||
|
|
f3796e2ba5 | ||
|
|
74ac17f910 | ||
|
|
73eabd2eea | ||
|
|
97f0f4b266 | ||
|
|
888af29485 | ||
|
|
f418f3ab02 | ||
|
|
92b5f1e963 | ||
|
|
223c8d7d0b | ||
|
|
c8cef9bec8 | ||
|
|
01faa3467d | ||
|
|
18d4df339f | ||
|
|
16984c138d | ||
|
|
cdf59e8315 | ||
|
|
c7cbf89f92 | ||
|
|
ac1106b259 | ||
|
|
e1067c623b | ||
|
|
a8e867dc7b | ||
|
|
080fb74642 | ||
|
|
2e201cab09 | ||
|
|
ec5b416b8a | ||
|
|
2566171b4c | ||
|
|
3a6a8cfa38 | ||
|
|
6648cb8917 | ||
|
|
4ca97c4fdf | ||
|
|
aa2f35656e | ||
|
|
c0420ac1f9 | ||
|
|
91634ca6f2 | ||
|
|
3355f7d8d9 | ||
|
|
c4523c70b5 | ||
|
|
a060ae1256 | ||
|
|
094ce49d4f | ||
|
|
0832b12608 | ||
|
|
9d2ec0e9e1 | ||
|
|
5c773371d6 | ||
|
|
3f06063b4b | ||
|
|
b167abfda5 | ||
|
|
a43008e9c7 | ||
|
|
a134132418 | ||
|
|
cbf5621a73 | ||
|
|
ff4f2eeb56 | ||
|
|
560e0f2362 | ||
|
|
f3ebe0545f | ||
|
|
89a117e1c1 | ||
|
|
317d33a9a7 | ||
|
|
42786956c7 | ||
|
|
9b6f5e028c | ||
|
|
84e3140f19 | ||
|
|
1974c33971 | ||
|
|
3e877c9f24 | ||
|
|
6c4fcd1486 |
423
.github/workflows/desktop-release.yml
vendored
423
.github/workflows/desktop-release.yml
vendored
@@ -3,27 +3,50 @@ name: Desktop Release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- 'desktop-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."
|
||||
required: false
|
||||
default: "all"
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- macos
|
||||
- linux
|
||||
- windows
|
||||
|
||||
concurrency:
|
||||
group: desktop-release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
|
||||
|
||||
jobs:
|
||||
publish-tauri:
|
||||
publish-macos:
|
||||
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'macos')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-macos-v'))) }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runner: macos-14
|
||||
rust_target: aarch64-apple-darwin
|
||||
- runner: macos-15-intel
|
||||
rust_target: x86_64-apple-darwin
|
||||
permissions:
|
||||
contents: write
|
||||
packages: read
|
||||
runs-on: macos-latest
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -31,18 +54,50 @@ jobs:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||
|
||||
- name: Normalize release tag
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source_tag="${SOURCE_TAG}"
|
||||
if [[ "$source_tag" == desktop-windows-v* ]]; then
|
||||
release_tag="v${source_tag#desktop-windows-v}"
|
||||
elif [[ "$source_tag" == desktop-linux-v* ]]; then
|
||||
release_tag="v${source_tag#desktop-linux-v}"
|
||||
elif [[ "$source_tag" == desktop-macos-v* ]]; then
|
||||
release_tag="v${source_tag#desktop-macos-v}"
|
||||
elif [[ "$source_tag" == desktop-v* ]]; then
|
||||
release_tag="v${source_tag#desktop-v}"
|
||||
else
|
||||
release_tag="$source_tag"
|
||||
fi
|
||||
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
|
||||
if [[ "$source_tag" == *gha-smoke* ]]; then
|
||||
echo "IS_SMOKE_TAG=true" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
registry-url: 'https://npm.pkg.github.com'
|
||||
scope: '@boudra'
|
||||
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: aarch64-apple-darwin
|
||||
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
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: npm ci
|
||||
@@ -52,6 +107,40 @@ jobs:
|
||||
- name: Build web app for Tauri
|
||||
run: npm run build:web --workspace=@getpaseo/app
|
||||
|
||||
- name: Build managed runtime for macOS
|
||||
run: npm run prepare: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: Build packaged app for macOS smoke
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
app_path="packages/desktop/src-tauri/target/${{ matrix.rust_target }}/release/bundle/macos/Paseo.app/Contents/MacOS/Paseo"
|
||||
if npm run tauri --workspace=@getpaseo/desktop build -- --target ${{ matrix.rust_target }}; then
|
||||
exit 0
|
||||
fi
|
||||
if [[ -f "$app_path" ]]; then
|
||||
echo "Smoke app created at $app_path despite tauri build exiting non-zero; continuing."
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
|
||||
- name: Smoke check managed runtime for macOS
|
||||
env:
|
||||
PASEO_MANAGED_SMOKE_SKIP_BUILD: "1"
|
||||
PASEO_MANAGED_SMOKE_RUST_TARGET: ${{ matrix.rust_target }}
|
||||
run: npm run smoke:managed-daemon --workspace=@getpaseo/desktop
|
||||
|
||||
- name: Sign bundled managed runtime for macOS
|
||||
env:
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
run: node ./packages/desktop/scripts/sign-managed-runtime-macos.mjs
|
||||
|
||||
- name: Set desktop version from tag
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -59,10 +148,10 @@ jobs:
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const rawTag = process.env.RELEASE_TAG;
|
||||
if (!rawTag) throw new Error('RELEASE_TAG env var is missing');
|
||||
const rawTag = process.env.SOURCE_TAG;
|
||||
if (!rawTag) throw new Error('SOURCE_TAG env var is missing');
|
||||
|
||||
const version = rawTag.replace(/^desktop-/, '').replace(/^v/, '');
|
||||
const version = rawTag.replace(/^desktop-(windows-|linux-|macos-)?/, '').replace(/^v/, '');
|
||||
console.log(`Using desktop version ${version} from tag ${rawTag}`);
|
||||
|
||||
const tauriConfPath = path.join('packages', 'desktop', 'src-tauri', 'tauri.conf.json');
|
||||
@@ -91,7 +180,22 @@ jobs:
|
||||
fs.writeFileSync(cargoTomlPath, `${nextLines.join('\n')}\n`);
|
||||
NODE
|
||||
|
||||
- name: Build and publish Tauri release
|
||||
- name: Detect existing GitHub release state
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if release_draft="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
|
||||
:
|
||||
else
|
||||
release_draft="false"
|
||||
fi
|
||||
echo "RELEASE_DRAFT=$release_draft" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build and publish macOS Tauri release
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -108,6 +212,289 @@ jobs:
|
||||
tagName: ${{ env.RELEASE_TAG }}
|
||||
releaseName: Paseo ${{ env.RELEASE_TAG }}
|
||||
releaseBody: See the assets to download and install this version.
|
||||
releaseDraft: false
|
||||
releaseDraft: ${{ env.RELEASE_DRAFT }}
|
||||
prerelease: false
|
||||
args: --target aarch64-apple-darwin
|
||||
args: --target ${{ matrix.rust_target }}
|
||||
|
||||
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'))) }}
|
||||
permissions:
|
||||
contents: write
|
||||
packages: read
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||
|
||||
- name: Normalize release tag
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source_tag="${SOURCE_TAG}"
|
||||
if [[ "$source_tag" == desktop-windows-v* ]]; then
|
||||
release_tag="v${source_tag#desktop-windows-v}"
|
||||
elif [[ "$source_tag" == desktop-linux-v* ]]; then
|
||||
release_tag="v${source_tag#desktop-linux-v}"
|
||||
elif [[ "$source_tag" == desktop-macos-v* ]]; then
|
||||
release_tag="v${source_tag#desktop-macos-v}"
|
||||
elif [[ "$source_tag" == desktop-v* ]]; then
|
||||
release_tag="v${source_tag#desktop-v}"
|
||||
else
|
||||
release_tag="$source_tag"
|
||||
fi
|
||||
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
|
||||
if [[ "$source_tag" == *gha-smoke* ]]; then
|
||||
echo "IS_SMOKE_TAG=true" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- 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
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
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
|
||||
|
||||
- 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 for Linux
|
||||
run: npm run prepare:managed-runtime --workspace=@getpaseo/desktop
|
||||
|
||||
- name: Smoke check managed runtime for Linux
|
||||
run: npm run smoke:managed-daemon --workspace=@getpaseo/desktop
|
||||
|
||||
- name: Set desktop version from tag
|
||||
shell: bash
|
||||
run: |
|
||||
node <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const rawTag = process.env.SOURCE_TAG;
|
||||
if (!rawTag) throw new Error('SOURCE_TAG env var is missing');
|
||||
|
||||
const version = rawTag.replace(/^desktop-(windows-|linux-|macos-)?/, '').replace(/^v/, '');
|
||||
console.log(`Using desktop version ${version} from tag ${rawTag}`);
|
||||
|
||||
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 field in ${tauriConfPath}`);
|
||||
}
|
||||
fs.writeFileSync(tauriConfPath, tauriConfText.replace(tauriRe, `$1${version}$3`));
|
||||
|
||||
const cargoTomlPath = path.join('packages', 'desktop', 'src-tauri', 'Cargo.toml');
|
||||
const cargoLines = fs.readFileSync(cargoTomlPath, 'utf8').split(/\r?\n/);
|
||||
let inPackage = false;
|
||||
let updated = false;
|
||||
const nextLines = cargoLines.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 package version in ${cargoTomlPath}`);
|
||||
fs.writeFileSync(cargoTomlPath, `${nextLines.join('\n')}\n`);
|
||||
NODE
|
||||
|
||||
- name: Detect existing GitHub release state
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if release_draft="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
|
||||
:
|
||||
else
|
||||
release_draft="false"
|
||||
fi
|
||||
echo "RELEASE_DRAFT=$release_draft" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build and publish Linux Tauri release
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
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 appimage
|
||||
|
||||
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'))) }}
|
||||
permissions:
|
||||
contents: write
|
||||
packages: read
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||
|
||||
- name: Normalize release tag
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source_tag="${SOURCE_TAG}"
|
||||
if [[ "$source_tag" == desktop-windows-v* ]]; then
|
||||
release_tag="v${source_tag#desktop-windows-v}"
|
||||
elif [[ "$source_tag" == desktop-linux-v* ]]; then
|
||||
release_tag="v${source_tag#desktop-linux-v}"
|
||||
elif [[ "$source_tag" == desktop-macos-v* ]]; then
|
||||
release_tag="v${source_tag#desktop-macos-v}"
|
||||
elif [[ "$source_tag" == desktop-v* ]]; then
|
||||
release_tag="v${source_tag#desktop-v}"
|
||||
else
|
||||
release_tag="$source_tag"
|
||||
fi
|
||||
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
|
||||
if [[ "$source_tag" == *gha-smoke* ]]; then
|
||||
echo "IS_SMOKE_TAG=true" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
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
|
||||
|
||||
- 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 for Windows
|
||||
run: npm run prepare:managed-runtime --workspace=@getpaseo/desktop
|
||||
|
||||
- name: Smoke check managed runtime for Windows
|
||||
run: npm run smoke:managed-daemon --workspace=@getpaseo/desktop
|
||||
|
||||
- name: Set desktop version from tag
|
||||
shell: bash
|
||||
run: |
|
||||
node <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const rawTag = process.env.SOURCE_TAG;
|
||||
if (!rawTag) throw new Error('SOURCE_TAG env var is missing');
|
||||
|
||||
const version = rawTag.replace(/^desktop-(windows-|linux-|macos-)?/, '').replace(/^v/, '');
|
||||
console.log(`Using desktop version ${version} from tag ${rawTag}`);
|
||||
|
||||
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 field in ${tauriConfPath}`);
|
||||
}
|
||||
fs.writeFileSync(tauriConfPath, tauriConfText.replace(tauriRe, `$1${version}$3`));
|
||||
|
||||
const cargoTomlPath = path.join('packages', 'desktop', 'src-tauri', 'Cargo.toml');
|
||||
const cargoLines = fs.readFileSync(cargoTomlPath, 'utf8').split(/\r?\n/);
|
||||
let inPackage = false;
|
||||
let updated = false;
|
||||
const nextLines = cargoLines.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 package version in ${cargoTomlPath}`);
|
||||
fs.writeFileSync(cargoTomlPath, `${nextLines.join('\n')}\n`);
|
||||
NODE
|
||||
|
||||
- name: Detect existing GitHub release state
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if release_draft="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
|
||||
:
|
||||
else
|
||||
release_draft="false"
|
||||
fi
|
||||
echo "RELEASE_DRAFT=$release_draft" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build and publish Windows Tauri release
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
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,msi
|
||||
|
||||
10
.github/workflows/release-notes-sync.yml
vendored
10
.github/workflows/release-notes-sync.yml
vendored
@@ -19,6 +19,11 @@ on:
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
draft:
|
||||
description: "Create missing release as draft."
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
concurrency:
|
||||
group: release-notes-sync-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||
@@ -43,6 +48,7 @@ jobs:
|
||||
REF: ${{ github.ref }}
|
||||
INPUT_TAG: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && github.ref_name || github.event.inputs.tag }}
|
||||
INPUT_CREATE_IF_MISSING: ${{ github.event.inputs.create_if_missing }}
|
||||
INPUT_DRAFT: ${{ github.event.inputs.draft }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -64,4 +70,8 @@ jobs:
|
||||
args+=(--create-if-missing)
|
||||
fi
|
||||
|
||||
if [ "${INPUT_DRAFT:-false}" = "true" ]; then
|
||||
args+=(--draft)
|
||||
fi
|
||||
|
||||
node scripts/sync-release-notes-from-changelog.mjs "${args[@]}"
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -74,3 +74,7 @@ valknut-report.json/
|
||||
.plans/
|
||||
packages/server/src/server/fixtures/dictation/dictation-debug-largest.wav
|
||||
packages/server/src/server/fixtures/dictation/dictation-debug-largest.transcript.txt
|
||||
|
||||
/artifacts
|
||||
packages/desktop/.cache/
|
||||
packages/desktop/src-tauri/resources/managed-runtime/
|
||||
|
||||
9
.mise.toml
Normal file
9
.mise.toml
Normal file
@@ -0,0 +1,9 @@
|
||||
[env]
|
||||
ANDROID_HOME = "{{env.HOME}}/.local/share/mise/installs/android-sdk/1.0"
|
||||
_.path = [
|
||||
"{{env.HOME}}/.local/share/mise/installs/android-sdk/1.0/platform-tools",
|
||||
"{{env.HOME}}/.local/share/mise/installs/android-sdk/1.0/emulator",
|
||||
]
|
||||
|
||||
[tools]
|
||||
java = "17"
|
||||
@@ -1,2 +1,4 @@
|
||||
rust 1.85.1
|
||||
nodejs 22.20.0
|
||||
rust 1.85.1
|
||||
nodejs 22.20.0
|
||||
java 21
|
||||
android-sdk latest
|
||||
|
||||
74
CHANGELOG.md
74
CHANGELOG.md
@@ -1,21 +1,75 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.20 - 2026-03-10
|
||||
### Added
|
||||
- Added workspace sidebar git actions with quick diff stats and archive controls.
|
||||
- Added refreshed website downloads and homepage presentation for desktop installs.
|
||||
|
||||
### Improved
|
||||
- Desktop release packaging now rebuilds and validates the bundled managed runtime during CI, improving installer reliability for macOS users.
|
||||
- Improved desktop and web stream rendering, settings polish, and React 19.1.4 compatibility.
|
||||
|
||||
### Fixed
|
||||
- Fixed Claude interrupt/restart regressions and strengthened managed-daemon smoke coverage for desktop releases.
|
||||
|
||||
## 0.1.19 - 2026-03-09
|
||||
### Added
|
||||
- Added a draft GitHub release flow so maintainers can upload and review desktop and Android release assets before publishing the final release.
|
||||
|
||||
## 0.1.18 - 2026-03-06
|
||||
### Added
|
||||
- Added a desktop `Mod+W` shortcut to close the current tab.
|
||||
|
||||
### Improved
|
||||
- New and newly selected terminals now take focus automatically so you can type immediately.
|
||||
- Kept newly created workspaces and projects in a more stable order in the sidebar.
|
||||
- Improved project naming for GitHub remotes and expanded project icon discovery to Phoenix `priv/static` assets.
|
||||
- Updated the website desktop download link to use the universal macOS DMG.
|
||||
|
||||
### Fixed
|
||||
- Restored automatic agent metadata generation for Claude runs.
|
||||
|
||||
## 0.1.17 - 2026-03-06
|
||||
### Added
|
||||
- New workspace-first navigation model with workspace tabs, file tabs, and sortable tab groups.
|
||||
- Keyboard shortcuts for workspace and tab navigation, with shortcut badges in the sidebar.
|
||||
- Workspace-level archive actions with improved worktree archiving flow and context menu support.
|
||||
- In-chat task notifications rendered as synthetic tool-call events for clearer status tracking.
|
||||
|
||||
### Improved
|
||||
- Desktop builds now ship as a universal macOS binary (Apple Silicon + Intel).
|
||||
- More reliable workspace routing and tab identity handling across refreshes and deep links.
|
||||
- Better sidebar drag-and-drop behavior with explicit drag handles and nested list interactions.
|
||||
- Smoother terminal/file rendering and WebGL-backed terminal performance improvements.
|
||||
- Stronger provider error surfacing and updated Claude model/runtime handling.
|
||||
|
||||
### Fixed
|
||||
- Fixed orphan workspace runs caused by non-canonical tab routes.
|
||||
- Fixed mobile terminal tab remount/routing restore issues.
|
||||
- Fixed agent metadata title/branch update reliability.
|
||||
- Fixed stream/timeline ordering and cursor synchronization issues in the app.
|
||||
- Fixed reversed edge-wheel scroll behavior in chat/tool stream views.
|
||||
|
||||
## 0.1.16 - 2026-02-22
|
||||
### Added
|
||||
- Added desktop Settings controls to update both the Paseo app and the local daemon.
|
||||
- Added support for provider thinking options and autonomous run handling.
|
||||
- Update the Paseo desktop app and local daemon directly from Settings.
|
||||
- Microphone and notification permission controls in Settings.
|
||||
- Thinking/reasoning mode — agents can use extended thinking when the provider supports it.
|
||||
- Autonomous run mode — let agents keep working without manual approval at each step.
|
||||
- `paseo wait` now shows a snapshot of recent agent activity while you wait.
|
||||
|
||||
### Improved
|
||||
- Improved streaming performance and scroll stability to reduce UI churn during long runs.
|
||||
- Improved archive behavior by interrupting active agents before archive operations.
|
||||
- Improved agent title consistency across refreshes.
|
||||
- Improved desktop update/settings copy and labeling for clarity.
|
||||
- Smoother streaming with less UI flicker and scroll jumping during long agent runs.
|
||||
- Faster agent sidebar list rendering.
|
||||
- Archiving an agent now stops it first instead of archiving a half-running session.
|
||||
- Agent titles no longer reset when refreshing.
|
||||
- More reliable relay connections.
|
||||
|
||||
### Fixed
|
||||
- Fixed duplicate user-message echoes by deduplicating with canonical message IDs.
|
||||
- Fixed app archive flow instability caused by hook-order issues.
|
||||
- Fixed startup crashes caused by the OpenCode SDK update.
|
||||
- Fixed noisy attention notifications from non-UI agent activity.
|
||||
- Fixed Claude background tasks desyncing the chat.
|
||||
- Fixed duplicate user messages appearing in the timeline.
|
||||
- Fixed a startup crash caused by an OpenCode SDK update.
|
||||
- Fixed spurious "needs attention" notifications from background agent activity.
|
||||
|
||||
## 0.1.15 - 2026-02-19
|
||||
### Added
|
||||
|
||||
27
CLAUDE.md
27
CLAUDE.md
@@ -37,6 +37,8 @@ PASEO_HOME=~/.paseo-blue npm run dev
|
||||
|
||||
- `PASEO_HOME` – path for runtime state (agent data, sockets, etc.). Defaults to `~/.paseo`; set this to a unique directory when running a secondary server instance.
|
||||
|
||||
For trace+ logs, check $PASEO_HOME/daemon.log
|
||||
|
||||
## Running and checking logs
|
||||
|
||||
Both the server and Expo app are running in a Tmux session. See CLAUDE.local.md for system-specific session details.
|
||||
@@ -139,12 +141,15 @@ From `packages/app`:
|
||||
|
||||
```bash
|
||||
# development (debug)
|
||||
APP_VARIANT=development npx expo prebuild --platform android --clean --non-interactive
|
||||
APP_VARIANT=development npx expo prebuild --platform android --non-interactive
|
||||
APP_VARIANT=development npx expo run:android --variant=debug
|
||||
|
||||
# production (release)
|
||||
APP_VARIANT=production npx expo prebuild --platform android --clean --non-interactive
|
||||
APP_VARIANT=production npx expo prebuild --platform android --non-interactive
|
||||
APP_VARIANT=production npx expo run:android --variant=release
|
||||
|
||||
# clean native project (when needed)
|
||||
npx expo prebuild --platform android --clean --non-interactive
|
||||
```
|
||||
|
||||
From repo root:
|
||||
@@ -152,9 +157,10 @@ From repo root:
|
||||
```bash
|
||||
npm run android:development
|
||||
npm run android:production
|
||||
npm run android:clean
|
||||
```
|
||||
|
||||
`npm run android:prod` and `npm run android:release` are aliases for `npm run android:production`.
|
||||
`npm run android:release` is an alias for `npm run android:production`.
|
||||
|
||||
### Cloud build + submit (EAS Workflows)
|
||||
|
||||
@@ -206,6 +212,21 @@ npm run release:publish
|
||||
npm run release:push # pushes HEAD and current version tag (triggers desktop + Android APK + EAS mobile workflows)
|
||||
```
|
||||
|
||||
### Draft release flow
|
||||
|
||||
```bash
|
||||
# Stage a draft GitHub release with assets, but do not publish npm yet.
|
||||
npm run draft-release:patch
|
||||
|
||||
# Publish npm and promote the same GitHub draft release to final.
|
||||
npm run release:finalize
|
||||
```
|
||||
|
||||
Behavior:
|
||||
- `draft-release:patch` bumps the version, runs release checks, pushes `HEAD` and the new `v*` tag, and creates the matching GitHub Release as a draft so desktop assets, APK uploads, and synced notes attach to that same draft release.
|
||||
- `release:finalize` requires that the current tag already has a GitHub draft release, publishes the npm packages for that exact version, and promotes the same GitHub Release from draft to published.
|
||||
- Use the same semver tag for both draft and final states; do not cut a second tag just to publish the release.
|
||||
|
||||
Notes:
|
||||
- `version:all:*` bumps the root package version and runs the root `version` lifecycle script to sync workspace versions and internal `@getpaseo/*` dependency versions before the release commit/tag is created.
|
||||
- `release:prepare` refreshes workspace `node_modules` links to prevent stale local package types during release checks.
|
||||
|
||||
684
LICENSE
684
LICENSE
@@ -1,21 +1,671 @@
|
||||
MIT License
|
||||
Copyright (c) 2025-present Mohamed Boudra
|
||||
|
||||
Copyright (c) 2025 Mohamed Boudra
|
||||
Portions of this software are licensed as follows:
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
* All third party components incorporated into the Paseo Software are
|
||||
licensed under the original license provided by the owner of the
|
||||
applicable component.
|
||||
* All content outside of the above mentioned restrictions is available
|
||||
under the "AGPLv3" license as defined below.
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
1
cli-client-id
Normal file
1
cli-client-id
Normal file
@@ -0,0 +1 @@
|
||||
cid_518a41c4c44340aea1120d2b760fc6c6
|
||||
13034
package-lock.json
generated
13034
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
19
package.json
19
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.16",
|
||||
"version": "0.1.20",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/server",
|
||||
@@ -17,16 +17,18 @@
|
||||
"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",
|
||||
"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 .",
|
||||
"start": "npm run start --workspace=@getpaseo/server",
|
||||
"android": "npm run android --workspace=@getpaseo/app",
|
||||
"android:development": "npm run android:development --workspace=@getpaseo/app",
|
||||
"android:prod": "npm run android:prod --workspace=@getpaseo/app",
|
||||
"android:production": "npm run android:production --workspace=@getpaseo/app",
|
||||
"android:release": "npm run android:prod --workspace=@getpaseo/app",
|
||||
"android:release": "npm run android:production --workspace=@getpaseo/app",
|
||||
"android:clean": "npm run android:clean --workspace=@getpaseo/app",
|
||||
"ios": "npm run ios --workspace=@getpaseo/app",
|
||||
"web": "npm run web --workspace=@getpaseo/app",
|
||||
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop",
|
||||
@@ -42,6 +44,11 @@
|
||||
"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: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",
|
||||
"draft-release:minor": "npm run version:all:minor && npm run release:check && npm run draft-release:push",
|
||||
"draft-release:major": "npm run version:all:major && npm run release:check && npm run draft-release:push",
|
||||
"release:finalize": "node scripts/finalize-current-release.mjs",
|
||||
"release:patch": "npm run version:all:patch && npm run release:check && npm run release:publish && npm run release:push",
|
||||
"release:minor": "npm run version:all:minor && npm run release:check && npm run release:publish && npm run release:push",
|
||||
"release:major": "npm run version:all:major && npm run release:check && npm run release:publish && npm run release:push"
|
||||
@@ -64,9 +71,11 @@
|
||||
"mcp"
|
||||
],
|
||||
"author": "moboudra",
|
||||
"license": "MIT",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"overrides": {
|
||||
"lightningcss": "1.30.1"
|
||||
"lightningcss": "1.30.1",
|
||||
"react": "19.1.4",
|
||||
"react-dom": "19.1.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11"
|
||||
|
||||
183
packages/app/e2e/agent-bottom-anchor.spec.ts
Normal file
183
packages/app/e2e/agent-bottom-anchor.spec.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
connectDaemonClient,
|
||||
createReplyTurn,
|
||||
expectDetachedFromBottom,
|
||||
expectNearBottom,
|
||||
getChatContainerKey,
|
||||
readScrollMetrics,
|
||||
scrollUpFromBottom,
|
||||
seedBottomAnchorAgent,
|
||||
waitForAgentReady,
|
||||
waitForContentGrowth,
|
||||
} from "./helpers/agent-bottom-anchor";
|
||||
|
||||
test.describe.configure({ timeout: 180000 });
|
||||
|
||||
async function openWorkspaceAgentTab(page: Page, agentId: string) {
|
||||
const tab = page.getByTestId(`workspace-tab-agent_${agentId}`).first();
|
||||
await expect(tab).toBeVisible({ timeout: 30000 });
|
||||
await tab.click();
|
||||
}
|
||||
|
||||
test("direct load and refresh land at the bottom for history-backed chats", async ({
|
||||
page,
|
||||
}) => {
|
||||
const repo = await createTempGitRepo("paseo-e2e-bottom-anchor-direct-");
|
||||
const client = await connectDaemonClient();
|
||||
|
||||
try {
|
||||
const agent = await seedBottomAnchorAgent({
|
||||
client,
|
||||
cwd: repo.path,
|
||||
title: `bottom-anchor-direct-${Date.now()}`,
|
||||
turnCount: 4,
|
||||
});
|
||||
|
||||
await page.goto(agent.url, { waitUntil: "domcontentloaded" });
|
||||
await openWorkspaceAgentTab(page, agent.id);
|
||||
await waitForAgentReady(page, agent.expectedTailText);
|
||||
await expectNearBottom(page);
|
||||
|
||||
await page.reload({ waitUntil: "commit" });
|
||||
await openWorkspaceAgentTab(page, agent.id);
|
||||
await waitForAgentReady(page, agent.expectedTailText);
|
||||
await expectNearBottom(page);
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("revisiting a loaded chat restores bottom anchoring", async ({
|
||||
page,
|
||||
}) => {
|
||||
const repo = await createTempGitRepo("paseo-e2e-bottom-anchor-switch-");
|
||||
const client = await connectDaemonClient();
|
||||
|
||||
try {
|
||||
const agent = await seedBottomAnchorAgent({
|
||||
client,
|
||||
cwd: repo.path,
|
||||
title: `bottom-anchor-switch-${Date.now()}`,
|
||||
turnCount: 4,
|
||||
});
|
||||
|
||||
await page.goto(agent.url, { waitUntil: "domcontentloaded" });
|
||||
await openWorkspaceAgentTab(page, agent.id);
|
||||
await waitForAgentReady(page, agent.expectedTailText);
|
||||
await expectNearBottom(page);
|
||||
|
||||
await page.getByTestId("sidebar-new-agent").first().click();
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
await openWorkspaceAgentTab(page, agent.id);
|
||||
await waitForAgentReady(page, agent.expectedTailText);
|
||||
await expectNearBottom(page);
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("sticky mode stays pinned through composer growth and viewport resize, but detached mode does not fight streamed updates", async ({
|
||||
page,
|
||||
}) => {
|
||||
const repo = await createTempGitRepo("paseo-e2e-bottom-anchor-sticky-");
|
||||
const client = await connectDaemonClient();
|
||||
|
||||
try {
|
||||
const agent = await seedBottomAnchorAgent({
|
||||
client,
|
||||
cwd: repo.path,
|
||||
title: `bottom-anchor-sticky-${Date.now()}`,
|
||||
turnCount: 10,
|
||||
});
|
||||
|
||||
await page.setViewportSize({ width: 1320, height: 920 });
|
||||
await page.goto(agent.url, { waitUntil: "domcontentloaded" });
|
||||
await openWorkspaceAgentTab(page, agent.id);
|
||||
await waitForAgentReady(page, agent.expectedTailText);
|
||||
await expectNearBottom(page);
|
||||
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
await composer.click();
|
||||
for (let index = 0; index < 6; index += 1) {
|
||||
await composer.pressSequentially(`composer growth line ${index + 1}`);
|
||||
if (index < 5) {
|
||||
await page.keyboard.press("Shift+Enter");
|
||||
}
|
||||
}
|
||||
await expectNearBottom(page);
|
||||
await expect(page.getByTestId("scroll-to-bottom-button")).toHaveCount(0);
|
||||
|
||||
await page.setViewportSize({ width: 820, height: 760 });
|
||||
await expectNearBottom(page);
|
||||
|
||||
await scrollUpFromBottom(page, 720);
|
||||
await expectDetachedFromBottom(page);
|
||||
const beforeExternalUpdate = await readScrollMetrics(page);
|
||||
|
||||
const externalTurn = createReplyTurn(`external-stream-${Date.now()}`);
|
||||
await client.sendAgentMessage(agent.id, externalTurn.message);
|
||||
await waitForContentGrowth(page, beforeExternalUpdate.contentHeight);
|
||||
const finish = await client.waitForFinish(agent.id, 120000);
|
||||
expect(finish.status).toBe("idle");
|
||||
await expectDetachedFromBottom(page);
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("web partial virtualization keeps bottom anchoring stable across direct load, refresh, and resize", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript(() => {
|
||||
(window as typeof window & {
|
||||
__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: number;
|
||||
__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS?: number;
|
||||
}).__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 6;
|
||||
(window as typeof window & {
|
||||
__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: number;
|
||||
__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS?: number;
|
||||
}).__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS = 4;
|
||||
});
|
||||
|
||||
const repo = await createTempGitRepo("paseo-e2e-bottom-anchor-virtualized-");
|
||||
const client = await connectDaemonClient();
|
||||
|
||||
try {
|
||||
const agent = await seedBottomAnchorAgent({
|
||||
client,
|
||||
cwd: repo.path,
|
||||
title: `bottom-anchor-virtualized-${Date.now()}`,
|
||||
turnCount: 4,
|
||||
});
|
||||
|
||||
await page.goto(agent.url, { waitUntil: "domcontentloaded" });
|
||||
await openWorkspaceAgentTab(page, agent.id);
|
||||
await waitForAgentReady(page, agent.expectedTailText);
|
||||
await expect
|
||||
.poll(async () => await getChatContainerKey(page))
|
||||
.toBe("web-partial-virtualized");
|
||||
await expectNearBottom(page);
|
||||
|
||||
await page.reload({ waitUntil: "commit" });
|
||||
await openWorkspaceAgentTab(page, agent.id);
|
||||
await waitForAgentReady(page, agent.expectedTailText);
|
||||
await expect
|
||||
.poll(async () => await getChatContainerKey(page))
|
||||
.toBe("web-partial-virtualized");
|
||||
await expectNearBottom(page);
|
||||
|
||||
await page.setViewportSize({ width: 780, height: 720 });
|
||||
await expectNearBottom(page);
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -14,8 +14,6 @@ test("agent details sheet shows IDs and copy toast", async ({ page }) => {
|
||||
await createAgent(page, prompt);
|
||||
|
||||
await page.getByTestId("agent-overflow-menu").click();
|
||||
await page.getByTestId("agent-menu-details").click();
|
||||
|
||||
await expect(page.getByTestId("agent-details-sheet")).toBeVisible();
|
||||
|
||||
await expect(page.getByTestId("agent-details-agent-id")).toBeVisible();
|
||||
|
||||
197
packages/app/e2e/agent-scroll-submit-firefox.spec.ts
Normal file
197
packages/app/e2e/agent-scroll-submit-firefox.spec.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
const SERVER_ID =
|
||||
process.env.PLAYWRIGHT_REPRO_SERVER_ID ?? "srv_ETXtcjYRGrCI";
|
||||
const AGENT_ID =
|
||||
process.env.PLAYWRIGHT_REPRO_AGENT_ID ??
|
||||
"3533e6c3-c0b3-4310-b85c-eb07cbb501a0";
|
||||
const APP_BASE_URL = process.env.PLAYWRIGHT_REPRO_BASE_URL ?? "http://localhost:8081";
|
||||
const DAEMON_ENDPOINT =
|
||||
process.env.PLAYWRIGHT_REPRO_DAEMON_ENDPOINT ?? "127.0.0.1:6767";
|
||||
const SUBMIT_TEXT = process.env.PLAYWRIGHT_REPRO_MESSAGE ?? "hello";
|
||||
const AGENT_URL = `${APP_BASE_URL}/h/${SERVER_ID}/agent/${AGENT_ID}`;
|
||||
const NEAR_BOTTOM_THRESHOLD_PX = 64;
|
||||
|
||||
type ScrollMetrics = {
|
||||
offsetY: number;
|
||||
contentHeight: number;
|
||||
viewportHeight: number;
|
||||
distanceFromBottom: number;
|
||||
};
|
||||
|
||||
test.use({ browserName: "firefox" });
|
||||
|
||||
function seedDaemonRegistryScript(params: {
|
||||
serverId: string;
|
||||
endpoint: string;
|
||||
nowIso: string;
|
||||
}) {
|
||||
const daemon = {
|
||||
serverId: params.serverId,
|
||||
label: "localhost",
|
||||
connections: [
|
||||
{
|
||||
id: `direct:${params.endpoint}`,
|
||||
type: "direct",
|
||||
endpoint: params.endpoint,
|
||||
},
|
||||
],
|
||||
preferredConnectionId: `direct:${params.endpoint}`,
|
||||
createdAt: params.nowIso,
|
||||
updatedAt: params.nowIso,
|
||||
};
|
||||
|
||||
localStorage.setItem("@paseo:e2e", "1");
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
|
||||
localStorage.setItem(
|
||||
"@paseo:create-agent-preferences",
|
||||
JSON.stringify({
|
||||
serverId: params.serverId,
|
||||
provider: "codex",
|
||||
providerPreferences: {
|
||||
claude: { model: "haiku" },
|
||||
codex: { model: "gpt-5.1-codex-mini", thinkingOptionId: "low" },
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function readScrollMetrics(page: Page): Promise<ScrollMetrics> {
|
||||
return page.getByTestId("agent-chat-scroll").evaluate((root: Element) => {
|
||||
const rootElement = root as HTMLElement;
|
||||
const candidates = [rootElement, ...Array.from(rootElement.querySelectorAll("*"))];
|
||||
const scrollElement =
|
||||
candidates.find(
|
||||
(element) =>
|
||||
element instanceof HTMLElement &&
|
||||
element.scrollHeight - element.clientHeight > 1
|
||||
) ?? rootElement;
|
||||
|
||||
const offsetY = Math.max(0, scrollElement.scrollTop);
|
||||
const contentHeight = Math.max(0, scrollElement.scrollHeight);
|
||||
const viewportHeight = Math.max(0, scrollElement.clientHeight);
|
||||
const distanceFromBottom = Math.max(
|
||||
0,
|
||||
contentHeight - (offsetY + viewportHeight)
|
||||
);
|
||||
|
||||
return {
|
||||
offsetY,
|
||||
contentHeight,
|
||||
viewportHeight,
|
||||
distanceFromBottom,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function scrollUpFromBottom(
|
||||
page: Page,
|
||||
pixels: number
|
||||
): Promise<void> {
|
||||
await page.getByTestId("agent-chat-scroll").evaluate(
|
||||
(root: Element, amount: number) => {
|
||||
const rootElement = root as HTMLElement;
|
||||
const candidates = [
|
||||
rootElement,
|
||||
...Array.from(rootElement.querySelectorAll("*")),
|
||||
];
|
||||
const scrollElement =
|
||||
candidates.find(
|
||||
(element) =>
|
||||
element instanceof HTMLElement &&
|
||||
element.scrollHeight - element.clientHeight > 1
|
||||
) ?? rootElement;
|
||||
|
||||
const bottomOffset = Math.max(
|
||||
0,
|
||||
scrollElement.scrollHeight - scrollElement.clientHeight
|
||||
);
|
||||
scrollElement.scrollTop = Math.max(0, bottomOffset - amount);
|
||||
},
|
||||
pixels
|
||||
);
|
||||
}
|
||||
|
||||
test("repro: submit while scrolled up should stay anchored to bottom (Firefox)", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const userAgent = await page.evaluate(() => navigator.userAgent);
|
||||
expect(userAgent).toContain("Firefox");
|
||||
|
||||
await page.addInitScript(seedDaemonRegistryScript, {
|
||||
serverId: SERVER_ID,
|
||||
endpoint: DAEMON_ENDPOINT,
|
||||
nowIso: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await page.goto(AGENT_URL, { waitUntil: "domcontentloaded" });
|
||||
await expect(page.getByTestId("agent-chat-scroll")).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
// Require enough history so the repro actually scrolls away from bottom.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const metrics = await readScrollMetrics(page);
|
||||
return Math.max(0, metrics.contentHeight - metrics.viewportHeight);
|
||||
})
|
||||
.toBeGreaterThan(300);
|
||||
|
||||
await scrollUpFromBottom(page, 900);
|
||||
await page.waitForTimeout(250);
|
||||
const beforeSubmit = await readScrollMetrics(page);
|
||||
const beforePath = testInfo.outputPath("before-submit.png");
|
||||
await page.screenshot({ path: beforePath, fullPage: true });
|
||||
await testInfo.attach("before-submit", {
|
||||
path: beforePath,
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
await page.getByRole("textbox", { name: "Message agent..." }).fill(SUBMIT_TEXT);
|
||||
await page.getByRole("textbox", { name: "Message agent..." }).press("Enter");
|
||||
await page.waitForTimeout(1200);
|
||||
|
||||
const afterSubmit = await readScrollMetrics(page);
|
||||
const afterPath = testInfo.outputPath("after-submit.png");
|
||||
await page.screenshot({ path: afterPath, fullPage: true });
|
||||
await testInfo.attach("after-submit", {
|
||||
path: afterPath,
|
||||
contentType: "image/png",
|
||||
});
|
||||
await testInfo.attach("firefox-scroll-metrics", {
|
||||
body: JSON.stringify(
|
||||
{
|
||||
url: AGENT_URL,
|
||||
submitText: SUBMIT_TEXT,
|
||||
thresholdPx: NEAR_BOTTOM_THRESHOLD_PX,
|
||||
beforeScreenshot: beforePath,
|
||||
afterScreenshot: afterPath,
|
||||
beforeSubmit,
|
||||
afterSubmit,
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
contentType: "application/json",
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[firefox-scroll-repro] ${JSON.stringify(
|
||||
{
|
||||
submitText: SUBMIT_TEXT,
|
||||
beforeSubmit,
|
||||
afterSubmit,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}`
|
||||
);
|
||||
|
||||
expect(beforeSubmit.distanceFromBottom).toBeGreaterThan(NEAR_BOTTOM_THRESHOLD_PX);
|
||||
expect(afterSubmit.distanceFromBottom).toBeLessThanOrEqual(
|
||||
NEAR_BOTTOM_THRESHOLD_PX
|
||||
);
|
||||
});
|
||||
@@ -4,20 +4,16 @@ import { createTempGitRepo } from './helpers/workspace';
|
||||
|
||||
test('agent timeline hydrates after reload via fetch_agent_timeline_request', async ({ page }) => {
|
||||
const repo = await createTempGitRepo();
|
||||
const marker = 'TIMELINE_HYDRATION_OK';
|
||||
const prompt = `Respond with exactly: ${marker}`;
|
||||
const prompt = 'Respond with exactly: TIMELINE_HYDRATION_OK';
|
||||
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
await createAgent(page, prompt);
|
||||
|
||||
const assistantMessage = page
|
||||
.getByTestId('assistant-message')
|
||||
.filter({ hasText: marker })
|
||||
.first();
|
||||
await expect(assistantMessage).toBeVisible({ timeout: 120000 });
|
||||
await expect(page.getByText(prompt, { exact: true }).first()).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
await page.reload({ waitUntil: 'commit' });
|
||||
await expect(page).toHaveURL(/\/agent\//);
|
||||
@@ -25,9 +21,6 @@ test('agent timeline hydrates after reload via fetch_agent_timeline_request', as
|
||||
await expect(page.getByText(prompt, { exact: true }).first()).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
await expect(
|
||||
page.getByTestId('assistant-message').filter({ hasText: marker }).first()
|
||||
).toBeVisible({ timeout: 30000 });
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { execSync } from 'node:child_process';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { test, expect, type Page } from './fixtures';
|
||||
import {
|
||||
createAgent,
|
||||
ensureHostSelected,
|
||||
gotoHome,
|
||||
setWorkingDirectory,
|
||||
@@ -12,6 +13,10 @@ import { createTempGitRepo } from './helpers/workspace';
|
||||
|
||||
test.describe.configure({ mode: 'serial', timeout: 120000 });
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function getChangesScope(page: Page) {
|
||||
return page.locator('[data-testid="explorer-content-area"]:visible').first();
|
||||
}
|
||||
@@ -20,6 +25,26 @@ function getChangesHeader(page: Page) {
|
||||
return getChangesScope(page).getByTestId('changes-header');
|
||||
}
|
||||
|
||||
async function ensureExplorerTabsVisible(page: Page) {
|
||||
const changesTab = page.getByTestId('explorer-tab-changes').first();
|
||||
if (await changesTab.isVisible().catch(() => false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const toggle = page
|
||||
.getByRole('button', { name: /open explorer|close explorer|toggle explorer/i })
|
||||
.first();
|
||||
await expect(toggle).toBeVisible({ timeout: 10000 });
|
||||
for (let attempt = 0; attempt < 4; attempt += 1) {
|
||||
if (await changesTab.isVisible().catch(() => false)) {
|
||||
return;
|
||||
}
|
||||
await toggle.click();
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
await expect(changesTab).toBeVisible({ timeout: 30000 });
|
||||
}
|
||||
|
||||
async function selectChangesView(page: Page, view: 'working' | 'base') {
|
||||
// Defensive: close any open dropdown menus (their backdrops intercept clicks).
|
||||
const primaryBackdrop = page.getByTestId('changes-primary-cta-menu-backdrop');
|
||||
@@ -32,6 +57,11 @@ async function selectChangesView(page: Page, view: 'working' | 'base') {
|
||||
await overflowBackdrop.click({ force: true });
|
||||
await expect(overflowBackdrop).toHaveCount(0);
|
||||
}
|
||||
const diffModeBackdrop = page.getByTestId('changes-diff-status-menu-backdrop');
|
||||
if (await diffModeBackdrop.isVisible().catch(() => false)) {
|
||||
await diffModeBackdrop.click({ force: true });
|
||||
await expect(diffModeBackdrop).toHaveCount(0);
|
||||
}
|
||||
|
||||
const scope = getChangesScope(page);
|
||||
const modeToggle = scope.getByTestId('changes-diff-status').first();
|
||||
@@ -42,16 +72,15 @@ async function selectChangesView(page: Page, view: 'working' | 'base') {
|
||||
const current = ((await modeToggle.innerText().catch(() => '')) ?? '').trim();
|
||||
if (current !== expected) {
|
||||
await modeToggle.click();
|
||||
const menu = page.getByTestId('changes-diff-status-menu');
|
||||
await expect(menu).toBeVisible({ timeout: 10000 });
|
||||
const optionTestId =
|
||||
view === 'working' ? 'changes-diff-mode-uncommitted' : 'changes-diff-mode-committed';
|
||||
await page.getByTestId(optionTestId).click({ force: true });
|
||||
}
|
||||
await expect(modeToggle).toContainText(expected, { timeout: 10000 });
|
||||
}
|
||||
|
||||
async function openChangesOverflowMenu(page: Page) {
|
||||
const menuButton = getChangesScope(page).locator('[data-testid="changes-overflow-menu"]:visible').first();
|
||||
await expect(menuButton).toBeVisible();
|
||||
await menuButton.click();
|
||||
}
|
||||
|
||||
async function openChangesPrimaryMenu(page: Page) {
|
||||
const scope = getChangesScope(page);
|
||||
const caret = scope.getByTestId('changes-primary-cta-caret').first();
|
||||
@@ -62,25 +91,10 @@ async function openChangesPrimaryMenu(page: Page) {
|
||||
}
|
||||
|
||||
async function openChangesPanel(page: Page, options?: { expectGit?: boolean }) {
|
||||
await ensureExplorerTabsVisible(page);
|
||||
const changesHeader = getChangesHeader(page);
|
||||
if (!(await changesHeader.isVisible())) {
|
||||
const explorerHeader = page.getByTestId('explorer-header');
|
||||
if (await explorerHeader.isVisible()) {
|
||||
const changesTab = explorerHeader.getByText('Changes', { exact: true });
|
||||
if (await changesTab.isVisible().catch(() => false)) {
|
||||
await changesTab.click();
|
||||
} else {
|
||||
const overflowMenu = page.getByTestId('agent-overflow-menu').first();
|
||||
await expect(overflowMenu).toBeVisible({ timeout: 10000 });
|
||||
await overflowMenu.click();
|
||||
await page.getByText(/view changes/i).first().click();
|
||||
}
|
||||
} else {
|
||||
const overflowMenu = page.getByTestId('agent-overflow-menu').first();
|
||||
await expect(overflowMenu).toBeVisible({ timeout: 10000 });
|
||||
await overflowMenu.click();
|
||||
await page.getByText(/view changes/i).first().click();
|
||||
}
|
||||
await page.getByTestId('explorer-tab-changes').first().click();
|
||||
}
|
||||
await expect(changesHeader).toBeVisible({ timeout: 30000 });
|
||||
if (options?.expectGit === false) {
|
||||
@@ -95,98 +109,62 @@ async function openChangesPanel(page: Page, options?: { expectGit?: boolean }) {
|
||||
});
|
||||
}
|
||||
|
||||
async function sendPrompt(page: Page, prompt: string) {
|
||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
||||
await expect(input).toBeEditable();
|
||||
await input.fill(prompt);
|
||||
await input.press('Enter');
|
||||
}
|
||||
|
||||
async function waitForAssistantText(page: Page, text: string) {
|
||||
const assistantMessage = page.getByTestId('assistant-message').filter({ hasText: text }).last();
|
||||
await expect(assistantMessage).toBeVisible({ timeout: 60000 });
|
||||
return assistantMessage;
|
||||
async function waitForAgentTurnToSettle(page: Page, timeout = 90000) {
|
||||
const stopButton = page.getByRole('button', { name: /stop agent|stop/i }).first();
|
||||
if (!(await stopButton.isVisible().catch(() => false))) {
|
||||
return;
|
||||
}
|
||||
await expect(stopButton).not.toBeVisible({ timeout });
|
||||
}
|
||||
|
||||
async function createAgentAndWait(page: Page, message: string) {
|
||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
||||
await expect(input).toBeEditable();
|
||||
await input.fill(message);
|
||||
await input.press('Enter');
|
||||
await expect(page).toHaveURL(/\/agent\//, { timeout: 120000 });
|
||||
await expect(page.getByText(message, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function requestCwd(page: Page) {
|
||||
await sendPrompt(page, 'Run `pwd` and respond with exactly: CWD: <path>');
|
||||
const message = await waitForAssistantText(page, 'CWD:');
|
||||
// The assistant streams tokens; make sure we capture the full path (not a partial prefix).
|
||||
await expect.poll(async () => (await message.textContent()) ?? '', { timeout: 60000 }).toContain('/worktrees/');
|
||||
const content = (await message.textContent()) ?? '';
|
||||
const match = content.match(/CWD:\s*(\S+)/);
|
||||
if (!match) {
|
||||
throw new Error(`Expected agent to respond with "CWD: <path>", got: ${content}`);
|
||||
}
|
||||
return match[1].trim();
|
||||
await createAgent(page, message);
|
||||
}
|
||||
|
||||
async function selectAttachWorktree(page: Page, branchName: string) {
|
||||
await page.getByTestId('worktree-attach-toggle').click();
|
||||
const picker = page.getByTestId('worktree-attach-picker');
|
||||
await expect(picker).toBeVisible();
|
||||
const trigger = page.getByTestId('worktree-select-trigger').first();
|
||||
await expect(trigger).toBeVisible({ timeout: 30000 });
|
||||
await trigger.click();
|
||||
|
||||
// Wait a bit for the worktree list to load
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await picker.click();
|
||||
|
||||
// Wait a bit for animation
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const sheet = page.getByLabel('Bottom Sheet', { exact: true });
|
||||
const backdrop = page.getByRole('button', { name: 'Bottom sheet backdrop' }).first();
|
||||
|
||||
await expect.poll(async () => {
|
||||
const sheetVisible = await sheet.isVisible().catch(() => false);
|
||||
const backdropVisible = await backdrop.isVisible().catch(() => false);
|
||||
// Also check if branch name is visible directly
|
||||
const branchVisible = await page.getByText(branchName, { exact: true }).first().isVisible().catch(() => false);
|
||||
return sheetVisible || backdropVisible || branchVisible;
|
||||
}, { timeout: 10000 }).toBeTruthy();
|
||||
const sheetVisible = await sheet.isVisible().catch(() => false);
|
||||
const scope = sheetVisible ? sheet : page;
|
||||
const preferredOption = scope.getByText(branchName, { exact: true }).first();
|
||||
if (await preferredOption.isVisible().catch(() => false)) {
|
||||
await preferredOption.click();
|
||||
await expect(picker).toContainText(branchName);
|
||||
return;
|
||||
const menu = page.getByTestId('combobox-desktop-container').first();
|
||||
await expect(menu).toBeVisible({ timeout: 10000 });
|
||||
const searchInput = page.getByRole('textbox', { name: /search worktrees/i }).first();
|
||||
if (await searchInput.isVisible().catch(() => false)) {
|
||||
await searchInput.fill(branchName);
|
||||
}
|
||||
|
||||
const options = scope.locator('[data-testid^="worktree-attach-option-"]');
|
||||
const optionCount = await options.count();
|
||||
if (optionCount === 0) {
|
||||
throw new Error(`No worktree options were available in the attach picker`);
|
||||
}
|
||||
const fallbackOption = options.first();
|
||||
const fallbackLabel = ((await fallbackOption.innerText()) ?? "").trim();
|
||||
await fallbackOption.click();
|
||||
if (fallbackLabel.length > 0) {
|
||||
await expect(picker).toContainText(fallbackLabel);
|
||||
}
|
||||
const preferredOption = menu
|
||||
.getByText(new RegExp(`^${escapeRegex(branchName)}$`, 'i'))
|
||||
.first();
|
||||
await expect(preferredOption).toBeVisible({ timeout: 10000 });
|
||||
await preferredOption.click({ force: true });
|
||||
await expect(menu).toHaveCount(0);
|
||||
await expect(trigger).toContainText(branchName, { timeout: 30000 });
|
||||
}
|
||||
|
||||
async function enableCreateWorktree(page: Page) {
|
||||
const createToggle = page.getByTestId('worktree-create-toggle');
|
||||
const willCreateLabel = page.getByText(/Will create:/);
|
||||
if (await willCreateLabel.isVisible()) {
|
||||
const trigger = page.getByTestId('worktree-select-trigger').first();
|
||||
await expect(trigger).toBeVisible({ timeout: 30000 });
|
||||
|
||||
const currentValue = ((await trigger.innerText().catch(() => '')) ?? '').trim();
|
||||
if (/Create new worktree/i.test(currentValue)) {
|
||||
await expect(page.getByTestId('worktree-base-branch-trigger')).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const readyLabel = page.getByText(
|
||||
/Run isolated from|Run in an isolated directory/
|
||||
);
|
||||
await expect(readyLabel).toBeVisible({ timeout: 30000 });
|
||||
await createToggle.click({ force: true });
|
||||
await expect(willCreateLabel).toBeVisible({ timeout: 30000 });
|
||||
|
||||
await trigger.click();
|
||||
const menu = page.getByTestId('combobox-desktop-container').first();
|
||||
await expect(menu).toBeVisible({ timeout: 10000 });
|
||||
const createOption = menu.getByText('Create new worktree', { exact: true }).first();
|
||||
await expect(createOption).toBeVisible({ timeout: 10000 });
|
||||
await createOption.click({ force: true });
|
||||
await expect(menu).toHaveCount(0);
|
||||
await expect(trigger).toContainText('Create new worktree', { timeout: 30000 });
|
||||
await expect(page.getByTestId('worktree-base-branch-trigger')).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshUncommittedMode(page: Page) {
|
||||
@@ -195,9 +173,9 @@ async function refreshUncommittedMode(page: Page) {
|
||||
}
|
||||
|
||||
async function refreshChangesTab(page: Page) {
|
||||
const header = page.locator('[data-testid="explorer-header"]:visible').first();
|
||||
await header.getByText('Files', { exact: true }).first().click();
|
||||
await header.getByText('Changes', { exact: true }).first().click();
|
||||
await ensureExplorerTabsVisible(page);
|
||||
await page.getByTestId('explorer-tab-files').first().click();
|
||||
await page.getByTestId('explorer-tab-changes').first().click();
|
||||
}
|
||||
|
||||
function normalizeTmpPath(value: string) {
|
||||
@@ -207,6 +185,72 @@ function normalizeTmpPath(value: string) {
|
||||
return value;
|
||||
}
|
||||
|
||||
type GitWorktreeEntry = {
|
||||
worktreePath: string;
|
||||
branchRef: string | null;
|
||||
};
|
||||
|
||||
function parseGitWorktreeList(raw: string): GitWorktreeEntry[] {
|
||||
const blocks = raw
|
||||
.split(/\n\s*\n/g)
|
||||
.map((block) => block.trim())
|
||||
.filter((block) => block.length > 0);
|
||||
|
||||
const entries: GitWorktreeEntry[] = [];
|
||||
for (const block of blocks) {
|
||||
const worktreeMatch = block.match(/^worktree (.+)$/m);
|
||||
if (!worktreeMatch) {
|
||||
continue;
|
||||
}
|
||||
const branchMatch = block.match(/^branch (.+)$/m);
|
||||
entries.push({
|
||||
worktreePath: worktreeMatch[1].trim(),
|
||||
branchRef: branchMatch ? branchMatch[1].trim() : null,
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function waitForCreatedWorktree(repoPath: string, timeoutMs = 30000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const normalizedRepoPath = normalizeTmpPath(repoPath);
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const output = execSync('git worktree list --porcelain', {
|
||||
cwd: repoPath,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
const entries = parseGitWorktreeList(output);
|
||||
const candidate = entries.find((entry) => {
|
||||
const normalizedWorktreePath = normalizeTmpPath(entry.worktreePath);
|
||||
if (normalizedWorktreePath === normalizedRepoPath) {
|
||||
return false;
|
||||
}
|
||||
if (!entry.branchRef) {
|
||||
return false;
|
||||
}
|
||||
return !/\/main$/i.test(entry.branchRef);
|
||||
});
|
||||
|
||||
if (candidate) {
|
||||
const branchName = candidate.branchRef?.split('/').filter(Boolean).pop();
|
||||
if (branchName) {
|
||||
return {
|
||||
worktreePath: candidate.worktreePath,
|
||||
branchName,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore transient git worktree read errors while polling.
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for a non-main worktree under ${repoPath}`);
|
||||
}
|
||||
|
||||
test('checkout-first Changes panel ship loop', async ({ page }) => {
|
||||
const repo = await createTempGitRepo('paseo-e2e-', { withRemote: true });
|
||||
const nonGitDir = await mkdtemp(path.join(tmpdir(), 'paseo-e2e-non-git-'));
|
||||
@@ -218,7 +262,7 @@ test('checkout-first Changes panel ship loop', async ({ page }) => {
|
||||
|
||||
await enableCreateWorktree(page);
|
||||
await createAgentAndWait(page, 'Respond with exactly: READY');
|
||||
await waitForAssistantText(page, 'READY');
|
||||
await waitForAgentTurnToSettle(page);
|
||||
|
||||
await openChangesPanel(page);
|
||||
const branchLabelLocator = getChangesScope(page).getByTestId('changes-branch');
|
||||
@@ -228,11 +272,9 @@ test('checkout-first Changes panel ship loop', async ({ page }) => {
|
||||
const branchNameFromUi = (await branchLabelLocator.innerText()).trim();
|
||||
expect(branchNameFromUi.length).toBeGreaterThan(0);
|
||||
|
||||
const firstCwd = await requestCwd(page);
|
||||
const worktreeBranch = execSync('git rev-parse --abbrev-ref HEAD', {
|
||||
cwd: firstCwd,
|
||||
encoding: 'utf8',
|
||||
}).trim();
|
||||
const { worktreePath: firstCwd, branchName: worktreeBranch } = await waitForCreatedWorktree(
|
||||
repo.path
|
||||
);
|
||||
expect(worktreeBranch.length).toBeGreaterThan(0);
|
||||
const [resolvedCwd, resolvedRepo] = await Promise.all([
|
||||
realpath(firstCwd).catch(() => firstCwd),
|
||||
@@ -244,19 +286,14 @@ test('checkout-first Changes panel ship loop', async ({ page }) => {
|
||||
expect(normalizedCwd.includes(expectedMarker)).toBeTruthy();
|
||||
|
||||
await page.getByTestId('sidebar-new-agent').click();
|
||||
await expect(page).toHaveURL(/\/agent\/?$/);
|
||||
await expect(page).toHaveURL(/\/h\/[^/]+\/agent(\?|$)/);
|
||||
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
await selectAttachWorktree(page, worktreeBranch);
|
||||
await createAgentAndWait(page, 'Respond with exactly: READY2');
|
||||
await waitForAssistantText(page, 'READY2');
|
||||
|
||||
const secondCwd = await requestCwd(page);
|
||||
expect(secondCwd).toBe(firstCwd);
|
||||
|
||||
await sendPrompt(page, "Respond with exactly: OK");
|
||||
await waitForAssistantText(page, "OK");
|
||||
await waitForAgentTurnToSettle(page);
|
||||
await openChangesPanel(page);
|
||||
|
||||
const readmePath = path.join(firstCwd, 'README.md');
|
||||
await appendFile(readmePath, '\nFirst change\n');
|
||||
@@ -384,43 +421,18 @@ test('checkout-first Changes panel ship loop', async ({ page }) => {
|
||||
execSync("git push", { cwd: repo.path });
|
||||
|
||||
await selectChangesView(page, 'base');
|
||||
await expect(getChangesScope(page).getByText(/No changes vs/i)).toBeVisible({
|
||||
timeout: 60000,
|
||||
});
|
||||
await expect(getChangesScope(page).getByTestId('changes-diff-status')).toContainText(
|
||||
'Committed',
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
await refreshChangesTab(page);
|
||||
await expect(getChangesScope(page).getByTestId('changes-primary-cta')).toHaveCount(0, { timeout: 30000 });
|
||||
|
||||
await openChangesOverflowMenu(page);
|
||||
await expect(page.getByTestId('changes-menu-archive-worktree')).toBeVisible();
|
||||
await page.getByTestId('changes-menu-archive-worktree').click();
|
||||
// Archiving a worktree deletes agents and redirects to home
|
||||
await expect(page).toHaveURL(/\/agent\/?(?:\?.*)?$/, { timeout: 30000 });
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
// Repo inspection is async; wait until git options are interactive again.
|
||||
await expect(page.getByText('Inspecting repository…')).toHaveCount(0, { timeout: 30000 });
|
||||
await page.getByTestId('worktree-attach-toggle').click();
|
||||
await expect(page.getByTestId('worktree-attach-picker')).toBeVisible({ timeout: 30000 });
|
||||
await page.getByTestId('worktree-attach-picker').click();
|
||||
await expect(page.getByText(worktreeBranch, { exact: true })).toHaveCount(0);
|
||||
const attachSheet = page.getByLabel('Bottom Sheet', { exact: true });
|
||||
if (await attachSheet.isVisible().catch(() => false)) {
|
||||
await page.getByTestId('dropdown-sheet-close').click({ force: true });
|
||||
await expect(attachSheet).toBeHidden({ timeout: 30000 });
|
||||
}
|
||||
await page.getByTestId('worktree-attach-toggle').click();
|
||||
await expect(page.getByTestId('worktree-attach-picker')).toBeHidden({ timeout: 30000 });
|
||||
// Post-ship UI behavior is implementation-dependent (archive can be promoted into
|
||||
// primary flow or hidden behind menu variants), so continue from a fresh draft.
|
||||
await page.getByTestId('sidebar-new-agent').click();
|
||||
await expect(page).toHaveURL(/\/h\/[^/]+\/agent(?:\?|$)/, { timeout: 30000 });
|
||||
|
||||
await setWorkingDirectory(page, nonGitDir);
|
||||
// Wait for git options to disappear (repo inspection is async and the git section can briefly render stale UI).
|
||||
await expect(page.getByTestId('worktree-attach-toggle')).toHaveCount(0, { timeout: 30000 });
|
||||
await expect(page.getByTestId('worktree-attach-picker')).toHaveCount(0);
|
||||
await createAgentAndWait(page, 'Respond with exactly: NON-GIT');
|
||||
await waitForAssistantText(page, 'NON-GIT');
|
||||
await openChangesPanel(page, { expectGit: false });
|
||||
await expect(getChangesScope(page).getByTestId('changes-not-git')).toBeVisible();
|
||||
await expect(getChangesScope(page).getByTestId('changes-primary-cta')).toHaveCount(0);
|
||||
await expect(getChangesScope(page).getByTestId('changes-overflow-menu')).toHaveCount(0);
|
||||
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeEditable();
|
||||
} finally {
|
||||
await rm(nonGitDir, { recursive: true, force: true });
|
||||
await repo.cleanup();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
|
||||
import { createAgentInRepo } from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
|
||||
test('create agent in a temp repo', async ({ page }) => {
|
||||
@@ -7,22 +7,20 @@ test('create agent in a temp repo', async ({ page }) => {
|
||||
const prompt = "Respond with exactly: Hello";
|
||||
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
await createAgent(page, prompt);
|
||||
await createAgentInRepo(page, { directory: repo.path, prompt });
|
||||
|
||||
// Verify user message is shown in the stream
|
||||
await expect(page.getByText(prompt, { exact: true })).toBeVisible();
|
||||
|
||||
// Verify we used a fast model (do not fall back to a default like Sonnet).
|
||||
await page.getByTestId('agent-overflow-menu').click();
|
||||
await expect(page.getByText('Model', { exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId('agent-overflow-content').getByText(/haiku/i)).toBeVisible();
|
||||
// Verify we used the seeded fast model (do not fall back to other defaults).
|
||||
const modelPicker = page.getByRole("button", { name: /select agent model/i }).first();
|
||||
await expect(modelPicker).toBeVisible({ timeout: 30000 });
|
||||
await expect(modelPicker).toContainText(/gpt-5\.1-codex-mini/i);
|
||||
|
||||
// Wait for agent response containing "Hello" within an assistant message
|
||||
const assistantMessage = page.getByTestId('assistant-message').filter({ hasText: 'Hello' });
|
||||
await expect(assistantMessage).toBeVisible({ timeout: 30000 });
|
||||
// Verify the assistant response is rendered.
|
||||
await expect(page.getByText("Hello", { exact: true }).first()).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
|
||||
@@ -17,15 +17,19 @@ test('deleting an agent persists after reload', async ({ page }) => {
|
||||
await expect(input).toBeEditable();
|
||||
await input.fill(prompt);
|
||||
await input.press('Enter');
|
||||
await page.waitForURL(/\/agent\//, { waitUntil: 'commit' });
|
||||
await expect(page).toHaveURL(/\/h\/[^/]+\/agent\/[^/?#]+(?:\?|$)/, {
|
||||
timeout: 30000,
|
||||
});
|
||||
// Wait for the initial turn to complete so the agent can be archived (web uses a hover action).
|
||||
const stopOrCancel = page.getByRole('button', { name: /Stop agent|Canceling agent/ });
|
||||
await stopOrCancel.first().waitFor({ state: 'visible', timeout: 30000 }).catch(() => undefined);
|
||||
await expect(stopOrCancel).toHaveCount(0, { timeout: 120000 });
|
||||
|
||||
const match = page.url().match(/\/agent\/([^/]+)\/([^/?#]+)/);
|
||||
const match =
|
||||
page.url().match(/\/h\/([^/]+)\/agent\/([^/?#]+)/) ??
|
||||
page.url().match(/\/agent\/([^/]+)\/([^/?#]+)/);
|
||||
if (!match) {
|
||||
throw new Error(`Expected /agent/:serverId/:agentId URL, got ${page.url()}`);
|
||||
throw new Error(`Expected /h/:serverId/agent/:agentId URL, got ${page.url()}`);
|
||||
}
|
||||
const serverId = decodeURIComponent(match[1]);
|
||||
const agentId = decodeURIComponent(match[2]);
|
||||
@@ -36,11 +40,16 @@ test('deleting an agent persists after reload', async ({ page }) => {
|
||||
const agentRow = page.getByTestId(rowTestId).first();
|
||||
await expect(agentRow).toBeVisible({ timeout: 30000 });
|
||||
|
||||
// Web UX: hover shows a quick-archive icon. (Long-press is touch-oriented and unreliable on desktop web.)
|
||||
// Web UX: hover shows a quick-archive icon. First click enters confirm state; second click archives.
|
||||
await agentRow.hover();
|
||||
const quickArchive = page.getByTestId(`agent-archive-${serverId}-${agentId}`).first();
|
||||
await expect(quickArchive).toBeVisible({ timeout: 10000 });
|
||||
await quickArchive.click({ force: true });
|
||||
const confirmArchive = page
|
||||
.getByTestId(`agent-archive-confirm-${serverId}-${agentId}`)
|
||||
.first();
|
||||
await expect(confirmArchive).toBeVisible({ timeout: 10000 });
|
||||
await confirmArchive.click({ force: true });
|
||||
|
||||
// Ensure deletion finished before reload (avoids races).
|
||||
await expect(page.getByTestId(rowTestId)).toHaveCount(0, { timeout: 30000 });
|
||||
|
||||
@@ -1,14 +1,33 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
|
||||
import {
|
||||
createAgent,
|
||||
createAgentInRepo,
|
||||
ensureHostSelected,
|
||||
gotoHome,
|
||||
openSettings,
|
||||
setWorkingDirectory,
|
||||
} from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
buildHostWorkspaceAgentRoute,
|
||||
buildHostWorkspaceRoute,
|
||||
} from '@/utils/host-routes';
|
||||
import {
|
||||
ensureWorkspaceAgentPaneVisible,
|
||||
waitForWorkspaceTabsVisible,
|
||||
} from './helpers/workspace-tabs';
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
async function addFakeMicrophone(page: Page) {
|
||||
const fixturePath = path.resolve(__dirname, 'fixtures', 'recording.webm');
|
||||
const fixturePath = path.resolve(__dirname, 'fixtures', 'recording.wav');
|
||||
const base64Audio = (await readFile(fixturePath)).toString('base64');
|
||||
const mimeType = 'audio/webm;codecs=opus';
|
||||
const mimeType = 'audio/wav';
|
||||
|
||||
return page.addInitScript(({ base64Audio, mimeType }) => {
|
||||
const mic = {
|
||||
@@ -39,6 +58,24 @@ async function addFakeMicrophone(page: Page) {
|
||||
};
|
||||
};
|
||||
|
||||
const AudioContextCtor =
|
||||
(window as any).AudioContext ?? (window as any).webkitAudioContext;
|
||||
if (AudioContextCtor?.prototype?.createMediaStreamSource) {
|
||||
const nativeCreateMediaStreamSource =
|
||||
AudioContextCtor.prototype.createMediaStreamSource;
|
||||
AudioContextCtor.prototype.createMediaStreamSource =
|
||||
function patchedCreateMediaStreamSource(stream: unknown) {
|
||||
const isFakeStream =
|
||||
!!stream &&
|
||||
typeof (stream as { getTracks?: unknown }).getTracks === 'function' &&
|
||||
typeof (stream as { id?: unknown }).id === 'undefined';
|
||||
if (isFakeStream) {
|
||||
throw new Error('Force recorder fallback for fake microphone stream');
|
||||
}
|
||||
return nativeCreateMediaStreamSource.call(this, stream);
|
||||
};
|
||||
}
|
||||
|
||||
const blobFromBase64 = (base64: string, mimeType: string): Blob => {
|
||||
const binaryString = atob(base64);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
@@ -88,6 +125,89 @@ async function addFakeMicrophone(page: Page) {
|
||||
}, { base64Audio, mimeType });
|
||||
}
|
||||
|
||||
async function expectComposerReady(page: Page) {
|
||||
await expect(page.getByRole('textbox', { name: 'Message agent...' }).first()).toBeEditable();
|
||||
}
|
||||
|
||||
async function expectDictationStarted(page: Page) {
|
||||
await expect
|
||||
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
|
||||
.toBe(1);
|
||||
}
|
||||
|
||||
async function expectDictationStopped(page: Page) {
|
||||
await expect
|
||||
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
|
||||
.toBe(0);
|
||||
}
|
||||
|
||||
test('dictation hotkey works on a workspace agent tab', async ({ page }) => {
|
||||
await addFakeMicrophone(page);
|
||||
|
||||
const repo = await createTempGitRepo();
|
||||
try {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error('E2E_SERVER_ID is not set.');
|
||||
}
|
||||
|
||||
await createAgentInRepo(page, {
|
||||
directory: repo.path,
|
||||
prompt: 'Respond with exactly: Hello',
|
||||
});
|
||||
await page.goto(buildHostWorkspaceRoute(serverId, repo.path));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await ensureWorkspaceAgentPaneVisible(page);
|
||||
await expect(page).toHaveURL(/\/workspace\//);
|
||||
await expectComposerReady(page);
|
||||
|
||||
await page.keyboard.press('Control+d');
|
||||
await expectDictationStarted(page);
|
||||
|
||||
const calls = await page.evaluate(() => (window as any).__mic.getUserMediaCalls as number);
|
||||
expect(calls).toBe(1);
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expectDictationStopped(page);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('dictation hotkey works on a workspace draft tab', async ({ page }) => {
|
||||
await addFakeMicrophone(page);
|
||||
|
||||
const repo = await createTempGitRepo();
|
||||
try {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error('E2E_SERVER_ID is not set.');
|
||||
}
|
||||
|
||||
await createAgentInRepo(page, {
|
||||
directory: repo.path,
|
||||
prompt: 'Respond with exactly: Hello',
|
||||
});
|
||||
await page.goto(buildHostWorkspaceRoute(serverId, repo.path));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await ensureWorkspaceAgentPaneVisible(page);
|
||||
|
||||
await page.getByTestId('workspace-new-agent-tab').first().click();
|
||||
await expectComposerReady(page);
|
||||
|
||||
await page.keyboard.press('Control+d');
|
||||
await expectDictationStarted(page);
|
||||
|
||||
const calls = await page.evaluate(() => (window as any).__mic.getUserMediaCalls as number);
|
||||
expect(calls).toBe(1);
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expectDictationStopped(page);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('dictation hotkeys do not trigger on background screens', async ({ page }) => {
|
||||
await addFakeMicrophone(page);
|
||||
|
||||
@@ -97,23 +217,18 @@ test('dictation hotkeys do not trigger on background screens', async ({ page })
|
||||
await ensureHostSelected(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await createAgent(page, 'Respond with exactly: Hello');
|
||||
await expect(page).toHaveURL(/\/workspace\//);
|
||||
await expectComposerReady(page);
|
||||
|
||||
await expect(page).toHaveURL(/\/agent\//);
|
||||
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeEditable();
|
||||
|
||||
await openSettings(page);
|
||||
await page.keyboard.press('Control+d');
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
const calls = await page.evaluate(() => (window as any).__mic.getUserMediaCalls as number);
|
||||
const active = await page.evaluate(() => (window as any).__mic.active as number);
|
||||
|
||||
expect(calls).toBe(1);
|
||||
expect(active).toBe(1);
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expect
|
||||
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
|
||||
.toBe(0);
|
||||
expect(calls).toBe(0);
|
||||
expect(active).toBe(0);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
@@ -128,20 +243,18 @@ test('dictation transcribes fixture via real STT', async ({ page }) => {
|
||||
await ensureHostSelected(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await createAgent(page, 'Respond with exactly: Hello');
|
||||
|
||||
await expect(page).toHaveURL(/\/agent\//);
|
||||
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeEditable();
|
||||
await expect(page).toHaveURL(/\/workspace\//);
|
||||
await expectComposerReady(page);
|
||||
|
||||
await page.keyboard.press('Control+d');
|
||||
await expect
|
||||
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
|
||||
.toBe(1);
|
||||
await expectDictationStarted(page);
|
||||
|
||||
const initialCopyMessageCount = await page
|
||||
.getByRole('button', { name: 'Copy message' })
|
||||
.count();
|
||||
|
||||
await page.keyboard.press('Control+d');
|
||||
await expectDictationStopped(page);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
@@ -164,13 +277,11 @@ test('cancel stops mic even if recorder is already inactive', async ({ page }) =
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await createAgent(page, 'Respond with exactly: Hello');
|
||||
|
||||
await expect(page).toHaveURL(/\/agent\//);
|
||||
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeEditable();
|
||||
await expect(page).toHaveURL(/\/workspace\//);
|
||||
await expectComposerReady(page);
|
||||
|
||||
await page.keyboard.press('Control+d');
|
||||
await expect
|
||||
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
|
||||
.toBe(1);
|
||||
await expectDictationStarted(page);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const mic = (window as any).__mic as { lastRecorder: null | { state: string } };
|
||||
@@ -180,9 +291,7 @@ test('cancel stops mic even if recorder is already inactive', async ({ page }) =
|
||||
});
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expect
|
||||
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
|
||||
.toBe(0);
|
||||
await expectDictationStopped(page);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
@@ -198,29 +307,35 @@ test('dictation confirm+send does not dispatch after navigating away', async ({
|
||||
await ensureHostSelected(page);
|
||||
await createAgent(page, 'Respond with exactly: Hello');
|
||||
|
||||
await expect(page).toHaveURL(/\/agent($|\/)/);
|
||||
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeEditable();
|
||||
await expect(page).toHaveURL(/\/workspace\//);
|
||||
const match = page.url().match(/\/h\/([^/]+)\/workspace\/[^?]+(?:\?open=agent%3A|\\?open=agent:)([^/?#&]+)/);
|
||||
if (!match) {
|
||||
throw new Error(`Expected workspace agent URL, got ${page.url()}`);
|
||||
}
|
||||
const serverId = decodeURIComponent(match[1]!);
|
||||
const agentId = decodeURIComponent(match[2]!);
|
||||
await expectComposerReady(page);
|
||||
const initialCopyMessageCount = await page.getByRole('button', { name: 'Copy message' }).count();
|
||||
|
||||
await page.keyboard.press('Control+d');
|
||||
await expect
|
||||
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
|
||||
.toBe(1);
|
||||
await expectDictationStarted(page);
|
||||
|
||||
await page.keyboard.press('Control+d');
|
||||
|
||||
const newAgentButton = page.getByTestId('sidebar-new-agent');
|
||||
await expect(newAgentButton).toBeVisible();
|
||||
await newAgentButton.click();
|
||||
await expect(page).toHaveURL(/\/agent\/?$/);
|
||||
await expect(page).toHaveURL(/\/h\/[^/]+\/new-agent(\?|$)/);
|
||||
|
||||
await page.waitForTimeout(10_000);
|
||||
|
||||
const agentEntry = page.getByText(repo.path).first();
|
||||
await expect(agentEntry).toBeVisible();
|
||||
await agentEntry.click();
|
||||
await expect(page).toHaveURL(/\/agent($|\/)/);
|
||||
await page.goto(buildHostWorkspaceAgentRoute(serverId, repo.path, agentId));
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`/h/${escapeRegex(serverId)}/workspace/[^?]+\\?open=agent(?:%3A|:)${escapeRegex(agentId)}(?:$|&)`)
|
||||
);
|
||||
|
||||
await expect(page.getByText(/voice note/i)).not.toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Copy message' })).toHaveCount(initialCopyMessageCount);
|
||||
await expect(page.getByTestId('agent-chat-scroll').getByText(/this is a voice note\./i)).toHaveCount(0);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ test("draft enables explorer after selecting a working directory", async ({ page
|
||||
const newAgentButton = page.getByTestId("sidebar-new-agent").first();
|
||||
await expect(newAgentButton).toBeVisible({ timeout: 30000 });
|
||||
await newAgentButton.click();
|
||||
await expect(page).toHaveURL(/\/agent\/?$/, { timeout: 30000 });
|
||||
await expect(page).toHaveURL(/\/h\/[^/]+\/agent(\?|$)/, { timeout: 30000 });
|
||||
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
|
||||
|
||||
@@ -77,10 +77,10 @@ test.beforeEach(async ({ page }) => {
|
||||
// Ensure create flow never uses a remembered host from the developer's real app.
|
||||
serverId: testDaemon.serverId,
|
||||
// Keep e2e fast/cheap by default.
|
||||
provider: 'claude',
|
||||
provider: 'codex',
|
||||
providerPreferences: {
|
||||
claude: { model: 'haiku' },
|
||||
codex: { model: 'gpt-5.1-codex-mini' },
|
||||
codex: { model: 'gpt-5.1-codex-mini', thinkingOptionId: 'low' },
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import path from 'node:path';
|
||||
import { appendFile } from 'node:fs/promises';
|
||||
import { test, expect, type Page } from './fixtures';
|
||||
import { ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
|
||||
import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
|
||||
test.describe.configure({ timeout: 90000 });
|
||||
@@ -10,18 +10,31 @@ function getChangesScope(page: Page) {
|
||||
return page.locator('[data-testid="explorer-content-area"]:visible').first();
|
||||
}
|
||||
|
||||
async function ensureExplorerTabsVisible(page: Page) {
|
||||
const changesTab = page.getByTestId('explorer-tab-changes').first();
|
||||
if (await changesTab.isVisible().catch(() => false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const toggle = page
|
||||
.getByRole('button', { name: /open explorer|close explorer|toggle explorer/i })
|
||||
.first();
|
||||
await expect(toggle).toBeVisible({ timeout: 10000 });
|
||||
for (let attempt = 0; attempt < 4; attempt += 1) {
|
||||
if (await changesTab.isVisible().catch(() => false)) {
|
||||
return;
|
||||
}
|
||||
await toggle.click();
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
await expect(changesTab).toBeVisible({ timeout: 30000 });
|
||||
}
|
||||
|
||||
async function openChangesPanel(page: Page) {
|
||||
await ensureExplorerTabsVisible(page);
|
||||
const changesHeader = getChangesScope(page).getByTestId('changes-header');
|
||||
if (!(await changesHeader.isVisible())) {
|
||||
const explorerHeader = page.getByTestId('explorer-header');
|
||||
if (await explorerHeader.isVisible()) {
|
||||
await page.getByText('Changes', { exact: true }).click();
|
||||
} else {
|
||||
const overflowMenu = page.getByTestId('agent-overflow-menu').first();
|
||||
await expect(overflowMenu).toBeVisible({ timeout: 10000 });
|
||||
await overflowMenu.click();
|
||||
await page.getByText('View Changes', { exact: true }).click();
|
||||
}
|
||||
await page.getByTestId('explorer-tab-changes').first().click();
|
||||
}
|
||||
await expect(changesHeader).toBeVisible();
|
||||
}
|
||||
@@ -31,22 +44,29 @@ async function refreshUncommittedMode(page: Page) {
|
||||
const toggle = scope.getByTestId('changes-diff-status').first();
|
||||
await expect(toggle).toBeVisible({ timeout: 30000 });
|
||||
|
||||
const diffModeBackdrop = page.getByTestId('changes-diff-status-menu-backdrop');
|
||||
if (await diffModeBackdrop.isVisible().catch(() => false)) {
|
||||
await diffModeBackdrop.click({ force: true });
|
||||
await expect(diffModeBackdrop).toHaveCount(0);
|
||||
}
|
||||
|
||||
const currentLabel = (await toggle.innerText()).trim();
|
||||
await toggle.click();
|
||||
await toggle.click({ force: true });
|
||||
await expect(page.getByTestId('changes-diff-status-menu')).toBeVisible({ timeout: 10000 });
|
||||
const firstTarget = currentLabel === 'Uncommitted' ? 'changes-diff-mode-committed' : 'changes-diff-mode-uncommitted';
|
||||
await page.getByTestId(firstTarget).click({ force: true });
|
||||
await expect.poll(async () => (await toggle.innerText()).trim()).not.toBe(currentLabel);
|
||||
|
||||
const nextLabel = (await toggle.innerText()).trim();
|
||||
await toggle.click();
|
||||
await toggle.click({ force: true });
|
||||
await expect(page.getByTestId('changes-diff-status-menu')).toBeVisible({ timeout: 10000 });
|
||||
const secondTarget = nextLabel === 'Uncommitted' ? 'changes-diff-mode-committed' : 'changes-diff-mode-uncommitted';
|
||||
await page.getByTestId(secondTarget).click({ force: true });
|
||||
await expect.poll(async () => (await toggle.innerText()).trim()).not.toBe(nextLabel);
|
||||
}
|
||||
|
||||
async function createAgentAndWait(page: Page, message: string) {
|
||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
||||
await expect(input).toBeEditable();
|
||||
await input.fill(message);
|
||||
await input.press('Enter');
|
||||
await expect(page).toHaveURL(/\/agent\//, { timeout: 120000 });
|
||||
await expect(page.getByText(message, { exact: true })).toBeVisible();
|
||||
await createAgent(page, message);
|
||||
}
|
||||
|
||||
test('keeps file header sticky while scrolling within a long diff', async ({ page }) => {
|
||||
|
||||
47
packages/app/e2e/global-draft-create-status-controls.spec.ts
Normal file
47
packages/app/e2e/global-draft-create-status-controls.spec.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { test, expect } from './fixtures'
|
||||
import { createTempGitRepo } from './helpers/workspace'
|
||||
import { ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app'
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
test('global draft create uses input status controls and preserves optimistic flow', async ({ page }) => {
|
||||
const repo = await createTempGitRepo('paseo-e2e-global-draft-')
|
||||
const prompt = `global draft prompt ${Date.now()}`
|
||||
|
||||
try {
|
||||
await gotoHome(page)
|
||||
await ensureHostSelected(page)
|
||||
await setWorkingDirectory(page, repo.path)
|
||||
|
||||
await expect(page.getByTestId('working-directory-select').first()).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
const providerSelector = page.getByTestId('agent-provider-selector').first()
|
||||
const modeSelector = page.getByTestId('agent-mode-selector').first()
|
||||
const modelSelector = page.getByTestId('agent-model-selector').first()
|
||||
const thinkingSelector = page.getByTestId('agent-thinking-selector').first()
|
||||
|
||||
await expect(providerSelector).toBeVisible({ timeout: 30_000 })
|
||||
await expect(modeSelector).toBeVisible({ timeout: 30_000 })
|
||||
await expect(modelSelector).toBeVisible({ timeout: 30_000 })
|
||||
await expect(thinkingSelector).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
const selectedMode = ((await modeSelector.innerText()) ?? '').trim()
|
||||
const selectedModel = ((await modelSelector.innerText()) ?? '').trim()
|
||||
const selectedThinking = ((await thinkingSelector.innerText()) ?? '').trim()
|
||||
|
||||
const composer = page.getByRole('textbox', { name: 'Message agent...' }).first()
|
||||
await composer.fill(prompt)
|
||||
await composer.press('Enter')
|
||||
|
||||
await expect(page.getByText(prompt, { exact: true }).first()).toBeVisible({ timeout: 5_000 })
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 })
|
||||
|
||||
await expect(modelSelector).toContainText(new RegExp(escapeRegex(selectedModel), 'i'))
|
||||
await expect(modeSelector).toContainText(new RegExp(escapeRegex(selectedMode), 'i'))
|
||||
await expect(thinkingSelector).toContainText(new RegExp(escapeRegex(selectedThinking), 'i'))
|
||||
} finally {
|
||||
await repo.cleanup()
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,219 @@
|
||||
import { test, expect } from './fixtures'
|
||||
import type { Page } from '@playwright/test'
|
||||
import { ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app'
|
||||
import { createTempGitRepo } from './helpers/workspace'
|
||||
import { getWorkspaceTabTestIds } from './helpers/workspace-tabs'
|
||||
|
||||
function parseWorkspaceContextFromUrl(rawUrl: string): { workspaceToken: string | null; agentId: string | null } {
|
||||
const url = new URL(rawUrl)
|
||||
const pathMatch = url.pathname.match(/\/workspace\/([^/?#]+)(?:\/agent\/([^/?#]+))?/) ?? null
|
||||
const workspaceToken = pathMatch?.[1] ?? null
|
||||
let agentId = pathMatch?.[2] ?? null
|
||||
|
||||
if (!agentId) {
|
||||
const open = url.searchParams.get('open')
|
||||
const openMatch = open?.match(/^agent:(.+)$/) ?? null
|
||||
if (openMatch?.[1]) {
|
||||
agentId = openMatch[1]
|
||||
}
|
||||
}
|
||||
|
||||
return { workspaceToken, agentId }
|
||||
}
|
||||
|
||||
function getOpenAgentIdFromUrl(rawUrl: string): string | null {
|
||||
const url = new URL(rawUrl)
|
||||
const open = url.searchParams.get('open')
|
||||
const openMatch = open?.match(/^agent:(.+)$/) ?? null
|
||||
return openMatch?.[1] ?? null
|
||||
}
|
||||
|
||||
async function preferSlowerThinkingOption(page: Page): Promise<void> {
|
||||
await page.keyboard.press('Escape').catch(() => undefined)
|
||||
const thinkingTrigger = page.getByTestId('agent-thinking-selector').first()
|
||||
if (!(await thinkingTrigger.isVisible().catch(() => false))) {
|
||||
return
|
||||
}
|
||||
await thinkingTrigger.click({ force: true })
|
||||
const menu = page.getByTestId('agent-thinking-menu').first()
|
||||
if (!(await menu.isVisible().catch(() => false))) {
|
||||
return
|
||||
}
|
||||
|
||||
const preferred = ['high', 'max', 'deep', 'long', 'medium']
|
||||
for (const label of preferred) {
|
||||
const option = menu.getByRole('button', { name: new RegExp(`^${label}$`, 'i') }).first()
|
||||
if (await option.isVisible().catch(() => false)) {
|
||||
await option.click({ force: true })
|
||||
await expect(menu).not.toBeVisible({ timeout: 5_000 })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const options = menu.getByRole('button')
|
||||
const count = await options.count()
|
||||
if (count > 0) {
|
||||
await options.last().click({ force: true })
|
||||
}
|
||||
await expect(menu).not.toBeVisible({ timeout: 5_000 })
|
||||
}
|
||||
|
||||
async function isAnyStopVisible(page: Page): Promise<boolean> {
|
||||
const candidates = page.getByRole('button', { name: /stop|cancel/i })
|
||||
const count = await candidates.count()
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
if (await candidates.nth(index).isVisible().catch(() => false)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function isRunningControlVisible(page: Page): Promise<boolean> {
|
||||
if (await isAnyStopVisible(page)) {
|
||||
return true
|
||||
}
|
||||
const interruptSendButton = page.getByRole('button', { name: /interrupt agent|send and interrupt/i })
|
||||
const count = await interruptSendButton.count()
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
if (await interruptSendButton.nth(index).isVisible().catch(() => false)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function hasVisibleText(page: Page, matcher: RegExp | string): Promise<boolean> {
|
||||
const locator = page.getByText(matcher).first()
|
||||
const matches = page.getByText(matcher)
|
||||
const count = await matches.count()
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
if (await matches.nth(index).isVisible().catch(() => false)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return await locator.isVisible().catch(() => false)
|
||||
}
|
||||
|
||||
test('global draft create in existing workspace redirects to that workspace with created agent tab and settles to idle', async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(360_000)
|
||||
const serverId = process.env.E2E_SERVER_ID
|
||||
if (!serverId) {
|
||||
throw new Error('E2E_SERVER_ID is not set.')
|
||||
}
|
||||
|
||||
const repo = await createTempGitRepo('paseo-e2e-global-existing-workspace-')
|
||||
const id = `${Date.now()}`
|
||||
const seedPrompt = `hello seed-${id}. please reply exactly: ACK_SEED_${id}`
|
||||
const secondPrompt = `hello second-${id}. please reply exactly: ACK_SECOND_${id}`
|
||||
const lifecyclePrompt = `hello lifecycle-${id}. wait 8 seconds, then reply exactly: ACK_LIFECYCLE_${id}`
|
||||
const seedToken = `ACK_SEED_${id}`
|
||||
const secondToken = `ACK_SECOND_${id}`
|
||||
expect(seedPrompt).not.toBe(secondPrompt)
|
||||
expect(seedToken).not.toBe(secondToken)
|
||||
|
||||
try {
|
||||
await gotoHome(page)
|
||||
await ensureHostSelected(page)
|
||||
await page.goto(`/h/${serverId}/new-agent`)
|
||||
await expect(page.locator('[data-testid="working-directory-select"]:visible').first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
})
|
||||
|
||||
// 1) Seed workspace with an existing agent.
|
||||
await setWorkingDirectory(page, repo.path)
|
||||
const seedComposer = page.getByRole('textbox', { name: 'Message agent...' }).first()
|
||||
await seedComposer.fill(seedPrompt)
|
||||
await seedComposer.press('Enter')
|
||||
|
||||
await expect(page.getByText(seedPrompt, { exact: true }).first()).toBeVisible({ timeout: 30_000 })
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 60_000 })
|
||||
const seededContext = parseWorkspaceContextFromUrl(page.url())
|
||||
if (!seededContext.workspaceToken || !seededContext.agentId) {
|
||||
throw new Error(`Expected seeded workspace token and agent id in URL, got: ${page.url()}`)
|
||||
}
|
||||
const seededAgentId = seededContext.agentId
|
||||
expect(getOpenAgentIdFromUrl(page.url())).toBe(seededAgentId)
|
||||
await expect(page.getByText(new RegExp(seedToken)).first()).toBeVisible({ timeout: 180_000 })
|
||||
|
||||
// 2) Use global New Agent entry and 3) select same workspace.
|
||||
await page.getByText('New agent', { exact: true }).first().click()
|
||||
await expect(page).toHaveURL(new RegExp(`/h/${serverId}/new-agent`), { timeout: 30_000 })
|
||||
await expect(page.locator('[data-testid="working-directory-select"]:visible').first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
})
|
||||
|
||||
await setWorkingDirectory(page, repo.path)
|
||||
await preferSlowerThinkingOption(page)
|
||||
await page.keyboard.press('Escape').catch(() => undefined)
|
||||
|
||||
// 4) Create second agent from global draft.
|
||||
const composer = page.getByRole('textbox', { name: 'Message agent...' }).first()
|
||||
await composer.fill(secondPrompt)
|
||||
await composer.press('Enter')
|
||||
await page.waitForTimeout(500)
|
||||
if (page.url().includes('/new-agent')) {
|
||||
const sendButton = page.getByRole('button', { name: /send message/i }).first()
|
||||
await expect(sendButton).toBeVisible({ timeout: 10_000 })
|
||||
await expect(sendButton).toBeEnabled({ timeout: 10_000 })
|
||||
await sendButton.click({ force: true })
|
||||
}
|
||||
|
||||
// 5) Assert redirect workspace context + created agent tab is active/open.
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 60_000 })
|
||||
const createdContext = parseWorkspaceContextFromUrl(page.url())
|
||||
expect(createdContext.workspaceToken).toBe(seededContext.workspaceToken)
|
||||
if (!createdContext.agentId) {
|
||||
throw new Error(`Expected created agent id in URL, got: ${page.url()}`)
|
||||
}
|
||||
const createdAgentId = createdContext.agentId
|
||||
expect(createdAgentId).toBeTruthy()
|
||||
expect(createdAgentId).not.toBe(seededAgentId)
|
||||
expect(getOpenAgentIdFromUrl(page.url())).toBe(createdAgentId)
|
||||
expect(getOpenAgentIdFromUrl(page.url())).not.toBe(seededAgentId)
|
||||
|
||||
const createdTabTestId = `workspace-tab-agent_${createdAgentId}`
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const finalTabIds = await getWorkspaceTabTestIds(page)
|
||||
return finalTabIds.includes(createdTabTestId)
|
||||
})
|
||||
.toBe(true)
|
||||
expect(getOpenAgentIdFromUrl(page.url())).toBe(createdAgentId)
|
||||
|
||||
// 6) Response assertions are scoped to created-agent active context.
|
||||
await expect
|
||||
.poll(async () => hasVisibleText(page, new RegExp(secondToken)), { timeout: 240_000 })
|
||||
.toBe(true)
|
||||
await expect(page.getByText(seedToken, { exact: true }).first()).not.toBeVisible({ timeout: 30_000 })
|
||||
|
||||
// 7) Lifecycle assertions on created agent only: running first, then idle.
|
||||
const lifecycleComposer = page.getByRole('textbox', { name: 'Message agent...' }).first()
|
||||
await lifecycleComposer.fill(lifecyclePrompt)
|
||||
await lifecycleComposer.press('Enter')
|
||||
await expect
|
||||
.poll(async () => hasVisibleText(page, lifecyclePrompt), { timeout: 30_000 })
|
||||
.toBe(true)
|
||||
|
||||
let sawRunning = false
|
||||
for (let attempt = 0; attempt < 1200; attempt += 1) {
|
||||
if (await isRunningControlVisible(page)) {
|
||||
sawRunning = true
|
||||
break
|
||||
}
|
||||
await page.waitForTimeout(50)
|
||||
}
|
||||
expect(sawRunning).toBe(true)
|
||||
|
||||
await expect
|
||||
.poll(async () => (await isRunningControlVisible(page)) === false, { timeout: 240_000 })
|
||||
.toBe(true)
|
||||
const idleComposer = page.getByRole('textbox', { name: 'Message agent...' }).first()
|
||||
await expect(idleComposer).toBeVisible({ timeout: 30_000 })
|
||||
await expect(idleComposer).toBeEditable({ timeout: 30_000 })
|
||||
} finally {
|
||||
await repo.cleanup()
|
||||
}
|
||||
})
|
||||
@@ -7,6 +7,14 @@ import net from 'node:net';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
type WaitForServerOptions = {
|
||||
host?: string;
|
||||
timeoutMs?: number;
|
||||
label: string;
|
||||
childProcess?: ChildProcess | null;
|
||||
getRecentOutput?: () => string;
|
||||
};
|
||||
|
||||
async function getAvailablePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
@@ -22,23 +30,157 @@ async function getAvailablePort(): Promise<number> {
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForServer(port: number, timeout = 15000): Promise<void> {
|
||||
function createLineBuffer(maxLines = 120): { add: (line: string) => void; dump: () => string } {
|
||||
const lines: string[] = [];
|
||||
return {
|
||||
add(line: string) {
|
||||
lines.push(line);
|
||||
if (lines.length > maxLines) {
|
||||
lines.shift();
|
||||
}
|
||||
},
|
||||
dump() {
|
||||
return lines.join('\n');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function formatRecentOutput(getRecentOutput?: () => string): string {
|
||||
if (!getRecentOutput) {
|
||||
return '';
|
||||
}
|
||||
const output = getRecentOutput().trim();
|
||||
if (!output) {
|
||||
return '';
|
||||
}
|
||||
return `\nRecent output:\n${output}`;
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function waitForServer(port: number, options: WaitForServerOptions): Promise<void> {
|
||||
const {
|
||||
host = '127.0.0.1',
|
||||
timeoutMs = 15000,
|
||||
label,
|
||||
childProcess,
|
||||
getRecentOutput,
|
||||
} = options;
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeout) {
|
||||
let lastConnectionError: unknown = null;
|
||||
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (childProcess && childProcess.exitCode !== null) {
|
||||
const signal = childProcess.signalCode ? `, signal ${childProcess.signalCode}` : '';
|
||||
throw new Error(
|
||||
`${label} exited before listening on ${host}:${port} (exit code ${childProcess.exitCode}${signal}).${formatRecentOutput(getRecentOutput)}`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket = net.connect(port, 'localhost', () => {
|
||||
const socket = net.connect(port, host, () => {
|
||||
socket.end();
|
||||
resolve();
|
||||
});
|
||||
socket.setTimeout(1000, () => {
|
||||
socket.destroy();
|
||||
reject(new Error(`Connection timed out to ${host}:${port}`));
|
||||
});
|
||||
socket.on('error', reject);
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
} catch (error) {
|
||||
lastConnectionError = error;
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
}
|
||||
throw new Error(`Server did not start on port ${port} within ${timeout}ms`);
|
||||
|
||||
const reason =
|
||||
lastConnectionError instanceof Error ? ` Last connection error: ${lastConnectionError.message}` : '';
|
||||
throw new Error(
|
||||
`${label} did not start on ${host}:${port} within ${timeoutMs}ms.${reason}${formatRecentOutput(getRecentOutput)}`
|
||||
);
|
||||
}
|
||||
|
||||
function parseRelayStartupFailure(line: string): string | null {
|
||||
const clean = stripAnsi(line);
|
||||
if (/Address already in use/i.test(clean)) {
|
||||
return clean;
|
||||
}
|
||||
if (/failed: ::bind\(/i.test(clean)) {
|
||||
return clean;
|
||||
}
|
||||
if (/Fatal uncaught/i.test(clean)) {
|
||||
return clean;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function stopProcess(child: ChildProcess | null): Promise<void> {
|
||||
if (!child) {
|
||||
return;
|
||||
}
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
return;
|
||||
}
|
||||
child.kill('SIGTERM');
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
child.kill('SIGKILL');
|
||||
}
|
||||
resolve();
|
||||
}, 5000);
|
||||
child.once('exit', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function summarizeOpenAiErrorBody(body: string): string {
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed) {
|
||||
return 'empty response body';
|
||||
}
|
||||
if (trimmed.length <= 240) {
|
||||
return trimmed;
|
||||
}
|
||||
return `${trimmed.slice(0, 240)}…`;
|
||||
}
|
||||
|
||||
async function isOpenAiApiKeyUsable(apiKey: string | undefined): Promise<boolean> {
|
||||
const key = apiKey?.trim();
|
||||
if (!key) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('https://api.openai.com/v1/models?limit=1', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`,
|
||||
},
|
||||
});
|
||||
if (response.ok) {
|
||||
return true;
|
||||
}
|
||||
const body = await response.text();
|
||||
console.warn(
|
||||
`[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;
|
||||
}
|
||||
}
|
||||
|
||||
let daemonProcess: ChildProcess | null = null;
|
||||
@@ -81,165 +223,293 @@ export default async function globalSetup() {
|
||||
}
|
||||
|
||||
const port = await getAvailablePort();
|
||||
const relayPort = await getAvailablePort();
|
||||
let relayPort = 0;
|
||||
const metroPort = await getAvailablePort();
|
||||
paseoHome = await mkdtemp(path.join(tmpdir(), 'paseo-e2e-home-'));
|
||||
let relayLineBuffer = createLineBuffer();
|
||||
const metroLineBuffer = createLineBuffer();
|
||||
const daemonLineBuffer = createLineBuffer();
|
||||
|
||||
const relayDir = path.resolve(__dirname, '..', '..', 'relay');
|
||||
relayProcess = spawn(
|
||||
'npx',
|
||||
['wrangler', 'dev', '--local', '--ip', '127.0.0.1', '--port', String(relayPort)],
|
||||
{
|
||||
cwd: relayDir,
|
||||
env: { ...process.env },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: false,
|
||||
}
|
||||
);
|
||||
|
||||
relayProcess.stdout?.on('data', (data: Buffer) => {
|
||||
const lines = data.toString().split('\n').filter((l) => l.trim());
|
||||
for (const line of lines) {
|
||||
console.log(`[relay] ${line}`);
|
||||
}
|
||||
});
|
||||
relayProcess.stderr?.on('data', (data: Buffer) => {
|
||||
const lines = data.toString().split('\n').filter((l) => l.trim());
|
||||
for (const line of lines) {
|
||||
console.error(`[relay] ${line}`);
|
||||
}
|
||||
});
|
||||
|
||||
await waitForServer(relayPort, 30000);
|
||||
|
||||
// Start Metro bundler on dynamic port
|
||||
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
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: false,
|
||||
});
|
||||
|
||||
metroProcess.stdout?.on('data', (data: Buffer) => {
|
||||
const lines = data.toString().split('\n').filter((l) => l.trim());
|
||||
for (const line of lines) {
|
||||
console.log(`[metro] ${line}`);
|
||||
}
|
||||
});
|
||||
|
||||
metroProcess.stderr?.on('data', (data: Buffer) => {
|
||||
console.error(`[metro] ${data.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;
|
||||
const offerPromise = new Promise<void>((resolve) => {
|
||||
offerResolve = resolve;
|
||||
});
|
||||
|
||||
daemonProcess = spawn(tsxBin, ['src/server/index.ts'], {
|
||||
cwd: serverDir,
|
||||
env: {
|
||||
...process.env,
|
||||
PASEO_HOME: paseoHome,
|
||||
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}`,
|
||||
// Keep e2e bootstrap fast and deterministic; terminal/sidebar tests do not need speech.
|
||||
PASEO_DICTATION_ENABLED: "0",
|
||||
PASEO_VOICE_MODE_ENABLED: "0",
|
||||
NODE_ENV: 'development',
|
||||
},
|
||||
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() ?? '';
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
if (!offerPayload) {
|
||||
const clean = stripAnsi(trimmed);
|
||||
try {
|
||||
const obj = JSON.parse(clean) as { msg?: string; 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')) {
|
||||
try {
|
||||
offerPayload = decodeOfferFromFragmentUrl(match[0]);
|
||||
offerResolve?.();
|
||||
} catch {
|
||||
// ignore parsing failures
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(`[daemon] ${trimmed}`);
|
||||
}
|
||||
});
|
||||
|
||||
daemonProcess.stderr?.on('data', (data: Buffer) => {
|
||||
console.error(`[daemon] ${data.toString().trim()}`);
|
||||
});
|
||||
|
||||
// Wait for both daemon and Metro to be ready
|
||||
await Promise.all([
|
||||
waitForServer(port),
|
||||
waitForServer(metroPort, 120000), // Metro can take longer to start
|
||||
]);
|
||||
|
||||
// Wait for daemon to emit a pairing offer (includes relay session ID).
|
||||
await Promise.race([
|
||||
offerPromise,
|
||||
new Promise((_, reject) =>
|
||||
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');
|
||||
}
|
||||
const offer = offerPayload as OfferPayload;
|
||||
|
||||
process.env.E2E_DAEMON_PORT = String(port);
|
||||
process.env.E2E_RELAY_PORT = String(relayPort);
|
||||
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}`);
|
||||
|
||||
return async () => {
|
||||
if (daemonProcess) {
|
||||
daemonProcess.kill('SIGTERM');
|
||||
daemonProcess = null;
|
||||
}
|
||||
if (metroProcess) {
|
||||
metroProcess.kill('SIGTERM');
|
||||
metroProcess = null;
|
||||
}
|
||||
if (relayProcess) {
|
||||
relayProcess.kill('SIGTERM');
|
||||
relayProcess = null;
|
||||
}
|
||||
const cleanup = async () => {
|
||||
await Promise.all([stopProcess(daemonProcess), stopProcess(metroProcess), stopProcess(relayProcess)]);
|
||||
daemonProcess = null;
|
||||
metroProcess = null;
|
||||
relayProcess = null;
|
||||
if (paseoHome) {
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
paseoHome = null;
|
||||
}
|
||||
console.log('[e2e] Test daemon stopped');
|
||||
};
|
||||
|
||||
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';
|
||||
|
||||
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.'
|
||||
);
|
||||
}
|
||||
|
||||
const localModelsDir = dictationProvider === 'local' ? defaultLocalModelsDir : null;
|
||||
console.log(
|
||||
`[e2e] Dictation STT provider: ${dictationProvider}${openAiUsable ? '' : ' (OpenAI probe failed)'}`
|
||||
);
|
||||
|
||||
try {
|
||||
const relayDir = path.resolve(__dirname, '..', '..', 'relay');
|
||||
const maxRelayStartupAttempts = 5;
|
||||
let relayStarted = false;
|
||||
let lastRelayStartupError: unknown = null;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRelayStartupAttempts; attempt += 1) {
|
||||
relayPort = await getAvailablePort();
|
||||
relayLineBuffer = createLineBuffer();
|
||||
let relayStartupFailureLine: string | null = null;
|
||||
let relayReadyForSelectedPort = false;
|
||||
|
||||
relayProcess = spawn(
|
||||
'npx',
|
||||
['wrangler', 'dev', '--local', '--ip', '127.0.0.1', '--port', String(relayPort)],
|
||||
{
|
||||
cwd: relayDir,
|
||||
env: { ...process.env },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: false,
|
||||
}
|
||||
);
|
||||
|
||||
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);
|
||||
if (failure) {
|
||||
relayStartupFailureLine = failure;
|
||||
}
|
||||
const clean = stripAnsi(line);
|
||||
const readyMatch = clean.match(/Ready on .*:(\d+)\b/i);
|
||||
if (readyMatch && Number(readyMatch[1]) === relayPort) {
|
||||
relayReadyForSelectedPort = true;
|
||||
}
|
||||
console.log(`[relay] ${line}`);
|
||||
}
|
||||
});
|
||||
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);
|
||||
if (failure) {
|
||||
relayStartupFailureLine = failure;
|
||||
}
|
||||
const clean = stripAnsi(line);
|
||||
const readyMatch = clean.match(/Ready on .*:(\d+)\b/i);
|
||||
if (readyMatch && Number(readyMatch[1]) === relayPort) {
|
||||
relayReadyForSelectedPort = true;
|
||||
}
|
||||
console.error(`[relay] ${line}`);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await waitForServer(relayPort, {
|
||||
label: 'Relay dev server',
|
||||
timeoutMs: 30000,
|
||||
childProcess: relayProcess,
|
||||
getRecentOutput: relayLineBuffer.dump,
|
||||
});
|
||||
|
||||
const readyDeadline = Date.now() + 5000;
|
||||
while (
|
||||
!relayReadyForSelectedPort &&
|
||||
relayStartupFailureLine === null &&
|
||||
relayProcess?.exitCode === null &&
|
||||
relayProcess?.signalCode === null &&
|
||||
Date.now() < readyDeadline
|
||||
) {
|
||||
await sleep(100);
|
||||
}
|
||||
|
||||
if (relayStartupFailureLine) {
|
||||
throw new Error(`Relay startup failed: ${relayStartupFailureLine}`);
|
||||
}
|
||||
if (!relayReadyForSelectedPort) {
|
||||
throw new Error(
|
||||
`Relay process did not report ready for selected port ${relayPort}.${formatRecentOutput(
|
||||
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
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
relayStarted = true;
|
||||
break;
|
||||
} catch (error) {
|
||||
lastRelayStartupError = error;
|
||||
await stopProcess(relayProcess);
|
||||
relayProcess = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!relayStarted) {
|
||||
const message =
|
||||
lastRelayStartupError instanceof Error
|
||||
? lastRelayStartupError.message
|
||||
: String(lastRelayStartupError);
|
||||
throw new Error(
|
||||
`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)], {
|
||||
cwd: appDir,
|
||||
env: {
|
||||
...process.env,
|
||||
BROWSER: 'none', // Don't auto-open browser
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: false,
|
||||
});
|
||||
|
||||
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());
|
||||
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();
|
||||
|
||||
let offerPayload: OfferPayload | null = null;
|
||||
let offerResolve: (() => void) | null = null;
|
||||
const offerPromise = new Promise<void>((resolve) => {
|
||||
offerResolve = resolve;
|
||||
});
|
||||
|
||||
daemonProcess = spawn(tsxBin, ['src/server/index.ts'], {
|
||||
cwd: serverDir,
|
||||
env: {
|
||||
...process.env,
|
||||
PASEO_HOME: paseoHome,
|
||||
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_STT_PROVIDER: dictationProvider,
|
||||
PASEO_VOICE_STT_PROVIDER: 'openai',
|
||||
PASEO_VOICE_TTS_PROVIDER: 'openai',
|
||||
...(localModelsDir ? { PASEO_LOCAL_MODELS_DIR: localModelsDir } : {}),
|
||||
NODE_ENV: 'development',
|
||||
},
|
||||
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() ?? '';
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
daemonLineBuffer.add(`[stdout] ${trimmed}`);
|
||||
if (!offerPayload) {
|
||||
const clean = stripAnsi(trimmed);
|
||||
try {
|
||||
const obj = JSON.parse(clean) as { msg?: string; 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')) {
|
||||
try {
|
||||
offerPayload = decodeOfferFromFragmentUrl(match[0]);
|
||||
offerResolve?.();
|
||||
} catch {
|
||||
// ignore parsing failures
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(`[daemon] ${trimmed}`);
|
||||
}
|
||||
});
|
||||
|
||||
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}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for both daemon and Metro to be ready
|
||||
await Promise.all([
|
||||
waitForServer(port, {
|
||||
label: 'Paseo daemon',
|
||||
childProcess: daemonProcess,
|
||||
getRecentOutput: daemonLineBuffer.dump,
|
||||
}),
|
||||
waitForServer(metroPort, {
|
||||
label: 'Metro web server',
|
||||
timeoutMs: 120000, // Metro can take longer to start
|
||||
childProcess: metroProcess,
|
||||
getRecentOutput: metroLineBuffer.dump,
|
||||
}),
|
||||
]);
|
||||
|
||||
// Wait for daemon to emit a pairing offer (includes relay session ID).
|
||||
await Promise.race([
|
||||
offerPromise,
|
||||
new Promise((_, reject) =>
|
||||
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');
|
||||
}
|
||||
const offer = offerPayload as OfferPayload;
|
||||
|
||||
process.env.E2E_DAEMON_PORT = String(port);
|
||||
process.env.E2E_RELAY_PORT = String(relayPort);
|
||||
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}`);
|
||||
|
||||
return async () => {
|
||||
await cleanup();
|
||||
console.log('[e2e] Test daemon stopped');
|
||||
};
|
||||
} catch (error) {
|
||||
await cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
273
packages/app/e2e/helpers/agent-bottom-anchor.ts
Normal file
273
packages/app/e2e/helpers/agent-bottom-anchor.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
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";
|
||||
|
||||
const NEAR_BOTTOM_THRESHOLD_PX = 72;
|
||||
|
||||
export type ScrollMetrics = {
|
||||
offsetY: number;
|
||||
contentHeight: number;
|
||||
viewportHeight: number;
|
||||
distanceFromBottom: number;
|
||||
};
|
||||
|
||||
export type SeededAgent = {
|
||||
id: string;
|
||||
title: string;
|
||||
expectedTailText: string;
|
||||
url: string;
|
||||
workspaceUrl: string;
|
||||
};
|
||||
|
||||
export type DaemonClientInstance = {
|
||||
connect(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
createAgent(options: {
|
||||
provider: string;
|
||||
model: string;
|
||||
thinkingOptionId: string;
|
||||
modeId: string;
|
||||
cwd: string;
|
||||
title: string;
|
||||
initialPrompt: string;
|
||||
}): Promise<{ id: string }>;
|
||||
sendAgentMessage(agentId: string, text: string): Promise<void>;
|
||||
waitForFinish(
|
||||
agentId: string,
|
||||
timeout?: number
|
||||
): Promise<{ status: string }>;
|
||||
};
|
||||
|
||||
function getDaemonWsUrl(): string {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
if (!daemonPort) {
|
||||
throw new Error("E2E_DAEMON_PORT is not set.");
|
||||
}
|
||||
return `ws://127.0.0.1:${daemonPort}/ws`;
|
||||
}
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
function buildReplyBlock(label: string, lineCount = 14): string {
|
||||
return Array.from({ length: lineCount }, (_, index) => {
|
||||
const line = (index + 1).toString().padStart(2, "0");
|
||||
return `${label} line ${line} anchor verification text keeps wrapping stable across resize and composer growth.`;
|
||||
}).join("\n");
|
||||
}
|
||||
|
||||
function buildProtocolMessage(label: string): string {
|
||||
return [
|
||||
"For every message in this chat, reply with exactly the text after the final line `REPLY:`.",
|
||||
"Do not add extra words, bullets, markdown fences, or tool calls.",
|
||||
"REPLY:",
|
||||
buildReplyBlock(label),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildReplyMessage(label: string): string {
|
||||
return ["REPLY:", buildReplyBlock(label)].join("\n");
|
||||
}
|
||||
|
||||
export function createReplyTurn(label: string): {
|
||||
message: string;
|
||||
expectedReply: string;
|
||||
} {
|
||||
return {
|
||||
message: buildReplyMessage(label),
|
||||
expectedReply: buildReplyBlock(label),
|
||||
};
|
||||
}
|
||||
|
||||
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")
|
||||
).href;
|
||||
const mod = (await import(moduleUrl)) as {
|
||||
DaemonClient: new (config: {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
}) => DaemonClientInstance;
|
||||
};
|
||||
return mod.DaemonClient;
|
||||
}
|
||||
|
||||
export async function connectDaemonClient(): Promise<DaemonClientInstance> {
|
||||
const DaemonClient = await loadDaemonClientConstructor();
|
||||
const client = new DaemonClient({
|
||||
url: getDaemonWsUrl(),
|
||||
clientId: `app-e2e-${randomUUID()}`,
|
||||
clientType: "cli",
|
||||
});
|
||||
await client.connect();
|
||||
return client;
|
||||
}
|
||||
|
||||
export async function seedBottomAnchorAgent(input: {
|
||||
client: DaemonClientInstance;
|
||||
cwd: string;
|
||||
title?: string;
|
||||
turnCount?: number;
|
||||
}): Promise<SeededAgent> {
|
||||
const title = input.title ?? `bottom-anchor-${Date.now()}`;
|
||||
const turnCount = Math.max(3, input.turnCount ?? 5);
|
||||
const created = await input.client.createAgent({
|
||||
provider: "codex",
|
||||
model: "gpt-5.1-codex-mini",
|
||||
thinkingOptionId: "low",
|
||||
modeId: "full-access",
|
||||
cwd: input.cwd,
|
||||
title,
|
||||
initialPrompt: buildProtocolMessage(`${title}-turn-00`),
|
||||
});
|
||||
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}.`
|
||||
);
|
||||
}
|
||||
|
||||
let expectedTailText = buildReplyBlock(`${title}-turn-00`);
|
||||
for (let index = 1; index < turnCount; index += 1) {
|
||||
const label = `${title}-turn-${index.toString().padStart(2, "0")}`;
|
||||
expectedTailText = buildReplyBlock(label);
|
||||
await input.client.sendAgentMessage(created.id, buildReplyMessage(label));
|
||||
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}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: created.id,
|
||||
title,
|
||||
expectedTailText,
|
||||
url: buildHostWorkspaceAgentRoute(getServerId(), input.cwd, created.id),
|
||||
workspaceUrl: buildHostWorkspaceRoute(getServerId(), input.cwd),
|
||||
};
|
||||
}
|
||||
|
||||
export async function readScrollMetrics(page: Page): Promise<ScrollMetrics> {
|
||||
return page.getByTestId("agent-chat-scroll").evaluate((root: Element) => {
|
||||
const rootElement = root as HTMLElement;
|
||||
const candidates = [rootElement, ...Array.from(rootElement.querySelectorAll("*"))];
|
||||
const scrollElement =
|
||||
candidates.find(
|
||||
(element) =>
|
||||
element instanceof HTMLElement &&
|
||||
element.scrollHeight - element.clientHeight > 1
|
||||
) ?? rootElement;
|
||||
|
||||
const offsetY = Math.max(0, scrollElement.scrollTop);
|
||||
const contentHeight = Math.max(0, scrollElement.scrollHeight);
|
||||
const viewportHeight = Math.max(0, scrollElement.clientHeight);
|
||||
const distanceFromBottom = Math.max(
|
||||
0,
|
||||
contentHeight - (offsetY + viewportHeight)
|
||||
);
|
||||
|
||||
return {
|
||||
offsetY,
|
||||
contentHeight,
|
||||
viewportHeight,
|
||||
distanceFromBottom,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function scrollUpFromBottom(page: Page, pixels: number): Promise<void> {
|
||||
await page.getByTestId("agent-chat-scroll").evaluate(
|
||||
(root: Element, amount: number) => {
|
||||
const rootElement = root as HTMLElement;
|
||||
const candidates = [rootElement, ...Array.from(rootElement.querySelectorAll("*"))];
|
||||
const scrollElement =
|
||||
candidates.find(
|
||||
(element) =>
|
||||
element instanceof HTMLElement &&
|
||||
element.scrollHeight - element.clientHeight > 1
|
||||
) ?? rootElement;
|
||||
|
||||
const bottomOffset = Math.max(
|
||||
0,
|
||||
scrollElement.scrollHeight - scrollElement.clientHeight
|
||||
);
|
||||
scrollElement.scrollTop = Math.max(0, bottomOffset - amount);
|
||||
},
|
||||
pixels
|
||||
);
|
||||
}
|
||||
|
||||
export async function waitForAgentReady(page: Page, expectedTailText?: string): Promise<void> {
|
||||
await expect(page.getByTestId("agent-chat-scroll")).toBeVisible({ timeout: 60000 });
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
|
||||
timeout: 60000,
|
||||
});
|
||||
await expect(page.getByTestId("agent-loading")).toHaveCount(0, { timeout: 60000 });
|
||||
if (expectedTailText) {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const metrics = await readScrollMetrics(page);
|
||||
return metrics.contentHeight;
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectNearBottom(page: Page): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const metrics = await readScrollMetrics(page);
|
||||
return metrics.distanceFromBottom;
|
||||
})
|
||||
.toBeLessThanOrEqual(NEAR_BOTTOM_THRESHOLD_PX);
|
||||
}
|
||||
|
||||
export async function expectDetachedFromBottom(page: Page): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const metrics = await readScrollMetrics(page);
|
||||
return metrics.distanceFromBottom;
|
||||
})
|
||||
.toBeGreaterThan(NEAR_BOTTOM_THRESHOLD_PX);
|
||||
}
|
||||
|
||||
export async function waitForContentGrowth(
|
||||
page: Page,
|
||||
previousContentHeight: number
|
||||
): Promise<ScrollMetrics> {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const metrics = await readScrollMetrics(page);
|
||||
return metrics.contentHeight;
|
||||
})
|
||||
.toBeGreaterThan(previousContentHeight);
|
||||
return readScrollMetrics(page);
|
||||
}
|
||||
|
||||
export async function getChatContainerKey(page: Page): Promise<string | null> {
|
||||
return page
|
||||
.getByTestId("agent-chat-scroll")
|
||||
.evaluate((element) => {
|
||||
const nativeId = (element as HTMLElement).id;
|
||||
const prefix = "agent-chat-scroll-";
|
||||
return nativeId.startsWith(prefix) ? nativeId.slice(prefix.length) : null;
|
||||
});
|
||||
}
|
||||
@@ -71,10 +71,10 @@ async function ensureE2EStorageSeeded(page: Page): Promise<void> {
|
||||
'@paseo:create-agent-preferences',
|
||||
JSON.stringify({
|
||||
serverId: expectedServerId,
|
||||
provider: 'claude',
|
||||
provider: 'codex',
|
||||
providerPreferences: {
|
||||
claude: { model: 'haiku' },
|
||||
codex: { model: 'gpt-5.1-codex-mini' },
|
||||
codex: { model: 'gpt-5.1-codex-mini', thinkingOptionId: 'low' },
|
||||
},
|
||||
})
|
||||
);
|
||||
@@ -158,7 +158,12 @@ export const gotoHome = async (page: Page) => {
|
||||
await page.goto('/');
|
||||
await ensureE2EStorageSeeded(page);
|
||||
await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeVisible();
|
||||
const composer = page.getByRole('textbox', { name: 'Message agent...' });
|
||||
if (!(await composer.first().isVisible().catch(() => false))) {
|
||||
const newAgentButton = page.getByText('New agent', { exact: true }).first();
|
||||
await newAgentButton.click();
|
||||
}
|
||||
await expect(composer.first()).toBeVisible({ timeout: 30000 });
|
||||
};
|
||||
|
||||
export const openSettings = async (page: Page) => {
|
||||
@@ -183,7 +188,7 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
|
||||
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 });
|
||||
const worktreeSheetTitle = page.getByText('Select worktree', { exact: true }).first();
|
||||
const closeBottomSheet = async () => {
|
||||
const bottomSheetBackdrop = page
|
||||
.getByRole('button', { name: 'Bottom sheet backdrop' })
|
||||
@@ -362,16 +367,86 @@ export const ensureHostSelected = async (page: Page) => {
|
||||
export const createAgent = async (page: Page, message: string) => {
|
||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
||||
await expect(input).toBeEditable();
|
||||
await preferFastThinkingOption(page);
|
||||
await input.fill(message);
|
||||
await input.press('Enter');
|
||||
|
||||
// Expo Router navigations can be "same-document" updates, so avoid waiting for a full `load`.
|
||||
await page.waitForURL(/\/agent\//, { waitUntil: 'commit' });
|
||||
// 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.
|
||||
await expect(page).toHaveURL(/\/(workspace|agent|new-agent)(\/|$|\?)/, { timeout: 30000 });
|
||||
await expect(page.getByText(message, { exact: true }).first()).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
};
|
||||
|
||||
async function preferFastThinkingOption(page: Page): Promise<void> {
|
||||
const providerTrigger = page
|
||||
.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();
|
||||
if (!/codex/i.test(providerText)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const thinkingTrigger = page.getByTestId('agent-thinking-selector').first();
|
||||
if (!(await thinkingTrigger.isVisible().catch(() => false))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentThinkingLabel = ((await thinkingTrigger.innerText().catch(() => '')) ?? '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (/\b(low|minimal|off)\b/.test(currentThinkingLabel)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await thinkingTrigger.click();
|
||||
const menu = page.getByTestId('agent-thinking-menu').first();
|
||||
if (!(await menu.isVisible().catch(() => false))) {
|
||||
return;
|
||||
}
|
||||
|
||||
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') })
|
||||
.first();
|
||||
if (await option.isVisible().catch(() => false)) {
|
||||
await option.click({ force: true });
|
||||
selected = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!selected) {
|
||||
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();
|
||||
if (!label) {
|
||||
continue;
|
||||
}
|
||||
if (label.toLowerCase() === currentThinkingLabel) {
|
||||
continue;
|
||||
}
|
||||
await option.click({ force: true });
|
||||
selected = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!selected) {
|
||||
await page.keyboard.press('Escape').catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
await expect(menu).not.toBeVisible({ timeout: 5000 });
|
||||
}
|
||||
|
||||
export interface AgentConfig {
|
||||
directory: string;
|
||||
provider?: string;
|
||||
@@ -381,42 +456,110 @@ export interface AgentConfig {
|
||||
}
|
||||
|
||||
export const selectProvider = async (page: Page, provider: string) => {
|
||||
const providerLabel = page.getByText('PROVIDER', { exact: true }).first();
|
||||
await expect(providerLabel).toBeVisible();
|
||||
await providerLabel.click();
|
||||
const normalizedProvider = provider.trim();
|
||||
if (!normalizedProvider) {
|
||||
throw new Error('Provider must be a non-empty string.');
|
||||
}
|
||||
|
||||
const option = page.getByText(provider, { exact: true }).first();
|
||||
const providerTrigger = page
|
||||
.locator('[data-testid="agent-provider-selector"]:visible, [data-testid="draft-provider-select"]:visible')
|
||||
.first();
|
||||
if (
|
||||
await providerTrigger
|
||||
.getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, 'i'))
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await providerTrigger.isVisible().catch(() => false)) {
|
||||
await providerTrigger.click();
|
||||
} else {
|
||||
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();
|
||||
if (await searchInput.isVisible().catch(() => false)) {
|
||||
await searchInput.fill(normalizedProvider);
|
||||
}
|
||||
|
||||
const option = dialog
|
||||
.getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, 'i'))
|
||||
.first();
|
||||
await expect(option).toBeVisible();
|
||||
await option.click();
|
||||
};
|
||||
|
||||
export const selectModel = async (page: Page, model: string) => {
|
||||
const modelLabel = page.getByText('MODEL', { exact: true }).first();
|
||||
await expect(modelLabel).toBeVisible();
|
||||
await modelLabel.click();
|
||||
const normalizedModel = model.trim();
|
||||
if (!normalizedModel) {
|
||||
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')
|
||||
.first();
|
||||
if (
|
||||
await modelTrigger
|
||||
.getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, 'i'))
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await modelTrigger.isVisible().catch(() => false)) {
|
||||
await modelTrigger.click();
|
||||
} else {
|
||||
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 });
|
||||
await expect(searchInput).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Type to search/filter models
|
||||
await searchInput.fill(model);
|
||||
await searchInput.fill(normalizedModel);
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
const option = dialog
|
||||
.getByText(new RegExp(`^${escapeRegex(model)}$`, 'i'))
|
||||
const exactOption = dialog
|
||||
.getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, 'i'))
|
||||
.first();
|
||||
await expect(option).toBeVisible({ timeout: 30000 });
|
||||
await option.click({ force: true });
|
||||
const exactVisible = await exactOption.isVisible().catch(() => false);
|
||||
if (exactVisible) {
|
||||
await exactOption.click({ force: true });
|
||||
} else {
|
||||
// Modern labels include version suffixes (for example "Haiku 4.5"), so
|
||||
// select the first filtered result using keyboard confirm.
|
||||
await searchInput.press('Enter');
|
||||
}
|
||||
|
||||
// Wait for dropdown to close
|
||||
if (await searchInput.isVisible().catch(() => false)) {
|
||||
await page.keyboard.press('Escape').catch(() => undefined);
|
||||
}
|
||||
await expect(searchInput).not.toBeVisible({ timeout: 5000 });
|
||||
};
|
||||
|
||||
export const selectMode = async (page: Page, mode: string) => {
|
||||
const modeLabel = page.getByText('MODE', { exact: true }).first();
|
||||
await expect(modeLabel).toBeVisible();
|
||||
await modeLabel.click();
|
||||
const modeTrigger = page
|
||||
.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();
|
||||
await expect(modeLabel).toBeVisible();
|
||||
await modeLabel.click();
|
||||
}
|
||||
|
||||
// Wait for the mode dropdown to open
|
||||
const searchInput = page.getByRole('textbox', { name: /search mode/i });
|
||||
@@ -456,6 +599,16 @@ export const createAgentWithConfig = async (page: Page, config: AgentConfig) =>
|
||||
await createAgent(page, config.prompt);
|
||||
};
|
||||
|
||||
export const createAgentInRepo = async (
|
||||
page: Page,
|
||||
config: Pick<AgentConfig, 'directory' | 'prompt'>
|
||||
) => {
|
||||
await gotoHome(page);
|
||||
await ensureHostSelected(page);
|
||||
await setWorkingDirectory(page, config.directory);
|
||||
await createAgent(page, config.prompt);
|
||||
};
|
||||
|
||||
export const waitForPermissionPrompt = async (page: Page, timeout = 30000) => {
|
||||
const promptText = page.getByTestId('permission-request-question').first();
|
||||
await expect(promptText).toBeVisible({ timeout });
|
||||
|
||||
52
packages/app/e2e/helpers/workspace-tabs.ts
Normal file
52
packages/app/e2e/helpers/workspace-tabs.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
export async function getWorkspaceTabTestIds(page: Page): Promise<string[]> {
|
||||
const tabs = page.locator('[data-testid^="workspace-tab-"]');
|
||||
const count = await tabs.count();
|
||||
const ids: string[] = [];
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const testId = await tabs.nth(index).getAttribute("data-testid");
|
||||
if (testId && !ids.includes(testId)) {
|
||||
ids.push(testId);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export async function waitForWorkspaceTabsVisible(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("workspace-tabs-row").first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByTestId("workspace-new-agent-tab").first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function ensureWorkspaceAgentPaneVisible(page: Page): Promise<void> {
|
||||
const toggle = page.getByTestId("workspace-explorer-toggle").first();
|
||||
if (!(await toggle.isVisible().catch(() => false))) {
|
||||
return;
|
||||
}
|
||||
const isExpanded = (await toggle.getAttribute("aria-expanded")) === "true";
|
||||
if (isExpanded) {
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "false", {
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function sampleWorkspaceTabIds(
|
||||
page: Page,
|
||||
options: { durationMs?: number; intervalMs?: number } = {}
|
||||
): Promise<string[][]> {
|
||||
const durationMs = options.durationMs ?? 2_500;
|
||||
const intervalMs = options.intervalMs ?? 50;
|
||||
const snapshots: string[][] = [];
|
||||
const start = Date.now();
|
||||
while (Date.now() - start <= durationMs) {
|
||||
snapshots.push(await getWorkspaceTabTestIds(page));
|
||||
await page.waitForTimeout(intervalMs);
|
||||
}
|
||||
return snapshots;
|
||||
}
|
||||
74
packages/app/e2e/helpers/workspace-ui.ts
Normal file
74
packages/app/e2e/helpers/workspace-ui.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
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);
|
||||
return parts[parts.length - 1] ?? normalized;
|
||||
}
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function candidateWorkspaceIds(inputPath: string): string[] {
|
||||
const trimmed = inputPath.replace(/\/+$/, '');
|
||||
const candidates = new Set<string>([trimmed]);
|
||||
if (trimmed.startsWith('/var/')) {
|
||||
candidates.add(`/private${trimmed}`);
|
||||
}
|
||||
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}"]`
|
||||
);
|
||||
return page.locator(ids.join(',')).first();
|
||||
}
|
||||
|
||||
export async function switchWorkspaceViaSidebar(input: {
|
||||
page: Page;
|
||||
serverId: string;
|
||||
targetWorkspacePath: string;
|
||||
}): Promise<void> {
|
||||
const row = workspaceRowLocator(input.page, input.serverId, input.targetWorkspacePath);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await row.click();
|
||||
|
||||
const targetWorkspaceRoute = buildHostWorkspaceRoute(input.serverId, input.targetWorkspacePath);
|
||||
await expect(input.page).toHaveURL(new RegExp(escapeRegex(targetWorkspaceRoute)), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectWorkspaceHeader(
|
||||
page: Page,
|
||||
input: { title: string; subtitle: string }
|
||||
): Promise<void> {
|
||||
const titleLocator = page.getByTestId('workspace-header-title');
|
||||
const subtitleLocator = page.getByTestId('workspace-header-subtitle');
|
||||
|
||||
await expect(titleLocator.first()).toHaveText(input.title, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(subtitleLocator.first()).toHaveText(input.subtitle, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function seedWorkspaceActivity(page: Page, marker: string): Promise<void> {
|
||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
await input.fill(marker);
|
||||
await input.press('Enter');
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||
}
|
||||
@@ -12,12 +12,15 @@ export const createTempGitRepo = async (
|
||||
prefix = 'paseo-e2e-',
|
||||
options?: { withRemote?: boolean }
|
||||
): Promise<TempRepo> => {
|
||||
const repoPath = await mkdtemp(path.join(tmpdir(), prefix));
|
||||
// Keep E2E repo paths short so terminal prompt + typed commands stay visible without zsh clipping.
|
||||
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' });
|
||||
|
||||
@@ -38,5 +38,7 @@ test('no hosts shows welcome; direct connection adds host and lands on agent cre
|
||||
|
||||
await expect(page.getByTestId('sidebar-new-agent')).toBeVisible();
|
||||
await expect(page.getByText(serverId, { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('Online', { exact: true })).toBeVisible({ timeout: 15000 });
|
||||
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeEditable({
|
||||
timeout: 15000,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,11 +78,21 @@ test('host removal removes the host from UI and persists after reload', async ({
|
||||
await expect(page.getByText('extra', { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByText(extraEndpoint, { exact: true }).first()).toBeVisible();
|
||||
|
||||
await page.getByTestId(`daemon-menu-trigger-${extraDaemon.serverId}`).click();
|
||||
await page.getByTestId(`daemon-menu-remove-${extraDaemon.serverId}`).click();
|
||||
const hostSettingsButton = page.getByTestId(`daemon-card-settings-${extraDaemon.serverId}`).first();
|
||||
await expect(hostSettingsButton).toBeVisible({ timeout: 10000 });
|
||||
await hostSettingsButton.click();
|
||||
|
||||
const hostDetailModal = page.getByTestId('host-detail-modal');
|
||||
await expect(hostDetailModal).toBeVisible({ timeout: 10000 });
|
||||
await hostDetailModal.getByText('Advanced', { exact: true }).click();
|
||||
await page.getByText('Remove host', { exact: true }).last().click();
|
||||
|
||||
await expect(page.getByTestId('remove-host-confirm-modal')).toBeVisible();
|
||||
await page.getByTestId('remove-host-confirm').click();
|
||||
|
||||
await expect(page.getByTestId(`daemon-card-${extraDaemon.serverId}`)).toHaveCount(0, {
|
||||
timeout: 30000,
|
||||
});
|
||||
await expect(page.getByText(extraEndpoint, { exact: true })).toHaveCount(0);
|
||||
await page.waitForFunction(
|
||||
(serverId) => {
|
||||
|
||||
@@ -41,10 +41,10 @@ test('new agent respects serverId in the URL', async ({ page }) => {
|
||||
};
|
||||
const createAgentPreferences = {
|
||||
serverId: testDaemon.serverId,
|
||||
provider: 'claude',
|
||||
provider: 'codex',
|
||||
providerPreferences: {
|
||||
claude: { model: 'haiku' },
|
||||
codex: { model: 'gpt-5.1-codex-mini' },
|
||||
codex: { model: 'gpt-5.1-codex-mini', thinkingOptionId: 'low' },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -60,11 +60,18 @@ test('new agent respects serverId in the URL', async ({ page }) => {
|
||||
{ daemon: testDaemon, preferences: createAgentPreferences }
|
||||
);
|
||||
await page.reload();
|
||||
await expect(page.getByText('Online', { exact: true }).first()).toBeVisible({ timeout: 20000 });
|
||||
await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible({ timeout: 20000 });
|
||||
|
||||
await page.goto(`/?serverId=${encodeURIComponent(serverId)}`);
|
||||
await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible();
|
||||
|
||||
const newAgentButton = page.getByTestId('sidebar-new-agent').first();
|
||||
if (await newAgentButton.isVisible().catch(() => false)) {
|
||||
await newAgentButton.click();
|
||||
} else {
|
||||
await page.getByText('New agent', { exact: true }).first().click();
|
||||
}
|
||||
|
||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
||||
await expect(input).toBeEditable({ timeout: 30000 });
|
||||
});
|
||||
@@ -95,7 +102,7 @@ test('new agent auto-selects first online host when no preference is stored', as
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
|
||||
await page.goto('/');
|
||||
await gotoHome(page);
|
||||
await page.evaluate(
|
||||
({ daemon }) => {
|
||||
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
|
||||
@@ -109,7 +116,6 @@ test('new agent auto-selects first online host when no preference is stored', as
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByText('Online', { exact: true }).first()).toBeVisible({ timeout: 20000 });
|
||||
|
||||
// Host should be auto-selected (no manual selection required).
|
||||
await expect(page.getByText('localhost', { exact: true }).first()).toBeVisible();
|
||||
|
||||
@@ -17,8 +17,28 @@ test('manual host add accepts host:port only and persists a direct connection',
|
||||
});
|
||||
await page.goto('/settings');
|
||||
|
||||
await page.getByText('+ Add connection', { exact: true }).click();
|
||||
await page.getByText('Direct connection', { exact: true }).click();
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
if (await page.getByText('Welcome to Paseo', { exact: true }).isVisible().catch(() => false)) {
|
||||
return 'welcome';
|
||||
}
|
||||
if (await page.getByText('+ Add connection', { exact: true }).isVisible().catch(() => false)) {
|
||||
return 'settings';
|
||||
}
|
||||
return '';
|
||||
},
|
||||
{ timeout: 15000 }
|
||||
)
|
||||
.not.toBe('');
|
||||
|
||||
const isWelcome = await page.getByText('Welcome to Paseo', { exact: true }).isVisible().catch(() => false);
|
||||
if (isWelcome) {
|
||||
await page.getByText('Direct connection', { exact: true }).first().click();
|
||||
} else {
|
||||
await page.getByText('+ Add connection', { exact: true }).click();
|
||||
await page.getByText('Direct connection', { exact: true }).click();
|
||||
}
|
||||
|
||||
const input = page.getByPlaceholder('host:6767');
|
||||
await expect(input).toBeVisible();
|
||||
@@ -27,11 +47,18 @@ test('manual host add accepts host:port only and persists a direct connection',
|
||||
await page.getByText('Connect', { exact: true }).click();
|
||||
|
||||
const nameModal = page.getByTestId('name-host-modal');
|
||||
await expect(nameModal).toBeVisible({ timeout: 15000 });
|
||||
await nameModal.getByTestId('name-host-skip').click();
|
||||
if (await nameModal.isVisible().catch(() => false)) {
|
||||
await nameModal.getByTestId('name-host-skip').click();
|
||||
}
|
||||
|
||||
await expect(page.getByTestId('sidebar-new-agent')).toBeVisible();
|
||||
await expect(page.getByText('Online', { exact: true })).toBeVisible({ timeout: 15000 });
|
||||
await expect(page.getByTestId('sidebar-new-agent')).toBeVisible({ timeout: 30000 });
|
||||
|
||||
const settingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first();
|
||||
await expect(settingsButton).toBeVisible({ timeout: 10000 });
|
||||
await settingsButton.click();
|
||||
await expect(page.locator(`[data-testid="daemon-card-${serverId}"]:visible`).first()).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
await page.waitForFunction(
|
||||
({ port, serverId }) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { gotoHome, openSettings } from './helpers/app';
|
||||
|
||||
function encodeBase64Url(input: string): string {
|
||||
return Buffer.from(input, 'utf8')
|
||||
@@ -10,35 +11,57 @@ function encodeBase64Url(input: string): string {
|
||||
}
|
||||
|
||||
test('pairing flow accepts #offer=ConnectionOfferV2 and stores relay-only host', async ({ page }) => {
|
||||
const relayPort = process.env.E2E_RELAY_PORT;
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
const daemonPublicKeyB64 = process.env.E2E_RELAY_DAEMON_PUBLIC_KEY;
|
||||
if (!relayPort || !serverId || !daemonPublicKeyB64) {
|
||||
throw new Error(
|
||||
'E2E_RELAY_PORT, E2E_SERVER_ID, or E2E_RELAY_DAEMON_PUBLIC_KEY is not set (expected from globalSetup).'
|
||||
);
|
||||
}
|
||||
|
||||
// Override the default fixture seeding for this test.
|
||||
await page.goto('/settings');
|
||||
await gotoHome(page);
|
||||
await openSettings(page);
|
||||
await page.evaluate(() => {
|
||||
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
|
||||
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
|
||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([]));
|
||||
localStorage.removeItem('@paseo:settings');
|
||||
});
|
||||
await page.reload();
|
||||
await page.goto('/');
|
||||
|
||||
const relayEndpoint = `127.0.0.1:${relayPort}`;
|
||||
|
||||
const offer = {
|
||||
v: 2 as const,
|
||||
serverId: 'e2e-server-123',
|
||||
daemonPublicKeyB64: Buffer.from('e2e-public-key', 'utf8').toString('base64'),
|
||||
relay: { endpoint: 'relay.local:443' },
|
||||
serverId,
|
||||
daemonPublicKeyB64,
|
||||
relay: { endpoint: relayEndpoint },
|
||||
};
|
||||
|
||||
const offerUrl = `https://app.paseo.sh/#offer=${encodeBase64Url(JSON.stringify(offer))}`;
|
||||
|
||||
await page.getByText('+ Add connection', { exact: true }).click();
|
||||
await page.getByText('Paste pairing link', { exact: true }).click();
|
||||
const welcomeTitle = page.getByText('Welcome to Paseo', { exact: true });
|
||||
if (await welcomeTitle.isVisible().catch(() => false)) {
|
||||
await page.getByTestId('welcome-paste-pairing-link').click();
|
||||
} else {
|
||||
await page.getByText('+ Add connection', { exact: true }).click();
|
||||
await page.getByText('Paste pairing link', { exact: true }).click();
|
||||
}
|
||||
|
||||
const input = page.getByPlaceholder('https://app.paseo.sh/#offer=...');
|
||||
await expect(input).toBeVisible();
|
||||
await input.fill(offerUrl);
|
||||
|
||||
await page.getByText('Pair', { exact: true }).click();
|
||||
await page.getByTestId('pair-link-submit').click();
|
||||
|
||||
await expect(page.getByTestId('sidebar-new-agent')).toBeVisible();
|
||||
const nameHostModal = page.getByTestId('name-host-modal');
|
||||
if (await nameHostModal.isVisible().catch(() => false)) {
|
||||
await nameHostModal.getByTestId('name-host-skip').click();
|
||||
}
|
||||
|
||||
await expect(page.getByTestId('sidebar-new-agent')).toBeVisible({ timeout: 30000 });
|
||||
|
||||
await page.waitForFunction(
|
||||
({ expected }) => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
waitForPermissionPrompt,
|
||||
allowPermission,
|
||||
denyPermission,
|
||||
waitForAgentFinishUI,
|
||||
} from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
|
||||
@@ -31,7 +32,7 @@ test.describe('permission prompts', () => {
|
||||
try {
|
||||
await createAgentWithConfig(page, {
|
||||
directory: repo.path,
|
||||
model: 'haiku',
|
||||
provider: 'claude',
|
||||
mode: 'Always Ask',
|
||||
prompt,
|
||||
});
|
||||
@@ -71,7 +72,7 @@ test.describe('permission prompts', () => {
|
||||
try {
|
||||
await createAgentWithConfig(page, {
|
||||
directory: repo.path,
|
||||
model: 'haiku',
|
||||
provider: 'claude',
|
||||
mode: 'Always Ask',
|
||||
prompt,
|
||||
});
|
||||
@@ -79,10 +80,8 @@ test.describe('permission prompts', () => {
|
||||
await waitForPermissionPrompt(page, 30000);
|
||||
|
||||
await denyPermission(page);
|
||||
|
||||
await expect(page.getByText(/denied by the user|permission\/authorization check/i)).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await waitForAgentFinishUI(page, 30000);
|
||||
await expect(page.getByTestId('permission-request-question')).toHaveCount(0);
|
||||
|
||||
expect(existsSync(filePath)).toBe(false);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { gotoHome, openSettings } from './helpers/app';
|
||||
|
||||
test('connects via relay when direct endpoints fail', async ({ page }) => {
|
||||
const relayPort = process.env.E2E_RELAY_PORT;
|
||||
@@ -26,7 +27,8 @@ test('connects via relay when direct endpoints fail', async ({ page }) => {
|
||||
};
|
||||
|
||||
// Override the default fixture seeding for this test.
|
||||
await page.goto('/settings');
|
||||
await gotoHome(page);
|
||||
await openSettings(page);
|
||||
await page.evaluate((daemon) => {
|
||||
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
|
||||
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { gotoHome, openSettings } from './helpers/app';
|
||||
|
||||
test('relay connection stays stable across multiple tabs', async ({ page }) => {
|
||||
const relayPort = process.env.E2E_RELAY_PORT;
|
||||
@@ -26,7 +27,8 @@ test('relay connection stays stable across multiple tabs', async ({ page }) => {
|
||||
};
|
||||
|
||||
// Use relay by making the direct endpoint intentionally fail.
|
||||
await page.goto('/settings');
|
||||
await gotoHome(page);
|
||||
await openSettings(page);
|
||||
await page.evaluate((daemon) => {
|
||||
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
|
||||
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
|
||||
@@ -45,7 +47,10 @@ test('relay connection stays stable across multiple tabs', async ({ page }) => {
|
||||
await page2.routeWebSocket(/:(6767)\b/, async (ws) => {
|
||||
await ws.close({ code: 1008, reason: 'Blocked connection to localhost:6767 during e2e.' });
|
||||
});
|
||||
await page2.goto('/settings');
|
||||
await page2.goto('/');
|
||||
const settingsButton2 = page2.locator('[data-testid="sidebar-settings"]:visible').first();
|
||||
await expect(settingsButton2).toBeVisible({ timeout: 20000 });
|
||||
await settingsButton2.click();
|
||||
const card2 = page2.getByTestId(`daemon-card-${serverId}`);
|
||||
await expect(card2.getByText('Relay', { exact: true })).toBeVisible({ timeout: 20000 });
|
||||
await expect(card2.getByText('Online', { exact: true })).toBeVisible({ timeout: 20000 });
|
||||
|
||||
@@ -13,10 +13,26 @@ test('sidebar New Agent opens a fresh create screen', async ({ page }) => {
|
||||
await createAgent(page, 'Agent A: respond with exactly A');
|
||||
await expect(page).toHaveURL(/\/agent\//);
|
||||
|
||||
// Click sidebar New Agent and assert it does not carry over agent settings via URL.
|
||||
// Click sidebar New Agent and assert it re-opens the host draft route while
|
||||
// preserving working directory context from the selected agent.
|
||||
await page.getByTestId('sidebar-new-agent').click();
|
||||
await expect(page).toHaveURL(/\/agent\/?$/);
|
||||
await expect(page).not.toHaveURL(new RegExp(encodeURIComponent(repoA.path)));
|
||||
await expect(page).toHaveURL(/\/h\/[^/]+\/agent(\?|$)/);
|
||||
const searchWorkingDir = await page.evaluate(() => {
|
||||
try {
|
||||
return new URL(window.location.href).searchParams.get('workingDir');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const normalizedCandidates = new Set<string>([repoA.path]);
|
||||
if (repoA.path.startsWith('/var/')) {
|
||||
normalizedCandidates.add(`/private${repoA.path}`);
|
||||
}
|
||||
if (repoA.path.startsWith('/private/var/')) {
|
||||
normalizedCandidates.add(repoA.path.replace(/^\/private/, ''));
|
||||
}
|
||||
expect(searchWorkingDir).not.toBeNull();
|
||||
expect(normalizedCandidates.has(searchWorkingDir ?? '')).toBe(true);
|
||||
} finally {
|
||||
await repoA.cleanup();
|
||||
}
|
||||
|
||||
@@ -26,26 +26,13 @@ test("project filter dropdown never appears visibly at 0,0 on open", async ({ pa
|
||||
});
|
||||
};
|
||||
|
||||
const getContainer = (node: HTMLElement): HTMLElement | null => {
|
||||
let current: HTMLElement | null = node;
|
||||
while (current && current !== document.body) {
|
||||
const style = getComputedStyle(current);
|
||||
if (style.position === "absolute" && style.backgroundColor !== "rgba(0, 0, 0, 0)") {
|
||||
return current;
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const tryResolveTarget = (root: HTMLElement) => {
|
||||
const stack = [root, ...Array.from(root.querySelectorAll<HTMLElement>("*"))];
|
||||
for (const element of stack) {
|
||||
if (!element.textContent?.includes("No projects")) continue;
|
||||
const container = getContainer(element);
|
||||
if (!container) continue;
|
||||
target = container;
|
||||
return true;
|
||||
if (element.dataset?.testid === "combobox-desktop-container") {
|
||||
target = element;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -1,11 +1,30 @@
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import {
|
||||
createAgent,
|
||||
createAgentInRepo,
|
||||
ensureHostSelected,
|
||||
gotoHome,
|
||||
setWorkingDirectory,
|
||||
} from "./helpers/app";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { getWorkspaceTabTestIds } from "./helpers/workspace-tabs";
|
||||
import { switchWorkspaceViaSidebar } from "./helpers/workspace-ui";
|
||||
|
||||
function visibleTestId(page: Page, testId: string) {
|
||||
return page.locator(`[data-testid="${testId}"]:visible`).first();
|
||||
}
|
||||
|
||||
function visibleTestIdPrefix(page: Page, prefix: string) {
|
||||
return page.locator(`[data-testid^="${prefix}"]:visible`);
|
||||
}
|
||||
|
||||
let terminalMarkerCounter = 0;
|
||||
|
||||
function shortTerminalMarker(prefix: string): string {
|
||||
terminalMarkerCounter += 1;
|
||||
return `${prefix.slice(0, 1)}${terminalMarkerCounter.toString(36)}`;
|
||||
}
|
||||
|
||||
function parseAgentFromUrl(url: string): { serverId: string; agentId: string } {
|
||||
const pathname = (() => {
|
||||
@@ -60,7 +79,6 @@ async function openNewAgentDraft(page: Page): Promise<void> {
|
||||
const newAgentButton = page.getByTestId("sidebar-new-agent").first();
|
||||
await expect(newAgentButton).toBeVisible({ timeout: 30000 });
|
||||
await newAgentButton.click();
|
||||
await expect(page).toHaveURL(/\/agent\/?$/, { timeout: 30000 });
|
||||
await expect(
|
||||
page.locator('[data-testid="working-directory-select"]:visible').first()
|
||||
).toBeVisible({
|
||||
@@ -68,38 +86,42 @@ async function openNewAgentDraft(page: Page): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
async function openTerminalsPanel(page: Page): Promise<void> {
|
||||
let header = page.locator('[data-testid="explorer-header"]:visible').first();
|
||||
if (!(await header.isVisible().catch(() => false))) {
|
||||
const toggle = page.getByRole("button", {
|
||||
name: /open explorer|close explorer|toggle explorer/i,
|
||||
});
|
||||
if (await toggle.first().isVisible().catch(() => false)) {
|
||||
await toggle.first().click();
|
||||
}
|
||||
async function ensureExplorerTabsVisible(page: Page): Promise<void> {
|
||||
const filesTab = visibleTestId(page, "explorer-tab-files");
|
||||
if (await filesTab.isVisible().catch(() => false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
header = page.locator('[data-testid="explorer-header"]:visible').first();
|
||||
await expect(header).toBeVisible({ timeout: 30000 });
|
||||
const toggle = page
|
||||
.getByRole("button", { name: /open explorer|close explorer|toggle explorer/i })
|
||||
.first();
|
||||
await expect(toggle).toBeVisible({ timeout: 10000 });
|
||||
await toggle.click();
|
||||
await expect(filesTab).toBeVisible({ timeout: 30000 });
|
||||
}
|
||||
|
||||
const terminalsTab = page.getByTestId("explorer-tab-terminals").first();
|
||||
async function openTerminalsPanel(page: Page): Promise<void> {
|
||||
await ensureExplorerTabsVisible(page);
|
||||
|
||||
const terminalsTab = visibleTestId(page, "explorer-tab-terminals");
|
||||
await expect(terminalsTab).toBeVisible({ timeout: 30000 });
|
||||
await terminalsTab.click();
|
||||
|
||||
await expect(page.getByTestId("terminals-header").first()).toBeVisible({
|
||||
await expect(visibleTestId(page, "terminals-header")).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
await expect(page.getByTestId("terminal-surface").first()).toBeVisible({
|
||||
await expect(visibleTestId(page, "terminal-surface")).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
async function openFilesPanel(page: Page): Promise<void> {
|
||||
const filesTab = page.getByTestId("explorer-tab-files").first();
|
||||
await ensureExplorerTabsVisible(page);
|
||||
const filesTab = visibleTestId(page, "explorer-tab-files");
|
||||
await expect(filesTab).toBeVisible({ timeout: 30000 });
|
||||
await filesTab.click();
|
||||
await expect(page.getByTestId("files-pane-header").first()).toBeVisible({
|
||||
await expect(visibleTestId(page, "files-pane-header")).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
@@ -124,7 +146,7 @@ async function getDesktopAgentSidebarOpen(page: Page): Promise<boolean | null> {
|
||||
|
||||
|
||||
async function selectNewestTerminalTab(page: Page): Promise<void> {
|
||||
const tabs = page.locator('[data-testid^="terminal-tab-"]');
|
||||
const tabs = visibleTestIdPrefix(page, "terminal-tab-");
|
||||
await expect(tabs.first()).toBeVisible({ timeout: 30000 });
|
||||
await expect
|
||||
.poll(async () => await tabs.count(), { timeout: 30000 })
|
||||
@@ -133,7 +155,7 @@ async function selectNewestTerminalTab(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
async function getFirstTerminalTabTestId(page: Page): Promise<string> {
|
||||
const firstTab = page.locator('[data-testid^="terminal-tab-"]').first();
|
||||
const firstTab = visibleTestIdPrefix(page, "terminal-tab-").first();
|
||||
await expect(firstTab).toBeVisible({ timeout: 30000 });
|
||||
const value = await firstTab.getAttribute("data-testid");
|
||||
if (!value) {
|
||||
@@ -143,7 +165,7 @@ async function getFirstTerminalTabTestId(page: Page): Promise<string> {
|
||||
}
|
||||
|
||||
async function runTerminalCommand(page: Page, command: string, expectedText: string): Promise<void> {
|
||||
const surface = page.getByTestId("terminal-surface").first();
|
||||
const surface = visibleTestId(page, "terminal-surface");
|
||||
await expect(surface).toBeVisible({ timeout: 30000 });
|
||||
await surface.click({ force: true });
|
||||
await page.keyboard.type(command, { delay: 1 });
|
||||
@@ -153,12 +175,25 @@ async function runTerminalCommand(page: Page, command: string, expectedText: str
|
||||
});
|
||||
}
|
||||
|
||||
async function runTerminalCommandAndWaitForBuffer(
|
||||
page: Page,
|
||||
command: string,
|
||||
expectedText: string
|
||||
): Promise<void> {
|
||||
const surface = visibleTestId(page, "terminal-surface");
|
||||
await expect(surface).toBeVisible({ timeout: 30000 });
|
||||
await surface.click({ force: true });
|
||||
await page.keyboard.type(command, { delay: 1 });
|
||||
await page.keyboard.press("Enter");
|
||||
await expectCurrentTerminalBufferToContain(page, expectedText);
|
||||
}
|
||||
|
||||
async function runTerminalCommandWithPreEnterEcho(
|
||||
page: Page,
|
||||
command: string,
|
||||
expectedText: string
|
||||
): Promise<void> {
|
||||
const surface = page.getByTestId("terminal-surface").first();
|
||||
const surface = visibleTestId(page, "terminal-surface");
|
||||
await expect(surface).toBeVisible({ timeout: 30000 });
|
||||
await surface.click({ force: true });
|
||||
await page.keyboard.type(command, { delay: 1 });
|
||||
@@ -171,6 +206,101 @@ async function runTerminalCommandWithPreEnterEcho(
|
||||
});
|
||||
}
|
||||
|
||||
async function readCurrentTerminalBuffer(page: Page): Promise<string> {
|
||||
const bufferText = await page.evaluate(() => {
|
||||
try {
|
||||
const terminal = (window as {
|
||||
__paseoTerminal?: {
|
||||
buffer?: {
|
||||
active?: {
|
||||
length?: number;
|
||||
getLine?: (
|
||||
line: number
|
||||
) =>
|
||||
| {
|
||||
translateToString: (trimRight?: boolean) => string;
|
||||
}
|
||||
| null;
|
||||
};
|
||||
};
|
||||
};
|
||||
}).__paseoTerminal;
|
||||
|
||||
const buffer = terminal?.buffer?.active;
|
||||
const lineCount = buffer?.length ?? 0;
|
||||
if (!buffer || typeof buffer.getLine !== "function" || lineCount <= 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
for (let index = 0; index < lineCount; index += 1) {
|
||||
let line:
|
||||
| {
|
||||
translateToString: (trimRight?: boolean) => string;
|
||||
}
|
||||
| null = null;
|
||||
try {
|
||||
line = buffer.getLine(index);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
lines.push(line.translateToString(true));
|
||||
}
|
||||
return lines.join("\n");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
});
|
||||
|
||||
if (bufferText.length > 0) {
|
||||
return bufferText;
|
||||
}
|
||||
|
||||
try {
|
||||
return await visibleTestId(page, "terminal-surface").innerText();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
async function expectTerminalFocused(page: Page): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await page.evaluate(() => {
|
||||
const surface = document.querySelector<HTMLElement>(
|
||||
'[data-testid="terminal-surface"]'
|
||||
);
|
||||
if (!surface) {
|
||||
return false;
|
||||
}
|
||||
const active = document.activeElement;
|
||||
return active instanceof HTMLElement && surface.contains(active);
|
||||
});
|
||||
})
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
async function expectCurrentTerminalBufferToContain(page: Page, marker: string): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => await readCurrentTerminalBuffer(page), { timeout: 30000 })
|
||||
.toContain(marker);
|
||||
}
|
||||
|
||||
async function expectCurrentTerminalBufferNotToContain(page: Page, marker: string): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => await readCurrentTerminalBuffer(page), { timeout: 5000 })
|
||||
.not.toContain(marker);
|
||||
}
|
||||
|
||||
async function waitForTerminalAttachToSettle(page: Page): Promise<void> {
|
||||
await expect(page.locator('[data-testid="terminal-attach-loading"]:visible')).toHaveCount(0, {
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
async function expectAnsiColorApplied(page: Page, marker: string): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
@@ -218,11 +348,11 @@ test("Terminals tab creates multiple terminals and streams command output", asyn
|
||||
await createAgent(page, "Reply with exactly: terminal smoke");
|
||||
|
||||
await openTerminalsPanel(page);
|
||||
await expect(page.locator('[data-testid^="terminal-tab-"]').first()).toBeVisible({
|
||||
await expect(visibleTestIdPrefix(page, "terminal-tab-").first()).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
const preEnterEchoMarker = `typed-echo-${Date.now()}`;
|
||||
const preEnterEchoMarker = shortTerminalMarker("typed");
|
||||
await runTerminalCommandWithPreEnterEcho(
|
||||
page,
|
||||
`echo ${preEnterEchoMarker}`,
|
||||
@@ -240,7 +370,7 @@ test("Terminals tab creates multiple terminals and streams command output", asyn
|
||||
const markerOne = `terminal-smoke-one-${Date.now()}`;
|
||||
await runTerminalCommand(page, `echo ${markerOne}`, markerOne);
|
||||
|
||||
await page.getByTestId("terminals-create-button").first().click();
|
||||
await visibleTestId(page, "terminals-create-button").click();
|
||||
await selectNewestTerminalTab(page);
|
||||
|
||||
const markerTwo = `terminal-smoke-two-${Date.now()}`;
|
||||
@@ -250,6 +380,112 @@ test("Terminals tab creates multiple terminals and streams command output", asyn
|
||||
}
|
||||
});
|
||||
|
||||
test("new terminal does not inherit output from the previously selected terminal", async ({
|
||||
page,
|
||||
}) => {
|
||||
const repo = await createTempGitRepo("paseo-e2e-terminal-output-isolation-");
|
||||
|
||||
try {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
|
||||
await createAgentInRepo(page, {
|
||||
directory: repo.path,
|
||||
prompt: "hello",
|
||||
});
|
||||
await page.goto(buildHostWorkspaceRoute(serverId, repo.path));
|
||||
await expect(page.getByTestId("workspace-new-terminal-tab").first()).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
await page.getByTestId("workspace-new-terminal-tab").first().click();
|
||||
await waitForTerminalAttachToSettle(page);
|
||||
const firstMarker = `terminal-isolation-one-${Date.now()}`;
|
||||
await runTerminalCommandAndWaitForBuffer(page, `echo ${firstMarker}`, firstMarker);
|
||||
|
||||
await page.getByTestId("workspace-new-terminal-tab").first().click();
|
||||
await waitForTerminalAttachToSettle(page);
|
||||
|
||||
await expectCurrentTerminalBufferNotToContain(page, firstMarker);
|
||||
|
||||
const secondMarker = `terminal-isolation-two-${Date.now()}`;
|
||||
await runTerminalCommandAndWaitForBuffer(page, `echo ${secondMarker}`, secondMarker);
|
||||
await expectCurrentTerminalBufferNotToContain(page, firstMarker);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace terminal tabs auto-focus on create and switch on desktop web", async ({
|
||||
page,
|
||||
}) => {
|
||||
const repo = await createTempGitRepo("paseo-e2e-terminal-focus-");
|
||||
|
||||
try {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
|
||||
await createAgentInRepo(page, {
|
||||
directory: repo.path,
|
||||
prompt: "hello",
|
||||
});
|
||||
await page.goto(buildHostWorkspaceRoute(serverId, repo.path));
|
||||
await expect(page.getByTestId("workspace-new-terminal-tab").first()).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
await page.getByTestId("workspace-new-terminal-tab").first().click();
|
||||
await waitForTerminalAttachToSettle(page);
|
||||
await expectTerminalFocused(page);
|
||||
|
||||
const firstMarker = `terminal-focus-one-${Date.now()}`;
|
||||
await page.keyboard.type(`echo ${firstMarker}`, { delay: 1 });
|
||||
await page.keyboard.press("Enter");
|
||||
await expectCurrentTerminalBufferToContain(page, firstMarker);
|
||||
|
||||
const terminalTabIdsBeforeSecondCreate = (await getWorkspaceTabTestIds(page)).filter((id) =>
|
||||
id.startsWith("workspace-tab-terminal_")
|
||||
);
|
||||
|
||||
await page.getByTestId("workspace-new-terminal-tab").first().click();
|
||||
await waitForTerminalAttachToSettle(page);
|
||||
await expectTerminalFocused(page);
|
||||
|
||||
const terminalTabIdsAfterSecondCreate = (await getWorkspaceTabTestIds(page)).filter((id) =>
|
||||
id.startsWith("workspace-tab-terminal_")
|
||||
);
|
||||
const secondTerminalTabId = terminalTabIdsAfterSecondCreate.find(
|
||||
(id) => !terminalTabIdsBeforeSecondCreate.includes(id)
|
||||
);
|
||||
const firstTerminalTabId = terminalTabIdsBeforeSecondCreate[0];
|
||||
|
||||
if (!firstTerminalTabId || !secondTerminalTabId) {
|
||||
throw new Error("Expected two distinct terminal tabs to exist.");
|
||||
}
|
||||
|
||||
const secondMarker = `terminal-focus-two-${Date.now()}`;
|
||||
await page.keyboard.type(`echo ${secondMarker}`, { delay: 1 });
|
||||
await page.keyboard.press("Enter");
|
||||
await expectCurrentTerminalBufferToContain(page, secondMarker);
|
||||
|
||||
await page.getByTestId(firstTerminalTabId).first().click();
|
||||
await waitForTerminalAttachToSettle(page);
|
||||
await expectTerminalFocused(page);
|
||||
await expectCurrentTerminalBufferToContain(page, firstMarker);
|
||||
|
||||
await page.getByTestId(secondTerminalTabId).first().click();
|
||||
await waitForTerminalAttachToSettle(page);
|
||||
await expectTerminalFocused(page);
|
||||
await expectCurrentTerminalBufferToContain(page, secondMarker);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("terminal reattaches cleanly after heavy output and tab switches", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("paseo-e2e-terminal-reattach-");
|
||||
|
||||
@@ -272,7 +508,7 @@ test("terminal reattaches cleanly after heavy output and tab switches", async ({
|
||||
await expect(page.getByText("Terminal stream ended. Reconnecting…")).toHaveCount(0, {
|
||||
timeout: 30000,
|
||||
});
|
||||
await expect(page.getByTestId("terminal-attach-loading")).toHaveCount(0, {
|
||||
await expect(page.locator('[data-testid="terminal-attach-loading"]:visible')).toHaveCount(0, {
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
@@ -284,6 +520,68 @@ test("terminal reattaches cleanly after heavy output and tab switches", async ({
|
||||
}
|
||||
});
|
||||
|
||||
test("mobile terminal tab switch keeps command input routed to the selected tab", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("paseo-e2e-terminal-mobile-routing-");
|
||||
|
||||
try {
|
||||
await openNewAgentDraft(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
await createAgent(page, "Reply with exactly: terminal routing");
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
|
||||
await ensureExplorerTabsVisible(page);
|
||||
const terminalsTab = visibleTestId(page, "explorer-tab-terminals");
|
||||
await expect(terminalsTab).toBeVisible({ timeout: 30000 });
|
||||
await terminalsTab.click({ force: true });
|
||||
await expect(visibleTestId(page, "terminals-header")).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
await expect(visibleTestId(page, "terminal-surface")).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
await waitForTerminalAttachToSettle(page);
|
||||
|
||||
await visibleTestId(page, "terminals-create-button").click();
|
||||
const tabs = visibleTestIdPrefix(page, "terminal-tab-");
|
||||
await expect
|
||||
.poll(async () => await tabs.count(), { timeout: 30000 })
|
||||
.toBeGreaterThanOrEqual(2);
|
||||
|
||||
const firstTab = tabs.first();
|
||||
const secondTab = tabs.nth(1);
|
||||
const firstTabId = await firstTab.getAttribute("data-testid");
|
||||
const secondTabId = await secondTab.getAttribute("data-testid");
|
||||
if (!firstTabId || !secondTabId) {
|
||||
throw new Error("Expected terminal tab IDs");
|
||||
}
|
||||
|
||||
const firstMarker = `mobile-route-one-${Date.now()}`;
|
||||
await firstTab.click();
|
||||
await visibleTestId(page, "terminal-surface").click({ force: true });
|
||||
await page.keyboard.type(`echo ${firstMarker}`, { delay: 1 });
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
const secondMarker = `mobile-route-two-${Date.now()}`;
|
||||
await secondTab.click();
|
||||
await visibleTestId(page, "terminal-surface").click({ force: true });
|
||||
await page.keyboard.type(`echo ${secondMarker}`, { delay: 1 });
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await page.locator(`[data-testid="${firstTabId}"]:visible`).first().click();
|
||||
await waitForTerminalAttachToSettle(page);
|
||||
await expectCurrentTerminalBufferToContain(page, firstMarker);
|
||||
await expectCurrentTerminalBufferNotToContain(page, secondMarker);
|
||||
|
||||
await page.locator(`[data-testid="${secondTabId}"]:visible`).first().click();
|
||||
await waitForTerminalAttachToSettle(page);
|
||||
await expectCurrentTerminalBufferToContain(page, secondMarker);
|
||||
await expectCurrentTerminalBufferNotToContain(page, firstMarker);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("terminal keeps prompt echo visible after enter and backspace churn", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("paseo-e2e-terminal-echo-churn-");
|
||||
|
||||
@@ -295,7 +593,7 @@ test("terminal keeps prompt echo visible after enter and backspace churn", async
|
||||
|
||||
await openTerminalsPanel(page);
|
||||
|
||||
const surface = page.getByTestId("terminal-surface").first();
|
||||
const surface = visibleTestId(page, "terminal-surface");
|
||||
await expect(surface).toBeVisible({ timeout: 30000 });
|
||||
await surface.click({ force: true });
|
||||
|
||||
@@ -303,7 +601,7 @@ test("terminal keeps prompt echo visible after enter and backspace churn", async
|
||||
await page.keyboard.press("Enter");
|
||||
}
|
||||
|
||||
const markerAfterEnters = `echo-visible-${Date.now()}`;
|
||||
const markerAfterEnters = shortTerminalMarker("visible");
|
||||
await page.keyboard.type(`echo ${markerAfterEnters}`, { delay: 0 });
|
||||
await expect(surface).toContainText(`echo ${markerAfterEnters}`, {
|
||||
timeout: 30000,
|
||||
@@ -319,7 +617,7 @@ test("terminal keeps prompt echo visible after enter and backspace churn", async
|
||||
await page.keyboard.press("Backspace");
|
||||
}
|
||||
|
||||
const markerAfterBackspace = `echo-backspace-${Date.now()}`;
|
||||
const markerAfterBackspace = shortTerminalMarker("backspace");
|
||||
await page.keyboard.type(markerAfterBackspace, { delay: 0 });
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(surface).toContainText(markerAfterBackspace, {
|
||||
@@ -341,7 +639,7 @@ test("terminal remains interactive after alternate-screen enter/exit", async ({
|
||||
|
||||
await openTerminalsPanel(page);
|
||||
|
||||
const surface = page.getByTestId("terminal-surface").first();
|
||||
const surface = visibleTestId(page, "terminal-surface");
|
||||
await expect(surface).toBeVisible({ timeout: 30000 });
|
||||
await surface.click({ force: true });
|
||||
|
||||
@@ -375,7 +673,7 @@ test("terminal tab is removed when shell exits", async ({ page }) => {
|
||||
|
||||
const exitedTabTestId = await getFirstTerminalTabTestId(page);
|
||||
|
||||
const surface = page.getByTestId("terminal-surface").first();
|
||||
const surface = visibleTestId(page, "terminal-surface");
|
||||
await expect(surface).toBeVisible({ timeout: 30000 });
|
||||
await surface.click({ force: true });
|
||||
await page.keyboard.type("exit", { delay: 1 });
|
||||
@@ -385,7 +683,7 @@ test("terminal tab is removed when shell exits", async ({ page }) => {
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
await expect(page.locator('[data-testid^="terminal-tab-"]').first()).toBeVisible({
|
||||
await expect(visibleTestIdPrefix(page, "terminal-tab-").first()).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
const nextTabTestId = await getFirstTerminalTabTestId(page);
|
||||
@@ -408,7 +706,7 @@ test("closing terminal with running command asks for confirmation", async ({ pag
|
||||
|
||||
const tabTestId = await getFirstTerminalTabTestId(page);
|
||||
const terminalId = tabTestId.replace("terminal-tab-", "");
|
||||
const tab = page.getByTestId(tabTestId).first();
|
||||
const tab = visibleTestId(page, tabTestId);
|
||||
await expect(tab).toBeVisible({ timeout: 30000 });
|
||||
|
||||
const runningMarker = `terminal-close-running-${Date.now()}`;
|
||||
@@ -425,7 +723,7 @@ test("closing terminal with running command asks for confirmation", async ({ pag
|
||||
await dialog.dismiss();
|
||||
}
|
||||
);
|
||||
await page.getByTestId(`terminal-close-${terminalId}`).first().click();
|
||||
await visibleTestId(page, `terminal-close-${terminalId}`).click();
|
||||
await dialogPromise;
|
||||
|
||||
await expect(tab).toBeVisible({ timeout: 30000 });
|
||||
@@ -447,7 +745,7 @@ test("confirming terminal close with running command removes the tab", async ({
|
||||
|
||||
const tabTestId = await getFirstTerminalTabTestId(page);
|
||||
const terminalId = tabTestId.replace("terminal-tab-", "");
|
||||
const tab = page.getByTestId(tabTestId).first();
|
||||
const tab = visibleTestId(page, tabTestId);
|
||||
await expect(tab).toBeVisible({ timeout: 30000 });
|
||||
|
||||
const runningMarker = `terminal-close-running-accept-${Date.now()}`;
|
||||
@@ -464,7 +762,7 @@ test("confirming terminal close with running command removes the tab", async ({
|
||||
await dialog.accept();
|
||||
}
|
||||
);
|
||||
await page.getByTestId(`terminal-close-${terminalId}`).first().click();
|
||||
await visibleTestId(page, `terminal-close-${terminalId}`).click();
|
||||
await dialogPromise;
|
||||
|
||||
await expect(page.getByTestId(tabTestId)).toHaveCount(0, {
|
||||
@@ -497,7 +795,7 @@ test("terminals are shared by agents on the same cwd", async ({ page }) => {
|
||||
|
||||
await openAgentFromSidebar(page, first.serverId, first.agentId);
|
||||
await openTerminalsPanel(page);
|
||||
await page.getByTestId("terminals-create-button").first().click();
|
||||
await visibleTestId(page, "terminals-create-button").click();
|
||||
await selectNewestTerminalTab(page);
|
||||
|
||||
await openAgentFromSidebar(page, second.serverId, second.agentId);
|
||||
@@ -510,7 +808,7 @@ test("terminals are shared by agents on the same cwd", async ({ page }) => {
|
||||
await openAgentFromSidebar(page, first.serverId, first.agentId);
|
||||
await openTerminalsPanel(page);
|
||||
await selectNewestTerminalTab(page);
|
||||
await expect(page.getByTestId("terminal-surface").first()).toContainText(sharedMarker, {
|
||||
await expect(visibleTestId(page, "terminal-surface")).toContainText(sharedMarker, {
|
||||
timeout: 30000,
|
||||
});
|
||||
} finally {
|
||||
@@ -529,7 +827,7 @@ test("terminal captures escape and ctrl+c key input", async ({ page }) => {
|
||||
|
||||
await openTerminalsPanel(page);
|
||||
|
||||
const surface = page.getByTestId("terminal-surface").first();
|
||||
const surface = visibleTestId(page, "terminal-surface");
|
||||
await expect(surface).toBeVisible({ timeout: 30000 });
|
||||
await surface.click({ force: true });
|
||||
|
||||
@@ -546,7 +844,9 @@ test("terminal captures escape and ctrl+c key input", async ({ page }) => {
|
||||
await page.keyboard.press("Control+B");
|
||||
await expect(surface).toContainText("^B", { timeout: 30000 });
|
||||
|
||||
const marker = `terminal-key-capture-${Date.now()}`;
|
||||
// Clear any line-editor residue before validating the next shell command.
|
||||
await page.keyboard.press("Enter");
|
||||
const marker = shortTerminalMarker("key-capture");
|
||||
await page.keyboard.type(`echo ${marker}`, { delay: 1 });
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(surface).toContainText(marker, { timeout: 30000 });
|
||||
@@ -565,7 +865,7 @@ test("Cmd+B toggles sidebar even when terminal is focused", async ({ page }) =>
|
||||
await createAgent(page, "Terminal Cmd+B");
|
||||
|
||||
await openTerminalsPanel(page);
|
||||
const surface = page.getByTestId("terminal-surface").first();
|
||||
const surface = visibleTestId(page, "terminal-surface");
|
||||
await expect(surface).toBeVisible({ timeout: 30000 });
|
||||
await surface.click({ force: true });
|
||||
|
||||
@@ -594,17 +894,28 @@ async function getTerminalRows(page: Page): Promise<number> {
|
||||
});
|
||||
}
|
||||
|
||||
async function setExplorerContentBottomPadding(page: Page, padding: number): Promise<void> {
|
||||
async function setTerminalHeightInset(page: Page, inset: number): Promise<void> {
|
||||
await page.evaluate((nextPadding) => {
|
||||
const container = document.querySelector<HTMLElement>(
|
||||
'[data-testid="explorer-content-area"]'
|
||||
const surfaces = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[data-testid="terminal-surface"]')
|
||||
);
|
||||
if (!container) {
|
||||
const activeSurface =
|
||||
surfaces.find((surface) => surface.offsetParent !== null) ?? surfaces[0] ?? null;
|
||||
if (!activeSurface) {
|
||||
return;
|
||||
}
|
||||
container.style.boxSizing = "border-box";
|
||||
container.style.paddingBottom = nextPadding + "px";
|
||||
}, padding);
|
||||
const host = activeSurface.parentElement as HTMLElement | null;
|
||||
if (!host) {
|
||||
return;
|
||||
}
|
||||
if (nextPadding > 0) {
|
||||
host.style.flex = "0 0 auto";
|
||||
host.style.height = `calc(100% - ${nextPadding}px)`;
|
||||
return;
|
||||
}
|
||||
host.style.flex = "1 1 auto";
|
||||
host.style.height = "100%";
|
||||
}, inset);
|
||||
}
|
||||
|
||||
async function getTerminalScrollbackDistance(page: Page): Promise<number> {
|
||||
@@ -646,12 +957,12 @@ test("terminal viewport resizes and uses xterm scrollback", async ({ page }) =>
|
||||
.toBeGreaterThan(0);
|
||||
const initialRows = await getTerminalRows(page);
|
||||
|
||||
await setExplorerContentBottomPadding(page, 220);
|
||||
await setTerminalHeightInset(page, 220);
|
||||
await expect
|
||||
.poll(() => getTerminalRows(page), { timeout: 30000 })
|
||||
.toBeLessThan(initialRows);
|
||||
|
||||
await setExplorerContentBottomPadding(page, 0);
|
||||
await setTerminalHeightInset(page, 0);
|
||||
await expect
|
||||
.poll(() => getTerminalRows(page), { timeout: 30000 })
|
||||
.toBeGreaterThanOrEqual(initialRows);
|
||||
@@ -679,7 +990,7 @@ test("terminal viewport resizes and uses xterm scrollback", async ({ page }) =>
|
||||
`${scrollbackMarker}-180`
|
||||
);
|
||||
|
||||
const surface = page.getByTestId("terminal-surface").first();
|
||||
const surface = visibleTestId(page, "terminal-surface");
|
||||
await surface.hover();
|
||||
await page.mouse.wheel(0, -3000);
|
||||
|
||||
|
||||
238
packages/app/e2e/terminal-responsiveness-benchmark.spec.ts
Normal file
238
packages/app/e2e/terminal-responsiveness-benchmark.spec.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { setWorkingDirectory } from "./helpers/app";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
openNewAgentComposer,
|
||||
seedWorkspaceActivity,
|
||||
switchWorkspaceViaSidebar,
|
||||
} from "./helpers/workspace-ui";
|
||||
|
||||
function percentile(values: number[], p: number): number {
|
||||
if (values.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const rank = Math.ceil((p / 100) * sorted.length);
|
||||
const index = Math.min(sorted.length - 1, Math.max(0, rank - 1));
|
||||
return sorted[index] ?? 0;
|
||||
}
|
||||
|
||||
function summarize(values: number[]) {
|
||||
if (values.length === 0) {
|
||||
return {
|
||||
count: 0,
|
||||
minMs: 0,
|
||||
maxMs: 0,
|
||||
avgMs: 0,
|
||||
p95Ms: 0,
|
||||
p99Ms: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const sum = values.reduce((acc, value) => acc + value, 0);
|
||||
return {
|
||||
count: values.length,
|
||||
minMs: Math.min(...values),
|
||||
maxMs: Math.max(...values),
|
||||
avgMs: Math.round((sum / values.length) * 100) / 100,
|
||||
p95Ms: percentile(values, 95),
|
||||
p99Ms: percentile(values, 99),
|
||||
};
|
||||
}
|
||||
|
||||
function buildStressCommand(doneMarker: string): string {
|
||||
// Deterministic synthetic "TUI-like" redraw loop: alternate screen + cursor-home repaint.
|
||||
const markerFile = ".paseo-terminal-benchmark-marker";
|
||||
return [
|
||||
"i=1",
|
||||
"printf '\\033[?1049h\\033[2J'",
|
||||
"while [ $i -le 240 ]; do",
|
||||
"printf '\\033[H'",
|
||||
"r=1",
|
||||
"while [ $r -le 24 ]; do",
|
||||
"printf 'bench frame:%03d row:%02d ########################################\\n' \"$i\" \"$r\"",
|
||||
"r=$((r+1))",
|
||||
"done",
|
||||
"sleep 0.01",
|
||||
"i=$((i+1))",
|
||||
"done",
|
||||
`printf '\\033[?1049l\\n${doneMarker}\\n'`,
|
||||
`printf '${doneMarker}\\n' > '${markerFile}'`,
|
||||
].join("; ");
|
||||
}
|
||||
|
||||
async function markerFileContains(filePath: string, marker: string): Promise<boolean> {
|
||||
try {
|
||||
const text = await readFile(filePath, "utf8");
|
||||
return text.includes(marker);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleExplorerAndMeasureLatency(page: Page): Promise<number> {
|
||||
const toggle = page.getByTestId("workspace-explorer-toggle").first();
|
||||
await expect(toggle).toBeVisible({ timeout: 30_000 });
|
||||
const currentlyExpanded = (await toggle.getAttribute("aria-expanded")) === "true";
|
||||
const expected = currentlyExpanded ? "false" : "true";
|
||||
|
||||
const start = Date.now();
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", expected, { timeout: 15_000 });
|
||||
return Date.now() - start;
|
||||
}
|
||||
|
||||
test("workspace terminal responsiveness benchmark (report-only, single stress profile)", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(180_000);
|
||||
const repo = await createTempGitRepo("paseo-e2e-terminal-benchmark-");
|
||||
const markerFilePath = path.join(repo.path, ".paseo-terminal-benchmark-marker");
|
||||
|
||||
try {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
|
||||
await openNewAgentComposer(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await seedWorkspaceActivity(page, `terminal benchmark seed ${Date.now()}`);
|
||||
|
||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repo.path });
|
||||
await expect(page.getByTestId("workspace-new-terminal-tab").first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const newTerminalButton = page.getByTestId("workspace-new-terminal-tab").first();
|
||||
await expect(newTerminalButton).toBeVisible({ timeout: 30_000 });
|
||||
await newTerminalButton.click();
|
||||
|
||||
const surface = page.locator('[data-testid="terminal-surface"]:visible').first();
|
||||
await expect(surface).toBeVisible({ timeout: 60_000 });
|
||||
await surface.click({ force: true });
|
||||
|
||||
await page.evaluate(() => {
|
||||
const monitor = {
|
||||
samples: [] as number[],
|
||||
active: true,
|
||||
rafId: 0,
|
||||
lastTs: performance.now(),
|
||||
};
|
||||
|
||||
const tick = (ts: number) => {
|
||||
if (!monitor.active) {
|
||||
return;
|
||||
}
|
||||
monitor.samples.push(ts - monitor.lastTs);
|
||||
monitor.lastTs = ts;
|
||||
monitor.rafId = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
monitor.rafId = requestAnimationFrame(tick);
|
||||
|
||||
(window as { __PASEO_E2E_RAF_MONITOR__?: { stop: () => { samples: number[] } } }).__PASEO_E2E_RAF_MONITOR__ =
|
||||
{
|
||||
stop: () => {
|
||||
if (!monitor.active) {
|
||||
return { samples: monitor.samples };
|
||||
}
|
||||
monitor.active = false;
|
||||
cancelAnimationFrame(monitor.rafId);
|
||||
return { samples: monitor.samples };
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const doneMarker = `TERMINAL_BENCH_DONE_${Date.now()}`;
|
||||
const postMarker = `TERMINAL_BENCH_POST_${Date.now()}`;
|
||||
const stressCommand = buildStressCommand(doneMarker);
|
||||
await page.keyboard.type(stressCommand, { delay: 0 });
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
const interactionLatenciesMs: number[] = [];
|
||||
for (let attempt = 0; attempt < 18; attempt += 1) {
|
||||
const latency = await toggleExplorerAndMeasureLatency(page);
|
||||
interactionLatenciesMs.push(latency);
|
||||
}
|
||||
|
||||
const rafResult = await page.evaluate(() => {
|
||||
const handle = (
|
||||
window as { __PASEO_E2E_RAF_MONITOR__?: { stop: () => { samples: number[] } } }
|
||||
).__PASEO_E2E_RAF_MONITOR__;
|
||||
if (!handle || typeof handle.stop !== "function") {
|
||||
return { samples: [] as number[] };
|
||||
}
|
||||
return handle.stop();
|
||||
});
|
||||
|
||||
await surface.click({ force: true });
|
||||
await page.keyboard.type(`echo ${postMarker} >> .paseo-terminal-benchmark-marker`, { delay: 0 });
|
||||
await page.keyboard.press("Enter");
|
||||
await expect.poll(async () => await markerFileContains(markerFilePath, postMarker), {
|
||||
timeout: 120_000,
|
||||
}).toBe(true);
|
||||
|
||||
const diagnostics = await page.evaluate(async () => {
|
||||
const debug = (
|
||||
window as {
|
||||
__PASEO_PERF_DIAGNOSTICS_DEBUG__?: {
|
||||
consumeReports?: () => Promise<unknown[]>;
|
||||
};
|
||||
}
|
||||
).__PASEO_PERF_DIAGNOSTICS_DEBUG__;
|
||||
if (!debug || typeof debug.consumeReports !== "function") {
|
||||
return { available: false, reports: [] as unknown[] };
|
||||
}
|
||||
try {
|
||||
const reports = await debug.consumeReports();
|
||||
return { available: true, reports: Array.isArray(reports) ? reports : [] };
|
||||
} catch (error) {
|
||||
return {
|
||||
available: true,
|
||||
reports: [] as unknown[],
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const frameGapsMs = (rafResult.samples ?? []).filter(
|
||||
(sample) => Number.isFinite(sample) && sample > 0
|
||||
);
|
||||
const report = {
|
||||
mode: "report-only",
|
||||
profile: "single-stress",
|
||||
generatedAt: new Date().toISOString(),
|
||||
workload: {
|
||||
frames: 240,
|
||||
rowsPerFrame: 24,
|
||||
frameSleepMs: 10,
|
||||
doneMarker,
|
||||
postMarker,
|
||||
doneMarkerObserved: await markerFileContains(markerFilePath, doneMarker),
|
||||
},
|
||||
frameGapMs: {
|
||||
...summarize(frameGapsMs),
|
||||
over100Ms: frameGapsMs.filter((gap) => gap > 100).length,
|
||||
over250Ms: frameGapsMs.filter((gap) => gap > 250).length,
|
||||
over500Ms: frameGapsMs.filter((gap) => gap > 500).length,
|
||||
},
|
||||
explorerToggleLatencyMs: summarize(interactionLatenciesMs),
|
||||
diagnostics: {
|
||||
available: diagnostics.available,
|
||||
reportCount: diagnostics.reports.length,
|
||||
reports: diagnostics.reports,
|
||||
error: "error" in diagnostics ? diagnostics.error : undefined,
|
||||
},
|
||||
};
|
||||
|
||||
await testInfo.attach("terminal-responsiveness-report", {
|
||||
body: JSON.stringify(report, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
107
packages/app/e2e/workspace-draft-create-status-controls.spec.ts
Normal file
107
packages/app/e2e/workspace-draft-create-status-controls.spec.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { test, expect } from './fixtures'
|
||||
import { ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app'
|
||||
import { createTempGitRepo } from './helpers/workspace'
|
||||
import {
|
||||
ensureWorkspaceAgentPaneVisible,
|
||||
getWorkspaceTabTestIds,
|
||||
waitForWorkspaceTabsVisible,
|
||||
} from './helpers/workspace-tabs'
|
||||
import { switchWorkspaceViaSidebar } from './helpers/workspace-ui'
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
test('workspace draft tab uses input status controls with optimistic create and in-place transition', async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = process.env.E2E_SERVER_ID
|
||||
if (!serverId) {
|
||||
throw new Error('E2E_SERVER_ID is not set.')
|
||||
}
|
||||
|
||||
const repo = await createTempGitRepo('paseo-e2e-workspace-draft-')
|
||||
const seedPrompt = `seed workspace ${Date.now()}`
|
||||
const createPrompt = `workspace draft prompt ${Date.now()}`
|
||||
|
||||
try {
|
||||
await gotoHome(page)
|
||||
await ensureHostSelected(page)
|
||||
|
||||
// Force the setup onto the global draft surface. In slow runs the app can
|
||||
// still be on a previously opened agent/workspace view where the placement
|
||||
// form is not rendered.
|
||||
await page.goto(`/h/${serverId}/new-agent`)
|
||||
await expect(page.locator('[data-testid="working-directory-select"]:visible').first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
})
|
||||
|
||||
await setWorkingDirectory(page, repo.path)
|
||||
const seedComposer = page.getByRole('textbox', { name: 'Message agent...' }).first()
|
||||
await seedComposer.fill(seedPrompt)
|
||||
await page.getByRole('button', { name: /send message/i }).first().click()
|
||||
await expect(page.getByText(seedPrompt, { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
})
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 60_000 })
|
||||
|
||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repo.path })
|
||||
const workspaceRouteToken = page.url().match(/\/workspace\/([^/?#]+)/)?.[1] ?? null
|
||||
expect(workspaceRouteToken).toBeTruthy()
|
||||
await waitForWorkspaceTabsVisible(page)
|
||||
await ensureWorkspaceAgentPaneVisible(page)
|
||||
|
||||
const beforeDraftIds = await getWorkspaceTabTestIds(page)
|
||||
await page.getByTestId('workspace-new-agent-tab').first().click()
|
||||
await ensureWorkspaceAgentPaneVisible(page)
|
||||
|
||||
const withDraftIds = await getWorkspaceTabTestIds(page)
|
||||
const draftTabTestId = withDraftIds.find((id) => !beforeDraftIds.includes(id))
|
||||
if (!draftTabTestId) {
|
||||
throw new Error('Expected a draft workspace tab to be created.')
|
||||
}
|
||||
|
||||
const draftId = draftTabTestId.replace('workspace-tab-', '')
|
||||
const draftCloseButton = page.getByTestId(`workspace-draft-close-${draftId}`).first()
|
||||
await expect(draftCloseButton).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
await expect(page.getByTestId('working-directory-select').first()).not.toBeVisible()
|
||||
await expect(page.getByTestId('worktree-select-trigger').first()).not.toBeVisible()
|
||||
|
||||
const providerSelector = page.getByTestId('agent-provider-selector').first()
|
||||
const modeSelector = page.getByTestId('agent-mode-selector').first()
|
||||
const modelSelector = page.getByTestId('agent-model-selector').first()
|
||||
const thinkingSelector = page.getByTestId('agent-thinking-selector').first()
|
||||
|
||||
await expect(providerSelector).toBeVisible({ timeout: 30_000 })
|
||||
await expect(modeSelector).toBeVisible({ timeout: 30_000 })
|
||||
await expect(modelSelector).toBeVisible({ timeout: 30_000 })
|
||||
await expect(thinkingSelector).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
const selectedMode = ((await modeSelector.innerText()) ?? '').trim()
|
||||
const selectedModel = ((await modelSelector.innerText()) ?? '').trim()
|
||||
const selectedThinking = ((await thinkingSelector.innerText()) ?? '').trim()
|
||||
|
||||
const composer = page.getByRole('textbox', { name: 'Message agent...' }).first()
|
||||
await composer.fill(createPrompt)
|
||||
await composer.press('Enter')
|
||||
|
||||
await expect(page.getByText(createPrompt, { exact: true }).first()).toBeVisible({ timeout: 5_000 })
|
||||
await expect(draftCloseButton).not.toBeVisible({ timeout: 30_000 })
|
||||
|
||||
const finalIds = await getWorkspaceTabTestIds(page)
|
||||
expect(finalIds).toContain(draftTabTestId)
|
||||
|
||||
await expect(modelSelector).toContainText(new RegExp(escapeRegex(selectedModel), 'i'))
|
||||
await expect(modeSelector).toContainText(new RegExp(escapeRegex(selectedMode), 'i'))
|
||||
await expect(thinkingSelector).toContainText(new RegExp(escapeRegex(selectedThinking), 'i'))
|
||||
|
||||
const currentUrl = page.url()
|
||||
if (!workspaceRouteToken) {
|
||||
throw new Error('Expected workspace route token to be present.')
|
||||
}
|
||||
expect(currentUrl).toContain(`/workspace/${workspaceRouteToken}`)
|
||||
} finally {
|
||||
await repo.cleanup()
|
||||
}
|
||||
})
|
||||
125
packages/app/e2e/workspace-draft-tab-retarget.spec.ts
Normal file
125
packages/app/e2e/workspace-draft-tab-retarget.spec.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { createAgentInRepo } from "./helpers/app";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
ensureWorkspaceAgentPaneVisible,
|
||||
getWorkspaceTabTestIds,
|
||||
sampleWorkspaceTabIds,
|
||||
waitForWorkspaceTabsVisible,
|
||||
} from "./helpers/workspace-tabs";
|
||||
import { switchWorkspaceViaSidebar } from "./helpers/workspace-ui";
|
||||
|
||||
async function expectComposerFocused(page: Page) {
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
await expect(composer).toBeEditable({ timeout: 30_000 });
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await composer.evaluate(
|
||||
(element) => document.activeElement === element
|
||||
);
|
||||
})
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
test("workspace draft submit retargets tab in place without transient extra tabs", async ({ page }) => {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
|
||||
const repo = await createTempGitRepo("paseo-e2e-draft-retarget-");
|
||||
const seedPrompt = `seed prompt ${Date.now()}`;
|
||||
const createPrompt = `retarget prompt ${Date.now()}`;
|
||||
|
||||
try {
|
||||
await createAgentInRepo(page, { directory: repo.path, prompt: seedPrompt });
|
||||
|
||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repo.path });
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await ensureWorkspaceAgentPaneVisible(page);
|
||||
|
||||
const beforeDraftIds = await getWorkspaceTabTestIds(page);
|
||||
await page.getByTestId("workspace-new-agent-tab").first().click();
|
||||
await ensureWorkspaceAgentPaneVisible(page);
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeEditable();
|
||||
|
||||
const withDraftIds = await getWorkspaceTabTestIds(page);
|
||||
expect(withDraftIds.length).toBe(beforeDraftIds.length + 1);
|
||||
const draftTabTestId = withDraftIds.find((id) => !beforeDraftIds.includes(id));
|
||||
expect(draftTabTestId).toBeTruthy();
|
||||
|
||||
const draftId = draftTabTestId!.replace("workspace-tab-", "");
|
||||
const draftCloseButton = page.getByTestId(`workspace-draft-close-${draftId}`).first();
|
||||
await expect(draftCloseButton).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const samplingPromise = sampleWorkspaceTabIds(page, { durationMs: 3_000, intervalMs: 40 });
|
||||
const input = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await input.fill(createPrompt);
|
||||
await input.press("Enter");
|
||||
await expect(page.getByText(createPrompt, { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const snapshots = await samplingPromise;
|
||||
const maxObservedCount = snapshots.reduce((max, ids) => Math.max(max, ids.length), 0);
|
||||
expect(maxObservedCount).toBe(withDraftIds.length);
|
||||
|
||||
const finalIds = await getWorkspaceTabTestIds(page);
|
||||
expect(finalIds.length).toBe(withDraftIds.length);
|
||||
expect(finalIds).toContain(draftTabTestId!);
|
||||
await expect(draftCloseButton).not.toBeVisible({ timeout: 30_000 });
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace agent tab switch focuses composer on desktop web", async ({ page }) => {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
|
||||
const repo = await createTempGitRepo("paseo-e2e-tab-focus-");
|
||||
const firstPrompt = `first tab prompt ${Date.now()}`;
|
||||
const secondPrompt = `second tab prompt ${Date.now()}`;
|
||||
|
||||
try {
|
||||
await createAgentInRepo(page, { directory: repo.path, prompt: firstPrompt });
|
||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repo.path });
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await ensureWorkspaceAgentPaneVisible(page);
|
||||
|
||||
const beforeSecondAgentIds = await getWorkspaceTabTestIds(page);
|
||||
await page.getByTestId("workspace-new-agent-tab").first().click();
|
||||
await expectComposerFocused(page);
|
||||
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
await composer.fill(secondPrompt);
|
||||
await composer.press("Enter");
|
||||
await expect(page.getByText(secondPrompt, { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const withSecondAgentIds = await getWorkspaceTabTestIds(page);
|
||||
const secondAgentTabTestId = withSecondAgentIds.find(
|
||||
(id) => !beforeSecondAgentIds.includes(id)
|
||||
);
|
||||
if (!secondAgentTabTestId) {
|
||||
throw new Error("Expected second agent tab to be created.");
|
||||
}
|
||||
|
||||
const firstAgentTabTestId = beforeSecondAgentIds[0];
|
||||
if (!firstAgentTabTestId) {
|
||||
throw new Error("Expected first agent tab to exist.");
|
||||
}
|
||||
|
||||
await page.getByTestId(firstAgentTabTestId).first().click();
|
||||
await expectComposerFocused(page);
|
||||
|
||||
await page.getByTestId(secondAgentTabTestId).first().click();
|
||||
await expectComposerFocused(page);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
165
packages/app/e2e/workspace-header-tabs-restore.spec.ts
Normal file
165
packages/app/e2e/workspace-header-tabs-restore.spec.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { createAgentInRepo } from "./helpers/app";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { switchWorkspaceViaSidebar } from "./helpers/workspace-ui";
|
||||
|
||||
async function openWorkspaceWithAgent(page: Page, workspacePath: string): Promise<void> {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
|
||||
await createAgentInRepo(page, {
|
||||
directory: workspacePath,
|
||||
prompt: `workspace header restore ${Date.now()}`,
|
||||
});
|
||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: workspacePath });
|
||||
await expect(page.getByTestId("workspace-new-agent-tab").first()).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
await expect(page.getByTestId("workspace-new-terminal-tab").first()).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
test("workspace new-tab buttons stay on-screen during horizontal scroll", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("paseo-e2e-workspace-new-tab-");
|
||||
|
||||
try {
|
||||
await openWorkspaceWithAgent(page, repo.path);
|
||||
|
||||
const agentButton = page.getByTestId("workspace-new-agent-tab").first();
|
||||
const terminalButton = page.getByTestId("workspace-new-terminal-tab").first();
|
||||
const tabsScroll = page.getByTestId("workspace-tabs-scroll").first();
|
||||
await expect(agentButton).toBeVisible({ timeout: 30000 });
|
||||
await expect(terminalButton).toBeVisible({ timeout: 30000 });
|
||||
await expect(tabsScroll).toBeVisible({ timeout: 30000 });
|
||||
|
||||
// Create enough terminal tabs to ensure the tabs row has overflow to scroll.
|
||||
const workspaceTabs = page.locator(
|
||||
'[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])'
|
||||
);
|
||||
const initialTabCount = await workspaceTabs.count();
|
||||
const targetTabCount = initialTabCount + 8;
|
||||
|
||||
for (let attempt = initialTabCount; attempt < targetTabCount; attempt += 1) {
|
||||
await expect(terminalButton).toBeEnabled({ timeout: 30000 });
|
||||
await terminalButton.click();
|
||||
await expect
|
||||
.poll(async () => await workspaceTabs.count(), { timeout: 30000 })
|
||||
.toBeGreaterThanOrEqual(attempt + 1);
|
||||
}
|
||||
|
||||
const agentBoundsBefore = await agentButton.boundingBox();
|
||||
const terminalBoundsBefore = await terminalButton.boundingBox();
|
||||
const viewport = page.viewportSize();
|
||||
|
||||
expect(agentBoundsBefore).not.toBeNull();
|
||||
expect(terminalBoundsBefore).not.toBeNull();
|
||||
expect(viewport).not.toBeNull();
|
||||
|
||||
if (!agentBoundsBefore || !terminalBoundsBefore || !viewport) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(agentBoundsBefore.x).toBeGreaterThanOrEqual(0);
|
||||
expect(agentBoundsBefore.y).toBeGreaterThanOrEqual(0);
|
||||
expect(agentBoundsBefore.x + agentBoundsBefore.width).toBeLessThanOrEqual(viewport.width);
|
||||
expect(agentBoundsBefore.y + agentBoundsBefore.height).toBeLessThanOrEqual(viewport.height);
|
||||
|
||||
expect(terminalBoundsBefore.x).toBeGreaterThanOrEqual(0);
|
||||
expect(terminalBoundsBefore.y).toBeGreaterThanOrEqual(0);
|
||||
expect(terminalBoundsBefore.x + terminalBoundsBefore.width).toBeLessThanOrEqual(viewport.width);
|
||||
expect(terminalBoundsBefore.y + terminalBoundsBefore.height).toBeLessThanOrEqual(viewport.height);
|
||||
|
||||
// Scroll tabs horizontally; the new-tab buttons should remain fixed on the right edge.
|
||||
await tabsScroll.evaluate((el) => {
|
||||
(el as HTMLElement).scrollLeft = (el as HTMLElement).scrollWidth;
|
||||
});
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
const agentBoundsAfter = await agentButton.boundingBox();
|
||||
const terminalBoundsAfter = await terminalButton.boundingBox();
|
||||
|
||||
expect(agentBoundsAfter).not.toBeNull();
|
||||
expect(terminalBoundsAfter).not.toBeNull();
|
||||
|
||||
if (!agentBoundsAfter || !terminalBoundsAfter) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(agentBoundsAfter.x).toBeGreaterThanOrEqual(0);
|
||||
expect(agentBoundsAfter.y).toBeGreaterThanOrEqual(0);
|
||||
expect(agentBoundsAfter.x + agentBoundsAfter.width).toBeLessThanOrEqual(viewport.width);
|
||||
expect(agentBoundsAfter.y + agentBoundsAfter.height).toBeLessThanOrEqual(viewport.height);
|
||||
|
||||
expect(terminalBoundsAfter.x).toBeGreaterThanOrEqual(0);
|
||||
expect(terminalBoundsAfter.y).toBeGreaterThanOrEqual(0);
|
||||
expect(terminalBoundsAfter.x + terminalBoundsAfter.width).toBeLessThanOrEqual(viewport.width);
|
||||
expect(terminalBoundsAfter.y + terminalBoundsAfter.height).toBeLessThanOrEqual(viewport.height);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace new-tab buttons sit immediately after tabs before overflow", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("paseo-e2e-workspace-new-tab-adjacent-");
|
||||
|
||||
try {
|
||||
await openWorkspaceWithAgent(page, repo.path);
|
||||
|
||||
const agentButton = page.getByTestId("workspace-new-agent-tab").first();
|
||||
const workspaceTabs = page.locator(
|
||||
'[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])'
|
||||
);
|
||||
|
||||
await expect(agentButton).toBeVisible({ timeout: 30000 });
|
||||
await expect(workspaceTabs).toHaveCount(1, { timeout: 30000 });
|
||||
|
||||
const lastTabBounds = await workspaceTabs.last().boundingBox();
|
||||
const agentBounds = await agentButton.boundingBox();
|
||||
|
||||
expect(lastTabBounds).not.toBeNull();
|
||||
expect(agentBounds).not.toBeNull();
|
||||
|
||||
if (!lastTabBounds || !agentBounds) {
|
||||
return;
|
||||
}
|
||||
|
||||
const horizontalGap = agentBounds.x - (lastTabBounds.x + lastTabBounds.width);
|
||||
|
||||
expect(horizontalGap).toBeGreaterThanOrEqual(0);
|
||||
expect(horizontalGap).toBeLessThanOrEqual(24);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace explorer toggle opens and closes explorer", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("paseo-e2e-workspace-explorer-toggle-");
|
||||
|
||||
try {
|
||||
await openWorkspaceWithAgent(page, repo.path);
|
||||
|
||||
const toggle = page.getByTestId("workspace-explorer-toggle").first();
|
||||
const explorerHeader = page.locator('[data-testid="explorer-header"]:visible').first();
|
||||
await expect(toggle).toBeVisible({ timeout: 30000 });
|
||||
|
||||
const initiallyExpanded = (await toggle.getAttribute("aria-expanded")) === "true";
|
||||
if (initiallyExpanded) {
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(explorerHeader).not.toBeVisible({ timeout: 10000 });
|
||||
}
|
||||
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||
await expect(explorerHeader).toBeVisible({ timeout: 10000 });
|
||||
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(explorerHeader).not.toBeVisible({ timeout: 10000 });
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
58
packages/app/e2e/workspace-sidebar-content-sync.spec.ts
Normal file
58
packages/app/e2e/workspace-sidebar-content-sync.spec.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
import { test } from './fixtures';
|
||||
import { setWorkingDirectory } from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
import {
|
||||
expectWorkspaceHeader,
|
||||
openNewAgentComposer,
|
||||
seedWorkspaceActivity,
|
||||
switchWorkspaceViaSidebar,
|
||||
workspaceLabelFromPath,
|
||||
} from './helpers/workspace-ui';
|
||||
|
||||
test('sidebar workspace switch keeps visible content in sync with selected workspace', async ({ page }) => {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error('E2E_SERVER_ID is not set.');
|
||||
}
|
||||
|
||||
const repoA = await createTempGitRepo('paseo-e2e-sync-a-');
|
||||
const repoB = await createTempGitRepo('paseo-e2e-sync-b-');
|
||||
|
||||
const tokenA = `SYNC_A_${Date.now()}`;
|
||||
const tokenB = `SYNC_B_${Date.now()}`;
|
||||
|
||||
try {
|
||||
execSync('git checkout -b sync-a-branch', { cwd: repoA.path, stdio: 'ignore' });
|
||||
execSync('git checkout -b sync-b-branch', { cwd: repoB.path, stdio: 'ignore' });
|
||||
|
||||
await openNewAgentComposer(page);
|
||||
await setWorkingDirectory(page, repoA.path);
|
||||
await seedWorkspaceActivity(page, tokenA);
|
||||
|
||||
await openNewAgentComposer(page);
|
||||
await setWorkingDirectory(page, repoB.path);
|
||||
await seedWorkspaceActivity(page, tokenB);
|
||||
|
||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repoA.path });
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: 'sync-a-branch',
|
||||
subtitle: workspaceLabelFromPath(repoA.path),
|
||||
});
|
||||
|
||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repoB.path });
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: 'sync-b-branch',
|
||||
subtitle: workspaceLabelFromPath(repoB.path),
|
||||
});
|
||||
|
||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repoA.path });
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: 'sync-a-branch',
|
||||
subtitle: workspaceLabelFromPath(repoA.path),
|
||||
});
|
||||
} finally {
|
||||
await repoA.cleanup();
|
||||
await repoB.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -4,7 +4,7 @@ const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const projectRoot = __dirname;
|
||||
const monorepoRoot = path.resolve(projectRoot, "../..");
|
||||
const appNodeModulesRoot = path.resolve(projectRoot, "node_modules");
|
||||
const serverSrcRoot = path.resolve(projectRoot, "../server/src");
|
||||
const relaySrcRoot = path.resolve(projectRoot, "../relay/src");
|
||||
const customWebPlatform = (process.env.PASEO_WEB_PLATFORM ?? "")
|
||||
@@ -15,19 +15,13 @@ const customWebPlatform = (process.env.PASEO_WEB_PLATFORM ?? "")
|
||||
const config = getDefaultConfig(projectRoot);
|
||||
const defaultResolveRequest = config.resolver.resolveRequest ?? resolve;
|
||||
|
||||
// This app imports TypeScript sources from sibling workspaces (server/relay).
|
||||
// Metro's default hierarchical lookup won't find hoisted deps when resolving
|
||||
// from those out-of-tree source files, so point Metro at the monorepo root.
|
||||
config.watchFolders = Array.from(
|
||||
new Set([...(config.watchFolders ?? []), monorepoRoot])
|
||||
);
|
||||
config.resolver.nodeModulesPaths = Array.from(
|
||||
new Set([
|
||||
...(config.resolver.nodeModulesPaths ?? []),
|
||||
path.join(projectRoot, "node_modules"),
|
||||
path.join(monorepoRoot, "node_modules"),
|
||||
])
|
||||
);
|
||||
config.resolver.extraNodeModules = {
|
||||
...(config.resolver.extraNodeModules ?? {}),
|
||||
react: path.join(appNodeModulesRoot, "react"),
|
||||
"react-dom": path.join(appNodeModulesRoot, "react-dom"),
|
||||
"react/jsx-runtime": path.join(appNodeModulesRoot, "react/jsx-runtime"),
|
||||
"react/jsx-dev-runtime": path.join(appNodeModulesRoot, "react/jsx-dev-runtime"),
|
||||
};
|
||||
|
||||
function isLocalModuleImport(moduleName) {
|
||||
return (
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"main": "index.ts",
|
||||
"version": "0.1.16",
|
||||
"version": "0.1.20",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"reset-project": "node ./scripts/reset-project.js",
|
||||
"android": "npm run android:development",
|
||||
"android:development": "APP_VARIANT=development expo prebuild --platform android --clean --non-interactive && APP_VARIANT=development expo run:android --variant=debug",
|
||||
"android:production": "APP_VARIANT=production expo prebuild --platform android --clean --non-interactive && APP_VARIANT=production expo run:android --variant=release",
|
||||
"android:prod": "npm run android:production",
|
||||
"android:clear-autolinking-cache": "node -e \"require('node:fs').rmSync('android/build/generated/autolinking', { recursive: true, force: true })\"",
|
||||
"android:development": "npm run android:clear-autolinking-cache && APP_VARIANT=development expo prebuild --platform android --non-interactive && APP_VARIANT=development expo run:android --variant=debug",
|
||||
"android:production": "npm run android:clear-autolinking-cache && APP_VARIANT=production expo prebuild --platform android --non-interactive && APP_VARIANT=production expo run:android --variant=release",
|
||||
"android:release": "npm run android:production",
|
||||
"android:clean": "expo prebuild --platform android --clean --non-interactive",
|
||||
"ios": "expo run:ios",
|
||||
"ios:release": "expo run:ios --configuration Release",
|
||||
"web": "expo start --web",
|
||||
@@ -32,7 +33,7 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@getpaseo/server": "0.1.16",
|
||||
"@getpaseo/server": "0.1.20",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@lezer/common": "^1.5.0",
|
||||
@@ -50,8 +51,11 @@
|
||||
"@react-navigation/elements": "^2.6.3",
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
"@tanstack/react-query": "^5.90.11",
|
||||
"@tanstack/react-virtual": "^3.13.21",
|
||||
"@tauri-apps/api": "^2.9.1",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-unicode11": "^0.9.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"base64-js": "^1.5.1",
|
||||
"buffer": "^6.0.3",
|
||||
@@ -82,8 +86,8 @@
|
||||
"lezer-elixir": "^1.1.2",
|
||||
"lucide-react-native": "^0.546.0",
|
||||
"mnemonic-id": "^3.2.7",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react": "19.1.4",
|
||||
"react-dom": "19.1.4",
|
||||
"react-native": "^0.81.5",
|
||||
"react-native-css": "^3.0.1",
|
||||
"react-native-draggable-flatlist": "^4.0.3",
|
||||
@@ -91,7 +95,7 @@
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-keyboard-controller": "^1.19.2",
|
||||
"react-native-markdown-display": "^7.0.2",
|
||||
"react-native-nitro-modules": "^0.30.0",
|
||||
"react-native-nitro-modules": "0.33.8",
|
||||
"react-native-permissions": "^5.4.2",
|
||||
"react-native-popover-view": "^6.1.0",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
|
||||
@@ -11,7 +11,10 @@ export default defineConfig({
|
||||
expect: {
|
||||
timeout: 10_000,
|
||||
},
|
||||
fullyParallel: true,
|
||||
// E2E tests share a single daemon/relay/metro stack from global setup.
|
||||
// Running tests concurrently causes cross-test contention and non-deterministic failures.
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
reporter: [['list']],
|
||||
use: {
|
||||
|
||||
30
packages/app/playwright.firefox.config.ts
Normal file
30
packages/app/playwright.firefox.config.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
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",
|
||||
timeout: 60_000,
|
||||
expect: {
|
||||
timeout: 10_000,
|
||||
},
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
reporter: [["list"]],
|
||||
use: {
|
||||
baseURL,
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
video: "retain-on-failure",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "Desktop Firefox",
|
||||
use: { ...devices["Desktop Firefox"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
30
packages/app/playwright.webkit.tmp.config.ts
Normal file
30
packages/app/playwright.webkit.tmp.config.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
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",
|
||||
timeout: 60_000,
|
||||
expect: {
|
||||
timeout: 10_000,
|
||||
},
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
retries: 0,
|
||||
reporter: [["list"]],
|
||||
use: {
|
||||
baseURL,
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
video: "retain-on-failure",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "Desktop Safari",
|
||||
use: { ...devices["Desktop Safari"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
import { ScrollViewStyleReset } from "expo-router/html";
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
// Ensure Unistyles runs before Expo Router statically renders each page.
|
||||
import "../styles/unistyles";
|
||||
|
||||
const webEcosystemStyles = /* css */ `
|
||||
html {
|
||||
touch-action: auto;
|
||||
}
|
||||
|
||||
body {
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
body * {
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
[data-testid="sidebar-agent-list-scroll"],
|
||||
[data-testid="agent-chat-scroll"],
|
||||
[data-testid="git-diff-scroll"],
|
||||
[data-testid="file-explorer-tree-scroll"] {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
[data-testid="sidebar-agent-list-scroll"]::-webkit-scrollbar,
|
||||
[data-testid="agent-chat-scroll"]::-webkit-scrollbar,
|
||||
[data-testid="git-diff-scroll"]::-webkit-scrollbar,
|
||||
[data-testid="file-explorer-tree-scroll"]::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
function WebRespectfulStyleReset() {
|
||||
return (
|
||||
<style
|
||||
id="paseo-web-ecosystem"
|
||||
dangerouslySetInnerHTML={{ __html: webEcosystemStyles }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Root({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, shrink-to-fit=no, user-scalable=yes, minimum-scale=1, maximum-scale=5"
|
||||
/>
|
||||
{/* Reset scroll styles so React Native Web views behave like native. */}
|
||||
<ScrollViewStyleReset />
|
||||
<WebRespectfulStyleReset />
|
||||
</head>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import "@/styles/unistyles";
|
||||
import { polyfillCrypto } from "@/polyfills/crypto";
|
||||
import { Stack, usePathname, useRouter } from "expo-router";
|
||||
import { Stack, useGlobalSearchParams, 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";
|
||||
@@ -32,9 +32,9 @@ import {
|
||||
HorizontalScrollProvider,
|
||||
useHorizontalScrollOptional,
|
||||
} from "@/contexts/horizontal-scroll-context";
|
||||
import { getIsTauri, getIsTauriMac } from "@/constants/layout";
|
||||
import { useTrafficLightPadding } from "@/utils/tauri-window";
|
||||
import { getIsTauri } from "@/constants/layout";
|
||||
import { CommandCenter } from "@/components/command-center";
|
||||
import { ProjectPickerModal } from "@/components/project-picker-modal";
|
||||
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { queryClient } from "@/query/query-client";
|
||||
@@ -45,8 +45,9 @@ import {
|
||||
} from "@/utils/os-notifications";
|
||||
import { buildNotificationRoute } from "@/utils/notification-routing";
|
||||
import {
|
||||
buildHostAgentDraftRoute,
|
||||
buildHostRootRoute,
|
||||
parseHostAgentRouteFromPathname,
|
||||
parseWorkspaceOpenIntent,
|
||||
} from "@/utils/host-routes";
|
||||
import { getTauri } from "@/utils/tauri";
|
||||
import { PerfDiagnosticsProvider } from "@/runtime/perf-diagnostics";
|
||||
@@ -275,21 +276,18 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
|
||||
]
|
||||
);
|
||||
|
||||
// When sidebar is collapsed on desktop Tauri macOS, add left padding for traffic lights
|
||||
const trafficLightPadding = useTrafficLightPadding();
|
||||
const needsTrafficLightPadding = !isMobile && !isOpen && getIsTauriMac();
|
||||
|
||||
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, paddingLeft: needsTrafficLightPadding ? trafficLightPadding.left : 0 }}>
|
||||
<View style={{ flex: 1 }}>
|
||||
{children}
|
||||
</View>
|
||||
</View>
|
||||
{isMobile && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
||||
<DownloadToast />
|
||||
<CommandCenter />
|
||||
<ProjectPickerModal />
|
||||
<KeyboardShortcutsDialog />
|
||||
</View>
|
||||
);
|
||||
@@ -350,7 +348,7 @@ function OfferLinkListener({
|
||||
if (cancelled) return;
|
||||
const serverId = (profile as any)?.serverId;
|
||||
if (typeof serverId !== "string" || !serverId) return;
|
||||
router.replace(buildHostAgentDraftRoute(serverId) as any);
|
||||
router.replace(buildHostRootRoute(serverId) as any);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) return;
|
||||
@@ -375,14 +373,24 @@ function OfferLinkListener({
|
||||
|
||||
function AppWithSidebar({ children }: { children: ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const params = useGlobalSearchParams<{ open?: string | string[] }>();
|
||||
useFaviconStatus();
|
||||
|
||||
// Parse selectedAgentKey directly from pathname
|
||||
// useLocalSearchParams doesn't update when navigating between same-pattern routes
|
||||
const selectedAgentKey = useMemo(() => {
|
||||
const workspaceMatch = pathname.match(/^\/h\/([^/]+)\/workspace\/[^/]+(?:\/|$)/);
|
||||
const workspaceServerId = workspaceMatch?.[1]?.trim() ?? "";
|
||||
const openValue = Array.isArray(params.open) ? params.open[0] : params.open;
|
||||
const openIntent = parseWorkspaceOpenIntent(openValue);
|
||||
if (workspaceServerId && openIntent?.kind === "agent") {
|
||||
const agentId = openIntent.agentId.trim();
|
||||
return agentId ? `${workspaceServerId}:${agentId}` : undefined;
|
||||
}
|
||||
|
||||
const match = parseHostAgentRouteFromPathname(pathname);
|
||||
return match ? `${match.serverId}:${match.agentId}` : undefined;
|
||||
}, [pathname]);
|
||||
}, [params.open, pathname]);
|
||||
|
||||
return (
|
||||
<AppContainer selectedAgentId={selectedAgentKey}>{children}</AppContainer>
|
||||
@@ -442,7 +450,9 @@ function MissingDaemonView() {
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<GestureHandlerRootView
|
||||
style={{ flex: 1, backgroundColor: darkTheme.colors.surface0 }}
|
||||
>
|
||||
<PerfDiagnosticsProvider scope="root_layout">
|
||||
<PortalProvider>
|
||||
<SafeAreaProvider>
|
||||
@@ -461,16 +471,22 @@ export default function RootLayout() {
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
animation: "none",
|
||||
contentStyle: {
|
||||
backgroundColor: darkTheme.colors.surface0,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="settings" />
|
||||
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
|
||||
<Stack.Screen
|
||||
name="h/[serverId]/agent/[agentId]"
|
||||
options={{ gestureEnabled: false }}
|
||||
/>
|
||||
<Stack.Screen name="h/[serverId]/index" />
|
||||
<Stack.Screen name="h/[serverId]/agent/index" />
|
||||
<Stack.Screen name="h/[serverId]/agents" />
|
||||
<Stack.Screen name="h/[serverId]/new-agent" />
|
||||
<Stack.Screen name="h/[serverId]/open-project" />
|
||||
<Stack.Screen name="h/[serverId]/settings" />
|
||||
<Stack.Screen name="pair-scan" />
|
||||
</Stack>
|
||||
|
||||
@@ -1,17 +1,99 @@
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { AgentReadyScreen } from "@/screens/agent/agent-ready-screen";
|
||||
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";
|
||||
|
||||
export default function HostAgentReadyRoute() {
|
||||
const router = useRouter();
|
||||
const params = useLocalSearchParams<{
|
||||
serverId?: string;
|
||||
agentId?: string;
|
||||
}>();
|
||||
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 agentCwd = useSessionStore((state) => {
|
||||
if (!serverId || !agentId) {
|
||||
return null;
|
||||
}
|
||||
return state.sessions[serverId]?.agents?.get(agentId)?.cwd ?? null;
|
||||
});
|
||||
|
||||
return (
|
||||
<AgentReadyScreen
|
||||
serverId={typeof params.serverId === "string" ? params.serverId : ""}
|
||||
agentId={typeof params.agentId === "string" ? params.agentId : ""}
|
||||
/>
|
||||
);
|
||||
useEffect(() => {
|
||||
if (redirectedRef.current) {
|
||||
return;
|
||||
}
|
||||
if (!serverId || !agentId) {
|
||||
redirectedRef.current = true;
|
||||
router.replace("/" as any);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedCwd = agentCwd?.trim();
|
||||
if (normalizedCwd) {
|
||||
redirectedRef.current = true;
|
||||
router.replace(
|
||||
buildHostWorkspaceAgentRoute(serverId, normalizedCwd, agentId) as any
|
||||
);
|
||||
}
|
||||
}, [agentCwd, agentId, router, serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (redirectedRef.current) {
|
||||
return;
|
||||
}
|
||||
if (!serverId || !agentId) {
|
||||
return;
|
||||
}
|
||||
if (agentCwd?.trim()) {
|
||||
return;
|
||||
}
|
||||
if (!client || !isConnected) {
|
||||
redirectedRef.current = true;
|
||||
router.replace(buildHostRootRoute(serverId) as any);
|
||||
}
|
||||
}, [agentCwd, agentId, client, isConnected, router, serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (redirectedRef.current) {
|
||||
return;
|
||||
}
|
||||
if (!serverId || !agentId || !client || !isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
void client
|
||||
.fetchAgent(agentId)
|
||||
.then((result) => {
|
||||
if (cancelled || redirectedRef.current) {
|
||||
return;
|
||||
}
|
||||
const cwd = result?.agent?.cwd?.trim();
|
||||
redirectedRef.current = true;
|
||||
if (cwd) {
|
||||
router.replace(buildHostWorkspaceAgentRoute(serverId, cwd, agentId) as any);
|
||||
return;
|
||||
}
|
||||
router.replace(buildHostRootRoute(serverId) as any);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled || redirectedRef.current) {
|
||||
return;
|
||||
}
|
||||
redirectedRef.current = true;
|
||||
router.replace(buildHostRootRoute(serverId) as any);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [agentId, client, isConnected, router, serverId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,91 @@
|
||||
import { useEffect } from "react";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { buildHostAgentDraftRoute } from "@/utils/host-routes";
|
||||
import { useLocalSearchParams, usePathname, useRouter } from "expo-router";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useFormPreferences } from "@/hooks/use-form-preferences";
|
||||
import {
|
||||
buildHostOpenProjectRoute,
|
||||
buildHostRootRoute,
|
||||
buildHostWorkspaceAgentRoute,
|
||||
buildHostWorkspaceRoute,
|
||||
} from "@/utils/host-routes";
|
||||
|
||||
const HOST_ROOT_REDIRECT_DELAY_MS = 300;
|
||||
|
||||
export default function HostIndexRoute() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
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 sessionWorkspaces = useSessionStore(
|
||||
(state) => (serverId ? state.sessions[serverId]?.workspaces : undefined)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (preferencesLoading) {
|
||||
return;
|
||||
}
|
||||
if (!serverId) {
|
||||
return;
|
||||
}
|
||||
router.replace(buildHostAgentDraftRoute(serverId) as any);
|
||||
}, [router, serverId]);
|
||||
const rootRoute = buildHostRootRoute(serverId);
|
||||
if (pathname !== rootRoute && pathname !== `${rootRoute}/`) {
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
if (pathname !== rootRoute && pathname !== `${rootRoute}/`) {
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleAgents = sessionAgents
|
||||
? Array.from(sessionAgents.values()).filter((agent) => !agent.archivedAt)
|
||||
: [];
|
||||
visibleAgents.sort(
|
||||
(left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime()
|
||||
);
|
||||
|
||||
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;
|
||||
return rightTime - leftTime;
|
||||
});
|
||||
|
||||
const primaryAgent = visibleAgents[0];
|
||||
if (primaryAgent?.cwd?.trim()) {
|
||||
router.replace(
|
||||
buildHostWorkspaceAgentRoute(
|
||||
serverId,
|
||||
primaryAgent.cwd.trim(),
|
||||
primaryAgent.id
|
||||
) as any
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const primaryWorkspace = visibleWorkspaces[0];
|
||||
if (primaryWorkspace?.id?.trim()) {
|
||||
router.replace(buildHostWorkspaceRoute(serverId, primaryWorkspace.id.trim()) as any);
|
||||
return;
|
||||
}
|
||||
|
||||
router.replace(buildHostOpenProjectRoute(serverId) as any);
|
||||
}, HOST_ROOT_REDIRECT_DELAY_MS);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [
|
||||
pathname,
|
||||
preferencesLoading,
|
||||
router,
|
||||
serverId,
|
||||
sessionAgents,
|
||||
sessionWorkspaces,
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
|
||||
|
||||
export default function HostDraftAgentRoute() {
|
||||
export default function HostNewAgentRoute() {
|
||||
const params = useLocalSearchParams<{ serverId?: string }>();
|
||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||
|
||||
return <DraftAgentScreen forcedServerId={serverId} />;
|
||||
}
|
||||
|
||||
9
packages/app/src/app/h/[serverId]/open-project.tsx
Normal file
9
packages/app/src/app/h/[serverId]/open-project.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { OpenProjectScreen } from "@/screens/open-project-screen";
|
||||
|
||||
export default function HostOpenProjectRoute() {
|
||||
const params = useLocalSearchParams<{ serverId?: string }>();
|
||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||
|
||||
return <OpenProjectScreen serverId={serverId} />;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useGlobalSearchParams, usePathname } from 'expo-router'
|
||||
import { WorkspaceScreen } from '@/screens/workspace/workspace-screen'
|
||||
import {
|
||||
parseHostWorkspaceRouteFromPathname,
|
||||
parseWorkspaceOpenIntent,
|
||||
} from '@/utils/host-routes'
|
||||
|
||||
export default function HostWorkspaceLayout() {
|
||||
const expoPathname = usePathname()
|
||||
const params = useGlobalSearchParams<{ open?: string | string[] }>()
|
||||
const activeRoute = parseHostWorkspaceRouteFromPathname(expoPathname)
|
||||
const serverId = activeRoute?.serverId ?? ''
|
||||
const workspaceId = activeRoute?.workspaceId ?? ''
|
||||
const openValue = Array.isArray(params.open) ? params.open[0] : params.open
|
||||
const openIntent = parseWorkspaceOpenIntent(openValue)
|
||||
|
||||
return (
|
||||
<WorkspaceScreen
|
||||
key={`${serverId}:${workspaceId}`}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
openIntent={openIntent}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function HostWorkspaceIndexRoute() {
|
||||
return null;
|
||||
}
|
||||
@@ -1,22 +1,33 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { ActivityIndicator, View } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useUnistyles } from "react-native-unistyles";
|
||||
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
|
||||
import { useLocalSearchParams, usePathname, useRouter } from "expo-router";
|
||||
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||
import { useFormPreferences } from "@/hooks/use-form-preferences";
|
||||
import { buildHostAgentDraftRoute } from "@/utils/host-routes";
|
||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
|
||||
import { WelcomeScreen } from "@/components/welcome-screen";
|
||||
|
||||
export default function Index() {
|
||||
const router = useRouter();
|
||||
const { theme } = useUnistyles();
|
||||
const { daemons, isLoading: registryLoading } = useDaemonRegistry();
|
||||
const pathname = usePathname();
|
||||
const params = useLocalSearchParams<{ serverId?: string }>();
|
||||
const { daemons, isLoading: registryLoading, isReconciling } = useDaemonRegistry();
|
||||
const { preferences, isLoading: preferencesLoading } = useFormPreferences();
|
||||
const requestedServerId = useMemo(() => {
|
||||
return typeof params.serverId === "string" ? params.serverId.trim() : "";
|
||||
}, [params.serverId]);
|
||||
|
||||
const targetServerId = useMemo(() => {
|
||||
if (daemons.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (requestedServerId) {
|
||||
const requested = daemons.find(
|
||||
(daemon) => daemon.serverId === requestedServerId
|
||||
);
|
||||
if (requested) {
|
||||
return requested.serverId;
|
||||
}
|
||||
}
|
||||
if (preferences.serverId) {
|
||||
const match = daemons.find((daemon) => daemon.serverId === preferences.serverId);
|
||||
if (match) {
|
||||
@@ -24,7 +35,7 @@ export default function Index() {
|
||||
}
|
||||
}
|
||||
return daemons[0]?.serverId ?? null;
|
||||
}, [daemons, preferences.serverId]);
|
||||
}, [daemons, preferences.serverId, requestedServerId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (registryLoading || preferencesLoading) {
|
||||
@@ -33,26 +44,21 @@ export default function Index() {
|
||||
if (!targetServerId) {
|
||||
return;
|
||||
}
|
||||
router.replace(buildHostAgentDraftRoute(targetServerId) as any);
|
||||
}, [preferencesLoading, registryLoading, router, targetServerId]);
|
||||
if (pathname !== "/" && pathname !== "") {
|
||||
return;
|
||||
}
|
||||
router.replace(buildHostRootRoute(targetServerId) as any);
|
||||
}, [pathname, preferencesLoading, registryLoading, router, targetServerId]);
|
||||
|
||||
if (registryLoading || preferencesLoading) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backgroundColor: theme.colors.surface0,
|
||||
}}
|
||||
>
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
);
|
||||
return <StartupSplashScreen />;
|
||||
}
|
||||
|
||||
if (!targetServerId) {
|
||||
return <DraftAgentScreen />;
|
||||
if (isReconciling) {
|
||||
return <StartupSplashScreen />;
|
||||
}
|
||||
return <WelcomeScreen />;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -12,7 +12,7 @@ import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-en
|
||||
import { probeConnection } from "@/utils/test-daemon-connection";
|
||||
import { ConnectionOfferSchema } from "@server/shared/connection-offer";
|
||||
import {
|
||||
buildHostAgentDraftRoute,
|
||||
buildHostRootRoute,
|
||||
buildHostSettingsRoute,
|
||||
} from "@/utils/host-routes";
|
||||
|
||||
@@ -170,7 +170,7 @@ export default function PairScanScreen() {
|
||||
const returnToSource = useCallback(
|
||||
(serverId: string) => {
|
||||
if (source === "onboarding") {
|
||||
router.replace(buildHostAgentDraftRoute(serverId) as any);
|
||||
router.replace(buildHostRootRoute(serverId) as any);
|
||||
return;
|
||||
}
|
||||
if (source === "editHost" && targetServerId) {
|
||||
|
||||
61
packages/app/src/app/settings.tsx
Normal file
61
packages/app/src/app/settings.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { ActivityIndicator, View } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useUnistyles } from "react-native-unistyles";
|
||||
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
|
||||
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||
import { useFormPreferences } from "@/hooks/use-form-preferences";
|
||||
import { buildHostSettingsRoute } from "@/utils/host-routes";
|
||||
|
||||
export default function LegacySettingsRoute() {
|
||||
const router = useRouter();
|
||||
const { theme } = useUnistyles();
|
||||
const { daemons, isLoading: registryLoading } = useDaemonRegistry();
|
||||
const { preferences, isLoading: preferencesLoading } = useFormPreferences();
|
||||
|
||||
const targetServerId = useMemo(() => {
|
||||
if (daemons.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (preferences.serverId) {
|
||||
const match = daemons.find(
|
||||
(daemon) => daemon.serverId === preferences.serverId
|
||||
);
|
||||
if (match) {
|
||||
return match.serverId;
|
||||
}
|
||||
}
|
||||
return daemons[0]?.serverId ?? null;
|
||||
}, [daemons, preferences.serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (registryLoading || preferencesLoading) {
|
||||
return;
|
||||
}
|
||||
if (!targetServerId) {
|
||||
return;
|
||||
}
|
||||
router.replace(buildHostSettingsRoute(targetServerId) as any);
|
||||
}, [preferencesLoading, registryLoading, router, targetServerId]);
|
||||
|
||||
if (registryLoading || preferencesLoading) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backgroundColor: theme.colors.surface0,
|
||||
}}
|
||||
>
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!targetServerId) {
|
||||
return <DraftAgentScreen />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -7,19 +7,19 @@ let attachmentStorePromise: Promise<AttachmentStore> | null = null;
|
||||
async function createAttachmentStore(): Promise<AttachmentStore> {
|
||||
if (Platform.OS === "web") {
|
||||
if (isTauriEnvironment()) {
|
||||
const { createDesktopAttachmentStore } = await import(
|
||||
const { createDesktopAttachmentStore } = require(
|
||||
"@/desktop/attachments/desktop-attachment-store"
|
||||
);
|
||||
return createDesktopAttachmentStore();
|
||||
}
|
||||
|
||||
const { createIndexedDbAttachmentStore } = await import(
|
||||
const { createIndexedDbAttachmentStore } = require(
|
||||
"@/attachments/web/indexeddb-attachment-store"
|
||||
);
|
||||
return createIndexedDbAttachmentStore();
|
||||
}
|
||||
|
||||
const { createNativeFileAttachmentStore } = await import(
|
||||
const { createNativeFileAttachmentStore } = require(
|
||||
"@/attachments/native/native-file-attachment-store"
|
||||
);
|
||||
return createNativeFileAttachmentStore();
|
||||
|
||||
@@ -65,7 +65,7 @@ export function AddHostMethodModal({
|
||||
<Link2 size={18} color={theme.colors.foreground} />
|
||||
<View style={styles.optionBody}>
|
||||
<Text style={styles.optionText}>Direct connection</Text>
|
||||
<Text style={styles.optionSubtext}>Local network or Tailscale (unencrypted).</Text>
|
||||
<Text style={styles.optionSubtext}>Local network or VPN.</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
@@ -74,7 +74,7 @@ export function AddHostMethodModal({
|
||||
<QrCode size={18} color={theme.colors.foreground} />
|
||||
<View style={styles.optionBody}>
|
||||
<Text style={styles.optionText}>Scan QR code</Text>
|
||||
<Text style={styles.optionSubtext}>Relay pairing (E2EE).</Text>
|
||||
<Text style={styles.optionSubtext}>Encrypted relay connection.</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
) : null}
|
||||
@@ -83,7 +83,7 @@ export function AddHostMethodModal({
|
||||
<ClipboardPaste size={18} color={theme.colors.foreground} />
|
||||
<View style={styles.optionBody}>
|
||||
<Text style={styles.optionText}>Paste pairing link</Text>
|
||||
<Text style={styles.optionSubtext}>Relay pairing (E2EE).</Text>
|
||||
<Text style={styles.optionSubtext}>Encrypted relay connection.</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
</AdaptiveModalSheet>
|
||||
|
||||
@@ -103,13 +103,13 @@ function buildConnectionFailureCopy(endpoint: string, error: unknown): { title:
|
||||
rawLower.includes("connection refused") ||
|
||||
rawLower.includes("err_connection_refused")
|
||||
) {
|
||||
detail = "Connection was refused. Is the daemon running on that host and port?";
|
||||
detail = "Connection refused. Is the server running at this address?";
|
||||
} else if (rawLower.includes("enotfound") || rawLower.includes("not found")) {
|
||||
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/certificate error. This app expects a daemon reachable over the local network or via relay.";
|
||||
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 {
|
||||
@@ -179,7 +179,11 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved, targetServer
|
||||
setIsSaving(true);
|
||||
setErrorMessage("");
|
||||
|
||||
const { serverId, hostname } = await probeConnection({ id: "probe", type: "direct", endpoint });
|
||||
const { serverId, hostname } = await probeConnection({
|
||||
id: "probe",
|
||||
type: "directTcp",
|
||||
endpoint,
|
||||
});
|
||||
if (targetServerId && serverId !== targetServerId) {
|
||||
const message = `That endpoint belongs to ${serverId}, not ${targetServerId}.`;
|
||||
setErrorMessage(message);
|
||||
@@ -217,7 +221,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved, targetServer
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet title="Direct connection" visible={visible} onClose={handleClose} testID="add-host-modal">
|
||||
<Text style={styles.helper}>Connect to a daemon by entering host:port.</Text>
|
||||
<Text style={styles.helper}>Enter the address of a Paseo server.</Text>
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Host</Text>
|
||||
@@ -225,7 +229,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved, targetServer
|
||||
ref={hostInputRef}
|
||||
value={endpointRaw}
|
||||
onChangeText={setEndpointRaw}
|
||||
placeholder="host:6767"
|
||||
placeholder="hostname:port"
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
style={styles.input}
|
||||
autoCapitalize="none"
|
||||
|
||||
@@ -324,6 +324,7 @@ interface ComboSelectProps {
|
||||
onSelect: (id: string) => void;
|
||||
icon?: ReactElement;
|
||||
showLabel?: boolean;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
export function ComboSelect({
|
||||
@@ -338,6 +339,7 @@ export function ComboSelect({
|
||||
onSelect,
|
||||
icon,
|
||||
showLabel = true,
|
||||
testID,
|
||||
}: ComboSelectProps): ReactElement {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const anchorRef = useRef<View>(null);
|
||||
@@ -361,6 +363,7 @@ export function ComboSelect({
|
||||
controlRef={anchorRef}
|
||||
icon={icon}
|
||||
showLabel={showLabel}
|
||||
testID={testID}
|
||||
/>
|
||||
<Combobox
|
||||
options={options}
|
||||
@@ -573,6 +576,7 @@ export function AgentConfigRow({
|
||||
onSelect={onSelectProvider}
|
||||
icon={<Bot size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />}
|
||||
showLabel={false}
|
||||
testID="draft-provider-select"
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.agentConfigColumn}>
|
||||
@@ -587,6 +591,7 @@ export function AgentConfigRow({
|
||||
onSelect={onSelectModel}
|
||||
icon={<Brain size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />}
|
||||
showLabel={false}
|
||||
testID="draft-model-select"
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.agentConfigColumn}>
|
||||
@@ -600,6 +605,7 @@ export function AgentConfigRow({
|
||||
onSelect={onSelectMode}
|
||||
icon={<Shield size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />}
|
||||
showLabel={false}
|
||||
testID="draft-mode-select"
|
||||
/>
|
||||
</View>
|
||||
{thinkingSelectOptions.length > 0 ? (
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
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')
|
||||
})
|
||||
|
||||
it('uses draft mode when controlled status controls are provided', () => {
|
||||
expect(
|
||||
resolveStatusControlMode({
|
||||
providerDefinitions: [],
|
||||
selectedProvider: 'codex',
|
||||
onSelectProvider: () => undefined,
|
||||
modeOptions: [],
|
||||
selectedMode: '',
|
||||
onSelectMode: () => undefined,
|
||||
models: [],
|
||||
selectedModel: '',
|
||||
onSelectModel: () => undefined,
|
||||
isModelLoading: false,
|
||||
thinkingOptions: [],
|
||||
selectedThinkingOptionId: '',
|
||||
onSelectThinkingOption: () => undefined,
|
||||
})
|
||||
).toBe('draft')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { DraftAgentStatusBarProps } from './agent-status-bar'
|
||||
|
||||
export function resolveStatusControlMode(statusControls?: DraftAgentStatusBarProps) {
|
||||
return statusControls ? 'draft' : 'ready'
|
||||
}
|
||||
@@ -1,14 +1,13 @@
|
||||
import { View, Pressable, Text, ActivityIndicator, Platform } from 'react-native'
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { StyleSheet, useUnistyles } from 'react-native-unistyles'
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
|
||||
import { ArrowUp, Square, Pencil, AudioLines } from 'lucide-react-native'
|
||||
import Animated, { useAnimatedStyle } from 'react-native-reanimated'
|
||||
import { useReanimatedKeyboardAnimation } from 'react-native-keyboard-controller'
|
||||
import Animated from 'react-native-reanimated'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useIsFocused } from '@react-navigation/native'
|
||||
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from '@/constants/layout'
|
||||
import { generateMessageId, type StreamItem } from '@/types/stream'
|
||||
import { AgentStatusBar } from './agent-status-bar'
|
||||
import { AgentStatusBar, DraftAgentStatusBar, type DraftAgentStatusBarProps } from './agent-status-bar'
|
||||
import { useImageAttachmentPicker } from '@/hooks/use-image-attachment-picker'
|
||||
import { useSessionStore } from '@/stores/session-store'
|
||||
import { useDraftStore } from '@/stores/draft-store'
|
||||
@@ -22,7 +21,6 @@ import {
|
||||
import { Theme } from '@/styles/theme'
|
||||
import type { DraftCommandConfig } from '@/hooks/use-agent-commands-query'
|
||||
import { encodeImages } from '@/utils/encode-images'
|
||||
import { useKeyboardShortcutsStore } from '@/stores/keyboard-shortcuts-store'
|
||||
import { focusWithRetries } from '@/utils/web-focus'
|
||||
import { useVoiceOptional } from '@/contexts/voice-context'
|
||||
import { useToast } from '@/contexts/toast-context'
|
||||
@@ -35,7 +33,12 @@ import {
|
||||
deleteAttachments,
|
||||
persistAttachmentFromFileUri,
|
||||
} from '@/attachments/service'
|
||||
import { resolveStatusControlMode } from '@/components/agent-input-area.status-controls'
|
||||
import { shouldSkipDraftPersist } from '@/components/agent-input-area.draft-persist-guard'
|
||||
import { markScrollInvestigationRender } from '@/utils/scroll-jank-investigation'
|
||||
import { useKeyboardShiftStyle } from '@/hooks/use-keyboard-shift-style'
|
||||
import { useKeyboardActionHandler } from '@/hooks/use-keyboard-action-handler'
|
||||
import type { KeyboardActionDefinition } from '@/keyboard/keyboard-action-dispatcher'
|
||||
|
||||
type QueuedMessage = {
|
||||
id: string
|
||||
@@ -60,9 +63,18 @@ interface AgentInputAreaProps {
|
||||
onAddImages?: (addImages: (images: ImageAttachment[]) => void) => void
|
||||
/** Optional draft context for listing commands before an agent exists. */
|
||||
commandDraftConfig?: DraftCommandConfig
|
||||
/** Called when a message is about to be sent (any path: keyboard, dictation, queued). */
|
||||
onMessageSent?: () => void
|
||||
onComposerHeightChange?: (height: number) => void
|
||||
onAttentionInputFocus?: () => void
|
||||
onAttentionPromptSend?: () => void
|
||||
/** Controlled status controls rendered in input area (draft flows). */
|
||||
statusControls?: DraftAgentStatusBarProps
|
||||
}
|
||||
|
||||
const EMPTY_ARRAY: readonly QueuedMessage[] = []
|
||||
const DESKTOP_MESSAGE_PLACEHOLDER = 'Message the agent, tag @files, or use /commands and /skills'
|
||||
const MOBILE_MESSAGE_PLACEHOLDER = 'Message, @files, /commands'
|
||||
|
||||
export function AgentInputArea({
|
||||
agentId,
|
||||
@@ -76,15 +88,16 @@ export function AgentInputArea({
|
||||
autoFocus = false,
|
||||
onAddImages,
|
||||
commandDraftConfig,
|
||||
onMessageSent,
|
||||
onComposerHeightChange,
|
||||
onAttentionInputFocus,
|
||||
onAttentionPromptSend,
|
||||
statusControls,
|
||||
}: AgentInputAreaProps) {
|
||||
markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`)
|
||||
const { theme } = useUnistyles()
|
||||
const insets = useSafeAreaInsets()
|
||||
const { height: keyboardHeight } = useReanimatedKeyboardAnimation()
|
||||
const isScreenFocused = useIsFocused()
|
||||
const messageInputActionRequest = useKeyboardShortcutsStore((s) => s.messageInputActionRequest)
|
||||
const clearMessageInputActionRequest = useKeyboardShortcutsStore(
|
||||
(s) => s.clearMessageInputActionRequest
|
||||
)
|
||||
|
||||
const { client, isConnected, snapshot } = useHostRuntimeSession(serverId)
|
||||
const toast = useToast()
|
||||
@@ -115,6 +128,13 @@ export function AgentInputArea({
|
||||
const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead)
|
||||
|
||||
const [internalInput, setInternalInput] = useState('')
|
||||
const isDesktopWebBreakpoint =
|
||||
Platform.OS === 'web' &&
|
||||
UnistylesRuntime.breakpoint !== 'xs' &&
|
||||
UnistylesRuntime.breakpoint !== 'sm'
|
||||
const messagePlaceholder = isDesktopWebBreakpoint
|
||||
? DESKTOP_MESSAGE_PLACEHOLDER
|
||||
: MOBILE_MESSAGE_PLACEHOLDER
|
||||
const userInput = value ?? internalInput
|
||||
const setUserInput = onChangeText ?? setInternalInput
|
||||
const [cursorIndex, setCursorIndex] = useState(0)
|
||||
@@ -122,10 +142,13 @@ export function AgentInputArea({
|
||||
const [selectedImages, setSelectedImages] = useState<ImageAttachment[]>([])
|
||||
const [isCancellingAgent, setIsCancellingAgent] = useState(false)
|
||||
const [sendError, setSendError] = useState<string | null>(null)
|
||||
const lastHandledMessageInputActionRequestIdRef = useRef<number | null>(null)
|
||||
const [isMessageInputFocused, setIsMessageInputFocused] = useState(false)
|
||||
const messageInputRef = useRef<MessageInputRef>(null)
|
||||
const draftGenerationRef = useRef(0)
|
||||
const hydratedGenerationRef = useRef(0)
|
||||
const keyboardHandlerIdRef = useRef(
|
||||
`message-input:${serverId}:${agentId}:${Math.random().toString(36).slice(2)}`
|
||||
)
|
||||
|
||||
const autocomplete = useAgentAutocomplete({
|
||||
userInput,
|
||||
@@ -167,6 +190,7 @@ export function AgentInputArea({
|
||||
}, [addImages, onAddImages])
|
||||
|
||||
const submitMessage = useCallback(async (text: string, images?: ImageAttachment[]) => {
|
||||
onMessageSent?.()
|
||||
if (onSubmitMessageRef.current) {
|
||||
await onSubmitMessageRef.current({ text, images })
|
||||
return
|
||||
@@ -175,7 +199,7 @@ export function AgentInputArea({
|
||||
throw new Error('Host is not connected')
|
||||
}
|
||||
await sendAgentMessageRef.current(agentIdRef.current, text, images)
|
||||
}, [])
|
||||
}, [onMessageSent])
|
||||
|
||||
useEffect(() => {
|
||||
agentIdRef.current = agentId
|
||||
@@ -227,8 +251,9 @@ export function AgentInputArea({
|
||||
messageId: clientMessageId,
|
||||
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
|
||||
})
|
||||
onAttentionPromptSend?.()
|
||||
}
|
||||
}, [client, serverId, setAgentStreamTail, setAgentStreamHead])
|
||||
}, [client, onAttentionPromptSend, serverId, setAgentStreamTail, setAgentStreamHead])
|
||||
|
||||
useEffect(() => {
|
||||
onSubmitMessageRef.current = onSubmitMessage
|
||||
@@ -500,58 +525,61 @@ export function AgentInputArea({
|
||||
userInput,
|
||||
])
|
||||
|
||||
// Keyboard-dispatched message-input actions are routed through store requests.
|
||||
useEffect(() => {
|
||||
if (!isScreenFocused) return
|
||||
if (!messageInputActionRequest) return
|
||||
|
||||
const currentKey = `${serverId}:${agentId}`
|
||||
if (messageInputActionRequest.agentKey !== currentKey) {
|
||||
return
|
||||
const handleKeyboardAction = useCallback((action: KeyboardActionDefinition): boolean => {
|
||||
if (!isScreenFocused) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (lastHandledMessageInputActionRequestIdRef.current === messageInputActionRequest.id) {
|
||||
return
|
||||
}
|
||||
lastHandledMessageInputActionRequestIdRef.current = messageInputActionRequest.id
|
||||
switch (action.id) {
|
||||
case 'message-input.focus':
|
||||
if (Platform.OS !== 'web') {
|
||||
messageInputRef.current?.focus()
|
||||
return true
|
||||
}
|
||||
|
||||
if (messageInputActionRequest.kind !== 'focus') {
|
||||
messageInputRef.current?.runKeyboardAction(messageInputActionRequest.kind)
|
||||
clearMessageInputActionRequest(messageInputActionRequest.id)
|
||||
return
|
||||
focusWithRetries({
|
||||
focus: () => messageInputRef.current?.focus(),
|
||||
isFocused: () => {
|
||||
const el = messageInputRef.current?.getNativeElement?.() ?? null
|
||||
const active = typeof document !== 'undefined' ? document.activeElement : null
|
||||
return Boolean(el) && active === el
|
||||
},
|
||||
})
|
||||
return true
|
||||
case 'message-input.dictation-toggle':
|
||||
messageInputRef.current?.runKeyboardAction('dictation-toggle')
|
||||
return true
|
||||
case 'message-input.dictation-cancel':
|
||||
messageInputRef.current?.runKeyboardAction('dictation-cancel')
|
||||
return true
|
||||
case 'message-input.voice-toggle':
|
||||
messageInputRef.current?.runKeyboardAction('voice-toggle')
|
||||
return true
|
||||
case 'message-input.voice-mute-toggle':
|
||||
messageInputRef.current?.runKeyboardAction('voice-mute-toggle')
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}, [isScreenFocused])
|
||||
|
||||
if (Platform.OS !== 'web') {
|
||||
messageInputRef.current?.focus()
|
||||
clearMessageInputActionRequest(messageInputActionRequest.id)
|
||||
return
|
||||
}
|
||||
useKeyboardActionHandler({
|
||||
handlerId: keyboardHandlerIdRef.current,
|
||||
actions: [
|
||||
'message-input.focus',
|
||||
'message-input.dictation-toggle',
|
||||
'message-input.dictation-cancel',
|
||||
'message-input.voice-toggle',
|
||||
'message-input.voice-mute-toggle',
|
||||
],
|
||||
enabled: isScreenFocused,
|
||||
priority: isMessageInputFocused ? 200 : 100,
|
||||
isActive: () => isScreenFocused,
|
||||
handle: handleKeyboardAction,
|
||||
})
|
||||
|
||||
return focusWithRetries({
|
||||
focus: () => messageInputRef.current?.focus(),
|
||||
isFocused: () => {
|
||||
const el = messageInputRef.current?.getNativeElement?.() ?? null
|
||||
const active = typeof document !== 'undefined' ? document.activeElement : null
|
||||
return Boolean(el) && active === el
|
||||
},
|
||||
onSuccess: () => clearMessageInputActionRequest(messageInputActionRequest.id),
|
||||
onTimeout: () => clearMessageInputActionRequest(messageInputActionRequest.id),
|
||||
})
|
||||
}, [
|
||||
agentId,
|
||||
clearMessageInputActionRequest,
|
||||
isScreenFocused,
|
||||
messageInputActionRequest,
|
||||
serverId,
|
||||
])
|
||||
|
||||
const keyboardAnimatedStyle = useAnimatedStyle(() => {
|
||||
'worklet'
|
||||
const absoluteHeight = Math.abs(keyboardHeight.value)
|
||||
const shift = Math.max(0, absoluteHeight - insets.bottom)
|
||||
return {
|
||||
transform: [{ translateY: -shift }],
|
||||
}
|
||||
const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({
|
||||
mode: 'translate',
|
||||
})
|
||||
|
||||
function handleCancelAgent() {
|
||||
@@ -569,7 +597,7 @@ export function AgentInputArea({
|
||||
const isVoiceModeForAgent = voice?.isVoiceModeForAgent(serverId, agentId) ?? false
|
||||
|
||||
const handleToggleRealtimeVoice = useCallback(() => {
|
||||
if (!voice || !isConnected) {
|
||||
if (!voice || !isConnected || !agent) {
|
||||
return
|
||||
}
|
||||
if (voice.isVoiceSwitching) {
|
||||
@@ -586,7 +614,7 @@ export function AgentInputArea({
|
||||
toast.error(message)
|
||||
}
|
||||
})
|
||||
}, [agentId, isConnected, serverId, toast, voice])
|
||||
}, [agent, agentId, isConnected, serverId, toast, voice])
|
||||
|
||||
function handleEditQueuedMessage(id: string) {
|
||||
const item = queuedMessages.find((q) => q.id === id)
|
||||
@@ -676,7 +704,7 @@ export function AgentInputArea({
|
||||
|
||||
const rightContent = (
|
||||
<View style={styles.rightControls}>
|
||||
{!isVoiceModeForAgent ? (
|
||||
{!isVoiceModeForAgent && agent ? (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
onPress={handleToggleRealtimeVoice}
|
||||
@@ -706,7 +734,12 @@ export function AgentInputArea({
|
||||
</View>
|
||||
)
|
||||
|
||||
const leftContent = <AgentStatusBar agentId={agentId} serverId={serverId} />
|
||||
const leftContent =
|
||||
resolveStatusControlMode(statusControls) === 'draft' && statusControls ? (
|
||||
<DraftAgentStatusBar {...statusControls} />
|
||||
) : (
|
||||
<AgentStatusBar agentId={agentId} serverId={serverId} />
|
||||
)
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
@@ -774,8 +807,9 @@ export function AgentInputArea({
|
||||
onRemoveImage={handleRemoveImage}
|
||||
client={client}
|
||||
isReadyForDictation={isDictationReady}
|
||||
placeholder="Message agent..."
|
||||
autoFocus={autoFocus}
|
||||
placeholder={messagePlaceholder}
|
||||
autoFocus={autoFocus && isDesktopWebBreakpoint}
|
||||
autoFocusKey={`${serverId}:${agentId}`}
|
||||
disabled={isSubmitLoading}
|
||||
isScreenFocused={isScreenFocused}
|
||||
leftContent={leftContent}
|
||||
@@ -789,6 +823,13 @@ export function AgentInputArea({
|
||||
onSelectionChange={(selection) => {
|
||||
setCursorIndex(selection.start)
|
||||
}}
|
||||
onFocusChange={(focused) => {
|
||||
setIsMessageInputFocused(focused)
|
||||
if (focused) {
|
||||
onAttentionInputFocus?.()
|
||||
}
|
||||
}}
|
||||
onHeightChange={onComposerHeightChange}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -4,324 +4,355 @@ import {
|
||||
Pressable,
|
||||
Modal,
|
||||
RefreshControl,
|
||||
SectionList,
|
||||
type ViewToken,
|
||||
type SectionListRenderItem,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useCallback, useMemo, useState, type ReactElement } from "react";
|
||||
import { router, usePathname } from "expo-router";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
import { deriveBranchLabel, deriveProjectPath } from "@/utils/agent-display-info";
|
||||
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
getHostRuntimeStore,
|
||||
isHostRuntimeConnected,
|
||||
} from "@/runtime/host-runtime";
|
||||
import { AgentStatusDot } from "@/components/agent-status-dot";
|
||||
import {
|
||||
CHECKOUT_STATUS_STALE_TIME,
|
||||
checkoutStatusQueryKey,
|
||||
useCheckoutStatusCacheOnly,
|
||||
} from "@/hooks/use-checkout-status-query";
|
||||
import {
|
||||
buildAgentNavigationKey,
|
||||
startNavigationTiming,
|
||||
} from "@/utils/navigation-timing";
|
||||
import {
|
||||
buildHostAgentDetailRoute,
|
||||
parseHostAgentRouteFromPathname,
|
||||
} from "@/utils/host-routes";
|
||||
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 { buildAgentNavigationKey, startNavigationTiming } from '@/utils/navigation-timing'
|
||||
import { buildHostWorkspaceAgentRoute } from '@/utils/host-routes'
|
||||
|
||||
interface AgentListProps {
|
||||
agents: AggregatedAgent[];
|
||||
showCheckoutInfo?: boolean;
|
||||
isRefreshing?: boolean;
|
||||
onRefresh?: () => void;
|
||||
selectedAgentId?: string;
|
||||
onAgentSelect?: () => void;
|
||||
listFooterComponent?: ReactElement | null;
|
||||
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[];
|
||||
key: string
|
||||
title: string
|
||||
data: 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()
|
||||
);
|
||||
)
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
interface AgentListRowProps {
|
||||
agent: AggregatedAgent;
|
||||
selectedAgentId?: string;
|
||||
showCheckoutInfo: boolean;
|
||||
onPress: (serverId: string, agentId: string) => void;
|
||||
onLongPress: (agent: AggregatedAgent) => void;
|
||||
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'
|
||||
default:
|
||||
return status
|
||||
}
|
||||
}
|
||||
|
||||
function AgentListRow({
|
||||
function SessionBadge({
|
||||
label,
|
||||
tone = 'neutral',
|
||||
}: {
|
||||
label: string
|
||||
tone?: 'neutral' | 'warning' | 'danger'
|
||||
}) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.badge,
|
||||
tone === 'warning' && styles.badgeWarning,
|
||||
tone === 'danger' && styles.badgeDanger,
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.badgeText,
|
||||
tone === 'warning' && styles.badgeTextWarning,
|
||||
tone === 'danger' && styles.badgeTextDanger,
|
||||
]}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionRow({
|
||||
agent,
|
||||
isMobile,
|
||||
selectedAgentId,
|
||||
showCheckoutInfo,
|
||||
showAttentionIndicator,
|
||||
onPress,
|
||||
onLongPress,
|
||||
}: AgentListRowProps) {
|
||||
const timeAgo = formatTimeAgo(agent.lastActivityAt);
|
||||
const agentKey = `${agent.serverId}:${agent.id}`;
|
||||
const isSelected = selectedAgentId === agentKey;
|
||||
const checkoutQuery = useCheckoutStatusCacheOnly({
|
||||
serverId: agent.serverId,
|
||||
cwd: agent.cwd,
|
||||
});
|
||||
const checkout = checkoutQuery.data ?? null;
|
||||
const projectPath = showCheckoutInfo
|
||||
? deriveProjectPath(agent.cwd, checkout)
|
||||
: agent.cwd;
|
||||
const branchLabel = showCheckoutInfo ? deriveBranchLabel(checkout) : null;
|
||||
}: {
|
||||
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)
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.agentItem,
|
||||
isSelected && styles.agentItemSelected,
|
||||
hovered && styles.agentItemHovered,
|
||||
pressed && styles.agentItemPressed,
|
||||
styles.row,
|
||||
isSelected && styles.rowSelected,
|
||||
hovered && styles.rowHovered,
|
||||
pressed && styles.rowPressed,
|
||||
]}
|
||||
onPress={() => onPress(agent.serverId, agent.id)}
|
||||
onPress={() => onPress(agent)}
|
||||
onLongPress={() => onLongPress(agent)}
|
||||
testID={`agent-row-${agent.serverId}-${agent.id}`}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<View style={styles.agentContent}>
|
||||
<View style={styles.row}>
|
||||
<AgentStatusDot status={agent.status} requiresAttention={agent.requiresAttention} />
|
||||
<Text
|
||||
style={[
|
||||
styles.agentTitle,
|
||||
(isSelected || hovered) && styles.agentTitleHighlighted,
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{agent.title || "New agent"}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.secondaryRow} numberOfLines={1}>
|
||||
{shortenPath(projectPath)}
|
||||
{branchLabel ? ` · ${branchLabel}` : ""} · {timeAgo}
|
||||
<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'}
|
||||
</Text>
|
||||
{agent.archivedAt ? <SessionBadge label="Archived" /> : null}
|
||||
{(agent.pendingPermissionCount ?? 0) > 0 ? (
|
||||
<SessionBadge label={`${agent.pendingPermissionCount} pending`} tone="warning" />
|
||||
) : null}
|
||||
{!isMobile && showAttentionIndicator && agent.requiresAttention ? (
|
||||
<SessionBadge label="Attention" tone="danger" />
|
||||
) : null}
|
||||
</View>
|
||||
{isMobile && (
|
||||
<View style={styles.rowMetaRow}>
|
||||
<Text style={styles.sessionMetaText} numberOfLines={1}>
|
||||
{projectPath}
|
||||
</Text>
|
||||
<Text style={styles.sessionMetaSeparator}>·</Text>
|
||||
<Text style={styles.sessionMetaText}>{statusLabel}</Text>
|
||||
<Text style={styles.sessionMetaSeparator}>·</Text>
|
||||
<Text style={styles.sessionMetaText}>{timeAgo}</Text>
|
||||
{agent.serverLabel ? (
|
||||
<>
|
||||
<Text style={styles.sessionMetaSeparator}>·</Text>
|
||||
<Text style={styles.sessionMetaText} numberOfLines={1}>
|
||||
{agent.serverLabel}
|
||||
</Text>
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{!isMobile && (
|
||||
<>
|
||||
<Text style={styles.columnMeta} numberOfLines={1}>
|
||||
{projectPath}
|
||||
</Text>
|
||||
<Text style={styles.columnMetaFixed}>{statusLabel}</Text>
|
||||
<Text style={styles.columnMetaFixed}>{timeAgo}</Text>
|
||||
</>
|
||||
)}
|
||||
{isMobile && showAttentionIndicator && agent.requiresAttention ? (
|
||||
<View style={styles.rowTrailing}>
|
||||
<SessionBadge label="Attention" tone="danger" />
|
||||
</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({
|
||||
agents,
|
||||
showCheckoutInfo = true,
|
||||
isRefreshing = false,
|
||||
onRefresh,
|
||||
selectedAgentId,
|
||||
onAgentSelect,
|
||||
listFooterComponent,
|
||||
showAttentionIndicator = true,
|
||||
}: AgentListProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const pathname = usePathname();
|
||||
const queryClient = useQueryClient();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null);
|
||||
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 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(
|
||||
(serverId: string, agentId: string) => {
|
||||
(agent: AggregatedAgent) => {
|
||||
if (isActionSheetVisible) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
const navigationKey = buildAgentNavigationKey(serverId, agentId);
|
||||
const serverId = agent.serverId
|
||||
const agentId = agent.id
|
||||
const navigationKey = buildAgentNavigationKey(serverId, agentId)
|
||||
startNavigationTiming(navigationKey, {
|
||||
from: "home",
|
||||
to: "agent",
|
||||
from: 'home',
|
||||
to: 'agent',
|
||||
params: { serverId, agentId },
|
||||
});
|
||||
})
|
||||
|
||||
const shouldReplace = Boolean(parseHostAgentRouteFromPathname(pathname));
|
||||
const navigate = shouldReplace ? router.replace : router.push;
|
||||
const shouldReplace = pathname.startsWith('/h/')
|
||||
const navigate = shouldReplace ? router.replace : router.push
|
||||
|
||||
onAgentSelect?.();
|
||||
onAgentSelect?.()
|
||||
|
||||
navigate(buildHostAgentDetailRoute(serverId, agentId) as any);
|
||||
const route: Href = buildHostWorkspaceAgentRoute(serverId, agent.cwd, agentId) as Href
|
||||
navigate(route)
|
||||
},
|
||||
[isActionSheetVisible, pathname, 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]);
|
||||
|
||||
const viewabilityConfig = useMemo(
|
||||
() => ({ itemVisiblePercentThreshold: 30 }),
|
||||
[]
|
||||
);
|
||||
|
||||
const onViewableItemsChanged = useCallback(
|
||||
({ viewableItems }: { viewableItems: Array<ViewToken> }) => {
|
||||
if (!showCheckoutInfo) {
|
||||
return;
|
||||
}
|
||||
for (const token of viewableItems) {
|
||||
const agent = token.item as AggregatedAgent | undefined;
|
||||
if (!agent) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const runtime = getHostRuntimeStore();
|
||||
const client = runtime.getClient(agent.serverId);
|
||||
const isConnected = isHostRuntimeConnected(runtime.getSnapshot(agent.serverId));
|
||||
if (!client || !isConnected) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const queryKey = checkoutStatusQueryKey(agent.serverId, agent.cwd);
|
||||
const queryState = queryClient.getQueryState(queryKey);
|
||||
const isFetching = queryState?.fetchStatus === "fetching";
|
||||
const isFresh =
|
||||
typeof queryState?.dataUpdatedAt === "number" &&
|
||||
Date.now() - queryState.dataUpdatedAt < CHECKOUT_STATUS_STALE_TIME;
|
||||
if (isFetching || isFresh) {
|
||||
continue;
|
||||
}
|
||||
|
||||
void queryClient.prefetchQuery({
|
||||
queryKey,
|
||||
queryFn: async () => await client.getCheckoutStatus(agent.cwd),
|
||||
staleTime: CHECKOUT_STATUS_STALE_TIME,
|
||||
}).catch((error) => {
|
||||
console.warn("[checkout_status] prefetch failed", error);
|
||||
});
|
||||
}
|
||||
},
|
||||
[queryClient, showCheckoutInfo]
|
||||
);
|
||||
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 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: AgentListSection[] = []
|
||||
for (const label of order) {
|
||||
const data = buckets.get(label);
|
||||
const data = buckets.get(label)
|
||||
if (!data || data.length === 0) {
|
||||
continue;
|
||||
continue
|
||||
}
|
||||
result.push({ key: `date:${label}`, title: label, data });
|
||||
result.push({ key: `date:${label}`, title: label, data })
|
||||
}
|
||||
return result;
|
||||
}, [agents]);
|
||||
return result
|
||||
}, [agents])
|
||||
|
||||
const renderAgentItem: SectionListRenderItem<AggregatedAgent, AgentListSection> =
|
||||
useCallback(
|
||||
({ item: agent }) => (
|
||||
<AgentListRow
|
||||
agent={agent}
|
||||
selectedAgentId={selectedAgentId}
|
||||
showCheckoutInfo={showCheckoutInfo}
|
||||
onPress={handleAgentPress}
|
||||
onLongPress={handleAgentLongPress}
|
||||
/>
|
||||
),
|
||||
[handleAgentLongPress, handleAgentPress, selectedAgentId, showCheckoutInfo]
|
||||
);
|
||||
|
||||
const renderSectionHeader = useCallback(
|
||||
({ section }: { section: AgentListSection }) => (
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={styles.sectionTitle}>{section.title}</Text>
|
||||
</View>
|
||||
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 keyExtractor = useCallback(
|
||||
(agent: AggregatedAgent) => `${agent.serverId}:${agent.id}`,
|
||||
[]
|
||||
);
|
||||
const keyExtractor = useCallback((section: AgentListSection) => section.key, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionList
|
||||
sections={sections}
|
||||
<FlatList
|
||||
data={sections}
|
||||
style={styles.list}
|
||||
contentContainerStyle={styles.listContent}
|
||||
keyExtractor={keyExtractor}
|
||||
renderItem={renderAgentItem}
|
||||
renderSectionHeader={renderSectionHeader}
|
||||
stickySectionHeadersEnabled={false}
|
||||
extraData={selectedAgentId}
|
||||
renderItem={renderSection}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
initialNumToRender={12}
|
||||
windowSize={7}
|
||||
maxToRenderPerBatch={12}
|
||||
updateCellsBatchingPeriod={16}
|
||||
removeClippedSubviews={true}
|
||||
ListFooterComponent={listFooterComponent}
|
||||
onViewableItemsChanged={onViewableItemsChanged}
|
||||
viewabilityConfig={viewabilityConfig}
|
||||
refreshControl={
|
||||
onRefresh ? (
|
||||
<RefreshControl
|
||||
@@ -341,16 +372,16 @@ export function AgentList({
|
||||
onRequestClose={handleCloseActionSheet}
|
||||
>
|
||||
<View style={styles.sheetOverlay}>
|
||||
<Pressable
|
||||
style={styles.sheetBackdrop}
|
||||
onPress={handleCloseActionSheet}
|
||||
/>
|
||||
<View style={[styles.sheetContainer, { paddingBottom: Math.max(insets.bottom, theme.spacing[6]) }]}>
|
||||
<Pressable style={styles.sheetBackdrop} onPress={handleCloseActionSheet} />
|
||||
<View
|
||||
style={[
|
||||
styles.sheetContainer,
|
||||
{ paddingBottom: Math.max(insets.bottom, theme.spacing[6]) },
|
||||
]}
|
||||
>
|
||||
<View style={styles.sheetHandle} />
|
||||
<Text style={styles.sheetTitle}>
|
||||
{isActionDaemonUnavailable
|
||||
? "Host offline"
|
||||
: "Archive this agent?"}
|
||||
{isActionDaemonUnavailable ? 'Host offline' : 'Archive this session?'}
|
||||
</Text>
|
||||
<View style={styles.sheetButtonRow}>
|
||||
<Pressable
|
||||
@@ -380,7 +411,7 @@ export function AgentList({
|
||||
</View>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
@@ -389,83 +420,172 @@ const styles = StyleSheet.create((theme) => ({
|
||||
minHeight: 0,
|
||||
},
|
||||
listContent: {
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingHorizontal: {
|
||||
xs: theme.spacing[3],
|
||||
md: theme.spacing[6],
|
||||
},
|
||||
paddingTop: theme.spacing[2],
|
||||
paddingBottom: theme.spacing[4],
|
||||
paddingBottom: theme.spacing[6],
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
sectionHeader: {
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
sectionBlock: {
|
||||
marginTop: theme.spacing[2],
|
||||
},
|
||||
sectionHeading: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
marginBottom: theme.spacing[2],
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: "500",
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foregroundMuted,
|
||||
textAlign: "left",
|
||||
},
|
||||
agentItem: {
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
marginBottom: theme.spacing[1],
|
||||
listCard: {
|
||||
overflow: {
|
||||
xs: 'hidden' as const,
|
||||
md: 'visible' as const,
|
||||
},
|
||||
borderRadius: {
|
||||
xs: theme.borderRadius.lg,
|
||||
md: 0,
|
||||
},
|
||||
},
|
||||
agentItemSelected: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
agentItemHovered: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
agentItemPressed: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
agentContent: {
|
||||
flex: 1,
|
||||
gap: theme.spacing[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: {
|
||||
xs: theme.borderRadius.lg,
|
||||
md: 0,
|
||||
},
|
||||
marginBottom: {
|
||||
xs: theme.spacing[1],
|
||||
md: 0,
|
||||
},
|
||||
},
|
||||
rowLeading: {
|
||||
marginRight: theme.spacing[3],
|
||||
},
|
||||
rowContent: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
rowTitleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
agentTitle: {
|
||||
flex: 1,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: "400",
|
||||
color: theme.colors.foreground,
|
||||
opacity: 0.8,
|
||||
rowMetaRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: theme.spacing[1],
|
||||
marginTop: 2,
|
||||
},
|
||||
agentTitleHighlighted: {
|
||||
rowTrailing: {
|
||||
marginLeft: theme.spacing[2],
|
||||
},
|
||||
rowSelected: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
rowHovered: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
rowPressed: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
sessionTitle: {
|
||||
flexShrink: 1,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: '500',
|
||||
color: theme.colors.foreground,
|
||||
opacity: 0.86,
|
||||
},
|
||||
sessionTitleHighlighted: {
|
||||
opacity: 1,
|
||||
},
|
||||
secondaryRow: {
|
||||
sessionMetaText: {
|
||||
maxWidth: '100%',
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: "300",
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
sessionMetaSeparator: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
opacity: 0.7,
|
||||
},
|
||||
columnMeta: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
flexShrink: 1,
|
||||
minWidth: 60,
|
||||
maxWidth: 200,
|
||||
marginLeft: theme.spacing[4],
|
||||
},
|
||||
columnMetaFixed: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
flexShrink: 0,
|
||||
width: 72,
|
||||
textAlign: 'right' as const,
|
||||
},
|
||||
badge: {
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
badgeWarning: {
|
||||
backgroundColor: 'rgba(245, 158, 11, 0.12)',
|
||||
},
|
||||
badgeDanger: {
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.14)',
|
||||
},
|
||||
badgeText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
badgeTextWarning: {
|
||||
color: theme.colors.palette.amber[500],
|
||||
},
|
||||
badgeTextDanger: {
|
||||
color: theme.colors.palette.red[300],
|
||||
},
|
||||
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,
|
||||
@@ -476,18 +596,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,
|
||||
@@ -508,4 +628,4 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
}));
|
||||
}))
|
||||
|
||||
60
packages/app/src/components/agent-status-bar.test.ts
Normal file
60
packages/app/src/components/agent-status-bar.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { 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()
|
||||
})
|
||||
|
||||
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',
|
||||
},
|
||||
],
|
||||
runtimeModelId: 'a',
|
||||
configuredModelId: 'b',
|
||||
explicitThinkingOptionId: null,
|
||||
})
|
||||
|
||||
expect(selection.activeModelId).toBe('a')
|
||||
expect(selection.displayModel).toBe('Model A')
|
||||
expect(selection.selectedThinkingId).toBe('low')
|
||||
})
|
||||
|
||||
it('uses explicit thinking option when provided', () => {
|
||||
const selection = resolveAgentModelSelection({
|
||||
models: [
|
||||
{
|
||||
id: 'a',
|
||||
provider: 'codex',
|
||||
label: 'Model A',
|
||||
thinkingOptions: [
|
||||
{ id: 'low', label: 'Low' },
|
||||
{ id: 'high', label: 'High' },
|
||||
],
|
||||
defaultThinkingOptionId: 'low',
|
||||
},
|
||||
],
|
||||
runtimeModelId: 'a',
|
||||
configuredModelId: null,
|
||||
explicitThinkingOptionId: 'high',
|
||||
})
|
||||
|
||||
expect(selection.selectedThinkingId).toBe('high')
|
||||
expect(selection.displayThinking).toBe('High')
|
||||
})
|
||||
})
|
||||
@@ -1,205 +1,267 @@
|
||||
import { View, Text, Platform, Pressable } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { Brain, ChevronDown, SlidersHorizontal } from "lucide-react-native";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { View, Text, Platform, Pressable } from 'react-native'
|
||||
import { StyleSheet, useUnistyles } from 'react-native-unistyles'
|
||||
import { Brain, ChevronDown, SlidersHorizontal } from 'lucide-react-native'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useSessionStore } from '@/stores/session-store'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Combobox, type ComboboxOption } from '@/components/ui/combobox'
|
||||
import { AdaptiveModalSheet } from '@/components/adaptive-modal-sheet'
|
||||
import type {
|
||||
AgentMode,
|
||||
AgentModelDefinition,
|
||||
AgentProvider,
|
||||
} from '@server/server/agent/agent-sdk-types'
|
||||
import type { AgentProviderDefinition } from '@server/server/agent/provider-manifest'
|
||||
import { normalizeModelId, resolveAgentModelSelection } from '@/components/agent-status-bar.utils'
|
||||
|
||||
type StatusOption = {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
type ControlledAgentStatusBarProps = {
|
||||
providerOptions?: StatusOption[]
|
||||
selectedProviderId?: string
|
||||
onSelectProvider?: (providerId: string) => void
|
||||
modeOptions?: StatusOption[]
|
||||
selectedModeId?: string
|
||||
onSelectMode?: (modeId: string) => void
|
||||
modelOptions?: StatusOption[]
|
||||
selectedModelId?: string
|
||||
onSelectModel?: (modelId: string) => void
|
||||
thinkingOptions?: StatusOption[]
|
||||
selectedThinkingOptionId?: string
|
||||
onSelectThinkingOption?: (thinkingOptionId: string) => void
|
||||
disabled?: boolean
|
||||
isModelLoading?: boolean
|
||||
}
|
||||
|
||||
export interface DraftAgentStatusBarProps {
|
||||
providerDefinitions: AgentProviderDefinition[]
|
||||
selectedProvider: AgentProvider
|
||||
onSelectProvider: (provider: AgentProvider) => void
|
||||
modeOptions: AgentMode[]
|
||||
selectedMode: string
|
||||
onSelectMode: (modeId: string) => void
|
||||
models: AgentModelDefinition[]
|
||||
selectedModel: string
|
||||
onSelectModel: (modelId: string) => void
|
||||
isModelLoading: boolean
|
||||
thinkingOptions: NonNullable<AgentModelDefinition['thinkingOptions']>
|
||||
selectedThinkingOptionId: string
|
||||
onSelectThinkingOption: (thinkingOptionId: string) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
interface AgentStatusBarProps {
|
||||
agentId: string;
|
||||
serverId: string;
|
||||
agentId: string
|
||||
serverId: string
|
||||
}
|
||||
|
||||
function normalizeModelId(modelId: string | null | undefined): string | null {
|
||||
const normalized = typeof modelId === "string" ? modelId.trim() : "";
|
||||
if (!normalized || normalized.toLowerCase() === "default") {
|
||||
return null;
|
||||
function findOptionLabel(options: StatusOption[] | undefined, selectedId: string | undefined, fallback: string) {
|
||||
if (!options || options.length === 0) {
|
||||
return fallback
|
||||
}
|
||||
return normalized;
|
||||
const selected = options.find((option) => option.id === selectedId)
|
||||
return selected?.label ?? fallback
|
||||
}
|
||||
|
||||
export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const IS_WEB = Platform.OS === "web";
|
||||
const [prefsOpen, setPrefsOpen] = useState(false);
|
||||
const dropdownMaxWidth = IS_WEB ? 360 : undefined;
|
||||
function ControlledStatusBar({
|
||||
providerOptions,
|
||||
selectedProviderId,
|
||||
onSelectProvider,
|
||||
modeOptions,
|
||||
selectedModeId,
|
||||
onSelectMode,
|
||||
modelOptions,
|
||||
selectedModelId,
|
||||
onSelectModel,
|
||||
thinkingOptions,
|
||||
selectedThinkingOptionId,
|
||||
onSelectThinkingOption,
|
||||
disabled = false,
|
||||
isModelLoading = false,
|
||||
}: ControlledAgentStatusBarProps) {
|
||||
const { theme } = useUnistyles()
|
||||
const isWeb = Platform.OS === 'web'
|
||||
const [prefsOpen, setPrefsOpen] = useState(false)
|
||||
const [openSelector, setOpenSelector] = useState<'provider' | 'mode' | 'model' | 'thinking' | null>(null)
|
||||
|
||||
// Select only the specific agent (not all agents)
|
||||
const agent = useSessionStore((state) =>
|
||||
state.sessions[serverId]?.agents?.get(agentId)
|
||||
);
|
||||
const providerAnchorRef = useRef<View>(null)
|
||||
const modeAnchorRef = useRef<View>(null)
|
||||
const modelAnchorRef = useRef<View>(null)
|
||||
const thinkingAnchorRef = useRef<View>(null)
|
||||
|
||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||
const canSelectProvider = Boolean(onSelectProvider && providerOptions && providerOptions.length > 0)
|
||||
const canSelectMode = Boolean(onSelectMode && modeOptions && modeOptions.length > 0)
|
||||
const canSelectModel = Boolean(onSelectModel)
|
||||
const canSelectThinking = Boolean(
|
||||
onSelectThinkingOption && thinkingOptions && thinkingOptions.length > 0
|
||||
)
|
||||
|
||||
const canFetchModels =
|
||||
Boolean(client) && Boolean(agent?.provider) && (IS_WEB || prefsOpen);
|
||||
const modelsQuery = useQuery({
|
||||
queryKey: [
|
||||
"providerModels",
|
||||
serverId,
|
||||
agent?.provider ?? "__missing_provider__",
|
||||
agent?.cwd ?? "__missing_cwd__",
|
||||
],
|
||||
enabled: canFetchModels,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
queryFn: async () => {
|
||||
if (!client || !agent) {
|
||||
throw new Error("Daemon client unavailable");
|
||||
}
|
||||
const payload = await client.listProviderModels(agent.provider, { cwd: agent.cwd });
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return payload.models ?? [];
|
||||
const displayProvider = findOptionLabel(providerOptions, selectedProviderId, 'Provider')
|
||||
const displayMode = findOptionLabel(modeOptions, selectedModeId, 'Default')
|
||||
const displayModel =
|
||||
isModelLoading && (!modelOptions || modelOptions.length === 0)
|
||||
? 'Loading models...'
|
||||
: findOptionLabel(modelOptions, selectedModelId, 'Auto')
|
||||
const displayThinking = findOptionLabel(thinkingOptions, selectedThinkingOptionId, 'auto')
|
||||
|
||||
const hasAnyControl =
|
||||
Boolean(providerOptions?.length) ||
|
||||
Boolean(modeOptions?.length) ||
|
||||
canSelectModel ||
|
||||
Boolean(thinkingOptions?.length)
|
||||
|
||||
if (!hasAnyControl) {
|
||||
return null
|
||||
}
|
||||
|
||||
const modelDisabled = disabled || isModelLoading || !modelOptions || modelOptions.length === 0
|
||||
|
||||
const SEARCH_THRESHOLD = 6
|
||||
|
||||
const comboboxProviderOptions = useMemo<ComboboxOption[]>(
|
||||
() => (providerOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
|
||||
[providerOptions]
|
||||
)
|
||||
const comboboxModeOptions = useMemo<ComboboxOption[]>(
|
||||
() => (modeOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
|
||||
[modeOptions]
|
||||
)
|
||||
const comboboxModelOptions = useMemo<ComboboxOption[]>(
|
||||
() => (modelOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
|
||||
[modelOptions]
|
||||
)
|
||||
const comboboxThinkingOptions = useMemo<ComboboxOption[]>(
|
||||
() => (thinkingOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
|
||||
[thinkingOptions]
|
||||
)
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(selector: 'provider' | 'mode' | 'model' | 'thinking') => (nextOpen: boolean) => {
|
||||
setOpenSelector(nextOpen ? selector : null)
|
||||
},
|
||||
});
|
||||
const models = modelsQuery.data ?? null;
|
||||
|
||||
function handleModeChange(modeId: string) {
|
||||
if (!client || !agent) {
|
||||
return;
|
||||
}
|
||||
void client.setAgentMode(agentId, modeId).catch((error) => {
|
||||
console.warn("[AgentStatusBar] setAgentMode failed", error);
|
||||
});
|
||||
}
|
||||
|
||||
const normalizedRuntimeModelId = normalizeModelId(agent?.runtimeInfo?.model);
|
||||
const normalizedConfiguredModelId = normalizeModelId(agent?.model);
|
||||
const preferredModelId = normalizedRuntimeModelId ?? normalizedConfiguredModelId;
|
||||
const selectedModel = useMemo(() => {
|
||||
if (!models || !preferredModelId) return null;
|
||||
return models.find((m) => m.id === preferredModelId) ?? null;
|
||||
}, [models, preferredModelId]);
|
||||
|
||||
const activeModelId = selectedModel?.id ?? preferredModelId ?? null;
|
||||
const displayModel = selectedModel ? selectedModel.label : preferredModelId ?? "Auto";
|
||||
|
||||
const thinkingOptions = selectedModel?.thinkingOptions ?? null;
|
||||
const explicitThinkingId =
|
||||
agent?.thinkingOptionId && agent.thinkingOptionId !== "default"
|
||||
? agent.thinkingOptionId
|
||||
: null;
|
||||
const selectedThinkingId =
|
||||
explicitThinkingId ?? selectedModel?.defaultThinkingOptionId ?? null;
|
||||
const selectedThinking = thinkingOptions?.find((o) => o.id === selectedThinkingId) ?? null;
|
||||
const displayThinking =
|
||||
selectedThinking?.label ??
|
||||
(selectedThinkingId === "default" ? "Model default" : selectedThinkingId ?? "auto");
|
||||
|
||||
const displayMode =
|
||||
agent?.availableModes?.find((m) => m.id === agent.currentModeId)?.label ||
|
||||
agent?.currentModeId ||
|
||||
"default";
|
||||
|
||||
if (!agent) {
|
||||
return null;
|
||||
}
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={[styles.container, IS_WEB && { marginBottom: -theme.spacing[1] }]}>
|
||||
{/* Agent Mode Badge (desktop only — on mobile, mode is in the preferences sheet) */}
|
||||
{IS_WEB && agent.availableModes && agent.availableModes.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
style={({ pressed, hovered, open }) => [
|
||||
styles.modeBadge,
|
||||
hovered && styles.modeBadgeHovered,
|
||||
(pressed || open) && styles.modeBadgePressed,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Select agent mode"
|
||||
testID="agent-mode-selector"
|
||||
>
|
||||
<Text style={styles.modeBadgeText}>
|
||||
{agent.availableModes?.find((m) => m.id === agent.currentModeId)
|
||||
?.label ||
|
||||
agent.currentModeId ||
|
||||
"default"}
|
||||
</Text>
|
||||
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="top"
|
||||
align="start"
|
||||
maxWidth={dropdownMaxWidth}
|
||||
testID="agent-mode-menu"
|
||||
>
|
||||
{agent.availableModes.map((mode) => {
|
||||
const isActive = mode.id === agent.currentModeId;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={mode.id}
|
||||
selected={isActive}
|
||||
onSelect={() => handleModeChange(mode.id)}
|
||||
>
|
||||
{mode.label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{/* Desktop: inline dropdowns for model/thinking */}
|
||||
{IS_WEB && (
|
||||
<View style={[styles.container, isWeb && { marginBottom: -theme.spacing[1] }]}>
|
||||
{isWeb ? (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
style={({ pressed, hovered, open }) => [
|
||||
styles.modeBadge,
|
||||
hovered && styles.modeBadgeHovered,
|
||||
(pressed || open) && styles.modeBadgePressed,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Select agent model"
|
||||
testID="agent-model-selector"
|
||||
>
|
||||
<Text style={styles.modeBadgeText}>{displayModel}</Text>
|
||||
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="top"
|
||||
align="start"
|
||||
maxWidth={dropdownMaxWidth}
|
||||
testID="agent-model-menu"
|
||||
>
|
||||
{models?.map((model) => {
|
||||
const isActive = model.id === activeModelId;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={model.id}
|
||||
selected={isActive}
|
||||
onSelect={() => {
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
void client.setAgentModel(agentId, model.id).catch((error) => {
|
||||
console.warn("[AgentStatusBar] setAgentModel failed", error);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{model.label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{thinkingOptions && thinkingOptions.length > 1 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
style={({ pressed, hovered, open }) => [
|
||||
{providerOptions && providerOptions.length > 0 ? (
|
||||
<>
|
||||
<Pressable
|
||||
ref={providerAnchorRef}
|
||||
collapsable={false}
|
||||
disabled={disabled || !canSelectProvider}
|
||||
onPress={() => setOpenSelector(openSelector === 'provider' ? null : 'provider')}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.modeBadge,
|
||||
hovered && styles.modeBadgeHovered,
|
||||
(pressed || open) && styles.modeBadgePressed,
|
||||
(pressed || openSelector === 'provider') && styles.modeBadgePressed,
|
||||
(disabled || !canSelectProvider) && styles.disabledBadge,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Select agent provider"
|
||||
testID="agent-provider-selector"
|
||||
>
|
||||
<Text style={styles.modeBadgeText}>{displayProvider}</Text>
|
||||
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
<Combobox
|
||||
options={comboboxProviderOptions}
|
||||
value={selectedProviderId ?? ''}
|
||||
onSelect={(id) => onSelectProvider?.(id)}
|
||||
searchable={comboboxProviderOptions.length > SEARCH_THRESHOLD}
|
||||
open={openSelector === 'provider'}
|
||||
onOpenChange={handleOpenChange('provider')}
|
||||
anchorRef={providerAnchorRef}
|
||||
desktopPlacement="top-start"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{modeOptions && modeOptions.length > 0 ? (
|
||||
<>
|
||||
<Pressable
|
||||
ref={modeAnchorRef}
|
||||
collapsable={false}
|
||||
disabled={disabled || !canSelectMode}
|
||||
onPress={() => setOpenSelector(openSelector === 'mode' ? null : 'mode')}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.modeBadge,
|
||||
hovered && styles.modeBadgeHovered,
|
||||
(pressed || openSelector === 'mode') && styles.modeBadgePressed,
|
||||
(disabled || !canSelectMode) && styles.disabledBadge,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Select agent mode"
|
||||
testID="agent-mode-selector"
|
||||
>
|
||||
<Text style={styles.modeBadgeText}>{displayMode}</Text>
|
||||
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
<Combobox
|
||||
options={comboboxModeOptions}
|
||||
value={selectedModeId ?? ''}
|
||||
onSelect={(id) => onSelectMode?.(id)}
|
||||
searchable={comboboxModeOptions.length > SEARCH_THRESHOLD}
|
||||
open={openSelector === 'mode'}
|
||||
onOpenChange={handleOpenChange('mode')}
|
||||
anchorRef={modeAnchorRef}
|
||||
desktopPlacement="top-start"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Pressable
|
||||
ref={modelAnchorRef}
|
||||
collapsable={false}
|
||||
disabled={modelDisabled}
|
||||
onPress={() => setOpenSelector(openSelector === 'model' ? null : 'model')}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.modeBadge,
|
||||
hovered && styles.modeBadgeHovered,
|
||||
(pressed || openSelector === 'model') && styles.modeBadgePressed,
|
||||
modelDisabled && styles.disabledBadge,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Select agent model"
|
||||
testID="agent-model-selector"
|
||||
>
|
||||
<Text style={styles.modeBadgeText}>{displayModel}</Text>
|
||||
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
<Combobox
|
||||
options={comboboxModelOptions}
|
||||
value={selectedModelId ?? ''}
|
||||
onSelect={(id) => onSelectModel?.(id)}
|
||||
searchable={comboboxModelOptions.length > SEARCH_THRESHOLD}
|
||||
open={openSelector === 'model'}
|
||||
onOpenChange={handleOpenChange('model')}
|
||||
anchorRef={modelAnchorRef}
|
||||
desktopPlacement="top-start"
|
||||
/>
|
||||
|
||||
{thinkingOptions && thinkingOptions.length > 0 ? (
|
||||
<>
|
||||
<Pressable
|
||||
ref={thinkingAnchorRef}
|
||||
collapsable={false}
|
||||
disabled={disabled || !canSelectThinking}
|
||||
onPress={() => setOpenSelector(openSelector === 'thinking' ? null : 'thinking')}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.modeBadge,
|
||||
hovered && styles.modeBadgeHovered,
|
||||
(pressed || openSelector === 'thinking') && styles.modeBadgePressed,
|
||||
(disabled || !canSelectThinking) && styles.disabledBadge,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Select thinking option"
|
||||
@@ -212,42 +274,21 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
||||
/>
|
||||
<Text style={styles.modeBadgeText}>{displayThinking}</Text>
|
||||
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="top"
|
||||
align="start"
|
||||
maxWidth={dropdownMaxWidth}
|
||||
testID="agent-thinking-menu"
|
||||
>
|
||||
{thinkingOptions.map((opt) => {
|
||||
const isActive = opt.id === selectedThinkingId;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={opt.id}
|
||||
selected={isActive}
|
||||
onSelect={() => {
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
void client
|
||||
.setAgentThinkingOption(agentId, opt.id)
|
||||
.catch((error) => {
|
||||
console.warn("[AgentStatusBar] setAgentThinkingOption failed", error);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</Pressable>
|
||||
<Combobox
|
||||
options={comboboxThinkingOptions}
|
||||
value={selectedThinkingOptionId ?? ''}
|
||||
onSelect={(id) => onSelectThinkingOption?.(id)}
|
||||
searchable={comboboxThinkingOptions.length > SEARCH_THRESHOLD}
|
||||
open={openSelector === 'thinking'}
|
||||
onOpenChange={handleOpenChange('thinking')}
|
||||
anchorRef={thinkingAnchorRef}
|
||||
desktopPlacement="top-start"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Mobile: preferences button opens a bottom sheet */}
|
||||
{!IS_WEB && (
|
||||
) : (
|
||||
<>
|
||||
<Pressable
|
||||
onPress={() => setPrefsOpen(true)}
|
||||
@@ -268,13 +309,47 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
||||
onClose={() => setPrefsOpen(false)}
|
||||
testID="agent-preferences-sheet"
|
||||
>
|
||||
{agent.availableModes && agent.availableModes.length > 0 && (
|
||||
{providerOptions && providerOptions.length > 0 ? (
|
||||
<View style={styles.sheetSection}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
disabled={disabled || !canSelectProvider}
|
||||
style={({ pressed }) => [
|
||||
styles.sheetSelect,
|
||||
pressed && styles.sheetSelectPressed,
|
||||
(disabled || !canSelectProvider) && styles.disabledSheetSelect,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Select agent provider"
|
||||
testID="agent-preferences-provider"
|
||||
>
|
||||
<Text style={styles.sheetSelectText}>{displayProvider}</Text>
|
||||
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="top" align="start">
|
||||
{providerOptions.map((provider) => (
|
||||
<DropdownMenuItem
|
||||
key={provider.id}
|
||||
selected={provider.id === selectedProviderId}
|
||||
onSelect={() => onSelectProvider?.(provider.id)}
|
||||
>
|
||||
{provider.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{modeOptions && modeOptions.length > 0 ? (
|
||||
<View style={styles.sheetSection}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
disabled={disabled || !canSelectMode}
|
||||
style={({ pressed }) => [
|
||||
styles.sheetSelect,
|
||||
pressed && styles.sheetSelectPressed,
|
||||
(disabled || !canSelectMode) && styles.disabledSheetSelect,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Select agent mode"
|
||||
@@ -284,29 +359,28 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
||||
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="top" align="start">
|
||||
{agent.availableModes.map((mode) => {
|
||||
const isActive = mode.id === agent.currentModeId;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={mode.id}
|
||||
selected={isActive}
|
||||
onSelect={() => handleModeChange(mode.id)}
|
||||
>
|
||||
{mode.label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
{modeOptions.map((mode) => (
|
||||
<DropdownMenuItem
|
||||
key={mode.id}
|
||||
selected={mode.id === selectedModeId}
|
||||
onSelect={() => onSelectMode?.(mode.id)}
|
||||
>
|
||||
{mode.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</View>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
<View style={styles.sheetSection}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
disabled={modelDisabled}
|
||||
style={({ pressed }) => [
|
||||
styles.sheetSelect,
|
||||
pressed && styles.sheetSelectPressed,
|
||||
modelDisabled && styles.disabledSheetSelect,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Select agent model"
|
||||
@@ -316,36 +390,28 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
||||
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="top" align="start">
|
||||
{models?.map((model) => {
|
||||
const isActive = model.id === activeModelId;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={model.id}
|
||||
selected={isActive}
|
||||
onSelect={() => {
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
void client.setAgentModel(agentId, model.id).catch((error) => {
|
||||
console.warn("[AgentStatusBar] setAgentModel failed", error);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{model.label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
{(modelOptions ?? []).map((model) => (
|
||||
<DropdownMenuItem
|
||||
key={model.id}
|
||||
selected={model.id === selectedModelId}
|
||||
onSelect={() => onSelectModel?.(model.id)}
|
||||
>
|
||||
{model.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</View>
|
||||
|
||||
{thinkingOptions && thinkingOptions.length > 1 && (
|
||||
{thinkingOptions && thinkingOptions.length > 0 ? (
|
||||
<View style={styles.sheetSection}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
disabled={disabled || !canSelectThinking}
|
||||
style={({ pressed }) => [
|
||||
styles.sheetSelect,
|
||||
pressed && styles.sheetSelectPressed,
|
||||
(disabled || !canSelectThinking) && styles.disabledSheetSelect,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Select thinking option"
|
||||
@@ -355,53 +421,209 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
||||
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="top" align="start">
|
||||
{thinkingOptions.map((opt) => {
|
||||
const isActive = opt.id === selectedThinkingId;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={opt.id}
|
||||
selected={isActive}
|
||||
onSelect={() => {
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
void client
|
||||
.setAgentThinkingOption(agentId, opt.id)
|
||||
.catch((error) => {
|
||||
console.warn("[AgentStatusBar] setAgentThinkingOption failed", error);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
{thinkingOptions.map((thinking) => (
|
||||
<DropdownMenuItem
|
||||
key={thinking.id}
|
||||
selected={thinking.id === selectedThinkingOptionId}
|
||||
onSelect={() => onSelectThinkingOption?.(thinking.id)}
|
||||
>
|
||||
{thinking.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</View>
|
||||
)}
|
||||
|
||||
) : null}
|
||||
</AdaptiveModalSheet>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
||||
const agent = useSessionStore((state) => state.sessions[serverId]?.agents?.get(agentId))
|
||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null)
|
||||
|
||||
const modelsQuery = useQuery({
|
||||
queryKey: [
|
||||
'providerModels',
|
||||
serverId,
|
||||
agent?.provider ?? '__missing_provider__',
|
||||
agent?.cwd ?? '__missing_cwd__',
|
||||
],
|
||||
enabled: Boolean(client && agent?.provider),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
queryFn: async () => {
|
||||
if (!client || !agent) {
|
||||
throw new Error('Daemon client unavailable')
|
||||
}
|
||||
const payload = await client.listProviderModels(agent.provider, { cwd: agent.cwd })
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error)
|
||||
}
|
||||
return payload.models ?? []
|
||||
},
|
||||
})
|
||||
|
||||
const models = modelsQuery.data ?? null
|
||||
|
||||
const displayMode =
|
||||
agent?.availableModes?.find((mode) => mode.id === agent.currentModeId)?.label ||
|
||||
agent?.currentModeId ||
|
||||
'default'
|
||||
|
||||
const modelSelection = resolveAgentModelSelection({
|
||||
models,
|
||||
runtimeModelId: agent?.runtimeInfo?.model,
|
||||
configuredModelId: agent?.model,
|
||||
explicitThinkingOptionId: agent?.thinkingOptionId,
|
||||
})
|
||||
|
||||
const modeOptions = useMemo<StatusOption[]>(() => {
|
||||
return (agent?.availableModes ?? []).map((mode) => ({
|
||||
id: mode.id,
|
||||
label: mode.label,
|
||||
}))
|
||||
}, [agent?.availableModes])
|
||||
|
||||
const modelOptions = useMemo<StatusOption[]>(() => {
|
||||
return (models ?? []).map((model) => ({ id: model.id, label: model.label }))
|
||||
}, [models])
|
||||
|
||||
const thinkingOptions = useMemo<StatusOption[]>(() => {
|
||||
return (modelSelection.thinkingOptions ?? []).map((option) => ({
|
||||
id: option.id,
|
||||
label: option.label,
|
||||
}))
|
||||
}, [modelSelection.thinkingOptions])
|
||||
|
||||
if (!agent) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<ControlledStatusBar
|
||||
modeOptions={
|
||||
modeOptions.length > 0
|
||||
? modeOptions
|
||||
: [{ id: agent.currentModeId ?? '', label: displayMode }]
|
||||
}
|
||||
selectedModeId={agent.currentModeId ?? undefined}
|
||||
onSelectMode={(modeId) => {
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
void client.setAgentMode(agentId, modeId).catch((error) => {
|
||||
console.warn('[AgentStatusBar] setAgentMode failed', error)
|
||||
})
|
||||
}}
|
||||
modelOptions={modelOptions}
|
||||
selectedModelId={modelSelection.activeModelId ?? undefined}
|
||||
onSelectModel={(modelId) => {
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
void client.setAgentModel(agentId, modelId).catch((error) => {
|
||||
console.warn('[AgentStatusBar] setAgentModel failed', error)
|
||||
})
|
||||
}}
|
||||
thinkingOptions={thinkingOptions.length > 1 ? thinkingOptions : undefined}
|
||||
selectedThinkingOptionId={modelSelection.selectedThinkingId ?? undefined}
|
||||
onSelectThinkingOption={(thinkingOptionId) => {
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
void client.setAgentThinkingOption(agentId, thinkingOptionId).catch((error) => {
|
||||
console.warn('[AgentStatusBar] setAgentThinkingOption failed', error)
|
||||
})
|
||||
}}
|
||||
isModelLoading={modelsQuery.isPending || modelsQuery.isFetching}
|
||||
disabled={!client}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function DraftAgentStatusBar({
|
||||
providerDefinitions,
|
||||
selectedProvider,
|
||||
onSelectProvider,
|
||||
modeOptions,
|
||||
selectedMode,
|
||||
onSelectMode,
|
||||
models,
|
||||
selectedModel,
|
||||
onSelectModel,
|
||||
isModelLoading,
|
||||
thinkingOptions,
|
||||
selectedThinkingOptionId,
|
||||
onSelectThinkingOption,
|
||||
disabled = false,
|
||||
}: DraftAgentStatusBarProps) {
|
||||
const providerOptions = useMemo<StatusOption[]>(() => {
|
||||
return providerDefinitions.map((definition) => ({
|
||||
id: definition.id,
|
||||
label: definition.label,
|
||||
}))
|
||||
}, [providerDefinitions])
|
||||
|
||||
const mappedModeOptions = useMemo<StatusOption[]>(() => {
|
||||
if (modeOptions.length === 0) {
|
||||
return [{ id: '', label: 'Default' }]
|
||||
}
|
||||
return modeOptions.map((mode) => ({ id: mode.id, label: mode.label }))
|
||||
}, [modeOptions])
|
||||
|
||||
const modelOptions = useMemo<StatusOption[]>(() => {
|
||||
const options: StatusOption[] = [{ id: '', label: 'Auto' }]
|
||||
for (const model of models) {
|
||||
options.push({ id: model.id, label: model.label })
|
||||
}
|
||||
return options
|
||||
}, [models])
|
||||
|
||||
const mappedThinkingOptions = useMemo<StatusOption[]>(() => {
|
||||
return thinkingOptions.map((option) => ({ id: option.id, label: option.label }))
|
||||
}, [thinkingOptions])
|
||||
|
||||
const effectiveSelectedMode = selectedMode || mappedModeOptions[0]?.id || ''
|
||||
const effectiveSelectedThinkingOption =
|
||||
selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined
|
||||
|
||||
return (
|
||||
<ControlledStatusBar
|
||||
providerOptions={providerOptions}
|
||||
selectedProviderId={selectedProvider}
|
||||
onSelectProvider={(providerId) => onSelectProvider(providerId as AgentProvider)}
|
||||
modeOptions={mappedModeOptions}
|
||||
selectedModeId={effectiveSelectedMode}
|
||||
onSelectMode={onSelectMode}
|
||||
modelOptions={modelOptions}
|
||||
selectedModelId={selectedModel}
|
||||
onSelectModel={onSelectModel}
|
||||
isModelLoading={isModelLoading}
|
||||
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
|
||||
selectedThinkingOptionId={effectiveSelectedThinkingOption}
|
||||
onSelectThinkingOption={onSelectThinkingOption}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
modeBadge: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: "transparent",
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'transparent',
|
||||
gap: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius["2xl"],
|
||||
borderRadius: theme.borderRadius['2xl'],
|
||||
},
|
||||
modeBadgeHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
@@ -409,6 +631,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
modeBadgePressed: {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
disabledBadge: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
modeBadgeText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
@@ -418,8 +643,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
prefsButtonPressed: {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
@@ -428,9 +653,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
sheetSelect: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[3],
|
||||
@@ -442,10 +667,13 @@ const styles = StyleSheet.create((theme) => ({
|
||||
sheetSelectPressed: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
disabledSheetSelect: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
sheetSelectText: {
|
||||
flex: 1,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
},
|
||||
}));
|
||||
}))
|
||||
|
||||
45
packages/app/src/components/agent-status-bar.utils.ts
Normal file
45
packages/app/src/components/agent-status-bar.utils.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { AgentModelDefinition } from '@server/server/agent/agent-sdk-types'
|
||||
|
||||
export function normalizeModelId(modelId: string | null | undefined): string | null {
|
||||
const normalized = typeof modelId === 'string' ? modelId.trim() : ''
|
||||
if (!normalized || normalized.toLowerCase() === 'default') {
|
||||
return null
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function resolveAgentModelSelection(input: {
|
||||
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 selectedModel =
|
||||
models && preferredModelId ? models.find((model) => model.id === preferredModelId) ?? null : null
|
||||
|
||||
const activeModelId = selectedModel?.id ?? preferredModelId ?? null
|
||||
const displayModel = selectedModel?.label ?? preferredModelId ?? 'Auto'
|
||||
|
||||
const thinkingOptions = selectedModel?.thinkingOptions ?? null
|
||||
const selectedThinkingId =
|
||||
explicitThinkingOptionId && explicitThinkingOptionId !== 'default'
|
||||
? explicitThinkingOptionId
|
||||
: selectedModel?.defaultThinkingOptionId ?? null
|
||||
const selectedThinking = thinkingOptions?.find((option) => option.id === selectedThinkingId) ?? null
|
||||
const displayThinking =
|
||||
selectedThinking?.label ??
|
||||
(selectedThinkingId === 'default' ? 'Model default' : selectedThinkingId ?? 'auto')
|
||||
|
||||
return {
|
||||
selectedModel,
|
||||
activeModelId,
|
||||
displayModel,
|
||||
thinkingOptions,
|
||||
selectedThinkingId,
|
||||
displayThinking,
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,41 @@
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
AGENT_LIFECYCLE_STATUSES,
|
||||
type AgentLifecycleStatus,
|
||||
} from "@server/shared/agent-lifecycle";
|
||||
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import { getStatusDotColor } from "@/utils/status-dot-color";
|
||||
|
||||
export function AgentStatusDot({
|
||||
status,
|
||||
requiresAttention,
|
||||
attentionReason,
|
||||
pendingPermissionCount,
|
||||
showInactive = false,
|
||||
}: {
|
||||
status: string | null | undefined;
|
||||
requiresAttention: boolean | null | undefined;
|
||||
attentionReason?: "finished" | "error" | "permission" | null;
|
||||
pendingPermissionCount?: number;
|
||||
showInactive?: boolean;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
const isRunning = status === "running";
|
||||
const color = isRunning
|
||||
? theme.colors.palette.blue[500]
|
||||
: requiresAttention
|
||||
? theme.colors.success
|
||||
: showInactive
|
||||
? theme.colors.border
|
||||
: null;
|
||||
if (!status) {
|
||||
return null;
|
||||
}
|
||||
if (!isAgentLifecycleStatus(status)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bucket = deriveSidebarStateBucket({
|
||||
status,
|
||||
requiresAttention: Boolean(requiresAttention),
|
||||
attentionReason: attentionReason ?? null,
|
||||
pendingPermissionCount: pendingPermissionCount ?? 0,
|
||||
});
|
||||
const color = getStatusDotColor({ theme, bucket, showDoneAsInactive: showInactive });
|
||||
|
||||
if (!color) {
|
||||
return null;
|
||||
@@ -28,6 +44,10 @@ export function AgentStatusDot({
|
||||
return <View style={[styles.dot, { backgroundColor: color }]} />;
|
||||
}
|
||||
|
||||
function isAgentLifecycleStatus(value: string): value is AgentLifecycleStatus {
|
||||
return AGENT_LIFECYCLE_STATUSES.some((status) => status === value);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
dot: {
|
||||
width: 8,
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { buildAgentStreamRenderModel } from "./agent-stream-render-model";
|
||||
|
||||
function createTimestamp(seed: number): Date {
|
||||
return new Date(`2026-01-01T00:00:${seed.toString().padStart(2, "0")}.000Z`);
|
||||
}
|
||||
|
||||
function userMessage(id: string, seed: number): StreamItem {
|
||||
return {
|
||||
kind: "user_message",
|
||||
id,
|
||||
text: id,
|
||||
timestamp: createTimestamp(seed),
|
||||
};
|
||||
}
|
||||
|
||||
function assistantMessage(id: string, seed: number): StreamItem {
|
||||
return {
|
||||
kind: "assistant_message",
|
||||
id,
|
||||
text: id,
|
||||
timestamp: createTimestamp(seed),
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildAgentStreamRenderModel", () => {
|
||||
it("keeps head separate from committed history on desktop web", () => {
|
||||
const tail: StreamItem[] = [];
|
||||
for (let index = 0; index < 60; index += 1) {
|
||||
const seed = index * 2;
|
||||
tail.push(userMessage(`u${index}`, seed + 1));
|
||||
tail.push(assistantMessage(`a${index}`, seed + 2));
|
||||
}
|
||||
const head = [assistantMessage("live-a", 121)];
|
||||
|
||||
const model = buildAgentStreamRenderModel({
|
||||
tail,
|
||||
head,
|
||||
platform: "web",
|
||||
isMobileBreakpoint: false,
|
||||
});
|
||||
|
||||
expect(model.segments.historyVirtualized.length).toBeGreaterThan(0);
|
||||
expect(model.segments.historyMounted.length).toBeGreaterThan(0);
|
||||
expect(model.segments.liveHead.map((item) => item.id)).toEqual(["live-a"]);
|
||||
expect(model.history).not.toContain(head[0]);
|
||||
});
|
||||
|
||||
it("keeps the full committed tail mounted on mobile web", () => {
|
||||
const tail = [userMessage("u1", 1), assistantMessage("a1", 2)];
|
||||
const head = [assistantMessage("live-a", 3)];
|
||||
|
||||
const model = buildAgentStreamRenderModel({
|
||||
tail,
|
||||
head,
|
||||
platform: "web",
|
||||
isMobileBreakpoint: true,
|
||||
});
|
||||
|
||||
expect(model.segments.historyVirtualized).toHaveLength(0);
|
||||
expect(model.segments.historyMounted).toBe(tail);
|
||||
expect(model.segments.liveHead).toBe(head);
|
||||
});
|
||||
|
||||
it("reuses ordered committed history when only the live head changes", () => {
|
||||
const tail = [userMessage("u1", 1), assistantMessage("a1", 2)];
|
||||
const firstHead = [assistantMessage("live-a", 3)];
|
||||
const secondHead = [assistantMessage("live-b", 4)];
|
||||
|
||||
const first = buildAgentStreamRenderModel({
|
||||
tail,
|
||||
head: firstHead,
|
||||
platform: "native",
|
||||
isMobileBreakpoint: false,
|
||||
});
|
||||
const second = buildAgentStreamRenderModel({
|
||||
tail,
|
||||
head: secondHead,
|
||||
platform: "native",
|
||||
isMobileBreakpoint: false,
|
||||
});
|
||||
|
||||
expect(first.history).toBe(second.history);
|
||||
expect(first.segments.historyMounted).toBe(second.segments.historyMounted);
|
||||
expect(second.segments.liveHead.map((item) => item.id)).toEqual(["live-b"]);
|
||||
});
|
||||
});
|
||||
178
packages/app/src/components/agent-stream-render-model.ts
Normal file
178
packages/app/src/components/agent-stream-render-model.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import {
|
||||
findMountedWindowStart,
|
||||
getWebMountedRecentStreamItems,
|
||||
getWebPartialVirtualizationThreshold,
|
||||
} from "./agent-stream-web-virtualization";
|
||||
import {
|
||||
orderHeadForStreamRenderStrategy,
|
||||
orderTailForStreamRenderStrategy,
|
||||
resolveStreamRenderStrategy,
|
||||
} from "./stream-strategy";
|
||||
|
||||
export type StreamRenderSegments = {
|
||||
historyVirtualized: StreamItem[];
|
||||
historyMounted: StreamItem[];
|
||||
liveHead: StreamItem[];
|
||||
};
|
||||
|
||||
export type StreamHistoryBoundary = {
|
||||
hasVirtualizedHistory: boolean;
|
||||
hasMountedHistory: boolean;
|
||||
hasLiveHead: boolean;
|
||||
historyToHeadGap: number;
|
||||
};
|
||||
|
||||
export type StreamRenderAuxiliary = {
|
||||
pendingPermissions: ReactNode;
|
||||
workingIndicator: ReactNode;
|
||||
};
|
||||
|
||||
export type AgentStreamRenderModel = {
|
||||
history: StreamItem[];
|
||||
segments: StreamRenderSegments;
|
||||
boundary: StreamHistoryBoundary;
|
||||
auxiliary: StreamRenderAuxiliary;
|
||||
};
|
||||
|
||||
export type BuildAgentStreamRenderModelInput = {
|
||||
tail: StreamItem[];
|
||||
head: StreamItem[];
|
||||
platform: "web" | "native";
|
||||
isMobileBreakpoint: boolean;
|
||||
};
|
||||
|
||||
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
|
||||
const EMPTY_AUXILIARY: StreamRenderAuxiliary = {
|
||||
pendingPermissions: null,
|
||||
workingIndicator: null,
|
||||
};
|
||||
|
||||
const orderedTailCache = new WeakMap<StreamItem[], Map<string, StreamItem[]>>();
|
||||
const orderedHeadCache = new WeakMap<StreamItem[], Map<string, StreamItem[]>>();
|
||||
const splitHistoryCache = new WeakMap<
|
||||
StreamItem[],
|
||||
Map<string, Pick<AgentStreamRenderModel, "history" | "segments">>
|
||||
>();
|
||||
|
||||
function getOrderedItems(params: {
|
||||
cache: WeakMap<StreamItem[], Map<string, StreamItem[]>>;
|
||||
source: StreamItem[];
|
||||
cacheKey: string;
|
||||
order: (items: StreamItem[]) => StreamItem[];
|
||||
}): StreamItem[] {
|
||||
const { cache, source, cacheKey, order } = params;
|
||||
let cachedByKey = cache.get(source);
|
||||
if (!cachedByKey) {
|
||||
cachedByKey = new Map();
|
||||
cache.set(source, cachedByKey);
|
||||
}
|
||||
const cached = cachedByKey.get(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const ordered = order(source);
|
||||
cachedByKey.set(cacheKey, ordered);
|
||||
return ordered;
|
||||
}
|
||||
|
||||
function splitOrderedTail(params: {
|
||||
orderedTail: StreamItem[];
|
||||
platform: "web" | "native";
|
||||
isMobileBreakpoint: boolean;
|
||||
}): Pick<AgentStreamRenderModel, "history" | "segments"> {
|
||||
const { orderedTail, platform, isMobileBreakpoint } = params;
|
||||
const shouldSplitHistory =
|
||||
platform === "web" &&
|
||||
!isMobileBreakpoint &&
|
||||
orderedTail.length > getWebPartialVirtualizationThreshold();
|
||||
const cacheKey = `${platform}:${isMobileBreakpoint}:${getWebMountedRecentStreamItems()}:${shouldSplitHistory}`;
|
||||
let cachedByKey = splitHistoryCache.get(orderedTail);
|
||||
if (!cachedByKey) {
|
||||
cachedByKey = new Map();
|
||||
splitHistoryCache.set(orderedTail, cachedByKey);
|
||||
}
|
||||
const cached = cachedByKey.get(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
if (!shouldSplitHistory) {
|
||||
const unsplit = {
|
||||
history: orderedTail,
|
||||
segments: {
|
||||
historyVirtualized: EMPTY_STREAM_ITEMS,
|
||||
historyMounted: orderedTail,
|
||||
liveHead: EMPTY_STREAM_ITEMS,
|
||||
},
|
||||
} satisfies Pick<AgentStreamRenderModel, "history" | "segments">;
|
||||
cachedByKey.set(cacheKey, unsplit);
|
||||
return unsplit;
|
||||
}
|
||||
|
||||
const mountedWindowStart = findMountedWindowStart({
|
||||
items: orderedTail,
|
||||
minMountedCount: getWebMountedRecentStreamItems(),
|
||||
});
|
||||
const split = {
|
||||
history: orderedTail,
|
||||
segments: {
|
||||
historyVirtualized: orderedTail.slice(0, mountedWindowStart),
|
||||
historyMounted: orderedTail.slice(mountedWindowStart),
|
||||
liveHead: EMPTY_STREAM_ITEMS,
|
||||
},
|
||||
} satisfies Pick<AgentStreamRenderModel, "history" | "segments">;
|
||||
cachedByKey.set(cacheKey, split);
|
||||
return split;
|
||||
}
|
||||
|
||||
export function buildAgentStreamRenderModel(
|
||||
input: BuildAgentStreamRenderModelInput
|
||||
): AgentStreamRenderModel {
|
||||
const strategy = resolveStreamRenderStrategy({
|
||||
platform: input.platform === "web" ? "web" : "native",
|
||||
isMobileBreakpoint: input.isMobileBreakpoint,
|
||||
});
|
||||
const orderingCacheKey = `${input.platform}:${input.isMobileBreakpoint}`;
|
||||
const orderedTail = getOrderedItems({
|
||||
cache: orderedTailCache,
|
||||
source: input.tail,
|
||||
cacheKey: orderingCacheKey,
|
||||
order: (items) =>
|
||||
orderTailForStreamRenderStrategy({
|
||||
strategy,
|
||||
streamItems: items,
|
||||
}),
|
||||
});
|
||||
const orderedHead = getOrderedItems({
|
||||
cache: orderedHeadCache,
|
||||
source: input.head,
|
||||
cacheKey: orderingCacheKey,
|
||||
order: (items) =>
|
||||
orderHeadForStreamRenderStrategy({
|
||||
strategy,
|
||||
streamHead: items,
|
||||
}),
|
||||
});
|
||||
const splitHistory = splitOrderedTail({
|
||||
orderedTail,
|
||||
platform: input.platform,
|
||||
isMobileBreakpoint: input.isMobileBreakpoint,
|
||||
});
|
||||
|
||||
return {
|
||||
history: splitHistory.history,
|
||||
segments: {
|
||||
...splitHistory.segments,
|
||||
liveHead: orderedHead,
|
||||
},
|
||||
boundary: {
|
||||
hasVirtualizedHistory: splitHistory.segments.historyVirtualized.length > 0,
|
||||
hasMountedHistory: splitHistory.segments.historyMounted.length > 0,
|
||||
hasLiveHead: orderedHead.length > 0,
|
||||
historyToHeadGap: 0,
|
||||
},
|
||||
auxiliary: EMPTY_AUXILIARY,
|
||||
};
|
||||
}
|
||||
330
packages/app/src/components/agent-stream-render-strategy.test.ts
Normal file
330
packages/app/src/components/agent-stream-render-strategy.test.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import {
|
||||
collectAssistantTurnContentForStreamRenderStrategy,
|
||||
getBottomOffsetForStreamRenderStrategy,
|
||||
getStreamEdgeSlotProps,
|
||||
getStreamNeighborIndex,
|
||||
getStreamNeighborItem,
|
||||
isNearBottomForStreamRenderStrategy,
|
||||
orderHeadForStreamRenderStrategy,
|
||||
orderTailForStreamRenderStrategy,
|
||||
resolveBottomAnchorTransportBehavior,
|
||||
resolveStreamRenderStrategy,
|
||||
} from "./agent-stream-render-strategy";
|
||||
|
||||
function createTimestamp(seed: number): Date {
|
||||
return new Date(`2026-01-01T00:00:0${seed}.000Z`);
|
||||
}
|
||||
|
||||
function userMessage(id: string, text: string, seed: number): StreamItem {
|
||||
return {
|
||||
kind: "user_message",
|
||||
id,
|
||||
text,
|
||||
timestamp: createTimestamp(seed),
|
||||
};
|
||||
}
|
||||
|
||||
function assistantMessage(id: string, text: string, seed: number): StreamItem {
|
||||
return {
|
||||
kind: "assistant_message",
|
||||
id,
|
||||
text,
|
||||
timestamp: createTimestamp(seed),
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveStreamRenderStrategy", () => {
|
||||
it("uses forward_stream on web", () => {
|
||||
const strategy = resolveStreamRenderStrategy({
|
||||
platform: "web",
|
||||
isMobileBreakpoint: false,
|
||||
});
|
||||
|
||||
expect(strategy.shouldUseVirtualizedList()).toBe(false);
|
||||
expect(strategy.getFlatListInverted()).toBe(false);
|
||||
expect(strategy.getOverlayScrollbarInverted()).toBe(false);
|
||||
expect(strategy.shouldAnchorBottomOnContentSizeChange()).toBe(true);
|
||||
expect(strategy.getBottomAnchorTransportBehavior()).toEqual({
|
||||
verificationDelayFrames: 0,
|
||||
verificationRetryMode: "rescroll",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses inverted_stream on native", () => {
|
||||
const strategy = resolveStreamRenderStrategy({
|
||||
platform: "ios",
|
||||
isMobileBreakpoint: false,
|
||||
});
|
||||
|
||||
expect(strategy.shouldUseVirtualizedList()).toBe(true);
|
||||
expect(strategy.getFlatListInverted()).toBe(true);
|
||||
expect(strategy.getOverlayScrollbarInverted()).toBe(true);
|
||||
expect(strategy.shouldAnchorBottomOnContentSizeChange()).toBe(false);
|
||||
expect(strategy.getBottomAnchorTransportBehavior()).toEqual({
|
||||
verificationDelayFrames: 2,
|
||||
verificationRetryMode: "recheck",
|
||||
});
|
||||
});
|
||||
|
||||
it("delays native verification while viewport settling is in flight", () => {
|
||||
const strategy = resolveStreamRenderStrategy({
|
||||
platform: "ios",
|
||||
isMobileBreakpoint: false,
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveBottomAnchorTransportBehavior({
|
||||
strategy,
|
||||
isViewportSettling: true,
|
||||
})
|
||||
).toEqual({
|
||||
verificationDelayFrames: 4,
|
||||
verificationRetryMode: "recheck",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not inflate forward-stream verification delays during web resize", () => {
|
||||
const strategy = resolveStreamRenderStrategy({
|
||||
platform: "web",
|
||||
isMobileBreakpoint: false,
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveBottomAnchorTransportBehavior({
|
||||
strategy,
|
||||
isViewportSettling: true,
|
||||
})
|
||||
).toEqual({
|
||||
verificationDelayFrames: 0,
|
||||
verificationRetryMode: "rescroll",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("stream ordering", () => {
|
||||
const streamItems: StreamItem[] = [
|
||||
userMessage("u1", "user-1", 1),
|
||||
assistantMessage("a1", "assistant-1", 2),
|
||||
assistantMessage("a2", "assistant-2", 3),
|
||||
];
|
||||
|
||||
it("keeps forward_stream order unchanged for tail and head", () => {
|
||||
const strategy = resolveStreamRenderStrategy({
|
||||
platform: "web",
|
||||
isMobileBreakpoint: false,
|
||||
});
|
||||
|
||||
const tail = orderTailForStreamRenderStrategy({ strategy, streamItems });
|
||||
const head = orderHeadForStreamRenderStrategy({ strategy, streamHead: streamItems });
|
||||
|
||||
expect(tail.map((item) => item.id)).toEqual(["u1", "a1", "a2"]);
|
||||
expect(head.map((item) => item.id)).toEqual(["u1", "a1", "a2"]);
|
||||
});
|
||||
|
||||
it("reverses inverted_stream order for tail and head", () => {
|
||||
const strategy = resolveStreamRenderStrategy({
|
||||
platform: "android",
|
||||
isMobileBreakpoint: false,
|
||||
});
|
||||
|
||||
const tail = orderTailForStreamRenderStrategy({ strategy, streamItems });
|
||||
const head = orderHeadForStreamRenderStrategy({ strategy, streamHead: streamItems });
|
||||
|
||||
expect(tail.map((item) => item.id)).toEqual(["a2", "a1", "u1"]);
|
||||
expect(head.map((item) => item.id)).toEqual(["a2", "a1", "u1"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("neighbor and traversal semantics", () => {
|
||||
it("maps above/below indices for forward and inverted streams", () => {
|
||||
const forward = resolveStreamRenderStrategy({
|
||||
platform: "web",
|
||||
isMobileBreakpoint: false,
|
||||
});
|
||||
const inverted = resolveStreamRenderStrategy({
|
||||
platform: "ios",
|
||||
isMobileBreakpoint: false,
|
||||
});
|
||||
|
||||
expect(getStreamNeighborIndex({ strategy: forward, index: 3, relation: "above" })).toBe(2);
|
||||
expect(getStreamNeighborIndex({ strategy: forward, index: 3, relation: "below" })).toBe(4);
|
||||
expect(getStreamNeighborIndex({ strategy: inverted, index: 3, relation: "above" })).toBe(4);
|
||||
expect(getStreamNeighborIndex({ strategy: inverted, index: 3, relation: "below" })).toBe(2);
|
||||
});
|
||||
|
||||
it("collects assistant turn content with strategy traversal direction", () => {
|
||||
const chronological: StreamItem[] = [
|
||||
userMessage("u1", "user-1", 1),
|
||||
assistantMessage("a1", "assistant-1", 2),
|
||||
assistantMessage("a2", "assistant-2", 3),
|
||||
userMessage("u2", "user-2", 4),
|
||||
];
|
||||
|
||||
const forward = resolveStreamRenderStrategy({
|
||||
platform: "web",
|
||||
isMobileBreakpoint: false,
|
||||
});
|
||||
const forwardStartIndex = chronological.findIndex((item) => item.id === "a2");
|
||||
expect(
|
||||
collectAssistantTurnContentForStreamRenderStrategy({
|
||||
strategy: forward,
|
||||
items: chronological,
|
||||
startIndex: forwardStartIndex,
|
||||
})
|
||||
).toBe("assistant-1\n\nassistant-2");
|
||||
|
||||
const inverted = resolveStreamRenderStrategy({
|
||||
platform: "android",
|
||||
isMobileBreakpoint: false,
|
||||
});
|
||||
const invertedItems = orderTailForStreamRenderStrategy({
|
||||
strategy: inverted,
|
||||
streamItems: chronological,
|
||||
});
|
||||
const invertedStartIndex = invertedItems.findIndex((item) => item.id === "a2");
|
||||
expect(
|
||||
collectAssistantTurnContentForStreamRenderStrategy({
|
||||
strategy: inverted,
|
||||
items: invertedItems,
|
||||
startIndex: invertedStartIndex,
|
||||
})
|
||||
).toBe("assistant-1\n\nassistant-2");
|
||||
});
|
||||
|
||||
it("returns undefined neighbor when index would be out of bounds", () => {
|
||||
const forward = resolveStreamRenderStrategy({
|
||||
platform: "web",
|
||||
isMobileBreakpoint: false,
|
||||
});
|
||||
const items: StreamItem[] = [userMessage("u1", "user-1", 1)];
|
||||
|
||||
expect(
|
||||
getStreamNeighborItem({
|
||||
strategy: forward,
|
||||
items,
|
||||
index: 0,
|
||||
relation: "above",
|
||||
})
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
getStreamNeighborItem({
|
||||
strategy: forward,
|
||||
items,
|
||||
index: 0,
|
||||
relation: "below",
|
||||
})
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("scroll/bottom calculations", () => {
|
||||
it("computes near-bottom using forward_stream distance-from-bottom math", () => {
|
||||
const strategy = resolveStreamRenderStrategy({
|
||||
platform: "web",
|
||||
isMobileBreakpoint: false,
|
||||
});
|
||||
|
||||
expect(
|
||||
isNearBottomForStreamRenderStrategy({
|
||||
strategy,
|
||||
offsetY: 680,
|
||||
viewportHeight: 300,
|
||||
contentHeight: 1000,
|
||||
threshold: 24,
|
||||
})
|
||||
).toBe(true);
|
||||
expect(
|
||||
isNearBottomForStreamRenderStrategy({
|
||||
strategy,
|
||||
offsetY: 600,
|
||||
viewportHeight: 300,
|
||||
contentHeight: 1000,
|
||||
threshold: 24,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("computes near-bottom and scroll-to-bottom offset for inverted_stream", () => {
|
||||
const strategy = resolveStreamRenderStrategy({
|
||||
platform: "ios",
|
||||
isMobileBreakpoint: false,
|
||||
});
|
||||
|
||||
expect(
|
||||
isNearBottomForStreamRenderStrategy({
|
||||
strategy,
|
||||
offsetY: 12,
|
||||
viewportHeight: 300,
|
||||
contentHeight: 1000,
|
||||
threshold: 24,
|
||||
})
|
||||
).toBe(true);
|
||||
expect(
|
||||
isNearBottomForStreamRenderStrategy({
|
||||
strategy,
|
||||
offsetY: 40,
|
||||
viewportHeight: 300,
|
||||
contentHeight: 1000,
|
||||
threshold: 24,
|
||||
})
|
||||
).toBe(false);
|
||||
expect(
|
||||
getBottomOffsetForStreamRenderStrategy({
|
||||
strategy,
|
||||
viewportHeight: 300,
|
||||
contentHeight: 1000,
|
||||
})
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("maps scroll-to-bottom to max offset for forward_stream", () => {
|
||||
const strategy = resolveStreamRenderStrategy({
|
||||
platform: "web",
|
||||
isMobileBreakpoint: false,
|
||||
});
|
||||
|
||||
expect(
|
||||
getBottomOffsetForStreamRenderStrategy({
|
||||
strategy,
|
||||
viewportHeight: 320,
|
||||
contentHeight: 1000,
|
||||
})
|
||||
).toBe(680);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge slot semantics", () => {
|
||||
it("uses footer slot for forward_stream and header slot for inverted_stream", () => {
|
||||
const EdgeSlot = () => null;
|
||||
const forward = resolveStreamRenderStrategy({
|
||||
platform: "web",
|
||||
isMobileBreakpoint: false,
|
||||
});
|
||||
const inverted = resolveStreamRenderStrategy({
|
||||
platform: "android",
|
||||
isMobileBreakpoint: false,
|
||||
});
|
||||
|
||||
const forwardProps = getStreamEdgeSlotProps({
|
||||
strategy: forward,
|
||||
component: EdgeSlot,
|
||||
gapSize: 4,
|
||||
});
|
||||
const invertedProps = getStreamEdgeSlotProps({
|
||||
strategy: inverted,
|
||||
component: EdgeSlot,
|
||||
gapSize: 4,
|
||||
});
|
||||
|
||||
expect(forwardProps).toEqual({
|
||||
ListFooterComponent: EdgeSlot,
|
||||
ListFooterComponentStyle: { marginTop: 4 },
|
||||
});
|
||||
expect(invertedProps).toEqual({
|
||||
ListHeaderComponent: EdgeSlot,
|
||||
ListHeaderComponentStyle: { marginBottom: 4 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./stream-strategy";
|
||||
export * from "./agent-stream-render-model";
|
||||
@@ -1,21 +1,23 @@
|
||||
import { useEffect, useMemo, useRef, useState, useCallback } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Pressable,
|
||||
FlatList,
|
||||
ListRenderItemInfo,
|
||||
NativeScrollEvent,
|
||||
NativeSyntheticEvent,
|
||||
InteractionManager,
|
||||
Platform,
|
||||
ActivityIndicator,
|
||||
} from "react-native";
|
||||
import Markdown from "react-native-markdown-display";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useRouter } from "expo-router";
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
FadeOut,
|
||||
@@ -51,13 +53,23 @@ import { ToolCallDetailsContent } from "./tool-call-details";
|
||||
import { QuestionFormCard } from "./question-form-card";
|
||||
import { ToolCallSheetProvider } from "./tool-call-sheet";
|
||||
import {
|
||||
WebDesktopScrollbarOverlay,
|
||||
useWebDesktopScrollbarMetrics,
|
||||
} from "./web-desktop-scrollbar";
|
||||
buildAgentStreamRenderModel,
|
||||
collectAssistantTurnContentForStreamRenderStrategy,
|
||||
getStreamNeighborItem,
|
||||
resolveStreamRenderStrategy,
|
||||
type AgentStreamRenderModel,
|
||||
type StreamSegmentRenderers,
|
||||
type StreamViewportHandle,
|
||||
} from "./agent-stream-render-strategy";
|
||||
import {
|
||||
type BottomAnchorLocalRequest,
|
||||
type BottomAnchorRouteRequest,
|
||||
} from "./use-bottom-anchor-controller";
|
||||
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
||||
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||
import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf";
|
||||
import { getMarkdownListMarker } from "@/utils/markdown-list";
|
||||
import { buildHostWorkspaceFileRoute } from "@/utils/host-routes";
|
||||
|
||||
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
|
||||
const isToolSequenceItem = (item?: StreamItem) =>
|
||||
@@ -66,51 +78,45 @@ const AGENT_STREAM_LOG_TAG = "[AgentStreamView]";
|
||||
const STREAM_ITEM_LOG_MIN_COUNT = 200;
|
||||
const STREAM_ITEM_LOG_DELTA_THRESHOLD = 50;
|
||||
|
||||
function collectAssistantTurnContent(
|
||||
flatListData: StreamItem[],
|
||||
startIndex: number
|
||||
): string {
|
||||
const messages: string[] = [];
|
||||
for (let i = startIndex; i < flatListData.length; i++) {
|
||||
const currentItem = flatListData[i];
|
||||
if (currentItem.kind === "user_message") {
|
||||
break;
|
||||
}
|
||||
if (currentItem.kind === "assistant_message") {
|
||||
messages.push(currentItem.text);
|
||||
}
|
||||
}
|
||||
return messages.reverse().join("\n\n");
|
||||
export interface AgentStreamViewHandle {
|
||||
scrollToBottom(reason?: BottomAnchorLocalRequest["reason"]): void;
|
||||
prepareForViewportChange(): void;
|
||||
}
|
||||
|
||||
export interface AgentStreamViewProps {
|
||||
agentId: string;
|
||||
serverId?: string;
|
||||
agent: Agent;
|
||||
streamItems: StreamItem[];
|
||||
pendingPermissions: Map<string, PendingPermission>;
|
||||
routeBottomAnchorRequest?: BottomAnchorRouteRequest | null;
|
||||
isAuthoritativeHistoryReady?: boolean;
|
||||
}
|
||||
|
||||
export function AgentStreamView({
|
||||
export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamViewProps>(function AgentStreamView({
|
||||
agentId,
|
||||
serverId,
|
||||
agent,
|
||||
streamItems,
|
||||
pendingPermissions,
|
||||
}: AgentStreamViewProps) {
|
||||
const flatListRef = useRef<FlatList<StreamItem>>(null);
|
||||
routeBottomAnchorRequest = null,
|
||||
isAuthoritativeHistoryReady = true,
|
||||
}, ref) {
|
||||
const viewportRef = useRef<StreamViewportHandle | null>(null);
|
||||
const { theme } = useUnistyles();
|
||||
const router = useRouter();
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
|
||||
const insets = useSafeAreaInsets();
|
||||
const streamRenderStrategy = useMemo(
|
||||
() =>
|
||||
resolveStreamRenderStrategy({
|
||||
platform: Platform.OS,
|
||||
isMobileBreakpoint: isMobile,
|
||||
}),
|
||||
[isMobile]
|
||||
);
|
||||
const [isNearBottom, setIsNearBottom] = useState(true);
|
||||
const hasScrolledInitially = useRef(false);
|
||||
const hasAutoScrolledOnce = useRef(false);
|
||||
const isNearBottomRef = useRef(true);
|
||||
const pendingAutoScrollFrameRef = useRef<number | null>(null);
|
||||
const pendingAutoScrollAnimatedRef = useRef(false);
|
||||
const streamItemCountRef = useRef(0);
|
||||
const streamScrollbarMetrics = useWebDesktopScrollbarMetrics();
|
||||
const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState<Set<string>>(new Set());
|
||||
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
|
||||
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
||||
@@ -125,8 +131,13 @@ export function AgentStreamView({
|
||||
state.sessions[resolvedServerId]?.agentStreamHead?.get(agentId)
|
||||
);
|
||||
|
||||
const { requestDirectoryListing, requestFilePreview, selectExplorerEntry } =
|
||||
useFileExplorerActions(resolvedServerId);
|
||||
const workspaceRoot = agent.cwd?.trim() || "";
|
||||
const workspaceId = agent.projectPlacement?.checkout?.cwd?.trim() || workspaceRoot;
|
||||
const { requestDirectoryListing } = useFileExplorerActions({
|
||||
serverId: resolvedServerId,
|
||||
workspaceId,
|
||||
workspaceRoot,
|
||||
});
|
||||
// Keep entry/exit animations off on Android due to RN dispatchDraw crashes
|
||||
// tracked in react-native-reanimated#8422.
|
||||
const shouldDisableEntryExitAnimations = Platform.OS === "android";
|
||||
@@ -138,9 +149,7 @@ export function AgentStreamView({
|
||||
: FadeOut.duration(200);
|
||||
|
||||
useEffect(() => {
|
||||
hasScrolledInitially.current = false;
|
||||
hasAutoScrolledOnce.current = false;
|
||||
isNearBottomRef.current = true;
|
||||
setIsNearBottom(true);
|
||||
setExpandedInlineToolCallIds(new Set());
|
||||
}, [agentId]);
|
||||
|
||||
@@ -155,14 +164,20 @@ export function AgentStreamView({
|
||||
return;
|
||||
}
|
||||
|
||||
requestDirectoryListing(agentId, normalized.directory, {
|
||||
if (normalized.file) {
|
||||
const route = buildHostWorkspaceFileRoute(
|
||||
resolvedServerId,
|
||||
workspaceId,
|
||||
normalized.file
|
||||
);
|
||||
router.replace(route as any);
|
||||
return;
|
||||
}
|
||||
|
||||
void requestDirectoryListing(normalized.directory, {
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
});
|
||||
if (normalized.file) {
|
||||
selectExplorerEntry(agentId, normalized.file);
|
||||
requestFilePreview(agentId, normalized.file);
|
||||
}
|
||||
|
||||
setExplorerTabForCheckout({
|
||||
serverId: resolvedServerId,
|
||||
@@ -174,166 +189,82 @@ export function AgentStreamView({
|
||||
},
|
||||
[
|
||||
agent.cwd,
|
||||
agentId,
|
||||
requestDirectoryListing,
|
||||
requestFilePreview,
|
||||
selectExplorerEntry,
|
||||
setExplorerTabForCheckout,
|
||||
openFileExplorer,
|
||||
requestDirectoryListing,
|
||||
resolvedServerId,
|
||||
router,
|
||||
setExplorerTabForCheckout,
|
||||
workspaceId,
|
||||
]
|
||||
);
|
||||
|
||||
const handleScroll = useCallback(
|
||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
const { contentOffset } = event.nativeEvent;
|
||||
const threshold = Math.max(insets.bottom, 32);
|
||||
// In inverted list: scrollTop 0 = bottom, higher values = scrolled up
|
||||
const nearBottom = contentOffset.y <= threshold;
|
||||
|
||||
if (isNearBottomRef.current !== nearBottom) {
|
||||
isNearBottomRef.current = nearBottom;
|
||||
setIsNearBottom(nearBottom);
|
||||
}
|
||||
|
||||
if (showDesktopWebScrollbar) {
|
||||
streamScrollbarMetrics.onScroll(event);
|
||||
}
|
||||
const baseRenderModel = useMemo(() => {
|
||||
return buildAgentStreamRenderModel({
|
||||
tail: streamItems,
|
||||
head: streamHead ?? [],
|
||||
platform: Platform.OS === "web" ? "web" : "native",
|
||||
isMobileBreakpoint: isMobile,
|
||||
});
|
||||
}, [isMobile, streamHead, streamItems]);
|
||||
useImperativeHandle(ref, () => ({
|
||||
scrollToBottom(reason = "jump-to-bottom") {
|
||||
viewportRef.current?.scrollToBottom(reason);
|
||||
},
|
||||
[insets.bottom, showDesktopWebScrollbar, streamScrollbarMetrics]
|
||||
);
|
||||
|
||||
const scrollToBottomInternal = useCallback(
|
||||
({ animated }: { animated: boolean }) => {
|
||||
const list = flatListRef.current;
|
||||
if (!list) {
|
||||
return;
|
||||
}
|
||||
|
||||
list.scrollToOffset({ offset: 0, animated });
|
||||
isNearBottomRef.current = true;
|
||||
setIsNearBottom(true);
|
||||
prepareForViewportChange() {
|
||||
viewportRef.current?.prepareForViewportChange();
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const scheduleAutoScroll = useCallback(
|
||||
({ animated }: { animated: boolean }) => {
|
||||
pendingAutoScrollAnimatedRef.current =
|
||||
pendingAutoScrollAnimatedRef.current || animated;
|
||||
|
||||
if (pendingAutoScrollFrameRef.current !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingAutoScrollFrameRef.current = requestAnimationFrame(() => {
|
||||
pendingAutoScrollFrameRef.current = null;
|
||||
const shouldAnimate = pendingAutoScrollAnimatedRef.current;
|
||||
pendingAutoScrollAnimatedRef.current = false;
|
||||
scrollToBottomInternal({ animated: shouldAnimate });
|
||||
});
|
||||
},
|
||||
[scrollToBottomInternal]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pendingAutoScrollFrameRef.current !== null) {
|
||||
cancelAnimationFrame(pendingAutoScrollFrameRef.current);
|
||||
pendingAutoScrollFrameRef.current = null;
|
||||
}
|
||||
pendingAutoScrollAnimatedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (streamItems.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasAutoScrolledOnce.current) {
|
||||
const handle = InteractionManager.runAfterInteractions(() => {
|
||||
scrollToBottomInternal({ animated: false });
|
||||
hasAutoScrolledOnce.current = true;
|
||||
hasScrolledInitially.current = true;
|
||||
});
|
||||
return () => handle.cancel();
|
||||
}
|
||||
|
||||
if (!isNearBottomRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldAnimate = hasScrolledInitially.current;
|
||||
scheduleAutoScroll({ animated: shouldAnimate });
|
||||
hasScrolledInitially.current = true;
|
||||
}, [streamItems, scheduleAutoScroll]);
|
||||
}), []);
|
||||
|
||||
function scrollToBottom() {
|
||||
scrollToBottomInternal({ animated: true });
|
||||
isNearBottomRef.current = true;
|
||||
setIsNearBottom(true);
|
||||
viewportRef.current?.scrollToBottom("jump-to-bottom");
|
||||
}
|
||||
|
||||
const flatListData = useMemo(() => {
|
||||
return [...streamItems].reverse();
|
||||
}, [streamItems]);
|
||||
|
||||
const tightGap = theme.spacing[1]; // 4px
|
||||
const looseGap = theme.spacing[4]; // 16px
|
||||
|
||||
// The FlatList is inverted, but each row is re-inverted by RN, so `marginBottom` still
|
||||
// manifests as the gap *below* the row in the final visual layout. Compute spacing
|
||||
// against the item visually below (index - 1 in our inverted list).
|
||||
const getGapBelow = useCallback(
|
||||
(item: StreamItem, index: number) => {
|
||||
const belowItem = flatListData[index - 1];
|
||||
if (!belowItem) {
|
||||
// This is the bottommost (newest) item; nothing below it visually.
|
||||
const getGapBetween = useCallback(
|
||||
(item: StreamItem | null, belowItem: StreamItem | null) => {
|
||||
if (!item || !belowItem) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Same type groups get tight gap (4px)
|
||||
if (isUserMessageItem(item) && isUserMessageItem(belowItem)) {
|
||||
return tightGap;
|
||||
}
|
||||
|
||||
if (isToolSequenceItem(item) && isToolSequenceItem(belowItem)) {
|
||||
return tightGap;
|
||||
}
|
||||
|
||||
// Give user messages more breathing room before tool sequences.
|
||||
if (item.kind === "user_message" && isToolSequenceItem(belowItem)) {
|
||||
return looseGap;
|
||||
}
|
||||
|
||||
// Keep tool sequences visually connected to the preceding user/assistant message.
|
||||
if (
|
||||
(item.kind === "user_message" || item.kind === "assistant_message") &&
|
||||
isToolSequenceItem(belowItem)
|
||||
) {
|
||||
return tightGap;
|
||||
}
|
||||
|
||||
// Keep todo lists visually connected to the following tool sequence (symmetry).
|
||||
if (item.kind === "todo_list" && isToolSequenceItem(belowItem)) {
|
||||
return tightGap;
|
||||
}
|
||||
|
||||
// Keep tool sequences visually connected to the assistant response (symmetry).
|
||||
if (isToolSequenceItem(item) && belowItem.kind === "assistant_message") {
|
||||
return tightGap;
|
||||
}
|
||||
|
||||
// Different types get loose gap (16px)
|
||||
return looseGap;
|
||||
},
|
||||
[flatListData, looseGap, tightGap]
|
||||
[looseGap, tightGap]
|
||||
);
|
||||
|
||||
const renderStreamItemContent = useCallback(
|
||||
(item: StreamItem, index: number) => {
|
||||
(
|
||||
item: StreamItem,
|
||||
index: number,
|
||||
items: StreamItem[],
|
||||
seamAboveItem: StreamItem | null = null
|
||||
) => {
|
||||
const handleInlineDetailsExpandedChange = (expanded: boolean) => {
|
||||
if (Platform.OS !== "web") {
|
||||
if (
|
||||
!streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setExpandedInlineToolCallIds((previous) => {
|
||||
@@ -349,11 +280,21 @@ export function AgentStreamView({
|
||||
|
||||
switch (item.kind) {
|
||||
case "user_message": {
|
||||
// In inverted list: index+1 is the item above, index-1 is below.
|
||||
const prevItem = flatListData[index + 1];
|
||||
const nextItem = flatListData[index - 1];
|
||||
const isFirstInGroup = prevItem?.kind !== "user_message";
|
||||
const isLastInGroup = nextItem?.kind !== "user_message";
|
||||
const aboveItem =
|
||||
getStreamNeighborItem({
|
||||
strategy: streamRenderStrategy,
|
||||
items,
|
||||
index,
|
||||
relation: "above",
|
||||
}) ?? seamAboveItem ?? undefined;
|
||||
const belowItem = getStreamNeighborItem({
|
||||
strategy: streamRenderStrategy,
|
||||
items,
|
||||
index,
|
||||
relation: "below",
|
||||
});
|
||||
const isFirstInGroup = aboveItem?.kind !== "user_message";
|
||||
const isLastInGroup = belowItem?.kind !== "user_message";
|
||||
return (
|
||||
<UserMessage
|
||||
message={item.text}
|
||||
@@ -375,8 +316,12 @@ export function AgentStreamView({
|
||||
);
|
||||
|
||||
case "thought": {
|
||||
// In inverted list: index+1 is the item above, index-1 is below.
|
||||
const nextItem = flatListData[index - 1];
|
||||
const nextItem = getStreamNeighborItem({
|
||||
strategy: streamRenderStrategy,
|
||||
items,
|
||||
index,
|
||||
relation: "below",
|
||||
});
|
||||
const isLastInSequence =
|
||||
nextItem?.kind !== "tool_call" && nextItem?.kind !== "thought";
|
||||
return (
|
||||
@@ -392,8 +337,12 @@ export function AgentStreamView({
|
||||
|
||||
case "tool_call": {
|
||||
const { payload } = item;
|
||||
// In inverted list: index+1 is the item above, index-1 is below.
|
||||
const nextItem = flatListData[index - 1];
|
||||
const nextItem = getStreamNeighborItem({
|
||||
strategy: streamRenderStrategy,
|
||||
items,
|
||||
index,
|
||||
relation: "below",
|
||||
});
|
||||
const isLastInSequence =
|
||||
nextItem?.kind !== "tool_call" && nextItem?.kind !== "thought";
|
||||
|
||||
@@ -455,23 +404,38 @@ export function AgentStreamView({
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[handleInlinePathPress, agent.cwd, flatListData]
|
||||
[handleInlinePathPress, agent.cwd, streamRenderStrategy]
|
||||
);
|
||||
|
||||
const renderStreamItem = useCallback(
|
||||
({ item, index }: ListRenderItemInfo<StreamItem>) => {
|
||||
const content = renderStreamItemContent(item, index);
|
||||
(
|
||||
item: StreamItem,
|
||||
index: number,
|
||||
items: StreamItem[],
|
||||
seamAboveItem: StreamItem | null = null
|
||||
) => {
|
||||
const content = renderStreamItemContent(item, index, items, seamAboveItem);
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const gapBelow = getGapBelow(item, index);
|
||||
const nextItem = flatListData[index - 1];
|
||||
const nextItem = getStreamNeighborItem({
|
||||
strategy: streamRenderStrategy,
|
||||
items,
|
||||
index,
|
||||
relation: "below",
|
||||
});
|
||||
const gapBelow = getGapBetween(item, nextItem ?? null);
|
||||
const isEndOfAssistantTurn =
|
||||
item.kind === "assistant_message" &&
|
||||
(nextItem?.kind === "user_message" ||
|
||||
(nextItem === undefined && agent.status !== "running"));
|
||||
const getTurnContent = () => collectAssistantTurnContent(flatListData, index);
|
||||
const getTurnContent = () =>
|
||||
collectAssistantTurnContentForStreamRenderStrategy({
|
||||
strategy: streamRenderStrategy,
|
||||
items,
|
||||
startIndex: index,
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={[stylesheet.streamItemWrapper, { marginBottom: gapBelow }]}>
|
||||
@@ -482,7 +446,12 @@ export function AgentStreamView({
|
||||
</View>
|
||||
);
|
||||
},
|
||||
[getGapBelow, renderStreamItemContent, flatListData, agent.status]
|
||||
[
|
||||
getGapBetween,
|
||||
renderStreamItemContent,
|
||||
agent.status,
|
||||
streamRenderStrategy,
|
||||
]
|
||||
);
|
||||
|
||||
const pendingPermissionItems = useMemo(
|
||||
@@ -562,111 +531,58 @@ export function AgentStreamView({
|
||||
}, [agentId, pendingPermissionItems.length, streamHead, streamItems]);
|
||||
|
||||
const showWorkingIndicator = agent.status === "running";
|
||||
const showBottomBar = showWorkingIndicator;
|
||||
|
||||
const listHeaderComponent = useMemo(() => {
|
||||
const hasPermissions = pendingPermissionItems.length > 0;
|
||||
const hasHeadItems = streamHead && streamHead.length > 0;
|
||||
|
||||
if (!hasPermissions && !showBottomBar && !hasHeadItems) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const leftContent = showWorkingIndicator ? <WorkingIndicator /> : null;
|
||||
|
||||
return (
|
||||
<View style={stylesheet.contentWrapper}>
|
||||
<View
|
||||
style={[
|
||||
stylesheet.listHeaderContent,
|
||||
// In an inverted FlatList, the header is rendered at the visual bottom, so its
|
||||
// top edge can sit directly against the newest timeline item. Add a small
|
||||
// padding to prevent badges (e.g. Thinking/Setup) from butting up against
|
||||
// the last user message when the streaming head is present.
|
||||
hasHeadItems ? { paddingTop: tightGap } : null,
|
||||
]}
|
||||
>
|
||||
{hasPermissions ? (
|
||||
<View style={stylesheet.permissionsContainer}>
|
||||
{pendingPermissionItems.map((permission) => (
|
||||
<PermissionRequestCard
|
||||
key={permission.key}
|
||||
permission={permission}
|
||||
client={client}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{hasHeadItems
|
||||
? [...streamHead].reverse().map((item, index) => {
|
||||
const rendered = renderStreamItemContent(item, index);
|
||||
return rendered ? (
|
||||
<View key={item.id} style={stylesheet.streamItemWrapper}>
|
||||
{rendered}
|
||||
</View>
|
||||
) : null;
|
||||
})
|
||||
: null}
|
||||
|
||||
{showBottomBar ? <View style={stylesheet.bottomBarWrapper}>{leftContent}</View> : null}
|
||||
const renderModel = useMemo<AgentStreamRenderModel>(() => {
|
||||
const pendingPermissionsNode =
|
||||
pendingPermissionItems.length > 0 ? (
|
||||
<View style={stylesheet.permissionsContainer}>
|
||||
{pendingPermissionItems.map((permission) => (
|
||||
<PermissionRequestCard
|
||||
key={permission.key}
|
||||
permission={permission}
|
||||
client={client}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : null;
|
||||
const workingIndicatorNode = showWorkingIndicator ? (
|
||||
<View style={stylesheet.bottomBarWrapper}>
|
||||
<WorkingIndicator />
|
||||
</View>
|
||||
);
|
||||
) : null;
|
||||
|
||||
return {
|
||||
...baseRenderModel,
|
||||
boundary: {
|
||||
...baseRenderModel.boundary,
|
||||
historyToHeadGap: getGapBetween(
|
||||
baseRenderModel.history.at(-1) ?? null,
|
||||
baseRenderModel.segments.liveHead[0] ?? null
|
||||
),
|
||||
},
|
||||
auxiliary: {
|
||||
pendingPermissions: pendingPermissionsNode,
|
||||
workingIndicator: workingIndicatorNode,
|
||||
},
|
||||
};
|
||||
}, [
|
||||
baseRenderModel,
|
||||
client,
|
||||
getGapBetween,
|
||||
pendingPermissionItems,
|
||||
showWorkingIndicator,
|
||||
client,
|
||||
streamHead,
|
||||
renderStreamItemContent,
|
||||
tightGap,
|
||||
showBottomBar,
|
||||
]);
|
||||
|
||||
const flatListExtraData = useMemo(
|
||||
() => ({
|
||||
pendingPermissionCount: pendingPermissionItems.length,
|
||||
showWorkingIndicator,
|
||||
showBottomBar,
|
||||
}),
|
||||
[
|
||||
pendingPermissionItems.length,
|
||||
showWorkingIndicator,
|
||||
showBottomBar,
|
||||
]
|
||||
);
|
||||
|
||||
// FlatList's ListHeaderComponent renders at the *bottom* when inverted.
|
||||
// Without explicit spacing, the newest "head" rows (like Thinking/Setup tool badges)
|
||||
// can butt up directly against the most recent stream item.
|
||||
const headerGapStyle = useMemo(() => {
|
||||
if (!listHeaderComponent) {
|
||||
return undefined;
|
||||
}
|
||||
return { marginBottom: tightGap };
|
||||
}, [listHeaderComponent, tightGap]);
|
||||
|
||||
const listEmptyComponent = useMemo(() => {
|
||||
const hasPermissions = pendingPermissionItems.length > 0;
|
||||
const hasHeadItems = (streamHead?.length ?? 0) > 0;
|
||||
if (hasPermissions || hasHeadItems) {
|
||||
if (
|
||||
renderModel.boundary.hasVirtualizedHistory ||
|
||||
renderModel.boundary.hasMountedHistory ||
|
||||
renderModel.boundary.hasLiveHead ||
|
||||
renderModel.auxiliary.pendingPermissions ||
|
||||
renderModel.auxiliary.workingIndicator
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const shouldShowWorking = agent.status === "running";
|
||||
|
||||
if (shouldShowWorking) {
|
||||
return (
|
||||
<View style={[stylesheet.emptyState, stylesheet.contentWrapper]}>
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
<Text style={stylesheet.emptyStateText}>Working…</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[stylesheet.emptyState, stylesheet.contentWrapper]}>
|
||||
<Text style={stylesheet.emptyStateText}>
|
||||
@@ -674,93 +590,137 @@ export function AgentStreamView({
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}, [
|
||||
agent.status,
|
||||
pendingPermissionItems.length,
|
||||
streamHead,
|
||||
theme.colors.foregroundMuted,
|
||||
]);
|
||||
}, [renderModel]);
|
||||
|
||||
const historyItems = renderModel.history;
|
||||
const liveHeadItems = renderModel.segments.liveHead;
|
||||
const { boundary, auxiliary } = renderModel;
|
||||
const lastHistoryItem = historyItems.at(-1) ?? null;
|
||||
|
||||
const historyIndexById = useMemo(() => {
|
||||
const indexById = new Map<string, number>();
|
||||
historyItems.forEach((item, index) => {
|
||||
indexById.set(item.id, index);
|
||||
});
|
||||
return indexById;
|
||||
}, [historyItems]);
|
||||
|
||||
const renderHistoryRow = useCallback(
|
||||
(item: StreamItem) => {
|
||||
const historyIndex = historyIndexById.get(item.id);
|
||||
if (historyIndex === undefined) {
|
||||
return null;
|
||||
}
|
||||
return renderStreamItem(item, historyIndex, historyItems);
|
||||
},
|
||||
[historyIndexById, historyItems, renderStreamItem]
|
||||
);
|
||||
|
||||
const renderHistoryVirtualizedRow = useCallback<StreamSegmentRenderers["renderHistoryVirtualizedRow"]>(
|
||||
(item) => renderHistoryRow(item),
|
||||
[renderHistoryRow]
|
||||
);
|
||||
const renderHistoryMountedRow = useCallback<StreamSegmentRenderers["renderHistoryMountedRow"]>(
|
||||
(item) => renderHistoryRow(item),
|
||||
[renderHistoryRow]
|
||||
);
|
||||
const renderLiveHeadRow = useCallback<StreamSegmentRenderers["renderLiveHeadRow"]>(
|
||||
(item, index, items) =>
|
||||
renderStreamItem(item, index, items, index === 0 ? lastHistoryItem : null),
|
||||
[lastHistoryItem, renderStreamItem]
|
||||
);
|
||||
const renderLiveAuxiliary = useCallback<StreamSegmentRenderers["renderLiveAuxiliary"]>(
|
||||
() => {
|
||||
if (!auxiliary.pendingPermissions && !auxiliary.workingIndicator) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<View style={stylesheet.contentWrapper}>
|
||||
<View
|
||||
style={[
|
||||
stylesheet.listHeaderContent,
|
||||
boundary.hasLiveHead ? { paddingTop: tightGap } : null,
|
||||
]}
|
||||
>
|
||||
{auxiliary.pendingPermissions}
|
||||
{auxiliary.workingIndicator}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
[
|
||||
auxiliary.pendingPermissions,
|
||||
auxiliary.workingIndicator,
|
||||
boundary.hasLiveHead,
|
||||
tightGap,
|
||||
]
|
||||
);
|
||||
|
||||
const renderers = useMemo<StreamSegmentRenderers>(
|
||||
() => ({
|
||||
renderHistoryVirtualizedRow,
|
||||
renderHistoryMountedRow,
|
||||
renderLiveHeadRow,
|
||||
renderLiveAuxiliary,
|
||||
}),
|
||||
[
|
||||
renderHistoryVirtualizedRow,
|
||||
renderHistoryMountedRow,
|
||||
renderLiveHeadRow,
|
||||
renderLiveAuxiliary,
|
||||
]
|
||||
);
|
||||
|
||||
const streamScrollEnabled =
|
||||
!streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion() ||
|
||||
expandedInlineToolCallIds.size === 0;
|
||||
|
||||
return (
|
||||
<ToolCallSheetProvider>
|
||||
<View style={stylesheet.container}>
|
||||
<MessageOuterSpacingProvider disableOuterSpacing>
|
||||
<FlatList
|
||||
ref={flatListRef}
|
||||
data={flatListData}
|
||||
renderItem={renderStreamItem}
|
||||
keyExtractor={(item) => item.id}
|
||||
testID="agent-chat-scroll"
|
||||
ListHeaderComponentStyle={headerGapStyle}
|
||||
contentContainerStyle={{
|
||||
paddingVertical: 0,
|
||||
flexGrow: 1,
|
||||
}}
|
||||
style={stylesheet.list}
|
||||
onLayout={
|
||||
showDesktopWebScrollbar
|
||||
? streamScrollbarMetrics.onLayout
|
||||
: undefined
|
||||
}
|
||||
onScroll={handleScroll}
|
||||
scrollEventThrottle={16}
|
||||
onContentSizeChange={
|
||||
showDesktopWebScrollbar
|
||||
? streamScrollbarMetrics.onContentSizeChange
|
||||
: undefined
|
||||
}
|
||||
ListEmptyComponent={listEmptyComponent}
|
||||
ListHeaderComponent={listHeaderComponent}
|
||||
extraData={flatListExtraData}
|
||||
maintainVisibleContentPosition={
|
||||
// Disable when streaming and user is at bottom - we handle auto-scroll ourselves
|
||||
agent.status === "running" && isNearBottom
|
||||
? undefined
|
||||
: { minIndexForVisible: 0, autoscrollToTopThreshold: 40 }
|
||||
}
|
||||
initialNumToRender={12}
|
||||
windowSize={10}
|
||||
scrollEnabled={Platform.OS !== "web" || expandedInlineToolCallIds.size === 0}
|
||||
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||
inverted
|
||||
/>
|
||||
</MessageOuterSpacingProvider>
|
||||
<WebDesktopScrollbarOverlay
|
||||
enabled={showDesktopWebScrollbar}
|
||||
metrics={streamScrollbarMetrics}
|
||||
inverted
|
||||
onScrollToOffset={(nextOffset) => {
|
||||
flatListRef.current?.scrollToOffset({
|
||||
offset: nextOffset,
|
||||
animated: false,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Scroll to bottom button */}
|
||||
{!isNearBottom && (
|
||||
<Animated.View
|
||||
style={stylesheet.scrollToBottomContainer}
|
||||
entering={scrollIndicatorFadeIn}
|
||||
exiting={scrollIndicatorFadeOut}
|
||||
>
|
||||
<View style={stylesheet.scrollToBottomInner}>
|
||||
<Pressable
|
||||
style={stylesheet.scrollToBottomButton}
|
||||
onPress={scrollToBottom}
|
||||
>
|
||||
<ChevronDown
|
||||
size={24}
|
||||
color={stylesheet.scrollToBottomIcon.color}
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Animated.View>
|
||||
)}
|
||||
</View>
|
||||
<MessageOuterSpacingProvider disableOuterSpacing>
|
||||
{streamRenderStrategy.render({
|
||||
agentId,
|
||||
segments: renderModel.segments,
|
||||
boundary,
|
||||
renderers,
|
||||
listEmptyComponent,
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest,
|
||||
isAuthoritativeHistoryReady,
|
||||
onNearBottomChange: setIsNearBottom,
|
||||
scrollEnabled: streamScrollEnabled,
|
||||
listStyle: stylesheet.list,
|
||||
baseListContentContainerStyle: stylesheet.listContentContainer,
|
||||
forwardListContentContainerStyle: stylesheet.forwardListContentContainer,
|
||||
})}
|
||||
</MessageOuterSpacingProvider>
|
||||
{!isNearBottom && (
|
||||
<Animated.View
|
||||
style={stylesheet.scrollToBottomContainer}
|
||||
entering={scrollIndicatorFadeIn}
|
||||
exiting={scrollIndicatorFadeOut}
|
||||
>
|
||||
<View style={stylesheet.scrollToBottomInner}>
|
||||
<Pressable
|
||||
style={stylesheet.scrollToBottomButton}
|
||||
onPress={scrollToBottom}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Scroll to bottom"
|
||||
testID="scroll-to-bottom-button"
|
||||
>
|
||||
<ChevronDown
|
||||
size={24}
|
||||
color={stylesheet.scrollToBottomIcon.color}
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Animated.View>
|
||||
)}
|
||||
</View>
|
||||
</ToolCallSheetProvider>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function normalizeInlinePath(
|
||||
rawPath: string,
|
||||
@@ -1284,13 +1244,21 @@ const stylesheet = StyleSheet.create((theme) => ({
|
||||
maxWidth: MAX_CONTENT_WIDTH,
|
||||
alignSelf: "center",
|
||||
},
|
||||
list: {
|
||||
flex: 1,
|
||||
listContentContainer: {
|
||||
paddingVertical: 0,
|
||||
flexGrow: 1,
|
||||
paddingHorizontal: {
|
||||
xs: theme.spacing[2],
|
||||
md: theme.spacing[4],
|
||||
},
|
||||
},
|
||||
forwardListContentContainer: {
|
||||
paddingTop: theme.spacing[4],
|
||||
paddingBottom: theme.spacing[4],
|
||||
},
|
||||
list: {
|
||||
flex: 1,
|
||||
},
|
||||
streamItemWrapper: {
|
||||
width: "100%",
|
||||
maxWidth: MAX_CONTENT_WIDTH,
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import {
|
||||
DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS,
|
||||
DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD,
|
||||
estimateStreamItemHeight,
|
||||
findMountedWindowStart,
|
||||
getWebMountedRecentStreamItems,
|
||||
getWebPartialVirtualizationThreshold,
|
||||
splitWebVirtualizedHistory,
|
||||
type IndexedStreamItem,
|
||||
} from "./agent-stream-web-virtualization";
|
||||
|
||||
function createTimestamp(seed: number): Date {
|
||||
return new Date(`2026-01-01T00:00:${seed.toString().padStart(2, "0")}.000Z`);
|
||||
}
|
||||
|
||||
function userMessage(id: string, seed: number): StreamItem {
|
||||
return {
|
||||
kind: "user_message",
|
||||
id,
|
||||
text: id,
|
||||
timestamp: createTimestamp(seed),
|
||||
};
|
||||
}
|
||||
|
||||
function assistantMessage(id: string, seed: number): StreamItem {
|
||||
return {
|
||||
kind: "assistant_message",
|
||||
id,
|
||||
text: id,
|
||||
timestamp: createTimestamp(seed),
|
||||
};
|
||||
}
|
||||
|
||||
function toolCall(id: string, seed: number): StreamItem {
|
||||
return {
|
||||
kind: "tool_call",
|
||||
id,
|
||||
timestamp: createTimestamp(seed),
|
||||
payload: {
|
||||
source: "orchestrator",
|
||||
data: {
|
||||
toolCallId: id,
|
||||
toolName: "test_tool",
|
||||
arguments: {},
|
||||
status: "completed",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function indexEntries(items: StreamItem[]): IndexedStreamItem[] {
|
||||
return items.map((item, index) => ({ item, index }));
|
||||
}
|
||||
|
||||
describe("findMountedWindowStart", () => {
|
||||
it("keeps all items mounted when the chat is below the threshold", () => {
|
||||
const items = [userMessage("u1", 1), assistantMessage("a1", 2)];
|
||||
|
||||
expect(
|
||||
findMountedWindowStart({
|
||||
items,
|
||||
minMountedCount: 50,
|
||||
})
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("rewinds to the previous user boundary when the cutoff lands inside a turn", () => {
|
||||
const items: StreamItem[] = [];
|
||||
for (let index = 0; index < 30; index += 1) {
|
||||
const seed = index * 3;
|
||||
items.push(userMessage(`u${index}`, seed + 1));
|
||||
items.push(toolCall(`t${index}`, seed + 2));
|
||||
items.push(assistantMessage(`a${index}`, seed + 3));
|
||||
}
|
||||
|
||||
expect(
|
||||
findMountedWindowStart({
|
||||
items,
|
||||
minMountedCount: 50,
|
||||
})
|
||||
).toBe(39);
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitWebVirtualizedHistory", () => {
|
||||
it("splits older entries into the virtualized section and keeps the recent window mounted", () => {
|
||||
const items: StreamItem[] = [];
|
||||
for (let index = 0; index < 30; index += 1) {
|
||||
const seed = index * 2;
|
||||
items.push(userMessage(`u${index}`, seed + 1));
|
||||
items.push(assistantMessage(`a${index}`, seed + 2));
|
||||
}
|
||||
|
||||
const window = splitWebVirtualizedHistory({
|
||||
entries: indexEntries(items),
|
||||
minMountedCount: 50,
|
||||
});
|
||||
|
||||
expect(window.virtualizedEntries).toHaveLength(10);
|
||||
expect(window.virtualizedEntries[0]?.item.id).toBe("u0");
|
||||
expect(window.virtualizedEntries.at(-1)?.item.id).toBe("a4");
|
||||
expect(window.mountedEntries[0]?.item.id).toBe("u5");
|
||||
expect(window.mountedEntries).toHaveLength(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe("estimateStreamItemHeight", () => {
|
||||
it("uses a larger estimate for user messages with image attachments", () => {
|
||||
const item: StreamItem = {
|
||||
kind: "user_message",
|
||||
id: "u-image",
|
||||
text: "image",
|
||||
timestamp: createTimestamp(1),
|
||||
images: [
|
||||
{
|
||||
id: "att-1",
|
||||
mimeType: "image/png",
|
||||
storageType: "desktop-file",
|
||||
storageKey: "/tmp/screenshot.png",
|
||||
fileName: "screenshot.png",
|
||||
byteSize: 1024,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(estimateStreamItemHeight(item)).toBe(220);
|
||||
});
|
||||
});
|
||||
|
||||
describe("web virtualization test overrides", () => {
|
||||
it("uses defaults unless explicit positive integer overrides are present", () => {
|
||||
const globalWithOverrides = globalThis as typeof globalThis & {
|
||||
__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;
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 6;
|
||||
globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS = 4;
|
||||
expect(getWebPartialVirtualizationThreshold()).toBe(6);
|
||||
expect(getWebMountedRecentStreamItems()).toBe(4);
|
||||
} finally {
|
||||
if (previousThreshold === undefined) {
|
||||
delete globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD;
|
||||
} else {
|
||||
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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
|
||||
export const DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 100;
|
||||
export const DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS = 50;
|
||||
|
||||
type BottomAnchorE2ETestGlobals = typeof globalThis & {
|
||||
__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: unknown;
|
||||
__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS?: unknown;
|
||||
};
|
||||
|
||||
function readPositiveIntegerOverride(value: unknown): number | null {
|
||||
if (!Number.isFinite(value)) {
|
||||
return null;
|
||||
}
|
||||
const normalized = Math.trunc(value as number);
|
||||
return normalized > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
export function getWebPartialVirtualizationThreshold(): number {
|
||||
const override = readPositiveIntegerOverride(
|
||||
(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
|
||||
);
|
||||
return override ?? DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS;
|
||||
}
|
||||
|
||||
export type IndexedStreamItem = {
|
||||
item: StreamItem;
|
||||
index: number;
|
||||
};
|
||||
|
||||
export type WebVirtualizedHistoryWindow = {
|
||||
virtualizedEntries: IndexedStreamItem[];
|
||||
mountedEntries: IndexedStreamItem[];
|
||||
};
|
||||
|
||||
export function estimateStreamItemHeight(item: StreamItem): number {
|
||||
switch (item.kind) {
|
||||
case "user_message":
|
||||
return item.images && item.images.length > 0 ? 220 : 96;
|
||||
case "assistant_message":
|
||||
return 220;
|
||||
case "tool_call":
|
||||
return 136;
|
||||
case "thought":
|
||||
return 112;
|
||||
case "todo_list":
|
||||
return 144;
|
||||
case "activity_log":
|
||||
return 88;
|
||||
case "compaction":
|
||||
return 72;
|
||||
default:
|
||||
return 120;
|
||||
}
|
||||
}
|
||||
|
||||
export function findMountedWindowStart(input: {
|
||||
items: StreamItem[];
|
||||
minMountedCount: number;
|
||||
}): number {
|
||||
const { items, minMountedCount } = input;
|
||||
if (items.length <= minMountedCount) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let startIndex = Math.max(items.length - minMountedCount, 0);
|
||||
while (startIndex > 0 && items[startIndex]?.kind !== "user_message") {
|
||||
startIndex -= 1;
|
||||
}
|
||||
return startIndex;
|
||||
}
|
||||
|
||||
export function splitWebVirtualizedHistory(input: {
|
||||
entries: IndexedStreamItem[];
|
||||
minMountedCount: number;
|
||||
}): WebVirtualizedHistoryWindow {
|
||||
const startIndex = findMountedWindowStart({
|
||||
items: input.entries.map((entry) => entry.item),
|
||||
minMountedCount: input.minMountedCount,
|
||||
});
|
||||
return {
|
||||
virtualizedEntries: input.entries.slice(0, startIndex),
|
||||
mountedEntries: input.entries.slice(startIndex),
|
||||
};
|
||||
}
|
||||
@@ -162,6 +162,8 @@ export function DictationOverlay({
|
||||
<Pressable
|
||||
onPress={handleCancel}
|
||||
disabled={actionsDisabled && !isFailed}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Cancel dictation"
|
||||
style={[
|
||||
overlayStyles.cancelButton,
|
||||
actionsDisabled && !isFailed && overlayStyles.buttonDisabled,
|
||||
@@ -213,6 +215,8 @@ export function DictationOverlay({
|
||||
) : isFailed ? (
|
||||
<Pressable
|
||||
onPress={onRetry}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Retry dictation"
|
||||
style={[
|
||||
overlayStyles.actionButton,
|
||||
{ backgroundColor: theme.colors.palette.white },
|
||||
@@ -228,6 +232,8 @@ export function DictationOverlay({
|
||||
<>
|
||||
<Pressable
|
||||
onPress={onAccept}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Insert transcription"
|
||||
style={[
|
||||
overlayStyles.actionButton,
|
||||
{ backgroundColor: "rgba(255, 255, 255, 0.25)" },
|
||||
@@ -241,6 +247,8 @@ export function DictationOverlay({
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={onAcceptAndSend}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Insert transcription and send"
|
||||
style={[
|
||||
overlayStyles.actionButton,
|
||||
{ backgroundColor: theme.colors.palette.white },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { RefreshControl } from "react-native";
|
||||
import { useCallback, useState } from "react";
|
||||
import DraggableFlatList, {
|
||||
NestableDraggableFlatList,
|
||||
type RenderItemParams,
|
||||
} from "react-native-draggable-flatlist";
|
||||
import { useUnistyles } from "react-native-unistyles";
|
||||
@@ -17,6 +18,7 @@ export function DraggableList<T>({
|
||||
renderItem,
|
||||
onDragEnd,
|
||||
style,
|
||||
containerStyle,
|
||||
contentContainerStyle,
|
||||
testID,
|
||||
ListFooterComponent,
|
||||
@@ -24,17 +26,20 @@ export function DraggableList<T>({
|
||||
ListEmptyComponent,
|
||||
showsVerticalScrollIndicator = true,
|
||||
enableDesktopWebScrollbar: _enableDesktopWebScrollbar = false,
|
||||
scrollEnabled = true,
|
||||
useDragHandle: _useDragHandle = false,
|
||||
refreshing,
|
||||
onRefresh,
|
||||
simultaneousGestureRef,
|
||||
waitFor,
|
||||
onDragBegin: onDragBeginProp,
|
||||
nestable = false,
|
||||
}: DraggableListProps<T>) {
|
||||
const { theme } = useUnistyles();
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
// Pass the ref directly to DraggableFlatList - it handles the gesture coordination
|
||||
// The ref may not have .current set yet, but that's okay - DraggableFlatList will
|
||||
// read it when the gesture is being recognized
|
||||
// Pass the ref directly to DraggableFlatList - it handles gesture
|
||||
// coordination internally for nestable lists.
|
||||
const simultaneousHandlers = simultaneousGestureRef ? [simultaneousGestureRef] : undefined;
|
||||
|
||||
const handleRenderItem = useCallback(
|
||||
@@ -61,37 +66,46 @@ export function DraggableList<T>({
|
||||
|
||||
const handleDragBegin = useCallback(() => {
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
onDragBeginProp?.();
|
||||
}, [onDragBeginProp]);
|
||||
|
||||
const handleRelease = useCallback(() => {
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const showRefreshControl = Boolean(onRefresh) && (!isDragging || Boolean(refreshing));
|
||||
const resolvedContainerStyle =
|
||||
containerStyle ?? (scrollEnabled ? { flex: 1 } : undefined);
|
||||
const shouldShowRefreshControl = showRefreshControl && !nestable;
|
||||
const ListComponent: typeof DraggableFlatList = (nestable
|
||||
? (NestableDraggableFlatList as any)
|
||||
: DraggableFlatList) as any;
|
||||
|
||||
return (
|
||||
<DraggableFlatList
|
||||
<ListComponent
|
||||
testID={testID}
|
||||
data={data}
|
||||
keyExtractor={keyExtractor}
|
||||
renderItem={handleRenderItem}
|
||||
onDragEnd={handleDragEnd}
|
||||
style={style}
|
||||
containerStyle={{ flex: 1 }}
|
||||
containerStyle={resolvedContainerStyle}
|
||||
contentContainerStyle={contentContainerStyle}
|
||||
ListFooterComponent={ListFooterComponent}
|
||||
ListHeaderComponent={ListHeaderComponent}
|
||||
ListEmptyComponent={ListEmptyComponent}
|
||||
showsVerticalScrollIndicator={showsVerticalScrollIndicator}
|
||||
scrollEnabled={scrollEnabled}
|
||||
simultaneousHandlers={simultaneousHandlers}
|
||||
// Higher activationDistance prevents drag from interfering with nested onLongPress handlers
|
||||
// Higher activation distance reduces accidental drag capture while nested
|
||||
// lists are inside a scroll container.
|
||||
activationDistance={20}
|
||||
onDragBegin={handleDragBegin}
|
||||
onRelease={handleRelease}
|
||||
// @ts-expect-error - waitFor is supported by RNGH FlatList but not typed in DraggableFlatList
|
||||
// @ts-ignore - waitFor is supported by RNGH FlatList but missing from DraggableFlatList types
|
||||
waitFor={waitFor}
|
||||
refreshControl={
|
||||
showRefreshControl ? (
|
||||
shouldShowRefreshControl ? (
|
||||
<RefreshControl
|
||||
refreshing={refreshing ?? false}
|
||||
onRefresh={onRefresh}
|
||||
|
||||
@@ -2,11 +2,22 @@ import type { ReactElement, MutableRefObject } from "react";
|
||||
import type { StyleProp, ViewStyle } from "react-native";
|
||||
import type { GestureType } from "react-native-gesture-handler";
|
||||
|
||||
export interface DraggableListDragHandleProps {
|
||||
/**
|
||||
* Web-only drag handle props (from dnd-kit). Spread these onto the element
|
||||
* that should initiate the drag. Native uses the `drag()` callback instead.
|
||||
*/
|
||||
attributes?: Record<string, unknown>;
|
||||
listeners?: Record<string, unknown>;
|
||||
setActivatorNodeRef?: (node: unknown) => void;
|
||||
}
|
||||
|
||||
export interface DraggableRenderItemInfo<T> {
|
||||
item: T;
|
||||
index: number;
|
||||
drag: () => void;
|
||||
isActive: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
}
|
||||
|
||||
export interface DraggableListProps<T> {
|
||||
@@ -15,6 +26,8 @@ export interface DraggableListProps<T> {
|
||||
renderItem: (info: DraggableRenderItemInfo<T>) => ReactElement;
|
||||
onDragEnd: (data: T[]) => void;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
/** Outer container style (useful for nested, non-scrolling lists). */
|
||||
containerStyle?: StyleProp<ViewStyle>;
|
||||
contentContainerStyle?: StyleProp<ViewStyle>;
|
||||
testID?: string;
|
||||
ListFooterComponent?: ReactElement | null;
|
||||
@@ -22,6 +35,13 @@ export interface DraggableListProps<T> {
|
||||
ListEmptyComponent?: ReactElement | null;
|
||||
showsVerticalScrollIndicator?: boolean;
|
||||
enableDesktopWebScrollbar?: boolean;
|
||||
/** When false, disables internal scrolling (use outer list to scroll). */
|
||||
scrollEnabled?: boolean;
|
||||
/**
|
||||
* Web-only: when true, the drag can only be initiated from the handle props
|
||||
* passed to `renderItem` (prevents nested lists from fighting).
|
||||
*/
|
||||
useDragHandle?: boolean;
|
||||
refreshing?: boolean;
|
||||
onRefresh?: () => void;
|
||||
/** Fill remaining space when content is smaller than container */
|
||||
@@ -30,4 +50,15 @@ export interface DraggableListProps<T> {
|
||||
simultaneousGestureRef?: MutableRefObject<GestureType | undefined>;
|
||||
/** Gesture ref(s) that the list should wait for before handling scroll */
|
||||
waitFor?: MutableRefObject<GestureType | undefined> | MutableRefObject<GestureType | undefined>[];
|
||||
/** Called when a drag gesture begins (before items are reordered) */
|
||||
onDragBegin?: () => void;
|
||||
/** Called immediately before invoking row `drag()` to lock outer owners. */
|
||||
onDragIntent?: () => void;
|
||||
/** Called when drag interaction ends (finger released). */
|
||||
onDragRelease?: () => void;
|
||||
/**
|
||||
* Native-only: use the nestable draggable-flatlist variant for nested drag
|
||||
* lists coordinated by a shared NestableScrollContainer.
|
||||
*/
|
||||
nestable?: boolean;
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ interface SortableItemProps<T> {
|
||||
index: number;
|
||||
renderItem: (info: DraggableRenderItemInfo<T>) => ReactElement;
|
||||
activeId: string | null;
|
||||
useDragHandle: boolean;
|
||||
}
|
||||
|
||||
function SortableItem<T>({
|
||||
@@ -49,11 +50,13 @@ function SortableItem<T>({
|
||||
index,
|
||||
renderItem,
|
||||
activeId,
|
||||
useDragHandle,
|
||||
}: SortableItemProps<T>) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
setActivatorNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
@@ -71,7 +74,13 @@ function SortableItem<T>({
|
||||
// Trigger drag - handled by dnd-kit's listeners
|
||||
};
|
||||
|
||||
const baseTransform = CSS.Transform.toString(transform);
|
||||
// dnd-kit can set `scaleX/scaleY` on the active item when dragging over a
|
||||
// differently-sized droppable. For variable-height rows this can look like
|
||||
// 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
|
||||
);
|
||||
const scaleTransform = isDragging ? "scale(1.02)" : "";
|
||||
const combinedTransform = [baseTransform, scaleTransform].filter(Boolean).join(" ");
|
||||
|
||||
@@ -87,10 +96,23 @@ function SortableItem<T>({
|
||||
index,
|
||||
drag,
|
||||
isActive: activeId === id,
|
||||
dragHandleProps: useDragHandle
|
||||
? {
|
||||
attributes: attributes as unknown as Record<string, unknown>,
|
||||
listeners: listeners as unknown as Record<string, unknown>,
|
||||
setActivatorNodeRef: setActivatorNodeRef as unknown as (
|
||||
node: unknown
|
||||
) => void,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const wrapperProps = useDragHandle
|
||||
? { ref: setNodeRef }
|
||||
: { ref: setNodeRef, ...attributes, ...listeners };
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
|
||||
<div {...wrapperProps} style={style}>
|
||||
{renderItem(info)}
|
||||
</div>
|
||||
);
|
||||
@@ -102,6 +124,7 @@ export function DraggableList<T>({
|
||||
renderItem,
|
||||
onDragEnd,
|
||||
style,
|
||||
containerStyle,
|
||||
contentContainerStyle,
|
||||
testID,
|
||||
ListFooterComponent,
|
||||
@@ -109,18 +132,18 @@ export function DraggableList<T>({
|
||||
ListEmptyComponent,
|
||||
showsVerticalScrollIndicator = true,
|
||||
enableDesktopWebScrollbar = false,
|
||||
scrollEnabled = true,
|
||||
useDragHandle = false,
|
||||
// simultaneousGestureRef is native-only, ignored on web
|
||||
onDragBegin,
|
||||
nestable: _nestable = false,
|
||||
}: DraggableListProps<T>) {
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [items, setItems] = useState(data);
|
||||
const [dragItems, setDragItems] = useState<T[] | null>(null);
|
||||
const items = dragItems ?? data;
|
||||
const scrollViewRef = useRef<ScrollView>(null);
|
||||
const scrollbarMetrics = useWebDesktopScrollbarMetrics();
|
||||
|
||||
// Sync items with data prop
|
||||
if (data !== items && !activeId) {
|
||||
setItems(data);
|
||||
}
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
@@ -132,15 +155,21 @@ export function DraggableList<T>({
|
||||
})
|
||||
);
|
||||
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
setActiveId(String(event.active.id));
|
||||
}, []);
|
||||
const handleDragStart = useCallback(
|
||||
(event: DragStartEvent) => {
|
||||
setDragItems(data);
|
||||
setActiveId(String(event.active.id));
|
||||
onDragBegin?.();
|
||||
},
|
||||
[data, onDragBegin]
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
setActiveId(null);
|
||||
setDragItems(null);
|
||||
|
||||
if (over && active.id !== over.id) {
|
||||
const oldIndex = items.findIndex(
|
||||
@@ -150,61 +179,100 @@ export function DraggableList<T>({
|
||||
(item, i) => keyExtractor(item, i) === over.id
|
||||
);
|
||||
|
||||
const newItems = arrayMove(items, oldIndex, newIndex);
|
||||
setItems(newItems);
|
||||
onDragEnd(newItems);
|
||||
if (oldIndex >= 0 && newIndex >= 0 && oldIndex !== newIndex) {
|
||||
const newItems = arrayMove(items, oldIndex, newIndex);
|
||||
onDragEnd(newItems);
|
||||
}
|
||||
}
|
||||
},
|
||||
[items, keyExtractor, onDragEnd]
|
||||
);
|
||||
|
||||
const ids = items.map((item, index) => keyExtractor(item, index));
|
||||
const showCustomScrollbar = enableDesktopWebScrollbar;
|
||||
const showCustomScrollbar = enableDesktopWebScrollbar && scrollEnabled;
|
||||
const wrapperStyle = [
|
||||
{ position: "relative" as const },
|
||||
scrollEnabled ? { flex: 1, minHeight: 0 } : null,
|
||||
containerStyle,
|
||||
];
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, minHeight: 0, position: "relative" }}>
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
testID={testID}
|
||||
style={style}
|
||||
contentContainerStyle={contentContainerStyle}
|
||||
showsVerticalScrollIndicator={
|
||||
showCustomScrollbar ? false : showsVerticalScrollIndicator
|
||||
}
|
||||
onLayout={showCustomScrollbar ? scrollbarMetrics.onLayout : undefined}
|
||||
onContentSizeChange={
|
||||
showCustomScrollbar ? scrollbarMetrics.onContentSizeChange : undefined
|
||||
}
|
||||
onScroll={showCustomScrollbar ? scrollbarMetrics.onScroll : undefined}
|
||||
scrollEventThrottle={showCustomScrollbar ? 16 : undefined}
|
||||
>
|
||||
{ListHeaderComponent}
|
||||
{items.length === 0 && ListEmptyComponent}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToVerticalAxis]}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
<View style={wrapperStyle}>
|
||||
{scrollEnabled ? (
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
testID={testID}
|
||||
style={style}
|
||||
contentContainerStyle={contentContainerStyle}
|
||||
showsVerticalScrollIndicator={
|
||||
showCustomScrollbar ? false : showsVerticalScrollIndicator
|
||||
}
|
||||
onLayout={showCustomScrollbar ? scrollbarMetrics.onLayout : undefined}
|
||||
onContentSizeChange={
|
||||
showCustomScrollbar ? scrollbarMetrics.onContentSizeChange : undefined
|
||||
}
|
||||
onScroll={showCustomScrollbar ? scrollbarMetrics.onScroll : undefined}
|
||||
scrollEventThrottle={showCustomScrollbar ? 16 : undefined}
|
||||
>
|
||||
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
|
||||
{items.map((item, index) => {
|
||||
const id = keyExtractor(item, index);
|
||||
return (
|
||||
<SortableItem
|
||||
key={id}
|
||||
id={id}
|
||||
item={item}
|
||||
index={index}
|
||||
renderItem={renderItem}
|
||||
activeId={activeId}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
{ListFooterComponent}
|
||||
</ScrollView>
|
||||
{ListHeaderComponent}
|
||||
{items.length === 0 && ListEmptyComponent}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToVerticalAxis]}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
|
||||
{items.map((item, index) => {
|
||||
const id = keyExtractor(item, index);
|
||||
return (
|
||||
<SortableItem
|
||||
key={id}
|
||||
id={id}
|
||||
item={item}
|
||||
index={index}
|
||||
renderItem={renderItem}
|
||||
activeId={activeId}
|
||||
useDragHandle={useDragHandle}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
{ListFooterComponent}
|
||||
</ScrollView>
|
||||
) : (
|
||||
<>
|
||||
{ListHeaderComponent}
|
||||
{items.length === 0 && ListEmptyComponent}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToVerticalAxis]}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
|
||||
{items.map((item, index) => {
|
||||
const id = keyExtractor(item, index);
|
||||
return (
|
||||
<SortableItem
|
||||
key={id}
|
||||
id={id}
|
||||
item={item}
|
||||
index={index}
|
||||
renderItem={renderItem}
|
||||
activeId={activeId}
|
||||
useDragHandle={useDragHandle}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
{ListFooterComponent}
|
||||
</>
|
||||
)}
|
||||
<WebDesktopScrollbarOverlay
|
||||
enabled={showCustomScrollbar}
|
||||
metrics={scrollbarMetrics}
|
||||
|
||||
@@ -1,6 +1,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,
|
||||
@@ -8,7 +9,6 @@ import Animated, {
|
||||
} from "react-native-reanimated";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
|
||||
import { X } from "lucide-react-native";
|
||||
import {
|
||||
usePanelStore,
|
||||
@@ -20,10 +20,9 @@ import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animati
|
||||
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
|
||||
import { GitDiffPane } from "./git-diff-pane";
|
||||
import { FileExplorerPane } from "./file-explorer-pane";
|
||||
import { TerminalPane } from "./terminal-pane";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
|
||||
const MIN_CHAT_WIDTH = 400;
|
||||
const IOS_KEYBOARD_INSET_MIN_HEIGHT = 120;
|
||||
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
|
||||
|
||||
function logExplorerSidebar(event: string, details: Record<string, unknown>): void {
|
||||
@@ -33,25 +32,23 @@ function logExplorerSidebar(event: string, details: Record<string, unknown>): vo
|
||||
console.log(`[ExplorerSidebar] ${event}`, details);
|
||||
}
|
||||
|
||||
function resolveKeyboardShift(rawHeight: number, inset: number): number {
|
||||
"worklet";
|
||||
// iOS can report a small accessory/prediction bar height during touch focus.
|
||||
// Treat that as non-keyboard so terminal scroll gestures don't "bounce" the layout.
|
||||
if (Platform.OS === "ios" && rawHeight < IOS_KEYBOARD_INSET_MIN_HEIGHT) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(0, rawHeight - inset);
|
||||
}
|
||||
|
||||
interface ExplorerSidebarProps {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
cwd: string;
|
||||
workspaceId?: string | null;
|
||||
workspaceRoot: string;
|
||||
isGit: boolean;
|
||||
onOpenFile?: (filePath: string) => void;
|
||||
}
|
||||
|
||||
export function ExplorerSidebar({ serverId, agentId, cwd, isGit }: ExplorerSidebarProps) {
|
||||
export function ExplorerSidebar({
|
||||
serverId,
|
||||
workspaceId,
|
||||
workspaceRoot,
|
||||
isGit,
|
||||
onOpenFile,
|
||||
}: ExplorerSidebarProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isScreenFocused = useIsFocused();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
@@ -63,14 +60,13 @@ export function ExplorerSidebar({ serverId, agentId, cwd, isGit }: ExplorerSideb
|
||||
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
||||
const setExplorerWidth = usePanelStore((state) => state.setExplorerWidth);
|
||||
const { width: viewportWidth } = useWindowDimensions();
|
||||
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
|
||||
const bottomInset = useSharedValue(insets.bottom);
|
||||
const closeTouchStartX = useSharedValue(0);
|
||||
const closeTouchStartY = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
bottomInset.value = insets.bottom;
|
||||
}, [bottomInset, insets.bottom]);
|
||||
const { style: mobileKeyboardInsetStyle } = useKeyboardShiftStyle({
|
||||
mode: "padding",
|
||||
enabled: isMobile,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobile) {
|
||||
@@ -119,9 +115,9 @@ export function ExplorerSidebar({ serverId, agentId, cwd, isGit }: ExplorerSideb
|
||||
|
||||
const handleTabPress = useCallback(
|
||||
(tab: ExplorerTab) => {
|
||||
setExplorerTabForCheckout({ serverId, cwd, isGit, tab });
|
||||
setExplorerTabForCheckout({ serverId, cwd: workspaceRoot, isGit, tab });
|
||||
},
|
||||
[cwd, isGit, serverId, setExplorerTabForCheckout]
|
||||
[isGit, serverId, setExplorerTabForCheckout, workspaceRoot]
|
||||
);
|
||||
|
||||
// Swipe gesture to close (swipe right on mobile)
|
||||
@@ -130,7 +126,7 @@ export function ExplorerSidebar({ serverId, agentId, cwd, isGit }: ExplorerSideb
|
||||
Gesture.Pan()
|
||||
.withRef(closeGestureRef)
|
||||
.enabled(enableSidebarCloseGesture)
|
||||
// Use manual activation so child views (e.g. WebView terminals) keep touch streams
|
||||
// Use manual activation so child views keep touch streams
|
||||
// unless we detect an intentional right-swipe close.
|
||||
.manualActivation(true)
|
||||
.onTouchesDown((event) => {
|
||||
@@ -251,14 +247,6 @@ export function ExplorerSidebar({ serverId, agentId, cwd, isGit }: ExplorerSideb
|
||||
pointerEvents: backdropOpacity.value > 0.01 ? "auto" : "none",
|
||||
}));
|
||||
|
||||
const mobileKeyboardInsetStyle = useAnimatedStyle(() => {
|
||||
const absoluteHeight = Math.abs(keyboardHeight.value);
|
||||
const shift = resolveKeyboardShift(absoluteHeight, bottomInset.value);
|
||||
return {
|
||||
paddingBottom: bottomInset.value + shift,
|
||||
};
|
||||
});
|
||||
|
||||
const resizeAnimatedStyle = useAnimatedStyle(() => ({
|
||||
width: resizeWidth.value,
|
||||
}));
|
||||
@@ -267,6 +255,12 @@ export function ExplorerSidebar({ serverId, agentId, cwd, isGit }: ExplorerSideb
|
||||
// On web, keep it interactive only while open so closed sidebars don't eat taps.
|
||||
const overlayPointerEvents = Platform.OS === "web" ? (isOpen ? "auto" : "none") : "box-none";
|
||||
|
||||
// Navigation stacks can keep previous screens mounted; hide sidebars for unfocused
|
||||
// screens so only the active screen exposes explorer/terminal surfaces.
|
||||
if (!isScreenFocused) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
|
||||
@@ -293,10 +287,11 @@ export function ExplorerSidebar({ serverId, agentId, cwd, isGit }: ExplorerSideb
|
||||
onTabPress={handleTabPress}
|
||||
onClose={() => handleClose("header-close-button")}
|
||||
serverId={serverId}
|
||||
agentId={agentId}
|
||||
cwd={cwd}
|
||||
workspaceId={workspaceId}
|
||||
workspaceRoot={workspaceRoot}
|
||||
isGit={isGit}
|
||||
isMobile={isMobile}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
@@ -310,7 +305,9 @@ export function ExplorerSidebar({ serverId, agentId, cwd, isGit }: ExplorerSideb
|
||||
}
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle]}>
|
||||
<Animated.View
|
||||
style={[styles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }]}
|
||||
>
|
||||
{/* Resize handle - absolutely positioned over left border */}
|
||||
<GestureDetector gesture={resizeGesture}>
|
||||
<View
|
||||
@@ -326,10 +323,11 @@ export function ExplorerSidebar({ serverId, agentId, cwd, isGit }: ExplorerSideb
|
||||
onTabPress={handleTabPress}
|
||||
onClose={() => handleClose("desktop-close-button")}
|
||||
serverId={serverId}
|
||||
agentId={agentId}
|
||||
cwd={cwd}
|
||||
workspaceId={workspaceId}
|
||||
workspaceRoot={workspaceRoot}
|
||||
isGit={isGit}
|
||||
isMobile={false}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
</Animated.View>
|
||||
);
|
||||
@@ -340,10 +338,11 @@ interface SidebarContentProps {
|
||||
onTabPress: (tab: ExplorerTab) => void;
|
||||
onClose: () => void;
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
cwd: string;
|
||||
workspaceId?: string | null;
|
||||
workspaceRoot: string;
|
||||
isGit: boolean;
|
||||
isMobile: boolean;
|
||||
onOpenFile?: (filePath: string) => void;
|
||||
}
|
||||
|
||||
function SidebarContent({
|
||||
@@ -351,10 +350,11 @@ function SidebarContent({
|
||||
onTabPress,
|
||||
onClose,
|
||||
serverId,
|
||||
agentId,
|
||||
cwd,
|
||||
workspaceId,
|
||||
workspaceRoot,
|
||||
isGit,
|
||||
isMobile,
|
||||
onOpenFile,
|
||||
}: SidebarContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const resolvedTab: ExplorerTab =
|
||||
@@ -395,20 +395,6 @@ function SidebarContent({
|
||||
Files
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
testID="explorer-tab-terminals"
|
||||
style={[styles.tab, resolvedTab === "terminals" && styles.tabActive]}
|
||||
onPress={() => onTabPress("terminals")}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabText,
|
||||
resolvedTab === "terminals" && styles.tabTextActive,
|
||||
]}
|
||||
>
|
||||
Terminals
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<View style={styles.headerRightSection}>
|
||||
{isMobile && (
|
||||
@@ -422,13 +408,20 @@ function SidebarContent({
|
||||
{/* Content based on active tab */}
|
||||
<View style={styles.contentArea} testID="explorer-content-area">
|
||||
{resolvedTab === "changes" && (
|
||||
<GitDiffPane serverId={serverId} agentId={agentId} cwd={cwd} />
|
||||
<GitDiffPane
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
cwd={workspaceRoot}
|
||||
hideHeaderRow={!isMobile}
|
||||
/>
|
||||
)}
|
||||
{resolvedTab === "files" && (
|
||||
<FileExplorerPane serverId={serverId} agentId={agentId} />
|
||||
)}
|
||||
{resolvedTab === "terminals" && (
|
||||
<TerminalPane serverId={serverId} cwd={cwd} />
|
||||
<FileExplorerPane
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
workspaceRoot={workspaceRoot}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
@@ -448,14 +441,14 @@ const styles = StyleSheet.create((theme) => ({
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
overflow: "hidden",
|
||||
},
|
||||
desktopSidebar: {
|
||||
position: "relative",
|
||||
borderLeftWidth: 1,
|
||||
borderLeftColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
},
|
||||
resizeHandle: {
|
||||
position: "absolute",
|
||||
|
||||
@@ -3,18 +3,16 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
Image as RNImage,
|
||||
ListRenderItemInfo,
|
||||
type LayoutChangeEvent,
|
||||
type NativeScrollEvent,
|
||||
type NativeSyntheticEvent,
|
||||
Pressable,
|
||||
ScrollView as RNScrollView,
|
||||
Text,
|
||||
View,
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import { ScrollView, Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import { Gesture } from "react-native-gesture-handler";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import Animated, {
|
||||
cancelAnimation,
|
||||
@@ -27,11 +25,6 @@ import Animated, {
|
||||
} from "react-native-reanimated";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import {
|
||||
BottomSheetModal,
|
||||
BottomSheetScrollView,
|
||||
BottomSheetBackdrop,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import {
|
||||
Copy,
|
||||
Download,
|
||||
@@ -47,7 +40,6 @@ import {
|
||||
import type {
|
||||
AgentFileExplorerState,
|
||||
ExplorerEntry,
|
||||
ExplorerFile,
|
||||
} from "@/stores/session-store";
|
||||
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
@@ -60,12 +52,13 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
||||
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
|
||||
import {
|
||||
usePanelStore,
|
||||
DEFAULT_EXPLORER_FILES_SPLIT_RATIO,
|
||||
type SortOption,
|
||||
} from "@/stores/panel-store";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
|
||||
import {
|
||||
WebDesktopScrollbarOverlay,
|
||||
useWebDesktopScrollbarMetrics,
|
||||
@@ -79,9 +72,21 @@ const SORT_OPTIONS: { value: SortOption; label: string }[] = [
|
||||
|
||||
const INDENT_PER_LEVEL = 12;
|
||||
|
||||
function formatFileSize({ size }: { size: number }): string {
|
||||
if (size < 1024) {
|
||||
return `${size} B`;
|
||||
}
|
||||
if (size < 1024 * 1024) {
|
||||
return `${(size / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
interface FileExplorerPaneProps {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
workspaceId?: string | null;
|
||||
workspaceRoot: string;
|
||||
onOpenFile?: (filePath: string) => void;
|
||||
}
|
||||
|
||||
interface TreeRow {
|
||||
@@ -89,7 +94,12 @@ interface TreeRow {
|
||||
depth: number;
|
||||
}
|
||||
|
||||
export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
|
||||
export function FileExplorerPane({
|
||||
serverId,
|
||||
workspaceId,
|
||||
workspaceRoot,
|
||||
onOpenFile,
|
||||
}: FileExplorerPaneProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
@@ -100,42 +110,48 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
|
||||
() => daemons.find((daemon) => daemon.serverId === serverId),
|
||||
[daemons, serverId]
|
||||
);
|
||||
const agentExists = useSessionStore((state) =>
|
||||
agentId && state.sessions[serverId]
|
||||
? state.sessions[serverId]?.agents.has(agentId)
|
||||
: false
|
||||
const normalizedWorkspaceRoot = useMemo(
|
||||
() => workspaceRoot.trim(),
|
||||
[workspaceRoot]
|
||||
);
|
||||
const workspaceStateKey = useMemo(
|
||||
() =>
|
||||
buildWorkspaceExplorerStateKey({
|
||||
workspaceId,
|
||||
workspaceRoot: normalizedWorkspaceRoot,
|
||||
}),
|
||||
[normalizedWorkspaceRoot, workspaceId]
|
||||
);
|
||||
const workspaceScopeId = useMemo(
|
||||
() => workspaceId?.trim() || normalizedWorkspaceRoot,
|
||||
[normalizedWorkspaceRoot, workspaceId]
|
||||
);
|
||||
const hasWorkspaceScope = Boolean(workspaceStateKey && normalizedWorkspaceRoot);
|
||||
const explorerState = useSessionStore((state) =>
|
||||
agentId && state.sessions[serverId]
|
||||
? state.sessions[serverId]?.fileExplorer.get(agentId)
|
||||
workspaceStateKey && state.sessions[serverId]
|
||||
? state.sessions[serverId]?.fileExplorer.get(workspaceStateKey)
|
||||
: undefined
|
||||
);
|
||||
|
||||
const {
|
||||
workspaceStateKey: actionsWorkspaceStateKey,
|
||||
requestDirectoryListing,
|
||||
requestFilePreview,
|
||||
requestFileDownloadToken,
|
||||
selectExplorerEntry,
|
||||
} = useFileExplorerActions(serverId);
|
||||
} = useFileExplorerActions({
|
||||
serverId,
|
||||
workspaceId,
|
||||
workspaceRoot: normalizedWorkspaceRoot,
|
||||
});
|
||||
const sortOption = usePanelStore((state) => state.explorerSortOption);
|
||||
const setSortOption = usePanelStore((state) => state.setExplorerSortOption);
|
||||
const splitRatio = usePanelStore((state) => state.explorerFilesSplitRatio);
|
||||
const setSplitRatio = usePanelStore((state) => state.setExplorerFilesSplitRatio);
|
||||
|
||||
const directories = explorerState?.directories ?? new Map();
|
||||
const files = explorerState?.files ?? new Map();
|
||||
const pendingRequest = explorerState?.pendingRequest ?? null;
|
||||
const isExplorerLoading = explorerState?.isLoading ?? false;
|
||||
const error = explorerState?.lastError ?? null;
|
||||
const selectedEntryPath = explorerState?.selectedEntryPath ?? null;
|
||||
|
||||
const preview = selectedEntryPath ? files.get(selectedEntryPath) : null;
|
||||
const isPreviewLoading = Boolean(
|
||||
isExplorerLoading &&
|
||||
pendingRequest?.mode === "file" &&
|
||||
pendingRequest?.path === selectedEntryPath
|
||||
);
|
||||
|
||||
const isDirectoryLoading = useCallback(
|
||||
(path: string) =>
|
||||
Boolean(
|
||||
@@ -145,30 +161,33 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
|
||||
);
|
||||
|
||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => new Set(["."]));
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const wasInlinePreviewVisibleRef = useRef(false);
|
||||
const treeListRef = useRef<FlatList<TreeRow>>(null);
|
||||
const treeScrollbarMetrics = useWebDesktopScrollbarMetrics();
|
||||
|
||||
// Bottom sheet for file preview (mobile)
|
||||
const previewSheetRef = useRef<BottomSheetModal>(null);
|
||||
const previewSnapPoints = useMemo(() => ["70%", "95%"], []);
|
||||
|
||||
const hasInitializedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!agentId || !requestDirectoryListing) {
|
||||
hasInitializedRef.current = false;
|
||||
setExpandedPaths(new Set(["."]));
|
||||
}, [actionsWorkspaceStateKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasWorkspaceScope) {
|
||||
return;
|
||||
}
|
||||
if (hasInitializedRef.current) {
|
||||
return;
|
||||
}
|
||||
hasInitializedRef.current = true;
|
||||
requestDirectoryListing(agentId, ".", { recordHistory: false, setCurrentPath: false });
|
||||
}, [agentId, requestDirectoryListing]);
|
||||
void requestDirectoryListing(".", {
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
});
|
||||
}, [hasWorkspaceScope, requestDirectoryListing]);
|
||||
|
||||
// Expand ancestor directories when a file is selected (e.g., from an inline path click)
|
||||
useEffect(() => {
|
||||
if (!agentId || !selectedEntryPath || !requestDirectoryListing) {
|
||||
if (!selectedEntryPath || !hasWorkspaceScope) {
|
||||
return;
|
||||
}
|
||||
const parentDir = getParentDirectory(selectedEntryPath);
|
||||
@@ -182,33 +201,17 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
|
||||
|
||||
ancestors.forEach((path) => {
|
||||
if (!directories.has(path)) {
|
||||
requestDirectoryListing(agentId, path, { recordHistory: false, setCurrentPath: false });
|
||||
void requestDirectoryListing(path, {
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [agentId, directories, requestDirectoryListing, selectedEntryPath]);
|
||||
|
||||
// Open/close preview sheet based on selection
|
||||
useEffect(() => {
|
||||
if (!isMobile) {
|
||||
return;
|
||||
}
|
||||
if (selectedEntryPath) {
|
||||
previewSheetRef.current?.present();
|
||||
} else {
|
||||
previewSheetRef.current?.dismiss();
|
||||
}
|
||||
}, [isMobile, selectedEntryPath]);
|
||||
|
||||
const handleClosePreview = useCallback(() => {
|
||||
if (!agentId) {
|
||||
return;
|
||||
}
|
||||
selectExplorerEntry(agentId, null);
|
||||
}, [agentId, selectExplorerEntry]);
|
||||
}, [directories, hasWorkspaceScope, requestDirectoryListing, selectedEntryPath]);
|
||||
|
||||
const handleToggleDirectory = useCallback(
|
||||
(entry: ExplorerEntry) => {
|
||||
if (!agentId || !requestDirectoryListing) {
|
||||
if (!hasWorkspaceScope) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -225,21 +228,24 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
|
||||
});
|
||||
|
||||
if (nextExpanded && !directories.has(entry.path)) {
|
||||
requestDirectoryListing(agentId, entry.path, { recordHistory: false, setCurrentPath: false });
|
||||
void requestDirectoryListing(entry.path, {
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
[agentId, directories, expandedPaths, requestDirectoryListing]
|
||||
[directories, expandedPaths, hasWorkspaceScope, requestDirectoryListing]
|
||||
);
|
||||
|
||||
const handleOpenFile = useCallback(
|
||||
(entry: ExplorerEntry) => {
|
||||
if (!agentId || !requestFilePreview) {
|
||||
if (!hasWorkspaceScope) {
|
||||
return;
|
||||
}
|
||||
selectExplorerEntry(agentId, entry.path);
|
||||
requestFilePreview(agentId, entry.path);
|
||||
selectExplorerEntry(entry.path);
|
||||
onOpenFile?.(entry.path);
|
||||
},
|
||||
[agentId, requestFilePreview, selectExplorerEntry]
|
||||
[hasWorkspaceScope, onOpenFile, selectExplorerEntry]
|
||||
);
|
||||
|
||||
const handleEntryPress = useCallback(
|
||||
@@ -253,27 +259,41 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
|
||||
[handleOpenFile, handleToggleDirectory]
|
||||
);
|
||||
|
||||
const handleCopyPath = useCallback(async (path: string) => {
|
||||
await Clipboard.setStringAsync(path);
|
||||
}, []);
|
||||
const handleCopyPath = useCallback(
|
||||
async (path: string) => {
|
||||
await Clipboard.setStringAsync(
|
||||
buildAbsoluteExplorerPath({
|
||||
workspaceRoot: normalizedWorkspaceRoot,
|
||||
entryPath: path,
|
||||
})
|
||||
);
|
||||
},
|
||||
[normalizedWorkspaceRoot]
|
||||
);
|
||||
|
||||
const startDownload = useDownloadStore((state) => state.startDownload);
|
||||
const handleDownloadEntry = useCallback(
|
||||
(entry: ExplorerEntry) => {
|
||||
if (!agentId || !requestFileDownloadToken || entry.kind !== "file") {
|
||||
if (!workspaceScopeId || entry.kind !== "file") {
|
||||
return;
|
||||
}
|
||||
|
||||
startDownload({
|
||||
serverId,
|
||||
agentId,
|
||||
scopeId: workspaceScopeId,
|
||||
fileName: entry.name,
|
||||
path: entry.path,
|
||||
daemonProfile,
|
||||
requestFileDownloadToken,
|
||||
requestFileDownloadToken: (targetPath) => requestFileDownloadToken(targetPath),
|
||||
});
|
||||
},
|
||||
[agentId, serverId, daemonProfile, requestFileDownloadToken, startDownload]
|
||||
[
|
||||
daemonProfile,
|
||||
requestFileDownloadToken,
|
||||
serverId,
|
||||
startDownload,
|
||||
workspaceScopeId,
|
||||
]
|
||||
);
|
||||
|
||||
const handleSortCycle = useCallback(() => {
|
||||
@@ -283,9 +303,9 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
|
||||
}, [sortOption, setSortOption]);
|
||||
|
||||
const { refetch: refetchExplorer, isFetching: isRefreshFetching } = useQuery({
|
||||
queryKey: ["fileExplorerRefresh", serverId, agentId],
|
||||
queryKey: ["fileExplorerRefresh", serverId, workspaceStateKey],
|
||||
queryFn: async () => {
|
||||
if (!agentId) {
|
||||
if (!hasWorkspaceScope) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -296,12 +316,11 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
|
||||
|
||||
await Promise.all([
|
||||
...directoryPaths.map((path) =>
|
||||
requestDirectoryListing(agentId, path, {
|
||||
requestDirectoryListing(path, {
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
})
|
||||
),
|
||||
...(selectedEntryPath ? [requestFilePreview(agentId, selectedEntryPath)] : []),
|
||||
]);
|
||||
return null;
|
||||
},
|
||||
@@ -379,93 +398,6 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
|
||||
[explorerState]
|
||||
);
|
||||
|
||||
const shouldShowInlinePreview = !isMobile && Boolean(selectedEntryPath);
|
||||
const minTreeWidth = 220;
|
||||
const minPreviewWidth = 320;
|
||||
|
||||
const safeSplitRatio = Number.isFinite(splitRatio)
|
||||
? splitRatio
|
||||
: DEFAULT_EXPLORER_FILES_SPLIT_RATIO;
|
||||
|
||||
const splitAvailableWidth = useSharedValue(0);
|
||||
const splitMaxTreeWidth = useSharedValue(minTreeWidth);
|
||||
const splitTreeWidth = useSharedValue(minTreeWidth);
|
||||
const splitStartTreeWidth = useSharedValue(minTreeWidth);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldShowInlinePreview) {
|
||||
wasInlinePreviewVisibleRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (containerWidth <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const available = Math.max(0, containerWidth);
|
||||
const maxTree = Math.max(minTreeWidth, available - minPreviewWidth);
|
||||
const isOpeningInlinePreview = !wasInlinePreviewVisibleRef.current;
|
||||
const desired = isOpeningInlinePreview
|
||||
? Math.round(available * safeSplitRatio)
|
||||
: splitTreeWidth.value;
|
||||
const clamped = Math.max(minTreeWidth, Math.min(maxTree, desired));
|
||||
|
||||
splitAvailableWidth.value = available;
|
||||
splitMaxTreeWidth.value = maxTree;
|
||||
splitTreeWidth.value = clamped;
|
||||
wasInlinePreviewVisibleRef.current = true;
|
||||
}, [
|
||||
containerWidth,
|
||||
minPreviewWidth,
|
||||
minTreeWidth,
|
||||
safeSplitRatio,
|
||||
shouldShowInlinePreview,
|
||||
splitAvailableWidth,
|
||||
splitMaxTreeWidth,
|
||||
splitTreeWidth,
|
||||
]);
|
||||
|
||||
const treePaneAnimatedStyle = useAnimatedStyle(() => ({
|
||||
width: splitTreeWidth.value,
|
||||
flexBasis: splitTreeWidth.value,
|
||||
flexGrow: 0,
|
||||
flexShrink: 0,
|
||||
}));
|
||||
|
||||
const splitResizeGesture = useMemo(() => {
|
||||
if (isMobile || !shouldShowInlinePreview) {
|
||||
return Gesture.Pan().enabled(false);
|
||||
}
|
||||
|
||||
return Gesture.Pan()
|
||||
.hitSlop({ left: 12, right: 12, top: 0, bottom: 0 })
|
||||
.onStart(() => {
|
||||
splitStartTreeWidth.value = splitTreeWidth.value;
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
const nextWidth = splitStartTreeWidth.value - event.translationX;
|
||||
const clamped = Math.max(
|
||||
minTreeWidth,
|
||||
Math.min(splitMaxTreeWidth.value, nextWidth)
|
||||
);
|
||||
splitTreeWidth.value = clamped;
|
||||
})
|
||||
.onEnd(() => {
|
||||
const available = splitAvailableWidth.value;
|
||||
const ratio = available > 0 ? splitTreeWidth.value / available : safeSplitRatio;
|
||||
runOnJS(setSplitRatio)(ratio);
|
||||
});
|
||||
}, [
|
||||
isMobile,
|
||||
minTreeWidth,
|
||||
safeSplitRatio,
|
||||
setSplitRatio,
|
||||
shouldShowInlinePreview,
|
||||
splitAvailableWidth,
|
||||
splitMaxTreeWidth,
|
||||
splitStartTreeWidth,
|
||||
splitTreeWidth,
|
||||
]);
|
||||
|
||||
const renderTreeRow = useCallback(
|
||||
({ item }: ListRenderItemInfo<TreeRow>) => {
|
||||
const entry = item.entry;
|
||||
@@ -565,37 +497,16 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
|
||||
]
|
||||
);
|
||||
|
||||
const handlePreviewSheetChange = useCallback(
|
||||
(index: number) => {
|
||||
if (index === -1) {
|
||||
handleClosePreview();
|
||||
}
|
||||
},
|
||||
[handleClosePreview]
|
||||
);
|
||||
|
||||
const renderPreviewBackdrop = useCallback(
|
||||
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
opacity={0.5}
|
||||
/>
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
const handleBackFromError = useCallback(() => {
|
||||
if (!agentId || !requestDirectoryListing) {
|
||||
if (!hasWorkspaceScope) {
|
||||
return;
|
||||
}
|
||||
selectExplorerEntry(agentId, null);
|
||||
requestDirectoryListing(agentId, errorRecoveryPath, {
|
||||
selectExplorerEntry(null);
|
||||
void requestDirectoryListing(errorRecoveryPath, {
|
||||
recordHistory: false,
|
||||
setCurrentPath: true,
|
||||
});
|
||||
}, [agentId, errorRecoveryPath, requestDirectoryListing, selectExplorerEntry]);
|
||||
}, [errorRecoveryPath, hasWorkspaceScope, requestDirectoryListing, selectExplorerEntry]);
|
||||
|
||||
const handleTreeListScroll = useCallback(
|
||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
@@ -615,10 +526,10 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
|
||||
[showDesktopWebScrollbar, treeScrollbarMetrics]
|
||||
);
|
||||
|
||||
if (!agentExists) {
|
||||
if (!hasWorkspaceScope) {
|
||||
return (
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.errorText}>Agent not found</Text>
|
||||
<Text style={styles.errorText}>Workspace is unavailable</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -626,7 +537,6 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
|
||||
return (
|
||||
<View
|
||||
style={styles.container}
|
||||
onLayout={(event) => setContainerWidth(event.nativeEvent.layout.width)}
|
||||
>
|
||||
{error ? (
|
||||
<View style={styles.centerState}>
|
||||
@@ -640,12 +550,10 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
|
||||
<Pressable
|
||||
style={styles.retryButton}
|
||||
onPress={() => {
|
||||
if (agentId) {
|
||||
requestDirectoryListing(agentId, ".", {
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
});
|
||||
}
|
||||
void requestDirectoryListing(".", {
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Text style={styles.retryButtonText}>Retry</Text>
|
||||
@@ -662,385 +570,66 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
|
||||
<Text style={styles.emptyText}>No files</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.desktopSplit}>
|
||||
{shouldShowInlinePreview ? (
|
||||
<View style={styles.previewPane}>
|
||||
<View style={styles.paneHeader} testID="preview-pane-header">
|
||||
<Text style={styles.previewHeaderText} numberOfLines={1}>
|
||||
{selectedEntryPath?.split("/").pop() ?? "Preview"}
|
||||
</Text>
|
||||
<View style={styles.previewHeaderRight}>
|
||||
{isPreviewLoading ? (
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
) : null}
|
||||
<Pressable
|
||||
onPress={handleClosePreview}
|
||||
hitSlop={8}
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.iconButton,
|
||||
(hovered || pressed) && styles.iconButtonHovered,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Close preview"
|
||||
>
|
||||
<X size={16} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<FilePreviewBody
|
||||
preview={preview}
|
||||
isLoading={isPreviewLoading}
|
||||
variant="inline"
|
||||
showDesktopWebScrollbar={showDesktopWebScrollbar}
|
||||
/>
|
||||
<View style={[styles.treePane, styles.treePaneFill]}>
|
||||
<View style={styles.paneHeader} testID="files-pane-header">
|
||||
<View style={styles.paneHeaderLeft} />
|
||||
<View style={styles.paneHeaderRight}>
|
||||
<Pressable
|
||||
onPress={handleRefresh}
|
||||
disabled={isRefreshFetching}
|
||||
hitSlop={8}
|
||||
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} />
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
<Pressable style={styles.sortButton} onPress={handleSortCycle}>
|
||||
<Text style={styles.sortButtonText}>{currentSortLabel}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{shouldShowInlinePreview ? (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.treePane,
|
||||
styles.treePaneWithPreview,
|
||||
{ minWidth: minTreeWidth },
|
||||
treePaneAnimatedStyle,
|
||||
]}
|
||||
>
|
||||
<GestureDetector gesture={splitResizeGesture}>
|
||||
<View
|
||||
style={[
|
||||
styles.splitResizeHandle,
|
||||
Platform.OS === "web" && ({ cursor: "col-resize" } as any),
|
||||
Platform.OS === "web" && ({ touchAction: "none", userSelect: "none" } as any),
|
||||
]}
|
||||
/>
|
||||
</GestureDetector>
|
||||
<View style={styles.paneHeader} testID="files-pane-header">
|
||||
<View style={styles.paneHeaderLeft} />
|
||||
<View style={styles.paneHeaderRight}>
|
||||
<Pressable
|
||||
onPress={handleRefresh}
|
||||
disabled={isRefreshFetching}
|
||||
hitSlop={8}
|
||||
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} />
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
<Pressable style={styles.sortButton} onPress={handleSortCycle}>
|
||||
<Text style={styles.sortButtonText}>{currentSortLabel}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
<FlatList
|
||||
ref={treeListRef}
|
||||
style={styles.treeList}
|
||||
data={treeRows}
|
||||
renderItem={renderTreeRow}
|
||||
keyExtractor={(row) => row.entry.path}
|
||||
testID="file-explorer-tree-scroll"
|
||||
contentContainerStyle={styles.entriesContent}
|
||||
onLayout={
|
||||
showDesktopWebScrollbar ? handleTreeListLayout : undefined
|
||||
}
|
||||
onScroll={
|
||||
showDesktopWebScrollbar ? handleTreeListScroll : undefined
|
||||
}
|
||||
onContentSizeChange={
|
||||
showDesktopWebScrollbar
|
||||
? treeScrollbarMetrics.onContentSizeChange
|
||||
: undefined
|
||||
}
|
||||
scrollEventThrottle={showDesktopWebScrollbar ? 16 : undefined}
|
||||
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||
initialNumToRender={24}
|
||||
maxToRenderPerBatch={40}
|
||||
windowSize={12}
|
||||
/>
|
||||
<WebDesktopScrollbarOverlay
|
||||
enabled={showDesktopWebScrollbar}
|
||||
metrics={treeScrollbarMetrics}
|
||||
onScrollToOffset={(nextOffset) => {
|
||||
treeListRef.current?.scrollToOffset({
|
||||
offset: nextOffset,
|
||||
animated: false,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Animated.View>
|
||||
) : (
|
||||
<View style={[styles.treePane, styles.treePaneFill]}>
|
||||
<View style={styles.paneHeader} testID="files-pane-header">
|
||||
<View style={styles.paneHeaderLeft} />
|
||||
<View style={styles.paneHeaderRight}>
|
||||
<Pressable
|
||||
onPress={handleRefresh}
|
||||
disabled={isRefreshFetching}
|
||||
hitSlop={8}
|
||||
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} />
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
<Pressable style={styles.sortButton} onPress={handleSortCycle}>
|
||||
<Text style={styles.sortButtonText}>{currentSortLabel}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
<FlatList
|
||||
ref={treeListRef}
|
||||
style={styles.treeList}
|
||||
data={treeRows}
|
||||
renderItem={renderTreeRow}
|
||||
keyExtractor={(row) => row.entry.path}
|
||||
testID="file-explorer-tree-scroll"
|
||||
contentContainerStyle={styles.entriesContent}
|
||||
onLayout={
|
||||
showDesktopWebScrollbar ? handleTreeListLayout : undefined
|
||||
}
|
||||
onScroll={
|
||||
showDesktopWebScrollbar ? handleTreeListScroll : undefined
|
||||
}
|
||||
onContentSizeChange={
|
||||
showDesktopWebScrollbar
|
||||
? treeScrollbarMetrics.onContentSizeChange
|
||||
: undefined
|
||||
}
|
||||
scrollEventThrottle={showDesktopWebScrollbar ? 16 : undefined}
|
||||
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||
initialNumToRender={24}
|
||||
maxToRenderPerBatch={40}
|
||||
windowSize={12}
|
||||
/>
|
||||
<WebDesktopScrollbarOverlay
|
||||
enabled={showDesktopWebScrollbar}
|
||||
metrics={treeScrollbarMetrics}
|
||||
onScrollToOffset={(nextOffset) => {
|
||||
treeListRef.current?.scrollToOffset({
|
||||
offset: nextOffset,
|
||||
animated: false,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<FlatList
|
||||
ref={treeListRef}
|
||||
style={styles.treeList}
|
||||
data={treeRows}
|
||||
renderItem={renderTreeRow}
|
||||
keyExtractor={(row) => row.entry.path}
|
||||
testID="file-explorer-tree-scroll"
|
||||
contentContainerStyle={styles.entriesContent}
|
||||
onLayout={showDesktopWebScrollbar ? handleTreeListLayout : undefined}
|
||||
onScroll={showDesktopWebScrollbar ? handleTreeListScroll : undefined}
|
||||
onContentSizeChange={
|
||||
showDesktopWebScrollbar ? treeScrollbarMetrics.onContentSizeChange : undefined
|
||||
}
|
||||
scrollEventThrottle={showDesktopWebScrollbar ? 16 : undefined}
|
||||
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||
initialNumToRender={24}
|
||||
maxToRenderPerBatch={40}
|
||||
windowSize={12}
|
||||
/>
|
||||
<WebDesktopScrollbarOverlay
|
||||
enabled={showDesktopWebScrollbar}
|
||||
metrics={treeScrollbarMetrics}
|
||||
onScrollToOffset={(nextOffset) => {
|
||||
treeListRef.current?.scrollToOffset({
|
||||
offset: nextOffset,
|
||||
animated: false,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{isMobile ? (
|
||||
<BottomSheetModal
|
||||
ref={previewSheetRef}
|
||||
snapPoints={previewSnapPoints}
|
||||
index={0}
|
||||
enableDynamicSizing={false}
|
||||
onChange={handlePreviewSheetChange}
|
||||
backdropComponent={renderPreviewBackdrop}
|
||||
enablePanDownToClose
|
||||
backgroundStyle={styles.sheetBackground}
|
||||
handleIndicatorStyle={styles.handleIndicator}
|
||||
>
|
||||
<View style={styles.sheetHeader}>
|
||||
<Text style={styles.sheetTitle} numberOfLines={1}>
|
||||
{selectedEntryPath?.split("/").pop() ?? "Preview"}
|
||||
</Text>
|
||||
<Pressable onPress={handleClosePreview} style={styles.sheetCloseButton}>
|
||||
<X size={20} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<FilePreviewBody
|
||||
preview={preview}
|
||||
isLoading={isPreviewLoading}
|
||||
variant="sheet"
|
||||
showDesktopWebScrollbar={false}
|
||||
/>
|
||||
</BottomSheetModal>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function FilePreviewBody({
|
||||
preview,
|
||||
isLoading,
|
||||
variant,
|
||||
showDesktopWebScrollbar,
|
||||
}: {
|
||||
preview: ExplorerFile | null;
|
||||
isLoading: boolean;
|
||||
variant: "inline" | "sheet";
|
||||
showDesktopWebScrollbar: boolean;
|
||||
}) {
|
||||
const enablePreviewDesktopScrollbar =
|
||||
variant === "inline" && showDesktopWebScrollbar;
|
||||
const previewScrollRef = useRef<RNScrollView>(null);
|
||||
const previewScrollbarMetrics = useWebDesktopScrollbarMetrics();
|
||||
|
||||
const handlePreviewScroll = useCallback(
|
||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
if (enablePreviewDesktopScrollbar) {
|
||||
previewScrollbarMetrics.onScroll(event);
|
||||
}
|
||||
},
|
||||
[enablePreviewDesktopScrollbar, previewScrollbarMetrics]
|
||||
);
|
||||
|
||||
const handlePreviewLayout = useCallback(
|
||||
(event: LayoutChangeEvent) => {
|
||||
if (enablePreviewDesktopScrollbar) {
|
||||
previewScrollbarMetrics.onLayout(event);
|
||||
}
|
||||
},
|
||||
[enablePreviewDesktopScrollbar, previewScrollbarMetrics]
|
||||
);
|
||||
|
||||
if (isLoading && !preview) {
|
||||
return (
|
||||
<View style={styles.sheetCenterState}>
|
||||
<ActivityIndicator size="small" />
|
||||
<Text style={styles.loadingText}>Loading file…</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!preview) {
|
||||
return (
|
||||
<View style={styles.sheetCenterState}>
|
||||
<Text style={styles.emptyText}>No preview available</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (preview.kind === "text") {
|
||||
if (variant === "sheet") {
|
||||
return (
|
||||
<BottomSheetScrollView style={styles.previewContent}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
contentContainerStyle={styles.previewCodeScrollContent}
|
||||
>
|
||||
<Text style={styles.codeText}>{preview.content}</Text>
|
||||
</ScrollView>
|
||||
</BottomSheetScrollView>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View style={styles.previewScrollContainer}>
|
||||
<RNScrollView
|
||||
ref={previewScrollRef}
|
||||
style={styles.previewContent}
|
||||
onLayout={enablePreviewDesktopScrollbar ? handlePreviewLayout : undefined}
|
||||
onScroll={enablePreviewDesktopScrollbar ? handlePreviewScroll : undefined}
|
||||
onContentSizeChange={
|
||||
enablePreviewDesktopScrollbar
|
||||
? previewScrollbarMetrics.onContentSizeChange
|
||||
: undefined
|
||||
}
|
||||
scrollEventThrottle={enablePreviewDesktopScrollbar ? 16 : undefined}
|
||||
showsVerticalScrollIndicator={!enablePreviewDesktopScrollbar}
|
||||
>
|
||||
<RNScrollView
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
contentContainerStyle={styles.previewCodeScrollContent}
|
||||
>
|
||||
<Text style={styles.codeText}>{preview.content}</Text>
|
||||
</RNScrollView>
|
||||
</RNScrollView>
|
||||
<WebDesktopScrollbarOverlay
|
||||
enabled={enablePreviewDesktopScrollbar}
|
||||
metrics={previewScrollbarMetrics}
|
||||
onScrollToOffset={(nextOffset) => {
|
||||
previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false });
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (preview.kind === "image" && preview.content) {
|
||||
if (variant === "sheet") {
|
||||
return (
|
||||
<BottomSheetScrollView contentContainerStyle={styles.previewImageScrollContent}>
|
||||
<RNImage
|
||||
source={{
|
||||
uri: `data:${preview.mimeType ?? "image/png"};base64,${preview.content}`,
|
||||
}}
|
||||
style={styles.previewImage}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</BottomSheetScrollView>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View style={styles.previewScrollContainer}>
|
||||
<RNScrollView
|
||||
ref={previewScrollRef}
|
||||
style={styles.previewContent}
|
||||
contentContainerStyle={styles.previewImageScrollContent}
|
||||
onLayout={enablePreviewDesktopScrollbar ? handlePreviewLayout : undefined}
|
||||
onScroll={enablePreviewDesktopScrollbar ? handlePreviewScroll : undefined}
|
||||
onContentSizeChange={
|
||||
enablePreviewDesktopScrollbar
|
||||
? previewScrollbarMetrics.onContentSizeChange
|
||||
: undefined
|
||||
}
|
||||
scrollEventThrottle={enablePreviewDesktopScrollbar ? 16 : undefined}
|
||||
showsVerticalScrollIndicator={!enablePreviewDesktopScrollbar}
|
||||
>
|
||||
<RNImage
|
||||
source={{
|
||||
uri: `data:${preview.mimeType ?? "image/png"};base64,${preview.content}`,
|
||||
}}
|
||||
style={styles.previewImage}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</RNScrollView>
|
||||
<WebDesktopScrollbarOverlay
|
||||
enabled={enablePreviewDesktopScrollbar}
|
||||
metrics={previewScrollbarMetrics}
|
||||
onScrollToOffset={(nextOffset) => {
|
||||
previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false });
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.sheetCenterState}>
|
||||
<Text style={styles.emptyText}>Binary preview unavailable</Text>
|
||||
<Text style={styles.binaryMetaText}>{formatFileSize({ size: preview.size })}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function formatFileSize({ size }: { size: number }): string {
|
||||
if (size < 1024) {
|
||||
return `${size} B`;
|
||||
}
|
||||
if (size < 1024 * 1024) {
|
||||
return `${(size / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
type EntryDisplayKind = "directory" | "image" | "text" | "other";
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set([
|
||||
|
||||
287
packages/app/src/components/file-pane.tsx
Normal file
287
packages/app/src/components/file-pane.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Image as RNImage,
|
||||
ScrollView as RNScrollView,
|
||||
Text,
|
||||
View,
|
||||
Platform,
|
||||
type LayoutChangeEvent,
|
||||
type NativeScrollEvent,
|
||||
type NativeSyntheticEvent,
|
||||
} from "react-native";
|
||||
import { StyleSheet, UnistylesRuntime } 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";
|
||||
|
||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function formatFileSize({ size }: { size: number }): string {
|
||||
if (size < 1024) {
|
||||
return `${size} B`;
|
||||
}
|
||||
if (size < 1024 * 1024) {
|
||||
return `${(size / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function FilePreviewBody({
|
||||
preview,
|
||||
isLoading,
|
||||
showDesktopWebScrollbar,
|
||||
isMobile,
|
||||
}: {
|
||||
preview: ExplorerFile | null;
|
||||
isLoading: boolean;
|
||||
showDesktopWebScrollbar: boolean;
|
||||
isMobile: boolean;
|
||||
}) {
|
||||
const enablePreviewDesktopScrollbar = showDesktopWebScrollbar;
|
||||
const previewScrollRef = useRef<RNScrollView>(null);
|
||||
const previewScrollbarMetrics = useWebDesktopScrollbarMetrics();
|
||||
|
||||
const handlePreviewScroll = useCallback(
|
||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
if (enablePreviewDesktopScrollbar) {
|
||||
previewScrollbarMetrics.onScroll(event);
|
||||
}
|
||||
},
|
||||
[enablePreviewDesktopScrollbar, previewScrollbarMetrics]
|
||||
);
|
||||
|
||||
const handlePreviewLayout = useCallback(
|
||||
(event: LayoutChangeEvent) => {
|
||||
if (enablePreviewDesktopScrollbar) {
|
||||
previewScrollbarMetrics.onLayout(event);
|
||||
}
|
||||
},
|
||||
[enablePreviewDesktopScrollbar, previewScrollbarMetrics]
|
||||
);
|
||||
|
||||
if (isLoading && !preview) {
|
||||
return (
|
||||
<View style={styles.centerState}>
|
||||
<ActivityIndicator size="small" />
|
||||
<Text style={styles.loadingText}>Loading file…</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!preview) {
|
||||
return (
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.emptyText}>No preview available</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (preview.kind === "text") {
|
||||
return (
|
||||
<View style={styles.previewScrollContainer}>
|
||||
<RNScrollView
|
||||
ref={previewScrollRef}
|
||||
style={styles.previewContent}
|
||||
onLayout={enablePreviewDesktopScrollbar ? handlePreviewLayout : undefined}
|
||||
onScroll={enablePreviewDesktopScrollbar ? handlePreviewScroll : undefined}
|
||||
onContentSizeChange={
|
||||
enablePreviewDesktopScrollbar
|
||||
? previewScrollbarMetrics.onContentSizeChange
|
||||
: undefined
|
||||
}
|
||||
scrollEventThrottle={enablePreviewDesktopScrollbar ? 16 : undefined}
|
||||
showsVerticalScrollIndicator={!enablePreviewDesktopScrollbar}
|
||||
>
|
||||
{isMobile ? (
|
||||
<View style={styles.previewCodeScrollContent}>
|
||||
<Text style={styles.codeText}>{preview.content}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<RNScrollView
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
contentContainerStyle={styles.previewCodeScrollContent}
|
||||
>
|
||||
<Text style={styles.codeText}>{preview.content}</Text>
|
||||
</RNScrollView>
|
||||
)}
|
||||
</RNScrollView>
|
||||
<WebDesktopScrollbarOverlay
|
||||
enabled={enablePreviewDesktopScrollbar}
|
||||
metrics={previewScrollbarMetrics}
|
||||
onScrollToOffset={(nextOffset) => {
|
||||
previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false });
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (preview.kind === "image" && preview.content) {
|
||||
return (
|
||||
<View style={styles.previewScrollContainer}>
|
||||
<RNScrollView
|
||||
ref={previewScrollRef}
|
||||
style={styles.previewContent}
|
||||
contentContainerStyle={styles.previewImageScrollContent}
|
||||
onLayout={enablePreviewDesktopScrollbar ? handlePreviewLayout : undefined}
|
||||
onScroll={enablePreviewDesktopScrollbar ? handlePreviewScroll : undefined}
|
||||
onContentSizeChange={
|
||||
enablePreviewDesktopScrollbar
|
||||
? previewScrollbarMetrics.onContentSizeChange
|
||||
: undefined
|
||||
}
|
||||
scrollEventThrottle={enablePreviewDesktopScrollbar ? 16 : undefined}
|
||||
showsVerticalScrollIndicator={!enablePreviewDesktopScrollbar}
|
||||
>
|
||||
<RNImage
|
||||
source={{
|
||||
uri: `data:${preview.mimeType ?? "image/png"};base64,${preview.content}`,
|
||||
}}
|
||||
style={styles.previewImage}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</RNScrollView>
|
||||
<WebDesktopScrollbarOverlay
|
||||
enabled={enablePreviewDesktopScrollbar}
|
||||
metrics={previewScrollbarMetrics}
|
||||
onScrollToOffset={(nextOffset) => {
|
||||
previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false });
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.emptyText}>Binary preview unavailable</Text>
|
||||
<Text style={styles.binaryMetaText}>{formatFileSize({ size: preview.size })}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function FilePane({
|
||||
serverId,
|
||||
workspaceRoot,
|
||||
filePath,
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceRoot: string;
|
||||
filePath: string;
|
||||
}) {
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
|
||||
|
||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
|
||||
const normalizedFilePath = useMemo(() => trimNonEmpty(filePath), [filePath]);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["workspaceFile", serverId, normalizedWorkspaceRoot, normalizedFilePath],
|
||||
enabled: Boolean(client && normalizedWorkspaceRoot && normalizedFilePath),
|
||||
queryFn: async () => {
|
||||
if (!client || !normalizedWorkspaceRoot || !normalizedFilePath) {
|
||||
return { file: null as ExplorerFile | null, error: "Host is not connected" };
|
||||
}
|
||||
const payload = await client.exploreFileSystem(
|
||||
normalizedWorkspaceRoot,
|
||||
normalizedFilePath,
|
||||
"file"
|
||||
);
|
||||
return { file: payload.file ?? null, error: payload.error ?? null };
|
||||
},
|
||||
staleTime: 5_000,
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={styles.container} testID="workspace-file-pane">
|
||||
{query.data?.error ? (
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.errorText}>{query.data.error}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<FilePreviewBody
|
||||
preview={query.data?.file ?? null}
|
||||
isLoading={query.isFetching}
|
||||
showDesktopWebScrollbar={showDesktopWebScrollbar}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
centerState: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: theme.spacing[4],
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: theme.spacing[2],
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
errorText: {
|
||||
color: theme.colors.destructive,
|
||||
fontSize: theme.fontSize.sm,
|
||||
textAlign: "center",
|
||||
},
|
||||
emptyText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
textAlign: "center",
|
||||
},
|
||||
binaryMetaText: {
|
||||
marginTop: theme.spacing[2],
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
previewScrollContainer: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
previewContent: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
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],
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
previewImage: {
|
||||
width: "100%",
|
||||
height: 420,
|
||||
},
|
||||
}));
|
||||
192
packages/app/src/components/git-actions-split-button.tsx
Normal file
192
packages/app/src/components/git-actions-split-button.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { useCallback } from "react";
|
||||
import { View, Text, ActivityIndicator, Pressable } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { ChevronDown, MoreVertical } from "lucide-react-native";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import type { GitAction, GitActions } from "@/hooks/use-git-actions";
|
||||
|
||||
interface GitActionsSplitButtonProps {
|
||||
gitActions: GitActions;
|
||||
}
|
||||
|
||||
export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps) {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
const getActionDisplayLabel = useCallback((action: GitAction): string => {
|
||||
if (action.status === "pending") return action.pendingLabel;
|
||||
if (action.status === "success") return action.successLabel;
|
||||
return action.label;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
{gitActions.primary ? (
|
||||
<View style={styles.splitButton}>
|
||||
<Pressable
|
||||
testID="changes-primary-cta"
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.splitButtonPrimary,
|
||||
(hovered || pressed) && styles.splitButtonPrimaryHovered,
|
||||
gitActions.primary!.disabled && styles.splitButtonPrimaryDisabled,
|
||||
]}
|
||||
onPress={gitActions.primary.handler}
|
||||
disabled={gitActions.primary.disabled}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={gitActions.primary.label}
|
||||
>
|
||||
{gitActions.primary.status === "pending" ? (
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={theme.colors.foreground}
|
||||
style={styles.splitButtonSpinnerOnly}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.splitButtonContent}>
|
||||
{gitActions.primary.icon}
|
||||
<Text style={styles.splitButtonText}>{getActionDisplayLabel(gitActions.primary)}</Text>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
{gitActions.secondary.length > 0 ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
testID="changes-primary-cta-caret"
|
||||
style={({ hovered, pressed, open }) => [
|
||||
styles.splitButtonCaret,
|
||||
(hovered || pressed || open) && styles.splitButtonCaretHovered,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="More options"
|
||||
>
|
||||
<ChevronDown size={16} color={theme.colors.foregroundMuted} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" testID="changes-primary-cta-menu">
|
||||
{gitActions.secondary.map((action, index) => {
|
||||
const needsSeparator = action.id === "merge-from-base" || action.id === "push";
|
||||
return (
|
||||
<View key={action.id}>
|
||||
{needsSeparator && index > 0 ? <DropdownMenuSeparator /> : null}
|
||||
<DropdownMenuItem
|
||||
testID={`changes-menu-${action.id}`}
|
||||
leading={action.icon}
|
||||
disabled={action.disabled}
|
||||
status={action.status}
|
||||
pendingLabel={action.pendingLabel}
|
||||
successLabel={action.successLabel}
|
||||
closeOnSelect={action.status === "idle" && action.id === "view-pr"}
|
||||
description={action.description}
|
||||
onSelect={action.handler}
|
||||
>
|
||||
{action.label}
|
||||
</DropdownMenuItem>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
{gitActions.menu.length > 0 ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
testID="changes-overflow-menu"
|
||||
hitSlop={8}
|
||||
style={[styles.iconButton, styles.overflowMenuButton]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="More actions"
|
||||
>
|
||||
<MoreVertical size={16} color={theme.colors.foregroundMuted} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" width={220} testID="changes-overflow-content">
|
||||
{gitActions.menu.map((action) => (
|
||||
<DropdownMenuItem
|
||||
key={action.id}
|
||||
testID={`changes-menu-${action.id}`}
|
||||
leading={action.icon}
|
||||
disabled={action.disabled}
|
||||
status={action.status}
|
||||
pendingLabel={action.pendingLabel}
|
||||
successLabel={action.successLabel}
|
||||
closeOnSelect={false}
|
||||
onSelect={action.handler}
|
||||
>
|
||||
{action.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
flexShrink: 0,
|
||||
},
|
||||
splitButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "stretch",
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.borderAccent,
|
||||
overflow: "hidden",
|
||||
},
|
||||
splitButtonPrimary: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[1],
|
||||
justifyContent: "center",
|
||||
position: "relative",
|
||||
},
|
||||
splitButtonPrimaryHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
splitButtonPrimaryDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
splitButtonText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: theme.fontSize.sm * 1.5,
|
||||
color: theme.colors.foreground,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
splitButtonContent: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
splitButtonSpinnerOnly: {
|
||||
transform: [{ scale: 0.8 }],
|
||||
},
|
||||
splitButtonCaret: {
|
||||
width: 28,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderLeftWidth: theme.borderWidth[1],
|
||||
borderLeftColor: theme.colors.borderAccent,
|
||||
},
|
||||
splitButtonCaretHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
iconButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
overflowMenuButton: {
|
||||
marginRight: -theme.spacing[2],
|
||||
},
|
||||
}));
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
GitMerge,
|
||||
ListChevronsDownUp,
|
||||
ListChevronsUpDown,
|
||||
MoreVertical,
|
||||
RefreshCcw,
|
||||
Upload,
|
||||
} from "lucide-react-native";
|
||||
@@ -47,7 +46,6 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
type ActionStatus,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||
import {
|
||||
@@ -58,36 +56,11 @@ import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { shouldShowMergeFromBaseAction } from "./git-action-visibility";
|
||||
|
||||
// =============================================================================
|
||||
// Git Actions Data Structure
|
||||
// =============================================================================
|
||||
import { type GitActionId, type GitAction, type GitActions } from "@/hooks/use-git-actions";
|
||||
import { GitActionsSplitButton } from "@/components/git-actions-split-button";
|
||||
|
||||
type GitActionId =
|
||||
| "commit"
|
||||
| "push"
|
||||
| "view-pr"
|
||||
| "create-pr"
|
||||
| "merge-branch"
|
||||
| "merge-from-base"
|
||||
| "archive-worktree";
|
||||
|
||||
interface GitAction {
|
||||
id: GitActionId;
|
||||
label: string;
|
||||
pendingLabel: string;
|
||||
successLabel: string;
|
||||
disabled: boolean;
|
||||
status: ActionStatus;
|
||||
description?: string;
|
||||
icon?: ReactElement;
|
||||
handler: () => void;
|
||||
}
|
||||
|
||||
interface GitActions {
|
||||
primary: GitAction | null;
|
||||
secondary: GitAction[];
|
||||
menu: GitAction[];
|
||||
}
|
||||
// Re-export types from shared hook
|
||||
export type { GitActionId, GitAction, GitActions } from "@/hooks/use-git-actions";
|
||||
|
||||
function openURLInNewTab(url: string): void {
|
||||
void openExternalUrl(url);
|
||||
@@ -464,15 +437,16 @@ function DiffFileBody({
|
||||
|
||||
interface GitDiffPaneProps {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
workspaceId?: string | null;
|
||||
cwd: string;
|
||||
hideHeaderRow?: boolean;
|
||||
}
|
||||
|
||||
type DiffFlatItem =
|
||||
| { type: "header"; file: ParsedDiffFile; fileIndex: number; isExpanded: boolean }
|
||||
| { type: "body"; file: ParsedDiffFile; fileIndex: number };
|
||||
|
||||
export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDiffPaneProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
@@ -750,7 +724,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
perfLog(DIFF_PANE_LOG_TAG, {
|
||||
event: "files_snapshot",
|
||||
serverId,
|
||||
agentId,
|
||||
workspaceId: workspaceId ?? cwd,
|
||||
fileCount: diffMetrics.fileCount,
|
||||
hunkCount: diffMetrics.hunkCount,
|
||||
lineCount: diffMetrics.lineCount,
|
||||
@@ -758,7 +732,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
isLoading: isDiffLoading,
|
||||
isFetching: isDiffFetching,
|
||||
});
|
||||
}, [agentId, diffMetrics, isDiffFetching, isDiffLoading, serverId]);
|
||||
}, [cwd, diffMetrics, isDiffFetching, isDiffLoading, serverId, workspaceId]);
|
||||
|
||||
const commitStatus = useCheckoutGitActionsStore((state) =>
|
||||
state.getStatus({ serverId, cwd, actionId: "commit" })
|
||||
@@ -948,7 +922,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
if (isStatusLoading) {
|
||||
bodyContent = (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" />
|
||||
<ActivityIndicator size="large" color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.loadingText}>Checking repository...</Text>
|
||||
</View>
|
||||
);
|
||||
@@ -967,8 +941,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
} else if (isDiffLoading) {
|
||||
bodyContent = (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" />
|
||||
<Text style={styles.loadingText}>Loading changes...</Text>
|
||||
<ActivityIndicator size="large" color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
);
|
||||
} else if (diffErrorMessage) {
|
||||
@@ -1220,122 +1193,22 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
]);
|
||||
|
||||
// Helper to get display label based on status
|
||||
const getActionDisplayLabel = useCallback((action: GitAction): string => {
|
||||
if (action.status === "pending") return action.pendingLabel;
|
||||
if (action.status === "success") return action.successLabel;
|
||||
return action.label;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header} testID="changes-header">
|
||||
<View style={styles.headerLeft}>
|
||||
<GitBranch size={16} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.branchLabel} testID="changes-branch" numberOfLines={1}>
|
||||
{branchLabel}
|
||||
</Text>
|
||||
{isStatusFetching && (
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
</View>
|
||||
{isGit ? (
|
||||
<View style={styles.headerRight}>
|
||||
{gitActions.primary ? (
|
||||
<View style={styles.splitButton}>
|
||||
<Pressable
|
||||
testID="changes-primary-cta"
|
||||
style={[
|
||||
styles.splitButtonPrimary,
|
||||
gitActions.primary.disabled && styles.splitButtonPrimaryDisabled,
|
||||
]}
|
||||
onPress={gitActions.primary.handler}
|
||||
disabled={gitActions.primary.disabled}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={gitActions.primary.label}
|
||||
>
|
||||
{gitActions.primary.status === "pending" ? (
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={theme.colors.foreground}
|
||||
style={styles.splitButtonSpinnerOnly}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.splitButtonContent}>
|
||||
{gitActions.primary.icon}
|
||||
<Text style={styles.splitButtonText}>{getActionDisplayLabel(gitActions.primary)}</Text>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
{gitActions.secondary.length > 0 ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
testID="changes-primary-cta-caret"
|
||||
style={styles.splitButtonCaret}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="More options"
|
||||
>
|
||||
<ChevronDown size={16} color={theme.colors.foregroundMuted} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" testID="changes-primary-cta-menu">
|
||||
{gitActions.secondary.map((action, index) => {
|
||||
const needsSeparator = action.id === "merge-from-base" || action.id === "push";
|
||||
return (
|
||||
<View key={action.id}>
|
||||
{needsSeparator && index > 0 ? <DropdownMenuSeparator /> : null}
|
||||
<DropdownMenuItem
|
||||
testID={`changes-menu-${action.id}`}
|
||||
leading={action.icon}
|
||||
disabled={action.disabled}
|
||||
status={action.status}
|
||||
pendingLabel={action.pendingLabel}
|
||||
successLabel={action.successLabel}
|
||||
closeOnSelect={action.status === "idle" && action.id === "view-pr"}
|
||||
description={action.description}
|
||||
onSelect={action.handler}
|
||||
>
|
||||
{action.label}
|
||||
</DropdownMenuItem>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
{gitActions.menu.length > 0 ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
testID="changes-overflow-menu"
|
||||
hitSlop={8}
|
||||
style={[styles.iconButton, styles.overflowMenuButton]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="More actions"
|
||||
>
|
||||
<MoreVertical size={16} color={theme.colors.foregroundMuted} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" width={220} testID="changes-overflow-content">
|
||||
{gitActions.menu.map((action) => (
|
||||
<DropdownMenuItem
|
||||
key={action.id}
|
||||
testID={`changes-menu-${action.id}`}
|
||||
leading={action.icon}
|
||||
disabled={action.disabled}
|
||||
status={action.status}
|
||||
pendingLabel={action.pendingLabel}
|
||||
successLabel={action.successLabel}
|
||||
closeOnSelect={false}
|
||||
onSelect={action.handler}
|
||||
>
|
||||
{action.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
{!hideHeaderRow ? (
|
||||
<View style={styles.header} testID="changes-header">
|
||||
<View style={styles.headerLeft}>
|
||||
<GitBranch size={16} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.branchLabel} testID="changes-branch" numberOfLines={1}>
|
||||
{branchLabel}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
{isGit ? (
|
||||
<GitActionsSplitButton gitActions={gitActions} />
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{isGit ? (
|
||||
<View style={styles.diffStatusContainer}>
|
||||
@@ -1442,12 +1315,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
headerRight: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
flexShrink: 0,
|
||||
},
|
||||
branchLabel: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foreground,
|
||||
@@ -1455,6 +1322,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexShrink: 1,
|
||||
},
|
||||
diffStatusContainer: {
|
||||
paddingVertical: 1.5,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
@@ -1500,112 +1368,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingVertical: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.base,
|
||||
},
|
||||
splitButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "stretch",
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.borderAccent,
|
||||
overflow: "hidden",
|
||||
},
|
||||
splitButtonPrimary: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[1],
|
||||
justifyContent: "center",
|
||||
position: "relative",
|
||||
},
|
||||
splitButtonPrimaryDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
splitButtonText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: theme.fontSize.sm * 1.5,
|
||||
color: theme.colors.foreground,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
splitButtonContent: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
splitButtonSpinnerOnly: {
|
||||
transform: [{ scale: 0.8 }],
|
||||
},
|
||||
splitButtonCaret: {
|
||||
width: 36,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderLeftWidth: theme.borderWidth[1],
|
||||
borderLeftColor: theme.colors.borderAccent,
|
||||
},
|
||||
iconButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
overflowMenuButton: {
|
||||
marginRight: -theme.spacing[2],
|
||||
},
|
||||
menuOverlay: {
|
||||
flex: 1,
|
||||
},
|
||||
menuBackdrop: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
},
|
||||
dropdownMenu: {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
overflow: "hidden",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
},
|
||||
menuItem: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
},
|
||||
menuItemSelected: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
menuItemDisabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
menuItemText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foreground,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
menuHintText: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingBottom: theme.spacing[2],
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
menuItemDestructive: {
|
||||
backgroundColor: "rgba(248, 81, 73, 0.08)",
|
||||
},
|
||||
menuItemTextDestructive: {
|
||||
color: theme.colors.destructive,
|
||||
},
|
||||
menuDivider: {
|
||||
height: 1,
|
||||
backgroundColor: theme.colors.border,
|
||||
},
|
||||
actionErrorText: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingBottom: theme.spacing[1],
|
||||
|
||||
@@ -1,10 +1,32 @@
|
||||
import type { PropsWithChildren, ReactElement } from "react";
|
||||
import { Text, View, type PressableProps, type StyleProp, type ViewStyle } from "react-native";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import {
|
||||
Platform,
|
||||
Text,
|
||||
View,
|
||||
type PressableProps,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
|
||||
interface HeaderToggleButtonState {
|
||||
hovered: boolean;
|
||||
pressed: boolean;
|
||||
}
|
||||
|
||||
interface HeaderToggleButtonProps extends Omit<PressableProps, "style" | "onPress" | "children"> {
|
||||
onPress: NonNullable<PressableProps["onPress"]>;
|
||||
tooltipLabel: string;
|
||||
tooltipKeys: ShortcutKey[];
|
||||
tooltipSide: "left" | "right" | "top" | "bottom";
|
||||
tooltipDelayDuration?: number;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
children: ReactNode | ((state: HeaderToggleButtonState) => ReactNode);
|
||||
}
|
||||
|
||||
export function HeaderToggleButton({
|
||||
onPress,
|
||||
tooltipLabel,
|
||||
@@ -15,32 +37,32 @@ export function HeaderToggleButton({
|
||||
disabled,
|
||||
children,
|
||||
...props
|
||||
}: PropsWithChildren<
|
||||
Omit<PressableProps, "style" | "onPress"> & {
|
||||
onPress: NonNullable<PressableProps["onPress"]>;
|
||||
tooltipLabel: string;
|
||||
tooltipKeys: ShortcutKey[];
|
||||
tooltipSide: "left" | "right" | "top" | "bottom";
|
||||
tooltipDelayDuration?: number;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
>): ReactElement {
|
||||
}: HeaderToggleButtonProps): ReactElement {
|
||||
const tooltipTestID =
|
||||
typeof props.testID === "string" && props.testID.length > 0
|
||||
? `${props.testID}-tooltip`
|
||||
: undefined;
|
||||
const expandedState = (props.accessibilityState as { expanded?: boolean } | undefined)?.expanded;
|
||||
const ariaExpandedProps =
|
||||
Platform.OS === "web" && typeof expandedState === "boolean"
|
||||
? ({ "aria-expanded": expandedState } as any)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={tooltipDelayDuration} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
{...props}
|
||||
{...ariaExpandedProps}
|
||||
disabled={disabled}
|
||||
onPress={(e) => {
|
||||
onPress(e);
|
||||
}}
|
||||
style={[styles.button, style]}
|
||||
>
|
||||
{children}
|
||||
{typeof children === "function"
|
||||
? (state: { pressed: boolean; hovered?: boolean }) =>
|
||||
children({ hovered: Boolean(state.hovered), pressed: state.pressed })
|
||||
: children}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent testID={tooltipTestID} side={tooltipSide} align="center" offset={8}>
|
||||
<View style={styles.tooltipRow}>
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, UnistylesRuntime } from "react-native-unistyles";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
HEADER_INNER_HEIGHT,
|
||||
HEADER_INNER_HEIGHT_MOBILE,
|
||||
HEADER_TOP_PADDING_MOBILE,
|
||||
getIsTauriMac,
|
||||
} from "@/constants/layout";
|
||||
import { useTauriDragHandlers } from "@/utils/tauri-window";
|
||||
import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-window";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
|
||||
interface ScreenHeaderProps {
|
||||
left?: ReactNode;
|
||||
@@ -26,19 +28,29 @@ export function ScreenHeader({
|
||||
leftStyle,
|
||||
rightStyle,
|
||||
}: ScreenHeaderProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||
const trafficLightPadding = useTrafficLightPadding();
|
||||
// Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets
|
||||
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
|
||||
const baseHorizontalPadding = theme.spacing[2];
|
||||
const collapsedSidebarTrafficLightInset =
|
||||
!isMobile && !desktopAgentListOpen && getIsTauriMac()
|
||||
? trafficLightPadding.left
|
||||
: 0;
|
||||
|
||||
// On Tauri macOS, enable window dragging and double-click to maximize
|
||||
// Left padding for traffic lights is handled by _layout.tsx when sidebar is collapsed
|
||||
const dragHandlers = useTauriDragHandlers();
|
||||
|
||||
return (
|
||||
<View style={styles.header}>
|
||||
<View style={[styles.inner, { paddingTop: insets.top + topPadding }]}>
|
||||
<View style={styles.row} {...dragHandlers}>
|
||||
<View
|
||||
style={[styles.row, { paddingLeft: baseHorizontalPadding + collapsedSidebarTrafficLightInset }]}
|
||||
{...dragHandlers}
|
||||
>
|
||||
<View style={[styles.left, leftStyle]}>{left}</View>
|
||||
<View style={[styles.right, rightStyle]}>{right}</View>
|
||||
</View>
|
||||
|
||||
17
packages/app/src/components/icons/paseo-logo.tsx
Normal file
17
packages/app/src/components/icons/paseo-logo.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
|
||||
interface PaseoLogoProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function PaseoLogo({ size = 64, color = "white" }: PaseoLogoProps) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 700 700" fill="none">
|
||||
<Path
|
||||
d="M291.495 91.399C333.897 104.892 379.155 135.075 416.229 173.191C453.389 211.394 484.429 259.725 495.708 311.251C497.555 319.693 498.865 328.216 499.586 336.776C509.755 326.554 519.867 317.815 529.89 311.547C540.647 304.821 553.808 299.297 568.641 299.785C584.29 300.299 597.395 307.326 607.747 317.632C632.173 341.947 629.612 372.898 619.872 397.936C610.185 422.833 591.557 447.826 572.732 469.124C553.591 490.78 532.713 510.308 516.779 524.318C508.775 531.355 501.936 537.073 497.07 541.052C494.635 543.043 492.689 544.603 491.334 545.679C490.657 546.217 490.126 546.635 489.756 546.926C489.571 547.071 489.425 547.184 489.321 547.265C489.269 547.305 489.227 547.338 489.196 547.362C489.181 547.374 489.168 547.385 489.157 547.393C489.153 547.397 489.147 547.401 489.144 547.403C489.134 547.4 488.837 547.06 473.001 528.499L489.135 547.411C478.157 555.911 462.033 554.334 453.122 543.89C444.213 533.448 445.887 518.094 456.861 509.592C456.863 509.591 456.865 509.588 456.869 509.586C456.88 509.577 456.902 509.561 456.933 509.536C456.997 509.487 457.101 509.404 457.245 509.292C457.533 509.066 457.979 508.715 458.569 508.247C459.749 507.31 461.506 505.901 463.742 504.073C468.216 500.414 474.589 495.088 482.073 488.508C497.114 475.284 516.315 457.282 533.578 437.75C551.157 417.862 565.26 398.01 571.859 381.048C578.403 364.227 575.681 356.302 570.724 351.367C568.928 349.579 567.744 348.902 567.267 348.676C566.888 348.496 566.811 348.52 566.804 348.52C566.605 348.513 563.971 348.537 557.953 352.3C545.161 360.299 528.815 377.492 506.807 403.867C494.927 418.106 481.871 434.435 467.547 451.957C463.709 457.28 459.503 462.538 454.91 467.717L454.702 467.549C420.808 508.347 380.37 553.856 332.335 593.848C301.853 619.226 262.656 622.597 228.642 614.743C194.834 606.936 162.658 587.448 142.217 561.686C108.054 518.631 100.57 469.801 108.223 427.836C115.56 387.606 137.391 351.005 166.502 331.557C161.248 315.813 156.813 299.49 153.519 283.013C142.593 228.368 143.239 167.031 174.28 119.619C186.922 100.31 205.846 89.1535 227.387 85.2773C248.1 81.5504 270.278 84.648 291.495 91.399ZM378.642 206.356C345.773 172.563 307.463 147.917 275.208 137.654C259.096 132.527 246.171 131.514 236.828 133.195C228.314 134.727 222.227 138.497 217.721 145.38C196.712 177.468 193.858 224.004 203.82 273.827C206.532 287.394 210.127 300.834 214.345 313.817C236.45 310.276 260.156 311.463 281.22 317.11C319.621 327.403 357.501 355.419 357.501 405.654C357.501 435.255 339.111 465.136 307.278 473.815C273.211 483.103 238.854 464.822 213.105 427.541C203.716 413.947 194.443 397.766 185.947 379.89C174.028 392.223 163.08 411.953 158.673 436.118C153.128 466.518 158.514 501.286 183.085 532.253C195.993 548.522 217.742 562.031 240.771 567.349C263.594 572.619 284.147 569.24 298.664 557.154C349.383 514.927 390.709 466.547 426.366 422.952C448.879 390.86 453.195 356.06 445.578 321.265C436.703 280.718 411.425 240.06 378.642 206.356ZM306.296 405.722C306.296 384.769 292.223 370.736 267.284 364.051C256.012 361.03 244.156 360.087 233.095 360.771C240.361 375.935 248.168 389.513 255.897 400.704C275.647 429.298 289.989 427.822 293.247 426.934C298.737 425.437 306.296 418.161 306.296 405.722Z"
|
||||
fill={color}
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import Svg, { Rect, Line } from "react-native-svg";
|
||||
|
||||
interface SourceControlPanelIconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function SourceControlPanelIcon({
|
||||
size = 16,
|
||||
color = "currentColor",
|
||||
}: SourceControlPanelIconProps) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<Rect
|
||||
x={3}
|
||||
y={3}
|
||||
width={18}
|
||||
height={18}
|
||||
rx={2}
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{/* Plus */}
|
||||
<Line x1={9} y1={9.5} x2={15} y2={9.5} stroke={color} strokeWidth={2} strokeLinecap="round" />
|
||||
<Line x1={12} y1={6.5} x2={12} y2={12.5} stroke={color} strokeWidth={2} strokeLinecap="round" />
|
||||
{/* Minus */}
|
||||
<Line x1={9} y1={16} x2={15} y2={16} stroke={color} strokeWidth={2} strokeLinecap="round" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
@@ -1,70 +1,60 @@
|
||||
import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import { View, Pressable, Text, Platform } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useCallback, useMemo, useState, useEffect, useRef, useSyncExternalStore } from 'react'
|
||||
import { View, Pressable, Text, Platform } from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
interpolate,
|
||||
Extrapolation,
|
||||
runOnJS,
|
||||
} from "react-native-reanimated";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { Plus, Settings, Users } from "lucide-react-native";
|
||||
import { router, usePathname } from "expo-router";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { SidebarAgentList } from "./sidebar-agent-list";
|
||||
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
||||
import { useSidebarAgentsList } from "@/hooks/use-sidebar-agents-list";
|
||||
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
||||
import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-window";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { formatConnectionStatus } from "@/utils/daemons";
|
||||
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout";
|
||||
import {
|
||||
checkoutStatusQueryKey,
|
||||
type CheckoutStatusPayload,
|
||||
} from "@/hooks/use-checkout-status-query";
|
||||
import { queryClient } from "@/query/query-client";
|
||||
import {
|
||||
buildNewAgentRoute,
|
||||
resolveNewAgentWorkingDir,
|
||||
resolveSelectedAgentForNewAgent,
|
||||
} from "@/utils/new-agent-routing";
|
||||
useSharedValue,
|
||||
} from 'react-native-reanimated'
|
||||
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
|
||||
import { MessagesSquare, Plus, Settings } from 'lucide-react-native'
|
||||
import { router, usePathname } from 'expo-router'
|
||||
import { usePanelStore } from '@/stores/panel-store'
|
||||
import { SidebarWorkspaceList } from './sidebar-workspace-list'
|
||||
import { SidebarAgentListSkeleton } from './sidebar-agent-list-skeleton'
|
||||
import { useSidebarWorkspacesList } from '@/hooks/use-sidebar-workspaces-list'
|
||||
import { useSidebarAnimation } from '@/contexts/sidebar-animation-context'
|
||||
import { useTauriDragHandlers, useTrafficLightPadding } from '@/utils/tauri-window'
|
||||
import { Combobox } from '@/components/ui/combobox'
|
||||
import { useDaemonRegistry } from '@/contexts/daemon-registry-context'
|
||||
import { getHostRuntimeStore } from '@/runtime/host-runtime'
|
||||
import { formatConnectionStatus } from '@/utils/daemons'
|
||||
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from '@/constants/layout'
|
||||
import {
|
||||
buildHostAgentsRoute,
|
||||
buildHostSettingsRoute,
|
||||
mapPathnameToServer,
|
||||
parseServerIdFromPathname,
|
||||
} from "@/utils/host-routes";
|
||||
} from '@/utils/host-routes'
|
||||
import { useKeyboardShortcutsStore } from '@/stores/keyboard-shortcuts-store'
|
||||
|
||||
const DESKTOP_SIDEBAR_WIDTH = 320;
|
||||
const DESKTOP_SIDEBAR_WIDTH = 320
|
||||
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__)
|
||||
|
||||
interface LeftSidebarProps {
|
||||
selectedAgentId?: string;
|
||||
function logLeftSidebarCloseGesture(event: string, details: Record<string, unknown>): void {
|
||||
if (!IS_DEV) {
|
||||
return
|
||||
}
|
||||
console.log(`[LeftSidebarCloseGesture] ${event}`, details)
|
||||
}
|
||||
|
||||
export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||
const closeToAgent = usePanelStore((state) => state.closeToAgent);
|
||||
const pathname = usePathname();
|
||||
const { daemons } = useDaemonRegistry();
|
||||
const runtime = getHostRuntimeStore();
|
||||
interface LeftSidebarProps {
|
||||
selectedAgentId?: string
|
||||
}
|
||||
|
||||
export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarProps) {
|
||||
const { theme } = useUnistyles()
|
||||
const insets = useSafeAreaInsets()
|
||||
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
|
||||
const mobileView = usePanelStore((state) => state.mobileView)
|
||||
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen)
|
||||
const closeToAgent = usePanelStore((state) => state.closeToAgent)
|
||||
const pathname = usePathname()
|
||||
const { daemons } = useDaemonRegistry()
|
||||
const runtime = getHostRuntimeStore()
|
||||
const runtimeConnectionStatusSignature = useSyncExternalStore(
|
||||
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
||||
() =>
|
||||
@@ -72,74 +62,69 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
|
||||
.map(
|
||||
(daemon) =>
|
||||
`${daemon.serverId}:${
|
||||
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting"
|
||||
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? 'connecting'
|
||||
}`
|
||||
)
|
||||
.join("|"),
|
||||
.join('|'),
|
||||
() =>
|
||||
daemons
|
||||
.map(
|
||||
(daemon) =>
|
||||
`${daemon.serverId}:${
|
||||
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting"
|
||||
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? 'connecting'
|
||||
}`
|
||||
)
|
||||
.join("|")
|
||||
);
|
||||
const activeServerIdFromPath = useMemo(
|
||||
() => parseServerIdFromPathname(pathname),
|
||||
[pathname]
|
||||
);
|
||||
const activeServerId = activeServerIdFromPath ?? daemons[0]?.serverId ?? null;
|
||||
.join('|')
|
||||
)
|
||||
const activeServerIdFromPath = useMemo(() => parseServerIdFromPathname(pathname), [pathname])
|
||||
const activeDaemon = useMemo(() => {
|
||||
if (daemons.length === 0) {
|
||||
return null
|
||||
}
|
||||
if (activeServerIdFromPath) {
|
||||
const routeMatch = daemons.find((entry) => entry.serverId === activeServerIdFromPath)
|
||||
if (routeMatch) {
|
||||
return routeMatch
|
||||
}
|
||||
}
|
||||
return daemons[0] ?? null
|
||||
}, [activeServerIdFromPath, daemons])
|
||||
const activeServerId = activeDaemon?.serverId ?? null
|
||||
const activeHostLabel = useMemo(() => {
|
||||
if (!activeServerId) return "No host";
|
||||
const daemon = daemons.find((entry) => entry.serverId === activeServerId);
|
||||
const trimmed = daemon?.label?.trim();
|
||||
return trimmed && trimmed.length > 0 ? trimmed : activeServerId;
|
||||
}, [activeServerId, daemons]);
|
||||
if (!activeDaemon) return 'No host'
|
||||
const trimmed = activeDaemon.label?.trim()
|
||||
return trimmed && trimmed.length > 0 ? trimmed : activeDaemon.serverId
|
||||
}, [activeDaemon])
|
||||
const activeHostStatus = activeServerId
|
||||
? runtime.getSnapshot(activeServerId)?.connectionStatus ?? "connecting"
|
||||
: "idle";
|
||||
? (runtime.getSnapshot(activeServerId)?.connectionStatus ?? 'connecting')
|
||||
: 'idle'
|
||||
const activeHostStatusColor =
|
||||
activeHostStatus === "online"
|
||||
activeHostStatus === 'online'
|
||||
? theme.colors.palette.green[400]
|
||||
: activeHostStatus === "connecting"
|
||||
: activeHostStatus === 'connecting'
|
||||
? theme.colors.palette.amber[500]
|
||||
: theme.colors.palette.red[500];
|
||||
: theme.colors.palette.red[500]
|
||||
const hostOptions = useMemo(
|
||||
() =>
|
||||
daemons.map((daemon) => ({
|
||||
id: daemon.serverId,
|
||||
label: daemon.label?.trim() || daemon.serverId,
|
||||
description: formatConnectionStatus(
|
||||
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting"
|
||||
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? 'connecting'
|
||||
),
|
||||
})),
|
||||
[daemons, runtime, runtimeConnectionStatusSignature]
|
||||
);
|
||||
const hostTriggerRef = useRef<View>(null);
|
||||
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false);
|
||||
)
|
||||
const hostTriggerRef = useRef<View>(null)
|
||||
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false)
|
||||
|
||||
// Derive isOpen from the unified panel state
|
||||
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
|
||||
const [selectedProjectFilterKeys, setSelectedProjectFilterKeys] = useState<
|
||||
string[]
|
||||
>([]);
|
||||
const isOpen = isMobile ? mobileView === 'agent-list' : desktopAgentListOpen
|
||||
|
||||
const {
|
||||
entries,
|
||||
projectFilterOptions,
|
||||
hasMoreEntries,
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
refreshAll,
|
||||
} = useSidebarAgentsList({
|
||||
const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarWorkspacesList({
|
||||
serverId: activeServerId,
|
||||
selectedProjectFilterKeys,
|
||||
});
|
||||
useEffect(() => {
|
||||
setSelectedProjectFilterKeys([]);
|
||||
}, [activeServerId]);
|
||||
enabled: isOpen,
|
||||
})
|
||||
const {
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
@@ -148,215 +133,174 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
|
||||
animateToClose,
|
||||
isGesturing,
|
||||
closeGestureRef,
|
||||
} = useSidebarAnimation();
|
||||
const dragHandlers = useTauriDragHandlers();
|
||||
const trafficLightPadding = useTrafficLightPadding();
|
||||
} = useSidebarAnimation()
|
||||
const dragHandlers = useTauriDragHandlers()
|
||||
const trafficLightPadding = useTrafficLightPadding()
|
||||
const closeTouchStartX = useSharedValue(0)
|
||||
const closeTouchStartY = useSharedValue(0)
|
||||
|
||||
// Track user-initiated refresh to avoid showing spinner on background revalidation
|
||||
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
||||
const [isManualRefresh, setIsManualRefresh] = useState(false)
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
setIsManualRefresh(true);
|
||||
refreshAll();
|
||||
}, [refreshAll]);
|
||||
setIsManualRefresh(true)
|
||||
refreshAll()
|
||||
}, [refreshAll])
|
||||
|
||||
// Reset manual refresh flag when revalidation completes
|
||||
useEffect(() => {
|
||||
if (!isRevalidating && isManualRefresh) {
|
||||
setIsManualRefresh(false);
|
||||
setIsManualRefresh(false)
|
||||
}
|
||||
}, [isRevalidating, isManualRefresh]);
|
||||
|
||||
const setSidebarShortcutAgentKeys = useKeyboardShortcutsStore(
|
||||
(s) => s.setSidebarShortcutAgentKeys
|
||||
);
|
||||
const sidebarShortcutAgentKeys = useMemo(() => {
|
||||
return entries.slice(0, 9).map((entry) => `${entry.agent.serverId}:${entry.agent.id}`);
|
||||
}, [entries]);
|
||||
|
||||
useEffect(() => {
|
||||
setSidebarShortcutAgentKeys(sidebarShortcutAgentKeys);
|
||||
}, [setSidebarShortcutAgentKeys, sidebarShortcutAgentKeys]);
|
||||
}, [isRevalidating, isManualRefresh])
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
closeToAgent();
|
||||
}, [closeToAgent]);
|
||||
closeToAgent()
|
||||
}, [closeToAgent])
|
||||
|
||||
const handleCreateAgentClean = useCallback(() => {
|
||||
let targetServerId = activeServerId;
|
||||
let targetWorkingDir: string | null = null;
|
||||
const setProjectPickerOpen = useKeyboardShortcutsStore((s) => s.setProjectPickerOpen)
|
||||
|
||||
const selectedAgent = resolveSelectedAgentForNewAgent({
|
||||
pathname,
|
||||
selectedAgentId,
|
||||
});
|
||||
if (selectedAgent) {
|
||||
targetServerId = selectedAgent.serverId;
|
||||
const agent = useSessionStore
|
||||
.getState()
|
||||
.sessions[selectedAgent.serverId]
|
||||
?.agents?.get(selectedAgent.agentId);
|
||||
const cwd = agent?.cwd?.trim();
|
||||
if (cwd) {
|
||||
const checkout =
|
||||
queryClient.getQueryData<CheckoutStatusPayload>(
|
||||
checkoutStatusQueryKey(selectedAgent.serverId, cwd)
|
||||
) ?? null;
|
||||
targetWorkingDir = resolveNewAgentWorkingDir(cwd, checkout);
|
||||
}
|
||||
}
|
||||
const handleOpenProjectMobile = useCallback(() => {
|
||||
closeToAgent()
|
||||
setProjectPickerOpen(true)
|
||||
}, [closeToAgent, setProjectPickerOpen])
|
||||
|
||||
if (!targetServerId) {
|
||||
return;
|
||||
}
|
||||
router.push(buildNewAgentRoute(targetServerId, targetWorkingDir) as any);
|
||||
}, [activeServerId, pathname, selectedAgentId]);
|
||||
|
||||
// Mobile: close sidebar and navigate
|
||||
const handleCreateAgentCleanMobile = useCallback(() => {
|
||||
closeToAgent();
|
||||
handleCreateAgentClean();
|
||||
}, [closeToAgent, handleCreateAgentClean]);
|
||||
|
||||
// Desktop: just navigate, don't close
|
||||
const handleCreateAgentCleanDesktop = useCallback(() => {
|
||||
handleCreateAgentClean();
|
||||
}, [handleCreateAgentClean]);
|
||||
const handleOpenProjectDesktop = useCallback(() => {
|
||||
setProjectPickerOpen(true)
|
||||
}, [setProjectPickerOpen])
|
||||
|
||||
// Mobile: close sidebar and navigate
|
||||
const handleSettingsMobile = useCallback(() => {
|
||||
if (!activeServerId) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
closeToAgent();
|
||||
router.push(buildHostSettingsRoute(activeServerId) as any);
|
||||
}, [activeServerId, closeToAgent]);
|
||||
closeToAgent()
|
||||
router.push(buildHostSettingsRoute(activeServerId) as any)
|
||||
}, [activeServerId, closeToAgent])
|
||||
|
||||
// Desktop: just navigate, don't close
|
||||
const handleSettingsDesktop = useCallback(() => {
|
||||
if (!activeServerId) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
router.push(buildHostSettingsRoute(activeServerId) as any);
|
||||
}, [activeServerId]);
|
||||
|
||||
// Mobile: close sidebar when agent is selected
|
||||
// Snap immediately since navigation interrupts animations
|
||||
const handleAgentSelectMobile = useCallback(() => {
|
||||
translateX.value = -windowWidth;
|
||||
backdropOpacity.value = 0;
|
||||
closeToAgent();
|
||||
}, [closeToAgent, translateX, backdropOpacity, windowWidth]);
|
||||
router.push(buildHostSettingsRoute(activeServerId) as any)
|
||||
}, [activeServerId])
|
||||
|
||||
const handleViewMore = useCallback(() => {
|
||||
if (!activeServerId) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
if (isMobile) {
|
||||
translateX.value = -windowWidth;
|
||||
backdropOpacity.value = 0;
|
||||
closeToAgent();
|
||||
translateX.value = -windowWidth
|
||||
backdropOpacity.value = 0
|
||||
closeToAgent()
|
||||
}
|
||||
router.push(buildHostAgentsRoute(activeServerId) as any);
|
||||
}, [
|
||||
activeServerId,
|
||||
backdropOpacity,
|
||||
closeToAgent,
|
||||
isMobile,
|
||||
translateX,
|
||||
windowWidth,
|
||||
]);
|
||||
|
||||
const listFooterComponent = useMemo(() => {
|
||||
if (!hasMoreEntries) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable style={styles.listViewMoreButton} onPress={handleViewMore}>
|
||||
{({ hovered }) => (
|
||||
<Text
|
||||
style={[
|
||||
styles.listViewMoreButtonText,
|
||||
hovered && styles.listViewMoreButtonTextHovered,
|
||||
]}
|
||||
>
|
||||
View more
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}, [handleViewMore, hasMoreEntries]);
|
||||
router.push(buildHostAgentsRoute(activeServerId) as any)
|
||||
}, [activeServerId, backdropOpacity, closeToAgent, isMobile, translateX, windowWidth])
|
||||
|
||||
const handleHostSelect = useCallback(
|
||||
(nextServerId: string) => {
|
||||
if (!nextServerId) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
const nextPath = mapPathnameToServer(pathname, nextServerId);
|
||||
setIsHostPickerOpen(false);
|
||||
router.push(nextPath as any);
|
||||
const nextPath = mapPathnameToServer(pathname, nextServerId)
|
||||
setIsHostPickerOpen(false)
|
||||
router.push(nextPath as any)
|
||||
},
|
||||
[pathname]
|
||||
);
|
||||
)
|
||||
|
||||
// Close gesture (swipe left to close when sidebar is open)
|
||||
// Only activates on leftward swipe, fails on rightward or vertical movement
|
||||
// This mirrors the explorer-sidebar pattern for the right sidebar
|
||||
const closeGesture = Gesture.Pan()
|
||||
.withRef(closeGestureRef)
|
||||
.enabled(isOpen)
|
||||
// Only activate on leftward swipe (negative X)
|
||||
.activeOffsetX(-15)
|
||||
// Fail on rightward movement (allow internal list scrolling)
|
||||
.failOffsetX(10)
|
||||
// Fail if vertical movement happens first (allow vertical scroll)
|
||||
.failOffsetY([-10, 10])
|
||||
// Use manual activation so child views keep touch streams unless we detect
|
||||
// an intentional left-swipe close (mirrors explorer-sidebar pattern).
|
||||
.manualActivation(true)
|
||||
.onTouchesDown((event) => {
|
||||
const touch = event.changedTouches[0]
|
||||
if (!touch) {
|
||||
return
|
||||
}
|
||||
closeTouchStartX.value = touch.absoluteX
|
||||
closeTouchStartY.value = touch.absoluteY
|
||||
})
|
||||
.onTouchesMove((event, stateManager) => {
|
||||
const touch = event.changedTouches[0]
|
||||
if (!touch || event.numberOfTouches !== 1) {
|
||||
stateManager.fail()
|
||||
return
|
||||
}
|
||||
|
||||
const deltaX = touch.absoluteX - closeTouchStartX.value
|
||||
const deltaY = touch.absoluteY - closeTouchStartY.value
|
||||
const absDeltaX = Math.abs(deltaX)
|
||||
const absDeltaY = Math.abs(deltaY)
|
||||
|
||||
// Fail quickly on clear rightward or vertical intent so child views keep control.
|
||||
if (deltaX >= 10) {
|
||||
stateManager.fail()
|
||||
return
|
||||
}
|
||||
if (absDeltaY > 10 && absDeltaY > absDeltaX) {
|
||||
stateManager.fail()
|
||||
return
|
||||
}
|
||||
|
||||
// Activate only on intentional leftward movement.
|
||||
if (deltaX <= -15 && absDeltaX > absDeltaY) {
|
||||
stateManager.activate()
|
||||
}
|
||||
})
|
||||
.onStart(() => {
|
||||
isGesturing.value = true;
|
||||
isGesturing.value = true
|
||||
runOnJS(logLeftSidebarCloseGesture)('start', { isOpen, isMobile })
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
if (!isMobile) return;
|
||||
if (!isMobile) return
|
||||
// Only allow swiping left (closing)
|
||||
const newTranslateX = Math.min(0, Math.max(-windowWidth, event.translationX));
|
||||
translateX.value = newTranslateX;
|
||||
const newTranslateX = Math.min(0, Math.max(-windowWidth, event.translationX))
|
||||
translateX.value = newTranslateX
|
||||
backdropOpacity.value = interpolate(
|
||||
newTranslateX,
|
||||
[-windowWidth, 0],
|
||||
[0, 1],
|
||||
Extrapolation.CLAMP
|
||||
);
|
||||
)
|
||||
})
|
||||
.onEnd((event) => {
|
||||
isGesturing.value = false;
|
||||
if (!isMobile) return;
|
||||
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500;
|
||||
isGesturing.value = false
|
||||
if (!isMobile) return
|
||||
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500
|
||||
runOnJS(logLeftSidebarCloseGesture)('end', {
|
||||
translationX: event.translationX,
|
||||
velocityX: event.velocityX,
|
||||
shouldClose,
|
||||
})
|
||||
if (shouldClose) {
|
||||
animateToClose();
|
||||
runOnJS(handleClose)();
|
||||
animateToClose()
|
||||
runOnJS(handleClose)()
|
||||
} else {
|
||||
animateToOpen();
|
||||
animateToOpen()
|
||||
}
|
||||
})
|
||||
.onFinalize(() => {
|
||||
isGesturing.value = false;
|
||||
});
|
||||
|
||||
isGesturing.value = false
|
||||
})
|
||||
|
||||
const sidebarAnimatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateX: translateX.value }],
|
||||
}));
|
||||
}))
|
||||
|
||||
const backdropAnimatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: backdropOpacity.value,
|
||||
pointerEvents: backdropOpacity.value > 0.01 ? "auto" : "none",
|
||||
}));
|
||||
|
||||
pointerEvents: backdropOpacity.value > 0.01 ? 'auto' : 'none',
|
||||
}))
|
||||
|
||||
// Render mobile sidebar
|
||||
// On web, keep the overlay interactive only while the sidebar is open.
|
||||
// This preserves swipe/scroll behavior without blocking taps when closed.
|
||||
const overlayPointerEvents = Platform.OS === "web" ? (isOpen ? "auto" : "none") : "box-none";
|
||||
const overlayPointerEvents = Platform.OS === 'web' ? (isOpen ? 'auto' : 'none') : 'box-none'
|
||||
if (isMobile) {
|
||||
return (
|
||||
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
|
||||
@@ -381,7 +325,7 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
|
||||
<Pressable
|
||||
style={styles.newAgentButton}
|
||||
testID="sidebar-new-agent"
|
||||
onPress={handleCreateAgentCleanMobile}
|
||||
onPress={handleOpenProjectMobile}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<>
|
||||
@@ -389,28 +333,31 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
|
||||
size={theme.iconSize.md}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
<Text style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}>New agent</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.newAgentButtonText,
|
||||
hovered && styles.newAgentButtonTextHovered,
|
||||
]}
|
||||
>
|
||||
Add project
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Middle: scrollable agent list */}
|
||||
{/* Middle: scrollable project/workspace tree */}
|
||||
{isInitialLoad ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
<SidebarAgentList
|
||||
<SidebarWorkspaceList
|
||||
isOpen={isOpen}
|
||||
entries={entries}
|
||||
projectFilterOptions={projectFilterOptions}
|
||||
selectedProjectFilterKeys={selectedProjectFilterKeys}
|
||||
onSelectedProjectFilterKeysChange={setSelectedProjectFilterKeys}
|
||||
serverId={activeServerId}
|
||||
projects={projects}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
listFooterComponent={listFooterComponent}
|
||||
selectedAgentId={selectedAgentId}
|
||||
onAgentSelect={handleAgentSelectMobile}
|
||||
onWorkspacePress={closeToAgent}
|
||||
parentGestureRef={closeGestureRef}
|
||||
/>
|
||||
)}
|
||||
@@ -428,10 +375,7 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
|
||||
disabled={hostOptions.length === 0}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.hostStatusDot,
|
||||
{ backgroundColor: activeHostStatusColor },
|
||||
]}
|
||||
style={[styles.hostStatusDot, { backgroundColor: activeHostStatusColor }]}
|
||||
/>
|
||||
<Text style={styles.hostTriggerText} numberOfLines={1}>
|
||||
{activeHostLabel}
|
||||
@@ -445,12 +389,12 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
|
||||
nativeID="sidebar-all-agents"
|
||||
collapsable={false}
|
||||
accessible
|
||||
accessibilityLabel="All agents"
|
||||
accessibilityLabel="Sessions"
|
||||
accessibilityRole="button"
|
||||
onPress={handleViewMore}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Users
|
||||
<MessagesSquare
|
||||
size={theme.iconSize.lg}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
@@ -476,7 +420,7 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
|
||||
</View>
|
||||
<Combobox
|
||||
options={hostOptions}
|
||||
value={activeServerId ?? ""}
|
||||
value={activeServerId ?? ''}
|
||||
onSelect={handleHostSelect}
|
||||
searchable={false}
|
||||
title="Switch host"
|
||||
@@ -490,25 +434,25 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</View>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
// Desktop: no edge swipe, just show/hide based on isOpen
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.desktopSidebar, { width: DESKTOP_SIDEBAR_WIDTH }]}>
|
||||
<View
|
||||
style={[styles.sidebarHeader, { paddingLeft: theme.spacing[2] + trafficLightPadding.left }]}
|
||||
{...dragHandlers}
|
||||
>
|
||||
{trafficLightPadding.top > 0 ? (
|
||||
<View style={{ height: trafficLightPadding.top }} {...dragHandlers} />
|
||||
) : null}
|
||||
<View style={styles.sidebarHeader} {...dragHandlers}>
|
||||
<View style={styles.sidebarHeaderRow}>
|
||||
<Pressable
|
||||
style={styles.newAgentButton}
|
||||
testID="sidebar-new-agent"
|
||||
onPress={handleCreateAgentCleanDesktop}
|
||||
onPress={handleOpenProjectDesktop}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<>
|
||||
@@ -516,27 +460,27 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
|
||||
size={theme.iconSize.md}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
<Text style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}>New agent</Text>
|
||||
<Text
|
||||
style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}
|
||||
>
|
||||
Add project
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Middle: scrollable agent list */}
|
||||
{/* Middle: scrollable project/workspace tree */}
|
||||
{isInitialLoad ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
<SidebarAgentList
|
||||
<SidebarWorkspaceList
|
||||
isOpen={isOpen}
|
||||
entries={entries}
|
||||
projectFilterOptions={projectFilterOptions}
|
||||
selectedProjectFilterKeys={selectedProjectFilterKeys}
|
||||
onSelectedProjectFilterKeysChange={setSelectedProjectFilterKeys}
|
||||
serverId={activeServerId}
|
||||
projects={projects}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
listFooterComponent={listFooterComponent}
|
||||
selectedAgentId={selectedAgentId}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -552,12 +496,7 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
|
||||
onPress={() => setIsHostPickerOpen(true)}
|
||||
disabled={hostOptions.length === 0}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.hostStatusDot,
|
||||
{ backgroundColor: activeHostStatusColor },
|
||||
]}
|
||||
/>
|
||||
<View style={[styles.hostStatusDot, { backgroundColor: activeHostStatusColor }]} />
|
||||
<Text style={styles.hostTriggerText} numberOfLines={1}>
|
||||
{activeHostLabel}
|
||||
</Text>
|
||||
@@ -570,12 +509,12 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
|
||||
nativeID="sidebar-all-agents"
|
||||
collapsable={false}
|
||||
accessible
|
||||
accessibilityLabel="All agents"
|
||||
accessibilityLabel="Sessions"
|
||||
accessibilityRole="button"
|
||||
onPress={handleViewMore}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Users
|
||||
<MessagesSquare
|
||||
size={theme.iconSize.lg}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
@@ -601,7 +540,7 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
|
||||
</View>
|
||||
<Combobox
|
||||
options={hostOptions}
|
||||
value={activeServerId ?? ""}
|
||||
value={activeServerId ?? ''}
|
||||
onSelect={handleHostSelect}
|
||||
searchable={false}
|
||||
title="Switch host"
|
||||
@@ -612,34 +551,34 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
backdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
},
|
||||
backdropPressable: {
|
||||
flex: 1,
|
||||
},
|
||||
mobileSidebar: {
|
||||
position: "absolute",
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
overflow: "hidden",
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
sidebarContent: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflow: "hidden",
|
||||
overflow: 'hidden',
|
||||
},
|
||||
desktopSidebar: {
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
},
|
||||
sidebarHeader: {
|
||||
height: {
|
||||
@@ -647,20 +586,20 @@ const styles = StyleSheet.create((theme) => ({
|
||||
md: HEADER_INNER_HEIGHT,
|
||||
},
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
justifyContent: "center",
|
||||
justifyContent: 'center',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
userSelect: "none",
|
||||
userSelect: 'none',
|
||||
},
|
||||
sidebarHeaderRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-start",
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
newAgentButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
@@ -676,9 +615,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
hostTrigger: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-start",
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
gap: theme.spacing[2],
|
||||
minWidth: 0,
|
||||
paddingVertical: theme.spacing[1],
|
||||
@@ -703,9 +642,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
minWidth: 0,
|
||||
},
|
||||
sidebarFooter: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[3],
|
||||
borderTopWidth: 1,
|
||||
@@ -718,38 +657,19 @@ const styles = StyleSheet.create((theme) => ({
|
||||
marginRight: theme.spacing[2],
|
||||
},
|
||||
footerIconRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing[2],
|
||||
flexShrink: 0,
|
||||
},
|
||||
footerIconButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
},
|
||||
listViewMoreButton: {
|
||||
marginTop: theme.spacing[2],
|
||||
marginHorizontal: theme.spacing[2],
|
||||
marginBottom: theme.spacing[1],
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingVertical: theme.spacing[2],
|
||||
},
|
||||
listViewMoreButtonText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
listViewMoreButtonTextHovered: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
hostPickerList: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
@@ -772,10 +692,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
backgroundColor: theme.colors.surface0,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
alignItems: "center",
|
||||
alignItems: 'center',
|
||||
},
|
||||
hostPickerCancelText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
}));
|
||||
}))
|
||||
|
||||
@@ -30,9 +30,14 @@ import {
|
||||
} from '@/utils/image-attachments-from-files'
|
||||
import type { AttachmentMetadata } from '@/attachments/types'
|
||||
import { useAttachmentPreviewUrl } from '@/attachments/use-attachment-preview-url'
|
||||
import { focusWithRetries } from '@/utils/web-focus'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { Shortcut } from '@/components/ui/shortcut'
|
||||
import type { MessageInputKeyboardActionKind } from '@/keyboard/actions'
|
||||
import {
|
||||
markScrollInvestigationEvent,
|
||||
markScrollInvestigationRender,
|
||||
} from '@/utils/scroll-jank-investigation'
|
||||
|
||||
export type ImageAttachment = AttachmentMetadata
|
||||
|
||||
@@ -58,6 +63,7 @@ export interface MessageInputProps {
|
||||
isReadyForDictation?: boolean
|
||||
placeholder?: string
|
||||
autoFocus?: boolean
|
||||
autoFocusKey?: string
|
||||
disabled?: boolean
|
||||
/** True when the containing screen is focused (React Navigation). Used to disable global hotkeys and cancel dictation when unfocused. */
|
||||
isScreenFocused?: boolean
|
||||
@@ -77,6 +83,8 @@ export interface MessageInputProps {
|
||||
onKeyPress?: (event: { key: string; preventDefault: () => void }) => boolean
|
||||
/** Reports cursor selection updates from the underlying input. */
|
||||
onSelectionChange?: (selection: { start: number; end: number }) => void
|
||||
onFocusChange?: (focused: boolean) => void
|
||||
onHeightChange?: (height: number) => void
|
||||
}
|
||||
|
||||
export interface MessageInputRef {
|
||||
@@ -93,6 +101,7 @@ export interface MessageInputRef {
|
||||
const MIN_INPUT_HEIGHT = 30
|
||||
const MAX_INPUT_HEIGHT = 160
|
||||
const IS_WEB = Platform.OS === 'web'
|
||||
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__)
|
||||
|
||||
type WebTextInputKeyPressEvent = NativeSyntheticEvent<
|
||||
TextInputKeyPressEventData & {
|
||||
@@ -104,12 +113,68 @@ type WebTextInputKeyPressEvent = NativeSyntheticEvent<
|
||||
|
||||
type TextAreaHandle = {
|
||||
scrollHeight?: number
|
||||
clientHeight?: number
|
||||
offsetHeight?: number
|
||||
scrollTop?: number
|
||||
selectionStart?: number | null
|
||||
selectionEnd?: number | null
|
||||
style?: {
|
||||
height?: string
|
||||
overflowY?: string
|
||||
} & Record<string, unknown>
|
||||
}
|
||||
|
||||
function logWebStickyBottom(
|
||||
event: string,
|
||||
details: Record<string, unknown>
|
||||
): void {
|
||||
if (!IS_DEV || !IS_WEB) {
|
||||
return
|
||||
}
|
||||
console.log('[WebStickyBottom]', event, details)
|
||||
}
|
||||
|
||||
function getDebugNow(): number | null {
|
||||
if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
|
||||
return Number(performance.now().toFixed(3))
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getElementDescriptor(element: HTMLElement | null): string | null {
|
||||
if (!element) return null
|
||||
const tag = element.tagName?.toLowerCase() ?? 'unknown'
|
||||
const id = element.id ? `#${element.id}` : ''
|
||||
const testId = element.getAttribute?.('data-testid')
|
||||
const label = element.getAttribute?.('aria-label')
|
||||
const suffix = testId
|
||||
? `[data-testid="${testId}"]`
|
||||
: label
|
||||
? `[aria-label="${label}"]`
|
||||
: ''
|
||||
return `${tag}${id}${suffix}`
|
||||
}
|
||||
|
||||
function getScrollableAncestorChain(element: HTMLElement | null): string[] {
|
||||
if (!element || typeof window === 'undefined') {
|
||||
return []
|
||||
}
|
||||
const results: string[] = []
|
||||
let current = element.parentElement
|
||||
while (current) {
|
||||
const style = window.getComputedStyle(current)
|
||||
const overflowY = style.overflowY
|
||||
const canScroll =
|
||||
(overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay') &&
|
||||
current.scrollHeight > current.clientHeight
|
||||
if (canScroll) {
|
||||
results.push(getElementDescriptor(current) ?? current.tagName.toLowerCase())
|
||||
}
|
||||
current = current.parentElement
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
function ImageAttachmentThumbnail({ image }: { image: ImageAttachment }) {
|
||||
const uri = useAttachmentPreviewUrl(image)
|
||||
if (!uri) {
|
||||
@@ -133,6 +198,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
isReadyForDictation,
|
||||
placeholder = 'Message...',
|
||||
autoFocus = false,
|
||||
autoFocusKey,
|
||||
disabled = false,
|
||||
isScreenFocused = true,
|
||||
leftContent,
|
||||
@@ -144,13 +210,19 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
onSubmitLoadingPress,
|
||||
onKeyPress: onKeyPressCallback,
|
||||
onSelectionChange: onSelectionChangeCallback,
|
||||
onFocusChange,
|
||||
onHeightChange,
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const { theme } = useUnistyles()
|
||||
const investigationComponentId = `MessageInput:${voiceServerId ?? 'unknown-server'}:${voiceAgentId ?? 'unknown-agent'}`
|
||||
markScrollInvestigationRender(investigationComponentId)
|
||||
const toast = useToast()
|
||||
const voice = useVoiceOptional()
|
||||
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT)
|
||||
const rootRef = useRef<View | null>(null)
|
||||
const inputWrapperRef = useRef<View | null>(null)
|
||||
const textInputRef = useRef<TextInput | (TextInput & { getNativeRef?: () => unknown }) | null>(
|
||||
null
|
||||
)
|
||||
@@ -225,15 +297,26 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
valueRef.current = value
|
||||
}, [value])
|
||||
|
||||
// Autofocus on web when autoFocus prop is true
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
onFocusChange?.(false)
|
||||
}
|
||||
}, [onFocusChange])
|
||||
|
||||
// Autofocus on web when autoFocus is true, and re-run when focus key changes.
|
||||
useEffect(() => {
|
||||
if (!IS_WEB || !autoFocus) return
|
||||
// Use requestAnimationFrame to ensure DOM is ready
|
||||
const rafId = requestAnimationFrame(() => {
|
||||
textInputRef.current?.focus()
|
||||
return focusWithRetries({
|
||||
focus: () => textInputRef.current?.focus(),
|
||||
isFocused: () => {
|
||||
const current = textInputRef.current as (TextInput & { getNativeRef?: () => unknown }) | null
|
||||
const native = typeof current?.getNativeRef === 'function' ? current.getNativeRef() : current
|
||||
const element = native instanceof HTMLElement ? native : null
|
||||
const active = typeof document !== 'undefined' ? document.activeElement : null
|
||||
return Boolean(element) && active === element
|
||||
},
|
||||
})
|
||||
return () => cancelAnimationFrame(rafId)
|
||||
}, [autoFocus])
|
||||
}, [autoFocus, autoFocusKey])
|
||||
|
||||
const handleDictationTranscript = useCallback(
|
||||
(text: string, _meta: { requestId: string }) => {
|
||||
@@ -247,7 +330,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
|
||||
if (shouldAutoSend) {
|
||||
const imageAttachments = images.length > 0 ? images : undefined
|
||||
onSubmit({ text: nextValue, images: imageAttachments })
|
||||
onSubmit({
|
||||
text: nextValue,
|
||||
images: imageAttachments,
|
||||
forceSend: isAgentRunning || undefined,
|
||||
})
|
||||
} else {
|
||||
onChangeText(nextValue)
|
||||
}
|
||||
@@ -258,7 +345,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
})
|
||||
}
|
||||
},
|
||||
[onChangeText, onSubmit, images]
|
||||
[onChangeText, onSubmit, images, isAgentRunning]
|
||||
)
|
||||
|
||||
const handleDictationError = useCallback((error: Error) => {
|
||||
@@ -302,8 +389,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
onError: handleDictationError,
|
||||
canStart: canStartDictation,
|
||||
canConfirm: canConfirmDictation,
|
||||
autoStopWhenHidden:
|
||||
Platform.OS === 'web' ? undefined : { isVisible: isScreenFocused },
|
||||
autoStopWhenHidden: { isVisible: isScreenFocused },
|
||||
enableDuration: true,
|
||||
})
|
||||
|
||||
@@ -330,9 +416,13 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
|
||||
const startDictationIfAvailable = useCallback(async () => {
|
||||
if (dictationUnavailableMessage) {
|
||||
isDictatingRef.current = false
|
||||
toast.error(dictationUnavailableMessage)
|
||||
return
|
||||
}
|
||||
// Keep hotkey toggling deterministic between the async start call and the
|
||||
// state-ref sync effect, so a rapid second toggle routes to confirm.
|
||||
isDictatingRef.current = true
|
||||
await startDictation()
|
||||
}, [dictationUnavailableMessage, startDictation, toast])
|
||||
|
||||
@@ -444,7 +534,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
onSubmit(payload)
|
||||
inputHeightRef.current = MIN_INPUT_HEIGHT
|
||||
setInputHeight(MIN_INPUT_HEIGHT)
|
||||
}, [value, images, onSubmit, isAgentRunning])
|
||||
onHeightChange?.(MIN_INPUT_HEIGHT)
|
||||
}, [value, images, onSubmit, isAgentRunning, onHeightChange])
|
||||
|
||||
const handleQueueMessage = useCallback(() => {
|
||||
if (!onQueue) return
|
||||
@@ -458,7 +549,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
onChangeText('')
|
||||
inputHeightRef.current = MIN_INPUT_HEIGHT
|
||||
setInputHeight(MIN_INPUT_HEIGHT)
|
||||
}, [value, images, onQueue, onChangeText])
|
||||
onHeightChange?.(MIN_INPUT_HEIGHT)
|
||||
}, [value, images, onQueue, onChangeText, onHeightChange])
|
||||
|
||||
// Web input height measurement
|
||||
function isTextAreaLike(v: unknown): v is TextAreaHandle {
|
||||
@@ -476,6 +568,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
return null
|
||||
}, [])
|
||||
|
||||
const getWebElement = useCallback((target: 'root' | 'wrapper'): HTMLElement | null => {
|
||||
const ref = target === 'root' ? rootRef.current : inputWrapperRef.current
|
||||
if (!ref) return null
|
||||
return ref instanceof HTMLElement ? ref : ((ref as unknown as { getBoundingClientRect?: () => DOMRect }).getBoundingClientRect ? (ref as unknown as HTMLElement) : null)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!IS_WEB || !onAddImages) {
|
||||
return
|
||||
@@ -529,35 +627,125 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
onAddImages,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (!IS_WEB || typeof ResizeObserver === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
const textarea = getWebTextArea()
|
||||
const root = getWebElement('root')
|
||||
const wrapper = getWebElement('wrapper')
|
||||
const observed = [
|
||||
{ name: 'composer_root', element: root },
|
||||
{ name: 'composer_wrapper', element: wrapper },
|
||||
{ name: 'composer_textarea', element: textarea as unknown as HTMLElement | null },
|
||||
].filter((entry): entry is { name: string; element: HTMLElement } => entry.element instanceof HTMLElement)
|
||||
|
||||
if (observed.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const target = entry.target as HTMLElement
|
||||
const match = observed.find((item) => item.element === target)
|
||||
if (!match) {
|
||||
continue
|
||||
}
|
||||
const textareaNode = getWebTextArea()
|
||||
logWebStickyBottom('composer_element_resized', {
|
||||
target: match.name,
|
||||
width: target.clientWidth,
|
||||
height: target.clientHeight,
|
||||
offsetHeight: target.offsetHeight,
|
||||
scrollHeight: target.scrollHeight,
|
||||
textareaClientHeight: textareaNode?.clientHeight ?? null,
|
||||
textareaOffsetHeight: textareaNode?.offsetHeight ?? null,
|
||||
textareaScrollHeight: textareaNode?.scrollHeight ?? null,
|
||||
textareaScrollTop: (textareaNode as unknown as HTMLTextAreaElement | null)?.scrollTop ?? null,
|
||||
valueLength: valueRef.current.length,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
for (const entry of observed) {
|
||||
observer.observe(entry.element)
|
||||
}
|
||||
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [getWebElement, getWebTextArea])
|
||||
|
||||
useEffect(() => {
|
||||
if (!IS_WEB) {
|
||||
return
|
||||
}
|
||||
const textarea = getWebTextArea() as (HTMLTextAreaElement & TextAreaHandle) | null
|
||||
if (!textarea || typeof textarea.addEventListener !== 'function') {
|
||||
return
|
||||
}
|
||||
|
||||
const handleScroll = () => {
|
||||
const textareaElement = textarea as unknown as HTMLElement
|
||||
const chatScroller =
|
||||
typeof document !== 'undefined'
|
||||
? (document.querySelector('[data-testid="agent-chat-scroll"]') as HTMLElement | null)
|
||||
: null
|
||||
logWebStickyBottom('composer_textarea_scrolled', {
|
||||
now: getDebugNow(),
|
||||
scrollTop: textarea.scrollTop,
|
||||
clientHeight: textarea.clientHeight ?? null,
|
||||
scrollHeight: textarea.scrollHeight ?? null,
|
||||
selectionStart: textarea.selectionStart ?? null,
|
||||
selectionEnd: textarea.selectionEnd ?? null,
|
||||
textareaDescriptor: getElementDescriptor(textareaElement),
|
||||
chatScrollerDescriptor: getElementDescriptor(chatScroller),
|
||||
chatScrollerContainsTextarea: Boolean(chatScroller && textareaElement && chatScroller.contains(textareaElement)),
|
||||
textareaScrollableAncestors: getScrollableAncestorChain(textareaElement),
|
||||
valueLength: valueRef.current.length,
|
||||
})
|
||||
}
|
||||
|
||||
textarea.addEventListener('scroll', handleScroll, { passive: true })
|
||||
return () => {
|
||||
textarea.removeEventListener('scroll', handleScroll)
|
||||
}
|
||||
}, [getWebTextArea])
|
||||
|
||||
function measureWebInputHeight(source: string): boolean {
|
||||
if (!IS_WEB) return false
|
||||
const textarea = getWebTextArea()
|
||||
if (!textarea || typeof textarea.scrollHeight !== 'number') return false
|
||||
|
||||
const prevHeight = textarea.style?.height
|
||||
const prevOverflow = textarea.style?.overflowY
|
||||
if (textarea.style) {
|
||||
textarea.style.height = 'auto'
|
||||
textarea.style.overflowY = 'hidden'
|
||||
}
|
||||
|
||||
const scrollHeight = textarea.scrollHeight ?? 0
|
||||
if (textarea.style) {
|
||||
textarea.style.height = prevHeight ?? ''
|
||||
textarea.style.overflowY = prevOverflow ?? ''
|
||||
}
|
||||
|
||||
if (baselineInputHeightRef.current === null && scrollHeight > 0) {
|
||||
baselineInputHeightRef.current = scrollHeight
|
||||
logWebStickyBottom('composer_baseline_measured', {
|
||||
source,
|
||||
baseline: scrollHeight,
|
||||
})
|
||||
}
|
||||
|
||||
const baseline = baselineInputHeightRef.current ?? MIN_INPUT_HEIGHT
|
||||
const rawTarget = scrollHeight > 0 ? scrollHeight : baseline
|
||||
const bounded = Math.max(MIN_INPUT_HEIGHT, Math.min(MAX_INPUT_HEIGHT, rawTarget))
|
||||
|
||||
if (Math.abs(inputHeightRef.current - bounded) >= 1) {
|
||||
const previousHeight = inputHeightRef.current
|
||||
if (Math.abs(previousHeight - bounded) >= 1) {
|
||||
inputHeightRef.current = bounded
|
||||
setInputHeight(bounded)
|
||||
onHeightChange?.(bounded)
|
||||
logWebStickyBottom('composer_height_changed', {
|
||||
source,
|
||||
previousHeight,
|
||||
nextHeight: bounded,
|
||||
scrollHeight,
|
||||
clientHeight: textarea.clientHeight ?? null,
|
||||
offsetHeight: textarea.offsetHeight ?? null,
|
||||
baseline,
|
||||
rawTarget,
|
||||
})
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -566,30 +754,58 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
function setBoundedInputHeight(nextHeight: number) {
|
||||
const bounded = Math.max(MIN_INPUT_HEIGHT, Math.min(MAX_INPUT_HEIGHT, nextHeight))
|
||||
if (Math.abs(inputHeightRef.current - bounded) < 1) return
|
||||
const previousHeight = inputHeightRef.current
|
||||
inputHeightRef.current = bounded
|
||||
setInputHeight(bounded)
|
||||
onHeightChange?.(bounded)
|
||||
logWebStickyBottom('composer_height_changed_native', {
|
||||
previousHeight,
|
||||
nextHeight: bounded,
|
||||
})
|
||||
}
|
||||
|
||||
function handleContentSizeChange(
|
||||
event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>
|
||||
) {
|
||||
const contentHeight = event.nativeEvent.contentSize.height
|
||||
if (IS_WEB) {
|
||||
measureWebInputHeight('contentSizeChange')
|
||||
logWebStickyBottom('composer_content_size_change', {
|
||||
reportedHeight: contentHeight,
|
||||
})
|
||||
if (baselineInputHeightRef.current === null && contentHeight > 0) {
|
||||
baselineInputHeightRef.current = contentHeight
|
||||
logWebStickyBottom('composer_baseline_measured', {
|
||||
source: 'contentSizeChange',
|
||||
baseline: contentHeight,
|
||||
})
|
||||
}
|
||||
setBoundedInputHeight(contentHeight)
|
||||
return
|
||||
}
|
||||
const contentHeight = event.nativeEvent.contentSize.height
|
||||
setBoundedInputHeight(contentHeight)
|
||||
}
|
||||
|
||||
function handleSelectionChange(event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) {
|
||||
const start = event.nativeEvent.selection?.start ?? 0
|
||||
const end = event.nativeEvent.selection?.end ?? start
|
||||
if (IS_WEB) {
|
||||
const textarea = getWebTextArea()
|
||||
logWebStickyBottom('composer_selection_changed', {
|
||||
now: getDebugNow(),
|
||||
start,
|
||||
end,
|
||||
textareaScrollTop: textarea?.scrollTop ?? null,
|
||||
textareaClientHeight: textarea?.clientHeight ?? null,
|
||||
textareaScrollHeight: textarea?.scrollHeight ?? null,
|
||||
})
|
||||
}
|
||||
onSelectionChangeCallback?.({ start, end })
|
||||
}
|
||||
|
||||
const shouldHandleDesktopSubmit = IS_WEB
|
||||
|
||||
function handleDesktopKeyPress(event: WebTextInputKeyPressEvent) {
|
||||
markScrollInvestigationEvent(investigationComponentId, 'keyPress')
|
||||
if (!shouldHandleDesktopSubmit) return
|
||||
|
||||
// Allow parent to intercept key events (e.g., for autocomplete navigation)
|
||||
@@ -634,10 +850,24 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
? 'Send and interrupt'
|
||||
: 'Send message'
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(nextValue: string) => {
|
||||
markScrollInvestigationEvent(investigationComponentId, 'inputChange')
|
||||
onChangeText(nextValue)
|
||||
if (IS_WEB) {
|
||||
logWebStickyBottom('composer_text_changed', {
|
||||
valueLength: nextValue.length,
|
||||
lineCount: nextValue.split('\n').length,
|
||||
})
|
||||
}
|
||||
},
|
||||
[investigationComponentId, onChangeText]
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={styles.container} testID="message-input-root">
|
||||
<View ref={rootRef} style={styles.container} testID="message-input-root">
|
||||
{/* Regular input */}
|
||||
<Animated.View style={[styles.inputWrapper, inputAnimatedStyle]}>
|
||||
<Animated.View ref={inputWrapperRef} style={[styles.inputWrapper, inputAnimatedStyle]}>
|
||||
{/* Image preview pills */}
|
||||
{hasImages && (
|
||||
<View style={styles.imagePreviewContainer} testID="message-input-image-preview">
|
||||
@@ -672,14 +902,17 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
<TextInput
|
||||
ref={textInputRef}
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
onChangeText={handleInputChange}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={theme.colors.mutedForeground}
|
||||
placeholderTextColor={theme.colors.surface4}
|
||||
accessibilityLabel="Message agent..."
|
||||
onFocus={() => {
|
||||
isInputFocusedRef.current = true
|
||||
onFocusChange?.(true)
|
||||
}}
|
||||
onBlur={() => {
|
||||
isInputFocusedRef.current = false
|
||||
onFocusChange?.(false)
|
||||
}}
|
||||
style={[
|
||||
styles.textInput,
|
||||
|
||||
@@ -72,6 +72,7 @@ import { getNowMs, isPerfLoggingEnabled, perfLog } from "@/utils/perf";
|
||||
import { parseInlinePathToken, type InlinePathTarget } from "@/utils/inline-path";
|
||||
import { getMarkdownListMarker } from "@/utils/markdown-list";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { markScrollInvestigationEvent } from "@/utils/scroll-jank-investigation";
|
||||
export type { InlinePathTarget } from "@/utils/inline-path";
|
||||
import { useToolCallSheet } from "./tool-call-sheet";
|
||||
import { ToolCallDetailsContent } from "./tool-call-details";
|
||||
@@ -645,6 +646,9 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
|
||||
marginLeft: theme.spacing[1],
|
||||
flexShrink: 0,
|
||||
},
|
||||
chevronExpanded: {
|
||||
transform: [{ rotate: "90deg" }],
|
||||
},
|
||||
detailWrapper: {
|
||||
borderBottomLeftRadius: theme.borderRadius.lg,
|
||||
borderBottomRightRadius: theme.borderRadius.lg,
|
||||
@@ -1288,11 +1292,16 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
const { theme } = useUnistyles();
|
||||
const resolvedDisableOuterSpacing =
|
||||
useDisableOuterSpacing(disableOuterSpacing);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isPressed, setIsPressed] = useState(false);
|
||||
const isInteractive = Boolean(onToggle);
|
||||
const hasDetailContent = Boolean(renderDetails);
|
||||
const detailContent =
|
||||
hasDetailContent && isExpanded ? renderDetails?.() : null;
|
||||
const detailWrapperRef = useRef<View | null>(null);
|
||||
const wheelInvestigationComponentId = `ExpandableBadgeWheel:${
|
||||
testID ?? label
|
||||
}`;
|
||||
|
||||
const nativeGradientIdRef = useRef(
|
||||
`shimmer-gradient-${Math.random().toString(36).substring(2, 9)}`
|
||||
@@ -1428,11 +1437,13 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
}
|
||||
};
|
||||
|
||||
markScrollInvestigationEvent(wheelInvestigationComponentId, "wheelAttach");
|
||||
node.addEventListener("wheel", stopWheelPropagation, { passive: true });
|
||||
return () => {
|
||||
markScrollInvestigationEvent(wheelInvestigationComponentId, "wheelDetach");
|
||||
node.removeEventListener("wheel", stopWheelPropagation);
|
||||
};
|
||||
}, [isExpanded, hasDetailContent]);
|
||||
}, [hasDetailContent, isExpanded, wheelInvestigationComponentId]);
|
||||
|
||||
const nativeShimmerPeakStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateX: shimmerTranslateX.value }],
|
||||
@@ -1473,6 +1484,107 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
} as never)
|
||||
: null;
|
||||
|
||||
const containerStyle = useMemo(
|
||||
() => [
|
||||
expandableBadgeStylesheet.container,
|
||||
!resolvedDisableOuterSpacing &&
|
||||
(isLastInSequence
|
||||
? expandableBadgeStylesheet.containerLastInSequence
|
||||
: expandableBadgeStylesheet.containerSpacing),
|
||||
style,
|
||||
],
|
||||
[isLastInSequence, resolvedDisableOuterSpacing, style]
|
||||
);
|
||||
|
||||
const pressableStyle = useMemo(
|
||||
() => [
|
||||
expandableBadgeStylesheet.pressable,
|
||||
isPressed && isInteractive
|
||||
? expandableBadgeStylesheet.pressablePressed
|
||||
: null,
|
||||
isExpanded && expandableBadgeStylesheet.pressableExpanded,
|
||||
],
|
||||
[isExpanded, isInteractive, isPressed]
|
||||
);
|
||||
|
||||
const accessibilityState = useMemo(
|
||||
() => (isInteractive ? { expanded: isExpanded } : undefined),
|
||||
[isExpanded, isInteractive]
|
||||
);
|
||||
|
||||
const labelStyle = useMemo(
|
||||
() => [
|
||||
expandableBadgeStylesheet.label,
|
||||
isLoading && expandableBadgeStylesheet.labelLoading,
|
||||
],
|
||||
[isLoading]
|
||||
);
|
||||
|
||||
const shimmerLabelTextStyle = useMemo(
|
||||
() => [
|
||||
expandableBadgeStylesheet.label,
|
||||
isLoading && expandableBadgeStylesheet.labelLoading,
|
||||
expandableBadgeStylesheet.shimmerText,
|
||||
shimmerLabelStyle,
|
||||
],
|
||||
[isLoading, shimmerLabelStyle]
|
||||
);
|
||||
|
||||
const shimmerSecondaryTextStyle = useMemo(
|
||||
() => [
|
||||
expandableBadgeStylesheet.secondaryLabel,
|
||||
expandableBadgeStylesheet.shimmerText,
|
||||
shimmerSecondaryStyle,
|
||||
],
|
||||
[shimmerSecondaryStyle]
|
||||
);
|
||||
|
||||
const nativeShimmerTrackStyle = useMemo(
|
||||
() => [
|
||||
expandableBadgeStylesheet.nativeShimmerTrack,
|
||||
{ width: labelRowWidth, height: labelRowHeight },
|
||||
],
|
||||
[labelRowHeight, labelRowWidth]
|
||||
);
|
||||
|
||||
const nativeShimmerMaskStyle = useMemo(
|
||||
() => [
|
||||
expandableBadgeStylesheet.shimmerMaskRow,
|
||||
{ width: labelRowWidth, height: labelRowHeight },
|
||||
],
|
||||
[labelRowHeight, labelRowWidth]
|
||||
);
|
||||
|
||||
const nativeLabelMaskStyle = useMemo(
|
||||
() => [expandableBadgeStylesheet.label, { color: "#000000", opacity: 1 }],
|
||||
[]
|
||||
);
|
||||
|
||||
const nativeSecondaryMaskStyle = useMemo(
|
||||
() => [
|
||||
expandableBadgeStylesheet.secondaryLabel,
|
||||
{ color: "#000000", opacity: 1 },
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const nativeShimmerPeakCombinedStyle = useMemo(
|
||||
() => [
|
||||
expandableBadgeStylesheet.nativeShimmerPeak,
|
||||
nativeShimmerPeakStyle,
|
||||
{ width: nativeShimmerPeakWidth, height: labelRowHeight },
|
||||
],
|
||||
[labelRowHeight, nativeShimmerPeakStyle, nativeShimmerPeakWidth]
|
||||
);
|
||||
|
||||
const chevronStyle = useMemo(
|
||||
() => [
|
||||
expandableBadgeStylesheet.chevron,
|
||||
isExpanded && expandableBadgeStylesheet.chevronExpanded,
|
||||
],
|
||||
[isExpanded]
|
||||
);
|
||||
|
||||
const IconComponent = icon;
|
||||
const iconColor = isError
|
||||
? theme.colors.destructive
|
||||
@@ -1487,186 +1599,142 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
expandableBadgeStylesheet.container,
|
||||
!resolvedDisableOuterSpacing &&
|
||||
(isLastInSequence
|
||||
? expandableBadgeStylesheet.containerLastInSequence
|
||||
: expandableBadgeStylesheet.containerSpacing),
|
||||
style,
|
||||
]}
|
||||
style={containerStyle}
|
||||
testID={testID}
|
||||
>
|
||||
<Pressable
|
||||
onPress={isInteractive ? onToggle : undefined}
|
||||
onHoverIn={isInteractive ? () => setIsHovered(true) : undefined}
|
||||
onHoverOut={
|
||||
isInteractive
|
||||
? () => {
|
||||
setIsHovered(false);
|
||||
setIsPressed(false);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onPressIn={isInteractive ? () => setIsPressed(true) : undefined}
|
||||
onPressOut={isInteractive ? () => setIsPressed(false) : undefined}
|
||||
disabled={!isInteractive}
|
||||
accessibilityRole={isInteractive ? "button" : undefined}
|
||||
accessibilityState={isInteractive ? { expanded: isExpanded } : undefined}
|
||||
style={({ pressed }) => [
|
||||
expandableBadgeStylesheet.pressable,
|
||||
pressed && isInteractive
|
||||
? expandableBadgeStylesheet.pressablePressed
|
||||
: null,
|
||||
isExpanded && expandableBadgeStylesheet.pressableExpanded,
|
||||
]}
|
||||
accessibilityState={accessibilityState}
|
||||
style={pressableStyle}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<>
|
||||
<View style={expandableBadgeStylesheet.headerRow}>
|
||||
<View style={expandableBadgeStylesheet.iconBadge}>{iconNode}</View>
|
||||
<View style={expandableBadgeStylesheet.headerRow}>
|
||||
<View style={expandableBadgeStylesheet.iconBadge}>{iconNode}</View>
|
||||
<View
|
||||
style={expandableBadgeStylesheet.labelRow}
|
||||
onLayout={shouldMeasureNativeShimmer ? handleLabelRowLayout : undefined}
|
||||
>
|
||||
<Text
|
||||
style={labelStyle}
|
||||
numberOfLines={1}
|
||||
onLayout={shouldMeasureWebShimmer ? handleLabelLayout : undefined}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
{secondaryLabel ? (
|
||||
<Text
|
||||
style={expandableBadgeStylesheet.secondaryLabel}
|
||||
numberOfLines={1}
|
||||
onLayout={shouldMeasureWebShimmer ? handleSecondaryLayout : undefined}
|
||||
>
|
||||
{secondaryLabel}
|
||||
</Text>
|
||||
) : (
|
||||
<View style={expandableBadgeStylesheet.spacer} />
|
||||
)}
|
||||
{isWebShimmer ? (
|
||||
<View
|
||||
style={expandableBadgeStylesheet.labelRow}
|
||||
onLayout={shouldMeasureNativeShimmer ? handleLabelRowLayout : undefined}
|
||||
style={expandableBadgeStylesheet.shimmerOverlay}
|
||||
pointerEvents="none"
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
expandableBadgeStylesheet.label,
|
||||
isLoading && expandableBadgeStylesheet.labelLoading,
|
||||
]}
|
||||
style={shimmerLabelTextStyle}
|
||||
numberOfLines={1}
|
||||
onLayout={shouldMeasureWebShimmer ? handleLabelLayout : undefined}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
{secondaryLabel ? (
|
||||
<Text
|
||||
style={expandableBadgeStylesheet.secondaryLabel}
|
||||
style={shimmerSecondaryTextStyle}
|
||||
numberOfLines={1}
|
||||
onLayout={shouldMeasureWebShimmer ? handleSecondaryLayout : undefined}
|
||||
>
|
||||
{secondaryLabel}
|
||||
</Text>
|
||||
) : (
|
||||
<View style={expandableBadgeStylesheet.spacer} />
|
||||
)}
|
||||
{isWebShimmer ? (
|
||||
<View
|
||||
style={expandableBadgeStylesheet.shimmerOverlay}
|
||||
pointerEvents="none"
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
expandableBadgeStylesheet.label,
|
||||
isLoading && expandableBadgeStylesheet.labelLoading,
|
||||
expandableBadgeStylesheet.shimmerText,
|
||||
shimmerLabelStyle,
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
{secondaryLabel ? (
|
||||
</View>
|
||||
) : null}
|
||||
{isNativeShimmer ? (
|
||||
<View
|
||||
style={expandableBadgeStylesheet.shimmerOverlay}
|
||||
pointerEvents="none"
|
||||
>
|
||||
<MaskedView
|
||||
style={nativeShimmerTrackStyle}
|
||||
maskElement={
|
||||
<View style={nativeShimmerMaskStyle}>
|
||||
<Text
|
||||
style={[
|
||||
expandableBadgeStylesheet.secondaryLabel,
|
||||
expandableBadgeStylesheet.shimmerText,
|
||||
shimmerSecondaryStyle,
|
||||
]}
|
||||
style={nativeLabelMaskStyle}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{secondaryLabel}
|
||||
{label}
|
||||
</Text>
|
||||
) : (
|
||||
<View style={expandableBadgeStylesheet.spacer} />
|
||||
)}
|
||||
</View>
|
||||
) : null}
|
||||
{isNativeShimmer ? (
|
||||
{secondaryLabel ? (
|
||||
<Text
|
||||
style={nativeSecondaryMaskStyle}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{secondaryLabel}
|
||||
</Text>
|
||||
) : (
|
||||
<View style={expandableBadgeStylesheet.spacer} />
|
||||
)}
|
||||
</View>
|
||||
}
|
||||
>
|
||||
<View
|
||||
style={expandableBadgeStylesheet.shimmerOverlay}
|
||||
pointerEvents="none"
|
||||
style={nativeShimmerTrackStyle}
|
||||
>
|
||||
<MaskedView
|
||||
style={[
|
||||
expandableBadgeStylesheet.nativeShimmerTrack,
|
||||
{ width: labelRowWidth, height: labelRowHeight },
|
||||
]}
|
||||
maskElement={
|
||||
<View
|
||||
style={[
|
||||
expandableBadgeStylesheet.shimmerMaskRow,
|
||||
{ width: labelRowWidth, height: labelRowHeight },
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
expandableBadgeStylesheet.label,
|
||||
{ color: "#000000", opacity: 1 },
|
||||
]}
|
||||
numberOfLines={1}
|
||||
<Animated.View style={nativeShimmerPeakCombinedStyle}>
|
||||
<Svg width="100%" height="100%" preserveAspectRatio="none">
|
||||
<Defs>
|
||||
<SvgLinearGradient
|
||||
id={nativeGradientIdRef.current}
|
||||
x1="0%"
|
||||
y1="0%"
|
||||
x2="100%"
|
||||
y2="0%"
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
{secondaryLabel ? (
|
||||
<Text
|
||||
style={[
|
||||
expandableBadgeStylesheet.secondaryLabel,
|
||||
{ color: "#000000", opacity: 1 },
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{secondaryLabel}
|
||||
</Text>
|
||||
) : (
|
||||
<View style={expandableBadgeStylesheet.spacer} />
|
||||
)}
|
||||
</View>
|
||||
}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
expandableBadgeStylesheet.nativeShimmerTrack,
|
||||
{ width: labelRowWidth, height: labelRowHeight },
|
||||
]}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
expandableBadgeStylesheet.nativeShimmerPeak,
|
||||
nativeShimmerPeakStyle,
|
||||
{ width: nativeShimmerPeakWidth, height: labelRowHeight },
|
||||
]}
|
||||
>
|
||||
<Svg width="100%" height="100%" preserveAspectRatio="none">
|
||||
<Defs>
|
||||
<SvgLinearGradient
|
||||
id={nativeGradientIdRef.current}
|
||||
x1="0%"
|
||||
y1="0%"
|
||||
x2="100%"
|
||||
y2="0%"
|
||||
>
|
||||
<Stop offset="0%" stopColor="#ffffff" stopOpacity={0} />
|
||||
<Stop offset="50%" stopColor="#ffffff" stopOpacity={1} />
|
||||
<Stop offset="100%" stopColor="#ffffff" stopOpacity={0} />
|
||||
</SvgLinearGradient>
|
||||
</Defs>
|
||||
<Rect
|
||||
x="0"
|
||||
y="0"
|
||||
width="100%"
|
||||
height="100%"
|
||||
fill={`url(#${nativeGradientIdRef.current})`}
|
||||
/>
|
||||
</Svg>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</MaskedView>
|
||||
<Stop offset="0%" stopColor="#ffffff" stopOpacity={0} />
|
||||
<Stop offset="50%" stopColor="#ffffff" stopOpacity={1} />
|
||||
<Stop offset="100%" stopColor="#ffffff" stopOpacity={0} />
|
||||
</SvgLinearGradient>
|
||||
</Defs>
|
||||
<Rect
|
||||
x="0"
|
||||
y="0"
|
||||
width="100%"
|
||||
height="100%"
|
||||
fill={`url(#${nativeGradientIdRef.current})`}
|
||||
/>
|
||||
</Svg>
|
||||
</Animated.View>
|
||||
</View>
|
||||
) : null}
|
||||
</MaskedView>
|
||||
</View>
|
||||
{isInteractive && hovered ? (
|
||||
<ChevronRight
|
||||
size={14}
|
||||
color={theme.colors.foregroundMuted}
|
||||
style={[
|
||||
expandableBadgeStylesheet.chevron,
|
||||
{ transform: [{ rotate: isExpanded ? "90deg" : "0deg" }] },
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
</View>
|
||||
{isInteractive && isHovered ? (
|
||||
<ChevronRight
|
||||
size={14}
|
||||
color={theme.colors.foregroundMuted}
|
||||
style={chevronStyle}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
{detailContent ? (
|
||||
<Pressable
|
||||
|
||||
@@ -149,7 +149,7 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved, targetServe
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet title="Paste pairing link" visible={visible} onClose={handleClose} testID="pair-link-modal">
|
||||
<Text style={styles.helper}>Paste the daemon’s pairing link.</Text>
|
||||
<Text style={styles.helper}>Paste the pairing link from your server.</Text>
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Pairing link</Text>
|
||||
|
||||
378
packages/app/src/components/project-picker-modal.tsx
Normal file
378
packages/app/src/components/project-picker-modal.tsx
Normal file
@@ -0,0 +1,378 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Modal,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import { Folder } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { router, usePathname } from "expo-router";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import {
|
||||
normalizeWorkspaceDescriptor,
|
||||
useSessionStore,
|
||||
} from "@/stores/session-store";
|
||||
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||
import { useHostRuntimeSession } from "@/runtime/host-runtime";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { parseServerIdFromPathname } from "@/utils/host-routes";
|
||||
import { buildHostWorkspaceRouteWithOpenIntent } from "@/utils/host-routes";
|
||||
import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions";
|
||||
|
||||
export function ProjectPickerModal() {
|
||||
const { theme } = useUnistyles();
|
||||
const toast = useToast();
|
||||
const pathname = usePathname();
|
||||
const { daemons } = useDaemonRegistry();
|
||||
|
||||
const open = useKeyboardShortcutsStore((s) => s.projectPickerOpen);
|
||||
const setOpen = useKeyboardShortcutsStore((s) => s.setProjectPickerOpen);
|
||||
|
||||
const serverId = useMemo(() => {
|
||||
const fromPath = parseServerIdFromPathname(pathname);
|
||||
if (fromPath) return fromPath;
|
||||
return daemons[0]?.serverId ?? null;
|
||||
}, [pathname, daemons]);
|
||||
|
||||
const { client, isConnected } = useHostRuntimeSession(serverId ?? "");
|
||||
const workspaces = useSessionStore((state) =>
|
||||
serverId ? state.sessions[serverId]?.workspaces : undefined
|
||||
);
|
||||
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
||||
const setHasHydratedWorkspaces = useSessionStore(
|
||||
(state) => state.setHasHydratedWorkspaces
|
||||
);
|
||||
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const recommendedPaths = useMemo(() => {
|
||||
if (!workspaces) return [];
|
||||
return Array.from(workspaces.values()).map(
|
||||
(workspace) => workspace.projectRootPath || workspace.id
|
||||
);
|
||||
}, [workspaces]);
|
||||
|
||||
const directorySuggestionsQuery = useQuery({
|
||||
queryKey: ["project-picker-directory-suggestions", serverId, query],
|
||||
queryFn: async () => {
|
||||
if (!client) return [];
|
||||
const result = await client.getDirectorySuggestions({
|
||||
query,
|
||||
includeDirectories: true,
|
||||
includeFiles: false,
|
||||
limit: 30,
|
||||
});
|
||||
return (
|
||||
result.entries?.flatMap((entry) =>
|
||||
entry.kind === "directory" ? [entry.path] : []
|
||||
) ?? []
|
||||
);
|
||||
},
|
||||
enabled: Boolean(client) && isConnected && open,
|
||||
staleTime: 15_000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
buildWorkingDirectorySuggestions({
|
||||
recommendedPaths,
|
||||
serverPaths: directorySuggestionsQuery.data ?? [],
|
||||
query,
|
||||
}),
|
||||
[query, directorySuggestionsQuery.data, recommendedPaths]
|
||||
);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setOpen(false);
|
||||
}, [setOpen]);
|
||||
|
||||
const handleSelectPath = useCallback(
|
||||
async (path: string) => {
|
||||
const trimmed = path.trim();
|
||||
if (!trimmed || !client || !serverId) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const payload = await client.openProject(trimmed);
|
||||
if (payload.error || !payload.workspace) {
|
||||
throw new Error(payload.error || "Failed to open project");
|
||||
}
|
||||
mergeWorkspaces(serverId, [
|
||||
normalizeWorkspaceDescriptor(payload.workspace),
|
||||
]);
|
||||
setHasHydratedWorkspaces(serverId, true);
|
||||
setOpen(false);
|
||||
router.replace(
|
||||
buildHostWorkspaceRouteWithOpenIntent(
|
||||
serverId,
|
||||
payload.workspace.id,
|
||||
{ kind: "draft", draftId: "new" }
|
||||
) as any
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to open project"
|
||||
);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
},
|
||||
[client, mergeWorkspaces, serverId, setHasHydratedWorkspaces, setOpen, toast]
|
||||
);
|
||||
|
||||
const handleSubmitCustom = useCallback(() => {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) return;
|
||||
void handleSelectPath(trimmed);
|
||||
}, [handleSelectPath, query]);
|
||||
|
||||
// Reset state when opening/closing
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setQuery("");
|
||||
setActiveIndex(0);
|
||||
const id = setTimeout(() => inputRef.current?.focus(), 0);
|
||||
return () => clearTimeout(id);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Clamp active index
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (activeIndex >= options.length) {
|
||||
setActiveIndex(options.length > 0 ? options.length - 1 : 0);
|
||||
}
|
||||
}, [activeIndex, options.length, open]);
|
||||
|
||||
// Keyboard navigation
|
||||
useEffect(() => {
|
||||
if (!open || Platform.OS !== "web") return;
|
||||
|
||||
function handler(event: KeyboardEvent) {
|
||||
const key = event.key;
|
||||
if (
|
||||
key !== "ArrowDown" &&
|
||||
key !== "ArrowUp" &&
|
||||
key !== "Enter" &&
|
||||
key !== "Escape"
|
||||
)
|
||||
return;
|
||||
|
||||
if (key === "Escape") {
|
||||
event.preventDefault();
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "Enter") {
|
||||
event.preventDefault();
|
||||
if (options.length > 0 && activeIndex < options.length) {
|
||||
void handleSelectPath(options[activeIndex]!);
|
||||
} else if (query.trim()) {
|
||||
handleSubmitCustom();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "ArrowDown" || key === "ArrowUp") {
|
||||
if (options.length === 0) return;
|
||||
event.preventDefault();
|
||||
setActiveIndex((current) => {
|
||||
const delta = key === "ArrowDown" ? 1 : -1;
|
||||
const next = current + delta;
|
||||
if (next < 0) return options.length - 1;
|
||||
if (next >= options.length) return 0;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handler, true);
|
||||
return () => window.removeEventListener("keydown", handler, true);
|
||||
}, [activeIndex, handleSelectPath, handleSubmitCustom, open, options, query, setOpen]);
|
||||
|
||||
if (!serverId) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={open}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={handleClose}
|
||||
>
|
||||
<View style={styles.overlay}>
|
||||
<Pressable style={styles.backdrop} onPress={handleClose} />
|
||||
|
||||
<View
|
||||
style={[
|
||||
styles.panel,
|
||||
{
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={[styles.header, { borderBottomColor: theme.colors.border }]}
|
||||
>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChangeText={(text) => {
|
||||
setQuery(text);
|
||||
setActiveIndex(0);
|
||||
}}
|
||||
placeholder="Type a directory path..."
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
style={[styles.input, { color: theme.colors.foreground }]}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
editable={!isSubmitting}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
style={styles.results}
|
||||
contentContainerStyle={styles.resultsContent}
|
||||
keyboardShouldPersistTaps="always"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<Text
|
||||
style={[
|
||||
styles.emptyText,
|
||||
{ color: theme.colors.foregroundMuted },
|
||||
]}
|
||||
>
|
||||
Opening project...
|
||||
</Text>
|
||||
) : options.length === 0 && !query.trim() ? (
|
||||
<Text
|
||||
style={[
|
||||
styles.emptyText,
|
||||
{ color: theme.colors.foregroundMuted },
|
||||
]}
|
||||
>
|
||||
Start typing a path
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
{options.map((path, index) => {
|
||||
const active = index === activeIndex;
|
||||
return (
|
||||
<Pressable
|
||||
key={path}
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.row,
|
||||
(hovered || pressed || active) && {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
]}
|
||||
onPress={() => void handleSelectPath(path)}
|
||||
>
|
||||
<View style={styles.rowContent}>
|
||||
<View style={styles.iconSlot}>
|
||||
<Folder
|
||||
size={16}
|
||||
strokeWidth={2.2}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
</View>
|
||||
<Text
|
||||
style={[
|
||||
styles.rowText,
|
||||
{ color: theme.colors.foreground },
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{path}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
justifyContent: "flex-start",
|
||||
alignItems: "center",
|
||||
paddingTop: theme.spacing[12],
|
||||
},
|
||||
backdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
},
|
||||
panel: {
|
||||
width: 640,
|
||||
maxWidth: "92%",
|
||||
maxHeight: "80%",
|
||||
borderWidth: 1,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
overflow: "hidden",
|
||||
shadowColor: "#000",
|
||||
shadowOpacity: 0.4,
|
||||
shadowRadius: 24,
|
||||
shadowOffset: { width: 0, height: 12 },
|
||||
},
|
||||
header: {
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[3],
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
input: {
|
||||
fontSize: theme.fontSize.lg,
|
||||
paddingVertical: theme.spacing[1],
|
||||
outlineStyle: "none",
|
||||
} as any,
|
||||
results: {
|
||||
flexGrow: 0,
|
||||
},
|
||||
resultsContent: {
|
||||
paddingVertical: theme.spacing[2],
|
||||
},
|
||||
row: {
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[2],
|
||||
},
|
||||
rowContent: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
iconSlot: {
|
||||
width: 16,
|
||||
height: 20,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
rowText: {
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: "400",
|
||||
lineHeight: 20,
|
||||
flexShrink: 1,
|
||||
},
|
||||
emptyText: {
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[4],
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
}));
|
||||
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