mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
60 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 |
420
.github/workflows/desktop-release.yml
vendored
420
.github/workflows/desktop-release.yml
vendored
@@ -5,28 +5,43 @@ on:
|
||||
tags:
|
||||
- "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)"
|
||||
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:
|
||||
RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
|
||||
SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
|
||||
|
||||
jobs:
|
||||
publish-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-13
|
||||
- runner: macos-15-intel
|
||||
rust_target: x86_64-apple-darwin
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -39,11 +54,67 @@ 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"
|
||||
cache-dependency-path: package-lock.json
|
||||
registry-url: "https://npm.pkg.github.com"
|
||||
scope: "@boudra"
|
||||
|
||||
@@ -52,6 +123,14 @@ jobs:
|
||||
with:
|
||||
targets: ${{ matrix.rust_target }}
|
||||
|
||||
- name: Restore Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: desktop-release-macos-${{ matrix.rust_target }}
|
||||
workspaces: |
|
||||
.
|
||||
packages/desktop/src-tauri -> target
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: npm ci
|
||||
env:
|
||||
@@ -60,46 +139,25 @@ jobs:
|
||||
- name: Build web app for Tauri
|
||||
run: npm run build:web --workspace=@getpaseo/app
|
||||
|
||||
- name: Set desktop version from tag
|
||||
shell: bash
|
||||
run: |
|
||||
node <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
- name: Build managed runtime
|
||||
run: npm run prepare:managed-runtime --workspace=@getpaseo/desktop
|
||||
|
||||
const rawTag = process.env.RELEASE_TAG;
|
||||
if (!rawTag) throw new Error('RELEASE_TAG env var is missing');
|
||||
- name: Validate managed runtime bundle
|
||||
run: npm run validate:managed-runtime --workspace=@getpaseo/desktop
|
||||
|
||||
const version = rawTag.replace(/^desktop-/, '').replace(/^v/, '');
|
||||
console.log(`Using desktop version ${version} from tag ${rawTag}`);
|
||||
- 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 }}
|
||||
|
||||
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: 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
|
||||
@@ -113,6 +171,8 @@ jobs:
|
||||
echo "RELEASE_DRAFT=$release_draft" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build and publish macOS Tauri release
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
id: tauri_build
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -133,11 +193,54 @@ jobs:
|
||||
prerelease: false
|
||||
args: --target ${{ matrix.rust_target }}
|
||||
|
||||
- name: Notarize and re-upload DMG
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
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-latest
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -145,22 +248,86 @@ 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: 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
|
||||
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:
|
||||
@@ -169,46 +336,27 @@ 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: Strip CUDA dependencies from onnxruntime
|
||||
shell: bash
|
||||
run: |
|
||||
node <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const rawTag = process.env.RELEASE_TAG;
|
||||
if (!rawTag) throw new Error('RELEASE_TAG env var is missing');
|
||||
|
||||
const 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
|
||||
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
|
||||
@@ -222,11 +370,14 @@ jobs:
|
||||
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 }}
|
||||
@@ -236,7 +387,12 @@ jobs:
|
||||
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
|
||||
@@ -248,17 +404,81 @@ 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"
|
||||
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:
|
||||
@@ -267,46 +487,14 @@ jobs:
|
||||
- name: Build web app for Tauri
|
||||
run: npm run build:web --workspace=@getpaseo/app
|
||||
|
||||
- name: Set desktop version from tag
|
||||
shell: bash
|
||||
run: |
|
||||
node <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
- name: Build managed runtime
|
||||
run: npm run prepare:managed-runtime --workspace=@getpaseo/desktop
|
||||
|
||||
const 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: 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
|
||||
@@ -320,6 +508,7 @@ jobs:
|
||||
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 }}
|
||||
@@ -333,3 +522,10 @@ jobs:
|
||||
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 }}
|
||||
|
||||
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
|
||||
|
||||
|
||||
27
CHANGELOG.md
27
CHANGELOG.md
@@ -1,5 +1,32 @@
|
||||
# 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.
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
# Runtime Simplification Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Remove the managed runtime install/copy layer. The desktop app must run the bundled runtime in place from the application resources/install directory on macOS, Windows, and Linux.
|
||||
|
||||
This is a simplification task. If a change preserves the old install-manager shape under a new name, it fails the goal.
|
||||
|
||||
## Hard Requirements
|
||||
|
||||
1. The bundled runtime is read-only.
|
||||
The desktop app must not copy the bundled runtime to app data, temp, cache, or any other writable directory.
|
||||
|
||||
2. The app must execute Node/CLI/server entrypoints directly from the bundled runtime root.
|
||||
On macOS this means inside `Paseo.app/Contents/Resources/...`.
|
||||
On Windows and Linux this means inside the installed app resources directory.
|
||||
|
||||
3. There is exactly one runtime per installed app.
|
||||
Remove versioned installed runtime directories like `runtime/<runtime-id>` from the runtime execution path.
|
||||
|
||||
4. The CLI shim may point into the installed app bundle/directory.
|
||||
Do not keep an extra installed runtime tree just to preserve a stable shim target across updates.
|
||||
|
||||
5. All mutable state remains outside the bundled runtime.
|
||||
Logs, sockets/pipes, PID files, daemon state, `PASEO_HOME`, and any other writable files must continue to live in managed home / app data locations.
|
||||
|
||||
6. Runtime discovery must stay cross-platform.
|
||||
The implementation must resolve the bundled runtime/resources path on macOS, Windows, and Linux using the app install/resources directory, not hardcoded `.app` assumptions.
|
||||
|
||||
7. Remove dead machinery, do not leave adapters behind.
|
||||
If install/copy/versioned-runtime code becomes unused, delete it instead of keeping fallback paths "just in case".
|
||||
|
||||
## Non-Goals
|
||||
|
||||
1. Do not change how the runtime is built into the desktop app bundle in this task.
|
||||
This task is about runtime execution and path management, not bundling format.
|
||||
|
||||
2. Do not reintroduce a second runtime location for migration compatibility.
|
||||
It is acceptable if older installed clients do not migrate cleanly.
|
||||
|
||||
3. Do not add feature flags, env-guarded fallback paths, or compatibility shims unless absolutely required by a real platform constraint proven in code.
|
||||
|
||||
## Concrete Implementation Direction
|
||||
|
||||
1. Treat `bundled_runtime_root(app)` plus `current-runtime.json` as the runtime source of truth.
|
||||
|
||||
2. Replace `paths.runtime_root` usage for runtime execution with the bundled runtime root selected by `current-runtime.json`.
|
||||
|
||||
3. Delete `install_runtime_if_needed(...)` and related copy/install staging logic if nothing else still needs it.
|
||||
|
||||
4. Rework CLI shim generation so the inner launcher points at the bundled runtime's Node + CLI entrypoint directly, while still keeping mutable state (`PASEO_HOME`) outside the runtime.
|
||||
|
||||
5. Simplify `ManagedPaths` and related structs if `runtime_root` and `stable_runtime_root` no longer need separate installed-runtime semantics.
|
||||
|
||||
6. Keep diagnostics/status reporting accurate.
|
||||
If the app reports bundled vs installed runtime roots today, update that output to reflect the new single-runtime model.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. No code path copies the bundled runtime tree into app data before launching the daemon or CLI.
|
||||
|
||||
2. No code path depends on a versioned installed runtime directory for execution.
|
||||
|
||||
3. The CLI shim and daemon launch path both resolve to bundled runtime executables/resources.
|
||||
|
||||
4. Typecheck passes.
|
||||
|
||||
5. Relevant desktop/runtime tests pass, updated to reflect the new direct-from-bundle model.
|
||||
|
||||
6. The resulting implementation is materially simpler:
|
||||
fewer runtime path concepts, fewer staging/install branches, fewer indirections.
|
||||
|
||||
## Review Bar
|
||||
|
||||
The implementation should be rejected if:
|
||||
|
||||
- it still copies the runtime anywhere before execution
|
||||
- it keeps runtime version directories in the execution path
|
||||
- it preserves the stable installed runtime launcher concept without a hard platform reason
|
||||
- it adds migration complexity for old installs
|
||||
- it introduces new fallback branches instead of removing obsolete ones
|
||||
12964
package-lock.json
generated
12964
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.19",
|
||||
"version": "0.1.25",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/server",
|
||||
@@ -73,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"
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"main": "index.ts",
|
||||
"version": "0.1.19",
|
||||
"version": "0.1.25",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
@@ -33,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.19",
|
||||
"@getpaseo/server": "0.1.25",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@lezer/common": "^1.5.0",
|
||||
@@ -86,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",
|
||||
@@ -95,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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -597,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) {
|
||||
@@ -614,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)
|
||||
@@ -704,7 +704,7 @@ export function AgentInputArea({
|
||||
|
||||
const rightContent = (
|
||||
<View style={styles.rightControls}>
|
||||
{!isVoiceModeForAgent ? (
|
||||
{!isVoiceModeForAgent && agent ? (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
onPress={handleToggleRealtimeVoice}
|
||||
|
||||
@@ -6,187 +6,129 @@ import {
|
||||
RefreshControl,
|
||||
FlatList,
|
||||
type ListRenderItem,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useCallback, useMemo, useState, type ReactElement } from "react";
|
||||
import { router, usePathname, type Href } from "expo-router";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { AgentStatusDot } from "@/components/agent-status-dot";
|
||||
import {
|
||||
buildAgentNavigationKey,
|
||||
startNavigationTiming,
|
||||
} from "@/utils/navigation-timing";
|
||||
import { buildHostWorkspaceAgentRoute } from "@/utils/host-routes";
|
||||
} from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useCallback, useMemo, useState, type ReactElement } from 'react'
|
||||
import { router, 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[]
|
||||
}
|
||||
|
||||
type SessionColumnKey = "session" | "project" | "host" | "status" | "updated";
|
||||
|
||||
interface SessionColumnDefinition {
|
||||
key: SessionColumnKey;
|
||||
label: string;
|
||||
flex: number;
|
||||
align?: "left" | "right";
|
||||
mobile?: boolean;
|
||||
requiresMultiHost?: boolean;
|
||||
}
|
||||
|
||||
const SESSION_COLUMNS: SessionColumnDefinition[] = [
|
||||
{ key: "session", label: "Session", flex: 2.3, mobile: true },
|
||||
{ key: "project", label: "Project", flex: 2.6 },
|
||||
{ key: "host", label: "Host", flex: 1.2, requiresMultiHost: true },
|
||||
{ key: "status", label: "Status", flex: 1.2, mobile: true },
|
||||
{ key: "updated", label: "Updated", flex: 1, align: "right", mobile: true },
|
||||
];
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
function formatStatusLabel(status: AggregatedAgent["status"]): string {
|
||||
function formatStatusLabel(status: AggregatedAgent['status']): string {
|
||||
switch (status) {
|
||||
case "initializing":
|
||||
return "Starting";
|
||||
case "idle":
|
||||
return "Idle";
|
||||
case "running":
|
||||
return "Running";
|
||||
case "error":
|
||||
return "Error";
|
||||
case "closed":
|
||||
return "Closed";
|
||||
case 'initializing':
|
||||
return 'Starting'
|
||||
case 'idle':
|
||||
return 'Idle'
|
||||
case 'running':
|
||||
return 'Running'
|
||||
case 'error':
|
||||
return 'Error'
|
||||
case 'closed':
|
||||
return 'Closed'
|
||||
default:
|
||||
return status;
|
||||
return status
|
||||
}
|
||||
}
|
||||
|
||||
function getVisibleColumns(input: {
|
||||
isMobile: boolean;
|
||||
showHostColumn: boolean;
|
||||
}): SessionColumnDefinition[] {
|
||||
return SESSION_COLUMNS.filter((column) => {
|
||||
if (!input.showHostColumn && column.requiresMultiHost) {
|
||||
return false;
|
||||
}
|
||||
if (input.isMobile && !column.mobile) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function SessionCell({
|
||||
align = "left",
|
||||
flex,
|
||||
children,
|
||||
}: {
|
||||
align?: "left" | "right";
|
||||
flex: number;
|
||||
children: ReactElement;
|
||||
}) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.cell,
|
||||
{ flex },
|
||||
align === "right" ? styles.cellRight : styles.cellLeft,
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionBadge({
|
||||
label,
|
||||
tone = "neutral",
|
||||
tone = 'neutral',
|
||||
}: {
|
||||
label: string;
|
||||
tone?: "neutral" | "warning" | "danger";
|
||||
label: string
|
||||
tone?: 'neutral' | 'warning' | 'danger'
|
||||
}) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.badge,
|
||||
tone === "warning" && styles.badgeWarning,
|
||||
tone === "danger" && styles.badgeDanger,
|
||||
tone === 'warning' && styles.badgeWarning,
|
||||
tone === 'danger' && styles.badgeDanger,
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.badgeText,
|
||||
tone === "warning" && styles.badgeTextWarning,
|
||||
tone === "danger" && styles.badgeTextDanger,
|
||||
tone === 'warning' && styles.badgeTextWarning,
|
||||
tone === 'danger' && styles.badgeTextDanger,
|
||||
]}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function SessionTableRow({
|
||||
function SessionRow({
|
||||
agent,
|
||||
columns,
|
||||
isMobile,
|
||||
selectedAgentId,
|
||||
showAttentionIndicator,
|
||||
onPress,
|
||||
onLongPress,
|
||||
}: {
|
||||
agent: AggregatedAgent;
|
||||
columns: SessionColumnDefinition[];
|
||||
isMobile: boolean;
|
||||
selectedAgentId?: string;
|
||||
onPress: (agent: AggregatedAgent) => void;
|
||||
onLongPress: (agent: AggregatedAgent) => void;
|
||||
agent: AggregatedAgent
|
||||
isMobile: boolean
|
||||
selectedAgentId?: string
|
||||
showAttentionIndicator: boolean
|
||||
onPress: (agent: AggregatedAgent) => void
|
||||
onLongPress: (agent: AggregatedAgent) => void
|
||||
}) {
|
||||
const timeAgo = formatTimeAgo(agent.lastActivityAt);
|
||||
const agentKey = `${agent.serverId}:${agent.id}`;
|
||||
const isSelected = selectedAgentId === agentKey;
|
||||
const statusLabel = formatStatusLabel(agent.status);
|
||||
const projectPath = shortenPath(agent.cwd);
|
||||
const 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
|
||||
@@ -200,163 +142,95 @@ function SessionTableRow({
|
||||
onLongPress={() => onLongPress(agent)}
|
||||
testID={`agent-row-${agent.serverId}-${agent.id}`}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<View style={styles.rowInner}>
|
||||
{columns.map((column) => {
|
||||
if (column.key === "session") {
|
||||
return (
|
||||
<SessionCell key={column.key} flex={column.flex} align={column.align}>
|
||||
<View style={styles.primaryCell}>
|
||||
<View style={styles.sessionTitleRow}>
|
||||
<Text
|
||||
style={[
|
||||
styles.sessionTitle,
|
||||
(isSelected || hovered) && 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}
|
||||
</View>
|
||||
{isMobile ? (
|
||||
<View style={styles.sessionMetaRow}>
|
||||
<Text style={styles.sessionMetaText} numberOfLines={1}>
|
||||
{projectPath}
|
||||
</Text>
|
||||
<Text style={styles.sessionMetaSeparator}>·</Text>
|
||||
<Text style={styles.sessionMetaText}>{statusLabel}</Text>
|
||||
{agent.serverLabel ? (
|
||||
<>
|
||||
<Text style={styles.sessionMetaSeparator}>·</Text>
|
||||
<Text style={styles.sessionMetaText} numberOfLines={1}>
|
||||
{agent.serverLabel}
|
||||
</Text>
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.secondaryBadgeRow}>
|
||||
{agent.requiresAttention ? (
|
||||
<SessionBadge label="Attention" tone="danger" />
|
||||
) : null}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</SessionCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (column.key === "project") {
|
||||
return (
|
||||
<SessionCell key={column.key} flex={column.flex} align={column.align}>
|
||||
<View style={styles.projectCell}>
|
||||
<Text style={styles.projectPath} numberOfLines={1}>
|
||||
{projectPath}
|
||||
</Text>
|
||||
<Text style={styles.projectProvider} numberOfLines={1}>
|
||||
{agent.provider}
|
||||
</Text>
|
||||
</View>
|
||||
</SessionCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (column.key === "host") {
|
||||
return (
|
||||
<SessionCell key={column.key} flex={column.flex} align={column.align}>
|
||||
<Text style={styles.hostText} numberOfLines={1}>
|
||||
{agent.serverLabel}
|
||||
</Text>
|
||||
</SessionCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (column.key === "status") {
|
||||
return (
|
||||
<SessionCell key={column.key} flex={column.flex} align={column.align}>
|
||||
<View style={styles.statusCell}>
|
||||
<AgentStatusDot
|
||||
status={agent.status}
|
||||
requiresAttention={agent.requiresAttention}
|
||||
/>
|
||||
<Text style={styles.statusText} numberOfLines={1}>
|
||||
{statusLabel}
|
||||
</Text>
|
||||
</View>
|
||||
</SessionCell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SessionCell key={column.key} flex={column.flex} align={column.align}>
|
||||
<Text style={styles.updatedText} numberOfLines={1}>
|
||||
{timeAgo}
|
||||
</Text>
|
||||
</SessionCell>
|
||||
);
|
||||
})}
|
||||
<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,
|
||||
columns,
|
||||
isMobile,
|
||||
selectedAgentId,
|
||||
showAttentionIndicator,
|
||||
onAgentPress,
|
||||
onAgentLongPress,
|
||||
}: {
|
||||
section: AgentListSection;
|
||||
columns: SessionColumnDefinition[];
|
||||
isMobile: boolean;
|
||||
selectedAgentId?: string;
|
||||
onAgentPress: (agent: AggregatedAgent) => void;
|
||||
onAgentLongPress: (agent: AggregatedAgent) => void;
|
||||
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 style={styles.sectionLine} />
|
||||
</View>
|
||||
|
||||
<View style={styles.tableCard}>
|
||||
<View style={styles.tableHeader}>
|
||||
{columns.map((column) => (
|
||||
<SessionCell key={column.key} flex={column.flex} align={column.align}>
|
||||
<Text
|
||||
style={[
|
||||
styles.columnLabel,
|
||||
column.align === "right" && styles.columnLabelRight,
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{column.label}
|
||||
</Text>
|
||||
</SessionCell>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={styles.listCard}>
|
||||
{section.data.map((agent, index) => (
|
||||
<View
|
||||
key={`${agent.serverId}:${agent.id}`}
|
||||
style={index > 0 ? styles.rowDivider : undefined}
|
||||
>
|
||||
<SessionTableRow
|
||||
<SessionRow
|
||||
agent={agent}
|
||||
columns={columns}
|
||||
isMobile={isMobile}
|
||||
selectedAgentId={selectedAgentId}
|
||||
showAttentionIndicator={showAttentionIndicator}
|
||||
onPress={onAgentPress}
|
||||
onLongPress={onAgentLongPress}
|
||||
/>
|
||||
@@ -364,7 +238,7 @@ function SessionTableSection({
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export function AgentList({
|
||||
@@ -374,111 +248,99 @@ export function AgentList({
|
||||
selectedAgentId,
|
||||
onAgentSelect,
|
||||
listFooterComponent,
|
||||
showAttentionIndicator = true,
|
||||
}: AgentListProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const pathname = usePathname();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null);
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const { theme } = useUnistyles()
|
||||
const 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 showHostColumn = useMemo(
|
||||
() => new Set(agents.map((agent) => agent.serverId)).size > 1,
|
||||
[agents]
|
||||
);
|
||||
const columns = useMemo(
|
||||
() => getVisibleColumns({ isMobile, showHostColumn }),
|
||||
[isMobile, showHostColumn]
|
||||
);
|
||||
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]);
|
||||
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 renderSection: ListRenderItem<AgentListSection> = useCallback(
|
||||
({ item: section }) => (
|
||||
<SessionTableSection
|
||||
section={section}
|
||||
columns={columns}
|
||||
isMobile={isMobile}
|
||||
selectedAgentId={selectedAgentId}
|
||||
showAttentionIndicator={showAttentionIndicator}
|
||||
onAgentPress={handleAgentPress}
|
||||
onAgentLongPress={handleAgentLongPress}
|
||||
/>
|
||||
),
|
||||
[columns, handleAgentLongPress, handleAgentPress, isMobile, selectedAgentId]
|
||||
);
|
||||
[handleAgentLongPress, handleAgentPress, isMobile, selectedAgentId, showAttentionIndicator]
|
||||
)
|
||||
|
||||
const keyExtractor = useCallback((section: AgentListSection) => section.key, []);
|
||||
const keyExtractor = useCallback((section: AgentListSection) => section.key, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -510,10 +372,7 @@ export function AgentList({
|
||||
onRequestClose={handleCloseActionSheet}
|
||||
>
|
||||
<View style={styles.sheetOverlay}>
|
||||
<Pressable
|
||||
style={styles.sheetBackdrop}
|
||||
onPress={handleCloseActionSheet}
|
||||
/>
|
||||
<Pressable style={styles.sheetBackdrop} onPress={handleCloseActionSheet} />
|
||||
<View
|
||||
style={[
|
||||
styles.sheetContainer,
|
||||
@@ -522,7 +381,7 @@ export function AgentList({
|
||||
>
|
||||
<View style={styles.sheetHandle} />
|
||||
<Text style={styles.sheetTitle}>
|
||||
{isActionDaemonUnavailable ? "Host offline" : "Archive this session?"}
|
||||
{isActionDaemonUnavailable ? 'Host offline' : 'Archive this session?'}
|
||||
</Text>
|
||||
<View style={styles.sheetButtonRow}>
|
||||
<Pressable
|
||||
@@ -552,7 +411,7 @@ export function AgentList({
|
||||
</View>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
@@ -573,118 +432,92 @@ const styles = StyleSheet.create((theme) => ({
|
||||
marginTop: theme.spacing[2],
|
||||
},
|
||||
sectionHeading: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
marginBottom: theme.spacing[2],
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: "600",
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foregroundMuted,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.6,
|
||||
},
|
||||
sectionLine: {
|
||||
flex: 1,
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
tableCard: {
|
||||
overflow: "hidden",
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: theme.colors.surface2,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
tableHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: {
|
||||
xs: theme.spacing[3],
|
||||
md: theme.spacing[4],
|
||||
listCard: {
|
||||
overflow: {
|
||||
xs: 'hidden' as const,
|
||||
md: 'visible' as const,
|
||||
},
|
||||
borderRadius: {
|
||||
xs: theme.borderRadius.lg,
|
||||
md: 0,
|
||||
},
|
||||
paddingVertical: theme.spacing[2],
|
||||
backgroundColor: theme.colors.surface0,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: theme.colors.surface2,
|
||||
},
|
||||
columnLabel: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: "600",
|
||||
color: theme.colors.foregroundMuted,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.6,
|
||||
},
|
||||
columnLabelRight: {
|
||||
textAlign: "right",
|
||||
},
|
||||
rowDivider: {
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: theme.colors.surface2,
|
||||
borderTopWidth: {
|
||||
xs: StyleSheet.hairlineWidth,
|
||||
md: 0,
|
||||
},
|
||||
borderTopColor: theme.colors.border,
|
||||
},
|
||||
row: {
|
||||
paddingHorizontal: {
|
||||
xs: theme.spacing[3],
|
||||
md: theme.spacing[4],
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: {
|
||||
xs: theme.borderRadius.lg,
|
||||
md: 0,
|
||||
},
|
||||
paddingVertical: {
|
||||
xs: theme.spacing[2],
|
||||
md: theme.spacing[3],
|
||||
marginBottom: {
|
||||
xs: theme.spacing[1],
|
||||
md: 0,
|
||||
},
|
||||
},
|
||||
rowInner: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
rowLeading: {
|
||||
marginRight: theme.spacing[3],
|
||||
},
|
||||
rowContent: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
rowTitleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
rowMetaRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: theme.spacing[1],
|
||||
marginTop: 2,
|
||||
},
|
||||
rowTrailing: {
|
||||
marginLeft: theme.spacing[2],
|
||||
},
|
||||
rowSelected: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
rowHovered: {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
rowPressed: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
cell: {
|
||||
minWidth: 0,
|
||||
},
|
||||
cellLeft: {
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
cellRight: {
|
||||
alignItems: "flex-end",
|
||||
},
|
||||
primaryCell: {
|
||||
width: "100%",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
sessionTitleRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
sessionTitle: {
|
||||
flexShrink: 1,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: "500",
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: '500',
|
||||
color: theme.colors.foreground,
|
||||
opacity: 0.86,
|
||||
},
|
||||
sessionTitleHighlighted: {
|
||||
opacity: 1,
|
||||
},
|
||||
sessionMetaRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
sessionMetaText: {
|
||||
maxWidth: "100%",
|
||||
maxWidth: '100%',
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
@@ -693,41 +526,20 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
opacity: 0.7,
|
||||
},
|
||||
secondaryBadgeRow: {
|
||||
minHeight: theme.spacing[6],
|
||||
justifyContent: "center",
|
||||
},
|
||||
projectCell: {
|
||||
width: "100%",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
projectPath: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
projectProvider: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.6,
|
||||
},
|
||||
hostText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
statusCell: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
statusText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
updatedText: {
|
||||
columnMeta: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
textAlign: "right",
|
||||
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],
|
||||
@@ -736,17 +548,15 @@ const styles = StyleSheet.create((theme) => ({
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
badgeWarning: {
|
||||
backgroundColor: "rgba(245, 158, 11, 0.12)",
|
||||
backgroundColor: 'rgba(245, 158, 11, 0.12)',
|
||||
},
|
||||
badgeDanger: {
|
||||
backgroundColor: "rgba(239, 68, 68, 0.14)",
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.14)',
|
||||
},
|
||||
badgeText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: "600",
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foregroundMuted,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.4,
|
||||
},
|
||||
badgeTextWarning: {
|
||||
color: theme.colors.palette.amber[500],
|
||||
@@ -756,26 +566,26 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
sheetOverlay: {
|
||||
flex: 1,
|
||||
justifyContent: "flex-end",
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
sheetBackdrop: {
|
||||
position: "absolute",
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
backgroundColor: "rgba(0,0,0,0.35)",
|
||||
backgroundColor: 'rgba(0,0,0,0.35)',
|
||||
},
|
||||
sheetContainer: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderTopLeftRadius: theme.borderRadius["2xl"],
|
||||
borderTopRightRadius: theme.borderRadius["2xl"],
|
||||
borderTopLeftRadius: theme.borderRadius['2xl'],
|
||||
borderTopRightRadius: theme.borderRadius['2xl'],
|
||||
paddingHorizontal: theme.spacing[6],
|
||||
paddingTop: theme.spacing[4],
|
||||
gap: theme.spacing[4],
|
||||
},
|
||||
sheetHandle: {
|
||||
alignSelf: "center",
|
||||
alignSelf: 'center',
|
||||
width: 40,
|
||||
height: 4,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
@@ -786,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,
|
||||
@@ -818,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,
|
||||
};
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./stream-strategy";
|
||||
export * from "./agent-stream-render-model";
|
||||
|
||||
@@ -53,13 +53,12 @@ import { ToolCallDetailsContent } from "./tool-call-details";
|
||||
import { QuestionFormCard } from "./question-form-card";
|
||||
import { ToolCallSheetProvider } from "./tool-call-sheet";
|
||||
import {
|
||||
buildAgentStreamRenderModel,
|
||||
collectAssistantTurnContentForStreamRenderStrategy,
|
||||
getStreamEdgeSlotProps,
|
||||
getStreamNeighborItem,
|
||||
orderHeadForStreamRenderStrategy,
|
||||
orderTailForStreamRenderStrategy,
|
||||
resolveStreamRenderStrategy,
|
||||
type StreamEdgeSlotProps,
|
||||
type AgentStreamRenderModel,
|
||||
type StreamSegmentRenderers,
|
||||
type StreamViewportHandle,
|
||||
} from "./agent-stream-render-strategy";
|
||||
import {
|
||||
@@ -199,12 +198,14 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
]
|
||||
);
|
||||
|
||||
const orderedStreamItems = useMemo(() => {
|
||||
return orderTailForStreamRenderStrategy({
|
||||
strategy: streamRenderStrategy,
|
||||
streamItems,
|
||||
const baseRenderModel = useMemo(() => {
|
||||
return buildAgentStreamRenderModel({
|
||||
tail: streamItems,
|
||||
head: streamHead ?? [],
|
||||
platform: Platform.OS === "web" ? "web" : "native",
|
||||
isMobileBreakpoint: isMobile,
|
||||
});
|
||||
}, [streamItems, streamRenderStrategy]);
|
||||
}, [isMobile, streamHead, streamItems]);
|
||||
useImperativeHandle(ref, () => ({
|
||||
scrollToBottom(reason = "jump-to-bottom") {
|
||||
viewportRef.current?.scrollToBottom(reason);
|
||||
@@ -218,68 +219,48 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
viewportRef.current?.scrollToBottom("jump-to-bottom");
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -299,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,
|
||||
@@ -426,19 +408,24 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
);
|
||||
|
||||
const renderStreamItem = useCallback(
|
||||
(item: StreamItem, index: number, items: StreamItem[]) => {
|
||||
const content = renderStreamItemContent(item, index, items);
|
||||
(
|
||||
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, items);
|
||||
const nextItem = getStreamNeighborItem({
|
||||
strategy: streamRenderStrategy,
|
||||
items,
|
||||
index,
|
||||
relation: "below",
|
||||
});
|
||||
const gapBelow = getGapBetween(item, nextItem ?? null);
|
||||
const isEndOfAssistantTurn =
|
||||
item.kind === "assistant_message" &&
|
||||
(nextItem?.kind === "user_message" ||
|
||||
@@ -460,7 +447,7 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
);
|
||||
},
|
||||
[
|
||||
getGapBelow,
|
||||
getGapBetween,
|
||||
renderStreamItemContent,
|
||||
agent.status,
|
||||
streamRenderStrategy,
|
||||
@@ -544,101 +531,58 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
}, [agentId, pendingPermissionItems.length, streamHead, streamItems]);
|
||||
|
||||
const showWorkingIndicator = agent.status === "running";
|
||||
const showBottomBar = showWorkingIndicator;
|
||||
|
||||
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 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}>
|
||||
@@ -646,12 +590,86 @@ 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() ||
|
||||
@@ -663,8 +681,9 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
<MessageOuterSpacingProvider disableOuterSpacing>
|
||||
{streamRenderStrategy.render({
|
||||
agentId,
|
||||
rows: orderedStreamItems,
|
||||
renderRow: renderStreamItem,
|
||||
segments: renderModel.segments,
|
||||
boundary,
|
||||
renderers,
|
||||
listEmptyComponent,
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest,
|
||||
@@ -674,7 +693,6 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
listStyle: stylesheet.list,
|
||||
baseListContentContainerStyle: stylesheet.listContentContainer,
|
||||
forwardListContentContainerStyle: stylesheet.forwardListContentContainer,
|
||||
edgeSlotProps: listEdgeSlotProps,
|
||||
})}
|
||||
</MessageOuterSpacingProvider>
|
||||
{!isNearBottom && (
|
||||
|
||||
@@ -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" && (
|
||||
|
||||
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}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -339,7 +339,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
||||
hovered && styles.newAgentButtonTextHovered,
|
||||
]}
|
||||
>
|
||||
Open project
|
||||
Add project
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
@@ -463,7 +463,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
||||
<Text
|
||||
style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}
|
||||
>
|
||||
Open project
|
||||
Add project
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -101,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 & {
|
||||
@@ -112,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) {
|
||||
@@ -164,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
|
||||
)
|
||||
@@ -509,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
|
||||
@@ -562,36 +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
|
||||
@@ -600,25 +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 })
|
||||
}
|
||||
|
||||
@@ -674,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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -155,7 +155,7 @@ export function ProjectPickerModal() {
|
||||
|
||||
// Keyboard navigation
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (!open || Platform.OS !== "web") return;
|
||||
|
||||
function handler(event: KeyboardEvent) {
|
||||
const key = event.key;
|
||||
@@ -200,7 +200,6 @@ export function ProjectPickerModal() {
|
||||
return () => window.removeEventListener("keydown", handler, true);
|
||||
}, [activeIndex, handleSelectPath, handleSubmitCustom, open, options, query, setOpen]);
|
||||
|
||||
if (Platform.OS !== "web") return null;
|
||||
if (!serverId) return null;
|
||||
|
||||
return (
|
||||
|
||||
@@ -23,7 +23,8 @@ import {
|
||||
import { router, usePathname } from 'expo-router'
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
|
||||
import { type GestureType } from 'react-native-gesture-handler'
|
||||
import { ChevronDown, ChevronRight, Plus } from 'lucide-react-native'
|
||||
import * as Clipboard from 'expo-clipboard'
|
||||
import { Archive, ChevronDown, ChevronRight, Copy, MoreVertical, Plus } from 'lucide-react-native'
|
||||
import { NestableScrollContainer } from 'react-native-draggable-flatlist'
|
||||
import { DraggableList, type DraggableRenderItemInfo } from './draggable-list'
|
||||
import type { DraggableListDragHandleProps } from './draggable-list.types'
|
||||
@@ -42,7 +43,6 @@ import {
|
||||
} from '@/hooks/use-sidebar-workspaces-list'
|
||||
import { useSidebarOrderStore } from '@/stores/sidebar-order-store'
|
||||
import { useKeyboardShortcutsStore } from '@/stores/keyboard-shortcuts-store'
|
||||
import { formatTimeAgo } from '@/utils/time'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
@@ -50,6 +50,12 @@ import {
|
||||
ContextMenuTrigger,
|
||||
useContextMenu,
|
||||
} from '@/components/ui/context-menu'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { SyncedLoader } from '@/components/synced-loader'
|
||||
import { useToast } from '@/contexts/toast-context'
|
||||
import { useCheckoutGitActionsStore } from '@/stores/checkout-git-actions-store'
|
||||
@@ -112,14 +118,13 @@ interface WorkspaceRowInnerProps {
|
||||
isArchiving: boolean
|
||||
dragHandleProps?: DraggableListDragHandleProps
|
||||
menuController: ReturnType<typeof useContextMenu> | null
|
||||
archiveLabel?: string
|
||||
archiveStatus?: 'idle' | 'pending' | 'success'
|
||||
archivePendingLabel?: string
|
||||
onArchive?: () => void
|
||||
onCopyPath?: () => void
|
||||
}
|
||||
|
||||
function resolveWorkspaceCreatedAtLabel(workspace: SidebarWorkspaceEntry): string | null {
|
||||
if (!workspace.activityAt) {
|
||||
return null
|
||||
}
|
||||
return formatTimeAgo(workspace.activityAt)
|
||||
}
|
||||
|
||||
function resolveStatusDotColor(input: {
|
||||
theme: ReturnType<typeof useUnistyles>['theme']
|
||||
@@ -601,8 +606,15 @@ function WorkspaceRowInner({
|
||||
isArchiving,
|
||||
dragHandleProps,
|
||||
menuController,
|
||||
archiveLabel,
|
||||
archiveStatus = 'idle',
|
||||
archivePendingLabel,
|
||||
onArchive,
|
||||
onCopyPath,
|
||||
}: WorkspaceRowInnerProps) {
|
||||
const createdAtLabel = resolveWorkspaceCreatedAtLabel(workspace)
|
||||
const { theme } = useUnistyles()
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const isMobile = Platform.OS !== 'web'
|
||||
const interaction = useLongPressDragInteraction({
|
||||
drag,
|
||||
menuController,
|
||||
@@ -617,83 +629,100 @@ function WorkspaceRowInner({
|
||||
onPress()
|
||||
}, [interaction.didLongPressRef, onPress])
|
||||
|
||||
const rowChildren = (
|
||||
<>
|
||||
<View
|
||||
{...(dragHandleProps?.attributes as any)}
|
||||
{...(dragHandleProps?.listeners as any)}
|
||||
ref={dragHandleProps?.setActivatorNodeRef as any}
|
||||
style={styles.workspaceRowLeft}
|
||||
>
|
||||
<WorkspaceStatusIndicator bucket={workspace.statusBucket} loading={isArchiving} />
|
||||
<Text style={styles.workspaceBranchText} numberOfLines={1}>
|
||||
{workspace.name}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.workspaceRowRight}>
|
||||
{createdAtLabel ? (
|
||||
<Text style={styles.workspaceCreatedAtText} numberOfLines={1}>
|
||||
{createdAtLabel}
|
||||
</Text>
|
||||
) : null}
|
||||
{showShortcutBadge && shortcutNumber !== null ? (
|
||||
<View style={styles.shortcutBadge}>
|
||||
<Text style={styles.shortcutBadgeText}>{shortcutNumber}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
|
||||
const trigger = menuController ? (
|
||||
<ContextMenuTrigger
|
||||
enabledOnMobile={false}
|
||||
disabled={isArchiving}
|
||||
style={({ pressed, hovered = false }) => [
|
||||
styles.workspaceRow,
|
||||
isDragging && styles.workspaceRowDragging,
|
||||
selected && styles.sidebarRowSelected,
|
||||
hovered && styles.workspaceRowHovered,
|
||||
pressed && styles.workspaceRowPressed,
|
||||
]}
|
||||
onPressIn={interaction.handlePressIn}
|
||||
onTouchMove={interaction.handleTouchMove}
|
||||
onPressOut={interaction.handlePressOut}
|
||||
onPress={handlePress}
|
||||
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
||||
>
|
||||
{rowChildren}
|
||||
</ContextMenuTrigger>
|
||||
) : (
|
||||
<Pressable
|
||||
disabled={isArchiving}
|
||||
style={({ pressed, hovered = false }) => [
|
||||
styles.workspaceRow,
|
||||
isDragging && styles.workspaceRowDragging,
|
||||
selected && styles.sidebarRowSelected,
|
||||
hovered && styles.workspaceRowHovered,
|
||||
pressed && styles.workspaceRowPressed,
|
||||
]}
|
||||
onPressIn={interaction.handlePressIn}
|
||||
onTouchMove={interaction.handleTouchMove}
|
||||
onPressOut={interaction.handlePressOut}
|
||||
onPress={handlePress}
|
||||
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
||||
>
|
||||
{rowChildren}
|
||||
</Pressable>
|
||||
)
|
||||
|
||||
const content = trigger
|
||||
|
||||
return (
|
||||
<View style={styles.workspaceRowContainer}>
|
||||
{content}
|
||||
<View
|
||||
style={styles.workspaceRowContainer}
|
||||
onPointerEnter={() => setIsHovered(true)}
|
||||
onPointerLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<Pressable
|
||||
disabled={isArchiving}
|
||||
style={({ pressed }) => [
|
||||
styles.workspaceRow,
|
||||
isDragging && styles.workspaceRowDragging,
|
||||
selected && styles.sidebarRowSelected,
|
||||
isHovered && styles.workspaceRowHovered,
|
||||
pressed && styles.workspaceRowPressed,
|
||||
]}
|
||||
onPressIn={interaction.handlePressIn}
|
||||
onTouchMove={interaction.handleTouchMove}
|
||||
onPressOut={interaction.handlePressOut}
|
||||
onPress={handlePress}
|
||||
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
||||
>
|
||||
<View
|
||||
{...(dragHandleProps?.attributes as any)}
|
||||
{...(dragHandleProps?.listeners as any)}
|
||||
ref={dragHandleProps?.setActivatorNodeRef as any}
|
||||
style={styles.workspaceRowLeft}
|
||||
>
|
||||
<WorkspaceStatusIndicator bucket={workspace.statusBucket} loading={isArchiving} />
|
||||
<Text
|
||||
style={[styles.workspaceBranchText, isHovered && styles.workspaceBranchTextHovered]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{workspace.name}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.workspaceRowRight}>
|
||||
{onArchive && (isHovered || isMobile) ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
hitSlop={8}
|
||||
style={({ hovered = false }) => [
|
||||
styles.kebabButton,
|
||||
hovered && styles.kebabButtonHovered,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Workspace actions"
|
||||
testID={`sidebar-workspace-kebab-${workspace.workspaceKey}`}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<MoreVertical
|
||||
size={14}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" width={200}>
|
||||
{onCopyPath ? (
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-copy-path-${workspace.workspaceKey}`}
|
||||
leading={<Copy size={14} color={theme.colors.foregroundMuted} />}
|
||||
onSelect={onCopyPath}
|
||||
>
|
||||
Copy path
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-archive-${workspace.workspaceKey}`}
|
||||
leading={<Archive size={14} color={theme.colors.foregroundMuted} />}
|
||||
status={archiveStatus}
|
||||
pendingLabel={archivePendingLabel}
|
||||
onSelect={onArchive}
|
||||
>
|
||||
{archiveLabel ?? 'Archive'}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : workspace.diffStat ? (
|
||||
<View style={styles.diffStatRow}>
|
||||
<Text style={styles.diffStatAdditions}>+{workspace.diffStat.additions}</Text>
|
||||
<Text style={styles.diffStatDeletions}>-{workspace.diffStat.deletions}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{showShortcutBadge && shortcutNumber !== null ? (
|
||||
<View style={styles.shortcutBadge}>
|
||||
<Text style={styles.shortcutBadgeText}>{shortcutNumber}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function WorkspaceRowWithMenuContent({
|
||||
function WorkspaceRowWithMenu({
|
||||
workspace,
|
||||
selected,
|
||||
shortcutNumber,
|
||||
@@ -713,7 +742,6 @@ function WorkspaceRowWithMenuContent({
|
||||
dragHandleProps?: DraggableListDragHandleProps
|
||||
}) {
|
||||
const toast = useToast()
|
||||
const contextMenu = useContextMenu()
|
||||
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree)
|
||||
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false)
|
||||
const archiveStatus = useCheckoutGitActionsStore((state) =>
|
||||
@@ -792,72 +820,29 @@ function WorkspaceRowWithMenuContent({
|
||||
})()
|
||||
}, [isArchivingWorkspace, toast, workspace.name, workspace.serverId, workspace.workspaceId])
|
||||
|
||||
return (
|
||||
<>
|
||||
<WorkspaceRowInner
|
||||
workspace={workspace}
|
||||
selected={selected}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
onPress={onPress}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
isArchiving={isArchiving}
|
||||
dragHandleProps={dragHandleProps}
|
||||
menuController={contextMenu}
|
||||
/>
|
||||
<ContextMenuContent
|
||||
align="start"
|
||||
width={220}
|
||||
mobileMode="sheet"
|
||||
testID={`sidebar-workspace-context-${workspace.workspaceKey}`}
|
||||
>
|
||||
<ContextMenuItem
|
||||
testID={`sidebar-workspace-context-${workspace.workspaceKey}-archive`}
|
||||
status={isWorktree ? archiveStatus : isArchivingWorkspace ? 'pending' : 'idle'}
|
||||
pendingLabel={isWorktree ? 'Archiving...' : 'Hiding...'}
|
||||
destructive
|
||||
onSelect={isWorktree ? handleArchiveWorktree : handleArchiveWorkspace}
|
||||
>
|
||||
{isWorktree ? 'Archive worktree' : 'Hide from sidebar'}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</>
|
||||
)
|
||||
}
|
||||
const handleCopyPath = useCallback(() => {
|
||||
void Clipboard.setStringAsync(workspace.workspaceId)
|
||||
toast.copied('Path copied')
|
||||
}, [toast, workspace.workspaceId])
|
||||
|
||||
function WorkspaceRowWithMenu({
|
||||
workspace,
|
||||
selected,
|
||||
shortcutNumber,
|
||||
showShortcutBadge,
|
||||
onPress,
|
||||
drag,
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry
|
||||
selected: boolean
|
||||
shortcutNumber: number | null
|
||||
showShortcutBadge: boolean
|
||||
onPress: () => void
|
||||
drag: () => void
|
||||
isDragging: boolean
|
||||
dragHandleProps?: DraggableListDragHandleProps
|
||||
}) {
|
||||
return (
|
||||
<ContextMenu>
|
||||
<WorkspaceRowWithMenuContent
|
||||
workspace={workspace}
|
||||
selected={selected}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
onPress={onPress}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
</ContextMenu>
|
||||
<WorkspaceRowInner
|
||||
workspace={workspace}
|
||||
selected={selected}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
onPress={onPress}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
isArchiving={isArchiving}
|
||||
dragHandleProps={dragHandleProps}
|
||||
menuController={null}
|
||||
archiveLabel={isWorktree ? 'Archive worktree' : 'Hide from sidebar'}
|
||||
archiveStatus={isWorktree ? archiveStatus : isArchivingWorkspace ? 'pending' : 'idle'}
|
||||
archivePendingLabel={isWorktree ? 'Archiving...' : 'Hiding...'}
|
||||
onArchive={isWorktree ? handleArchiveWorktree : handleArchiveWorkspace}
|
||||
onCopyPath={handleCopyPath}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1746,26 +1731,44 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
workspaceCreatedAtText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
workspaceBranchTextHovered: {
|
||||
opacity: 1,
|
||||
},
|
||||
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],
|
||||
},
|
||||
kebabButton: {
|
||||
padding: 2,
|
||||
borderRadius: 4,
|
||||
marginLeft: 2,
|
||||
},
|
||||
kebabButtonHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
shortcutBadge: {
|
||||
minWidth: 18,
|
||||
height: 18,
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.full,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
shortcutBadgeText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: '600',
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
lineHeight: 14,
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -8,9 +8,7 @@ import {
|
||||
type NativeSyntheticEvent,
|
||||
} from "react-native";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import {
|
||||
useBottomAnchorController,
|
||||
} from "./use-bottom-anchor-controller";
|
||||
import { useBottomAnchorController } from "./use-bottom-anchor-controller";
|
||||
import type {
|
||||
StreamRenderInput,
|
||||
StreamStrategy,
|
||||
@@ -30,8 +28,9 @@ const DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION = Object.freeze({
|
||||
function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrategy }) {
|
||||
const {
|
||||
agentId,
|
||||
rows,
|
||||
renderRow,
|
||||
segments,
|
||||
boundary,
|
||||
renderers,
|
||||
listEmptyComponent,
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest,
|
||||
@@ -40,7 +39,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
scrollEnabled,
|
||||
listStyle,
|
||||
baseListContentContainerStyle,
|
||||
edgeSlotProps,
|
||||
strategy,
|
||||
} = props;
|
||||
const flatListRef = useRef<FlatList<StreamItem>>(null);
|
||||
@@ -58,6 +56,13 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
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);
|
||||
@@ -90,19 +95,22 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
[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 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,
|
||||
@@ -163,7 +171,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
|
||||
useEffect(() => {
|
||||
bottomAnchorController.prepareForStickyContentChange();
|
||||
}, [bottomAnchorController, rows]);
|
||||
}, [bottomAnchorController, historyRows, segments.liveHead]);
|
||||
|
||||
useEffect(() => {
|
||||
const handle: StreamViewportHandle = {
|
||||
@@ -272,30 +280,50 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item, index }: ListRenderItemInfo<StreamItem>) => {
|
||||
const rendered = renderRow(item, index, rows);
|
||||
const rendered = renderers.renderHistoryMountedRow(item, index, historyRows);
|
||||
return rendered ? <Fragment>{rendered}</Fragment> : null;
|
||||
},
|
||||
[renderRow, rows]
|
||||
[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={rows}
|
||||
data={historyRows}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={(item) => item.id}
|
||||
testID="agent-chat-scroll"
|
||||
nativeID="agent-chat-scroll-native-virtualized"
|
||||
{...edgeSlotProps}
|
||||
ListHeaderComponent={liveHeaderContent ? () => liveHeaderContent : undefined}
|
||||
contentContainerStyle={baseListContentContainerStyle}
|
||||
style={listStyle}
|
||||
onLayout={handleListLayout}
|
||||
onScroll={handleScroll}
|
||||
scrollEventThrottle={16}
|
||||
onContentSizeChange={handleContentSizeChange}
|
||||
ListEmptyComponent={
|
||||
listEmptyComponent ? () => <Fragment>{listEmptyComponent}</Fragment> : undefined
|
||||
}
|
||||
maintainVisibleContentPosition={DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION}
|
||||
initialNumToRender={12}
|
||||
windowSize={10}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import {
|
||||
Fragment,
|
||||
type CSSProperties,
|
||||
createElement,
|
||||
isValidElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
@@ -11,13 +9,7 @@ import {
|
||||
useState,
|
||||
} from 'react'
|
||||
import { measureElement as measureVirtualElement, useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { View } from 'react-native'
|
||||
import {
|
||||
estimateStreamItemHeight,
|
||||
getWebMountedRecentStreamItems,
|
||||
getWebPartialVirtualizationThreshold,
|
||||
splitWebVirtualizedHistory,
|
||||
} from './agent-stream-web-virtualization'
|
||||
import { estimateStreamItemHeight } from './agent-stream-web-virtualization'
|
||||
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from './stream-strategy'
|
||||
import { createStreamStrategy } from './stream-strategy'
|
||||
|
||||
@@ -32,6 +24,7 @@ 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 {
|
||||
@@ -47,6 +40,20 @@ const WEB_STREAM_SCROLLBAR_STYLE = `
|
||||
}
|
||||
`
|
||||
|
||||
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
|
||||
@@ -68,28 +75,6 @@ function isScrollContainerAtBottom(
|
||||
return isScrollContainerNearBottom(scrollContainer, AUTO_SCROLL_RESUME_THRESHOLD_PX)
|
||||
}
|
||||
|
||||
function renderEdge(
|
||||
content: StreamRenderInput['edgeSlotProps']['ListHeaderComponent'],
|
||||
style?: CSSProperties
|
||||
) {
|
||||
if (!content) {
|
||||
return null
|
||||
}
|
||||
const rendered = isValidElement(content) ? content : createElement(content)
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '100%',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{rendered}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function scrollElementToBottom(
|
||||
scrollContainer: HTMLElement,
|
||||
behavior: ScrollBehaviorLike = 'auto'
|
||||
@@ -127,18 +112,15 @@ function isScrollContainerOverscrolledPastBottom(
|
||||
|
||||
function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: boolean }) {
|
||||
const {
|
||||
rows,
|
||||
renderRow,
|
||||
segments,
|
||||
boundary,
|
||||
renderers,
|
||||
listEmptyComponent,
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest,
|
||||
isAuthoritativeHistoryReady,
|
||||
onNearBottomChange,
|
||||
scrollEnabled,
|
||||
listStyle,
|
||||
baseListContentContainerStyle,
|
||||
forwardListContentContainerStyle,
|
||||
edgeSlotProps,
|
||||
isMobileBreakpoint,
|
||||
} = props
|
||||
const { WebDesktopScrollbarOverlay, useWebDesktopScrollbarMetrics } =
|
||||
@@ -147,12 +129,18 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const contentRef = useRef<HTMLElement | null>(null)
|
||||
const [followOutput, setFollowOutputr] = useState(true)
|
||||
const setFollowOutput = (value: boolean) => {
|
||||
console.trace('setFollowOutput', value)
|
||||
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)
|
||||
@@ -160,32 +148,25 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const pendingAutoScrollTimeoutRef = useRef<number | null>(null)
|
||||
const streamScrollbarMetrics = useWebDesktopScrollbarMetrics()
|
||||
const showDesktopWebScrollbar = !isMobileBreakpoint
|
||||
const shouldUseVirtualizer =
|
||||
!isMobileBreakpoint && rows.length > getWebPartialVirtualizationThreshold()
|
||||
const shouldUseVirtualizer = segments.historyVirtualized.length > 0
|
||||
const {
|
||||
renderHistoryVirtualizedRow,
|
||||
renderHistoryMountedRow,
|
||||
renderLiveHeadRow,
|
||||
renderLiveAuxiliary,
|
||||
} = renderers
|
||||
|
||||
followOutputRef.current = followOutput
|
||||
|
||||
const indexedRows = useMemo(() => rows.map((item, index) => ({ item, index })), [rows])
|
||||
const webVirtualizedHistoryWindow = useMemo(() => {
|
||||
if (!shouldUseVirtualizer) {
|
||||
return null
|
||||
}
|
||||
return splitWebVirtualizedHistory({
|
||||
entries: indexedRows,
|
||||
minMountedCount: getWebMountedRecentStreamItems(),
|
||||
})
|
||||
}, [indexedRows, shouldUseVirtualizer])
|
||||
const virtualizedEntries = webVirtualizedHistoryWindow?.virtualizedEntries ?? []
|
||||
const mountedEntries = webVirtualizedHistoryWindow?.mountedEntries ?? indexedRows
|
||||
const activationKey = routeBottomAnchorRequest?.requestKey ?? props.agentId
|
||||
const isActivationReady = routeBottomAnchorRequest === null || isAuthoritativeHistoryReady
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: virtualizedEntries.length,
|
||||
count: segments.historyVirtualized.length,
|
||||
getScrollElement: () => scrollContainerRef.current,
|
||||
getItemKey: (index: number) => virtualizedEntries[index]?.item.id ?? index,
|
||||
getItemKey: (index: number) => segments.historyVirtualized[index]?.id ?? index,
|
||||
estimateSize: (index: number) => {
|
||||
const row = virtualizedEntries[index]?.item
|
||||
const row = segments.historyVirtualized[index]
|
||||
return row ? estimateStreamItemHeight(row) : 120
|
||||
},
|
||||
measureElement: measureVirtualElement,
|
||||
@@ -197,6 +178,17 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
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 () => {
|
||||
@@ -220,7 +212,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}, [])
|
||||
|
||||
const scrollMessagesToBottom = useCallback(
|
||||
(behavior: ScrollBehaviorLike = 'auto', source = 'unknown') => {
|
||||
(behavior: ScrollBehaviorLike = 'auto') => {
|
||||
const scrollContainer = scrollContainerRef.current
|
||||
if (!scrollContainer) {
|
||||
return
|
||||
@@ -228,33 +220,56 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
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]
|
||||
[onNearBottomChange, props.agentId]
|
||||
)
|
||||
|
||||
const scheduleStickToBottom = useCallback((source = 'unknown') => {
|
||||
const scrollContainer = scrollContainerRef.current
|
||||
if (scrollContainer && isScrollContainerOverscrolledPastBottom(scrollContainer)) {
|
||||
return
|
||||
}
|
||||
if (pendingAutoScrollFrameRef.current !== null) {
|
||||
return
|
||||
}
|
||||
pendingAutoScrollFrameRef.current = window.requestAnimationFrame(() => {
|
||||
pendingAutoScrollFrameRef.current = null
|
||||
if (!followOutputRef.current) {
|
||||
const scheduleStickToBottom = useCallback(
|
||||
() => {
|
||||
const scrollContainer = scrollContainerRef.current
|
||||
if (scrollContainer && isScrollContainerOverscrolledPastBottom(scrollContainer)) {
|
||||
return
|
||||
}
|
||||
scrollMessagesToBottom('auto', source)
|
||||
})
|
||||
}, [scrollMessagesToBottom])
|
||||
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', 'force')
|
||||
scheduleStickToBottom('force')
|
||||
scrollMessagesToBottom('auto')
|
||||
scheduleStickToBottom()
|
||||
}, [cancelPendingStickToBottom, scheduleStickToBottom, scrollMessagesToBottom])
|
||||
|
||||
const updateScrollMetrics = useCallback(() => {
|
||||
@@ -291,7 +306,31 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
},
|
||||
} as never)
|
||||
syncNearBottom(scrollContainer, onNearBottomChange)
|
||||
}, [onNearBottomChange, streamScrollbarMetrics])
|
||||
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
|
||||
@@ -300,7 +339,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}
|
||||
|
||||
const currentScrollTop = scrollContainer.scrollTop
|
||||
const isNearBottom = syncNearBottom(scrollContainer, onNearBottomChange)
|
||||
const isAtBottom = isScrollContainerAtBottom(scrollContainer)
|
||||
const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - USER_SCROLL_DELTA_EPSILON
|
||||
|
||||
@@ -321,8 +359,21 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}
|
||||
|
||||
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, onNearBottomChange, updateScrollMetrics])
|
||||
}, [cancelPendingStickToBottom, updateScrollMetrics])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isActivationReady) {
|
||||
@@ -331,13 +382,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
setFollowOutput(true)
|
||||
forceStickToBottom()
|
||||
const timeout = window.setTimeout(() => {
|
||||
console.log('timeout', {
|
||||
followOutputRef: followOutputRef.current,
|
||||
scrollContainerRef: scrollContainerRef.current,
|
||||
isScrollContainerNearBottom: scrollContainerRef.current
|
||||
? isScrollContainerNearBottom(scrollContainerRef.current)
|
||||
: null,
|
||||
})
|
||||
if (!followOutputRef.current) {
|
||||
return
|
||||
}
|
||||
@@ -348,7 +392,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
if (isScrollContainerNearBottom(scrollContainer)) {
|
||||
return
|
||||
}
|
||||
scheduleStickToBottom('activation-timeout')
|
||||
scheduleStickToBottom()
|
||||
}, WEB_BOTTOM_SETTLE_TIMEOUT_MS)
|
||||
return () => {
|
||||
window.clearTimeout(timeout)
|
||||
@@ -359,22 +403,30 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
if (!followOutputRef.current) {
|
||||
return
|
||||
}
|
||||
scheduleStickToBottom('rows-effect')
|
||||
}, [rows, scheduleStickToBottom])
|
||||
scheduleStickToBottom()
|
||||
}, [
|
||||
scheduleStickToBottom,
|
||||
segments.historyMounted,
|
||||
segments.historyVirtualized,
|
||||
segments.liveHead,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (!followOutputRef.current) {
|
||||
if (!followOutputRef.current || !shouldUseVirtualizer) {
|
||||
return
|
||||
}
|
||||
if (!webVirtualizedHistoryWindow) {
|
||||
return
|
||||
}
|
||||
scheduleStickToBottom('virtual-total-size-effect')
|
||||
}, [scheduleStickToBottom, virtualTotalSize, webVirtualizedHistoryWindow])
|
||||
scheduleStickToBottom()
|
||||
}, [scheduleStickToBottom, shouldUseVirtualizer, virtualTotalSize])
|
||||
|
||||
useEffect(() => {
|
||||
updateScrollMetrics()
|
||||
}, [updateScrollMetrics, virtualTotalSize, rows.length])
|
||||
}, [
|
||||
segments.historyMounted.length,
|
||||
segments.historyVirtualized.length,
|
||||
segments.liveHead.length,
|
||||
updateScrollMetrics,
|
||||
virtualTotalSize,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
const scrollContainer = scrollContainerRef.current
|
||||
@@ -385,11 +437,20 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
|
||||
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('resize-observer')
|
||||
scheduleStickToBottom()
|
||||
})
|
||||
observer.observe(scrollContainer)
|
||||
if (contentNode) {
|
||||
@@ -398,7 +459,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [scheduleStickToBottom, updateScrollMetrics])
|
||||
}, [props.agentId, scheduleStickToBottom, updateScrollMetrics])
|
||||
|
||||
useEffect(() => {
|
||||
const scrollContainer = scrollContainerRef.current
|
||||
@@ -406,6 +467,59 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
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
|
||||
@@ -440,6 +554,25 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
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 })
|
||||
@@ -450,6 +583,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
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)
|
||||
@@ -461,8 +597,15 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
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])
|
||||
}, [cancelPendingStickToBottom, handleDomScroll, props.agentId])
|
||||
|
||||
useEffect(() => {
|
||||
const handle: StreamViewportHandle = {
|
||||
@@ -475,7 +618,17 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
if (!followOutputRef.current) {
|
||||
return
|
||||
}
|
||||
scheduleStickToBottom('prepare-for-viewport-change')
|
||||
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
|
||||
@@ -485,7 +638,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}
|
||||
cancelPendingStickToBottom()
|
||||
}
|
||||
}, [cancelPendingStickToBottom, scheduleStickToBottom, forceStickToBottom, viewportRef])
|
||||
}, [cancelPendingStickToBottom, forceStickToBottom, props.agentId, scheduleStickToBottom, viewportRef])
|
||||
|
||||
const contentContainerStyle = useMemo(
|
||||
(): CSSProperties => ({
|
||||
@@ -510,11 +663,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}),
|
||||
[scrollEnabled]
|
||||
)
|
||||
const headerEdgeContent = renderEdge(edgeSlotProps.ListHeaderComponent)
|
||||
const footerEdgeContent = renderEdge(
|
||||
edgeSlotProps.ListFooterComponent,
|
||||
edgeSlotProps.ListFooterComponentStyle as CSSProperties | undefined
|
||||
)
|
||||
const virtualRowsContainerStyle = useMemo(
|
||||
(): CSSProperties => ({
|
||||
position: 'relative',
|
||||
@@ -535,6 +683,30 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}),
|
||||
[]
|
||||
)
|
||||
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 (
|
||||
<>
|
||||
@@ -553,12 +725,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}}
|
||||
style={contentContainerStyle}
|
||||
>
|
||||
{headerEdgeContent}
|
||||
{webVirtualizedHistoryWindow ? (
|
||||
{shouldUseVirtualizer ? (
|
||||
<div style={virtualRowsContainerStyle}>
|
||||
{virtualRows.map((virtualRow) => {
|
||||
const entry = virtualizedEntries[virtualRow.index]
|
||||
if (!entry) {
|
||||
const item = segments.historyVirtualized[virtualRow.index]
|
||||
if (!item) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
@@ -568,17 +739,23 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
ref={rowVirtualizer.measureElement}
|
||||
style={renderVirtualRowStyle(virtualRow.start)}
|
||||
>
|
||||
{renderRow(entry.item, entry.index, rows)}
|
||||
{renderHistoryVirtualizedRow(
|
||||
item,
|
||||
virtualRow.index,
|
||||
segments.historyVirtualized
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{mountedEntries.map((entry) => (
|
||||
<Fragment key={entry.item.id}>{renderRow(entry.item, entry.index, rows)}</Fragment>
|
||||
))}
|
||||
{rows.length === 0 ? listEmptyComponent : null}
|
||||
{footerEdgeContent}
|
||||
{mountedHistoryRows}
|
||||
{boundary.hasMountedHistory && boundary.hasLiveHead && boundary.historyToHeadGap > 0 ? (
|
||||
<div style={{ height: boundary.historyToHeadGap, width: '100%' }} />
|
||||
) : null}
|
||||
{liveHeadRows}
|
||||
{liveAuxiliary}
|
||||
{shouldRenderEmpty ? listEmptyComponent : null}
|
||||
</div>
|
||||
</div>
|
||||
<WebDesktopScrollbarOverlay
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
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,
|
||||
@@ -44,10 +48,30 @@ export type StreamViewportHandle = {
|
||||
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;
|
||||
rows: StreamItem[];
|
||||
renderRow: (item: StreamItem, index: number, items: StreamItem[]) => ReactNode;
|
||||
segments: StreamRenderSegments;
|
||||
boundary: StreamHistoryBoundary;
|
||||
renderers: StreamSegmentRenderers;
|
||||
listEmptyComponent: ReactNode;
|
||||
viewportRef: RefObject<StreamViewportHandle | null>;
|
||||
routeBottomAnchorRequest: BottomAnchorRouteRequest | null;
|
||||
@@ -57,7 +81,6 @@ export type StreamRenderInput = {
|
||||
listStyle: StyleProp<ViewStyle>;
|
||||
baseListContentContainerStyle: StyleProp<ViewStyle>;
|
||||
forwardListContentContainerStyle: StyleProp<ViewStyle>;
|
||||
edgeSlotProps: StreamEdgeSlotProps;
|
||||
};
|
||||
|
||||
export type ResolveStreamRenderStrategyInput = {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useRef } from 'react'
|
||||
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
@@ -99,6 +99,7 @@ export type DesktopStartupReconciliationInput = {
|
||||
interface DaemonRegistryContextValue {
|
||||
daemons: HostProfile[]
|
||||
isLoading: boolean
|
||||
isReconciling: boolean
|
||||
error: unknown | null
|
||||
upsertDirectConnection: (input: {
|
||||
serverId: string
|
||||
@@ -565,6 +566,7 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
|
||||
const queryClient = useQueryClient()
|
||||
const desktopStartupReconciledRef = useRef(false)
|
||||
const localhostBootstrapAttemptedRef = useRef(false)
|
||||
const [isReconciling, setIsReconciling] = useState(true)
|
||||
const { settings, isLoading: settingsLoading } = useAppSettings()
|
||||
const {
|
||||
data: daemons = [],
|
||||
@@ -761,7 +763,11 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}
|
||||
|
||||
void reconcileDesktopStartup()
|
||||
void reconcileDesktopStartup().finally(() => {
|
||||
if (!cancelled) {
|
||||
setIsReconciling(false)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
@@ -826,7 +832,11 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}
|
||||
|
||||
void bootstrapLocalhost()
|
||||
void bootstrapLocalhost().finally(() => {
|
||||
if (!cancelled) {
|
||||
setIsReconciling(false)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
@@ -895,6 +905,7 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
|
||||
const value: DaemonRegistryContextValue = {
|
||||
daemons,
|
||||
isLoading: isPending,
|
||||
isReconciling,
|
||||
error: error ?? null,
|
||||
upsertDirectConnection,
|
||||
upsertRelayConnection,
|
||||
|
||||
@@ -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,10 +1,11 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ActivityIndicator, Alert, Image, Pressable, Text, View } from "react-native";
|
||||
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 { ArrowUpRight } from "lucide-react-native";
|
||||
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";
|
||||
@@ -35,6 +36,7 @@ export interface LocalDaemonSectionProps {
|
||||
}
|
||||
|
||||
export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const showSection = shouldUseManagedDesktopDaemon();
|
||||
const { settings, updateSettings } = useAppSettings();
|
||||
const [managedStatus, setManagedStatus] = useState<ManagedDaemonStatus | null>(null);
|
||||
@@ -331,19 +333,22 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.section}>
|
||||
<View style={settingsStyles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={styles.sectionTitle}>Built-in daemon</Text>
|
||||
<Pressable
|
||||
accessibilityRole="link"
|
||||
onPress={() => void openExternalUrl(ADVANCED_DAEMON_SETTINGS_URL)}
|
||||
<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"
|
||||
>
|
||||
<Text style={styles.sectionLinkText}>Advanced settings</Text>
|
||||
<ArrowUpRight size={14} color={styles.sectionLinkText.color} />
|
||||
</Pressable>
|
||||
Advanced settings
|
||||
</Button>
|
||||
</View>
|
||||
<View style={styles.card}>
|
||||
<View style={settingsStyles.card}>
|
||||
<View style={styles.row}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowTitle}>Status</Text>
|
||||
@@ -366,7 +371,9 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
style={styles.primaryActionButton}
|
||||
leftIcon={isDaemonManagementPaused
|
||||
? <Play size={theme.iconSize.sm} color={theme.colors.foreground} />
|
||||
: <Pause size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
||||
onPress={handleToggleDaemonManagement}
|
||||
disabled={isUpdatingDaemonManagement}
|
||||
>
|
||||
@@ -390,7 +397,7 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
style={styles.primaryActionButton}
|
||||
leftIcon={<RotateCw size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
||||
onPress={handleUpdateLocalDaemon}
|
||||
disabled={isRestartingDaemon}
|
||||
>
|
||||
@@ -412,7 +419,7 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
style={styles.secondaryActionButton}
|
||||
leftIcon={<Terminal size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
||||
onPress={handleToggleCliShim}
|
||||
disabled={isInstallingCli}
|
||||
>
|
||||
@@ -434,13 +441,14 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
|
||||
</View>
|
||||
<View style={styles.actionGroup}>
|
||||
{(managedLogs?.logPath ?? managedStatus?.logPath) ? (
|
||||
<Button variant="outline" size="sm" onPress={handleCopyLogPath}>
|
||||
<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}
|
||||
>
|
||||
@@ -455,7 +463,7 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
|
||||
Connect your phone to this computer.
|
||||
</Text>
|
||||
</View>
|
||||
<Button variant="outline" size="sm" style={styles.secondaryActionButton} onPress={handleOpenPairingModal}>
|
||||
<Button variant="outline" size="sm" leftIcon={<Smartphone size={theme.iconSize.sm} color={theme.colors.foreground} />} onPress={handleOpenPairingModal}>
|
||||
Pair device
|
||||
</Button>
|
||||
</View>
|
||||
@@ -488,7 +496,7 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
|
||||
{cliInstallInstructions?.commands ?? ""}
|
||||
</Text>
|
||||
<View style={styles.modalActions}>
|
||||
<Button variant="secondary" size="sm" onPress={() => setIsCliInstallModalOpen(false)}>
|
||||
<Button variant="outline" size="sm" onPress={() => setIsCliInstallModalOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
<Button size="sm" onPress={handleCopyCliInstallCommands}>
|
||||
@@ -627,7 +635,7 @@ function PairingOfferDialogContent(input: {
|
||||
{pairingOffer.url}
|
||||
</Text>
|
||||
<View style={styles.modalActions}>
|
||||
<Button variant="secondary" size="sm" onPress={onCopyLink}>
|
||||
<Button variant="outline" size="sm" onPress={onCopyLink}>
|
||||
Copy link
|
||||
</Button>
|
||||
</View>
|
||||
@@ -636,9 +644,6 @@ function PairingOfferDialogContent(input: {
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
section: {
|
||||
marginBottom: theme.spacing[6],
|
||||
},
|
||||
sectionHeader: {
|
||||
alignItems: "center",
|
||||
flexDirection: "row",
|
||||
@@ -646,11 +651,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
marginBottom: theme.spacing[3],
|
||||
marginLeft: theme.spacing[1],
|
||||
},
|
||||
sectionTitle: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
sectionLink: {
|
||||
alignItems: "center",
|
||||
flexDirection: "row",
|
||||
@@ -660,13 +660,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
overflow: "hidden",
|
||||
},
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
@@ -692,12 +685,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "flex-end",
|
||||
gap: 2,
|
||||
},
|
||||
primaryActionButton: {
|
||||
minWidth: 124,
|
||||
},
|
||||
secondaryActionButton: {
|
||||
minWidth: 112,
|
||||
},
|
||||
rowTitle: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
|
||||
@@ -3,7 +3,9 @@ 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,
|
||||
@@ -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 {
|
||||
@@ -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;
|
||||
const serverIdFromPath = activeServerId;
|
||||
return serverIdFromPath ? (buildHostOpenProjectRoute(serverIdFromPath) as Href) : "/";
|
||||
}, [fallbackServerId, pathname]);
|
||||
}, [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) =>
|
||||
@@ -209,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;
|
||||
@@ -259,6 +291,7 @@ export function useCommandCenter() {
|
||||
if (!open) return;
|
||||
|
||||
const handler = (event: KeyboardEvent) => {
|
||||
const currentItems = itemsRef.current;
|
||||
const key = event.key;
|
||||
if (
|
||||
key !== "ArrowDown" &&
|
||||
@@ -271,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;
|
||||
});
|
||||
}
|
||||
@@ -299,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 };
|
||||
}
|
||||
@@ -33,6 +33,7 @@ function workspace(
|
||||
name: input.name,
|
||||
status: input.status,
|
||||
activityAt: input.activityAt,
|
||||
diffStat: null,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface SidebarWorkspaceEntry {
|
||||
name: string
|
||||
activityAt: Date | null
|
||||
statusBucket: SidebarStateBucket
|
||||
diffStat: { additions: number; deletions: number } | null
|
||||
}
|
||||
|
||||
export interface SidebarProjectEntry {
|
||||
@@ -129,6 +130,7 @@ export function buildSidebarProjectsFromWorkspaces(input: {
|
||||
name: workspace.name,
|
||||
activityAt: workspace.activityAt,
|
||||
statusBucket: workspace.status,
|
||||
diffStat: workspace.diffStat,
|
||||
}
|
||||
|
||||
project.workspaces.push(row)
|
||||
|
||||
@@ -152,8 +152,9 @@ describe("keyboard-shortcuts", () => {
|
||||
payload: { delta: 1 },
|
||||
},
|
||||
{
|
||||
name: "matches Alt+Shift+T to open new tab",
|
||||
event: { key: "T", code: "KeyT", altKey: true, shiftKey: true },
|
||||
name: "matches Mod+T to open new tab",
|
||||
event: { key: "t", code: "KeyT", metaKey: true },
|
||||
context: { isMac: true },
|
||||
action: "workspace.tab.new",
|
||||
},
|
||||
{
|
||||
@@ -222,6 +223,10 @@ describe("keyboard-shortcuts", () => {
|
||||
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 },
|
||||
@@ -269,6 +274,7 @@ describe("keyboard-shortcut help sections", () => {
|
||||
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"],
|
||||
@@ -279,6 +285,7 @@ describe("keyboard-shortcut help sections", () => {
|
||||
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"],
|
||||
|
||||
@@ -138,20 +138,19 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
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"],
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -74,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,
|
||||
@@ -946,12 +953,19 @@ function AgentScreenContent({
|
||||
onAttentionInputFocus={attentionController.clearOnInputFocus}
|
||||
onAttentionPromptSend={attentionController.clearOnPromptSend}
|
||||
onAddImages={handleAddImagesCallback}
|
||||
onComposerHeightChange={() =>
|
||||
streamViewRef.current?.prepareForViewportChange()
|
||||
}
|
||||
onMessageSent={() =>
|
||||
streamViewRef.current?.scrollToBottom("message-sent")
|
||||
}
|
||||
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");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ export function AgentsScreen({ serverId }: { serverId: string }) {
|
||||
showCheckoutInfo={false}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
showAttentionIndicator={false}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1,17 +1,38 @@
|
||||
import { useEffect } from "react";
|
||||
import { View, Text, Pressable } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
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 setProjectPickerOpen = useKeyboardShortcutsStore(
|
||||
(s) => s.setProjectPickerOpen
|
||||
);
|
||||
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>
|
||||
@@ -24,7 +45,7 @@ export function OpenProjectScreen({ serverId: _serverId }: { serverId: string })
|
||||
testID="open-project-submit"
|
||||
>
|
||||
<FolderOpen size={16} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.openButtonText}>Open a project</Text>
|
||||
<Text style={styles.openButtonText}>Add a project</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
@@ -36,6 +57,12 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
menuToggle: {
|
||||
position: "absolute",
|
||||
top: theme.spacing[3],
|
||||
left: theme.spacing[3],
|
||||
zIndex: 1,
|
||||
},
|
||||
content: {
|
||||
flexGrow: 1,
|
||||
justifyContent: "center",
|
||||
|
||||
@@ -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) => {
|
||||
@@ -112,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,
|
||||
@@ -139,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",
|
||||
},
|
||||
@@ -234,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",
|
||||
@@ -269,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],
|
||||
@@ -328,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: {
|
||||
@@ -375,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],
|
||||
},
|
||||
@@ -389,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: {
|
||||
@@ -501,7 +456,7 @@ function DesktopAppUpdateRow() {
|
||||
</View>
|
||||
<View style={styles.aboutUpdateActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onPress={handleCheckForUpdates}
|
||||
disabled={isChecking || isInstalling}
|
||||
@@ -722,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>
|
||||
) : (
|
||||
@@ -741,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
|
||||
@@ -878,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>
|
||||
@@ -915,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>
|
||||
@@ -1176,12 +1134,15 @@ function HostDetailModal({
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<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}
|
||||
@@ -1332,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 }}>
|
||||
@@ -1341,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>
|
||||
);
|
||||
}
|
||||
@@ -1400,7 +1364,7 @@ function DaemonCard({
|
||||
|
||||
return (
|
||||
<View
|
||||
style={styles.hostCard}
|
||||
style={[settingsStyles.card, styles.hostCard]}
|
||||
testID={`daemon-card-${daemon.serverId}`}
|
||||
>
|
||||
<View style={styles.hostCardContent}>
|
||||
@@ -1432,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>
|
||||
);
|
||||
}
|
||||
@@ -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[];
|
||||
@@ -44,10 +45,6 @@ type WorkspaceDesktopTabsRowProps = {
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -71,10 +68,6 @@ export function WorkspaceDesktopTabsRow({
|
||||
onCloseOtherTabs,
|
||||
onSelectNewTabOption,
|
||||
newTabAgentOptionId,
|
||||
newTabTerminalOptionId,
|
||||
createTerminalPending,
|
||||
isNewTerminalHovered,
|
||||
setIsNewTerminalHovered,
|
||||
onReorderTabs,
|
||||
}: WorkspaceDesktopTabsRowProps) {
|
||||
const { theme } = useUnistyles();
|
||||
@@ -375,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>
|
||||
@@ -522,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} />;
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
Copy,
|
||||
Folder,
|
||||
GitBranch,
|
||||
MoreVertical,
|
||||
Ellipsis,
|
||||
PanelRight,
|
||||
Plus,
|
||||
SquareTerminal,
|
||||
@@ -26,6 +26,7 @@ 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,
|
||||
@@ -40,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";
|
||||
@@ -98,7 +101,6 @@ import {
|
||||
|
||||
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[] = [];
|
||||
|
||||
@@ -751,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
|
||||
@@ -828,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(
|
||||
@@ -1161,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,
|
||||
@@ -1349,87 +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}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
testID="workspace-header-menu-trigger"
|
||||
style={styles.headerActionButton}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Workspace actions"
|
||||
>
|
||||
<MoreVertical
|
||||
size={theme.iconSize.md}
|
||||
color={theme.colors.foregroundMuted}
|
||||
{!isMobile && isGitCheckout ? (
|
||||
<>
|
||||
<WorkspaceGitActions
|
||||
serverId={normalizedServerId}
|
||||
cwd={normalizedWorkspaceId}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
width={220}
|
||||
testID="workspace-header-menu"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
testID="workspace-header-copy-path"
|
||||
leading={
|
||||
<Copy
|
||||
size={16}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
}
|
||||
disabled={!normalizedWorkspaceId.startsWith("/")}
|
||||
onSelect={handleCopyWorkspacePath}
|
||||
<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,
|
||||
]}
|
||||
>
|
||||
Copy workspace path
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<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 }}
|
||||
>
|
||||
{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
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</HeaderToggleButton>
|
||||
|
||||
{({ 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>
|
||||
}
|
||||
/>
|
||||
@@ -1482,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
|
||||
@@ -1568,10 +1583,6 @@ function WorkspaceScreenContent({
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
@@ -1654,13 +1665,49 @@ const styles = StyleSheet.create((theme) => ({
|
||||
headerRight: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
gap: {
|
||||
xs: theme.spacing[1],
|
||||
md: theme.spacing[2],
|
||||
},
|
||||
},
|
||||
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",
|
||||
@@ -1679,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,
|
||||
|
||||
@@ -18,6 +18,7 @@ describe('workspace source of truth consumption', () => {
|
||||
name: 'feat/workspace-sot',
|
||||
status: 'running',
|
||||
activityAt: new Date('2026-03-01T00:00:00.000Z'),
|
||||
diffStat: null,
|
||||
}
|
||||
|
||||
const header = resolveWorkspaceHeader({ workspace })
|
||||
|
||||
@@ -120,6 +120,7 @@ export interface WorkspaceDescriptor {
|
||||
name: string;
|
||||
status: WorkspaceDescriptorPayload["status"];
|
||||
activityAt: Date | null;
|
||||
diffStat: { additions: number; deletions: number } | null;
|
||||
}
|
||||
|
||||
export function normalizeWorkspaceDescriptor(
|
||||
@@ -137,6 +138,7 @@ export function normalizeWorkspaceDescriptor(
|
||||
status: payload.status,
|
||||
activityAt:
|
||||
activityAt && !Number.isNaN(activityAt.getTime()) ? activityAt : null,
|
||||
diffStat: payload.diffStat ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
21
packages/app/src/styles/settings.ts
Normal file
21
packages/app/src/styles/settings.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
|
||||
export const settingsStyles = StyleSheet.create((theme) => ({
|
||||
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],
|
||||
},
|
||||
card: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
overflow: "hidden",
|
||||
},
|
||||
}));
|
||||
@@ -133,8 +133,6 @@ const lightSemanticColors = {
|
||||
|
||||
// Legacy aliases (for gradual migration)
|
||||
background: "#ffffff",
|
||||
card: "#ffffff",
|
||||
cardForeground: "#09090b",
|
||||
popover: "#ffffff",
|
||||
popoverForeground: "#09090b",
|
||||
primary: "#18181b",
|
||||
@@ -204,8 +202,6 @@ const darkSemanticColors = {
|
||||
|
||||
// Legacy aliases (for gradual migration)
|
||||
background: "#18181c",
|
||||
card: "#27272a",
|
||||
cardForeground: "#fafafa",
|
||||
popover: "#27272a",
|
||||
popoverForeground: "#fafafa",
|
||||
primary: "#fafafa",
|
||||
|
||||
@@ -14,6 +14,7 @@ function workspace(overrides: Partial<SidebarWorkspaceEntry> = {}): SidebarWorks
|
||||
name: 'paseo',
|
||||
activityAt: null,
|
||||
statusBucket: 'done',
|
||||
diffStat: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ function workspace(serverId: string, cwd: string): SidebarWorkspaceEntry {
|
||||
name: cwd,
|
||||
activityAt: null,
|
||||
statusBucket: "done",
|
||||
diffStat: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ export function useTauriDragHandlers() {
|
||||
// Only handle primary button, ignore if clicking on interactive elements.
|
||||
// Tauri docs recommend using `e.detail` on mousedown for double-click maximize.
|
||||
if (e.defaultPrevented) return;
|
||||
if (typeof e.buttons === "number" ? e.buttons !== 1 : e.button !== 0) return;
|
||||
if (e.button !== 0) return;
|
||||
const target = e.target instanceof Element ? e.target : null;
|
||||
if (target?.closest(NON_DRAGGABLE_SELECTOR)) return;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.19",
|
||||
"version": "0.1.25",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"type": "module",
|
||||
"files": [
|
||||
@@ -24,8 +24,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/relay": "0.1.19",
|
||||
"@getpaseo/server": "0.1.19",
|
||||
"@getpaseo/relay": "0.1.25",
|
||||
"@getpaseo/server": "0.1.25",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.19",
|
||||
"version": "0.1.25",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Tauri wrapper)",
|
||||
"scripts": {
|
||||
@@ -8,6 +8,7 @@
|
||||
"prepare:managed-runtime": "npm --prefix ../.. run build:daemon && npm run build:managed-runtime",
|
||||
"dev": "npm run prepare:managed-runtime && tauri dev",
|
||||
"build": "npm --prefix ../.. run build:web --workspace=@getpaseo/app && npm run prepare:managed-runtime && tauri build",
|
||||
"validate:managed-runtime": "node ./scripts/validate-managed-runtime.mjs",
|
||||
"smoke:managed-daemon": "node ./scripts/managed-daemon-smoke.mjs",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
|
||||
@@ -196,10 +196,13 @@ function runCommand(command, args, options = {}) {
|
||||
encoding: "utf8",
|
||||
...options,
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
if (result.status !== 0 || result.error) {
|
||||
throw new Error(
|
||||
[
|
||||
`Command failed: ${command} ${args.join(" ")}`,
|
||||
result.error ? String(result.error) : null,
|
||||
result.signal ? `signal: ${result.signal}` : null,
|
||||
result.status != null ? `exit code: ${result.status}` : null,
|
||||
result.stdout?.trim(),
|
||||
result.stderr?.trim(),
|
||||
]
|
||||
@@ -245,7 +248,17 @@ async function ensureWorkspaceBuilds() {
|
||||
|
||||
async function packWorkspace(packageRoot, tarballRoot) {
|
||||
await ensureDir(tarballRoot);
|
||||
const result = runCommand("npm", ["pack", "--json", "--pack-destination", tarballRoot], {
|
||||
const npmExecPath = process.env.npm_execpath;
|
||||
if (!npmExecPath) {
|
||||
throw new Error("Missing npm_execpath while building managed runtime.");
|
||||
}
|
||||
const result = runCommand(process.execPath, [
|
||||
npmExecPath,
|
||||
"pack",
|
||||
"--json",
|
||||
"--pack-destination",
|
||||
tarballRoot,
|
||||
], {
|
||||
cwd: packageRoot,
|
||||
});
|
||||
const [{ filename }] = JSON.parse(result.stdout.trim());
|
||||
@@ -347,6 +360,15 @@ async function pruneOnnxRuntime(runtimeRoot) {
|
||||
await removeIfExists(path.join(onnxRoot, "darwin"));
|
||||
await removeIfExists(path.join(onnxRoot, "win32"));
|
||||
await pruneChildrenExcept(path.join(onnxRoot, "linux"), new Set([process.arch]));
|
||||
const archDir = path.join(onnxRoot, "linux", process.arch);
|
||||
if (await pathExists(archDir)) {
|
||||
const entries = await fs.readdir(archDir);
|
||||
await Promise.all(
|
||||
entries
|
||||
.filter((name) => name.includes("cuda") || name.includes("tensorrt"))
|
||||
.map((name) => fs.rm(path.join(archDir, name), { force: true }))
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
@@ -368,18 +390,23 @@ async function pruneNodePty(runtimeRoot) {
|
||||
}
|
||||
|
||||
async function pruneClaudeAgentSdk(runtimeRoot) {
|
||||
const ripgrepRoot = path.join(
|
||||
const vendorRoot = path.join(
|
||||
runtimeRoot,
|
||||
"node_modules",
|
||||
"@anthropic-ai",
|
||||
"claude-agent-sdk",
|
||||
"vendor",
|
||||
"ripgrep"
|
||||
"vendor"
|
||||
);
|
||||
const ripgrepRoot = path.join(vendorRoot, "ripgrep");
|
||||
const keepName = ripgrepPlatformDirMap[process.platform]?.[process.arch];
|
||||
if (keepName) {
|
||||
await pruneChildrenExcept(ripgrepRoot, new Set(["COPYING", keepName]));
|
||||
}
|
||||
|
||||
const treeSitterBashRoot = path.join(vendorRoot, "tree-sitter-bash");
|
||||
if (keepName) {
|
||||
await pruneChildrenExcept(treeSitterBashRoot, new Set([keepName]));
|
||||
}
|
||||
}
|
||||
|
||||
async function pruneManagedRuntime(runtimeRoot) {
|
||||
|
||||
@@ -5,12 +5,14 @@ import fs from "node:fs/promises";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import WebSocket from "ws";
|
||||
import { createClientChannel } from "@getpaseo/relay/e2ee";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const repoRoot = path.resolve(new URL("../../..", import.meta.url).pathname);
|
||||
const COMMAND_TIMEOUT_MS = 120_000;
|
||||
const repoRoot = fileURLToPath(new URL("../../..", import.meta.url));
|
||||
const desktopRoot = path.join(repoRoot, "packages", "desktop");
|
||||
const relayRoot = path.join(repoRoot, "packages", "relay");
|
||||
const desktopPackageJson = JSON.parse(
|
||||
@@ -26,12 +28,18 @@ const currentRuntimePointer = JSON.parse(
|
||||
const currentRuntimeId = currentRuntimePointer.runtimeId;
|
||||
|
||||
function resolvePackagedBinary() {
|
||||
const targetRoot = process.env.PASEO_MANAGED_SMOKE_RUST_TARGET
|
||||
? path.join(
|
||||
desktopRoot,
|
||||
"src-tauri",
|
||||
"target",
|
||||
process.env.PASEO_MANAGED_SMOKE_RUST_TARGET,
|
||||
"release"
|
||||
)
|
||||
: path.join(desktopRoot, "src-tauri", "target", "release");
|
||||
if (process.platform === "darwin") {
|
||||
return path.join(
|
||||
desktopRoot,
|
||||
"src-tauri",
|
||||
"target",
|
||||
"release",
|
||||
targetRoot,
|
||||
"bundle",
|
||||
"macos",
|
||||
"Paseo.app",
|
||||
@@ -42,15 +50,18 @@ function resolvePackagedBinary() {
|
||||
}
|
||||
if (process.platform === "linux") {
|
||||
return path.join(
|
||||
desktopRoot,
|
||||
"src-tauri",
|
||||
"target",
|
||||
"release",
|
||||
targetRoot,
|
||||
"bundle",
|
||||
"appimage",
|
||||
`Paseo_${desktopPackageJson.version}_amd64.AppImage`
|
||||
);
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
return path.join(
|
||||
targetRoot,
|
||||
"Paseo.exe"
|
||||
);
|
||||
}
|
||||
throw new Error(`Managed desktop smoke is not implemented for ${process.platform} yet.`);
|
||||
}
|
||||
|
||||
@@ -99,13 +110,42 @@ function escapeForRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
async function waitForChildExit(child, timeoutMs) {
|
||||
if (!child || child.exitCode !== null || child.killed) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
const timeout = setTimeout(resolve, timeoutMs);
|
||||
child.once("exit", () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function execFileWithTimeout(command, args, options, label) {
|
||||
try {
|
||||
return await execFileAsync(command, args, {
|
||||
timeout: COMMAND_TIMEOUT_MS,
|
||||
...options,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.killed && error?.signal === "SIGTERM") {
|
||||
const renderedArgs = args.map((arg) => JSON.stringify(arg)).join(" ");
|
||||
throw new Error(
|
||||
`Timed out after ${COMMAND_TIMEOUT_MS}ms running ${label}: ${JSON.stringify(command)} ${renderedArgs}`
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function runBinary(binaryPath, args, env) {
|
||||
const { stdout, stderr } = await execFileAsync(binaryPath, args, {
|
||||
const { stdout, stderr } = await execFileWithTimeout(binaryPath, args, {
|
||||
env,
|
||||
cwd: repoRoot,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
}, "packaged desktop binary");
|
||||
const trimmed = stdout.trim();
|
||||
return {
|
||||
stdout,
|
||||
@@ -114,8 +154,36 @@ async function runBinary(binaryPath, args, env) {
|
||||
};
|
||||
}
|
||||
|
||||
async function terminateChildProcess(child, label) {
|
||||
if (!child || child.exitCode !== null || child.killed) {
|
||||
return;
|
||||
}
|
||||
if (process.platform === "win32" && typeof child.pid === "number") {
|
||||
try {
|
||||
await execFileWithTimeout(
|
||||
"taskkill",
|
||||
["/pid", String(child.pid), "/T", "/F"],
|
||||
{ maxBuffer: 1024 * 1024 },
|
||||
`${label} taskkill`
|
||||
);
|
||||
} catch {}
|
||||
await waitForChildExit(child, 5_000);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
child.kill("SIGTERM");
|
||||
} catch {}
|
||||
await waitForChildExit(child, 5_000);
|
||||
if (child.exitCode === null && !child.killed) {
|
||||
try {
|
||||
child.kill("SIGKILL");
|
||||
} catch {}
|
||||
await waitForChildExit(child, 5_000);
|
||||
}
|
||||
}
|
||||
|
||||
async function runWorkspaceCli(args, env) {
|
||||
const { stdout, stderr } = await execFileAsync(
|
||||
const { stdout, stderr } = await execFileWithTimeout(
|
||||
process.execPath,
|
||||
[path.join(repoRoot, "packages", "cli", "dist", "index.js"), ...args],
|
||||
{
|
||||
@@ -126,6 +194,7 @@ async function runWorkspaceCli(args, env) {
|
||||
},
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
},
|
||||
"workspace CLI"
|
||||
);
|
||||
let json = null;
|
||||
if (stdout.trim()) {
|
||||
@@ -142,7 +211,7 @@ async function runBundledRuntimeCli(runtimeRoot, managedHome, args, env) {
|
||||
const manifest = JSON.parse(
|
||||
await fs.readFile(path.join(runtimeRoot, "runtime-manifest.json"), "utf8")
|
||||
);
|
||||
const { stdout, stderr } = await execFileAsync(
|
||||
const { stdout, stderr } = await execFileWithTimeout(
|
||||
path.join(runtimeRoot, manifest.nodeRelativePath),
|
||||
[path.join(runtimeRoot, manifest.cliEntrypointRelativePath), ...args],
|
||||
{
|
||||
@@ -153,7 +222,8 @@ async function runBundledRuntimeCli(runtimeRoot, managedHome, args, env) {
|
||||
PASEO_HOME: managedHome,
|
||||
},
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
}
|
||||
},
|
||||
"bundled runtime CLI"
|
||||
);
|
||||
return { stdout, stderr };
|
||||
}
|
||||
@@ -349,12 +419,19 @@ function assertNoForbiddenPathsOrPorts(value, forbidden) {
|
||||
}
|
||||
|
||||
async function ensurePackagedArtifact(binaryPath) {
|
||||
if (process.env.PASEO_MANAGED_SMOKE_SKIP_BUILD === "1") {
|
||||
return;
|
||||
}
|
||||
const npmExecPath = process.env.npm_execpath;
|
||||
if (!npmExecPath) {
|
||||
throw new Error("npm_execpath is required to build the packaged desktop artifact during smoke tests.");
|
||||
}
|
||||
try {
|
||||
await execFileAsync("npm", ["run", "build"], {
|
||||
await execFileWithTimeout(process.execPath, [npmExecPath, "run", "build"], {
|
||||
cwd: desktopRoot,
|
||||
env: process.env,
|
||||
maxBuffer: 20 * 1024 * 1024,
|
||||
});
|
||||
}, "desktop smoke artifact build");
|
||||
} catch (error) {
|
||||
const combined = `${error.stdout ?? ""}\n${error.stderr ?? ""}`;
|
||||
const signingBlocked =
|
||||
@@ -382,9 +459,15 @@ async function waitFor(assertion, timeoutMs, label) {
|
||||
}
|
||||
|
||||
function logStep(label) {
|
||||
currentStepLabel = label;
|
||||
currentStepStartedAt = Date.now();
|
||||
console.log(`\n[managed-smoke] ${label}`);
|
||||
}
|
||||
|
||||
function shouldAttemptCliShimInstall(env) {
|
||||
return !(process.platform === "darwin" && env.CI === "true");
|
||||
}
|
||||
|
||||
const packagedBinary = resolvePackagedBinary();
|
||||
await ensurePackagedArtifact(packagedBinary);
|
||||
if (!(await pathExists(packagedBinary))) {
|
||||
@@ -409,6 +492,23 @@ await fs.mkdir(externalHome, { recursive: true });
|
||||
await fs.writeFile(path.join(fakePaseoHome, "sentinel.txt"), "do not touch\n", "utf8");
|
||||
|
||||
const fakePaseoSnapshotBefore = await snapshotTree(fakePaseoHome);
|
||||
const smokeStartedAt = Date.now();
|
||||
const smokeDeadlineMs = 15 * 60 * 1000;
|
||||
let currentStepLabel = "initializing";
|
||||
let currentStepStartedAt = smokeStartedAt;
|
||||
const heartbeat = setInterval(() => {
|
||||
const elapsedSeconds = Math.floor((Date.now() - smokeStartedAt) / 1000);
|
||||
const currentStepSeconds = Math.floor((Date.now() - currentStepStartedAt) / 1000);
|
||||
console.log(
|
||||
`[managed-smoke] heartbeat elapsed=${elapsedSeconds}s currentStep=${JSON.stringify(currentStepLabel)} stepElapsed=${currentStepSeconds}s`
|
||||
);
|
||||
if (Date.now() - smokeStartedAt > smokeDeadlineMs) {
|
||||
console.error(
|
||||
`[managed-smoke] FAIL exceeded ${smokeDeadlineMs / 1000}s overall deadline during ${JSON.stringify(currentStepLabel)}`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}, 30_000);
|
||||
const managedEnv = {
|
||||
...process.env,
|
||||
HOME: fakeHome,
|
||||
@@ -428,10 +528,17 @@ let externalPid = null;
|
||||
let startedTemporaryExternalDaemon = false;
|
||||
let relayProcess = null;
|
||||
const forbiddenManagedReferences = ["127.0.0.1:6767", fakePaseoHome, managedRuntimeDir];
|
||||
const npmExecPath = process.env.npm_execpath;
|
||||
if (!npmExecPath) {
|
||||
throw new Error("npm_execpath is required to launch wrangler during managed desktop smoke tests.");
|
||||
}
|
||||
|
||||
try {
|
||||
logStep(`Starting isolated local relay on ${relayEndpoint}`);
|
||||
relayProcess = spawn(process.platform === "win32" ? "npx.cmd" : "npx", [
|
||||
relayProcess = spawn(process.execPath, [
|
||||
npmExecPath,
|
||||
"exec",
|
||||
"--",
|
||||
"wrangler",
|
||||
"dev",
|
||||
"--local",
|
||||
@@ -454,7 +561,7 @@ try {
|
||||
});
|
||||
await waitFor(async () => {
|
||||
await execFileAsync(process.execPath, ["-e", `require("node:net").connect(${relayPort}, "127.0.0.1").on("connect", function () { this.end(); process.exit(0); }).on("error", () => process.exit(1));`]);
|
||||
}, 30_000, "relay HTTP endpoint to start");
|
||||
}, 60_000, "relay HTTP endpoint to start");
|
||||
await waitForRelayWebSocketReady(relayEndpoint, 60_000);
|
||||
|
||||
logStep(`Starting isolated external daemon on ${externalEndpoint}`);
|
||||
@@ -502,19 +609,33 @@ try {
|
||||
assert.equal(stateAfterRuntimeStatus.runtimeId, currentRuntimeId);
|
||||
assert.equal(stateAfterRuntimeStatus.runtimeRoot, runtimeStatus.json.runtimeRoot);
|
||||
|
||||
const managedStart = await runBinary(packagedBinary, ["--managed-headless", "bootstrap"], managedEnv);
|
||||
assert.equal(managedStart.json.daemonRunning, true);
|
||||
assert.ok(managedStart.json.daemonPid, "managed daemon pid should exist");
|
||||
assert.equal(managedStart.json.runtimeRoot, runtimeStatus.json.runtimeRoot);
|
||||
const managedBootstrap = await runBinary(packagedBinary, ["--managed-headless", "bootstrap"], managedEnv);
|
||||
const managedStart = managedBootstrap.json ?? await waitFor(
|
||||
async () => {
|
||||
const status = await runBinary(
|
||||
packagedBinary,
|
||||
["--managed-headless", "daemon-status"],
|
||||
managedEnv
|
||||
);
|
||||
assert.equal(status.json?.daemonRunning, true);
|
||||
assert.ok(status.json?.daemonPid, "managed daemon pid should exist");
|
||||
return status.json;
|
||||
},
|
||||
10_000,
|
||||
"managed daemon bootstrap status"
|
||||
);
|
||||
assert.equal(managedStart.daemonRunning, true);
|
||||
assert.ok(managedStart.daemonPid, "managed daemon pid should exist");
|
||||
assert.equal(managedStart.runtimeRoot, runtimeStatus.json.runtimeRoot);
|
||||
assert.ok(
|
||||
managedStart.json.transportType === "socket" || managedStart.json.transportType === "pipe",
|
||||
managedStart.transportType === "socket" || managedStart.transportType === "pipe",
|
||||
"managed daemon should default to private IPC transport"
|
||||
);
|
||||
assert.notEqual(managedStart.json.transportPath, "127.0.0.1:6767");
|
||||
assertNoForbiddenPathsOrPorts(managedStart.json, forbiddenManagedReferences);
|
||||
assert.notEqual(managedStart.transportPath, "127.0.0.1:6767");
|
||||
assertNoForbiddenPathsOrPorts(managedStart, forbiddenManagedReferences);
|
||||
assert.equal(await pathExists(managedRuntimeDir), false, "starting the daemon should not install a runtime copy");
|
||||
|
||||
const managedPid = managedStart.json.daemonPid;
|
||||
const managedPid = managedStart.daemonPid;
|
||||
const stateFile = path.join(testRoot, "managed-state.json");
|
||||
assert.equal(await pathExists(stateFile), true, "managed state file should be written");
|
||||
|
||||
@@ -530,46 +651,63 @@ try {
|
||||
assert.equal(persistedManagedStatus.json.relayEnabled, true);
|
||||
assertNoForbiddenPathsOrPorts(persistedManagedStatus.json, forbiddenManagedReferences);
|
||||
|
||||
logStep("Installing CLI shim and verifying the bundled CLI target");
|
||||
const cliInstall = await runBinary(
|
||||
packagedBinary,
|
||||
["--managed-headless", "install-cli-shim"],
|
||||
managedEnv
|
||||
);
|
||||
const attemptCliShimInstall = shouldAttemptCliShimInstall(managedEnv);
|
||||
const cliInstall = attemptCliShimInstall
|
||||
? await (async () => {
|
||||
logStep("Installing CLI shim and verifying the bundled CLI target");
|
||||
return await runBinary(
|
||||
packagedBinary,
|
||||
["--managed-headless", "install-cli-shim"],
|
||||
managedEnv
|
||||
);
|
||||
})()
|
||||
: {
|
||||
json: {
|
||||
status: "skippedInCi",
|
||||
installed: false,
|
||||
path: null,
|
||||
message: "Skipping privileged macOS CLI shim install in CI; verifying bundled CLI directly.",
|
||||
},
|
||||
};
|
||||
const cliShimPath = cliInstall.json.path;
|
||||
assert.ok(cliShimPath, "CLI shim path should be returned");
|
||||
const cliShimInstalled = cliInstall.json.installed === true && (await pathExists(cliShimPath));
|
||||
if (!cliShimInstalled) {
|
||||
assert.ok(cliInstall.json.manualInstructions, "manual CLI install instructions should be returned");
|
||||
assert.match(
|
||||
cliInstall.json.manualInstructions.commands,
|
||||
new RegExp(escapeForRegExp(runtimeStatus.json.runtimeRoot)),
|
||||
"manual CLI install instructions should point at the bundled runtime"
|
||||
);
|
||||
assertNoForbiddenPathsOrPorts(cliInstall.json.manualInstructions, forbiddenManagedReferences);
|
||||
const cliShimInstalled =
|
||||
Boolean(attemptCliShimInstall && cliShimPath) && cliInstall.json.installed === true && (await pathExists(cliShimPath));
|
||||
if (attemptCliShimInstall) {
|
||||
assert.ok(cliShimPath, "CLI shim path should be returned");
|
||||
if (!cliShimInstalled) {
|
||||
assert.ok(cliInstall.json.manualInstructions, "manual CLI install instructions should be returned");
|
||||
assert.match(
|
||||
cliInstall.json.manualInstructions.commands,
|
||||
new RegExp(escapeForRegExp(runtimeStatus.json.runtimeRoot)),
|
||||
"manual CLI install instructions should point at the bundled runtime"
|
||||
);
|
||||
assertNoForbiddenPathsOrPorts(cliInstall.json.manualInstructions, forbiddenManagedReferences);
|
||||
}
|
||||
} else {
|
||||
logStep("Skipping privileged CLI shim install in CI and verifying the bundled CLI target directly");
|
||||
}
|
||||
const cliVersion = cliShimInstalled
|
||||
? await execFileAsync(cliShimPath, ["--version"], {
|
||||
? await execFileWithTimeout(cliShimPath, ["--version"], {
|
||||
env: managedEnv,
|
||||
cwd: repoRoot,
|
||||
maxBuffer: 1024 * 1024,
|
||||
})
|
||||
}, "installed CLI shim version check")
|
||||
: await runBundledRuntimeCli(
|
||||
runtimeStatus.json.runtimeRoot,
|
||||
managedStart.json.managedHome,
|
||||
managedStart.managedHome,
|
||||
["--version"],
|
||||
managedEnv
|
||||
);
|
||||
assert.match(cliVersion.stdout.trim(), /^0\./);
|
||||
const shimStatus = cliShimInstalled
|
||||
? await execFileAsync(cliShimPath, ["daemon", "status", "--json"], {
|
||||
? await execFileWithTimeout(cliShimPath, ["daemon", "status", "--json"], {
|
||||
env: managedEnv,
|
||||
cwd: repoRoot,
|
||||
maxBuffer: 1024 * 1024,
|
||||
})
|
||||
}, "installed CLI shim daemon status")
|
||||
: await runBundledRuntimeCli(
|
||||
runtimeStatus.json.runtimeRoot,
|
||||
managedStart.json.managedHome,
|
||||
managedStart.managedHome,
|
||||
["daemon", "status", "--json"],
|
||||
managedEnv
|
||||
);
|
||||
@@ -579,19 +717,20 @@ try {
|
||||
|
||||
logStep("Verifying relay connectivity still works after the desktop command has exited");
|
||||
const relayPairing = cliShimInstalled
|
||||
? await execFileAsync(
|
||||
? await execFileWithTimeout(
|
||||
cliShimPath,
|
||||
["daemon", "pair", "--home", managedStart.json.managedHome],
|
||||
["daemon", "pair", "--home", managedStart.managedHome],
|
||||
{
|
||||
env: managedEnv,
|
||||
cwd: repoRoot,
|
||||
maxBuffer: 4 * 1024 * 1024,
|
||||
}
|
||||
},
|
||||
"installed CLI shim relay pairing"
|
||||
)
|
||||
: await runBundledRuntimeCli(
|
||||
runtimeStatus.json.runtimeRoot,
|
||||
managedStart.json.managedHome,
|
||||
["daemon", "pair", "--home", managedStart.json.managedHome],
|
||||
managedStart.managedHome,
|
||||
["daemon", "pair", "--home", managedStart.managedHome],
|
||||
managedEnv
|
||||
);
|
||||
const relayOfferUrl = parseOfferUrlFromCommandOutput(relayPairing.stdout);
|
||||
@@ -670,7 +809,8 @@ try {
|
||||
JSON.stringify(
|
||||
{
|
||||
runtimeStatus: runtimeStatus.json,
|
||||
managedStart: managedStart.json,
|
||||
managedBootstrap: managedBootstrap.json,
|
||||
managedStart,
|
||||
stateAfterRuntimeStatus,
|
||||
persistedManagedStatus: persistedManagedStatus.json,
|
||||
managedRestartless: managedRestartless.json,
|
||||
@@ -694,11 +834,14 @@ try {
|
||||
|
||||
console.log(`\n[managed-smoke] PASS (${testRoot})`);
|
||||
} finally {
|
||||
clearInterval(heartbeat);
|
||||
try {
|
||||
await runBinary(packagedBinary, ["--managed-headless", "stop-daemon"], managedEnv);
|
||||
} catch {}
|
||||
try {
|
||||
await runBinary(packagedBinary, ["--managed-headless", "uninstall-cli-shim"], managedEnv);
|
||||
if (shouldAttemptCliShimInstall(managedEnv)) {
|
||||
await runBinary(packagedBinary, ["--managed-headless", "uninstall-cli-shim"], managedEnv);
|
||||
}
|
||||
} catch {}
|
||||
try {
|
||||
if (startedTemporaryExternalDaemon) {
|
||||
@@ -706,6 +849,6 @@ try {
|
||||
}
|
||||
} catch {}
|
||||
try {
|
||||
relayProcess?.kill("SIGTERM");
|
||||
await terminateChildProcess(relayProcess, "local relay");
|
||||
} catch {}
|
||||
}
|
||||
|
||||
144
packages/desktop/scripts/sign-managed-runtime-macos.mjs
Normal file
144
packages/desktop/scripts/sign-managed-runtime-macos.mjs
Normal file
@@ -0,0 +1,144 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const repoRoot = path.resolve(new URL("../../..", import.meta.url).pathname);
|
||||
const desktopRoot = path.join(repoRoot, "packages", "desktop");
|
||||
const resourcesRoot = path.join(desktopRoot, "src-tauri", "resources", "managed-runtime");
|
||||
const signingIdentity = process.env.APPLE_SIGNING_IDENTITY;
|
||||
|
||||
if (process.platform !== "darwin") {
|
||||
throw new Error("sign-managed-runtime-macos.mjs can only run on macOS.");
|
||||
}
|
||||
|
||||
if (!signingIdentity) {
|
||||
throw new Error("APPLE_SIGNING_IDENTITY is required to sign the managed runtime.");
|
||||
}
|
||||
|
||||
async function pathExists(target) {
|
||||
try {
|
||||
await fs.access(target);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function walkFiles(root) {
|
||||
const files = [];
|
||||
async function walk(current) {
|
||||
const stat = await fs.stat(current);
|
||||
if (stat.isDirectory()) {
|
||||
const children = await fs.readdir(current);
|
||||
children.sort();
|
||||
for (const child of children) {
|
||||
await walk(path.join(current, child));
|
||||
}
|
||||
return;
|
||||
}
|
||||
files.push(current);
|
||||
}
|
||||
await walk(root);
|
||||
return files;
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
const result = spawnSync(command, args, {
|
||||
stdio: "pipe",
|
||||
encoding: "utf8",
|
||||
...options,
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
[
|
||||
`Command failed: ${command} ${args.join(" ")}`,
|
||||
result.stdout?.trim(),
|
||||
result.stderr?.trim(),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
);
|
||||
}
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
function getFileKind(target) {
|
||||
return run("file", ["-b", target]);
|
||||
}
|
||||
|
||||
function isMachO(fileKind) {
|
||||
return fileKind.includes("Mach-O");
|
||||
}
|
||||
|
||||
function needsHardenedRuntime(fileKind) {
|
||||
return fileKind.includes("executable");
|
||||
}
|
||||
|
||||
function extractEntitlements(file) {
|
||||
const result = spawnSync("codesign", ["-d", "--entitlements", "-", "--xml", file], {
|
||||
stdio: "pipe",
|
||||
encoding: "utf8",
|
||||
});
|
||||
if (result.status !== 0 || !result.stdout || result.stdout.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const pointer = JSON.parse(
|
||||
await fs.readFile(path.join(resourcesRoot, "current-runtime.json"), "utf8")
|
||||
);
|
||||
const runtimeRoot = path.join(resourcesRoot, pointer.relativeRoot);
|
||||
if (!(await pathExists(runtimeRoot))) {
|
||||
throw new Error(`Managed runtime root does not exist: ${runtimeRoot}`);
|
||||
}
|
||||
|
||||
const files = await walkFiles(runtimeRoot);
|
||||
const signTargets = [];
|
||||
for (const file of files) {
|
||||
const fileKind = getFileKind(file);
|
||||
if (!isMachO(fileKind)) {
|
||||
continue;
|
||||
}
|
||||
signTargets.push({
|
||||
file,
|
||||
needsRuntime: needsHardenedRuntime(fileKind),
|
||||
});
|
||||
}
|
||||
|
||||
signTargets.sort((left, right) => left.file.localeCompare(right.file));
|
||||
|
||||
for (const target of signTargets) {
|
||||
let entitlementsFile = null;
|
||||
if (target.needsRuntime) {
|
||||
const entitlements = extractEntitlements(target.file);
|
||||
if (entitlements) {
|
||||
entitlementsFile = `${target.file}.entitlements.plist`;
|
||||
await fs.writeFile(entitlementsFile, entitlements, "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
const args = [
|
||||
"--force",
|
||||
"--sign",
|
||||
signingIdentity,
|
||||
"--timestamp",
|
||||
];
|
||||
if (target.needsRuntime) {
|
||||
args.push("--options", "runtime");
|
||||
if (entitlementsFile) {
|
||||
args.push("--entitlements", entitlementsFile);
|
||||
}
|
||||
}
|
||||
args.push(target.file);
|
||||
console.log(`[managed-runtime-sign] ${path.relative(repoRoot, target.file)}`);
|
||||
run("codesign", args);
|
||||
|
||||
if (entitlementsFile) {
|
||||
await fs.unlink(entitlementsFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await main();
|
||||
62
packages/desktop/scripts/validate-managed-runtime.mjs
Normal file
62
packages/desktop/scripts/validate-managed-runtime.mjs
Normal file
@@ -0,0 +1,62 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const desktopRoot = fileURLToPath(new URL("..", import.meta.url));
|
||||
const resourcesRoot = path.join(desktopRoot, "src-tauri", "resources", "managed-runtime");
|
||||
|
||||
const desktopPackageJson = JSON.parse(
|
||||
await fs.readFile(path.join(desktopRoot, "package.json"), "utf8")
|
||||
);
|
||||
const expectedVersion = desktopPackageJson.version;
|
||||
|
||||
const currentRuntime = JSON.parse(
|
||||
await fs.readFile(path.join(resourcesRoot, "current-runtime.json"), "utf8")
|
||||
);
|
||||
assert.equal(currentRuntime.runtimeVersion, expectedVersion, "current-runtime.json version mismatch");
|
||||
assert.ok(currentRuntime.runtimeId, "current-runtime.json missing runtimeId");
|
||||
assert.ok(currentRuntime.relativeRoot, "current-runtime.json missing relativeRoot");
|
||||
|
||||
const runtimeRoot = path.join(resourcesRoot, currentRuntime.relativeRoot);
|
||||
const manifest = JSON.parse(
|
||||
await fs.readFile(path.join(runtimeRoot, "runtime-manifest.json"), "utf8")
|
||||
);
|
||||
assert.equal(manifest.runtimeId, currentRuntime.runtimeId, "manifest runtimeId mismatch");
|
||||
assert.equal(manifest.runtimeVersion, expectedVersion, "manifest version mismatch");
|
||||
assert.ok(manifest.nodeRelativePath, "manifest missing nodeRelativePath");
|
||||
assert.ok(manifest.cliEntrypointRelativePath, "manifest missing cliEntrypointRelativePath");
|
||||
assert.ok(manifest.serverRunnerRelativePath, "manifest missing serverRunnerRelativePath");
|
||||
|
||||
const nodeBinary = path.join(runtimeRoot, manifest.nodeRelativePath);
|
||||
await fs.access(nodeBinary).catch(() => {
|
||||
throw new Error(`Bundled Node binary not found: ${nodeBinary}`);
|
||||
});
|
||||
|
||||
const cliEntry = path.join(runtimeRoot, manifest.cliEntrypointRelativePath);
|
||||
await fs.access(cliEntry).catch(() => {
|
||||
throw new Error(`CLI entrypoint not found: ${cliEntry}`);
|
||||
});
|
||||
|
||||
const serverRunner = path.join(runtimeRoot, manifest.serverRunnerRelativePath);
|
||||
await fs.access(serverRunner).catch(() => {
|
||||
throw new Error(`Server runner not found: ${serverRunner}`);
|
||||
});
|
||||
|
||||
const runtimePackageJson = JSON.parse(
|
||||
await fs.readFile(path.join(runtimeRoot, "package.json"), "utf8")
|
||||
);
|
||||
assert.equal(runtimePackageJson.version, expectedVersion, "runtime package.json version mismatch");
|
||||
|
||||
for (const pkg of ["@getpaseo/relay", "@getpaseo/server", "@getpaseo/cli"]) {
|
||||
const pkgDir = path.join(runtimeRoot, "node_modules", ...pkg.split("/"));
|
||||
await fs.access(pkgDir).catch(() => {
|
||||
throw new Error(`Missing bundled dependency: ${pkg} (expected at ${pkgDir})`);
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`[validate-managed-runtime] PASS`);
|
||||
console.log(` runtimeId: ${manifest.runtimeId}`);
|
||||
console.log(` version: ${expectedVersion}`);
|
||||
console.log(` platform: ${manifest.platform}`);
|
||||
console.log(` arch: ${manifest.arch}`);
|
||||
2
packages/desktop/src-tauri/Cargo.lock
generated
2
packages/desktop/src-tauri/Cargo.lock
generated
@@ -2629,7 +2629,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "paseo"
|
||||
version = "0.1.19"
|
||||
version = "0.1.24"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"dirs",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "paseo"
|
||||
version = "0.1.19"
|
||||
version = "0.1.25"
|
||||
description = "Paseo Desktop"
|
||||
authors = ["moboudra"]
|
||||
license = "MIT"
|
||||
@@ -42,3 +42,9 @@ tokio-tungstenite = "0.24"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
<key>CFBundleName</key>
|
||||
<string>Paseo</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.1.19</string>
|
||||
<string>0.1.25</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>0.1.19</string>
|
||||
<string>0.1.25</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Paseo needs access to your microphone for voice dictation and voice mode.</string>
|
||||
</dict>
|
||||
|
||||
@@ -4,6 +4,8 @@ use http::Request;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::env;
|
||||
#[cfg(windows)]
|
||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "Paseo",
|
||||
"version": "0.1.19",
|
||||
"version": "0.1.25",
|
||||
"identifier": "dev.paseo.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../../app/dist",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.19",
|
||||
"version": "0.1.25",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.19",
|
||||
"version": "0.1.25",
|
||||
"description": "Paseo backend server",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
@@ -36,7 +36,7 @@
|
||||
"dev:tsx": "NODE_ENV=development tsx watch --ignore '**/*.timestamp-*' src/server/index.ts",
|
||||
"build": "node -e \"require('node:fs').rmSync('dist',{ recursive: true, force: true })\" && npm run build:lib && npm run build:scripts",
|
||||
"build:lib": "tsc -p tsconfig.server.json --incremental false",
|
||||
"build:scripts": "tsc -p tsconfig.scripts.json --incremental false && mkdir -p dist/scripts && cp scripts/mcp-stdio-socket-bridge-cli.mjs dist/scripts/mcp-stdio-socket-bridge-cli.mjs",
|
||||
"build:scripts": "tsc -p tsconfig.scripts.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/scripts',{recursive:true}); fs.copyFileSync('scripts/mcp-stdio-socket-bridge-cli.mjs','dist/scripts/mcp-stdio-socket-bridge-cli.mjs');\"",
|
||||
"prepack": "npm run build",
|
||||
"start": "NODE_ENV=production node dist/server/server/index.js",
|
||||
"typecheck": "tsc -p tsconfig.server.typecheck.json --noEmit",
|
||||
@@ -63,7 +63,7 @@
|
||||
"@ai-sdk/openai": "2.0.52",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@deepgram/sdk": "^3.4.0",
|
||||
"@getpaseo/relay": "0.1.19",
|
||||
"@getpaseo/relay": "0.1.25",
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/css": "^1.3.0",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
@@ -81,6 +81,7 @@
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.18.2",
|
||||
"express-basic-auth": "^1.2.1",
|
||||
"fast-uri": "^3.1.0",
|
||||
"lezer-elixir": "^1.1.2",
|
||||
"mnemonic-id": "^3.2.7",
|
||||
"node-pty": "1.2.0-beta.11",
|
||||
@@ -89,11 +90,11 @@
|
||||
"pino": "^10.2.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"qrcode": "^1.5.4",
|
||||
"rotating-file-stream": "^3.2.9",
|
||||
"sherpa-onnx": "^1.12.23",
|
||||
"sherpa-onnx-node": "^1.12.23",
|
||||
"strip-ansi": "^7.1.2",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"rotating-file-stream": "^3.2.9",
|
||||
"uuid": "^9.0.1",
|
||||
"ws": "^8.14.2",
|
||||
"zod": "^3.23.8",
|
||||
|
||||
@@ -291,7 +291,12 @@ describe("ClaudeAgentSession interrupt restart regression", () => {
|
||||
await firstTurn.next();
|
||||
|
||||
const secondTurnPromise = collectUntilTerminal(session.stream("second prompt"));
|
||||
await Promise.resolve();
|
||||
for (let attempt = 0; attempt < 40; attempt += 1) {
|
||||
if (sdkMocks.secondQuery) {
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
sdkMocks.releaseOldAssistant?.();
|
||||
|
||||
const secondTurnEvents = await secondTurnPromise;
|
||||
@@ -309,6 +314,93 @@ describe("ClaudeAgentSession interrupt restart regression", () => {
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("ignores stale interrupted query completion after the replacement run starts", async () => {
|
||||
const logger = createTestLogger();
|
||||
const releaseOldDone = deferred<void>();
|
||||
let queryCreateCount = 0;
|
||||
|
||||
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||
queryCreateCount += 1;
|
||||
if (queryCreateCount === 1) {
|
||||
let step = 0;
|
||||
const mock = {
|
||||
next: vi.fn(async () => {
|
||||
if (step === 0) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "interrupt-stale-done-session",
|
||||
permissionMode: "default",
|
||||
model: "opus",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 1) {
|
||||
await releaseOldDone.promise;
|
||||
step += 1;
|
||||
return { done: true, value: undefined };
|
||||
}
|
||||
return { done: true, value: undefined };
|
||||
}),
|
||||
interrupt: vi.fn(async () => undefined),
|
||||
return: vi.fn(async () => undefined),
|
||||
setPermissionMode: vi.fn(async () => undefined),
|
||||
setModel: vi.fn(async () => undefined),
|
||||
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
|
||||
supportedCommands: vi.fn(async () => []),
|
||||
rewindFiles: vi.fn(async () => ({ canRewind: true })),
|
||||
} satisfies QueryMock;
|
||||
sdkMocks.firstQuery = mock;
|
||||
return mock;
|
||||
}
|
||||
|
||||
const mock = buildSecondQueryMock(prompt);
|
||||
if (queryCreateCount === 2) {
|
||||
sdkMocks.secondQuery = mock;
|
||||
}
|
||||
return mock;
|
||||
});
|
||||
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
const firstTurn = session.stream("first prompt");
|
||||
await firstTurn.next();
|
||||
|
||||
const secondTurnPromise = collectUntilTerminal(session.stream("second prompt"));
|
||||
for (let attempt = 0; attempt < 40; attempt += 1) {
|
||||
if (sdkMocks.secondQuery) {
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
releaseOldDone.resolve(undefined);
|
||||
|
||||
const secondTurnEvents = await secondTurnPromise;
|
||||
const secondAssistantText = collectAssistantText(secondTurnEvents);
|
||||
|
||||
expect(sdkMocks.firstQuery?.interrupt).toHaveBeenCalledTimes(1);
|
||||
expect(sdkMocks.secondQuery?.next).toHaveBeenCalled();
|
||||
expect(secondAssistantText).toContain("NEW_TURN_RESPONSE");
|
||||
expect(
|
||||
secondTurnEvents.some(
|
||||
(event) =>
|
||||
event.type === "turn_failed" &&
|
||||
event.error.includes("Claude stream ended before terminal result")
|
||||
)
|
||||
).toBe(false);
|
||||
expect(secondTurnEvents.some((event) => event.type === "turn_completed")).toBe(true);
|
||||
|
||||
await firstTurn.return?.();
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("ignores stale task-notification assistant/result events queued before the current prompt", async () => {
|
||||
const logger = createTestLogger();
|
||||
|
||||
|
||||
@@ -1449,9 +1449,11 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
});
|
||||
|
||||
const logger = createTestLogger();
|
||||
const claudeClient = new ClaudeAgentClient({ logger });
|
||||
vi.spyOn(claudeClient, "isAvailable").mockResolvedValue(true);
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
claude: new ClaudeAgentClient({ logger }),
|
||||
claude: claudeClient,
|
||||
},
|
||||
logger,
|
||||
});
|
||||
|
||||
@@ -1038,7 +1038,7 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
|
||||
|
||||
let capturedQuestion: AgentPermissionRequest | null = null;
|
||||
let sawResolvedAllow = false;
|
||||
let sawDone = false;
|
||||
let assistantText = "";
|
||||
|
||||
for await (const event of session.stream(prompt)) {
|
||||
if (
|
||||
@@ -1071,10 +1071,9 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
|
||||
|
||||
if (
|
||||
event.type === "timeline" &&
|
||||
event.item.type === "assistant_message" &&
|
||||
event.item.text.includes("QUESTION_FLOW_DONE")
|
||||
event.item.type === "assistant_message"
|
||||
) {
|
||||
sawDone = true;
|
||||
assistantText += event.item.text;
|
||||
}
|
||||
|
||||
if (event.type === "turn_completed" || event.type === "turn_failed") {
|
||||
@@ -1085,7 +1084,7 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
|
||||
expect(capturedQuestion).not.toBeNull();
|
||||
expect(sawResolvedAllow).toBe(true);
|
||||
expect(session.getPendingPermissions()).toHaveLength(0);
|
||||
expect(sawDone).toBe(true);
|
||||
expect(assistantText).toContain("QUESTION_FLOW_DONE");
|
||||
} finally {
|
||||
await closeSessionAndCleanup(session, cwd);
|
||||
}
|
||||
|
||||
@@ -363,14 +363,18 @@ type ClaudeAgentSessionOptions = {
|
||||
logger: Logger;
|
||||
};
|
||||
|
||||
function resolveClaudeBinary(): string {
|
||||
function whichClaude(): string | null {
|
||||
try {
|
||||
const claudePath = execSync("which claude", { encoding: "utf8" }).trim();
|
||||
if (claudePath) {
|
||||
return claudePath;
|
||||
}
|
||||
return execSync("which claude", { encoding: "utf8" }).trim() || null;
|
||||
} catch {
|
||||
// fall through
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveClaudeBinary(): string {
|
||||
const claudePath = whichClaude();
|
||||
if (claudePath) {
|
||||
return claudePath;
|
||||
}
|
||||
throw new Error(
|
||||
"Claude CLI not found. Install claude or configure agents.providers.claude.command.mode='replace'."
|
||||
@@ -1410,10 +1414,16 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
this.defaults = options.defaults;
|
||||
this.logger = options.logger.child({ module: "agent", provider: "claude" });
|
||||
this.runtimeSettings = options.runtimeSettings;
|
||||
try {
|
||||
this.claudePath = execSync("which claude", { encoding: "utf8" }).trim() || null;
|
||||
} catch {
|
||||
this.claudePath = null;
|
||||
this.claudePath = whichClaude();
|
||||
if (this.claudePath) {
|
||||
try {
|
||||
const version = execSync(`${this.claudePath} --version`, { encoding: "utf8" }).trim();
|
||||
this.logger.trace({ claudePath: this.claudePath, version }, "Resolved Claude binary");
|
||||
} catch {
|
||||
this.logger.trace({ claudePath: this.claudePath }, "Resolved Claude binary (version unknown)");
|
||||
}
|
||||
} else {
|
||||
this.logger.trace("Claude binary not found in PATH; SDK will use bundled binary");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2854,7 +2864,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
const pump = this.runQueryPump().catch((error) => {
|
||||
this.logger.warn({ err: error }, "Claude query pump exited unexpectedly");
|
||||
this.logger.trace({ err: error }, "Claude query pump exited unexpectedly");
|
||||
});
|
||||
|
||||
this.queryPumpPromise = pump;
|
||||
@@ -2876,7 +2886,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
try {
|
||||
q = await this.ensureQuery();
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error }, "Failed to initialize Claude query pump");
|
||||
this.logger.trace({ err: error }, "Failed to initialize Claude query pump");
|
||||
await this.waitForLiveHistoryPoll();
|
||||
continue;
|
||||
}
|
||||
@@ -2884,12 +2894,23 @@ class ClaudeAgentSession implements AgentSession {
|
||||
let next: IteratorResult<SDKMessage, void>;
|
||||
try {
|
||||
next = await q.next();
|
||||
this.logger.info(
|
||||
this.logger.trace(
|
||||
{ claudeSessionId: this.claudeSessionId, next },
|
||||
"Claude query pump raw next()"
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error }, "Claude query pump next() failed");
|
||||
if (this.query !== q) {
|
||||
this.logger.trace(
|
||||
{ err: error, staleQuery: true },
|
||||
"Ignoring Claude query pump next() failure from replaced query"
|
||||
);
|
||||
await this.awaitWithTimeout(
|
||||
q.return?.(),
|
||||
"query pump return after stale failure"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
this.logger.trace({ err: error }, "Claude query pump next() failed");
|
||||
for (const run of this.runTracker.listActiveRuns()) {
|
||||
this.failRun(
|
||||
run,
|
||||
@@ -2907,6 +2928,21 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
if (next.done) {
|
||||
if (this.query !== q) {
|
||||
this.logger.trace(
|
||||
{
|
||||
claudeSessionId: this.claudeSessionId,
|
||||
activeRunCount: this.runTracker.listActiveRuns().length,
|
||||
staleQuery: true,
|
||||
},
|
||||
"Ignoring replaced Claude query pump completion"
|
||||
);
|
||||
await this.awaitWithTimeout(
|
||||
q.return?.(),
|
||||
"query pump return on stale done"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
this.logger.trace(
|
||||
{
|
||||
claudeSessionId: this.claudeSessionId,
|
||||
@@ -2935,6 +2971,22 @@ class ClaudeAgentSession implements AgentSession {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.query !== q) {
|
||||
this.logger.trace(
|
||||
{
|
||||
claudeSessionId: this.claudeSessionId,
|
||||
messageType: sdkMessage.type,
|
||||
staleQuery: true,
|
||||
},
|
||||
"Ignoring Claude SDK message from replaced query"
|
||||
);
|
||||
await this.awaitWithTimeout(
|
||||
q.return?.(),
|
||||
"query pump return on stale message"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (await this.handleMissingResumedConversation(sdkMessage, q)) {
|
||||
continue;
|
||||
}
|
||||
@@ -2942,7 +2994,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
try {
|
||||
this.routeSdkMessageFromPump(sdkMessage);
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error }, "Failed to route Claude SDK message from query pump");
|
||||
this.logger.trace({ err: error }, "Failed to route Claude SDK message from query pump");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3219,22 +3271,22 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private async interruptActiveTurn(): Promise<void> {
|
||||
const queryToInterrupt = this.query;
|
||||
if (!queryToInterrupt || typeof queryToInterrupt.interrupt !== "function") {
|
||||
this.logger.debug("interruptActiveTurn: no query to interrupt");
|
||||
this.logger.trace("interruptActiveTurn: no query to interrupt");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.logger.debug("interruptActiveTurn: calling query.interrupt()...");
|
||||
this.logger.trace("interruptActiveTurn: calling query.interrupt()...");
|
||||
const t0 = Date.now();
|
||||
await queryToInterrupt.interrupt();
|
||||
this.logger.debug({ durationMs: Date.now() - t0 }, "interruptActiveTurn: query.interrupt() returned");
|
||||
this.logger.trace({ durationMs: Date.now() - t0 }, "interruptActiveTurn: query.interrupt() returned");
|
||||
// After interrupt(), the query iterator is done (returns done: true).
|
||||
// Clear it so ensureQuery() creates a fresh query for the next turn.
|
||||
// Also end the input stream and call return() to clean up the SDK process.
|
||||
this.input?.end();
|
||||
this.logger.debug("interruptActiveTurn: calling query.return()...");
|
||||
this.logger.trace("interruptActiveTurn: calling query.return()...");
|
||||
const t1 = Date.now();
|
||||
await queryToInterrupt.return?.();
|
||||
this.logger.debug({ durationMs: Date.now() - t1 }, "interruptActiveTurn: query.return() returned");
|
||||
this.logger.trace({ durationMs: Date.now() - t1 }, "interruptActiveTurn: query.return() returned");
|
||||
this.query = null;
|
||||
this.input = null;
|
||||
this.queryRestartNeeded = false;
|
||||
|
||||
@@ -137,6 +137,7 @@ import {
|
||||
import { createAgentWorktree, runAsyncWorktreeBootstrap } from './worktree-bootstrap.js'
|
||||
import {
|
||||
getCheckoutDiff,
|
||||
getCheckoutShortstat,
|
||||
getCheckoutStatus,
|
||||
listBranchSuggestions,
|
||||
NotGitRepoError,
|
||||
@@ -5253,6 +5254,13 @@ export class Session {
|
||||
// Fall back to the persisted label if checkout metadata is unavailable.
|
||||
}
|
||||
|
||||
let diffStat: { additions: number; deletions: number } | null = null
|
||||
try {
|
||||
diffStat = await getCheckoutShortstat(workspace.cwd)
|
||||
} catch {
|
||||
// Non-critical — leave null on failure.
|
||||
}
|
||||
|
||||
return {
|
||||
id: workspace.workspaceId,
|
||||
projectId: workspace.projectId,
|
||||
@@ -5263,6 +5271,7 @@ export class Session {
|
||||
name: displayName,
|
||||
status: 'done',
|
||||
activityAt: null,
|
||||
diffStat,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1490,6 +1490,10 @@ export const WorkspaceDescriptorPayloadSchema = z.object({
|
||||
name: z.string(),
|
||||
status: WorkspaceStateBucketSchema,
|
||||
activityAt: z.string().nullable(),
|
||||
diffStat: z.object({
|
||||
additions: z.number(),
|
||||
deletions: z.number(),
|
||||
}).nullable().optional(),
|
||||
})
|
||||
|
||||
export const AgentUpdateMessageSchema = z.object({
|
||||
|
||||
@@ -6,6 +6,7 @@ import { tmpdir } from "os";
|
||||
import {
|
||||
commitAll,
|
||||
getCheckoutDiff,
|
||||
getCheckoutShortstat,
|
||||
getPullRequestStatus,
|
||||
getCheckoutStatus,
|
||||
getCheckoutStatusLite,
|
||||
@@ -138,6 +139,39 @@ describe("checkout git utilities", () => {
|
||||
expect(divergedStatus.behindOfOrigin).toBe(1);
|
||||
});
|
||||
|
||||
it("uses the freshest comparison base for status and shortstat when local main is stale", async () => {
|
||||
const remoteDir = join(tempDir, "remote.git");
|
||||
const cloneDir = join(tempDir, "clone");
|
||||
execSync(`git init --bare -b main ${remoteDir}`);
|
||||
execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir });
|
||||
execSync("git push -u origin main", { cwd: repoDir });
|
||||
|
||||
execSync(`git clone ${remoteDir} ${cloneDir}`);
|
||||
execSync("git config user.email 'test@test.com'", { cwd: cloneDir });
|
||||
execSync("git config user.name 'Test'", { cwd: cloneDir });
|
||||
writeFileSync(join(cloneDir, "upstream.txt"), "upstream 1\nupstream 2\n");
|
||||
execSync("git add upstream.txt", { cwd: cloneDir });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'remote update'", { cwd: cloneDir });
|
||||
execSync("git push", { cwd: cloneDir });
|
||||
|
||||
execSync("git fetch origin", { cwd: repoDir });
|
||||
execSync("git checkout -b feature origin/main", { cwd: repoDir });
|
||||
writeFileSync(join(repoDir, "feature.txt"), "feature\n");
|
||||
execSync("git add feature.txt", { cwd: repoDir });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'feature update'", { cwd: repoDir });
|
||||
|
||||
const status = await getCheckoutStatus(repoDir);
|
||||
expect(status.isGit).toBe(true);
|
||||
if (!status.isGit) {
|
||||
return;
|
||||
}
|
||||
expect(status.baseRef).toBe("main");
|
||||
expect(status.aheadBehind).toEqual({ ahead: 1, behind: 0 });
|
||||
|
||||
const shortstat = await getCheckoutShortstat(repoDir);
|
||||
expect(shortstat).toEqual({ additions: 1, deletions: 0 });
|
||||
});
|
||||
|
||||
it("commits messages with quotes safely", async () => {
|
||||
const message = `He said "hello" and it's fine`;
|
||||
writeFileSync(join(repoDir, "file.txt"), "quoted\n");
|
||||
|
||||
@@ -771,7 +771,7 @@ async function doesGitRefExist(cwd: string, fullRef: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveBestBaseRefForMerge(cwd: string, normalizedBaseRef: string): Promise<string> {
|
||||
async function resolveBestComparisonBaseRef(cwd: string, normalizedBaseRef: string): Promise<string> {
|
||||
const [hasLocal, hasOrigin] = await Promise.all([
|
||||
doesGitRefExist(cwd, `refs/heads/${normalizedBaseRef}`),
|
||||
doesGitRefExist(cwd, `refs/remotes/origin/${normalizedBaseRef}`),
|
||||
@@ -811,8 +811,9 @@ async function getAheadBehind(cwd: string, baseRef: string, currentBranch: strin
|
||||
if (!normalizedBaseRef || !currentBranch || normalizedBaseRef === currentBranch) {
|
||||
return null;
|
||||
}
|
||||
const comparisonBaseRef = await resolveBestComparisonBaseRef(cwd, normalizedBaseRef);
|
||||
const { stdout } = await execAsync(
|
||||
`git rev-list --left-right --count ${normalizedBaseRef}...${currentBranch}`,
|
||||
`git rev-list --left-right --count ${comparisonBaseRef}...${currentBranch}`,
|
||||
{ cwd, env: READ_ONLY_GIT_ENV }
|
||||
);
|
||||
const [behindRaw, aheadRaw] = stdout.trim().split(/\s+/);
|
||||
@@ -1087,6 +1088,82 @@ export async function getCheckoutStatusLite(
|
||||
};
|
||||
}
|
||||
|
||||
export interface CheckoutShortstat {
|
||||
additions: number;
|
||||
deletions: number;
|
||||
}
|
||||
|
||||
export async function getCheckoutShortstat(
|
||||
cwd: string,
|
||||
context?: CheckoutContext
|
||||
): Promise<CheckoutShortstat | null> {
|
||||
try {
|
||||
await requireGitRepo(cwd);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const configured = await getConfiguredBaseRefForCwd(cwd, context);
|
||||
const localBaseRef = configured.baseRef ?? (await resolveBaseRef(cwd));
|
||||
if (!localBaseRef) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentBranch = await getCurrentBranch(cwd);
|
||||
if (currentBranch === localBaseRef) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const comparisonBaseRef = await resolveBestComparisonBaseRef(
|
||||
cwd,
|
||||
normalizeLocalBranchRefName(localBaseRef)
|
||||
);
|
||||
|
||||
let mergeBase: string;
|
||||
try {
|
||||
const { stdout } = await execAsync(`git merge-base HEAD ${comparisonBaseRef}`, {
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
mergeBase = stdout.trim();
|
||||
if (!mergeBase) {
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await execAsync(`git diff --shortstat ${mergeBase} HEAD`, {
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
const text = stdout.trim();
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let additions = 0;
|
||||
let deletions = 0;
|
||||
const addMatch = text.match(/(\d+)\s+insertion/);
|
||||
if (addMatch) {
|
||||
additions = Number.parseInt(addMatch[1]!, 10);
|
||||
}
|
||||
const delMatch = text.match(/(\d+)\s+deletion/);
|
||||
if (delMatch) {
|
||||
deletions = Number.parseInt(delMatch[1]!, 10);
|
||||
}
|
||||
|
||||
if (additions === 0 && deletions === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { additions, deletions };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCheckoutDiff(
|
||||
cwd: string,
|
||||
compare: CheckoutDiffCompare,
|
||||
@@ -1109,7 +1186,7 @@ export async function getCheckoutDiff(
|
||||
}
|
||||
|
||||
const normalizedBaseRef = normalizeLocalBranchRefName(baseRef);
|
||||
const bestBaseRef = await resolveBestBaseRefForMerge(cwd, normalizedBaseRef);
|
||||
const bestBaseRef = await resolveBestComparisonBaseRef(cwd, normalizedBaseRef);
|
||||
refForDiff = (await tryResolveMergeBase(cwd, bestBaseRef)) ?? bestBaseRef;
|
||||
}
|
||||
|
||||
@@ -1450,7 +1527,7 @@ export async function mergeFromBase(
|
||||
}
|
||||
|
||||
const normalizedBaseRef = normalizeLocalBranchRefName(baseRef);
|
||||
const bestBaseRef = await resolveBestBaseRefForMerge(cwd, normalizedBaseRef);
|
||||
const bestBaseRef = await resolveBestComparisonBaseRef(cwd, normalizedBaseRef);
|
||||
if (bestBaseRef === currentBranch) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.19",
|
||||
"version": "0.1.25",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -15,8 +15,9 @@
|
||||
"@cloudflare/workers-types": "^4.20260114.0",
|
||||
"@tanstack/react-router": "^1.120.3",
|
||||
"@tanstack/react-start": "^1.120.3",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"framer-motion": "^12.35.2",
|
||||
"react": "^19.1.4",
|
||||
"react-dom": "^19.1.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"wrangler": "^4.59.1"
|
||||
},
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 2.1 MiB |
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,11 @@ if (!hasDraggableFlatlist) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const cmd = process.platform === "win32" ? "patch-package.cmd" : "patch-package";
|
||||
const result = spawnSync(cmd, { stdio: "inherit" });
|
||||
const isWindows = process.platform === "win32";
|
||||
const cmd = isWindows ? "patch-package.cmd" : "patch-package";
|
||||
const result = spawnSync(cmd, [], {
|
||||
shell: isWindows,
|
||||
stdio: "inherit",
|
||||
windowsHide: true,
|
||||
});
|
||||
process.exit(result.status ?? 1);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user