mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
85 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c37684b246 | ||
|
|
e2068e3d72 | ||
|
|
443eb16e67 | ||
|
|
240dc26013 | ||
|
|
6b07555a46 | ||
|
|
b69bd5271b | ||
|
|
cf4cae2c7d | ||
|
|
d51f18a2f7 | ||
|
|
006db65f08 | ||
|
|
f21221c1e1 | ||
|
|
9faa88e13b | ||
|
|
faf1eed0ab | ||
|
|
8fc37eac52 | ||
|
|
9e76d1c2d6 | ||
|
|
5609c89517 | ||
|
|
4e4a751921 | ||
|
|
6b4978b428 | ||
|
|
e1f4e6fafb | ||
|
|
f09b43eef0 | ||
|
|
2813f35eb1 | ||
|
|
90dfe36e3e | ||
|
|
7588c1791b | ||
|
|
bfa7f65c3d | ||
|
|
51bbebcdd5 | ||
|
|
7b4ca8394b | ||
|
|
438a9f6d48 | ||
|
|
9604b8d57b | ||
|
|
2a0b0b9109 | ||
|
|
f3338ee824 | ||
|
|
e73d40b260 | ||
|
|
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 |
520
.github/workflows/desktop-release.yml
vendored
520
.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,82 @@ jobs:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||
|
||||
- name: Resolve release metadata
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source_tag="${SOURCE_TAG}"
|
||||
release_tag="$source_tag"
|
||||
for prefix in desktop-windows-v desktop-linux-v desktop-macos-v desktop-v; do
|
||||
if [[ "$source_tag" == ${prefix}* ]]; then
|
||||
release_tag="v${source_tag#${prefix}}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
|
||||
|
||||
version="${release_tag#v}"
|
||||
echo "DESKTOP_VERSION=$version" >> "$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: Set desktop version from tag
|
||||
shell: bash
|
||||
run: |
|
||||
node <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const version = process.env.DESKTOP_VERSION;
|
||||
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
|
||||
console.log(`Setting desktop version to ${version}`);
|
||||
|
||||
const tauriConfPath = path.join('packages', 'desktop', 'src-tauri', 'tauri.conf.json');
|
||||
const tauriConfText = fs.readFileSync(tauriConfPath, 'utf8');
|
||||
const tauriRe = /("version"\s*:\s*")([^"]+)(")/;
|
||||
if (!tauriRe.test(tauriConfText)) throw new Error('Failed to find version in tauri.conf.json');
|
||||
fs.writeFileSync(tauriConfPath, tauriConfText.replace(tauriRe, `$1${version}$3`));
|
||||
|
||||
const cargoTomlPath = path.join('packages', 'desktop', 'src-tauri', 'Cargo.toml');
|
||||
const lines = fs.readFileSync(cargoTomlPath, 'utf8').split(/\r?\n/);
|
||||
let inPackage = false, updated = false;
|
||||
const result = lines.map((line) => {
|
||||
if (/^\[package\]\s*$/.test(line)) inPackage = true;
|
||||
else if (inPackage && /^\[/.test(line)) inPackage = false;
|
||||
if (inPackage && /^version\s*=\s*".*"\s*$/.test(line)) {
|
||||
updated = true;
|
||||
return `version = "${version}"`;
|
||||
}
|
||||
return line;
|
||||
});
|
||||
if (!updated) throw new Error('Failed to update Cargo.toml version');
|
||||
fs.writeFileSync(cargoTomlPath, result.join('\n') + '\n');
|
||||
NODE
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
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,x86_64-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,46 +139,40 @@ jobs:
|
||||
- name: Build web app for Tauri
|
||||
run: npm run build:web --workspace=@getpaseo/app
|
||||
|
||||
- name: Set desktop version from tag
|
||||
- name: Build managed runtime
|
||||
run: npm run prepare:managed-runtime --workspace=@getpaseo/desktop
|
||||
|
||||
- name: Validate managed runtime bundle
|
||||
run: npm run validate:managed-runtime --workspace=@getpaseo/desktop
|
||||
|
||||
- name: Import Apple code-signing certificate
|
||||
uses: apple-actions/import-codesign-certs@v3
|
||||
with:
|
||||
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
|
||||
- name: Sign bundled managed runtime
|
||||
env:
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
run: node ./packages/desktop/scripts/sign-managed-runtime-macos.mjs
|
||||
|
||||
- name: Detect existing GitHub release state
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
node <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
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"
|
||||
|
||||
const rawTag = process.env.RELEASE_TAG;
|
||||
if (!rawTag) throw new Error('RELEASE_TAG env var is missing');
|
||||
|
||||
const version = rawTag.replace(/^desktop-/, '').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: Build and publish Tauri release
|
||||
- name: Build and publish macOS Tauri release
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
id: tauri_build
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -108,6 +189,343 @@ 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 universal-apple-darwin
|
||||
args: --target ${{ matrix.rust_target }}
|
||||
|
||||
- name: Notarize and re-upload DMG
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
artifacts='${{ steps.tauri_build.outputs.artifactPaths }}'
|
||||
dmg_path=$(echo "$artifacts" | jq -r '.[] | select(endswith(".dmg"))')
|
||||
if [ -z "$dmg_path" ]; then
|
||||
echo "::error::No DMG found in tauri build artifacts"
|
||||
exit 1
|
||||
fi
|
||||
echo "DMG: $dmg_path"
|
||||
|
||||
echo "Signing DMG..."
|
||||
codesign --force --sign "$APPLE_SIGNING_IDENTITY" --timestamp "$dmg_path"
|
||||
|
||||
echo "Submitting DMG for notarization..."
|
||||
xcrun notarytool submit "$dmg_path" \
|
||||
--apple-id "$APPLE_ID" \
|
||||
--password "$APPLE_PASSWORD" \
|
||||
--team-id "$APPLE_TEAM_ID" \
|
||||
--wait
|
||||
|
||||
echo "Stapling notarization ticket..."
|
||||
xcrun stapler staple "$dmg_path"
|
||||
|
||||
echo "Verifying..."
|
||||
spctl --assess --type install --verbose "$dmg_path"
|
||||
|
||||
echo "Replacing release asset with notarized DMG..."
|
||||
gh release upload "$RELEASE_TAG" "$dmg_path" --repo "${{ github.repository }}" --clobber
|
||||
|
||||
- name: Build macOS app (smoke only)
|
||||
if: env.IS_SMOKE_TAG == 'true'
|
||||
run: npm run tauri --workspace=@getpaseo/desktop build -- --target ${{ matrix.rust_target }} --no-bundle
|
||||
|
||||
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-22.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||
|
||||
- name: Resolve release metadata
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source_tag="${SOURCE_TAG}"
|
||||
release_tag="$source_tag"
|
||||
for prefix in desktop-windows-v desktop-linux-v desktop-macos-v desktop-v; do
|
||||
if [[ "$source_tag" == ${prefix}* ]]; then
|
||||
release_tag="v${source_tag#${prefix}}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
|
||||
|
||||
version="${release_tag#v}"
|
||||
echo "DESKTOP_VERSION=$version" >> "$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: Set desktop version from tag
|
||||
shell: bash
|
||||
run: |
|
||||
node <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const version = process.env.DESKTOP_VERSION;
|
||||
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
|
||||
console.log(`Setting desktop version to ${version}`);
|
||||
|
||||
const tauriConfPath = path.join('packages', 'desktop', 'src-tauri', 'tauri.conf.json');
|
||||
const tauriConfText = fs.readFileSync(tauriConfPath, 'utf8');
|
||||
const tauriRe = /("version"\s*:\s*")([^"]+)(")/;
|
||||
if (!tauriRe.test(tauriConfText)) throw new Error('Failed to find version in tauri.conf.json');
|
||||
fs.writeFileSync(tauriConfPath, tauriConfText.replace(tauriRe, `$1${version}$3`));
|
||||
|
||||
const cargoTomlPath = path.join('packages', 'desktop', 'src-tauri', 'Cargo.toml');
|
||||
const lines = fs.readFileSync(cargoTomlPath, 'utf8').split(/\r?\n/);
|
||||
let inPackage = false, updated = false;
|
||||
const result = lines.map((line) => {
|
||||
if (/^\[package\]\s*$/.test(line)) inPackage = true;
|
||||
else if (inPackage && /^\[/.test(line)) inPackage = false;
|
||||
if (inPackage && /^version\s*=\s*".*"\s*$/.test(line)) {
|
||||
updated = true;
|
||||
return `version = "${version}"`;
|
||||
}
|
||||
return line;
|
||||
});
|
||||
if (!updated) throw new Error('Failed to update Cargo.toml version');
|
||||
fs.writeFileSync(cargoTomlPath, result.join('\n') + '\n');
|
||||
NODE
|
||||
|
||||
- name: Install Linux packaging dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libappindicator3-dev librsvg2-dev patchelf libfuse2
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
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
|
||||
run: npm run prepare:managed-runtime --workspace=@getpaseo/desktop
|
||||
|
||||
- name: Validate managed runtime bundle
|
||||
run: npm run validate:managed-runtime --workspace=@getpaseo/desktop
|
||||
|
||||
- name: Strip CUDA dependencies from onnxruntime
|
||||
shell: bash
|
||||
run: |
|
||||
find packages/desktop/src-tauri/resources/managed-runtime -path '*/onnxruntime-node/bin/*' \( -name '*cuda*' -o -name '*tensorrt*' \) -delete || true
|
||||
# Remove CUDA shared library references from onnxruntime .so files so linuxdeploy
|
||||
# doesn't try to bundle them (they're optional runtime deps, not needed for CPU inference)
|
||||
for f in $(find packages/desktop/src-tauri/resources/managed-runtime -path '*/onnxruntime-node/bin/*' \( -name '*.so' -o -name '*.so.*' \)); do
|
||||
for lib in $(patchelf --print-needed "$f" 2>/dev/null | grep -iE 'cublas|cudnn|cudart|cufft|curand|cusolver|cusparse|nccl|nvrtc|tensorrt|nvinfer'); do
|
||||
echo "Removing needed $lib from $f"
|
||||
patchelf --remove-needed "$lib" "$f"
|
||||
done
|
||||
done
|
||||
|
||||
- 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 }}
|
||||
NO_STRIP: "true"
|
||||
APPIMAGE_EXTRACT_AND_RUN: "1"
|
||||
with:
|
||||
projectPath: packages/desktop
|
||||
tagName: ${{ env.RELEASE_TAG }}
|
||||
releaseName: Paseo ${{ env.RELEASE_TAG }}
|
||||
releaseBody: See the assets to download and install this version.
|
||||
releaseDraft: ${{ env.RELEASE_DRAFT }}
|
||||
prerelease: false
|
||||
args: --bundles appimage
|
||||
|
||||
- name: Build Linux app (smoke only)
|
||||
if: env.IS_SMOKE_TAG == 'true'
|
||||
run: npm run tauri --workspace=@getpaseo/desktop build -- --no-bundle
|
||||
|
||||
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: Resolve release metadata
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source_tag="${SOURCE_TAG}"
|
||||
release_tag="$source_tag"
|
||||
for prefix in desktop-windows-v desktop-linux-v desktop-macos-v desktop-v; do
|
||||
if [[ "$source_tag" == ${prefix}* ]]; then
|
||||
release_tag="v${source_tag#${prefix}}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
|
||||
|
||||
version="${release_tag#v}"
|
||||
echo "DESKTOP_VERSION=$version" >> "$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: Set desktop version from tag
|
||||
shell: bash
|
||||
run: |
|
||||
node <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const version = process.env.DESKTOP_VERSION;
|
||||
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
|
||||
console.log(`Setting desktop version to ${version}`);
|
||||
|
||||
const tauriConfPath = path.join('packages', 'desktop', 'src-tauri', 'tauri.conf.json');
|
||||
const tauriConfText = fs.readFileSync(tauriConfPath, 'utf8');
|
||||
const tauriRe = /("version"\s*:\s*")([^"]+)(")/;
|
||||
if (!tauriRe.test(tauriConfText)) throw new Error('Failed to find version in tauri.conf.json');
|
||||
fs.writeFileSync(tauriConfPath, tauriConfText.replace(tauriRe, `$1${version}$3`));
|
||||
|
||||
const cargoTomlPath = path.join('packages', 'desktop', 'src-tauri', 'Cargo.toml');
|
||||
const lines = fs.readFileSync(cargoTomlPath, 'utf8').split(/\r?\n/);
|
||||
let inPackage = false, updated = false;
|
||||
const result = lines.map((line) => {
|
||||
if (/^\[package\]\s*$/.test(line)) inPackage = true;
|
||||
else if (inPackage && /^\[/.test(line)) inPackage = false;
|
||||
if (inPackage && /^version\s*=\s*".*"\s*$/.test(line)) {
|
||||
updated = true;
|
||||
return `version = "${version}"`;
|
||||
}
|
||||
return line;
|
||||
});
|
||||
if (!updated) throw new Error('Failed to update Cargo.toml version');
|
||||
fs.writeFileSync(cargoTomlPath, result.join('\n') + '\n');
|
||||
NODE
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
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
|
||||
run: npm run prepare:managed-runtime --workspace=@getpaseo/desktop
|
||||
|
||||
- name: Validate managed runtime bundle
|
||||
run: npm run validate:managed-runtime --workspace=@getpaseo/desktop
|
||||
|
||||
- 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
|
||||
|
||||
- name: Build Windows app (smoke only)
|
||||
if: env.IS_SMOKE_TAG == 'true'
|
||||
run: npm run tauri --workspace=@getpaseo/desktop build -- --no-bundle
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
|
||||
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[@]}"
|
||||
|
||||
3
.github/workflows/server-ci.yml
vendored
3
.github/workflows/server-ci.yml
vendored
@@ -36,6 +36,9 @@ jobs:
|
||||
- name: Install server dependencies
|
||||
run: npm install --workspace=@getpaseo/server --include-workspace-root
|
||||
|
||||
- name: Build relay dependency
|
||||
run: npm run build --workspace=@getpaseo/relay
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck --workspace=@getpaseo/server
|
||||
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -76,3 +76,5 @@ 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/
|
||||
|
||||
31
CHANGELOG.md
31
CHANGELOG.md
@@ -1,5 +1,36 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.24 - 2026-03-10
|
||||
|
||||
### Improved
|
||||
- Improved command center keyboard navigation and new tab shortcut.
|
||||
- Simplified desktop release pipeline for faster and more reliable builds.
|
||||
|
||||
## 0.1.21 - 2026-03-10
|
||||
### Improved
|
||||
- Improved desktop release reliability by fixing the Windows managed-runtime build path during GitHub Actions releases.
|
||||
|
||||
### Fixed
|
||||
- Fixed a desktop release CI failure caused by a Unix-only server build script on Windows runners.
|
||||
- Fixed server CI to build the relay dependency before running tests, restoring relay E2EE test coverage on clean runners.
|
||||
- Fixed a Claude redesign test that depended on the local Claude CLI being installed.
|
||||
|
||||
## 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.
|
||||
|
||||
15
CLAUDE.md
15
CLAUDE.md
@@ -212,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.
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
# Execution Plan — Iteration 2 Projects → Workspaces → Tabs (Paseo Orchestrated)
|
||||
|
||||
This document describes **how** we will execute the fixes defined in:
|
||||
- `PLAN_ITERATION_2_PROJECTS_WORKSPACES_TABS.md`
|
||||
|
||||
It is optimized for parallel work using **Paseo-managed worktrees** and strict quality gates.
|
||||
|
||||
---
|
||||
|
||||
## Constraints / Guardrails
|
||||
|
||||
- **Do not restart or modify** the user’s main daemon on `localhost:6767`.
|
||||
- Use **isolated dev stacks** for manual verification (new daemon + new Metro) via:
|
||||
- `PASEO_HOME=~/.paseo-<unique>` and `npm run dev` (auto-picks free ports).
|
||||
- No “legacy view” preserved: we fix the current UX directly (no dead/unused code paths left behind).
|
||||
- Agents must treat **terminals and agents as equal first-class tab types** (no special layouts).
|
||||
- Keep changes focused to the reported issues; avoid unrelated refactors.
|
||||
|
||||
---
|
||||
|
||||
## Work Breakdown (Parallel)
|
||||
|
||||
### Agent A — Sidebar drag scoping + sidebar polish
|
||||
|
||||
**Worktree:** `polish/sidebar-dnd-and-style`
|
||||
|
||||
Responsibilities:
|
||||
- Fix project drag so dragging a **project header** reorders the **entire project section** (header + workspaces).
|
||||
- Fix workspace drag so workspaces reorder **only within their project** (no cross-project placement).
|
||||
- Ensure sidebar list **snaps back** to canonical `Project → Workspaces` structure after any drag.
|
||||
- Sidebar visuals:
|
||||
- remove workspace “border” style, match project “ghost” style language
|
||||
- remove “No agents yet”
|
||||
- reduce workspace indentation/padding (mobile-friendly)
|
||||
- Navigation polish:
|
||||
- clicking a workspace closes the left sidebar (mobile)
|
||||
|
||||
### Agent B — Workspace header + tabs polish (icons, unified create, persistence)
|
||||
|
||||
**Worktree:** `polish/workspace-tabs-and-header`
|
||||
|
||||
Responsibilities:
|
||||
- Workspace header shows **branch name** for git workspaces (including base branch like `main`).
|
||||
- Replace separate “create agent” vs “create terminal” rows with **one unified New Tab control** (agent + terminal).
|
||||
- Agent tabs show **provider icons** (Claude + Codex minimum, using existing assets/components).
|
||||
- Fix “remember focused tab per workspace” so:
|
||||
- switching away and back restores the last focused agent/terminal tab
|
||||
- stored selection is **not overwritten** while agent/terminal lists are still loading
|
||||
|
||||
### Agent C — Review / sanity check (no code changes)
|
||||
|
||||
Runs after merges to:
|
||||
- review diff for edge cases + regressions
|
||||
- double-check acceptance criteria mapping
|
||||
- call out missing verification steps
|
||||
|
||||
---
|
||||
|
||||
## Agent Launch Commands (local CLI)
|
||||
|
||||
We use the repo-local CLI:
|
||||
|
||||
```bash
|
||||
npm run -s cli -- run --provider codex --model gpt-5.3-codex --mode full-access --worktree <name> --name "<title>" --detach "<prompt>" --quiet
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `--detach --quiet` returns the agent ID quickly so we can launch in parallel.
|
||||
- Each agent must **commit** their work in their worktree branch before finishing.
|
||||
|
||||
---
|
||||
|
||||
## Prompts (exact)
|
||||
|
||||
### Prompt for Agent A
|
||||
|
||||
Title: `🎭 Sidebar DnD + Polish`
|
||||
|
||||
Prompt:
|
||||
- Implement **only** the items in “Sidebar drag behavior” + “Sidebar visuals + navigation polish” from `PLAN_ITERATION_2_PROJECTS_WORKSPACES_TABS.md`.
|
||||
- Do not change gestures beyond the required drag constraints.
|
||||
- Ensure the post-drag list snaps back to the canonical project/workspace grouping.
|
||||
- Remove “No agents yet” and fix workspace row styling/indentation.
|
||||
- Close sidebar on workspace selection (mobile).
|
||||
- Run `npm run typecheck` and `npm run test --workspace=@getpaseo/app` in the worktree.
|
||||
- Commit with a clear message.
|
||||
|
||||
### Prompt for Agent B
|
||||
|
||||
Title: `🎭 Workspace Tabs + Header`
|
||||
|
||||
Prompt:
|
||||
- Implement **only** the items in “Workspace header + tab bar fixes” from `PLAN_ITERATION_2_PROJECTS_WORKSPACES_TABS.md`.
|
||||
- Terminals and agents must be treated as identical first-class tab types (no separate rows/layout).
|
||||
- Add provider icons for agent tabs (Claude/Codex minimum) using existing app icon components.
|
||||
- Fix per-workspace focused-tab persistence (don’t overwrite selection while queries are pending).
|
||||
- Run `npm run typecheck` and `npm run test --workspace=@getpaseo/app` in the worktree.
|
||||
- Commit with a clear message.
|
||||
|
||||
### Prompt for Agent C (review-only)
|
||||
|
||||
Title: `🎭 Review: Sidebar + Tabs Polish`
|
||||
|
||||
Prompt:
|
||||
- Review the combined diff for correctness vs acceptance criteria in `PLAN_ITERATION_2_PROJECTS_WORKSPACES_TABS.md`.
|
||||
- DO NOT edit code. Provide a checklist of anything missing or risky.
|
||||
|
||||
---
|
||||
|
||||
## Merge Strategy (back to `main`)
|
||||
|
||||
1. Wait for Agents A + B to complete.
|
||||
2. For each worktree branch:
|
||||
- verify it has a clean commit history (no unrelated changes)
|
||||
- re-run `npm run typecheck` if needed
|
||||
3. Merge into `main` sequentially:
|
||||
- merge A
|
||||
- rebase/merge B on top of updated `main` (resolve conflicts if any)
|
||||
4. Do not delete/prune worktrees until the user has manually verified.
|
||||
|
||||
---
|
||||
|
||||
## Verification Gates (strict)
|
||||
|
||||
### 1) Automated (must pass)
|
||||
|
||||
From repo root on `main` after merges:
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
npm run test --workspace=@getpaseo/app
|
||||
```
|
||||
|
||||
Optional (run if environment supports it; starts isolated daemon/metro itself):
|
||||
|
||||
```bash
|
||||
npm run test:e2e --workspace=@getpaseo/app
|
||||
```
|
||||
|
||||
### 2) Manual (must be performed by us before handing back)
|
||||
|
||||
Use an **isolated dev stack** (new daemon + new Metro):
|
||||
|
||||
```bash
|
||||
PASEO_HOME=~/.paseo-iter2-polish npm run dev
|
||||
```
|
||||
|
||||
Then use `agent-browser` to verify the “Manual (agent-browser)” section in:
|
||||
- `PLAN_ITERATION_2_PROJECTS_WORKSPACES_TABS.md`
|
||||
|
||||
---
|
||||
|
||||
## Completion Definition
|
||||
|
||||
We are “done” when:
|
||||
- All acceptance criteria in `PLAN_ITERATION_2_PROJECTS_WORKSPACES_TABS.md` are met.
|
||||
- Automated verification gates pass.
|
||||
- Manual verification steps pass.
|
||||
- Changes are merged into `main` with no leftover legacy code paths.
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
# Iteration 3 Execution Plan — Paseo Orchestrated
|
||||
|
||||
## Principles
|
||||
|
||||
- Work happens in an isolated git worktree and merges back to `main` once verified.
|
||||
- Do not restart or touch the daemon on `localhost:6767`.
|
||||
- Use the repo’s existing Playwright global setup (isolated daemon/metro) for E2E.
|
||||
- Pass all gates before merging:
|
||||
- Typecheck
|
||||
- Vitest
|
||||
- Playwright E2E
|
||||
- Manual `agent-browser` verification
|
||||
|
||||
## Agent Delegation (Paseo)
|
||||
|
||||
### Implementation agent (1)
|
||||
|
||||
- **Agent:** Codex
|
||||
- **Mode:** full-access
|
||||
- **Worktree:** `iter3-workspace-header-tabs-restore`
|
||||
- **Mission:**
|
||||
- Fix New tab dropdown visibility (on-screen, correct pattern)
|
||||
- Restore workspace header structure + explorer toggle + agent kebab menu
|
||||
- Add terminal close (X) from workspace tab strip
|
||||
- Add/adjust Playwright E2E specs for the above
|
||||
- Run verification commands before declaring done
|
||||
|
||||
### Reviewer (optional, if needed)
|
||||
|
||||
- Only used if implementation is large/risky or tests expose subtle regressions.
|
||||
- Codex or Claude Sonnet as a second-pass reviewer for UI regressions.
|
||||
|
||||
## Merge Strategy
|
||||
|
||||
1. Agent commits all changes in the worktree.
|
||||
2. Orchestrator reviews the diff on the worktree.
|
||||
3. Run gates locally on the worktree:
|
||||
- `npm run typecheck`
|
||||
- `npm run test --workspace=@getpaseo/app`
|
||||
- `npm run test:e2e --workspace=@getpaseo/app`
|
||||
4. Manual `agent-browser` verification:
|
||||
- Desktop viewport: New tab menu visible + explorer toggle + kebab menu
|
||||
- Mobile viewport: explorer icon uses git/folder, toggles right sidebar, left sidebar unaffected
|
||||
5. Merge worktree back into `main` with a fast-forward merge if possible; otherwise merge commit.
|
||||
|
||||
## Verification Checklist (strict)
|
||||
|
||||
- [ ] New tab menu opens and is visible (desktop)
|
||||
- [ ] Selecting Agent tab routes to draft agent flow scoped to workspace
|
||||
- [ ] Selecting Terminal tab creates terminal and focuses it
|
||||
- [ ] Terminal tabs show X; closing kills terminal and removes tab
|
||||
- [ ] Workspace header shows branch name
|
||||
- [ ] Workspace header has explorer toggle with correct icon behavior
|
||||
- [ ] Agent kebab menu exists when Agent tab active
|
||||
- [ ] Right sidebar opens/closes via header and mobile swipe gesture
|
||||
- [ ] Left sidebar gestures unchanged
|
||||
- [ ] `npm run typecheck` ✅
|
||||
- [ ] `npm run test --workspace=@getpaseo/app` ✅
|
||||
- [ ] `npm run test:e2e --workspace=@getpaseo/app` ✅
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
# Execution Plan — Iteration 4 (Paseo-orchestrated)
|
||||
|
||||
## Strategy
|
||||
|
||||
Use a Paseo-managed implementation agent in an isolated git worktree to patch the workspace screen to restore legacy header/layout parity while keeping tabs. Then review, validate with tests + agent-browser, and merge back to `main`.
|
||||
|
||||
## Agents
|
||||
|
||||
### 1) Implementation agent (Codex)
|
||||
|
||||
- Provider/model: `codex / gpt-5.3-codex`
|
||||
- Mode: `full-access`
|
||||
- Worktree: `iter4-workspace-header-layout-restore` (base: `main`)
|
||||
- Responsibilities:
|
||||
- Fix `New tab` dropdown to use the established dropdown/menu pattern (not off-screen combobox).
|
||||
- Restore workspace header explorer toggle parity with legacy `AgentReadyScreen` (icons, aria state).
|
||||
- Restore agent overflow (kebab) menu when active tab is an agent.
|
||||
- Add terminal tab close `X` on desktop with confirm + kill terminal mutation.
|
||||
- Add/extend Playwright E2E specs for `New tab` on-screen + explorer toggle open/close.
|
||||
- Commit changes.
|
||||
|
||||
### 2) Reviewer/validator (you/me)
|
||||
|
||||
- Review diffs locally.
|
||||
- Run:
|
||||
- `npm run typecheck`
|
||||
- `npm run test --workspace=@getpaseo/app`
|
||||
- Targeted Playwright spec(s) for this iteration
|
||||
- Perform agent-browser manual verification (desktop + mobile viewports).
|
||||
|
||||
## Tooling / Commands (canonical)
|
||||
|
||||
### Create agent (detached)
|
||||
|
||||
```bash
|
||||
paseo run -d \
|
||||
--worktree iter4-workspace-header-layout-restore \
|
||||
--base main \
|
||||
--provider codex \
|
||||
--model gpt-5.3-codex \
|
||||
--mode full-access \
|
||||
--name "🎭 Iter4 workspace header/layout restore" \
|
||||
"<paste the implementation prompt>"
|
||||
```
|
||||
|
||||
### Wait
|
||||
|
||||
```bash
|
||||
paseo wait <agent-id>
|
||||
```
|
||||
|
||||
### Review worktree diff
|
||||
|
||||
```bash
|
||||
cd ~/.paseo/worktrees/<hash>/iter4-workspace-header-layout-restore
|
||||
git status --short --branch
|
||||
git log -n 5 --oneline
|
||||
git diff main..HEAD
|
||||
```
|
||||
|
||||
### Merge into main
|
||||
|
||||
Prefer `git cherry-pick <commit>` into `/Users/moboudra/dev/paseo` `main` after verification.
|
||||
|
||||
## Verification gates (must be green)
|
||||
|
||||
### 1) Typecheck
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
### 2) App unit tests
|
||||
|
||||
```bash
|
||||
npm run test --workspace=@getpaseo/app
|
||||
```
|
||||
|
||||
### 3) Playwright E2E (targeted)
|
||||
|
||||
Run only the spec(s) for this iteration (avoid unrelated flakes):
|
||||
|
||||
```bash
|
||||
cd packages/app
|
||||
npx playwright test e2e/workspace-header-tabs-restore.spec.ts
|
||||
```
|
||||
|
||||
## Manual verification (agent-browser)
|
||||
|
||||
Use explicit sessions:
|
||||
|
||||
```bash
|
||||
agent-browser --session iter4-desktop open http://localhost:8081
|
||||
agent-browser --session iter4-mobile open http://localhost:8081
|
||||
```
|
||||
|
||||
Desktop:
|
||||
- Validate `New tab` dropdown opens on-screen
|
||||
- Validate explorer toggle opens/closes explorer
|
||||
- Validate kebab menu appears for agent tabs
|
||||
- Validate terminal tab `X` close flow
|
||||
|
||||
Mobile viewport:
|
||||
- Validate git/folder icon for explorer toggle
|
||||
- Validate gestures for left/right sidebars
|
||||
|
||||
## Rollback plan
|
||||
|
||||
If verification fails:
|
||||
- Do not merge.
|
||||
- Patch in worktree until acceptance criteria + gates pass.
|
||||
- Only then cherry-pick/merge into `main`.
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
# Execution Plan (Paseo Orchestrator)
|
||||
|
||||
This document is the concrete execution plan for `PLAN_PROJECTS_WORKSPACES_TABS.md`, using the Paseo CLI to delegate work to sub-agents running inside isolated git worktrees.
|
||||
|
||||
## Conventions
|
||||
|
||||
- All agent names are prefixed with `🎭` so they’re easy to identify.
|
||||
- Use **Codex** for implementation work (`--provider codex --mode full-access`).
|
||||
- Each agent runs in its **own worktree** to avoid concurrent writes to the same git working directory.
|
||||
- Agents must avoid “legacy/compat mode” code paths: we are fully redirecting/reworking.
|
||||
|
||||
## 0) Preflight
|
||||
|
||||
```bash
|
||||
git branch --show-current
|
||||
npm run -s cli -- daemon status
|
||||
```
|
||||
|
||||
Expected:
|
||||
- current branch is `main` (or the branch you want as base)
|
||||
- daemon is reachable
|
||||
|
||||
## 1) Launch parallel agents (detached)
|
||||
|
||||
### Agent A — Server protocol + workspace data
|
||||
|
||||
Scope:
|
||||
- Replace file explorer + download token RPC to be **workspace-scoped** (remove `agentId` usage).
|
||||
- Extend worktree list payload with `createdAt` (and a stable `workspaceId` if needed).
|
||||
- Ensure agent index loads once at daemon startup (no repeated disk hydration).
|
||||
|
||||
```bash
|
||||
SERVER_ID=$(npm run -s cli -- run -d -q \
|
||||
--name "🎭 PWT Server: workspace RPCs" \
|
||||
--provider codex --mode full-access \
|
||||
--worktree pwt-server-workspace-rpcs --base main \
|
||||
"Implement server-side changes from PLAN_PROJECTS_WORKSPACES_TABS.md: replace file_explorer_request and file_download_token_request to be workspace-scoped (no agentId), update handlers + client types, add createdAt to paseo_worktree_list_response, and make agent storage/index load once at daemon startup. Do NOT add legacy compatibility. Keep changes minimal and typecheck. Output a short checklist of touched files + how to test."
|
||||
)
|
||||
echo "$SERVER_ID"
|
||||
```
|
||||
|
||||
### Agent B — App: workspace routes + tabs main view
|
||||
|
||||
Scope:
|
||||
- Add workspace routes/screens and redirect old agent routes.
|
||||
- Implement workspace header + tab bar (agent + terminal tabs).
|
||||
- Persist/restore last focused tab per workspace.
|
||||
- Mobile tab switcher + plus button (draft agent flow pre-scoped).
|
||||
|
||||
```bash
|
||||
APP_TABS_ID=$(npm run -s cli -- run -d -q \
|
||||
--name "🎭 PWT App: workspace tabs" \
|
||||
--provider codex --mode full-access \
|
||||
--worktree pwt-app-workspace-tabs --base main \
|
||||
"Implement app-side workspace main view + tabs from PLAN_PROJECTS_WORKSPACES_TABS.md. Replace old /h/:serverId/agent routes with workspace routes, render a workspace screen with a horizontal tab bar (agent + terminal tabs), restore last focused tab, and implement a mobile tab switcher + header plus button to open draft agent flow pre-scoped to workspace. Terminals are just tabs; no special casing. Do NOT keep legacy UI. Keep gestures/overlay sidebars unchanged. Typecheck."
|
||||
)
|
||||
echo \"$APP_TABS_ID\"
|
||||
```
|
||||
|
||||
### Agent C — App: left sidebar projects → workspaces
|
||||
|
||||
Scope:
|
||||
- Replace left sidebar agent list with Project → Workspace tree.
|
||||
- Project icon, status dot aggregation, reorder persistence.
|
||||
- Workspace rows: branch + createdAt, no path.
|
||||
|
||||
```bash
|
||||
APP_SIDEBAR_ID=$(npm run -s cli -- run -d -q \
|
||||
--name "🎭 PWT App: projects sidebar" \
|
||||
--provider codex --mode full-access \
|
||||
--worktree pwt-app-projects-sidebar --base main \
|
||||
"Implement the left sidebar rewrite per PLAN_PROJECTS_WORKSPACES_TABS.md: Projects grouped by projectKey (remote when available else local), each project shows icon + status dot, and contains workspaces (main checkout + Paseo worktrees incl empty). Workspace row shows branch name + createdAt only (no path). Keep drag reorder for projects + workspaces and persist on-device. Do not show agents in sidebar. Typecheck."
|
||||
)
|
||||
echo \"$APP_SIDEBAR_ID\"
|
||||
```
|
||||
|
||||
### Agent D — App: right sidebar changes + files (workspace-scoped)
|
||||
|
||||
Scope:
|
||||
- Remove terminals from the right sidebar.
|
||||
- Make Changes/Files sidebar workspace-scoped (not agent-scoped).
|
||||
- Update file explorer calls to use new workspace-scoped RPC.
|
||||
|
||||
```bash
|
||||
APP_EXPLORER_ID=$(npm run -s cli -- run -d -q \
|
||||
--name "🎭 PWT App: explorer sidebar" \
|
||||
--provider codex --mode full-access \
|
||||
--worktree pwt-app-explorer-sidebar --base main \
|
||||
"Update the right sidebar per PLAN_PROJECTS_WORKSPACES_TABS.md: it must contain only Changes + Files and be scoped to the opened workspace (not the selected tab). Remove terminals from the right sidebar entirely. Replace file explorer client actions/state to use the new workspace-scoped RPC (no agentId). Ensure empty workspaces still work. Typecheck."
|
||||
)
|
||||
echo \"$APP_EXPLORER_ID\"
|
||||
```
|
||||
|
||||
## 2) Wait for completion
|
||||
|
||||
```bash
|
||||
npm run -s cli -- wait "$SERVER_ID"
|
||||
npm run -s cli -- wait "$APP_TABS_ID"
|
||||
npm run -s cli -- wait "$APP_SIDEBAR_ID"
|
||||
npm run -s cli -- wait "$APP_EXPLORER_ID"
|
||||
```
|
||||
|
||||
## 3) Collect diffs from each worktree
|
||||
|
||||
```bash
|
||||
SERVER_CWD=$(npm run -s cli -- inspect "$SERVER_ID" --json | jq -r '.cwd')
|
||||
APP_TABS_CWD=$(npm run -s cli -- inspect "$APP_TABS_ID" --json | jq -r '.cwd')
|
||||
APP_SIDEBAR_CWD=$(npm run -s cli -- inspect "$APP_SIDEBAR_ID" --json | jq -r '.cwd')
|
||||
APP_EXPLORER_CWD=$(npm run -s cli -- inspect "$APP_EXPLORER_ID" --json | jq -r '.cwd')
|
||||
|
||||
git -C "$SERVER_CWD" diff > /tmp/pwt-server.patch
|
||||
git -C "$APP_TABS_CWD" diff > /tmp/pwt-app-tabs.patch
|
||||
git -C "$APP_SIDEBAR_CWD" diff > /tmp/pwt-app-sidebar.patch
|
||||
git -C "$APP_EXPLORER_CWD" diff > /tmp/pwt-app-explorer.patch
|
||||
```
|
||||
|
||||
## 4) Integrate (apply patches in order)
|
||||
|
||||
Recommended: create a clean integration worktree/branch first, then apply patches.
|
||||
|
||||
```bash
|
||||
# In a clean integration branch/worktree:
|
||||
git apply /tmp/pwt-server.patch
|
||||
git apply /tmp/pwt-app-tabs.patch
|
||||
git apply /tmp/pwt-app-sidebar.patch
|
||||
git apply /tmp/pwt-app-explorer.patch
|
||||
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
If `git apply` fails due to overlap, apply one patch at a time and resolve manually, then re-run typecheck.
|
||||
|
||||
## 5) QA pass
|
||||
|
||||
- Verify acceptance criteria list in `PLAN_PROJECTS_WORKSPACES_TABS.md`.
|
||||
- Smoke test navigation: open workspace → tabs → right sidebar → plus flow.
|
||||
- Confirm no remaining agentId-based explorer/download usage.
|
||||
|
||||
1
cli-client-id
Normal file
1
cli-client-id
Normal file
@@ -0,0 +1 @@
|
||||
cid_518a41c4c44340aea1120d2b760fc6c6
|
||||
12994
package-lock.json
generated
12994
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.25",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/server",
|
||||
@@ -44,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"
|
||||
@@ -68,7 +73,9 @@
|
||||
"author": "moboudra",
|
||||
"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();
|
||||
}
|
||||
});
|
||||
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;
|
||||
});
|
||||
}
|
||||
@@ -102,6 +102,39 @@ test("workspace new-tab buttons stay on-screen during horizontal scroll", async
|
||||
}
|
||||
});
|
||||
|
||||
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-");
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const projectRoot = __dirname;
|
||||
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 ?? "")
|
||||
@@ -13,9 +14,14 @@ const customWebPlatform = (process.env.PASEO_WEB_PLATFORM ?? "")
|
||||
|
||||
const config = getDefaultConfig(projectRoot);
|
||||
const defaultResolveRequest = config.resolver.resolveRequest ?? resolve;
|
||||
config.transformer.asyncRequireModulePath = require.resolve(
|
||||
"@expo/metro-config/build/async-require"
|
||||
);
|
||||
|
||||
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,14 +1,15 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"main": "index.ts",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.25",
|
||||
"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 --non-interactive && APP_VARIANT=development expo run:android --variant=debug",
|
||||
"android:production": "APP_VARIANT=production expo prebuild --platform android --non-interactive && APP_VARIANT=production expo run:android --variant=release",
|
||||
"android: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",
|
||||
@@ -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.18",
|
||||
"@getpaseo/server": "0.1.25",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@lezer/common": "^1.5.0",
|
||||
@@ -50,6 +51,7 @@
|
||||
"@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",
|
||||
@@ -84,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",
|
||||
@@ -93,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",
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
} from "@/contexts/horizontal-scroll-context";
|
||||
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";
|
||||
@@ -286,6 +287,7 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
|
||||
{isMobile && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
||||
<DownloadToast />
|
||||
<CommandCenter />
|
||||
<ProjectPickerModal />
|
||||
<KeyboardShortcutsDialog />
|
||||
</View>
|
||||
);
|
||||
@@ -448,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>
|
||||
@@ -467,6 +471,9 @@ export default function RootLayout() {
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
animation: "none",
|
||||
contentStyle: {
|
||||
backgroundColor: darkTheme.colors.surface0,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="index" />
|
||||
@@ -479,6 +486,7 @@ export default function RootLayout() {
|
||||
<Stack.Screen name="h/[serverId]/index" />
|
||||
<Stack.Screen name="h/[serverId]/agents" />
|
||||
<Stack.Screen name="h/[serverId]/new-agent" />
|
||||
<Stack.Screen name="h/[serverId]/open-project" />
|
||||
<Stack.Screen name="h/[serverId]/settings" />
|
||||
<Stack.Screen name="pair-scan" />
|
||||
</Stack>
|
||||
|
||||
@@ -3,6 +3,7 @@ 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,
|
||||
@@ -15,10 +16,13 @@ export default function HostIndexRoute() {
|
||||
const pathname = usePathname();
|
||||
const params = useLocalSearchParams<{ serverId?: string }>();
|
||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||
const { preferences, isLoading: preferencesLoading } = useFormPreferences();
|
||||
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) {
|
||||
@@ -37,14 +41,21 @@ export default function HostIndexRoute() {
|
||||
}
|
||||
|
||||
const visibleAgents = sessionAgents
|
||||
? Array.from(sessionAgents.values()).filter(
|
||||
(agent) => !agent.archivedAt
|
||||
)
|
||||
? 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(
|
||||
@@ -57,21 +68,23 @@ export default function HostIndexRoute() {
|
||||
return;
|
||||
}
|
||||
|
||||
const preferredWorkingDir =
|
||||
preferences.serverId === serverId ? preferences.workingDir?.trim() : "";
|
||||
const workspaceId = preferredWorkingDir || ".";
|
||||
router.replace(buildHostWorkspaceRoute(serverId, workspaceId) as any);
|
||||
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,
|
||||
preferences.serverId,
|
||||
preferences.workingDir,
|
||||
preferencesLoading,
|
||||
router,
|
||||
serverId,
|
||||
sessionAgents,
|
||||
sessionWorkspaces,
|
||||
]);
|
||||
|
||||
return null;
|
||||
|
||||
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} />;
|
||||
}
|
||||
@@ -1,18 +1,16 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { ActivityIndicator, View } from "react-native";
|
||||
import { useLocalSearchParams, usePathname, 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 { 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 pathname = usePathname();
|
||||
const params = useLocalSearchParams<{ serverId?: string }>();
|
||||
const { theme } = useUnistyles();
|
||||
const { daemons, isLoading: registryLoading } = useDaemonRegistry();
|
||||
const { daemons, isLoading: registryLoading, isReconciling } = useDaemonRegistry();
|
||||
const { preferences, isLoading: preferencesLoading } = useFormPreferences();
|
||||
const requestedServerId = useMemo(() => {
|
||||
return typeof params.serverId === "string" ? params.serverId.trim() : "";
|
||||
@@ -53,22 +51,14 @@ export default function Index() {
|
||||
}, [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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -39,7 +39,6 @@ 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'
|
||||
import { shouldClearAgentAttention } from '@/utils/agent-attention'
|
||||
|
||||
type QueuedMessage = {
|
||||
id: string
|
||||
@@ -66,11 +65,16 @@ interface AgentInputAreaProps {
|
||||
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,
|
||||
@@ -85,6 +89,9 @@ export function AgentInputArea({
|
||||
onAddImages,
|
||||
commandDraftConfig,
|
||||
onMessageSent,
|
||||
onComposerHeightChange,
|
||||
onAttentionInputFocus,
|
||||
onAttentionPromptSend,
|
||||
statusControls,
|
||||
}: AgentInputAreaProps) {
|
||||
markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`)
|
||||
@@ -125,6 +132,9 @@ export function AgentInputArea({
|
||||
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)
|
||||
@@ -241,18 +251,9 @@ export function AgentInputArea({
|
||||
messageId: clientMessageId,
|
||||
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
|
||||
})
|
||||
if (
|
||||
shouldClearAgentAttention({
|
||||
agentId,
|
||||
isConnected,
|
||||
requiresAttention: agent?.requiresAttention,
|
||||
attentionReason: agent?.attentionReason,
|
||||
})
|
||||
) {
|
||||
client.clearAgentAttention(agentId)
|
||||
}
|
||||
onAttentionPromptSend?.()
|
||||
}
|
||||
}, [agent?.attentionReason, agent?.requiresAttention, client, isConnected, serverId, setAgentStreamTail, setAgentStreamHead])
|
||||
}, [client, onAttentionPromptSend, serverId, setAgentStreamTail, setAgentStreamHead])
|
||||
|
||||
useEffect(() => {
|
||||
onSubmitMessageRef.current = onSubmitMessage
|
||||
@@ -596,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) {
|
||||
@@ -613,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)
|
||||
@@ -703,7 +704,7 @@ export function AgentInputArea({
|
||||
|
||||
const rightContent = (
|
||||
<View style={styles.rightControls}>
|
||||
{!isVoiceModeForAgent ? (
|
||||
{!isVoiceModeForAgent && agent ? (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
onPress={handleToggleRealtimeVoice}
|
||||
@@ -806,7 +807,7 @@ export function AgentInputArea({
|
||||
onRemoveImage={handleRemoveImage}
|
||||
client={client}
|
||||
isReadyForDictation={isDictationReady}
|
||||
placeholder="Message agent..."
|
||||
placeholder={messagePlaceholder}
|
||||
autoFocus={autoFocus && isDesktopWebBreakpoint}
|
||||
autoFocusKey={`${serverId}:${agentId}`}
|
||||
disabled={isSubmitLoading}
|
||||
@@ -822,7 +823,13 @@ export function AgentInputArea({
|
||||
onSelectionChange={(selection) => {
|
||||
setCursorIndex(selection.start)
|
||||
}}
|
||||
onFocusChange={setIsMessageInputFocused}
|
||||
onFocusChange={(focused) => {
|
||||
setIsMessageInputFocused(focused)
|
||||
if (focused) {
|
||||
onAttentionInputFocus?.()
|
||||
}
|
||||
}}
|
||||
onHeightChange={onComposerHeightChange}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -4,332 +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, type Href } 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 {
|
||||
buildHostWorkspaceAgentRoute,
|
||||
} 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: (agent: AggregatedAgent) => 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 archivedLabel = agent.archivedAt ? "Archived" : null;
|
||||
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)}
|
||||
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}` : ""}
|
||||
{archivedLabel ? ` · ${archivedLabel}` : ""} · {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(
|
||||
(agent: AggregatedAgent) => {
|
||||
if (isActionSheetVisible) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
const serverId = agent.serverId;
|
||||
const agentId = agent.id;
|
||||
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 = pathname.startsWith("/h/");
|
||||
const navigate = shouldReplace ? router.replace : router.push;
|
||||
const shouldReplace = pathname.startsWith('/h/')
|
||||
const navigate = shouldReplace ? router.replace : router.push
|
||||
|
||||
onAgentSelect?.();
|
||||
onAgentSelect?.()
|
||||
|
||||
const route: Href = buildHostWorkspaceAgentRoute(
|
||||
serverId,
|
||||
agent.cwd,
|
||||
agentId
|
||||
) as Href;
|
||||
navigate(route);
|
||||
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
|
||||
@@ -349,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
|
||||
@@ -388,7 +411,7 @@ export function AgentList({
|
||||
</View>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
@@ -397,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,
|
||||
@@ -484,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,
|
||||
@@ -516,4 +628,4 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
}));
|
||||
}))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
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'
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Combobox, type ComboboxOption } from '@/components/ui/combobox'
|
||||
import { AdaptiveModalSheet } from '@/components/adaptive-modal-sheet'
|
||||
import type {
|
||||
AgentMode,
|
||||
@@ -90,7 +91,12 @@ function ControlledStatusBar({
|
||||
const { theme } = useUnistyles()
|
||||
const isWeb = Platform.OS === 'web'
|
||||
const [prefsOpen, setPrefsOpen] = useState(false)
|
||||
const dropdownMaxWidth = isWeb ? 360 : undefined
|
||||
const [openSelector, setOpenSelector] = useState<'provider' | 'mode' | 'model' | 'thinking' | null>(null)
|
||||
|
||||
const providerAnchorRef = useRef<View>(null)
|
||||
const modeAnchorRef = useRef<View>(null)
|
||||
const modelAnchorRef = useRef<View>(null)
|
||||
const thinkingAnchorRef = useRef<View>(null)
|
||||
|
||||
const canSelectProvider = Boolean(onSelectProvider && providerOptions && providerOptions.length > 0)
|
||||
const canSelectMode = Boolean(onSelectMode && modeOptions && modeOptions.length > 0)
|
||||
@@ -119,18 +125,47 @@ function ControlledStatusBar({
|
||||
|
||||
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)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={[styles.container, isWeb && { marginBottom: -theme.spacing[1] }]}>
|
||||
{isWeb ? (
|
||||
<>
|
||||
{providerOptions && providerOptions.length > 0 ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
<>
|
||||
<Pressable
|
||||
ref={providerAnchorRef}
|
||||
collapsable={false}
|
||||
disabled={disabled || !canSelectProvider}
|
||||
style={({ pressed, hovered, open }) => [
|
||||
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"
|
||||
@@ -139,34 +174,31 @@ function ControlledStatusBar({
|
||||
>
|
||||
<Text style={styles.modeBadgeText}>{displayProvider}</Text>
|
||||
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="top"
|
||||
align="start"
|
||||
maxWidth={dropdownMaxWidth}
|
||||
testID="agent-provider-menu"
|
||||
>
|
||||
{providerOptions.map((provider) => (
|
||||
<DropdownMenuItem
|
||||
key={provider.id}
|
||||
selected={provider.id === selectedProviderId}
|
||||
onSelect={() => onSelectProvider?.(provider.id)}
|
||||
>
|
||||
{provider.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</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 ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
<>
|
||||
<Pressable
|
||||
ref={modeAnchorRef}
|
||||
collapsable={false}
|
||||
disabled={disabled || !canSelectMode}
|
||||
style={({ pressed, hovered, open }) => [
|
||||
onPress={() => setOpenSelector(openSelector === 'mode' ? null : 'mode')}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.modeBadge,
|
||||
hovered && styles.modeBadgeHovered,
|
||||
(pressed || open) && styles.modeBadgePressed,
|
||||
(pressed || openSelector === 'mode') && styles.modeBadgePressed,
|
||||
(disabled || !canSelectMode) && styles.disabledBadge,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
@@ -175,68 +207,60 @@ function ControlledStatusBar({
|
||||
>
|
||||
<Text style={styles.modeBadgeText}>{displayMode}</Text>
|
||||
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="top"
|
||||
align="start"
|
||||
maxWidth={dropdownMaxWidth}
|
||||
testID="agent-mode-menu"
|
||||
>
|
||||
{modeOptions.map((mode) => (
|
||||
<DropdownMenuItem
|
||||
key={mode.id}
|
||||
selected={mode.id === selectedModeId}
|
||||
onSelect={() => onSelectMode?.(mode.id)}
|
||||
>
|
||||
{mode.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</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}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
disabled={modelDisabled}
|
||||
style={({ pressed, hovered, open }) => [
|
||||
styles.modeBadge,
|
||||
hovered && styles.modeBadgeHovered,
|
||||
(pressed || open) && 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} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="top"
|
||||
align="start"
|
||||
maxWidth={dropdownMaxWidth}
|
||||
testID="agent-model-menu"
|
||||
>
|
||||
{(modelOptions ?? []).map((model) => (
|
||||
<DropdownMenuItem
|
||||
key={model.id}
|
||||
selected={model.id === selectedModelId}
|
||||
onSelect={() => onSelectModel?.(model.id)}
|
||||
>
|
||||
{model.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<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 ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
<>
|
||||
<Pressable
|
||||
ref={thinkingAnchorRef}
|
||||
collapsable={false}
|
||||
disabled={disabled || !canSelectThinking}
|
||||
style={({ pressed, hovered, open }) => [
|
||||
onPress={() => setOpenSelector(openSelector === 'thinking' ? null : 'thinking')}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.modeBadge,
|
||||
hovered && styles.modeBadgeHovered,
|
||||
(pressed || open) && styles.modeBadgePressed,
|
||||
(pressed || openSelector === 'thinking') && styles.modeBadgePressed,
|
||||
(disabled || !canSelectThinking) && styles.disabledBadge,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
@@ -250,24 +274,18 @@ function ControlledStatusBar({
|
||||
/>
|
||||
<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((thinking) => (
|
||||
<DropdownMenuItem
|
||||
key={thinking.id}
|
||||
selected={thinking.id === selectedThinkingOptionId}
|
||||
onSelect={() => onSelectThinkingOption?.(thinking.id)}
|
||||
>
|
||||
{thinking.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}
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
isNearBottomForStreamRenderStrategy,
|
||||
orderHeadForStreamRenderStrategy,
|
||||
orderTailForStreamRenderStrategy,
|
||||
resolveBottomAnchorTransportBehavior,
|
||||
resolveStreamRenderStrategy,
|
||||
} from "./agent-stream-render-strategy";
|
||||
|
||||
@@ -45,6 +46,10 @@ describe("resolveStreamRenderStrategy", () => {
|
||||
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", () => {
|
||||
@@ -57,6 +62,44 @@ describe("resolveStreamRenderStrategy", () => {
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,438 +1,2 @@
|
||||
import type { ComponentType, ReactElement, RefObject } from "react";
|
||||
import type { FlatList, ScrollView, StyleProp, View, ViewStyle } from "react-native";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
|
||||
type EdgeSlot = "header" | "footer";
|
||||
type NeighborRelation = "above" | "below";
|
||||
type AssistantTurnTraversalStep = -1 | 1;
|
||||
|
||||
export type MaintainVisibleContentPositionConfig = Readonly<{
|
||||
minIndexForVisible: number;
|
||||
autoscrollToTopThreshold: number;
|
||||
}>;
|
||||
|
||||
export type StreamViewportMetrics = {
|
||||
contentHeight: number;
|
||||
viewportHeight: number;
|
||||
};
|
||||
|
||||
export type StreamNearBottomInput = StreamViewportMetrics & {
|
||||
offsetY: number;
|
||||
threshold: number;
|
||||
};
|
||||
|
||||
export type StreamEdgeSlotProps = {
|
||||
ListHeaderComponent?: ReactElement | ComponentType<any> | null;
|
||||
ListHeaderComponentStyle?: StyleProp<ViewStyle>;
|
||||
ListFooterComponent?: ReactElement | ComponentType<any> | null;
|
||||
ListFooterComponentStyle?: StyleProp<ViewStyle>;
|
||||
};
|
||||
|
||||
export type StreamRenderRefs = {
|
||||
flatListRef: RefObject<FlatList<StreamItem> | null>;
|
||||
scrollViewRef: RefObject<ScrollView | null>;
|
||||
bottomAnchorRef: RefObject<View | null>;
|
||||
};
|
||||
|
||||
export type ResolveStreamRenderStrategyInput = {
|
||||
platform: string;
|
||||
isMobileBreakpoint: boolean;
|
||||
};
|
||||
|
||||
export interface StreamRenderStrategy {
|
||||
orderTail: (streamItems: StreamItem[]) => StreamItem[];
|
||||
orderHead: (streamHead: StreamItem[]) => StreamItem[];
|
||||
getNeighborIndex: (index: number, relation: NeighborRelation) => number;
|
||||
getNeighborItem: (
|
||||
items: StreamItem[],
|
||||
index: number,
|
||||
relation: NeighborRelation
|
||||
) => StreamItem | undefined;
|
||||
collectAssistantTurnContent: (items: StreamItem[], startIndex: number) => string;
|
||||
isNearBottom: (input: StreamNearBottomInput) => boolean;
|
||||
getBottomOffset: (metrics: StreamViewportMetrics) => number;
|
||||
getEdgeSlotProps: (
|
||||
component: ReactElement | ComponentType<any> | null,
|
||||
gapSize: number
|
||||
) => StreamEdgeSlotProps;
|
||||
getMaintainVisibleContentPosition: () =>
|
||||
| MaintainVisibleContentPositionConfig
|
||||
| undefined;
|
||||
getFlatListInverted: () => boolean;
|
||||
getOverlayScrollbarInverted: () => boolean;
|
||||
shouldDisableParentScrollOnInlineDetailsExpansion: () => boolean;
|
||||
shouldAnchorBottomOnContentSizeChange: () => boolean;
|
||||
shouldAnimateManualScrollToBottom: () => boolean;
|
||||
shouldUseVirtualizedList: () => boolean;
|
||||
scrollToBottom: (params: {
|
||||
refs: StreamRenderRefs;
|
||||
metrics: StreamViewportMetrics;
|
||||
animated: boolean;
|
||||
}) => void;
|
||||
scrollToOffset: (params: {
|
||||
refs: StreamRenderRefs;
|
||||
offset: number;
|
||||
animated: boolean;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
type StreamRenderStrategyConfig = {
|
||||
orderTailReverse: boolean;
|
||||
orderHeadReverse: boolean;
|
||||
assistantTurnTraversalStep: AssistantTurnTraversalStep;
|
||||
edgeSlot: EdgeSlot;
|
||||
flatListInverted: boolean;
|
||||
overlayScrollbarInverted: boolean;
|
||||
maintainVisibleContentPosition?: MaintainVisibleContentPositionConfig;
|
||||
disableParentScrollOnInlineDetailsExpansion: boolean;
|
||||
anchorBottomOnContentSizeChange: boolean;
|
||||
animateManualScrollToBottom: boolean;
|
||||
useVirtualizedList: boolean;
|
||||
isNearBottom: (input: StreamNearBottomInput) => boolean;
|
||||
getBottomOffset: (metrics: StreamViewportMetrics) => number;
|
||||
scrollToBottom: (params: {
|
||||
refs: StreamRenderRefs;
|
||||
metrics: StreamViewportMetrics;
|
||||
animated: boolean;
|
||||
}) => void;
|
||||
scrollToOffset: (params: {
|
||||
refs: StreamRenderRefs;
|
||||
offset: number;
|
||||
animated: boolean;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
const DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION: MaintainVisibleContentPositionConfig =
|
||||
Object.freeze({
|
||||
minIndexForVisible: 0,
|
||||
autoscrollToTopThreshold: 0,
|
||||
});
|
||||
|
||||
function scrollAnchorIntoView(params: {
|
||||
refs: StreamRenderRefs;
|
||||
animated: boolean;
|
||||
}): boolean {
|
||||
const anchorHandle = params.refs.bottomAnchorRef.current as
|
||||
| ({ getNativeRef?: () => unknown; scrollIntoView?: (options?: unknown) => void } &
|
||||
object)
|
||||
| null;
|
||||
if (!anchorHandle) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const maybeNative =
|
||||
typeof anchorHandle.getNativeRef === "function"
|
||||
? anchorHandle.getNativeRef()
|
||||
: anchorHandle;
|
||||
|
||||
const domElement = maybeNative as { scrollIntoView?: (options?: unknown) => void };
|
||||
if (typeof domElement.scrollIntoView !== "function") {
|
||||
return false;
|
||||
}
|
||||
|
||||
domElement.scrollIntoView({
|
||||
block: "end",
|
||||
behavior: params.animated ? "smooth" : "auto",
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function forceScrollContainerToBottom(
|
||||
refs: StreamRenderRefs,
|
||||
fallbackOffset: number
|
||||
): void {
|
||||
const resolveNode = (
|
||||
input: unknown
|
||||
): HTMLElement | null => {
|
||||
if (!(input instanceof HTMLElement)) {
|
||||
return null;
|
||||
}
|
||||
if (input.scrollHeight - input.clientHeight > 1) {
|
||||
return input;
|
||||
}
|
||||
let node: HTMLElement | null = input.parentElement;
|
||||
while (node) {
|
||||
if (node.scrollHeight - node.clientHeight > 1) {
|
||||
return node;
|
||||
}
|
||||
node = node.parentElement;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const scrollViewHandle = refs.scrollViewRef.current as
|
||||
| {
|
||||
getNativeScrollRef?: () => unknown;
|
||||
getScrollableNode?: () => unknown;
|
||||
getInnerViewNode?: () => unknown;
|
||||
getNativeRef?: () => unknown;
|
||||
}
|
||||
| null;
|
||||
const anchorHandle = refs.bottomAnchorRef.current as
|
||||
| ({ getNativeRef?: () => unknown } & object)
|
||||
| null;
|
||||
|
||||
const candidates: unknown[] = [
|
||||
scrollViewHandle?.getNativeScrollRef?.(),
|
||||
scrollViewHandle?.getScrollableNode?.(),
|
||||
scrollViewHandle?.getInnerViewNode?.(),
|
||||
scrollViewHandle?.getNativeRef?.(),
|
||||
scrollViewHandle,
|
||||
typeof anchorHandle?.getNativeRef === "function"
|
||||
? anchorHandle.getNativeRef()
|
||||
: anchorHandle,
|
||||
];
|
||||
|
||||
let scrollNode: HTMLElement | null = null;
|
||||
for (const candidate of candidates) {
|
||||
scrollNode = resolveNode(candidate);
|
||||
if (scrollNode) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!scrollNode && typeof document !== "undefined") {
|
||||
scrollNode = resolveNode(
|
||||
document.querySelector("[data-testid='agent-chat-scroll']")
|
||||
);
|
||||
}
|
||||
|
||||
if (!scrollNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const snap = () => {
|
||||
scrollNode.scrollTop = Math.max(
|
||||
fallbackOffset,
|
||||
scrollNode.scrollHeight - scrollNode.clientHeight
|
||||
);
|
||||
};
|
||||
snap();
|
||||
if (typeof requestAnimationFrame === "function") {
|
||||
requestAnimationFrame(snap);
|
||||
}
|
||||
}
|
||||
|
||||
function createStreamRenderStrategy(
|
||||
config: StreamRenderStrategyConfig
|
||||
): StreamRenderStrategy {
|
||||
return {
|
||||
orderTail: (streamItems) =>
|
||||
config.orderTailReverse ? [...streamItems].reverse() : streamItems,
|
||||
orderHead: (streamHead) =>
|
||||
config.orderHeadReverse ? [...streamHead].reverse() : streamHead,
|
||||
getNeighborIndex: (index, relation) =>
|
||||
relation === "above"
|
||||
? index + config.assistantTurnTraversalStep
|
||||
: index - config.assistantTurnTraversalStep,
|
||||
getNeighborItem: (items, index, relation) => {
|
||||
const neighborIndex =
|
||||
relation === "above"
|
||||
? index + config.assistantTurnTraversalStep
|
||||
: index - config.assistantTurnTraversalStep;
|
||||
if (neighborIndex < 0 || neighborIndex >= items.length) {
|
||||
return undefined;
|
||||
}
|
||||
return items[neighborIndex];
|
||||
},
|
||||
collectAssistantTurnContent: (items, startIndex) => {
|
||||
const messages: string[] = [];
|
||||
for (
|
||||
let index = startIndex;
|
||||
index >= 0 && index < items.length;
|
||||
index += config.assistantTurnTraversalStep
|
||||
) {
|
||||
const currentItem = items[index];
|
||||
if (currentItem.kind === "user_message") {
|
||||
break;
|
||||
}
|
||||
if (currentItem.kind === "assistant_message") {
|
||||
messages.push(currentItem.text);
|
||||
}
|
||||
}
|
||||
return messages.reverse().join("\n\n");
|
||||
},
|
||||
isNearBottom: (input) => config.isNearBottom(input),
|
||||
getBottomOffset: (metrics) => config.getBottomOffset(metrics),
|
||||
getEdgeSlotProps: (component, gapSize) => {
|
||||
if (config.edgeSlot === "header") {
|
||||
return {
|
||||
ListHeaderComponent: component,
|
||||
ListHeaderComponentStyle: { marginBottom: gapSize },
|
||||
};
|
||||
}
|
||||
return {
|
||||
ListFooterComponent: component,
|
||||
ListFooterComponentStyle: { marginTop: gapSize },
|
||||
};
|
||||
},
|
||||
getMaintainVisibleContentPosition: () => config.maintainVisibleContentPosition,
|
||||
getFlatListInverted: () => config.flatListInverted,
|
||||
getOverlayScrollbarInverted: () => config.overlayScrollbarInverted,
|
||||
shouldDisableParentScrollOnInlineDetailsExpansion: () =>
|
||||
config.disableParentScrollOnInlineDetailsExpansion,
|
||||
shouldAnchorBottomOnContentSizeChange: () =>
|
||||
config.anchorBottomOnContentSizeChange,
|
||||
shouldAnimateManualScrollToBottom: () => config.animateManualScrollToBottom,
|
||||
shouldUseVirtualizedList: () => config.useVirtualizedList,
|
||||
scrollToBottom: (params) => config.scrollToBottom(params),
|
||||
scrollToOffset: (params) => config.scrollToOffset(params),
|
||||
};
|
||||
}
|
||||
|
||||
function createInvertedStreamStrategy(): StreamRenderStrategy {
|
||||
return createStreamRenderStrategy({
|
||||
orderTailReverse: true,
|
||||
orderHeadReverse: true,
|
||||
assistantTurnTraversalStep: 1,
|
||||
edgeSlot: "header",
|
||||
flatListInverted: true,
|
||||
overlayScrollbarInverted: true,
|
||||
maintainVisibleContentPosition: DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION,
|
||||
disableParentScrollOnInlineDetailsExpansion: false,
|
||||
anchorBottomOnContentSizeChange: false,
|
||||
animateManualScrollToBottom: true,
|
||||
useVirtualizedList: true,
|
||||
isNearBottom: (input) => input.offsetY <= input.threshold,
|
||||
getBottomOffset: () => 0,
|
||||
scrollToBottom: ({ refs, animated }) => {
|
||||
refs.flatListRef.current?.scrollToOffset({
|
||||
offset: 0,
|
||||
animated,
|
||||
});
|
||||
},
|
||||
scrollToOffset: ({ refs, offset, animated }) => {
|
||||
refs.flatListRef.current?.scrollToOffset({ offset, animated });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createForwardStreamStrategy(): StreamRenderStrategy {
|
||||
return createStreamRenderStrategy({
|
||||
orderTailReverse: false,
|
||||
orderHeadReverse: false,
|
||||
assistantTurnTraversalStep: -1,
|
||||
edgeSlot: "footer",
|
||||
flatListInverted: false,
|
||||
overlayScrollbarInverted: false,
|
||||
maintainVisibleContentPosition: undefined,
|
||||
disableParentScrollOnInlineDetailsExpansion: false,
|
||||
anchorBottomOnContentSizeChange: true,
|
||||
animateManualScrollToBottom: false,
|
||||
useVirtualizedList: false,
|
||||
isNearBottom: (inputMetrics) => {
|
||||
const distanceFromBottom = Math.max(
|
||||
0,
|
||||
inputMetrics.contentHeight -
|
||||
(inputMetrics.offsetY + inputMetrics.viewportHeight)
|
||||
);
|
||||
return distanceFromBottom <= inputMetrics.threshold;
|
||||
},
|
||||
getBottomOffset: (metrics) =>
|
||||
Math.max(0, metrics.contentHeight - metrics.viewportHeight),
|
||||
scrollToBottom: ({ refs, metrics, animated }) => {
|
||||
const bottomOffset = Math.max(
|
||||
0,
|
||||
metrics.contentHeight - metrics.viewportHeight
|
||||
);
|
||||
const usedAnchor = scrollAnchorIntoView({ refs, animated });
|
||||
if (!usedAnchor) {
|
||||
refs.scrollViewRef.current?.scrollToEnd?.({ animated });
|
||||
}
|
||||
// Always apply deterministic bottom offset to avoid partial anchors.
|
||||
refs.scrollViewRef.current?.scrollTo?.({
|
||||
y: bottomOffset,
|
||||
animated,
|
||||
});
|
||||
forceScrollContainerToBottom(refs, bottomOffset);
|
||||
},
|
||||
scrollToOffset: ({ refs, offset, animated }) => {
|
||||
refs.scrollViewRef.current?.scrollTo({ y: offset, animated });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveStreamRenderStrategy(
|
||||
input: ResolveStreamRenderStrategyInput
|
||||
): StreamRenderStrategy {
|
||||
if (input.platform === "web") {
|
||||
return createForwardStreamStrategy();
|
||||
}
|
||||
return createInvertedStreamStrategy();
|
||||
}
|
||||
|
||||
export function orderTailForStreamRenderStrategy(params: {
|
||||
strategy: StreamRenderStrategy;
|
||||
streamItems: StreamItem[];
|
||||
}): StreamItem[] {
|
||||
return params.strategy.orderTail(params.streamItems);
|
||||
}
|
||||
|
||||
export function orderHeadForStreamRenderStrategy(params: {
|
||||
strategy: StreamRenderStrategy;
|
||||
streamHead: StreamItem[];
|
||||
}): StreamItem[] {
|
||||
return params.strategy.orderHead(params.streamHead);
|
||||
}
|
||||
|
||||
export function getStreamNeighborIndex(params: {
|
||||
strategy: StreamRenderStrategy;
|
||||
index: number;
|
||||
relation: NeighborRelation;
|
||||
}): number {
|
||||
return params.strategy.getNeighborIndex(params.index, params.relation);
|
||||
}
|
||||
|
||||
export function getStreamNeighborItem(params: {
|
||||
strategy: StreamRenderStrategy;
|
||||
items: StreamItem[];
|
||||
index: number;
|
||||
relation: NeighborRelation;
|
||||
}): StreamItem | undefined {
|
||||
return params.strategy.getNeighborItem(
|
||||
params.items,
|
||||
params.index,
|
||||
params.relation
|
||||
);
|
||||
}
|
||||
|
||||
export function collectAssistantTurnContentForStreamRenderStrategy(params: {
|
||||
strategy: StreamRenderStrategy;
|
||||
items: StreamItem[];
|
||||
startIndex: number;
|
||||
}): string {
|
||||
return params.strategy.collectAssistantTurnContent(
|
||||
params.items,
|
||||
params.startIndex
|
||||
);
|
||||
}
|
||||
|
||||
export function isNearBottomForStreamRenderStrategy(
|
||||
params: StreamNearBottomInput & { strategy: StreamRenderStrategy }
|
||||
): boolean {
|
||||
return params.strategy.isNearBottom({
|
||||
offsetY: params.offsetY,
|
||||
threshold: params.threshold,
|
||||
contentHeight: params.contentHeight,
|
||||
viewportHeight: params.viewportHeight,
|
||||
});
|
||||
}
|
||||
|
||||
export function getBottomOffsetForStreamRenderStrategy(
|
||||
params: StreamViewportMetrics & {
|
||||
strategy: StreamRenderStrategy;
|
||||
}
|
||||
): number {
|
||||
return params.strategy.getBottomOffset({
|
||||
contentHeight: params.contentHeight,
|
||||
viewportHeight: params.viewportHeight,
|
||||
});
|
||||
}
|
||||
|
||||
export function getStreamEdgeSlotProps(params: {
|
||||
strategy: StreamRenderStrategy;
|
||||
component: ReactElement | ComponentType<any> | null;
|
||||
gapSize: number;
|
||||
}): StreamEdgeSlotProps {
|
||||
return params.strategy.getEdgeSlotProps(params.component, params.gapSize);
|
||||
}
|
||||
export * from "./stream-strategy";
|
||||
export * from "./agent-stream-render-model";
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import {
|
||||
Fragment,
|
||||
createElement,
|
||||
forwardRef,
|
||||
isValidElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
@@ -10,23 +7,14 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import type { ComponentType, ReactElement, ReactNode } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Pressable,
|
||||
FlatList,
|
||||
ScrollView,
|
||||
ListRenderItemInfo,
|
||||
LayoutChangeEvent,
|
||||
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";
|
||||
@@ -65,19 +53,18 @@ 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";
|
||||
import {
|
||||
buildAgentStreamRenderModel,
|
||||
collectAssistantTurnContentForStreamRenderStrategy,
|
||||
getStreamEdgeSlotProps,
|
||||
getStreamNeighborItem,
|
||||
isNearBottomForStreamRenderStrategy,
|
||||
orderHeadForStreamRenderStrategy,
|
||||
orderTailForStreamRenderStrategy,
|
||||
resolveStreamRenderStrategy,
|
||||
type StreamEdgeSlotProps,
|
||||
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";
|
||||
@@ -90,26 +77,10 @@ const isToolSequenceItem = (item?: StreamItem) =>
|
||||
const AGENT_STREAM_LOG_TAG = "[AgentStreamView]";
|
||||
const STREAM_ITEM_LOG_MIN_COUNT = 200;
|
||||
const STREAM_ITEM_LOG_DELTA_THRESHOLD = 50;
|
||||
const NOOP_SEPARATORS: ListRenderItemInfo<StreamItem>["separators"] = {
|
||||
highlight: () => {},
|
||||
unhighlight: () => {},
|
||||
updateProps: () => {},
|
||||
};
|
||||
|
||||
function renderStreamEdgeComponent(
|
||||
component: ReactElement | ComponentType<any> | null | undefined
|
||||
): ReactNode {
|
||||
if (!component) {
|
||||
return null;
|
||||
}
|
||||
if (isValidElement(component)) {
|
||||
return component;
|
||||
}
|
||||
return createElement(component);
|
||||
}
|
||||
|
||||
export interface AgentStreamViewHandle {
|
||||
scrollToBottom(): void;
|
||||
scrollToBottom(reason?: BottomAnchorLocalRequest["reason"]): void;
|
||||
prepareForViewportChange(): void;
|
||||
}
|
||||
|
||||
export interface AgentStreamViewProps {
|
||||
@@ -118,6 +89,8 @@ export interface AgentStreamViewProps {
|
||||
agent: Agent;
|
||||
streamItems: StreamItem[];
|
||||
pendingPermissions: Map<string, PendingPermission>;
|
||||
routeBottomAnchorRequest?: BottomAnchorRouteRequest | null;
|
||||
isAuthoritativeHistoryReady?: boolean;
|
||||
}
|
||||
|
||||
export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamViewProps>(function AgentStreamView({
|
||||
@@ -126,10 +99,10 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
agent,
|
||||
streamItems,
|
||||
pendingPermissions,
|
||||
routeBottomAnchorRequest = null,
|
||||
isAuthoritativeHistoryReady = true,
|
||||
}, ref) {
|
||||
const flatListRef = useRef<FlatList<StreamItem>>(null);
|
||||
const scrollViewRef = useRef<ScrollView>(null);
|
||||
const bottomAnchorRef = useRef<View>(null);
|
||||
const viewportRef = useRef<StreamViewportHandle | null>(null);
|
||||
const { theme } = useUnistyles();
|
||||
const router = useRouter();
|
||||
const isMobile =
|
||||
@@ -142,29 +115,11 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
}),
|
||||
[isMobile]
|
||||
);
|
||||
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
|
||||
const insets = useSafeAreaInsets();
|
||||
const [isNearBottom, setIsNearBottom] = useState(true);
|
||||
const hasScrolledInitially = useRef(false);
|
||||
const hasAutoScrolledOnce = useRef(false);
|
||||
const isNearBottomRef = useRef(true);
|
||||
const pendingAnchorRequestRef = useRef(false);
|
||||
const pendingAutoScrollFrameRef = useRef<number | null>(null);
|
||||
const pendingAutoScrollAnimatedRef = useRef(false);
|
||||
const scrollOffsetYRef = useRef(0);
|
||||
const streamItemCountRef = useRef(0);
|
||||
const streamViewportMetricsRef = useRef({
|
||||
contentHeight: 0,
|
||||
viewportHeight: 0,
|
||||
});
|
||||
const streamScrollbarMetrics = useWebDesktopScrollbarMetrics();
|
||||
const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState<Set<string>>(new Set());
|
||||
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
|
||||
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
||||
const streamRenderRefs = useMemo(
|
||||
() => ({ flatListRef, scrollViewRef, bottomAnchorRef }),
|
||||
[]
|
||||
);
|
||||
|
||||
// Get serverId (fallback to agent's serverId if not provided)
|
||||
const resolvedServerId = serverId ?? agent.serverId ?? "";
|
||||
@@ -194,10 +149,7 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
: FadeOut.duration(200);
|
||||
|
||||
useEffect(() => {
|
||||
hasScrolledInitially.current = false;
|
||||
hasAutoScrolledOnce.current = false;
|
||||
isNearBottomRef.current = true;
|
||||
pendingAnchorRequestRef.current = false;
|
||||
setIsNearBottom(true);
|
||||
setExpandedInlineToolCallIds(new Set());
|
||||
}, [agentId]);
|
||||
|
||||
@@ -246,284 +198,69 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
]
|
||||
);
|
||||
|
||||
const updateNearBottom = useCallback((value: boolean) => {
|
||||
if (isNearBottomRef.current === value) return;
|
||||
isNearBottomRef.current = value;
|
||||
setIsNearBottom(value);
|
||||
}, []);
|
||||
|
||||
const requestAnchorToBottom = useCallback(() => {
|
||||
pendingAnchorRequestRef.current = true;
|
||||
}, []);
|
||||
|
||||
const handleScroll = useCallback(
|
||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
|
||||
const previousOffsetY = scrollOffsetYRef.current;
|
||||
const previousContentHeight = streamViewportMetricsRef.current.contentHeight;
|
||||
scrollOffsetYRef.current = contentOffset.y;
|
||||
streamViewportMetricsRef.current = {
|
||||
contentHeight: Math.max(0, contentSize.height),
|
||||
viewportHeight: Math.max(0, layoutMeasurement.height),
|
||||
};
|
||||
const offsetDelta = contentOffset.y - previousOffsetY;
|
||||
const contentHeightDelta =
|
||||
streamViewportMetricsRef.current.contentHeight - previousContentHeight;
|
||||
const threshold = Math.max(insets.bottom, 32);
|
||||
const nearBottom = isNearBottomForStreamRenderStrategy({
|
||||
strategy: streamRenderStrategy,
|
||||
offsetY: contentOffset.y,
|
||||
threshold,
|
||||
contentHeight: streamViewportMetricsRef.current.contentHeight,
|
||||
viewportHeight: streamViewportMetricsRef.current.viewportHeight,
|
||||
});
|
||||
|
||||
const pendingAnchorBefore = pendingAnchorRequestRef.current;
|
||||
const shouldSuppressFalseNearBottom =
|
||||
pendingAnchorBefore &&
|
||||
!nearBottom &&
|
||||
Math.abs(offsetDelta) <= 1 &&
|
||||
contentHeightDelta > 0;
|
||||
if (shouldSuppressFalseNearBottom) {
|
||||
updateNearBottom(true);
|
||||
} else {
|
||||
updateNearBottom(nearBottom);
|
||||
}
|
||||
|
||||
const shouldClearPendingAnchor =
|
||||
pendingAnchorBefore && !nearBottom && Math.abs(offsetDelta) > 1;
|
||||
if (shouldClearPendingAnchor) {
|
||||
pendingAnchorRequestRef.current = false;
|
||||
}
|
||||
|
||||
if (showDesktopWebScrollbar) {
|
||||
streamScrollbarMetrics.onScroll(event);
|
||||
}
|
||||
},
|
||||
[
|
||||
insets.bottom,
|
||||
showDesktopWebScrollbar,
|
||||
streamRenderStrategy,
|
||||
streamScrollbarMetrics,
|
||||
updateNearBottom,
|
||||
]
|
||||
);
|
||||
|
||||
const handleListLayout = useCallback(
|
||||
(event: LayoutChangeEvent) => {
|
||||
streamViewportMetricsRef.current = {
|
||||
...streamViewportMetricsRef.current,
|
||||
viewportHeight: Math.max(0, event.nativeEvent.layout.height),
|
||||
};
|
||||
if (showDesktopWebScrollbar) {
|
||||
streamScrollbarMetrics.onLayout(event);
|
||||
}
|
||||
},
|
||||
[showDesktopWebScrollbar, streamScrollbarMetrics]
|
||||
);
|
||||
|
||||
const scrollToBottomInternal = useCallback(
|
||||
({ animated }: { animated: boolean }) => {
|
||||
const targetOffset = streamRenderStrategy.getBottomOffset(
|
||||
streamViewportMetricsRef.current
|
||||
);
|
||||
streamRenderStrategy.scrollToBottom({
|
||||
refs: streamRenderRefs,
|
||||
metrics: streamViewportMetricsRef.current,
|
||||
animated,
|
||||
});
|
||||
scrollOffsetYRef.current = targetOffset;
|
||||
updateNearBottom(true);
|
||||
},
|
||||
[updateNearBottom, streamRenderRefs, streamRenderStrategy]
|
||||
);
|
||||
|
||||
const baseRenderModel = useMemo(() => {
|
||||
return buildAgentStreamRenderModel({
|
||||
tail: streamItems,
|
||||
head: streamHead ?? [],
|
||||
platform: Platform.OS === "web" ? "web" : "native",
|
||||
isMobileBreakpoint: isMobile,
|
||||
});
|
||||
}, [isMobile, streamHead, streamItems]);
|
||||
useImperativeHandle(ref, () => ({
|
||||
scrollToBottom() {
|
||||
requestAnchorToBottom();
|
||||
scrollToBottom(reason = "jump-to-bottom") {
|
||||
viewportRef.current?.scrollToBottom(reason);
|
||||
},
|
||||
}), [requestAnchorToBottom]);
|
||||
|
||||
const handleContentSizeChange = useCallback(
|
||||
(width: number, height: number) => {
|
||||
const previousMetrics = streamViewportMetricsRef.current;
|
||||
const threshold = Math.max(insets.bottom, 32);
|
||||
const wasNearBottom = isNearBottomForStreamRenderStrategy({
|
||||
strategy: streamRenderStrategy,
|
||||
offsetY: scrollOffsetYRef.current,
|
||||
threshold,
|
||||
contentHeight: previousMetrics.contentHeight,
|
||||
viewportHeight: previousMetrics.viewportHeight,
|
||||
});
|
||||
|
||||
streamViewportMetricsRef.current = {
|
||||
...previousMetrics,
|
||||
contentHeight: Math.max(0, height),
|
||||
};
|
||||
|
||||
if (streamRenderStrategy.shouldAnchorBottomOnContentSizeChange()) {
|
||||
if (!hasAutoScrolledOnce.current) {
|
||||
scrollToBottomInternal({ animated: false });
|
||||
hasAutoScrolledOnce.current = true;
|
||||
hasScrolledInitially.current = true;
|
||||
} else if (
|
||||
wasNearBottom ||
|
||||
isNearBottomRef.current ||
|
||||
pendingAnchorRequestRef.current
|
||||
) {
|
||||
scrollToBottomInternal({ animated: false });
|
||||
}
|
||||
}
|
||||
|
||||
if (showDesktopWebScrollbar) {
|
||||
streamScrollbarMetrics.onContentSizeChange(width, height);
|
||||
}
|
||||
prepareForViewportChange() {
|
||||
viewportRef.current?.prepareForViewportChange();
|
||||
},
|
||||
[
|
||||
insets.bottom,
|
||||
scrollToBottomInternal,
|
||||
showDesktopWebScrollbar,
|
||||
streamRenderStrategy,
|
||||
streamScrollbarMetrics,
|
||||
]
|
||||
);
|
||||
|
||||
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 (streamRenderStrategy.shouldAnchorBottomOnContentSizeChange()) {
|
||||
// Forward streams anchor from measurement updates in handleContentSizeChange.
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasAutoScrolledOnce.current) {
|
||||
const handle = InteractionManager.runAfterInteractions(() => {
|
||||
scrollToBottomInternal({ animated: false });
|
||||
hasAutoScrolledOnce.current = true;
|
||||
hasScrolledInitially.current = true;
|
||||
});
|
||||
return () => handle.cancel();
|
||||
}
|
||||
|
||||
if (!isNearBottomRef.current && !pendingAnchorRequestRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldAnimate = hasScrolledInitially.current;
|
||||
scheduleAutoScroll({ animated: shouldAnimate });
|
||||
hasScrolledInitially.current = true;
|
||||
}, [
|
||||
scheduleAutoScroll,
|
||||
scrollToBottomInternal,
|
||||
streamItems,
|
||||
streamRenderStrategy,
|
||||
]);
|
||||
}), []);
|
||||
|
||||
function scrollToBottom() {
|
||||
const animated = streamRenderStrategy.shouldAnimateManualScrollToBottom();
|
||||
scrollToBottomInternal({ animated });
|
||||
viewportRef.current?.scrollToBottom("jump-to-bottom");
|
||||
}
|
||||
|
||||
const flatListData = useMemo(() => {
|
||||
return orderTailForStreamRenderStrategy({
|
||||
strategy: streamRenderStrategy,
|
||||
streamItems,
|
||||
});
|
||||
}, [streamItems, streamRenderStrategy]);
|
||||
|
||||
const orderedStreamHead = useMemo(() => {
|
||||
return orderHeadForStreamRenderStrategy({
|
||||
strategy: streamRenderStrategy,
|
||||
streamHead: streamHead ?? [],
|
||||
});
|
||||
}, [streamHead, streamRenderStrategy]);
|
||||
|
||||
const tightGap = theme.spacing[1]; // 4px
|
||||
const looseGap = theme.spacing[4]; // 16px
|
||||
|
||||
const getGapBelow = useCallback(
|
||||
(item: StreamItem, index: number, items: StreamItem[]) => {
|
||||
const belowItem = getStreamNeighborItem({
|
||||
strategy: streamRenderStrategy,
|
||||
items,
|
||||
index,
|
||||
relation: "below",
|
||||
});
|
||||
if (!belowItem) {
|
||||
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;
|
||||
},
|
||||
[looseGap, streamRenderStrategy, tightGap]
|
||||
[looseGap, tightGap]
|
||||
);
|
||||
|
||||
const renderStreamItemContent = useCallback(
|
||||
(item: StreamItem, index: number, items: StreamItem[]) => {
|
||||
(
|
||||
item: StreamItem,
|
||||
index: number,
|
||||
items: StreamItem[],
|
||||
seamAboveItem: StreamItem | null = null
|
||||
) => {
|
||||
const handleInlineDetailsExpandedChange = (expanded: boolean) => {
|
||||
if (
|
||||
!streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion()
|
||||
@@ -543,12 +280,13 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
|
||||
switch (item.kind) {
|
||||
case "user_message": {
|
||||
const aboveItem = getStreamNeighborItem({
|
||||
strategy: streamRenderStrategy,
|
||||
items,
|
||||
index,
|
||||
relation: "above",
|
||||
});
|
||||
const aboveItem =
|
||||
getStreamNeighborItem({
|
||||
strategy: streamRenderStrategy,
|
||||
items,
|
||||
index,
|
||||
relation: "above",
|
||||
}) ?? seamAboveItem ?? undefined;
|
||||
const belowItem = getStreamNeighborItem({
|
||||
strategy: streamRenderStrategy,
|
||||
items,
|
||||
@@ -670,19 +408,24 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
);
|
||||
|
||||
const renderStreamItem = useCallback(
|
||||
({ item, index }: ListRenderItemInfo<StreamItem>) => {
|
||||
const content = renderStreamItemContent(item, index, flatListData);
|
||||
(
|
||||
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, flatListData);
|
||||
const nextItem = getStreamNeighborItem({
|
||||
strategy: streamRenderStrategy,
|
||||
items: flatListData,
|
||||
items,
|
||||
index,
|
||||
relation: "below",
|
||||
});
|
||||
const gapBelow = getGapBetween(item, nextItem ?? null);
|
||||
const isEndOfAssistantTurn =
|
||||
item.kind === "assistant_message" &&
|
||||
(nextItem?.kind === "user_message" ||
|
||||
@@ -690,7 +433,7 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
const getTurnContent = () =>
|
||||
collectAssistantTurnContentForStreamRenderStrategy({
|
||||
strategy: streamRenderStrategy,
|
||||
items: flatListData,
|
||||
items,
|
||||
startIndex: index,
|
||||
});
|
||||
|
||||
@@ -704,9 +447,8 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
);
|
||||
},
|
||||
[
|
||||
getGapBelow,
|
||||
getGapBetween,
|
||||
renderStreamItemContent,
|
||||
flatListData,
|
||||
agent.status,
|
||||
streamRenderStrategy,
|
||||
]
|
||||
@@ -789,115 +531,58 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
}, [agentId, pendingPermissionItems.length, streamHead, streamItems]);
|
||||
|
||||
const showWorkingIndicator = agent.status === "running";
|
||||
const showBottomBar = showWorkingIndicator;
|
||||
const usesVirtualizedList = streamRenderStrategy.shouldUseVirtualizedList();
|
||||
|
||||
const listEdgeSlotComponent = useMemo(() => {
|
||||
const hasPermissions = pendingPermissionItems.length > 0;
|
||||
const hasHeadItems = orderedStreamHead.length > 0;
|
||||
|
||||
if (!hasPermissions && !showBottomBar && !hasHeadItems) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const leftContent = showWorkingIndicator ? <WorkingIndicator /> : null;
|
||||
|
||||
return (
|
||||
<View style={stylesheet.contentWrapper}>
|
||||
<View
|
||||
style={[
|
||||
stylesheet.listHeaderContent,
|
||||
// The edge slot (header for inverted streams, footer for forward streams)
|
||||
// sits next to the newest timeline item.
|
||||
hasHeadItems ? { paddingTop: tightGap } : null,
|
||||
]}
|
||||
>
|
||||
{hasPermissions ? (
|
||||
<View style={stylesheet.permissionsContainer}>
|
||||
{pendingPermissionItems.map((permission) => (
|
||||
<PermissionRequestCard
|
||||
key={permission.key}
|
||||
permission={permission}
|
||||
client={client}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{hasHeadItems
|
||||
? orderedStreamHead.map((item, index) => {
|
||||
const rendered = renderStreamItemContent(
|
||||
item,
|
||||
index,
|
||||
orderedStreamHead
|
||||
);
|
||||
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,
|
||||
orderedStreamHead,
|
||||
renderStreamItemContent,
|
||||
showBottomBar,
|
||||
tightGap,
|
||||
]);
|
||||
|
||||
const flatListExtraData = useMemo(
|
||||
() => ({
|
||||
pendingPermissionCount: pendingPermissionItems.length,
|
||||
showWorkingIndicator,
|
||||
showBottomBar,
|
||||
}),
|
||||
[
|
||||
pendingPermissionItems.length,
|
||||
showWorkingIndicator,
|
||||
showBottomBar,
|
||||
]
|
||||
);
|
||||
|
||||
const listEdgeSlotProps = useMemo<StreamEdgeSlotProps>(() => {
|
||||
if (!listEdgeSlotComponent) {
|
||||
return {};
|
||||
}
|
||||
return getStreamEdgeSlotProps({
|
||||
strategy: streamRenderStrategy,
|
||||
component: listEdgeSlotComponent,
|
||||
gapSize: tightGap,
|
||||
});
|
||||
}, [listEdgeSlotComponent, streamRenderStrategy, tightGap]);
|
||||
|
||||
const listEmptyComponent = useMemo(() => {
|
||||
const hasPermissions = pendingPermissionItems.length > 0;
|
||||
const hasHeadItems = orderedStreamHead.length > 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}>
|
||||
@@ -905,119 +590,111 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}, [
|
||||
agent.status,
|
||||
pendingPermissionItems.length,
|
||||
orderedStreamHead,
|
||||
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;
|
||||
const listContentContainerStyle = useMemo(
|
||||
() =>
|
||||
usesVirtualizedList
|
||||
? stylesheet.listContentContainer
|
||||
: [stylesheet.listContentContainer, stylesheet.forwardListContentContainer],
|
||||
[usesVirtualizedList]
|
||||
);
|
||||
const headerEdgeContent = renderStreamEdgeComponent(
|
||||
listEdgeSlotProps.ListHeaderComponent
|
||||
);
|
||||
const footerEdgeContent = renderStreamEdgeComponent(
|
||||
listEdgeSlotProps.ListFooterComponent
|
||||
);
|
||||
const nonVirtualizedItems = useMemo(() => {
|
||||
if (flatListData.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return flatListData.map((item, index) => {
|
||||
const rendered = renderStreamItem({
|
||||
item,
|
||||
index,
|
||||
separators: NOOP_SEPARATORS,
|
||||
});
|
||||
if (!rendered) {
|
||||
return null;
|
||||
}
|
||||
return <Fragment key={item.id}>{rendered}</Fragment>;
|
||||
});
|
||||
}, [flatListData, renderStreamItem]);
|
||||
|
||||
return (
|
||||
<ToolCallSheetProvider>
|
||||
<View style={stylesheet.container}>
|
||||
<MessageOuterSpacingProvider disableOuterSpacing>
|
||||
{usesVirtualizedList ? (
|
||||
<FlatList
|
||||
ref={flatListRef}
|
||||
data={flatListData}
|
||||
renderItem={renderStreamItem}
|
||||
keyExtractor={(item) => item.id}
|
||||
testID="agent-chat-scroll"
|
||||
{...listEdgeSlotProps}
|
||||
contentContainerStyle={listContentContainerStyle}
|
||||
style={stylesheet.list}
|
||||
onLayout={handleListLayout}
|
||||
onScroll={handleScroll}
|
||||
scrollEventThrottle={16}
|
||||
onContentSizeChange={handleContentSizeChange}
|
||||
ListEmptyComponent={listEmptyComponent}
|
||||
extraData={flatListExtraData}
|
||||
maintainVisibleContentPosition={
|
||||
streamRenderStrategy.getMaintainVisibleContentPosition()
|
||||
}
|
||||
initialNumToRender={12}
|
||||
windowSize={10}
|
||||
scrollEnabled={streamScrollEnabled}
|
||||
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||
inverted={streamRenderStrategy.getFlatListInverted()}
|
||||
/>
|
||||
) : (
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
testID="agent-chat-scroll"
|
||||
contentContainerStyle={listContentContainerStyle}
|
||||
style={stylesheet.list}
|
||||
onLayout={handleListLayout}
|
||||
onScroll={handleScroll}
|
||||
scrollEventThrottle={16}
|
||||
onContentSizeChange={handleContentSizeChange}
|
||||
scrollEnabled={streamScrollEnabled}
|
||||
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||
>
|
||||
{headerEdgeContent ? (
|
||||
<View style={listEdgeSlotProps.ListHeaderComponentStyle}>
|
||||
{headerEdgeContent}
|
||||
</View>
|
||||
) : null}
|
||||
{nonVirtualizedItems}
|
||||
{flatListData.length === 0 ? listEmptyComponent : null}
|
||||
{footerEdgeContent ? (
|
||||
<View style={listEdgeSlotProps.ListFooterComponentStyle}>
|
||||
{footerEdgeContent}
|
||||
</View>
|
||||
) : null}
|
||||
<View ref={bottomAnchorRef} collapsable={false} />
|
||||
</ScrollView>
|
||||
)}
|
||||
{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>
|
||||
<WebDesktopScrollbarOverlay
|
||||
enabled={showDesktopWebScrollbar}
|
||||
metrics={streamScrollbarMetrics}
|
||||
inverted={streamRenderStrategy.getOverlayScrollbarInverted()}
|
||||
onScrollToOffset={(nextOffset) => {
|
||||
streamRenderStrategy.scrollToOffset({
|
||||
refs: streamRenderRefs,
|
||||
offset: nextOffset,
|
||||
animated: false,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Scroll to bottom button */}
|
||||
{!isNearBottom && (
|
||||
<Animated.View
|
||||
style={stylesheet.scrollToBottomContainer}
|
||||
@@ -1028,6 +705,9 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
<Pressable
|
||||
style={stylesheet.scrollToBottomButton}
|
||||
onPress={scrollToBottom}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Scroll to bottom"
|
||||
testID="scroll-to-bottom-button"
|
||||
>
|
||||
<ChevronDown
|
||||
size={24}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
View,
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import { memo, useEffect, useMemo, useRef, type ReactNode } from "react";
|
||||
import { Plus, Settings } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useCommandCenter } from "@/hooks/use-command-center";
|
||||
@@ -20,6 +21,37 @@ function agentKey(agent: Pick<AggregatedAgent, "serverId" | "id">): string {
|
||||
return `${agent.serverId}:${agent.id}`;
|
||||
}
|
||||
|
||||
type CommandCenterRowProps = {
|
||||
active: boolean;
|
||||
children: ReactNode;
|
||||
onPress: () => void;
|
||||
registerRow: (el: View | null) => void;
|
||||
};
|
||||
|
||||
const CommandCenterRow = memo(function CommandCenterRow({
|
||||
active,
|
||||
children,
|
||||
onPress,
|
||||
registerRow,
|
||||
}: CommandCenterRowProps) {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
ref={registerRow}
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.row,
|
||||
(hovered || pressed || active) && {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
]}
|
||||
onPress={onPress}
|
||||
>
|
||||
{children}
|
||||
</Pressable>
|
||||
);
|
||||
});
|
||||
|
||||
export function CommandCenter() {
|
||||
const { theme } = useUnistyles();
|
||||
const {
|
||||
@@ -33,10 +65,52 @@ export function CommandCenter() {
|
||||
handleSelectItem,
|
||||
} = useCommandCenter();
|
||||
|
||||
const rowRefs = useRef<Map<number, View>>(new Map());
|
||||
const resultsRef = useRef<ScrollView>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const row = rowRefs.current.get(activeIndex);
|
||||
if (!row || typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
const scrollNode =
|
||||
(resultsRef.current as
|
||||
| (ScrollView & {
|
||||
getScrollableNode?: () => HTMLElement | null;
|
||||
})
|
||||
| null)?.getScrollableNode?.() ?? null;
|
||||
const rowEl = row as unknown as HTMLElement;
|
||||
|
||||
if (!scrollNode) {
|
||||
rowEl.scrollIntoView?.({ block: "nearest" });
|
||||
return;
|
||||
}
|
||||
|
||||
const rowTop = rowEl.offsetTop;
|
||||
const rowBottom = rowTop + rowEl.offsetHeight;
|
||||
const visibleTop = scrollNode.scrollTop;
|
||||
const visibleBottom = visibleTop + scrollNode.clientHeight;
|
||||
|
||||
if (rowTop < visibleTop) {
|
||||
scrollNode.scrollTop = rowTop;
|
||||
return;
|
||||
}
|
||||
|
||||
if (rowBottom > visibleBottom) {
|
||||
scrollNode.scrollTop = rowBottom - scrollNode.clientHeight;
|
||||
}
|
||||
}, [activeIndex]);
|
||||
|
||||
if (Platform.OS !== "web") return null;
|
||||
|
||||
const actionItems = items.filter((item) => item.kind === "action");
|
||||
const agentItems = items.filter((item) => item.kind === "agent");
|
||||
const actionItems = useMemo(
|
||||
() => items.filter((item) => item.kind === "action"),
|
||||
[items]
|
||||
);
|
||||
const agentItems = useMemo(
|
||||
() => items.filter((item) => item.kind === "agent"),
|
||||
[items]
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -71,6 +145,7 @@ export function CommandCenter() {
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
ref={resultsRef}
|
||||
style={styles.results}
|
||||
contentContainerStyle={styles.resultsContent}
|
||||
keyboardShouldPersistTaps="always"
|
||||
@@ -105,14 +180,13 @@ export function CommandCenter() {
|
||||
/>
|
||||
) : null;
|
||||
return (
|
||||
<Pressable
|
||||
<CommandCenterRow
|
||||
key={`action:${action.id}`}
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.row,
|
||||
(hovered || pressed || active) && {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
]}
|
||||
registerRow={(el: View | null) => {
|
||||
if (el) rowRefs.current.set(index, el);
|
||||
else rowRefs.current.delete(index);
|
||||
}}
|
||||
active={active}
|
||||
onPress={() => handleSelectItem(item)}
|
||||
>
|
||||
<View style={styles.rowContent}>
|
||||
@@ -133,7 +207,7 @@ export function CommandCenter() {
|
||||
<Shortcut keys={action.shortcutKeys} style={styles.rowShortcut} />
|
||||
) : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
</CommandCenterRow>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
@@ -154,14 +228,13 @@ export function CommandCenter() {
|
||||
const active = rowIndex === activeIndex;
|
||||
const agent = item.agent;
|
||||
return (
|
||||
<Pressable
|
||||
<CommandCenterRow
|
||||
key={agentKey(agent)}
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.row,
|
||||
(hovered || pressed || active) && {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
]}
|
||||
registerRow={(el: View | null) => {
|
||||
if (el) rowRefs.current.set(rowIndex, el);
|
||||
else rowRefs.current.delete(rowIndex);
|
||||
}}
|
||||
active={active}
|
||||
onPress={() => handleSelectItem(item)}
|
||||
>
|
||||
<View style={styles.rowContent}>
|
||||
@@ -184,12 +257,12 @@ export function CommandCenter() {
|
||||
style={[styles.subtitle, { color: theme.colors.foregroundMuted }]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{agent.serverLabel} · {shortenPath(agent.cwd)} · {formatTimeAgo(agent.lastActivityAt)}
|
||||
{shortenPath(agent.cwd)} · {formatTimeAgo(agent.lastActivityAt)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
</CommandCenterRow>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
@@ -277,6 +350,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
justifyContent: "center",
|
||||
},
|
||||
textContent: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
gap: 2,
|
||||
},
|
||||
rowShortcut: {
|
||||
|
||||
@@ -412,6 +412,7 @@ function SidebarContent({
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
cwd={workspaceRoot}
|
||||
hideHeaderRow={!isMobile}
|
||||
/>
|
||||
)}
|
||||
{resolvedTab === "files" && (
|
||||
|
||||
@@ -41,10 +41,12 @@ function FilePreviewBody({
|
||||
preview,
|
||||
isLoading,
|
||||
showDesktopWebScrollbar,
|
||||
isMobile,
|
||||
}: {
|
||||
preview: ExplorerFile | null;
|
||||
isLoading: boolean;
|
||||
showDesktopWebScrollbar: boolean;
|
||||
isMobile: boolean;
|
||||
}) {
|
||||
const enablePreviewDesktopScrollbar = showDesktopWebScrollbar;
|
||||
const previewScrollRef = useRef<RNScrollView>(null);
|
||||
@@ -101,14 +103,20 @@ function FilePreviewBody({
|
||||
scrollEventThrottle={enablePreviewDesktopScrollbar ? 16 : undefined}
|
||||
showsVerticalScrollIndicator={!enablePreviewDesktopScrollbar}
|
||||
>
|
||||
<RNScrollView
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
contentContainerStyle={styles.previewCodeScrollContent}
|
||||
>
|
||||
<Text style={styles.codeText}>{preview.content}</Text>
|
||||
</RNScrollView>
|
||||
{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}
|
||||
@@ -211,6 +219,7 @@ export function FilePane({
|
||||
preview={query.data?.file ?? null}
|
||||
isLoading={query.isFetching}
|
||||
showDesktopWebScrollbar={showDesktopWebScrollbar}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
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);
|
||||
@@ -466,13 +439,14 @@ interface GitDiffPaneProps {
|
||||
serverId: 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, workspaceId, cwd }: GitDiffPaneProps) {
|
||||
export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDiffPaneProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
@@ -1219,119 +1193,22 @@ export function GitDiffPane({ serverId, workspaceId, 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>
|
||||
</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}>
|
||||
@@ -1438,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,
|
||||
@@ -1451,6 +1322,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexShrink: 1,
|
||||
},
|
||||
diffStatusContainer: {
|
||||
paddingVertical: 1.5,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
@@ -1496,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,4 +1,4 @@
|
||||
import type { PropsWithChildren, ReactElement } from "react";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import {
|
||||
Platform,
|
||||
Text,
|
||||
@@ -12,6 +12,21 @@ 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,
|
||||
@@ -22,16 +37,7 @@ 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`
|
||||
@@ -53,7 +59,10 @@ export function HeaderToggleButton({
|
||||
}}
|
||||
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}>
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import Animated, {
|
||||
} 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 { 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'
|
||||
@@ -25,11 +25,11 @@ import { formatConnectionStatus } from '@/utils/daemons'
|
||||
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from '@/constants/layout'
|
||||
import {
|
||||
buildHostAgentsRoute,
|
||||
buildHostNewAgentRoute,
|
||||
buildHostSettingsRoute,
|
||||
mapPathnameToServer,
|
||||
parseServerIdFromPathname,
|
||||
} from '@/utils/host-routes'
|
||||
import { useKeyboardShortcutsStore } from '@/stores/keyboard-shortcuts-store'
|
||||
|
||||
const DESKTOP_SIDEBAR_WIDTH = 320
|
||||
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__)
|
||||
@@ -158,23 +158,16 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
||||
closeToAgent()
|
||||
}, [closeToAgent])
|
||||
|
||||
const handleCreateAgentClean = useCallback(() => {
|
||||
if (!activeServerId) {
|
||||
return
|
||||
}
|
||||
router.push(buildHostNewAgentRoute(activeServerId) as any)
|
||||
}, [activeServerId])
|
||||
const setProjectPickerOpen = useKeyboardShortcutsStore((s) => s.setProjectPickerOpen)
|
||||
|
||||
// Mobile: close sidebar and navigate
|
||||
const handleCreateAgentCleanMobile = useCallback(() => {
|
||||
const handleOpenProjectMobile = useCallback(() => {
|
||||
closeToAgent()
|
||||
handleCreateAgentClean()
|
||||
}, [closeToAgent, handleCreateAgentClean])
|
||||
setProjectPickerOpen(true)
|
||||
}, [closeToAgent, setProjectPickerOpen])
|
||||
|
||||
// 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(() => {
|
||||
@@ -332,7 +325,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
||||
<Pressable
|
||||
style={styles.newAgentButton}
|
||||
testID="sidebar-new-agent"
|
||||
onPress={handleCreateAgentCleanMobile}
|
||||
onPress={handleOpenProjectMobile}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<>
|
||||
@@ -346,7 +339,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
||||
hovered && styles.newAgentButtonTextHovered,
|
||||
]}
|
||||
>
|
||||
New agent
|
||||
Add project
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
@@ -396,12 +389,12 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
||||
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}
|
||||
/>
|
||||
@@ -459,7 +452,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
||||
<Pressable
|
||||
style={styles.newAgentButton}
|
||||
testID="sidebar-new-agent"
|
||||
onPress={handleCreateAgentCleanDesktop}
|
||||
onPress={handleOpenProjectDesktop}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<>
|
||||
@@ -470,7 +463,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
||||
<Text
|
||||
style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}
|
||||
>
|
||||
New agent
|
||||
Add project
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
@@ -516,12 +509,12 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
||||
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}
|
||||
/>
|
||||
|
||||
@@ -84,6 +84,7 @@ export interface MessageInputProps {
|
||||
/** 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 {
|
||||
@@ -100,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 & {
|
||||
@@ -111,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) {
|
||||
@@ -153,6 +211,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
onKeyPress: onKeyPressCallback,
|
||||
onSelectionChange: onSelectionChangeCallback,
|
||||
onFocusChange,
|
||||
onHeightChange,
|
||||
},
|
||||
ref
|
||||
) {
|
||||
@@ -162,6 +221,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
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
|
||||
)
|
||||
@@ -473,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
|
||||
@@ -487,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 {
|
||||
@@ -505,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
|
||||
@@ -558,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
|
||||
@@ -595,24 +754,51 @@ 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 })
|
||||
}
|
||||
|
||||
@@ -668,14 +854,20 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
(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">
|
||||
@@ -712,7 +904,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
value={value}
|
||||
onChangeText={handleInputChange}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={theme.colors.mutedForeground}
|
||||
placeholderTextColor={theme.colors.surface4}
|
||||
accessibilityLabel="Message agent..."
|
||||
onFocus={() => {
|
||||
isInputFocusedRef.current = true
|
||||
onFocusChange?.(true)
|
||||
|
||||
@@ -646,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,
|
||||
@@ -1289,6 +1292,8 @@ 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 =
|
||||
@@ -1479,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
|
||||
@@ -1493,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
364
packages/app/src/components/stream-strategy-native.tsx
Normal file
364
packages/app/src/components/stream-strategy-native.tsx
Normal file
@@ -0,0 +1,364 @@
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
FlatList,
|
||||
Keyboard,
|
||||
type LayoutChangeEvent,
|
||||
type ListRenderItemInfo,
|
||||
type NativeScrollEvent,
|
||||
type NativeSyntheticEvent,
|
||||
} from "react-native";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { useBottomAnchorController } from "./use-bottom-anchor-controller";
|
||||
import type {
|
||||
StreamRenderInput,
|
||||
StreamStrategy,
|
||||
StreamViewportHandle,
|
||||
} from "./stream-strategy";
|
||||
import {
|
||||
createStreamStrategy,
|
||||
isNearBottomForStreamRenderStrategy,
|
||||
resolveBottomAnchorTransportBehavior,
|
||||
} from "./stream-strategy";
|
||||
|
||||
const DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION = Object.freeze({
|
||||
minIndexForVisible: 0,
|
||||
autoscrollToTopThreshold: 0,
|
||||
});
|
||||
|
||||
function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrategy }) {
|
||||
const {
|
||||
agentId,
|
||||
segments,
|
||||
boundary,
|
||||
renderers,
|
||||
listEmptyComponent,
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest,
|
||||
isAuthoritativeHistoryReady,
|
||||
onNearBottomChange,
|
||||
scrollEnabled,
|
||||
listStyle,
|
||||
baseListContentContainerStyle,
|
||||
strategy,
|
||||
} = props;
|
||||
const flatListRef = useRef<FlatList<StreamItem>>(null);
|
||||
const streamViewportMetricsRef = useRef({
|
||||
containerKey: "native-virtualized",
|
||||
contentHeight: 0,
|
||||
viewportWidth: 0,
|
||||
viewportHeight: 0,
|
||||
offsetY: 0,
|
||||
viewportMeasuredForKey: null as string | null,
|
||||
contentMeasuredForKey: null as string | null,
|
||||
});
|
||||
const scrollOffsetYRef = useRef(0);
|
||||
const programmaticScrollEventBudgetRef = useRef(0);
|
||||
const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false);
|
||||
const nativeViewportSettlingFrameIdRef = useRef<number | null>(null);
|
||||
|
||||
const historyRows = useMemo(() => {
|
||||
if (segments.historyVirtualized.length === 0) {
|
||||
return segments.historyMounted;
|
||||
}
|
||||
return [...segments.historyVirtualized, ...segments.historyMounted];
|
||||
}, [segments.historyMounted, segments.historyVirtualized]);
|
||||
|
||||
const clearNativeViewportSettling = useCallback(() => {
|
||||
if (nativeViewportSettlingFrameIdRef.current !== null) {
|
||||
cancelAnimationFrame(nativeViewportSettlingFrameIdRef.current);
|
||||
nativeViewportSettlingFrameIdRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const markNativeViewportSettling = useCallback(() => {
|
||||
clearNativeViewportSettling();
|
||||
setIsNativeViewportSettling(true);
|
||||
let remainingFrames = 4;
|
||||
const tick = () => {
|
||||
if (remainingFrames <= 0) {
|
||||
nativeViewportSettlingFrameIdRef.current = null;
|
||||
setIsNativeViewportSettling(false);
|
||||
return;
|
||||
}
|
||||
remainingFrames -= 1;
|
||||
nativeViewportSettlingFrameIdRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
nativeViewportSettlingFrameIdRef.current = requestAnimationFrame(tick);
|
||||
}, [clearNativeViewportSettling]);
|
||||
|
||||
const bottomAnchorTransportBehavior = useMemo(
|
||||
() =>
|
||||
resolveBottomAnchorTransportBehavior({
|
||||
strategy,
|
||||
isViewportSettling: isNativeViewportSettling,
|
||||
}),
|
||||
[isNativeViewportSettling, strategy]
|
||||
);
|
||||
|
||||
const scrollToBottom = useCallback(
|
||||
(animated: boolean) => {
|
||||
programmaticScrollEventBudgetRef.current = 3;
|
||||
flatListRef.current?.scrollToOffset({
|
||||
offset: 0,
|
||||
animated,
|
||||
});
|
||||
scrollOffsetYRef.current = 0;
|
||||
streamViewportMetricsRef.current = {
|
||||
...streamViewportMetricsRef.current,
|
||||
offsetY: 0,
|
||||
};
|
||||
onNearBottomChange(true);
|
||||
},
|
||||
[onNearBottomChange]
|
||||
);
|
||||
|
||||
const bottomAnchorController = useBottomAnchorController({
|
||||
agentId,
|
||||
routeRequest: routeBottomAnchorRequest,
|
||||
isAuthoritativeHistoryReady,
|
||||
renderStrategy: "inverted-stream",
|
||||
transportBehavior: bottomAnchorTransportBehavior,
|
||||
getMeasurementState: () => streamViewportMetricsRef.current,
|
||||
isNearBottom: () => {
|
||||
const metrics = streamViewportMetricsRef.current;
|
||||
return isNearBottomForStreamRenderStrategy({
|
||||
strategy,
|
||||
offsetY: metrics.offsetY,
|
||||
threshold: 32,
|
||||
contentHeight: metrics.contentHeight,
|
||||
viewportHeight: metrics.viewportHeight,
|
||||
});
|
||||
},
|
||||
scrollToBottom,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
streamViewportMetricsRef.current = {
|
||||
containerKey: "native-virtualized",
|
||||
contentHeight: 0,
|
||||
viewportWidth: 0,
|
||||
viewportHeight: 0,
|
||||
offsetY: 0,
|
||||
viewportMeasuredForKey: null,
|
||||
contentMeasuredForKey: null,
|
||||
};
|
||||
scrollOffsetYRef.current = 0;
|
||||
clearNativeViewportSettling();
|
||||
setIsNativeViewportSettling(false);
|
||||
}, [agentId, clearNativeViewportSettling]);
|
||||
|
||||
useEffect(() => {
|
||||
const keyboardEvents = [
|
||||
"keyboardWillShow",
|
||||
"keyboardWillHide",
|
||||
"keyboardDidShow",
|
||||
"keyboardDidHide",
|
||||
"keyboardWillChangeFrame",
|
||||
"keyboardDidChangeFrame",
|
||||
] as const;
|
||||
const subscriptions = keyboardEvents.map((eventName) =>
|
||||
Keyboard.addListener(eventName, () => {
|
||||
markNativeViewportSettling();
|
||||
})
|
||||
);
|
||||
return () => {
|
||||
for (const subscription of subscriptions) {
|
||||
subscription.remove();
|
||||
}
|
||||
clearNativeViewportSettling();
|
||||
};
|
||||
}, [clearNativeViewportSettling, markNativeViewportSettling]);
|
||||
|
||||
useEffect(() => {
|
||||
bottomAnchorController.prepareForStickyContentChange();
|
||||
}, [bottomAnchorController, historyRows, segments.liveHead]);
|
||||
|
||||
useEffect(() => {
|
||||
const handle: StreamViewportHandle = {
|
||||
scrollToBottom: (reason = "jump-to-bottom") => {
|
||||
bottomAnchorController.requestLocalAnchor({
|
||||
agentId,
|
||||
reason,
|
||||
});
|
||||
},
|
||||
prepareForViewportChange: () => {
|
||||
bottomAnchorController.prepareForStickyViewportChange();
|
||||
markNativeViewportSettling();
|
||||
},
|
||||
};
|
||||
viewportRef.current = handle;
|
||||
return () => {
|
||||
if (viewportRef.current === handle) {
|
||||
viewportRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [agentId, bottomAnchorController, markNativeViewportSettling, viewportRef]);
|
||||
|
||||
const handleScroll = useCallback(
|
||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
|
||||
const previousOffsetY = scrollOffsetYRef.current;
|
||||
scrollOffsetYRef.current = contentOffset.y;
|
||||
streamViewportMetricsRef.current = {
|
||||
contentHeight: Math.max(0, contentSize.height),
|
||||
viewportWidth: Math.max(0, layoutMeasurement.width),
|
||||
viewportHeight: Math.max(0, layoutMeasurement.height),
|
||||
containerKey: "native-virtualized",
|
||||
offsetY: contentOffset.y,
|
||||
viewportMeasuredForKey: "native-virtualized",
|
||||
contentMeasuredForKey: "native-virtualized",
|
||||
};
|
||||
|
||||
const nearBottom = isNearBottomForStreamRenderStrategy({
|
||||
strategy,
|
||||
offsetY: contentOffset.y,
|
||||
threshold: 32,
|
||||
contentHeight: streamViewportMetricsRef.current.contentHeight,
|
||||
viewportHeight: streamViewportMetricsRef.current.viewportHeight,
|
||||
});
|
||||
onNearBottomChange(nearBottom);
|
||||
|
||||
if (programmaticScrollEventBudgetRef.current > 0 && contentOffset.y <= 8) {
|
||||
programmaticScrollEventBudgetRef.current -= 1;
|
||||
} else {
|
||||
programmaticScrollEventBudgetRef.current = 0;
|
||||
bottomAnchorController.handleScrollNearBottomChange({
|
||||
nextIsNearBottom: nearBottom,
|
||||
scrollDelta: contentOffset.y - previousOffsetY,
|
||||
});
|
||||
}
|
||||
},
|
||||
[bottomAnchorController, onNearBottomChange, strategy]
|
||||
);
|
||||
|
||||
const handleListLayout = useCallback(
|
||||
(event: LayoutChangeEvent) => {
|
||||
const previousViewportWidth = streamViewportMetricsRef.current.viewportWidth;
|
||||
const previousViewportHeight = streamViewportMetricsRef.current.viewportHeight;
|
||||
const viewportWidth = Math.max(0, event.nativeEvent.layout.width);
|
||||
const viewportHeight = Math.max(0, event.nativeEvent.layout.height);
|
||||
const viewportChanged =
|
||||
(previousViewportWidth > 0 && previousViewportWidth !== viewportWidth) ||
|
||||
(previousViewportHeight > 0 && previousViewportHeight !== viewportHeight);
|
||||
streamViewportMetricsRef.current = {
|
||||
...streamViewportMetricsRef.current,
|
||||
containerKey: "native-virtualized",
|
||||
viewportWidth,
|
||||
viewportHeight,
|
||||
viewportMeasuredForKey: "native-virtualized",
|
||||
};
|
||||
if (viewportChanged) {
|
||||
markNativeViewportSettling();
|
||||
}
|
||||
bottomAnchorController.handleViewportMetricsChange({
|
||||
previousViewportWidth,
|
||||
viewportWidth,
|
||||
previousViewportHeight,
|
||||
viewportHeight,
|
||||
});
|
||||
},
|
||||
[bottomAnchorController, markNativeViewportSettling]
|
||||
);
|
||||
|
||||
const handleContentSizeChange = useCallback(
|
||||
(_width: number, height: number) => {
|
||||
const previousContentHeight = streamViewportMetricsRef.current.contentHeight;
|
||||
const nextContentHeight = Math.max(0, height);
|
||||
streamViewportMetricsRef.current = {
|
||||
...streamViewportMetricsRef.current,
|
||||
containerKey: "native-virtualized",
|
||||
contentHeight: nextContentHeight,
|
||||
contentMeasuredForKey: "native-virtualized",
|
||||
};
|
||||
bottomAnchorController.handleContentSizeChange({
|
||||
previousContentHeight,
|
||||
contentHeight: nextContentHeight,
|
||||
});
|
||||
},
|
||||
[bottomAnchorController]
|
||||
);
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item, index }: ListRenderItemInfo<StreamItem>) => {
|
||||
const rendered = renderers.renderHistoryMountedRow(item, index, historyRows);
|
||||
return rendered ? <Fragment>{rendered}</Fragment> : null;
|
||||
},
|
||||
[historyRows, renderers]
|
||||
);
|
||||
|
||||
const liveHeaderContent = useMemo(() => {
|
||||
const liveHeadRows = segments.liveHead.map((item, index) => (
|
||||
<Fragment key={item.id}>
|
||||
{renderers.renderLiveHeadRow(item, index, segments.liveHead)}
|
||||
</Fragment>
|
||||
));
|
||||
const liveAuxiliary = renderers.renderLiveAuxiliary();
|
||||
if (
|
||||
liveHeadRows.length === 0 &&
|
||||
!liveAuxiliary &&
|
||||
!boundary.hasMountedHistory &&
|
||||
!boundary.hasVirtualizedHistory
|
||||
) {
|
||||
return listEmptyComponent ? <Fragment>{listEmptyComponent}</Fragment> : null;
|
||||
}
|
||||
return (
|
||||
<Fragment>
|
||||
{liveHeadRows}
|
||||
{liveAuxiliary}
|
||||
</Fragment>
|
||||
);
|
||||
}, [boundary, listEmptyComponent, renderers, segments.liveHead]);
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
ref={flatListRef}
|
||||
data={historyRows}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={(item) => item.id}
|
||||
testID="agent-chat-scroll"
|
||||
nativeID="agent-chat-scroll-native-virtualized"
|
||||
ListHeaderComponent={liveHeaderContent ? () => liveHeaderContent : undefined}
|
||||
contentContainerStyle={baseListContentContainerStyle}
|
||||
style={listStyle}
|
||||
onLayout={handleListLayout}
|
||||
onScroll={handleScroll}
|
||||
scrollEventThrottle={16}
|
||||
onContentSizeChange={handleContentSizeChange}
|
||||
maintainVisibleContentPosition={DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION}
|
||||
initialNumToRender={12}
|
||||
windowSize={10}
|
||||
scrollEnabled={scrollEnabled}
|
||||
showsVerticalScrollIndicator
|
||||
inverted
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function createNativeStreamStrategy(): StreamStrategy {
|
||||
const strategy = createStreamStrategy({
|
||||
render: (renderInput) => (
|
||||
<NativeStreamViewport
|
||||
{...renderInput}
|
||||
strategy={strategy}
|
||||
/>
|
||||
),
|
||||
orderTailReverse: true,
|
||||
orderHeadReverse: true,
|
||||
assistantTurnTraversalStep: 1,
|
||||
edgeSlot: "header",
|
||||
flatListInverted: true,
|
||||
overlayScrollbarInverted: true,
|
||||
maintainVisibleContentPosition: DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION,
|
||||
bottomAnchorTransportBehavior: {
|
||||
verificationDelayFrames: 2,
|
||||
verificationRetryMode: "recheck",
|
||||
},
|
||||
disableParentScrollOnInlineDetailsExpansion: false,
|
||||
anchorBottomOnContentSizeChange: false,
|
||||
animateManualScrollToBottom: true,
|
||||
useVirtualizedList: true,
|
||||
isNearBottom: (input) => input.offsetY <= input.threshold,
|
||||
getBottomOffset: () => 0,
|
||||
});
|
||||
return strategy;
|
||||
}
|
||||
812
packages/app/src/components/stream-strategy-web.tsx
Normal file
812
packages/app/src/components/stream-strategy-web.tsx
Normal file
@@ -0,0 +1,812 @@
|
||||
import {
|
||||
Fragment,
|
||||
type CSSProperties,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { measureElement as measureVirtualElement, useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { estimateStreamItemHeight } from './agent-stream-web-virtualization'
|
||||
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from './stream-strategy'
|
||||
import { createStreamStrategy } from './stream-strategy'
|
||||
|
||||
type CreateWebStreamStrategyInput = {
|
||||
isMobileBreakpoint: boolean
|
||||
}
|
||||
|
||||
type ScrollBehaviorLike = 'auto' | 'smooth'
|
||||
|
||||
const WEB_BOTTOM_SETTLE_TIMEOUT_MS = 200
|
||||
const USER_SCROLL_DELTA_EPSILON = 1
|
||||
const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64
|
||||
const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1
|
||||
const WEB_STREAM_SCROLLBAR_STYLE_ID = 'web-stream-viewport-scrollbar-style'
|
||||
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__)
|
||||
const WEB_STREAM_SCROLLBAR_STYLE = `
|
||||
#agent-chat-scroll-web-dom-scroll,
|
||||
#agent-chat-scroll-web-dom-virtualized {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
#agent-chat-scroll-web-dom-scroll::-webkit-scrollbar,
|
||||
#agent-chat-scroll-web-dom-virtualized::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
`
|
||||
|
||||
function logWebStickyBottom(event: string, details: Record<string, unknown>): void {
|
||||
if (!IS_DEV) {
|
||||
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 isScrollContainerNearBottom(
|
||||
scrollContainer: Pick<HTMLElement, 'scrollTop' | 'clientHeight' | 'scrollHeight'>,
|
||||
thresholdPx = AUTO_SCROLL_BOTTOM_THRESHOLD_PX
|
||||
): boolean {
|
||||
const threshold = Number.isFinite(thresholdPx)
|
||||
? Math.max(0, thresholdPx)
|
||||
: AUTO_SCROLL_BOTTOM_THRESHOLD_PX
|
||||
const { scrollTop, clientHeight, scrollHeight } = scrollContainer
|
||||
if (![scrollTop, clientHeight, scrollHeight].every(Number.isFinite)) {
|
||||
return true
|
||||
}
|
||||
const distanceFromBottom = scrollHeight - clientHeight - scrollTop
|
||||
return distanceFromBottom <= threshold
|
||||
}
|
||||
|
||||
function isScrollContainerAtBottom(
|
||||
scrollContainer: Pick<HTMLElement, 'scrollTop' | 'clientHeight' | 'scrollHeight'>
|
||||
): boolean {
|
||||
return isScrollContainerNearBottom(scrollContainer, AUTO_SCROLL_RESUME_THRESHOLD_PX)
|
||||
}
|
||||
|
||||
function scrollElementToBottom(
|
||||
scrollContainer: HTMLElement,
|
||||
behavior: ScrollBehaviorLike = 'auto'
|
||||
): void {
|
||||
scrollContainer.scrollTo({
|
||||
top: scrollContainer.scrollHeight,
|
||||
behavior,
|
||||
})
|
||||
}
|
||||
|
||||
function syncNearBottom(
|
||||
scrollContainer: HTMLElement | null,
|
||||
onNearBottomChange: (value: boolean) => void
|
||||
): boolean {
|
||||
if (!scrollContainer) {
|
||||
onNearBottomChange(true)
|
||||
return true
|
||||
}
|
||||
const nextValue = isScrollContainerNearBottom(scrollContainer)
|
||||
onNearBottomChange(nextValue)
|
||||
return nextValue
|
||||
}
|
||||
|
||||
function getScrollContainerDistanceFromBottom(
|
||||
scrollContainer: Pick<HTMLElement, 'scrollTop' | 'clientHeight' | 'scrollHeight'>
|
||||
): number {
|
||||
return scrollContainer.scrollHeight - scrollContainer.clientHeight - scrollContainer.scrollTop
|
||||
}
|
||||
|
||||
function isScrollContainerOverscrolledPastBottom(
|
||||
scrollContainer: Pick<HTMLElement, 'scrollTop' | 'clientHeight' | 'scrollHeight'>
|
||||
): boolean {
|
||||
return getScrollContainerDistanceFromBottom(scrollContainer) < 0
|
||||
}
|
||||
|
||||
function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: boolean }) {
|
||||
const {
|
||||
segments,
|
||||
boundary,
|
||||
renderers,
|
||||
listEmptyComponent,
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest,
|
||||
isAuthoritativeHistoryReady,
|
||||
onNearBottomChange,
|
||||
scrollEnabled,
|
||||
isMobileBreakpoint,
|
||||
} = props
|
||||
const { WebDesktopScrollbarOverlay, useWebDesktopScrollbarMetrics } =
|
||||
require('./web-desktop-scrollbar') as typeof import('./web-desktop-scrollbar')
|
||||
const scrollContainerRef = useRef<HTMLElement | null>(null)
|
||||
const contentRef = useRef<HTMLElement | null>(null)
|
||||
const [followOutput, setFollowOutputr] = useState(true)
|
||||
const setFollowOutput = (value: boolean) => {
|
||||
setFollowOutputr(value)
|
||||
return value
|
||||
}
|
||||
const followOutputRef = useRef(followOutput)
|
||||
const lastKnownScrollTopRef = useRef(0)
|
||||
const lastLoggedMetricsRef = useRef<{
|
||||
scrollTop: number
|
||||
clientWidth: number
|
||||
clientHeight: number
|
||||
scrollWidth: number
|
||||
scrollHeight: number
|
||||
} | null>(null)
|
||||
const pendingUserScrollUpIntentRef = useRef(false)
|
||||
const isPointerScrollActiveRef = useRef(false)
|
||||
const lastTouchClientYRef = useRef<number | null>(null)
|
||||
const pendingAutoScrollFrameRef = useRef<number | null>(null)
|
||||
const pendingAutoScrollTimeoutRef = useRef<number | null>(null)
|
||||
const streamScrollbarMetrics = useWebDesktopScrollbarMetrics()
|
||||
const showDesktopWebScrollbar = !isMobileBreakpoint
|
||||
const shouldUseVirtualizer = segments.historyVirtualized.length > 0
|
||||
const {
|
||||
renderHistoryVirtualizedRow,
|
||||
renderHistoryMountedRow,
|
||||
renderLiveHeadRow,
|
||||
renderLiveAuxiliary,
|
||||
} = renderers
|
||||
|
||||
followOutputRef.current = followOutput
|
||||
|
||||
const activationKey = routeBottomAnchorRequest?.requestKey ?? props.agentId
|
||||
const isActivationReady = routeBottomAnchorRequest === null || isAuthoritativeHistoryReady
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: segments.historyVirtualized.length,
|
||||
getScrollElement: () => scrollContainerRef.current,
|
||||
getItemKey: (index: number) => segments.historyVirtualized[index]?.id ?? index,
|
||||
estimateSize: (index: number) => {
|
||||
const row = segments.historyVirtualized[index]
|
||||
return row ? estimateStreamItemHeight(row) : 120
|
||||
},
|
||||
measureElement: measureVirtualElement,
|
||||
useAnimationFrameWithResizeObserver: true,
|
||||
overscan: 8,
|
||||
})
|
||||
useEffect(() => {
|
||||
rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (_item, _delta, instance) => {
|
||||
const viewportHeight = instance.scrollRect?.height ?? 0
|
||||
const scrollOffset = instance.scrollOffset ?? 0
|
||||
const remainingDistance = instance.getTotalSize() - (scrollOffset + viewportHeight)
|
||||
logWebStickyBottom('virtualizer_item_size_change', {
|
||||
agentId: props.agentId,
|
||||
delta: _delta,
|
||||
itemIndex: _item.index,
|
||||
itemStart: _item.start,
|
||||
itemSize: _item.size,
|
||||
viewportHeight,
|
||||
scrollOffset,
|
||||
totalSize: instance.getTotalSize(),
|
||||
remainingDistance,
|
||||
})
|
||||
return remainingDistance > AUTO_SCROLL_BOTTOM_THRESHOLD_PX
|
||||
}
|
||||
return () => {
|
||||
rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = undefined
|
||||
}
|
||||
}, [rowVirtualizer])
|
||||
const virtualRows = rowVirtualizer.getVirtualItems()
|
||||
const virtualTotalSize = rowVirtualizer.getTotalSize()
|
||||
|
||||
const cancelPendingStickToBottom = useCallback(() => {
|
||||
const pendingFrame = pendingAutoScrollFrameRef.current
|
||||
if (pendingFrame !== null) {
|
||||
pendingAutoScrollFrameRef.current = null
|
||||
window.cancelAnimationFrame(pendingFrame)
|
||||
}
|
||||
const pendingTimeout = pendingAutoScrollTimeoutRef.current
|
||||
if (pendingTimeout !== null) {
|
||||
pendingAutoScrollTimeoutRef.current = null
|
||||
window.clearTimeout(pendingTimeout)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const scrollMessagesToBottom = useCallback(
|
||||
(behavior: ScrollBehaviorLike = 'auto') => {
|
||||
const scrollContainer = scrollContainerRef.current
|
||||
if (!scrollContainer) {
|
||||
return
|
||||
}
|
||||
if (isScrollContainerOverscrolledPastBottom(scrollContainer)) {
|
||||
return
|
||||
}
|
||||
logWebStickyBottom('viewport_scroll_to_bottom', {
|
||||
agentId: props.agentId,
|
||||
behavior,
|
||||
followOutput: followOutputRef.current,
|
||||
scrollTop: scrollContainer.scrollTop,
|
||||
clientWidth: scrollContainer.clientWidth,
|
||||
clientHeight: scrollContainer.clientHeight,
|
||||
scrollWidth: scrollContainer.scrollWidth,
|
||||
scrollHeight: scrollContainer.scrollHeight,
|
||||
})
|
||||
scrollElementToBottom(scrollContainer, behavior)
|
||||
lastKnownScrollTopRef.current = scrollContainer.scrollTop
|
||||
syncNearBottom(scrollContainer, onNearBottomChange)
|
||||
},
|
||||
[onNearBottomChange, props.agentId]
|
||||
)
|
||||
|
||||
const scheduleStickToBottom = useCallback(
|
||||
() => {
|
||||
const scrollContainer = scrollContainerRef.current
|
||||
if (scrollContainer && isScrollContainerOverscrolledPastBottom(scrollContainer)) {
|
||||
return
|
||||
}
|
||||
if (pendingAutoScrollFrameRef.current !== null) {
|
||||
return
|
||||
}
|
||||
logWebStickyBottom('viewport_schedule_stick_to_bottom', {
|
||||
agentId: props.agentId,
|
||||
followOutput: followOutputRef.current,
|
||||
scrollTop: scrollContainer?.scrollTop ?? null,
|
||||
clientWidth: scrollContainer?.clientWidth ?? null,
|
||||
clientHeight: scrollContainer?.clientHeight ?? null,
|
||||
scrollWidth: scrollContainer?.scrollWidth ?? null,
|
||||
scrollHeight: scrollContainer?.scrollHeight ?? null,
|
||||
})
|
||||
pendingAutoScrollFrameRef.current = window.requestAnimationFrame(() => {
|
||||
pendingAutoScrollFrameRef.current = null
|
||||
if (!followOutputRef.current) {
|
||||
return
|
||||
}
|
||||
scrollMessagesToBottom('auto')
|
||||
})
|
||||
},
|
||||
[props.agentId, scrollMessagesToBottom]
|
||||
)
|
||||
|
||||
const forceStickToBottom = useCallback(() => {
|
||||
cancelPendingStickToBottom()
|
||||
scrollMessagesToBottom('auto')
|
||||
scheduleStickToBottom()
|
||||
}, [cancelPendingStickToBottom, scheduleStickToBottom, scrollMessagesToBottom])
|
||||
|
||||
const updateScrollMetrics = useCallback(() => {
|
||||
const scrollContainer = scrollContainerRef.current
|
||||
if (!scrollContainer) {
|
||||
onNearBottomChange(true)
|
||||
return
|
||||
}
|
||||
streamScrollbarMetrics.onContentSizeChange(
|
||||
scrollContainer.clientWidth,
|
||||
scrollContainer.scrollHeight
|
||||
)
|
||||
streamScrollbarMetrics.onLayout({
|
||||
nativeEvent: {
|
||||
layout: {
|
||||
width: scrollContainer.clientWidth,
|
||||
height: scrollContainer.clientHeight,
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
},
|
||||
} as never)
|
||||
streamScrollbarMetrics.onScroll({
|
||||
nativeEvent: {
|
||||
contentOffset: { x: 0, y: scrollContainer.scrollTop },
|
||||
contentSize: {
|
||||
width: scrollContainer.clientWidth,
|
||||
height: scrollContainer.scrollHeight,
|
||||
},
|
||||
layoutMeasurement: {
|
||||
width: scrollContainer.clientWidth,
|
||||
height: scrollContainer.clientHeight,
|
||||
},
|
||||
},
|
||||
} as never)
|
||||
syncNearBottom(scrollContainer, onNearBottomChange)
|
||||
const currentMetrics = {
|
||||
scrollTop: scrollContainer.scrollTop,
|
||||
clientWidth: scrollContainer.clientWidth,
|
||||
clientHeight: scrollContainer.clientHeight,
|
||||
scrollWidth: scrollContainer.scrollWidth,
|
||||
scrollHeight: scrollContainer.scrollHeight,
|
||||
}
|
||||
const previousMetrics = lastLoggedMetricsRef.current
|
||||
const shouldLog =
|
||||
!previousMetrics ||
|
||||
previousMetrics.scrollTop !== currentMetrics.scrollTop ||
|
||||
previousMetrics.clientWidth !== currentMetrics.clientWidth ||
|
||||
previousMetrics.clientHeight !== currentMetrics.clientHeight ||
|
||||
previousMetrics.scrollWidth !== currentMetrics.scrollWidth ||
|
||||
previousMetrics.scrollHeight !== currentMetrics.scrollHeight
|
||||
if (shouldLog) {
|
||||
lastLoggedMetricsRef.current = currentMetrics
|
||||
logWebStickyBottom('viewport_metrics_updated', {
|
||||
agentId: props.agentId,
|
||||
followOutput: followOutputRef.current,
|
||||
distanceFromBottom: getScrollContainerDistanceFromBottom(scrollContainer),
|
||||
...currentMetrics,
|
||||
})
|
||||
}
|
||||
}, [onNearBottomChange, props.agentId, streamScrollbarMetrics])
|
||||
|
||||
const handleDomScroll = useCallback(() => {
|
||||
const scrollContainer = scrollContainerRef.current
|
||||
if (!scrollContainer) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentScrollTop = scrollContainer.scrollTop
|
||||
const isAtBottom = isScrollContainerAtBottom(scrollContainer)
|
||||
const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - USER_SCROLL_DELTA_EPSILON
|
||||
|
||||
if (!followOutputRef.current && isAtBottom) {
|
||||
setFollowOutput(true)
|
||||
pendingUserScrollUpIntentRef.current = false
|
||||
} else if (followOutputRef.current && pendingUserScrollUpIntentRef.current) {
|
||||
if (scrolledUp) {
|
||||
cancelPendingStickToBottom()
|
||||
setFollowOutput(false)
|
||||
}
|
||||
pendingUserScrollUpIntentRef.current = false
|
||||
} else if (followOutputRef.current && isPointerScrollActiveRef.current) {
|
||||
if (scrolledUp) {
|
||||
cancelPendingStickToBottom()
|
||||
setFollowOutput(false)
|
||||
}
|
||||
}
|
||||
|
||||
lastKnownScrollTopRef.current = currentScrollTop
|
||||
logWebStickyBottom('viewport_dom_scroll', {
|
||||
agentId: props.agentId,
|
||||
now: getDebugNow(),
|
||||
scrollTop: currentScrollTop,
|
||||
clientHeight: scrollContainer.clientHeight,
|
||||
scrollHeight: scrollContainer.scrollHeight,
|
||||
activeElementTag:
|
||||
typeof document !== 'undefined' ? document.activeElement?.tagName?.toLowerCase() ?? null : null,
|
||||
activeElementRole:
|
||||
typeof document !== 'undefined'
|
||||
? document.activeElement?.getAttribute?.('aria-label') ?? null
|
||||
: null,
|
||||
})
|
||||
updateScrollMetrics()
|
||||
}, [cancelPendingStickToBottom, updateScrollMetrics])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isActivationReady) {
|
||||
return
|
||||
}
|
||||
setFollowOutput(true)
|
||||
forceStickToBottom()
|
||||
const timeout = window.setTimeout(() => {
|
||||
if (!followOutputRef.current) {
|
||||
return
|
||||
}
|
||||
const scrollContainer = scrollContainerRef.current
|
||||
if (!scrollContainer) {
|
||||
return
|
||||
}
|
||||
if (isScrollContainerNearBottom(scrollContainer)) {
|
||||
return
|
||||
}
|
||||
scheduleStickToBottom()
|
||||
}, WEB_BOTTOM_SETTLE_TIMEOUT_MS)
|
||||
return () => {
|
||||
window.clearTimeout(timeout)
|
||||
}
|
||||
}, [activationKey, forceStickToBottom, isActivationReady, scheduleStickToBottom])
|
||||
|
||||
useEffect(() => {
|
||||
if (!followOutputRef.current) {
|
||||
return
|
||||
}
|
||||
scheduleStickToBottom()
|
||||
}, [
|
||||
scheduleStickToBottom,
|
||||
segments.historyMounted,
|
||||
segments.historyVirtualized,
|
||||
segments.liveHead,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (!followOutputRef.current || !shouldUseVirtualizer) {
|
||||
return
|
||||
}
|
||||
scheduleStickToBottom()
|
||||
}, [scheduleStickToBottom, shouldUseVirtualizer, virtualTotalSize])
|
||||
|
||||
useEffect(() => {
|
||||
updateScrollMetrics()
|
||||
}, [
|
||||
segments.historyMounted.length,
|
||||
segments.historyVirtualized.length,
|
||||
segments.liveHead.length,
|
||||
updateScrollMetrics,
|
||||
virtualTotalSize,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
const scrollContainer = scrollContainerRef.current
|
||||
const contentNode = contentRef.current
|
||||
if (!scrollContainer || typeof ResizeObserver === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
updateScrollMetrics()
|
||||
const observer = new ResizeObserver(() => {
|
||||
logWebStickyBottom('viewport_resize_observed', {
|
||||
agentId: props.agentId,
|
||||
followOutput: followOutputRef.current,
|
||||
scrollTop: scrollContainer.scrollTop,
|
||||
clientWidth: scrollContainer.clientWidth,
|
||||
clientHeight: scrollContainer.clientHeight,
|
||||
scrollWidth: scrollContainer.scrollWidth,
|
||||
scrollHeight: scrollContainer.scrollHeight,
|
||||
})
|
||||
updateScrollMetrics()
|
||||
if (!followOutputRef.current) {
|
||||
return
|
||||
}
|
||||
scheduleStickToBottom()
|
||||
})
|
||||
observer.observe(scrollContainer)
|
||||
if (contentNode) {
|
||||
observer.observe(contentNode)
|
||||
}
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [props.agentId, scheduleStickToBottom, updateScrollMetrics])
|
||||
|
||||
useEffect(() => {
|
||||
const scrollContainer = scrollContainerRef.current
|
||||
if (!scrollContainer) {
|
||||
return
|
||||
}
|
||||
|
||||
const originalScrollTo = scrollContainer.scrollTo.bind(scrollContainer)
|
||||
const scrollTopDescriptor =
|
||||
Object.getOwnPropertyDescriptor(Object.getPrototypeOf(scrollContainer), 'scrollTop') ??
|
||||
Object.getOwnPropertyDescriptor(Element.prototype, 'scrollTop')
|
||||
scrollContainer.scrollTo = ((...args: Parameters<HTMLElement['scrollTo']>) => {
|
||||
const firstArg = args[0] as ScrollToOptions | number | undefined
|
||||
const target =
|
||||
typeof firstArg === 'object' && firstArg !== null
|
||||
? {
|
||||
top: firstArg.top ?? null,
|
||||
left: firstArg.left ?? null,
|
||||
behavior: firstArg.behavior ?? null,
|
||||
}
|
||||
: {
|
||||
top: typeof args[1] === 'number' ? args[1] : null,
|
||||
left: typeof firstArg === 'number' ? firstArg : null,
|
||||
behavior: null,
|
||||
}
|
||||
logWebStickyBottom('viewport_scroll_to_called', {
|
||||
agentId: props.agentId,
|
||||
now: getDebugNow(),
|
||||
currentScrollTop: scrollContainer.scrollTop,
|
||||
target,
|
||||
stack:
|
||||
typeof Error !== 'undefined'
|
||||
? new Error().stack?.split('\n').slice(1, 6).join('\n') ?? null
|
||||
: null,
|
||||
})
|
||||
return originalScrollTo(...args)
|
||||
}) as typeof scrollContainer.scrollTo
|
||||
if (scrollTopDescriptor?.get && scrollTopDescriptor?.set) {
|
||||
Object.defineProperty(scrollContainer, 'scrollTop', {
|
||||
configurable: true,
|
||||
enumerable: scrollTopDescriptor.enumerable ?? false,
|
||||
get() {
|
||||
return scrollTopDescriptor.get?.call(scrollContainer)
|
||||
},
|
||||
set(value: number) {
|
||||
logWebStickyBottom('viewport_scroll_top_set', {
|
||||
agentId: props.agentId,
|
||||
now: getDebugNow(),
|
||||
currentScrollTop: scrollTopDescriptor.get?.call(scrollContainer) ?? null,
|
||||
nextScrollTop: value,
|
||||
stack:
|
||||
typeof Error !== 'undefined'
|
||||
? new Error().stack?.split('\n').slice(1, 6).join('\n') ?? null
|
||||
: null,
|
||||
})
|
||||
return scrollTopDescriptor.set?.call(scrollContainer, value)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
if (event.deltaY < 0) {
|
||||
pendingUserScrollUpIntentRef.current = true
|
||||
cancelPendingStickToBottom()
|
||||
}
|
||||
}
|
||||
const handlePointerDown = () => {
|
||||
isPointerScrollActiveRef.current = true
|
||||
}
|
||||
const handlePointerUp = () => {
|
||||
isPointerScrollActiveRef.current = false
|
||||
}
|
||||
const handleTouchStart = (event: TouchEvent) => {
|
||||
const touch = event.touches[0]
|
||||
if (!touch) {
|
||||
return
|
||||
}
|
||||
lastTouchClientYRef.current = touch.clientY
|
||||
}
|
||||
const handleTouchMove = (event: TouchEvent) => {
|
||||
const touch = event.touches[0]
|
||||
if (!touch) {
|
||||
return
|
||||
}
|
||||
const previousTouchY = lastTouchClientYRef.current
|
||||
if (previousTouchY !== null && touch.clientY > previousTouchY + 1) {
|
||||
pendingUserScrollUpIntentRef.current = true
|
||||
cancelPendingStickToBottom()
|
||||
}
|
||||
lastTouchClientYRef.current = touch.clientY
|
||||
}
|
||||
const handleTouchEnd = () => {
|
||||
lastTouchClientYRef.current = null
|
||||
}
|
||||
const handleSelectionChange = () => {
|
||||
const activeElement =
|
||||
typeof document !== 'undefined' ? (document.activeElement as HTMLTextAreaElement | null) : null
|
||||
logWebStickyBottom('document_selection_changed', {
|
||||
agentId: props.agentId,
|
||||
now: getDebugNow(),
|
||||
activeElementTag: activeElement?.tagName?.toLowerCase() ?? null,
|
||||
activeElementRole: activeElement?.getAttribute?.('aria-label') ?? null,
|
||||
selectionStart:
|
||||
activeElement && typeof activeElement.selectionStart === 'number'
|
||||
? activeElement.selectionStart
|
||||
: null,
|
||||
selectionEnd:
|
||||
activeElement && typeof activeElement.selectionEnd === 'number'
|
||||
? activeElement.selectionEnd
|
||||
: null,
|
||||
scrollTop: scrollContainer.scrollTop,
|
||||
})
|
||||
}
|
||||
|
||||
scrollContainer.addEventListener('scroll', handleDomScroll, { passive: true })
|
||||
scrollContainer.addEventListener('wheel', handleWheel, { passive: true })
|
||||
scrollContainer.addEventListener('pointerdown', handlePointerDown, { passive: true })
|
||||
scrollContainer.addEventListener('pointerup', handlePointerUp, { passive: true })
|
||||
scrollContainer.addEventListener('pointercancel', handlePointerUp, { passive: true })
|
||||
scrollContainer.addEventListener('touchstart', handleTouchStart, { passive: true })
|
||||
scrollContainer.addEventListener('touchmove', handleTouchMove, { passive: true })
|
||||
scrollContainer.addEventListener('touchend', handleTouchEnd, { passive: true })
|
||||
scrollContainer.addEventListener('touchcancel', handleTouchEnd, { passive: true })
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('selectionchange', handleSelectionChange, { passive: true })
|
||||
}
|
||||
|
||||
return () => {
|
||||
scrollContainer.removeEventListener('scroll', handleDomScroll)
|
||||
scrollContainer.removeEventListener('wheel', handleWheel)
|
||||
scrollContainer.removeEventListener('pointerdown', handlePointerDown)
|
||||
scrollContainer.removeEventListener('pointerup', handlePointerUp)
|
||||
scrollContainer.removeEventListener('pointercancel', handlePointerUp)
|
||||
scrollContainer.removeEventListener('touchstart', handleTouchStart)
|
||||
scrollContainer.removeEventListener('touchmove', handleTouchMove)
|
||||
scrollContainer.removeEventListener('touchend', handleTouchEnd)
|
||||
scrollContainer.removeEventListener('touchcancel', handleTouchEnd)
|
||||
scrollContainer.scrollTo = originalScrollTo
|
||||
if (scrollTopDescriptor) {
|
||||
Reflect.deleteProperty(scrollContainer, 'scrollTop')
|
||||
}
|
||||
if (typeof document !== 'undefined') {
|
||||
document.removeEventListener('selectionchange', handleSelectionChange)
|
||||
}
|
||||
}
|
||||
}, [cancelPendingStickToBottom, handleDomScroll, props.agentId])
|
||||
|
||||
useEffect(() => {
|
||||
const handle: StreamViewportHandle = {
|
||||
scrollToBottom: () => {
|
||||
setFollowOutput(true)
|
||||
cancelPendingStickToBottom()
|
||||
forceStickToBottom()
|
||||
},
|
||||
prepareForViewportChange: () => {
|
||||
if (!followOutputRef.current) {
|
||||
return
|
||||
}
|
||||
const scrollContainer = scrollContainerRef.current
|
||||
logWebStickyBottom('viewport_prepare_for_change', {
|
||||
agentId: props.agentId,
|
||||
followOutput: followOutputRef.current,
|
||||
scrollTop: scrollContainer?.scrollTop ?? null,
|
||||
clientWidth: scrollContainer?.clientWidth ?? null,
|
||||
clientHeight: scrollContainer?.clientHeight ?? null,
|
||||
scrollWidth: scrollContainer?.scrollWidth ?? null,
|
||||
scrollHeight: scrollContainer?.scrollHeight ?? null,
|
||||
})
|
||||
scheduleStickToBottom()
|
||||
},
|
||||
}
|
||||
viewportRef.current = handle
|
||||
return () => {
|
||||
if (viewportRef.current === handle) {
|
||||
viewportRef.current = null
|
||||
}
|
||||
cancelPendingStickToBottom()
|
||||
}
|
||||
}, [cancelPendingStickToBottom, forceStickToBottom, props.agentId, scheduleStickToBottom, viewportRef])
|
||||
|
||||
const contentContainerStyle = useMemo(
|
||||
(): CSSProperties => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: '100%',
|
||||
paddingTop: 16,
|
||||
paddingBottom: 16,
|
||||
paddingLeft: isMobileBreakpoint ? 8 : 16,
|
||||
paddingRight: isMobileBreakpoint ? 8 : 16,
|
||||
boxSizing: 'border-box',
|
||||
}),
|
||||
[isMobileBreakpoint]
|
||||
)
|
||||
const scrollContainerStyle = useMemo(
|
||||
(): CSSProperties => ({
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflowX: 'hidden',
|
||||
overflowY: scrollEnabled ? 'auto' : 'hidden',
|
||||
overscrollBehaviorY: 'contain',
|
||||
}),
|
||||
[scrollEnabled]
|
||||
)
|
||||
const virtualRowsContainerStyle = useMemo(
|
||||
(): CSSProperties => ({
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: virtualTotalSize,
|
||||
}),
|
||||
[virtualTotalSize]
|
||||
)
|
||||
const renderVirtualRowStyle = useCallback(
|
||||
(start: number): CSSProperties => ({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '100%',
|
||||
transform: `translateY(${start}px)`,
|
||||
}),
|
||||
[]
|
||||
)
|
||||
const mountedHistoryRows = useMemo(
|
||||
() =>
|
||||
segments.historyMounted.map((item, index) => (
|
||||
<Fragment key={item.id}>
|
||||
{renderHistoryMountedRow(item, index, segments.historyMounted)}
|
||||
</Fragment>
|
||||
)),
|
||||
[renderHistoryMountedRow, segments.historyMounted]
|
||||
)
|
||||
const liveHeadRows = useMemo(
|
||||
() =>
|
||||
segments.liveHead.map((item, index) => (
|
||||
<Fragment key={item.id}>
|
||||
{renderLiveHeadRow(item, index, segments.liveHead)}
|
||||
</Fragment>
|
||||
)),
|
||||
[renderLiveHeadRow, segments.liveHead]
|
||||
)
|
||||
const liveAuxiliary = useMemo(() => renderLiveAuxiliary(), [renderLiveAuxiliary])
|
||||
const shouldRenderEmpty =
|
||||
!boundary.hasMountedHistory &&
|
||||
!boundary.hasVirtualizedHistory &&
|
||||
!boundary.hasLiveHead &&
|
||||
!liveAuxiliary
|
||||
|
||||
return (
|
||||
<>
|
||||
<style id={WEB_STREAM_SCROLLBAR_STYLE_ID}>{WEB_STREAM_SCROLLBAR_STYLE}</style>
|
||||
<div
|
||||
ref={(node) => {
|
||||
scrollContainerRef.current = node
|
||||
}}
|
||||
data-testid="agent-chat-scroll"
|
||||
id={`agent-chat-scroll-${shouldUseVirtualizer ? 'web-dom-virtualized' : 'web-dom-scroll'}`}
|
||||
style={scrollContainerStyle}
|
||||
>
|
||||
<div
|
||||
ref={(node) => {
|
||||
contentRef.current = node
|
||||
}}
|
||||
style={contentContainerStyle}
|
||||
>
|
||||
{shouldUseVirtualizer ? (
|
||||
<div style={virtualRowsContainerStyle}>
|
||||
{virtualRows.map((virtualRow) => {
|
||||
const item = segments.historyVirtualized[virtualRow.index]
|
||||
if (!item) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
data-index={virtualRow.index}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
style={renderVirtualRowStyle(virtualRow.start)}
|
||||
>
|
||||
{renderHistoryVirtualizedRow(
|
||||
item,
|
||||
virtualRow.index,
|
||||
segments.historyVirtualized
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{mountedHistoryRows}
|
||||
{boundary.hasMountedHistory && boundary.hasLiveHead && boundary.historyToHeadGap > 0 ? (
|
||||
<div style={{ height: boundary.historyToHeadGap, width: '100%' }} />
|
||||
) : null}
|
||||
{liveHeadRows}
|
||||
{liveAuxiliary}
|
||||
{shouldRenderEmpty ? listEmptyComponent : null}
|
||||
</div>
|
||||
</div>
|
||||
<WebDesktopScrollbarOverlay
|
||||
enabled={showDesktopWebScrollbar}
|
||||
metrics={streamScrollbarMetrics}
|
||||
inverted={false}
|
||||
onScrollToOffset={(nextOffset) => {
|
||||
const scrollContainer = scrollContainerRef.current
|
||||
if (!scrollContainer) {
|
||||
return
|
||||
}
|
||||
scrollContainer.scrollTo({ top: nextOffset, behavior: 'auto' })
|
||||
lastKnownScrollTopRef.current = scrollContainer.scrollTop
|
||||
updateScrollMetrics()
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function createWebStreamStrategy(input: CreateWebStreamStrategyInput): StreamStrategy {
|
||||
return createStreamStrategy({
|
||||
render: (renderInput) => (
|
||||
<WebStreamViewport
|
||||
key={renderInput.agentId}
|
||||
{...renderInput}
|
||||
isMobileBreakpoint={input.isMobileBreakpoint}
|
||||
/>
|
||||
),
|
||||
orderTailReverse: false,
|
||||
orderHeadReverse: false,
|
||||
assistantTurnTraversalStep: -1,
|
||||
edgeSlot: 'footer',
|
||||
flatListInverted: false,
|
||||
overlayScrollbarInverted: false,
|
||||
maintainVisibleContentPosition: undefined,
|
||||
bottomAnchorTransportBehavior: {
|
||||
verificationDelayFrames: 0,
|
||||
verificationRetryMode: 'rescroll',
|
||||
},
|
||||
disableParentScrollOnInlineDetailsExpansion: false,
|
||||
anchorBottomOnContentSizeChange: true,
|
||||
animateManualScrollToBottom: false,
|
||||
useVirtualizedList: false,
|
||||
isNearBottom: (inputMetrics) => {
|
||||
const distanceFromBottom = Math.max(
|
||||
0,
|
||||
inputMetrics.contentHeight - (inputMetrics.offsetY + inputMetrics.viewportHeight)
|
||||
)
|
||||
return distanceFromBottom <= inputMetrics.threshold
|
||||
},
|
||||
getBottomOffset: (metrics) => Math.max(0, metrics.contentHeight - metrics.viewportHeight),
|
||||
})
|
||||
}
|
||||
309
packages/app/src/components/stream-strategy.ts
Normal file
309
packages/app/src/components/stream-strategy.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
import type { ComponentType, ReactElement, ReactNode, RefObject } from "react";
|
||||
import type { StyleProp, ViewStyle } from "react-native";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type {
|
||||
StreamHistoryBoundary,
|
||||
StreamRenderSegments,
|
||||
} from "./agent-stream-render-model";
|
||||
import type {
|
||||
BottomAnchorLocalRequest,
|
||||
BottomAnchorRouteRequest,
|
||||
} from "./use-bottom-anchor-controller";
|
||||
import { createNativeStreamStrategy } from "./stream-strategy-native";
|
||||
import { createWebStreamStrategy } from "./stream-strategy-web";
|
||||
|
||||
type EdgeSlot = "header" | "footer";
|
||||
type NeighborRelation = "above" | "below";
|
||||
type AssistantTurnTraversalStep = -1 | 1;
|
||||
|
||||
export type MaintainVisibleContentPositionConfig = Readonly<{
|
||||
minIndexForVisible: number;
|
||||
autoscrollToTopThreshold: number;
|
||||
}>;
|
||||
|
||||
export type BottomAnchorTransportBehavior = Readonly<{
|
||||
verificationDelayFrames: number;
|
||||
verificationRetryMode: "rescroll" | "recheck";
|
||||
}>;
|
||||
|
||||
export type StreamViewportMetrics = {
|
||||
contentHeight: number;
|
||||
viewportHeight: number;
|
||||
};
|
||||
|
||||
export type StreamNearBottomInput = StreamViewportMetrics & {
|
||||
offsetY: number;
|
||||
threshold: number;
|
||||
};
|
||||
|
||||
export type StreamEdgeSlotProps = {
|
||||
ListHeaderComponent?: ReactElement | ComponentType<any> | null;
|
||||
ListHeaderComponentStyle?: StyleProp<ViewStyle>;
|
||||
ListFooterComponent?: ReactElement | ComponentType<any> | null;
|
||||
ListFooterComponentStyle?: StyleProp<ViewStyle>;
|
||||
};
|
||||
|
||||
export type StreamViewportHandle = {
|
||||
scrollToBottom: (reason?: BottomAnchorLocalRequest["reason"]) => void;
|
||||
prepareForViewportChange: () => void;
|
||||
};
|
||||
|
||||
export type StreamSegmentRenderers = {
|
||||
renderHistoryVirtualizedRow: (
|
||||
item: StreamItem,
|
||||
index: number,
|
||||
items: StreamItem[]
|
||||
) => ReactNode;
|
||||
renderHistoryMountedRow: (
|
||||
item: StreamItem,
|
||||
index: number,
|
||||
items: StreamItem[]
|
||||
) => ReactNode;
|
||||
renderLiveHeadRow: (
|
||||
item: StreamItem,
|
||||
index: number,
|
||||
items: StreamItem[]
|
||||
) => ReactNode;
|
||||
renderLiveAuxiliary: () => ReactNode;
|
||||
};
|
||||
|
||||
export type StreamRenderInput = {
|
||||
agentId: string;
|
||||
segments: StreamRenderSegments;
|
||||
boundary: StreamHistoryBoundary;
|
||||
renderers: StreamSegmentRenderers;
|
||||
listEmptyComponent: ReactNode;
|
||||
viewportRef: RefObject<StreamViewportHandle | null>;
|
||||
routeBottomAnchorRequest: BottomAnchorRouteRequest | null;
|
||||
isAuthoritativeHistoryReady: boolean;
|
||||
onNearBottomChange: (value: boolean) => void;
|
||||
scrollEnabled: boolean;
|
||||
listStyle: StyleProp<ViewStyle>;
|
||||
baseListContentContainerStyle: StyleProp<ViewStyle>;
|
||||
forwardListContentContainerStyle: StyleProp<ViewStyle>;
|
||||
};
|
||||
|
||||
export type ResolveStreamRenderStrategyInput = {
|
||||
platform: string;
|
||||
isMobileBreakpoint: boolean;
|
||||
};
|
||||
|
||||
export interface StreamStrategy {
|
||||
render: (input: StreamRenderInput) => ReactNode;
|
||||
orderTail: (streamItems: StreamItem[]) => StreamItem[];
|
||||
orderHead: (streamHead: StreamItem[]) => StreamItem[];
|
||||
getNeighborIndex: (index: number, relation: NeighborRelation) => number;
|
||||
getNeighborItem: (
|
||||
items: StreamItem[],
|
||||
index: number,
|
||||
relation: NeighborRelation
|
||||
) => StreamItem | undefined;
|
||||
collectAssistantTurnContent: (items: StreamItem[], startIndex: number) => string;
|
||||
isNearBottom: (input: StreamNearBottomInput) => boolean;
|
||||
getBottomOffset: (metrics: StreamViewportMetrics) => number;
|
||||
getEdgeSlotProps: (
|
||||
component: ReactElement | ComponentType<any> | null,
|
||||
gapSize: number
|
||||
) => StreamEdgeSlotProps;
|
||||
getMaintainVisibleContentPosition: () =>
|
||||
| MaintainVisibleContentPositionConfig
|
||||
| undefined;
|
||||
getBottomAnchorTransportBehavior: () => BottomAnchorTransportBehavior;
|
||||
getFlatListInverted: () => boolean;
|
||||
getOverlayScrollbarInverted: () => boolean;
|
||||
shouldDisableParentScrollOnInlineDetailsExpansion: () => boolean;
|
||||
shouldAnchorBottomOnContentSizeChange: () => boolean;
|
||||
shouldAnimateManualScrollToBottom: () => boolean;
|
||||
shouldUseVirtualizedList: () => boolean;
|
||||
}
|
||||
|
||||
type StreamStrategyConfig = {
|
||||
render: StreamStrategy["render"];
|
||||
orderTailReverse: boolean;
|
||||
orderHeadReverse: boolean;
|
||||
assistantTurnTraversalStep: AssistantTurnTraversalStep;
|
||||
edgeSlot: EdgeSlot;
|
||||
flatListInverted: boolean;
|
||||
overlayScrollbarInverted: boolean;
|
||||
maintainVisibleContentPosition?: MaintainVisibleContentPositionConfig;
|
||||
bottomAnchorTransportBehavior: BottomAnchorTransportBehavior;
|
||||
disableParentScrollOnInlineDetailsExpansion: boolean;
|
||||
anchorBottomOnContentSizeChange: boolean;
|
||||
animateManualScrollToBottom: boolean;
|
||||
useVirtualizedList: boolean;
|
||||
isNearBottom: (input: StreamNearBottomInput) => boolean;
|
||||
getBottomOffset: (metrics: StreamViewportMetrics) => number;
|
||||
};
|
||||
|
||||
const NATIVE_SETTLING_VERIFICATION_DELAY_FRAMES = 4;
|
||||
|
||||
export function createStreamStrategy(
|
||||
config: StreamStrategyConfig
|
||||
): StreamStrategy {
|
||||
return {
|
||||
render: config.render,
|
||||
orderTail: (streamItems) =>
|
||||
config.orderTailReverse ? [...streamItems].reverse() : streamItems,
|
||||
orderHead: (streamHead) =>
|
||||
config.orderHeadReverse ? [...streamHead].reverse() : streamHead,
|
||||
getNeighborIndex: (index, relation) =>
|
||||
relation === "above"
|
||||
? index + config.assistantTurnTraversalStep
|
||||
: index - config.assistantTurnTraversalStep,
|
||||
getNeighborItem: (items, index, relation) => {
|
||||
const neighborIndex =
|
||||
relation === "above"
|
||||
? index + config.assistantTurnTraversalStep
|
||||
: index - config.assistantTurnTraversalStep;
|
||||
if (neighborIndex < 0 || neighborIndex >= items.length) {
|
||||
return undefined;
|
||||
}
|
||||
return items[neighborIndex];
|
||||
},
|
||||
collectAssistantTurnContent: (items, startIndex) => {
|
||||
const messages: string[] = [];
|
||||
for (
|
||||
let index = startIndex;
|
||||
index >= 0 && index < items.length;
|
||||
index += config.assistantTurnTraversalStep
|
||||
) {
|
||||
const currentItem = items[index];
|
||||
if (currentItem.kind === "user_message") {
|
||||
break;
|
||||
}
|
||||
if (currentItem.kind === "assistant_message") {
|
||||
messages.push(currentItem.text);
|
||||
}
|
||||
}
|
||||
return messages.reverse().join("\n\n");
|
||||
},
|
||||
isNearBottom: (input) => config.isNearBottom(input),
|
||||
getBottomOffset: (metrics) => config.getBottomOffset(metrics),
|
||||
getEdgeSlotProps: (component, gapSize) => {
|
||||
if (config.edgeSlot === "header") {
|
||||
return {
|
||||
ListHeaderComponent: component,
|
||||
ListHeaderComponentStyle: { marginBottom: gapSize },
|
||||
};
|
||||
}
|
||||
return {
|
||||
ListFooterComponent: component,
|
||||
ListFooterComponentStyle: { marginTop: gapSize },
|
||||
};
|
||||
},
|
||||
getMaintainVisibleContentPosition: () => config.maintainVisibleContentPosition,
|
||||
getBottomAnchorTransportBehavior: () => config.bottomAnchorTransportBehavior,
|
||||
getFlatListInverted: () => config.flatListInverted,
|
||||
getOverlayScrollbarInverted: () => config.overlayScrollbarInverted,
|
||||
shouldDisableParentScrollOnInlineDetailsExpansion: () =>
|
||||
config.disableParentScrollOnInlineDetailsExpansion,
|
||||
shouldAnchorBottomOnContentSizeChange: () =>
|
||||
config.anchorBottomOnContentSizeChange,
|
||||
shouldAnimateManualScrollToBottom: () => config.animateManualScrollToBottom,
|
||||
shouldUseVirtualizedList: () => config.useVirtualizedList,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveStreamRenderStrategy(
|
||||
input: ResolveStreamRenderStrategyInput
|
||||
): StreamStrategy {
|
||||
if (input.platform === "web") {
|
||||
return createWebStreamStrategy({
|
||||
isMobileBreakpoint: input.isMobileBreakpoint,
|
||||
});
|
||||
}
|
||||
return createNativeStreamStrategy();
|
||||
}
|
||||
|
||||
export function resolveBottomAnchorTransportBehavior(input: {
|
||||
strategy: StreamStrategy;
|
||||
isViewportSettling: boolean;
|
||||
}): BottomAnchorTransportBehavior {
|
||||
const baseBehavior = input.strategy.getBottomAnchorTransportBehavior();
|
||||
if (!input.isViewportSettling || !input.strategy.getFlatListInverted()) {
|
||||
return baseBehavior;
|
||||
}
|
||||
return {
|
||||
verificationDelayFrames: Math.max(
|
||||
baseBehavior.verificationDelayFrames,
|
||||
NATIVE_SETTLING_VERIFICATION_DELAY_FRAMES
|
||||
),
|
||||
verificationRetryMode: "recheck",
|
||||
};
|
||||
}
|
||||
|
||||
export function orderTailForStreamRenderStrategy(params: {
|
||||
strategy: StreamStrategy;
|
||||
streamItems: StreamItem[];
|
||||
}): StreamItem[] {
|
||||
return params.strategy.orderTail(params.streamItems);
|
||||
}
|
||||
|
||||
export function orderHeadForStreamRenderStrategy(params: {
|
||||
strategy: StreamStrategy;
|
||||
streamHead: StreamItem[];
|
||||
}): StreamItem[] {
|
||||
return params.strategy.orderHead(params.streamHead);
|
||||
}
|
||||
|
||||
export function getStreamNeighborIndex(params: {
|
||||
strategy: StreamStrategy;
|
||||
index: number;
|
||||
relation: NeighborRelation;
|
||||
}): number {
|
||||
return params.strategy.getNeighborIndex(params.index, params.relation);
|
||||
}
|
||||
|
||||
export function getStreamNeighborItem(params: {
|
||||
strategy: StreamStrategy;
|
||||
items: StreamItem[];
|
||||
index: number;
|
||||
relation: NeighborRelation;
|
||||
}): StreamItem | undefined {
|
||||
return params.strategy.getNeighborItem(
|
||||
params.items,
|
||||
params.index,
|
||||
params.relation
|
||||
);
|
||||
}
|
||||
|
||||
export function collectAssistantTurnContentForStreamRenderStrategy(params: {
|
||||
strategy: StreamStrategy;
|
||||
items: StreamItem[];
|
||||
startIndex: number;
|
||||
}): string {
|
||||
return params.strategy.collectAssistantTurnContent(
|
||||
params.items,
|
||||
params.startIndex
|
||||
);
|
||||
}
|
||||
|
||||
export function isNearBottomForStreamRenderStrategy(
|
||||
params: StreamNearBottomInput & { strategy: StreamStrategy }
|
||||
): boolean {
|
||||
return params.strategy.isNearBottom({
|
||||
offsetY: params.offsetY,
|
||||
threshold: params.threshold,
|
||||
contentHeight: params.contentHeight,
|
||||
viewportHeight: params.viewportHeight,
|
||||
});
|
||||
}
|
||||
|
||||
export function getBottomOffsetForStreamRenderStrategy(
|
||||
params: StreamViewportMetrics & {
|
||||
strategy: StreamStrategy;
|
||||
}
|
||||
): number {
|
||||
return params.strategy.getBottomOffset({
|
||||
contentHeight: params.contentHeight,
|
||||
viewportHeight: params.viewportHeight,
|
||||
});
|
||||
}
|
||||
|
||||
export function getStreamEdgeSlotProps(params: {
|
||||
strategy: StreamStrategy;
|
||||
component: ReactElement | ComponentType<any> | null;
|
||||
gapSize: number;
|
||||
}): StreamEdgeSlotProps {
|
||||
return params.strategy.getEdgeSlotProps(params.component, params.gapSize);
|
||||
}
|
||||
170
packages/app/src/components/synced-loader.tsx
Normal file
170
packages/app/src/components/synced-loader.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import { View } from "react-native";
|
||||
import Animated, {
|
||||
Easing,
|
||||
makeMutable,
|
||||
type SharedValue,
|
||||
useAnimatedStyle,
|
||||
withRepeat,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { useEffect } from "react";
|
||||
|
||||
const SYNCED_LOADER_DURATION_MS = 950;
|
||||
const SYNCED_LOADER_EPOCH_MS = 0;
|
||||
const DOT_SEQUENCE = [0, 1, 3, 5, 4, 2] as const;
|
||||
const DOT_COUNT = DOT_SEQUENCE.length;
|
||||
const GRID_ROWS = 3;
|
||||
const GRID_COLUMNS = 2;
|
||||
const SNAKE_SEGMENT_OFFSETS = [0, -1, -2, -3, -4] as const;
|
||||
const SNAKE_OPACITIES = [1, 0.72, 0.46, 0.22, 0] as const;
|
||||
const sharedStepProgress = makeMutable(0);
|
||||
let sharedLoopStarted = false;
|
||||
|
||||
function ensureSharedStepLoopStarted(): void {
|
||||
if (sharedLoopStarted) {
|
||||
return;
|
||||
}
|
||||
|
||||
sharedLoopStarted = true;
|
||||
const elapsedMs =
|
||||
(Date.now() - SYNCED_LOADER_EPOCH_MS) % SYNCED_LOADER_DURATION_MS;
|
||||
sharedStepProgress.value = (elapsedMs / SYNCED_LOADER_DURATION_MS) * DOT_COUNT;
|
||||
sharedStepProgress.value = withTiming(
|
||||
DOT_COUNT,
|
||||
{
|
||||
duration: Math.max(1, Math.round(SYNCED_LOADER_DURATION_MS - elapsedMs)),
|
||||
easing: Easing.linear,
|
||||
},
|
||||
(finished) => {
|
||||
if (!finished) {
|
||||
sharedLoopStarted = false;
|
||||
return;
|
||||
}
|
||||
sharedStepProgress.value = 0;
|
||||
sharedStepProgress.value = withRepeat(
|
||||
withTiming(DOT_COUNT, {
|
||||
duration: SYNCED_LOADER_DURATION_MS,
|
||||
easing: Easing.linear,
|
||||
}),
|
||||
-1,
|
||||
false
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function SyncedLoader({
|
||||
size = 10,
|
||||
color,
|
||||
}: {
|
||||
size?: number;
|
||||
color: string;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
ensureSharedStepLoopStarted();
|
||||
}, []);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: 1,
|
||||
}));
|
||||
|
||||
const gap = Math.max(1, Math.round(size * 0.12));
|
||||
const dotSize = Math.max(2, Math.floor((size - gap * 2) / 3));
|
||||
const gridWidth = dotSize * 2 + gap;
|
||||
const gridHeight = dotSize * 3 + gap * 2;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
animatedStyle,
|
||||
{
|
||||
width: gridWidth,
|
||||
height: gridHeight,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{Array.from({ length: DOT_COUNT }).map((_, dotIndex) => {
|
||||
const rowIndex = Math.floor(dotIndex / GRID_COLUMNS);
|
||||
const columnIndex = dotIndex % GRID_COLUMNS;
|
||||
const sequenceIndex = DOT_SEQUENCE.indexOf(
|
||||
dotIndex as (typeof DOT_SEQUENCE)[number]
|
||||
);
|
||||
|
||||
return (
|
||||
<SpinnerDot
|
||||
key={dotIndex}
|
||||
color={color}
|
||||
dotSize={dotSize}
|
||||
sequenceIndex={sequenceIndex}
|
||||
progress={sharedStepProgress}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: columnIndex * (dotSize + gap),
|
||||
top: rowIndex * (dotSize + gap),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function SpinnerDot({
|
||||
color,
|
||||
dotSize,
|
||||
sequenceIndex,
|
||||
progress,
|
||||
style,
|
||||
}: {
|
||||
color: string;
|
||||
dotSize: number;
|
||||
sequenceIndex: number;
|
||||
progress: SharedValue<number>;
|
||||
style: {
|
||||
position: "absolute";
|
||||
left: number;
|
||||
top: number;
|
||||
};
|
||||
}) {
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
const headIndex = Math.floor(progress.value) % DOT_COUNT;
|
||||
let opacity = 0;
|
||||
|
||||
for (let segmentIndex = 0; segmentIndex < SNAKE_SEGMENT_OFFSETS.length; segmentIndex += 1) {
|
||||
const activeSequenceIndex =
|
||||
(headIndex + SNAKE_SEGMENT_OFFSETS[segmentIndex] + DOT_COUNT) % DOT_COUNT;
|
||||
if (sequenceIndex === activeSequenceIndex) {
|
||||
opacity = SNAKE_OPACITIES[segmentIndex] ?? 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
opacity,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
animatedStyle,
|
||||
{
|
||||
width: dotSize,
|
||||
height: dotSize,
|
||||
borderRadius: dotSize / 2,
|
||||
backgroundColor: color,
|
||||
},
|
||||
style,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -35,12 +35,12 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderColor: theme.colors.accent,
|
||||
},
|
||||
secondary: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface3,
|
||||
borderColor: theme.colors.surface3,
|
||||
},
|
||||
outline: {
|
||||
backgroundColor: "transparent",
|
||||
borderColor: theme.colors.border,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
},
|
||||
ghost: {
|
||||
backgroundColor: "transparent",
|
||||
@@ -59,7 +59,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
text: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
textDefault: {
|
||||
color: theme.colors.palette.white,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
useState,
|
||||
type PropsWithChildren,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
@@ -184,8 +185,9 @@ type TriggerStyleProp =
|
||||
| StyleProp<ViewStyle>
|
||||
| ((state: TriggerState) => StyleProp<ViewStyle>);
|
||||
|
||||
interface DropdownMenuTriggerProps extends Omit<PressableProps, "style"> {
|
||||
interface DropdownMenuTriggerProps extends Omit<PressableProps, "style" | "children"> {
|
||||
style?: TriggerStyleProp;
|
||||
children: ReactNode | ((state: TriggerState) => ReactNode);
|
||||
}
|
||||
|
||||
export function DropdownMenuTrigger({
|
||||
@@ -193,7 +195,7 @@ export function DropdownMenuTrigger({
|
||||
disabled,
|
||||
style,
|
||||
...props
|
||||
}: PropsWithChildren<DropdownMenuTriggerProps>): ReactElement {
|
||||
}: DropdownMenuTriggerProps): ReactElement {
|
||||
const ctx = useDropdownMenuContext("DropdownMenuTrigger");
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
@@ -215,7 +217,10 @@ export function DropdownMenuTrigger({
|
||||
return style;
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
{({ pressed, hovered = false }) => {
|
||||
const state: TriggerState = { pressed, hovered: Boolean(hovered), open: ctx.open };
|
||||
return typeof children === "function" ? children(state) : children;
|
||||
}}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
698
packages/app/src/components/use-bottom-anchor-controller.test.ts
Normal file
698
packages/app/src/components/use-bottom-anchor-controller.test.ts
Normal file
@@ -0,0 +1,698 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
__private__,
|
||||
deriveBottomAnchorBlockedReason,
|
||||
type BottomAnchorMode,
|
||||
} from "./use-bottom-anchor-controller";
|
||||
import type { BottomAnchorTransportBehavior } from "./agent-stream-render-strategy";
|
||||
|
||||
type MeasurementState = ReturnType<typeof createMeasurementState>;
|
||||
|
||||
function createMeasurementState(
|
||||
overrides?: Partial<{
|
||||
containerKey: string;
|
||||
viewportWidth: number;
|
||||
viewportHeight: number;
|
||||
contentHeight: number;
|
||||
offsetY: number;
|
||||
viewportMeasuredForKey: string | null;
|
||||
contentMeasuredForKey: string | null;
|
||||
}>
|
||||
) {
|
||||
return {
|
||||
containerKey: "scroll-view",
|
||||
viewportWidth: 0,
|
||||
viewportHeight: 0,
|
||||
contentHeight: 0,
|
||||
offsetY: 0,
|
||||
viewportMeasuredForKey: null,
|
||||
contentMeasuredForKey: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createPendingRequest() {
|
||||
return {
|
||||
id: 1,
|
||||
agentId: "agent-1",
|
||||
reason: "initial-entry" as const,
|
||||
requestKey: "route:agent-1",
|
||||
};
|
||||
}
|
||||
|
||||
function createFrameScheduler() {
|
||||
let sequence = 0;
|
||||
const tasks = new Map<
|
||||
number,
|
||||
{
|
||||
cancelled: boolean;
|
||||
remainingFrames: number;
|
||||
callback: () => void;
|
||||
kind: "attempt" | "verification";
|
||||
}
|
||||
>();
|
||||
|
||||
return {
|
||||
schedule(params: {
|
||||
kind: "attempt" | "verification";
|
||||
callback: () => void;
|
||||
delayFrames?: number;
|
||||
}) {
|
||||
const id = ++sequence;
|
||||
tasks.set(id, {
|
||||
cancelled: false,
|
||||
remainingFrames: Math.max(0, params.delayFrames ?? 0),
|
||||
callback: params.callback,
|
||||
kind: params.kind,
|
||||
});
|
||||
return id;
|
||||
},
|
||||
cancel(handle: unknown) {
|
||||
const task = tasks.get(handle as number);
|
||||
if (task) {
|
||||
task.cancelled = true;
|
||||
}
|
||||
},
|
||||
flushFrame() {
|
||||
const due: Array<() => void> = [];
|
||||
for (const [id, task] of Array.from(tasks.entries())) {
|
||||
if (task.cancelled) {
|
||||
tasks.delete(id);
|
||||
continue;
|
||||
}
|
||||
if (task.remainingFrames > 0) {
|
||||
task.remainingFrames -= 1;
|
||||
continue;
|
||||
}
|
||||
tasks.delete(id);
|
||||
due.push(task.callback);
|
||||
}
|
||||
for (const callback of due) {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
flushAll(limit = 20) {
|
||||
for (let index = 0; index < limit && tasks.size > 0; index += 1) {
|
||||
this.flushFrame();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createDriverHarness(input?: {
|
||||
transportBehavior?: BottomAnchorTransportBehavior;
|
||||
isNearBottom?: boolean;
|
||||
measurementState?: MeasurementState;
|
||||
authoritativeReady?: boolean;
|
||||
}) {
|
||||
const scheduler = createFrameScheduler();
|
||||
const measurementState =
|
||||
input?.measurementState ??
|
||||
createMeasurementState({
|
||||
viewportWidth: 800,
|
||||
viewportHeight: 480,
|
||||
contentHeight: 1200,
|
||||
viewportMeasuredForKey: "scroll-view",
|
||||
contentMeasuredForKey: "scroll-view",
|
||||
});
|
||||
const context = {
|
||||
agentId: "agent-1",
|
||||
authoritativeReady: input?.authoritativeReady ?? true,
|
||||
renderStrategy: "forward-stream",
|
||||
transportBehavior:
|
||||
input?.transportBehavior ?? {
|
||||
verificationDelayFrames: 0,
|
||||
verificationRetryMode: "rescroll",
|
||||
},
|
||||
measurementState,
|
||||
nearBottom: input?.isNearBottom ?? true,
|
||||
};
|
||||
const scrollToBottom = vi.fn(() => {
|
||||
context.nearBottom = true;
|
||||
context.measurementState.offsetY = 720;
|
||||
});
|
||||
const modeChanges: BottomAnchorMode[] = [];
|
||||
const warnings: Array<{ agentId: string; reason: string }> = [];
|
||||
const logs: Array<{ event: string; details: Record<string, unknown> }> = [];
|
||||
const driver = __private__.createBottomAnchorControllerDriver({
|
||||
getAgentId: () => context.agentId,
|
||||
getIsAuthoritativeHistoryReady: () => context.authoritativeReady,
|
||||
getRenderStrategy: () => context.renderStrategy,
|
||||
getTransportBehavior: () => context.transportBehavior,
|
||||
getMeasurementState: () => context.measurementState,
|
||||
isNearBottom: () => context.nearBottom,
|
||||
scrollToBottom,
|
||||
onModeChange: (mode) => {
|
||||
modeChanges.push(mode);
|
||||
},
|
||||
log: (event, details) => {
|
||||
logs.push({ event, details });
|
||||
},
|
||||
warn: (details) => warnings.push(details),
|
||||
scheduleFrame: (params) => scheduler.schedule(params),
|
||||
cancelFrame: (handle) => scheduler.cancel(handle),
|
||||
});
|
||||
|
||||
return {
|
||||
context,
|
||||
driver,
|
||||
scheduler,
|
||||
scrollToBottom,
|
||||
modeChanges,
|
||||
logs,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
describe("deriveBottomAnchorBlockedReason", () => {
|
||||
it("keeps initial-entry pending until history is ready and geometry is measurable", () => {
|
||||
const pendingRequest = createPendingRequest();
|
||||
|
||||
expect(
|
||||
deriveBottomAnchorBlockedReason({
|
||||
pendingRequest,
|
||||
isAuthoritativeHistoryReady: false,
|
||||
measurementState: createMeasurementState(),
|
||||
pendingVerificationRequestId: null,
|
||||
})
|
||||
).toBe("waiting_for_history_readiness");
|
||||
|
||||
expect(
|
||||
deriveBottomAnchorBlockedReason({
|
||||
pendingRequest,
|
||||
isAuthoritativeHistoryReady: true,
|
||||
measurementState: createMeasurementState({
|
||||
viewportHeight: 480,
|
||||
viewportMeasuredForKey: "scroll-view",
|
||||
}),
|
||||
pendingVerificationRequestId: null,
|
||||
})
|
||||
).toBe("waiting_for_measurable_content");
|
||||
|
||||
expect(
|
||||
deriveBottomAnchorBlockedReason({
|
||||
pendingRequest,
|
||||
isAuthoritativeHistoryReady: true,
|
||||
measurementState: createMeasurementState({
|
||||
viewportHeight: 480,
|
||||
contentHeight: 1200,
|
||||
viewportMeasuredForKey: "scroll-view",
|
||||
contentMeasuredForKey: "scroll-view",
|
||||
}),
|
||||
pendingVerificationRequestId: pendingRequest.id,
|
||||
})
|
||||
).toBe("waiting_for_post_layout_verification");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bottom anchor controller driver", () => {
|
||||
it("keeps initial-entry pending until authoritative history and current geometry exist", () => {
|
||||
const harness = createDriverHarness({
|
||||
authoritativeReady: false,
|
||||
measurementState: createMeasurementState(),
|
||||
});
|
||||
|
||||
harness.driver.applyRouteRequest({
|
||||
agentId: "agent-1",
|
||||
reason: "initial-entry",
|
||||
requestKey: "route:agent-1:initial-entry",
|
||||
});
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.scrollToBottom).not.toHaveBeenCalled();
|
||||
expect(harness.driver.getSnapshot()).toMatchObject({
|
||||
mode: "sticky-bottom",
|
||||
blockedReason: "waiting_for_history_readiness",
|
||||
pendingRequest: {
|
||||
reason: "initial-entry",
|
||||
},
|
||||
});
|
||||
|
||||
harness.context.authoritativeReady = true;
|
||||
harness.context.measurementState.viewportHeight = 480;
|
||||
harness.context.measurementState.contentHeight = 1200;
|
||||
harness.context.measurementState.viewportMeasuredForKey = "scroll-view";
|
||||
harness.context.measurementState.contentMeasuredForKey = "scroll-view";
|
||||
harness.context.nearBottom = true;
|
||||
harness.driver.notifyAuthoritativeHistoryMaybeChanged();
|
||||
harness.driver.reevaluate();
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
|
||||
expect(harness.driver.getSnapshot()).toMatchObject({
|
||||
blockedReason: null,
|
||||
pendingRequest: null,
|
||||
pendingVerification: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("suppresses sticky maintenance while detached", () => {
|
||||
const harness = createDriverHarness();
|
||||
|
||||
harness.driver.detachByUser();
|
||||
harness.driver.handleContentSizeChange({
|
||||
previousContentHeight: 1200,
|
||||
contentHeight: 1500,
|
||||
});
|
||||
harness.driver.handleViewportMetricsChange({
|
||||
previousViewportWidth: 800,
|
||||
viewportWidth: 640,
|
||||
previousViewportHeight: 480,
|
||||
viewportHeight: 420,
|
||||
});
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.driver.getSnapshot().mode).toBe("detached");
|
||||
expect(harness.scrollToBottom).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("switches back to sticky-bottom for explicit jump-to-bottom", () => {
|
||||
const harness = createDriverHarness({
|
||||
isNearBottom: false,
|
||||
});
|
||||
|
||||
harness.driver.detachByUser();
|
||||
harness.driver.requestLocalAnchor({
|
||||
agentId: "agent-1",
|
||||
reason: "jump-to-bottom",
|
||||
});
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.modeChanges).toContain("detached");
|
||||
expect(harness.modeChanges).toContain("sticky-bottom");
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
|
||||
expect(harness.driver.getSnapshot().mode).toBe("sticky-bottom");
|
||||
});
|
||||
|
||||
it("schedules sticky maintenance on viewport and content growth", () => {
|
||||
const harness = createDriverHarness();
|
||||
|
||||
harness.driver.handleViewportMetricsChange({
|
||||
previousViewportWidth: 800,
|
||||
viewportWidth: 640,
|
||||
previousViewportHeight: 480,
|
||||
viewportHeight: 420,
|
||||
});
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
harness.driver.handleContentSizeChange({
|
||||
previousContentHeight: 1200,
|
||||
contentHeight: 1600,
|
||||
});
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("keeps a pending request blocked when stale container measurements arrive", () => {
|
||||
const harness = createDriverHarness({
|
||||
measurementState: createMeasurementState({
|
||||
containerKey: "web-partial-virtualized",
|
||||
viewportHeight: 420,
|
||||
contentHeight: 1200,
|
||||
viewportMeasuredForKey: "scroll-view",
|
||||
contentMeasuredForKey: "scroll-view",
|
||||
}),
|
||||
});
|
||||
|
||||
harness.driver.applyRouteRequest({
|
||||
agentId: "agent-1",
|
||||
reason: "resume",
|
||||
requestKey: "route:agent-1:resume",
|
||||
});
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.scrollToBottom).not.toHaveBeenCalled();
|
||||
expect(harness.driver.getSnapshot()).toMatchObject({
|
||||
blockedReason: "waiting_for_measurable_viewport",
|
||||
pendingRequest: {
|
||||
reason: "resume",
|
||||
},
|
||||
});
|
||||
|
||||
harness.context.measurementState.viewportMeasuredForKey = "web-partial-virtualized";
|
||||
harness.context.measurementState.contentMeasuredForKey = "web-partial-virtualized";
|
||||
harness.driver.reevaluate();
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
|
||||
expect(harness.driver.getSnapshot().pendingRequest).toBeNull();
|
||||
});
|
||||
|
||||
it("uses delayed rechecks instead of repeated rescroll loops for native transport", () => {
|
||||
const harness = createDriverHarness({
|
||||
transportBehavior: {
|
||||
verificationDelayFrames: 2,
|
||||
verificationRetryMode: "recheck",
|
||||
},
|
||||
isNearBottom: false,
|
||||
});
|
||||
harness.scrollToBottom.mockImplementation(() => {
|
||||
harness.context.measurementState.offsetY = 0;
|
||||
});
|
||||
|
||||
harness.driver.requestLocalAnchor({
|
||||
agentId: "agent-1",
|
||||
reason: "jump-to-bottom",
|
||||
});
|
||||
|
||||
harness.scheduler.flushFrame();
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
|
||||
|
||||
harness.scheduler.flushFrame();
|
||||
harness.scheduler.flushFrame();
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
|
||||
|
||||
harness.context.nearBottom = true;
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
|
||||
expect(harness.warnings).toEqual([]);
|
||||
expect(harness.driver.getSnapshot().pendingRequest).toBeNull();
|
||||
});
|
||||
|
||||
it("does not stay blocked on post-layout verification after a retry-scroll request", () => {
|
||||
const harness = createDriverHarness({
|
||||
measurementState: createMeasurementState({
|
||||
containerKey: "web-partial-virtualized",
|
||||
viewportWidth: 828,
|
||||
viewportHeight: 846,
|
||||
contentHeight: 14322,
|
||||
offsetY: 0,
|
||||
viewportMeasuredForKey: "web-partial-virtualized",
|
||||
contentMeasuredForKey: "web-partial-virtualized",
|
||||
}),
|
||||
isNearBottom: false,
|
||||
});
|
||||
|
||||
harness.scrollToBottom.mockImplementation(() => {
|
||||
harness.context.measurementState.offsetY = 13476;
|
||||
});
|
||||
|
||||
harness.driver.applyRouteRequest({
|
||||
agentId: "agent-1",
|
||||
reason: "resume",
|
||||
requestKey: "route:agent-1:resume",
|
||||
});
|
||||
|
||||
harness.scheduler.flushFrame();
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
|
||||
|
||||
harness.context.measurementState.contentHeight = 14804;
|
||||
harness.context.nearBottom = false;
|
||||
harness.driver.handleContentSizeChange({
|
||||
previousContentHeight: 14322,
|
||||
contentHeight: 14804,
|
||||
});
|
||||
|
||||
harness.scheduler.flushFrame();
|
||||
|
||||
expect(harness.driver.getSnapshot()).toMatchObject({
|
||||
pendingRequest: {
|
||||
reason: "resume",
|
||||
},
|
||||
pendingVerification: {
|
||||
requestId: 1,
|
||||
retries: 1,
|
||||
},
|
||||
});
|
||||
|
||||
harness.scheduler.flushFrame();
|
||||
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(2);
|
||||
expect(harness.driver.getSnapshot()).toMatchObject({
|
||||
blockedReason: "waiting_for_post_layout_verification",
|
||||
pendingRequest: {
|
||||
reason: "resume",
|
||||
},
|
||||
pendingVerification: {
|
||||
requestId: 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not fulfill a web partial-virtualized resume request before a confirmation pass", () => {
|
||||
const harness = createDriverHarness({
|
||||
measurementState: createMeasurementState({
|
||||
containerKey: "web-partial-virtualized",
|
||||
viewportWidth: 828,
|
||||
viewportHeight: 846,
|
||||
contentHeight: 14322,
|
||||
offsetY: 0,
|
||||
viewportMeasuredForKey: "web-partial-virtualized",
|
||||
contentMeasuredForKey: "web-partial-virtualized",
|
||||
}),
|
||||
isNearBottom: false,
|
||||
});
|
||||
|
||||
harness.scrollToBottom.mockImplementation(() => {
|
||||
harness.context.measurementState.offsetY = Math.max(
|
||||
0,
|
||||
harness.context.measurementState.contentHeight -
|
||||
harness.context.measurementState.viewportHeight
|
||||
);
|
||||
harness.context.nearBottom = true;
|
||||
});
|
||||
|
||||
harness.driver.applyRouteRequest({
|
||||
agentId: "agent-1",
|
||||
reason: "resume",
|
||||
requestKey: "route:agent-1:resume-confirmation",
|
||||
});
|
||||
|
||||
harness.scheduler.flushFrame();
|
||||
harness.scheduler.flushFrame();
|
||||
|
||||
expect(harness.driver.getSnapshot()).toMatchObject({
|
||||
pendingRequest: {
|
||||
reason: "resume",
|
||||
},
|
||||
blockedReason: "waiting_for_post_layout_verification",
|
||||
});
|
||||
|
||||
harness.context.measurementState.contentHeight = 16230;
|
||||
harness.context.nearBottom = false;
|
||||
harness.driver.handleContentSizeChange({
|
||||
previousContentHeight: 14322,
|
||||
contentHeight: 16230,
|
||||
});
|
||||
|
||||
harness.scheduler.flushFrame();
|
||||
harness.scheduler.flushFrame();
|
||||
harness.scheduler.flushFrame();
|
||||
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(2);
|
||||
expect(harness.driver.getSnapshot().pendingRequest).toMatchObject({
|
||||
reason: "resume",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps sticky-bottom during viewport growth until bottom is re-verified", () => {
|
||||
const harness = createDriverHarness();
|
||||
harness.context.nearBottom = false;
|
||||
harness.scrollToBottom.mockImplementation(() => {
|
||||
harness.context.measurementState.offsetY = 720;
|
||||
});
|
||||
|
||||
harness.driver.handleViewportMetricsChange({
|
||||
previousViewportWidth: 800,
|
||||
viewportWidth: 800,
|
||||
previousViewportHeight: 480,
|
||||
viewportHeight: 420,
|
||||
});
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(4);
|
||||
expect(harness.driver.getSnapshot()).toMatchObject({
|
||||
mode: "sticky-bottom",
|
||||
pendingRequest: null,
|
||||
pendingVerification: null,
|
||||
});
|
||||
|
||||
harness.driver.handleScrollNearBottomChange({
|
||||
nextIsNearBottom: false,
|
||||
scrollDelta: 0,
|
||||
});
|
||||
|
||||
expect(harness.driver.getSnapshot().mode).toBe("sticky-bottom");
|
||||
|
||||
harness.context.nearBottom = true;
|
||||
harness.driver.handleScrollNearBottomChange({
|
||||
nextIsNearBottom: true,
|
||||
scrollDelta: 0,
|
||||
});
|
||||
harness.scheduler.flushAll();
|
||||
harness.driver.handleScrollNearBottomChange({
|
||||
nextIsNearBottom: false,
|
||||
scrollDelta: 64,
|
||||
});
|
||||
|
||||
expect(harness.driver.getSnapshot().mode).toBe("detached");
|
||||
});
|
||||
|
||||
it("keeps sticky-bottom during streaming growth until bottom is re-verified", () => {
|
||||
const harness = createDriverHarness();
|
||||
harness.context.nearBottom = false;
|
||||
harness.scrollToBottom.mockImplementation(() => {
|
||||
harness.context.measurementState.offsetY = 900;
|
||||
});
|
||||
|
||||
harness.driver.handleContentSizeChange({
|
||||
previousContentHeight: 1200,
|
||||
contentHeight: 1400,
|
||||
});
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(4);
|
||||
expect(harness.driver.getSnapshot()).toMatchObject({
|
||||
mode: "sticky-bottom",
|
||||
pendingRequest: null,
|
||||
pendingVerification: null,
|
||||
});
|
||||
|
||||
harness.driver.handleScrollNearBottomChange({
|
||||
nextIsNearBottom: false,
|
||||
scrollDelta: 0,
|
||||
});
|
||||
|
||||
expect(harness.driver.getSnapshot().mode).toBe("sticky-bottom");
|
||||
|
||||
harness.context.nearBottom = true;
|
||||
harness.driver.handleScrollNearBottomChange({
|
||||
nextIsNearBottom: true,
|
||||
scrollDelta: 0,
|
||||
});
|
||||
harness.scheduler.flushAll();
|
||||
harness.driver.handleScrollNearBottomChange({
|
||||
nextIsNearBottom: false,
|
||||
scrollDelta: 64,
|
||||
});
|
||||
|
||||
expect(harness.driver.getSnapshot().mode).toBe("detached");
|
||||
});
|
||||
});
|
||||
|
||||
describe("controller helper predicates", () => {
|
||||
it("rejects stale container measurements during post-scroll verification", () => {
|
||||
expect(
|
||||
__private__.deriveVerificationBlockedReason({
|
||||
isAuthoritativeHistoryReady: true,
|
||||
measurementState: createMeasurementState({
|
||||
containerKey: "web-partial-virtualized",
|
||||
viewportHeight: 420,
|
||||
contentHeight: 1200,
|
||||
viewportMeasuredForKey: "scroll-view",
|
||||
contentMeasuredForKey: "scroll-view",
|
||||
}),
|
||||
})
|
||||
).toBe("waiting_for_measurable_viewport");
|
||||
});
|
||||
|
||||
it("allows verification only after authoritative readiness and current geometry exist", () => {
|
||||
expect(
|
||||
__private__.deriveVerificationBlockedReason({
|
||||
isAuthoritativeHistoryReady: false,
|
||||
measurementState: createMeasurementState({
|
||||
containerKey: "scroll-view",
|
||||
viewportHeight: 420,
|
||||
contentHeight: 1200,
|
||||
viewportMeasuredForKey: "scroll-view",
|
||||
contentMeasuredForKey: "scroll-view",
|
||||
}),
|
||||
})
|
||||
).toBe("waiting_for_history_readiness");
|
||||
|
||||
expect(
|
||||
__private__.deriveVerificationBlockedReason({
|
||||
isAuthoritativeHistoryReady: true,
|
||||
measurementState: createMeasurementState({
|
||||
containerKey: "scroll-view",
|
||||
viewportHeight: 420,
|
||||
contentHeight: 1200,
|
||||
viewportMeasuredForKey: "scroll-view",
|
||||
contentMeasuredForKey: "scroll-view",
|
||||
}),
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("suppresses auto-anchor helpers while detached", () => {
|
||||
const mode: BottomAnchorMode = "detached";
|
||||
|
||||
expect(
|
||||
__private__.shouldRestickOnContentChange({
|
||||
mode,
|
||||
previousContentHeight: 1000,
|
||||
contentHeight: 1100,
|
||||
})
|
||||
).toBe(false);
|
||||
expect(
|
||||
__private__.shouldRestickOnViewportChange({
|
||||
mode,
|
||||
previousViewportWidth: 800,
|
||||
viewportWidth: 640,
|
||||
previousViewportHeight: 400,
|
||||
viewportHeight: 360,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not detach from sticky while a restick request is still pending", () => {
|
||||
expect(
|
||||
__private__.shouldDetachFromScrollAway({
|
||||
mode: "sticky-bottom",
|
||||
nextIsNearBottom: false,
|
||||
scrollDelta: 0,
|
||||
hasPendingRequest: true,
|
||||
hasPendingVerification: false,
|
||||
hasUnverifiedStickyMeasurementChange: false,
|
||||
})
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
__private__.shouldDetachFromScrollAway({
|
||||
mode: "sticky-bottom",
|
||||
nextIsNearBottom: false,
|
||||
scrollDelta: 0,
|
||||
hasPendingRequest: false,
|
||||
hasPendingVerification: true,
|
||||
hasUnverifiedStickyMeasurementChange: false,
|
||||
})
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
__private__.shouldDetachFromScrollAway({
|
||||
mode: "sticky-bottom",
|
||||
nextIsNearBottom: false,
|
||||
scrollDelta: 0,
|
||||
hasPendingRequest: false,
|
||||
hasPendingVerification: false,
|
||||
hasUnverifiedStickyMeasurementChange: true,
|
||||
})
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
__private__.shouldDetachFromScrollAway({
|
||||
mode: "sticky-bottom",
|
||||
nextIsNearBottom: false,
|
||||
scrollDelta: 0,
|
||||
hasPendingRequest: false,
|
||||
hasPendingVerification: false,
|
||||
hasUnverifiedStickyMeasurementChange: false,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("treats a large scroll delta as user detach even during an unverified sticky change", () => {
|
||||
expect(
|
||||
__private__.shouldDetachFromScrollAway({
|
||||
mode: "sticky-bottom",
|
||||
nextIsNearBottom: false,
|
||||
scrollDelta: 48,
|
||||
hasPendingRequest: false,
|
||||
hasPendingVerification: false,
|
||||
hasUnverifiedStickyMeasurementChange: true,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
976
packages/app/src/components/use-bottom-anchor-controller.ts
Normal file
976
packages/app/src/components/use-bottom-anchor-controller.ts
Normal file
@@ -0,0 +1,976 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { BottomAnchorTransportBehavior } from "./agent-stream-render-strategy";
|
||||
|
||||
export type BottomAnchorMode = "sticky-bottom" | "detached";
|
||||
|
||||
export type BottomAnchorRouteRequest = {
|
||||
reason: "initial-entry" | "resume";
|
||||
agentId: string;
|
||||
requestKey: string;
|
||||
};
|
||||
|
||||
export type BottomAnchorLocalRequest = {
|
||||
reason: "jump-to-bottom" | "message-sent";
|
||||
agentId: string;
|
||||
};
|
||||
|
||||
export type BottomAnchorBlockedReason =
|
||||
| "waiting_for_history_readiness"
|
||||
| "waiting_for_measurable_viewport"
|
||||
| "waiting_for_measurable_content"
|
||||
| "waiting_for_post_layout_verification";
|
||||
|
||||
type BottomAnchorRequestReason =
|
||||
| BottomAnchorRouteRequest["reason"]
|
||||
| BottomAnchorLocalRequest["reason"];
|
||||
|
||||
type BottomAnchorRequest = {
|
||||
id: number;
|
||||
agentId: string;
|
||||
reason: BottomAnchorRequestReason;
|
||||
requestKey: string;
|
||||
};
|
||||
|
||||
type ControllerMeasurementState = {
|
||||
containerKey: string;
|
||||
viewportWidth: number;
|
||||
viewportHeight: number;
|
||||
contentHeight: number;
|
||||
offsetY: number;
|
||||
viewportMeasuredForKey: string | null;
|
||||
contentMeasuredForKey: string | null;
|
||||
};
|
||||
|
||||
type AttemptContext = {
|
||||
requestId: number | null;
|
||||
retries: number;
|
||||
confirmationPasses?: number;
|
||||
startedContentHeight?: number;
|
||||
startedOffsetY?: number;
|
||||
startedViewportHeight?: number;
|
||||
};
|
||||
|
||||
type ScheduledFrameHandle = {
|
||||
cancelled: boolean;
|
||||
rafId: number | null;
|
||||
remainingFrames: number;
|
||||
callback: () => void;
|
||||
};
|
||||
|
||||
type BottomAnchorEvent =
|
||||
| "request_created"
|
||||
| "evaluate_called"
|
||||
| "attempt_started"
|
||||
| "attempt_verified"
|
||||
| "attempt_failed"
|
||||
| "request_fulfilled"
|
||||
| "request_cancelled"
|
||||
| "detached_by_user"
|
||||
| "verification_scheduled"
|
||||
| "blocked_reason_changed";
|
||||
|
||||
type BottomAnchorControllerDriver = {
|
||||
destroy: () => void;
|
||||
getSnapshot: () => {
|
||||
mode: BottomAnchorMode;
|
||||
pendingRequest: BottomAnchorRequest | null;
|
||||
pendingVerification: AttemptContext | null;
|
||||
blockedReason: BottomAnchorBlockedReason | null;
|
||||
};
|
||||
resetForAgent: () => void;
|
||||
applyRouteRequest: (request: BottomAnchorRouteRequest | null) => void;
|
||||
requestLocalAnchor: (request: BottomAnchorLocalRequest) => void;
|
||||
detachByUser: () => void;
|
||||
handleViewportMetricsChange: (params: {
|
||||
previousViewportWidth: number;
|
||||
viewportWidth: number;
|
||||
previousViewportHeight: number;
|
||||
viewportHeight: number;
|
||||
}) => void;
|
||||
handleContentSizeChange: (params: {
|
||||
previousContentHeight: number;
|
||||
contentHeight: number;
|
||||
}) => void;
|
||||
prepareForStickyViewportChange: () => void;
|
||||
prepareForStickyContentChange: () => void;
|
||||
handleScrollNearBottomChange: (params: {
|
||||
nextIsNearBottom: boolean;
|
||||
scrollDelta: number;
|
||||
}) => void;
|
||||
notifyAuthoritativeHistoryMaybeChanged: () => void;
|
||||
reevaluate: (animated?: boolean) => void;
|
||||
};
|
||||
|
||||
type CreateBottomAnchorControllerDriverInput = {
|
||||
getAgentId: () => string;
|
||||
getIsAuthoritativeHistoryReady: () => boolean;
|
||||
getRenderStrategy: () => string;
|
||||
getTransportBehavior: () => BottomAnchorTransportBehavior;
|
||||
getMeasurementState: () => ControllerMeasurementState;
|
||||
isNearBottom: () => boolean;
|
||||
scrollToBottom: (animated: boolean) => void;
|
||||
onModeChange: (mode: BottomAnchorMode) => void;
|
||||
log: (event: BottomAnchorEvent, details: Record<string, unknown>) => void;
|
||||
warn: (details: { agentId: string; reason: BottomAnchorRequestReason }) => void;
|
||||
scheduleFrame: (params: {
|
||||
kind: "attempt" | "verification";
|
||||
callback: () => void;
|
||||
delayFrames?: number;
|
||||
}) => unknown;
|
||||
cancelFrame: (handle: unknown) => void;
|
||||
};
|
||||
|
||||
const MAX_VERIFICATION_RETRIES = 3;
|
||||
const WEB_PARTIAL_VIRTUALIZED_CONFIRMATION_DELAY_FRAMES = 1;
|
||||
const USER_SCROLL_AWAY_DELTA_PX = 24;
|
||||
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
|
||||
|
||||
function logBottomAnchorEvent(
|
||||
event: BottomAnchorEvent,
|
||||
details: Record<string, unknown>
|
||||
): void {
|
||||
if (!IS_DEV) {
|
||||
return;
|
||||
}
|
||||
console.debug("[BottomAnchor]", event, details);
|
||||
}
|
||||
|
||||
function scheduleAnimationFrameWithDelay(input: {
|
||||
callback: () => void;
|
||||
delayFrames?: number;
|
||||
}): ScheduledFrameHandle {
|
||||
const handle: ScheduledFrameHandle = {
|
||||
cancelled: false,
|
||||
rafId: null,
|
||||
remainingFrames: Math.max(0, input.delayFrames ?? 0),
|
||||
callback: input.callback,
|
||||
};
|
||||
|
||||
const tick = () => {
|
||||
if (handle.cancelled) {
|
||||
return;
|
||||
}
|
||||
if (handle.remainingFrames > 0) {
|
||||
handle.remainingFrames -= 1;
|
||||
handle.rafId = requestAnimationFrame(tick);
|
||||
return;
|
||||
}
|
||||
handle.rafId = null;
|
||||
input.callback();
|
||||
};
|
||||
|
||||
handle.rafId = requestAnimationFrame(tick);
|
||||
return handle;
|
||||
}
|
||||
|
||||
function cancelScheduledAnimationFrame(handle: unknown): void {
|
||||
const scheduled = handle as ScheduledFrameHandle | null;
|
||||
if (!scheduled) {
|
||||
return;
|
||||
}
|
||||
scheduled.cancelled = true;
|
||||
if (scheduled.rafId !== null) {
|
||||
cancelAnimationFrame(scheduled.rafId);
|
||||
scheduled.rafId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function deriveVerificationBlockedReason(input: {
|
||||
isAuthoritativeHistoryReady: boolean;
|
||||
measurementState: ControllerMeasurementState;
|
||||
}): Exclude<BottomAnchorBlockedReason, "waiting_for_post_layout_verification"> | null {
|
||||
if (!input.isAuthoritativeHistoryReady) {
|
||||
return "waiting_for_history_readiness";
|
||||
}
|
||||
if (
|
||||
input.measurementState.viewportHeight <= 0 ||
|
||||
input.measurementState.viewportMeasuredForKey !== input.measurementState.containerKey
|
||||
) {
|
||||
return "waiting_for_measurable_viewport";
|
||||
}
|
||||
if (
|
||||
input.measurementState.contentHeight <= 0 ||
|
||||
input.measurementState.contentMeasuredForKey !== input.measurementState.containerKey
|
||||
) {
|
||||
return "waiting_for_measurable_content";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function deriveBottomAnchorBlockedReason(input: {
|
||||
pendingRequest: BottomAnchorRequest | null;
|
||||
isAuthoritativeHistoryReady: boolean;
|
||||
measurementState: ControllerMeasurementState;
|
||||
pendingVerificationRequestId: number | null;
|
||||
}): BottomAnchorBlockedReason | null {
|
||||
if (!input.pendingRequest) {
|
||||
return null;
|
||||
}
|
||||
if (!input.isAuthoritativeHistoryReady) {
|
||||
return "waiting_for_history_readiness";
|
||||
}
|
||||
if (
|
||||
input.measurementState.viewportHeight <= 0 ||
|
||||
input.measurementState.viewportMeasuredForKey !== input.measurementState.containerKey
|
||||
) {
|
||||
return "waiting_for_measurable_viewport";
|
||||
}
|
||||
if (
|
||||
input.measurementState.contentHeight <= 0 ||
|
||||
input.measurementState.contentMeasuredForKey !== input.measurementState.containerKey
|
||||
) {
|
||||
return "waiting_for_measurable_content";
|
||||
}
|
||||
if (input.pendingVerificationRequestId === input.pendingRequest.id) {
|
||||
return "waiting_for_post_layout_verification";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function deriveRetryDisposition(input: {
|
||||
mode: BottomAnchorMode;
|
||||
retries: number;
|
||||
verificationRetryMode: BottomAnchorTransportBehavior["verificationRetryMode"];
|
||||
}): "retry-scroll" | "retry-verify" | "fail" {
|
||||
if (input.mode !== "sticky-bottom" || input.retries >= MAX_VERIFICATION_RETRIES) {
|
||||
return "fail";
|
||||
}
|
||||
return input.verificationRetryMode === "recheck"
|
||||
? "retry-verify"
|
||||
: "retry-scroll";
|
||||
}
|
||||
|
||||
function shouldRequireRouteRequestConfirmation(input: {
|
||||
request: BottomAnchorRequest | null;
|
||||
measurementState: ControllerMeasurementState;
|
||||
confirmationPasses: number;
|
||||
}): boolean {
|
||||
if (!input.request) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
input.request.reason !== "initial-entry" &&
|
||||
input.request.reason !== "resume"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (input.measurementState.containerKey !== "web-partial-virtualized") {
|
||||
return false;
|
||||
}
|
||||
return input.confirmationPasses < 1;
|
||||
}
|
||||
|
||||
function getDetailedMeasurementState(
|
||||
measurementState: ControllerMeasurementState
|
||||
): Record<string, unknown> {
|
||||
const distanceFromBottom = Math.max(
|
||||
0,
|
||||
measurementState.contentHeight -
|
||||
(measurementState.offsetY + measurementState.viewportHeight)
|
||||
);
|
||||
return {
|
||||
containerKey: measurementState.containerKey,
|
||||
viewportWidth: measurementState.viewportWidth,
|
||||
viewportHeight: measurementState.viewportHeight,
|
||||
contentHeight: measurementState.contentHeight,
|
||||
offsetY: measurementState.offsetY,
|
||||
distanceFromBottom,
|
||||
viewportMeasuredForKey: measurementState.viewportMeasuredForKey,
|
||||
contentMeasuredForKey: measurementState.contentMeasuredForKey,
|
||||
};
|
||||
}
|
||||
|
||||
function createBottomAnchorControllerDriver(
|
||||
input: CreateBottomAnchorControllerDriverInput
|
||||
): BottomAnchorControllerDriver {
|
||||
let requestSequence = 0;
|
||||
let mode: BottomAnchorMode = "sticky-bottom";
|
||||
let pendingRequest: BottomAnchorRequest | null = null;
|
||||
let pendingVerification: AttemptContext | null = null;
|
||||
let blockedReason: BottomAnchorBlockedReason | null = null;
|
||||
let attemptHandle: unknown = null;
|
||||
let verificationHandle: unknown = null;
|
||||
let lastRouteRequestKey: string | null = null;
|
||||
let stickyMeasurementRevision = 0;
|
||||
let lastVerifiedStickyMeasurementRevision = 0;
|
||||
|
||||
const getLogContext = (extra?: Record<string, unknown>) => {
|
||||
const measurementState = input.getMeasurementState();
|
||||
const distanceFromBottom = Math.max(
|
||||
0,
|
||||
measurementState.contentHeight -
|
||||
(measurementState.offsetY + measurementState.viewportHeight)
|
||||
);
|
||||
return {
|
||||
agentId: input.getAgentId(),
|
||||
requestReason: pendingRequest?.reason ?? null,
|
||||
authoritativeHistoryReady: input.getIsAuthoritativeHistoryReady(),
|
||||
contentHeight: measurementState.contentHeight,
|
||||
viewportHeight: measurementState.viewportHeight,
|
||||
offset: measurementState.offsetY,
|
||||
distanceFromBottom,
|
||||
renderStrategy: input.getRenderStrategy(),
|
||||
blockedReason,
|
||||
mode,
|
||||
containerKey: measurementState.containerKey,
|
||||
transportBehavior: input.getTransportBehavior(),
|
||||
...extra,
|
||||
};
|
||||
};
|
||||
|
||||
const setBlockedReason = (nextBlockedReason: BottomAnchorBlockedReason | null) => {
|
||||
if (blockedReason === nextBlockedReason) {
|
||||
return;
|
||||
}
|
||||
blockedReason = nextBlockedReason;
|
||||
input.log(
|
||||
"blocked_reason_changed",
|
||||
getLogContext({ nextBlockedReason })
|
||||
);
|
||||
};
|
||||
|
||||
const setModeInternal = (nextMode: BottomAnchorMode) => {
|
||||
if (mode === nextMode) {
|
||||
return;
|
||||
}
|
||||
mode = nextMode;
|
||||
input.onModeChange(nextMode);
|
||||
if (nextMode === "detached") {
|
||||
lastVerifiedStickyMeasurementRevision = stickyMeasurementRevision;
|
||||
}
|
||||
};
|
||||
|
||||
const markStickyMeasurementChanged = () => {
|
||||
stickyMeasurementRevision += 1;
|
||||
};
|
||||
|
||||
const markStickyMeasurementVerified = () => {
|
||||
lastVerifiedStickyMeasurementRevision = stickyMeasurementRevision;
|
||||
};
|
||||
|
||||
const cancelPendingAttempt = () => {
|
||||
if (attemptHandle) {
|
||||
input.cancelFrame(attemptHandle);
|
||||
attemptHandle = null;
|
||||
}
|
||||
if (verificationHandle) {
|
||||
input.cancelFrame(verificationHandle);
|
||||
verificationHandle = null;
|
||||
}
|
||||
pendingVerification = null;
|
||||
};
|
||||
|
||||
const cancelPendingRequest = (reason: string) => {
|
||||
const currentRequest = pendingRequest;
|
||||
if (!currentRequest) {
|
||||
cancelPendingAttempt();
|
||||
setBlockedReason(null);
|
||||
return;
|
||||
}
|
||||
input.log(
|
||||
"request_cancelled",
|
||||
getLogContext({
|
||||
cancelledRequestReason: currentRequest.reason,
|
||||
cancelReason: reason,
|
||||
})
|
||||
);
|
||||
pendingRequest = null;
|
||||
cancelPendingAttempt();
|
||||
setBlockedReason(null);
|
||||
};
|
||||
|
||||
const deriveDriverBlockedReason = (
|
||||
measurementState: ControllerMeasurementState
|
||||
) =>
|
||||
deriveBottomAnchorBlockedReason({
|
||||
pendingRequest,
|
||||
isAuthoritativeHistoryReady: input.getIsAuthoritativeHistoryReady(),
|
||||
measurementState,
|
||||
pendingVerificationRequestId:
|
||||
verificationHandle !== null ? pendingVerification?.requestId ?? null : null,
|
||||
});
|
||||
|
||||
const scheduleVerification = (
|
||||
attemptContext: AttemptContext,
|
||||
delayFramesOverride?: number
|
||||
) => {
|
||||
const scheduledMeasurementState = input.getMeasurementState();
|
||||
if (verificationHandle) {
|
||||
input.cancelFrame(verificationHandle);
|
||||
}
|
||||
input.log(
|
||||
"verification_scheduled",
|
||||
getLogContext({
|
||||
retries: attemptContext.retries,
|
||||
startedContentHeight: attemptContext.startedContentHeight ?? null,
|
||||
startedOffsetY: attemptContext.startedOffsetY ?? null,
|
||||
startedViewportHeight: attemptContext.startedViewportHeight ?? null,
|
||||
scheduledMeasurementState:
|
||||
getDetailedMeasurementState(scheduledMeasurementState),
|
||||
verificationDelayFrames:
|
||||
delayFramesOverride ?? input.getTransportBehavior().verificationDelayFrames,
|
||||
})
|
||||
);
|
||||
verificationHandle = input.scheduleFrame({
|
||||
kind: "verification",
|
||||
delayFrames:
|
||||
delayFramesOverride ?? input.getTransportBehavior().verificationDelayFrames,
|
||||
callback: () => {
|
||||
verificationHandle = null;
|
||||
const currentRequest = pendingRequest;
|
||||
const isRequestAttempt =
|
||||
currentRequest && attemptContext.requestId === currentRequest.id;
|
||||
const measurementState = input.getMeasurementState();
|
||||
const verificationBlockedReason = deriveVerificationBlockedReason({
|
||||
isAuthoritativeHistoryReady: input.getIsAuthoritativeHistoryReady(),
|
||||
measurementState,
|
||||
});
|
||||
|
||||
if (verificationBlockedReason) {
|
||||
input.log(
|
||||
"attempt_verified",
|
||||
getLogContext({
|
||||
verificationPhase: "blocked",
|
||||
verificationBlockedReason,
|
||||
retries: attemptContext.retries,
|
||||
measurementState: getDetailedMeasurementState(measurementState),
|
||||
})
|
||||
);
|
||||
pendingVerification = attemptContext;
|
||||
setBlockedReason(verificationBlockedReason);
|
||||
return;
|
||||
}
|
||||
|
||||
const verifiedNearBottom = input.isNearBottom();
|
||||
const retryDisposition = verifiedNearBottom
|
||||
? null
|
||||
: deriveRetryDisposition({
|
||||
mode,
|
||||
retries: attemptContext.retries,
|
||||
verificationRetryMode:
|
||||
input.getTransportBehavior().verificationRetryMode,
|
||||
});
|
||||
|
||||
input.log(
|
||||
"attempt_verified",
|
||||
getLogContext({
|
||||
verifiedNearBottom,
|
||||
retries: attemptContext.retries,
|
||||
retryDisposition,
|
||||
contentHeightDeltaSinceAttempt:
|
||||
measurementState.contentHeight -
|
||||
(attemptContext.startedContentHeight ?? measurementState.contentHeight),
|
||||
offsetDeltaSinceAttempt:
|
||||
measurementState.offsetY -
|
||||
(attemptContext.startedOffsetY ?? measurementState.offsetY),
|
||||
viewportHeightDeltaSinceAttempt:
|
||||
measurementState.viewportHeight -
|
||||
(attemptContext.startedViewportHeight ??
|
||||
measurementState.viewportHeight),
|
||||
measurementState: getDetailedMeasurementState(measurementState),
|
||||
})
|
||||
);
|
||||
|
||||
if (verifiedNearBottom) {
|
||||
if (
|
||||
isRequestAttempt &&
|
||||
shouldRequireRouteRequestConfirmation({
|
||||
request: currentRequest,
|
||||
measurementState,
|
||||
confirmationPasses: attemptContext.confirmationPasses ?? 0,
|
||||
})
|
||||
) {
|
||||
pendingVerification = {
|
||||
...attemptContext,
|
||||
confirmationPasses: (attemptContext.confirmationPasses ?? 0) + 1,
|
||||
};
|
||||
setBlockedReason("waiting_for_post_layout_verification");
|
||||
scheduleVerification(
|
||||
pendingVerification,
|
||||
WEB_PARTIAL_VIRTUALIZED_CONFIRMATION_DELAY_FRAMES
|
||||
);
|
||||
return;
|
||||
}
|
||||
pendingVerification = null;
|
||||
markStickyMeasurementVerified();
|
||||
if (isRequestAttempt) {
|
||||
input.log("request_fulfilled", getLogContext());
|
||||
pendingRequest = null;
|
||||
}
|
||||
setBlockedReason(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (retryDisposition === "retry-verify") {
|
||||
pendingVerification = {
|
||||
requestId: attemptContext.requestId,
|
||||
retries: attemptContext.retries + 1,
|
||||
};
|
||||
setBlockedReason("waiting_for_post_layout_verification");
|
||||
scheduleVerification(pendingVerification);
|
||||
return;
|
||||
}
|
||||
|
||||
if (retryDisposition === "retry-scroll") {
|
||||
pendingVerification = {
|
||||
requestId: attemptContext.requestId,
|
||||
retries: attemptContext.retries + 1,
|
||||
};
|
||||
evaluate(false, "retry_scroll");
|
||||
return;
|
||||
}
|
||||
|
||||
input.log(
|
||||
"attempt_failed",
|
||||
getLogContext({
|
||||
retries: attemptContext.retries,
|
||||
retryDisposition,
|
||||
measurementState: getDetailedMeasurementState(measurementState),
|
||||
})
|
||||
);
|
||||
pendingVerification = null;
|
||||
if (isRequestAttempt && currentRequest) {
|
||||
input.warn({
|
||||
agentId: input.getAgentId(),
|
||||
reason: currentRequest.reason,
|
||||
});
|
||||
}
|
||||
setBlockedReason(
|
||||
isRequestAttempt ? "waiting_for_post_layout_verification" : null
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const runAttempt = (animated: boolean) => {
|
||||
const measurementState = input.getMeasurementState();
|
||||
const attemptContext: AttemptContext = {
|
||||
requestId: pendingRequest?.id ?? null,
|
||||
retries: pendingVerification?.retries ?? 0,
|
||||
startedContentHeight: measurementState.contentHeight,
|
||||
startedOffsetY: measurementState.offsetY,
|
||||
startedViewportHeight: measurementState.viewportHeight,
|
||||
};
|
||||
pendingVerification = attemptContext;
|
||||
input.log(
|
||||
"attempt_started",
|
||||
getLogContext({
|
||||
animated,
|
||||
retries: attemptContext.retries,
|
||||
measurementState: getDetailedMeasurementState(measurementState),
|
||||
})
|
||||
);
|
||||
input.scrollToBottom(animated);
|
||||
scheduleVerification(attemptContext);
|
||||
setBlockedReason(deriveDriverBlockedReason(input.getMeasurementState()));
|
||||
};
|
||||
|
||||
const evaluate = (
|
||||
animated: boolean,
|
||||
reason:
|
||||
| "request_created"
|
||||
| "viewport_change"
|
||||
| "content_size_change"
|
||||
| "scroll_near_bottom_change"
|
||||
| "history_readiness_change"
|
||||
| "manual_reevaluate"
|
||||
| "retry_scroll"
|
||||
) => {
|
||||
input.log(
|
||||
"evaluate_called",
|
||||
getLogContext({
|
||||
evaluateReason: reason,
|
||||
animated,
|
||||
hasAttemptHandle: attemptHandle !== null,
|
||||
hasVerificationHandle: verificationHandle !== null,
|
||||
pendingVerificationRequestId: pendingVerification?.requestId ?? null,
|
||||
pendingVerificationRetries: pendingVerification?.retries ?? null,
|
||||
measurementState: getDetailedMeasurementState(input.getMeasurementState()),
|
||||
})
|
||||
);
|
||||
if (attemptHandle) {
|
||||
return;
|
||||
}
|
||||
attemptHandle = input.scheduleFrame({
|
||||
kind: "attempt",
|
||||
callback: () => {
|
||||
attemptHandle = null;
|
||||
const measurementState = input.getMeasurementState();
|
||||
const nextBlockedReason = deriveDriverBlockedReason(measurementState);
|
||||
setBlockedReason(nextBlockedReason);
|
||||
|
||||
const shouldAttemptForPendingRequest =
|
||||
pendingRequest !== null && nextBlockedReason === null;
|
||||
const shouldAttemptForStickyVerification =
|
||||
mode === "sticky-bottom" &&
|
||||
pendingVerification !== null &&
|
||||
nextBlockedReason === null;
|
||||
|
||||
if (
|
||||
!shouldAttemptForPendingRequest &&
|
||||
!shouldAttemptForStickyVerification
|
||||
) {
|
||||
input.log(
|
||||
"attempt_started",
|
||||
getLogContext({
|
||||
attemptPhase: "skipped",
|
||||
evaluateReason: reason,
|
||||
nextBlockedReason,
|
||||
shouldAttemptForPendingRequest,
|
||||
shouldAttemptForStickyVerification,
|
||||
measurementState: getDetailedMeasurementState(measurementState),
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
runAttempt(animated);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const createRequest = (request: BottomAnchorRouteRequest | BottomAnchorLocalRequest) => {
|
||||
const existing = pendingRequest;
|
||||
if (existing) {
|
||||
input.log(
|
||||
"request_cancelled",
|
||||
getLogContext({
|
||||
cancelledRequestReason: existing.reason,
|
||||
cancelReason: "replaced_by_new_request",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
cancelPendingAttempt();
|
||||
const nextRequest: BottomAnchorRequest = {
|
||||
id: requestSequence + 1,
|
||||
agentId: request.agentId,
|
||||
reason: request.reason,
|
||||
requestKey:
|
||||
"requestKey" in request
|
||||
? request.requestKey
|
||||
: `${request.agentId}:${request.reason}:${requestSequence + 1}`,
|
||||
};
|
||||
requestSequence = nextRequest.id;
|
||||
pendingRequest = nextRequest;
|
||||
pendingVerification = null;
|
||||
setModeInternal(
|
||||
"requestKey" in request
|
||||
? "sticky-bottom"
|
||||
: __private__.deriveModeForLocalRequest({ reason: request.reason })
|
||||
);
|
||||
input.log(
|
||||
"request_created",
|
||||
getLogContext({ requestReason: request.reason })
|
||||
);
|
||||
evaluate(request.reason === "jump-to-bottom", "request_created");
|
||||
};
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
cancelPendingAttempt();
|
||||
},
|
||||
getSnapshot() {
|
||||
return {
|
||||
mode,
|
||||
pendingRequest,
|
||||
pendingVerification,
|
||||
blockedReason,
|
||||
};
|
||||
},
|
||||
resetForAgent() {
|
||||
lastRouteRequestKey = null;
|
||||
pendingRequest = null;
|
||||
blockedReason = null;
|
||||
cancelPendingAttempt();
|
||||
stickyMeasurementRevision = 0;
|
||||
lastVerifiedStickyMeasurementRevision = 0;
|
||||
mode = "sticky-bottom";
|
||||
input.onModeChange("sticky-bottom");
|
||||
},
|
||||
applyRouteRequest(request) {
|
||||
if (!request) {
|
||||
return;
|
||||
}
|
||||
if (lastRouteRequestKey === request.requestKey) {
|
||||
return;
|
||||
}
|
||||
lastRouteRequestKey = request.requestKey;
|
||||
createRequest(request);
|
||||
},
|
||||
requestLocalAnchor(request) {
|
||||
createRequest(request);
|
||||
},
|
||||
detachByUser() {
|
||||
if (mode === "detached") {
|
||||
return;
|
||||
}
|
||||
cancelPendingRequest("user_scrolled_away");
|
||||
setModeInternal("detached");
|
||||
input.log("detached_by_user", getLogContext());
|
||||
},
|
||||
handleViewportMetricsChange(params) {
|
||||
if (
|
||||
params.previousViewportWidth !== params.viewportWidth ||
|
||||
params.previousViewportHeight !== params.viewportHeight
|
||||
) {
|
||||
markStickyMeasurementChanged();
|
||||
}
|
||||
const shouldRestick = __private__.shouldRestickOnViewportChange({
|
||||
mode,
|
||||
previousViewportWidth: params.previousViewportWidth,
|
||||
viewportWidth: params.viewportWidth,
|
||||
previousViewportHeight: params.previousViewportHeight,
|
||||
viewportHeight: params.viewportHeight,
|
||||
});
|
||||
if (shouldRestick && !pendingRequest) {
|
||||
pendingVerification = { requestId: null, retries: 0 };
|
||||
}
|
||||
if (shouldRestick || pendingRequest) {
|
||||
evaluate(false, "viewport_change");
|
||||
}
|
||||
},
|
||||
handleContentSizeChange(params) {
|
||||
if (params.previousContentHeight !== params.contentHeight) {
|
||||
markStickyMeasurementChanged();
|
||||
}
|
||||
const shouldRestick = __private__.shouldRestickOnContentChange({
|
||||
mode,
|
||||
previousContentHeight: params.previousContentHeight,
|
||||
contentHeight: params.contentHeight,
|
||||
});
|
||||
if (shouldRestick && !pendingRequest) {
|
||||
pendingVerification = { requestId: null, retries: 0 };
|
||||
}
|
||||
if (shouldRestick || pendingRequest) {
|
||||
evaluate(false, "content_size_change");
|
||||
}
|
||||
},
|
||||
prepareForStickyViewportChange() {
|
||||
if (mode !== "sticky-bottom") {
|
||||
return;
|
||||
}
|
||||
markStickyMeasurementChanged();
|
||||
},
|
||||
prepareForStickyContentChange() {
|
||||
if (mode !== "sticky-bottom") {
|
||||
return;
|
||||
}
|
||||
markStickyMeasurementChanged();
|
||||
},
|
||||
handleScrollNearBottomChange(params) {
|
||||
const { nextIsNearBottom, scrollDelta } = params;
|
||||
if (
|
||||
nextIsNearBottom &&
|
||||
mode === "sticky-bottom" &&
|
||||
stickyMeasurementRevision !== lastVerifiedStickyMeasurementRevision
|
||||
) {
|
||||
markStickyMeasurementVerified();
|
||||
}
|
||||
const hasUnverifiedStickyMeasurementChange =
|
||||
stickyMeasurementRevision !== lastVerifiedStickyMeasurementRevision;
|
||||
if (
|
||||
__private__.shouldDetachFromScrollAway({
|
||||
mode,
|
||||
nextIsNearBottom,
|
||||
scrollDelta,
|
||||
hasPendingRequest: pendingRequest !== null,
|
||||
hasPendingVerification: pendingVerification !== null,
|
||||
hasUnverifiedStickyMeasurementChange,
|
||||
})
|
||||
) {
|
||||
this.detachByUser();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
mode === "sticky-bottom" &&
|
||||
!nextIsNearBottom &&
|
||||
hasUnverifiedStickyMeasurementChange
|
||||
) {
|
||||
if (!pendingRequest && !pendingVerification) {
|
||||
pendingVerification = { requestId: null, retries: 0 };
|
||||
}
|
||||
evaluate(false, "scroll_near_bottom_change");
|
||||
return;
|
||||
}
|
||||
if (nextIsNearBottom && pendingRequest) {
|
||||
evaluate(false, "scroll_near_bottom_change");
|
||||
}
|
||||
},
|
||||
notifyAuthoritativeHistoryMaybeChanged() {
|
||||
if (!pendingVerification && !pendingRequest) {
|
||||
return;
|
||||
}
|
||||
evaluate(false, "history_readiness_change");
|
||||
},
|
||||
reevaluate(animated = false) {
|
||||
evaluate(animated, "manual_reevaluate");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const __private__ = {
|
||||
createBottomAnchorControllerDriver,
|
||||
deriveBottomAnchorBlockedReason,
|
||||
deriveVerificationBlockedReason,
|
||||
deriveRetryDisposition,
|
||||
deriveModeForLocalRequest(input: {
|
||||
reason: BottomAnchorLocalRequest["reason"];
|
||||
}): BottomAnchorMode {
|
||||
return "sticky-bottom";
|
||||
},
|
||||
shouldRestickOnViewportChange(input: {
|
||||
mode: BottomAnchorMode;
|
||||
previousViewportWidth: number;
|
||||
viewportWidth: number;
|
||||
previousViewportHeight: number;
|
||||
viewportHeight: number;
|
||||
}): boolean {
|
||||
return (
|
||||
input.mode === "sticky-bottom" &&
|
||||
((input.previousViewportHeight > 0 &&
|
||||
input.viewportHeight > 0 &&
|
||||
input.previousViewportHeight !== input.viewportHeight) ||
|
||||
(input.previousViewportWidth > 0 &&
|
||||
input.viewportWidth > 0 &&
|
||||
input.previousViewportWidth !== input.viewportWidth))
|
||||
);
|
||||
},
|
||||
shouldRestickOnContentChange(input: {
|
||||
mode: BottomAnchorMode;
|
||||
previousContentHeight: number;
|
||||
contentHeight: number;
|
||||
}): boolean {
|
||||
return (
|
||||
input.mode === "sticky-bottom" &&
|
||||
input.previousContentHeight > 0 &&
|
||||
input.contentHeight > input.previousContentHeight
|
||||
);
|
||||
},
|
||||
shouldDetachFromScrollAway(input: {
|
||||
mode: BottomAnchorMode;
|
||||
nextIsNearBottom: boolean;
|
||||
scrollDelta: number;
|
||||
hasPendingRequest: boolean;
|
||||
hasPendingVerification: boolean;
|
||||
hasUnverifiedStickyMeasurementChange: boolean;
|
||||
}): boolean {
|
||||
const scrolledAwayIntentionally =
|
||||
Math.abs(input.scrollDelta) >= USER_SCROLL_AWAY_DELTA_PX;
|
||||
return (
|
||||
input.mode === "sticky-bottom" &&
|
||||
!input.nextIsNearBottom &&
|
||||
!input.hasPendingRequest &&
|
||||
!input.hasPendingVerification &&
|
||||
(!input.hasUnverifiedStickyMeasurementChange || scrolledAwayIntentionally)
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export function useBottomAnchorController(input: {
|
||||
agentId: string;
|
||||
routeRequest: BottomAnchorRouteRequest | null;
|
||||
isAuthoritativeHistoryReady: boolean;
|
||||
renderStrategy: string;
|
||||
transportBehavior: BottomAnchorTransportBehavior;
|
||||
getMeasurementState: () => ControllerMeasurementState;
|
||||
isNearBottom: () => boolean;
|
||||
scrollToBottom: (animated: boolean) => void;
|
||||
}) {
|
||||
const [mode, setMode] = useState<BottomAnchorMode>("sticky-bottom");
|
||||
const agentIdRef = useRef(input.agentId);
|
||||
const readinessRef = useRef(input.isAuthoritativeHistoryReady);
|
||||
const renderStrategyRef = useRef(input.renderStrategy);
|
||||
const transportBehaviorRef = useRef(input.transportBehavior);
|
||||
const getMeasurementStateRef = useRef(input.getMeasurementState);
|
||||
const isNearBottomRef = useRef(input.isNearBottom);
|
||||
const scrollToBottomRef = useRef(input.scrollToBottom);
|
||||
const driverRef = useRef<BottomAnchorControllerDriver | null>(null);
|
||||
|
||||
agentIdRef.current = input.agentId;
|
||||
readinessRef.current = input.isAuthoritativeHistoryReady;
|
||||
renderStrategyRef.current = input.renderStrategy;
|
||||
transportBehaviorRef.current = input.transportBehavior;
|
||||
getMeasurementStateRef.current = input.getMeasurementState;
|
||||
isNearBottomRef.current = input.isNearBottom;
|
||||
scrollToBottomRef.current = input.scrollToBottom;
|
||||
|
||||
if (!driverRef.current) {
|
||||
driverRef.current = __private__.createBottomAnchorControllerDriver({
|
||||
getAgentId: () => agentIdRef.current,
|
||||
getIsAuthoritativeHistoryReady: () => readinessRef.current,
|
||||
getRenderStrategy: () => renderStrategyRef.current,
|
||||
getTransportBehavior: () => transportBehaviorRef.current,
|
||||
getMeasurementState: () => getMeasurementStateRef.current(),
|
||||
isNearBottom: () => isNearBottomRef.current(),
|
||||
scrollToBottom: (animated) => scrollToBottomRef.current(animated),
|
||||
onModeChange: (nextMode) => setMode(nextMode),
|
||||
log: (event, details) => logBottomAnchorEvent(event, details),
|
||||
warn: (details) => {
|
||||
console.warn("[BottomAnchor] request could not be fulfilled", details);
|
||||
},
|
||||
scheduleFrame: ({ callback, delayFrames }) =>
|
||||
scheduleAnimationFrameWithDelay({ callback, delayFrames }),
|
||||
cancelFrame: (handle) => cancelScheduledAnimationFrame(handle),
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
driverRef.current?.resetForAgent();
|
||||
}, [input.agentId]);
|
||||
|
||||
useEffect(() => {
|
||||
driverRef.current?.applyRouteRequest(input.routeRequest);
|
||||
}, [input.routeRequest]);
|
||||
|
||||
useEffect(() => {
|
||||
driverRef.current?.notifyAuthoritativeHistoryMaybeChanged();
|
||||
}, [input.isAuthoritativeHistoryReady]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
driverRef.current?.destroy();
|
||||
driverRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
mode,
|
||||
requestLocalAnchor(request: BottomAnchorLocalRequest) {
|
||||
driverRef.current?.requestLocalAnchor(request);
|
||||
},
|
||||
detachByUser() {
|
||||
driverRef.current?.detachByUser();
|
||||
},
|
||||
handleViewportLayout() {},
|
||||
handleViewportMetricsChange(params: {
|
||||
previousViewportWidth: number;
|
||||
viewportWidth: number;
|
||||
previousViewportHeight: number;
|
||||
viewportHeight: number;
|
||||
}) {
|
||||
driverRef.current?.handleViewportMetricsChange(params);
|
||||
},
|
||||
handleContentSizeChange(params: {
|
||||
previousContentHeight: number;
|
||||
contentHeight: number;
|
||||
}) {
|
||||
driverRef.current?.handleContentSizeChange(params);
|
||||
},
|
||||
prepareForStickyViewportChange() {
|
||||
driverRef.current?.prepareForStickyViewportChange();
|
||||
},
|
||||
prepareForStickyContentChange() {
|
||||
driverRef.current?.prepareForStickyContentChange();
|
||||
},
|
||||
handleScrollNearBottomChange(params: {
|
||||
nextIsNearBottom: boolean;
|
||||
scrollDelta: number;
|
||||
}) {
|
||||
driverRef.current?.handleScrollNearBottomChange(params);
|
||||
},
|
||||
reevaluate(animated = false) {
|
||||
driverRef.current?.reevaluate(animated);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
hostHasDirectEndpoint,
|
||||
registryHasDirectEndpoint,
|
||||
reconcileDesktopStartupRegistry,
|
||||
resolveManagedDesktopStartupStatus,
|
||||
type HostProfile,
|
||||
} from './daemon-registry-context'
|
||||
|
||||
@@ -10,6 +12,12 @@ function makeHost(input: Partial<HostProfile> & Pick<HostProfile, 'serverId'>):
|
||||
return {
|
||||
serverId: input.serverId,
|
||||
label: input.label ?? input.serverId,
|
||||
lifecycle: input.lifecycle ?? {
|
||||
managed: false,
|
||||
managedRuntimeId: null,
|
||||
managedRuntimeVersion: null,
|
||||
associatedServerId: null,
|
||||
},
|
||||
connections: input.connections ?? [],
|
||||
preferredConnectionId: input.preferredConnectionId ?? null,
|
||||
createdAt: input.createdAt ?? now,
|
||||
@@ -21,7 +29,7 @@ describe('hostHasDirectEndpoint', () => {
|
||||
it('returns true when host has matching direct endpoint', () => {
|
||||
const host = makeHost({
|
||||
serverId: 'srv_local',
|
||||
connections: [{ id: 'direct:localhost:6767', type: 'direct', endpoint: 'localhost:6767' }],
|
||||
connections: [{ id: 'direct:localhost:6767', type: 'directTcp', endpoint: 'localhost:6767' }],
|
||||
preferredConnectionId: 'direct:localhost:6767',
|
||||
})
|
||||
|
||||
@@ -51,12 +59,12 @@ describe('registryHasDirectEndpoint', () => {
|
||||
const hosts: HostProfile[] = [
|
||||
makeHost({
|
||||
serverId: 'srv_one',
|
||||
connections: [{ id: 'direct:127.0.0.1:7777', type: 'direct', endpoint: '127.0.0.1:7777' }],
|
||||
connections: [{ id: 'direct:127.0.0.1:7777', type: 'directTcp', endpoint: '127.0.0.1:7777' }],
|
||||
preferredConnectionId: 'direct:127.0.0.1:7777',
|
||||
}),
|
||||
makeHost({
|
||||
serverId: 'srv_two',
|
||||
connections: [{ id: 'direct:localhost:6767', type: 'direct', endpoint: 'localhost:6767' }],
|
||||
connections: [{ id: 'direct:localhost:6767', type: 'directTcp', endpoint: 'localhost:6767' }],
|
||||
preferredConnectionId: 'direct:localhost:6767',
|
||||
}),
|
||||
]
|
||||
@@ -68,7 +76,7 @@ describe('registryHasDirectEndpoint', () => {
|
||||
const hosts: HostProfile[] = [
|
||||
makeHost({
|
||||
serverId: 'srv_one',
|
||||
connections: [{ id: 'direct:127.0.0.1:7777', type: 'direct', endpoint: '127.0.0.1:7777' }],
|
||||
connections: [{ id: 'direct:127.0.0.1:7777', type: 'directTcp', endpoint: '127.0.0.1:7777' }],
|
||||
preferredConnectionId: 'direct:127.0.0.1:7777',
|
||||
}),
|
||||
]
|
||||
@@ -76,3 +84,240 @@ describe('registryHasDirectEndpoint', () => {
|
||||
expect(registryHasDirectEndpoint(hosts, 'localhost:6767')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reconcileDesktopStartupRegistry', () => {
|
||||
it('seeds managed and localhost connections as normal host entries', () => {
|
||||
const now = '2026-03-08T00:00:00.000Z'
|
||||
|
||||
const result = reconcileDesktopStartupRegistry({
|
||||
existing: [],
|
||||
managed: {
|
||||
serverId: 'srv_managed',
|
||||
hostname: 'managed-host',
|
||||
runtimeId: 'runtime_1',
|
||||
runtimeVersion: '1.2.3',
|
||||
transportType: 'socket',
|
||||
transportPath: '/Users/test/.paseo-test/paseo.sock',
|
||||
associatedServerId: 'srv_managed',
|
||||
},
|
||||
localhost: {
|
||||
serverId: 'srv_localhost',
|
||||
hostname: 'local-dev',
|
||||
endpoint: 'localhost:6767',
|
||||
},
|
||||
now,
|
||||
})
|
||||
|
||||
expect(result).toEqual([
|
||||
makeHost({
|
||||
serverId: 'srv_managed',
|
||||
label: 'managed-host',
|
||||
lifecycle: {
|
||||
managed: true,
|
||||
managedRuntimeId: 'runtime_1',
|
||||
managedRuntimeVersion: '1.2.3',
|
||||
associatedServerId: 'srv_managed',
|
||||
},
|
||||
connections: [
|
||||
{
|
||||
id: 'socket:/Users/test/.paseo-test/paseo.sock',
|
||||
type: 'directSocket',
|
||||
path: '/Users/test/.paseo-test/paseo.sock',
|
||||
},
|
||||
],
|
||||
preferredConnectionId: 'socket:/Users/test/.paseo-test/paseo.sock',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}),
|
||||
makeHost({
|
||||
serverId: 'srv_localhost',
|
||||
label: 'local-dev',
|
||||
connections: [
|
||||
{
|
||||
id: 'direct:localhost:6767',
|
||||
type: 'directTcp',
|
||||
endpoint: 'localhost:6767',
|
||||
},
|
||||
],
|
||||
preferredConnectionId: 'direct:localhost:6767',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps managed and localhost connections together when they resolve to the same server', () => {
|
||||
const now = '2026-03-08T00:00:00.000Z'
|
||||
|
||||
const result = reconcileDesktopStartupRegistry({
|
||||
existing: [],
|
||||
managed: {
|
||||
serverId: 'srv_shared',
|
||||
hostname: 'devbox',
|
||||
runtimeId: 'runtime_1',
|
||||
runtimeVersion: '1.2.3',
|
||||
transportType: 'socket',
|
||||
transportPath: '/Users/test/.paseo-test/paseo.sock',
|
||||
associatedServerId: 'srv_shared',
|
||||
},
|
||||
localhost: {
|
||||
serverId: 'srv_shared',
|
||||
hostname: 'devbox',
|
||||
endpoint: 'localhost:6767',
|
||||
},
|
||||
now,
|
||||
})
|
||||
|
||||
expect(result).toEqual([
|
||||
makeHost({
|
||||
serverId: 'srv_shared',
|
||||
label: 'devbox',
|
||||
lifecycle: {
|
||||
managed: true,
|
||||
managedRuntimeId: 'runtime_1',
|
||||
managedRuntimeVersion: '1.2.3',
|
||||
associatedServerId: 'srv_shared',
|
||||
},
|
||||
connections: [
|
||||
{
|
||||
id: 'socket:/Users/test/.paseo-test/paseo.sock',
|
||||
type: 'directSocket',
|
||||
path: '/Users/test/.paseo-test/paseo.sock',
|
||||
},
|
||||
{
|
||||
id: 'direct:localhost:6767',
|
||||
type: 'directTcp',
|
||||
endpoint: 'localhost:6767',
|
||||
},
|
||||
],
|
||||
preferredConnectionId: 'socket:/Users/test/.paseo-test/paseo.sock',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('is idempotent for repeated desktop startup reconciliation', () => {
|
||||
const now = '2026-03-08T00:00:00.000Z'
|
||||
|
||||
const first = reconcileDesktopStartupRegistry({
|
||||
existing: [],
|
||||
managed: {
|
||||
serverId: 'srv_shared',
|
||||
hostname: 'devbox',
|
||||
runtimeId: 'runtime_1',
|
||||
runtimeVersion: '1.2.3',
|
||||
transportType: 'socket',
|
||||
transportPath: '/Users/test/.paseo-test/paseo.sock',
|
||||
associatedServerId: 'srv_shared',
|
||||
},
|
||||
localhost: {
|
||||
serverId: 'srv_shared',
|
||||
hostname: 'devbox',
|
||||
endpoint: 'localhost:6767',
|
||||
},
|
||||
now,
|
||||
})
|
||||
|
||||
const second = reconcileDesktopStartupRegistry({
|
||||
existing: first,
|
||||
managed: {
|
||||
serverId: 'srv_shared',
|
||||
hostname: 'devbox',
|
||||
runtimeId: 'runtime_1',
|
||||
runtimeVersion: '1.2.3',
|
||||
transportType: 'socket',
|
||||
transportPath: '/Users/test/.paseo-test/paseo.sock',
|
||||
associatedServerId: 'srv_shared',
|
||||
},
|
||||
localhost: {
|
||||
serverId: 'srv_shared',
|
||||
hostname: 'devbox',
|
||||
endpoint: 'localhost:6767',
|
||||
},
|
||||
now: '2026-03-09T00:00:00.000Z',
|
||||
})
|
||||
|
||||
expect(second).toEqual(first)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveManagedDesktopStartupStatus', () => {
|
||||
it('starts the managed daemon when management is enabled', async () => {
|
||||
const managedStatus = {
|
||||
runtimeId: 'runtime_1',
|
||||
runtimeVersion: '1.2.3',
|
||||
runtimeRoot: '/runtime',
|
||||
managedHome: '/home',
|
||||
transportType: 'socket',
|
||||
transportPath: '/tmp/paseo.sock',
|
||||
daemonPid: 123,
|
||||
daemonRunning: true,
|
||||
daemonStatus: 'running',
|
||||
logPath: '/tmp/daemon.log',
|
||||
serverId: 'srv_managed',
|
||||
hostname: 'managed-host',
|
||||
relayEnabled: true,
|
||||
tcpEnabled: false,
|
||||
tcpListen: null,
|
||||
cliShimPath: null,
|
||||
}
|
||||
let startCalls = 0
|
||||
let statusCalls = 0
|
||||
|
||||
const result = await resolveManagedDesktopStartupStatus({
|
||||
settings: { manageBuiltInDaemon: true },
|
||||
startManagedDaemonFn: async () => {
|
||||
startCalls += 1
|
||||
return managedStatus
|
||||
},
|
||||
getManagedDaemonStatusFn: async () => {
|
||||
statusCalls += 1
|
||||
return managedStatus
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toEqual(managedStatus)
|
||||
expect(startCalls).toBe(1)
|
||||
expect(statusCalls).toBe(0)
|
||||
})
|
||||
|
||||
it('only reads managed daemon status when management is paused', async () => {
|
||||
const managedStatus = {
|
||||
runtimeId: 'runtime_1',
|
||||
runtimeVersion: '1.2.3',
|
||||
runtimeRoot: '/runtime',
|
||||
managedHome: '/home',
|
||||
transportType: 'socket',
|
||||
transportPath: '/tmp/paseo.sock',
|
||||
daemonPid: null,
|
||||
daemonRunning: false,
|
||||
daemonStatus: 'stopped',
|
||||
logPath: '/tmp/daemon.log',
|
||||
serverId: null,
|
||||
hostname: null,
|
||||
relayEnabled: true,
|
||||
tcpEnabled: false,
|
||||
tcpListen: null,
|
||||
cliShimPath: null,
|
||||
}
|
||||
let startCalls = 0
|
||||
let statusCalls = 0
|
||||
|
||||
const result = await resolveManagedDesktopStartupStatus({
|
||||
settings: { manageBuiltInDaemon: false },
|
||||
startManagedDaemonFn: async () => {
|
||||
startCalls += 1
|
||||
return managedStatus
|
||||
},
|
||||
getManagedDaemonStatusFn: async () => {
|
||||
statusCalls += 1
|
||||
return managedStatus
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toEqual(managedStatus)
|
||||
expect(startCalls).toBe(0)
|
||||
expect(statusCalls).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -34,8 +34,9 @@ import {
|
||||
import {
|
||||
useSessionStore,
|
||||
type Agent,
|
||||
type WorkspaceDescriptor,
|
||||
type SessionState,
|
||||
type WorkspaceDescriptor,
|
||||
normalizeWorkspaceDescriptor,
|
||||
} from "@/stores/session-store";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
import type { AgentDirectoryEntry } from "@/types/agent-directory";
|
||||
@@ -52,7 +53,6 @@ import {
|
||||
normalizeAgentSnapshot,
|
||||
} from "@/utils/agent-snapshots";
|
||||
import { resolveProjectPlacement } from "@/utils/project-placement";
|
||||
import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity";
|
||||
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
|
||||
@@ -130,24 +130,6 @@ type WorkspaceUpdatePayload = Extract<
|
||||
const getAgentIdFromUpdate = (update: AgentUpdatePayload): string =>
|
||||
update.kind === "remove" ? update.agentId : update.agent.id;
|
||||
|
||||
function normalizeWorkspaceDescriptor(
|
||||
payload: Extract<WorkspaceUpdatePayload, { kind: "upsert" }>["workspace"]
|
||||
): WorkspaceDescriptor {
|
||||
const activityAt = payload.activityAt
|
||||
? new Date(payload.activityAt)
|
||||
: null;
|
||||
return {
|
||||
id: normalizeWorkspaceIdentity(payload.id) ?? payload.id,
|
||||
projectId: payload.projectId,
|
||||
name: payload.name,
|
||||
status: payload.status,
|
||||
activityAt:
|
||||
activityAt && !Number.isNaN(activityAt.getTime())
|
||||
? activityAt
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Module-level pending agent updates buffer (scoped by serverId)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -258,6 +240,9 @@ function SessionProviderInternal({
|
||||
const markAgentHistorySynchronized = useSessionStore(
|
||||
(state) => state.markAgentHistorySynchronized
|
||||
);
|
||||
const setAgentAuthoritativeHistoryApplied = useSessionStore(
|
||||
(state) => state.setAgentAuthoritativeHistoryApplied
|
||||
);
|
||||
const setHasHydratedAgents = useSessionStore(
|
||||
(state) => state.setHasHydratedAgents
|
||||
);
|
||||
@@ -737,6 +722,7 @@ function SessionProviderInternal({
|
||||
next.delete(agentId);
|
||||
return next;
|
||||
});
|
||||
setAgentAuthoritativeHistoryApplied(serverId, agentId, false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -789,6 +775,8 @@ function SessionProviderInternal({
|
||||
) => {
|
||||
const agentId = payload.agentId;
|
||||
const initKey = getInitKey(serverId, agentId);
|
||||
const shouldMarkAuthoritativeHistoryApplied =
|
||||
payload.direction === "tail" || payload.direction === "after";
|
||||
|
||||
// Read current store state
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
@@ -908,6 +896,9 @@ function SessionProviderInternal({
|
||||
});
|
||||
}
|
||||
|
||||
if (shouldMarkAuthoritativeHistoryApplied) {
|
||||
setAgentAuthoritativeHistoryApplied(serverId, agentId, true);
|
||||
}
|
||||
if (result.initResolution === "resolve") {
|
||||
resolveInitDeferred(initKey);
|
||||
}
|
||||
@@ -922,6 +913,7 @@ function SessionProviderInternal({
|
||||
markAgentHistorySynchronized,
|
||||
requestCanonicalCatchUp,
|
||||
serverId,
|
||||
setAgentAuthoritativeHistoryApplied,
|
||||
setAgentStreamTail,
|
||||
setAgentTimelineCursor,
|
||||
setInitializingAgents,
|
||||
|
||||
@@ -41,7 +41,7 @@ export function DesktopPermissionRow({
|
||||
<Text style={styles.permissionStatusText}>Granted</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Button variant="secondary" size="sm" onPress={onRequest} disabled={isRequesting}>
|
||||
<Button variant="outline" size="sm" onPress={onRequest} disabled={isRequesting}>
|
||||
{isRequesting ? "Requesting..." : "Request"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { View, Text, Pressable } from "react-native";
|
||||
import { View, Text } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { RotateCw } from "lucide-react-native";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DesktopPermissionRow } from "@/desktop/components/desktop-permission-row";
|
||||
import { useDesktopPermissions } from "@/desktop/permissions/use-desktop-permissions";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
|
||||
export function DesktopPermissionsSection() {
|
||||
const { theme } = useUnistyles();
|
||||
@@ -22,26 +24,23 @@ export function DesktopPermissionsSection() {
|
||||
const isBusy = isRefreshing || requestingPermission !== null;
|
||||
|
||||
return (
|
||||
<View style={styles.section}>
|
||||
<View style={settingsStyles.section}>
|
||||
<View style={styles.permissionSectionHeader}>
|
||||
<Text style={styles.sectionTitle}>Desktop Permissions</Text>
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.permissionRefreshButton,
|
||||
isBusy && styles.permissionRefreshButtonDisabled,
|
||||
pressed && { opacity: 0.85 },
|
||||
]}
|
||||
<Text style={settingsStyles.sectionTitle}>Desktop Permissions</Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={<RotateCw size={theme.iconSize.md} color={theme.colors.foregroundMuted} />}
|
||||
onPress={() => {
|
||||
void refreshPermissions();
|
||||
}}
|
||||
disabled={isBusy}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Refresh desktop permissions"
|
||||
>
|
||||
<RotateCw size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
{isRefreshing ? "Refreshing..." : "Refresh"}
|
||||
</Button>
|
||||
</View>
|
||||
<View style={styles.audioCard}>
|
||||
<View style={settingsStyles.card}>
|
||||
<DesktopPermissionRow
|
||||
title="Notifications"
|
||||
status={snapshot?.notifications ?? null}
|
||||
@@ -65,16 +64,6 @@ export function DesktopPermissionsSection() {
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
section: {
|
||||
marginBottom: theme.spacing[6],
|
||||
},
|
||||
sectionTitle: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
marginBottom: 0,
|
||||
marginLeft: theme.spacing[1],
|
||||
},
|
||||
permissionSectionHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
@@ -82,24 +71,4 @@ const styles = StyleSheet.create((theme) => ({
|
||||
gap: theme.spacing[2],
|
||||
marginBottom: theme.spacing[3],
|
||||
},
|
||||
permissionRefreshButton: {
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
permissionRefreshButtonDisabled: {
|
||||
opacity: theme.opacity[50],
|
||||
},
|
||||
audioCard: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
overflow: "hidden",
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -1,64 +1,118 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { Alert, Text, View } from "react-native";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ActivityIndicator, Alert, Image, Text, View } from "react-native";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import * as QRCode from "qrcode";
|
||||
import { useFocusEffect } from "@react-navigation/native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { ArrowUpRight, Play, Pause, RotateCw, Terminal, Copy, FileText, Smartphone } from "lucide-react-native";
|
||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import {
|
||||
buildDaemonUpdateDiagnostics,
|
||||
formatVersionWithPrefix,
|
||||
getLocalDaemonVersion,
|
||||
isVersionMismatch,
|
||||
runLocalDaemonUpdate,
|
||||
shouldShowDesktopUpdateSection,
|
||||
} from "@/desktop/updates/desktop-updates";
|
||||
import {
|
||||
getManagedDaemonLogs,
|
||||
getManagedDaemonPairing,
|
||||
getManagedDaemonStatus,
|
||||
installManagedCliShim,
|
||||
restartManagedDaemon,
|
||||
shouldUseManagedDesktopDaemon,
|
||||
startManagedDaemon,
|
||||
stopManagedDaemon,
|
||||
uninstallManagedCliShim,
|
||||
type ManagedDaemonLogs,
|
||||
type ManagedPairingOffer,
|
||||
type ManagedDaemonStatus,
|
||||
type CliManualInstructions,
|
||||
} from "@/desktop/managed-runtime/managed-runtime";
|
||||
|
||||
export interface LocalDaemonSectionProps {
|
||||
appVersion: string | null;
|
||||
}
|
||||
|
||||
export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
|
||||
const showSection = shouldShowDesktopUpdateSection();
|
||||
const [localDaemonVersion, setLocalDaemonVersion] = useState<string | null>(null);
|
||||
const [localDaemonVersionError, setLocalDaemonVersionError] = useState<string | null>(null);
|
||||
const [isUpdatingLocalDaemon, setIsUpdatingLocalDaemon] = useState(false);
|
||||
const [localDaemonUpdateMessage, setLocalDaemonUpdateMessage] = useState<string | null>(null);
|
||||
const [localDaemonUpdateDiagnostics, setLocalDaemonUpdateDiagnostics] = useState<string | null>(
|
||||
const { theme } = useUnistyles();
|
||||
const showSection = shouldUseManagedDesktopDaemon();
|
||||
const { settings, updateSettings } = useAppSettings();
|
||||
const [managedStatus, setManagedStatus] = useState<ManagedDaemonStatus | null>(null);
|
||||
const [statusError, setStatusError] = useState<string | null>(null);
|
||||
const [isRestartingDaemon, setIsRestartingDaemon] = useState(false);
|
||||
const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false);
|
||||
const [isInstallingCli, setIsInstallingCli] = useState(false);
|
||||
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
||||
const [cliStatusMessage, setCliStatusMessage] = useState<string | null>(null);
|
||||
const [managedLogs, setManagedLogs] = useState<ManagedDaemonLogs | null>(null);
|
||||
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false);
|
||||
const [isPairingModalOpen, setIsPairingModalOpen] = useState(false);
|
||||
const [isCliInstallModalOpen, setIsCliInstallModalOpen] = useState(false);
|
||||
const [isLoadingPairing, setIsLoadingPairing] = useState(false);
|
||||
const [pairingOffer, setPairingOffer] = useState<ManagedPairingOffer | null>(null);
|
||||
const [cliInstallInstructions, setCliInstallInstructions] = useState<CliManualInstructions | null>(
|
||||
null
|
||||
);
|
||||
const [pairingStatusMessage, setPairingStatusMessage] = useState<string | null>(null);
|
||||
|
||||
const loadManagedStatus = useCallback(() => {
|
||||
if (!showSection) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.all([getManagedDaemonStatus(), getManagedDaemonLogs()])
|
||||
.then(([status, logs]) => {
|
||||
setManagedStatus(status);
|
||||
setManagedLogs(logs);
|
||||
setStatusError(null);
|
||||
})
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setStatusError(message);
|
||||
});
|
||||
}, [showSection]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (!showSection) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
void getLocalDaemonVersion().then((result) => {
|
||||
setLocalDaemonVersion(result.version);
|
||||
setLocalDaemonVersionError(result.error);
|
||||
});
|
||||
void loadManagedStatus();
|
||||
return undefined;
|
||||
}, [showSection])
|
||||
}, [loadManagedStatus, showSection])
|
||||
);
|
||||
|
||||
const localDaemonVersionText = formatVersionWithPrefix(localDaemonVersion);
|
||||
const daemonVersionMismatch = isVersionMismatch(appVersion, localDaemonVersion);
|
||||
const daemonVersionHint = localDaemonVersionError ?? "Daemon installed on this computer.";
|
||||
const localDaemonVersionText = formatVersionWithPrefix(managedStatus?.runtimeVersion ?? null);
|
||||
const daemonVersionMismatch = isVersionMismatch(appVersion, managedStatus?.runtimeVersion ?? null);
|
||||
const daemonStatusStateText =
|
||||
statusError ??
|
||||
(managedStatus?.daemonRunning
|
||||
? managedStatus?.daemonStatus ?? "running"
|
||||
: "not running");
|
||||
const daemonStatusDetailText = `PID ${managedStatus?.daemonPid ? managedStatus.daemonPid : "—"}`;
|
||||
const isDaemonManagementPaused = !settings.manageBuiltInDaemon;
|
||||
const daemonActionLabel = managedStatus?.daemonRunning ? "Restart daemon" : "Start daemon";
|
||||
const daemonActionMessage = managedStatus?.daemonRunning
|
||||
? "Restarts the built-in daemon."
|
||||
: isDaemonManagementPaused
|
||||
? "Starts the built-in daemon manually. Paseo will not auto-start it while paused."
|
||||
: "Starts the built-in daemon.";
|
||||
|
||||
const handleUpdateLocalDaemon = useCallback(() => {
|
||||
if (!showSection) {
|
||||
return;
|
||||
}
|
||||
if (isUpdatingLocalDaemon) {
|
||||
if (isRestartingDaemon) {
|
||||
return;
|
||||
}
|
||||
|
||||
void confirmDialog({
|
||||
title: "Update local daemon",
|
||||
message:
|
||||
"This updates the Paseo daemon on this computer. A restart is required afterwards.",
|
||||
confirmLabel: "Update daemon",
|
||||
title: daemonActionLabel,
|
||||
message: managedStatus?.daemonRunning
|
||||
? "This will restart the built-in daemon. The app will reconnect automatically."
|
||||
: "This will start the built-in daemon.",
|
||||
confirmLabel: daemonActionLabel,
|
||||
cancelLabel: "Cancel",
|
||||
})
|
||||
.then((confirmed) => {
|
||||
@@ -66,104 +120,351 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUpdatingLocalDaemon(true);
|
||||
setLocalDaemonUpdateMessage(null);
|
||||
setLocalDaemonUpdateDiagnostics(null);
|
||||
setIsRestartingDaemon(true);
|
||||
setStatusMessage(null);
|
||||
|
||||
void runLocalDaemonUpdate()
|
||||
.then((result) => {
|
||||
const diagnostics = buildDaemonUpdateDiagnostics(result);
|
||||
if (result.exitCode !== 0) {
|
||||
setLocalDaemonUpdateMessage(
|
||||
`Local daemon update failed (exit code ${result.exitCode}). Copy diagnostics below to troubleshoot.`
|
||||
);
|
||||
setLocalDaemonUpdateDiagnostics(diagnostics);
|
||||
return;
|
||||
}
|
||||
const action = managedStatus?.daemonRunning ? restartManagedDaemon : startManagedDaemon;
|
||||
|
||||
setLocalDaemonUpdateMessage(
|
||||
"Local daemon update finished. Restart is required: run `paseo daemon restart` on this computer."
|
||||
void action()
|
||||
.then((status) => {
|
||||
setManagedStatus(status);
|
||||
setStatusMessage(
|
||||
managedStatus?.daemonRunning ? "Daemon restarted." : "Daemon started."
|
||||
);
|
||||
if (result.stdout.trim().length > 0 || result.stderr.trim().length > 0) {
|
||||
setLocalDaemonUpdateDiagnostics(diagnostics);
|
||||
}
|
||||
|
||||
void getLocalDaemonVersion().then((versionResult) => {
|
||||
setLocalDaemonVersion(versionResult.version);
|
||||
setLocalDaemonVersionError(versionResult.error);
|
||||
});
|
||||
return loadManagedStatus();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Settings] Failed to update local daemon", error);
|
||||
console.error("[Settings] Failed to change managed daemon state", error);
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setLocalDaemonUpdateMessage(
|
||||
"Local daemon update failed before completion. Copy diagnostics below to troubleshoot."
|
||||
);
|
||||
setLocalDaemonUpdateDiagnostics(
|
||||
buildDaemonUpdateDiagnostics({
|
||||
exitCode: -1,
|
||||
stdout: "",
|
||||
stderr: message,
|
||||
})
|
||||
);
|
||||
setStatusMessage(`${daemonActionLabel} failed: ${message}`);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsUpdatingLocalDaemon(false);
|
||||
setIsRestartingDaemon(false);
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Settings] Failed to open daemon update confirmation", error);
|
||||
Alert.alert("Error", "Unable to open the daemon update confirmation dialog.");
|
||||
console.error("[Settings] Failed to open managed daemon action confirmation", error);
|
||||
Alert.alert("Error", "Unable to open the daemon confirmation dialog.");
|
||||
});
|
||||
}, [isUpdatingLocalDaemon, showSection]);
|
||||
}, [daemonActionLabel, isRestartingDaemon, loadManagedStatus, managedStatus?.daemonRunning, showSection]);
|
||||
|
||||
const handleCopyDaemonDiagnostics = useCallback(() => {
|
||||
if (!localDaemonUpdateDiagnostics) {
|
||||
const handleToggleDaemonManagement = useCallback(() => {
|
||||
if (isUpdatingDaemonManagement) {
|
||||
return;
|
||||
}
|
||||
|
||||
void Clipboard.setStringAsync(localDaemonUpdateDiagnostics)
|
||||
.then(() => {
|
||||
Alert.alert("Copied", "Daemon update diagnostics copied.");
|
||||
if (!settings.manageBuiltInDaemon) {
|
||||
setIsUpdatingDaemonManagement(true);
|
||||
setStatusMessage(null);
|
||||
void updateSettings({ manageBuiltInDaemon: true })
|
||||
.then(() => {
|
||||
setStatusMessage("Paseo will resume managing the built-in daemon on startup.");
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Settings] Failed to update built-in daemon management", error);
|
||||
Alert.alert("Error", "Unable to update built-in daemon management.");
|
||||
})
|
||||
.finally(() => {
|
||||
setIsUpdatingDaemonManagement(false);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
void confirmDialog({
|
||||
title: "Pause built-in daemon",
|
||||
message:
|
||||
"This will stop the built-in daemon immediately and prevent Paseo from auto-starting it on launch. Running agents and terminals connected to the built-in daemon will be stopped.",
|
||||
confirmLabel: "Pause and stop",
|
||||
cancelLabel: "Cancel",
|
||||
destructive: true,
|
||||
})
|
||||
.then((confirmed) => {
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUpdatingDaemonManagement(true);
|
||||
setStatusMessage(null);
|
||||
|
||||
const stopPromise = managedStatus?.daemonRunning
|
||||
? stopManagedDaemon()
|
||||
: Promise.resolve(managedStatus ?? null);
|
||||
|
||||
void stopPromise
|
||||
.then(() => updateSettings({ manageBuiltInDaemon: false }))
|
||||
.then(() => loadManagedStatus())
|
||||
.then(() => {
|
||||
setStatusMessage(
|
||||
"Paseo paused the built-in daemon and will no longer auto-start it on launch."
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Settings] Failed to pause built-in daemon management", error);
|
||||
Alert.alert("Error", "Unable to pause built-in daemon management.");
|
||||
})
|
||||
.finally(() => {
|
||||
setIsUpdatingDaemonManagement(false);
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Settings] Failed to copy daemon update diagnostics", error);
|
||||
Alert.alert("Error", "Unable to copy diagnostics.");
|
||||
console.error("[Settings] Failed to open built-in daemon pause confirmation", error);
|
||||
Alert.alert("Error", "Unable to open the daemon confirmation dialog.");
|
||||
});
|
||||
}, [localDaemonUpdateDiagnostics]);
|
||||
}, [
|
||||
isUpdatingDaemonManagement,
|
||||
loadManagedStatus,
|
||||
managedStatus,
|
||||
settings.manageBuiltInDaemon,
|
||||
updateSettings,
|
||||
]);
|
||||
|
||||
const handleToggleCliShim = useCallback(() => {
|
||||
if (!showSection || isInstallingCli) {
|
||||
return;
|
||||
}
|
||||
setIsInstallingCli(true);
|
||||
const isInstalling = !managedStatus?.cliShimPath;
|
||||
setCliStatusMessage(
|
||||
isInstalling
|
||||
? "A permissions popup may appear while Paseo installs the CLI globally."
|
||||
: null
|
||||
);
|
||||
const action = managedStatus?.cliShimPath ? uninstallManagedCliShim : installManagedCliShim;
|
||||
void action()
|
||||
.then((result) => {
|
||||
setCliStatusMessage(result.message);
|
||||
if (result.manualInstructions) {
|
||||
setCliInstallInstructions(result.manualInstructions);
|
||||
setIsCliInstallModalOpen(true);
|
||||
} else {
|
||||
setCliInstallInstructions(null);
|
||||
setIsCliInstallModalOpen(false);
|
||||
}
|
||||
return loadManagedStatus();
|
||||
})
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setCliStatusMessage(`CLI install failed: ${message}`);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsInstallingCli(false);
|
||||
});
|
||||
}, [isInstallingCli, loadManagedStatus, managedStatus?.cliShimPath, showSection]);
|
||||
|
||||
const handleCopyCliInstallCommands = useCallback(() => {
|
||||
if (!cliInstallInstructions?.commands) {
|
||||
return;
|
||||
}
|
||||
void Clipboard.setStringAsync(cliInstallInstructions.commands)
|
||||
.then(() => {
|
||||
Alert.alert("Copied", "CLI install commands copied.");
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Settings] Failed to copy CLI install commands", error);
|
||||
Alert.alert("Error", "Unable to copy CLI install commands.");
|
||||
});
|
||||
}, [cliInstallInstructions?.commands]);
|
||||
|
||||
const handleCopyLogPath = useCallback(() => {
|
||||
const logPath = managedLogs?.logPath ?? managedStatus?.logPath;
|
||||
if (!logPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
void Clipboard.setStringAsync(logPath)
|
||||
.then(() => {
|
||||
Alert.alert("Copied", "Log path copied.");
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Settings] Failed to copy log path", error);
|
||||
Alert.alert("Error", "Unable to copy log path.");
|
||||
});
|
||||
}, [managedLogs?.logPath, managedStatus?.logPath]);
|
||||
|
||||
const handleOpenLogs = useCallback(() => {
|
||||
if (!managedLogs) {
|
||||
return;
|
||||
}
|
||||
setIsLogsModalOpen(true);
|
||||
}, [managedLogs]);
|
||||
|
||||
const handleOpenPairingModal = useCallback(() => {
|
||||
if (isLoadingPairing) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPairingModalOpen(true);
|
||||
setIsLoadingPairing(true);
|
||||
setPairingStatusMessage(null);
|
||||
|
||||
void getManagedDaemonPairing()
|
||||
.then((pairing) => {
|
||||
setPairingOffer(pairing);
|
||||
if (!pairing.relayEnabled || !pairing.url) {
|
||||
setPairingStatusMessage("Relay pairing is not available.");
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setPairingOffer(null);
|
||||
setPairingStatusMessage(`Unable to load pairing offer: ${message}`);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoadingPairing(false);
|
||||
});
|
||||
}, [isLoadingPairing]);
|
||||
|
||||
const handleCopyPairingLink = useCallback(() => {
|
||||
if (!pairingOffer?.url) {
|
||||
return;
|
||||
}
|
||||
void Clipboard.setStringAsync(pairingOffer.url)
|
||||
.then(() => {
|
||||
Alert.alert("Copied", "Pairing link copied.");
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Settings] Failed to copy pairing link", error);
|
||||
Alert.alert("Error", "Unable to copy pairing link.");
|
||||
});
|
||||
}, [pairingOffer?.url]);
|
||||
|
||||
if (!showSection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Local daemon</Text>
|
||||
<View style={styles.card}>
|
||||
<View style={settingsStyles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={settingsStyles.sectionTitle}>Built-in daemon</Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={<ArrowUpRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />}
|
||||
textStyle={styles.sectionLinkText}
|
||||
style={styles.sectionLink}
|
||||
onPress={() => void openExternalUrl(ADVANCED_DAEMON_SETTINGS_URL)}
|
||||
accessibilityLabel="Open advanced daemon settings"
|
||||
>
|
||||
Advanced settings
|
||||
</Button>
|
||||
</View>
|
||||
<View style={settingsStyles.card}>
|
||||
<View style={styles.row}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowTitle}>Version</Text>
|
||||
<Text style={styles.hintText}>{daemonVersionHint}</Text>
|
||||
<Text style={styles.rowTitle}>Status</Text>
|
||||
<Text style={styles.hintText}>Only the built-in managed daemon is shown here.</Text>
|
||||
</View>
|
||||
<View style={styles.statusValueGroup}>
|
||||
<Text style={styles.valueText}>{daemonStatusStateText}</Text>
|
||||
<Text style={styles.valueSubtext}>{daemonStatusDetailText}</Text>
|
||||
</View>
|
||||
<Text style={styles.valueText}>{localDaemonVersionText}</Text>
|
||||
</View>
|
||||
<View style={[styles.row, styles.rowBorder]}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowTitle}>Update daemon</Text>
|
||||
<Text style={styles.rowTitle}>Daemon management</Text>
|
||||
<Text style={styles.hintText}>
|
||||
Updates the daemon on this computer only. Requires a restart.
|
||||
{isDaemonManagementPaused
|
||||
? "Paused. Paseo will not auto-start the built-in daemon on app launch."
|
||||
: "Enabled. Paseo will start the built-in daemon automatically when needed."}
|
||||
</Text>
|
||||
{localDaemonUpdateMessage ? (
|
||||
<Text style={styles.statusText}>{localDaemonUpdateMessage}</Text>
|
||||
</View>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={isDaemonManagementPaused
|
||||
? <Play size={theme.iconSize.sm} color={theme.colors.foreground} />
|
||||
: <Pause size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
||||
onPress={handleToggleDaemonManagement}
|
||||
disabled={isUpdatingDaemonManagement}
|
||||
>
|
||||
{isUpdatingDaemonManagement
|
||||
? isDaemonManagementPaused
|
||||
? "Resuming..."
|
||||
: "Pausing..."
|
||||
: isDaemonManagementPaused
|
||||
? "Resume"
|
||||
: "Pause"}
|
||||
</Button>
|
||||
</View>
|
||||
<View style={[styles.row, styles.rowBorder]}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowTitle}>{daemonActionLabel}</Text>
|
||||
<Text style={styles.hintText}>{daemonActionMessage}</Text>
|
||||
{statusMessage ? (
|
||||
<Text style={styles.statusText}>{statusMessage}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<Button
|
||||
variant="secondary"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<RotateCw size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
||||
onPress={handleUpdateLocalDaemon}
|
||||
disabled={isUpdatingLocalDaemon}
|
||||
disabled={isRestartingDaemon}
|
||||
>
|
||||
{isUpdatingLocalDaemon ? "Updating..." : "Update daemon"}
|
||||
{isRestartingDaemon
|
||||
? managedStatus?.daemonRunning
|
||||
? "Restarting..."
|
||||
: "Starting..."
|
||||
: daemonActionLabel}
|
||||
</Button>
|
||||
</View>
|
||||
<View style={[styles.row, styles.rowBorder]}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowTitle}>Command line (CLI)</Text>
|
||||
<Text style={styles.hintText}>
|
||||
Adds the `paseo` command to your terminal.
|
||||
</Text>
|
||||
{cliStatusMessage ? <Text style={styles.statusText}>{cliStatusMessage}</Text> : null}
|
||||
</View>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Terminal size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
||||
onPress={handleToggleCliShim}
|
||||
disabled={isInstallingCli}
|
||||
>
|
||||
{isInstallingCli
|
||||
? "Working..."
|
||||
: managedStatus?.cliShimPath
|
||||
? "Uninstall CLI"
|
||||
: "Install CLI"}
|
||||
</Button>
|
||||
</View>
|
||||
<View style={[styles.row, styles.rowBorder]}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowTitle}>Log file</Text>
|
||||
<Text style={styles.hintText}>
|
||||
{managedLogs?.logPath ??
|
||||
managedStatus?.logPath ??
|
||||
"Log path unavailable."}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.actionGroup}>
|
||||
{(managedLogs?.logPath ?? managedStatus?.logPath) ? (
|
||||
<Button variant="outline" size="sm" leftIcon={<Copy size={theme.iconSize.sm} color={theme.colors.foreground} />} onPress={handleCopyLogPath}>
|
||||
Copy path
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<FileText size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
||||
onPress={handleOpenLogs}
|
||||
disabled={!managedLogs}
|
||||
>
|
||||
Open logs
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
<View style={[styles.row, styles.rowBorder]}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowTitle}>Pair device</Text>
|
||||
<Text style={styles.hintText}>
|
||||
Connect your phone to this computer.
|
||||
</Text>
|
||||
</View>
|
||||
<Button variant="outline" size="sm" leftIcon={<Smartphone size={theme.iconSize.sm} color={theme.colors.foreground} />} onPress={handleOpenPairingModal}>
|
||||
Pair device
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
@@ -171,46 +472,193 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
|
||||
{daemonVersionMismatch ? (
|
||||
<View style={styles.warningCard}>
|
||||
<Text style={styles.warningText}>
|
||||
Desktop app and local daemon versions differ. Keep both on the same version to avoid
|
||||
stability issues or breaking changes.
|
||||
App and daemon versions don't match. Update both to the same version for the best
|
||||
experience.
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{localDaemonUpdateDiagnostics ? (
|
||||
<View style={styles.diagnosticsCard}>
|
||||
<View style={styles.diagnosticsHeader}>
|
||||
<Text style={styles.diagnosticsTitle}>Daemon update diagnostics</Text>
|
||||
<Button variant="secondary" size="sm" onPress={handleCopyDaemonDiagnostics}>
|
||||
Copy output
|
||||
<AdaptiveModalSheet
|
||||
visible={isCliInstallModalOpen}
|
||||
onClose={() => setIsCliInstallModalOpen(false)}
|
||||
title="Install CLI manually"
|
||||
testID="managed-daemon-cli-install-dialog"
|
||||
>
|
||||
<View style={styles.modalBody}>
|
||||
<Text style={styles.hintText}>
|
||||
A permissions popup should appear when Paseo installs the CLI globally. If it does not
|
||||
complete, open a terminal and run the commands below.
|
||||
</Text>
|
||||
{cliInstallInstructions?.detail ? (
|
||||
<Text style={styles.hintText}>{cliInstallInstructions.detail}</Text>
|
||||
) : null}
|
||||
<Text style={styles.codeBlock} selectable>
|
||||
{cliInstallInstructions?.commands ?? ""}
|
||||
</Text>
|
||||
<View style={styles.modalActions}>
|
||||
<Button variant="outline" size="sm" onPress={() => setIsCliInstallModalOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
<Button size="sm" onPress={handleCopyCliInstallCommands}>
|
||||
Copy commands
|
||||
</Button>
|
||||
</View>
|
||||
<Text style={styles.diagnosticsText} selectable>
|
||||
{localDaemonUpdateDiagnostics}
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
|
||||
<AdaptiveModalSheet
|
||||
visible={isPairingModalOpen}
|
||||
onClose={() => setIsPairingModalOpen(false)}
|
||||
title="Pair device"
|
||||
testID="managed-daemon-pairing-dialog"
|
||||
>
|
||||
<PairingOfferDialogContent
|
||||
isLoading={isLoadingPairing}
|
||||
pairingOffer={pairingOffer}
|
||||
statusMessage={pairingStatusMessage}
|
||||
onCopyLink={handleCopyPairingLink}
|
||||
/>
|
||||
</AdaptiveModalSheet>
|
||||
|
||||
<AdaptiveModalSheet
|
||||
visible={isLogsModalOpen}
|
||||
onClose={() => setIsLogsModalOpen(false)}
|
||||
title="Daemon logs"
|
||||
testID="managed-daemon-logs-dialog"
|
||||
snapPoints={["70%", "92%"]}
|
||||
>
|
||||
<View style={styles.modalBody}>
|
||||
<Text style={styles.hintText}>
|
||||
{managedLogs?.logPath ??
|
||||
managedStatus?.logPath ??
|
||||
"Log path unavailable."}
|
||||
</Text>
|
||||
<Text style={styles.logOutput} selectable>
|
||||
{managedLogs?.contents.length ? managedLogs.contents : "(log file is empty)"}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</AdaptiveModalSheet>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const ADVANCED_DAEMON_SETTINGS_URL = "https://paseo.sh/docs/configuration";
|
||||
|
||||
function PairingOfferDialogContent(input: {
|
||||
isLoading: boolean;
|
||||
pairingOffer: ManagedPairingOffer | null;
|
||||
statusMessage: string | null;
|
||||
onCopyLink: () => void;
|
||||
}) {
|
||||
const { isLoading, pairingOffer, statusMessage, onCopyLink } = input;
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
|
||||
const [qrError, setQrError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
if (!pairingOffer?.url) {
|
||||
setQrDataUrl(null);
|
||||
setQrError(null);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
setQrError(null);
|
||||
setQrDataUrl(null);
|
||||
|
||||
void QRCode.toDataURL(pairingOffer.url, {
|
||||
errorCorrectionLevel: "M",
|
||||
margin: 1,
|
||||
width: 320,
|
||||
})
|
||||
.then((dataUrl) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setQrDataUrl(dataUrl);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setQrError(error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [pairingOffer?.url]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={styles.pairingState}>
|
||||
<ActivityIndicator size="small" />
|
||||
<Text style={styles.hintText}>Loading pairing offer…</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (statusMessage) {
|
||||
return (
|
||||
<View style={styles.modalBody}>
|
||||
<Text style={styles.hintText}>{statusMessage}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!pairingOffer?.url) {
|
||||
return (
|
||||
<View style={styles.modalBody}>
|
||||
<Text style={styles.hintText}>Pairing offer unavailable.</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.modalBody}>
|
||||
<Text style={styles.hintText}>
|
||||
Scan this QR code in Paseo, or copy the pairing link below.
|
||||
</Text>
|
||||
<View style={styles.qrCard}>
|
||||
{qrDataUrl ? (
|
||||
<Image source={{ uri: qrDataUrl }} style={styles.qrImage} />
|
||||
) : qrError ? (
|
||||
<Text style={styles.hintText}>QR unavailable: {qrError}</Text>
|
||||
) : (
|
||||
<ActivityIndicator size="small" />
|
||||
)}
|
||||
</View>
|
||||
<Text style={styles.linkLabel}>Pairing link</Text>
|
||||
<Text style={styles.linkText} selectable>
|
||||
{pairingOffer.url}
|
||||
</Text>
|
||||
<View style={styles.modalActions}>
|
||||
<Button variant="outline" size="sm" onPress={onCopyLink}>
|
||||
Copy link
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
section: {
|
||||
marginBottom: theme.spacing[6],
|
||||
},
|
||||
sectionTitle: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
sectionHeader: {
|
||||
alignItems: "center",
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: theme.spacing[3],
|
||||
marginLeft: theme.spacing[1],
|
||||
},
|
||||
card: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
overflow: "hidden",
|
||||
sectionLink: {
|
||||
alignItems: "center",
|
||||
flexDirection: "row",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
sectionLinkText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
@@ -227,6 +675,16 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
marginRight: theme.spacing[3],
|
||||
},
|
||||
actionGroup: {
|
||||
flexDirection: "row",
|
||||
gap: theme.spacing[2],
|
||||
flexWrap: "wrap",
|
||||
justifyContent: "flex-end",
|
||||
},
|
||||
statusValueGroup: {
|
||||
alignItems: "flex-end",
|
||||
gap: 2,
|
||||
},
|
||||
rowTitle: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
@@ -236,6 +694,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
valueSubtext: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
hintText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
@@ -259,28 +721,61 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.palette.amber[500],
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
diagnosticsCard: {
|
||||
marginTop: theme.spacing[3],
|
||||
modalBody: {
|
||||
gap: theme.spacing[3],
|
||||
paddingBottom: theme.spacing[2],
|
||||
},
|
||||
pairingState: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[6],
|
||||
},
|
||||
qrCard: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
alignSelf: "center",
|
||||
minHeight: 220,
|
||||
minWidth: 220,
|
||||
padding: theme.spacing[4],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
padding: theme.spacing[3],
|
||||
gap: theme.spacing[2],
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
diagnosticsHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[2],
|
||||
qrImage: {
|
||||
width: 220,
|
||||
height: 220,
|
||||
},
|
||||
diagnosticsTitle: {
|
||||
linkLabel: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
diagnosticsText: {
|
||||
linkText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
lineHeight: 18,
|
||||
},
|
||||
logOutput: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
||||
lineHeight: 18,
|
||||
},
|
||||
codeBlock: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
||||
lineHeight: 18,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
padding: theme.spacing[3],
|
||||
},
|
||||
modalActions: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "flex-end",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseCliShimResult } from "./managed-runtime";
|
||||
|
||||
describe("parseCliShimResult", () => {
|
||||
it("parses manual install payloads from the desktop backend", () => {
|
||||
expect(
|
||||
parseCliShimResult({
|
||||
status: "manualInstallRequired",
|
||||
installed: false,
|
||||
path: "/usr/local/bin/paseo",
|
||||
message: "Install it manually.",
|
||||
manualInstructions: {
|
||||
title: "Install from Terminal",
|
||||
detail: "Run these commands.",
|
||||
commands: "sudo tee /usr/local/bin/paseo",
|
||||
},
|
||||
})
|
||||
).toEqual({
|
||||
status: "manualInstallRequired",
|
||||
installed: false,
|
||||
path: "/usr/local/bin/paseo",
|
||||
message: "Install it manually.",
|
||||
manualInstructions: {
|
||||
title: "Install from Terminal",
|
||||
detail: "Run these commands.",
|
||||
commands: "sudo tee /usr/local/bin/paseo",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to installed or removed when older payloads omit status", () => {
|
||||
expect(
|
||||
parseCliShimResult({
|
||||
installed: true,
|
||||
path: "/usr/local/bin/paseo",
|
||||
message: "Installed.",
|
||||
})
|
||||
).toEqual({
|
||||
status: "installed",
|
||||
installed: true,
|
||||
path: "/usr/local/bin/paseo",
|
||||
message: "Installed.",
|
||||
manualInstructions: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
282
packages/app/src/desktop/managed-runtime/managed-runtime.ts
Normal file
282
packages/app/src/desktop/managed-runtime/managed-runtime.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
import { invokeDesktopCommand } from "@/desktop/tauri/invoke-desktop-command";
|
||||
import { getTauri, isTauriEnvironment } from "@/utils/tauri";
|
||||
|
||||
export type ManagedRuntimeStatus = {
|
||||
runtimeId: string;
|
||||
runtimeVersion: string;
|
||||
runtimeRoot: string;
|
||||
managedHome: string;
|
||||
transportType: string;
|
||||
transportPath: string;
|
||||
diagnosticsRoot: string;
|
||||
stateFilePath: string;
|
||||
};
|
||||
|
||||
export type ManagedDaemonStatus = {
|
||||
runtimeId: string;
|
||||
runtimeVersion: string;
|
||||
runtimeRoot: string;
|
||||
managedHome: string;
|
||||
transportType: string;
|
||||
transportPath: string;
|
||||
daemonPid: number | null;
|
||||
daemonRunning: boolean;
|
||||
daemonStatus: string;
|
||||
logPath: string;
|
||||
serverId: string | null;
|
||||
hostname: string | null;
|
||||
relayEnabled: boolean;
|
||||
tcpEnabled: boolean;
|
||||
tcpListen: string | null;
|
||||
cliShimPath: string | null;
|
||||
};
|
||||
|
||||
export type ManagedDaemonLogs = {
|
||||
logPath: string;
|
||||
contents: string;
|
||||
};
|
||||
|
||||
export type ManagedPairingOffer = {
|
||||
relayEnabled: boolean;
|
||||
url: string | null;
|
||||
qr: string | null;
|
||||
};
|
||||
|
||||
export type CliShimResult = {
|
||||
status:
|
||||
| "installed"
|
||||
| "removed"
|
||||
| "elevationDenied"
|
||||
| "automaticInstallUnavailable"
|
||||
| "manualInstallRequired";
|
||||
installed: boolean;
|
||||
path: string | null;
|
||||
message: string;
|
||||
manualInstructions: CliManualInstructions | null;
|
||||
};
|
||||
|
||||
export type CliManualInstructions = {
|
||||
title: string;
|
||||
detail: string;
|
||||
commands: string;
|
||||
};
|
||||
|
||||
export type ManagedTcpSettings = {
|
||||
enabled: boolean;
|
||||
host: string;
|
||||
port: number;
|
||||
};
|
||||
|
||||
export type LocalTransportTarget = {
|
||||
transportType: "socket" | "pipe";
|
||||
transportPath: string;
|
||||
};
|
||||
|
||||
type LocalTransportEventPayload = {
|
||||
sessionId: string;
|
||||
kind: "open" | "message" | "close" | "error";
|
||||
text?: string | null;
|
||||
binaryBase64?: string | null;
|
||||
code?: number | null;
|
||||
reason?: string | null;
|
||||
error?: string | null;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function toStringOrNull(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function toNumberOrNull(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function parseManagedRuntimeStatus(raw: unknown): ManagedRuntimeStatus {
|
||||
if (!isRecord(raw)) {
|
||||
throw new Error("Unexpected managed runtime status response.");
|
||||
}
|
||||
return {
|
||||
runtimeId: toStringOrNull(raw.runtimeId) ?? "",
|
||||
runtimeVersion: toStringOrNull(raw.runtimeVersion) ?? "",
|
||||
runtimeRoot: toStringOrNull(raw.runtimeRoot) ?? "",
|
||||
managedHome: toStringOrNull(raw.managedHome) ?? "",
|
||||
transportType: toStringOrNull(raw.transportType) ?? "socket",
|
||||
transportPath: toStringOrNull(raw.transportPath) ?? "",
|
||||
diagnosticsRoot: toStringOrNull(raw.diagnosticsRoot) ?? "",
|
||||
stateFilePath: toStringOrNull(raw.stateFilePath) ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function parseManagedDaemonStatus(raw: unknown): ManagedDaemonStatus {
|
||||
if (!isRecord(raw)) {
|
||||
throw new Error("Unexpected managed daemon status response.");
|
||||
}
|
||||
return {
|
||||
runtimeId: toStringOrNull(raw.runtimeId) ?? "",
|
||||
runtimeVersion: toStringOrNull(raw.runtimeVersion) ?? "",
|
||||
runtimeRoot: toStringOrNull(raw.runtimeRoot) ?? "",
|
||||
managedHome: toStringOrNull(raw.managedHome) ?? "",
|
||||
transportType: toStringOrNull(raw.transportType) ?? "socket",
|
||||
transportPath: toStringOrNull(raw.transportPath) ?? "",
|
||||
daemonPid: toNumberOrNull(raw.daemonPid),
|
||||
daemonRunning: raw.daemonRunning === true,
|
||||
daemonStatus: toStringOrNull(raw.daemonStatus) ?? "unknown",
|
||||
logPath: toStringOrNull(raw.logPath) ?? "",
|
||||
serverId: toStringOrNull(raw.serverId),
|
||||
hostname: toStringOrNull(raw.hostname),
|
||||
relayEnabled: raw.relayEnabled === true,
|
||||
tcpEnabled: raw.tcpEnabled === true,
|
||||
tcpListen: toStringOrNull(raw.tcpListen),
|
||||
cliShimPath: toStringOrNull(raw.cliShimPath),
|
||||
};
|
||||
}
|
||||
|
||||
function parseManagedDaemonLogs(raw: unknown): ManagedDaemonLogs {
|
||||
if (!isRecord(raw)) {
|
||||
throw new Error("Unexpected managed daemon logs response.");
|
||||
}
|
||||
return {
|
||||
logPath: toStringOrNull(raw.logPath) ?? "",
|
||||
contents: typeof raw.contents === "string" ? raw.contents : "",
|
||||
};
|
||||
}
|
||||
|
||||
function parseManagedPairingOffer(raw: unknown): ManagedPairingOffer {
|
||||
if (!isRecord(raw)) {
|
||||
throw new Error("Unexpected managed daemon pairing response.");
|
||||
}
|
||||
return {
|
||||
relayEnabled: raw.relayEnabled === true,
|
||||
url: toStringOrNull(raw.url),
|
||||
qr: toStringOrNull(raw.qr),
|
||||
};
|
||||
}
|
||||
|
||||
function parseCliManualInstructions(raw: unknown): CliManualInstructions | null {
|
||||
if (!isRecord(raw)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
title: toStringOrNull(raw.title) ?? "",
|
||||
detail: toStringOrNull(raw.detail) ?? "",
|
||||
commands: toStringOrNull(raw.commands) ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export function parseCliShimResult(raw: unknown): CliShimResult {
|
||||
if (!isRecord(raw)) {
|
||||
throw new Error("Unexpected CLI shim response.");
|
||||
}
|
||||
return {
|
||||
status:
|
||||
(toStringOrNull(raw.status) as CliShimResult["status"] | null) ??
|
||||
(raw.installed === true ? "installed" : "removed"),
|
||||
installed: raw.installed === true,
|
||||
path: toStringOrNull(raw.path),
|
||||
message: toStringOrNull(raw.message) ?? "",
|
||||
manualInstructions: parseCliManualInstructions(raw.manualInstructions),
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldUseManagedDesktopDaemon(): boolean {
|
||||
return isTauriEnvironment() && getTauri() !== null;
|
||||
}
|
||||
|
||||
export async function getManagedRuntimeStatus(): Promise<ManagedRuntimeStatus> {
|
||||
return parseManagedRuntimeStatus(await invokeDesktopCommand("managed_runtime_status"));
|
||||
}
|
||||
|
||||
export async function getManagedDaemonStatus(): Promise<ManagedDaemonStatus> {
|
||||
return parseManagedDaemonStatus(await invokeDesktopCommand("managed_daemon_status"));
|
||||
}
|
||||
|
||||
export async function startManagedDaemon(): Promise<ManagedDaemonStatus> {
|
||||
return parseManagedDaemonStatus(await invokeDesktopCommand("start_managed_daemon"));
|
||||
}
|
||||
|
||||
export async function stopManagedDaemon(): Promise<ManagedDaemonStatus> {
|
||||
return parseManagedDaemonStatus(await invokeDesktopCommand("stop_managed_daemon"));
|
||||
}
|
||||
|
||||
export async function restartManagedDaemon(): Promise<ManagedDaemonStatus> {
|
||||
return parseManagedDaemonStatus(await invokeDesktopCommand("restart_managed_daemon"));
|
||||
}
|
||||
|
||||
export async function getManagedDaemonLogs(): Promise<ManagedDaemonLogs> {
|
||||
return parseManagedDaemonLogs(await invokeDesktopCommand("managed_daemon_logs"));
|
||||
}
|
||||
|
||||
export async function getManagedDaemonPairing(): Promise<ManagedPairingOffer> {
|
||||
return parseManagedPairingOffer(await invokeDesktopCommand("managed_daemon_pairing"));
|
||||
}
|
||||
|
||||
export async function installManagedCliShim(): Promise<CliShimResult> {
|
||||
return parseCliShimResult(await invokeDesktopCommand("install_cli_shim"));
|
||||
}
|
||||
|
||||
export async function uninstallManagedCliShim(): Promise<CliShimResult> {
|
||||
return parseCliShimResult(await invokeDesktopCommand("uninstall_cli_shim"));
|
||||
}
|
||||
|
||||
export async function updateManagedDaemonTcpSettings(
|
||||
settings: ManagedTcpSettings
|
||||
): Promise<ManagedDaemonStatus> {
|
||||
return parseManagedDaemonStatus(
|
||||
await invokeDesktopCommand("update_managed_daemon_tcp_settings", { settings })
|
||||
);
|
||||
}
|
||||
|
||||
export type LocalTransportEventUnlisten = () => void;
|
||||
export type LocalTransportEventHandler = (payload: LocalTransportEventPayload) => void;
|
||||
|
||||
export async function listenToLocalTransportEvents(
|
||||
handler: LocalTransportEventHandler
|
||||
): Promise<LocalTransportEventUnlisten> {
|
||||
const listen = getTauri()?.event?.listen;
|
||||
if (typeof listen !== "function") {
|
||||
throw new Error("Tauri event API is unavailable.");
|
||||
}
|
||||
const unlisten = await listen("local-daemon-transport-event", (event: unknown) => {
|
||||
const payload = isRecord(event) && isRecord(event.payload) ? event.payload : null;
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
handler({
|
||||
sessionId: toStringOrNull(payload.sessionId) ?? "",
|
||||
kind: (toStringOrNull(payload.kind) ?? "error") as LocalTransportEventPayload["kind"],
|
||||
text: toStringOrNull(payload.text),
|
||||
binaryBase64: toStringOrNull(payload.binaryBase64),
|
||||
code: toNumberOrNull(payload.code),
|
||||
reason: toStringOrNull(payload.reason),
|
||||
error: toStringOrNull(payload.error),
|
||||
});
|
||||
});
|
||||
return typeof unlisten === "function" ? unlisten : () => {};
|
||||
}
|
||||
|
||||
export async function openLocalTransportSession(target: LocalTransportTarget): Promise<string> {
|
||||
const raw = await invokeDesktopCommand<unknown>("open_local_daemon_transport", target);
|
||||
if (typeof raw !== "string" || raw.trim().length === 0) {
|
||||
throw new Error("Unexpected local transport session response.");
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
export async function sendLocalTransportMessage(input: {
|
||||
sessionId: string;
|
||||
text?: string;
|
||||
binaryBase64?: string;
|
||||
}): Promise<void> {
|
||||
await invokeDesktopCommand("send_local_daemon_transport_message", {
|
||||
sessionId: input.sessionId,
|
||||
...(input.text ? { text: input.text } : {}),
|
||||
...(input.binaryBase64 ? { binaryBase64: input.binaryBase64 } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function closeLocalTransportSession(sessionId: string): Promise<void> {
|
||||
await invokeDesktopCommand("close_local_daemon_transport", { sessionId });
|
||||
}
|
||||
152
packages/app/src/hooks/use-agent-attention-clear.ts
Normal file
152
packages/app/src/hooks/use-agent-attention-clear.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { AppState, Platform } from "react-native";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import {
|
||||
shouldClearAgentAttention,
|
||||
type AgentAttentionClearTrigger,
|
||||
} from "@/utils/agent-attention";
|
||||
|
||||
type AttentionReason = "finished" | "error" | "permission" | null | undefined;
|
||||
|
||||
interface UseAgentAttentionClearParams {
|
||||
agentId: string | null | undefined;
|
||||
client: DaemonClient | null;
|
||||
isConnected: boolean;
|
||||
requiresAttention: boolean | null | undefined;
|
||||
attentionReason: AttentionReason;
|
||||
isScreenFocused: boolean;
|
||||
}
|
||||
|
||||
interface AgentAttentionClearController {
|
||||
clearOnInputFocus: () => void;
|
||||
clearOnPromptSend: () => void;
|
||||
clearOnAgentBlur: () => void;
|
||||
}
|
||||
|
||||
function getIsAppVisible(): boolean {
|
||||
const isAppStateActive = AppState.currentState === "active";
|
||||
if (Platform.OS !== "web") {
|
||||
return isAppStateActive;
|
||||
}
|
||||
const documentVisible =
|
||||
typeof document === "undefined" || document.visibilityState === "visible";
|
||||
const windowFocused =
|
||||
typeof document === "undefined" ||
|
||||
typeof document.hasFocus !== "function" ||
|
||||
document.hasFocus();
|
||||
return isAppStateActive && documentVisible && windowFocused;
|
||||
}
|
||||
|
||||
export function useAgentAttentionClear({
|
||||
agentId,
|
||||
client,
|
||||
isConnected,
|
||||
requiresAttention,
|
||||
attentionReason,
|
||||
isScreenFocused,
|
||||
}: UseAgentAttentionClearParams): AgentAttentionClearController {
|
||||
const [isAppVisible, setIsAppVisible] = useState<boolean>(() => getIsAppVisible());
|
||||
const deferredFocusEntryClearRef = useRef(false);
|
||||
const prevRequiresAttentionRef = useRef(Boolean(requiresAttention));
|
||||
const prevActivelyViewedRef = useRef(isScreenFocused && getIsAppVisible());
|
||||
const prevScreenFocusedRef = useRef(false);
|
||||
const prevAppVisibleRef = useRef(getIsAppVisible());
|
||||
|
||||
const clearAttention = useCallback(
|
||||
(trigger: AgentAttentionClearTrigger) => {
|
||||
const resolvedAgentId = agentId?.trim();
|
||||
if (!client || !resolvedAgentId) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!shouldClearAgentAttention({
|
||||
agentId: resolvedAgentId,
|
||||
isConnected,
|
||||
requiresAttention,
|
||||
attentionReason,
|
||||
trigger,
|
||||
hasDeferredFocusEntryClear: deferredFocusEntryClearRef.current,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
deferredFocusEntryClearRef.current = false;
|
||||
client.clearAgentAttention(resolvedAgentId);
|
||||
},
|
||||
[agentId, attentionReason, client, isConnected, requiresAttention]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const updateVisibility = () => {
|
||||
setIsAppVisible(getIsAppVisible());
|
||||
};
|
||||
|
||||
const appStateSubscription = AppState.addEventListener(
|
||||
"change",
|
||||
updateVisibility
|
||||
);
|
||||
|
||||
if (Platform.OS === "web" && typeof document !== "undefined") {
|
||||
document.addEventListener("visibilitychange", updateVisibility);
|
||||
window.addEventListener("focus", updateVisibility);
|
||||
window.addEventListener("blur", updateVisibility);
|
||||
|
||||
return () => {
|
||||
appStateSubscription.remove();
|
||||
document.removeEventListener("visibilitychange", updateVisibility);
|
||||
window.removeEventListener("focus", updateVisibility);
|
||||
window.removeEventListener("blur", updateVisibility);
|
||||
};
|
||||
}
|
||||
|
||||
return () => {
|
||||
appStateSubscription.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!requiresAttention) {
|
||||
deferredFocusEntryClearRef.current = false;
|
||||
}
|
||||
}, [requiresAttention]);
|
||||
|
||||
useEffect(() => {
|
||||
const isActivelyViewed = isScreenFocused && isAppVisible;
|
||||
if (
|
||||
!prevRequiresAttentionRef.current &&
|
||||
Boolean(requiresAttention) &&
|
||||
prevActivelyViewedRef.current &&
|
||||
isActivelyViewed
|
||||
) {
|
||||
deferredFocusEntryClearRef.current = true;
|
||||
}
|
||||
prevRequiresAttentionRef.current = Boolean(requiresAttention);
|
||||
prevActivelyViewedRef.current = isActivelyViewed;
|
||||
}, [isAppVisible, isScreenFocused, requiresAttention]);
|
||||
|
||||
useEffect(() => {
|
||||
const enteredScreenFocus =
|
||||
!prevScreenFocusedRef.current && isScreenFocused && isAppVisible;
|
||||
const resumedIntoFocusedAgent =
|
||||
!prevAppVisibleRef.current && isAppVisible && isScreenFocused;
|
||||
|
||||
if (enteredScreenFocus || resumedIntoFocusedAgent) {
|
||||
clearAttention("focus-entry");
|
||||
}
|
||||
|
||||
prevScreenFocusedRef.current = isScreenFocused;
|
||||
prevAppVisibleRef.current = isAppVisible;
|
||||
}, [clearAttention, isAppVisible, isScreenFocused]);
|
||||
|
||||
return {
|
||||
clearOnInputFocus: useCallback(() => {
|
||||
clearAttention("input-focus");
|
||||
}, [clearAttention]),
|
||||
clearOnPromptSend: useCallback(() => {
|
||||
clearAttention("prompt-send");
|
||||
}, [clearAttention]),
|
||||
clearOnAgentBlur: useCallback(() => {
|
||||
clearAttention("agent-blur");
|
||||
}, [clearAttention]),
|
||||
};
|
||||
}
|
||||
@@ -64,4 +64,10 @@ describe("useAgentInitialization timeline request policy", () => {
|
||||
projection: "canonical",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not expose an RPC-success init fallback", () => {
|
||||
expect(
|
||||
"shouldResolveInitFromRpcSuccess" in __private__
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,7 +62,7 @@ export function useAgentInitialization({
|
||||
const cursor = session?.agentTimelineCursor.get(agentId);
|
||||
const initialTimelineLimit = resolveInitialTimelineLimit();
|
||||
const hasAuthoritativeHistory =
|
||||
(session?.agentHistorySyncGeneration.get(agentId) ?? -1) >= 0;
|
||||
session?.agentAuthoritativeHistoryApplied.get(agentId) === true;
|
||||
const timelineRequest = deriveInitialTimelineRequest({
|
||||
cursor: cursor
|
||||
? { epoch: cursor.epoch, seq: cursor.endSeq }
|
||||
@@ -95,10 +95,6 @@ export function useAgentInitialization({
|
||||
|
||||
client
|
||||
.fetchAgentTimeline(agentId, timelineRequest)
|
||||
.then(() => {
|
||||
// No-op: hydration completion is handled by SessionContext
|
||||
// when it processes fetch_agent_timeline_response.
|
||||
})
|
||||
.catch((error) => {
|
||||
setAgentInitializing(agentId, false);
|
||||
rejectInitDeferred(
|
||||
|
||||
@@ -342,6 +342,46 @@ describe("deriveAgentScreenViewState", () => {
|
||||
expect(ready.sync.status).toBe("idle");
|
||||
});
|
||||
|
||||
it("keeps first route entry blocked until authoritative history is applied", () => {
|
||||
const memory = createBaseMemory();
|
||||
const input: AgentScreenMachineInput = {
|
||||
...createBaseInput(),
|
||||
agent: createAgent("agent-1"),
|
||||
needsAuthoritativeSync: true,
|
||||
isHistorySyncing: true,
|
||||
hasHydratedHistoryBefore: false,
|
||||
};
|
||||
|
||||
const result = deriveAgentScreenViewState({ input, memory });
|
||||
|
||||
expect(result.state).toEqual({
|
||||
tag: "boot",
|
||||
reason: "loading",
|
||||
source: "none",
|
||||
});
|
||||
expect(result.memory.hasRenderedReady).toBe(false);
|
||||
expect(result.memory.lastReadyAgent).toBeNull();
|
||||
});
|
||||
|
||||
it("still allows optimistic create flow to render before authoritative history arrives", () => {
|
||||
const memory = createBaseMemory();
|
||||
const input: AgentScreenMachineInput = {
|
||||
...createBaseInput(),
|
||||
agent: createAgentWithStatus({ id: "agent-1", status: "idle" }),
|
||||
placeholderAgent: createAgent("agent-1"),
|
||||
shouldUseOptimisticStream: true,
|
||||
needsAuthoritativeSync: true,
|
||||
isHistorySyncing: true,
|
||||
hasHydratedHistoryBefore: false,
|
||||
};
|
||||
|
||||
const result = deriveAgentScreenViewState({ input, memory });
|
||||
const ready = expectReadyState(result.state);
|
||||
|
||||
expect(ready.source).toBe("optimistic");
|
||||
expect(ready.agent.status).toBe("running");
|
||||
});
|
||||
|
||||
it("keeps optimistic flow non-blocking while transitioning to authoritative stream", () => {
|
||||
const initialMemory = createBaseMemory();
|
||||
const optimisticInput: AgentScreenMachineInput = {
|
||||
|
||||
@@ -19,6 +19,16 @@ export interface AgentScreenMachineInput {
|
||||
hasHydratedHistoryBefore: boolean;
|
||||
}
|
||||
|
||||
function shouldBlockInitialAuthoritativeReadyState(
|
||||
input: AgentScreenMachineInput
|
||||
): boolean {
|
||||
return (
|
||||
!input.shouldUseOptimisticStream &&
|
||||
!input.hasHydratedHistoryBefore &&
|
||||
(input.needsAuthoritativeSync || input.isHistorySyncing)
|
||||
);
|
||||
}
|
||||
|
||||
export type AgentScreenToastLatch = "none" | "history_refresh" | "sync_error";
|
||||
|
||||
export interface AgentScreenMachineMemory {
|
||||
@@ -104,10 +114,7 @@ export function deriveAgentScreenViewState({
|
||||
input.agent && useOptimisticCreateFlowAgent && input.placeholderAgent
|
||||
? { ...input.agent, status: input.placeholderAgent.status }
|
||||
: input.agent ?? input.placeholderAgent;
|
||||
if (candidateAgent) {
|
||||
nextMemory.hasRenderedReady = true;
|
||||
nextMemory.lastReadyAgent = candidateAgent;
|
||||
}
|
||||
const shouldBlockReadyState = shouldBlockInitialAuthoritativeReadyState(input);
|
||||
|
||||
if (input.missingAgentState.kind === "not_found") {
|
||||
return {
|
||||
@@ -129,6 +136,22 @@ export function deriveAgentScreenViewState({
|
||||
};
|
||||
}
|
||||
|
||||
if (candidateAgent && shouldBlockReadyState) {
|
||||
return {
|
||||
state: {
|
||||
tag: "boot",
|
||||
reason: "loading",
|
||||
source: "none",
|
||||
},
|
||||
memory: nextMemory,
|
||||
};
|
||||
}
|
||||
|
||||
if (candidateAgent) {
|
||||
nextMemory.hasRenderedReady = true;
|
||||
nextMemory.lastReadyAgent = candidateAgent;
|
||||
}
|
||||
|
||||
const displayAgent =
|
||||
candidateAgent ?? (nextMemory.hasRenderedReady ? nextMemory.lastReadyAgent : null);
|
||||
if (!displayAgent) {
|
||||
|
||||
@@ -19,9 +19,12 @@ export interface AggregatedAgentsResult {
|
||||
refreshAll: () => void;
|
||||
}
|
||||
|
||||
export function useAggregatedAgents(): AggregatedAgentsResult {
|
||||
export function useAggregatedAgents(options?: {
|
||||
includeArchived?: boolean;
|
||||
}): AggregatedAgentsResult {
|
||||
const { daemons } = useDaemonRegistry();
|
||||
const runtime = getHostRuntimeStore();
|
||||
const includeArchived = options?.includeArchived ?? false;
|
||||
const runtimeVersion = useSyncExternalStore(
|
||||
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
||||
() => runtime.getVersion(),
|
||||
@@ -55,7 +58,7 @@ export function useAggregatedAgents(): AggregatedAgentsResult {
|
||||
}
|
||||
const serverLabel = serverLabelById.get(serverId) ?? serverId;
|
||||
for (const agent of agents.values()) {
|
||||
if (agent.archivedAt) {
|
||||
if (!includeArchived && agent.archivedAt) {
|
||||
continue;
|
||||
}
|
||||
const nextAgent: AggregatedAgent = {
|
||||
@@ -112,7 +115,7 @@ export function useAggregatedAgents(): AggregatedAgentsResult {
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
};
|
||||
}, [daemons, runtime, runtimeVersion, sessionAgents]);
|
||||
}, [daemons, includeArchived, runtime, runtimeVersion, sessionAgents]);
|
||||
|
||||
return {
|
||||
...result,
|
||||
|
||||
79
packages/app/src/hooks/use-all-agents-list.test.ts
Normal file
79
packages/app/src/hooks/use-all-agents-list.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { __private__ } from "./use-all-agents-list";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
|
||||
function makeAgent(input?: Partial<Agent>): Agent {
|
||||
const timestamp = new Date("2026-03-08T10:00:00.000Z");
|
||||
return {
|
||||
serverId: "server-1",
|
||||
id: input?.id ?? "agent-1",
|
||||
provider: input?.provider ?? "codex",
|
||||
status: input?.status ?? "idle",
|
||||
createdAt: input?.createdAt ?? timestamp,
|
||||
updatedAt: input?.updatedAt ?? timestamp,
|
||||
lastUserMessageAt: input?.lastUserMessageAt ?? null,
|
||||
lastActivityAt: input?.lastActivityAt ?? timestamp,
|
||||
capabilities: input?.capabilities ?? {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
currentModeId: input?.currentModeId ?? null,
|
||||
availableModes: input?.availableModes ?? [],
|
||||
pendingPermissions: input?.pendingPermissions ?? [],
|
||||
persistence: input?.persistence ?? null,
|
||||
runtimeInfo: input?.runtimeInfo,
|
||||
lastUsage: input?.lastUsage,
|
||||
lastError: input?.lastError ?? null,
|
||||
title: input?.title ?? "Agent",
|
||||
cwd: input?.cwd ?? "/tmp/project",
|
||||
model: input?.model ?? null,
|
||||
thinkingOptionId: input?.thinkingOptionId,
|
||||
requiresAttention: input?.requiresAttention ?? false,
|
||||
attentionReason: input?.attentionReason ?? null,
|
||||
attentionTimestamp: input?.attentionTimestamp ?? null,
|
||||
archivedAt: input?.archivedAt ?? null,
|
||||
labels: input?.labels ?? {},
|
||||
projectPlacement: input?.projectPlacement ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("useAllAgentsList", () => {
|
||||
it("excludes archived agents by default", () => {
|
||||
const visibleAgent = makeAgent({ id: "visible" });
|
||||
const archivedAgent = makeAgent({
|
||||
id: "archived",
|
||||
archivedAt: new Date("2026-03-08T11:00:00.000Z"),
|
||||
});
|
||||
|
||||
const result = __private__.buildAllAgentsList({
|
||||
agents: [visibleAgent, archivedAgent],
|
||||
serverId: "server-1",
|
||||
serverLabel: "Local",
|
||||
includeArchived: false,
|
||||
});
|
||||
|
||||
expect(result.map((agent) => agent.id)).toEqual(["visible"]);
|
||||
});
|
||||
|
||||
it("includes archived agents when requested", () => {
|
||||
const visibleAgent = makeAgent({ id: "visible" });
|
||||
const archivedAgent = makeAgent({
|
||||
id: "archived",
|
||||
archivedAt: new Date("2026-03-08T11:00:00.000Z"),
|
||||
});
|
||||
|
||||
const result = __private__.buildAllAgentsList({
|
||||
agents: [visibleAgent, archivedAgent],
|
||||
serverId: "server-1",
|
||||
serverLabel: "Local",
|
||||
includeArchived: true,
|
||||
});
|
||||
|
||||
expect(result.map((agent) => agent.id)).toEqual(["visible", "archived"]);
|
||||
expect(result[1]?.archivedAt).toEqual(archivedAgent.archivedAt);
|
||||
});
|
||||
});
|
||||
@@ -35,8 +35,44 @@ function toAggregatedAgent(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function buildAllAgentsList(params: {
|
||||
agents: Iterable<Agent>;
|
||||
serverId: string;
|
||||
serverLabel: string;
|
||||
includeArchived: boolean;
|
||||
}): AggregatedAgent[] {
|
||||
const list: AggregatedAgent[] = [];
|
||||
|
||||
for (const agent of params.agents) {
|
||||
const aggregated = toAggregatedAgent({
|
||||
source: agent,
|
||||
serverId: params.serverId,
|
||||
serverLabel: params.serverLabel,
|
||||
});
|
||||
if (!params.includeArchived && aggregated.archivedAt) {
|
||||
continue;
|
||||
}
|
||||
list.push(aggregated);
|
||||
}
|
||||
|
||||
list.sort((left, right) => {
|
||||
const leftRunning = left.status === "running";
|
||||
const rightRunning = right.status === "running";
|
||||
if (leftRunning && !rightRunning) {
|
||||
return -1;
|
||||
}
|
||||
if (!leftRunning && rightRunning) {
|
||||
return 1;
|
||||
}
|
||||
return right.lastActivityAt.getTime() - left.lastActivityAt.getTime();
|
||||
});
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
export function useAllAgentsList(options?: {
|
||||
serverId?: string | null;
|
||||
includeArchived?: boolean;
|
||||
}): AggregatedAgentsResult {
|
||||
const { daemons } = useDaemonRegistry();
|
||||
const runtime = getHostRuntimeStore();
|
||||
@@ -47,6 +83,7 @@ export function useAllAgentsList(options?: {
|
||||
? value.trim()
|
||||
: null;
|
||||
}, [options?.serverId]);
|
||||
const includeArchived = options?.includeArchived ?? false;
|
||||
|
||||
const liveAgents = useSessionStore((state) =>
|
||||
serverId ? state.sessions[serverId]?.agents ?? null : null
|
||||
@@ -66,34 +103,13 @@ export function useAllAgentsList(options?: {
|
||||
}
|
||||
const serverLabel =
|
||||
daemons.find((daemon) => daemon.serverId === serverId)?.label ?? serverId;
|
||||
const list: AggregatedAgent[] = [];
|
||||
|
||||
for (const agent of liveAgents.values()) {
|
||||
const aggregated = toAggregatedAgent({
|
||||
source: agent,
|
||||
serverId,
|
||||
serverLabel,
|
||||
});
|
||||
if (aggregated.archivedAt) {
|
||||
continue;
|
||||
}
|
||||
list.push(aggregated);
|
||||
}
|
||||
|
||||
list.sort((left, right) => {
|
||||
const leftRunning = left.status === "running";
|
||||
const rightRunning = right.status === "running";
|
||||
if (leftRunning && !rightRunning) {
|
||||
return -1;
|
||||
}
|
||||
if (!leftRunning && rightRunning) {
|
||||
return 1;
|
||||
}
|
||||
return right.lastActivityAt.getTime() - left.lastActivityAt.getTime();
|
||||
return buildAllAgentsList({
|
||||
agents: liveAgents.values(),
|
||||
serverId,
|
||||
serverLabel,
|
||||
includeArchived,
|
||||
});
|
||||
|
||||
return list;
|
||||
}, [daemons, liveAgents, serverId]);
|
||||
}, [daemons, includeArchived, liveAgents, serverId]);
|
||||
|
||||
const isDirectoryLoading = Boolean(serverId && isHostRuntimeDirectoryLoading(snapshot));
|
||||
const isInitialLoad = isDirectoryLoading && agents.length === 0;
|
||||
@@ -107,3 +123,8 @@ export function useAllAgentsList(options?: {
|
||||
refreshAll,
|
||||
};
|
||||
}
|
||||
|
||||
export const __private__ = {
|
||||
buildAllAgentsList,
|
||||
toAggregatedAgent,
|
||||
};
|
||||
|
||||
@@ -3,13 +3,15 @@ import type { TextInput } from "react-native";
|
||||
import { router, usePathname, type Href } from "expo-router";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||
import { useAllAgentsList } from "@/hooks/use-all-agents-list";
|
||||
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import {
|
||||
clearCommandCenterFocusRestoreElement,
|
||||
takeCommandCenterFocusRestoreElement,
|
||||
} from "@/utils/command-center-focus-restore";
|
||||
import {
|
||||
buildHostNewAgentRoute,
|
||||
buildHostOpenProjectRoute,
|
||||
buildHostWorkspaceAgentRoute,
|
||||
buildHostSettingsRoute,
|
||||
parseHostAgentRouteFromPathname,
|
||||
@@ -23,8 +25,7 @@ function isMatch(agent: AggregatedAgent, query: string): boolean {
|
||||
const q = query.toLowerCase();
|
||||
const title = (agent.title ?? "New agent").toLowerCase();
|
||||
const cwd = agent.cwd.toLowerCase();
|
||||
const host = agent.serverLabel.toLowerCase();
|
||||
return title.includes(q) || cwd.includes(q) || host.includes(q);
|
||||
return title.includes(q) || cwd.includes(q);
|
||||
}
|
||||
|
||||
function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number {
|
||||
@@ -55,10 +56,10 @@ type CommandCenterActionDefinition = {
|
||||
const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [
|
||||
{
|
||||
id: "new-agent",
|
||||
title: "New agent",
|
||||
title: "Open project",
|
||||
icon: "plus",
|
||||
shortcutKeys: ["mod", "shift", "O"],
|
||||
keywords: ["new", "new agent", "create", "start", "launch", "agent"],
|
||||
keywords: ["open", "project", "folder", "workspace", "repo"],
|
||||
buildRoute: ({ newAgentRoute }) => newAgentRoute,
|
||||
},
|
||||
{
|
||||
@@ -103,34 +104,49 @@ export type CommandCenterItem =
|
||||
|
||||
export function useCommandCenter() {
|
||||
const pathname = usePathname();
|
||||
const { agents } = useAggregatedAgents();
|
||||
const { daemons } = useDaemonRegistry();
|
||||
const open = useKeyboardShortcutsStore((s) => s.commandCenterOpen);
|
||||
const setOpen = useKeyboardShortcutsStore((s) => s.setCommandCenterOpen);
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const didNavigateRef = useRef(false);
|
||||
const prevOpenRef = useRef(open);
|
||||
const activeIndexRef = useRef(0);
|
||||
const itemsRef = useRef<CommandCenterItem[]>([]);
|
||||
const handleCloseRef = useRef<() => void>(() => undefined);
|
||||
const handleSelectItemRef = useRef<(item: CommandCenterItem) => void>(() => undefined);
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
|
||||
const activeServerId = useMemo(() => {
|
||||
const serverIdFromPath = parseServerIdFromPathname(pathname);
|
||||
if (serverIdFromPath) {
|
||||
const routeMatch = daemons.find((entry) => entry.serverId === serverIdFromPath);
|
||||
if (routeMatch) {
|
||||
return routeMatch.serverId;
|
||||
}
|
||||
}
|
||||
return daemons[0]?.serverId ?? null;
|
||||
}, [daemons, pathname]);
|
||||
|
||||
const { agents } = useAllAgentsList({
|
||||
serverId: activeServerId,
|
||||
});
|
||||
|
||||
const agentResults = useMemo(() => {
|
||||
const filtered = agents.filter((agent) => isMatch(agent, query));
|
||||
filtered.sort(sortAgents);
|
||||
return filtered;
|
||||
}, [agents, query]);
|
||||
|
||||
const fallbackServerId = agents[0]?.serverId ?? null;
|
||||
|
||||
const newAgentRoute = useMemo<Href>(() => {
|
||||
const serverIdFromPath =
|
||||
parseServerIdFromPathname(pathname) ?? fallbackServerId;
|
||||
return serverIdFromPath ? (buildHostNewAgentRoute(serverIdFromPath) as Href) : "/";
|
||||
}, [fallbackServerId, pathname]);
|
||||
const serverIdFromPath = activeServerId;
|
||||
return serverIdFromPath ? (buildHostOpenProjectRoute(serverIdFromPath) as Href) : "/";
|
||||
}, [activeServerId]);
|
||||
|
||||
const settingsRoute = useMemo<Href>(() => {
|
||||
const serverIdFromPath =
|
||||
parseServerIdFromPathname(pathname) ?? fallbackServerId;
|
||||
const serverIdFromPath = activeServerId;
|
||||
return serverIdFromPath ? (buildHostSettingsRoute(serverIdFromPath) as Href) : "/";
|
||||
}, [fallbackServerId, pathname]);
|
||||
}, [activeServerId]);
|
||||
|
||||
const actionItems = useMemo(() => {
|
||||
return COMMAND_CENTER_ACTIONS.filter((action) =>
|
||||
@@ -185,12 +201,18 @@ export function useCommandCenter() {
|
||||
[pathname, setOpen]
|
||||
);
|
||||
|
||||
const setProjectPickerOpen = useKeyboardShortcutsStore((s) => s.setProjectPickerOpen);
|
||||
|
||||
const handleSelectAction = useCallback((action: CommandCenterActionItem) => {
|
||||
didNavigateRef.current = true;
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
setOpen(false);
|
||||
if (action.id === "new-agent") {
|
||||
setProjectPickerOpen(true);
|
||||
return;
|
||||
}
|
||||
didNavigateRef.current = true;
|
||||
router.push(action.route);
|
||||
}, [setOpen]);
|
||||
}, [setOpen, setProjectPickerOpen]);
|
||||
|
||||
const handleSelectItem = useCallback(
|
||||
(item: CommandCenterItem) => {
|
||||
@@ -203,6 +225,22 @@ export function useCommandCenter() {
|
||||
[handleSelectAction, handleSelectAgent]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
activeIndexRef.current = activeIndex;
|
||||
}, [activeIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
itemsRef.current = items;
|
||||
}, [items]);
|
||||
|
||||
useEffect(() => {
|
||||
handleCloseRef.current = handleClose;
|
||||
}, [handleClose]);
|
||||
|
||||
useEffect(() => {
|
||||
handleSelectItemRef.current = handleSelectItem;
|
||||
}, [handleSelectItem]);
|
||||
|
||||
useEffect(() => {
|
||||
const prevOpen = prevOpenRef.current;
|
||||
prevOpenRef.current = open;
|
||||
@@ -253,6 +291,7 @@ export function useCommandCenter() {
|
||||
if (!open) return;
|
||||
|
||||
const handler = (event: KeyboardEvent) => {
|
||||
const currentItems = itemsRef.current;
|
||||
const key = event.key;
|
||||
if (
|
||||
key !== "ArrowDown" &&
|
||||
@@ -265,26 +304,29 @@ export function useCommandCenter() {
|
||||
|
||||
if (key === "Escape") {
|
||||
event.preventDefault();
|
||||
handleClose();
|
||||
handleCloseRef.current();
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "Enter") {
|
||||
if (items.length === 0) return;
|
||||
if (currentItems.length === 0) return;
|
||||
event.preventDefault();
|
||||
const index = Math.max(0, Math.min(activeIndex, items.length - 1));
|
||||
handleSelectItem(items[index]!);
|
||||
const index = Math.max(
|
||||
0,
|
||||
Math.min(activeIndexRef.current, currentItems.length - 1)
|
||||
);
|
||||
handleSelectItemRef.current(currentItems[index]!);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "ArrowDown" || key === "ArrowUp") {
|
||||
if (items.length === 0) return;
|
||||
if (currentItems.length === 0) return;
|
||||
event.preventDefault();
|
||||
setActiveIndex((current) => {
|
||||
const delta = key === "ArrowDown" ? 1 : -1;
|
||||
const next = current + delta;
|
||||
if (next < 0) return items.length - 1;
|
||||
if (next >= items.length) return 0;
|
||||
if (next < 0) return currentItems.length - 1;
|
||||
if (next >= currentItems.length) return 0;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
@@ -293,7 +335,7 @@ export function useCommandCenter() {
|
||||
// react-native-web can stop propagation on key events, so listen in capture phase.
|
||||
window.addEventListener("keydown", handler, true);
|
||||
return () => window.removeEventListener("keydown", handler, true);
|
||||
}, [activeIndex, handleClose, handleSelectItem, items, open]);
|
||||
}, [open]);
|
||||
|
||||
return {
|
||||
open,
|
||||
|
||||
445
packages/app/src/hooks/use-git-actions.ts
Normal file
445
packages/app/src/hooks/use-git-actions.ts
Normal file
@@ -0,0 +1,445 @@
|
||||
import { useState, useCallback, useEffect, useMemo, type ReactElement } from "react";
|
||||
import { useRouter } from "expo-router";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useCheckoutGitActionsStore } from "@/stores/checkout-git-actions-store";
|
||||
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
|
||||
import { useCheckoutPrStatusQuery } from "@/hooks/use-checkout-pr-status-query";
|
||||
import { shouldShowMergeFromBaseAction } from "@/components/git-action-visibility";
|
||||
import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import type { ActionStatus } from "@/components/ui/dropdown-menu";
|
||||
|
||||
export type GitActionId =
|
||||
| "commit"
|
||||
| "push"
|
||||
| "view-pr"
|
||||
| "create-pr"
|
||||
| "merge-branch"
|
||||
| "merge-from-base"
|
||||
| "archive-worktree";
|
||||
|
||||
export interface GitAction {
|
||||
id: GitActionId;
|
||||
label: string;
|
||||
pendingLabel: string;
|
||||
successLabel: string;
|
||||
disabled: boolean;
|
||||
status: ActionStatus;
|
||||
description?: string;
|
||||
icon?: ReactElement;
|
||||
handler: () => void;
|
||||
}
|
||||
|
||||
export interface GitActions {
|
||||
primary: GitAction | null;
|
||||
secondary: GitAction[];
|
||||
menu: GitAction[];
|
||||
}
|
||||
|
||||
function openURLInNewTab(url: string): void {
|
||||
void openExternalUrl(url);
|
||||
}
|
||||
|
||||
interface UseGitActionsInput {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
icons: {
|
||||
commit: ReactElement;
|
||||
push: ReactElement;
|
||||
viewPr: ReactElement;
|
||||
createPr: ReactElement;
|
||||
merge: ReactElement;
|
||||
mergeFromBase: ReactElement;
|
||||
archive: ReactElement;
|
||||
};
|
||||
}
|
||||
|
||||
interface UseGitActionsResult {
|
||||
gitActions: GitActions;
|
||||
branchLabel: string;
|
||||
actionError: string | null;
|
||||
isGit: boolean;
|
||||
}
|
||||
|
||||
export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): UseGitActionsResult {
|
||||
const router = useRouter();
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
|
||||
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
|
||||
|
||||
const { status, isLoading: isStatusLoading } =
|
||||
useCheckoutStatusQuery({ serverId, cwd });
|
||||
const gitStatus = status && status.isGit ? status : null;
|
||||
const isGit = Boolean(gitStatus);
|
||||
const notGit = status !== null && !status.isGit && !status.error;
|
||||
const baseRef = gitStatus?.baseRef ?? undefined;
|
||||
|
||||
const hasUncommittedChanges = Boolean(gitStatus?.isDirty);
|
||||
|
||||
const {
|
||||
status: prStatus,
|
||||
githubFeaturesEnabled,
|
||||
} = useCheckoutPrStatusQuery({
|
||||
serverId,
|
||||
cwd,
|
||||
enabled: isGit,
|
||||
});
|
||||
|
||||
// Ship default persistence
|
||||
const shipDefaultStorageKey = useMemo(() => {
|
||||
if (!gitStatus?.repoRoot) {
|
||||
return null;
|
||||
}
|
||||
return `@paseo:changes-ship-default:${gitStatus.repoRoot}`;
|
||||
}, [gitStatus?.repoRoot]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shipDefaultStorageKey) {
|
||||
return;
|
||||
}
|
||||
let isActive = true;
|
||||
AsyncStorage.getItem(shipDefaultStorageKey)
|
||||
.then((value) => {
|
||||
if (!isActive) return;
|
||||
if (value === "pr" || value === "merge") {
|
||||
setShipDefault(value);
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
isActive = false;
|
||||
};
|
||||
}, [shipDefaultStorageKey]);
|
||||
|
||||
const persistShipDefault = useCallback(
|
||||
async (next: "merge" | "pr") => {
|
||||
setShipDefault(next);
|
||||
if (!shipDefaultStorageKey) return;
|
||||
try {
|
||||
await AsyncStorage.setItem(shipDefaultStorageKey, next);
|
||||
} catch {
|
||||
// Ignore persistence failures; default will reset to "merge".
|
||||
}
|
||||
},
|
||||
[shipDefaultStorageKey]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setPostShipArchiveSuggested(false);
|
||||
}, [cwd]);
|
||||
|
||||
// Store selectors
|
||||
const commitStatus = useCheckoutGitActionsStore((state) =>
|
||||
state.getStatus({ serverId, cwd, actionId: "commit" })
|
||||
);
|
||||
const pushStatus = useCheckoutGitActionsStore((state) =>
|
||||
state.getStatus({ serverId, cwd, actionId: "push" })
|
||||
);
|
||||
const prCreateStatus = useCheckoutGitActionsStore((state) =>
|
||||
state.getStatus({ serverId, cwd, actionId: "create-pr" })
|
||||
);
|
||||
const mergeStatus = useCheckoutGitActionsStore((state) =>
|
||||
state.getStatus({ serverId, cwd, actionId: "merge-branch" })
|
||||
);
|
||||
const mergeFromBaseStatus = useCheckoutGitActionsStore((state) =>
|
||||
state.getStatus({ serverId, cwd, actionId: "merge-from-base" })
|
||||
);
|
||||
const archiveStatus = useCheckoutGitActionsStore((state) =>
|
||||
state.getStatus({ serverId, cwd, actionId: "archive-worktree" })
|
||||
);
|
||||
|
||||
const runCommit = useCheckoutGitActionsStore((state) => state.commit);
|
||||
const runPush = useCheckoutGitActionsStore((state) => state.push);
|
||||
const runCreatePr = useCheckoutGitActionsStore((state) => state.createPr);
|
||||
const runMergeBranch = useCheckoutGitActionsStore((state) => state.mergeBranch);
|
||||
const runMergeFromBase = useCheckoutGitActionsStore((state) => state.mergeFromBase);
|
||||
const runArchiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
|
||||
|
||||
// Handlers
|
||||
const handleCommit = useCallback(() => {
|
||||
setActionError(null);
|
||||
void runCommit({ serverId, cwd }).catch((err) => {
|
||||
const message = err instanceof Error ? err.message : "Failed to commit";
|
||||
setActionError(message);
|
||||
});
|
||||
}, [runCommit, serverId, cwd]);
|
||||
|
||||
const handlePush = useCallback(() => {
|
||||
setActionError(null);
|
||||
void runPush({ serverId, cwd }).catch((err) => {
|
||||
const message = err instanceof Error ? err.message : "Failed to push";
|
||||
setActionError(message);
|
||||
});
|
||||
}, [runPush, serverId, cwd]);
|
||||
|
||||
const handleCreatePr = useCallback(() => {
|
||||
void persistShipDefault("pr");
|
||||
setActionError(null);
|
||||
void runCreatePr({ serverId, cwd }).catch((err) => {
|
||||
const message = err instanceof Error ? err.message : "Failed to create PR";
|
||||
setActionError(message);
|
||||
});
|
||||
}, [persistShipDefault, runCreatePr, serverId, cwd]);
|
||||
|
||||
const handleMergeBranch = useCallback(() => {
|
||||
if (!baseRef) {
|
||||
setActionError("Base ref unavailable");
|
||||
return;
|
||||
}
|
||||
void persistShipDefault("merge");
|
||||
setActionError(null);
|
||||
void runMergeBranch({ serverId, cwd, baseRef })
|
||||
.then(() => {
|
||||
setPostShipArchiveSuggested(true);
|
||||
})
|
||||
.catch((err) => {
|
||||
const message = err instanceof Error ? err.message : "Failed to merge";
|
||||
setActionError(message);
|
||||
});
|
||||
}, [baseRef, persistShipDefault, runMergeBranch, serverId, cwd]);
|
||||
|
||||
const handleMergeFromBase = useCallback(() => {
|
||||
if (!baseRef) {
|
||||
setActionError("Base ref unavailable");
|
||||
return;
|
||||
}
|
||||
setActionError(null);
|
||||
void runMergeFromBase({ serverId, cwd, baseRef }).catch((err) => {
|
||||
const message = err instanceof Error ? err.message : "Failed to merge from base";
|
||||
setActionError(message);
|
||||
});
|
||||
}, [baseRef, runMergeFromBase, serverId, cwd]);
|
||||
|
||||
const handleArchiveWorktree = useCallback(() => {
|
||||
const worktreePath = status?.cwd;
|
||||
if (!worktreePath) {
|
||||
setActionError("Worktree path unavailable");
|
||||
return;
|
||||
}
|
||||
setActionError(null);
|
||||
const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null);
|
||||
void runArchiveWorktree({ serverId, cwd, worktreePath })
|
||||
.then(() => {
|
||||
router.replace(buildNewAgentRoute(serverId, targetWorkingDir) as any);
|
||||
})
|
||||
.catch((err) => {
|
||||
const message = err instanceof Error ? err.message : "Failed to archive worktree";
|
||||
setActionError(message);
|
||||
});
|
||||
}, [runArchiveWorktree, router, serverId, cwd, status]);
|
||||
|
||||
// Derived state
|
||||
const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading;
|
||||
const aheadCount = gitStatus?.aheadBehind?.ahead ?? 0;
|
||||
const aheadOfOrigin = gitStatus?.aheadOfOrigin ?? 0;
|
||||
const behindOfOrigin = gitStatus?.behindOfOrigin ?? 0;
|
||||
const baseRefLabel = useMemo(() => {
|
||||
if (!baseRef) return "base";
|
||||
const trimmed = baseRef.replace(/^refs\/(heads|remotes)\//, "").trim();
|
||||
return trimmed.startsWith("origin/") ? trimmed.slice("origin/".length) : trimmed;
|
||||
}, [baseRef]);
|
||||
const hasPullRequest = Boolean(prStatus?.url);
|
||||
const hasRemote = gitStatus?.hasRemote ?? false;
|
||||
const isPaseoOwnedWorktree = gitStatus?.isPaseoOwnedWorktree ?? false;
|
||||
const isMergedPullRequest = Boolean(prStatus?.isMerged);
|
||||
const currentBranch = gitStatus?.currentBranch;
|
||||
const isOnBaseBranch = currentBranch === baseRefLabel;
|
||||
const shouldPromoteArchive =
|
||||
isPaseoOwnedWorktree &&
|
||||
!hasUncommittedChanges &&
|
||||
(postShipArchiveSuggested || isMergedPullRequest);
|
||||
|
||||
const commitDisabled = actionsDisabled || commitStatus === "pending";
|
||||
const prDisabled = actionsDisabled || prCreateStatus === "pending";
|
||||
const mergeDisabled =
|
||||
actionsDisabled || mergeStatus === "pending" || hasUncommittedChanges || !baseRef;
|
||||
const mergeFromBaseDisabled =
|
||||
actionsDisabled ||
|
||||
mergeFromBaseStatus === "pending" ||
|
||||
hasUncommittedChanges ||
|
||||
!baseRef ||
|
||||
(isOnBaseBranch && !hasRemote);
|
||||
const pushDisabled =
|
||||
actionsDisabled || pushStatus === "pending" || !(gitStatus?.hasRemote ?? false);
|
||||
const archiveDisabled =
|
||||
actionsDisabled ||
|
||||
archiveStatus === "pending" ||
|
||||
!gitStatus?.isPaseoOwnedWorktree;
|
||||
|
||||
const branchLabel =
|
||||
gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD"
|
||||
? gitStatus.currentBranch
|
||||
: notGit
|
||||
? "Not a git repository"
|
||||
: "Unknown";
|
||||
|
||||
// Build actions
|
||||
const gitActions: GitActions = useMemo(() => {
|
||||
if (!isGit) {
|
||||
return { primary: null, secondary: [], menu: [] };
|
||||
}
|
||||
|
||||
const allActions = new Map<GitActionId, GitAction>();
|
||||
|
||||
allActions.set("commit", {
|
||||
id: "commit",
|
||||
label: "Commit",
|
||||
pendingLabel: "Committing...",
|
||||
successLabel: "Committed",
|
||||
disabled: commitDisabled,
|
||||
status: commitStatus,
|
||||
icon: icons.commit,
|
||||
handler: handleCommit,
|
||||
});
|
||||
|
||||
if (hasRemote) {
|
||||
allActions.set("push", {
|
||||
id: "push",
|
||||
label: "Push",
|
||||
pendingLabel: "Pushing...",
|
||||
successLabel: "Pushed",
|
||||
disabled: pushDisabled,
|
||||
status: pushStatus,
|
||||
description: !hasRemote ? "No remote configured" : undefined,
|
||||
icon: icons.push,
|
||||
handler: handlePush,
|
||||
});
|
||||
}
|
||||
|
||||
if (githubFeaturesEnabled && hasPullRequest && prStatus?.url) {
|
||||
const prUrl = prStatus.url;
|
||||
allActions.set("view-pr", {
|
||||
id: "view-pr",
|
||||
label: "View PR",
|
||||
pendingLabel: "View PR",
|
||||
successLabel: "View PR",
|
||||
disabled: false,
|
||||
status: "idle",
|
||||
icon: icons.viewPr,
|
||||
handler: () => openURLInNewTab(prUrl),
|
||||
});
|
||||
}
|
||||
|
||||
if (githubFeaturesEnabled && aheadCount > 0 && !hasPullRequest) {
|
||||
allActions.set("create-pr", {
|
||||
id: "create-pr",
|
||||
label: "Create PR",
|
||||
pendingLabel: "Creating PR...",
|
||||
successLabel: "PR Created",
|
||||
disabled: prDisabled,
|
||||
status: prCreateStatus,
|
||||
icon: icons.createPr,
|
||||
handler: handleCreatePr,
|
||||
});
|
||||
}
|
||||
|
||||
if (aheadCount > 0) {
|
||||
allActions.set("merge-branch", {
|
||||
id: "merge-branch",
|
||||
label: `Merge into ${baseRefLabel}`,
|
||||
pendingLabel: "Merging...",
|
||||
successLabel: "Merged",
|
||||
disabled: mergeDisabled,
|
||||
status: mergeStatus,
|
||||
description: hasUncommittedChanges ? "Requires clean working tree" : undefined,
|
||||
icon: icons.merge,
|
||||
handler: handleMergeBranch,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
shouldShowMergeFromBaseAction({
|
||||
isOnBaseBranch,
|
||||
hasRemote,
|
||||
aheadOfOrigin,
|
||||
behindOfOrigin,
|
||||
})
|
||||
) {
|
||||
allActions.set("merge-from-base", {
|
||||
id: "merge-from-base",
|
||||
label: isOnBaseBranch ? "Sync" : `Update from ${baseRefLabel}`,
|
||||
pendingLabel: "Updating...",
|
||||
successLabel: "Updated",
|
||||
disabled: mergeFromBaseDisabled,
|
||||
status: mergeFromBaseStatus,
|
||||
description:
|
||||
hasUncommittedChanges
|
||||
? "Requires clean working tree"
|
||||
: isOnBaseBranch && !hasRemote
|
||||
? "No remote configured"
|
||||
: undefined,
|
||||
icon: icons.mergeFromBase,
|
||||
handler: handleMergeFromBase,
|
||||
});
|
||||
}
|
||||
|
||||
if (isPaseoOwnedWorktree) {
|
||||
allActions.set("archive-worktree", {
|
||||
id: "archive-worktree",
|
||||
label: "Archive worktree",
|
||||
pendingLabel: "Archiving...",
|
||||
successLabel: "Archived",
|
||||
disabled: archiveDisabled,
|
||||
status: archiveStatus,
|
||||
icon: icons.archive,
|
||||
handler: handleArchiveWorktree,
|
||||
});
|
||||
}
|
||||
|
||||
// Select primary action (priority rules)
|
||||
let primaryActionId: GitActionId | null = null;
|
||||
|
||||
if (shouldPromoteArchive && allActions.has("archive-worktree")) {
|
||||
primaryActionId = "archive-worktree";
|
||||
} else if (hasUncommittedChanges) {
|
||||
primaryActionId = "commit";
|
||||
} else if (aheadOfOrigin > 0 && allActions.has("push")) {
|
||||
primaryActionId = "push";
|
||||
} else if (hasPullRequest) {
|
||||
primaryActionId = "view-pr";
|
||||
} else if (isOnBaseBranch && allActions.has("merge-from-base")) {
|
||||
primaryActionId = "merge-from-base";
|
||||
} else if (aheadCount > 0) {
|
||||
const preferred: GitActionId = shipDefault === "merge" ? "merge-branch" : "create-pr";
|
||||
const fallback: GitActionId = shipDefault === "merge" ? "create-pr" : "merge-branch";
|
||||
|
||||
const preferredAction = allActions.get(preferred);
|
||||
const fallbackAction = allActions.get(fallback);
|
||||
|
||||
if (preferredAction && !preferredAction.disabled) {
|
||||
primaryActionId = preferred;
|
||||
} else if (fallbackAction && !fallbackAction.disabled) {
|
||||
primaryActionId = fallback;
|
||||
} else if (preferredAction) {
|
||||
primaryActionId = preferred;
|
||||
}
|
||||
}
|
||||
|
||||
const primary = primaryActionId ? allActions.get(primaryActionId) ?? null : null;
|
||||
|
||||
const secondaryIds: GitActionId[] = [
|
||||
"merge-branch",
|
||||
"create-pr",
|
||||
"view-pr",
|
||||
"merge-from-base",
|
||||
"push",
|
||||
"archive-worktree",
|
||||
];
|
||||
const secondary = secondaryIds
|
||||
.filter(id => id !== primaryActionId && allActions.has(id))
|
||||
.map(id => allActions.get(id)!);
|
||||
|
||||
const menu: GitAction[] = [];
|
||||
|
||||
return { primary, secondary, menu };
|
||||
}, [
|
||||
isGit, hasRemote, hasPullRequest, prStatus?.url, aheadCount, isPaseoOwnedWorktree, isOnBaseBranch, githubFeaturesEnabled,
|
||||
hasUncommittedChanges, aheadOfOrigin, behindOfOrigin, shipDefault, baseRefLabel, shouldPromoteArchive,
|
||||
commitDisabled, pushDisabled, prDisabled, mergeDisabled, mergeFromBaseDisabled, archiveDisabled,
|
||||
commitStatus, pushStatus, prCreateStatus, mergeStatus, mergeFromBaseStatus, archiveStatus,
|
||||
handleCommit, handlePush, handleCreatePr, handleMergeBranch, handleMergeFromBase, handleArchiveWorktree,
|
||||
icons, baseRef,
|
||||
]);
|
||||
|
||||
return { gitActions, branchLabel, actionError, isGit };
|
||||
}
|
||||
@@ -2,15 +2,12 @@ import { useEffect } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { usePathname, useRouter } from "expo-router";
|
||||
import { getIsTauri } from "@/constants/layout";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
||||
import {
|
||||
buildHostNewAgentRoute,
|
||||
buildHostWorkspaceRoute,
|
||||
parseHostAgentRouteFromPathname,
|
||||
parseHostWorkspaceRouteFromPathname,
|
||||
parseServerIdFromPathname,
|
||||
} from "@/utils/host-routes";
|
||||
import {
|
||||
type MessageInputKeyboardActionKind,
|
||||
@@ -99,19 +96,8 @@ export function useKeyboardShortcuts({
|
||||
return true;
|
||||
};
|
||||
|
||||
const navigateToNewAgent = (): boolean => {
|
||||
let targetServerId = parseServerIdFromPathname(pathname);
|
||||
|
||||
if (!targetServerId) {
|
||||
const sessionServerIds = Object.keys(useSessionStore.getState().sessions);
|
||||
targetServerId = sessionServerIds[0] ?? null;
|
||||
}
|
||||
|
||||
if (!targetServerId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
router.push(buildHostNewAgentRoute(targetServerId) as any);
|
||||
const openProjectPicker = (): boolean => {
|
||||
useKeyboardShortcutsStore.getState().setProjectPickerOpen(true);
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -171,7 +157,7 @@ export function useKeyboardShortcuts({
|
||||
}): boolean => {
|
||||
switch (input.action) {
|
||||
case "agent.new":
|
||||
return navigateToNewAgent();
|
||||
return openProjectPicker();
|
||||
case "workspace.tab.new":
|
||||
return requestWorkspaceTabAction({ kind: "new" });
|
||||
case "workspace.tab.close.current":
|
||||
|
||||
53
packages/app/src/hooks/use-settings.test.ts
Normal file
53
packages/app/src/hooks/use-settings.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const asyncStorageMock = vi.hoisted(() => ({
|
||||
getItem: vi.fn<(_: string) => Promise<string | null>>(),
|
||||
setItem: vi.fn<(_: string, __: string) => Promise<void>>(),
|
||||
}));
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||
default: asyncStorageMock,
|
||||
}));
|
||||
|
||||
describe("use-settings", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
asyncStorageMock.getItem.mockReset();
|
||||
asyncStorageMock.setItem.mockReset();
|
||||
});
|
||||
|
||||
it("defaults built-in daemon management to enabled when storage is empty", async () => {
|
||||
asyncStorageMock.getItem.mockResolvedValue(null);
|
||||
asyncStorageMock.setItem.mockResolvedValue();
|
||||
|
||||
const mod = await import("./use-settings");
|
||||
const result = await mod.loadSettingsFromStorage();
|
||||
|
||||
expect(result).toEqual(mod.DEFAULT_APP_SETTINGS);
|
||||
expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
|
||||
mod.APP_SETTINGS_KEY,
|
||||
JSON.stringify(mod.DEFAULT_APP_SETTINGS)
|
||||
);
|
||||
});
|
||||
|
||||
it("loads persisted built-in daemon management state", async () => {
|
||||
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
|
||||
if (key === "@paseo:app-settings") {
|
||||
return JSON.stringify({
|
||||
theme: "light",
|
||||
manageBuiltInDaemon: false,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const mod = await import("./use-settings");
|
||||
const result = await mod.loadSettingsFromStorage();
|
||||
|
||||
expect(result).toEqual({
|
||||
theme: "light",
|
||||
manageBuiltInDaemon: false,
|
||||
});
|
||||
expect(asyncStorageMock.setItem).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -2,16 +2,18 @@ import { useCallback } from "react";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||
export const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||
const LEGACY_SETTINGS_KEY = "@paseo:settings";
|
||||
const APP_SETTINGS_QUERY_KEY = ["app-settings"];
|
||||
|
||||
export interface AppSettings {
|
||||
theme: "dark" | "light" | "auto";
|
||||
manageBuiltInDaemon: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_APP_SETTINGS: AppSettings = {
|
||||
export const DEFAULT_APP_SETTINGS: AppSettings = {
|
||||
theme: "dark",
|
||||
manageBuiltInDaemon: true,
|
||||
};
|
||||
|
||||
export interface UseAppSettingsReturn {
|
||||
@@ -66,7 +68,7 @@ export function useAppSettings(): UseAppSettingsReturn {
|
||||
};
|
||||
}
|
||||
|
||||
async function loadSettingsFromStorage(): Promise<AppSettings> {
|
||||
export async function loadSettingsFromStorage(): Promise<AppSettings> {
|
||||
try {
|
||||
const stored = await AsyncStorage.getItem(APP_SETTINGS_KEY);
|
||||
if (stored) {
|
||||
@@ -98,6 +100,9 @@ function pickAppSettingsFromLegacy(legacy: Record<string, unknown>): Partial<App
|
||||
if (legacy.theme === "dark" || legacy.theme === "light" || legacy.theme === "auto") {
|
||||
result.theme = legacy.theme;
|
||||
}
|
||||
if (typeof legacy.manageBuiltInDaemon === "boolean") {
|
||||
result.manageBuiltInDaemon = legacy.manageBuiltInDaemon;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,29 @@ function item(key: string): OrderedItem {
|
||||
return { key }
|
||||
}
|
||||
|
||||
function workspace(
|
||||
input: Pick<WorkspaceDescriptor, "id" | "projectId" | "name" | "status" | "activityAt"> &
|
||||
Partial<
|
||||
Pick<
|
||||
WorkspaceDescriptor,
|
||||
"projectDisplayName" | "projectRootPath" | "projectKind" | "workspaceKind"
|
||||
>
|
||||
>
|
||||
): WorkspaceDescriptor {
|
||||
return {
|
||||
id: input.id,
|
||||
projectId: input.projectId,
|
||||
projectDisplayName: input.projectDisplayName ?? input.projectId,
|
||||
projectRootPath: input.projectRootPath ?? input.id,
|
||||
projectKind: input.projectKind ?? "git",
|
||||
workspaceKind: input.workspaceKind ?? "local_checkout",
|
||||
name: input.name,
|
||||
status: input.status,
|
||||
activityAt: input.activityAt,
|
||||
diffStat: null,
|
||||
}
|
||||
}
|
||||
|
||||
describe('applyStoredOrdering', () => {
|
||||
it('keeps unknown items on the baseline while applying stored order', () => {
|
||||
const result = applyStoredOrdering({
|
||||
@@ -72,13 +95,13 @@ describe('appendMissingOrderKeys', () => {
|
||||
describe('buildSidebarProjectsFromWorkspaces', () => {
|
||||
it('uses workspace descriptor name and status directly', () => {
|
||||
const workspaces: WorkspaceDescriptor[] = [
|
||||
{
|
||||
workspace({
|
||||
id: '/repo/main',
|
||||
projectId: 'project-1',
|
||||
name: 'feat/hard-cut',
|
||||
status: 'failed',
|
||||
activityAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
const projects = buildSidebarProjectsFromWorkspaces({
|
||||
@@ -96,20 +119,20 @@ describe('buildSidebarProjectsFromWorkspaces', () => {
|
||||
|
||||
it('preserves stored project order even when activity changes', () => {
|
||||
const initialWorkspaces: WorkspaceDescriptor[] = [
|
||||
{
|
||||
workspace({
|
||||
id: '/repo/b',
|
||||
projectId: 'project-b',
|
||||
name: 'feat/b',
|
||||
status: 'running',
|
||||
activityAt: new Date('2026-01-02T00:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
}),
|
||||
workspace({
|
||||
id: '/repo/a',
|
||||
projectId: 'project-a',
|
||||
name: 'feat/a',
|
||||
status: 'running',
|
||||
activityAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
const seededOrder = appendMissingOrderKeys({
|
||||
@@ -125,20 +148,20 @@ describe('buildSidebarProjectsFromWorkspaces', () => {
|
||||
const updatedProjects = buildSidebarProjectsFromWorkspaces({
|
||||
serverId: 'srv',
|
||||
workspaces: [
|
||||
{
|
||||
workspace({
|
||||
id: '/repo/b',
|
||||
projectId: 'project-b',
|
||||
name: 'feat/b',
|
||||
status: 'running',
|
||||
activityAt: new Date('2026-01-02T00:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
}),
|
||||
workspace({
|
||||
id: '/repo/a',
|
||||
projectId: 'project-a',
|
||||
name: 'feat/a',
|
||||
status: 'running',
|
||||
activityAt: new Date('2026-01-03T00:00:00.000Z'),
|
||||
},
|
||||
}),
|
||||
],
|
||||
projectOrder: seededOrder,
|
||||
workspaceOrderByScope: {},
|
||||
@@ -151,27 +174,27 @@ describe('buildSidebarProjectsFromWorkspaces', () => {
|
||||
const projects = buildSidebarProjectsFromWorkspaces({
|
||||
serverId: 'srv',
|
||||
workspaces: [
|
||||
{
|
||||
workspace({
|
||||
id: '/repo/c',
|
||||
projectId: 'project-c',
|
||||
name: 'feat/c',
|
||||
status: 'running',
|
||||
activityAt: new Date('2026-01-04T00:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
}),
|
||||
workspace({
|
||||
id: '/repo/b',
|
||||
projectId: 'project-b',
|
||||
name: 'feat/b',
|
||||
status: 'running',
|
||||
activityAt: new Date('2026-01-02T00:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
}),
|
||||
workspace({
|
||||
id: '/repo/a',
|
||||
projectId: 'project-a',
|
||||
name: 'feat/a',
|
||||
status: 'running',
|
||||
activityAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
},
|
||||
}),
|
||||
],
|
||||
projectOrder: ['project-b', 'project-a', 'project-c'],
|
||||
workspaceOrderByScope: {},
|
||||
@@ -184,20 +207,20 @@ describe('buildSidebarProjectsFromWorkspaces', () => {
|
||||
const initialProjects = buildSidebarProjectsFromWorkspaces({
|
||||
serverId: 'srv',
|
||||
workspaces: [
|
||||
{
|
||||
workspace({
|
||||
id: '/repo/main',
|
||||
projectId: 'project-1',
|
||||
name: 'main',
|
||||
status: 'running',
|
||||
activityAt: new Date('2026-01-02T00:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
}),
|
||||
workspace({
|
||||
id: '/repo/feature',
|
||||
projectId: 'project-1',
|
||||
name: 'feature',
|
||||
status: 'running',
|
||||
activityAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
},
|
||||
}),
|
||||
],
|
||||
projectOrder: ['project-1'],
|
||||
workspaceOrderByScope: {},
|
||||
@@ -211,20 +234,20 @@ describe('buildSidebarProjectsFromWorkspaces', () => {
|
||||
const projects = buildSidebarProjectsFromWorkspaces({
|
||||
serverId: 'srv',
|
||||
workspaces: [
|
||||
{
|
||||
workspace({
|
||||
id: '/repo/main',
|
||||
projectId: 'project-1',
|
||||
name: 'main',
|
||||
status: 'running',
|
||||
activityAt: new Date('2026-01-02T00:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
}),
|
||||
workspace({
|
||||
id: '/repo/feature',
|
||||
projectId: 'project-1',
|
||||
name: 'feature',
|
||||
status: 'running',
|
||||
activityAt: new Date('2026-01-03T00:00:00.000Z'),
|
||||
},
|
||||
}),
|
||||
],
|
||||
projectOrder: ['project-1'],
|
||||
workspaceOrderByScope: {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useCallback, useEffect, useMemo, useSyncExternalStore } from 'react'
|
||||
import { useSessionStore } from '@/stores/session-store'
|
||||
import { normalizeWorkspaceDescriptor, useSessionStore } from '@/stores/session-store'
|
||||
import { getHostRuntimeStore } from '@/runtime/host-runtime'
|
||||
import { useSidebarOrderStore } from '@/stores/sidebar-order-store'
|
||||
import type { WorkspaceDescriptor } from '@/stores/session-store'
|
||||
import { projectDisplayNameFromProjectId } from '@/utils/project-display-name'
|
||||
import { normalizeWorkspaceIdentity } from '@/utils/workspace-identity'
|
||||
|
||||
const EMPTY_ORDER: string[] = []
|
||||
const EMPTY_PROJECTS: SidebarProjectEntry[] = []
|
||||
@@ -15,14 +14,17 @@ export interface SidebarWorkspaceEntry {
|
||||
workspaceKey: string
|
||||
serverId: string
|
||||
workspaceId: string
|
||||
workspaceKind: WorkspaceDescriptor['workspaceKind']
|
||||
name: string
|
||||
activityAt: Date | null
|
||||
statusBucket: SidebarStateBucket
|
||||
diffStat: { additions: number; deletions: number } | null
|
||||
}
|
||||
|
||||
export interface SidebarProjectEntry {
|
||||
projectKey: string
|
||||
projectName: string
|
||||
projectKind: WorkspaceDescriptor['projectKind']
|
||||
iconWorkingDir: string
|
||||
statusBucket: SidebarStateBucket
|
||||
activeCount: number
|
||||
@@ -110,8 +112,9 @@ export function buildSidebarProjectsFromWorkspaces(input: {
|
||||
byProject.get(workspace.projectId) ??
|
||||
({
|
||||
projectKey: workspace.projectId,
|
||||
projectName: projectDisplayNameFromProjectId(workspace.projectId),
|
||||
iconWorkingDir: workspace.id,
|
||||
projectName: workspace.projectDisplayName || projectDisplayNameFromProjectId(workspace.projectId),
|
||||
projectKind: workspace.projectKind,
|
||||
iconWorkingDir: workspace.projectRootPath || workspace.id,
|
||||
statusBucket: 'done',
|
||||
activeCount: 0,
|
||||
totalWorkspaces: 0,
|
||||
@@ -123,9 +126,11 @@ export function buildSidebarProjectsFromWorkspaces(input: {
|
||||
workspaceKey: `${input.serverId}:${workspace.id}`,
|
||||
serverId: input.serverId,
|
||||
workspaceId: workspace.id,
|
||||
workspaceKind: workspace.workspaceKind,
|
||||
name: workspace.name,
|
||||
activityAt: workspace.activityAt,
|
||||
statusBucket: workspace.status,
|
||||
diffStat: workspace.diffStat,
|
||||
}
|
||||
|
||||
project.workspaces.push(row)
|
||||
@@ -241,18 +246,15 @@ function getWorkspaceOrderScopeKey(serverId: string, projectKey: string): string
|
||||
function toWorkspaceDescriptor(payload: {
|
||||
id: string
|
||||
projectId: string
|
||||
projectDisplayName: string
|
||||
projectRootPath: string
|
||||
projectKind: WorkspaceDescriptor['projectKind']
|
||||
workspaceKind: WorkspaceDescriptor['workspaceKind']
|
||||
name: string
|
||||
status: WorkspaceDescriptor['status']
|
||||
activityAt: string | null
|
||||
}): WorkspaceDescriptor {
|
||||
const activityAt = payload.activityAt ? new Date(payload.activityAt) : null
|
||||
return {
|
||||
id: normalizeWorkspaceIdentity(payload.id) ?? payload.id,
|
||||
projectId: payload.projectId,
|
||||
name: payload.name,
|
||||
status: payload.status,
|
||||
activityAt: activityAt && !Number.isNaN(activityAt.getTime()) ? activityAt : null,
|
||||
}
|
||||
return normalizeWorkspaceDescriptor(payload)
|
||||
}
|
||||
|
||||
export function useSidebarWorkspacesList(options?: {
|
||||
|
||||
@@ -31,306 +31,226 @@ function shortcutContext(
|
||||
};
|
||||
}
|
||||
|
||||
function expectShortcutResolution(input: {
|
||||
event: Partial<KeyboardEvent>;
|
||||
context?: Partial<KeyboardShortcutContext>;
|
||||
action: string;
|
||||
payload?: unknown;
|
||||
preventDefault?: boolean;
|
||||
stopPropagation?: boolean;
|
||||
}) {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent(input.event),
|
||||
context: shortcutContext(input.context),
|
||||
});
|
||||
|
||||
expect(match?.action).toBe(input.action);
|
||||
if ("payload" in input) {
|
||||
expect(match?.payload).toEqual(input.payload);
|
||||
}
|
||||
expect(match?.preventDefault).toBe(input.preventDefault ?? true);
|
||||
expect(match?.stopPropagation).toBe(input.stopPropagation ?? true);
|
||||
}
|
||||
|
||||
function expectNoShortcutResolution(input: {
|
||||
event: Partial<KeyboardEvent>;
|
||||
context?: Partial<KeyboardShortcutContext>;
|
||||
}) {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent(input.event),
|
||||
context: shortcutContext(input.context),
|
||||
});
|
||||
|
||||
expect(match).toBeNull();
|
||||
}
|
||||
|
||||
type MatchingShortcutCase = {
|
||||
name: string;
|
||||
event: Partial<KeyboardEvent>;
|
||||
context?: Partial<KeyboardShortcutContext>;
|
||||
action: string;
|
||||
payload?: unknown;
|
||||
preventDefault?: boolean;
|
||||
stopPropagation?: boolean;
|
||||
};
|
||||
|
||||
type NonMatchingShortcutCase = {
|
||||
name: string;
|
||||
event: Partial<KeyboardEvent>;
|
||||
context?: Partial<KeyboardShortcutContext>;
|
||||
};
|
||||
|
||||
type HelpSectionCase = {
|
||||
name: string;
|
||||
context: {
|
||||
isMac: boolean;
|
||||
isTauri: boolean;
|
||||
};
|
||||
expectedKeys: Record<string, string[]>;
|
||||
};
|
||||
|
||||
describe("keyboard-shortcuts", () => {
|
||||
it("matches Mod+Shift+O to create new agent", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
key: "O",
|
||||
code: "KeyO",
|
||||
metaKey: true,
|
||||
shiftKey: true,
|
||||
}),
|
||||
context: shortcutContext({ isMac: true }),
|
||||
});
|
||||
const matchingCases: MatchingShortcutCase[] = [
|
||||
{
|
||||
name: "matches Mod+Shift+O to create new agent",
|
||||
event: { key: "O", code: "KeyO", metaKey: true, shiftKey: true },
|
||||
context: { isMac: true },
|
||||
action: "agent.new",
|
||||
},
|
||||
{
|
||||
name: "matches question-mark shortcut to toggle the shortcuts dialog",
|
||||
event: { key: "?", code: "Slash", shiftKey: true },
|
||||
context: { focusScope: "other" },
|
||||
action: "shortcuts.dialog.toggle",
|
||||
},
|
||||
{
|
||||
name: "matches workspace index jump on web via Alt+digit",
|
||||
event: { key: "2", code: "Digit2", altKey: true },
|
||||
context: { isTauri: false },
|
||||
action: "workspace.navigate.index",
|
||||
payload: { index: 2 },
|
||||
},
|
||||
{
|
||||
name: "matches workspace index jump on tauri via Mod+digit",
|
||||
event: { key: "2", code: "Digit2", metaKey: true },
|
||||
context: { isMac: true, isTauri: true },
|
||||
action: "workspace.navigate.index",
|
||||
payload: { index: 2 },
|
||||
},
|
||||
{
|
||||
name: "matches tab index jump on tauri via Alt+digit",
|
||||
event: { key: "2", code: "Digit2", altKey: true },
|
||||
context: { isTauri: true },
|
||||
action: "workspace.tab.navigate.index",
|
||||
payload: { index: 2 },
|
||||
},
|
||||
{
|
||||
name: "matches tab index jump on web via Alt+Shift+digit",
|
||||
event: { key: "@", code: "Digit2", altKey: true, shiftKey: true },
|
||||
context: { isTauri: false },
|
||||
action: "workspace.tab.navigate.index",
|
||||
payload: { index: 2 },
|
||||
},
|
||||
{
|
||||
name: "matches workspace relative navigation on web via Alt+[",
|
||||
event: { key: "[", code: "BracketLeft", altKey: true },
|
||||
context: { isTauri: false },
|
||||
action: "workspace.navigate.relative",
|
||||
payload: { delta: -1 },
|
||||
},
|
||||
{
|
||||
name: "matches workspace relative navigation on tauri via Mod+]",
|
||||
event: { key: "]", code: "BracketRight", ctrlKey: true },
|
||||
context: { isTauri: true },
|
||||
action: "workspace.navigate.relative",
|
||||
payload: { delta: 1 },
|
||||
},
|
||||
{
|
||||
name: "matches tab relative navigation via Alt+Shift+]",
|
||||
event: { key: "}", code: "BracketRight", altKey: true, shiftKey: true },
|
||||
action: "workspace.tab.navigate.relative",
|
||||
payload: { delta: 1 },
|
||||
},
|
||||
{
|
||||
name: "matches Mod+T to open new tab",
|
||||
event: { key: "t", code: "KeyT", metaKey: true },
|
||||
context: { isMac: true },
|
||||
action: "workspace.tab.new",
|
||||
},
|
||||
{
|
||||
name: "matches Alt+Shift+W to close current tab on web",
|
||||
event: { key: "W", code: "KeyW", altKey: true, shiftKey: true },
|
||||
context: { isTauri: false },
|
||||
action: "workspace.tab.close.current",
|
||||
},
|
||||
{
|
||||
name: "matches Mod+W to close current tab on tauri",
|
||||
event: { key: "w", code: "KeyW", metaKey: true },
|
||||
context: { isMac: true, isTauri: true },
|
||||
action: "workspace.tab.close.current",
|
||||
},
|
||||
{
|
||||
name: "matches Cmd+B sidebar toggle on macOS",
|
||||
event: { key: "b", code: "KeyB", metaKey: true },
|
||||
context: { isMac: true },
|
||||
action: "sidebar.toggle.left",
|
||||
},
|
||||
{
|
||||
name: "keeps Mod+. as sidebar toggle fallback",
|
||||
event: { key: ".", code: "Period", ctrlKey: true },
|
||||
context: { isMac: false },
|
||||
action: "sidebar.toggle.left",
|
||||
},
|
||||
{
|
||||
name: "routes Mod+D to message-input action outside terminal",
|
||||
event: { key: "d", code: "KeyD", metaKey: true },
|
||||
context: { isMac: true, focusScope: "message-input" },
|
||||
action: "message-input.action",
|
||||
payload: { kind: "dictation-toggle" },
|
||||
},
|
||||
{
|
||||
name: "routes space to voice mute toggle outside editable scopes",
|
||||
event: { key: " ", code: "Space" },
|
||||
context: { focusScope: "other" },
|
||||
action: "message-input.action",
|
||||
payload: { kind: "voice-mute-toggle" },
|
||||
},
|
||||
{
|
||||
name: "lets Escape continue to local handlers while routing dictation cancel",
|
||||
event: { key: "Escape", code: "Escape" },
|
||||
context: { focusScope: "message-input" },
|
||||
action: "message-input.action",
|
||||
payload: { kind: "dictation-cancel" },
|
||||
preventDefault: false,
|
||||
stopPropagation: false,
|
||||
},
|
||||
];
|
||||
|
||||
expect(match?.action).toBe("agent.new");
|
||||
it.each(matchingCases)("$name", ({ event, context, action, payload, preventDefault, stopPropagation }) => {
|
||||
expectShortcutResolution({
|
||||
event,
|
||||
context,
|
||||
action,
|
||||
...(payload !== undefined ? { payload } : {}),
|
||||
...(preventDefault !== undefined ? { preventDefault } : {}),
|
||||
...(stopPropagation !== undefined ? { stopPropagation } : {}),
|
||||
});
|
||||
});
|
||||
|
||||
it("does not keep old Mod+Alt+N binding", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
key: "n",
|
||||
code: "KeyN",
|
||||
metaKey: true,
|
||||
altKey: true,
|
||||
}),
|
||||
context: shortcutContext({ isMac: true }),
|
||||
});
|
||||
const nonMatchingCases: NonMatchingShortcutCase[] = [
|
||||
{
|
||||
name: "does not keep old Mod+Alt+N binding",
|
||||
event: { key: "n", code: "KeyN", metaKey: true, altKey: true },
|
||||
context: { isMac: true },
|
||||
},
|
||||
{
|
||||
name: "does not keep old Alt+Shift+T binding",
|
||||
event: { key: "T", code: "KeyT", altKey: true, shiftKey: true },
|
||||
},
|
||||
{
|
||||
name: "does not match question-mark shortcut inside editable scopes",
|
||||
event: { key: "?", code: "Slash", shiftKey: true },
|
||||
context: { focusScope: "message-input" },
|
||||
},
|
||||
{
|
||||
name: "does not bind Ctrl+B on non-mac",
|
||||
event: { key: "b", code: "KeyB", ctrlKey: true },
|
||||
context: { isMac: false },
|
||||
},
|
||||
{
|
||||
name: "does not route message-input actions when terminal is focused",
|
||||
event: { key: "d", code: "KeyD", metaKey: true },
|
||||
context: { isMac: true, focusScope: "terminal" },
|
||||
},
|
||||
{
|
||||
name: "keeps space typing available in message input",
|
||||
event: { key: " ", code: "Space" },
|
||||
context: { focusScope: "message-input" },
|
||||
},
|
||||
];
|
||||
|
||||
expect(match).toBeNull();
|
||||
});
|
||||
|
||||
it("matches question-mark shortcut to toggle the shortcuts dialog", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
key: "?",
|
||||
code: "Slash",
|
||||
shiftKey: true,
|
||||
}),
|
||||
context: shortcutContext({ focusScope: "other" }),
|
||||
});
|
||||
|
||||
expect(match?.action).toBe("shortcuts.dialog.toggle");
|
||||
});
|
||||
|
||||
it("does not match question-mark shortcut inside editable scopes", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
key: "?",
|
||||
code: "Slash",
|
||||
shiftKey: true,
|
||||
}),
|
||||
context: shortcutContext({ focusScope: "message-input" }),
|
||||
});
|
||||
|
||||
expect(match).toBeNull();
|
||||
});
|
||||
|
||||
it("matches workspace index jump on web via Alt+digit", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
key: "2",
|
||||
code: "Digit2",
|
||||
altKey: true,
|
||||
}),
|
||||
context: shortcutContext({ isTauri: false }),
|
||||
});
|
||||
|
||||
expect(match?.action).toBe("workspace.navigate.index");
|
||||
expect(match?.payload).toEqual({ index: 2 });
|
||||
});
|
||||
|
||||
it("matches workspace index jump on tauri via Mod+digit", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
key: "2",
|
||||
code: "Digit2",
|
||||
metaKey: true,
|
||||
}),
|
||||
context: shortcutContext({ isMac: true, isTauri: true }),
|
||||
});
|
||||
|
||||
expect(match?.action).toBe("workspace.navigate.index");
|
||||
expect(match?.payload).toEqual({ index: 2 });
|
||||
});
|
||||
|
||||
it("matches tab index jump on tauri via Alt+digit", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
key: "2",
|
||||
code: "Digit2",
|
||||
altKey: true,
|
||||
}),
|
||||
context: shortcutContext({ isTauri: true }),
|
||||
});
|
||||
|
||||
expect(match?.action).toBe("workspace.tab.navigate.index");
|
||||
expect(match?.payload).toEqual({ index: 2 });
|
||||
});
|
||||
|
||||
it("matches tab index jump on web via Alt+Shift+digit", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
key: "@",
|
||||
code: "Digit2",
|
||||
altKey: true,
|
||||
shiftKey: true,
|
||||
}),
|
||||
context: shortcutContext({ isTauri: false }),
|
||||
});
|
||||
|
||||
expect(match?.action).toBe("workspace.tab.navigate.index");
|
||||
expect(match?.payload).toEqual({ index: 2 });
|
||||
});
|
||||
|
||||
it("matches workspace relative navigation on web via Alt+[", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
key: "[",
|
||||
code: "BracketLeft",
|
||||
altKey: true,
|
||||
}),
|
||||
context: shortcutContext({ isTauri: false }),
|
||||
});
|
||||
|
||||
expect(match?.action).toBe("workspace.navigate.relative");
|
||||
expect(match?.payload).toEqual({ delta: -1 });
|
||||
});
|
||||
|
||||
it("matches workspace relative navigation on tauri via Mod+]", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
key: "]",
|
||||
code: "BracketRight",
|
||||
ctrlKey: true,
|
||||
}),
|
||||
context: shortcutContext({ isTauri: true }),
|
||||
});
|
||||
|
||||
expect(match?.action).toBe("workspace.navigate.relative");
|
||||
expect(match?.payload).toEqual({ delta: 1 });
|
||||
});
|
||||
|
||||
it("matches tab relative navigation via Alt+Shift+]", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
key: "}",
|
||||
code: "BracketRight",
|
||||
altKey: true,
|
||||
shiftKey: true,
|
||||
}),
|
||||
context: shortcutContext(),
|
||||
});
|
||||
|
||||
expect(match?.action).toBe("workspace.tab.navigate.relative");
|
||||
expect(match?.payload).toEqual({ delta: 1 });
|
||||
});
|
||||
|
||||
it("matches Alt+Shift+T to open new tab", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
key: "T",
|
||||
code: "KeyT",
|
||||
altKey: true,
|
||||
shiftKey: true,
|
||||
}),
|
||||
context: shortcutContext(),
|
||||
});
|
||||
|
||||
expect(match?.action).toBe("workspace.tab.new");
|
||||
});
|
||||
|
||||
it("matches Alt+Shift+W to close current tab on web", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
key: "W",
|
||||
code: "KeyW",
|
||||
altKey: true,
|
||||
shiftKey: true,
|
||||
}),
|
||||
context: shortcutContext({ isTauri: false }),
|
||||
});
|
||||
|
||||
expect(match?.action).toBe("workspace.tab.close.current");
|
||||
});
|
||||
|
||||
it("matches Mod+W to close current tab on tauri", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
key: "w",
|
||||
code: "KeyW",
|
||||
metaKey: true,
|
||||
}),
|
||||
context: shortcutContext({ isMac: true, isTauri: true }),
|
||||
});
|
||||
|
||||
expect(match?.action).toBe("workspace.tab.close.current");
|
||||
});
|
||||
|
||||
it("matches Cmd+B sidebar toggle on macOS", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
key: "b",
|
||||
code: "KeyB",
|
||||
metaKey: true,
|
||||
}),
|
||||
context: shortcutContext({ isMac: true }),
|
||||
});
|
||||
|
||||
expect(match?.action).toBe("sidebar.toggle.left");
|
||||
});
|
||||
|
||||
it("does not bind Ctrl+B on non-mac", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
key: "b",
|
||||
code: "KeyB",
|
||||
ctrlKey: true,
|
||||
}),
|
||||
context: shortcutContext({ isMac: false }),
|
||||
});
|
||||
|
||||
expect(match).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps Mod+. as sidebar toggle fallback", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
key: ".",
|
||||
code: "Period",
|
||||
ctrlKey: true,
|
||||
}),
|
||||
context: shortcutContext({ isMac: false }),
|
||||
});
|
||||
|
||||
expect(match?.action).toBe("sidebar.toggle.left");
|
||||
});
|
||||
|
||||
it("routes Mod+D to message-input action outside terminal", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
key: "d",
|
||||
code: "KeyD",
|
||||
metaKey: true,
|
||||
}),
|
||||
context: shortcutContext({ isMac: true, focusScope: "message-input" }),
|
||||
});
|
||||
|
||||
expect(match?.action).toBe("message-input.action");
|
||||
expect(match?.payload).toEqual({ kind: "dictation-toggle" });
|
||||
});
|
||||
|
||||
it("does not route message-input actions when terminal is focused", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
key: "d",
|
||||
code: "KeyD",
|
||||
metaKey: true,
|
||||
}),
|
||||
context: shortcutContext({ isMac: true, focusScope: "terminal" }),
|
||||
});
|
||||
|
||||
expect(match).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps space typing available in message input", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
key: " ",
|
||||
code: "Space",
|
||||
}),
|
||||
context: shortcutContext({ focusScope: "message-input" }),
|
||||
});
|
||||
|
||||
expect(match).toBeNull();
|
||||
});
|
||||
|
||||
it("routes space to voice mute toggle outside editable scopes", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
key: " ",
|
||||
code: "Space",
|
||||
}),
|
||||
context: shortcutContext({ focusScope: "other" }),
|
||||
});
|
||||
|
||||
expect(match?.action).toBe("message-input.action");
|
||||
expect(match?.payload).toEqual({ kind: "voice-mute-toggle" });
|
||||
});
|
||||
|
||||
it("lets Escape continue to local handlers while routing dictation cancel", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
key: "Escape",
|
||||
code: "Escape",
|
||||
}),
|
||||
context: shortcutContext({ focusScope: "message-input" }),
|
||||
});
|
||||
|
||||
expect(match?.action).toBe("message-input.action");
|
||||
expect(match?.payload).toEqual({ kind: "dictation-cancel" });
|
||||
expect(match?.preventDefault).toBe(false);
|
||||
expect(match?.stopPropagation).toBe(false);
|
||||
it.each(nonMatchingCases)("$name", ({ event, context }) => {
|
||||
expectNoShortcutResolution({ event, context });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -348,50 +268,43 @@ describe("keyboard-shortcut help sections", () => {
|
||||
return null;
|
||||
}
|
||||
|
||||
it("uses web defaults for workspace and tab jump", () => {
|
||||
const sections = buildKeyboardShortcutHelpSections({
|
||||
isMac: true,
|
||||
isTauri: false,
|
||||
});
|
||||
const helpCases: HelpSectionCase[] = [
|
||||
{
|
||||
name: "uses web defaults for workspace and tab jump",
|
||||
context: { isMac: true, isTauri: false },
|
||||
expectedKeys: {
|
||||
"new-agent": ["mod", "shift", "O"],
|
||||
"workspace-tab-new": ["mod", "T"],
|
||||
"workspace-jump-index": ["alt", "1-9"],
|
||||
"workspace-tab-jump-index": ["alt", "shift", "1-9"],
|
||||
"workspace-tab-close-current": ["alt", "shift", "W"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "uses tauri defaults for workspace and tab jump",
|
||||
context: { isMac: true, isTauri: true },
|
||||
expectedKeys: {
|
||||
"new-agent": ["mod", "shift", "O"],
|
||||
"workspace-tab-new": ["mod", "T"],
|
||||
"workspace-jump-index": ["mod", "1-9"],
|
||||
"workspace-tab-jump-index": ["alt", "1-9"],
|
||||
"workspace-tab-close-current": ["mod", "W"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "uses mod+period as non-mac left sidebar shortcut",
|
||||
context: { isMac: false, isTauri: false },
|
||||
expectedKeys: {
|
||||
"toggle-left-sidebar": ["mod", "."],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
expect(findRow(sections, "new-agent")?.keys).toEqual(["mod", "shift", "O"]);
|
||||
expect(findRow(sections, "workspace-jump-index")?.keys).toEqual(["alt", "1-9"]);
|
||||
expect(findRow(sections, "workspace-tab-jump-index")?.keys).toEqual([
|
||||
"alt",
|
||||
"shift",
|
||||
"1-9",
|
||||
]);
|
||||
expect(findRow(sections, "workspace-tab-close-current")?.keys).toEqual([
|
||||
"alt",
|
||||
"shift",
|
||||
"W",
|
||||
]);
|
||||
});
|
||||
it.each(helpCases)("$name", ({ context, expectedKeys }) => {
|
||||
const sections = buildKeyboardShortcutHelpSections(context);
|
||||
|
||||
it("uses tauri defaults for workspace and tab jump", () => {
|
||||
const sections = buildKeyboardShortcutHelpSections({
|
||||
isMac: true,
|
||||
isTauri: true,
|
||||
});
|
||||
|
||||
expect(findRow(sections, "new-agent")?.keys).toEqual(["mod", "shift", "O"]);
|
||||
expect(findRow(sections, "workspace-jump-index")?.keys).toEqual(["mod", "1-9"]);
|
||||
expect(findRow(sections, "workspace-tab-jump-index")?.keys).toEqual(["alt", "1-9"]);
|
||||
expect(findRow(sections, "workspace-tab-close-current")?.keys).toEqual([
|
||||
"mod",
|
||||
"W",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses mod+period as non-mac left sidebar shortcut", () => {
|
||||
const sections = buildKeyboardShortcutHelpSections({
|
||||
isMac: false,
|
||||
isTauri: false,
|
||||
});
|
||||
|
||||
expect(findRow(sections, "toggle-left-sidebar")?.keys).toEqual([
|
||||
"mod",
|
||||
".",
|
||||
]);
|
||||
for (const [id, keys] of Object.entries(expectedKeys)) {
|
||||
expect(findRow(sections, id)?.keys).toEqual(keys);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -133,25 +133,24 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
||||
help: {
|
||||
id: "new-agent",
|
||||
section: "global",
|
||||
label: "Create new agent",
|
||||
label: "Open project",
|
||||
keys: ["mod", "shift", "O"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "workspace-tab-new-alt-shift-t",
|
||||
id: "workspace-tab-new-mod-t",
|
||||
action: "workspace.tab.new",
|
||||
matches: (event) =>
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
event.altKey &&
|
||||
event.shiftKey &&
|
||||
isMod(event) &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey &&
|
||||
(event.code === "KeyT" || event.key.toLowerCase() === "t"),
|
||||
when: (context) => !context.commandCenterOpen,
|
||||
help: {
|
||||
id: "workspace-tab-new",
|
||||
section: "global",
|
||||
label: "New agent tab",
|
||||
keys: ["alt", "shift", "T"],
|
||||
keys: ["mod", "T"],
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -163,7 +163,7 @@ function makeFetchAgentsEntry(input: {
|
||||
function makeHost(input?: Partial<HostProfile>): HostProfile {
|
||||
const direct: HostConnection = {
|
||||
id: "direct:lan:6767",
|
||||
type: "direct",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
};
|
||||
const relay: HostConnection = {
|
||||
@@ -176,6 +176,12 @@ function makeHost(input?: Partial<HostProfile>): HostProfile {
|
||||
return {
|
||||
serverId: input?.serverId ?? "srv_test",
|
||||
label: input?.label ?? "test host",
|
||||
lifecycle: input?.lifecycle ?? {
|
||||
managed: false,
|
||||
managedRuntimeId: null,
|
||||
managedRuntimeVersion: null,
|
||||
associatedServerId: null,
|
||||
},
|
||||
connections: input?.connections ?? [direct, relay],
|
||||
preferredConnectionId: input?.preferredConnectionId ?? direct.id,
|
||||
createdAt: input?.createdAt ?? new Date(0).toISOString(),
|
||||
@@ -227,7 +233,7 @@ describe("HostRuntimeController", () => {
|
||||
connections: [
|
||||
{
|
||||
id: "direct:lan:6767",
|
||||
type: "direct",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
},
|
||||
],
|
||||
@@ -262,7 +268,7 @@ describe("HostRuntimeController", () => {
|
||||
connections: [
|
||||
{
|
||||
id: "direct:lan:6767",
|
||||
type: "direct",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
},
|
||||
],
|
||||
@@ -438,7 +444,7 @@ describe("HostRuntimeController", () => {
|
||||
connections: [
|
||||
{
|
||||
id: "direct:lan:6767",
|
||||
type: "direct",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
},
|
||||
],
|
||||
@@ -577,7 +583,7 @@ describe("HostRuntimeController", () => {
|
||||
connections: [
|
||||
{
|
||||
id: "direct:lan:6767",
|
||||
type: "direct",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
},
|
||||
{
|
||||
@@ -667,7 +673,7 @@ describe("HostRuntimeController", () => {
|
||||
connections: [
|
||||
{
|
||||
id: "direct:lan:6767",
|
||||
type: "direct",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
},
|
||||
],
|
||||
@@ -723,7 +729,7 @@ describe("HostRuntimeController", () => {
|
||||
connections: [
|
||||
{
|
||||
id: "direct:lan:6767",
|
||||
type: "direct",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
},
|
||||
],
|
||||
@@ -789,7 +795,7 @@ describe("HostRuntimeStore", () => {
|
||||
connections: [
|
||||
{
|
||||
id: "direct:lan:6767",
|
||||
type: "direct",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
},
|
||||
],
|
||||
@@ -837,7 +843,7 @@ describe("HostRuntimeStore", () => {
|
||||
connections: [
|
||||
{
|
||||
id: "direct:lan:6767",
|
||||
type: "direct",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
},
|
||||
],
|
||||
@@ -885,7 +891,7 @@ describe("HostRuntimeStore", () => {
|
||||
connections: [
|
||||
{
|
||||
id: "direct:lan:6767",
|
||||
type: "direct",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
},
|
||||
],
|
||||
@@ -978,12 +984,74 @@ describe("HostRuntimeStore", () => {
|
||||
useSessionStore.getState().clearSession(host.serverId);
|
||||
});
|
||||
|
||||
it("re-subscribes agent directory updates after reconnect", async () => {
|
||||
const host = makeHost({
|
||||
serverId: "srv_resubscribe",
|
||||
connections: [
|
||||
{
|
||||
id: "direct:lan:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
},
|
||||
],
|
||||
});
|
||||
const fakeClient = new FakeDaemonClient();
|
||||
const store = new HostRuntimeStore({
|
||||
deps: {
|
||||
createClient: () => fakeClient as unknown as DaemonClient,
|
||||
measureLatency: async () => 5,
|
||||
getClientId: async () => "cid_test_runtime",
|
||||
},
|
||||
});
|
||||
|
||||
useSessionStore.getState().initializeSession(
|
||||
host.serverId,
|
||||
fakeClient as unknown as DaemonClient,
|
||||
null as any
|
||||
);
|
||||
store.syncHosts([host]);
|
||||
|
||||
const initialTimeoutAt = Date.now() + 200;
|
||||
while (fakeClient.fetchAgentsCalls.length < 1 && Date.now() < initialTimeoutAt) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
fakeClient.setConnectionState({
|
||||
status: "disconnected",
|
||||
reason: "client_closed",
|
||||
});
|
||||
fakeClient.setConnectionState({ status: "connected" });
|
||||
|
||||
const reconnectTimeoutAt = Date.now() + 200;
|
||||
while (fakeClient.fetchAgentsCalls.length < 2 && Date.now() < reconnectTimeoutAt) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
expect(fakeClient.fetchAgentsCalls).toEqual([
|
||||
{
|
||||
filter: { includeArchived: true },
|
||||
sort: [{ key: "updated_at", direction: "desc" }],
|
||||
subscribe: { subscriptionId: "app:srv_resubscribe" },
|
||||
page: { limit: 200 },
|
||||
},
|
||||
{
|
||||
filter: { includeArchived: true },
|
||||
sort: [{ key: "updated_at", direction: "desc" }],
|
||||
subscribe: { subscriptionId: "app:srv_resubscribe" },
|
||||
page: { limit: 200 },
|
||||
},
|
||||
]);
|
||||
|
||||
store.syncHosts([]);
|
||||
useSessionStore.getState().clearSession(host.serverId);
|
||||
});
|
||||
|
||||
it("surfaces startup failures as error instead of leaving host idle", async () => {
|
||||
const host = makeHost({
|
||||
connections: [
|
||||
{
|
||||
id: "direct:lan:6767",
|
||||
type: "direct",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -17,6 +17,10 @@ import {
|
||||
type ConnectionCandidate,
|
||||
type ConnectionProbeState,
|
||||
} from "@/utils/connection-selection";
|
||||
import {
|
||||
buildLocalDaemonTransportUrl,
|
||||
createTauriLocalDaemonTransportFactory,
|
||||
} from "@/utils/managed-tauri-daemon-transport";
|
||||
import { createTauriWebSocketTransportFactory } from "@/utils/tauri-daemon-transport";
|
||||
import { applyFetchedAgentDirectory } from "@/utils/agent-directory-sync";
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
@@ -33,7 +37,9 @@ export type HostRuntimeConnectionStatus =
|
||||
| "error";
|
||||
|
||||
export type ActiveConnection =
|
||||
| { type: "direct"; endpoint: string; display: string }
|
||||
| { type: "directTcp"; endpoint: string; display: string }
|
||||
| { type: "directSocket"; endpoint: string; display: "socket" }
|
||||
| { type: "directPipe"; endpoint: string; display: "pipe" }
|
||||
| { type: "relay"; endpoint: string; display: "relay" };
|
||||
|
||||
export type HostRuntimeAgentDirectoryStatus =
|
||||
@@ -179,9 +185,23 @@ function readFetchAgentsNextCursor(
|
||||
}
|
||||
|
||||
function toActiveConnection(connection: HostConnection): ActiveConnection {
|
||||
if (connection.type === "direct") {
|
||||
if (connection.type === "directSocket") {
|
||||
return {
|
||||
type: "direct",
|
||||
type: "directSocket",
|
||||
endpoint: connection.path,
|
||||
display: "socket",
|
||||
};
|
||||
}
|
||||
if (connection.type === "directPipe") {
|
||||
return {
|
||||
type: "directPipe",
|
||||
endpoint: connection.path,
|
||||
display: "pipe",
|
||||
};
|
||||
}
|
||||
if (connection.type === "directTcp") {
|
||||
return {
|
||||
type: "directTcp",
|
||||
endpoint: connection.endpoint,
|
||||
display: connection.endpoint,
|
||||
};
|
||||
@@ -421,11 +441,14 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
|
||||
serverId: host.serverId,
|
||||
connectionType: connection.type,
|
||||
endpoint:
|
||||
connection.type === "direct"
|
||||
connection.type === "directTcp"
|
||||
? connection.endpoint
|
||||
: connection.type === "directSocket" || connection.type === "directPipe"
|
||||
? connection.path
|
||||
: connection.relayEndpoint,
|
||||
});
|
||||
const tauriTransportFactory = createTauriWebSocketTransportFactory();
|
||||
const localTransportFactory = createTauriLocalDaemonTransportFactory();
|
||||
const base = {
|
||||
suppressSendErrors: true,
|
||||
clientId,
|
||||
@@ -433,18 +456,31 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
|
||||
runtimeGeneration,
|
||||
onDiagnosticsEvent: (event: DaemonClientDiagnosticsEvent) =>
|
||||
recordDaemonClientDiagnostics(host.serverId, event),
|
||||
...(tauriTransportFactory
|
||||
? { transportFactory: tauriTransportFactory }
|
||||
: {}),
|
||||
};
|
||||
if (connection.type === "direct") {
|
||||
if (connection.type === "directSocket" || connection.type === "directPipe") {
|
||||
return new DaemonClient({
|
||||
...base,
|
||||
...(localTransportFactory ? { transportFactory: localTransportFactory } : {}),
|
||||
url: buildLocalDaemonTransportUrl({
|
||||
transportType: connection.type === "directSocket" ? "socket" : "pipe",
|
||||
transportPath: connection.path,
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (connection.type === "directTcp") {
|
||||
return new DaemonClient({
|
||||
...base,
|
||||
...(tauriTransportFactory
|
||||
? { transportFactory: tauriTransportFactory }
|
||||
: {}),
|
||||
url: buildDaemonWebSocketUrl(connection.endpoint),
|
||||
});
|
||||
}
|
||||
return new DaemonClient({
|
||||
...base,
|
||||
...(tauriTransportFactory
|
||||
? { transportFactory: tauriTransportFactory }
|
||||
: {}),
|
||||
url: buildRelayWebSocketUrl({
|
||||
endpoint: connection.relayEndpoint,
|
||||
serverId: host.serverId,
|
||||
@@ -1043,16 +1079,19 @@ export class HostRuntimeStore {
|
||||
const snapshot = controller.getSnapshot();
|
||||
const previousStatus = this.lastConnectionStatusByServer.get(serverId);
|
||||
this.lastConnectionStatusByServer.set(serverId, snapshot.connectionStatus);
|
||||
if (snapshot.connectionStatus === "online" && previousStatus !== "online") {
|
||||
const didTransitionOnline =
|
||||
snapshot.connectionStatus === "online" && previousStatus !== "online";
|
||||
if (didTransitionOnline) {
|
||||
useSessionStore.getState().bumpHistorySyncGeneration(serverId);
|
||||
}
|
||||
|
||||
// Runtime owns directory bootstrap policy, including reconnect and delayed
|
||||
// session initialization races.
|
||||
if (
|
||||
snapshot.connectionStatus !== "online" ||
|
||||
snapshot.hasEverLoadedAgentDirectory
|
||||
) {
|
||||
if (snapshot.connectionStatus !== "online") {
|
||||
this.clearAgentDirectorySessionRetry(serverId);
|
||||
return;
|
||||
}
|
||||
if (!didTransitionOnline && snapshot.hasEverLoadedAgentDirectory) {
|
||||
this.clearAgentDirectorySessionRetry(serverId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ function shouldSampleFastTransportEvent(): boolean {
|
||||
|
||||
export function recordHostRuntimeCreateClient(params: {
|
||||
serverId: string;
|
||||
connectionType: "direct" | "relay";
|
||||
connectionType: "directTcp" | "directSocket" | "directPipe" | "relay";
|
||||
endpoint: string;
|
||||
}): void {
|
||||
recordPerfDiagnosticMark(
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { BottomAnchorRouteRequest } from "@/components/use-bottom-anchor-controller";
|
||||
|
||||
export type RouteBottomAnchorIntent = {
|
||||
routeKey: string;
|
||||
reason: BottomAnchorRouteRequest["reason"];
|
||||
};
|
||||
|
||||
export function deriveRouteBottomAnchorIntent(input: {
|
||||
cachedIntent: RouteBottomAnchorIntent | null;
|
||||
routeKey: string | null;
|
||||
hasAppliedAuthoritativeHistoryAtEntry: boolean;
|
||||
}): RouteBottomAnchorIntent | null {
|
||||
if (!input.routeKey) {
|
||||
return null;
|
||||
}
|
||||
if (input.cachedIntent?.routeKey === input.routeKey) {
|
||||
return input.cachedIntent;
|
||||
}
|
||||
return {
|
||||
routeKey: input.routeKey,
|
||||
reason: input.hasAppliedAuthoritativeHistoryAtEntry ? "resume" : "initial-entry",
|
||||
};
|
||||
}
|
||||
|
||||
export function deriveRouteBottomAnchorRequest(input: {
|
||||
intent: RouteBottomAnchorIntent | null;
|
||||
effectiveAgentId: string | null;
|
||||
}): BottomAnchorRouteRequest | null {
|
||||
if (!input.intent || !input.effectiveAgentId) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
reason: input.intent.reason,
|
||||
agentId: input.effectiveAgentId,
|
||||
requestKey: `${input.intent.routeKey}:${input.intent.reason}`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
deriveRouteBottomAnchorIntent,
|
||||
deriveRouteBottomAnchorRequest,
|
||||
} from "./agent-ready-screen-bottom-anchor";
|
||||
|
||||
describe("agent-ready-screen bottom anchor intent", () => {
|
||||
it("latches initial-entry on first route entry before authoritative history is applied", () => {
|
||||
const intentAtEntry = deriveRouteBottomAnchorIntent({
|
||||
cachedIntent: null,
|
||||
routeKey: "server-1:agent-1",
|
||||
hasAppliedAuthoritativeHistoryAtEntry: false,
|
||||
});
|
||||
|
||||
const intentAfterHistoryApplies = deriveRouteBottomAnchorIntent({
|
||||
cachedIntent: intentAtEntry,
|
||||
routeKey: "server-1:agent-1",
|
||||
hasAppliedAuthoritativeHistoryAtEntry: true,
|
||||
});
|
||||
|
||||
expect(intentAfterHistoryApplies).toEqual({
|
||||
routeKey: "server-1:agent-1",
|
||||
reason: "initial-entry",
|
||||
});
|
||||
expect(
|
||||
deriveRouteBottomAnchorRequest({
|
||||
intent: intentAfterHistoryApplies,
|
||||
effectiveAgentId: "agent-1",
|
||||
})
|
||||
).toEqual({
|
||||
agentId: "agent-1",
|
||||
reason: "initial-entry",
|
||||
requestKey: "server-1:agent-1:initial-entry",
|
||||
});
|
||||
});
|
||||
|
||||
it("creates resume requests when revisiting an already-hydrated route", () => {
|
||||
const intent = deriveRouteBottomAnchorIntent({
|
||||
cachedIntent: null,
|
||||
routeKey: "server-1:agent-2",
|
||||
hasAppliedAuthoritativeHistoryAtEntry: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
deriveRouteBottomAnchorRequest({
|
||||
intent,
|
||||
effectiveAgentId: "agent-2",
|
||||
})
|
||||
).toEqual({
|
||||
agentId: "agent-2",
|
||||
reason: "resume",
|
||||
requestKey: "server-1:agent-2:resume",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not create a request until the effective agent exists", () => {
|
||||
const intent = deriveRouteBottomAnchorIntent({
|
||||
cachedIntent: null,
|
||||
routeKey: "server-1:agent-3",
|
||||
hasAppliedAuthoritativeHistoryAtEntry: false,
|
||||
});
|
||||
|
||||
expect(
|
||||
deriveRouteBottomAnchorRequest({
|
||||
intent,
|
||||
effectiveAgentId: null,
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
Platform,
|
||||
BackHandler,
|
||||
} from "react-native";
|
||||
import { useFocusEffect } from "@react-navigation/native";
|
||||
import { useFocusEffect, useIsFocused } from "@react-navigation/native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import ReanimatedAnimated from "react-native-reanimated";
|
||||
@@ -55,10 +55,14 @@ import {
|
||||
} from "@/utils/agent-snapshots";
|
||||
import { mergePendingCreateImages } from "@/utils/pending-create-images";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import { shouldClearAgentAttention } from "@/utils/agent-attention";
|
||||
import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
|
||||
import type { ExplorerCheckoutContext } from "@/stores/panel-store";
|
||||
import {
|
||||
deriveRouteBottomAnchorIntent,
|
||||
deriveRouteBottomAnchorRequest,
|
||||
} from "./agent-ready-screen-bottom-anchor";
|
||||
|
||||
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
|
||||
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
|
||||
@@ -70,6 +74,13 @@ function logAgentExplorer(event: string, details: Record<string, unknown>): void
|
||||
console.log(`[AgentExplorer] ${event}`, details);
|
||||
}
|
||||
|
||||
function logWebStickyBottom(event: string, details: Record<string, unknown>): void {
|
||||
if (!IS_DEV || Platform.OS !== "web") {
|
||||
return;
|
||||
}
|
||||
console.log("[WebStickyBottom]", event, details);
|
||||
}
|
||||
|
||||
export function AgentReadyScreen({
|
||||
serverId,
|
||||
agentId,
|
||||
@@ -390,12 +401,19 @@ function AgentScreenContent({
|
||||
const historySyncGeneration = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.historySyncGeneration ?? 0
|
||||
);
|
||||
const hasAppliedAuthoritativeHistory = useSessionStore((state) =>
|
||||
resolvedAgentId
|
||||
? state.sessions[serverId]?.agentAuthoritativeHistoryApplied?.get(
|
||||
resolvedAgentId
|
||||
) === true
|
||||
: false
|
||||
);
|
||||
const agentHistorySyncGeneration = useSessionStore((state) =>
|
||||
resolvedAgentId
|
||||
? state.sessions[serverId]?.agentHistorySyncGeneration?.get(resolvedAgentId) ?? -1
|
||||
: -1
|
||||
);
|
||||
const hasHydratedHistoryBefore = agentHistorySyncGeneration >= 0;
|
||||
const hasHydratedHistoryBefore = hasAppliedAuthoritativeHistory;
|
||||
|
||||
// Select raw pending permissions - filter with useMemo to avoid new Map on every render
|
||||
const allPendingPermissions = useSessionStore(
|
||||
@@ -420,9 +438,7 @@ function AgentScreenContent({
|
||||
const hasSession = useSessionStore(
|
||||
(state) => Boolean(state.sessions[serverId])
|
||||
);
|
||||
const focusedAgentId = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.focusedAgentId ?? null
|
||||
);
|
||||
const isScreenFocused = useIsFocused();
|
||||
const { ensureAgentIsInitialized } = useAgentInitialization({
|
||||
serverId,
|
||||
client: hasSession ? client : null,
|
||||
@@ -432,8 +448,10 @@ function AgentScreenContent({
|
||||
});
|
||||
const reconnectToastArmedRef = useRef(false);
|
||||
const initAttemptTokenRef = useRef(0);
|
||||
const attentionClientRef = useRef(client);
|
||||
const attentionConnectedRef = useRef(isConnected);
|
||||
const routeBottomAnchorRequestRef = useRef<{
|
||||
routeKey: string;
|
||||
reason: "initial-entry" | "resume";
|
||||
} | null>(null);
|
||||
const setFocusedAgentId = useCallback(
|
||||
(agentId: string | null) => {
|
||||
useSessionStore.getState().setFocusedAgentId(serverId, agentId);
|
||||
@@ -441,10 +459,14 @@ function AgentScreenContent({
|
||||
[serverId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
attentionClientRef.current = client;
|
||||
attentionConnectedRef.current = isConnected;
|
||||
}, [client, isConnected]);
|
||||
const attentionController = useAgentAttentionClear({
|
||||
agentId: resolvedAgentId,
|
||||
client,
|
||||
isConnected,
|
||||
requiresAttention: agent?.requiresAttention,
|
||||
attentionReason: agent?.attentionReason,
|
||||
isScreenFocused,
|
||||
});
|
||||
|
||||
const { style: animatedKeyboardStyle } = useKeyboardShiftStyle({
|
||||
mode: "translate",
|
||||
@@ -519,33 +541,20 @@ function AgentScreenContent({
|
||||
isArchivingAgent({ serverId, agentId: resolvedAgentId })
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!resolvedAgentId) {
|
||||
setFocusedAgentId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setFocusedAgentId(resolvedAgentId);
|
||||
return () => {
|
||||
const latestClient = attentionClientRef.current;
|
||||
const latestAgent = useSessionStore
|
||||
.getState()
|
||||
.sessions[serverId]
|
||||
?.agents.get(resolvedAgentId);
|
||||
if (
|
||||
latestClient &&
|
||||
shouldClearAgentAttention({
|
||||
agentId: resolvedAgentId,
|
||||
isConnected: attentionConnectedRef.current,
|
||||
requiresAttention: latestAgent?.requiresAttention,
|
||||
attentionReason: latestAgent?.attentionReason,
|
||||
})
|
||||
) {
|
||||
latestClient.clearAgentAttention(resolvedAgentId);
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (!resolvedAgentId) {
|
||||
setFocusedAgentId(null);
|
||||
return;
|
||||
}
|
||||
setFocusedAgentId(null);
|
||||
};
|
||||
}, [resolvedAgentId, serverId, setFocusedAgentId]);
|
||||
|
||||
setFocusedAgentId(resolvedAgentId);
|
||||
return () => {
|
||||
attentionController.clearOnAgentBlur();
|
||||
setFocusedAgentId(null);
|
||||
};
|
||||
}, [attentionController, resolvedAgentId, setFocusedAgentId])
|
||||
);
|
||||
|
||||
const isInitializing = resolvedAgentId ? isInitializingFromMap !== false : false;
|
||||
const isHistorySyncing = useMemo(() => {
|
||||
@@ -658,6 +667,20 @@ function AgentScreenContent({
|
||||
});
|
||||
|
||||
const effectiveAgent = viewState.tag === "ready" ? viewState.agent : null;
|
||||
const routeEntryKey = resolvedAgentId ? `${serverId}:${resolvedAgentId}` : null;
|
||||
routeBottomAnchorRequestRef.current = deriveRouteBottomAnchorIntent({
|
||||
cachedIntent: routeBottomAnchorRequestRef.current,
|
||||
routeKey: routeEntryKey,
|
||||
hasAppliedAuthoritativeHistoryAtEntry: hasAppliedAuthoritativeHistory,
|
||||
});
|
||||
const routeBottomAnchorRequest = useMemo(
|
||||
() =>
|
||||
deriveRouteBottomAnchorRequest({
|
||||
intent: routeBottomAnchorRequestRef.current,
|
||||
effectiveAgentId: effectiveAgent?.id ?? null,
|
||||
}),
|
||||
[effectiveAgent?.id]
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!isPendingCreateForRoute || !pendingCreate) {
|
||||
return;
|
||||
@@ -914,6 +937,8 @@ function AgentScreenContent({
|
||||
shouldUseOptimisticStream ? mergedStreamItems : streamItems
|
||||
}
|
||||
pendingPermissions={pendingPermissions}
|
||||
routeBottomAnchorRequest={routeBottomAnchorRequest}
|
||||
isAuthoritativeHistoryReady={hasAppliedAuthoritativeHistory}
|
||||
/>
|
||||
</ReanimatedAnimated.View>
|
||||
</View>
|
||||
@@ -925,8 +950,22 @@ function AgentScreenContent({
|
||||
serverId={serverId}
|
||||
autoFocus
|
||||
isSubmitLoading={showPendingCreateSubmitLoading}
|
||||
onAttentionInputFocus={attentionController.clearOnInputFocus}
|
||||
onAttentionPromptSend={attentionController.clearOnPromptSend}
|
||||
onAddImages={handleAddImagesCallback}
|
||||
onMessageSent={() => streamViewRef.current?.scrollToBottom()}
|
||||
onComposerHeightChange={(height) => {
|
||||
logWebStickyBottom("screen_composer_height_change", {
|
||||
agentId: resolvedAgentId,
|
||||
height,
|
||||
});
|
||||
streamViewRef.current?.prepareForViewportChange();
|
||||
}}
|
||||
onMessageSent={() => {
|
||||
logWebStickyBottom("screen_message_sent_scroll_to_bottom", {
|
||||
agentId: resolvedAgentId,
|
||||
});
|
||||
streamViewRef.current?.scrollToBottom("message-sent");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -104,6 +104,7 @@ type DraftAgentParams = {
|
||||
model?: string
|
||||
thinkingOptionId?: string
|
||||
workingDir?: string
|
||||
worktreeMode?: string
|
||||
}
|
||||
|
||||
type DraftAgentScreenProps = {
|
||||
@@ -160,6 +161,11 @@ function DraftAgentScreenContent({
|
||||
const resolvedModel = getParamValue(params.model)
|
||||
const resolvedThinkingOptionId = getParamValue(params.thinkingOptionId)
|
||||
const resolvedWorkingDir = getParamValue(params.workingDir)
|
||||
const resolvedWorktreeMode = getParamValue(params.worktreeMode)
|
||||
const initialWorktreeMode =
|
||||
resolvedWorktreeMode === 'create' || resolvedWorktreeMode === 'attach'
|
||||
? resolvedWorktreeMode
|
||||
: 'none'
|
||||
|
||||
const onlineServerIds = useMemo(() => {
|
||||
if (daemons.length === 0) return []
|
||||
@@ -234,7 +240,9 @@ function DraftAgentScreenContent({
|
||||
const draftIdRef = useRef(generateDraftId())
|
||||
const draftAgentIdRef = useRef(generateDraftId())
|
||||
|
||||
const [worktreeMode, setWorktreeMode] = useState<'none' | 'create' | 'attach'>('none')
|
||||
const [worktreeMode, setWorktreeMode] = useState<'none' | 'create' | 'attach'>(
|
||||
initialWorktreeMode
|
||||
)
|
||||
const [baseBranch, setBaseBranch] = useState('')
|
||||
const [worktreeSlug, setWorktreeSlug] = useState('')
|
||||
const [selectedWorktreePath, setSelectedWorktreePath] = useState('')
|
||||
|
||||
@@ -10,6 +10,7 @@ import { buildHostRootRoute } from "@/utils/host-routes";
|
||||
export function AgentsScreen({ serverId }: { serverId: string }) {
|
||||
const { agents, isRevalidating, refreshAll } = useAllAgentsList({
|
||||
serverId,
|
||||
includeArchived: true,
|
||||
});
|
||||
|
||||
// Track user-initiated refresh to avoid showing spinner on background revalidation
|
||||
@@ -43,7 +44,7 @@ export function AgentsScreen({ serverId }: { serverId: string }) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<BackHeader
|
||||
title="All agents"
|
||||
title="Sessions"
|
||||
onBack={() => router.replace(buildHostRootRoute(serverId) as any)}
|
||||
/>
|
||||
<AgentList
|
||||
@@ -51,6 +52,7 @@ export function AgentsScreen({ serverId }: { serverId: string }) {
|
||||
showCheckoutInfo={false}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
showAttentionIndicator={false}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
99
packages/app/src/screens/open-project-screen.tsx
Normal file
99
packages/app/src/screens/open-project-screen.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useEffect } from "react";
|
||||
import { View, Text, Pressable } from "react-native";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { FolderOpen } from "lucide-react-native";
|
||||
import { PaseoLogo } from "@/components/icons/paseo-logo";
|
||||
import { SidebarMenuToggle } from "@/components/headers/menu-header";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { getIsTauriMac } from "@/constants/layout";
|
||||
import { useTrafficLightPadding } from "@/utils/tauri-window";
|
||||
|
||||
export function OpenProjectScreen({ serverId: _serverId }: { serverId: string }) {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const trafficLightPadding = useTrafficLightPadding();
|
||||
const desktopAgentListOpen = usePanelStore((s) => s.desktop.agentListOpen);
|
||||
const openAgentList = usePanelStore((s) => s.openAgentList);
|
||||
const setProjectPickerOpen = useKeyboardShortcutsStore((s) => s.setProjectPickerOpen);
|
||||
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const needsTrafficLightInset = !isMobile && !desktopAgentListOpen && getIsTauriMac();
|
||||
const trafficLightInset = needsTrafficLightInset ? trafficLightPadding.left : 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile) {
|
||||
openAgentList();
|
||||
}
|
||||
}, [isMobile, openAgentList]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={[styles.menuToggle, { paddingTop: insets.top, paddingLeft: trafficLightInset }]}>
|
||||
<SidebarMenuToggle />
|
||||
</View>
|
||||
<View style={styles.content}>
|
||||
<PaseoLogo size={56} />
|
||||
<Text style={styles.heading}>What shall we build today?</Text>
|
||||
<Pressable
|
||||
style={({ hovered }) => [
|
||||
styles.openButton,
|
||||
hovered && styles.openButtonHovered,
|
||||
]}
|
||||
onPress={() => setProjectPickerOpen(true)}
|
||||
testID="open-project-submit"
|
||||
>
|
||||
<FolderOpen size={16} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.openButtonText}>Add a project</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
menuToggle: {
|
||||
position: "absolute",
|
||||
top: theme.spacing[3],
|
||||
left: theme.spacing[3],
|
||||
zIndex: 1,
|
||||
},
|
||||
content: {
|
||||
flexGrow: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[6],
|
||||
padding: theme.spacing[6],
|
||||
},
|
||||
heading: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize["2xl"],
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
textAlign: "center",
|
||||
},
|
||||
openButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
openButtonHovered: {
|
||||
borderColor: theme.colors.borderAccent,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
openButtonText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
}));
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
Pressable,
|
||||
Alert,
|
||||
Platform,
|
||||
} from "react-native";
|
||||
@@ -42,6 +41,7 @@ import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section
|
||||
import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater";
|
||||
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
|
||||
import { resolveAppVersion } from "@/utils/app-version";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
|
||||
const delay = (ms: number) =>
|
||||
new Promise<void>((resolve) => {
|
||||
@@ -51,6 +51,42 @@ const delay = (ms: number) =>
|
||||
}, ms);
|
||||
});
|
||||
|
||||
function formatHostConnectionLabel(connection: HostConnection): string {
|
||||
if (connection.type === "relay") {
|
||||
return `Relay (${connection.relayEndpoint})`;
|
||||
}
|
||||
if (connection.type === "directSocket") {
|
||||
return `Local (${connection.path})`;
|
||||
}
|
||||
if (connection.type === "directPipe") {
|
||||
return `Local (${connection.path})`;
|
||||
}
|
||||
return `TCP (${connection.endpoint})`;
|
||||
}
|
||||
|
||||
function formatActiveConnectionBadge(input: {
|
||||
activeConnection: { type: HostConnection["type"]; display: string } | null;
|
||||
theme: ReturnType<typeof useUnistyles>["theme"];
|
||||
}) {
|
||||
const { activeConnection, theme } = input;
|
||||
if (!activeConnection) {
|
||||
return null;
|
||||
}
|
||||
if (activeConnection.type === "relay") {
|
||||
return { icon: <Globe size={theme.iconSize.xs} color={theme.colors.foregroundMuted} />, text: "Relay" };
|
||||
}
|
||||
if (activeConnection.type === "directSocket") {
|
||||
return { icon: <Monitor size={theme.iconSize.xs} color={theme.colors.foregroundMuted} />, text: "Local" };
|
||||
}
|
||||
if (activeConnection.type === "directPipe") {
|
||||
return { icon: <Monitor size={theme.iconSize.xs} color={theme.colors.foregroundMuted} />, text: "Local" };
|
||||
}
|
||||
return {
|
||||
icon: <Monitor size={theme.iconSize.xs} color={theme.colors.foregroundMuted} />,
|
||||
text: activeConnection.display,
|
||||
};
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
@@ -76,16 +112,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
maxWidth: 720,
|
||||
alignSelf: "center",
|
||||
},
|
||||
section: {
|
||||
marginBottom: theme.spacing[6],
|
||||
},
|
||||
sectionTitle: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
marginBottom: theme.spacing[3],
|
||||
marginLeft: theme.spacing[1],
|
||||
},
|
||||
label: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
@@ -103,10 +129,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
// Host card styles
|
||||
hostCard: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
marginBottom: theme.spacing[3],
|
||||
overflow: "hidden",
|
||||
},
|
||||
@@ -198,17 +220,12 @@ const styles = StyleSheet.create((theme) => ({
|
||||
hostSettingsButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
paddingVertical: 0,
|
||||
paddingHorizontal: 0,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderWidth: 1,
|
||||
borderColor: "transparent",
|
||||
backgroundColor: "transparent",
|
||||
gap: 0,
|
||||
marginLeft: theme.spacing[2],
|
||||
},
|
||||
hostSettingsButtonActive: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
advancedTrigger: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
@@ -233,27 +250,13 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
// Add host button
|
||||
addButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderStyle: "dashed",
|
||||
},
|
||||
addButtonText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
// Add/Edit form
|
||||
formCard: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
padding: theme.spacing[4],
|
||||
marginBottom: theme.spacing[3],
|
||||
gap: theme.spacing[4],
|
||||
@@ -292,10 +295,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
// Audio settings card
|
||||
audioCard: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
overflow: "hidden",
|
||||
},
|
||||
audioRow: {
|
||||
@@ -339,10 +338,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
// Empty state
|
||||
emptyCard: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
padding: theme.spacing[4],
|
||||
marginBottom: theme.spacing[3],
|
||||
},
|
||||
@@ -353,10 +348,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
// Dev section
|
||||
devCard: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
overflow: "hidden",
|
||||
},
|
||||
devButton: {
|
||||
@@ -465,7 +456,7 @@ function DesktopAppUpdateRow() {
|
||||
</View>
|
||||
<View style={styles.aboutUpdateActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onPress={handleCheckForUpdates}
|
||||
disabled={isChecking || isInstalling}
|
||||
@@ -669,7 +660,7 @@ export default function SettingsScreen() {
|
||||
);
|
||||
|
||||
const restartConfirmationMessage =
|
||||
"This will immediately stop the Paseo daemon process. The app will disconnect until it restarts.";
|
||||
"This will restart the daemon. The app will reconnect automatically.";
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -686,11 +677,11 @@ export default function SettingsScreen() {
|
||||
<ScrollView style={styles.scrollView} contentContainerStyle={{ paddingBottom: insets.bottom }}>
|
||||
<View style={styles.content}>
|
||||
{/* Host Management */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Hosts</Text>
|
||||
<View style={settingsStyles.section}>
|
||||
<Text style={settingsStyles.sectionTitle}>Hosts</Text>
|
||||
|
||||
{daemons.length === 0 ? (
|
||||
<View style={styles.emptyCard}>
|
||||
<View style={[settingsStyles.card, styles.emptyCard]}>
|
||||
<Text style={styles.emptyText}>No hosts configured</Text>
|
||||
</View>
|
||||
) : (
|
||||
@@ -705,16 +696,19 @@ export default function SettingsScreen() {
|
||||
})
|
||||
)}
|
||||
|
||||
<Pressable
|
||||
<Button
|
||||
variant="outline"
|
||||
size="md"
|
||||
style={styles.addButton}
|
||||
textStyle={styles.addButtonText}
|
||||
onPress={() => {
|
||||
setAddConnectionTargetServerId(null);
|
||||
setPendingEditReopenServerId(null);
|
||||
setIsAddHostMethodVisible(true);
|
||||
}}
|
||||
>
|
||||
<Text style={styles.addButtonText}>+ Add connection</Text>
|
||||
</Pressable>
|
||||
+ Add connection
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<AddHostMethodModal
|
||||
@@ -842,9 +836,9 @@ export default function SettingsScreen() {
|
||||
/>
|
||||
|
||||
{/* Appearance */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Appearance</Text>
|
||||
<View style={styles.audioCard}>
|
||||
<View style={settingsStyles.section}>
|
||||
<Text style={settingsStyles.sectionTitle}>Appearance</Text>
|
||||
<View style={[settingsStyles.card, styles.audioCard]}>
|
||||
<View style={styles.audioRow}>
|
||||
<View style={styles.audioRowContent}>
|
||||
<Text style={styles.audioRowTitle}>Theme</Text>
|
||||
@@ -879,9 +873,9 @@ export default function SettingsScreen() {
|
||||
{isDesktop ? <LocalDaemonSection appVersion={appVersion} /> : null}
|
||||
|
||||
{/* About */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>About</Text>
|
||||
<View style={styles.audioCard}>
|
||||
<View style={settingsStyles.section}>
|
||||
<Text style={settingsStyles.sectionTitle}>About</Text>
|
||||
<View style={[settingsStyles.card, styles.audioCard]}>
|
||||
<View style={styles.audioRow}>
|
||||
<View style={styles.audioRowContent}>
|
||||
<Text style={styles.audioRowTitle}>Version</Text>
|
||||
@@ -1045,14 +1039,10 @@ function HostDetailModal({
|
||||
? "rgba(248, 113, 113, 0.1)"
|
||||
: "rgba(161, 161, 170, 0.1)";
|
||||
const connectionBadge = (() => {
|
||||
if (!activeConnection) return null;
|
||||
if (activeConnection.type === "relay") {
|
||||
return { icon: <Globe size={theme.iconSize.xs} color={theme.colors.foregroundMuted} />, text: "Relay" };
|
||||
}
|
||||
return {
|
||||
icon: <Monitor size={theme.iconSize.xs} color={theme.colors.foregroundMuted} />,
|
||||
text: activeConnection.display,
|
||||
};
|
||||
return formatActiveConnectionBadge({
|
||||
activeConnection,
|
||||
theme,
|
||||
});
|
||||
})();
|
||||
const versionBadgeText = formatDaemonVersionBadge(daemonVersion);
|
||||
const connectionError = typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null;
|
||||
@@ -1138,20 +1128,21 @@ function HostDetailModal({
|
||||
latencyError={probe?.status === "unavailable"}
|
||||
onRemove={() => {
|
||||
const title =
|
||||
conn.type === "relay"
|
||||
? `Relay (${conn.relayEndpoint})`
|
||||
: `Direct (${conn.endpoint})`;
|
||||
formatHostConnectionLabel(conn);
|
||||
setPendingRemoveConnection({ serverId: host.serverId, connectionId: conn.id, title });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<Pressable
|
||||
<Button
|
||||
variant="outline"
|
||||
size="md"
|
||||
style={styles.addButton}
|
||||
textStyle={styles.addButtonText}
|
||||
onPress={onAddConnection}
|
||||
>
|
||||
<Text style={styles.addButtonText}>+ Add connection</Text>
|
||||
</Pressable>
|
||||
+ Add connection
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
@@ -1276,9 +1267,7 @@ function ConnectionRow({
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const title =
|
||||
connection.type === "relay"
|
||||
? `Relay (${connection.relayEndpoint})`
|
||||
: `Direct (${connection.endpoint})`;
|
||||
formatHostConnectionLabel(connection);
|
||||
|
||||
const latencyText = (() => {
|
||||
if (latencyLoading) return "...";
|
||||
@@ -1304,7 +1293,7 @@ function ConnectionRow({
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: theme.colors.foreground, fontSize: 12, flex: 1 }}>
|
||||
@@ -1313,11 +1302,14 @@ function ConnectionRow({
|
||||
<Text style={{ color: latencyColor, fontSize: 11 }}>
|
||||
{latencyText}
|
||||
</Text>
|
||||
<Pressable onPress={onRemove}>
|
||||
<Text style={{ color: theme.colors.destructive, fontSize: 12, fontWeight: "500" }}>
|
||||
Remove
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
textStyle={{ color: theme.colors.destructive }}
|
||||
onPress={onRemove}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1363,20 +1355,16 @@ function DaemonCard({
|
||||
? "rgba(248, 113, 113, 0.1)"
|
||||
: "rgba(161, 161, 170, 0.1)";
|
||||
const connectionBadge = (() => {
|
||||
if (!activeConnection) return null;
|
||||
if (activeConnection.type === "relay") {
|
||||
return { icon: <Globe size={theme.iconSize.xs} color={theme.colors.foregroundMuted} />, text: "Relay" };
|
||||
}
|
||||
return {
|
||||
icon: <Monitor size={theme.iconSize.xs} color={theme.colors.foregroundMuted} />,
|
||||
text: activeConnection.display,
|
||||
};
|
||||
return formatActiveConnectionBadge({
|
||||
activeConnection,
|
||||
theme,
|
||||
});
|
||||
})();
|
||||
const versionBadgeText = formatDaemonVersionBadge(daemonVersion);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={styles.hostCard}
|
||||
style={[settingsStyles.card, styles.hostCard]}
|
||||
testID={`daemon-card-${daemon.serverId}`}
|
||||
>
|
||||
<View style={styles.hostCardContent}>
|
||||
@@ -1408,23 +1396,18 @@ function DaemonCard({
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<Pressable
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.hostSettingsButton,
|
||||
(pressed || hovered) && styles.hostSettingsButtonActive,
|
||||
]}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
style={styles.hostSettingsButton}
|
||||
textStyle={{ fontSize: 0, lineHeight: 0 }}
|
||||
leftIcon={<Settings size={theme.iconSize.md} color={theme.colors.foregroundMuted} />}
|
||||
onPress={() => onOpenSettings(daemon)}
|
||||
testID={`daemon-card-settings-${daemon.serverId}`}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Open settings for ${daemon.label}`}
|
||||
>
|
||||
{({ pressed, hovered }) => (
|
||||
<Settings
|
||||
size={theme.iconSize.md}
|
||||
color={pressed || hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
{" "}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
{connectionError ? <Text style={styles.hostError}>{connectionError}</Text> : null}
|
||||
|
||||
33
packages/app/src/screens/startup-splash-screen.tsx
Normal file
33
packages/app/src/screens/startup-splash-screen.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Image, Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
logo: {
|
||||
width: 96,
|
||||
height: 96,
|
||||
marginBottom: theme.spacing[6],
|
||||
},
|
||||
status: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
}));
|
||||
|
||||
export function StartupSplashScreen() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Image
|
||||
source={require("../../assets/images/icon.png")}
|
||||
style={styles.logo}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
<Text style={styles.status}>Starting up…</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildBulkCloseConfirmationMessage,
|
||||
classifyBulkClosableTabs,
|
||||
} from "@/screens/workspace/workspace-bulk-close";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
|
||||
function makeAgentTab(id: string): WorkspaceTabDescriptor {
|
||||
return {
|
||||
key: `agent_${id}`,
|
||||
tabId: `agent_${id}`,
|
||||
kind: "agent",
|
||||
agentId: id,
|
||||
provider: "codex",
|
||||
label: `Agent ${id}`,
|
||||
subtitle: "",
|
||||
titleState: "ready",
|
||||
};
|
||||
}
|
||||
|
||||
function makeTerminalTab(id: string): WorkspaceTabDescriptor {
|
||||
return {
|
||||
key: `terminal_${id}`,
|
||||
tabId: `terminal_${id}`,
|
||||
kind: "terminal",
|
||||
terminalId: id,
|
||||
label: `Terminal ${id}`,
|
||||
subtitle: "",
|
||||
};
|
||||
}
|
||||
|
||||
function makeFileTab(path: string): WorkspaceTabDescriptor {
|
||||
return {
|
||||
key: `file_${path}`,
|
||||
tabId: `file_${path}`,
|
||||
kind: "file",
|
||||
filePath: path,
|
||||
label: path.split("/").pop() ?? path,
|
||||
subtitle: path,
|
||||
};
|
||||
}
|
||||
|
||||
describe("workspace bulk close helpers", () => {
|
||||
it("classifies agent, terminal, and passive tabs for shared bulk close handling", () => {
|
||||
const groups = classifyBulkClosableTabs([
|
||||
makeAgentTab("a1"),
|
||||
makeTerminalTab("t1"),
|
||||
makeFileTab("/repo/README.md"),
|
||||
]);
|
||||
|
||||
expect(groups).toEqual({
|
||||
agentTabs: [{ tabId: "agent_a1", agentId: "a1" }],
|
||||
terminalTabs: [{ tabId: "terminal_t1", terminalId: "t1" }],
|
||||
otherTabs: [{ tabId: "file_/repo/README.md" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("describes mixed destructive bulk close operations in the confirmation copy", () => {
|
||||
const message = buildBulkCloseConfirmationMessage(
|
||||
classifyBulkClosableTabs([
|
||||
makeAgentTab("a1"),
|
||||
makeAgentTab("a2"),
|
||||
makeTerminalTab("t1"),
|
||||
makeFileTab("/repo/README.md"),
|
||||
])
|
||||
);
|
||||
|
||||
expect(message).toBe(
|
||||
"This will archive 2 agent(s), close 1 terminal(s), and close 1 tab(s). Any running process in a closed terminal will be stopped immediately."
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps terminal-only confirmations explicit about stopping running processes", () => {
|
||||
const message = buildBulkCloseConfirmationMessage(
|
||||
classifyBulkClosableTabs([makeTerminalTab("t1")])
|
||||
);
|
||||
|
||||
expect(message).toBe(
|
||||
"This will close 1 terminal(s). Any running process in a closed terminal will be stopped immediately."
|
||||
);
|
||||
});
|
||||
});
|
||||
52
packages/app/src/screens/workspace/workspace-bulk-close.ts
Normal file
52
packages/app/src/screens/workspace/workspace-bulk-close.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
|
||||
export type BulkClosableTabGroups = {
|
||||
agentTabs: Array<{ tabId: string; agentId: string }>;
|
||||
terminalTabs: Array<{ tabId: string; terminalId: string }>;
|
||||
otherTabs: Array<{ tabId: string }>;
|
||||
};
|
||||
|
||||
export function classifyBulkClosableTabs(tabs: WorkspaceTabDescriptor[]): BulkClosableTabGroups {
|
||||
const groups: BulkClosableTabGroups = {
|
||||
agentTabs: [],
|
||||
terminalTabs: [],
|
||||
otherTabs: [],
|
||||
};
|
||||
|
||||
for (const tab of tabs) {
|
||||
if (tab.kind === "agent") {
|
||||
groups.agentTabs.push({ tabId: tab.tabId, agentId: tab.agentId });
|
||||
continue;
|
||||
}
|
||||
if (tab.kind === "terminal") {
|
||||
groups.terminalTabs.push({ tabId: tab.tabId, terminalId: tab.terminalId });
|
||||
continue;
|
||||
}
|
||||
groups.otherTabs.push({ tabId: tab.tabId });
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
export function buildBulkCloseConfirmationMessage(input: BulkClosableTabGroups): string {
|
||||
const { agentTabs, terminalTabs, otherTabs } = input;
|
||||
if (agentTabs.length > 0 && terminalTabs.length > 0 && otherTabs.length > 0) {
|
||||
return `This will archive ${agentTabs.length} agent(s), close ${terminalTabs.length} terminal(s), and close ${otherTabs.length} tab(s). Any running process in a closed terminal will be stopped immediately.`;
|
||||
}
|
||||
if (agentTabs.length > 0 && terminalTabs.length > 0) {
|
||||
return `This will archive ${agentTabs.length} agent(s) and close ${terminalTabs.length} terminal(s). Any running process in a closed terminal will be stopped immediately.`;
|
||||
}
|
||||
if (terminalTabs.length > 0 && otherTabs.length > 0) {
|
||||
return `This will close ${terminalTabs.length} terminal(s) and close ${otherTabs.length} tab(s). Any running process in a closed terminal will be stopped immediately.`;
|
||||
}
|
||||
if (agentTabs.length > 0 && otherTabs.length > 0) {
|
||||
return `This will archive ${agentTabs.length} agent(s) and close ${otherTabs.length} tab(s).`;
|
||||
}
|
||||
if (terminalTabs.length > 0) {
|
||||
return `This will close ${terminalTabs.length} terminal(s). Any running process in a closed terminal will be stopped immediately.`;
|
||||
}
|
||||
if (otherTabs.length > 0) {
|
||||
return `This will close ${otherTabs.length} tab(s).`;
|
||||
}
|
||||
return `This will archive ${agentTabs.length} agent(s).`;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useMemo, useState, type Dispatch, type SetStateAction } from "react";
|
||||
import { ActivityIndicator, Pressable, ScrollView, Text, View, type LayoutChangeEvent } from "react-native";
|
||||
import { Plus, SquareTerminal, X } from "lucide-react-native";
|
||||
import { Plus, X } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { SortableInlineList } from "@/components/sortable-inline-list";
|
||||
import {
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useWorkspaceTabLayout } from "@/screens/workspace/use-workspace-tab-layout";
|
||||
import {
|
||||
@@ -22,7 +23,7 @@ import type { Agent } from "@/stores/session-store";
|
||||
|
||||
const DROPDOWN_WIDTH = 220;
|
||||
const LOADING_TAB_LABEL_SKELETON_WIDTH = 80;
|
||||
type NewTabOptionId = "__new_tab_agent__" | "__new_tab_terminal__";
|
||||
type NewTabOptionId = "__new_tab_agent__";
|
||||
|
||||
type WorkspaceDesktopTabsRowProps = {
|
||||
tabs: WorkspaceTabDescriptor[];
|
||||
@@ -39,13 +40,11 @@ type WorkspaceDesktopTabsRowProps = {
|
||||
onCloseTab: (tabId: string) => Promise<void> | void;
|
||||
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
|
||||
onCopyAgentId: (agentId: string) => Promise<void> | void;
|
||||
onCloseTabsToLeft: (tabId: string) => Promise<void> | void;
|
||||
onCloseTabsToRight: (tabId: string) => Promise<void> | void;
|
||||
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
|
||||
onSelectNewTabOption: (optionId: NewTabOptionId) => void;
|
||||
newTabAgentOptionId: NewTabOptionId;
|
||||
newTabTerminalOptionId: NewTabOptionId;
|
||||
createTerminalPending: boolean;
|
||||
isNewTerminalHovered: boolean;
|
||||
setIsNewTerminalHovered: Dispatch<SetStateAction<boolean>>;
|
||||
onReorderTabs: (nextTabs: WorkspaceTabDescriptor[]) => void;
|
||||
};
|
||||
|
||||
@@ -64,13 +63,11 @@ export function WorkspaceDesktopTabsRow({
|
||||
onCloseTab,
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onCloseTabsToLeft,
|
||||
onCloseTabsToRight,
|
||||
onCloseOtherTabs,
|
||||
onSelectNewTabOption,
|
||||
newTabAgentOptionId,
|
||||
newTabTerminalOptionId,
|
||||
createTerminalPending,
|
||||
isNewTerminalHovered,
|
||||
setIsNewTerminalHovered,
|
||||
onReorderTabs,
|
||||
}: WorkspaceDesktopTabsRowProps) {
|
||||
const { theme } = useUnistyles();
|
||||
@@ -129,7 +126,10 @@ export function WorkspaceDesktopTabsRow({
|
||||
horizontal
|
||||
scrollEnabled={layout.requiresHorizontalScrollFallback}
|
||||
testID="workspace-tabs-scroll"
|
||||
style={styles.tabsScroll}
|
||||
style={[
|
||||
styles.tabsScroll,
|
||||
layout.requiresHorizontalScrollFallback ? styles.tabsScrollOverflow : styles.tabsScrollFitContent,
|
||||
]}
|
||||
contentContainerStyle={styles.tabsContent}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
>
|
||||
@@ -159,112 +159,134 @@ export function WorkspaceDesktopTabsRow({
|
||||
const labelCharCap = layoutItem?.labelCharCap ?? tab.label.length;
|
||||
const renderedLabel = showLabel ? tab.label.slice(0, Math.max(1, labelCharCap)) : "";
|
||||
const presentation = deriveWorkspaceTabPresentation({ tab, agent: tabAgent });
|
||||
const tooltipLabel =
|
||||
tab.kind === "agent" && tab.titleState === "loading"
|
||||
? "Loading agent title"
|
||||
: presentation.label;
|
||||
|
||||
const contextMenuTestId = `workspace-tab-context-${tab.key}`;
|
||||
const isFirstTab = index === 0;
|
||||
const isLastTab = index === tabs.length - 1;
|
||||
const isOnlyTab = tabs.length <= 1;
|
||||
|
||||
return (
|
||||
<ContextMenu key={tab.key}>
|
||||
<ContextMenuTrigger
|
||||
testID={`workspace-tab-${tab.key}`}
|
||||
enabledOnMobile={false}
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.tab,
|
||||
{
|
||||
minWidth: resolvedTabWidth,
|
||||
width: resolvedTabWidth,
|
||||
maxWidth: resolvedTabWidth,
|
||||
},
|
||||
isActive && styles.tabActive,
|
||||
(hovered || pressed || isCloseHovered) && styles.tabHovered,
|
||||
]}
|
||||
onHoverIn={() => {
|
||||
setHoveredTabKey(tab.key);
|
||||
}}
|
||||
onHoverOut={() => {
|
||||
setHoveredTabKey((current) => (current === tab.key ? null : current));
|
||||
}}
|
||||
onPressIn={() => {
|
||||
onNavigateTab(tab.tabId);
|
||||
}}
|
||||
onPress={() => {
|
||||
onNavigateTab(tab.tabId);
|
||||
}}
|
||||
accessibilityLabel={
|
||||
tab.kind === "agent" && tab.titleState === "loading"
|
||||
? "Loading agent title"
|
||||
: tab.label
|
||||
}
|
||||
>
|
||||
<View
|
||||
{...(dragHandleProps?.attributes as any)}
|
||||
{...(dragHandleProps?.listeners as any)}
|
||||
ref={dragHandleProps?.setActivatorNodeRef}
|
||||
style={styles.tabHandle}
|
||||
<Tooltip delayDuration={400} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
testID={`workspace-tab-tooltip-${tab.key}`}
|
||||
accessibilityRole="none"
|
||||
style={styles.tabTooltipTrigger}
|
||||
>
|
||||
<View style={styles.tabIcon}>
|
||||
<WorkspaceTabIcon presentation={presentation} active={isActive} />
|
||||
</View>
|
||||
{showLabel ? (
|
||||
presentation.titleState === "loading" ? (
|
||||
<View
|
||||
style={[
|
||||
styles.tabLabelSkeleton,
|
||||
shouldShowCloseButton && styles.tabLabelSkeletonWithCloseButton,
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<Text
|
||||
style={[
|
||||
styles.tabLabel,
|
||||
isActive && styles.tabLabelActive,
|
||||
shouldShowCloseButton && styles.tabLabelWithCloseButton,
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{renderedLabel}
|
||||
</Text>
|
||||
)
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{shouldShowCloseButton ? (
|
||||
<Pressable
|
||||
testID={
|
||||
tab.kind === "agent"
|
||||
? `workspace-agent-close-${tab.agentId}`
|
||||
: tab.kind === "terminal"
|
||||
? `workspace-terminal-close-${tab.terminalId}`
|
||||
: tab.kind === "draft"
|
||||
? `workspace-draft-close-${tab.draftId}`
|
||||
: `workspace-file-close-${encodeFilePathForPathSegment(tab.filePath)}`
|
||||
}
|
||||
disabled={isClosingTab}
|
||||
<ContextMenuTrigger
|
||||
testID={`workspace-tab-${tab.key}`}
|
||||
enabledOnMobile={false}
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.tab,
|
||||
{
|
||||
minWidth: resolvedTabWidth,
|
||||
width: resolvedTabWidth,
|
||||
maxWidth: resolvedTabWidth,
|
||||
},
|
||||
isActive && styles.tabActive,
|
||||
!isActive && (hovered || pressed || isCloseHovered) && styles.tabHovered,
|
||||
]}
|
||||
onHoverIn={() => {
|
||||
setHoveredTabKey(tab.key);
|
||||
setHoveredCloseTabKey(tab.key);
|
||||
}}
|
||||
onHoverOut={() => {
|
||||
setHoveredTabKey((current) => (current === tab.key ? null : current));
|
||||
setHoveredCloseTabKey((current) => (current === tab.key ? null : current));
|
||||
}}
|
||||
onPress={(event) => {
|
||||
event.stopPropagation?.();
|
||||
void onCloseTab(tab.tabId);
|
||||
onPressIn={() => {
|
||||
onNavigateTab(tab.tabId);
|
||||
}}
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.tabCloseButton,
|
||||
styles.tabCloseButtonShown,
|
||||
(hovered || pressed) && styles.tabCloseButtonActive,
|
||||
]}
|
||||
onPress={() => {
|
||||
onNavigateTab(tab.tabId);
|
||||
}}
|
||||
accessibilityLabel={tooltipLabel}
|
||||
>
|
||||
{isClosingTab ? (
|
||||
<ActivityIndicator size={12} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<X size={12} color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
</Pressable>
|
||||
) : null}
|
||||
</ContextMenuTrigger>
|
||||
<View
|
||||
{...(dragHandleProps?.attributes as any)}
|
||||
{...(dragHandleProps?.listeners as any)}
|
||||
ref={dragHandleProps?.setActivatorNodeRef}
|
||||
style={styles.tabHandle}
|
||||
>
|
||||
<View style={styles.tabIcon}>
|
||||
<WorkspaceTabIcon presentation={presentation} active={isActive} />
|
||||
</View>
|
||||
{showLabel ? (
|
||||
presentation.titleState === "loading" ? (
|
||||
<View
|
||||
style={[
|
||||
styles.tabLabelSkeleton,
|
||||
shouldShowCloseButton && styles.tabLabelSkeletonWithCloseButton,
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<Text
|
||||
style={[
|
||||
styles.tabLabel,
|
||||
isActive && styles.tabLabelActive,
|
||||
shouldShowCloseButton && styles.tabLabelWithCloseButton,
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{renderedLabel}
|
||||
</Text>
|
||||
)
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{shouldShowCloseButton ? (
|
||||
<Pressable
|
||||
testID={
|
||||
tab.kind === "agent"
|
||||
? `workspace-agent-close-${tab.agentId}`
|
||||
: tab.kind === "terminal"
|
||||
? `workspace-terminal-close-${tab.terminalId}`
|
||||
: tab.kind === "draft"
|
||||
? `workspace-draft-close-${tab.draftId}`
|
||||
: `workspace-file-close-${encodeFilePathForPathSegment(tab.filePath)}`
|
||||
}
|
||||
disabled={isClosingTab}
|
||||
onHoverIn={() => {
|
||||
setHoveredTabKey(tab.key);
|
||||
setHoveredCloseTabKey(tab.key);
|
||||
}}
|
||||
onHoverOut={() => {
|
||||
setHoveredTabKey((current) => (current === tab.key ? null : current));
|
||||
setHoveredCloseTabKey((current) => (current === tab.key ? null : current));
|
||||
}}
|
||||
onPress={(event) => {
|
||||
event.stopPropagation?.();
|
||||
void onCloseTab(tab.tabId);
|
||||
}}
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.tabCloseButton,
|
||||
styles.tabCloseButtonShown,
|
||||
(hovered || pressed) && styles.tabCloseButtonActive,
|
||||
]}
|
||||
>
|
||||
{({ hovered, pressed }) =>
|
||||
isClosingTab ? (
|
||||
<ActivityIndicator
|
||||
size={12}
|
||||
color={hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
) : (
|
||||
<X
|
||||
size={12}
|
||||
color={hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</Pressable>
|
||||
) : null}
|
||||
</ContextMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<Text style={styles.newTabTooltipText}>{tooltipLabel}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<ContextMenuContent align="start" width={DROPDOWN_WIDTH} testID={contextMenuTestId}>
|
||||
{tab.kind === "agent" ? (
|
||||
@@ -290,15 +312,33 @@ export function WorkspaceDesktopTabsRow({
|
||||
|
||||
<ContextMenuSeparator />
|
||||
|
||||
<ContextMenuItem
|
||||
testID={`${contextMenuTestId}-close-left`}
|
||||
disabled={isFirstTab}
|
||||
onSelect={() => {
|
||||
void onCloseTabsToLeft(tab.tabId);
|
||||
}}
|
||||
>
|
||||
Close to the left
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
testID={`${contextMenuTestId}-close-right`}
|
||||
disabled={tabs.findIndex((t) => t.key === tab.key) === tabs.length - 1}
|
||||
disabled={isLastTab}
|
||||
onSelect={() => {
|
||||
void onCloseTabsToRight(tab.tabId);
|
||||
}}
|
||||
>
|
||||
Close to the right
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
testID={`${contextMenuTestId}-close-others`}
|
||||
disabled={isOnlyTab}
|
||||
onSelect={() => {
|
||||
void onCloseOtherTabs(tab.tabId);
|
||||
}}
|
||||
>
|
||||
Close other tabs
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
testID={`${contextMenuTestId}-close`}
|
||||
onSelect={() => {
|
||||
@@ -328,37 +368,10 @@ export function WorkspaceDesktopTabsRow({
|
||||
<Plus size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="end" offset={8}>
|
||||
<Text style={styles.newTabTooltipText}>New agent tab</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
testID="workspace-new-terminal-tab"
|
||||
onPress={() => onSelectNewTabOption(newTabTerminalOptionId)}
|
||||
onHoverIn={() => setIsNewTerminalHovered(true)}
|
||||
onHoverOut={() => setIsNewTerminalHovered(false)}
|
||||
disabled={createTerminalPending}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="New terminal tab"
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.newTabActionButton,
|
||||
createTerminalPending && styles.newTabActionButtonDisabled,
|
||||
(hovered || pressed) && styles.newTabActionButtonHovered,
|
||||
]}
|
||||
>
|
||||
{createTerminalPending ? (
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<View style={styles.terminalPlusIcon}>
|
||||
<SquareTerminal size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
<View style={[styles.terminalPlusBadge, isNewTerminalHovered && styles.terminalPlusBadgeHovered]}>
|
||||
<Plus size={10} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="end" offset={8}>
|
||||
<Text style={styles.newTabTooltipText}>New terminal tab</Text>
|
||||
<View style={styles.newTabTooltipRow}>
|
||||
<Text style={styles.newTabTooltipText}>New agent tab</Text>
|
||||
<Shortcut keys={["mod", "T"]} style={styles.newTabTooltipShortcut} />
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</View>
|
||||
@@ -375,9 +388,15 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "center",
|
||||
},
|
||||
tabsScroll: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
tabsScrollFitContent: {
|
||||
flexGrow: 0,
|
||||
flexShrink: 1,
|
||||
},
|
||||
tabsScrollOverflow: {
|
||||
flex: 1,
|
||||
},
|
||||
tabsContent: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
@@ -392,6 +411,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingRight: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1],
|
||||
},
|
||||
tabTooltipTrigger: {
|
||||
flexShrink: 0,
|
||||
},
|
||||
tab: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
@@ -411,7 +433,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexShrink: 0,
|
||||
},
|
||||
tabActive: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
tabHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
@@ -466,33 +488,17 @@ const styles = StyleSheet.create((theme) => ({
|
||||
newTabActionButtonHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
newTabActionButtonDisabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
terminalPlusIcon: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
terminalPlusBadge: {
|
||||
position: "absolute",
|
||||
right: -7,
|
||||
bottom: -7,
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: 6,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.surface0,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
terminalPlusBadgeHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
newTabTooltipText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
newTabTooltipRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
newTabTooltipShortcut: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
},
|
||||
}));
|
||||
|
||||
39
packages/app/src/screens/workspace/workspace-git-actions.tsx
Normal file
39
packages/app/src/screens/workspace/workspace-git-actions.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useMemo } from "react";
|
||||
import { useUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
Archive,
|
||||
GitCommitHorizontal,
|
||||
GitMerge,
|
||||
RefreshCcw,
|
||||
Upload,
|
||||
} from "lucide-react-native";
|
||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||
import { GitActionsSplitButton } from "@/components/git-actions-split-button";
|
||||
import { useGitActions } from "@/hooks/use-git-actions";
|
||||
|
||||
interface WorkspaceGitActionsProps {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
export function WorkspaceGitActions({ serverId, cwd }: WorkspaceGitActionsProps) {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
const icons = useMemo(() => ({
|
||||
commit: <GitCommitHorizontal size={16} color={theme.colors.foregroundMuted} />,
|
||||
push: <Upload size={16} color={theme.colors.foregroundMuted} />,
|
||||
viewPr: <GitHubIcon size={16} color={theme.colors.foregroundMuted} />,
|
||||
createPr: <GitHubIcon size={16} color={theme.colors.foregroundMuted} />,
|
||||
merge: <GitMerge size={16} color={theme.colors.foregroundMuted} />,
|
||||
mergeFromBase: <RefreshCcw size={16} color={theme.colors.foregroundMuted} />,
|
||||
archive: <Archive size={16} color={theme.colors.foregroundMuted} />,
|
||||
}), [theme.colors.foregroundMuted]);
|
||||
|
||||
const { gitActions, isGit } = useGitActions({ serverId, cwd, icons });
|
||||
|
||||
if (!isGit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <GitActionsSplitButton gitActions={gitActions} />;
|
||||
}
|
||||
@@ -12,8 +12,10 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import {
|
||||
ChevronDown,
|
||||
Copy,
|
||||
Folder,
|
||||
GitBranch,
|
||||
Ellipsis,
|
||||
PanelRight,
|
||||
Plus,
|
||||
SquareTerminal,
|
||||
@@ -24,6 +26,13 @@ import { SidebarMenuToggle } from "@/components/headers/menu-header";
|
||||
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
|
||||
import { ScreenHeader } from "@/components/headers/screen-header";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -32,6 +41,8 @@ import {
|
||||
import { ExplorerSidebar } from "@/components/explorer-sidebar";
|
||||
import { FilePane } from "@/components/file-pane";
|
||||
import { TerminalPane } from "@/components/terminal-pane";
|
||||
import { SourceControlPanelIcon } from "@/components/icons/source-control-panel-icon";
|
||||
import { WorkspaceGitActions } from "@/screens/workspace/workspace-git-actions";
|
||||
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
|
||||
@@ -83,10 +94,13 @@ import {
|
||||
import {
|
||||
deriveWorkspaceTabModel,
|
||||
} from "@/screens/workspace/workspace-tab-model";
|
||||
import {
|
||||
buildBulkCloseConfirmationMessage,
|
||||
classifyBulkClosableTabs,
|
||||
} from "@/screens/workspace/workspace-bulk-close";
|
||||
|
||||
const TERMINALS_QUERY_STALE_TIME = 5_000;
|
||||
const NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__";
|
||||
const NEW_TAB_TERMINAL_OPTION_ID = "__new_tab_terminal__";
|
||||
const EMPTY_UI_TABS: ReturnType<typeof useWorkspaceTabsStore.getState>["uiTabsByWorkspace"][string] = [];
|
||||
const EMPTY_TAB_ORDER: string[] = [];
|
||||
|
||||
@@ -112,6 +126,21 @@ function decodeSegment(value: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function buildOpenIntentKey(input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
openIntent?: WorkspaceOpenIntent | null;
|
||||
}): string | null {
|
||||
if (!input.openIntent) {
|
||||
return null;
|
||||
}
|
||||
const openParam = buildWorkspaceOpenIntentParam(input.openIntent);
|
||||
if (!openParam) {
|
||||
return null;
|
||||
}
|
||||
return `${input.serverId}:${input.workspaceId}:${openParam}`;
|
||||
}
|
||||
|
||||
export function WorkspaceScreen({
|
||||
serverId,
|
||||
workspaceId,
|
||||
@@ -428,18 +457,43 @@ function WorkspaceScreenContent({
|
||||
(state) => state.clearWorkspaceTabActionRequest
|
||||
);
|
||||
const consumedOpenIntentsRef = useRef(new Set<string>());
|
||||
const [resolvedOpenIntentKey, setResolvedOpenIntentKey] = useState<string | null>(null);
|
||||
const currentOpenIntentKey = useMemo(
|
||||
() =>
|
||||
buildOpenIntentKey({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
openIntent,
|
||||
}),
|
||||
[normalizedServerId, normalizedWorkspaceId, openIntent]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentOpenIntentKey) {
|
||||
if (resolvedOpenIntentKey !== null) {
|
||||
setResolvedOpenIntentKey(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (resolvedOpenIntentKey === currentOpenIntentKey) {
|
||||
return;
|
||||
}
|
||||
}, [currentOpenIntentKey, resolvedOpenIntentKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!openIntent || !persistenceKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const openParam = buildWorkspaceOpenIntentParam(openIntent);
|
||||
if (!openParam) {
|
||||
if (!currentOpenIntentKey) {
|
||||
return;
|
||||
}
|
||||
const intentKey = `${normalizedServerId}:${normalizedWorkspaceId}:${openParam}`;
|
||||
const intentKey = currentOpenIntentKey;
|
||||
if (consumedOpenIntentsRef.current.has(intentKey)) {
|
||||
if (resolvedOpenIntentKey !== intentKey) {
|
||||
setResolvedOpenIntentKey(intentKey);
|
||||
}
|
||||
return;
|
||||
}
|
||||
consumedOpenIntentsRef.current.add(intentKey);
|
||||
@@ -464,6 +518,7 @@ function WorkspaceScreenContent({
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
tabId,
|
||||
});
|
||||
setResolvedOpenIntentKey(intentKey);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -484,8 +539,10 @@ function WorkspaceScreenContent({
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
tabId,
|
||||
});
|
||||
setResolvedOpenIntentKey(intentKey);
|
||||
}
|
||||
}, [
|
||||
currentOpenIntentKey,
|
||||
focusTab,
|
||||
openDraftTab,
|
||||
openIntent,
|
||||
@@ -493,8 +550,13 @@ function WorkspaceScreenContent({
|
||||
persistenceKey,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
resolvedOpenIntentKey,
|
||||
]);
|
||||
|
||||
const unresolvedOpenIntent = currentOpenIntentKey && resolvedOpenIntentKey !== currentOpenIntentKey
|
||||
? openIntent
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!normalizedServerId || !normalizedWorkspaceId) {
|
||||
return;
|
||||
@@ -582,17 +644,17 @@ function WorkspaceScreenContent({
|
||||
tabOrder,
|
||||
focusedTabId,
|
||||
preferredTarget:
|
||||
openIntent?.kind === "agent"
|
||||
? { kind: "agent", agentId: openIntent.agentId }
|
||||
: openIntent?.kind === "terminal"
|
||||
? { kind: "terminal", terminalId: openIntent.terminalId }
|
||||
: openIntent?.kind === "draft"
|
||||
? { kind: "draft", draftId: openIntent.draftId }
|
||||
: openIntent?.kind === "file"
|
||||
? { kind: "file", path: openIntent.path }
|
||||
unresolvedOpenIntent?.kind === "agent"
|
||||
? { kind: "agent", agentId: unresolvedOpenIntent.agentId }
|
||||
: unresolvedOpenIntent?.kind === "terminal"
|
||||
? { kind: "terminal", terminalId: unresolvedOpenIntent.terminalId }
|
||||
: unresolvedOpenIntent?.kind === "draft"
|
||||
? { kind: "draft", draftId: unresolvedOpenIntent.draftId }
|
||||
: unresolvedOpenIntent?.kind === "file"
|
||||
? { kind: "file", path: unresolvedOpenIntent.path }
|
||||
: null,
|
||||
}),
|
||||
[focusedTabId, openIntent, tabOrder, terminals, uiTabs, workspaceAgents]
|
||||
[focusedTabId, tabOrder, terminals, uiTabs, unresolvedOpenIntent, workspaceAgents]
|
||||
);
|
||||
const activeTabId = tabModel.activeTabId;
|
||||
|
||||
@@ -691,7 +753,6 @@ function WorkspaceScreenContent({
|
||||
);
|
||||
|
||||
const [isTabSwitcherOpen, setIsTabSwitcherOpen] = useState(false);
|
||||
const [isNewTerminalHovered, setIsNewTerminalHovered] = useState(false);
|
||||
const [hoveredTabKey, setHoveredTabKey] = useState<string | null>(null);
|
||||
const [hoveredCloseTabKey, setHoveredCloseTabKey] = useState<string | null>(
|
||||
null
|
||||
@@ -768,16 +829,12 @@ function WorkspaceScreenContent({
|
||||
);
|
||||
|
||||
const handleSelectNewTabOption = useCallback(
|
||||
(key: typeof NEW_TAB_AGENT_OPTION_ID | typeof NEW_TAB_TERMINAL_OPTION_ID) => {
|
||||
(key: typeof NEW_TAB_AGENT_OPTION_ID) => {
|
||||
if (key === NEW_TAB_AGENT_OPTION_ID) {
|
||||
handleCreateDraftTab();
|
||||
return;
|
||||
}
|
||||
if (key === NEW_TAB_TERMINAL_OPTION_ID) {
|
||||
handleCreateTerminal();
|
||||
}
|
||||
},
|
||||
[handleCreateDraftTab, handleCreateTerminal]
|
||||
[handleCreateDraftTab]
|
||||
);
|
||||
|
||||
const handleCloseTerminalTab = useCallback(
|
||||
@@ -954,46 +1011,31 @@ function WorkspaceScreenContent({
|
||||
[sessionAgents, toast]
|
||||
);
|
||||
|
||||
const handleCloseTabsToRight = useCallback(
|
||||
async (tabKey: string) => {
|
||||
const startIndex = tabs.findIndex((tab) => tab.tabId === tabKey);
|
||||
if (startIndex < 0) {
|
||||
return;
|
||||
}
|
||||
const toClose = tabs.slice(startIndex + 1);
|
||||
if (toClose.length === 0) {
|
||||
const handleCopyWorkspacePath = useCallback(async () => {
|
||||
if (!normalizedWorkspaceId.startsWith("/")) {
|
||||
toast.error("Workspace path not available");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await Clipboard.setStringAsync(normalizedWorkspaceId);
|
||||
toast.copied("Workspace path");
|
||||
} catch {
|
||||
toast.error("Copy failed");
|
||||
}
|
||||
}, [normalizedWorkspaceId, toast]);
|
||||
|
||||
const handleBulkCloseTabs = useCallback(
|
||||
async (input: { tabsToClose: WorkspaceTabDescriptor[]; title: string; logLabel: string }) => {
|
||||
const { tabsToClose, title, logLabel } = input;
|
||||
if (tabsToClose.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const agentTabs: Array<{ tabId: string; agentId: string }> = [];
|
||||
const terminalTabs: Array<{ tabId: string; terminalId: string }> = [];
|
||||
const otherTabs: Array<{ tabId: string }> = [];
|
||||
for (const tab of toClose) {
|
||||
if (tab.kind === "agent") {
|
||||
agentTabs.push({ tabId: tab.tabId, agentId: tab.agentId });
|
||||
} else if (tab.kind === "terminal") {
|
||||
terminalTabs.push({ tabId: tab.tabId, terminalId: tab.terminalId });
|
||||
} else {
|
||||
otherTabs.push({ tabId: tab.tabId });
|
||||
}
|
||||
}
|
||||
|
||||
const groups = classifyBulkClosableTabs(tabsToClose);
|
||||
const confirmed = await confirmDialog({
|
||||
title: "Close tabs to the right?",
|
||||
message:
|
||||
agentTabs.length > 0 && terminalTabs.length > 0 && otherTabs.length > 0
|
||||
? `This will archive ${agentTabs.length} agent(s), close ${terminalTabs.length} terminal(s), and close ${otherTabs.length} tab(s). Any running process in a closed terminal will be stopped immediately.`
|
||||
: agentTabs.length > 0 && terminalTabs.length > 0
|
||||
? `This will archive ${agentTabs.length} agent(s) and close ${terminalTabs.length} terminal(s). Any running process in a closed terminal will be stopped immediately.`
|
||||
: terminalTabs.length > 0 && otherTabs.length > 0
|
||||
? `This will close ${terminalTabs.length} terminal(s) and close ${otherTabs.length} tab(s). Any running process in a closed terminal will be stopped immediately.`
|
||||
: agentTabs.length > 0 && otherTabs.length > 0
|
||||
? `This will archive ${agentTabs.length} agent(s) and close ${otherTabs.length} tab(s).`
|
||||
: terminalTabs.length > 0
|
||||
? `This will close ${terminalTabs.length} terminal(s). Any running process in a closed terminal will be stopped immediately.`
|
||||
: otherTabs.length > 0
|
||||
? `This will close ${otherTabs.length} tab(s).`
|
||||
: `This will archive ${agentTabs.length} agent(s).`,
|
||||
title,
|
||||
message: buildBulkCloseConfirmationMessage(groups),
|
||||
confirmLabel: "Close",
|
||||
cancelLabel: "Cancel",
|
||||
destructive: true,
|
||||
@@ -1002,7 +1044,7 @@ function WorkspaceScreenContent({
|
||||
return;
|
||||
}
|
||||
|
||||
for (const { tabId, terminalId } of terminalTabs) {
|
||||
for (const { tabId, terminalId } of groups.terminalTabs) {
|
||||
try {
|
||||
await killTerminalMutation.mutateAsync(terminalId);
|
||||
queryClient.setQueryData<ListTerminalsPayload>(terminalsQueryKey, (current) => {
|
||||
@@ -1020,11 +1062,11 @@ function WorkspaceScreenContent({
|
||||
tabId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("[WorkspaceScreen] Failed to close terminal tab to the right", { terminalId, error });
|
||||
console.warn(`[WorkspaceScreen] Failed to close terminal tab ${logLabel}`, { terminalId, error });
|
||||
}
|
||||
}
|
||||
|
||||
for (const { tabId, agentId } of agentTabs) {
|
||||
for (const { tabId, agentId } of groups.agentTabs) {
|
||||
if (!normalizedServerId) {
|
||||
continue;
|
||||
}
|
||||
@@ -1036,11 +1078,11 @@ function WorkspaceScreenContent({
|
||||
tabId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("[WorkspaceScreen] Failed to archive agent tab to the right", { agentId, error });
|
||||
console.warn(`[WorkspaceScreen] Failed to archive agent tab ${logLabel}`, { agentId, error });
|
||||
}
|
||||
}
|
||||
|
||||
for (const { tabId } of otherTabs) {
|
||||
for (const { tabId } of groups.otherTabs) {
|
||||
closeWorkspaceTab({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
@@ -1048,7 +1090,7 @@ function WorkspaceScreenContent({
|
||||
});
|
||||
}
|
||||
|
||||
const closedKeys = new Set(toClose.map((tab) => tab.key));
|
||||
const closedKeys = new Set(tabsToClose.map((tab) => tab.key));
|
||||
setHoveredTabKey((current) => (current && closedKeys.has(current) ? null : current));
|
||||
setHoveredCloseTabKey((current) => (current && closedKeys.has(current) ? null : current));
|
||||
},
|
||||
@@ -1059,11 +1101,52 @@ function WorkspaceScreenContent({
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
queryClient,
|
||||
tabs,
|
||||
terminalsQueryKey,
|
||||
]
|
||||
);
|
||||
|
||||
const handleCloseTabsToLeft = useCallback(
|
||||
async (tabId: string) => {
|
||||
const index = tabs.findIndex((tab) => tab.tabId === tabId);
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
await handleBulkCloseTabs({
|
||||
tabsToClose: tabs.slice(0, index),
|
||||
title: "Close tabs to the left?",
|
||||
logLabel: "to the left",
|
||||
});
|
||||
},
|
||||
[handleBulkCloseTabs, tabs]
|
||||
);
|
||||
|
||||
const handleCloseTabsToRight = useCallback(
|
||||
async (tabId: string) => {
|
||||
const index = tabs.findIndex((tab) => tab.tabId === tabId);
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
await handleBulkCloseTabs({
|
||||
tabsToClose: tabs.slice(index + 1),
|
||||
title: "Close tabs to the right?",
|
||||
logLabel: "to the right",
|
||||
});
|
||||
},
|
||||
[handleBulkCloseTabs, tabs]
|
||||
);
|
||||
|
||||
const handleCloseOtherTabs = useCallback(
|
||||
async (tabId: string) => {
|
||||
const tabsToClose = tabs.filter((tab) => tab.tabId !== tabId);
|
||||
await handleBulkCloseTabs({
|
||||
tabsToClose,
|
||||
title: "Close other tabs?",
|
||||
logLabel: "from close other tabs",
|
||||
});
|
||||
},
|
||||
[handleBulkCloseTabs, tabs]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceTabActionRequest) {
|
||||
return;
|
||||
@@ -1075,38 +1158,36 @@ function WorkspaceScreenContent({
|
||||
return;
|
||||
}
|
||||
|
||||
if (workspaceTabActionRequest.kind === "new") {
|
||||
const request = workspaceTabActionRequest;
|
||||
clearWorkspaceTabActionRequest(request.id);
|
||||
|
||||
if (request.kind === "new") {
|
||||
handleCreateDraftTab();
|
||||
clearWorkspaceTabActionRequest(workspaceTabActionRequest.id);
|
||||
return;
|
||||
}
|
||||
if (workspaceTabActionRequest.kind === "close-current") {
|
||||
if (request.kind === "close-current") {
|
||||
if (activeTabId) {
|
||||
void handleCloseTabById(activeTabId);
|
||||
}
|
||||
clearWorkspaceTabActionRequest(workspaceTabActionRequest.id);
|
||||
return;
|
||||
}
|
||||
if (workspaceTabActionRequest.kind === "navigate-index") {
|
||||
const next = tabs[workspaceTabActionRequest.index - 1] ?? null;
|
||||
if (request.kind === "navigate-index") {
|
||||
const next = tabs[request.index - 1] ?? null;
|
||||
if (next?.tabId) {
|
||||
navigateToTabId(next.tabId);
|
||||
}
|
||||
clearWorkspaceTabActionRequest(workspaceTabActionRequest.id);
|
||||
return;
|
||||
}
|
||||
if (workspaceTabActionRequest.kind === "navigate-relative") {
|
||||
if (request.kind === "navigate-relative") {
|
||||
if (tabs.length > 0) {
|
||||
const currentIndex = tabs.findIndex((tab) => tab.tabId === activeTabId);
|
||||
const fromIndex = currentIndex >= 0 ? currentIndex : 0;
|
||||
const nextIndex =
|
||||
(fromIndex + workspaceTabActionRequest.delta + tabs.length) % tabs.length;
|
||||
const nextIndex = (fromIndex + request.delta + tabs.length) % tabs.length;
|
||||
const next = tabs[nextIndex] ?? null;
|
||||
if (next?.tabId) {
|
||||
navigateToTabId(next.tabId);
|
||||
}
|
||||
}
|
||||
clearWorkspaceTabActionRequest(workspaceTabActionRequest.id);
|
||||
}
|
||||
}, [
|
||||
activeTabId,
|
||||
@@ -1188,6 +1269,7 @@ function WorkspaceScreenContent({
|
||||
if (target.kind === "agent") {
|
||||
return (
|
||||
<AgentReadyScreen
|
||||
key={`${normalizedServerId}:${normalizedWorkspaceId}:${target.agentId}`}
|
||||
serverId={normalizedServerId}
|
||||
agentId={target.agentId}
|
||||
showExplorerSidebar={false}
|
||||
@@ -1262,55 +1344,134 @@ function WorkspaceScreenContent({
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
testID="workspace-header-menu-trigger"
|
||||
style={styles.headerActionButton}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Workspace actions"
|
||||
>
|
||||
{({ hovered, open }) => (
|
||||
<Ellipsis
|
||||
size={theme.iconSize.md}
|
||||
color={hovered || open ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
width={220}
|
||||
testID="workspace-header-menu"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
testID="workspace-header-new-terminal"
|
||||
leading={
|
||||
<SquareTerminal
|
||||
size={16}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
}
|
||||
disabled={createTerminalMutation.isPending}
|
||||
onSelect={handleCreateTerminal}
|
||||
>
|
||||
New terminal
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
testID="workspace-header-copy-path"
|
||||
leading={
|
||||
<Copy
|
||||
size={16}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
}
|
||||
disabled={!normalizedWorkspaceId.startsWith("/")}
|
||||
onSelect={handleCopyWorkspacePath}
|
||||
>
|
||||
Copy workspace path
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</View>
|
||||
</>
|
||||
}
|
||||
right={
|
||||
<View style={styles.headerRight}>
|
||||
<HeaderToggleButton
|
||||
testID="workspace-explorer-toggle"
|
||||
onPress={handleToggleExplorer}
|
||||
tooltipLabel="Toggle explorer"
|
||||
tooltipKeys={["mod", "E"]}
|
||||
tooltipSide="left"
|
||||
style={styles.menuButton}
|
||||
accessible
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={isExplorerOpen ? "Close explorer" : "Open explorer"}
|
||||
accessibilityState={{ expanded: isExplorerOpen }}
|
||||
>
|
||||
{isMobile ? (
|
||||
isGitCheckout ? (
|
||||
<GitBranch
|
||||
size={theme.iconSize.lg}
|
||||
color={
|
||||
isExplorerOpen
|
||||
? theme.colors.foreground
|
||||
: theme.colors.foregroundMuted
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Folder
|
||||
size={theme.iconSize.lg}
|
||||
color={
|
||||
isExplorerOpen
|
||||
? theme.colors.foreground
|
||||
: theme.colors.foregroundMuted
|
||||
}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<PanelRight
|
||||
size={theme.iconSize.md}
|
||||
color={
|
||||
isExplorerOpen
|
||||
? theme.colors.foreground
|
||||
: theme.colors.foregroundMuted
|
||||
}
|
||||
{!isMobile && isGitCheckout ? (
|
||||
<>
|
||||
<WorkspaceGitActions
|
||||
serverId={normalizedServerId}
|
||||
cwd={normalizedWorkspaceId}
|
||||
/>
|
||||
)}
|
||||
</HeaderToggleButton>
|
||||
|
||||
<Pressable
|
||||
testID="workspace-explorer-toggle"
|
||||
onPress={handleToggleExplorer}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={isExplorerOpen ? "Close explorer" : "Open explorer"}
|
||||
accessibilityState={{ expanded: isExplorerOpen }}
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.sourceControlButton,
|
||||
workspaceDescriptor?.diffStat && styles.sourceControlButtonWithStats,
|
||||
(hovered || pressed || isExplorerOpen) && styles.sourceControlButtonHovered,
|
||||
]}
|
||||
>
|
||||
{({ hovered, pressed }) => {
|
||||
const active = isExplorerOpen || hovered || pressed;
|
||||
const iconColor = active ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||
return (
|
||||
<>
|
||||
<SourceControlPanelIcon size={theme.iconSize.md} color={iconColor} />
|
||||
{workspaceDescriptor?.diffStat ? (
|
||||
<View style={styles.diffStatRow}>
|
||||
<Text style={styles.diffStatAdditions}>+{workspaceDescriptor.diffStat.additions}</Text>
|
||||
<Text style={styles.diffStatDeletions}>-{workspaceDescriptor.diffStat.deletions}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Pressable>
|
||||
</>
|
||||
) : null}
|
||||
{!isMobile && !isGitCheckout ? (
|
||||
<HeaderToggleButton
|
||||
testID="workspace-explorer-toggle"
|
||||
onPress={handleToggleExplorer}
|
||||
tooltipLabel="Toggle explorer"
|
||||
tooltipKeys={["mod", "E"]}
|
||||
tooltipSide="left"
|
||||
style={styles.headerActionButton}
|
||||
accessible
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={isExplorerOpen ? "Close explorer" : "Open explorer"}
|
||||
accessibilityState={{ expanded: isExplorerOpen }}
|
||||
>
|
||||
{({ hovered }) => {
|
||||
const color = isExplorerOpen || hovered ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||
return <PanelRight size={theme.iconSize.md} color={color} />;
|
||||
}}
|
||||
</HeaderToggleButton>
|
||||
) : null}
|
||||
{isMobile ? (
|
||||
<HeaderToggleButton
|
||||
testID="workspace-explorer-toggle"
|
||||
onPress={handleToggleExplorer}
|
||||
tooltipLabel="Toggle explorer"
|
||||
tooltipKeys={["mod", "E"]}
|
||||
tooltipSide="left"
|
||||
style={styles.headerActionButton}
|
||||
accessible
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={isExplorerOpen ? "Close explorer" : "Open explorer"}
|
||||
accessibilityState={{ expanded: isExplorerOpen }}
|
||||
>
|
||||
{({ hovered }) => {
|
||||
const color = isExplorerOpen || hovered ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||
return isGitCheckout
|
||||
? <GitBranch size={theme.iconSize.lg} color={color} />
|
||||
: <Folder size={theme.iconSize.lg} color={color} />;
|
||||
}}
|
||||
</HeaderToggleButton>
|
||||
) : null}
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
@@ -1363,40 +1524,13 @@ function WorkspaceScreenContent({
|
||||
<Plus size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="end" offset={8}>
|
||||
<Text style={styles.newTabTooltipText}>New agent tab</Text>
|
||||
<View style={styles.newTabTooltipRow}>
|
||||
<Text style={styles.newTabTooltipText}>New agent tab</Text>
|
||||
<Shortcut keys={["mod", "T"]} style={styles.newTabTooltipShortcut} />
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
testID="workspace-new-terminal-tab"
|
||||
onPress={() => handleSelectNewTabOption(NEW_TAB_TERMINAL_OPTION_ID)}
|
||||
onHoverIn={() => setIsNewTerminalHovered(true)}
|
||||
onHoverOut={() => setIsNewTerminalHovered(false)}
|
||||
disabled={createTerminalMutation.isPending}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="New terminal tab"
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.newTabActionButton,
|
||||
createTerminalMutation.isPending && styles.newTabActionButtonDisabled,
|
||||
(hovered || pressed) && styles.newTabActionButtonHovered,
|
||||
]}
|
||||
>
|
||||
{createTerminalMutation.isPending ? (
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<View style={styles.terminalPlusIcon}>
|
||||
<SquareTerminal size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
<View style={[styles.terminalPlusBadge, isNewTerminalHovered && styles.terminalPlusBadgeHovered]}>
|
||||
<Plus size={10} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="end" offset={8}>
|
||||
<Text style={styles.newTabTooltipText}>New terminal tab</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</View>
|
||||
|
||||
<Combobox
|
||||
@@ -1444,13 +1578,11 @@ function WorkspaceScreenContent({
|
||||
onCloseTab={handleCloseTabById}
|
||||
onCopyResumeCommand={handleCopyResumeCommand}
|
||||
onCopyAgentId={handleCopyAgentId}
|
||||
onCloseTabsToLeft={handleCloseTabsToLeft}
|
||||
onCloseTabsToRight={handleCloseTabsToRight}
|
||||
onCloseOtherTabs={handleCloseOtherTabs}
|
||||
onSelectNewTabOption={handleSelectNewTabOption}
|
||||
newTabAgentOptionId={NEW_TAB_AGENT_OPTION_ID}
|
||||
newTabTerminalOptionId={NEW_TAB_TERMINAL_OPTION_ID}
|
||||
createTerminalPending={createTerminalMutation.isPending}
|
||||
isNewTerminalHovered={isNewTerminalHovered}
|
||||
setIsNewTerminalHovered={setIsNewTerminalHovered}
|
||||
onReorderTabs={handleReorderTabs}
|
||||
/>
|
||||
)}
|
||||
@@ -1533,12 +1665,49 @@ const styles = StyleSheet.create((theme) => ({
|
||||
headerRight: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
gap: {
|
||||
xs: theme.spacing[1],
|
||||
md: theme.spacing[2],
|
||||
},
|
||||
},
|
||||
menuButton: {
|
||||
padding: theme.spacing[3],
|
||||
headerActionButton: {
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
sourceControlButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
paddingVertical: theme.spacing[1],
|
||||
minHeight: Math.ceil(theme.fontSize.sm * 1.5) + theme.spacing[1] * 2,
|
||||
minWidth: Math.ceil(theme.fontSize.sm * 1.5) + theme.spacing[1] * 2,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
sourceControlButtonWithStats: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
},
|
||||
sourceControlButtonHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
diffStatRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
flexShrink: 0,
|
||||
},
|
||||
diffStatAdditions: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.palette.green[400],
|
||||
},
|
||||
diffStatDeletions: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.palette.red[500],
|
||||
},
|
||||
newTabActions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
@@ -1557,33 +1726,18 @@ const styles = StyleSheet.create((theme) => ({
|
||||
newTabActionButtonHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
newTabActionButtonDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
newTabTooltipText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.popoverForeground,
|
||||
},
|
||||
terminalPlusIcon: {
|
||||
position: "relative",
|
||||
width: theme.iconSize.sm,
|
||||
height: theme.iconSize.sm,
|
||||
newTabTooltipRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
terminalPlusBadge: {
|
||||
position: "absolute",
|
||||
right: -5,
|
||||
bottom: -5,
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: 6,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
terminalPlusBadgeHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
newTabTooltipShortcut: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
},
|
||||
mobileTabsRow: {
|
||||
borderBottomWidth: 1,
|
||||
|
||||
@@ -11,9 +11,14 @@ describe('workspace source of truth consumption', () => {
|
||||
const workspace: WorkspaceDescriptor = {
|
||||
id: '/repo/main',
|
||||
projectId: 'remote:github.com/getpaseo/paseo',
|
||||
projectDisplayName: 'getpaseo/paseo',
|
||||
projectRootPath: '/repo/main',
|
||||
projectKind: 'git',
|
||||
workspaceKind: 'local_checkout',
|
||||
name: 'feat/workspace-sot',
|
||||
status: 'running',
|
||||
activityAt: new Date('2026-03-01T00:00:00.000Z'),
|
||||
diffStat: null,
|
||||
}
|
||||
|
||||
const header = resolveWorkspaceHeader({ workspace })
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user