mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-30 21:23:04 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e026223c80 | ||
|
|
2190910e6f | ||
|
|
0d3f59feed | ||
|
|
edcd2fe5d5 | ||
|
|
7d02e24d84 |
@@ -34,33 +34,15 @@ jobs:
|
|||||||
|
|
||||||
- name: Resolve release tag
|
- name: Resolve release tag
|
||||||
shell: bash
|
shell: bash
|
||||||
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
- name: Ensure GitHub release exists
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
shell: bash
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
source_tag="${SOURCE_TAG}"
|
||||||
if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then
|
if [[ "$source_tag" =~ ^(android-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
|
||||||
exit 0
|
release_tag="v${BASH_REMATCH[2]}"
|
||||||
fi
|
else
|
||||||
|
release_tag="$source_tag"
|
||||||
release_args=(
|
|
||||||
release create "$RELEASE_TAG"
|
|
||||||
--repo "${{ github.repository }}"
|
|
||||||
--title "Paseo $RELEASE_TAG"
|
|
||||||
--generate-notes
|
|
||||||
)
|
|
||||||
|
|
||||||
if [[ "$IS_PRERELEASE" == "true" ]]; then
|
|
||||||
release_args+=(--prerelease)
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! gh "${release_args[@]}"; then
|
|
||||||
echo "Release creation raced with another workflow; continuing."
|
|
||||||
fi
|
fi
|
||||||
|
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -125,6 +107,24 @@ jobs:
|
|||||||
echo "asset_name=$asset_name" >> "$GITHUB_OUTPUT"
|
echo "asset_name=$asset_name" >> "$GITHUB_OUTPUT"
|
||||||
echo "asset_path=$asset_path" >> "$GITHUB_OUTPUT"
|
echo "asset_path=$asset_path" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Wait for GitHub release tag
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
for attempt in $(seq 1 90); do
|
||||||
|
if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then
|
||||||
|
echo "Found release for tag $RELEASE_TAG"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "Release for $RELEASE_TAG is not available yet (attempt $attempt/90)."
|
||||||
|
sleep 20
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Timed out waiting for GitHub release tag $RELEASE_TAG."
|
||||||
|
exit 1
|
||||||
|
|
||||||
- name: Upload APK to GitHub Release
|
- name: Upload APK to GitHub Release
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
@@ -1,209 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
format:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm install
|
|
||||||
|
|
||||||
- name: Check formatting
|
|
||||||
run: npx biome format .
|
|
||||||
|
|
||||||
typecheck:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm install
|
|
||||||
|
|
||||||
- name: Build highlight dependency
|
|
||||||
run: npm run build --workspace=@getpaseo/highlight
|
|
||||||
|
|
||||||
- name: Build relay dependency
|
|
||||||
run: npm run build --workspace=@getpaseo/relay
|
|
||||||
|
|
||||||
- name: Typecheck all packages
|
|
||||||
run: npm run typecheck
|
|
||||||
|
|
||||||
server-tests:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Fetch origin/main (worktree tests)
|
|
||||||
run: git fetch --no-tags origin main:refs/remotes/origin/main
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm install
|
|
||||||
|
|
||||||
- name: Build highlight dependency
|
|
||||||
run: npm run build --workspace=@getpaseo/highlight
|
|
||||||
|
|
||||||
- name: Build relay dependency
|
|
||||||
run: npm run build --workspace=@getpaseo/relay
|
|
||||||
|
|
||||||
- name: Run server tests
|
|
||||||
run: npm run test --workspace=@getpaseo/server
|
|
||||||
env:
|
|
||||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
|
||||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
|
||||||
|
|
||||||
server-tests-windows:
|
|
||||||
runs-on: windows-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm install
|
|
||||||
|
|
||||||
- name: Build highlight dependency
|
|
||||||
run: npm run build --workspace=@getpaseo/highlight
|
|
||||||
|
|
||||||
- name: Build relay dependency
|
|
||||||
run: npm run build --workspace=@getpaseo/relay
|
|
||||||
|
|
||||||
- name: Run Windows-critical server tests
|
|
||||||
working-directory: packages/server
|
|
||||||
run: npx vitest run src/utils/executable.test.ts src/utils/spawn.test.ts src/utils/run-git-command.test.ts src/server/agent/provider-registry.test.ts src/server/agent/provider-launch-config.test.ts src/server/agent/provider-snapshot-manager.test.ts src/server/persisted-config.test.ts
|
|
||||||
|
|
||||||
app-tests:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm install
|
|
||||||
|
|
||||||
- name: Build highlight dependency
|
|
||||||
run: npm run build --workspace=@getpaseo/highlight
|
|
||||||
|
|
||||||
- name: Run app unit tests
|
|
||||||
run: npm run test --workspace=@getpaseo/app
|
|
||||||
|
|
||||||
playwright:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm install
|
|
||||||
|
|
||||||
- name: Install Playwright browsers
|
|
||||||
run: npx playwright install --with-deps chromium
|
|
||||||
|
|
||||||
- name: Build highlight dependency
|
|
||||||
run: npm run build --workspace=@getpaseo/highlight
|
|
||||||
|
|
||||||
- name: Build relay dependency
|
|
||||||
run: npm run build --workspace=@getpaseo/relay
|
|
||||||
|
|
||||||
- name: Build server dependency
|
|
||||||
run: npm run build --workspace=@getpaseo/server
|
|
||||||
|
|
||||||
- name: Install agent CLIs for provider tests
|
|
||||||
run: npm install -g @openai/codex@0.105.0 opencode-ai
|
|
||||||
|
|
||||||
- name: Run Playwright E2E tests
|
|
||||||
run: npm run test:e2e --workspace=@getpaseo/app
|
|
||||||
env:
|
|
||||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
|
||||||
|
|
||||||
- name: Upload test artifacts
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
if: failure()
|
|
||||||
with:
|
|
||||||
name: playwright-results
|
|
||||||
path: |
|
|
||||||
packages/app/test-results/
|
|
||||||
packages/app/playwright-report/
|
|
||||||
retention-days: 7
|
|
||||||
|
|
||||||
relay-tests:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm install
|
|
||||||
|
|
||||||
- name: Build relay
|
|
||||||
run: npm run build --workspace=@getpaseo/relay
|
|
||||||
|
|
||||||
- name: Run relay tests
|
|
||||||
run: npm run test --workspace=@getpaseo/relay
|
|
||||||
|
|
||||||
cli-tests:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm install
|
|
||||||
|
|
||||||
- name: Install agent CLIs for provider tests
|
|
||||||
run: npm install -g @openai/codex@0.105.0 opencode-ai
|
|
||||||
|
|
||||||
- name: Build highlight dependency
|
|
||||||
run: npm run build --workspace=@getpaseo/highlight
|
|
||||||
|
|
||||||
- name: Run CLI tests
|
|
||||||
run: npm run test --workspace=@getpaseo/cli
|
|
||||||
env:
|
|
||||||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: '0'
|
|
||||||
PASEO_DICTATION_ENABLED: '0'
|
|
||||||
PASEO_VOICE_MODE_ENABLED: '0'
|
|
||||||
@@ -4,9 +4,7 @@ on:
|
|||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
- '!v*-rc.*'
|
|
||||||
- 'app-v*'
|
- 'app-v*'
|
||||||
- '!app-v*-rc.*'
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
if: ${{ github.event_name != 'release' || (!github.event.release.prerelease && !github.event.release.draft) }}
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -35,43 +35,8 @@ env:
|
|||||||
DESKTOP_PACKAGE_PATH: 'packages/desktop'
|
DESKTOP_PACKAGE_PATH: 'packages/desktop'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
create-release:
|
|
||||||
if: ${{ (github.event_name == 'push' && !startsWith(github.ref_name, 'desktop-macos-v') && !startsWith(github.ref_name, 'desktop-linux-v') && !startsWith(github.ref_name, 'desktop-windows-v')) || (github.event_name == 'workflow_dispatch' && github.event.inputs.platform == 'all') }}
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
sparse-checkout: scripts
|
|
||||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
|
||||||
|
|
||||||
- name: Resolve release metadata
|
|
||||||
shell: bash
|
|
||||||
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
- name: Create GitHub release
|
|
||||||
if: env.IS_SMOKE_TAG != 'true'
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" > /dev/null 2>&1; then
|
|
||||||
echo "Release $RELEASE_TAG already exists, skipping creation"
|
|
||||||
else
|
|
||||||
prerelease_flag=""
|
|
||||||
if [[ "$IS_PRERELEASE" == "true" ]]; then
|
|
||||||
prerelease_flag="--prerelease"
|
|
||||||
fi
|
|
||||||
gh release create "$RELEASE_TAG" \
|
|
||||||
--repo "${{ github.repository }}" \
|
|
||||||
--title "Paseo $RELEASE_TAG" \
|
|
||||||
--generate-notes \
|
|
||||||
$prerelease_flag
|
|
||||||
fi
|
|
||||||
|
|
||||||
publish-macos:
|
publish-macos:
|
||||||
needs: [create-release]
|
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'))) }}
|
||||||
if: ${{ always() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') && ((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:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -93,7 +58,24 @@ jobs:
|
|||||||
|
|
||||||
- name: Resolve release metadata
|
- name: Resolve release metadata
|
||||||
shell: bash
|
shell: bash
|
||||||
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
source_tag="${SOURCE_TAG}"
|
||||||
|
if [[ "$source_tag" =~ ^(desktop-(windows|linux|macos)-|desktop-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
|
||||||
|
release_tag="v${BASH_REMATCH[3]}"
|
||||||
|
else
|
||||||
|
release_tag="$source_tag"
|
||||||
|
fi
|
||||||
|
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
version="${release_tag#v}"
|
||||||
|
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: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -128,6 +110,24 @@ jobs:
|
|||||||
- name: Build web app for desktop
|
- name: Build web app for desktop
|
||||||
run: npm run build:web --workspace=@getpaseo/app
|
run: npm run build:web --workspace=@getpaseo/app
|
||||||
|
|
||||||
|
- name: Detect existing GitHub release state
|
||||||
|
if: env.IS_SMOKE_TAG != 'true'
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if release_draft="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
|
||||||
|
if [[ "$release_draft" == "true" ]]; then
|
||||||
|
release_type="draft"
|
||||||
|
else
|
||||||
|
release_type="release"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
release_type="draft"
|
||||||
|
fi
|
||||||
|
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Build desktop release
|
- name: Build desktop release
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
@@ -149,113 +149,8 @@ jobs:
|
|||||||
|
|
||||||
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --mac --${{ matrix.electron_arch }} "${publish_args[@]}"
|
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --mac --${{ matrix.electron_arch }} "${publish_args[@]}"
|
||||||
|
|
||||||
- name: Upload manifest artifact
|
|
||||||
if: env.IS_SMOKE_TAG != 'true'
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: mac-manifest-${{ matrix.electron_arch }}
|
|
||||||
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/latest-mac.yml
|
|
||||||
retention-days: 1
|
|
||||||
|
|
||||||
finalize-mac-manifest:
|
|
||||||
needs: [publish-macos]
|
|
||||||
if: ${{ needs.publish-macos.result == 'success' }}
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
sparse-checkout: scripts
|
|
||||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
|
||||||
|
|
||||||
- name: Resolve release tag
|
|
||||||
shell: bash
|
|
||||||
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
- name: Download manifest artifacts
|
|
||||||
if: env.IS_SMOKE_TAG != 'true'
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
pattern: mac-manifest-*
|
|
||||||
|
|
||||||
- name: Merge manifests
|
|
||||||
if: env.IS_SMOKE_TAG != 'true'
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
node <<'NODE'
|
|
||||||
const fs = require('node:fs');
|
|
||||||
|
|
||||||
// Simple YAML parser for electron-builder's latest-mac.yml format
|
|
||||||
function parseManifest(text) {
|
|
||||||
const lines = text.split('\n');
|
|
||||||
const result = { files: [] };
|
|
||||||
let currentFile = null;
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
if (line.startsWith('version:')) result.version = line.split(': ')[1].trim();
|
|
||||||
else if (line.startsWith('path:')) result.path = line.split(': ')[1].trim();
|
|
||||||
else if (line.startsWith('sha512:') && !currentFile) result.sha512 = line.split(': ')[1].trim();
|
|
||||||
else if (line.startsWith('releaseDate:')) result.releaseDate = line.split(': ')[1].trim().replace(/'/g, '');
|
|
||||||
else if (line.trim().startsWith('- url:')) {
|
|
||||||
currentFile = { url: line.trim().replace('- url: ', '') };
|
|
||||||
result.files.push(currentFile);
|
|
||||||
} else if (line.trim().startsWith('sha512:') && currentFile) {
|
|
||||||
currentFile.sha512 = line.trim().split(': ')[1].trim();
|
|
||||||
} else if (line.trim().startsWith('size:') && currentFile) {
|
|
||||||
currentFile.size = parseInt(line.trim().split(': ')[1].trim(), 10);
|
|
||||||
currentFile = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toYaml(manifest) {
|
|
||||||
let out = `version: ${manifest.version}\n`;
|
|
||||||
out += `files:\n`;
|
|
||||||
for (const f of manifest.files) {
|
|
||||||
out += ` - url: ${f.url}\n`;
|
|
||||||
out += ` sha512: ${f.sha512}\n`;
|
|
||||||
out += ` size: ${f.size}\n`;
|
|
||||||
}
|
|
||||||
out += `path: ${manifest.path}\n`;
|
|
||||||
out += `sha512: ${manifest.sha512}\n`;
|
|
||||||
out += `releaseDate: '${manifest.releaseDate}'\n`;
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
const arm64Text = fs.readFileSync('mac-manifest-arm64/latest-mac.yml', 'utf8');
|
|
||||||
const x64Text = fs.readFileSync('mac-manifest-x64/latest-mac.yml', 'utf8');
|
|
||||||
|
|
||||||
const arm64 = parseManifest(arm64Text);
|
|
||||||
const x64 = parseManifest(x64Text);
|
|
||||||
|
|
||||||
// Merge: all files from both, default path points to arm64 zip
|
|
||||||
const merged = {
|
|
||||||
version: arm64.version,
|
|
||||||
files: [...arm64.files, ...x64.files],
|
|
||||||
path: arm64.path,
|
|
||||||
sha512: arm64.sha512,
|
|
||||||
releaseDate: arm64.releaseDate || x64.releaseDate,
|
|
||||||
};
|
|
||||||
|
|
||||||
const output = toYaml(merged);
|
|
||||||
fs.writeFileSync('latest-mac.yml', output);
|
|
||||||
console.log('Merged manifest:\n' + output);
|
|
||||||
NODE
|
|
||||||
|
|
||||||
- name: Upload merged manifest to release
|
|
||||||
if: env.IS_SMOKE_TAG != 'true'
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: gh release upload "$RELEASE_TAG" latest-mac.yml --clobber --repo "${{ github.repository }}"
|
|
||||||
|
|
||||||
publish-linux:
|
publish-linux:
|
||||||
needs: [create-release]
|
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'))) }}
|
||||||
if: ${{ always() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') && ((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:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
packages: read
|
packages: read
|
||||||
@@ -269,7 +164,24 @@ jobs:
|
|||||||
|
|
||||||
- name: Resolve release metadata
|
- name: Resolve release metadata
|
||||||
shell: bash
|
shell: bash
|
||||||
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
source_tag="${SOURCE_TAG}"
|
||||||
|
if [[ "$source_tag" =~ ^(desktop-(windows|linux|macos)-|desktop-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
|
||||||
|
release_tag="v${BASH_REMATCH[3]}"
|
||||||
|
else
|
||||||
|
release_tag="$source_tag"
|
||||||
|
fi
|
||||||
|
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
version="${release_tag#v}"
|
||||||
|
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: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -304,6 +216,24 @@ jobs:
|
|||||||
- name: Build web app for desktop
|
- name: Build web app for desktop
|
||||||
run: npm run build:web --workspace=@getpaseo/app
|
run: npm run build:web --workspace=@getpaseo/app
|
||||||
|
|
||||||
|
- name: Detect existing GitHub release state
|
||||||
|
if: env.IS_SMOKE_TAG != 'true'
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if release_draft="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
|
||||||
|
if [[ "$release_draft" == "true" ]]; then
|
||||||
|
release_type="draft"
|
||||||
|
else
|
||||||
|
release_type="release"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
release_type="draft"
|
||||||
|
fi
|
||||||
|
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Build desktop release
|
- name: Build desktop release
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
@@ -321,8 +251,7 @@ jobs:
|
|||||||
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --linux --x64 "${publish_args[@]}"
|
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --linux --x64 "${publish_args[@]}"
|
||||||
|
|
||||||
publish-windows:
|
publish-windows:
|
||||||
needs: [create-release]
|
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'))) }}
|
||||||
if: ${{ always() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') && ((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:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
packages: read
|
packages: read
|
||||||
@@ -336,7 +265,24 @@ jobs:
|
|||||||
|
|
||||||
- name: Resolve release metadata
|
- name: Resolve release metadata
|
||||||
shell: bash
|
shell: bash
|
||||||
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
source_tag="${SOURCE_TAG}"
|
||||||
|
if [[ "$source_tag" =~ ^(desktop-(windows|linux|macos)-|desktop-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
|
||||||
|
release_tag="v${BASH_REMATCH[3]}"
|
||||||
|
else
|
||||||
|
release_tag="$source_tag"
|
||||||
|
fi
|
||||||
|
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
version="${release_tag#v}"
|
||||||
|
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: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -379,6 +325,24 @@ jobs:
|
|||||||
npx expo export --platform web
|
npx expo export --platform web
|
||||||
working-directory: packages/app
|
working-directory: packages/app
|
||||||
|
|
||||||
|
- name: Detect existing GitHub release state
|
||||||
|
if: env.IS_SMOKE_TAG != 'true'
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if release_draft="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
|
||||||
|
if [[ "$release_draft" == "true" ]]; then
|
||||||
|
release_type="draft"
|
||||||
|
else
|
||||||
|
release_type="release"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
release_type="draft"
|
||||||
|
fi
|
||||||
|
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Build desktop release
|
- name: Build desktop release
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ on:
|
|||||||
required: false
|
required: false
|
||||||
default: false
|
default: false
|
||||||
type: boolean
|
type: boolean
|
||||||
|
draft:
|
||||||
|
description: "Create missing release as draft."
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
type: boolean
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: release-notes-sync-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
group: release-notes-sync-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||||
@@ -43,6 +48,7 @@ jobs:
|
|||||||
REF: ${{ github.ref }}
|
REF: ${{ github.ref }}
|
||||||
INPUT_TAG: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && github.ref_name || github.event.inputs.tag }}
|
INPUT_TAG: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && github.ref_name || github.event.inputs.tag }}
|
||||||
INPUT_CREATE_IF_MISSING: ${{ github.event.inputs.create_if_missing }}
|
INPUT_CREATE_IF_MISSING: ${{ github.event.inputs.create_if_missing }}
|
||||||
|
INPUT_DRAFT: ${{ github.event.inputs.draft }}
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -64,4 +70,8 @@ jobs:
|
|||||||
args+=(--create-if-missing)
|
args+=(--create-if-missing)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ "${INPUT_DRAFT:-false}" = "true" ]; then
|
||||||
|
args+=(--draft)
|
||||||
|
fi
|
||||||
|
|
||||||
node scripts/sync-release-notes-from-changelog.mjs "${args[@]}"
|
node scripts/sync-release-notes-from-changelog.mjs "${args[@]}"
|
||||||
|
|||||||
-244
@@ -1,249 +1,5 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## 0.1.54 - 2026-04-12
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- Inline image previews in agent messages — screenshots and images generated by agents render directly in the conversation instead of showing as raw markdown links.
|
|
||||||
|
|
||||||
### Improved
|
|
||||||
- Paseo tools are no longer injected into agents by default — opt in from Settings when you need agent-to-agent orchestration.
|
|
||||||
- Agent provider and mode are now resolved server-side, so CLI commands like `paseo run` use consistent defaults without client-side lookups.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Shift+Enter now correctly inserts a newline in agent terminal input instead of submitting.
|
|
||||||
- Windows: MCP configuration is no longer mangled when spawning Claude agents.
|
|
||||||
- Branch ahead/behind count no longer errors for branches with no remote tracking branch.
|
|
||||||
|
|
||||||
## 0.1.53 - 2026-04-12
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- Agents get Paseo tools automatically — every new agent gets access to terminals, schedules, worktrees, and other agents through MCP. Toggle it off in Settings under "Inject Paseo tools".
|
|
||||||
- Git pull — pull remote changes directly from the workspace header. Promoted to the primary action when your branch is behind origin.
|
|
||||||
- Child agent notifications — parent agents are automatically notified when a child agent finishes, errors, or needs permission approval.
|
|
||||||
- Agent reload — `paseo agent reload` restarts an agent's underlying process from the CLI.
|
|
||||||
- Middle-click to close tabs on desktop.
|
|
||||||
- Keyboard shortcut to cycle themes.
|
|
||||||
|
|
||||||
### Improved
|
|
||||||
- Unavailable git actions now explain why in a toast instead of being silently greyed out.
|
|
||||||
- Streaming markdown on mobile renders significantly faster.
|
|
||||||
- Sidebar, branch switcher, and agent panel no longer re-render unnecessarily — noticeable on large workspaces.
|
|
||||||
- Paseo tool calls in agent timelines show the Paseo logo and human-readable names.
|
|
||||||
- Relay and pairing URLs are stripped from daemon logs.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Closed agent tabs no longer reappear after reconnecting.
|
|
||||||
- Desktop notification badge counts match across all workspaces.
|
|
||||||
- Host switcher status syncs correctly when switching between hosts.
|
|
||||||
|
|
||||||
## 0.1.52 - 2026-04-10
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- Theme selector — choose from six themes including Midnight, Claude, and Ghostty dark variants.
|
|
||||||
- Branch switching — switch git branches directly from the workspace header, with automatic stash and restore for uncommitted changes.
|
|
||||||
- Auto-download updates — desktop updates download silently in the background so they're ready to install when you are.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Layout now responds correctly when resizing the window or rotating a tablet — previously the app could get stuck in mobile layout on a large screen.
|
|
||||||
- Terminal no longer causes massive memory spikes from snapshot thrashing during heavy output.
|
|
||||||
- Typing in the terminal works reliably — special keys, Ctrl combos, and paste are handled natively by the terminal emulator.
|
|
||||||
- Initializing agents no longer show a loading spinner as if they're running.
|
|
||||||
- Reconnecting to a running agent now works even when session persistence is unavailable.
|
|
||||||
- Error screens on desktop are now scrollable.
|
|
||||||
- Model list refreshes in the background when you open the model selector.
|
|
||||||
- Draft agent feature preferences (like thinking mode) are remembered across sessions.
|
|
||||||
|
|
||||||
## 0.1.51 - 2026-04-09
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- Image attachments for OpenCode — attach screenshots and images to OpenCode agent prompts.
|
|
||||||
- WebStorm — added to the "Open in editor" list alongside Cursor, VS Code, and Zed.
|
|
||||||
- Send behavior setting — choose whether pressing Enter while an agent is running interrupts immediately or queues your message.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Model selector no longer crashes on iPad.
|
|
||||||
- Pairing now uses the correct hostname, fixing connection failures on some network setups.
|
|
||||||
- OpenCode agents show the correct terminal state and refresh models reliably.
|
|
||||||
- Follow-up messages to agents that just finished a turn now work correctly.
|
|
||||||
- Commands now load properly for Pi agents.
|
|
||||||
- Internal debug output no longer appears in Claude agent timelines.
|
|
||||||
- QR scan screen cleaned up with simpler visuals.
|
|
||||||
|
|
||||||
## 0.1.50 - 2026-04-07
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- Context window meter — see how much of the context window your agent has used, with color thresholds at 70% and 90%. Works with Claude Code, Codex, and OpenCode.
|
|
||||||
- Open in editor — jump from any workspace straight into Cursor, VS Code, Zed, or your file manager. Paseo remembers your choice.
|
|
||||||
- Side-by-side diffs — toggle between unified and split-column diff views, with a whitespace visibility option.
|
|
||||||
- Spoken messages — when using voice mode, agent speech now appears as regular messages in the conversation instead of raw tool output.
|
|
||||||
- Plan actions — plan cards now show the actions your agent supports (e.g. "Implement", "Deny") instead of generic accept/reject buttons.
|
|
||||||
- Background git fetch — ahead/behind counts in the Changes pane stay up to date automatically.
|
|
||||||
|
|
||||||
### Improved
|
|
||||||
- Workspaces load instantly on connect instead of waiting for a full sync.
|
|
||||||
- File explorer and diff pane remember which folders are expanded when you switch tabs.
|
|
||||||
- Closing a workspace tab is now instant.
|
|
||||||
- Settings shows a Refresh button for providers and displays error details inline.
|
|
||||||
- Reload agent moved away from the close button to prevent accidental taps.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Voice mode no longer drifts into false speech detection during long sessions.
|
|
||||||
- Garbled overlapping text on plan cards.
|
|
||||||
- Changes pane could show stale diffs when working with git worktrees.
|
|
||||||
- Restarting an agent quickly could crash the session.
|
|
||||||
- Copilot no longer pauses for permission prompts in autopilot mode.
|
|
||||||
- Connection and pairing dialogs now display correctly on tablets.
|
|
||||||
- Orchestration errors from agents are now surfaced instead of silently lost.
|
|
||||||
- Diff stats no longer reset to zero when reconnecting.
|
|
||||||
|
|
||||||
## 0.1.49 - 2026-04-07
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Models and providers now load reliably on first connect instead of requiring a manual refresh.
|
|
||||||
- Model picker only shows models from the agent's own provider, not every provider on the server.
|
|
||||||
- Model lists stay consistent regardless of which screen you open first.
|
|
||||||
|
|
||||||
## 0.1.48 - 2026-04-05
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- Provider diagnostics — tap a provider in Settings to see binary path, version, model count, and status at a glance. Helps troubleshoot why an agent type isn't available.
|
|
||||||
- Provider snapshot system — daemon now pushes real-time provider availability and model lists to the app, replacing the old poll-based approach. Models and modes update live as providers come online or go offline.
|
|
||||||
- Codex question handling — Codex agents can now ask the user questions mid-session (e.g. "which file?") and receive answers inline, matching the Claude Code question flow.
|
|
||||||
- Reload tab action — right-click a workspace tab to reload its agent list without restarting the app.
|
|
||||||
|
|
||||||
### Improved
|
|
||||||
- Model selector redesigned — grouped by provider with status badges, search, and better touch targets on mobile.
|
|
||||||
- Enter key now submits question card answers and confirms dictation, matching the expected keyboard flow.
|
|
||||||
- Removed noisy agent lifecycle toasts that fired on every state change.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Desktop app now resolves the user's full login shell environment at startup, fixing tools like `codex`, `node`, `bun`, and `direnv` not being found when Paseo is launched from Finder or Dock. Terminals spawned by Paseo now inherit the same PATH and environment variables as a normal terminal session. Approach adapted from VS Code's battle-tested shell environment resolution.
|
|
||||||
- Input field on running agent screens now correctly receives keyboard focus.
|
|
||||||
- Mobile model selector alignment and sizing.
|
|
||||||
|
|
||||||
## 0.1.47 - 2026-04-05
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Voice TTS in Electron — sherpa now requests copied buffers and the voice MCP bridge sets `ELECTRON_RUN_AS_NODE`, preventing "external buffers not allowed" crashes.
|
|
||||||
- QR pairing in desktop — CLI JSON output parsing now tolerates Node deprecation warnings in stdout.
|
|
||||||
- STT segment race condition — segment ID and audio buffer are snapshotted before the async transcription call, so rapid commits no longer interleave.
|
|
||||||
- Per-host "Add connection" button removed — it blocked multi-host setups by scoping new connections to a single server.
|
|
||||||
|
|
||||||
## 0.1.46 - 2026-04-04
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Voice activation in packaged builds — Silero VAD model is now copied out of the Electron asar archive so native code can read it.
|
|
||||||
- App version sent in probe client hello so the daemon's version gate no longer hides Pi/Copilot from reconnected sessions.
|
|
||||||
- `worktreeRoot` schema made backward-compatible for old clients and daemons that don't send the field.
|
|
||||||
- Punycode deprecation warning (DEP0040) suppressed in CLI and desktop daemon entrypoints.
|
|
||||||
|
|
||||||
## 0.1.45 - 2026-04-04
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- Pi (pi.dev) agent provider — connect Pi as a new ACP-based agent type with thinking levels and tool call support.
|
|
||||||
- Copilot agent provider re-enabled after ACP compatibility fixes.
|
|
||||||
- `paseo .` and `paseo <path>` open the desktop app with the given project, similar to `code .`.
|
|
||||||
- Provider-declared features system — providers can expose dynamic toggles and selects that the app renders automatically. First consumer: Codex fast mode.
|
|
||||||
- Codex plan mode — start agents in plan-only mode with a dedicated plan card UI for reviewing proposed changes before execution.
|
|
||||||
- OpenCode custom agents and slash commands — user-defined agents from opencode.json now appear in the mode picker, and slash commands accept optional arguments.
|
|
||||||
- Desktop Integrations settings — install the Paseo CLI and orchestration skills directly from the app without touching the terminal.
|
|
||||||
- Daemon status dialog in desktop settings for quick health checks.
|
|
||||||
- Auto-restart daemon on version mismatch — the desktop app detects when the running daemon is outdated and restarts it automatically.
|
|
||||||
- Setup hint and paseo.sh link on the mobile welcome screen so new App Store users know what to do next.
|
|
||||||
|
|
||||||
### Improved
|
|
||||||
- Desktop startup is faster — existing daemon connections are raced against bootstrap so the app is usable sooner.
|
|
||||||
- Settings sections reordered for better grouping (integrations and daemon together).
|
|
||||||
- Sidebar projects and workspaces now persist across sessions, with a context menu to remove projects.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Sidebar crash when switching iOS theme (Unistyles/Reanimated interaction).
|
|
||||||
- Silero VAD crash caused by external buffer mode in CircularBuffer.
|
|
||||||
- Bulk close now correctly archives stored agents instead of leaving orphans.
|
|
||||||
- Pinned archived agents are no longer pruned when closing tabs.
|
|
||||||
- OpenCode event stream starvation during slash command execution.
|
|
||||||
- Duplicate workspaces when multiple git worktrees share the same root.
|
|
||||||
- `gh` executable resolution for desktop users whose login shell sets a different PATH.
|
|
||||||
- Agent creation timeout increased to 60s to handle slow first-launch scenarios.
|
|
||||||
- Forward-compatible provider handling so older app clients don't break on new provider types.
|
|
||||||
- Input event listener race condition in the web scrollbar hook.
|
|
||||||
- Open-project screen content now vertically centered.
|
|
||||||
- Website download page fetches the release version at runtime with asset validation, fixing stale links.
|
|
||||||
|
|
||||||
## 0.1.44 - 2026-04-03
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Desktop app now stops the daemon cleanly before auto-update restarts.
|
|
||||||
- Disabled claude-acp and copilot providers from the agent registry.
|
|
||||||
- Keyboard focus scope resolution now checks multiple candidates for broader compatibility.
|
|
||||||
- OpenCode interrupt now reaches correct terminal state parity with tool-call flows.
|
|
||||||
- Shell injection, symlink escape, and pairing endpoint security hardening.
|
|
||||||
|
|
||||||
## 0.1.43 - 2026-04-02
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- Copilot agent support via ACP base provider — connect GitHub Copilot as a new agent type.
|
|
||||||
- Searchable model favorites — quickly find and pin preferred models.
|
|
||||||
- Slash command support for OpenCode agents.
|
|
||||||
|
|
||||||
### Improved
|
|
||||||
- Refined model selector UX with better mobile sheet behavior.
|
|
||||||
- Workspace status now uses amber alert styling for "needs input" state.
|
|
||||||
- Themed scrollbar on message input for consistent styling.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Ctrl+C/V copy and paste now works correctly in the terminal on Windows and Linux.
|
|
||||||
- Shell arguments with spaces are now properly quoted on Windows.
|
|
||||||
- Claude models with 1M context support are now correctly reported.
|
|
||||||
|
|
||||||
## 0.1.42 - 2026-04-01
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Fixed Claude Code failing to launch on Windows when installed to a path with spaces (e.g. `C:\Program Files\...`).
|
|
||||||
|
|
||||||
## 0.1.41 - 2026-04-01
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Fixed agent spawning on Windows — all providers (Claude, Codex, OpenCode) now use shell mode so npm shims and `.cmd` wrappers resolve correctly.
|
|
||||||
- Fixed terminal creation on Windows defaulting to a Unix shell instead of `cmd.exe`.
|
|
||||||
- Fixed path handling across the app to support Windows drive-letter paths (`C:\...`) and UNC paths (`\\...`).
|
|
||||||
- Fixed executable resolution on Windows to work with `nvm4w` and similar Node version managers.
|
|
||||||
- Eliminated white flash on window resize in dark mode by setting the native window background color to match the theme.
|
|
||||||
- Fixed titlebar drag region — replaced the fragile pointer-event approach with VS Code's proven static CSS `app-region: drag` pattern.
|
|
||||||
- Fixed context menu for copy/paste across the desktop app.
|
|
||||||
- Fixed shortcut rebinding UI to show held modifier keys and recognize additional keys (Tab, Delete, Home, End, Page Up/Down, Insert, F1–F12).
|
|
||||||
- Removed the 40-item cap on activity timeline output so long agent sessions display their full history.
|
|
||||||
|
|
||||||
### Improved
|
|
||||||
- Improved light mode theming with dedicated workspace background, scrollbar handle colors, and lighter shadows.
|
|
||||||
- Window controls overlay on Windows/Linux reduced from 48px to 29px height for a more compact titlebar.
|
|
||||||
|
|
||||||
## 0.1.40 - 2026-04-01
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- Workspace tabs can now be closed in batches.
|
|
||||||
|
|
||||||
### Improved
|
|
||||||
- Provider model lists are now cached per server and provider, reducing redundant model lookups in the UI.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- OpenCode reasoning content no longer appears duplicated as assistant text.
|
|
||||||
- Daemon no longer crashes when a Codex binary is missing or fails to spawn.
|
|
||||||
- Archive tab now correctly reconciles agent visibility after archiving.
|
|
||||||
- File diff tracking in workspaces now works correctly on Linux.
|
|
||||||
- iPad layout now renders correctly in desktop mode.
|
|
||||||
- macOS auto-updater now correctly delivers both arm64 and x64 binaries — previously whichever architecture finished building last would overwrite the other's update manifest.
|
|
||||||
|
|
||||||
## 0.1.39 - 2026-03-30
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **Terminal management from the CLI** — new `paseo terminal` command group lets you list, create, and interact with workspace terminals without leaving your terminal.
|
|
||||||
- **Material file icons in the explorer** — the file explorer tree now shows language-specific icons (TypeScript, JSON, Markdown, etc.) so you can spot files at a glance.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Fixed iOS sidebar scroll flicker caused by redundant overflow clipping.
|
|
||||||
- Centralized window controls padding into a shared hook, eliminating layout inconsistencies across platforms.
|
|
||||||
|
|
||||||
## 0.1.38 - 2026-03-30
|
## 0.1.38 - 2026-03-30
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
# CI Test Status
|
|
||||||
|
|
||||||
Tracking progress toward all-green CI.
|
|
||||||
|
|
||||||
## CI Jobs
|
|
||||||
|
|
||||||
| Job | Status | Notes |
|
|
||||||
|-----|--------|-------|
|
|
||||||
| format | unknown | `npx biome format .` |
|
|
||||||
| typecheck | unknown | `npm run typecheck` |
|
|
||||||
| server-tests | unknown | unit + integration (vitest) |
|
|
||||||
| app-tests | unknown | unit tests (vitest) |
|
|
||||||
| playwright | unknown | E2E tests (playwright) |
|
|
||||||
| relay-tests | unknown | unit tests (vitest) |
|
|
||||||
| cli-tests | unknown | local tests |
|
|
||||||
|
|
||||||
## Log
|
|
||||||
|
|
||||||
- 2026-04-10: Branch created, automated agents begin iterating
|
|
||||||
@@ -35,8 +35,8 @@ npm run dev # Start daemon + Expo in Tmux
|
|||||||
npm run cli -- ls -a -g # List all agents
|
npm run cli -- ls -a -g # List all agents
|
||||||
npm run cli -- daemon status # Check daemon status
|
npm run cli -- daemon status # Check daemon status
|
||||||
npm run typecheck # Always run after changes
|
npm run typecheck # Always run after changes
|
||||||
npm run format # Auto-format with Biome
|
npm run db:query # Show DB table row counts
|
||||||
npm run format:check # Check formatting without writing
|
npm run db:query -- "SELECT ..." # Run arbitrary SQL against SQLite
|
||||||
```
|
```
|
||||||
|
|
||||||
See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requirements, and debugging.
|
See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requirements, and debugging.
|
||||||
@@ -47,53 +47,6 @@ See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requir
|
|||||||
- **NEVER assume a timeout means the service needs restarting** — timeouts can be transient.
|
- **NEVER assume a timeout means the service needs restarting** — timeouts can be transient.
|
||||||
- **NEVER add auth checks to tests** — agent providers handle their own auth.
|
- **NEVER add auth checks to tests** — agent providers handle their own auth.
|
||||||
- **Always run typecheck after every change.**
|
- **Always run typecheck after every change.**
|
||||||
- **Run `npm run format` before committing.** This repo uses Biome for formatting. Do not manually fix formatting — let the formatter handle it.
|
|
||||||
- **NEVER make breaking changes to WebSocket or message schemas.** The primary compatibility path is old mobile app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Every schema change MUST be backward-compatible for old clients against new daemons:
|
|
||||||
- New fields: always `.optional()` with a sensible default or `.transform()` fallback.
|
|
||||||
- Never change a field from optional to required.
|
|
||||||
- Never remove a field — deprecate it (keep accepting it, stop sending it).
|
|
||||||
- Never narrow a field's type (e.g. `string` → `enum`, `nullable` → non-null).
|
|
||||||
- Test with: "does a 6-month-old client still parse this?" and "does a 6-month-old daemon still send something this client accepts?"
|
|
||||||
|
|
||||||
## Platform gating
|
|
||||||
|
|
||||||
The app runs on iOS, Android, web (browser), and web (Electron desktop). Code is cross-platform by default. Gate only when you must. Import gates from `@/constants/platform`.
|
|
||||||
|
|
||||||
### The four gates
|
|
||||||
|
|
||||||
| Gate | Type | When to use |
|
|
||||||
|---|---|---|
|
|
||||||
| `isWeb` | constant | DOM APIs — `document`, `window`, `<div>`, `addEventListener`, `ResizeObserver`. This is the **exception**, not the default. |
|
|
||||||
| `isNative` | constant | Native-only APIs — Haptics, `StatusBar.currentHeight`, push tokens, camera/scanner, `expo-av`. |
|
|
||||||
| `getIsElectron()` | cached fn | Desktop wrapper features — file dialogs, titlebar drag region, daemon management, app updates, dock badges. |
|
|
||||||
| `useIsCompactFormFactor()` | hook | Layout decisions — sidebar overlay vs pinned, modal vs full screen, single-panel vs split. From `@/constants/layout`. |
|
|
||||||
|
|
||||||
### Decision matrix
|
|
||||||
|
|
||||||
| I need to... | Use |
|
|
||||||
|---|---|
|
|
||||||
| Access DOM (`document`, `window`, `<div>`, `addEventListener`) | `if (isWeb)` |
|
|
||||||
| Use a native-only API (Haptics, push tokens, camera) | `if (isNative)` |
|
|
||||||
| Use an Electron bridge (file dialog, titlebar, updates) | `if (getIsElectron())` |
|
|
||||||
| Switch layout between phone and tablet/desktop | `useIsCompactFormFactor()` |
|
|
||||||
| Show something on hover, always-visible on native | `isHovered \|\| isNative \|\| isCompact` (hover only works on web) |
|
|
||||||
| Gate to iOS or Android specifically | `Platform.OS === "ios"` / `Platform.OS === "android"` (rare, keep inline) |
|
|
||||||
|
|
||||||
### Rules
|
|
||||||
|
|
||||||
- **Default is cross-platform.** Don't gate unless you have a specific reason.
|
|
||||||
- **Prefer Metro file extensions over `if` statements.** When a module has fundamentally different implementations per platform, use `.web.ts` / `.native.ts` file extensions instead of runtime `if (isWeb)` branches. Metro resolves the correct file at build time — the unused platform code is never bundled. Reserve `if (isWeb)` for small, inline checks (a single line or a few props). If you find yourself writing a large `if (isWeb) { ... } else { ... }` block, split into separate files instead.
|
|
||||||
```
|
|
||||||
hooks/
|
|
||||||
use-audio-recorder.web.ts ← uses Web Audio API
|
|
||||||
use-audio-recorder.native.ts ← uses expo-audio
|
|
||||||
```
|
|
||||||
Import as `@/hooks/use-audio-recorder` — Metro picks the right file automatically.
|
|
||||||
- **NEVER use raw DOM APIs without `isWeb` guard.** DOM APIs crash native. Casting a RN ref to `HTMLElement` is a red flag — ensure the block is web-only.
|
|
||||||
- **NEVER use `onPointerEnter`/`onPointerLeave`.** They don't fire on native iOS.
|
|
||||||
- **Hover only works on web.** React Native's `onHoverIn`/`onHoverOut` on `Pressable` does NOT fire on native iOS/iPad — the underlying W3C pointer events are behind disabled experimental flags. For hover-to-show UI (kebab menus, action buttons), use `isHovered || isNative || isCompact` so the controls are always visible on native and hover-to-show on web.
|
|
||||||
- **Don't use Platform.OS as a proxy for layout capabilities.** Use breakpoints for layout decisions, not platform checks.
|
|
||||||
- **Import `isWeb`/`isNative` from `@/constants/platform`.** Never write `const isWeb = Platform.OS === "web"` locally.
|
|
||||||
|
|
||||||
## Debugging
|
## Debugging
|
||||||
|
|
||||||
|
|||||||
-168
@@ -1,168 +0,0 @@
|
|||||||
# Contributing to Paseo
|
|
||||||
|
|
||||||
Thanks for taking the time to contribute.
|
|
||||||
|
|
||||||
## How this project works
|
|
||||||
|
|
||||||
Paseo is a BDFL project. Product direction, scope, and what ships are the maintainer's call.
|
|
||||||
|
|
||||||
This means:
|
|
||||||
|
|
||||||
- PRs submitted without prior discussion will likely be rejected, heavily modified, or scoped down.
|
|
||||||
- The maintainer may rewrite, split, cherry-pick from, or close any PR at their discretion.
|
|
||||||
- There is no obligation to merge a PR as-submitted, regardless of code quality.
|
|
||||||
|
|
||||||
This is not meant to discourage contributions. It is meant to set clear expectations so nobody wastes their time.
|
|
||||||
|
|
||||||
## How to contribute
|
|
||||||
|
|
||||||
1. **Open an issue first.** Describe the problem or improvement. Get a thumbs up before writing code.
|
|
||||||
2. **Keep it small.** One bug, one flow, one focused change.
|
|
||||||
3. **Open a PR** once there is alignment on scope.
|
|
||||||
|
|
||||||
If you want to propose a direction change, start a conversation.
|
|
||||||
|
|
||||||
## Before you start
|
|
||||||
|
|
||||||
Please read these first:
|
|
||||||
|
|
||||||
- [README.md](README.md)
|
|
||||||
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
|
|
||||||
- [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md)
|
|
||||||
- [docs/CODING_STANDARDS.md](docs/CODING_STANDARDS.md)
|
|
||||||
- [docs/TESTING.md](docs/TESTING.md)
|
|
||||||
- [CLAUDE.md](CLAUDE.md)
|
|
||||||
|
|
||||||
## What is most helpful
|
|
||||||
|
|
||||||
The most useful contributions right now are:
|
|
||||||
|
|
||||||
- bug fixes
|
|
||||||
- windows and linux specific fixes
|
|
||||||
- regression fixes
|
|
||||||
- doc improvements
|
|
||||||
- packaging / platform fixes
|
|
||||||
- focused UX improvements that fit the existing product direction
|
|
||||||
- tests that lock down important behavior
|
|
||||||
|
|
||||||
## Scope expectations
|
|
||||||
|
|
||||||
Please keep PRs narrow.
|
|
||||||
|
|
||||||
Good:
|
|
||||||
|
|
||||||
- fix one bug
|
|
||||||
- improve one flow
|
|
||||||
- add one focused panel or command
|
|
||||||
- tighten one piece of UI
|
|
||||||
|
|
||||||
Bad:
|
|
||||||
|
|
||||||
- combine multiple product ideas in one PR
|
|
||||||
- bundle unrelated refactors with a feature
|
|
||||||
- sneak in roadmap decisions
|
|
||||||
|
|
||||||
If a contribution contains multiple ideas, split it up.
|
|
||||||
|
|
||||||
## Product fit matters
|
|
||||||
|
|
||||||
Paseo is an opinionated product.
|
|
||||||
|
|
||||||
When reviewing contributions, the bar is not just:
|
|
||||||
|
|
||||||
- is this useful?
|
|
||||||
- is this well implemented?
|
|
||||||
|
|
||||||
It is also:
|
|
||||||
|
|
||||||
- does this fit Paseo?
|
|
||||||
- does this add product surface that will be hard to maintain?
|
|
||||||
- does the value justify the maintenance surface it adds?
|
|
||||||
- does this solve a common need or over-serve an edge case?
|
|
||||||
- does this preserve the product's current direction?
|
|
||||||
|
|
||||||
## Development setup
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
- Node.js matching `.tool-versions`
|
|
||||||
- npm workspaces
|
|
||||||
|
|
||||||
### Start local development
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# runs both daemon and expo app
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Useful commands:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run dev:server
|
|
||||||
npm run dev:app
|
|
||||||
npm run dev:desktop
|
|
||||||
npm run dev:website
|
|
||||||
npm run cli -- ls -a -g
|
|
||||||
```
|
|
||||||
|
|
||||||
Read [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for build-sync gotchas, local state, ports, and daemon details.
|
|
||||||
|
|
||||||
## Multi-platform testing
|
|
||||||
|
|
||||||
Paseo ships to mobile (iOS/Android), web, and desktop (Electron). Every UI change must be tested on mobile and web at minimum, and desktop if relevant. Things that look fine on one surface regularly break on another.
|
|
||||||
|
|
||||||
Common checks:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run typecheck
|
|
||||||
npm run test --workspaces --if-present
|
|
||||||
```
|
|
||||||
|
|
||||||
Important rules:
|
|
||||||
|
|
||||||
- always run `npm run typecheck` after changes
|
|
||||||
- tests should be deterministic
|
|
||||||
- prefer real dependencies over mocks when possible
|
|
||||||
- do not make breaking WebSocket / protocol changes
|
|
||||||
- app and daemon versions in the wild lag each other, so compatibility matters
|
|
||||||
|
|
||||||
If you touch protocol or shared client/server behavior, read the compatibility notes in [CLAUDE.md](CLAUDE.md).
|
|
||||||
|
|
||||||
## Coding standards
|
|
||||||
|
|
||||||
Paseo has explicit standards. Follow them.
|
|
||||||
|
|
||||||
The full guide lives in [docs/CODING_STANDARDS.md](docs/CODING_STANDARDS.md).
|
|
||||||
|
|
||||||
## PR checklist
|
|
||||||
|
|
||||||
Before opening a PR, make sure:
|
|
||||||
|
|
||||||
- there was prior discussion and alignment on scope (issue or conversation)
|
|
||||||
- the change is focused, one idea per PR
|
|
||||||
- the PR description explains what changed and why
|
|
||||||
- **UI changes include screenshots or videos** for every affected platform (mobile, web, desktop)
|
|
||||||
- UI changes have been tested on mobile and web at minimum
|
|
||||||
- typecheck passes
|
|
||||||
- tests pass, or you clearly explain what could not be run
|
|
||||||
- relevant docs were updated if needed
|
|
||||||
|
|
||||||
## Communication
|
|
||||||
|
|
||||||
If you are unsure whether something fits, ask first.
|
|
||||||
|
|
||||||
That is especially true for:
|
|
||||||
|
|
||||||
- new core UX
|
|
||||||
- naming / terminology changes
|
|
||||||
- new extension points
|
|
||||||
- new orchestration models
|
|
||||||
- anything that would be hard to remove later
|
|
||||||
|
|
||||||
Early alignment saves everyone time.
|
|
||||||
|
|
||||||
## Forks are fine
|
|
||||||
|
|
||||||
If you want to explore a different product direction, a fork is completely fine.
|
|
||||||
|
|
||||||
Paseo is open source on purpose. Not every idea needs to land in the main repo to be valuable.
|
|
||||||
@@ -4,21 +4,6 @@
|
|||||||
|
|
||||||
<h1 align="center">Paseo</h1>
|
<h1 align="center">Paseo</h1>
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="https://github.com/getpaseo/paseo/stargazers">
|
|
||||||
<img src="https://img.shields.io/github/stars/getpaseo/paseo?style=flat&logo=github" alt="GitHub stars">
|
|
||||||
</a>
|
|
||||||
<a href="https://github.com/getpaseo/paseo/releases">
|
|
||||||
<img src="https://img.shields.io/github/v/release/getpaseo/paseo?style=flat&logo=github" alt="GitHub release">
|
|
||||||
</a>
|
|
||||||
<a href="https://x.com/moboudra">
|
|
||||||
<img src="https://img.shields.io/badge/%40moboudra-555?logo=x" alt="X">
|
|
||||||
</a>
|
|
||||||
<a href="https://discord.gg/jz8T2uahpH">
|
|
||||||
<img src="https://img.shields.io/badge/Discord-555?logo=discord" alt="Discord">
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">One interface for all your Claude Code, Codex and OpenCode agents.</p>
|
<p align="center">One interface for all your Claude Code, Codex and OpenCode agents.</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
|||||||
+4
-16
@@ -22,7 +22,7 @@ The relay is designed to be untrusted. All traffic between your phone and daemon
|
|||||||
1. The daemon generates a persistent ECDH keypair and stores it locally
|
1. The daemon generates a persistent ECDH keypair and stores it locally
|
||||||
2. When you scan the QR code or click the pairing link, your phone receives the daemon's public key
|
2. When you scan the QR code or click the pairing link, your phone receives the daemon's public key
|
||||||
3. Your phone sends a handshake message with its own public key. The daemon will not accept any commands until this handshake completes.
|
3. Your phone sends a handshake message with its own public key. The daemon will not accept any commands until this handshake completes.
|
||||||
4. Both sides perform an ECDH key exchange to derive a shared secret. All subsequent messages are encrypted with XSalsa20-Poly1305 (NaCl box).
|
4. Both sides perform an ECDH key exchange to derive a shared secret. All subsequent messages are encrypted with AES-256-GCM.
|
||||||
|
|
||||||
The relay sees only: IP addresses, timing, message sizes, and session IDs. It cannot read message contents, forge messages, or derive encryption keys from observing the handshake.
|
The relay sees only: IP addresses, timing, message sizes, and session IDs. It cannot read message contents, forge messages, or derive encryption keys from observing the handshake.
|
||||||
|
|
||||||
@@ -31,26 +31,14 @@ The relay sees only: IP addresses, timing, message sizes, and session IDs. It ca
|
|||||||
The daemon requires a valid cryptographic handshake before processing any commands. A compromised relay cannot:
|
The daemon requires a valid cryptographic handshake before processing any commands. A compromised relay cannot:
|
||||||
|
|
||||||
- **Send commands** — Without your phone's private key, it cannot complete the handshake
|
- **Send commands** — Without your phone's private key, it cannot complete the handshake
|
||||||
- **Read your traffic** — All messages are encrypted with XSalsa20-Poly1305 (NaCl box) after the handshake
|
- **Read your traffic** — All messages are encrypted with AES-256-GCM after the handshake
|
||||||
- **Forge messages** — NaCl box provides authenticated encryption; tampered messages are rejected
|
- **Forge messages** — GCM provides authenticated encryption; tampered messages are rejected
|
||||||
- **Replay old messages across sessions** — Each session derives fresh encryption keys, so ciphertext from one session cannot be replayed into another session. Within a live session, replay protection is not yet implemented; the protocol uses random nonces and does not track nonce reuse or message counters.
|
- **Replay old messages** — Each session derives fresh encryption keys
|
||||||
|
|
||||||
### Trust model
|
### Trust model
|
||||||
|
|
||||||
The QR code or pairing link is the trust anchor. It contains the daemon's public key, which is required to establish the encrypted connection. Treat it like a password — don't share it publicly.
|
The QR code or pairing link is the trust anchor. It contains the daemon's public key, which is required to establish the encrypted connection. Treat it like a password — don't share it publicly.
|
||||||
|
|
||||||
## Local daemon trust boundary
|
|
||||||
|
|
||||||
By default, the daemon binds to `127.0.0.1`. The local control plane is trusted by network reachability, not by an additional authentication token.
|
|
||||||
|
|
||||||
Anything that can reach the daemon socket can control the daemon. This is the same security model Docker documents for its daemon: the security boundary is access to the socket or listening address.
|
|
||||||
|
|
||||||
If you expose the daemon beyond loopback, such as by binding to `0.0.0.0`, forwarding it through a tunnel or reverse proxy, or publishing it from a Docker container, you are responsible for restricting and securing that access.
|
|
||||||
|
|
||||||
For remote access, use the relay connection. It is the supported path for reaching the daemon off-machine, and it adds end-to-end encryption plus a pairing handshake before commands are accepted.
|
|
||||||
|
|
||||||
Host header validation and CORS origin checks are defense-in-depth controls for localhost exposure. They help block DNS rebinding and browser-based attacks, but they do not replace network isolation.
|
|
||||||
|
|
||||||
## DNS rebinding protection
|
## DNS rebinding protection
|
||||||
|
|
||||||
CORS is not a complete security boundary. It controls which browser origins can make requests, but does not prevent a malicious website from resolving its domain to your local machine (DNS rebinding).
|
CORS is not a complete security boundary. It controls which browser origins can make requests, but does not prevent a malicious website from resolving its domain to your local machine (DNS rebinding).
|
||||||
|
|||||||
@@ -1,159 +0,0 @@
|
|||||||
# Ad-hoc daemon testing
|
|
||||||
|
|
||||||
Spin up an isolated daemon programmatically without touching the main daemon on port 6767.
|
|
||||||
|
|
||||||
## Quick start
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
|
||||||
import pino from "pino";
|
|
||||||
import { createPaseoDaemon } from "./bootstrap.js";
|
|
||||||
import { DaemonClient } from "./test-utils/daemon-client.js";
|
|
||||||
|
|
||||||
const logger = pino({ level: "warn" });
|
|
||||||
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-test-"));
|
|
||||||
const paseoHome = path.join(paseoHomeRoot, ".paseo");
|
|
||||||
await mkdir(paseoHome, { recursive: true });
|
|
||||||
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
|
|
||||||
|
|
||||||
const daemon = await createPaseoDaemon(
|
|
||||||
{
|
|
||||||
listen: "127.0.0.1:0", // OS picks a free port
|
|
||||||
paseoHome,
|
|
||||||
corsAllowedOrigins: [],
|
|
||||||
allowedHosts: true,
|
|
||||||
mcpEnabled: false,
|
|
||||||
staticDir,
|
|
||||||
mcpDebug: false,
|
|
||||||
agentClients: {},
|
|
||||||
agentStoragePath: path.join(paseoHome, "agents"),
|
|
||||||
relayEnabled: false,
|
|
||||||
relayEndpoint: "relay.paseo.sh:443",
|
|
||||||
appBaseUrl: "https://app.paseo.sh",
|
|
||||||
// Add custom config here, e.g.:
|
|
||||||
// providerOverrides: { ... },
|
|
||||||
},
|
|
||||||
logger,
|
|
||||||
);
|
|
||||||
|
|
||||||
await daemon.start();
|
|
||||||
const target = daemon.getListenTarget();
|
|
||||||
const port = target!.type === "tcp" ? target!.port : null;
|
|
||||||
|
|
||||||
const client = new DaemonClient({
|
|
||||||
url: `ws://127.0.0.1:${port}/ws`,
|
|
||||||
appVersion: "0.1.54", // see gotcha #1
|
|
||||||
});
|
|
||||||
await client.connect();
|
|
||||||
await client.fetchAgents({ subscribe: { subscriptionId: "test" } });
|
|
||||||
|
|
||||||
// ... do your testing ...
|
|
||||||
|
|
||||||
await client.close();
|
|
||||||
await daemon.stop();
|
|
||||||
await rm(paseoHomeRoot, { recursive: true, force: true });
|
|
||||||
await rm(staticDir, { recursive: true, force: true });
|
|
||||||
```
|
|
||||||
|
|
||||||
Run with:
|
|
||||||
```bash
|
|
||||||
npx tsx packages/server/src/server/your-script.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
## Using the test helper
|
|
||||||
|
|
||||||
For simpler cases, `createTestPaseoDaemon` + `DaemonClient` handles temp dirs and port selection:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
|
|
||||||
import { DaemonClient } from "./test-utils/daemon-client.js";
|
|
||||||
|
|
||||||
const daemon = await createTestPaseoDaemon();
|
|
||||||
const client = new DaemonClient({
|
|
||||||
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
|
||||||
appVersion: "0.1.54",
|
|
||||||
});
|
|
||||||
await client.connect();
|
|
||||||
await client.fetchAgents({ subscribe: { subscriptionId: "test" } });
|
|
||||||
|
|
||||||
// ... test ...
|
|
||||||
|
|
||||||
await client.close();
|
|
||||||
await daemon.close(); // stops daemon + cleans up temp dirs
|
|
||||||
```
|
|
||||||
|
|
||||||
The test helper does **not** expose `providerOverrides`. Use `createPaseoDaemon` directly when you need it (see quick start above).
|
|
||||||
|
|
||||||
## Common client methods
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Provider discovery
|
|
||||||
const snapshot = await client.getProvidersSnapshot({ cwd: "/tmp" });
|
|
||||||
const models = await client.listProviderModels("claude");
|
|
||||||
const modes = await client.listProviderModes("claude");
|
|
||||||
|
|
||||||
// Agent lifecycle
|
|
||||||
const agent = await client.createAgent({ provider: "claude", cwd: "/tmp" });
|
|
||||||
await client.sendMessage(agent.id, "Hello");
|
|
||||||
const updated = await client.waitForAgentUpsert(agent.id, (s) => s.status === "idle");
|
|
||||||
```
|
|
||||||
|
|
||||||
## Gotchas
|
|
||||||
|
|
||||||
### 1. appVersion gates provider visibility
|
|
||||||
|
|
||||||
The daemon hides non-legacy providers (anything other than claude, codex, opencode) from clients that don't send an `appVersion >= 0.1.45`. The `DaemonClient` sends no version by default, so custom providers like ACP-based ones will be invisible in snapshot responses.
|
|
||||||
|
|
||||||
Always pass `appVersion`:
|
|
||||||
```typescript
|
|
||||||
const client = new DaemonClient({
|
|
||||||
url: `ws://127.0.0.1:${port}/ws`,
|
|
||||||
appVersion: "0.1.54",
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Provider snapshots are async
|
|
||||||
|
|
||||||
After the daemon starts, providers are probed in the background. The first `getProvidersSnapshot()` call will likely return `status: "loading"` for most providers. Poll until the provider you care about is no longer loading:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
let snapshot = await client.getProvidersSnapshot({ cwd: "/tmp" });
|
|
||||||
for (let i = 0; i < 20; i++) {
|
|
||||||
const entry = snapshot.entries.find((e) => e.provider === "gemini");
|
|
||||||
if (entry && entry.status !== "loading") break;
|
|
||||||
await new Promise((r) => setTimeout(r, 2_000));
|
|
||||||
snapshot = await client.getProvidersSnapshot({ cwd: "/tmp" });
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. fetchAgents is required before most operations
|
|
||||||
|
|
||||||
Call `client.fetchAgents()` after connecting. The daemon session expects this handshake before it processes other requests — without it, messages like `get_providers_snapshot_request` will silently hang.
|
|
||||||
|
|
||||||
### 4. listen: "127.0.0.1:0" for port allocation
|
|
||||||
|
|
||||||
Always use port `0` so the OS picks a free port. Never hardcode a port — it will collide with the main daemon or other test runs.
|
|
||||||
|
|
||||||
### 5. Script must live inside packages/server
|
|
||||||
|
|
||||||
The test utilities use relative imports through the TypeScript project. Place your script somewhere under `packages/server/src/` and import from there. Scripts outside the repo will fail with module resolution errors.
|
|
||||||
|
|
||||||
### 6. Cleanup on failure
|
|
||||||
|
|
||||||
Wrap your test logic in try/finally to ensure the daemon stops and temp dirs are cleaned up, even if an assertion fails:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
try {
|
|
||||||
// ... test logic ...
|
|
||||||
} finally {
|
|
||||||
await client.close();
|
|
||||||
await daemon.stop().catch(() => undefined);
|
|
||||||
await rm(paseoHomeRoot, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7. ACP providers spawn real processes
|
|
||||||
|
|
||||||
When testing ACP providers (e.g., Gemini with `extends: "acp"`), the daemon will spawn real processes to probe for models and modes. The binary must be installed and on PATH. Probing can take 5-15 seconds depending on the provider.
|
|
||||||
+1
-3
@@ -46,13 +46,11 @@ adb exec-out screencap -p > screenshot.png
|
|||||||
|
|
||||||
## Cloud build + submit (EAS)
|
## Cloud build + submit (EAS)
|
||||||
|
|
||||||
Stable tag pushes like `v0.1.0` trigger:
|
Tag pushes like `v0.1.0` trigger:
|
||||||
|
|
||||||
- `packages/app/.eas/workflows/release-mobile.yml` on Expo servers (iOS + Android build + submit)
|
- `packages/app/.eas/workflows/release-mobile.yml` on Expo servers (iOS + Android build + submit)
|
||||||
- `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release)
|
- `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release)
|
||||||
|
|
||||||
Release candidate tags like `v0.1.1-rc.1` only trigger the GitHub APK workflow. They publish a GitHub prerelease APK for testing and do not submit to the stores.
|
|
||||||
|
|
||||||
### Useful commands
|
### Useful commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -1,428 +0,0 @@
|
|||||||
# Data Model
|
|
||||||
|
|
||||||
Paseo uses **file-based JSON persistence** instead of a traditional database. All data is validated at runtime with Zod schemas and written atomically (write to temp file, then rename). There are no migrations — schemas use optional fields with defaults for forward compatibility.
|
|
||||||
|
|
||||||
All server-side stores live under `$PASEO_HOME` (defaults to `~/.paseo`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Directory layout
|
|
||||||
|
|
||||||
```
|
|
||||||
$PASEO_HOME/
|
|
||||||
├── config.json # Daemon configuration
|
|
||||||
├── agents/
|
|
||||||
│ └── {project-dir}/
|
|
||||||
│ └── {agentId}.json # One file per agent
|
|
||||||
├── schedules/
|
|
||||||
│ └── {scheduleId}.json # One file per schedule
|
|
||||||
├── chat/
|
|
||||||
│ └── rooms.json # All rooms + messages
|
|
||||||
├── loops/
|
|
||||||
│ └── loops.json # All loop records
|
|
||||||
├── projects/
|
|
||||||
│ ├── projects.json # Project registry
|
|
||||||
│ └── workspaces.json # Workspace registry
|
|
||||||
└── push-tokens.json # Expo push notification tokens
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Agent Record
|
|
||||||
|
|
||||||
**Path:** `$PASEO_HOME/agents/{project-dir}/{agentId}.json`
|
|
||||||
|
|
||||||
Each agent is stored as a separate JSON file, grouped by project directory.
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `id` | `string` | UUID, primary key |
|
|
||||||
| `provider` | `string` | Agent provider (`"claude"`, `"codex"`, `"opencode"`, etc.) |
|
|
||||||
| `cwd` | `string` | Working directory the agent operates in |
|
|
||||||
| `createdAt` | `string` (ISO 8601) | Creation timestamp |
|
|
||||||
| `updatedAt` | `string` (ISO 8601) | Last update timestamp |
|
|
||||||
| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp |
|
|
||||||
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
|
|
||||||
| `title` | `string?` | User-visible title |
|
|
||||||
| `labels` | `Record<string, string>` | Key-value labels (default `{}`) |
|
|
||||||
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"` |
|
|
||||||
| `lastModeId` | `string?` | Last active mode ID |
|
|
||||||
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
|
|
||||||
| `runtimeInfo` | `RuntimeInfo?` | Live runtime state (see below) |
|
|
||||||
| `features` | `AgentFeature[]?` | Provider-reported features (toggles/selects) |
|
|
||||||
| `persistence` | `PersistenceHandle?` | Handle for resuming sessions |
|
|
||||||
| `requiresAttention` | `boolean?` | Whether the agent needs user attention |
|
|
||||||
| `attentionReason` | `"finished" \| "error" \| "permission"?` | Why attention is needed |
|
|
||||||
| `attentionTimestamp` | `string?` (ISO 8601) | When attention was flagged |
|
|
||||||
| `internal` | `boolean?` | Whether this is a system-internal agent (loop workers, etc.) |
|
|
||||||
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
|
|
||||||
|
|
||||||
### Nested: SerializableConfig
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `title` | `string?` | Configured title |
|
|
||||||
| `modeId` | `string?` | Configured mode |
|
|
||||||
| `model` | `string?` | Configured model |
|
|
||||||
| `thinkingOptionId` | `string?` | Thinking/reasoning level |
|
|
||||||
| `featureValues` | `Record<string, unknown>?` | Feature preference overrides |
|
|
||||||
| `extra` | `Record<string, any>?` | Provider-specific config |
|
|
||||||
| `systemPrompt` | `string?` | Custom system prompt |
|
|
||||||
| `mcpServers` | `Record<string, any>?` | MCP server configurations |
|
|
||||||
|
|
||||||
### Nested: RuntimeInfo
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `provider` | `string` | Active provider |
|
|
||||||
| `sessionId` | `string?` | Active session ID |
|
|
||||||
| `model` | `string?` | Active model |
|
|
||||||
| `thinkingOptionId` | `string?` | Active thinking option |
|
|
||||||
| `modeId` | `string?` | Active mode |
|
|
||||||
| `extra` | `Record<string, unknown>?` | Provider-specific runtime data |
|
|
||||||
|
|
||||||
### Nested: PersistenceHandle
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `provider` | `string` | Provider that owns the session |
|
|
||||||
| `sessionId` | `string` | Session ID for resumption |
|
|
||||||
| `nativeHandle` | `any?` | Provider-specific handle (Codex thread ID, Claude resume token, etc.) |
|
|
||||||
| `metadata` | `Record<string, any>?` | Extra metadata |
|
|
||||||
|
|
||||||
### Nested: AgentFeature (discriminated union on `type`)
|
|
||||||
|
|
||||||
**Toggle:**
|
|
||||||
|
|
||||||
| Field | Type |
|
|
||||||
|---|---|
|
|
||||||
| `type` | `"toggle"` |
|
|
||||||
| `id` | `string` |
|
|
||||||
| `label` | `string` |
|
|
||||||
| `description` | `string?` |
|
|
||||||
| `tooltip` | `string?` |
|
|
||||||
| `icon` | `string?` |
|
|
||||||
| `value` | `boolean` |
|
|
||||||
|
|
||||||
**Select:**
|
|
||||||
|
|
||||||
| Field | Type |
|
|
||||||
|---|---|
|
|
||||||
| `type` | `"select"` |
|
|
||||||
| `id` | `string` |
|
|
||||||
| `label` | `string` |
|
|
||||||
| `description` | `string?` |
|
|
||||||
| `tooltip` | `string?` |
|
|
||||||
| `icon` | `string?` |
|
|
||||||
| `value` | `string?` |
|
|
||||||
| `options` | `AgentSelectOption[]` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Daemon Configuration
|
|
||||||
|
|
||||||
**Path:** `$PASEO_HOME/config.json`
|
|
||||||
|
|
||||||
Single file, validated with `PersistedConfigSchema`.
|
|
||||||
|
|
||||||
```
|
|
||||||
{
|
|
||||||
version: 1,
|
|
||||||
daemon: {
|
|
||||||
listen: "127.0.0.1:6767",
|
|
||||||
allowedHosts: true | string[],
|
|
||||||
mcp: { enabled: boolean },
|
|
||||||
cors: { allowedOrigins: string[] },
|
|
||||||
relay: { enabled: boolean, endpoint: string, publicEndpoint: string }
|
|
||||||
},
|
|
||||||
app: {
|
|
||||||
baseUrl: string
|
|
||||||
},
|
|
||||||
providers: {
|
|
||||||
openai: { apiKey: string },
|
|
||||||
local: { modelsDir: string }
|
|
||||||
},
|
|
||||||
agents: {
|
|
||||||
providers: {
|
|
||||||
[provider: string]: {
|
|
||||||
command: { mode: "default" } | { mode: "append", args: string[] } | { mode: "replace", argv: string[] },
|
|
||||||
env: Record<string, string>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
features: {
|
|
||||||
dictation: { enabled, stt: { provider, model, confidenceThreshold } },
|
|
||||||
voiceMode: { enabled, llm, stt, turnDetection, tts: { provider, model, voice, speakerId, speed } }
|
|
||||||
},
|
|
||||||
log: {
|
|
||||||
level, format,
|
|
||||||
console: { level, format },
|
|
||||||
file: { level, path, rotate: { maxSize, maxFiles } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
All fields are optional with sensible defaults.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Schedule
|
|
||||||
|
|
||||||
**Path:** `$PASEO_HOME/schedules/{id}.json`
|
|
||||||
|
|
||||||
One file per schedule. ID is 8 hex characters.
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `id` | `string` | 8-char hex ID |
|
|
||||||
| `name` | `string?` | Human-readable name |
|
|
||||||
| `prompt` | `string` | The prompt to send |
|
|
||||||
| `cadence` | `ScheduleCadence` | Timing (see below) |
|
|
||||||
| `target` | `ScheduleTarget` | What to run (see below) |
|
|
||||||
| `status` | `"active" \| "paused" \| "completed"` | Current state |
|
|
||||||
| `createdAt` | `string` (ISO 8601) | |
|
|
||||||
| `updatedAt` | `string` (ISO 8601) | |
|
|
||||||
| `nextRunAt` | `string?` (ISO 8601) | Next scheduled execution |
|
|
||||||
| `lastRunAt` | `string?` (ISO 8601) | Last execution time |
|
|
||||||
| `pausedAt` | `string?` (ISO 8601) | When paused |
|
|
||||||
| `expiresAt` | `string?` (ISO 8601) | Auto-expire time |
|
|
||||||
| `maxRuns` | `number?` | Max executions before completing |
|
|
||||||
| `runs` | `ScheduleRun[]` | Execution history |
|
|
||||||
|
|
||||||
### Nested: ScheduleCadence (discriminated union on `type`)
|
|
||||||
|
|
||||||
- `{ type: "every", everyMs: number }` — interval in milliseconds
|
|
||||||
- `{ type: "cron", expression: string }` — cron expression
|
|
||||||
|
|
||||||
### Nested: ScheduleTarget (discriminated union on `type`)
|
|
||||||
|
|
||||||
- `{ type: "agent", agentId: string }` — send to existing agent
|
|
||||||
- `{ type: "new-agent", config: { provider, cwd, modeId?, model?, thinkingOptionId?, title?, approvalPolicy?, sandboxMode?, networkAccess?, webSearch?, extra?, systemPrompt?, mcpServers? } }` — create a new agent
|
|
||||||
|
|
||||||
### Nested: ScheduleRun
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `id` | `string` | Run ID |
|
|
||||||
| `scheduledFor` | `string` (ISO 8601) | Intended execution time |
|
|
||||||
| `startedAt` | `string` (ISO 8601) | |
|
|
||||||
| `endedAt` | `string?` (ISO 8601) | |
|
|
||||||
| `status` | `"running" \| "succeeded" \| "failed"` | |
|
|
||||||
| `agentId` | `string?` (UUID) | Agent used for this run |
|
|
||||||
| `output` | `string?` | Agent output text |
|
|
||||||
| `error` | `string?` | Error message if failed |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Chat
|
|
||||||
|
|
||||||
**Path:** `$PASEO_HOME/chat/rooms.json`
|
|
||||||
|
|
||||||
Single file containing all rooms and messages.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"rooms": [ ... ],
|
|
||||||
"messages": [ ... ]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### ChatRoom
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `id` | `string` (UUID) | |
|
|
||||||
| `name` | `string` | Unique room name (case-insensitive) |
|
|
||||||
| `purpose` | `string?` | Room description |
|
|
||||||
| `createdAt` | `string` (ISO 8601) | |
|
|
||||||
| `updatedAt` | `string` (ISO 8601) | Updated on each new message |
|
|
||||||
|
|
||||||
### ChatMessage
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `id` | `string` (UUID) | |
|
|
||||||
| `roomId` | `string` | FK to ChatRoom.id |
|
|
||||||
| `authorAgentId` | `string` | Agent ID of the author |
|
|
||||||
| `body` | `string` | Message text (supports `@mentions`) |
|
|
||||||
| `replyToMessageId` | `string?` | FK to another ChatMessage.id |
|
|
||||||
| `mentionAgentIds` | `string[]` | Extracted `@mention` agent IDs |
|
|
||||||
| `createdAt` | `string` (ISO 8601) | |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Loop
|
|
||||||
|
|
||||||
**Path:** `$PASEO_HOME/loops/loops.json`
|
|
||||||
|
|
||||||
Single file containing an array of all loop records.
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `id` | `string` | 8-char UUID prefix |
|
|
||||||
| `name` | `string?` | Human-readable name |
|
|
||||||
| `prompt` | `string` | Worker prompt |
|
|
||||||
| `cwd` | `string` | Working directory |
|
|
||||||
| `provider` | `string` | Default provider |
|
|
||||||
| `model` | `string?` | Default model |
|
|
||||||
| `workerProvider` | `string?` | Override provider for workers |
|
|
||||||
| `workerModel` | `string?` | Override model for workers |
|
|
||||||
| `verifierProvider` | `string?` | Override provider for verifiers |
|
|
||||||
| `verifierModel` | `string?` | Override model for verifiers |
|
|
||||||
| `verifyPrompt` | `string?` | LLM verification prompt |
|
|
||||||
| `verifyChecks` | `string[]` | Shell commands to run as checks |
|
|
||||||
| `archive` | `boolean` | Whether to archive worker agents after use |
|
|
||||||
| `sleepMs` | `number` | Delay between iterations (ms) |
|
|
||||||
| `maxIterations` | `number?` | Cap on iterations |
|
|
||||||
| `maxTimeMs` | `number?` | Total time budget (ms) |
|
|
||||||
| `status` | `"running" \| "succeeded" \| "failed" \| "stopped"` | |
|
|
||||||
| `createdAt` | `string` (ISO 8601) | |
|
|
||||||
| `updatedAt` | `string` (ISO 8601) | |
|
|
||||||
| `startedAt` | `string` (ISO 8601) | |
|
|
||||||
| `completedAt` | `string?` (ISO 8601) | |
|
|
||||||
| `stopRequestedAt` | `string?` (ISO 8601) | |
|
|
||||||
| `iterations` | `LoopIteration[]` | |
|
|
||||||
| `logs` | `LoopLogEntry[]` | |
|
|
||||||
| `nextLogSeq` | `number` | Monotonic log sequence counter |
|
|
||||||
| `activeIteration` | `number?` | Currently executing iteration index |
|
|
||||||
| `activeWorkerAgentId` | `string?` | Currently running worker agent |
|
|
||||||
| `activeVerifierAgentId` | `string?` | Currently running verifier agent |
|
|
||||||
|
|
||||||
### Nested: LoopIteration
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `index` | `number` | 1-based iteration index |
|
|
||||||
| `workerAgentId` | `string?` | Agent ID of the worker |
|
|
||||||
| `workerStartedAt` | `string` (ISO 8601) | |
|
|
||||||
| `workerCompletedAt` | `string?` (ISO 8601) | |
|
|
||||||
| `verifierAgentId` | `string?` | Agent ID of the verifier |
|
|
||||||
| `status` | `"running" \| "succeeded" \| "failed" \| "stopped"` | |
|
|
||||||
| `workerOutcome` | `"completed" \| "failed" \| "canceled"?` | |
|
|
||||||
| `failureReason` | `string?` | |
|
|
||||||
| `verifyChecks` | `LoopVerifyCheckResult[]` | Shell check results |
|
|
||||||
| `verifyPrompt` | `LoopVerifyPromptResult?` | LLM verification result |
|
|
||||||
|
|
||||||
### Nested: LoopLogEntry
|
|
||||||
|
|
||||||
| Field | Type |
|
|
||||||
|---|---|
|
|
||||||
| `seq` | `number` (monotonic) |
|
|
||||||
| `timestamp` | `string` (ISO 8601) |
|
|
||||||
| `iteration` | `number?` |
|
|
||||||
| `source` | `"loop" \| "worker" \| "verifier" \| "verify-check"` |
|
|
||||||
| `level` | `"info" \| "error"` |
|
|
||||||
| `text` | `string` |
|
|
||||||
|
|
||||||
### Nested: LoopVerifyCheckResult
|
|
||||||
|
|
||||||
| Field | Type |
|
|
||||||
|---|---|
|
|
||||||
| `command` | `string` |
|
|
||||||
| `exitCode` | `number` |
|
|
||||||
| `passed` | `boolean` |
|
|
||||||
| `stdout` | `string` |
|
|
||||||
| `stderr` | `string` |
|
|
||||||
| `startedAt` | `string` (ISO 8601) |
|
|
||||||
| `completedAt` | `string` (ISO 8601) |
|
|
||||||
|
|
||||||
### Nested: LoopVerifyPromptResult
|
|
||||||
|
|
||||||
| Field | Type |
|
|
||||||
|---|---|
|
|
||||||
| `passed` | `boolean` |
|
|
||||||
| `reason` | `string` |
|
|
||||||
| `verifierAgentId` | `string?` |
|
|
||||||
| `startedAt` | `string` (ISO 8601) |
|
|
||||||
| `completedAt` | `string` (ISO 8601) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Project Registry
|
|
||||||
|
|
||||||
**Path:** `$PASEO_HOME/projects/projects.json`
|
|
||||||
|
|
||||||
Array of project records.
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `projectId` | `string` | Primary key |
|
|
||||||
| `rootPath` | `string` | Filesystem root of the project |
|
|
||||||
| `kind` | `"git" \| "non_git"` | |
|
|
||||||
| `displayName` | `string` | |
|
|
||||||
| `createdAt` | `string` (ISO 8601) | |
|
|
||||||
| `updatedAt` | `string` (ISO 8601) | |
|
|
||||||
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Workspace Registry
|
|
||||||
|
|
||||||
**Path:** `$PASEO_HOME/projects/workspaces.json`
|
|
||||||
|
|
||||||
Array of workspace records. A workspace is a specific working directory within a project.
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `workspaceId` | `string` | Primary key |
|
|
||||||
| `projectId` | `string` | FK to Project.projectId |
|
|
||||||
| `cwd` | `string` | Filesystem path |
|
|
||||||
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | |
|
|
||||||
| `displayName` | `string` | |
|
|
||||||
| `createdAt` | `string` (ISO 8601) | |
|
|
||||||
| `updatedAt` | `string` (ISO 8601) | |
|
|
||||||
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Push Token Store
|
|
||||||
|
|
||||||
**Path:** `$PASEO_HOME/push-tokens.json`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"tokens": ["ExponentPushToken[...]", ...]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Simple set of Expo push notification tokens. No schema validation — just an array of strings.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Client-side stores (App)
|
|
||||||
|
|
||||||
These live in React Native `AsyncStorage` or browser `IndexedDB`, not on the daemon filesystem.
|
|
||||||
|
|
||||||
### Draft Store
|
|
||||||
|
|
||||||
**AsyncStorage key:** `paseo-drafts` (version 2)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
drafts: Record<draftKey, {
|
|
||||||
input: { text: string, images: AttachmentMetadata[] },
|
|
||||||
lifecycle: "active" | "abandoned" | "sent",
|
|
||||||
updatedAt: number, // epoch ms
|
|
||||||
version: number // optimistic concurrency
|
|
||||||
}>,
|
|
||||||
createModalDraft: DraftRecord | null
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Attachment Store (Web)
|
|
||||||
|
|
||||||
**IndexedDB database:** `paseo-attachment-bytes`, object store: `attachments`
|
|
||||||
|
|
||||||
Stores binary attachment blobs keyed by attachment ID.
|
|
||||||
|
|
||||||
### AttachmentMetadata
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `id` | `string` | Unique attachment ID |
|
|
||||||
| `mimeType` | `string` | MIME type |
|
|
||||||
| `storageType` | `string` | Storage backend identifier |
|
|
||||||
| `storageKey` | `string` | Key within the storage backend |
|
|
||||||
| `createdAt` | `number` | Epoch ms |
|
|
||||||
| `fileName` | `string?` | Original filename |
|
|
||||||
| `byteSize` | `number?` | Size in bytes |
|
|
||||||
@@ -35,6 +35,25 @@ In worktrees or with `npm run dev`, ports may differ. Never assume defaults.
|
|||||||
|
|
||||||
Check `$PASEO_HOME/daemon.log` for trace-level logs.
|
Check `$PASEO_HOME/daemon.log` for trace-level logs.
|
||||||
|
|
||||||
|
### Database queries
|
||||||
|
|
||||||
|
Run arbitrary SQL against the SQLite database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Show table row counts
|
||||||
|
npm run db:query
|
||||||
|
|
||||||
|
# Run any SQL
|
||||||
|
npm run db:query -- "SELECT agent_id, title, last_status FROM agent_snapshots"
|
||||||
|
npm run db:query -- "SELECT agent_id, seq, item_kind FROM agent_timeline_rows ORDER BY committed_at DESC LIMIT 10"
|
||||||
|
|
||||||
|
# Point at a specific DB directory
|
||||||
|
npm run db:query -- --db /path/to/db "SELECT ..."
|
||||||
|
```
|
||||||
|
|
||||||
|
Auto-detects the running dev daemon's database from `/tmp/paseo-dev.*`, `PASEO_HOME`, or `~/.paseo/db`.
|
||||||
|
Pass either a DB directory or a `paseo.sqlite` file to `--db`. The script opens the database directly in read-only mode.
|
||||||
|
|
||||||
## Build sync gotchas
|
## Build sync gotchas
|
||||||
|
|
||||||
### Relay → Daemon
|
### Relay → Daemon
|
||||||
|
|||||||
@@ -1,206 +0,0 @@
|
|||||||
# Mobile Testing
|
|
||||||
|
|
||||||
## Maestro
|
|
||||||
|
|
||||||
Maestro flows live in `packages/app/maestro/`. Reusable sub-flows live in `packages/app/maestro/flows/`.
|
|
||||||
|
|
||||||
Run a flow:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
maestro test packages/app/maestro/my-flow.yaml
|
|
||||||
```
|
|
||||||
|
|
||||||
### Screenshots
|
|
||||||
|
|
||||||
`takeScreenshot` writes to the **current working directory** — there's no way to configure the output path in the YAML. To keep screenshots out of the checkout, `cd` into a temp directory and use an absolute path for the flow:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
FLOW="$(pwd)/packages/app/maestro/my-flow.yaml"
|
|
||||||
mkdir -p /tmp/maestro-out
|
|
||||||
cd /tmp/maestro-out && maestro test "$FLOW"
|
|
||||||
```
|
|
||||||
|
|
||||||
`packages/app/maestro/.gitignore` excludes `*.png` as a safety net.
|
|
||||||
|
|
||||||
### Element targeting
|
|
||||||
|
|
||||||
Use `testID` or `nativeID` on components, then target with `id:` in flows. Prefer this over text matching — text breaks on copy changes.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// Component
|
|
||||||
<Pressable testID="sidebar-sessions" onPress={onPress}>
|
|
||||||
```
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# Flow
|
|
||||||
- tapOn:
|
|
||||||
id: "sidebar-sessions"
|
|
||||||
- assertVisible:
|
|
||||||
id: "sidebar-sessions"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Conditional steps
|
|
||||||
|
|
||||||
Use `runFlow:when:visible` for steps that should only execute when a specific element is on screen:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- runFlow:
|
|
||||||
when:
|
|
||||||
visible:
|
|
||||||
id: "sidebar-sessions"
|
|
||||||
commands:
|
|
||||||
- swipe:
|
|
||||||
direction: LEFT
|
|
||||||
duration: 300
|
|
||||||
```
|
|
||||||
|
|
||||||
This is how `flows/dev-client.yaml` handles Expo dev client screens that only appear in dev builds.
|
|
||||||
|
|
||||||
### Don't use launchApp against a running dev app
|
|
||||||
|
|
||||||
`launchApp` kills and restarts the app, disrupting Expo dev client state and host connections. For flows that test against an already-running dev app, **omit launchApp entirely** — just interact with whatever is on screen.
|
|
||||||
|
|
||||||
Use `launchApp` only in flows that need a clean start (e.g., onboarding tests).
|
|
||||||
|
|
||||||
### Swipe gestures
|
|
||||||
|
|
||||||
Use `start`/`end` with percentage coordinates for precise control:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# Edge swipe from left to open sidebar
|
|
||||||
- swipe:
|
|
||||||
start: "5%,50%"
|
|
||||||
end: "80%,50%"
|
|
||||||
duration: 300
|
|
||||||
```
|
|
||||||
|
|
||||||
`direction: RIGHT` is simpler but less precise — use it for generic swipes, use coordinates when the start position matters (edge gestures, avoiding specific UI regions).
|
|
||||||
|
|
||||||
### Assertions
|
|
||||||
|
|
||||||
`assertVisible` checks **actual screen visibility**, not just view tree presence. An element that exists in the tree but is off-screen (e.g., `translateX: -400`) will correctly fail `assertVisible`. This makes it reliable for catching animation bugs where state says "open" but the view is visually hidden.
|
|
||||||
|
|
||||||
For async elements, use `extendedWaitUntil`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- extendedWaitUntil:
|
|
||||||
visible: ".*Online.*"
|
|
||||||
timeout: 90000
|
|
||||||
```
|
|
||||||
|
|
||||||
### Dev client handling
|
|
||||||
|
|
||||||
Two reusable flows handle Expo dev client screens after launch:
|
|
||||||
|
|
||||||
- `flows/launch.yaml` — handles dev launcher, dismisses dev menu, asserts "Welcome to Paseo"
|
|
||||||
- `flows/dev-client.yaml` — same but without asserting a particular app route
|
|
||||||
|
|
||||||
## Self-verification loops
|
|
||||||
|
|
||||||
Maestro can only interact with the app UI — it can't toggle iOS appearance, change locale, or simulate network conditions. For bugs that depend on system-level state, wrap Maestro in a bash script that handles the system changes between Maestro runs.
|
|
||||||
|
|
||||||
This pattern also lets agents self-verify fixes without manual user testing.
|
|
||||||
|
|
||||||
### Pattern
|
|
||||||
|
|
||||||
1. Run baseline Maestro flow (confirm feature works)
|
|
||||||
2. Make system-level change via `xcrun simctl` (toggle appearance, etc.)
|
|
||||||
3. Re-run Maestro flow (confirm feature still works)
|
|
||||||
4. Repeat N iterations to catch intermittent failures
|
|
||||||
|
|
||||||
Scripts run `maestro test` from inside a temp directory so screenshots don't dirty the checkout.
|
|
||||||
|
|
||||||
See `packages/app/maestro/test-sidebar-theme.sh` for the canonical example:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash packages/app/maestro/test-sidebar-theme.sh 6 1
|
|
||||||
# Args: iterations=6, wait_seconds=1 between toggle and test
|
|
||||||
```
|
|
||||||
|
|
||||||
Key elements of the script pattern:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
set -euo pipefail
|
|
||||||
ITERATIONS="${1:-3}"
|
|
||||||
|
|
||||||
for i in $(seq 1 "$ITERATIONS"); do
|
|
||||||
# Toggle system state
|
|
||||||
xcrun simctl ui booted appearance light
|
|
||||||
|
|
||||||
# Wait for change to propagate
|
|
||||||
sleep 1
|
|
||||||
|
|
||||||
# Run Maestro flow and capture result
|
|
||||||
if maestro test "$FLOW" 2>&1 | tee "$ITER_DIR/test.log"; then
|
|
||||||
echo "PASS"
|
|
||||||
else
|
|
||||||
echo "FAIL"
|
|
||||||
xcrun simctl io booted screenshot "$ITER_DIR/failure-state.png"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
```
|
|
||||||
|
|
||||||
## Unistyles + Reanimated
|
|
||||||
|
|
||||||
### The crash
|
|
||||||
|
|
||||||
Applying Unistyles theme-reactive styles (`StyleSheet.create((theme) => ...)`) directly to `Animated.View` causes **"Unable to find node on an unmounted component"** on theme change.
|
|
||||||
|
|
||||||
Unistyles wraps styled components in `<UnistylesComponent>` and patches native view properties via C++. Reanimated also manages the same native node for animated transforms. When the theme changes, both systems try to update the node simultaneously and the view crashes.
|
|
||||||
|
|
||||||
### The fix
|
|
||||||
|
|
||||||
Use plain React Native `StyleSheet.create` for static positioning on `Animated.View`. Pass theme-dependent values as inline styles from `useUnistyles()`:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// BAD: Unistyles dynamic style on Animated.View
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
|
||||||
sidebar: {
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
bottom: 0,
|
|
||||||
backgroundColor: theme.colors.surfaceSidebar, // theme-reactive
|
|
||||||
overflow: "hidden",
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
<Animated.View style={[styles.sidebar, animatedStyle]} />
|
|
||||||
```
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// GOOD: static stylesheet + inline theme values
|
|
||||||
import { StyleSheet as RNStyleSheet } from "react-native";
|
|
||||||
|
|
||||||
const staticStyles = RNStyleSheet.create({
|
|
||||||
sidebar: {
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
bottom: 0,
|
|
||||||
overflow: "hidden",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { theme } = useUnistyles();
|
|
||||||
|
|
||||||
<Animated.View
|
|
||||||
style={[staticStyles.sidebar, animatedStyle, { backgroundColor: theme.colors.surfaceSidebar }]}
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
Regular `View` components can safely use Unistyles dynamic styles — the conflict is specific to `Animated.View`.
|
|
||||||
|
|
||||||
## iOS Simulator
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Screenshot
|
|
||||||
xcrun simctl io booted screenshot /tmp/screenshot.png
|
|
||||||
|
|
||||||
# Dark/light mode
|
|
||||||
xcrun simctl ui booted appearance # check current
|
|
||||||
xcrun simctl ui booted appearance dark # set dark
|
|
||||||
xcrun simctl ui booted appearance light # set light
|
|
||||||
```
|
|
||||||
|
|
||||||
Expo dev server logs are in the tmux pane running `npm run dev`. Daemon logs are at `$PASEO_HOME/daemon.log` (see [DEVELOPMENT.md](DEVELOPMENT.md)).
|
|
||||||
@@ -1,359 +0,0 @@
|
|||||||
# Adding a New Provider to Paseo
|
|
||||||
|
|
||||||
This guide walks through adding a new agent provider end-to-end. There are two integration patterns, and this doc covers both.
|
|
||||||
|
|
||||||
## Two Integration Patterns
|
|
||||||
|
|
||||||
### ACP (Agent Client Protocol) -- recommended
|
|
||||||
|
|
||||||
Extend `ACPAgentClient`. The base class handles process spawning, stdio transport, session lifecycle, streaming, permissions, and model discovery. You provide configuration (command, modes, capabilities) and optionally override `isAvailable()` for auth checks.
|
|
||||||
|
|
||||||
Existing ACP providers: `claude-acp`, `copilot`.
|
|
||||||
|
|
||||||
### Direct
|
|
||||||
|
|
||||||
Implement the `AgentClient` and `AgentSession` interfaces yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
|
|
||||||
|
|
||||||
Existing direct providers: `claude`, `codex`, `opencode`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ACP Provider Checklist
|
|
||||||
|
|
||||||
### 1. Create the provider class
|
|
||||||
|
|
||||||
Create `packages/server/src/server/agent/providers/{name}-agent.ts`.
|
|
||||||
|
|
||||||
Define capabilities, modes, and a thin subclass of `ACPAgentClient`:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { Logger } from "pino";
|
|
||||||
import type { AgentCapabilityFlags, AgentMode } from "../agent-sdk-types.js";
|
|
||||||
import type { ProviderRuntimeSettings } from "../provider-launch-config.js";
|
|
||||||
import { ACPAgentClient } from "./acp-agent.js";
|
|
||||||
|
|
||||||
const MY_PROVIDER_CAPABILITIES: AgentCapabilityFlags = {
|
|
||||||
supportsStreaming: true,
|
|
||||||
supportsSessionPersistence: true,
|
|
||||||
supportsDynamicModes: true,
|
|
||||||
supportsMcpServers: true,
|
|
||||||
supportsReasoningStream: true,
|
|
||||||
supportsToolInvocations: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const MY_PROVIDER_MODES: AgentMode[] = [
|
|
||||||
{
|
|
||||||
id: "default",
|
|
||||||
label: "Default",
|
|
||||||
description: "Standard agent mode",
|
|
||||||
},
|
|
||||||
// Add more modes as needed
|
|
||||||
];
|
|
||||||
|
|
||||||
type MyProviderClientOptions = {
|
|
||||||
logger: Logger;
|
|
||||||
runtimeSettings?: ProviderRuntimeSettings;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class MyProviderACPAgentClient extends ACPAgentClient {
|
|
||||||
constructor(options: MyProviderClientOptions) {
|
|
||||||
super({
|
|
||||||
provider: "my-provider", // Must match the ID used everywhere else
|
|
||||||
logger: options.logger,
|
|
||||||
runtimeSettings: options.runtimeSettings,
|
|
||||||
defaultCommand: ["my-agent-binary", "--acp"], // CLI command to spawn
|
|
||||||
defaultModes: MY_PROVIDER_MODES,
|
|
||||||
capabilities: MY_PROVIDER_CAPABILITIES,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Override isAvailable() if the provider needs specific auth/env vars
|
|
||||||
override async isAvailable(): Promise<boolean> {
|
|
||||||
if (!(await super.isAvailable())) {
|
|
||||||
return false; // Binary not found
|
|
||||||
}
|
|
||||||
return Boolean(process.env["MY_PROVIDER_API_KEY"]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The `super.isAvailable()` call checks that the binary from `defaultCommand` is on `$PATH`. Override only to add credential checks on top.
|
|
||||||
|
|
||||||
For reference, here is how Copilot does it -- no auth override needed because the CLI handles auth itself:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export class CopilotACPAgentClient extends ACPAgentClient {
|
|
||||||
constructor(options: CopilotACPAgentClientOptions) {
|
|
||||||
super({
|
|
||||||
provider: "copilot",
|
|
||||||
logger: options.logger,
|
|
||||||
runtimeSettings: options.runtimeSettings,
|
|
||||||
defaultCommand: ["copilot", "--acp"],
|
|
||||||
defaultModes: COPILOT_MODES,
|
|
||||||
capabilities: COPILOT_CAPABILITIES,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
override async isAvailable(): Promise<boolean> {
|
|
||||||
return super.isAvailable();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Add to the provider manifest
|
|
||||||
|
|
||||||
In `packages/server/src/server/agent/provider-manifest.ts`, add mode definitions with UI metadata (icons, color tiers) and a provider definition entry.
|
|
||||||
|
|
||||||
First, define the modes with visual metadata:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const MY_PROVIDER_MODES: AgentProviderModeDefinition[] = [
|
|
||||||
{
|
|
||||||
id: "default",
|
|
||||||
label: "Default",
|
|
||||||
description: "Standard agent mode",
|
|
||||||
icon: "ShieldCheck",
|
|
||||||
colorTier: "safe",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "autonomous",
|
|
||||||
label: "Autonomous",
|
|
||||||
description: "Runs without prompting",
|
|
||||||
icon: "ShieldOff",
|
|
||||||
colorTier: "dangerous",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
```
|
|
||||||
|
|
||||||
Available `colorTier` values: `"safe"`, `"moderate"`, `"dangerous"`, `"planning"`.
|
|
||||||
Available `icon` values: `"ShieldCheck"`, `"ShieldAlert"`, `"ShieldOff"`.
|
|
||||||
|
|
||||||
Then add to the `AGENT_PROVIDER_DEFINITIONS` array:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
|
|
||||||
// ... existing providers ...
|
|
||||||
{
|
|
||||||
id: "my-provider",
|
|
||||||
label: "My Provider",
|
|
||||||
description: "Short description of the provider",
|
|
||||||
defaultModeId: "default",
|
|
||||||
modes: MY_PROVIDER_MODES,
|
|
||||||
// Optional: enable voice
|
|
||||||
voice: {
|
|
||||||
enabled: true,
|
|
||||||
defaultModeId: "default",
|
|
||||||
defaultModel: "some-model",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Add the factory to the provider registry
|
|
||||||
|
|
||||||
In `packages/server/src/server/agent/provider-registry.ts`, import your class and add a factory entry:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { MyProviderACPAgentClient } from "./providers/my-provider-agent.js";
|
|
||||||
|
|
||||||
const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
|
|
||||||
// ... existing factories ...
|
|
||||||
"my-provider": (logger, runtimeSettings) =>
|
|
||||||
new MyProviderACPAgentClient({
|
|
||||||
logger,
|
|
||||||
runtimeSettings: runtimeSettings?.["my-provider"],
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Add a provider icon (app)
|
|
||||||
|
|
||||||
Create `packages/app/src/components/icons/my-provider-icon.tsx` following the pattern from existing icons (e.g., `claude-icon.tsx`):
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import Svg, { Path } from "react-native-svg";
|
|
||||||
|
|
||||||
interface MyProviderIconProps {
|
|
||||||
size?: number;
|
|
||||||
color?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MyProviderIcon({ size = 16, color = "currentColor" }: MyProviderIconProps) {
|
|
||||||
return (
|
|
||||||
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
|
|
||||||
<Path d="..." />
|
|
||||||
</Svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Then register it in `packages/app/src/components/provider-icons.ts`:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { MyProviderIcon } from "@/components/icons/my-provider-icon";
|
|
||||||
|
|
||||||
const PROVIDER_ICONS: Record<string, typeof Bot> = {
|
|
||||||
claude: ClaudeIcon as unknown as typeof Bot,
|
|
||||||
codex: CodexIcon as unknown as typeof Bot,
|
|
||||||
"my-provider": MyProviderIcon as unknown as typeof Bot,
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
If no icon is registered, the app falls back to a generic `Bot` icon from lucide.
|
|
||||||
|
|
||||||
### 5. Add E2E test config
|
|
||||||
|
|
||||||
In `packages/server/src/server/daemon-e2e/agent-configs.ts`, add your provider:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export const agentConfigs = {
|
|
||||||
// ... existing configs ...
|
|
||||||
"my-provider": {
|
|
||||||
provider: "my-provider",
|
|
||||||
model: "default-model-id",
|
|
||||||
modes: {
|
|
||||||
full: "autonomous", // Mode with no permission prompts
|
|
||||||
ask: "default", // Mode that requires permission approval
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as const satisfies Record<string, AgentTestConfig>;
|
|
||||||
```
|
|
||||||
|
|
||||||
Add an availability check in `isProviderAvailable()`:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
case "my-provider":
|
|
||||||
return (
|
|
||||||
isCommandAvailable("my-agent-binary") &&
|
|
||||||
Boolean(process.env.MY_PROVIDER_API_KEY)
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
Add to the `allProviders` array:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export const allProviders: AgentProvider[] = [
|
|
||||||
"claude",
|
|
||||||
"claude-acp",
|
|
||||||
"codex",
|
|
||||||
"copilot",
|
|
||||||
"opencode",
|
|
||||||
"my-provider",
|
|
||||||
];
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. Run typecheck
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run typecheck
|
|
||||||
```
|
|
||||||
|
|
||||||
This is required after every change per project rules.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Direct Provider Checklist
|
|
||||||
|
|
||||||
If your agent does not speak ACP, implement the interfaces from `agent-sdk-types.ts` directly.
|
|
||||||
|
|
||||||
### Interfaces to implement
|
|
||||||
|
|
||||||
**`AgentClient`** -- factory for sessions and model listing:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface AgentClient {
|
|
||||||
readonly provider: AgentProvider;
|
|
||||||
readonly capabilities: AgentCapabilityFlags;
|
|
||||||
createSession(config: AgentSessionConfig, launchContext?: AgentLaunchContext): Promise<AgentSession>;
|
|
||||||
resumeSession(handle: AgentPersistenceHandle, overrides?: Partial<AgentSessionConfig>, launchContext?: AgentLaunchContext): Promise<AgentSession>;
|
|
||||||
listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]>;
|
|
||||||
isAvailable(): Promise<boolean>;
|
|
||||||
// Optional:
|
|
||||||
listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**`AgentSession`** -- a running agent conversation:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface AgentSession {
|
|
||||||
readonly provider: AgentProvider;
|
|
||||||
readonly id: string | null;
|
|
||||||
readonly capabilities: AgentCapabilityFlags;
|
|
||||||
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
|
|
||||||
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
|
|
||||||
subscribe(callback: (event: AgentStreamEvent) => void): () => void;
|
|
||||||
streamHistory(): AsyncGenerator<AgentStreamEvent>;
|
|
||||||
getRuntimeInfo(): Promise<AgentRuntimeInfo>;
|
|
||||||
getAvailableModes(): Promise<AgentMode[]>;
|
|
||||||
getCurrentMode(): Promise<string | null>;
|
|
||||||
setMode(modeId: string): Promise<void>;
|
|
||||||
getPendingPermissions(): AgentPermissionRequest[];
|
|
||||||
respondToPermission(requestId: string, response: AgentPermissionResponse): Promise<void>;
|
|
||||||
describePersistence(): AgentPersistenceHandle | null;
|
|
||||||
interrupt(): Promise<void>;
|
|
||||||
close(): Promise<void>;
|
|
||||||
// Optional:
|
|
||||||
listCommands?(): Promise<AgentSlashCommand[]>;
|
|
||||||
setModel?(modelId: string | null): Promise<void>;
|
|
||||||
setThinkingOption?(thinkingOptionId: string | null): Promise<void>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Steps
|
|
||||||
|
|
||||||
1. Create `packages/server/src/server/agent/providers/{name}-agent.ts` implementing both interfaces
|
|
||||||
2. Add to the provider manifest (same as ACP step 2 above)
|
|
||||||
3. Add factory to the registry (same as ACP step 3 above)
|
|
||||||
4. Add icon (same as ACP step 4 above)
|
|
||||||
5. Add E2E config (same as ACP step 5 above)
|
|
||||||
6. Run typecheck
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
### Manual testing with the CLI
|
|
||||||
|
|
||||||
Start the daemon if not already running, then:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Launch an agent with your provider
|
|
||||||
paseo run --provider my-provider
|
|
||||||
|
|
||||||
# Launch with a specific model and mode
|
|
||||||
paseo run --provider my-provider --model some-model --mode default
|
|
||||||
|
|
||||||
# List running agents
|
|
||||||
paseo ls -a -g
|
|
||||||
|
|
||||||
# Check if the provider reports models
|
|
||||||
paseo models --provider my-provider
|
|
||||||
```
|
|
||||||
|
|
||||||
### E2E test patterns
|
|
||||||
|
|
||||||
The E2E configs in `agent-configs.ts` expose two helpers:
|
|
||||||
|
|
||||||
- `getFullAccessConfig(provider)` -- returns config for a session with no permission prompts
|
|
||||||
- `getAskModeConfig(provider)` -- returns config for a session that triggers permission requests
|
|
||||||
|
|
||||||
Tests use `isProviderAvailable(provider)` to skip when the binary or credentials are missing, so CI will not fail for providers that are not installed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Gotchas
|
|
||||||
|
|
||||||
**Mode IDs can be URIs.** ACP providers like Copilot use full URIs as mode IDs (e.g., `"https://agentclientprotocol.com/protocol/session-modes#agent"`). Never assume mode IDs are simple strings. The manifest `defaultModeId` must match exactly.
|
|
||||||
|
|
||||||
**Models and modes are discovered dynamically.** ACP providers report available models and modes at runtime via the protocol. The static definitions in `provider-manifest.ts` are used for UI scaffolding (icons, color tiers) but the runtime values from the agent process are the source of truth.
|
|
||||||
|
|
||||||
**`AgentProvider` is always `string`.** The type alias is `type AgentProvider = string`. Provider IDs are validated against the manifest at runtime, not at the type level.
|
|
||||||
|
|
||||||
**Auth patterns vary.** Some providers need API keys in env vars (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`), some use OAuth tokens (`CLAUDE_CODE_OAUTH_TOKEN`), some use auth files (`~/.codex/auth.json`), and some handle auth entirely in their CLI binary (Copilot). Your `isAvailable()` method should check whatever is needed.
|
|
||||||
|
|
||||||
**The manifest mode list and the agent class mode list are separate.** The manifest in `provider-manifest.ts` includes UI metadata (`icon`, `colorTier`). The agent class defines modes without UI metadata (just `id`, `label`, `description`). Keep them in sync.
|
|
||||||
|
|
||||||
**`defaultCommand` is a tuple.** The first element is the binary name, the rest are default arguments. The base class uses this to find the executable and spawn the process.
|
|
||||||
|
|
||||||
**Runtime settings can override the command.** Users can configure custom binary paths or environment variables per provider via `ProviderRuntimeSettings`. Your factory in the registry should pass `runtimeSettings?.["your-provider"]` through to the constructor.
|
|
||||||
+13
-104
@@ -2,21 +2,8 @@
|
|||||||
|
|
||||||
All workspaces share one version and release together.
|
All workspaces share one version and release together.
|
||||||
|
|
||||||
## Two paths
|
|
||||||
|
|
||||||
There are two supported ways to ship from `main`:
|
|
||||||
|
|
||||||
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
|
|
||||||
2. **Release candidate flow**: you want public test builds first, but you are not ready for the website, npm, or production mobile release flows to move yet.
|
|
||||||
|
|
||||||
## Standard release (patch)
|
## Standard release (patch)
|
||||||
|
|
||||||
Before running any stable patch release command:
|
|
||||||
|
|
||||||
- Make sure the intended release commit is already committed to `main` and the working tree is clean.
|
|
||||||
- Make sure local `npm run typecheck` passes on that commit.
|
|
||||||
- Do not use `npm run release:patch` as a substitute for checking whether the current commit is actually ready.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run release:patch
|
npm run release:patch
|
||||||
```
|
```
|
||||||
@@ -25,56 +12,36 @@ This bumps the version across all workspaces, runs checks, publishes to npm, and
|
|||||||
|
|
||||||
If asked to "release paseo" without specifying major/minor, treat it as a patch release.
|
If asked to "release paseo" without specifying major/minor, treat it as a patch release.
|
||||||
|
|
||||||
Use the direct stable path when the current `main` changes are ready to become the public release immediately.
|
|
||||||
|
|
||||||
## Manual step-by-step
|
## Manual step-by-step
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run typecheck # Verify the exact commit you intend to release
|
|
||||||
npm run release:check # Typecheck, build, dry-run pack
|
npm run release:check # Typecheck, build, dry-run pack
|
||||||
npm run version:all:patch # Bump version, create commit + tag
|
npm run version:all:patch # Bump version, create commit + tag
|
||||||
npm run release:publish # Publish to npm
|
npm run release:publish # Publish to npm
|
||||||
npm run release:push # Push HEAD + tag (triggers CI workflows)
|
npm run release:push # Push HEAD + tag (triggers CI workflows)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Release candidate flow
|
## Draft release flow
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run release:rc:patch # Bump to X.Y.Z-rc.1, push commit + tag
|
npm run draft-release:patch # Bump, push tag, create draft GitHub Release
|
||||||
# ... test desktop and APK prerelease assets from GitHub Releases ...
|
# ... test builds from the draft release assets ...
|
||||||
npm run release:rc:next # Optional: cut X.Y.Z-rc.2, rc.3, ...
|
npm run release:finalize # Publish npm, promote draft to published
|
||||||
npm run release:promote # Promote X.Y.Z-rc.N to stable X.Y.Z
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- RC tags are published GitHub prereleases like `v0.1.41-rc.1`
|
- `draft-release:patch` creates the GitHub Release as a draft so desktop assets, APK uploads, and synced notes attach to it
|
||||||
- RCs publish desktop assets and APKs for testing, but they do not publish npm packages and do not trigger the production web/mobile release flows
|
- `release:finalize` publishes npm and promotes the same draft release
|
||||||
- `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the RC tag
|
- Use the same semver tag for both; don't cut a second tag
|
||||||
- Desktop assets now come from the Electron package at `packages/desktop`
|
- Desktop assets now come from the Electron package at `packages/desktop`
|
||||||
- **Do NOT create a changelog entry for RCs.** The changelog remains stable-only. RC release notes are generated automatically so the website stays pinned to the latest published stable release.
|
- **Do NOT create a changelog entry for drafts.** The changelog entry is written only when finalizing. The website parses `CHANGELOG.md` to determine the latest published version for download links — adding an entry for a draft will point the homepage at untested assets.
|
||||||
|
|
||||||
Use the RC path when you need to:
|
|
||||||
|
|
||||||
- test a build manually in a Linux or Windows VM
|
|
||||||
- send a build to a user who is hitting a specific problem
|
|
||||||
- iterate on `rc.1`, `rc.2`, `rc.3`, and so on before deciding to ship broadly
|
|
||||||
|
|
||||||
## Website behavior
|
|
||||||
|
|
||||||
- The website download page points to GitHub's latest published **stable** release.
|
|
||||||
- Published RC prereleases are public on GitHub Releases, but they do **not** become the website download target.
|
|
||||||
- The website only moves when you publish the final stable release tag like `v0.1.41`.
|
|
||||||
|
|
||||||
## Fixing a failed release build
|
## Fixing a failed release build
|
||||||
|
|
||||||
**NEVER bump the version to fix a build problem.** New versions are reserved for meaningful product changes (features, fixes, improvements). Build/CI failures are fixed on the current version.
|
**NEVER bump the version to fix a build problem.** New versions are reserved for meaningful product changes (features, fixes, improvements). Build/CI failures are fixed on the current version.
|
||||||
|
|
||||||
**Do not rely on `workflow_dispatch` for tagged code fixes.** The `workflow_dispatch` trigger runs the workflow file from the default branch but checks out the code at the tag ref (`ref: ${{ inputs.tag }}`). That means fixes committed to `main` won't change the tagged source tree being built. `workflow_dispatch` only helps when the fix lives in the workflow file itself.
|
**NEVER use `workflow_dispatch` to retry release builds.** The `workflow_dispatch` trigger runs the workflow file from the default branch but checks out the code at the tag ref (`ref: ${{ inputs.tag }}`). This means build fixes committed to `main` won't be picked up — the old broken code at the tag gets built again.
|
||||||
|
|
||||||
To retry a failed workflow, **always push a retry tag** on the commit you want to build. Reusing the same tag name is expected: move it with `git tag -f ...` and push it with `--force` so the workflow rebuilds the commit you actually want.
|
To retry a failed workflow, **always push a retry tag** on the commit you want to build:
|
||||||
|
|
||||||
Prefer a tag push over `workflow_dispatch` whenever you are rebuilding release code or release assets.
|
|
||||||
|
|
||||||
The retry tag patterns below still work and remain the supported way to rebuild specific release targets:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Desktop (all platforms)
|
# Desktop (all platforms)
|
||||||
@@ -87,29 +54,21 @@ git tag -f desktop-windows-v0.1.28 HEAD && git push origin desktop-windows-v0.1.
|
|||||||
|
|
||||||
# Android APK
|
# Android APK
|
||||||
git tag -f android-v0.1.28 HEAD && git push origin android-v0.1.28 --force
|
git tag -f android-v0.1.28 HEAD && git push origin android-v0.1.28 --force
|
||||||
|
|
||||||
# RC
|
|
||||||
git tag -f v0.1.29-rc.2 HEAD && git push origin v0.1.29-rc.2 --force
|
|
||||||
```
|
```
|
||||||
|
|
||||||
This ensures the checkout ref matches the actual code on `main` with the fix included.
|
This ensures the checkout ref matches the actual code on `main` with the fix included.
|
||||||
|
|
||||||
- `vX.Y.Z` or `vX.Y.Z-rc.N` rebuilds the full tagged release
|
|
||||||
- `desktop-vX.Y.Z` rebuilds desktop for all desktop platforms only
|
|
||||||
- `desktop-macos-vX.Y.Z`, `desktop-linux-vX.Y.Z`, and `desktop-windows-vX.Y.Z` rebuild only that desktop platform
|
|
||||||
- `android-vX.Y.Z` rebuilds the Android APK release only
|
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- `version:all:*` bumps root + syncs workspace versions and `@getpaseo/*` dependency versions
|
- `version:all:*` bumps root + syncs workspace versions and `@getpaseo/*` dependency versions
|
||||||
- `release:prepare` refreshes workspace `node_modules` links to prevent stale types
|
- `release:prepare` refreshes workspace `node_modules` links to prevent stale types
|
||||||
- `npm run dev:desktop` and `npm run build:desktop` target the Electron desktop package in `packages/desktop`
|
- `npm run dev:desktop` and `npm run build:desktop` target the Electron desktop package in `packages/desktop`
|
||||||
- If `release:publish` partially fails, re-run it — npm skips already-published versions
|
- If `release:publish` partially fails, re-run it — npm skips already-published versions
|
||||||
- The website uses GitHub's latest published release API for download links, so published RC prereleases do not replace the stable download target.
|
- The website parses the first `## X.Y.Z` heading in `CHANGELOG.md` to determine the download version. This is why changelog entries must only be added at finalization, not during drafts.
|
||||||
|
|
||||||
## Changelog format
|
## Changelog format
|
||||||
|
|
||||||
Stable release notes depend on the changelog heading format. The heading **must** be strictly followed:
|
The website depends on the changelog to determine the latest download version. The heading format **must** be strictly followed:
|
||||||
|
|
||||||
```
|
```
|
||||||
## X.Y.Z - YYYY-MM-DD
|
## X.Y.Z - YYYY-MM-DD
|
||||||
@@ -117,61 +76,11 @@ Stable release notes depend on the changelog heading format. The heading **must*
|
|||||||
|
|
||||||
No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to extract the version. A malformed heading will break download links on the homepage.
|
No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to extract the version. A malformed heading will break download links on the homepage.
|
||||||
|
|
||||||
## Changelog policy
|
|
||||||
|
|
||||||
- `CHANGELOG.md` is for **final stable releases only**.
|
|
||||||
- Do not add or edit changelog entries while iterating on RCs.
|
|
||||||
- Write the proper changelog entry when you are cutting the final stable release that comes after the RC cycle.
|
|
||||||
- Between stable releases, keep changelog work out of the repo until the final release is ready.
|
|
||||||
|
|
||||||
## Changelog ownership
|
|
||||||
|
|
||||||
- **Only Claude should write changelog entries.**
|
|
||||||
- If you are Codex and a stable release needs a changelog entry, launch a Claude agent with Paseo to draft it, then review and commit the result.
|
|
||||||
|
|
||||||
## Changelog voice
|
|
||||||
|
|
||||||
The changelog is shown on the Paseo homepage. Write it for **end users**, not developers.
|
|
||||||
|
|
||||||
- **Frame everything from the user's perspective.** Describe what changed in the app, not what changed in the code. Users care that "workspaces load instantly" — not that a component no longer remounts.
|
|
||||||
- **Never mention component names, internal modules, or implementation details.** No `WorkingIndicator`, no `accumulatedUsage`, no `reconcileAndEmitWorkspaceUpdates`.
|
|
||||||
- **Collapse internal iterations.** If a feature was added and then fixed within the same release, just list the feature as working. Users never saw the broken version.
|
|
||||||
- **Only list changes relative to the previous stable release.** The diff is `v(previous)..HEAD`. If something was introduced and fixed between those two tags, it never shipped — don't mention the fix.
|
|
||||||
- **Cut low-signal entries.** "Toolbar buttons have consistent sizing" is too granular. Combine small polish items or drop them.
|
|
||||||
|
|
||||||
## Pre-release sanity check
|
|
||||||
|
|
||||||
Before cutting any release (RC or stable), run a Codex review of the diff as a last line of defence against shipping bugs.
|
|
||||||
|
|
||||||
Load the `paseo` skill and launch a **Codex 5.4** agent with a prompt like:
|
|
||||||
|
|
||||||
> Review the diff between the latest release tag and HEAD. Focus on:
|
|
||||||
>
|
|
||||||
> 1. **Breaking changes** — especially in the WebSocket protocol, agent lifecycle, and any server↔client contract.
|
|
||||||
> 2. **Backward compatibility** — the important direction is old app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Flag anything that breaks old clients against new daemons or requires both sides to update in lockstep.
|
|
||||||
> 3. **Regressions** — anything that looks like it could break existing functionality.
|
|
||||||
>
|
|
||||||
> Diff: `git diff <latest-release-tag>..HEAD`
|
|
||||||
|
|
||||||
The agent's job is a deep sanity check, not a full code review. If it flags anything, investigate before proceeding.
|
|
||||||
|
|
||||||
## Changelog scope
|
|
||||||
|
|
||||||
The changelog always covers **stable-to-HEAD**:
|
|
||||||
|
|
||||||
- **RC release**: the diff and release notes cover `latest stable tag → HEAD`. RC release notes are auto-generated and not added to `CHANGELOG.md`.
|
|
||||||
- **Stable release**: the diff and changelog entry cover `latest stable tag → HEAD`. Any intermediate RCs are skipped — the changelog captures the full delta from the previous stable release, not just what changed since the last RC.
|
|
||||||
|
|
||||||
In other words, RCs are checkpoints along the way; the changelog only records the final jump from one stable version to the next.
|
|
||||||
|
|
||||||
## Completion checklist
|
## Completion checklist
|
||||||
|
|
||||||
- [ ] Run the pre-release sanity check (see above) and address any findings
|
|
||||||
- [ ] Ensure the intended release commit is already committed and the git worktree is clean before running any `release:*` patch/promote command
|
|
||||||
- [ ] Ensure local `npm run typecheck` passes on that exact commit before running any `release:*` patch/promote command
|
|
||||||
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors)
|
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors)
|
||||||
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
|
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
|
||||||
- [ ] `npm run release:patch` or `npm run release:promote` completes successfully
|
- [ ] `npm run release:patch` (or `release:finalize` for drafts) completes successfully
|
||||||
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
|
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
|
||||||
- [ ] GitHub `Android APK Release` workflow for the same tag is green
|
- [ ] GitHub `Android APK Release` workflow for the same tag is green
|
||||||
- [ ] EAS `release-mobile.yml` workflow for the same tag is green
|
- [ ] EAS `release-mobile.yml` workflow for the same tag is green
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
# Storage Revamp Plan
|
||||||
|
|
||||||
|
Status: active rollout, phases 1 and 2 complete
|
||||||
|
|
||||||
|
This document now tracks the storage revamp as it exists today, not as a speculative design exercise.
|
||||||
|
The DB foundation and the project/workspace identity cutover have landed. What remains is the explicit
|
||||||
|
creation/archive surface cleanup, timeline durability cutover, and final removal of legacy paths.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- make structured records durable in Drizzle + SQLite
|
||||||
|
- make projects and workspaces explicit first-class records
|
||||||
|
- stop deriving project/workspace identity from agent `cwd`
|
||||||
|
- keep agent snapshot persistence behind clear ownership
|
||||||
|
- move committed timeline history to storage-owned rows
|
||||||
|
- remove legacy JSON and in-memory authority once the DB path is proven
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- moving config, keypairs, push tokens, or server identity into the DB
|
||||||
|
- persisting raw provider deltas or transport-only chunk streams
|
||||||
|
- designing a hosted/remote database story beyond keeping the schema portable
|
||||||
|
- durable reasoning history unless product explicitly asks for it later
|
||||||
|
|
||||||
|
## Current state
|
||||||
|
|
||||||
|
The storage revamp is no longer hypothetical.
|
||||||
|
|
||||||
|
Completed:
|
||||||
|
|
||||||
|
- Drizzle + SQLite database bootstrap is in place
|
||||||
|
- `projects`, `workspaces`, and `agent_snapshots` use integer primary keys
|
||||||
|
- `workspaces.project_id` and `agent_snapshots.workspace_id` cascade on delete
|
||||||
|
- `agent_snapshots.workspace_id` is `NOT NULL`
|
||||||
|
- legacy JSON import feeds the DB-backed structured records
|
||||||
|
- project/workspace records use explicit `directory` fields instead of path-as-identity
|
||||||
|
- session read paths now use persisted workspace/project rows instead of cwd/git derivation
|
||||||
|
- `workspace-reconciliation-service.ts` is deleted
|
||||||
|
- `workspace-registry-bootstrap.ts` is deleted
|
||||||
|
- `workspace-registry-model.ts` is reduced to `normalizeWorkspaceId`
|
||||||
|
|
||||||
|
Still pending:
|
||||||
|
|
||||||
|
- explicit `create_project` / `create_workspace` API cleanup
|
||||||
|
- final archive cascade behavior for descendants and live agents
|
||||||
|
- committed timeline storage cutover
|
||||||
|
- removal of remaining legacy JSON and in-memory committed-history authority
|
||||||
|
|
||||||
|
## Converged decisions
|
||||||
|
|
||||||
|
### Structured record authority
|
||||||
|
|
||||||
|
Projects, workspaces, and agent snapshots are DB-backed structured records.
|
||||||
|
The server should not recreate project/workspace identity from:
|
||||||
|
|
||||||
|
- git remotes
|
||||||
|
- worktree main-repo roots
|
||||||
|
- normalized cwd strings
|
||||||
|
|
||||||
|
Temporary exception:
|
||||||
|
|
||||||
|
- agent creation may still find-or-create a workspace by directory if the UI has not yet provided
|
||||||
|
`workspaceId` explicitly
|
||||||
|
|
||||||
|
That fallback is transitional and should be deleted once the client always sends the workspace id.
|
||||||
|
|
||||||
|
### Storage seams
|
||||||
|
|
||||||
|
The useful seams remain concrete and domain-shaped:
|
||||||
|
|
||||||
|
- `ProjectRegistry`
|
||||||
|
- `WorkspaceRegistry`
|
||||||
|
- `AgentSnapshotStore`
|
||||||
|
- `AgentTimelineStore`
|
||||||
|
|
||||||
|
There is no reason to reintroduce a reconciliation service layer for project/workspace identity.
|
||||||
|
|
||||||
|
### Timeline contract
|
||||||
|
|
||||||
|
The long-term timeline contract remains:
|
||||||
|
|
||||||
|
- committed rows are durable, canonical history
|
||||||
|
- provisional live updates are transient subscription state
|
||||||
|
- committed history is fetched by seq
|
||||||
|
- provider history replay is not the durability mechanism
|
||||||
|
|
||||||
|
The structured-record cutover is complete before the timeline cutover so timeline rows can rely on
|
||||||
|
stable DB-backed agent and workspace identity.
|
||||||
|
|
||||||
|
## Remaining phases
|
||||||
|
|
||||||
|
### Phase 3: Explicit creation and archive cleanup
|
||||||
|
|
||||||
|
Goal:
|
||||||
|
Remove the last transitional write paths that still infer state from directories.
|
||||||
|
|
||||||
|
Required work:
|
||||||
|
|
||||||
|
- add explicit `create_project` handling
|
||||||
|
- add explicit `create_workspace` handling
|
||||||
|
- make agent creation require `workspaceId` once the UI is ready
|
||||||
|
- finish archive semantics for workspaces/projects and any descendant agent state
|
||||||
|
- remove the temporary find-or-create-by-directory fallback from agent creation
|
||||||
|
|
||||||
|
Exit gate:
|
||||||
|
|
||||||
|
- project/workspace creation is explicit end to end
|
||||||
|
- no normal creation path infers identity from cwd or git metadata
|
||||||
|
- archive flows behave consistently for structured records and live runtime state
|
||||||
|
|
||||||
|
### Phase 4: Timeline storage cutover
|
||||||
|
|
||||||
|
Goal:
|
||||||
|
Make committed history durable and storage-owned.
|
||||||
|
|
||||||
|
Required work:
|
||||||
|
|
||||||
|
- make `AgentTimelineStore` authoritative for committed history
|
||||||
|
- write one committed row per finalized logical item
|
||||||
|
- support tail, before-seq, and after-seq queries from storage
|
||||||
|
- stop treating provider history hydration as the normal refresh/load path
|
||||||
|
- keep provisional live updates in memory only
|
||||||
|
|
||||||
|
Exit gate:
|
||||||
|
|
||||||
|
- committed history survives daemon restart
|
||||||
|
- reconnect uses committed catch-up plus future live events without gaps or duplicates
|
||||||
|
- unloaded agents can serve committed history from storage alone
|
||||||
|
|
||||||
|
### Phase 5: Legacy cleanup
|
||||||
|
|
||||||
|
Goal:
|
||||||
|
Remove compatibility paths after the DB-backed model is fully authoritative.
|
||||||
|
|
||||||
|
Required work:
|
||||||
|
|
||||||
|
- remove legacy JSON authority for structured records
|
||||||
|
- remove in-memory committed-history ownership
|
||||||
|
- remove provider-history rehydrate compatibility paths
|
||||||
|
- trim dead protocol and reducer logic from the pre-storage model
|
||||||
|
- update architecture docs to match the final model
|
||||||
|
|
||||||
|
Exit gate:
|
||||||
|
|
||||||
|
- there is one durable storage path for structured records
|
||||||
|
- there is one durable storage path for committed timeline history
|
||||||
|
- the runtime no longer depends on the removed JSON/in-memory model
|
||||||
|
|
||||||
|
## Data model summary
|
||||||
|
|
||||||
|
### Projects
|
||||||
|
|
||||||
|
- integer primary key
|
||||||
|
- `directory` is unique
|
||||||
|
- `display_name`
|
||||||
|
- `kind`: `git | directory`
|
||||||
|
- optional `git_remote`
|
||||||
|
- timestamps and archive state
|
||||||
|
|
||||||
|
### Workspaces
|
||||||
|
|
||||||
|
- integer primary key
|
||||||
|
- belongs to a project by `project_id`
|
||||||
|
- `directory` is unique
|
||||||
|
- `display_name`
|
||||||
|
- `kind`: `checkout | worktree`
|
||||||
|
- timestamps and archive state
|
||||||
|
|
||||||
|
### Agent snapshots
|
||||||
|
|
||||||
|
- `agent_id` remains the primary key
|
||||||
|
- belongs to a workspace by integer `workspace_id`
|
||||||
|
- `workspace_id` is required
|
||||||
|
- timestamps, lifecycle state, persistence metadata, attention metadata, archive state
|
||||||
|
|
||||||
|
### Timeline rows
|
||||||
|
|
||||||
|
Target shape once Phase 4 lands:
|
||||||
|
|
||||||
|
- `agent_id`
|
||||||
|
- committed `seq`
|
||||||
|
- committed timestamp
|
||||||
|
- canonical finalized item payload
|
||||||
|
|
||||||
|
Not part of durable history:
|
||||||
|
|
||||||
|
- raw streaming chunks
|
||||||
|
- provisional assistant text
|
||||||
|
- provisional reasoning text
|
||||||
|
|
||||||
|
## Verification requirements
|
||||||
|
|
||||||
|
Every remaining phase should keep the same bar:
|
||||||
|
|
||||||
|
- `npm run typecheck`
|
||||||
|
- targeted tests for the touched storage/session/runtime paths
|
||||||
|
- migration/import coverage when storage authority changes
|
||||||
|
- reconnect and catch-up scenario coverage when timeline behavior changes
|
||||||
|
|
||||||
|
At minimum, timeline cutover must explicitly prove:
|
||||||
|
|
||||||
|
- `fetch-after-seq`
|
||||||
|
- `fetch-before-seq`
|
||||||
|
- restart durability
|
||||||
|
- no-gap/no-duplicate reconnect behavior
|
||||||
|
|
||||||
|
## Main risks
|
||||||
|
|
||||||
|
- timeline work reintroduces provider-history replay as hidden authority
|
||||||
|
- archive behavior diverges between stored records and live in-memory agents
|
||||||
|
- explicit creation work leaves the transitional cwd fallback in place too long
|
||||||
|
- cleanup stalls after compatibility paths stop being exercised
|
||||||
|
|
||||||
|
## Rule of thumb
|
||||||
|
|
||||||
|
If a new change needs to ask "what can we infer from this cwd?" for project or workspace identity,
|
||||||
|
it is probably moving in the wrong direction.
|
||||||
@@ -0,0 +1,506 @@
|
|||||||
|
# Terminal Mode — Implementation Plan
|
||||||
|
|
||||||
|
## Concept
|
||||||
|
|
||||||
|
Terminal mode wraps an agent TUI (Claude Code, Codex, OpenCode, Gemini, etc.) in a Paseo agent entity. The agent is tracked in sessions, has a provider/icon/title, and can be archived — but instead of rendering a structured chat view, it renders a terminal running the agent's CLI.
|
||||||
|
|
||||||
|
**Key principle:** `agent.terminal` is a boolean flag on the agent entity. If `true`, the panel renders a terminal. If `false` (default), it renders the current structured AgentStreamView.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
### Phase 1: Server — Data Model & Provider Interface
|
||||||
|
|
||||||
|
#### 1.1 Add `terminal` flag to `ManagedAgentBase`
|
||||||
|
|
||||||
|
**File:** `packages/server/src/server/agent/agent-manager.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type ManagedAgentBase = {
|
||||||
|
// ...existing fields...
|
||||||
|
terminal: boolean; // NEW — if true, this agent renders as a terminal TUI
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
This flag is set at creation time and never changes. A terminal agent is always a terminal agent.
|
||||||
|
|
||||||
|
#### 1.2 Add `terminal` to `AgentSessionConfig`
|
||||||
|
|
||||||
|
**File:** `packages/server/src/server/agent/agent-sdk-types.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export type AgentSessionConfig = {
|
||||||
|
// ...existing fields...
|
||||||
|
terminal?: boolean; // NEW — create as terminal agent
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.3 Add `terminal` to the Zod schema
|
||||||
|
|
||||||
|
**File:** `packages/server/src/shared/messages.ts`
|
||||||
|
|
||||||
|
Add to `AgentSessionConfigSchema`:
|
||||||
|
```typescript
|
||||||
|
terminal: z.boolean().optional(),
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to the `AgentStateSchema` (the wire format sent to clients):
|
||||||
|
```typescript
|
||||||
|
terminal: z.boolean().optional(),
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.4 Add terminal command builders to `AgentClient`
|
||||||
|
|
||||||
|
**File:** `packages/server/src/server/agent/agent-sdk-types.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export type TerminalCommand = {
|
||||||
|
command: string;
|
||||||
|
args: string[];
|
||||||
|
env?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface AgentClient {
|
||||||
|
// ...existing methods...
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the shell command to launch this agent's TUI for a new session.
|
||||||
|
* Only available if capabilities.supportsTerminalMode is true.
|
||||||
|
*/
|
||||||
|
buildTerminalCreateCommand?(config: AgentSessionConfig): TerminalCommand;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the shell command to resume an existing session in the agent's TUI.
|
||||||
|
* Only available if capabilities.supportsTerminalMode is true.
|
||||||
|
*/
|
||||||
|
buildTerminalResumeCommand?(handle: AgentPersistenceHandle): TerminalCommand;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.5 Add `supportsTerminalMode` capability
|
||||||
|
|
||||||
|
**File:** `packages/server/src/server/agent/agent-sdk-types.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export type AgentCapabilityFlags = {
|
||||||
|
// ...existing flags...
|
||||||
|
supportsTerminalMode: boolean; // NEW
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Also add to the Zod schema in `messages.ts`:
|
||||||
|
```typescript
|
||||||
|
supportsTerminalMode: z.boolean(),
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.6 Implement terminal command builders in providers
|
||||||
|
|
||||||
|
**Claude** (`packages/server/src/server/agent/providers/claude-agent.ts`):
|
||||||
|
```typescript
|
||||||
|
buildTerminalCreateCommand(config: AgentSessionConfig): TerminalCommand {
|
||||||
|
const args: string[] = [];
|
||||||
|
if (config.modeId === "bypassPermissions") {
|
||||||
|
args.push("--dangerously-skip-permissions");
|
||||||
|
}
|
||||||
|
if (config.model) args.push("--model", config.model);
|
||||||
|
// mode mapping: default → nothing, plan → --plan, etc.
|
||||||
|
return { command: "claude", args, env: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTerminalResumeCommand(handle: AgentPersistenceHandle): TerminalCommand {
|
||||||
|
return {
|
||||||
|
command: "claude",
|
||||||
|
args: ["--resume", handle.sessionId],
|
||||||
|
env: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Codex** (`packages/server/src/server/agent/providers/codex-app-server-agent.ts`):
|
||||||
|
```typescript
|
||||||
|
buildTerminalCreateCommand(config: AgentSessionConfig): TerminalCommand {
|
||||||
|
const args: string[] = [];
|
||||||
|
if (config.model) args.push("--model", config.model);
|
||||||
|
if (config.modeId) args.push("--approval-mode", config.modeId);
|
||||||
|
return { command: "codex", args, env: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTerminalResumeCommand(handle: AgentPersistenceHandle): TerminalCommand {
|
||||||
|
return {
|
||||||
|
command: "codex",
|
||||||
|
args: ["--resume", handle.nativeHandle ?? handle.sessionId],
|
||||||
|
env: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**OpenCode** (`packages/server/src/server/agent/providers/opencode-agent.ts`):
|
||||||
|
```typescript
|
||||||
|
buildTerminalCreateCommand(config: AgentSessionConfig): TerminalCommand {
|
||||||
|
return { command: "opencode", args: [], env: {} };
|
||||||
|
}
|
||||||
|
// No resume support for OpenCode initially
|
||||||
|
```
|
||||||
|
|
||||||
|
Capabilities for each provider:
|
||||||
|
- Claude: `supportsTerminalMode: true`
|
||||||
|
- Codex: `supportsTerminalMode: true`
|
||||||
|
- OpenCode: `supportsTerminalMode: true`
|
||||||
|
|
||||||
|
#### 1.7 Handle terminal agent creation in `AgentManager.createAgent()`
|
||||||
|
|
||||||
|
**File:** `packages/server/src/server/agent/agent-manager.ts`
|
||||||
|
|
||||||
|
When `config.terminal === true`:
|
||||||
|
1. Do NOT call `client.createSession()` — there is no managed session
|
||||||
|
2. Call `client.buildTerminalCreateCommand(config)` to get the command
|
||||||
|
3. Create a `TerminalSession` via `terminalManager.createTerminal()` with the command
|
||||||
|
4. Register the agent with `terminal: true`, `lifecycle: "idle"`, `session: null`
|
||||||
|
5. Store the terminal ID in the agent's metadata or a new field
|
||||||
|
6. The agent's persistence handle can be populated later (the CLI will create its own session file)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async createAgent(config: AgentSessionConfig, agentId?: string, options?: { labels?: Record<string, string> }): Promise<ManagedAgent> {
|
||||||
|
const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent");
|
||||||
|
const normalizedConfig = await this.normalizeConfig(config);
|
||||||
|
const client = this.requireClient(normalizedConfig.provider);
|
||||||
|
|
||||||
|
if (normalizedConfig.terminal) {
|
||||||
|
// Terminal mode — no managed session, just build the command
|
||||||
|
const buildCmd = client.buildTerminalCreateCommand;
|
||||||
|
if (!buildCmd) {
|
||||||
|
throw new Error(`Provider '${normalizedConfig.provider}' does not support terminal mode`);
|
||||||
|
}
|
||||||
|
const cmd = buildCmd.call(client, normalizedConfig);
|
||||||
|
return this.registerTerminalAgent(resolvedAgentId, normalizedConfig, cmd, {
|
||||||
|
labels: options?.labels,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ...existing managed agent flow...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
New method `registerTerminalAgent()`:
|
||||||
|
- Creates a ManagedAgent with `terminal: true`
|
||||||
|
- Stores the `TerminalCommand` in agent metadata for later use (resume, reconnect)
|
||||||
|
- Sets lifecycle to `"idle"` (the terminal itself manages the agent's internal state)
|
||||||
|
- Does NOT have an `AgentSession` — the `session` field is `null` (like closed agents)
|
||||||
|
- Broadcasts `agent_state` event so clients know about it
|
||||||
|
|
||||||
|
#### 1.8 New message: create terminal for agent
|
||||||
|
|
||||||
|
The client needs a way to request a terminal for a terminal agent. Options:
|
||||||
|
|
||||||
|
**Option A:** Extend `createTerminal` to accept an agent ID. When provided, the server looks up the agent, gets the command, and creates a terminal pre-configured with that command.
|
||||||
|
|
||||||
|
**Option B:** New message type `create_terminal_agent_request` that combines agent creation + terminal creation in one step.
|
||||||
|
|
||||||
|
**Recommendation: Option A.** Add optional `agentId` to `CreateTerminalRequestMessage`. If provided:
|
||||||
|
- Look up the agent (must be a terminal agent)
|
||||||
|
- Use the agent's stored command to create the terminal
|
||||||
|
- Associate the terminal with the agent
|
||||||
|
|
||||||
|
**File:** `packages/server/src/shared/messages.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const CreateTerminalRequestMessageSchema = z.object({
|
||||||
|
type: z.literal("create_terminal_request"),
|
||||||
|
cwd: z.string(),
|
||||||
|
name: z.string().optional(),
|
||||||
|
agentId: z.string().optional(), // NEW — if provided, create terminal for this terminal agent
|
||||||
|
requestId: z.string(),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.9 Terminal → Agent lifecycle binding
|
||||||
|
|
||||||
|
When a terminal associated with a terminal agent exits:
|
||||||
|
- Set agent lifecycle to `"closed"`
|
||||||
|
- Attempt to detect the agent's session file for persistence handle
|
||||||
|
- Broadcast state update
|
||||||
|
|
||||||
|
When a terminal agent is opened from the sessions page:
|
||||||
|
- Server calls `buildTerminalResumeCommand(handle)` if persistence handle exists
|
||||||
|
- Otherwise calls `buildTerminalCreateCommand(config)`
|
||||||
|
- Creates a new terminal with that command
|
||||||
|
|
||||||
|
#### 1.10 Extend `createTerminal()` to support command + args
|
||||||
|
|
||||||
|
**File:** `packages/server/src/terminal/terminal.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface CreateTerminalOptions {
|
||||||
|
cwd: string;
|
||||||
|
shell?: string;
|
||||||
|
env?: Record<string, string>;
|
||||||
|
rows?: number;
|
||||||
|
cols?: number;
|
||||||
|
name?: string;
|
||||||
|
command?: string; // NEW — if provided, run this instead of shell
|
||||||
|
args?: string[]; // NEW — arguments for command
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In `createTerminal()`:
|
||||||
|
```typescript
|
||||||
|
const spawnCommand = options.command ?? shell;
|
||||||
|
const spawnArgs = options.command ? (options.args ?? []) : [];
|
||||||
|
|
||||||
|
const ptyProcess = pty.spawn(spawnCommand, spawnArgs, {
|
||||||
|
name: "xterm-256color",
|
||||||
|
cols, rows, cwd,
|
||||||
|
env: { ...process.env, ...env, TERM: "xterm-256color" },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 2: App — Draft UI & Terminal Toggle
|
||||||
|
|
||||||
|
#### 2.1 Add terminal toggle to draft tab
|
||||||
|
|
||||||
|
**File:** `packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx`
|
||||||
|
|
||||||
|
Add a toggle switch in the draft UI: **"Chat" / "Terminal"**
|
||||||
|
|
||||||
|
State:
|
||||||
|
```typescript
|
||||||
|
const [isTerminalMode, setIsTerminalMode] = useState(false);
|
||||||
|
```
|
||||||
|
|
||||||
|
The toggle should be persistent per draft (stored in the draft store or as a preference).
|
||||||
|
|
||||||
|
When terminal mode is selected:
|
||||||
|
- The provider/model pickers still work (same UI)
|
||||||
|
- The mode picker still works
|
||||||
|
- The "send" button label changes to "Launch" or "Start"
|
||||||
|
- The initial prompt input may be hidden or optional (terminal agents don't need an initial prompt — the user types directly into the TUI)
|
||||||
|
|
||||||
|
#### 2.2 Modify agent creation to pass `terminal: true`
|
||||||
|
|
||||||
|
When the user submits a draft in terminal mode:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const config: AgentSessionConfig = {
|
||||||
|
provider: selectedProvider,
|
||||||
|
cwd: workspaceId,
|
||||||
|
model: selectedModel,
|
||||||
|
modeId: selectedMode,
|
||||||
|
terminal: true, // NEW
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
The `CreateAgentRequestMessage` already carries `config`, so no new wire message needed.
|
||||||
|
|
||||||
|
#### 2.3 Terminal mode in `AgentStatusBar`
|
||||||
|
|
||||||
|
**File:** `packages/app/src/components/agent-status-bar.tsx`
|
||||||
|
|
||||||
|
When rendering a draft's status bar, filter the capability:
|
||||||
|
- If `supportsTerminalMode` is false for a provider, disable the terminal toggle when that provider is selected
|
||||||
|
- The terminal toggle can live next to the provider selector or as a segmented control above the input area
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 3: App — Agent Panel Rendering
|
||||||
|
|
||||||
|
#### 3.1 Branch rendering in `AgentPanel`
|
||||||
|
|
||||||
|
**File:** `packages/app/src/panels/agent-panel.tsx`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function AgentPanelContent({ agentId, ... }) {
|
||||||
|
const agent = useAgentState(agentId);
|
||||||
|
|
||||||
|
if (agent?.terminal) {
|
||||||
|
return <TerminalAgentPanel agentId={agentId} agent={agent} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <AgentPanelBody agent={agent} ... />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.2 New component: `TerminalAgentPanel`
|
||||||
|
|
||||||
|
**File:** `packages/app/src/panels/terminal-agent-panel.tsx` (new file)
|
||||||
|
|
||||||
|
This component:
|
||||||
|
1. Gets the terminal ID associated with the agent (from agent metadata or a new field)
|
||||||
|
2. Renders a `TerminalPane` connected to that terminal session
|
||||||
|
3. If no terminal exists yet (agent from sessions page), requests terminal creation via `createTerminal({ agentId })`
|
||||||
|
4. Handles terminal exit → agent close lifecycle
|
||||||
|
|
||||||
|
Essentially: it's the existing `TerminalPane` component, but associated with an agent entity instead of a standalone terminal.
|
||||||
|
|
||||||
|
#### 3.3 Tab descriptor for terminal agents
|
||||||
|
|
||||||
|
**File:** `packages/app/src/panels/agent-panel.tsx` → `useAgentPanelDescriptor`
|
||||||
|
|
||||||
|
The tab descriptor (icon, label) already comes from the agent's provider. Terminal agents get the same icon/label as managed agents — that's the whole point. No changes needed here unless we want a "terminal" badge.
|
||||||
|
|
||||||
|
Optional: add a small terminal icon badge to distinguish terminal agents from managed agents in the tab bar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 4: Sessions Page
|
||||||
|
|
||||||
|
#### 4.1 Terminal agents appear in sessions list
|
||||||
|
|
||||||
|
No changes needed for listing — terminal agents are real agents, they already show up via `AgentManager.getAgents()`.
|
||||||
|
|
||||||
|
#### 4.2 Opening a terminal agent from sessions
|
||||||
|
|
||||||
|
**File:** `packages/app/src/screens/sessions/` (sessions screen)
|
||||||
|
|
||||||
|
When the user clicks a closed terminal agent:
|
||||||
|
1. Server calls `buildTerminalResumeCommand(handle)` if persistence exists
|
||||||
|
2. Creates a new terminal with that command
|
||||||
|
3. Opens agent tab in workspace
|
||||||
|
|
||||||
|
If no persistence handle (session was ephemeral), show "Start new session" which calls `buildTerminalCreateCommand(config)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 5: CLI Gating
|
||||||
|
|
||||||
|
#### 5.1 `paseo send` — error for terminal agents
|
||||||
|
|
||||||
|
**File:** `packages/cli/src/commands/send.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
if (agent.terminal) {
|
||||||
|
throw new Error("Cannot send messages to terminal agents. Open the terminal in the UI instead.");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5.2 `paseo run` — could support `--terminal` flag (future)
|
||||||
|
|
||||||
|
Not in v1. For now, `paseo run` always creates managed agents. Terminal mode is UI-only.
|
||||||
|
|
||||||
|
#### 5.3 `paseo ls` — show terminal flag
|
||||||
|
|
||||||
|
Add a `terminal` column or badge to `paseo ls` output so users can distinguish terminal agents.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wire Format Changes Summary
|
||||||
|
|
||||||
|
### AgentSessionConfig (create request)
|
||||||
|
```diff
|
||||||
|
{
|
||||||
|
provider: string;
|
||||||
|
cwd: string;
|
||||||
|
model?: string;
|
||||||
|
modeId?: string;
|
||||||
|
+ terminal?: boolean;
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### AgentState (server → client)
|
||||||
|
```diff
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
provider: string;
|
||||||
|
lifecycle: string;
|
||||||
|
+ terminal?: boolean;
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### AgentCapabilityFlags
|
||||||
|
```diff
|
||||||
|
{
|
||||||
|
supportsStreaming: boolean;
|
||||||
|
supportsSessionPersistence: boolean;
|
||||||
|
+ supportsTerminalMode: boolean;
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### CreateTerminalRequest
|
||||||
|
```diff
|
||||||
|
{
|
||||||
|
type: "create_terminal_request";
|
||||||
|
cwd: string;
|
||||||
|
name?: string;
|
||||||
|
+ agentId?: string;
|
||||||
|
requestId: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### TerminalCommand (new type)
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
command: string;
|
||||||
|
args: string[];
|
||||||
|
env?: Record<string, string>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Phases & Agent Assignments
|
||||||
|
|
||||||
|
### Phase 1: Server data model (1 agent)
|
||||||
|
- Add `terminal` to types, schemas, and agent manager
|
||||||
|
- Add `TerminalCommand` type and `buildTerminalCreateCommand`/`buildTerminalResumeCommand` to `AgentClient`
|
||||||
|
- Add `supportsTerminalMode` capability flag
|
||||||
|
- Extend `createTerminal()` to support command+args
|
||||||
|
- Implement terminal agent creation flow in `AgentManager`
|
||||||
|
- Wire terminal exit → agent close lifecycle
|
||||||
|
- Implement command builders in Claude, Codex, OpenCode providers
|
||||||
|
- Typecheck must pass
|
||||||
|
|
||||||
|
### Phase 2: App draft UI + terminal toggle (1 agent)
|
||||||
|
- Add terminal mode toggle to `workspace-draft-agent-tab.tsx`
|
||||||
|
- Pass `terminal: true` in config when toggle is on
|
||||||
|
- Filter toggle based on `supportsTerminalMode` capability
|
||||||
|
- Persist toggle preference
|
||||||
|
- Typecheck must pass
|
||||||
|
|
||||||
|
### Phase 3: App panel rendering (1 agent)
|
||||||
|
- Branch `AgentPanelContent` on `agent.terminal`
|
||||||
|
- Create `TerminalAgentPanel` component
|
||||||
|
- Handle terminal creation for agent on open
|
||||||
|
- Handle terminal exit lifecycle
|
||||||
|
- Typecheck must pass
|
||||||
|
|
||||||
|
### Phase 4: Sessions page + CLI gating (1 agent)
|
||||||
|
- Terminal agents show in sessions with badge
|
||||||
|
- Opening from sessions resumes or creates terminal
|
||||||
|
- `paseo send` errors for terminal agents
|
||||||
|
- `paseo ls` shows terminal badge
|
||||||
|
- Typecheck must pass
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature Interaction Guards
|
||||||
|
|
||||||
|
Terminal agents are explicitly excluded from automated dispatch paths:
|
||||||
|
|
||||||
|
- **LoopService**: `buildWorkerConfig` and `buildVerifierConfig` set `terminal: false`
|
||||||
|
- **ScheduleService**: `executeSchedule` rejects terminal agents with a clear error for agent-targeted schedules; new-agent schedules set `terminal: false`
|
||||||
|
- **Voice mode / `handleSendAgentMessage`**: Guarded by `getStructuredSendRejection()` before send
|
||||||
|
- **CLI `paseo send`**: Returns error for terminal agents
|
||||||
|
- **MCP agent creation**: Programmatic paths don't pass `terminal: true`
|
||||||
|
|
||||||
|
All session-specific operations (`runAgent`, `streamAgent`, `setMode`, `cancelAgentRun`, etc.) are guarded by the centralized `requireSessionAgent()` which rejects terminal agents.
|
||||||
|
|
||||||
|
## What This Does NOT Change
|
||||||
|
|
||||||
|
- The existing managed agent flow is untouched
|
||||||
|
- Terminal sessions (non-agent) still work as before
|
||||||
|
- The `AgentSession` interface is unchanged
|
||||||
|
- Mobile experience is unchanged (terminal mode is web/desktop only for now)
|
||||||
|
- No new providers are added (existing providers gain terminal command builders)
|
||||||
|
- No hooks, no env injection, no process tree detection (v1 keeps it simple)
|
||||||
|
|
||||||
|
## Future Work (Not In This Plan)
|
||||||
|
|
||||||
|
- Auto-detect agent type from PTY process tree (for standalone terminals)
|
||||||
|
- "Convert to chat" / "Convert to terminal" actions
|
||||||
|
- Terminal title/icon from OSC sequences
|
||||||
|
- `paseo run --terminal` CLI support
|
||||||
|
- Mobile terminal mode (if xterm.js works well enough on mobile web)
|
||||||
|
- Gemini / Aider / Goose provider definitions (terminal-only providers)
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
# Plan Approval Normalization
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Normalize plan approval across providers so the UI renders one consistent plan approval card and action row, while each provider keeps its own execution quirks behind the session permission interface.
|
|
||||||
|
|
||||||
## Compatibility Constraints
|
|
||||||
|
|
||||||
- Older clients must remain compatible with newer daemons.
|
|
||||||
- All new wire fields must be optional.
|
|
||||||
- Existing plan permissions without action metadata must still render and work.
|
|
||||||
- Existing question permissions must keep their current behavior.
|
|
||||||
|
|
||||||
## Design
|
|
||||||
|
|
||||||
### Shared abstraction
|
|
||||||
|
|
||||||
Add optional permission action definitions to the shared permission request/response types.
|
|
||||||
|
|
||||||
- Permission requests may include `actions`.
|
|
||||||
- Permission responses may include `selectedActionId`.
|
|
||||||
- `kind: "plan"` remains the normalized concept for plan approval.
|
|
||||||
- The UI renders actions from the permission request instead of hardcoding provider-specific buttons.
|
|
||||||
|
|
||||||
### Claude
|
|
||||||
|
|
||||||
Keep Claude's plan permission flow, but enrich it with explicit action definitions.
|
|
||||||
|
|
||||||
- Always expose `Reject`.
|
|
||||||
- Always expose `Implement`.
|
|
||||||
- If the agent entered plan mode from a more permissive mode like `bypassPermissions`, also expose `Implement with <previous mode>`.
|
|
||||||
- Resolve the selected action entirely inside `respondToPermission()`.
|
|
||||||
|
|
||||||
### Codex
|
|
||||||
|
|
||||||
Synthesize a normalized `kind: "plan"` permission after a Codex plan-mode turn completes with a plan result.
|
|
||||||
|
|
||||||
- Emit a plan permission with `Reject` and `Implement` actions.
|
|
||||||
- On `Implement`, disable `plan_mode`, disable `fast_mode`, and automatically start a follow-up implementation turn.
|
|
||||||
- On `Reject`, resolve without starting a follow-up turn.
|
|
||||||
- Keep the implementation prompt and state transitions inside the Codex provider.
|
|
||||||
|
|
||||||
### Manager and state sync
|
|
||||||
|
|
||||||
After permission resolution, refresh provider-derived state so the UI sees internal mode/feature changes without knowing provider quirks.
|
|
||||||
|
|
||||||
- Refresh current mode
|
|
||||||
- Refresh pending permissions
|
|
||||||
- Refresh runtime info
|
|
||||||
- Refresh features
|
|
||||||
- Persist refreshed state
|
|
||||||
|
|
||||||
### UI
|
|
||||||
|
|
||||||
Render plan permissions through the existing plan card, but generate buttons from normalized permission actions.
|
|
||||||
|
|
||||||
- If `actions` are absent, fall back to legacy buttons.
|
|
||||||
- Plan cards should use `Implement` as the default primary label.
|
|
||||||
- Do not add provider-specific rendering branches.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
1. Shared schema/type tests for optional `actions` and `selectedActionId`
|
|
||||||
2. App tests for generic plan-action rendering
|
|
||||||
3. Claude tests for third action when resuming from a more permissive mode
|
|
||||||
4. Codex tests for synthetic plan approval and automatic implementation follow-up
|
|
||||||
5. Manager tests for post-permission state refresh
|
|
||||||
6. `npm run typecheck`
|
|
||||||
+1
-1
@@ -42,7 +42,7 @@ buildNpmPackage rec {
|
|||||||
|
|
||||||
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
||||||
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
||||||
npmDepsHash = "sha256-sVWb8lj3cgxgVcRvBmS5f0AyPT6uS46KfBEWgG6s8ww=";
|
npmDepsHash = "sha256-r9y8rUyT/56wHFUp8D/yA7mjy715jjezSYaEuj1D4TQ=";
|
||||||
|
|
||||||
# Prevent onnxruntime-node's install script from running during automatic
|
# Prevent onnxruntime-node's install script from running during automatic
|
||||||
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
||||||
|
|||||||
Generated
+1552
-224
File diff suppressed because it is too large
Load Diff
+11
-15
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.55-rc.1",
|
"version": "0.1.38",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/expo-two-way-audio",
|
"packages/expo-two-way-audio",
|
||||||
@@ -37,27 +37,23 @@
|
|||||||
"web": "npm run web --workspace=@getpaseo/app",
|
"web": "npm run web --workspace=@getpaseo/app",
|
||||||
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop",
|
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop",
|
||||||
"build:desktop": "npm run version:sync-internal && npm run build:web --workspace=@getpaseo/app && npm run build --workspace=@getpaseo/desktop",
|
"build:desktop": "npm run version:sync-internal && npm run build:web --workspace=@getpaseo/app && npm run build --workspace=@getpaseo/desktop",
|
||||||
|
"db:query": "npm run db:query --workspace=@getpaseo/server --",
|
||||||
"cli": "npx tsx packages/cli/src/index.js",
|
"cli": "npx tsx packages/cli/src/index.js",
|
||||||
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
|
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
|
||||||
"version:sync-internal": "node scripts/sync-workspace-versions.mjs",
|
"version:sync-internal": "node scripts/sync-workspace-versions.mjs",
|
||||||
"release:prepare": "npm install --workspaces --include-workspace-root",
|
"release:prepare": "npm install --workspaces --include-workspace-root",
|
||||||
"version:all:patch": "node scripts/set-release-version.mjs --mode patch",
|
"version:all:patch": "npm version patch --include-workspace-root --message \"chore(release): cut %s\"",
|
||||||
"version:all:minor": "node scripts/set-release-version.mjs --mode minor",
|
"version:all:minor": "npm version minor --include-workspace-root --message \"chore(release): cut %s\"",
|
||||||
"version:all:major": "node scripts/set-release-version.mjs --mode major",
|
"version:all:major": "npm version major --include-workspace-root --message \"chore(release): cut %s\"",
|
||||||
"version:all:rc:patch": "node scripts/set-release-version.mjs --mode rc-patch",
|
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
|
||||||
"version:all:rc:minor": "node scripts/set-release-version.mjs --mode rc-minor",
|
|
||||||
"version:all:rc:major": "node scripts/set-release-version.mjs --mode rc-major",
|
|
||||||
"version:all:rc:next": "node scripts/set-release-version.mjs --mode rc-next",
|
|
||||||
"version:all:promote": "node scripts/set-release-version.mjs --mode promote",
|
|
||||||
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
|
|
||||||
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
|
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
|
||||||
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
|
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
|
||||||
"release:push": "node scripts/push-current-release-tag.mjs",
|
"release:push": "node scripts/push-current-release-tag.mjs",
|
||||||
"release:rc:patch": "npm run release:check && npm run version:all:rc:patch && npm run release:push",
|
"draft-release:push": "node scripts/push-current-release-tag.mjs --draft-release",
|
||||||
"release:rc:minor": "npm run release:check && npm run version:all:rc:minor && npm run release:push",
|
"draft-release:patch": "npm run release:check && npm run version:all:patch && npm run draft-release:push",
|
||||||
"release:rc:major": "npm run release:check && npm run version:all:rc:major && npm run release:push",
|
"draft-release:minor": "npm run release:check && npm run version:all:minor && npm run draft-release:push",
|
||||||
"release:rc:next": "npm run release:check && npm run version:all:rc:next && npm run release:push",
|
"draft-release:major": "npm run release:check && npm run version:all:major && npm run draft-release:push",
|
||||||
"release:promote": "npm run release:check && npm run version:all:promote && npm run release:publish && npm run release:push",
|
"release:finalize": "node scripts/finalize-current-release.mjs",
|
||||||
"release:patch": "npm run release:check && npm run version:all:patch && npm run release:publish && npm run release:push",
|
"release:patch": "npm run release:check && npm run version:all:patch && npm run release:publish && npm run release:push",
|
||||||
"release:minor": "npm run release:check && npm run version:all:minor && npm run release:publish && npm run release:push",
|
"release:minor": "npm run release:check && npm run version:all:minor && npm run release:publish && npm run release:push",
|
||||||
"release:major": "npm run release:check && npm run version:all:major && npm run release:publish && npm run release:push"
|
"release:major": "npm run release:check && npm run version:all:major && npm run release:publish && npm run release:push"
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ on:
|
|||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- "v*"
|
- "v*"
|
||||||
- "!v*-rc.*"
|
|
||||||
workflow_dispatch: {}
|
workflow_dispatch: {}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.4 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.6 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 3.8 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.7 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.6 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.7 KiB |
@@ -1,108 +0,0 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
|
||||||
import { test } from "./fixtures";
|
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
|
||||||
import {
|
|
||||||
archiveAgentFromDaemon,
|
|
||||||
archiveAgentFromSessions,
|
|
||||||
connectArchiveTabDaemonClient,
|
|
||||||
createIdleAgent,
|
|
||||||
expectSessionRowVisible,
|
|
||||||
expectWorkspaceArchiveOutcome,
|
|
||||||
openSessions,
|
|
||||||
openWorkspaceWithAgents,
|
|
||||||
primeAdditionalPage,
|
|
||||||
resetSeededPageState,
|
|
||||||
reloadWorkspace,
|
|
||||||
} from "./helpers/archive-tab";
|
|
||||||
|
|
||||||
test.describe("Archive tab reconciliation", () => {
|
|
||||||
let client: Awaited<ReturnType<typeof connectArchiveTabDaemonClient>>;
|
|
||||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
|
||||||
|
|
||||||
test.describe.configure({ timeout: 120_000 });
|
|
||||||
|
|
||||||
test.beforeAll(async () => {
|
|
||||||
tempRepo = await createTempGitRepo("archive-tab-");
|
|
||||||
client = await connectArchiveTabDaemonClient();
|
|
||||||
});
|
|
||||||
|
|
||||||
test.afterAll(async () => {
|
|
||||||
await client?.close().catch(() => undefined);
|
|
||||||
await tempRepo?.cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("non-UI archive prunes the archived tab across open pages and reload", async ({ page }) => {
|
|
||||||
const archived = await createIdleAgent(client, {
|
|
||||||
cwd: tempRepo.path,
|
|
||||||
title: `cli-archive-${randomUUID().slice(0, 8)}`,
|
|
||||||
});
|
|
||||||
const surviving = await createIdleAgent(client, {
|
|
||||||
cwd: tempRepo.path,
|
|
||||||
title: `cli-control-${randomUUID().slice(0, 8)}`,
|
|
||||||
});
|
|
||||||
const passivePage = await page.context().newPage();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await primeAdditionalPage(passivePage);
|
|
||||||
await resetSeededPageState(page);
|
|
||||||
await resetSeededPageState(passivePage);
|
|
||||||
await openSessions(page);
|
|
||||||
await expectSessionRowVisible(page, archived.title);
|
|
||||||
await expectSessionRowVisible(page, surviving.title);
|
|
||||||
await openSessions(passivePage);
|
|
||||||
await expectSessionRowVisible(passivePage, archived.title);
|
|
||||||
await expectSessionRowVisible(passivePage, surviving.title);
|
|
||||||
await openWorkspaceWithAgents(page, [archived, surviving]);
|
|
||||||
await openWorkspaceWithAgents(passivePage, [archived, surviving]);
|
|
||||||
await archiveAgentFromDaemon(client, archived.id);
|
|
||||||
await expectWorkspaceArchiveOutcome(page, {
|
|
||||||
archivedAgentId: archived.id,
|
|
||||||
survivingAgentId: surviving.id,
|
|
||||||
});
|
|
||||||
await expectWorkspaceArchiveOutcome(passivePage, {
|
|
||||||
archivedAgentId: archived.id,
|
|
||||||
survivingAgentId: surviving.id,
|
|
||||||
});
|
|
||||||
await reloadWorkspace(passivePage, tempRepo.path);
|
|
||||||
await expectWorkspaceArchiveOutcome(passivePage, {
|
|
||||||
archivedAgentId: archived.id,
|
|
||||||
survivingAgentId: surviving.id,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
await passivePage.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Sessions archive prunes the archived tab across open pages", async ({ page }) => {
|
|
||||||
const archived = await createIdleAgent(client, {
|
|
||||||
cwd: tempRepo.path,
|
|
||||||
title: `ui-archive-${randomUUID().slice(0, 8)}`,
|
|
||||||
});
|
|
||||||
const surviving = await createIdleAgent(client, {
|
|
||||||
cwd: tempRepo.path,
|
|
||||||
title: `ui-control-${randomUUID().slice(0, 8)}`,
|
|
||||||
});
|
|
||||||
const passivePage = await page.context().newPage();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await primeAdditionalPage(passivePage);
|
|
||||||
await resetSeededPageState(page);
|
|
||||||
await resetSeededPageState(passivePage);
|
|
||||||
await openWorkspaceWithAgents(page, [archived, surviving]);
|
|
||||||
await openWorkspaceWithAgents(passivePage, [archived, surviving]);
|
|
||||||
await openSessions(page);
|
|
||||||
await archiveAgentFromSessions(page, { agentId: archived.id, title: archived.title });
|
|
||||||
await reloadWorkspace(page, tempRepo.path);
|
|
||||||
await expectWorkspaceArchiveOutcome(page, {
|
|
||||||
archivedAgentId: archived.id,
|
|
||||||
survivingAgentId: surviving.id,
|
|
||||||
});
|
|
||||||
await expectWorkspaceArchiveOutcome(passivePage, {
|
|
||||||
archivedAgentId: archived.id,
|
|
||||||
survivingAgentId: surviving.id,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
await passivePage.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -2,7 +2,6 @@ import { expect, type Page } from "@playwright/test";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
|
||||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||||
|
|
||||||
const NEAR_BOTTOM_THRESHOLD_PX = 72;
|
const NEAR_BOTTOM_THRESHOLD_PX = 72;
|
||||||
@@ -84,36 +83,33 @@ export function createReplyTurn(label: string): {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
type DaemonClientConfig = {
|
|
||||||
url: string;
|
|
||||||
clientId: string;
|
|
||||||
clientType: "cli";
|
|
||||||
webSocketFactory?: NodeWebSocketFactory;
|
|
||||||
};
|
|
||||||
|
|
||||||
async function loadDaemonClientConstructor(): Promise<
|
async function loadDaemonClientConstructor(): Promise<
|
||||||
new (
|
new (config: {
|
||||||
config: DaemonClientConfig,
|
url: string;
|
||||||
) => DaemonClientInstance
|
clientId: string;
|
||||||
|
clientType: "cli";
|
||||||
|
}) => DaemonClientInstance
|
||||||
> {
|
> {
|
||||||
const repoRoot = path.resolve(__dirname, "../../../../");
|
const repoRoot = path.resolve(process.cwd(), "../..");
|
||||||
const moduleUrl = pathToFileURL(
|
const moduleUrl = pathToFileURL(
|
||||||
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
|
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
|
||||||
).href;
|
).href;
|
||||||
const mod = (await import(moduleUrl)) as {
|
const mod = (await import(moduleUrl)) as {
|
||||||
DaemonClient: new (config: DaemonClientConfig) => DaemonClientInstance;
|
DaemonClient: new (config: {
|
||||||
|
url: string;
|
||||||
|
clientId: string;
|
||||||
|
clientType: "cli";
|
||||||
|
}) => DaemonClientInstance;
|
||||||
};
|
};
|
||||||
return mod.DaemonClient;
|
return mod.DaemonClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function connectDaemonClient(): Promise<DaemonClientInstance> {
|
export async function connectDaemonClient(): Promise<DaemonClientInstance> {
|
||||||
const DaemonClient = await loadDaemonClientConstructor();
|
const DaemonClient = await loadDaemonClientConstructor();
|
||||||
const webSocketFactory = createNodeWebSocketFactory();
|
|
||||||
const client = new DaemonClient({
|
const client = new DaemonClient({
|
||||||
url: getDaemonWsUrl(),
|
url: getDaemonWsUrl(),
|
||||||
clientId: `app-e2e-${randomUUID()}`,
|
clientId: `app-e2e-${randomUUID()}`,
|
||||||
clientType: "cli",
|
clientType: "cli",
|
||||||
webSocketFactory,
|
|
||||||
});
|
});
|
||||||
await client.connect();
|
await client.connect();
|
||||||
return client;
|
return client;
|
||||||
@@ -131,7 +127,7 @@ export async function seedBottomAnchorAgent(input: {
|
|||||||
const lineCount = Math.max(14, input.lineCount ?? 14);
|
const lineCount = Math.max(14, input.lineCount ?? 14);
|
||||||
const created = await input.client.createAgent({
|
const created = await input.client.createAgent({
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
model: "gpt-5.4-mini",
|
model: "gpt-5.1-codex-mini",
|
||||||
thinkingOptionId: "low",
|
thinkingOptionId: "low",
|
||||||
modeId: "full-access",
|
modeId: "full-access",
|
||||||
cwd: input.cwd,
|
cwd: input.cwd,
|
||||||
|
|||||||
@@ -1,266 +0,0 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
|
||||||
import path from "node:path";
|
|
||||||
import { pathToFileURL } from "node:url";
|
|
||||||
import { expect, type Page } from "@playwright/test";
|
|
||||||
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
|
|
||||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
|
||||||
import { waitForWorkspaceTabsVisible } from "./workspace-tabs";
|
|
||||||
import {
|
|
||||||
buildHostAgentDetailRoute,
|
|
||||||
buildHostSessionsRoute,
|
|
||||||
buildHostWorkspaceRoute,
|
|
||||||
} from "@/utils/host-routes";
|
|
||||||
|
|
||||||
export type ArchiveTabAgent = {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
cwd: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ArchiveTabDaemonClient = {
|
|
||||||
connect(): Promise<void>;
|
|
||||||
close(): Promise<void>;
|
|
||||||
createAgent(options: {
|
|
||||||
provider: string;
|
|
||||||
model: string;
|
|
||||||
thinkingOptionId?: string;
|
|
||||||
modeId: string;
|
|
||||||
cwd: string;
|
|
||||||
title: string;
|
|
||||||
initialPrompt: string;
|
|
||||||
}): Promise<{ id: string }>;
|
|
||||||
archiveAgent(agentId: string): Promise<{ archivedAt: string }>;
|
|
||||||
waitForFinish(agentId: string, timeout?: number): Promise<{ status: string }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
function getDaemonPort(): string {
|
|
||||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
|
||||||
if (!daemonPort) {
|
|
||||||
throw new Error("E2E_DAEMON_PORT is not set.");
|
|
||||||
}
|
|
||||||
if (daemonPort === "6767") {
|
|
||||||
throw new Error("E2E_DAEMON_PORT must not point at the developer daemon.");
|
|
||||||
}
|
|
||||||
return daemonPort;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getServerId(): string {
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error("E2E_SERVER_ID is not set.");
|
|
||||||
}
|
|
||||||
return serverId;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDaemonWsUrl(): string {
|
|
||||||
return `ws://127.0.0.1:${getDaemonPort()}/ws`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSeededStoragePayload() {
|
|
||||||
const nowIso = new Date().toISOString();
|
|
||||||
return {
|
|
||||||
daemon: buildSeededHost({
|
|
||||||
serverId: getServerId(),
|
|
||||||
endpoint: `127.0.0.1:${getDaemonPort()}`,
|
|
||||||
nowIso,
|
|
||||||
}),
|
|
||||||
preferences: buildCreateAgentPreferences(getServerId()),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
type ArchiveTabDaemonClientConfig = {
|
|
||||||
url: string;
|
|
||||||
clientId: string;
|
|
||||||
clientType: "cli";
|
|
||||||
webSocketFactory?: NodeWebSocketFactory;
|
|
||||||
};
|
|
||||||
|
|
||||||
async function loadDaemonClientConstructor(): Promise<
|
|
||||||
new (
|
|
||||||
config: ArchiveTabDaemonClientConfig,
|
|
||||||
) => ArchiveTabDaemonClient
|
|
||||||
> {
|
|
||||||
const repoRoot = path.resolve(__dirname, "../../../../");
|
|
||||||
const moduleUrl = pathToFileURL(
|
|
||||||
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
|
|
||||||
).href;
|
|
||||||
const mod = (await import(moduleUrl)) as {
|
|
||||||
DaemonClient: new (config: ArchiveTabDaemonClientConfig) => ArchiveTabDaemonClient;
|
|
||||||
};
|
|
||||||
return mod.DaemonClient;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function connectArchiveTabDaemonClient(): Promise<ArchiveTabDaemonClient> {
|
|
||||||
const DaemonClient = await loadDaemonClientConstructor();
|
|
||||||
const webSocketFactory = createNodeWebSocketFactory();
|
|
||||||
const client = new DaemonClient({
|
|
||||||
url: getDaemonWsUrl(),
|
|
||||||
clientId: `app-e2e-archive-tab-${randomUUID()}`,
|
|
||||||
clientType: "cli",
|
|
||||||
webSocketFactory,
|
|
||||||
});
|
|
||||||
await client.connect();
|
|
||||||
return client;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createIdleAgent(
|
|
||||||
client: ArchiveTabDaemonClient,
|
|
||||||
input: { cwd: string; title: string },
|
|
||||||
): Promise<ArchiveTabAgent> {
|
|
||||||
const created = await client.createAgent({
|
|
||||||
provider: "opencode",
|
|
||||||
model: "opencode/gpt-5-nano",
|
|
||||||
modeId: "default",
|
|
||||||
cwd: input.cwd,
|
|
||||||
title: input.title,
|
|
||||||
initialPrompt: "Reply with exactly READY.",
|
|
||||||
});
|
|
||||||
const finished = await client.waitForFinish(created.id, 120_000);
|
|
||||||
if (finished.status !== "idle") {
|
|
||||||
throw new Error(
|
|
||||||
`Expected agent ${created.id} to become idle, got ${finished.status}. Error: ${JSON.stringify((finished as Record<string, unknown>).error ?? "unknown")}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
id: created.id,
|
|
||||||
title: input.title,
|
|
||||||
cwd: input.cwd,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function archiveAgentFromDaemon(
|
|
||||||
client: ArchiveTabDaemonClient,
|
|
||||||
agentId: string,
|
|
||||||
): Promise<void> {
|
|
||||||
await client.archiveAgent(agentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function primeAdditionalPage(page: Page): Promise<void> {
|
|
||||||
const seedNonce = randomUUID();
|
|
||||||
const { daemon, preferences } = buildSeededStoragePayload();
|
|
||||||
|
|
||||||
await page.route(/:(6767)\b/, (route) => route.abort());
|
|
||||||
await page.routeWebSocket(/:(6767)\b/, async (ws) => {
|
|
||||||
await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." });
|
|
||||||
});
|
|
||||||
await page.addInitScript(
|
|
||||||
({ daemon, preferences, seedNonce }) => {
|
|
||||||
const disableOnceKey = "@paseo:e2e-disable-default-seed-once";
|
|
||||||
const disableValue = localStorage.getItem(disableOnceKey);
|
|
||||||
if (disableValue) {
|
|
||||||
localStorage.removeItem(disableOnceKey);
|
|
||||||
if (disableValue === seedNonce) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
localStorage.setItem("@paseo:e2e", "1");
|
|
||||||
localStorage.setItem("@paseo:e2e-seed-nonce", seedNonce);
|
|
||||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
|
|
||||||
localStorage.removeItem("@paseo:settings");
|
|
||||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
|
||||||
},
|
|
||||||
{ daemon, preferences, seedNonce },
|
|
||||||
);
|
|
||||||
await page.goto("/");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resetSeededPageState(page: Page): Promise<void> {
|
|
||||||
const { daemon, preferences } = buildSeededStoragePayload();
|
|
||||||
await page.goto("/");
|
|
||||||
await page.evaluate(
|
|
||||||
({ daemon, preferences }) => {
|
|
||||||
localStorage.clear();
|
|
||||||
localStorage.setItem("@paseo:e2e", "1");
|
|
||||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
|
|
||||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
|
||||||
localStorage.removeItem("@paseo:settings");
|
|
||||||
},
|
|
||||||
{ daemon, preferences },
|
|
||||||
);
|
|
||||||
await page.goto("/");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function openWorkspaceWithAgents(
|
|
||||||
page: Page,
|
|
||||||
agents: [ArchiveTabAgent, ArchiveTabAgent],
|
|
||||||
): Promise<void> {
|
|
||||||
const serverId = getServerId();
|
|
||||||
for (const agent of agents) {
|
|
||||||
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.cwd));
|
|
||||||
await waitForWorkspaceTabsVisible(page);
|
|
||||||
await expectWorkspaceTabVisible(page, agent.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function expectWorkspaceTabVisible(page: Page, agentId: string): Promise<void> {
|
|
||||||
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`).first()).toBeVisible({
|
|
||||||
timeout: 30_000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function expectWorkspaceTabHidden(page: Page, agentId: string): Promise<void> {
|
|
||||||
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`)).toHaveCount(0, {
|
|
||||||
timeout: 30_000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function expectWorkspaceArchiveOutcome(
|
|
||||||
page: Page,
|
|
||||||
input: { archivedAgentId: string; survivingAgentId: string },
|
|
||||||
): Promise<void> {
|
|
||||||
await expectWorkspaceTabHidden(page, input.archivedAgentId);
|
|
||||||
await expectWorkspaceTabVisible(page, input.survivingAgentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function reloadWorkspace(page: Page, workspaceId: string): Promise<void> {
|
|
||||||
const serverId = getServerId();
|
|
||||||
await page.goto(buildHostWorkspaceRoute(serverId, workspaceId));
|
|
||||||
await waitForWorkspaceTabsVisible(page);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function openSessions(page: Page): Promise<void> {
|
|
||||||
const sessionsButton = page.getByTestId("sidebar-sessions");
|
|
||||||
await expect(sessionsButton).toBeVisible({ timeout: 30_000 });
|
|
||||||
await sessionsButton.click();
|
|
||||||
await expect(page).toHaveURL(new RegExp(`${buildHostSessionsRoute(getServerId())}$`), {
|
|
||||||
timeout: 30_000,
|
|
||||||
});
|
|
||||||
await expect(page.getByText("Sessions", { exact: true }).last()).toBeVisible({
|
|
||||||
timeout: 30_000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSessionRowByTitle(page: Page, title: string) {
|
|
||||||
return page.locator('[data-testid^="agent-row-"]').filter({ hasText: title }).first();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function expectSessionRowVisible(page: Page, title: string): Promise<void> {
|
|
||||||
await expect(getSessionRowByTitle(page, title)).toBeVisible({ timeout: 30_000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function expectSessionRowArchived(page: Page, title: string): Promise<void> {
|
|
||||||
await expect(getSessionRowByTitle(page, title)).toContainText("Archived", { timeout: 30_000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function archiveAgentFromSessions(
|
|
||||||
page: Page,
|
|
||||||
input: { agentId: string; title: string },
|
|
||||||
): Promise<void> {
|
|
||||||
const row = getSessionRowByTitle(page, input.title);
|
|
||||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
|
||||||
const box = await row.boundingBox();
|
|
||||||
if (!box) {
|
|
||||||
throw new Error(`Could not read bounding box for session row ${input.agentId}.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
|
||||||
await page.mouse.down();
|
|
||||||
await page.waitForTimeout(900);
|
|
||||||
await page.mouse.up();
|
|
||||||
|
|
||||||
const archiveButton = page.getByTestId("agent-action-archive").first();
|
|
||||||
await expect(archiveButton).toBeVisible({ timeout: 10_000 });
|
|
||||||
await archiveButton.click();
|
|
||||||
await expectSessionRowArchived(page, input.title);
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,7 @@ export const TEST_HOST_LABEL = "localhost";
|
|||||||
|
|
||||||
export const TEST_PROVIDER_PREFERENCES = {
|
export const TEST_PROVIDER_PREFERENCES = {
|
||||||
claude: { model: "haiku" },
|
claude: { model: "haiku" },
|
||||||
codex: { model: "gpt-5.4-mini", thinkingOptionId: "low" },
|
codex: { model: "gpt-5.1-codex-mini", thinkingOptionId: "low" },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export function buildDirectTcpConnection(endpoint: string) {
|
export function buildDirectTcpConnection(endpoint: string) {
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||||
|
import { createTempGitRepo } from "./workspace";
|
||||||
|
|
||||||
|
// ─── Navigation ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function getServerId(): string {
|
||||||
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
|
if (!serverId) {
|
||||||
|
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||||
|
}
|
||||||
|
return serverId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Navigate to a workspace and wait for the tab bar to appear. */
|
||||||
|
export async function gotoWorkspace(page: Page, cwd: string): Promise<void> {
|
||||||
|
const route = buildHostWorkspaceRoute(getServerId(), cwd);
|
||||||
|
await page.goto(route);
|
||||||
|
await waitForTabBar(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tab bar queries ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Wait for the workspace tab bar to be visible. */
|
||||||
|
export async function waitForTabBar(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByTestId("workspace-tabs-row").first()).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return all tab test IDs currently in the tab bar. */
|
||||||
|
export async function getTabTestIds(page: Page): Promise<string[]> {
|
||||||
|
const tabs = page.locator('[data-testid^="workspace-tab-"]');
|
||||||
|
const count = await tabs.count();
|
||||||
|
const ids: string[] = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const testId = await tabs.nth(i).getAttribute("data-testid");
|
||||||
|
if (testId) ids.push(testId);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return the number of tabs matching a kind prefix (e.g. "launcher", "draft", "terminal", "agent"). */
|
||||||
|
export async function countTabsOfKind(page: Page, kind: string): Promise<number> {
|
||||||
|
const ids = await getTabTestIds(page);
|
||||||
|
return ids.filter((id) => id.includes(kind)).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return the currently active tab's test ID (the one with aria-selected or focus styling). */
|
||||||
|
export async function getActiveTabTestId(page: Page): Promise<string | null> {
|
||||||
|
// Active tab has the focused highlight — check for the aria-selected or data-active attribute
|
||||||
|
const activeTab = page.locator('[data-testid^="workspace-tab-"][aria-selected="true"]').first();
|
||||||
|
if (await activeTab.isVisible().catch(() => false)) {
|
||||||
|
return activeTab.getAttribute("data-testid");
|
||||||
|
}
|
||||||
|
// Fallback: the tab with focused styling
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tab actions ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Click the '+' button in the tab bar to open a new launcher tab. */
|
||||||
|
export async function clickNewTabButton(page: Page): Promise<void> {
|
||||||
|
const button = page.getByTestId("workspace-new-tab");
|
||||||
|
await expect(button).toBeVisible({ timeout: 10_000 });
|
||||||
|
await button.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Press Cmd+T (macOS) to open a new tab. */
|
||||||
|
export async function pressNewTabShortcut(page: Page): Promise<void> {
|
||||||
|
await page.keyboard.press("Meta+t");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Launcher panel assertions ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Wait for the launcher panel to render with its primary tiles. */
|
||||||
|
export async function waitForLauncherPanel(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByRole("button", { name: "New Chat" }).first()).toBeVisible({
|
||||||
|
timeout: 15_000,
|
||||||
|
});
|
||||||
|
await expect(page.getByRole("button", { name: "Terminal" }).first()).toBeVisible({
|
||||||
|
timeout: 15_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Assert that the launcher panel shows provider tiles under "Terminal Agents". */
|
||||||
|
export async function assertProviderTilesVisible(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByText("Terminal Agents", { exact: true }).first()).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Assert the launcher panel has a "New Chat" tile. */
|
||||||
|
export async function assertNewChatTileVisible(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByRole("button", { name: "New Chat" }).first()).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Assert the launcher panel has a "Terminal" tile. */
|
||||||
|
export async function assertTerminalTileVisible(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByRole("button", { name: "Terminal" }).first()).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Launcher tile clicks ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Click the "New Chat" tile on the launcher panel. */
|
||||||
|
export async function clickNewChat(page: Page): Promise<void> {
|
||||||
|
const button = page.getByRole("button", { name: "New Chat" }).first();
|
||||||
|
await expect(button).toBeVisible({ timeout: 10_000 });
|
||||||
|
await button.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Click the "Terminal" tile on the launcher panel. */
|
||||||
|
export async function clickTerminal(page: Page): Promise<void> {
|
||||||
|
const button = page.getByRole("button", { name: "Terminal" }).first();
|
||||||
|
await expect(button).toBeVisible({ timeout: 10_000 });
|
||||||
|
await button.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Click a provider tile by label (e.g. "Claude Code", "Codex"). */
|
||||||
|
export async function clickProviderTile(page: Page, providerLabel: string): Promise<void> {
|
||||||
|
const tile = page.getByRole("button", { name: providerLabel }).first();
|
||||||
|
await expect(tile).toBeVisible({ timeout: 10_000 });
|
||||||
|
await tile.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tab title assertions ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Wait for any tab in the bar to display the given title text. */
|
||||||
|
export async function waitForTabWithTitle(
|
||||||
|
page: Page,
|
||||||
|
title: string | RegExp,
|
||||||
|
timeout = 30_000,
|
||||||
|
): Promise<void> {
|
||||||
|
const matcher = typeof title === "string" ? new RegExp(title, "i") : title;
|
||||||
|
await expect(page.locator('[data-testid^="workspace-tab-"]').filter({ hasText: matcher }).first())
|
||||||
|
.toBeVisible({ timeout });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Assert the new-tab '+' button is visible and there is only one. */
|
||||||
|
export async function assertSingleNewTabButton(page: Page): Promise<void> {
|
||||||
|
const buttons = page.getByTestId("workspace-new-tab");
|
||||||
|
// There might be multiple panes, each with a "+" button
|
||||||
|
// But within a single pane there should only be one
|
||||||
|
const count = await buttons.count();
|
||||||
|
expect(count).toBeGreaterThanOrEqual(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── No-flash measurement ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Measure the time between clicking a launcher tile and the replacement panel becoming visible.
|
||||||
|
* Returns elapsed milliseconds.
|
||||||
|
*/
|
||||||
|
export async function measureTileTransition(
|
||||||
|
page: Page,
|
||||||
|
clickAction: () => Promise<void>,
|
||||||
|
successLocator: ReturnType<Page["locator"]>,
|
||||||
|
timeout = 5_000,
|
||||||
|
): Promise<number> {
|
||||||
|
const start = Date.now();
|
||||||
|
await clickAction();
|
||||||
|
await expect(successLocator).toBeVisible({ timeout });
|
||||||
|
return Date.now() - start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sample tab IDs at high frequency across a transition to detect blank/intermediate states.
|
||||||
|
* Returns all unique snapshots observed.
|
||||||
|
*/
|
||||||
|
export async function sampleTabsDuringTransition(
|
||||||
|
page: Page,
|
||||||
|
action: () => Promise<void>,
|
||||||
|
durationMs = 2_000,
|
||||||
|
intervalMs = 30,
|
||||||
|
): Promise<string[][]> {
|
||||||
|
const snapshots: string[][] = [];
|
||||||
|
const startSampling = async () => {
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < durationMs) {
|
||||||
|
snapshots.push(await getTabTestIds(page));
|
||||||
|
await page.waitForTimeout(intervalMs);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const samplingPromise = startSampling();
|
||||||
|
await action();
|
||||||
|
await samplingPromise;
|
||||||
|
return snapshots;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Workspace setup ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Create a temp git repo and return its path with a cleanup function. */
|
||||||
|
export async function createWorkspace(prefix = "launcher-e2e-"): ReturnType<typeof createTempGitRepo> {
|
||||||
|
return createTempGitRepo(prefix);
|
||||||
|
}
|
||||||
@@ -1,231 +0,0 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
|
||||||
import path from "node:path";
|
|
||||||
import { pathToFileURL } from "node:url";
|
|
||||||
import { expect, type Page } from "@playwright/test";
|
|
||||||
import type { DaemonClient as ServerDaemonClient } from "@server/client/daemon-client";
|
|
||||||
import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
|
|
||||||
import { expectWorkspaceHeader, workspaceLabelFromPath } from "./workspace-ui";
|
|
||||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
|
||||||
|
|
||||||
type NewWorkspaceDaemonClient = Pick<
|
|
||||||
ServerDaemonClient,
|
|
||||||
| "archivePaseoWorktree"
|
|
||||||
| "archiveWorkspace"
|
|
||||||
| "close"
|
|
||||||
| "connect"
|
|
||||||
| "createPaseoWorktree"
|
|
||||||
| "openProject"
|
|
||||||
>;
|
|
||||||
|
|
||||||
type NewWorkspaceDaemonClientConfig = {
|
|
||||||
url: string;
|
|
||||||
clientId: string;
|
|
||||||
clientType: "cli";
|
|
||||||
webSocketFactory?: NodeWebSocketFactory;
|
|
||||||
};
|
|
||||||
|
|
||||||
type OpenProjectPayload = Awaited<ReturnType<NewWorkspaceDaemonClient["openProject"]>>;
|
|
||||||
|
|
||||||
export type OpenedProject = {
|
|
||||||
workspaceId: string;
|
|
||||||
projectKey: string;
|
|
||||||
projectDisplayName: string;
|
|
||||||
workspaceName: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function getDaemonPort(): string {
|
|
||||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
|
||||||
if (!daemonPort) {
|
|
||||||
throw new Error("E2E_DAEMON_PORT is not set.");
|
|
||||||
}
|
|
||||||
if (daemonPort === "6767") {
|
|
||||||
throw new Error("E2E_DAEMON_PORT must not point at the developer daemon.");
|
|
||||||
}
|
|
||||||
return daemonPort;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDaemonWsUrl(): string {
|
|
||||||
return `ws://127.0.0.1:${getDaemonPort()}/ws`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadDaemonClientConstructor(): Promise<
|
|
||||||
new (
|
|
||||||
config: NewWorkspaceDaemonClientConfig,
|
|
||||||
) => NewWorkspaceDaemonClient
|
|
||||||
> {
|
|
||||||
const repoRoot = path.resolve(__dirname, "../../../../");
|
|
||||||
const moduleUrl = pathToFileURL(
|
|
||||||
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
|
|
||||||
).href;
|
|
||||||
const mod = (await import(moduleUrl)) as {
|
|
||||||
DaemonClient: new (config: NewWorkspaceDaemonClientConfig) => NewWorkspaceDaemonClient;
|
|
||||||
};
|
|
||||||
return mod.DaemonClient;
|
|
||||||
}
|
|
||||||
|
|
||||||
function requireWorkspace(payload: OpenProjectPayload) {
|
|
||||||
if (payload.error) {
|
|
||||||
throw new Error(payload.error);
|
|
||||||
}
|
|
||||||
if (!payload.workspace) {
|
|
||||||
throw new Error("openProject returned no workspace.");
|
|
||||||
}
|
|
||||||
return payload.workspace;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseWorkspaceIdFromPageUrl(page: Page, serverId: string): string | null {
|
|
||||||
const pathname = new URL(page.url()).pathname;
|
|
||||||
const match = pathname.match(
|
|
||||||
new RegExp(`^/h/${serverId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/workspace/([^/?#]+)`),
|
|
||||||
);
|
|
||||||
if (!match?.[1]) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return decodeWorkspaceIdFromPathSegment(match[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseWorkspaceIdFromSidebarRowTestId(
|
|
||||||
testId: string,
|
|
||||||
input: { serverId: string; previousWorkspaceId: string },
|
|
||||||
): string | null {
|
|
||||||
const prefix = `sidebar-workspace-row-${input.serverId}:`;
|
|
||||||
if (!testId.startsWith(prefix)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const workspaceId = testId.slice(prefix.length).trim();
|
|
||||||
if (!workspaceId || workspaceId === input.previousWorkspaceId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return workspaceId;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function connectNewWorkspaceDaemonClient(): Promise<NewWorkspaceDaemonClient> {
|
|
||||||
const DaemonClient = await loadDaemonClientConstructor();
|
|
||||||
const webSocketFactory = createNodeWebSocketFactory();
|
|
||||||
const client = new DaemonClient({
|
|
||||||
url: getDaemonWsUrl(),
|
|
||||||
clientId: `app-e2e-new-workspace-${randomUUID()}`,
|
|
||||||
clientType: "cli",
|
|
||||||
webSocketFactory,
|
|
||||||
});
|
|
||||||
await client.connect();
|
|
||||||
return client;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function openProjectViaDaemon(
|
|
||||||
client: NewWorkspaceDaemonClient,
|
|
||||||
repoPath: string,
|
|
||||||
): Promise<OpenedProject> {
|
|
||||||
const workspace = requireWorkspace(await client.openProject(repoPath));
|
|
||||||
return {
|
|
||||||
workspaceId: workspace.id,
|
|
||||||
projectKey: workspace.projectId,
|
|
||||||
projectDisplayName: workspace.projectDisplayName,
|
|
||||||
workspaceName: workspace.name,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function archiveWorkspaceFromDaemon(
|
|
||||||
client: NewWorkspaceDaemonClient,
|
|
||||||
workspaceId: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const payload = await client.archivePaseoWorktree({ worktreePath: workspaceId });
|
|
||||||
if (payload.error) {
|
|
||||||
throw new Error(payload.error.message);
|
|
||||||
}
|
|
||||||
if (!payload.success) {
|
|
||||||
throw new Error(`Failed to archive workspace: ${workspaceId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function archiveLocalWorkspaceFromDaemon(
|
|
||||||
client: NewWorkspaceDaemonClient,
|
|
||||||
workspaceId: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const payload = await client.archiveWorkspace(workspaceId);
|
|
||||||
if (payload.error) {
|
|
||||||
throw new Error(payload.error);
|
|
||||||
}
|
|
||||||
if (!payload.archivedAt) {
|
|
||||||
throw new Error(`Failed to archive workspace: ${workspaceId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createWorktreeViaDaemon(
|
|
||||||
client: NewWorkspaceDaemonClient,
|
|
||||||
input: { cwd: string; slug: string },
|
|
||||||
): Promise<OpenedProject> {
|
|
||||||
const payload = await client.createPaseoWorktree({
|
|
||||||
cwd: input.cwd,
|
|
||||||
worktreeSlug: input.slug,
|
|
||||||
});
|
|
||||||
const workspace = requireWorkspace(payload);
|
|
||||||
return {
|
|
||||||
workspaceId: workspace.id,
|
|
||||||
projectKey: workspace.projectId,
|
|
||||||
projectDisplayName: workspace.projectDisplayName,
|
|
||||||
workspaceName: workspace.name,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function clickNewWorkspaceButton(
|
|
||||||
page: Page,
|
|
||||||
input: { projectKey: string; projectDisplayName: string },
|
|
||||||
): Promise<void> {
|
|
||||||
const projectRow = page.getByTestId(`sidebar-project-row-${input.projectKey}`).first();
|
|
||||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
|
||||||
await projectRow.hover();
|
|
||||||
|
|
||||||
const button = page.getByTestId(`sidebar-project-new-worktree-${input.projectKey}`).first();
|
|
||||||
await expect(button).toBeVisible({ timeout: 30_000 });
|
|
||||||
await button.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function assertNewWorkspaceSidebarAndHeader(
|
|
||||||
page: Page,
|
|
||||||
input: { serverId: string; previousWorkspaceId: string; projectDisplayName: string },
|
|
||||||
): Promise<{ workspaceId: string }> {
|
|
||||||
let workspaceId: string | null = null;
|
|
||||||
const sidebarWorkspaceRows = page.locator(
|
|
||||||
`[data-testid^="sidebar-workspace-row-${input.serverId}:"]`,
|
|
||||||
);
|
|
||||||
const deadline = Date.now() + 30_000;
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
const sidebarRowTestIds = await sidebarWorkspaceRows.evaluateAll((elements) =>
|
|
||||||
elements.map((element) => element.getAttribute("data-testid") ?? ""),
|
|
||||||
);
|
|
||||||
workspaceId =
|
|
||||||
sidebarRowTestIds
|
|
||||||
.map((testId) => parseWorkspaceIdFromSidebarRowTestId(testId, input))
|
|
||||||
.find((id) => id !== null) ?? null;
|
|
||||||
if (workspaceId) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
await page.waitForTimeout(250);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!workspaceId || workspaceId === input.previousWorkspaceId) {
|
|
||||||
const sidebarWorkspaceRowIds = await sidebarWorkspaceRows.evaluateAll((elements) =>
|
|
||||||
elements.map((element) => element.getAttribute("data-testid") ?? "<missing-testid>"),
|
|
||||||
);
|
|
||||||
throw new Error(
|
|
||||||
[
|
|
||||||
"Expected a newly created workspace to load.",
|
|
||||||
`Current URL: ${page.url()}`,
|
|
||||||
`Sidebar rows: ${sidebarWorkspaceRowIds.join(", ") || "<none>"}`,
|
|
||||||
].join("\n"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const createdWorkspaceRow = page.getByTestId(
|
|
||||||
`sidebar-workspace-row-${input.serverId}:${workspaceId}`,
|
|
||||||
);
|
|
||||||
await expect(createdWorkspaceRow.first()).toBeVisible({ timeout: 30_000 });
|
|
||||||
|
|
||||||
await expectWorkspaceHeader(page, {
|
|
||||||
title: workspaceLabelFromPath(workspaceId),
|
|
||||||
subtitle: input.projectDisplayName,
|
|
||||||
});
|
|
||||||
|
|
||||||
return { workspaceId };
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import WebSocket from "ws";
|
|
||||||
|
|
||||||
type WebSocketLike = {
|
|
||||||
readyState: number;
|
|
||||||
send: (data: string | Uint8Array | ArrayBuffer) => void;
|
|
||||||
close: (code?: number, reason?: string) => void;
|
|
||||||
binaryType?: string;
|
|
||||||
on?: (event: string, listener: (...args: any[]) => void) => void;
|
|
||||||
off?: (event: string, listener: (...args: any[]) => void) => void;
|
|
||||||
removeListener?: (event: string, listener: (...args: any[]) => void) => void;
|
|
||||||
addEventListener?: (event: string, listener: (event: any) => void) => void;
|
|
||||||
removeEventListener?: (event: string, listener: (event: any) => void) => void;
|
|
||||||
onopen?: ((event: any) => void) | null;
|
|
||||||
onclose?: ((event: any) => void) | null;
|
|
||||||
onerror?: ((event: any) => void) | null;
|
|
||||||
onmessage?: ((event: any) => void) | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type NodeWebSocketFactory = (
|
|
||||||
url: string,
|
|
||||||
options?: { headers?: Record<string, string> },
|
|
||||||
) => WebSocketLike;
|
|
||||||
|
|
||||||
export function createNodeWebSocketFactory(): NodeWebSocketFactory {
|
|
||||||
return (url: string, options?: { headers?: Record<string, string> }) =>
|
|
||||||
new WebSocket(url, { headers: options?.headers }) as unknown as WebSocketLike;
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ import type { Page } from "@playwright/test";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
|
||||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||||
|
|
||||||
export type TerminalPerfDaemonClient = {
|
export type TerminalPerfDaemonClient = {
|
||||||
@@ -44,36 +43,29 @@ function getServerId(): string {
|
|||||||
return serverId;
|
return serverId;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TerminalPerfDaemonClientConfig = {
|
|
||||||
url: string;
|
|
||||||
clientId: string;
|
|
||||||
clientType: "cli";
|
|
||||||
webSocketFactory?: NodeWebSocketFactory;
|
|
||||||
};
|
|
||||||
|
|
||||||
async function loadDaemonClientConstructor(): Promise<
|
async function loadDaemonClientConstructor(): Promise<
|
||||||
new (
|
new (config: { url: string; clientId: string; clientType: "cli" }) => TerminalPerfDaemonClient
|
||||||
config: TerminalPerfDaemonClientConfig,
|
|
||||||
) => TerminalPerfDaemonClient
|
|
||||||
> {
|
> {
|
||||||
const repoRoot = path.resolve(__dirname, "../../../../");
|
const repoRoot = path.resolve(process.cwd(), "../..");
|
||||||
const moduleUrl = pathToFileURL(
|
const moduleUrl = pathToFileURL(
|
||||||
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
|
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
|
||||||
).href;
|
).href;
|
||||||
const mod = (await import(moduleUrl)) as {
|
const mod = (await import(moduleUrl)) as {
|
||||||
DaemonClient: new (config: TerminalPerfDaemonClientConfig) => TerminalPerfDaemonClient;
|
DaemonClient: new (config: {
|
||||||
|
url: string;
|
||||||
|
clientId: string;
|
||||||
|
clientType: "cli";
|
||||||
|
}) => TerminalPerfDaemonClient;
|
||||||
};
|
};
|
||||||
return mod.DaemonClient;
|
return mod.DaemonClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function connectTerminalClient(): Promise<TerminalPerfDaemonClient> {
|
export async function connectTerminalClient(): Promise<TerminalPerfDaemonClient> {
|
||||||
const DaemonClient = await loadDaemonClientConstructor();
|
const DaemonClient = await loadDaemonClientConstructor();
|
||||||
const webSocketFactory = createNodeWebSocketFactory();
|
|
||||||
const client = new DaemonClient({
|
const client = new DaemonClient({
|
||||||
url: getDaemonWsUrl(),
|
url: getDaemonWsUrl(),
|
||||||
clientId: `terminal-perf-${randomUUID()}`,
|
clientId: `terminal-perf-${randomUUID()}`,
|
||||||
clientType: "cli",
|
clientType: "cli",
|
||||||
webSocketFactory,
|
|
||||||
});
|
});
|
||||||
await client.connect();
|
await client.connect();
|
||||||
return client;
|
return client;
|
||||||
@@ -85,10 +77,6 @@ export function buildTerminalWorkspaceUrl(cwd: string, terminalId: string): stri
|
|||||||
return `${route}?open=${encodeURIComponent(`terminal:${terminalId}`)}`;
|
return `${route}?open=${encodeURIComponent(`terminal:${terminalId}`)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildWorkspaceUrl(cwd: string): string {
|
|
||||||
return buildHostWorkspaceRoute(getServerId(), cwd);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getTerminalBufferText(page: Page): Promise<string> {
|
export async function getTerminalBufferText(page: Page): Promise<string> {
|
||||||
return page.evaluate(() => {
|
return page.evaluate(() => {
|
||||||
const term = (window as any).__paseoTerminal;
|
const term = (window as any).__paseoTerminal;
|
||||||
@@ -130,29 +118,33 @@ export async function navigateToTerminal(
|
|||||||
// Boot the app at the workspace route directly.
|
// Boot the app at the workspace route directly.
|
||||||
// The fixtures.ts beforeEach addInitScript seeds localStorage on every navigation,
|
// The fixtures.ts beforeEach addInitScript seeds localStorage on every navigation,
|
||||||
// so the daemon registry is already configured when the app starts.
|
// so the daemon registry is already configured when the app starts.
|
||||||
const workspaceRoute = buildTerminalWorkspaceUrl(input.cwd, input.terminalId);
|
const workspaceRoute = buildHostWorkspaceRoute(getServerId(), input.cwd);
|
||||||
await page.goto(workspaceRoute);
|
await page.goto(workspaceRoute);
|
||||||
|
|
||||||
// The workspace layout consumes `?open=...`, returns null during the effect,
|
|
||||||
// then replaces the URL with the clean workspace route after preparing the tab.
|
|
||||||
const cleanWorkspaceRoute = buildWorkspaceUrl(input.cwd);
|
|
||||||
await page.waitForURL(
|
|
||||||
(url) => url.pathname === cleanWorkspaceRoute && !url.searchParams.has("open"),
|
|
||||||
{ timeout: 15_000 },
|
|
||||||
);
|
|
||||||
|
|
||||||
// Wait for daemon connection (sidebar shows host label)
|
// Wait for daemon connection (sidebar shows host label)
|
||||||
await page
|
await page.getByText("localhost", { exact: true }).first().waitFor({ state: "visible", timeout: 15_000 });
|
||||||
.getByText("localhost", { exact: true })
|
|
||||||
.first()
|
|
||||||
.waitFor({ state: "visible", timeout: 15_000 });
|
|
||||||
|
|
||||||
// The open intent should have prepared and focused the exact pre-created terminal tab.
|
|
||||||
const terminalTab = page.locator(`[data-testid="workspace-tab-terminal_${input.terminalId}"]`);
|
|
||||||
await terminalTab.waitFor({ state: "visible", timeout: 15_000 });
|
|
||||||
await terminalTab.click();
|
|
||||||
|
|
||||||
|
// The workspace should now query listTerminals and discover our terminal.
|
||||||
|
// Click the terminal tab if it auto-appeared, or wait for it.
|
||||||
const terminalSurface = page.locator('[data-testid="terminal-surface"]');
|
const terminalSurface = page.locator('[data-testid="terminal-surface"]');
|
||||||
|
const surfaceVisible = await terminalSurface.isVisible().catch(() => false);
|
||||||
|
|
||||||
|
if (!surfaceVisible) {
|
||||||
|
// Terminal tab might not be focused — look for it in the tab row and click it
|
||||||
|
const terminalTab = page.locator(`[data-testid="workspace-tab-terminal:${input.terminalId}"]`);
|
||||||
|
const tabExists = await terminalTab.isVisible({ timeout: 5_000 }).catch(() => false);
|
||||||
|
|
||||||
|
if (tabExists) {
|
||||||
|
await terminalTab.click();
|
||||||
|
} else {
|
||||||
|
// Terminal tab not yet created — click "New terminal tab" to create one through the UI
|
||||||
|
const newTerminalBtn = page.getByRole("button", { name: "New terminal tab" });
|
||||||
|
await newTerminalBtn.waitFor({ state: "visible", timeout: 10_000 });
|
||||||
|
await newTerminalBtn.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for terminal surface to be visible
|
||||||
await terminalSurface.waitFor({ state: "visible", timeout: 15_000 });
|
await terminalSurface.waitFor({ state: "visible", timeout: 15_000 });
|
||||||
|
|
||||||
// Wait for loading overlay to disappear (terminal attached)
|
// Wait for loading overlay to disappear (terminal attached)
|
||||||
@@ -163,7 +155,6 @@ export async function navigateToTerminal(
|
|||||||
// overlay may never appear if attachment is instant
|
// overlay may never appear if attachment is instant
|
||||||
});
|
});
|
||||||
|
|
||||||
await terminalSurface.scrollIntoViewIfNeeded();
|
|
||||||
await terminalSurface.click();
|
await terminalSurface.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,343 @@
|
|||||||
|
import { test, expect } from "./fixtures";
|
||||||
|
import { createTempGitRepo } from "./helpers/workspace";
|
||||||
|
import {
|
||||||
|
gotoWorkspace,
|
||||||
|
waitForLauncherPanel,
|
||||||
|
assertProviderTilesVisible,
|
||||||
|
assertNewChatTileVisible,
|
||||||
|
assertTerminalTileVisible,
|
||||||
|
assertSingleNewTabButton,
|
||||||
|
clickNewTabButton,
|
||||||
|
pressNewTabShortcut,
|
||||||
|
clickNewChat,
|
||||||
|
clickTerminal,
|
||||||
|
clickProviderTile,
|
||||||
|
countTabsOfKind,
|
||||||
|
getTabTestIds,
|
||||||
|
waitForTabWithTitle,
|
||||||
|
measureTileTransition,
|
||||||
|
sampleTabsDuringTransition,
|
||||||
|
} from "./helpers/launcher";
|
||||||
|
import {
|
||||||
|
connectTerminalClient,
|
||||||
|
waitForTerminalContent,
|
||||||
|
setupDeterministicPrompt,
|
||||||
|
type TerminalPerfDaemonClient,
|
||||||
|
} from "./helpers/terminal-perf";
|
||||||
|
|
||||||
|
// ─── Shared state ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
tempRepo = await createTempGitRepo("launcher-e2e-");
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
if (tempRepo) await tempRepo.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Launcher Tab Tests
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
test.describe("Launcher tab", () => {
|
||||||
|
test("Cmd+T opens launcher panel with New Chat, Terminal, and provider tiles", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await gotoWorkspace(page, tempRepo.path);
|
||||||
|
|
||||||
|
await pressNewTabShortcut(page);
|
||||||
|
|
||||||
|
await waitForLauncherPanel(page);
|
||||||
|
await assertNewChatTileVisible(page);
|
||||||
|
await assertTerminalTileVisible(page);
|
||||||
|
await assertProviderTilesVisible(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("opening two new tabs creates two launcher tabs", async ({ page }) => {
|
||||||
|
await gotoWorkspace(page, tempRepo.path);
|
||||||
|
|
||||||
|
await pressNewTabShortcut(page);
|
||||||
|
await waitForLauncherPanel(page);
|
||||||
|
const countAfterFirst = await countTabsOfKind(page, "launcher");
|
||||||
|
|
||||||
|
await pressNewTabShortcut(page);
|
||||||
|
await waitForLauncherPanel(page);
|
||||||
|
const countAfterSecond = await countTabsOfKind(page, "launcher");
|
||||||
|
|
||||||
|
expect(countAfterSecond).toBe(countAfterFirst + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clicking New Chat replaces launcher in-place with draft tab", async ({ page }) => {
|
||||||
|
await gotoWorkspace(page, tempRepo.path);
|
||||||
|
|
||||||
|
await clickNewTabButton(page);
|
||||||
|
await waitForLauncherPanel(page);
|
||||||
|
|
||||||
|
const tabsBefore = await getTabTestIds(page);
|
||||||
|
const launcherCountBefore = tabsBefore.filter((id) => id.includes("launcher")).length;
|
||||||
|
|
||||||
|
await clickNewChat(page);
|
||||||
|
|
||||||
|
// Draft composer should appear (the agent message input)
|
||||||
|
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||||
|
await expect(composer.first()).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
// Launcher tab should have been replaced (not added alongside)
|
||||||
|
const tabsAfter = await getTabTestIds(page);
|
||||||
|
const launcherCountAfter = tabsAfter.filter((id) => id.includes("launcher")).length;
|
||||||
|
const draftCountAfter = tabsAfter.filter((id) => id.includes("draft")).length;
|
||||||
|
|
||||||
|
expect(launcherCountAfter).toBe(launcherCountBefore - 1);
|
||||||
|
expect(draftCountAfter).toBeGreaterThanOrEqual(1);
|
||||||
|
// Total tab count should stay the same (replaced, not added)
|
||||||
|
expect(tabsAfter.length).toBe(tabsBefore.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clicking Terminal replaces launcher with standalone terminal", async ({ page }) => {
|
||||||
|
test.setTimeout(45_000);
|
||||||
|
await gotoWorkspace(page, tempRepo.path);
|
||||||
|
|
||||||
|
await clickNewTabButton(page);
|
||||||
|
await waitForLauncherPanel(page);
|
||||||
|
|
||||||
|
const tabsBefore = await getTabTestIds(page);
|
||||||
|
|
||||||
|
await clickTerminal(page);
|
||||||
|
|
||||||
|
// Terminal surface should appear
|
||||||
|
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||||
|
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
|
||||||
|
|
||||||
|
// Tab count stays the same (in-place replacement)
|
||||||
|
const tabsAfter = await getTabTestIds(page);
|
||||||
|
expect(tabsAfter.length).toBe(tabsBefore.length);
|
||||||
|
|
||||||
|
// The launcher tab is gone, a terminal tab exists
|
||||||
|
const terminalTabs = tabsAfter.filter((id) => id.includes("terminal"));
|
||||||
|
expect(terminalTabs.length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clicking a provider tile replaces launcher with terminal agent tab", async ({ page }) => {
|
||||||
|
test.setTimeout(45_000);
|
||||||
|
await gotoWorkspace(page, tempRepo.path);
|
||||||
|
|
||||||
|
await clickNewTabButton(page);
|
||||||
|
await waitForLauncherPanel(page);
|
||||||
|
|
||||||
|
const tabsBefore = await getTabTestIds(page);
|
||||||
|
|
||||||
|
// Click the first visible provider tile under "Terminal Agents"
|
||||||
|
const providerTiles = page.locator('[role="button"]').filter({
|
||||||
|
has: page.locator("text=Terminal Agents").locator("..").locator(".."),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Try clicking any provider tile — find the first one after the "Terminal Agents" label
|
||||||
|
const terminalAgentsLabel = page.getByText("Terminal Agents", { exact: true }).first();
|
||||||
|
await expect(terminalAgentsLabel).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
|
// The provider grid follows the label. Click the first provider tile.
|
||||||
|
const providerGrid = terminalAgentsLabel.locator("~ *").first();
|
||||||
|
const firstProvider = providerGrid.getByRole("button").first();
|
||||||
|
if (await firstProvider.isVisible().catch(() => false)) {
|
||||||
|
await firstProvider.click();
|
||||||
|
} else {
|
||||||
|
// Fallback: look for any provider button after the section label
|
||||||
|
const allButtons = page.getByRole("button");
|
||||||
|
const count = await allButtons.count();
|
||||||
|
let clicked = false;
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const btn = allButtons.nth(i);
|
||||||
|
const text = await btn.innerText().catch(() => "");
|
||||||
|
// Skip known non-provider buttons
|
||||||
|
if (["New Chat", "Terminal", "More", "+"].includes(text.trim())) continue;
|
||||||
|
if (!text.trim()) continue;
|
||||||
|
await btn.click();
|
||||||
|
clicked = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!clicked) {
|
||||||
|
test.skip(true, "No provider tiles available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should see an agent panel (terminal surface or agent stream)
|
||||||
|
const agentOrTerminal = page.locator(
|
||||||
|
'[data-testid="terminal-surface"], [data-testid^="agent-"]',
|
||||||
|
);
|
||||||
|
await expect(agentOrTerminal.first()).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
// Tab count stays the same (replaced, not added)
|
||||||
|
const tabsAfter = await getTabTestIds(page);
|
||||||
|
expect(tabsAfter.length).toBe(tabsBefore.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tab bar shows a single + button per pane", async ({ page }) => {
|
||||||
|
await gotoWorkspace(page, tempRepo.path);
|
||||||
|
await assertSingleNewTabButton(page);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Terminal Title Tests
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
test.describe("Terminal title propagation", () => {
|
||||||
|
let client: TerminalPerfDaemonClient;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
client = await connectTerminalClient();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
if (client) await client.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("terminal tab title updates from OSC title escape sequence", async ({ page }) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
|
||||||
|
const result = await client.createTerminal(tempRepo.path, "title-test");
|
||||||
|
if (!result.terminal) throw new Error(`Failed to create terminal: ${result.error}`);
|
||||||
|
const terminalId = result.terminal.id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Navigate to workspace and open the terminal
|
||||||
|
await gotoWorkspace(page, tempRepo.path);
|
||||||
|
await clickNewTabButton(page);
|
||||||
|
await waitForLauncherPanel(page);
|
||||||
|
await clickTerminal(page);
|
||||||
|
|
||||||
|
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||||
|
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
|
||||||
|
await terminal.first().click();
|
||||||
|
|
||||||
|
await setupDeterministicPrompt(page);
|
||||||
|
|
||||||
|
// Send OSC 0 (set window title) escape sequence
|
||||||
|
const testTitle = `E2E-Title-${Date.now()}`;
|
||||||
|
await terminal
|
||||||
|
.first()
|
||||||
|
.pressSequentially(`printf '\\033]0;${testTitle}\\007'\n`, { delay: 0 });
|
||||||
|
|
||||||
|
// Wait for the tab to reflect the new title
|
||||||
|
await waitForTabWithTitle(page, testTitle, 15_000);
|
||||||
|
} finally {
|
||||||
|
await client.killTerminal(terminalId).catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("title debouncing coalesces rapid changes", async ({ page }) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
|
||||||
|
const result = await client.createTerminal(tempRepo.path, "debounce-test");
|
||||||
|
if (!result.terminal) throw new Error(`Failed to create terminal: ${result.error}`);
|
||||||
|
const terminalId = result.terminal.id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await gotoWorkspace(page, tempRepo.path);
|
||||||
|
await clickNewTabButton(page);
|
||||||
|
await waitForLauncherPanel(page);
|
||||||
|
await clickTerminal(page);
|
||||||
|
|
||||||
|
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||||
|
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
|
||||||
|
await terminal.first().click();
|
||||||
|
|
||||||
|
await setupDeterministicPrompt(page);
|
||||||
|
|
||||||
|
// Fire many rapid title changes — only the last should stick
|
||||||
|
const finalTitle = `Final-${Date.now()}`;
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await terminal
|
||||||
|
.first()
|
||||||
|
.pressSequentially(`printf '\\033]0;Rapid-${i}\\007'\n`, { delay: 0 });
|
||||||
|
}
|
||||||
|
await terminal
|
||||||
|
.first()
|
||||||
|
.pressSequentially(`printf '\\033]0;${finalTitle}\\007'\n`, { delay: 0 });
|
||||||
|
|
||||||
|
// The tab should eventually settle on the final title
|
||||||
|
await waitForTabWithTitle(page, finalTitle, 15_000);
|
||||||
|
} finally {
|
||||||
|
await client.killTerminal(terminalId).catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// No-Flash Transition Tests
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
test.describe("Launcher transitions (no flash)", () => {
|
||||||
|
test("New Chat transition has no blank intermediate tab state", async ({ page }) => {
|
||||||
|
await gotoWorkspace(page, tempRepo.path);
|
||||||
|
|
||||||
|
await clickNewTabButton(page);
|
||||||
|
await waitForLauncherPanel(page);
|
||||||
|
|
||||||
|
// Sample tabs at high frequency across the transition
|
||||||
|
const snapshots = await sampleTabsDuringTransition(
|
||||||
|
page,
|
||||||
|
() => clickNewChat(page),
|
||||||
|
2_000,
|
||||||
|
30,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Every snapshot should have at least one tab — no blank/zero-tab frames
|
||||||
|
for (const snapshot of snapshots) {
|
||||||
|
expect(snapshot.length).toBeGreaterThanOrEqual(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab count should never increase (no duplicate flash from add-then-remove)
|
||||||
|
const counts = snapshots.map((s) => s.length);
|
||||||
|
const maxCount = Math.max(...counts);
|
||||||
|
const initialCount = counts[0] ?? 0;
|
||||||
|
|
||||||
|
// Allow at most +1 transient tab (tolerance for React render batching)
|
||||||
|
expect(maxCount).toBeLessThanOrEqual(initialCount + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Terminal transition completes within visual budget", async ({ page }) => {
|
||||||
|
test.setTimeout(30_000);
|
||||||
|
await gotoWorkspace(page, tempRepo.path);
|
||||||
|
|
||||||
|
await clickNewTabButton(page);
|
||||||
|
await waitForLauncherPanel(page);
|
||||||
|
|
||||||
|
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||||
|
const elapsed = await measureTileTransition(
|
||||||
|
page,
|
||||||
|
() => clickTerminal(page),
|
||||||
|
terminal.first(),
|
||||||
|
20_000,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Terminal surface should appear within a reasonable budget.
|
||||||
|
// Note: terminal creation involves a server round-trip, so we allow more time
|
||||||
|
// than a pure in-memory transition, but it should still be well under 5 seconds.
|
||||||
|
expect(elapsed).toBeLessThan(5_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("New Chat click → composer appears without launcher flash", async ({ page }) => {
|
||||||
|
await gotoWorkspace(page, tempRepo.path);
|
||||||
|
|
||||||
|
await clickNewTabButton(page);
|
||||||
|
await waitForLauncherPanel(page);
|
||||||
|
|
||||||
|
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||||
|
|
||||||
|
const elapsed = await measureTileTransition(
|
||||||
|
page,
|
||||||
|
() => clickNewChat(page),
|
||||||
|
composer,
|
||||||
|
10_000,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Draft replacement is fully in-memory — should be fast
|
||||||
|
// We use a generous budget here because CI can be slow, but the key assertion
|
||||||
|
// is that no blank/flash frame appears (tested above).
|
||||||
|
expect(elapsed).toBeLessThan(3_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
|
||||||
import { expect, test } from "./fixtures";
|
|
||||||
import {
|
|
||||||
archiveWorkspaceFromDaemon,
|
|
||||||
archiveLocalWorkspaceFromDaemon,
|
|
||||||
assertNewWorkspaceSidebarAndHeader,
|
|
||||||
clickNewWorkspaceButton,
|
|
||||||
connectNewWorkspaceDaemonClient,
|
|
||||||
createWorktreeViaDaemon,
|
|
||||||
openProjectViaDaemon,
|
|
||||||
} from "./helpers/new-workspace";
|
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
|
||||||
import {
|
|
||||||
expectWorkspaceHeader,
|
|
||||||
switchWorkspaceViaSidebar,
|
|
||||||
workspaceLabelFromPath,
|
|
||||||
} from "./helpers/workspace-ui";
|
|
||||||
|
|
||||||
test.describe("New workspace flow", () => {
|
|
||||||
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
|
||||||
const localWorkspaceIds = new Set<string>();
|
|
||||||
const createdWorktreeIds = new Set<string>();
|
|
||||||
|
|
||||||
test.describe.configure({ timeout: 120_000 });
|
|
||||||
|
|
||||||
test.beforeEach(async () => {
|
|
||||||
client = await connectNewWorkspaceDaemonClient();
|
|
||||||
});
|
|
||||||
|
|
||||||
test.afterEach(async () => {
|
|
||||||
if (client) {
|
|
||||||
for (const workspaceId of createdWorktreeIds) {
|
|
||||||
await archiveWorkspaceFromDaemon(client, workspaceId).catch(() => undefined);
|
|
||||||
}
|
|
||||||
for (const workspaceId of localWorkspaceIds) {
|
|
||||||
await archiveLocalWorkspaceFromDaemon(client, workspaceId).catch(() => undefined);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
createdWorktreeIds.clear();
|
|
||||||
localWorkspaceIds.clear();
|
|
||||||
await client?.close().catch(() => undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("sidebar workspace navigation updates URL and header", async ({ page }) => {
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error("E2E_SERVER_ID is not set.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstRepo = await createTempGitRepo("workspace-nav-a-");
|
|
||||||
const secondRepo = await createTempGitRepo("workspace-nav-b-");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const firstWorkspace = await openProjectViaDaemon(client, firstRepo.path);
|
|
||||||
const secondWorkspace = await openProjectViaDaemon(client, secondRepo.path);
|
|
||||||
localWorkspaceIds.add(firstWorkspace.workspaceId);
|
|
||||||
localWorkspaceIds.add(secondWorkspace.workspaceId);
|
|
||||||
|
|
||||||
await page.goto(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId));
|
|
||||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId));
|
|
||||||
await expectWorkspaceHeader(page, {
|
|
||||||
title: firstWorkspace.workspaceName,
|
|
||||||
subtitle: workspaceLabelFromPath(firstRepo.path),
|
|
||||||
});
|
|
||||||
|
|
||||||
await switchWorkspaceViaSidebar({
|
|
||||||
page,
|
|
||||||
serverId,
|
|
||||||
targetWorkspacePath: secondWorkspace.workspaceId,
|
|
||||||
});
|
|
||||||
await expectWorkspaceHeader(page, {
|
|
||||||
title: secondWorkspace.workspaceName,
|
|
||||||
subtitle: workspaceLabelFromPath(secondRepo.path),
|
|
||||||
});
|
|
||||||
|
|
||||||
await switchWorkspaceViaSidebar({
|
|
||||||
page,
|
|
||||||
serverId,
|
|
||||||
targetWorkspacePath: firstWorkspace.workspaceId,
|
|
||||||
});
|
|
||||||
await expectWorkspaceHeader(page, {
|
|
||||||
title: firstWorkspace.workspaceName,
|
|
||||||
subtitle: workspaceLabelFromPath(firstRepo.path),
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
await secondRepo.cleanup();
|
|
||||||
await firstRepo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("clicking new workspace redirects, renders header, shows sidebar row, and keeps one draft tab", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error("E2E_SERVER_ID is not set.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const tempRepo = await createTempGitRepo("new-workspace-");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
|
|
||||||
localWorkspaceIds.add(openedProject.workspaceId);
|
|
||||||
|
|
||||||
await page.goto(buildHostWorkspaceRoute(serverId, openedProject.workspaceId));
|
|
||||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, openedProject.workspaceId));
|
|
||||||
await expectWorkspaceHeader(page, {
|
|
||||||
title: openedProject.workspaceName,
|
|
||||||
subtitle: workspaceLabelFromPath(tempRepo.path),
|
|
||||||
});
|
|
||||||
|
|
||||||
await clickNewWorkspaceButton(page, {
|
|
||||||
projectKey: openedProject.projectKey,
|
|
||||||
projectDisplayName: openedProject.projectDisplayName,
|
|
||||||
});
|
|
||||||
|
|
||||||
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
|
|
||||||
serverId,
|
|
||||||
previousWorkspaceId: openedProject.workspaceId,
|
|
||||||
projectDisplayName: openedProject.projectDisplayName,
|
|
||||||
});
|
|
||||||
createdWorktreeIds.add(createdWorkspace.workspaceId);
|
|
||||||
|
|
||||||
expect(createdWorkspace.workspaceId).not.toBe(openedProject.workspaceId);
|
|
||||||
await expect(page).toHaveURL(
|
|
||||||
buildHostWorkspaceRoute(serverId, createdWorkspace.workspaceId),
|
|
||||||
{
|
|
||||||
timeout: 30_000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const createdWorkspaceRow = page.getByTestId(
|
|
||||||
`sidebar-workspace-row-${serverId}:${createdWorkspace.workspaceId}`,
|
|
||||||
);
|
|
||||||
await expect(createdWorkspaceRow).toBeVisible({ timeout: 30_000 });
|
|
||||||
|
|
||||||
await expectWorkspaceHeader(page, {
|
|
||||||
title: workspaceLabelFromPath(createdWorkspace.workspaceId),
|
|
||||||
subtitle: openedProject.projectDisplayName,
|
|
||||||
});
|
|
||||||
|
|
||||||
const draftTabs = page.locator('[data-testid^="workspace-tab-"]').filter({
|
|
||||||
has: page.getByText("New Agent", { exact: true }),
|
|
||||||
});
|
|
||||||
await expect(draftTabs).toHaveCount(1, { timeout: 30_000 });
|
|
||||||
|
|
||||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
|
||||||
await expect(composer).toBeEditable({ timeout: 30_000 });
|
|
||||||
} finally {
|
|
||||||
await tempRepo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -54,11 +54,7 @@ test.describe("Terminal wire performance", () => {
|
|||||||
|
|
||||||
await terminal.pressSequentially(`seq 1 ${LINE_COUNT}; echo ${sentinel}\n`, { delay: 0 });
|
await terminal.pressSequentially(`seq 1 ${LINE_COUNT}; echo ${sentinel}\n`, { delay: 0 });
|
||||||
|
|
||||||
await waitForTerminalContent(
|
await waitForTerminalContent(page, (text) => text.includes(sentinel), THROUGHPUT_BUDGET_MS + 15_000);
|
||||||
page,
|
|
||||||
(text) => text.includes(sentinel),
|
|
||||||
THROUGHPUT_BUDGET_MS + 15_000,
|
|
||||||
);
|
|
||||||
|
|
||||||
const elapsedMs = Date.now() - startMs;
|
const elapsedMs = Date.now() - startMs;
|
||||||
|
|
||||||
@@ -85,10 +81,9 @@ test.describe("Terminal wire performance", () => {
|
|||||||
`[perf] Throughput: ${report.throughputMBps} MB/s — ${LINE_COUNT} lines in ${elapsedMs}ms`,
|
`[perf] Throughput: ${report.throughputMBps} MB/s — ${LINE_COUNT} lines in ${elapsedMs}ms`,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(
|
expect(elapsedMs, `${LINE_COUNT} lines should render within ${THROUGHPUT_BUDGET_MS}ms`).toBeLessThan(
|
||||||
elapsedMs,
|
THROUGHPUT_BUDGET_MS,
|
||||||
`${LINE_COUNT} lines should render within ${THROUGHPUT_BUDGET_MS}ms`,
|
);
|
||||||
).toBeLessThan(THROUGHPUT_BUDGET_MS);
|
|
||||||
} finally {
|
} finally {
|
||||||
await client.killTerminal(terminalId).catch(() => {});
|
await client.killTerminal(terminalId).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
# Maestro takeScreenshot artifacts
|
|
||||||
*.png
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
appId: sh.paseo
|
|
||||||
---
|
|
||||||
# Ensure sidebar is closed: if sidebar-sessions is visible, close it via swipe left
|
|
||||||
- runFlow:
|
|
||||||
when:
|
|
||||||
visible:
|
|
||||||
id: "sidebar-sessions"
|
|
||||||
commands:
|
|
||||||
- swipe:
|
|
||||||
direction: LEFT
|
|
||||||
duration: 300
|
|
||||||
|
|
||||||
# Small pause for close animation
|
|
||||||
- takeScreenshot: 00-sidebar-closed
|
|
||||||
|
|
||||||
# Open sidebar via swipe right gesture (the actual path that triggers the bug)
|
|
||||||
- swipe:
|
|
||||||
start: "5%,50%"
|
|
||||||
end: "80%,50%"
|
|
||||||
duration: 300
|
|
||||||
|
|
||||||
# Verify sidebar opened
|
|
||||||
- assertVisible:
|
|
||||||
id: "sidebar-sessions"
|
|
||||||
- takeScreenshot: 01-sidebar-opened
|
|
||||||
|
|
||||||
# Close sidebar via swipe left
|
|
||||||
- swipe:
|
|
||||||
direction: LEFT
|
|
||||||
duration: 300
|
|
||||||
- takeScreenshot: 02-sidebar-closed-again
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Verification loop for sidebar theme bug.
|
|
||||||
#
|
|
||||||
# Maestro can't toggle iOS appearance, so this script bridges the gap:
|
|
||||||
# toggle appearance via xcrun simctl, then run Maestro to verify the sidebar
|
|
||||||
# still works. Runs N iterations to catch intermittent failures.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# bash packages/app/maestro/test-sidebar-theme.sh [iterations] [wait_seconds]
|
|
||||||
# bash packages/app/maestro/test-sidebar-theme.sh 6 1
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
|
|
||||||
FLOW="$REPO_ROOT/packages/app/maestro/sidebar-theme-repro.yaml"
|
|
||||||
OUT_DIR="/tmp/sidebar-theme-test-$(date +%s)"
|
|
||||||
ITERATIONS="${1:-3}"
|
|
||||||
WAIT_SECS="${2:-1}"
|
|
||||||
mkdir -p "$OUT_DIR"
|
|
||||||
|
|
||||||
echo "=== Sidebar Theme Bug Verification ==="
|
|
||||||
echo "Output dir: $OUT_DIR"
|
|
||||||
echo "Iterations: $ITERATIONS, wait after toggle: ${WAIT_SECS}s"
|
|
||||||
|
|
||||||
FAILURES=0
|
|
||||||
|
|
||||||
for i in $(seq 1 "$ITERATIONS"); do
|
|
||||||
echo ""
|
|
||||||
echo "========== Iteration $i / $ITERATIONS =========="
|
|
||||||
|
|
||||||
CURRENT=$(xcrun simctl ui booted appearance 2>&1 | tr -d '[:space:]')
|
|
||||||
echo "Current appearance: $CURRENT"
|
|
||||||
|
|
||||||
if [ "$CURRENT" = "dark" ]; then
|
|
||||||
xcrun simctl ui booted appearance light
|
|
||||||
echo "Switched to light mode"
|
|
||||||
else
|
|
||||||
xcrun simctl ui booted appearance dark
|
|
||||||
echo "Switched to dark mode"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Waiting ${WAIT_SECS}s..."
|
|
||||||
sleep "$WAIT_SECS"
|
|
||||||
|
|
||||||
ITER_DIR="$OUT_DIR/iter-$i"
|
|
||||||
mkdir -p "$ITER_DIR"
|
|
||||||
|
|
||||||
# Run maestro from the output dir so takeScreenshot artifacts land there
|
|
||||||
if (cd "$ITER_DIR" && maestro test "$FLOW") 2>&1 | tee "$ITER_DIR/test.log"; then
|
|
||||||
echo " -> PASS (iteration $i)"
|
|
||||||
else
|
|
||||||
echo " -> FAIL (iteration $i) — bug reproduced!"
|
|
||||||
FAILURES=$((FAILURES + 1))
|
|
||||||
xcrun simctl io booted screenshot "$ITER_DIR/failure-state.png" 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
# Restore to dark mode
|
|
||||||
xcrun simctl ui booted appearance dark
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=== Summary: $FAILURES failures out of $ITERATIONS iterations ==="
|
|
||||||
echo "Output: $OUT_DIR"
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"version": "0.1.55-rc.1",
|
"version": "0.1.38",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
@@ -31,9 +31,9 @@
|
|||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@expo/vector-icons": "^15.0.2",
|
"@expo/vector-icons": "^15.0.2",
|
||||||
"@floating-ui/react-native": "^0.10.7",
|
"@floating-ui/react-native": "^0.10.7",
|
||||||
"@getpaseo/expo-two-way-audio": "0.1.55-rc.1",
|
"@getpaseo/expo-two-way-audio": "0.1.38",
|
||||||
"@getpaseo/highlight": "0.1.55-rc.1",
|
"@getpaseo/highlight": "0.1.38",
|
||||||
"@getpaseo/server": "0.1.55-rc.1",
|
"@getpaseo/server": "0.1.38",
|
||||||
"@gorhom/bottom-sheet": "^5.2.6",
|
"@gorhom/bottom-sheet": "^5.2.6",
|
||||||
"@gorhom/portal": "^1.0.14",
|
"@gorhom/portal": "^1.0.14",
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
@@ -108,7 +108,6 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.56.1",
|
"@playwright/test": "^1.56.1",
|
||||||
"@types/react": "~19.2.0",
|
"@types/react": "~19.2.0",
|
||||||
"@types/ws": "^8.18.1",
|
|
||||||
"eas-cli": "^16.24.1",
|
"eas-cli": "^16.24.1",
|
||||||
"eslint": "^9.25.0",
|
"eslint": "^9.25.0",
|
||||||
"eslint-config-expo": "~10.0.0",
|
"eslint-config-expo": "~10.0.0",
|
||||||
@@ -116,7 +115,6 @@
|
|||||||
"playwright": "^1.56.1",
|
"playwright": "^1.56.1",
|
||||||
"typescript": "~5.9.2",
|
"typescript": "~5.9.2",
|
||||||
"vitest": "^3.2.4",
|
"vitest": "^3.2.4",
|
||||||
"wrangler": "^4.59.1",
|
"wrangler": "^4.59.1"
|
||||||
"ws": "^8.20.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,12 +19,6 @@
|
|||||||
body {
|
body {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
/* Prevent white flash before React mounts */
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
html, body {
|
|
||||||
background-color: #181B1A;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/* These styles make the root element full-height */
|
/* These styles make the root element full-height */
|
||||||
#root {
|
#root {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
|||||||
import { PortalProvider } from "@gorhom/portal";
|
import { PortalProvider } from "@gorhom/portal";
|
||||||
import { VoiceProvider } from "@/contexts/voice-context";
|
import { VoiceProvider } from "@/contexts/voice-context";
|
||||||
import { useAppSettings } from "@/hooks/use-settings";
|
import { useAppSettings } from "@/hooks/use-settings";
|
||||||
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
|
|
||||||
import { useFaviconStatus } from "@/hooks/use-favicon-status";
|
import { useFaviconStatus } from "@/hooks/use-favicon-status";
|
||||||
import { View, Text } from "react-native";
|
import { View, Text } from "react-native";
|
||||||
import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { darkTheme } from "@/styles/theme";
|
||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
getHostRuntimeStore,
|
getHostRuntimeStore,
|
||||||
@@ -28,7 +28,6 @@ import {
|
|||||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||||
import { loadSettingsFromStorage } from "@/hooks/use-settings";
|
import { loadSettingsFromStorage } from "@/hooks/use-settings";
|
||||||
import { useColorScheme } from "@/hooks/use-color-scheme";
|
import { useColorScheme } from "@/hooks/use-color-scheme";
|
||||||
import { useOpenProject } from "@/hooks/use-open-project";
|
|
||||||
import { SessionProvider } from "@/contexts/session-context";
|
import { SessionProvider } from "@/contexts/session-context";
|
||||||
import type { HostProfile } from "@/types/host-connection";
|
import type { HostProfile } from "@/types/host-connection";
|
||||||
import {
|
import {
|
||||||
@@ -58,10 +57,11 @@ import {
|
|||||||
HorizontalScrollProvider,
|
HorizontalScrollProvider,
|
||||||
useHorizontalScrollOptional,
|
useHorizontalScrollOptional,
|
||||||
} from "@/contexts/horizontal-scroll-context";
|
} from "@/contexts/horizontal-scroll-context";
|
||||||
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
|
import { getIsDesktop } from "@/constants/layout";
|
||||||
import { CommandCenter } from "@/components/command-center";
|
import { CommandCenter } from "@/components/command-center";
|
||||||
import { ProjectPickerModal } from "@/components/project-picker-modal";
|
import { ProjectPickerModal } from "@/components/project-picker-modal";
|
||||||
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
||||||
|
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
|
||||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||||
import { queryClient } from "@/query/query-client";
|
import { queryClient } from "@/query/query-client";
|
||||||
import {
|
import {
|
||||||
@@ -69,9 +69,8 @@ import {
|
|||||||
type WebNotificationClickDetail,
|
type WebNotificationClickDetail,
|
||||||
ensureOsNotificationPermission,
|
ensureOsNotificationPermission,
|
||||||
} from "@/utils/os-notifications";
|
} from "@/utils/os-notifications";
|
||||||
import { listenToDesktopEvent } from "@/desktop/electron/events";
|
|
||||||
import { getDesktopHost } from "@/desktop/host";
|
import { getDesktopHost } from "@/desktop/host";
|
||||||
import { updateDesktopWindowControls } from "@/desktop/electron/window";
|
import { setDesktopTitleBarTheme } from "@/desktop/electron/window";
|
||||||
import { buildNotificationRoute } from "@/utils/notification-routing";
|
import { buildNotificationRoute } from "@/utils/notification-routing";
|
||||||
import {
|
import {
|
||||||
buildHostRootRoute,
|
buildHostRootRoute,
|
||||||
@@ -81,7 +80,6 @@ import {
|
|||||||
parseWorkspaceOpenIntent,
|
parseWorkspaceOpenIntent,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store";
|
import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||||
import { isWeb, isNative } from "@/constants/platform";
|
|
||||||
|
|
||||||
polyfillCrypto();
|
polyfillCrypto();
|
||||||
|
|
||||||
@@ -91,22 +89,6 @@ export type HostRuntimeBootstrapState = {
|
|||||||
retry: () => void;
|
retry: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getRouteParamValue(value: string | string[] | undefined): string | undefined {
|
|
||||||
if (typeof value === "string") {
|
|
||||||
const trimmed = value.trim();
|
|
||||||
return trimmed.length > 0 ? trimmed : undefined;
|
|
||||||
}
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
const firstValue = value[0];
|
|
||||||
if (typeof firstValue !== "string") {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const trimmed = firstValue.trim();
|
|
||||||
return trimmed.length > 0 ? trimmed : undefined;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
|
const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
|
||||||
phase: "starting-daemon",
|
phase: "starting-daemon",
|
||||||
error: null,
|
error: null,
|
||||||
@@ -118,11 +100,11 @@ function PushNotificationRouter() {
|
|||||||
const lastHandledIdRef = useRef<string | null>(null);
|
const lastHandledIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isWeb) {
|
if (Platform.OS === "web") {
|
||||||
let removeDesktopNotificationListener: (() => void) | null = null;
|
let removeDesktopNotificationListener: (() => void) | null = null;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
if (getIsElectronRuntime()) {
|
if (getIsDesktop()) {
|
||||||
void ensureOsNotificationPermission();
|
void ensureOsNotificationPermission();
|
||||||
|
|
||||||
const unlistenResult = getDesktopHost()?.events?.on?.(
|
const unlistenResult = getDesktopHost()?.events?.on?.(
|
||||||
@@ -250,7 +232,6 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let cancelAnyOnline: (() => void) | null = null;
|
|
||||||
const shouldManageDesktop = shouldUseDesktopDaemon();
|
const shouldManageDesktop = shouldUseDesktopDaemon();
|
||||||
const store = getHostRuntimeStore();
|
const store = getHostRuntimeStore();
|
||||||
|
|
||||||
@@ -261,53 +242,28 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
|||||||
if (isDesktopManaged) {
|
if (isDesktopManaged) {
|
||||||
setPhase("starting-daemon");
|
setPhase("starting-daemon");
|
||||||
setError(null);
|
setError(null);
|
||||||
|
const bootstrapResult = await store.bootstrapDesktop();
|
||||||
let raceSettled = false;
|
if (!bootstrapResult.ok) {
|
||||||
|
if (!cancelled) {
|
||||||
const anyOnline = store.waitForAnyConnectionOnline();
|
|
||||||
cancelAnyOnline = anyOnline.cancel;
|
|
||||||
|
|
||||||
const bootstrapPromise = (async (): Promise<
|
|
||||||
{ type: "online" } | { type: "error"; error: string }
|
|
||||||
> => {
|
|
||||||
try {
|
|
||||||
const bootstrapResult = await store.bootstrapDesktop();
|
|
||||||
if (!bootstrapResult.ok) {
|
|
||||||
return { type: "error", error: bootstrapResult.error };
|
|
||||||
}
|
|
||||||
if (!cancelled && !raceSettled) {
|
|
||||||
setPhase("connecting");
|
|
||||||
}
|
|
||||||
await store.addConnectionFromListenAndWaitForOnline({
|
|
||||||
listenAddress: bootstrapResult.listenAddress,
|
|
||||||
serverId: bootstrapResult.serverId,
|
|
||||||
hostname: bootstrapResult.hostname,
|
|
||||||
});
|
|
||||||
return { type: "online" };
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
type: "error",
|
|
||||||
error: err instanceof Error ? err.message : String(err),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
const result = await Promise.race([
|
|
||||||
anyOnline.promise.then((): { type: "online" } => ({ type: "online" })),
|
|
||||||
bootstrapPromise,
|
|
||||||
]);
|
|
||||||
|
|
||||||
raceSettled = true;
|
|
||||||
anyOnline.cancel();
|
|
||||||
|
|
||||||
if (!cancelled) {
|
|
||||||
if (result.type === "online") {
|
|
||||||
setPhase("online");
|
|
||||||
setError(null);
|
|
||||||
} else {
|
|
||||||
setPhase("error");
|
setPhase("error");
|
||||||
setError(result.error);
|
setError(bootstrapResult.error);
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPhase("connecting");
|
||||||
|
await store.addConnectionFromListenAndWaitForOnline({
|
||||||
|
listenAddress: bootstrapResult.listenAddress,
|
||||||
|
serverId: bootstrapResult.serverId,
|
||||||
|
hostname: bootstrapResult.hostname,
|
||||||
|
});
|
||||||
|
if (!cancelled) {
|
||||||
|
setPhase("online");
|
||||||
|
setError(null);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
void store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon });
|
void store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon });
|
||||||
@@ -334,7 +290,6 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
cancelAnyOnline?.();
|
|
||||||
};
|
};
|
||||||
}, [retryToken]);
|
}, [retryToken]);
|
||||||
|
|
||||||
@@ -375,8 +330,6 @@ interface AppContainerProps {
|
|||||||
chromeEnabled?: boolean;
|
chromeEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const THEME_CYCLE_ORDER: ThemeName[] = ["dark", "zinc", "midnight", "claude", "ghostty", "light"];
|
|
||||||
|
|
||||||
function AppContainer({
|
function AppContainer({
|
||||||
children,
|
children,
|
||||||
selectedAgentId,
|
selectedAgentId,
|
||||||
@@ -384,62 +337,23 @@ function AppContainer({
|
|||||||
}: AppContainerProps) {
|
}: AppContainerProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
const { settings, updateSettings } = useAppSettings();
|
|
||||||
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
|
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
|
||||||
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
|
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
|
||||||
const toggleBothSidebars = usePanelStore((state) => state.toggleBothSidebars);
|
const toggleBothSidebars = usePanelStore((state) => state.toggleBothSidebars);
|
||||||
const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode);
|
const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode);
|
||||||
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
|
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
|
||||||
const agentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
|
||||||
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
|
||||||
|
|
||||||
const cycleTheme = useCallback(() => {
|
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
const currentIndex = THEME_CYCLE_ORDER.indexOf(settings.theme as ThemeName);
|
|
||||||
const nextIndex = (currentIndex + 1) % THEME_CYCLE_ORDER.length;
|
|
||||||
void updateSettings({ theme: THEME_CYCLE_ORDER[nextIndex]! });
|
|
||||||
}, [settings.theme, updateSettings]);
|
|
||||||
|
|
||||||
const isCompactLayout = useIsCompactFormFactor();
|
|
||||||
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
|
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const bp = UnistylesRuntime.breakpoint;
|
|
||||||
const screenW = UnistylesRuntime.screen.width;
|
|
||||||
const screenH = UnistylesRuntime.screen.height;
|
|
||||||
const isElectron = getIsElectronRuntime();
|
|
||||||
const windowW = isWeb ? window.innerWidth : undefined;
|
|
||||||
const windowH = isWeb ? window.innerHeight : undefined;
|
|
||||||
const dpr = isWeb ? window.devicePixelRatio : undefined;
|
|
||||||
const ua = isWeb ? navigator.userAgent : undefined;
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
"[layout-debug]",
|
|
||||||
JSON.stringify({
|
|
||||||
breakpoint: bp,
|
|
||||||
isCompactLayout,
|
|
||||||
isElectron,
|
|
||||||
chromeEnabled,
|
|
||||||
isFocusModeEnabled,
|
|
||||||
agentListOpen,
|
|
||||||
sidebarWidth,
|
|
||||||
sidebarRenderedInRow: !isCompactLayout && chromeEnabled && !isFocusModeEnabled,
|
|
||||||
unistylesScreen: { w: screenW, h: screenH },
|
|
||||||
window: { w: windowW, h: windowH },
|
|
||||||
devicePixelRatio: dpr,
|
|
||||||
userAgent: ua,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}, [isCompactLayout, chromeEnabled, isFocusModeEnabled, agentListOpen, sidebarWidth]);
|
|
||||||
|
|
||||||
useKeyboardShortcuts({
|
useKeyboardShortcuts({
|
||||||
enabled: chromeEnabled,
|
enabled: chromeEnabled,
|
||||||
isMobile: isCompactLayout,
|
isMobile,
|
||||||
toggleAgentList,
|
toggleAgentList,
|
||||||
selectedAgentId,
|
selectedAgentId,
|
||||||
toggleFileExplorer,
|
toggleFileExplorer,
|
||||||
toggleBothSidebars,
|
toggleBothSidebars,
|
||||||
toggleFocusMode,
|
toggleFocusMode,
|
||||||
cycleTheme,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const containerStyle = useMemo(
|
const containerStyle = useMemo(
|
||||||
@@ -450,21 +364,20 @@ function AppContainer({
|
|||||||
const content = (
|
const content = (
|
||||||
<View style={containerStyle}>
|
<View style={containerStyle}>
|
||||||
<View style={rowStyle}>
|
<View style={rowStyle}>
|
||||||
{!isCompactLayout && chromeEnabled && !isFocusModeEnabled && (
|
{!isMobile && chromeEnabled && !isFocusModeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
||||||
<LeftSidebar selectedAgentId={selectedAgentId} />
|
|
||||||
)}
|
|
||||||
<View style={flexStyle}>{children}</View>
|
<View style={flexStyle}>{children}</View>
|
||||||
</View>
|
</View>
|
||||||
{isCompactLayout && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
{isMobile && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
||||||
<DownloadToast />
|
<DownloadToast />
|
||||||
<UpdateBanner />
|
<UpdateBanner />
|
||||||
<CommandCenter />
|
<CommandCenter />
|
||||||
<ProjectPickerModal />
|
<ProjectPickerModal />
|
||||||
|
<WorkspaceSetupDialog />
|
||||||
<KeyboardShortcutsDialog />
|
<KeyboardShortcutsDialog />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!isCompactLayout) {
|
if (!isMobile) {
|
||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -481,28 +394,14 @@ function MobileGestureWrapper({
|
|||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const mobileView = usePanelStore((state) => state.mobileView);
|
||||||
const openAgentList = usePanelStore((state) => state.openAgentList);
|
const openAgentList = usePanelStore((state) => state.openAgentList);
|
||||||
const horizontalScroll = useHorizontalScrollOptional();
|
const horizontalScroll = useHorizontalScrollOptional();
|
||||||
const {
|
const { translateX, backdropOpacity, windowWidth, animateToOpen, animateToClose, isGesturing } =
|
||||||
translateX,
|
useSidebarAnimation();
|
||||||
backdropOpacity,
|
|
||||||
windowWidth,
|
|
||||||
animateToOpen,
|
|
||||||
animateToClose,
|
|
||||||
isGesturing,
|
|
||||||
gestureAnimatingRef,
|
|
||||||
openGestureRef,
|
|
||||||
} = useSidebarAnimation();
|
|
||||||
const touchStartX = useSharedValue(0);
|
const touchStartX = useSharedValue(0);
|
||||||
const openGestureEnabled = chromeEnabled && mobileView === "agent";
|
const openGestureEnabled = chromeEnabled && mobileView === "agent";
|
||||||
|
|
||||||
const handleGestureOpen = useCallback(() => {
|
|
||||||
gestureAnimatingRef.current = true;
|
|
||||||
openAgentList();
|
|
||||||
}, [openAgentList, gestureAnimatingRef]);
|
|
||||||
|
|
||||||
const openGesture = useMemo(
|
const openGesture = useMemo(
|
||||||
() =>
|
() =>
|
||||||
Gesture.Pan()
|
Gesture.Pan()
|
||||||
.withRef(openGestureRef)
|
|
||||||
.enabled(openGestureEnabled)
|
.enabled(openGestureEnabled)
|
||||||
.manualActivation(true)
|
.manualActivation(true)
|
||||||
.failOffsetY([-10, 10])
|
.failOffsetY([-10, 10])
|
||||||
@@ -545,7 +444,7 @@ function MobileGestureWrapper({
|
|||||||
const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500;
|
const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500;
|
||||||
if (shouldOpen) {
|
if (shouldOpen) {
|
||||||
animateToOpen();
|
animateToOpen();
|
||||||
runOnJS(handleGestureOpen)();
|
runOnJS(openAgentList)();
|
||||||
} else {
|
} else {
|
||||||
animateToClose();
|
animateToClose();
|
||||||
}
|
}
|
||||||
@@ -560,9 +459,8 @@ function MobileGestureWrapper({
|
|||||||
backdropOpacity,
|
backdropOpacity,
|
||||||
animateToOpen,
|
animateToOpen,
|
||||||
animateToClose,
|
animateToClose,
|
||||||
handleGestureOpen,
|
openAgentList,
|
||||||
isGesturing,
|
isGesturing,
|
||||||
openGestureRef,
|
|
||||||
horizontalScroll?.isAnyScrolledRight,
|
horizontalScroll?.isAnyScrolledRight,
|
||||||
touchStartX,
|
touchStartX,
|
||||||
],
|
],
|
||||||
@@ -579,7 +477,6 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
|
|||||||
const { settings, isLoading: settingsLoading } = useAppSettings();
|
const { settings, isLoading: settingsLoading } = useAppSettings();
|
||||||
const { upsertConnectionFromOfferUrl } = useHostMutations();
|
const { upsertConnectionFromOfferUrl } = useHostMutations();
|
||||||
const systemColorScheme = useColorScheme();
|
const systemColorScheme = useColorScheme();
|
||||||
const { theme } = useUnistyles();
|
|
||||||
const resolvedTheme = settings.theme === "auto" ? (systemColorScheme ?? "light") : settings.theme;
|
const resolvedTheme = settings.theme === "auto" ? (systemColorScheme ?? "light") : settings.theme;
|
||||||
|
|
||||||
// Apply theme setting on mount and when it changes
|
// Apply theme setting on mount and when it changes
|
||||||
@@ -589,22 +486,19 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
|
|||||||
UnistylesRuntime.setAdaptiveThemes(true);
|
UnistylesRuntime.setAdaptiveThemes(true);
|
||||||
} else {
|
} else {
|
||||||
UnistylesRuntime.setAdaptiveThemes(false);
|
UnistylesRuntime.setAdaptiveThemes(false);
|
||||||
UnistylesRuntime.setTheme(THEME_TO_UNISTYLES[settings.theme]);
|
UnistylesRuntime.setTheme(settings.theme);
|
||||||
}
|
}
|
||||||
}, [settingsLoading, settings.theme]);
|
}, [settingsLoading, settings.theme]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (settingsLoading || isNative) {
|
if (settingsLoading || Platform.OS !== "web") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void updateDesktopWindowControls({
|
void setDesktopTitleBarTheme(resolvedTheme).catch((error) => {
|
||||||
backgroundColor: theme.colors.surface0,
|
console.warn("[DesktopWindow] Failed to update title bar theme", error);
|
||||||
foregroundColor: theme.colors.foreground,
|
|
||||||
}).catch((error) => {
|
|
||||||
console.warn("[DesktopWindow] Failed to update window controls overlay", error);
|
|
||||||
});
|
});
|
||||||
}, [settingsLoading, resolvedTheme, theme.colors.foreground, theme.colors.surface0]);
|
}, [settingsLoading, resolvedTheme]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VoiceProvider>
|
<VoiceProvider>
|
||||||
@@ -633,7 +527,7 @@ function OfferLinkListener({
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const serverId = (profile as any)?.serverId;
|
const serverId = (profile as any)?.serverId;
|
||||||
if (typeof serverId !== "string" || !serverId) return;
|
if (typeof serverId !== "string" || !serverId) return;
|
||||||
router.replace(buildHostRootRoute(serverId));
|
router.replace(buildHostRootRoute(serverId) as any);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
@@ -658,84 +552,6 @@ function OfferLinkListener({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OpenProjectEventPayload {
|
|
||||||
path?: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
function OpenProjectListener() {
|
|
||||||
const hosts = useHosts();
|
|
||||||
const serverId = hosts[0]?.serverId ?? null;
|
|
||||||
const client = useHostRuntimeClient(serverId ?? "");
|
|
||||||
const openProject = useOpenProject(serverId);
|
|
||||||
const pendingPathRef = useRef<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let disposed = false;
|
|
||||||
let unlisten: (() => void) | null = null;
|
|
||||||
const maybeOpenProject = (inputPath: string) => {
|
|
||||||
const nextPath = inputPath.trim();
|
|
||||||
if (!nextPath) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
pendingPathRef.current = nextPath;
|
|
||||||
|
|
||||||
if (!serverId || !client) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pathToOpen = pendingPathRef.current;
|
|
||||||
pendingPathRef.current = null;
|
|
||||||
if (!pathToOpen) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
void openProject(pathToOpen).catch(() => undefined);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Pull any path that was passed on cold start (before the listener existed).
|
|
||||||
// Store in the ref even if this effect instance is disposed — the next
|
|
||||||
// effect run picks it up via maybeOpenProject(pendingPathRef.current).
|
|
||||||
void getDesktopHost()
|
|
||||||
?.getPendingOpenProject?.()
|
|
||||||
?.then((pending) => {
|
|
||||||
if (pending) {
|
|
||||||
pendingPathRef.current = pending;
|
|
||||||
}
|
|
||||||
if (!disposed && pending) {
|
|
||||||
maybeOpenProject(pending);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => undefined);
|
|
||||||
|
|
||||||
// Listen for hot-start paths relayed via the second-instance event.
|
|
||||||
void listenToDesktopEvent<OpenProjectEventPayload>("open-project", (payload) => {
|
|
||||||
if (disposed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const nextPath = typeof payload?.path === "string" ? payload.path.trim() : "";
|
|
||||||
maybeOpenProject(nextPath);
|
|
||||||
})
|
|
||||||
.then((dispose) => {
|
|
||||||
if (disposed) {
|
|
||||||
dispose();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
unlisten = dispose;
|
|
||||||
})
|
|
||||||
.catch(() => undefined);
|
|
||||||
|
|
||||||
maybeOpenProject(pendingPathRef.current ?? "");
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
disposed = true;
|
|
||||||
unlisten?.();
|
|
||||||
};
|
|
||||||
}, [client, openProject, serverId]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function AppWithSidebar({ children }: { children: ReactNode }) {
|
function AppWithSidebar({ children }: { children: ReactNode }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -751,7 +567,7 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
|
|||||||
if (hosts.some((host) => host.serverId === activeServerId)) {
|
if (hosts.some((host) => host.serverId === activeServerId)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.replace(mapPathnameToServer(pathname, hosts[0]!.serverId));
|
router.replace(mapPathnameToServer(pathname, hosts[0]!.serverId) as any);
|
||||||
}, [activeServerId, hosts, pathname, router]);
|
}, [activeServerId, hosts, pathname, router]);
|
||||||
|
|
||||||
// Parse selectedAgentKey directly from pathname
|
// Parse selectedAgentKey directly from pathname
|
||||||
@@ -787,7 +603,6 @@ function FaviconStatusSync() {
|
|||||||
|
|
||||||
function RootStack() {
|
function RootStack() {
|
||||||
const storeReady = useStoreReady();
|
const storeReady = useStoreReady();
|
||||||
const { theme } = useUnistyles();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack
|
<Stack
|
||||||
@@ -795,28 +610,24 @@ function RootStack() {
|
|||||||
headerShown: false,
|
headerShown: false,
|
||||||
animation: "none",
|
animation: "none",
|
||||||
contentStyle: {
|
contentStyle: {
|
||||||
backgroundColor: theme.colors.surface0,
|
backgroundColor: darkTheme.colors.surface0,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack.Protected guard={storeReady}>
|
<Stack.Protected guard={storeReady}>
|
||||||
<Stack.Screen name="welcome" />
|
<Stack.Screen name="welcome" />
|
||||||
<Stack.Screen name="settings" />
|
<Stack.Screen name="settings" />
|
||||||
|
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
|
||||||
|
<Stack.Screen
|
||||||
|
name="h/[serverId]/agent/[agentId]"
|
||||||
|
options={{ gestureEnabled: false }}
|
||||||
|
/>
|
||||||
|
<Stack.Screen name="h/[serverId]/index" />
|
||||||
|
<Stack.Screen name="h/[serverId]/sessions" />
|
||||||
|
<Stack.Screen name="h/[serverId]/open-project" />
|
||||||
|
<Stack.Screen name="h/[serverId]/settings" />
|
||||||
<Stack.Screen name="pair-scan" />
|
<Stack.Screen name="pair-scan" />
|
||||||
</Stack.Protected>
|
</Stack.Protected>
|
||||||
<Stack.Screen
|
|
||||||
name="h/[serverId]/workspace/[workspaceId]"
|
|
||||||
getId={({ params }) => {
|
|
||||||
const serverId = getRouteParamValue(params?.serverId);
|
|
||||||
const workspaceId = getRouteParamValue(params?.workspaceId);
|
|
||||||
return serverId && workspaceId ? `${serverId}:${workspaceId}` : undefined;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen name="h/[serverId]/agent/[agentId]" options={{ gestureEnabled: false }} />
|
|
||||||
<Stack.Screen name="h/[serverId]/index" />
|
|
||||||
<Stack.Screen name="h/[serverId]/sessions" />
|
|
||||||
<Stack.Screen name="h/[serverId]/open-project" />
|
|
||||||
<Stack.Screen name="h/[serverId]/settings" />
|
|
||||||
<Stack.Screen name="index" />
|
<Stack.Screen name="index" />
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
@@ -843,10 +654,8 @@ function NavigationActiveWorkspaceObserver() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
const { theme } = useUnistyles();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GestureHandlerRootView style={{ flex: 1, backgroundColor: theme.colors.surface0 }}>
|
<GestureHandlerRootView style={{ flex: 1, backgroundColor: darkTheme.colors.surface0 }}>
|
||||||
<NavigationActiveWorkspaceObserver />
|
<NavigationActiveWorkspaceObserver />
|
||||||
<PortalProvider>
|
<PortalProvider>
|
||||||
<SafeAreaProvider>
|
<SafeAreaProvider>
|
||||||
@@ -859,7 +668,6 @@ export default function RootLayout() {
|
|||||||
<SidebarAnimationProvider>
|
<SidebarAnimationProvider>
|
||||||
<HorizontalScrollProvider>
|
<HorizontalScrollProvider>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<OpenProjectListener />
|
|
||||||
<AppWithSidebar>
|
<AppWithSidebar>
|
||||||
<RootStack />
|
<RootStack />
|
||||||
</AppWithSidebar>
|
</AppWithSidebar>
|
||||||
|
|||||||
@@ -1,20 +1,11 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
|
|
||||||
export default function HostAgentReadyRoute() {
|
export default function HostAgentReadyRoute() {
|
||||||
return (
|
|
||||||
<HostRouteBootstrapBoundary>
|
|
||||||
<HostAgentReadyRouteContent />
|
|
||||||
</HostRouteBootstrapBoundary>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function HostAgentReadyRouteContent() {
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useLocalSearchParams<{
|
const params = useLocalSearchParams<{
|
||||||
serverId?: string;
|
serverId?: string;
|
||||||
@@ -67,7 +58,7 @@ function HostAgentReadyRouteContent() {
|
|||||||
}
|
}
|
||||||
if (!client || !isConnected) {
|
if (!client || !isConnected) {
|
||||||
redirectedRef.current = true;
|
redirectedRef.current = true;
|
||||||
router.replace(buildHostRootRoute(serverId));
|
router.replace(buildHostRootRoute(serverId) as any);
|
||||||
}
|
}
|
||||||
}, [agentCwd, agentId, client, isConnected, router, serverId]);
|
}, [agentCwd, agentId, client, isConnected, router, serverId]);
|
||||||
|
|
||||||
@@ -98,14 +89,14 @@ function HostAgentReadyRouteContent() {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.replace(buildHostRootRoute(serverId));
|
router.replace(buildHostRootRoute(serverId) as any);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (cancelled || redirectedRef.current) {
|
if (cancelled || redirectedRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
redirectedRef.current = true;
|
redirectedRef.current = true;
|
||||||
router.replace(buildHostRootRoute(serverId));
|
router.replace(buildHostRootRoute(serverId) as any);
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useLocalSearchParams, usePathname, useRouter } from "expo-router";
|
import { useLocalSearchParams, usePathname, useRouter } from "expo-router";
|
||||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useFormPreferences } from "@/hooks/use-form-preferences";
|
import { useFormPreferences } from "@/hooks/use-form-preferences";
|
||||||
import {
|
import {
|
||||||
buildHostOpenProjectRoute,
|
buildHostOpenProjectRoute,
|
||||||
buildHostRootRoute,
|
buildHostRootRoute,
|
||||||
|
buildHostWorkspaceOpenRoute,
|
||||||
buildHostWorkspaceRoute,
|
buildHostWorkspaceRoute,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
@@ -13,14 +13,6 @@ import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
|||||||
const HOST_ROOT_REDIRECT_DELAY_MS = 300;
|
const HOST_ROOT_REDIRECT_DELAY_MS = 300;
|
||||||
|
|
||||||
export default function HostIndexRoute() {
|
export default function HostIndexRoute() {
|
||||||
return (
|
|
||||||
<HostRouteBootstrapBoundary>
|
|
||||||
<HostIndexRouteContent />
|
|
||||||
</HostRouteBootstrapBoundary>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function HostIndexRouteContent() {
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const params = useLocalSearchParams<{ serverId?: string }>();
|
const params = useLocalSearchParams<{ serverId?: string }>();
|
||||||
@@ -57,6 +49,11 @@ function HostIndexRouteContent() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const visibleWorkspaces = sessionWorkspaces ? Array.from(sessionWorkspaces.values()) : [];
|
const visibleWorkspaces = sessionWorkspaces ? Array.from(sessionWorkspaces.values()) : [];
|
||||||
|
visibleWorkspaces.sort((left, right) => {
|
||||||
|
const leftTime = left.activityAt?.getTime() ?? Number.NEGATIVE_INFINITY;
|
||||||
|
const rightTime = right.activityAt?.getTime() ?? Number.NEGATIVE_INFINITY;
|
||||||
|
return rightTime - leftTime;
|
||||||
|
});
|
||||||
|
|
||||||
const primaryAgent = visibleAgents[0];
|
const primaryAgent = visibleAgents[0];
|
||||||
if (primaryAgent?.cwd?.trim()) {
|
if (primaryAgent?.cwd?.trim()) {
|
||||||
@@ -72,11 +69,13 @@ function HostIndexRouteContent() {
|
|||||||
|
|
||||||
const primaryWorkspace = visibleWorkspaces[0];
|
const primaryWorkspace = visibleWorkspaces[0];
|
||||||
if (primaryWorkspace?.id?.trim()) {
|
if (primaryWorkspace?.id?.trim()) {
|
||||||
router.replace(buildHostWorkspaceRoute(serverId, primaryWorkspace.id.trim()));
|
router.replace(
|
||||||
|
buildHostWorkspaceOpenRoute(serverId, primaryWorkspace.id.trim(), "draft:new") as any,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
router.replace(buildHostOpenProjectRoute(serverId));
|
router.replace(buildHostOpenProjectRoute(serverId) as any);
|
||||||
}, HOST_ROOT_REDIRECT_DELAY_MS);
|
}, HOST_ROOT_REDIRECT_DELAY_MS);
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
|
|||||||
@@ -1,16 +1,7 @@
|
|||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
|
||||||
import { OpenProjectScreen } from "@/screens/open-project-screen";
|
import { OpenProjectScreen } from "@/screens/open-project-screen";
|
||||||
|
|
||||||
export default function HostOpenProjectRoute() {
|
export default function HostOpenProjectRoute() {
|
||||||
return (
|
|
||||||
<HostRouteBootstrapBoundary>
|
|
||||||
<HostOpenProjectRouteContent />
|
|
||||||
</HostRouteBootstrapBoundary>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function HostOpenProjectRouteContent() {
|
|
||||||
const params = useLocalSearchParams<{ serverId?: string }>();
|
const params = useLocalSearchParams<{ serverId?: string }>();
|
||||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,7 @@
|
|||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
|
||||||
import { SessionsScreen } from "@/screens/sessions-screen";
|
import { SessionsScreen } from "@/screens/sessions-screen";
|
||||||
|
|
||||||
export default function HostAgentsRoute() {
|
export default function HostAgentsRoute() {
|
||||||
return (
|
|
||||||
<HostRouteBootstrapBoundary>
|
|
||||||
<HostAgentsRouteContent />
|
|
||||||
</HostRouteBootstrapBoundary>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function HostAgentsRouteContent() {
|
|
||||||
const params = useLocalSearchParams<{ serverId?: string }>();
|
const params = useLocalSearchParams<{ serverId?: string }>();
|
||||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,3 @@
|
|||||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
|
||||||
import SettingsScreen from "@/screens/settings-screen";
|
import SettingsScreen from "@/screens/settings-screen";
|
||||||
|
|
||||||
export default function HostSettingsRoute() {
|
export default SettingsScreen;
|
||||||
return (
|
|
||||||
<HostRouteBootstrapBoundary>
|
|
||||||
<SettingsScreen />
|
|
||||||
</HostRouteBootstrapBoundary>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { useGlobalSearchParams, useLocalSearchParams, useRootNavigationState } from "expo-router";
|
import { useGlobalSearchParams, useLocalSearchParams, useRouter } from "expo-router";
|
||||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
|
||||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||||
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
||||||
import {
|
import {
|
||||||
|
buildHostWorkspaceRoute,
|
||||||
decodeWorkspaceIdFromPathSegment,
|
decodeWorkspaceIdFromPathSegment,
|
||||||
parseWorkspaceOpenIntent,
|
parseWorkspaceOpenIntent,
|
||||||
type WorkspaceOpenIntent,
|
type WorkspaceOpenIntent,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
import { isWeb } from "@/constants/platform";
|
|
||||||
|
|
||||||
function getParamValue(value: string | string[] | undefined): string {
|
function getParamValue(value: string | string[] | undefined): string {
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
@@ -36,17 +35,8 @@ function getOpenIntentTarget(openIntent: WorkspaceOpenIntent): WorkspaceTabTarge
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function HostWorkspaceLayout() {
|
export default function HostWorkspaceLayout() {
|
||||||
return (
|
const router = useRouter();
|
||||||
<HostRouteBootstrapBoundary>
|
|
||||||
<HostWorkspaceLayoutContent />
|
|
||||||
</HostRouteBootstrapBoundary>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function HostWorkspaceLayoutContent() {
|
|
||||||
const rootNavigationState = useRootNavigationState();
|
|
||||||
const consumedIntentRef = useRef<string | null>(null);
|
const consumedIntentRef = useRef<string | null>(null);
|
||||||
const [intentConsumed, setIntentConsumed] = useState(false);
|
|
||||||
const params = useLocalSearchParams<{
|
const params = useLocalSearchParams<{
|
||||||
serverId?: string | string[];
|
serverId?: string | string[];
|
||||||
workspaceId?: string | string[];
|
workspaceId?: string | string[];
|
||||||
@@ -65,9 +55,6 @@ function HostWorkspaceLayoutContent() {
|
|||||||
if (!openValue) {
|
if (!openValue) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!rootNavigationState?.key) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const consumptionKey = `${serverId}:${workspaceId}:${openValue}`;
|
const consumptionKey = `${serverId}:${workspaceId}:${openValue}`;
|
||||||
if (consumedIntentRef.current === consumptionKey) {
|
if (consumedIntentRef.current === consumptionKey) {
|
||||||
@@ -76,30 +63,19 @@ function HostWorkspaceLayoutContent() {
|
|||||||
consumedIntentRef.current = consumptionKey;
|
consumedIntentRef.current = consumptionKey;
|
||||||
|
|
||||||
const openIntent = parseWorkspaceOpenIntent(openValue);
|
const openIntent = parseWorkspaceOpenIntent(openValue);
|
||||||
if (openIntent) {
|
const route = openIntent
|
||||||
prepareWorkspaceTab({
|
? prepareWorkspaceTab({
|
||||||
serverId,
|
serverId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
target: getOpenIntentTarget(openIntent),
|
target: getOpenIntentTarget(openIntent),
|
||||||
pin: openIntent.kind === "agent",
|
pin: openIntent.kind === "agent",
|
||||||
});
|
})
|
||||||
}
|
: buildHostWorkspaceRoute(serverId, workspaceId);
|
||||||
|
|
||||||
// Expo Router's replace ignores query-param-only changes (findDivergentState
|
router.replace(route as any);
|
||||||
// skips search params). Strip ?open from the browser URL directly so the
|
}, [openValue, router, serverId, workspaceId]);
|
||||||
// address bar reflects the clean workspace route.
|
|
||||||
if (isWeb && typeof window !== "undefined") {
|
|
||||||
const url = new URL(window.location.href);
|
|
||||||
if (url.searchParams.has("open")) {
|
|
||||||
url.searchParams.delete("open");
|
|
||||||
window.history.replaceState(null, "", url.toString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setIntentConsumed(true);
|
if (openValue) {
|
||||||
}, [openValue, rootNavigationState?.key, serverId, workspaceId]);
|
|
||||||
|
|
||||||
if (openValue && !intentConsumed) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import { useEffect, useSyncExternalStore } from "react";
|
import { useEffect, useSyncExternalStore } from "react";
|
||||||
import { usePathname, useRouter } from "expo-router";
|
import { usePathname, useRouter } from "expo-router";
|
||||||
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
|
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
|
||||||
import { useHostRuntimeBootstrapState, useStoreReady } from "@/app/_layout";
|
import {
|
||||||
import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime";
|
useHostRuntimeBootstrapState,
|
||||||
|
useStoreReady,
|
||||||
|
} from "@/app/_layout";
|
||||||
|
import {
|
||||||
|
getHostRuntimeStore,
|
||||||
|
isHostRuntimeConnected,
|
||||||
|
useHosts,
|
||||||
|
} from "@/runtime/host-runtime";
|
||||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||||
|
|
||||||
const WELCOME_ROUTE = "/welcome";
|
const WELCOME_ROUTE = "/welcome";
|
||||||
@@ -48,8 +55,10 @@ export default function Index() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetRoute = anyOnlineServerId ? buildHostRootRoute(anyOnlineServerId) : WELCOME_ROUTE;
|
const targetRoute = anyOnlineServerId
|
||||||
router.replace(targetRoute);
|
? buildHostRootRoute(anyOnlineServerId)
|
||||||
|
: WELCOME_ROUTE;
|
||||||
|
router.replace(targetRoute as any);
|
||||||
}, [anyOnlineServerId, pathname, router, storeReady]);
|
}, [anyOnlineServerId, pathname, router, storeReady]);
|
||||||
|
|
||||||
return <StartupSplashScreen bootstrapState={bootstrapState} />;
|
return <StartupSplashScreen bootstrapState={bootstrapState} />;
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { Alert, Pressable, Text, View } from "react-native";
|
import { Alert, Platform, Pressable, Text, View } from "react-native";
|
||||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { CameraView, useCameraPermissions } from "expo-camera";
|
import { CameraView, useCameraPermissions } from "expo-camera";
|
||||||
import type { BarcodeScanningResult } from "expo-camera";
|
import type { BarcodeScanningResult } from "expo-camera";
|
||||||
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
|
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
|
||||||
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
import { NameHostModal } from "@/components/name-host-modal";
|
||||||
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
|
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
|
||||||
import { connectToDaemon } from "@/utils/test-daemon-connection";
|
import { connectToDaemon } from "@/utils/test-daemon-connection";
|
||||||
import { ConnectionOfferSchema } from "@server/shared/connection-offer";
|
import { ConnectionOfferSchema } from "@server/shared/connection-offer";
|
||||||
import { buildHostRootRoute, buildHostSettingsRoute } from "@/utils/host-routes";
|
import { buildHostRootRoute, buildHostSettingsRoute } from "@/utils/host-routes";
|
||||||
import { isWeb } from "@/constants/platform";
|
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
container: {
|
container: {
|
||||||
@@ -60,7 +61,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
position: "absolute",
|
position: "absolute",
|
||||||
width: 36,
|
width: 36,
|
||||||
height: 36,
|
height: 36,
|
||||||
borderColor: theme.colors.accent,
|
borderColor: theme.colors.palette.blue[400],
|
||||||
},
|
},
|
||||||
cornerTL: {
|
cornerTL: {
|
||||||
left: 0,
|
left: 0,
|
||||||
@@ -147,16 +148,33 @@ export default function PairScanScreen() {
|
|||||||
const sourceServerId = typeof params.sourceServerId === "string" ? params.sourceServerId : null;
|
const sourceServerId = typeof params.sourceServerId === "string" ? params.sourceServerId : null;
|
||||||
const targetServerId = typeof params.targetServerId === "string" ? params.targetServerId : null;
|
const targetServerId = typeof params.targetServerId === "string" ? params.targetServerId : null;
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
|
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl, renameHost } = useHostMutations();
|
||||||
|
|
||||||
const [permission, requestPermission] = useCameraPermissions();
|
const [permission, requestPermission] = useCameraPermissions();
|
||||||
const [isPairing, setIsPairing] = useState(false);
|
const [isPairing, setIsPairing] = useState(false);
|
||||||
const lastScannedRef = useRef<string | null>(null);
|
const lastScannedRef = useRef<string | null>(null);
|
||||||
|
const [pendingNameHost, setPendingNameHost] = useState<{
|
||||||
|
serverId: string;
|
||||||
|
hostname: string | null;
|
||||||
|
} | null>(null);
|
||||||
|
const pendingNameHostname = useSessionStore(
|
||||||
|
useCallback(
|
||||||
|
(state) => {
|
||||||
|
if (!pendingNameHost) return null;
|
||||||
|
return (
|
||||||
|
state.sessions[pendingNameHost.serverId]?.serverInfo?.hostname ??
|
||||||
|
pendingNameHost.hostname ??
|
||||||
|
null
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[pendingNameHost],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const returnToSource = useCallback(
|
const returnToSource = useCallback(
|
||||||
(serverId: string) => {
|
(serverId: string) => {
|
||||||
if (source === "onboarding") {
|
if (source === "onboarding") {
|
||||||
router.replace(buildHostRootRoute(serverId));
|
router.replace(buildHostRootRoute(serverId) as any);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (source === "editHost" && targetServerId) {
|
if (source === "editHost" && targetServerId) {
|
||||||
@@ -172,7 +190,7 @@ export default function PairScanScreen() {
|
|||||||
router.back();
|
router.back();
|
||||||
} catch {
|
} catch {
|
||||||
const settingsServerId = sourceServerId ?? serverId;
|
const settingsServerId = sourceServerId ?? serverId;
|
||||||
router.replace(buildHostSettingsRoute(settingsServerId));
|
router.replace(buildHostSettingsRoute(settingsServerId) as any);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[router, source, sourceServerId, targetServerId],
|
[router, source, sourceServerId, targetServerId],
|
||||||
@@ -191,7 +209,7 @@ export default function PairScanScreen() {
|
|||||||
router.back();
|
router.back();
|
||||||
} catch {
|
} catch {
|
||||||
if (sourceServerId) {
|
if (sourceServerId) {
|
||||||
router.replace(buildHostSettingsRoute(sourceServerId));
|
router.replace(buildHostSettingsRoute(sourceServerId) as any);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.replace("/" as any);
|
router.replace("/" as any);
|
||||||
@@ -199,13 +217,14 @@ export default function PairScanScreen() {
|
|||||||
}, [router, source, sourceServerId, targetServerId]);
|
}, [router, source, sourceServerId, targetServerId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isWeb) return;
|
if (Platform.OS === "web") return;
|
||||||
if (permission && permission.granted) return;
|
if (permission && permission.granted) return;
|
||||||
void requestPermission().catch(() => undefined);
|
void requestPermission().catch(() => undefined);
|
||||||
}, [permission, requestPermission]);
|
}, [permission, requestPermission]);
|
||||||
|
|
||||||
const handleScan = useCallback(
|
const handleScan = useCallback(
|
||||||
async (result: BarcodeScanningResult) => {
|
async (result: BarcodeScanningResult) => {
|
||||||
|
if (pendingNameHost) return;
|
||||||
if (isPairing) return;
|
if (isPairing) return;
|
||||||
const offerUrl = extractOfferUrlFromScan(result);
|
const offerUrl = extractOfferUrlFromScan(result);
|
||||||
if (!offerUrl) return;
|
if (!offerUrl) return;
|
||||||
@@ -229,7 +248,7 @@ export default function PairScanScreen() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { client, hostname } = await connectToDaemon(
|
const { client } = await connectToDaemon(
|
||||||
{
|
{
|
||||||
id: "probe",
|
id: "probe",
|
||||||
type: "relay",
|
type: "relay",
|
||||||
@@ -240,7 +259,13 @@ export default function PairScanScreen() {
|
|||||||
);
|
);
|
||||||
await client.close().catch(() => undefined);
|
await client.close().catch(() => undefined);
|
||||||
|
|
||||||
const profile = await upsertDaemonFromOfferUrl(offerUrl, hostname ?? undefined);
|
const isNewHost = !daemons.some((daemon) => daemon.serverId === offer.serverId);
|
||||||
|
const profile = await upsertDaemonFromOfferUrl(offerUrl);
|
||||||
|
|
||||||
|
if (isNewHost) {
|
||||||
|
setPendingNameHost({ serverId: profile.serverId, hostname: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
returnToSource(profile.serverId);
|
returnToSource(profile.serverId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -251,10 +276,10 @@ export default function PairScanScreen() {
|
|||||||
setIsPairing(false);
|
setIsPairing(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[daemons, isPairing, returnToSource, targetServerId, upsertDaemonFromOfferUrl],
|
[daemons, isPairing, pendingNameHost, returnToSource, targetServerId, upsertDaemonFromOfferUrl],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isWeb) {
|
if (Platform.OS === "web") {
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<View style={[styles.header, { paddingTop: insets.top + theme.spacing[2] }]}>
|
<View style={[styles.header, { paddingTop: insets.top + theme.spacing[2] }]}>
|
||||||
@@ -282,6 +307,25 @@ export default function PairScanScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
|
{pendingNameHost ? (
|
||||||
|
<NameHostModal
|
||||||
|
visible
|
||||||
|
serverId={pendingNameHost.serverId}
|
||||||
|
hostname={pendingNameHostname}
|
||||||
|
onSkip={() => {
|
||||||
|
const serverId = pendingNameHost.serverId;
|
||||||
|
setPendingNameHost(null);
|
||||||
|
returnToSource(serverId);
|
||||||
|
}}
|
||||||
|
onSave={(label) => {
|
||||||
|
const serverId = pendingNameHost.serverId;
|
||||||
|
void renameHost(serverId, label).finally(() => {
|
||||||
|
setPendingNameHost(null);
|
||||||
|
returnToSource(serverId);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<View style={[styles.header, { paddingTop: insets.top + theme.spacing[2] }]}>
|
<View style={[styles.header, { paddingTop: insets.top + theme.spacing[2] }]}>
|
||||||
<Text style={styles.headerTitle}>Scan QR</Text>
|
<Text style={styles.headerTitle}>Scan QR</Text>
|
||||||
<Pressable onPress={closeToSource}>
|
<Pressable onPress={closeToSource}>
|
||||||
@@ -315,6 +359,7 @@ export default function PairScanScreen() {
|
|||||||
<View style={[styles.corner, styles.cornerBL]} />
|
<View style={[styles.corner, styles.cornerBL]} />
|
||||||
<View style={[styles.corner, styles.cornerBR]} />
|
<View style={[styles.corner, styles.cornerBR]} />
|
||||||
</View>
|
</View>
|
||||||
|
<Text style={styles.helperText}>Point your camera at the pairing QR code.</Text>
|
||||||
{isPairing ? (
|
{isPairing ? (
|
||||||
<Text style={[styles.helperText, { color: theme.colors.foreground }]}>
|
<Text style={[styles.helperText, { color: theme.colors.foreground }]}>
|
||||||
Pairing…
|
Pairing…
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export default function LegacySettingsRoute() {
|
|||||||
if (!targetServerId) {
|
if (!targetServerId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.replace(buildHostSettingsRoute(targetServerId));
|
router.replace(buildHostSettingsRoute(targetServerId) as any);
|
||||||
}, [router, targetServerId]);
|
}, [router, targetServerId]);
|
||||||
|
|
||||||
if (!targetServerId) {
|
if (!targetServerId) {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { createLocalFileAttachmentStore } from "@/attachments/local-file-attachment-store";
|
import { createLocalFileAttachmentStore } from "@/attachments/local-file-attachment-store";
|
||||||
import { isAbsolutePath } from "@/utils/path";
|
|
||||||
|
|
||||||
export function createNativeFileAttachmentStore() {
|
export function createNativeFileAttachmentStore() {
|
||||||
return createLocalFileAttachmentStore({
|
return createLocalFileAttachmentStore({
|
||||||
@@ -9,17 +8,8 @@ export function createNativeFileAttachmentStore() {
|
|||||||
if (attachment.storageKey.startsWith("file://")) {
|
if (attachment.storageKey.startsWith("file://")) {
|
||||||
return attachment.storageKey;
|
return attachment.storageKey;
|
||||||
}
|
}
|
||||||
if (isAbsolutePath(attachment.storageKey)) {
|
if (attachment.storageKey.startsWith("/")) {
|
||||||
if (attachment.storageKey.startsWith("/")) {
|
return `file://${attachment.storageKey}`;
|
||||||
return `file://${attachment.storageKey}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// UNC paths: \\server\share -> file://server/share
|
|
||||||
if (attachment.storageKey.startsWith("\\\\")) {
|
|
||||||
return `file:${attachment.storageKey.replace(/\\/g, "/")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `file:///${attachment.storageKey.replace(/\\/g, "/")}`;
|
|
||||||
}
|
}
|
||||||
return attachment.storageKey;
|
return attachment.storageKey;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { isElectronRuntime } from "@/desktop/host";
|
import { Platform } from "react-native";
|
||||||
|
import { isDesktop } from "@/desktop/host";
|
||||||
import type { AttachmentStore } from "@/attachments/types";
|
import type { AttachmentStore } from "@/attachments/types";
|
||||||
import { isWeb } from "@/constants/platform";
|
|
||||||
|
|
||||||
let attachmentStorePromise: Promise<AttachmentStore> | null = null;
|
let attachmentStorePromise: Promise<AttachmentStore> | null = null;
|
||||||
|
|
||||||
async function createAttachmentStore(): Promise<AttachmentStore> {
|
async function createAttachmentStore(): Promise<AttachmentStore> {
|
||||||
if (isWeb) {
|
if (Platform.OS === "web") {
|
||||||
if (isElectronRuntime()) {
|
if (isDesktop()) {
|
||||||
const { createDesktopAttachmentStore } = await import(
|
const { createDesktopAttachmentStore } = await import(
|
||||||
"../desktop/attachments/desktop-attachment-store"
|
"../desktop/attachments/desktop-attachment-store"
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { pathToFileUri } from "./utils";
|
|
||||||
|
|
||||||
describe("pathToFileUri", () => {
|
|
||||||
it("converts POSIX absolute paths to file URIs", () => {
|
|
||||||
expect(pathToFileUri("/home/user/file.txt")).toBe("file:///home/user/file.txt");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("converts Windows drive-letter paths to file URIs", () => {
|
|
||||||
expect(pathToFileUri("C:\\Users\\file.txt")).toBe("file:///C:/Users/file.txt");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("converts UNC paths to host-based file URIs", () => {
|
|
||||||
expect(pathToFileUri("\\\\server\\share\\dir")).toBe("file://server/share/dir");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("passes through file URIs unchanged", () => {
|
|
||||||
expect(pathToFileUri("file:///already/uri")).toBe("file:///already/uri");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("passes through relative paths unchanged", () => {
|
|
||||||
expect(pathToFileUri("relative/path")).toBe("relative/path");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { generateMessageId } from "@/types/stream";
|
import { generateMessageId } from "@/types/stream";
|
||||||
import { isAbsolutePath } from "@/utils/path";
|
|
||||||
|
|
||||||
export function generateAttachmentId(): string {
|
export function generateAttachmentId(): string {
|
||||||
return `att_${generateMessageId()}`;
|
return `att_${generateMessageId()}`;
|
||||||
@@ -54,21 +53,10 @@ export function pathToFileUri(path: string): string {
|
|||||||
if (path.startsWith("file://")) {
|
if (path.startsWith("file://")) {
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isAbsolutePath(path)) {
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path.startsWith("/")) {
|
if (path.startsWith("/")) {
|
||||||
return `file://${path}`;
|
return `file://${path}`;
|
||||||
}
|
}
|
||||||
|
return path;
|
||||||
// UNC paths: \\server\share -> file://server/share
|
|
||||||
if (path.startsWith("\\\\")) {
|
|
||||||
return `file:${path.replace(/\\/g, "/")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `file:///${path.replace(/\\/g, "/")}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fileUriToPath(uri: string): string {
|
export function fileUriToPath(uri: string): string {
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react";
|
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
|
import { Modal, Platform, Pressable, ScrollView, Text, TextInput, View } from "react-native";
|
||||||
import type { TextInputProps } from "react-native";
|
import type { TextInputProps } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
|
||||||
import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root";
|
import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root";
|
||||||
import {
|
import {
|
||||||
BottomSheetModal,
|
BottomSheetModal,
|
||||||
@@ -14,7 +13,6 @@ import {
|
|||||||
type BottomSheetBackgroundProps,
|
type BottomSheetBackgroundProps,
|
||||||
} from "@gorhom/bottom-sheet";
|
} from "@gorhom/bottom-sheet";
|
||||||
import { X } from "lucide-react-native";
|
import { X } from "lucide-react-native";
|
||||||
import { isWeb } from "@/constants/platform";
|
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
desktopOverlay: {
|
desktopOverlay: {
|
||||||
@@ -30,8 +28,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
width: "100%",
|
width: "100%",
|
||||||
maxWidth: 520,
|
maxWidth: 520,
|
||||||
maxHeight: "85%",
|
maxHeight: "85%",
|
||||||
flexShrink: 1,
|
|
||||||
minHeight: 0,
|
|
||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surface1,
|
||||||
borderRadius: theme.borderRadius.xl,
|
borderRadius: theme.borderRadius.xl,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
@@ -58,13 +54,11 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
},
|
},
|
||||||
desktopScroll: {
|
desktopScroll: {
|
||||||
flexShrink: 1,
|
flex: 1,
|
||||||
minHeight: 0,
|
|
||||||
},
|
},
|
||||||
desktopContent: {
|
desktopContent: {
|
||||||
padding: theme.spacing[6],
|
padding: theme.spacing[6],
|
||||||
gap: theme.spacing[4],
|
gap: theme.spacing[4],
|
||||||
flexGrow: 1,
|
|
||||||
},
|
},
|
||||||
bottomSheetHandle: {
|
bottomSheetHandle: {
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
@@ -121,7 +115,7 @@ export function AdaptiveModalSheet({
|
|||||||
testID,
|
testID,
|
||||||
}: AdaptiveModalSheetProps) {
|
}: AdaptiveModalSheetProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
const sheetRef = useRef<BottomSheetModal>(null);
|
const sheetRef = useRef<BottomSheetModal>(null);
|
||||||
const dismissingForVisibilityRef = useRef(false);
|
const dismissingForVisibilityRef = useRef(false);
|
||||||
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
|
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
|
||||||
@@ -217,7 +211,7 @@ export function AdaptiveModalSheet({
|
|||||||
);
|
);
|
||||||
|
|
||||||
// On web, use portal to overlay root for consistent stacking with toasts
|
// On web, use portal to overlay root for consistent stacking with toasts
|
||||||
if (isWeb && typeof document !== "undefined") {
|
if (Platform.OS === "web" && typeof document !== "undefined") {
|
||||||
if (!visible) return null;
|
if (!visible) return null;
|
||||||
return createPortal(desktopContent, getOverlayRoot());
|
return createPortal(desktopContent, getOverlayRoot());
|
||||||
}
|
}
|
||||||
@@ -241,7 +235,7 @@ export function AdaptiveModalSheet({
|
|||||||
*/
|
*/
|
||||||
export const AdaptiveTextInput = forwardRef<TextInput, TextInputProps>(
|
export const AdaptiveTextInput = forwardRef<TextInput, TextInputProps>(
|
||||||
function AdaptiveTextInput(props, ref) {
|
function AdaptiveTextInput(props, ref) {
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return <BottomSheetTextInput ref={ref as any} {...props} />;
|
return <BottomSheetTextInput ref={ref as any} {...props} />;
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { Pressable, Text, View } from "react-native";
|
import { Pressable, Text, View, Platform } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
|
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
|
||||||
import { AdaptiveModalSheet } from "./adaptive-modal-sheet";
|
import { AdaptiveModalSheet } from "./adaptive-modal-sheet";
|
||||||
import { isNative } from "@/constants/platform";
|
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
option: {
|
option: {
|
||||||
@@ -79,7 +78,7 @@ export function AddHostMethodModal({
|
|||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
{isNative ? (
|
{Platform.OS !== "web" ? (
|
||||||
<Pressable style={styles.option} onPress={handleScan} accessibilityLabel="Scan QR code">
|
<Pressable style={styles.option} onPress={handleScan} accessibilityLabel="Scan QR code">
|
||||||
<QrCode size={18} color={theme.colors.foreground} />
|
<QrCode size={18} color={theme.colors.foreground} />
|
||||||
<View style={styles.optionBody}>
|
<View style={styles.optionBody}>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useCallback, useRef, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import { Alert, Text, TextInput, View } from "react-native";
|
import { Alert, Text, TextInput, View } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
|
||||||
import { Link2 } from "lucide-react-native";
|
import { Link2 } from "lucide-react-native";
|
||||||
import type { HostProfile } from "@/types/host-connection";
|
import type { HostProfile } from "@/types/host-connection";
|
||||||
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
|
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
|
||||||
@@ -152,7 +151,7 @@ export function AddHostModal({
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
const { upsertDirectConnection } = useHostMutations();
|
const { upsertDirectConnection } = useHostMutations();
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
|
|
||||||
const hostInputRef = useRef<TextInput>(null);
|
const hostInputRef = useRef<TextInput>(null);
|
||||||
|
|
||||||
@@ -219,7 +218,6 @@ export function AddHostModal({
|
|||||||
const profile = await upsertDirectConnection({
|
const profile = await upsertDirectConnection({
|
||||||
serverId,
|
serverId,
|
||||||
endpoint,
|
endpoint,
|
||||||
label: hostname ?? undefined,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onSaved?.({ profile, serverId, hostname, isNewHost });
|
onSaved?.({ profile, serverId, hostname, isNewHost });
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { ReactElement, ReactNode } from "react";
|
import type { ReactElement, ReactNode } from "react";
|
||||||
import { View, Text, Pressable, TextInput, ActivityIndicator } from "react-native";
|
import { View, Text, Pressable, TextInput, ActivityIndicator, Platform } from "react-native";
|
||||||
import type { StyleProp, ViewStyle, TextProps } from "react-native";
|
import type { StyleProp, ViewStyle, TextProps } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import {
|
import {
|
||||||
@@ -32,7 +32,6 @@ import type { AgentProviderDefinition } from "@server/server/agent/provider-mani
|
|||||||
import { getModeVisuals, type AgentModeIcon } from "@server/server/agent/provider-manifest";
|
import { getModeVisuals, type AgentModeIcon } from "@server/server/agent/provider-manifest";
|
||||||
import { Combobox, ComboboxItem, ComboboxEmpty } from "@/components/ui/combobox";
|
import { Combobox, ComboboxItem, ComboboxEmpty } from "@/components/ui/combobox";
|
||||||
import { baseColors } from "@/styles/theme";
|
import { baseColors } from "@/styles/theme";
|
||||||
import { isNative } from "@/constants/platform";
|
|
||||||
|
|
||||||
const MODE_ICON_MAP: Record<AgentModeIcon, typeof ShieldCheck> = {
|
const MODE_ICON_MAP: Record<AgentModeIcon, typeof ShieldCheck> = {
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
@@ -169,7 +168,7 @@ export function SelectField({
|
|||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(event: unknown) => {
|
(event: unknown) => {
|
||||||
if (isNative) return;
|
if (Platform.OS !== "web") return;
|
||||||
const key = getWebKey(event);
|
const key = getWebKey(event);
|
||||||
if (key === "Enter" || key === " ") {
|
if (key === "Enter" || key === " ") {
|
||||||
preventWebDefault(event);
|
preventWebDefault(event);
|
||||||
@@ -431,7 +430,7 @@ export function FormSelectTrigger({
|
|||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(event: unknown) => {
|
(event: unknown) => {
|
||||||
if (isNative) return;
|
if (Platform.OS !== "web") return;
|
||||||
const key = getWebKey(event);
|
const key = getWebKey(event);
|
||||||
if (key === "Enter" || key === " ") {
|
if (key === "Enter" || key === " ") {
|
||||||
preventWebDefault(event);
|
preventWebDefault(event);
|
||||||
@@ -557,11 +556,7 @@ export function AgentConfigRow({
|
|||||||
const effectiveSelectedThinkingOption =
|
const effectiveSelectedThinkingOption =
|
||||||
selectedThinkingOptionId || thinkingSelectOptions[0]?.id || "";
|
selectedThinkingOptionId || thinkingSelectOptions[0]?.id || "";
|
||||||
|
|
||||||
const selectedModeVisuals = getModeVisuals(
|
const selectedModeVisuals = getModeVisuals(selectedProvider, effectiveSelectedMode);
|
||||||
selectedProvider,
|
|
||||||
effectiveSelectedMode,
|
|
||||||
providerDefinitions,
|
|
||||||
);
|
|
||||||
const ModeIcon = MODE_ICON_MAP[selectedModeVisuals?.icon ?? "ShieldCheck"];
|
const ModeIcon = MODE_ICON_MAP[selectedModeVisuals?.icon ?? "ShieldCheck"];
|
||||||
const modeIconColor = MODE_COLOR_MAP[selectedModeVisuals?.colorTier ?? "safe"];
|
const modeIconColor = MODE_COLOR_MAP[selectedModeVisuals?.colorTier ?? "safe"];
|
||||||
|
|
||||||
@@ -778,8 +773,7 @@ export function ModelDropdown({
|
|||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const anchorRef = useRef<View>(null);
|
const anchorRef = useRef<View>(null);
|
||||||
|
|
||||||
const selectedLabel =
|
const selectedLabel = models.find((model) => model.id === selectedModel)?.label ?? selectedModel ?? "Select model";
|
||||||
models.find((model) => model.id === selectedModel)?.label ?? selectedModel ?? "Select model";
|
|
||||||
const placeholder = isLoading && models.length === 0 ? "Loading..." : "Select model";
|
const placeholder = isLoading && models.length === 0 ? "Loading..." : "Select model";
|
||||||
const helperText = error
|
const helperText = error
|
||||||
? undefined
|
? undefined
|
||||||
@@ -1450,7 +1444,11 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
borderRadius: theme.borderRadius.lg,
|
borderRadius: theme.borderRadius.lg,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: theme.colors.borderAccent,
|
borderColor: theme.colors.borderAccent,
|
||||||
...theme.shadow.md,
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.2,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 8,
|
||||||
maxHeight: 400,
|
maxHeight: 400,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,13 +10,13 @@ import {
|
|||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useCallback, useMemo, useState, type ReactElement } from "react";
|
import { useCallback, useMemo, useState, type ReactElement } from "react";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
|
||||||
import { formatTimeAgo } from "@/utils/time";
|
import { formatTimeAgo } from "@/utils/time";
|
||||||
import { shortenPath } from "@/utils/shorten-path";
|
import { shortenPath } from "@/utils/shorten-path";
|
||||||
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { Archive } from "lucide-react-native";
|
import { Archive, SquareTerminal } from "lucide-react-native";
|
||||||
|
import { getProviderIcon } from "@/components/provider-icons";
|
||||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
|
|
||||||
interface AgentListProps {
|
interface AgentListProps {
|
||||||
@@ -131,6 +131,7 @@ function SessionRow({
|
|||||||
const isSelected = selectedAgentId === agentKey;
|
const isSelected = selectedAgentId === agentKey;
|
||||||
const statusLabel = formatStatusLabel(agent.status);
|
const statusLabel = formatStatusLabel(agent.status);
|
||||||
const projectPath = shortenPath(agent.cwd);
|
const projectPath = shortenPath(agent.cwd);
|
||||||
|
const ProviderIcon = getProviderIcon(agent.provider);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -146,12 +147,21 @@ function SessionRow({
|
|||||||
>
|
>
|
||||||
<View style={styles.rowContent}>
|
<View style={styles.rowContent}>
|
||||||
<View style={styles.rowTitleRow}>
|
<View style={styles.rowTitleRow}>
|
||||||
|
<View style={styles.providerIconWrap}>
|
||||||
|
<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
|
</View>
|
||||||
<Text
|
<Text
|
||||||
style={[styles.sessionTitle, isSelected && styles.sessionTitleHighlighted]}
|
style={[styles.sessionTitle, isSelected && styles.sessionTitleHighlighted]}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
{agent.title || "New session"}
|
{agent.title || "New session"}
|
||||||
</Text>
|
</Text>
|
||||||
|
{agent.terminal ? (
|
||||||
|
<SessionBadge
|
||||||
|
label="Terminal"
|
||||||
|
icon={<SquareTerminal size={theme.fontSize.xs} color={theme.colors.foregroundMuted} />}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{agent.archivedAt ? (
|
{agent.archivedAt ? (
|
||||||
<SessionBadge
|
<SessionBadge
|
||||||
label="Archived"
|
label="Archived"
|
||||||
@@ -215,7 +225,7 @@ export function AgentList({
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null);
|
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null);
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
|
|
||||||
const actionClient = useSessionStore((state) =>
|
const actionClient = useSessionStore((state) =>
|
||||||
actionAgent?.serverId ? (state.sessions[actionAgent.serverId]?.client ?? null) : null,
|
actionAgent?.serverId ? (state.sessions[actionAgent.serverId]?.client ?? null) : null,
|
||||||
@@ -240,8 +250,9 @@ export function AgentList({
|
|||||||
workspaceId: agent.cwd,
|
workspaceId: agent.cwd,
|
||||||
target: { kind: "agent", agentId },
|
target: { kind: "agent", agentId },
|
||||||
pin: Boolean(agent.archivedAt),
|
pin: Boolean(agent.archivedAt),
|
||||||
|
requestReopen: agent.terminal && agent.status === "closed",
|
||||||
});
|
});
|
||||||
router.navigate(route);
|
router.navigate(route as any);
|
||||||
},
|
},
|
||||||
[isActionSheetVisible, onAgentSelect],
|
[isActionSheetVisible, onAgentSelect],
|
||||||
);
|
);
|
||||||
@@ -258,8 +269,7 @@ export function AgentList({
|
|||||||
if (!actionAgent || !actionClient) {
|
if (!actionAgent || !actionClient) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Timeout errors are swallowed — the daemon will still process the archive
|
void actionClient.archiveAgent(actionAgent.id);
|
||||||
void actionClient.archiveAgent(actionAgent.id).catch(() => {});
|
|
||||||
setActionAgent(null);
|
setActionAgent(null);
|
||||||
}, [actionAgent, actionClient]);
|
}, [actionAgent, actionClient]);
|
||||||
|
|
||||||
@@ -435,6 +445,11 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
flexWrap: "wrap",
|
flexWrap: "wrap",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
},
|
},
|
||||||
|
providerIconWrap: {
|
||||||
|
width: theme.iconSize.md,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
rowMetaRow: {
|
rowMetaRow: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
import { QueryClient, QueryObserver } from "@tanstack/react-query";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import { isProviderModelsQueryLoading } from "./agent-status-bar.model-loading";
|
|
||||||
|
|
||||||
describe("isProviderModelsQueryLoading", () => {
|
|
||||||
it("does not treat a disabled pending query as loading", () => {
|
|
||||||
const queryClient = new QueryClient();
|
|
||||||
const observer = new QueryObserver(queryClient, {
|
|
||||||
queryKey: ["providerModels", "server-1", "__missing_provider__"],
|
|
||||||
enabled: false,
|
|
||||||
queryFn: async () => [],
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = observer.getCurrentResult();
|
|
||||||
|
|
||||||
expect(result.isPending).toBe(true);
|
|
||||||
expect(result.isLoading).toBe(false);
|
|
||||||
expect(result.isFetching).toBe(false);
|
|
||||||
expect(isProviderModelsQueryLoading(result)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats an active fetch as loading", () => {
|
|
||||||
expect(
|
|
||||||
isProviderModelsQueryLoading({
|
|
||||||
isLoading: false,
|
|
||||||
isFetching: true,
|
|
||||||
}),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
interface ProviderModelsQueryState {
|
|
||||||
isFetching: boolean;
|
|
||||||
isLoading: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isProviderModelsQueryLoading(input: ProviderModelsQueryState): boolean {
|
|
||||||
return input.isLoading || input.isFetching;
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
getFeatureHighlightColor,
|
|
||||||
getFeatureTooltip,
|
|
||||||
getStatusSelectorHint,
|
getStatusSelectorHint,
|
||||||
normalizeModelId,
|
normalizeModelId,
|
||||||
resolveAgentModelSelection,
|
resolveAgentModelSelection,
|
||||||
@@ -15,31 +13,6 @@ describe("getStatusSelectorHint", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("feature metadata helpers", () => {
|
|
||||||
it("prefers explicit feature tooltip copy", () => {
|
|
||||||
expect(
|
|
||||||
getFeatureTooltip({
|
|
||||||
label: "Plan",
|
|
||||||
tooltip: "Toggle plan mode",
|
|
||||||
}),
|
|
||||||
).toBe("Toggle plan mode");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to the feature label when no tooltip is provided", () => {
|
|
||||||
expect(
|
|
||||||
getFeatureTooltip({
|
|
||||||
label: "Custom",
|
|
||||||
}),
|
|
||||||
).toBe("Custom");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("maps feature highlight colors by feature id", () => {
|
|
||||||
expect(getFeatureHighlightColor("fast_mode")).toBe("yellow");
|
|
||||||
expect(getFeatureHighlightColor("plan_mode")).toBe("blue");
|
|
||||||
expect(getFeatureHighlightColor("other")).toBe("default");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("normalizeModelId", () => {
|
describe("normalizeModelId", () => {
|
||||||
it("treats empty values as unset", () => {
|
it("treats empty values as unset", () => {
|
||||||
expect(normalizeModelId("")).toBeNull();
|
expect(normalizeModelId("")).toBeNull();
|
||||||
|
|||||||
@@ -1,29 +1,14 @@
|
|||||||
import { memo, useCallback, useMemo, useRef, useState } from "react";
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { View, Text, Pressable, Keyboard } from "react-native";
|
import { View, Text, Platform, Pressable, Keyboard } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { useShallow } from "zustand/shallow";
|
import { useShallow } from "zustand/shallow";
|
||||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||||
import {
|
import { Brain, ChevronDown, ShieldAlert, ShieldCheck, ShieldOff } from "lucide-react-native";
|
||||||
Brain,
|
|
||||||
ChevronDown,
|
|
||||||
ListTodo,
|
|
||||||
Settings2,
|
|
||||||
ShieldAlert,
|
|
||||||
ShieldCheck,
|
|
||||||
ShieldOff,
|
|
||||||
Zap,
|
|
||||||
} from "lucide-react-native";
|
|
||||||
import { getProviderIcon } from "@/components/provider-icons";
|
import { getProviderIcon } from "@/components/provider-icons";
|
||||||
import { CombinedModelSelector } from "@/components/combined-model-selector";
|
import { CombinedModelSelector } from "@/components/combined-model-selector";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
import { mergeProviderPreferences, useFormPreferences } from "@/hooks/use-form-preferences";
|
||||||
import { resolveProviderDefinition } from "@/utils/provider-definitions";
|
|
||||||
import {
|
|
||||||
buildFavoriteModelKey,
|
|
||||||
mergeProviderPreferences,
|
|
||||||
toggleFavoriteModel,
|
|
||||||
useFormPreferences,
|
|
||||||
} from "@/hooks/use-form-preferences";
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -34,7 +19,6 @@ import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/com
|
|||||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import type {
|
import type {
|
||||||
AgentFeature,
|
|
||||||
AgentMode,
|
AgentMode,
|
||||||
AgentModelDefinition,
|
AgentModelDefinition,
|
||||||
AgentProvider,
|
AgentProvider,
|
||||||
@@ -46,19 +30,16 @@ import {
|
|||||||
type AgentModeIcon,
|
type AgentModeIcon,
|
||||||
} from "@server/server/agent/provider-manifest";
|
} from "@server/server/agent/provider-manifest";
|
||||||
import {
|
import {
|
||||||
getFeatureHighlightColor,
|
|
||||||
getFeatureTooltip,
|
|
||||||
getStatusSelectorHint,
|
getStatusSelectorHint,
|
||||||
resolveAgentModelSelection,
|
resolveAgentModelSelection,
|
||||||
} from "@/components/agent-status-bar.utils";
|
} from "@/components/agent-status-bar.utils";
|
||||||
import { isWeb as platformIsWeb } from "@/constants/platform";
|
|
||||||
|
|
||||||
type StatusOption = {
|
type StatusOption = {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type StatusSelector = "provider" | "mode" | "model" | "thinking" | `feature-${string}`;
|
type StatusSelector = "provider" | "mode" | "model" | "thinking";
|
||||||
|
|
||||||
type ControlledAgentStatusBarProps = {
|
type ControlledAgentStatusBarProps = {
|
||||||
provider: string;
|
provider: string;
|
||||||
@@ -71,21 +52,11 @@ type ControlledAgentStatusBarProps = {
|
|||||||
modelOptions?: StatusOption[];
|
modelOptions?: StatusOption[];
|
||||||
selectedModelId?: string;
|
selectedModelId?: string;
|
||||||
onSelectModel?: (modelId: string) => void;
|
onSelectModel?: (modelId: string) => void;
|
||||||
onSelectProviderAndModel?: (provider: string, modelId: string) => void;
|
|
||||||
thinkingOptions?: StatusOption[];
|
thinkingOptions?: StatusOption[];
|
||||||
selectedThinkingOptionId?: string;
|
selectedThinkingOptionId?: string;
|
||||||
onSelectThinkingOption?: (thinkingOptionId: string) => void;
|
onSelectThinkingOption?: (thinkingOptionId: string) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
isModelLoading?: boolean;
|
isModelLoading?: boolean;
|
||||||
providerDefinitions: AgentProviderDefinition[];
|
|
||||||
allProviderModels?: Map<string, AgentModelDefinition[]>;
|
|
||||||
canSelectModelProvider?: (providerId: string) => boolean;
|
|
||||||
favoriteKeys?: Set<string>;
|
|
||||||
onToggleFavoriteModel?: (provider: string, modelId: string) => void;
|
|
||||||
features?: AgentFeature[];
|
|
||||||
onSetFeature?: (featureId: string, value: unknown) => void;
|
|
||||||
onDropdownClose?: () => void;
|
|
||||||
onModelSelectorOpen?: () => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface DraftAgentStatusBarProps {
|
export interface DraftAgentStatusBarProps {
|
||||||
@@ -105,17 +76,12 @@ export interface DraftAgentStatusBarProps {
|
|||||||
thinkingOptions: NonNullable<AgentModelDefinition["thinkingOptions"]>;
|
thinkingOptions: NonNullable<AgentModelDefinition["thinkingOptions"]>;
|
||||||
selectedThinkingOptionId: string;
|
selectedThinkingOptionId: string;
|
||||||
onSelectThinkingOption: (thinkingOptionId: string) => void;
|
onSelectThinkingOption: (thinkingOptionId: string) => void;
|
||||||
features?: AgentFeature[];
|
|
||||||
onSetFeature?: (featureId: string, value: unknown) => void;
|
|
||||||
onDropdownClose?: () => void;
|
|
||||||
onModelSelectorOpen?: () => void;
|
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AgentStatusBarProps {
|
interface AgentStatusBarProps {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
onDropdownClose?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function findOptionLabel(
|
function findOptionLabel(
|
||||||
@@ -130,38 +96,6 @@ function findOptionLabel(
|
|||||||
return selected?.label ?? fallback;
|
return selected?.label ?? fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FEATURE_ICONS: Record<string, typeof Zap> = {
|
|
||||||
"list-todo": ListTodo,
|
|
||||||
zap: Zap,
|
|
||||||
};
|
|
||||||
|
|
||||||
function getFeatureIcon(icon?: string) {
|
|
||||||
return (icon && FEATURE_ICONS[icon]) || Settings2;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFeatureIconColor(
|
|
||||||
featureId: string,
|
|
||||||
enabled: boolean,
|
|
||||||
palette: {
|
|
||||||
blue: { 400: string };
|
|
||||||
yellow: { 400: string };
|
|
||||||
},
|
|
||||||
foregroundMuted: string,
|
|
||||||
): string {
|
|
||||||
if (!enabled) {
|
|
||||||
return foregroundMuted;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (getFeatureHighlightColor(featureId)) {
|
|
||||||
case "blue":
|
|
||||||
return palette.blue[400];
|
|
||||||
case "yellow":
|
|
||||||
return palette.yellow[400];
|
|
||||||
default:
|
|
||||||
return foregroundMuted;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const MODE_ICONS = {
|
const MODE_ICONS = {
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
@@ -202,23 +136,14 @@ function ControlledStatusBar({
|
|||||||
modelOptions,
|
modelOptions,
|
||||||
selectedModelId,
|
selectedModelId,
|
||||||
onSelectModel,
|
onSelectModel,
|
||||||
onSelectProviderAndModel,
|
|
||||||
thinkingOptions,
|
thinkingOptions,
|
||||||
selectedThinkingOptionId,
|
selectedThinkingOptionId,
|
||||||
onSelectThinkingOption,
|
onSelectThinkingOption,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
isModelLoading = false,
|
isModelLoading = false,
|
||||||
providerDefinitions,
|
|
||||||
allProviderModels,
|
|
||||||
canSelectModelProvider,
|
|
||||||
favoriteKeys = new Set<string>(),
|
|
||||||
onToggleFavoriteModel,
|
|
||||||
features,
|
|
||||||
onSetFeature,
|
|
||||||
onDropdownClose,
|
|
||||||
onModelSelectorOpen,
|
|
||||||
}: ControlledAgentStatusBarProps) {
|
}: ControlledAgentStatusBarProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
|
const isWeb = Platform.OS === "web";
|
||||||
const [prefsOpen, setPrefsOpen] = useState(false);
|
const [prefsOpen, setPrefsOpen] = useState(false);
|
||||||
const [openSelector, setOpenSelector] = useState<StatusSelector | null>(null);
|
const [openSelector, setOpenSelector] = useState<StatusSelector | null>(null);
|
||||||
|
|
||||||
@@ -248,9 +173,7 @@ function ControlledStatusBar({
|
|||||||
thinkingOptions?.[0]?.label ?? "Unknown",
|
thinkingOptions?.[0]?.label ?? "Unknown",
|
||||||
);
|
);
|
||||||
|
|
||||||
const modeVisuals = selectedModeId
|
const modeVisuals = selectedModeId ? getModeVisuals(provider, selectedModeId) : undefined;
|
||||||
? getModeVisuals(provider, selectedModeId, providerDefinitions)
|
|
||||||
: undefined;
|
|
||||||
const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null;
|
const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null;
|
||||||
const modeIconColor = getModeIconColor(modeVisuals?.colorTier, theme.colors.palette);
|
const modeIconColor = getModeIconColor(modeVisuals?.colorTier, theme.colors.palette);
|
||||||
const ProviderIcon = getProviderIcon(provider);
|
const ProviderIcon = getProviderIcon(provider);
|
||||||
@@ -259,14 +182,13 @@ function ControlledStatusBar({
|
|||||||
Boolean(providerOptions?.length) ||
|
Boolean(providerOptions?.length) ||
|
||||||
Boolean(modeOptions?.length) ||
|
Boolean(modeOptions?.length) ||
|
||||||
canSelectModel ||
|
canSelectModel ||
|
||||||
Boolean(thinkingOptions?.length) ||
|
Boolean(thinkingOptions?.length);
|
||||||
Boolean(features?.length);
|
|
||||||
|
|
||||||
if (!hasAnyControl) {
|
if (!hasAnyControl) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const modelDisabled = disabled;
|
const modelDisabled = disabled || isModelLoading || !modelOptions || modelOptions.length === 0;
|
||||||
|
|
||||||
const SEARCH_THRESHOLD = 6;
|
const SEARCH_THRESHOLD = 6;
|
||||||
|
|
||||||
@@ -282,25 +204,6 @@ function ControlledStatusBar({
|
|||||||
() => (modelOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
|
() => (modelOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
|
||||||
[modelOptions],
|
[modelOptions],
|
||||||
);
|
);
|
||||||
const fallbackAllProviderModels = useMemo(() => {
|
|
||||||
const map = new Map<string, AgentModelDefinition[]>();
|
|
||||||
if (!modelOptions || modelOptions.length === 0) {
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
map.set(
|
|
||||||
provider,
|
|
||||||
modelOptions.map((option) => ({
|
|
||||||
provider: provider as AgentProvider,
|
|
||||||
id: option.id,
|
|
||||||
label: option.label,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
return map;
|
|
||||||
}, [modelOptions, provider]);
|
|
||||||
const effectiveProviderDefinitions = providerDefinitions;
|
|
||||||
const effectiveAllProviderModels = allProviderModels ?? fallbackAllProviderModels;
|
|
||||||
const canSelectProviderInModelMenu = canSelectModelProvider ?? (() => true);
|
|
||||||
const comboboxThinkingOptions = useMemo<ComboboxOption[]>(
|
const comboboxThinkingOptions = useMemo<ComboboxOption[]>(
|
||||||
() => (thinkingOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
|
() => (thinkingOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
|
||||||
[thinkingOptions],
|
[thinkingOptions],
|
||||||
@@ -318,7 +221,7 @@ function ControlledStatusBar({
|
|||||||
active: boolean;
|
active: boolean;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
const visuals = getModeVisuals(provider, option.id, providerDefinitions);
|
const visuals = getModeVisuals(provider, option.id);
|
||||||
const IconComponent = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck;
|
const IconComponent = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck;
|
||||||
return (
|
return (
|
||||||
<ComboboxItem
|
<ComboboxItem
|
||||||
@@ -330,17 +233,14 @@ function ControlledStatusBar({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[provider, providerDefinitions, theme.colors.foreground],
|
[provider, theme.colors.foreground],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleOpenChange = useCallback(
|
const handleOpenChange = useCallback(
|
||||||
(selector: StatusSelector) => (nextOpen: boolean) => {
|
(selector: StatusSelector) => (nextOpen: boolean) => {
|
||||||
setOpenSelector(nextOpen ? selector : null);
|
setOpenSelector(nextOpen ? selector : null);
|
||||||
if (!nextOpen) {
|
|
||||||
onDropdownClose?.();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[onDropdownClose],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSelectorPress = useCallback(
|
const handleSelectorPress = useCallback(
|
||||||
@@ -352,7 +252,7 @@ function ControlledStatusBar({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
{platformIsWeb ? (
|
{isWeb ? (
|
||||||
<>
|
<>
|
||||||
{providerOptions && providerOptions.length > 0 ? (
|
{providerOptions && providerOptions.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
@@ -388,38 +288,49 @@ function ControlledStatusBar({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{canSelectModel ? (
|
{canSelectModel ? (
|
||||||
<Tooltip
|
<>
|
||||||
key={`model-${displayModel}`}
|
<Tooltip
|
||||||
delayDuration={0}
|
key={`model-${openSelector === "model" ? "open" : "closed"}`}
|
||||||
enabledOnDesktop
|
delayDuration={0}
|
||||||
enabledOnMobile={false}
|
enabledOnDesktop
|
||||||
>
|
enabledOnMobile={false}
|
||||||
<TooltipTrigger asChild triggerRefProp="ref">
|
>
|
||||||
<View>
|
<TooltipTrigger asChild triggerRefProp="ref">
|
||||||
<CombinedModelSelector
|
<Pressable
|
||||||
providerDefinitions={effectiveProviderDefinitions}
|
ref={modelAnchorRef}
|
||||||
allProviderModels={effectiveAllProviderModels}
|
collapsable={false}
|
||||||
selectedProvider={provider}
|
|
||||||
selectedModel={selectedModelId ?? ""}
|
|
||||||
canSelectProvider={canSelectProviderInModelMenu}
|
|
||||||
onSelect={(selectedProviderId, modelId) => {
|
|
||||||
if (selectedProviderId === provider) {
|
|
||||||
onSelectModel?.(modelId);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
favoriteKeys={favoriteKeys}
|
|
||||||
onToggleFavorite={onToggleFavoriteModel}
|
|
||||||
isLoading={isModelLoading}
|
|
||||||
disabled={modelDisabled}
|
disabled={modelDisabled}
|
||||||
onOpen={onModelSelectorOpen}
|
onPress={() => handleSelectorPress("model")}
|
||||||
onClose={onDropdownClose}
|
style={({ pressed, hovered }) => [
|
||||||
/>
|
styles.modeBadge,
|
||||||
</View>
|
hovered && styles.modeBadgeHovered,
|
||||||
</TooltipTrigger>
|
(pressed || openSelector === "model") && styles.modeBadgePressed,
|
||||||
<TooltipContent side="top" align="center" offset={8}>
|
modelDisabled && styles.disabledBadge,
|
||||||
<Text style={styles.tooltipText}>{getStatusSelectorHint("model")}</Text>
|
]}
|
||||||
</TooltipContent>
|
accessibilityRole="button"
|
||||||
</Tooltip>
|
accessibilityLabel="Select agent model"
|
||||||
|
testID="agent-model-selector"
|
||||||
|
>
|
||||||
|
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
|
<Text style={styles.modeBadgeText}>{displayModel}</Text>
|
||||||
|
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
|
</Pressable>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" align="center" offset={8}>
|
||||||
|
<Text style={styles.tooltipText}>{getStatusSelectorHint("model")}</Text>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Combobox
|
||||||
|
options={comboboxModelOptions}
|
||||||
|
value={selectedModelId ?? ""}
|
||||||
|
onSelect={(id) => onSelectModel?.(id)}
|
||||||
|
searchable={comboboxModelOptions.length > SEARCH_THRESHOLD}
|
||||||
|
open={openSelector === "model"}
|
||||||
|
onOpenChange={handleOpenChange("model")}
|
||||||
|
anchorRef={modelAnchorRef}
|
||||||
|
desktopPlacement="top-start"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{thinkingOptions && thinkingOptions.length > 0 ? (
|
{thinkingOptions && thinkingOptions.length > 0 ? (
|
||||||
@@ -516,105 +427,6 @@ function ControlledStatusBar({
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{features?.map((feature) => {
|
|
||||||
if (feature.type === "toggle") {
|
|
||||||
const FeatureIcon = getFeatureIcon(feature.icon);
|
|
||||||
return (
|
|
||||||
<Tooltip
|
|
||||||
key={`feature-${feature.id}`}
|
|
||||||
delayDuration={0}
|
|
||||||
enabledOnDesktop
|
|
||||||
enabledOnMobile={false}
|
|
||||||
>
|
|
||||||
<TooltipTrigger asChild triggerRefProp="ref">
|
|
||||||
<Pressable
|
|
||||||
disabled={disabled}
|
|
||||||
onPress={() => onSetFeature?.(feature.id, !feature.value)}
|
|
||||||
style={({ pressed, hovered }) => [
|
|
||||||
styles.modeIconBadge,
|
|
||||||
hovered && styles.modeBadgeHovered,
|
|
||||||
pressed && styles.modeBadgePressed,
|
|
||||||
disabled && styles.disabledBadge,
|
|
||||||
]}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={getFeatureTooltip(feature)}
|
|
||||||
testID={`agent-feature-${feature.id}`}
|
|
||||||
>
|
|
||||||
<FeatureIcon
|
|
||||||
size={theme.iconSize.md}
|
|
||||||
color={getFeatureIconColor(
|
|
||||||
feature.id,
|
|
||||||
feature.value,
|
|
||||||
theme.colors.palette,
|
|
||||||
theme.colors.foregroundMuted,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top" align="center" offset={8}>
|
|
||||||
<Text style={styles.tooltipText}>{getFeatureTooltip(feature)}</Text>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (feature.type === "select") {
|
|
||||||
const FeatureIcon = getFeatureIcon(feature.icon);
|
|
||||||
const selectedOption = feature.options.find((o) => o.id === feature.value);
|
|
||||||
return (
|
|
||||||
<DropdownMenu
|
|
||||||
key={`feature-${feature.id}`}
|
|
||||||
open={openSelector === `feature-${feature.id}`}
|
|
||||||
onOpenChange={handleOpenChange(`feature-${feature.id}`)}
|
|
||||||
>
|
|
||||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
|
||||||
<TooltipTrigger asChild triggerRefProp="ref">
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
disabled={disabled}
|
|
||||||
style={({ pressed, hovered }) => [
|
|
||||||
styles.modeBadge,
|
|
||||||
hovered && styles.modeBadgeHovered,
|
|
||||||
(pressed || openSelector === `feature-${feature.id}`) &&
|
|
||||||
styles.modeBadgePressed,
|
|
||||||
disabled && styles.disabledBadge,
|
|
||||||
]}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={getFeatureTooltip(feature)}
|
|
||||||
testID={`agent-feature-${feature.id}`}
|
|
||||||
>
|
|
||||||
<FeatureIcon
|
|
||||||
size={theme.iconSize.md}
|
|
||||||
color={theme.colors.foregroundMuted}
|
|
||||||
/>
|
|
||||||
<Text style={styles.modeBadgeText}>
|
|
||||||
{selectedOption?.label ?? feature.label}
|
|
||||||
</Text>
|
|
||||||
<ChevronDown
|
|
||||||
size={theme.iconSize.sm}
|
|
||||||
color={theme.colors.foregroundMuted}
|
|
||||||
/>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top" align="center" offset={8}>
|
|
||||||
<Text style={styles.tooltipText}>{getFeatureTooltip(feature)}</Text>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<DropdownMenuContent side="top" align="start">
|
|
||||||
{feature.options.map((option) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={option.id}
|
|
||||||
selected={option.id === feature.value}
|
|
||||||
onSelect={() => onSetFeature?.(feature.id, option.id)}
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})}
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -641,42 +453,73 @@ function ControlledStatusBar({
|
|||||||
stackBehavior="replace"
|
stackBehavior="replace"
|
||||||
testID="agent-preferences-sheet"
|
testID="agent-preferences-sheet"
|
||||||
>
|
>
|
||||||
|
{providerOptions && providerOptions.length > 0 ? (
|
||||||
|
<View style={styles.sheetSection}>
|
||||||
|
<DropdownMenu
|
||||||
|
open={openSelector === "provider"}
|
||||||
|
onOpenChange={handleOpenChange("provider")}
|
||||||
|
>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
disabled={disabled || !canSelectProvider}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.sheetSelect,
|
||||||
|
pressed && styles.sheetSelectPressed,
|
||||||
|
(disabled || !canSelectProvider) && styles.disabledSheetSelect,
|
||||||
|
]}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Select agent provider"
|
||||||
|
testID="agent-preferences-provider"
|
||||||
|
>
|
||||||
|
<Text style={styles.sheetSelectText}>{displayProvider}</Text>
|
||||||
|
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent side="top" align="start">
|
||||||
|
{providerOptions.map((provider) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={provider.id}
|
||||||
|
selected={provider.id === selectedProviderId}
|
||||||
|
onSelect={() => onSelectProvider?.(provider.id)}
|
||||||
|
>
|
||||||
|
{provider.label}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{canSelectModel ? (
|
{canSelectModel ? (
|
||||||
<View style={styles.sheetSection}>
|
<View style={styles.sheetSection}>
|
||||||
<CombinedModelSelector
|
<DropdownMenu
|
||||||
providerDefinitions={effectiveProviderDefinitions}
|
open={openSelector === "model"}
|
||||||
allProviderModels={effectiveAllProviderModels}
|
onOpenChange={handleOpenChange("model")}
|
||||||
selectedProvider={provider}
|
>
|
||||||
selectedModel={selectedModelId ?? ""}
|
<DropdownMenuTrigger
|
||||||
canSelectProvider={canSelectProviderInModelMenu}
|
disabled={modelDisabled}
|
||||||
onSelect={(selectedProviderId, modelId) => {
|
style={({ pressed }) => [
|
||||||
if (onSelectProviderAndModel) {
|
styles.sheetSelect,
|
||||||
onSelectProviderAndModel(selectedProviderId, modelId);
|
pressed && styles.sheetSelectPressed,
|
||||||
} else {
|
modelDisabled && styles.disabledSheetSelect,
|
||||||
if (selectedProviderId !== provider) {
|
]}
|
||||||
onSelectProvider?.(selectedProviderId);
|
accessibilityRole="button"
|
||||||
}
|
accessibilityLabel="Select agent model"
|
||||||
onSelectModel?.(modelId);
|
testID="agent-preferences-model"
|
||||||
}
|
>
|
||||||
}}
|
<Text style={styles.sheetSelectText}>{displayModel}</Text>
|
||||||
favoriteKeys={favoriteKeys}
|
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
onToggleFavorite={onToggleFavoriteModel}
|
</DropdownMenuTrigger>
|
||||||
isLoading={isModelLoading}
|
<DropdownMenuContent side="top" align="start">
|
||||||
disabled={modelDisabled}
|
{(modelOptions ?? []).map((model) => (
|
||||||
onOpen={onModelSelectorOpen}
|
<DropdownMenuItem
|
||||||
onClose={onDropdownClose}
|
key={model.id}
|
||||||
renderTrigger={({ selectedModelLabel }) => (
|
selected={model.id === selectedModelId}
|
||||||
<View
|
onSelect={() => onSelectModel?.(model.id)}
|
||||||
style={[styles.sheetSelect, modelDisabled && styles.disabledSheetSelect]}
|
>
|
||||||
pointerEvents="none"
|
{model.label}
|
||||||
testID="agent-preferences-model"
|
</DropdownMenuItem>
|
||||||
>
|
))}
|
||||||
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
</DropdownMenuContent>
|
||||||
<Text style={styles.sheetSelectText}>{selectedModelLabel}</Text>
|
</DropdownMenu>
|
||||||
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -697,7 +540,6 @@ function ControlledStatusBar({
|
|||||||
accessibilityLabel="Select thinking option"
|
accessibilityLabel="Select thinking option"
|
||||||
testID="agent-preferences-thinking"
|
testID="agent-preferences-thinking"
|
||||||
>
|
>
|
||||||
<Brain size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
|
||||||
<Text style={styles.sheetSelectText}>{displayThinking}</Text>
|
<Text style={styles.sheetSelectText}>{displayThinking}</Text>
|
||||||
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
@@ -741,7 +583,7 @@ function ControlledStatusBar({
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent side="top" align="start">
|
<DropdownMenuContent side="top" align="start">
|
||||||
{modeOptions.map((mode) => {
|
{modeOptions.map((mode) => {
|
||||||
const visuals = getModeVisuals(provider, mode.id, providerDefinitions);
|
const visuals = getModeVisuals(provider, mode.id);
|
||||||
const Icon = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck;
|
const Icon = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck;
|
||||||
return (
|
return (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
@@ -758,83 +600,6 @@ function ControlledStatusBar({
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{features?.map((feature) => {
|
|
||||||
if (feature.type === "toggle") {
|
|
||||||
const FeatureIcon = getFeatureIcon(feature.icon);
|
|
||||||
return (
|
|
||||||
<View key={`feature-${feature.id}`} style={styles.sheetSection}>
|
|
||||||
<Pressable
|
|
||||||
disabled={disabled}
|
|
||||||
onPress={() => onSetFeature?.(feature.id, !feature.value)}
|
|
||||||
style={({ pressed }) => [
|
|
||||||
styles.sheetSelect,
|
|
||||||
pressed && styles.sheetSelectPressed,
|
|
||||||
disabled && styles.disabledSheetSelect,
|
|
||||||
]}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={getFeatureTooltip(feature)}
|
|
||||||
testID={`agent-feature-${feature.id}`}
|
|
||||||
>
|
|
||||||
<FeatureIcon
|
|
||||||
size={theme.iconSize.md}
|
|
||||||
color={getFeatureIconColor(
|
|
||||||
feature.id,
|
|
||||||
feature.value,
|
|
||||||
theme.colors.palette,
|
|
||||||
theme.colors.foregroundMuted,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Text style={styles.sheetSelectText}>{feature.label}</Text>
|
|
||||||
<Text style={styles.modeBadgeText}>{feature.value ? "On" : "Off"}</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (feature.type === "select") {
|
|
||||||
const selectedOption = feature.options.find((o) => o.id === feature.value);
|
|
||||||
return (
|
|
||||||
<View key={`feature-${feature.id}`} style={styles.sheetSection}>
|
|
||||||
<DropdownMenu
|
|
||||||
open={openSelector === `feature-${feature.id}`}
|
|
||||||
onOpenChange={handleOpenChange(`feature-${feature.id}`)}
|
|
||||||
>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
disabled={disabled}
|
|
||||||
style={({ pressed }) => [
|
|
||||||
styles.sheetSelect,
|
|
||||||
pressed && styles.sheetSelectPressed,
|
|
||||||
disabled && styles.disabledSheetSelect,
|
|
||||||
]}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={getFeatureTooltip(feature)}
|
|
||||||
testID={`agent-feature-${feature.id}`}
|
|
||||||
>
|
|
||||||
<Text style={styles.sheetSelectText}>
|
|
||||||
{selectedOption?.label ?? feature.label}
|
|
||||||
</Text>
|
|
||||||
<ChevronDown
|
|
||||||
size={theme.iconSize.md}
|
|
||||||
color={theme.colors.foregroundMuted}
|
|
||||||
/>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent side="top" align="start">
|
|
||||||
{feature.options.map((option) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={option.id}
|
|
||||||
selected={option.id === feature.value}
|
|
||||||
onSelect={() => onSetFeature?.(feature.id, option.id)}
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})}
|
|
||||||
</AdaptiveModalSheet>
|
</AdaptiveModalSheet>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -844,11 +609,7 @@ function ControlledStatusBar({
|
|||||||
|
|
||||||
const EMPTY_MODES: AgentMode[] = [];
|
const EMPTY_MODES: AgentMode[] = [];
|
||||||
|
|
||||||
export const AgentStatusBar = memo(function AgentStatusBar({
|
export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
||||||
agentId,
|
|
||||||
serverId,
|
|
||||||
onDropdownClose,
|
|
||||||
}: AgentStatusBarProps) {
|
|
||||||
const { preferences, updatePreferences } = useFormPreferences();
|
const { preferences, updatePreferences } = useFormPreferences();
|
||||||
const agent = useSessionStore(
|
const agent = useSessionStore(
|
||||||
useShallow((state) => {
|
useShallow((state) => {
|
||||||
@@ -860,9 +621,7 @@ export const AgentStatusBar = memo(function AgentStatusBar({
|
|||||||
currentModeId: currentAgent.currentModeId,
|
currentModeId: currentAgent.currentModeId,
|
||||||
runtimeModelId: currentAgent.runtimeInfo?.model ?? null,
|
runtimeModelId: currentAgent.runtimeInfo?.model ?? null,
|
||||||
model: currentAgent.model,
|
model: currentAgent.model,
|
||||||
features: currentAgent.features,
|
|
||||||
thinkingOptionId: currentAgent.thinkingOptionId,
|
thinkingOptionId: currentAgent.thinkingOptionId,
|
||||||
lastUsage: currentAgent.lastUsage,
|
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
}),
|
}),
|
||||||
@@ -874,37 +633,28 @@ export const AgentStatusBar = memo(function AgentStatusBar({
|
|||||||
);
|
);
|
||||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||||
|
|
||||||
const {
|
const modelsQuery = useQuery({
|
||||||
entries: snapshotEntries,
|
queryKey: [
|
||||||
isLoading: snapshotIsLoading,
|
"providerModels",
|
||||||
isFetching: snapshotIsFetching,
|
serverId,
|
||||||
invalidate: invalidateSnapshot,
|
agent?.provider ?? "__missing_provider__",
|
||||||
} = useProvidersSnapshot(serverId);
|
agent?.cwd ?? "__missing_cwd__",
|
||||||
|
],
|
||||||
|
enabled: Boolean(client && agent?.provider),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!client || !agent) {
|
||||||
|
throw new Error("Daemon client unavailable");
|
||||||
|
}
|
||||||
|
const payload = await client.listProviderModels(agent.provider, { cwd: agent.cwd });
|
||||||
|
if (payload.error) {
|
||||||
|
throw new Error(payload.error);
|
||||||
|
}
|
||||||
|
return payload.models ?? [];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const snapshotModels = useMemo(() => {
|
const models = modelsQuery.data ?? null;
|
||||||
if (!snapshotEntries || !agent?.provider) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const entry = snapshotEntries.find((e) => e.provider === agent.provider);
|
|
||||||
return entry?.models ?? null;
|
|
||||||
}, [snapshotEntries, agent?.provider]);
|
|
||||||
|
|
||||||
const models = snapshotModels;
|
|
||||||
|
|
||||||
const agentProviderDefinitions = useMemo(() => {
|
|
||||||
const definition = agent?.provider
|
|
||||||
? resolveProviderDefinition(agent.provider, snapshotEntries)
|
|
||||||
: undefined;
|
|
||||||
return definition ? [definition] : [];
|
|
||||||
}, [agent?.provider, snapshotEntries]);
|
|
||||||
|
|
||||||
const agentProviderModels = useMemo(() => {
|
|
||||||
const map = new Map<string, AgentModelDefinition[]>();
|
|
||||||
if (agent?.provider && snapshotModels) {
|
|
||||||
map.set(agent.provider, snapshotModels);
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}, [agent?.provider, snapshotModels]);
|
|
||||||
|
|
||||||
const displayMode =
|
const displayMode =
|
||||||
availableModes.find((mode) => mode.id === agent?.currentModeId)?.label ||
|
availableModes.find((mode) => mode.id === agent?.currentModeId)?.label ||
|
||||||
@@ -928,13 +678,6 @@ export const AgentStatusBar = memo(function AgentStatusBar({
|
|||||||
const modelOptions = useMemo<StatusOption[]>(() => {
|
const modelOptions = useMemo<StatusOption[]>(() => {
|
||||||
return (models ?? []).map((model) => ({ id: model.id, label: model.label }));
|
return (models ?? []).map((model) => ({ id: model.id, label: model.label }));
|
||||||
}, [models]);
|
}, [models]);
|
||||||
const favoriteKeys = useMemo(
|
|
||||||
() =>
|
|
||||||
new Set(
|
|
||||||
(preferences.favoriteModels ?? []).map((favorite) => buildFavoriteModelKey(favorite)),
|
|
||||||
),
|
|
||||||
[preferences.favoriteModels],
|
|
||||||
);
|
|
||||||
|
|
||||||
const thinkingOptions = useMemo<StatusOption[]>(() => {
|
const thinkingOptions = useMemo<StatusOption[]>(() => {
|
||||||
return (modelSelection.thinkingOptions ?? []).map((option) => ({
|
return (modelSelection.thinkingOptions ?? []).map((option) => ({
|
||||||
@@ -951,13 +694,9 @@ export const AgentStatusBar = memo(function AgentStatusBar({
|
|||||||
<ControlledStatusBar
|
<ControlledStatusBar
|
||||||
provider={agent.provider}
|
provider={agent.provider}
|
||||||
modeOptions={
|
modeOptions={
|
||||||
modeOptions.length > 0
|
modeOptions.length > 0 ? modeOptions : [{ id: agent.currentModeId ?? "", label: displayMode }]
|
||||||
? modeOptions
|
|
||||||
: [{ id: agent.currentModeId ?? "", label: displayMode }]
|
|
||||||
}
|
}
|
||||||
selectedModeId={agent.currentModeId ?? undefined}
|
selectedModeId={agent.currentModeId ?? undefined}
|
||||||
providerDefinitions={agentProviderDefinitions}
|
|
||||||
allProviderModels={agentProviderModels}
|
|
||||||
onSelectMode={(modeId) => {
|
onSelectMode={(modeId) => {
|
||||||
if (!client) {
|
if (!client) {
|
||||||
return;
|
return;
|
||||||
@@ -972,9 +711,9 @@ export const AgentStatusBar = memo(function AgentStatusBar({
|
|||||||
if (!client) {
|
if (!client) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void updatePreferences((current) =>
|
void updatePreferences(
|
||||||
mergeProviderPreferences({
|
mergeProviderPreferences({
|
||||||
preferences: current,
|
preferences,
|
||||||
provider: agent.provider,
|
provider: agent.provider,
|
||||||
updates: {
|
updates: {
|
||||||
model: modelId,
|
model: modelId,
|
||||||
@@ -987,14 +726,6 @@ export const AgentStatusBar = memo(function AgentStatusBar({
|
|||||||
console.warn("[AgentStatusBar] setAgentModel failed", error);
|
console.warn("[AgentStatusBar] setAgentModel failed", error);
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
favoriteKeys={favoriteKeys}
|
|
||||||
onToggleFavoriteModel={(provider, modelId) => {
|
|
||||||
void updatePreferences((current) =>
|
|
||||||
toggleFavoriteModel({ preferences: current, provider, modelId }),
|
|
||||||
).catch((error) => {
|
|
||||||
console.warn("[AgentStatusBar] toggle favorite model failed", error);
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
thinkingOptions={thinkingOptions.length > 1 ? thinkingOptions : undefined}
|
thinkingOptions={thinkingOptions.length > 1 ? thinkingOptions : undefined}
|
||||||
selectedThinkingOptionId={modelSelection.selectedThinkingId ?? undefined}
|
selectedThinkingOptionId={modelSelection.selectedThinkingId ?? undefined}
|
||||||
onSelectThinkingOption={(thinkingOptionId) => {
|
onSelectThinkingOption={(thinkingOptionId) => {
|
||||||
@@ -1003,9 +734,9 @@ export const AgentStatusBar = memo(function AgentStatusBar({
|
|||||||
}
|
}
|
||||||
const activeModelId = modelSelection.activeModelId;
|
const activeModelId = modelSelection.activeModelId;
|
||||||
if (activeModelId) {
|
if (activeModelId) {
|
||||||
void updatePreferences((current) =>
|
void updatePreferences(
|
||||||
mergeProviderPreferences({
|
mergeProviderPreferences({
|
||||||
preferences: current,
|
preferences,
|
||||||
provider: agent.provider,
|
provider: agent.provider,
|
||||||
updates: {
|
updates: {
|
||||||
model: activeModelId,
|
model: activeModelId,
|
||||||
@@ -1022,35 +753,11 @@ export const AgentStatusBar = memo(function AgentStatusBar({
|
|||||||
console.warn("[AgentStatusBar] setAgentThinkingOption failed", error);
|
console.warn("[AgentStatusBar] setAgentThinkingOption failed", error);
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
features={agent.features}
|
isModelLoading={modelsQuery.isPending || modelsQuery.isFetching}
|
||||||
onSetFeature={(featureId, value) => {
|
|
||||||
if (!client) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void updatePreferences((current) =>
|
|
||||||
mergeProviderPreferences({
|
|
||||||
preferences: current,
|
|
||||||
provider: agent.provider,
|
|
||||||
updates: {
|
|
||||||
featureValues: {
|
|
||||||
[featureId]: value,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
).catch((error) => {
|
|
||||||
console.warn("[AgentStatusBar] persist feature preference failed", error);
|
|
||||||
});
|
|
||||||
void client.setAgentFeature(agentId, featureId, value).catch((error) => {
|
|
||||||
console.warn("[AgentStatusBar] setAgentFeature failed", error);
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
isModelLoading={snapshotIsLoading || snapshotIsFetching}
|
|
||||||
onModelSelectorOpen={invalidateSnapshot}
|
|
||||||
onDropdownClose={onDropdownClose}
|
|
||||||
disabled={!client}
|
disabled={!client}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
|
||||||
export function DraftAgentStatusBar({
|
export function DraftAgentStatusBar({
|
||||||
providerDefinitions,
|
providerDefinitions,
|
||||||
@@ -1069,13 +776,9 @@ export function DraftAgentStatusBar({
|
|||||||
thinkingOptions,
|
thinkingOptions,
|
||||||
selectedThinkingOptionId,
|
selectedThinkingOptionId,
|
||||||
onSelectThinkingOption,
|
onSelectThinkingOption,
|
||||||
features,
|
|
||||||
onSetFeature,
|
|
||||||
onDropdownClose,
|
|
||||||
onModelSelectorOpen,
|
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}: DraftAgentStatusBarProps) {
|
}: DraftAgentStatusBarProps) {
|
||||||
const { preferences, updatePreferences } = useFormPreferences();
|
const isWeb = Platform.OS === "web";
|
||||||
|
|
||||||
const mappedModeOptions = useMemo<StatusOption[]>(() => {
|
const mappedModeOptions = useMemo<StatusOption[]>(() => {
|
||||||
if (modeOptions.length === 0) {
|
if (modeOptions.length === 0) {
|
||||||
@@ -1090,19 +793,12 @@ export function DraftAgentStatusBar({
|
|||||||
const mappedThinkingOptions = useMemo<StatusOption[]>(() => {
|
const mappedThinkingOptions = useMemo<StatusOption[]>(() => {
|
||||||
return thinkingOptions.map((option) => ({ id: option.id, label: option.label }));
|
return thinkingOptions.map((option) => ({ id: option.id, label: option.label }));
|
||||||
}, [thinkingOptions]);
|
}, [thinkingOptions]);
|
||||||
const favoriteKeys = useMemo(
|
|
||||||
() =>
|
|
||||||
new Set(
|
|
||||||
(preferences.favoriteModels ?? []).map((favorite) => buildFavoriteModelKey(favorite)),
|
|
||||||
),
|
|
||||||
[preferences.favoriteModels],
|
|
||||||
);
|
|
||||||
|
|
||||||
const effectiveSelectedMode = selectedMode || mappedModeOptions[0]?.id || "";
|
const effectiveSelectedMode = selectedMode || mappedModeOptions[0]?.id || "";
|
||||||
const effectiveSelectedThinkingOption =
|
const effectiveSelectedThinkingOption =
|
||||||
selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined;
|
selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined;
|
||||||
|
|
||||||
if (platformIsWeb) {
|
if (isWeb) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<CombinedModelSelector
|
<CombinedModelSelector
|
||||||
@@ -1111,73 +807,51 @@ export function DraftAgentStatusBar({
|
|||||||
selectedProvider={selectedProvider}
|
selectedProvider={selectedProvider}
|
||||||
selectedModel={selectedModel}
|
selectedModel={selectedModel}
|
||||||
onSelect={onSelectProviderAndModel}
|
onSelect={onSelectProviderAndModel}
|
||||||
favoriteKeys={favoriteKeys}
|
|
||||||
onToggleFavorite={(provider, modelId) => {
|
|
||||||
void updatePreferences((current) =>
|
|
||||||
toggleFavoriteModel({ preferences: current, provider, modelId }),
|
|
||||||
).catch((error) => {
|
|
||||||
console.warn("[DraftAgentStatusBar] toggle favorite model failed", error);
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
isLoading={isAllModelsLoading}
|
isLoading={isAllModelsLoading}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onOpen={onModelSelectorOpen}
|
|
||||||
onClose={onDropdownClose}
|
|
||||||
/>
|
/>
|
||||||
<ControlledStatusBar
|
<ControlledStatusBar
|
||||||
provider={selectedProvider}
|
provider={selectedProvider}
|
||||||
providerDefinitions={providerDefinitions}
|
|
||||||
modeOptions={mappedModeOptions}
|
modeOptions={mappedModeOptions}
|
||||||
selectedModeId={effectiveSelectedMode}
|
selectedModeId={effectiveSelectedMode}
|
||||||
onSelectMode={onSelectMode}
|
onSelectMode={onSelectMode}
|
||||||
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
|
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
|
||||||
selectedThinkingOptionId={effectiveSelectedThinkingOption}
|
selectedThinkingOptionId={effectiveSelectedThinkingOption}
|
||||||
onSelectThinkingOption={onSelectThinkingOption}
|
onSelectThinkingOption={onSelectThinkingOption}
|
||||||
features={features}
|
|
||||||
onSetFeature={onSetFeature}
|
|
||||||
onDropdownClose={onDropdownClose}
|
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const modelOptions: StatusOption[] = models.map((model) => ({
|
const providerOptions = providerDefinitions.map((definition) => ({
|
||||||
id: model.id,
|
id: definition.id,
|
||||||
label: model.label,
|
label: definition.label,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const modelOptions: StatusOption[] = [];
|
||||||
|
for (const model of models) {
|
||||||
|
modelOptions.push({ id: model.id, label: model.label });
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<ControlledStatusBar
|
||||||
<ControlledStatusBar
|
provider={selectedProvider}
|
||||||
provider={selectedProvider}
|
providerOptions={providerOptions}
|
||||||
providerDefinitions={providerDefinitions}
|
selectedProviderId={selectedProvider}
|
||||||
allProviderModels={allProviderModels}
|
onSelectProvider={(providerId) => onSelectProvider(providerId as AgentProvider)}
|
||||||
modeOptions={mappedModeOptions}
|
modeOptions={mappedModeOptions}
|
||||||
selectedModeId={effectiveSelectedMode}
|
selectedModeId={effectiveSelectedMode}
|
||||||
onSelectMode={onSelectMode}
|
onSelectMode={onSelectMode}
|
||||||
modelOptions={modelOptions}
|
modelOptions={modelOptions}
|
||||||
selectedModelId={selectedModel}
|
selectedModelId={selectedModel}
|
||||||
onSelectModel={(modelId) => onSelectModel(modelId)}
|
onSelectModel={onSelectModel}
|
||||||
onSelectProviderAndModel={onSelectProviderAndModel}
|
isModelLoading={isModelLoading}
|
||||||
isModelLoading={isAllModelsLoading}
|
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
|
||||||
favoriteKeys={favoriteKeys}
|
selectedThinkingOptionId={effectiveSelectedThinkingOption}
|
||||||
onToggleFavoriteModel={(provider, modelId) => {
|
onSelectThinkingOption={onSelectThinkingOption}
|
||||||
void updatePreferences((current) =>
|
disabled={disabled}
|
||||||
toggleFavoriteModel({ preferences: current, provider, modelId }),
|
/>
|
||||||
).catch((error) => {
|
|
||||||
console.warn("[DraftAgentStatusBar] toggle favorite model failed", error);
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
|
|
||||||
selectedThinkingOptionId={effectiveSelectedThinkingOption}
|
|
||||||
onSelectThinkingOption={onSelectThinkingOption}
|
|
||||||
features={features}
|
|
||||||
onSetFeature={onSetFeature}
|
|
||||||
onModelSelectorOpen={onModelSelectorOpen}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { AgentFeature, AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
|
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
|
||||||
|
|
||||||
export type ExplainedStatusSelector = "mode" | "model" | "thinking";
|
export type ExplainedStatusSelector = "mode" | "model" | "thinking";
|
||||||
export type FeatureHighlightColor = "blue" | "default" | "yellow";
|
|
||||||
|
|
||||||
export function getStatusSelectorHint(selector: ExplainedStatusSelector): string {
|
export function getStatusSelectorHint(selector: ExplainedStatusSelector): string {
|
||||||
switch (selector) {
|
switch (selector) {
|
||||||
@@ -22,21 +21,6 @@ export function normalizeModelId(modelId: string | null | undefined): string | n
|
|||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getFeatureTooltip(feature: Pick<AgentFeature, "label" | "tooltip">): string {
|
|
||||||
return feature.tooltip ?? feature.label;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getFeatureHighlightColor(featureId: string): FeatureHighlightColor {
|
|
||||||
switch (featureId) {
|
|
||||||
case "fast_mode":
|
|
||||||
return "yellow";
|
|
||||||
case "plan_mode":
|
|
||||||
return "blue";
|
|
||||||
default:
|
|
||||||
return "default";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveAgentModelSelection(input: {
|
export function resolveAgentModelSelection(input: {
|
||||||
models: AgentModelDefinition[] | null;
|
models: AgentModelDefinition[] | null;
|
||||||
runtimeModelId: string | null | undefined;
|
runtimeModelId: string | null | undefined;
|
||||||
@@ -52,7 +36,8 @@ export function resolveAgentModelSelection(input: {
|
|||||||
: null;
|
: null;
|
||||||
const preferredModelId =
|
const preferredModelId =
|
||||||
runtimeSelectedModel?.id ?? normalizedConfiguredModelId ?? normalizedRuntimeModelId;
|
runtimeSelectedModel?.id ?? normalizedConfiguredModelId ?? normalizedRuntimeModelId;
|
||||||
const fallbackModel = models?.find((model) => model.isDefault) ?? models?.[0] ?? null;
|
const fallbackModel =
|
||||||
|
models?.find((model) => model.isDefault) ?? models?.[0] ?? null;
|
||||||
const selectedModel =
|
const selectedModel =
|
||||||
models && preferredModelId
|
models && preferredModelId
|
||||||
? (models.find((model) => model.id === preferredModelId) ?? fallbackModel ?? null)
|
? (models.find((model) => model.id === preferredModelId) ?? fallbackModel ?? null)
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { View, Text, Pressable, Platform, ActivityIndicator } from "react-native";
|
import { View, Text, Pressable, Platform, ActivityIndicator } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import Markdown from "react-native-markdown-display";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
@@ -27,7 +27,6 @@ import { Check, ChevronDown, X } from "lucide-react-native";
|
|||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import {
|
import {
|
||||||
AssistantMessage,
|
AssistantMessage,
|
||||||
SpeakMessage,
|
|
||||||
UserMessage,
|
UserMessage,
|
||||||
ActivityLog,
|
ActivityLog,
|
||||||
ToolCall,
|
ToolCall,
|
||||||
@@ -37,13 +36,9 @@ import {
|
|||||||
MessageOuterSpacingProvider,
|
MessageOuterSpacingProvider,
|
||||||
type InlinePathTarget,
|
type InlinePathTarget,
|
||||||
} from "./message";
|
} from "./message";
|
||||||
import { PlanCard } from "./plan-card";
|
|
||||||
import type { StreamItem } from "@/types/stream";
|
import type { StreamItem } from "@/types/stream";
|
||||||
import type { PendingPermission } from "@/types/shared";
|
import type { PendingPermission } from "@/types/shared";
|
||||||
import type {
|
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
|
||||||
AgentPermissionAction,
|
|
||||||
AgentPermissionResponse,
|
|
||||||
} from "@server/server/agent/agent-sdk-types";
|
|
||||||
import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
|
import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
||||||
@@ -64,7 +59,9 @@ import {
|
|||||||
type BottomAnchorLocalRequest,
|
type BottomAnchorLocalRequest,
|
||||||
type BottomAnchorRouteRequest,
|
type BottomAnchorRouteRequest,
|
||||||
} from "./use-bottom-anchor-controller";
|
} from "./use-bottom-anchor-controller";
|
||||||
|
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
||||||
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||||
|
import { getMarkdownListMarker } from "@/utils/markdown-list";
|
||||||
import { normalizeInlinePathTarget } from "@/utils/inline-path";
|
import { normalizeInlinePathTarget } from "@/utils/inline-path";
|
||||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||||
@@ -73,7 +70,6 @@ import {
|
|||||||
WORKING_INDICATOR_CYCLE_MS,
|
WORKING_INDICATOR_CYCLE_MS,
|
||||||
WORKING_INDICATOR_OFFSETS,
|
WORKING_INDICATOR_OFFSETS,
|
||||||
} from "@/utils/working-indicator";
|
} from "@/utils/working-indicator";
|
||||||
import { isWeb } from "@/constants/platform";
|
|
||||||
|
|
||||||
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
|
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
|
||||||
const isToolSequenceItem = (item?: StreamItem) =>
|
const isToolSequenceItem = (item?: StreamItem) =>
|
||||||
@@ -111,7 +107,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
const viewportRef = useRef<StreamViewportHandle | null>(null);
|
const viewportRef = useRef<StreamViewportHandle | null>(null);
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
const streamRenderStrategy = useMemo(
|
const streamRenderStrategy = useMemo(
|
||||||
() =>
|
() =>
|
||||||
resolveStreamRenderStrategy({
|
resolveStreamRenderStrategy({
|
||||||
@@ -184,7 +180,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
workspaceId,
|
workspaceId,
|
||||||
target: { kind: "file", path: normalized.file },
|
target: { kind: "file", path: normalized.file },
|
||||||
});
|
});
|
||||||
router.navigate(route);
|
router.navigate(route as any);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,7 +213,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
return buildAgentStreamRenderModel({
|
return buildAgentStreamRenderModel({
|
||||||
tail: streamItems,
|
tail: streamItems,
|
||||||
head: streamHead ?? [],
|
head: streamHead ?? [],
|
||||||
platform: isWeb ? "web" : "native",
|
platform: Platform.OS === "web" ? "web" : "native",
|
||||||
isMobileBreakpoint: isMobile,
|
isMobileBreakpoint: isMobile,
|
||||||
});
|
});
|
||||||
}, [isMobile, streamHead, streamItems]);
|
}, [isMobile, streamHead, streamItems]);
|
||||||
@@ -273,6 +269,44 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
[looseGap, tightGap],
|
[looseGap, tightGap],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DEBUG: track when render callback deps change
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const debugStreamPrevRef = useRef<Record<string, unknown>>({});
|
||||||
|
useEffect(() => {
|
||||||
|
const prev = debugStreamPrevRef.current;
|
||||||
|
const curr: Record<string, unknown> = {
|
||||||
|
// handleInlinePathPress deps (line 196-205)
|
||||||
|
"hip.agent.cwd": agent.cwd,
|
||||||
|
"hip.openFileExplorer": openFileExplorer,
|
||||||
|
"hip.requestDirectoryListing": requestDirectoryListing,
|
||||||
|
"hip.resolvedServerId": resolvedServerId,
|
||||||
|
"hip.router": router,
|
||||||
|
"hip.setExplorerTabForCheckout": setExplorerTabForCheckout,
|
||||||
|
"hip.onOpenWorkspaceFile": onOpenWorkspaceFile,
|
||||||
|
"hip.workspaceId": workspaceId,
|
||||||
|
// top-level deps
|
||||||
|
handleInlinePathPress,
|
||||||
|
"agent.status": agent.status,
|
||||||
|
streamRenderStrategy,
|
||||||
|
getGapBetween,
|
||||||
|
streamItems,
|
||||||
|
"streamItems.length": streamItems.length,
|
||||||
|
streamHead,
|
||||||
|
baseRenderModel,
|
||||||
|
};
|
||||||
|
const changed: string[] = [];
|
||||||
|
for (const key of Object.keys(curr)) {
|
||||||
|
if (!Object.is(prev[key], curr[key])) {
|
||||||
|
changed.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed.length > 0 && Object.keys(prev).length > 0) {
|
||||||
|
console.log("[AgentStreamView] deps changed:", changed.join(", "));
|
||||||
|
}
|
||||||
|
debugStreamPrevRef.current = curr;
|
||||||
|
});
|
||||||
|
|
||||||
const renderStreamItemContent = useCallback(
|
const renderStreamItemContent = useCallback(
|
||||||
(
|
(
|
||||||
item: StreamItem,
|
item: StreamItem,
|
||||||
@@ -332,10 +366,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
timestamp={item.timestamp.getTime()}
|
timestamp={item.timestamp.getTime()}
|
||||||
onInlinePathPress={handleInlinePathPress}
|
onInlinePathPress={handleInlinePathPress}
|
||||||
workspaceRoot={workspaceRoot}
|
workspaceRoot={workspaceRoot}
|
||||||
serverId={serverId}
|
|
||||||
client={client}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "thought": {
|
case "thought": {
|
||||||
const nextItem = getStreamNeighborItem({
|
const nextItem = getStreamNeighborItem({
|
||||||
strategy: streamRenderStrategy,
|
strategy: streamRenderStrategy,
|
||||||
@@ -367,18 +400,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
|
|
||||||
if (payload.source === "agent") {
|
if (payload.source === "agent") {
|
||||||
const data = payload.data;
|
const data = payload.data;
|
||||||
|
|
||||||
if (
|
|
||||||
data.name === "speak" &&
|
|
||||||
data.detail.type === "unknown" &&
|
|
||||||
typeof data.detail.input === "string" &&
|
|
||||||
data.detail.input.trim()
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<SpeakMessage message={data.detail.input} timestamp={item.timestamp.getTime()} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToolCall
|
<ToolCall
|
||||||
toolName={data.name}
|
toolName={data.name}
|
||||||
@@ -711,35 +732,12 @@ function PermissionRequestCard({
|
|||||||
client: DaemonClient | null;
|
client: DaemonClient | null;
|
||||||
}) {
|
}) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
|
|
||||||
const { request } = permission;
|
const { request } = permission;
|
||||||
const isPlanRequest = request.kind === "plan";
|
const isPlanRequest = request.kind === "plan";
|
||||||
const title = isPlanRequest ? "Plan" : (request.title ?? request.name ?? "Permission Required");
|
const title = isPlanRequest ? "Plan" : (request.title ?? request.name ?? "Permission Required");
|
||||||
const description = request.description ?? "";
|
const description = request.description ?? "";
|
||||||
const resolvedActions = useMemo((): AgentPermissionAction[] => {
|
|
||||||
if (request.kind === "question") {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
if (Array.isArray(request.actions) && request.actions.length > 0) {
|
|
||||||
return request.actions;
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: "reject",
|
|
||||||
label: "Deny",
|
|
||||||
behavior: "deny",
|
|
||||||
variant: "danger",
|
|
||||||
intent: "dismiss",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "accept",
|
|
||||||
label: isPlanRequest ? "Implement" : "Accept",
|
|
||||||
behavior: "allow",
|
|
||||||
variant: "primary",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}, [isPlanRequest, request]);
|
|
||||||
|
|
||||||
const planMarkdown = useMemo(() => {
|
const planMarkdown = useMemo(() => {
|
||||||
if (!request) {
|
if (!request) {
|
||||||
@@ -757,6 +755,90 @@ function PermissionRequestCard({
|
|||||||
return undefined;
|
return undefined;
|
||||||
}, [request]);
|
}, [request]);
|
||||||
|
|
||||||
|
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
|
||||||
|
|
||||||
|
const markdownRules = useMemo(() => {
|
||||||
|
return {
|
||||||
|
text: (
|
||||||
|
node: any,
|
||||||
|
_children: React.ReactNode[],
|
||||||
|
_parent: any,
|
||||||
|
styles: any,
|
||||||
|
inheritedStyles: any = {},
|
||||||
|
) => (
|
||||||
|
<Text key={node.key} style={[inheritedStyles, styles.text]}>
|
||||||
|
{node.content}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
textgroup: (
|
||||||
|
node: any,
|
||||||
|
children: React.ReactNode[],
|
||||||
|
_parent: any,
|
||||||
|
styles: any,
|
||||||
|
inheritedStyles: any = {},
|
||||||
|
) => (
|
||||||
|
<Text key={node.key} style={[inheritedStyles, styles.textgroup]}>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
code_block: (
|
||||||
|
node: any,
|
||||||
|
_children: React.ReactNode[],
|
||||||
|
_parent: any,
|
||||||
|
styles: any,
|
||||||
|
inheritedStyles: any = {},
|
||||||
|
) => (
|
||||||
|
<Text key={node.key} style={[inheritedStyles, styles.code_block]}>
|
||||||
|
{node.content}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
fence: (
|
||||||
|
node: any,
|
||||||
|
_children: React.ReactNode[],
|
||||||
|
_parent: any,
|
||||||
|
styles: any,
|
||||||
|
inheritedStyles: any = {},
|
||||||
|
) => (
|
||||||
|
<Text key={node.key} style={[inheritedStyles, styles.fence]}>
|
||||||
|
{node.content}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
code_inline: (
|
||||||
|
node: any,
|
||||||
|
_children: React.ReactNode[],
|
||||||
|
_parent: any,
|
||||||
|
styles: any,
|
||||||
|
inheritedStyles: any = {},
|
||||||
|
) => (
|
||||||
|
<Text key={node.key} style={[inheritedStyles, styles.code_inline]}>
|
||||||
|
{node.content}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
bullet_list: (node: any, children: React.ReactNode[], _parent: any, styles: any) => (
|
||||||
|
<View key={node.key} style={styles.bullet_list}>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
ordered_list: (node: any, children: React.ReactNode[], _parent: any, styles: any) => (
|
||||||
|
<View key={node.key} style={styles.ordered_list}>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
list_item: (node: any, children: React.ReactNode[], parent: any, styles: any) => {
|
||||||
|
const { isOrdered, marker } = getMarkdownListMarker(node, parent);
|
||||||
|
const iconStyle = isOrdered ? styles.ordered_list_icon : styles.bullet_list_icon;
|
||||||
|
const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View key={node.key} style={[styles.list_item, { flexShrink: 0 }]}>
|
||||||
|
<Text style={iconStyle}>{marker}</Text>
|
||||||
|
<Text style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}>{children}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const permissionMutation = useMutation({
|
const permissionMutation = useMutation({
|
||||||
mutationFn: async (input: {
|
mutationFn: async (input: {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
@@ -780,11 +862,11 @@ function PermissionRequestCard({
|
|||||||
isPending: isResponding,
|
isPending: isResponding,
|
||||||
} = permissionMutation;
|
} = permissionMutation;
|
||||||
|
|
||||||
const [respondingActionId, setRespondingActionId] = useState<string | null>(null);
|
const [respondingAction, setRespondingAction] = useState<"accept" | "deny" | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
resetPermissionMutation();
|
resetPermissionMutation();
|
||||||
setRespondingActionId(null);
|
setRespondingAction(null);
|
||||||
}, [permission.request.id, resetPermissionMutation]);
|
}, [permission.request.id, resetPermissionMutation]);
|
||||||
const handleResponse = useCallback(
|
const handleResponse = useCallback(
|
||||||
(response: AgentPermissionResponse) => {
|
(response: AgentPermissionResponse) => {
|
||||||
@@ -798,24 +880,6 @@ function PermissionRequestCard({
|
|||||||
},
|
},
|
||||||
[permission.agentId, permission.request.id, respondToPermission],
|
[permission.agentId, permission.request.id, respondToPermission],
|
||||||
);
|
);
|
||||||
const handleActionPress = useCallback(
|
|
||||||
(action: AgentPermissionAction) => {
|
|
||||||
setRespondingActionId(action.id);
|
|
||||||
if (action.behavior === "allow") {
|
|
||||||
handleResponse({
|
|
||||||
behavior: "allow",
|
|
||||||
selectedActionId: action.id,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
handleResponse({
|
|
||||||
behavior: "deny",
|
|
||||||
selectedActionId: action.id,
|
|
||||||
message: "Denied by user",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[handleResponse],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (request.kind === "question") {
|
if (request.kind === "question") {
|
||||||
return (
|
return (
|
||||||
@@ -827,79 +891,6 @@ function PermissionRequestCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const footer = (
|
|
||||||
<>
|
|
||||||
<Text
|
|
||||||
testID="permission-request-question"
|
|
||||||
style={[permissionStyles.question, { color: theme.colors.foregroundMuted }]}
|
|
||||||
>
|
|
||||||
How would you like to proceed?
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
permissionStyles.optionsContainer,
|
|
||||||
!isMobile && permissionStyles.optionsContainerDesktop,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{resolvedActions.map((action) => {
|
|
||||||
const isDanger = action.variant === "danger" || action.behavior === "deny";
|
|
||||||
const isPrimary = action.variant === "primary";
|
|
||||||
const isRespondingAction = respondingActionId === action.id;
|
|
||||||
const textColor = isPrimary ? theme.colors.foreground : theme.colors.foregroundMuted;
|
|
||||||
const iconColor = textColor;
|
|
||||||
const Icon = action.behavior === "allow" ? Check : X;
|
|
||||||
const testID =
|
|
||||||
action.behavior === "deny"
|
|
||||||
? "permission-request-deny"
|
|
||||||
: action.id === "accept" || action.id === "implement"
|
|
||||||
? "permission-request-accept"
|
|
||||||
: `permission-request-action-${action.id}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Pressable
|
|
||||||
key={action.id}
|
|
||||||
testID={testID}
|
|
||||||
style={({ pressed, hovered = false }) => [
|
|
||||||
permissionStyles.optionButton,
|
|
||||||
{
|
|
||||||
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
|
|
||||||
borderColor: isDanger ? theme.colors.borderAccent : theme.colors.borderAccent,
|
|
||||||
},
|
|
||||||
pressed ? permissionStyles.optionButtonPressed : null,
|
|
||||||
]}
|
|
||||||
onPress={() => handleActionPress(action)}
|
|
||||||
disabled={isResponding}
|
|
||||||
>
|
|
||||||
{isRespondingAction ? (
|
|
||||||
<ActivityIndicator size="small" color={textColor} />
|
|
||||||
) : (
|
|
||||||
<View style={permissionStyles.optionContent}>
|
|
||||||
<Icon size={14} color={iconColor} />
|
|
||||||
<Text style={[permissionStyles.optionText, { color: textColor }]}>
|
|
||||||
{action.label}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isPlanRequest && planMarkdown) {
|
|
||||||
return (
|
|
||||||
<PlanCard
|
|
||||||
title={title}
|
|
||||||
description={description}
|
|
||||||
text={planMarkdown}
|
|
||||||
footer={footer}
|
|
||||||
disableOuterSpacing
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
@@ -919,7 +910,16 @@ function PermissionRequestCard({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{planMarkdown ? (
|
{planMarkdown ? (
|
||||||
<PlanCard title="Proposed plan" text={planMarkdown} disableOuterSpacing />
|
<View style={permissionStyles.section}>
|
||||||
|
{!isPlanRequest ? (
|
||||||
|
<Text style={[permissionStyles.sectionTitle, { color: theme.colors.foregroundMuted }]}>
|
||||||
|
Proposed plan
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<Markdown style={markdownStyles} rules={markdownRules}>
|
||||||
|
{planMarkdown}
|
||||||
|
</Markdown>
|
||||||
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!isPlanRequest ? (
|
{!isPlanRequest ? (
|
||||||
@@ -935,7 +935,78 @@ function PermissionRequestCard({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{footer}
|
<Text
|
||||||
|
testID="permission-request-question"
|
||||||
|
style={[permissionStyles.question, { color: theme.colors.foregroundMuted }]}
|
||||||
|
>
|
||||||
|
How would you like to proceed?
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
permissionStyles.optionsContainer,
|
||||||
|
!isMobile && permissionStyles.optionsContainerDesktop,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
testID="permission-request-deny"
|
||||||
|
style={({ pressed, hovered = false }) => [
|
||||||
|
permissionStyles.optionButton,
|
||||||
|
{
|
||||||
|
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
|
||||||
|
borderColor: theme.colors.borderAccent,
|
||||||
|
},
|
||||||
|
pressed ? permissionStyles.optionButtonPressed : null,
|
||||||
|
]}
|
||||||
|
onPress={() => {
|
||||||
|
setRespondingAction("deny");
|
||||||
|
handleResponse({
|
||||||
|
behavior: "deny",
|
||||||
|
message: "Denied by user",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={isResponding}
|
||||||
|
>
|
||||||
|
{respondingAction === "deny" ? (
|
||||||
|
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||||
|
) : (
|
||||||
|
<View style={permissionStyles.optionContent}>
|
||||||
|
<X size={14} color={theme.colors.foregroundMuted} />
|
||||||
|
<Text style={[permissionStyles.optionText, { color: theme.colors.foregroundMuted }]}>
|
||||||
|
Deny
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
testID="permission-request-accept"
|
||||||
|
style={({ pressed, hovered = false }) => [
|
||||||
|
permissionStyles.optionButton,
|
||||||
|
{
|
||||||
|
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
|
||||||
|
borderColor: theme.colors.borderAccent,
|
||||||
|
},
|
||||||
|
pressed ? permissionStyles.optionButtonPressed : null,
|
||||||
|
]}
|
||||||
|
onPress={() => {
|
||||||
|
setRespondingAction("accept");
|
||||||
|
handleResponse({ behavior: "allow" });
|
||||||
|
}}
|
||||||
|
disabled={isResponding}
|
||||||
|
>
|
||||||
|
{respondingAction === "accept" ? (
|
||||||
|
<ActivityIndicator size="small" color={theme.colors.foreground} />
|
||||||
|
) : (
|
||||||
|
<View style={permissionStyles.optionContent}>
|
||||||
|
<Check size={14} color={theme.colors.foreground} />
|
||||||
|
<Text style={[permissionStyles.optionText, { color: theme.colors.foreground }]}>
|
||||||
|
Accept
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1056,7 +1127,14 @@ const stylesheet = StyleSheet.create((theme) => ({
|
|||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
...theme.shadow.sm,
|
shadowColor: "#000",
|
||||||
|
shadowOffset: {
|
||||||
|
width: 0,
|
||||||
|
height: 2,
|
||||||
|
},
|
||||||
|
shadowOpacity: 0.25,
|
||||||
|
shadowRadius: 3.84,
|
||||||
|
elevation: 5,
|
||||||
},
|
},
|
||||||
scrollToBottomIcon: {
|
scrollToBottomIcon: {
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { StreamItem } from "@/types/stream";
|
import type { StreamItem } from "@/types/stream";
|
||||||
import {
|
|
||||||
clearAssistantImageMetadataCache,
|
|
||||||
setAssistantImageMetadata,
|
|
||||||
} from "@/utils/assistant-image-metadata";
|
|
||||||
import {
|
import {
|
||||||
DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS,
|
DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS,
|
||||||
DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD,
|
DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD,
|
||||||
@@ -132,25 +128,6 @@ describe("estimateStreamItemHeight", () => {
|
|||||||
|
|
||||||
expect(estimateStreamItemHeight(item)).toBe(220);
|
expect(estimateStreamItemHeight(item)).toBe(220);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses cached assistant image metadata when available", () => {
|
|
||||||
clearAssistantImageMetadataCache();
|
|
||||||
setAssistantImageMetadata(
|
|
||||||
{
|
|
||||||
source: "https://example.com/tall.png",
|
|
||||||
},
|
|
||||||
{ width: 800, height: 1600 },
|
|
||||||
);
|
|
||||||
|
|
||||||
const item: StreamItem = {
|
|
||||||
kind: "assistant_message",
|
|
||||||
id: "a-image",
|
|
||||||
text: "Look at this\n\n",
|
|
||||||
timestamp: createTimestamp(2),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(estimateStreamItemHeight(item)).toBeGreaterThan(220);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("web virtualization test overrides", () => {
|
describe("web virtualization test overrides", () => {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { StreamItem } from "@/types/stream";
|
import type { StreamItem } from "@/types/stream";
|
||||||
import { estimateAssistantMessageHeightFromCache } from "@/utils/assistant-image-metadata";
|
|
||||||
|
|
||||||
export const DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 100;
|
export const DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 100;
|
||||||
export const DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS = 50;
|
export const DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS = 50;
|
||||||
@@ -46,7 +45,7 @@ export function estimateStreamItemHeight(item: StreamItem): number {
|
|||||||
case "user_message":
|
case "user_message":
|
||||||
return item.images && item.images.length > 0 ? 220 : 96;
|
return item.images && item.images.length > 0 ? 220 : 96;
|
||||||
case "assistant_message":
|
case "assistant_message":
|
||||||
return estimateAssistantMessageHeightFromCache(item.text) ?? 220;
|
return 220;
|
||||||
case "tool_call":
|
case "tool_call":
|
||||||
return 136;
|
return 136;
|
||||||
case "thought":
|
case "thought":
|
||||||
|
|||||||
@@ -1,123 +0,0 @@
|
|||||||
import { useRef } from "react";
|
|
||||||
import { Pressable, Text, View } from "react-native";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { ChevronDown, GitBranch } from "lucide-react-native";
|
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
|
||||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
|
||||||
import { useToast } from "@/contexts/toast-context";
|
|
||||||
import { useBranchSwitcher } from "@/hooks/use-branch-switcher";
|
|
||||||
|
|
||||||
interface BranchSwitcherProps {
|
|
||||||
currentBranchName: string | null;
|
|
||||||
title: string;
|
|
||||||
serverId: string;
|
|
||||||
workspaceId: string;
|
|
||||||
isGitCheckout: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BranchSwitcher({
|
|
||||||
currentBranchName,
|
|
||||||
title,
|
|
||||||
serverId,
|
|
||||||
workspaceId,
|
|
||||||
isGitCheckout,
|
|
||||||
}: BranchSwitcherProps) {
|
|
||||||
const { theme } = useUnistyles();
|
|
||||||
const anchorRef = useRef<View>(null);
|
|
||||||
const client = useHostRuntimeClient(serverId);
|
|
||||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
|
||||||
const toast = useToast();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const { branchOptions, isOpen, setIsOpen, handleBranchSelect } = useBranchSwitcher({
|
|
||||||
client,
|
|
||||||
normalizedServerId: serverId,
|
|
||||||
normalizedWorkspaceId: workspaceId,
|
|
||||||
currentBranchName,
|
|
||||||
isGitCheckout,
|
|
||||||
isConnected,
|
|
||||||
toast,
|
|
||||||
queryClient,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!currentBranchName) {
|
|
||||||
return (
|
|
||||||
<Text testID="workspace-header-title" style={styles.headerTitle} numberOfLines={1}>
|
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View ref={anchorRef} collapsable={false}>
|
|
||||||
<Pressable
|
|
||||||
testID="workspace-header-branch-switcher"
|
|
||||||
onPress={() => setIsOpen(true)}
|
|
||||||
style={({ hovered, pressed }) => [
|
|
||||||
styles.branchSwitcherTrigger,
|
|
||||||
(hovered || pressed) && styles.branchSwitcherTriggerHovered,
|
|
||||||
]}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={`Current branch: ${currentBranchName}. Press to switch branch.`}
|
|
||||||
>
|
|
||||||
<GitBranch size={14} color={theme.colors.foregroundMuted} />
|
|
||||||
<Text testID="workspace-header-title" style={styles.headerTitle} numberOfLines={1}>
|
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
<ChevronDown size={12} color={theme.colors.foregroundMuted} />
|
|
||||||
</Pressable>
|
|
||||||
<Combobox
|
|
||||||
options={branchOptions}
|
|
||||||
value={currentBranchName}
|
|
||||||
onSelect={handleBranchSelect}
|
|
||||||
searchable
|
|
||||||
placeholder="Switch branch..."
|
|
||||||
searchPlaceholder="Filter branches..."
|
|
||||||
emptyText="No branches found."
|
|
||||||
title="Switch branch"
|
|
||||||
open={isOpen}
|
|
||||||
onOpenChange={setIsOpen}
|
|
||||||
anchorRef={anchorRef}
|
|
||||||
desktopPlacement="bottom-start"
|
|
||||||
desktopPreventInitialFlash
|
|
||||||
desktopMinWidth={280}
|
|
||||||
renderOption={({ option, selected, active, onPress }) => (
|
|
||||||
<ComboboxItem
|
|
||||||
key={option.id}
|
|
||||||
label={option.label}
|
|
||||||
selected={selected}
|
|
||||||
active={active}
|
|
||||||
onPress={onPress}
|
|
||||||
leadingSlot={<GitBranch size={14} color={theme.colors.foregroundMuted} />}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
|
||||||
headerTitle: {
|
|
||||||
fontSize: theme.fontSize.base,
|
|
||||||
fontWeight: {
|
|
||||||
xs: "400",
|
|
||||||
md: "300",
|
|
||||||
},
|
|
||||||
color: theme.colors.foreground,
|
|
||||||
flexShrink: 1,
|
|
||||||
},
|
|
||||||
branchSwitcherTrigger: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing[1],
|
|
||||||
paddingVertical: theme.spacing[1],
|
|
||||||
paddingHorizontal: theme.spacing[2],
|
|
||||||
borderRadius: theme.borderRadius.md,
|
|
||||||
flexShrink: 1,
|
|
||||||
minWidth: 0,
|
|
||||||
},
|
|
||||||
branchSwitcherTriggerHovered: {
|
|
||||||
backgroundColor: theme.colors.surface1,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
|
|
||||||
import {
|
|
||||||
buildModelRows,
|
|
||||||
buildSelectedTriggerLabel,
|
|
||||||
matchesSearch,
|
|
||||||
resolveProviderLabel,
|
|
||||||
} from "./combined-model-selector.utils";
|
|
||||||
|
|
||||||
describe("combined model selector helpers", () => {
|
|
||||||
const providerDefinitions = [
|
|
||||||
{
|
|
||||||
id: "claude",
|
|
||||||
label: "Claude",
|
|
||||||
description: "Claude provider",
|
|
||||||
defaultModeId: "default",
|
|
||||||
modes: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "codex",
|
|
||||||
label: "Codex",
|
|
||||||
description: "Codex provider",
|
|
||||||
defaultModeId: "auto",
|
|
||||||
modes: [],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const claudeModels: AgentModelDefinition[] = [
|
|
||||||
{
|
|
||||||
provider: "claude",
|
|
||||||
id: "sonnet-4.6",
|
|
||||||
label: "Sonnet 4.6",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const codexModels: AgentModelDefinition[] = [
|
|
||||||
{
|
|
||||||
provider: "codex",
|
|
||||||
id: "gpt-5.4",
|
|
||||||
label: "GPT-5.4",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
it("keeps enough data to search by model and provider name", async () => {
|
|
||||||
const rows = buildModelRows(
|
|
||||||
providerDefinitions,
|
|
||||||
new Map([
|
|
||||||
["claude", claudeModels],
|
|
||||||
["codex", codexModels],
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(rows).toEqual([
|
|
||||||
expect.objectContaining({
|
|
||||||
providerLabel: "Claude",
|
|
||||||
modelLabel: "Sonnet 4.6",
|
|
||||||
modelId: "sonnet-4.6",
|
|
||||||
}),
|
|
||||||
expect.objectContaining({
|
|
||||||
providerLabel: "Codex",
|
|
||||||
modelLabel: "GPT-5.4",
|
|
||||||
modelId: "gpt-5.4",
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(matchesSearch(rows[0]!, "claude")).toBe(true);
|
|
||||||
expect(matchesSearch(rows[1]!, "gpt-5.4")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("builds an explicit trigger label for the selected provider and model", () => {
|
|
||||||
expect(resolveProviderLabel(providerDefinitions, "codex")).toBe("Codex");
|
|
||||||
expect(buildSelectedTriggerLabel("Codex", "GPT-5.4")).toBe("GPT-5.4");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,74 +1,15 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import { View, Text, Pressable, Platform } from "react-native";
|
||||||
View,
|
|
||||||
Text,
|
|
||||||
TextInput,
|
|
||||||
Pressable,
|
|
||||||
ActivityIndicator,
|
|
||||||
type GestureResponderEvent,
|
|
||||||
} from "react-native";
|
|
||||||
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
|
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { ArrowLeft, Check, ChevronDown, ChevronRight } from "lucide-react-native";
|
||||||
import { isWeb as platformIsWeb } from "@/constants/platform";
|
|
||||||
import { ArrowLeft, ChevronDown, ChevronRight, Search, Star } from "lucide-react-native";
|
|
||||||
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
|
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||||
const IS_WEB = platformIsWeb;
|
import { Combobox, ComboboxItem, SearchInput } from "@/components/ui/combobox";
|
||||||
|
|
||||||
import { Combobox, ComboboxItem } from "@/components/ui/combobox";
|
|
||||||
import { getProviderIcon } from "@/components/provider-icons";
|
import { getProviderIcon } from "@/components/provider-icons";
|
||||||
import type { FavoriteModelRow } from "@/hooks/use-form-preferences";
|
|
||||||
import {
|
|
||||||
buildModelRows,
|
|
||||||
buildSelectedTriggerLabel,
|
|
||||||
matchesSearch,
|
|
||||||
resolveProviderLabel,
|
|
||||||
type SelectorModelRow,
|
|
||||||
} from "./combined-model-selector.utils";
|
|
||||||
|
|
||||||
// TODO: this should be configured per provider in the provider manifest
|
const INLINE_MODEL_THRESHOLD = 8;
|
||||||
const PROVIDERS_WITH_MODEL_DESCRIPTIONS = new Set(["opencode", "pi"]);
|
|
||||||
|
|
||||||
type SelectorView =
|
type DrillDownView = { provider: string };
|
||||||
| { kind: "all" }
|
|
||||||
| { kind: "provider"; providerId: string; providerLabel: string };
|
|
||||||
|
|
||||||
interface CombinedModelSelectorProps {
|
|
||||||
providerDefinitions: AgentProviderDefinition[];
|
|
||||||
allProviderModels: Map<string, AgentModelDefinition[]>;
|
|
||||||
selectedProvider: string;
|
|
||||||
selectedModel: string;
|
|
||||||
onSelect: (provider: AgentProvider, modelId: string) => void;
|
|
||||||
isLoading: boolean;
|
|
||||||
canSelectProvider?: (provider: string) => boolean;
|
|
||||||
favoriteKeys?: Set<string>;
|
|
||||||
onToggleFavorite?: (provider: string, modelId: string) => void;
|
|
||||||
renderTrigger?: (input: {
|
|
||||||
selectedModelLabel: string;
|
|
||||||
onPress: () => void;
|
|
||||||
disabled: boolean;
|
|
||||||
isOpen: boolean;
|
|
||||||
}) => React.ReactNode;
|
|
||||||
onOpen?: () => void;
|
|
||||||
onClose?: () => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SelectorContentProps {
|
|
||||||
view: SelectorView;
|
|
||||||
providerDefinitions: AgentProviderDefinition[];
|
|
||||||
allProviderModels: Map<string, AgentModelDefinition[]>;
|
|
||||||
selectedProvider: string;
|
|
||||||
selectedModel: string;
|
|
||||||
searchQuery: string;
|
|
||||||
onSearchChange: (query: string) => void;
|
|
||||||
favoriteKeys: Set<string>;
|
|
||||||
onSelect: (provider: string, modelId: string) => void;
|
|
||||||
canSelectProvider: (provider: string) => boolean;
|
|
||||||
onToggleFavorite?: (provider: string, modelId: string) => void;
|
|
||||||
onDrillDown: (providerId: string, providerLabel: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveDefaultModelLabel(models: AgentModelDefinition[] | undefined): string {
|
function resolveDefaultModelLabel(models: AgentModelDefinition[] | undefined): string {
|
||||||
if (!models || models.length === 0) {
|
if (!models || models.length === 0) {
|
||||||
@@ -77,429 +18,14 @@ function resolveDefaultModelLabel(models: AgentModelDefinition[] | undefined): s
|
|||||||
return (models.find((model) => model.isDefault) ?? models[0])?.label ?? "Select model";
|
return (models.find((model) => model.isDefault) ?? models[0])?.label ?? "Select model";
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSearchQuery(value: string): string {
|
interface CombinedModelSelectorProps {
|
||||||
return value.trim().toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function partitionRows(
|
|
||||||
rows: SelectorModelRow[],
|
|
||||||
favoriteKeys: Set<string>,
|
|
||||||
): { favoriteRows: SelectorModelRow[]; regularRows: SelectorModelRow[] } {
|
|
||||||
const favoriteRows: SelectorModelRow[] = [];
|
|
||||||
const regularRows: SelectorModelRow[] = [];
|
|
||||||
|
|
||||||
for (const row of rows) {
|
|
||||||
if (favoriteKeys.has(row.favoriteKey)) {
|
|
||||||
favoriteRows.push(row);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
regularRows.push(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { favoriteRows, regularRows };
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortFavoritesFirst(
|
|
||||||
rows: SelectorModelRow[],
|
|
||||||
favoriteKeys: Set<string>,
|
|
||||||
): SelectorModelRow[] {
|
|
||||||
const favorites: SelectorModelRow[] = [];
|
|
||||||
const rest: SelectorModelRow[] = [];
|
|
||||||
for (const row of rows) {
|
|
||||||
if (favoriteKeys.has(row.favoriteKey)) {
|
|
||||||
favorites.push(row);
|
|
||||||
} else {
|
|
||||||
rest.push(row);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [...favorites, ...rest];
|
|
||||||
}
|
|
||||||
|
|
||||||
function groupRowsByProvider(
|
|
||||||
rows: SelectorModelRow[],
|
|
||||||
): Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }> {
|
|
||||||
const grouped = new Map<
|
|
||||||
string,
|
|
||||||
{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }
|
|
||||||
>();
|
|
||||||
|
|
||||||
for (const row of rows) {
|
|
||||||
const existing = grouped.get(row.provider);
|
|
||||||
if (existing) {
|
|
||||||
existing.rows.push(row);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
grouped.set(row.provider, {
|
|
||||||
providerId: row.provider,
|
|
||||||
providerLabel: row.providerLabel,
|
|
||||||
rows: [row],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(grouped.values());
|
|
||||||
}
|
|
||||||
|
|
||||||
function ModelRow({
|
|
||||||
row,
|
|
||||||
isSelected,
|
|
||||||
isFavorite,
|
|
||||||
disabled = false,
|
|
||||||
elevated = false,
|
|
||||||
onPress,
|
|
||||||
onToggleFavorite,
|
|
||||||
}: {
|
|
||||||
row: SelectorModelRow;
|
|
||||||
isSelected: boolean;
|
|
||||||
isFavorite: boolean;
|
|
||||||
disabled?: boolean;
|
|
||||||
elevated?: boolean;
|
|
||||||
onPress: () => void;
|
|
||||||
onToggleFavorite?: (provider: string, modelId: string) => void;
|
|
||||||
}) {
|
|
||||||
const { theme } = useUnistyles();
|
|
||||||
const ProviderIcon = getProviderIcon(row.provider);
|
|
||||||
|
|
||||||
const handleToggleFavorite = useCallback(
|
|
||||||
(event: GestureResponderEvent) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
onToggleFavorite?.(row.provider, row.modelId);
|
|
||||||
},
|
|
||||||
[onToggleFavorite, row.modelId, row.provider],
|
|
||||||
);
|
|
||||||
|
|
||||||
const showDescription = row.description && PROVIDERS_WITH_MODEL_DESCRIPTIONS.has(row.provider);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ComboboxItem
|
|
||||||
label={row.modelLabel}
|
|
||||||
description={showDescription ? row.description : undefined}
|
|
||||||
selected={isSelected}
|
|
||||||
disabled={disabled}
|
|
||||||
elevated={elevated}
|
|
||||||
onPress={onPress}
|
|
||||||
leadingSlot={<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />}
|
|
||||||
trailingSlot={
|
|
||||||
onToggleFavorite && !disabled ? (
|
|
||||||
<Pressable
|
|
||||||
onPress={handleToggleFavorite}
|
|
||||||
hitSlop={8}
|
|
||||||
style={({ pressed, hovered }) => [
|
|
||||||
styles.favoriteButton,
|
|
||||||
hovered && styles.favoriteButtonHovered,
|
|
||||||
pressed && styles.favoriteButtonPressed,
|
|
||||||
]}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={isFavorite ? "Unfavorite model" : "Favorite model"}
|
|
||||||
testID={`favorite-model-${row.provider}-${row.modelId}`}
|
|
||||||
>
|
|
||||||
{({ hovered }) => (
|
|
||||||
<Star
|
|
||||||
size={16}
|
|
||||||
color={
|
|
||||||
isFavorite
|
|
||||||
? theme.colors.palette.amber[500]
|
|
||||||
: hovered
|
|
||||||
? theme.colors.foregroundMuted
|
|
||||||
: theme.colors.border
|
|
||||||
}
|
|
||||||
fill={isFavorite ? theme.colors.palette.amber[500] : "transparent"}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FavoritesSection({
|
|
||||||
favoriteRows,
|
|
||||||
selectedProvider,
|
|
||||||
selectedModel,
|
|
||||||
favoriteKeys,
|
|
||||||
onSelect,
|
|
||||||
canSelectProvider,
|
|
||||||
onToggleFavorite,
|
|
||||||
}: {
|
|
||||||
favoriteRows: SelectorModelRow[];
|
|
||||||
selectedProvider: string;
|
|
||||||
selectedModel: string;
|
|
||||||
favoriteKeys: Set<string>;
|
|
||||||
onSelect: (provider: string, modelId: string) => void;
|
|
||||||
canSelectProvider: (provider: string) => boolean;
|
|
||||||
onToggleFavorite?: (provider: string, modelId: string) => void;
|
|
||||||
}) {
|
|
||||||
const { theme } = useUnistyles();
|
|
||||||
|
|
||||||
if (favoriteRows.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.favoritesContainer}>
|
|
||||||
<View style={styles.sectionHeading}>
|
|
||||||
<Text style={styles.sectionHeadingText}>Favorites</Text>
|
|
||||||
</View>
|
|
||||||
{favoriteRows.map((row) => (
|
|
||||||
<ModelRow
|
|
||||||
key={row.favoriteKey}
|
|
||||||
row={row}
|
|
||||||
isSelected={row.provider === selectedProvider && row.modelId === selectedModel}
|
|
||||||
isFavorite={favoriteKeys.has(row.favoriteKey)}
|
|
||||||
disabled={!canSelectProvider(row.provider)}
|
|
||||||
elevated
|
|
||||||
onPress={() => onSelect(row.provider, row.modelId)}
|
|
||||||
onToggleFavorite={onToggleFavorite}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function GroupedProviderRows({
|
|
||||||
providerDefinitions,
|
|
||||||
groupedRows,
|
|
||||||
selectedProvider,
|
|
||||||
selectedModel,
|
|
||||||
favoriteKeys,
|
|
||||||
onSelect,
|
|
||||||
canSelectProvider,
|
|
||||||
onToggleFavorite,
|
|
||||||
onDrillDown,
|
|
||||||
viewKind,
|
|
||||||
}: {
|
|
||||||
providerDefinitions: AgentProviderDefinition[];
|
providerDefinitions: AgentProviderDefinition[];
|
||||||
groupedRows: Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }>;
|
allProviderModels: Map<string, AgentModelDefinition[]>;
|
||||||
selectedProvider: string;
|
selectedProvider: string;
|
||||||
selectedModel: string;
|
selectedModel: string;
|
||||||
favoriteKeys: Set<string>;
|
onSelect: (provider: AgentProvider, modelId: string) => void;
|
||||||
onSelect: (provider: string, modelId: string) => void;
|
isLoading: boolean;
|
||||||
canSelectProvider: (provider: string) => boolean;
|
disabled?: boolean;
|
||||||
onToggleFavorite?: (provider: string, modelId: string) => void;
|
|
||||||
onDrillDown: (providerId: string, providerLabel: string) => void;
|
|
||||||
viewKind: SelectorView["kind"];
|
|
||||||
}) {
|
|
||||||
const { theme } = useUnistyles();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View>
|
|
||||||
{groupedRows.map((group, index) => {
|
|
||||||
const providerDefinition = providerDefinitions.find(
|
|
||||||
(definition) => definition.id === group.providerId,
|
|
||||||
);
|
|
||||||
const ProvIcon = getProviderIcon(group.providerId);
|
|
||||||
const isInline = viewKind === "provider";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View key={group.providerId}>
|
|
||||||
{index > 0 ? <View style={styles.separator} /> : null}
|
|
||||||
{isInline ? (
|
|
||||||
<>
|
|
||||||
{sortFavoritesFirst(group.rows, favoriteKeys).map((row) => (
|
|
||||||
<ModelRow
|
|
||||||
key={row.favoriteKey}
|
|
||||||
row={row}
|
|
||||||
isSelected={row.provider === selectedProvider && row.modelId === selectedModel}
|
|
||||||
isFavorite={favoriteKeys.has(row.favoriteKey)}
|
|
||||||
disabled={!canSelectProvider(row.provider)}
|
|
||||||
onPress={() => onSelect(row.provider, row.modelId)}
|
|
||||||
onToggleFavorite={onToggleFavorite}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Pressable
|
|
||||||
onPress={() => onDrillDown(group.providerId, group.providerLabel)}
|
|
||||||
style={({ pressed, hovered }) => [
|
|
||||||
styles.drillDownRow,
|
|
||||||
hovered && styles.drillDownRowHovered,
|
|
||||||
pressed && styles.drillDownRowPressed,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<ProvIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
|
||||||
<Text style={styles.drillDownText}>{group.providerLabel}</Text>
|
|
||||||
<View style={styles.drillDownTrailing}>
|
|
||||||
<Text style={styles.drillDownCount}>
|
|
||||||
{group.rows.length} {group.rows.length === 1 ? "model" : "models"}
|
|
||||||
</Text>
|
|
||||||
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
|
||||||
</View>
|
|
||||||
</Pressable>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProviderSearchInput({
|
|
||||||
value,
|
|
||||||
onChangeText,
|
|
||||||
autoFocus = false,
|
|
||||||
}: {
|
|
||||||
value: string;
|
|
||||||
onChangeText: (text: string) => void;
|
|
||||||
autoFocus?: boolean;
|
|
||||||
}) {
|
|
||||||
const { theme } = useUnistyles();
|
|
||||||
const inputRef = useRef<TextInput>(null);
|
|
||||||
const isMobile = useIsCompactFormFactor();
|
|
||||||
const InputComponent = isMobile ? BottomSheetTextInput : TextInput;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (autoFocus && platformIsWeb && inputRef.current) {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
inputRef.current?.focus();
|
|
||||||
}, 50);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}, [autoFocus]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.providerSearchContainer}>
|
|
||||||
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
|
||||||
<InputComponent
|
|
||||||
ref={inputRef as any}
|
|
||||||
// @ts-expect-error - outlineStyle is web-only
|
|
||||||
style={[styles.providerSearchInput, platformIsWeb && { outlineStyle: "none" }]}
|
|
||||||
placeholder="Search models..."
|
|
||||||
placeholderTextColor={theme.colors.foregroundMuted}
|
|
||||||
value={value}
|
|
||||||
onChangeText={onChangeText}
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoCorrect={false}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectorContent({
|
|
||||||
view,
|
|
||||||
providerDefinitions,
|
|
||||||
allProviderModels,
|
|
||||||
selectedProvider,
|
|
||||||
selectedModel,
|
|
||||||
searchQuery,
|
|
||||||
onSearchChange,
|
|
||||||
favoriteKeys,
|
|
||||||
onSelect,
|
|
||||||
canSelectProvider,
|
|
||||||
onToggleFavorite,
|
|
||||||
onDrillDown,
|
|
||||||
}: SelectorContentProps) {
|
|
||||||
const { theme } = useUnistyles();
|
|
||||||
const allRows = useMemo(
|
|
||||||
() => buildModelRows(providerDefinitions, allProviderModels),
|
|
||||||
[allProviderModels, providerDefinitions],
|
|
||||||
);
|
|
||||||
|
|
||||||
const scopedRows = useMemo(() => {
|
|
||||||
if (view.kind === "provider") {
|
|
||||||
return allRows.filter((row) => row.provider === view.providerId);
|
|
||||||
}
|
|
||||||
return allRows;
|
|
||||||
}, [allRows, view]);
|
|
||||||
|
|
||||||
const normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]);
|
|
||||||
|
|
||||||
const visibleRows = useMemo(
|
|
||||||
() => scopedRows.filter((row) => matchesSearch(row, normalizedQuery)),
|
|
||||||
[normalizedQuery, scopedRows],
|
|
||||||
);
|
|
||||||
|
|
||||||
const { favoriteRows, regularRows } = useMemo(
|
|
||||||
() => partitionRows(visibleRows, favoriteKeys),
|
|
||||||
[favoriteKeys, visibleRows],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Group ALL visible rows by provider — favorites are a cross-cutting view,
|
|
||||||
// not a partition. A model being favorited doesn't remove it from its provider.
|
|
||||||
const allGroupedRows = useMemo(() => groupRowsByProvider(visibleRows), [visibleRows]);
|
|
||||||
|
|
||||||
// When searching at Level 1, filter grouped rows to only providers whose name or models match
|
|
||||||
const filteredGroupedRows = useMemo(() => {
|
|
||||||
if (view.kind === "provider" || !normalizedQuery) {
|
|
||||||
return allGroupedRows;
|
|
||||||
}
|
|
||||||
return allGroupedRows.filter(
|
|
||||||
(group) =>
|
|
||||||
group.providerLabel.toLowerCase().includes(normalizedQuery) || group.rows.length > 0,
|
|
||||||
);
|
|
||||||
}, [allGroupedRows, normalizedQuery, view.kind]);
|
|
||||||
|
|
||||||
const hasResults = favoriteRows.length > 0 || filteredGroupedRows.length > 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View>
|
|
||||||
{view.kind === "all" ? (
|
|
||||||
<FavoritesSection
|
|
||||||
favoriteRows={favoriteRows}
|
|
||||||
selectedProvider={selectedProvider}
|
|
||||||
selectedModel={selectedModel}
|
|
||||||
favoriteKeys={favoriteKeys}
|
|
||||||
onSelect={onSelect}
|
|
||||||
canSelectProvider={canSelectProvider}
|
|
||||||
onToggleFavorite={onToggleFavorite}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{filteredGroupedRows.length > 0 ? (
|
|
||||||
<GroupedProviderRows
|
|
||||||
providerDefinitions={providerDefinitions}
|
|
||||||
groupedRows={filteredGroupedRows}
|
|
||||||
selectedProvider={selectedProvider}
|
|
||||||
selectedModel={selectedModel}
|
|
||||||
favoriteKeys={favoriteKeys}
|
|
||||||
onSelect={onSelect}
|
|
||||||
canSelectProvider={canSelectProvider}
|
|
||||||
onToggleFavorite={onToggleFavorite}
|
|
||||||
onDrillDown={onDrillDown}
|
|
||||||
viewKind={view.kind}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{!hasResults ? (
|
|
||||||
<View style={styles.emptyState}>
|
|
||||||
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
|
||||||
<Text style={styles.emptyStateText}>No models match your search</Text>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProviderBackButton({
|
|
||||||
providerId,
|
|
||||||
providerLabel,
|
|
||||||
onBack,
|
|
||||||
}: {
|
|
||||||
providerId: string;
|
|
||||||
providerLabel: string;
|
|
||||||
onBack?: () => void;
|
|
||||||
}) {
|
|
||||||
const { theme } = useUnistyles();
|
|
||||||
const ProviderIcon = getProviderIcon(providerId);
|
|
||||||
|
|
||||||
if (!onBack) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Pressable
|
|
||||||
onPress={onBack}
|
|
||||||
style={({ pressed, hovered }) => [
|
|
||||||
styles.backButton,
|
|
||||||
hovered && styles.backButtonHovered,
|
|
||||||
pressed && styles.backButtonPressed,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<ArrowLeft size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
|
||||||
<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
|
||||||
<Text style={styles.backButtonText}>{providerLabel}</Text>
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CombinedModelSelector({
|
export function CombinedModelSelector({
|
||||||
@@ -509,102 +35,48 @@ export function CombinedModelSelector({
|
|||||||
selectedModel,
|
selectedModel,
|
||||||
onSelect,
|
onSelect,
|
||||||
isLoading,
|
isLoading,
|
||||||
canSelectProvider = () => true,
|
|
||||||
favoriteKeys = new Set<string>(),
|
|
||||||
onToggleFavorite,
|
|
||||||
renderTrigger,
|
|
||||||
onOpen,
|
|
||||||
onClose,
|
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}: CombinedModelSelectorProps) {
|
}: CombinedModelSelectorProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const anchorRef = useRef<View>(null);
|
const anchorRef = useRef<View>(null);
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [isContentReady, setIsContentReady] = useState(platformIsWeb);
|
const [view, setView] = useState<"groups" | DrillDownView>("groups");
|
||||||
const [view, setView] = useState<SelectorView>({ kind: "all" });
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
// Single-provider mode: only one provider with models → skip Level 1 entirely
|
|
||||||
const singleProviderView = useMemo<SelectorView | null>(() => {
|
|
||||||
const providers = Array.from(allProviderModels.keys());
|
|
||||||
if (providers.length !== 1) return null;
|
|
||||||
const providerId = providers[0]!;
|
|
||||||
const label = resolveProviderLabel(providerDefinitions, providerId);
|
|
||||||
return { kind: "provider", providerId, providerLabel: label };
|
|
||||||
}, [allProviderModels, providerDefinitions]);
|
|
||||||
|
|
||||||
const handleOpenChange = useCallback(
|
const handleOpenChange = useCallback(
|
||||||
(open: boolean) => {
|
(open: boolean) => {
|
||||||
setIsOpen(open);
|
setIsOpen(open);
|
||||||
setView(singleProviderView ?? { kind: "all" });
|
|
||||||
if (open) {
|
if (open) {
|
||||||
onOpen?.();
|
const models = allProviderModels.get(selectedProvider);
|
||||||
|
if (models && models.length > INLINE_MODEL_THRESHOLD) {
|
||||||
|
setView({ provider: selectedProvider });
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
|
setView("groups");
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
onClose?.();
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onOpen, onClose, singleProviderView],
|
[allProviderModels, selectedProvider],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSelect = useCallback(
|
const handleSelect = useCallback(
|
||||||
(provider: string, modelId: string) => {
|
(provider: string, modelId: string) => {
|
||||||
onSelect(provider as AgentProvider, modelId);
|
onSelect(provider as AgentProvider, modelId);
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
setView(singleProviderView ?? { kind: "all" });
|
setView("groups");
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
},
|
},
|
||||||
[onSelect, singleProviderView],
|
[onSelect],
|
||||||
);
|
);
|
||||||
|
|
||||||
const ProviderIcon = getProviderIcon(selectedProvider);
|
const ProviderIcon = getProviderIcon(selectedProvider);
|
||||||
const selectedProviderLabel = useMemo(
|
|
||||||
() => resolveProviderLabel(providerDefinitions, selectedProvider),
|
|
||||||
[providerDefinitions, selectedProvider],
|
|
||||||
);
|
|
||||||
|
|
||||||
const selectedModelLabel = useMemo(() => {
|
const selectedModelLabel = useMemo(() => {
|
||||||
const models = allProviderModels.get(selectedProvider);
|
const models = allProviderModels.get(selectedProvider);
|
||||||
if (!models) {
|
if (!models) return isLoading ? "Loading..." : "Select model";
|
||||||
return isLoading ? "Loading..." : "Select model";
|
const model = models.find((m) => m.id === selectedModel);
|
||||||
}
|
|
||||||
const model = models.find((entry) => entry.id === selectedModel);
|
|
||||||
return model?.label ?? resolveDefaultModelLabel(models);
|
return model?.label ?? resolveDefaultModelLabel(models);
|
||||||
}, [allProviderModels, isLoading, selectedModel, selectedProvider]);
|
}, [allProviderModels, selectedProvider, selectedModel, isLoading]);
|
||||||
|
|
||||||
const desktopFixedHeight = useMemo(() => {
|
|
||||||
if (view.kind !== "provider") {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const models = allProviderModels.get(view.providerId);
|
|
||||||
const modelCount = models?.length ?? 0;
|
|
||||||
return Math.min(80 + modelCount * 40, 400);
|
|
||||||
}, [allProviderModels, view]);
|
|
||||||
|
|
||||||
const triggerLabel = useMemo(() => {
|
|
||||||
if (selectedModelLabel === "Loading..." || selectedModelLabel === "Select model") {
|
|
||||||
return selectedModelLabel;
|
|
||||||
}
|
|
||||||
|
|
||||||
return buildSelectedTriggerLabel(selectedProviderLabel, selectedModelLabel);
|
|
||||||
}, [selectedModelLabel, selectedProviderLabel]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (platformIsWeb) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isOpen) {
|
|
||||||
setIsContentReady(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const frame = requestAnimationFrame(() => {
|
|
||||||
setIsContentReady(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => cancelAnimationFrame(frame);
|
|
||||||
}, [isOpen, platformIsWeb]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -618,26 +90,14 @@ export function CombinedModelSelector({
|
|||||||
hovered && styles.triggerHovered,
|
hovered && styles.triggerHovered,
|
||||||
(pressed || isOpen) && styles.triggerPressed,
|
(pressed || isOpen) && styles.triggerPressed,
|
||||||
disabled && styles.triggerDisabled,
|
disabled && styles.triggerDisabled,
|
||||||
renderTrigger ? styles.customTriggerWrapper : null,
|
|
||||||
]}
|
]}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={`Select model (${selectedModelLabel})`}
|
accessibilityLabel={`Select model (${selectedModelLabel})`}
|
||||||
testID="combined-model-selector"
|
testID="combined-model-selector"
|
||||||
>
|
>
|
||||||
{renderTrigger ? (
|
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
renderTrigger({
|
<Text style={styles.triggerText}>{selectedModelLabel}</Text>
|
||||||
selectedModelLabel: triggerLabel,
|
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
onPress: () => handleOpenChange(!isOpen),
|
|
||||||
disabled,
|
|
||||||
isOpen,
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
|
||||||
<Text style={styles.triggerText}>{triggerLabel}</Text>
|
|
||||||
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Combobox
|
<Combobox
|
||||||
options={[]}
|
options={[]}
|
||||||
@@ -645,62 +105,184 @@ export function CombinedModelSelector({
|
|||||||
onSelect={() => {}}
|
onSelect={() => {}}
|
||||||
open={isOpen}
|
open={isOpen}
|
||||||
onOpenChange={handleOpenChange}
|
onOpenChange={handleOpenChange}
|
||||||
stackBehavior="push"
|
|
||||||
anchorRef={anchorRef}
|
anchorRef={anchorRef}
|
||||||
desktopPlacement="top-start"
|
desktopPlacement="top-start"
|
||||||
desktopMinWidth={360}
|
|
||||||
desktopFixedHeight={desktopFixedHeight}
|
|
||||||
title="Select model"
|
title="Select model"
|
||||||
stickyHeader={
|
|
||||||
view.kind === "provider" ? (
|
|
||||||
<View style={styles.level2Header}>
|
|
||||||
{!singleProviderView ? (
|
|
||||||
<ProviderBackButton
|
|
||||||
providerId={view.providerId}
|
|
||||||
providerLabel={view.providerLabel}
|
|
||||||
onBack={() => {
|
|
||||||
setView({ kind: "all" });
|
|
||||||
setSearchQuery("");
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<ProviderSearchInput
|
|
||||||
value={searchQuery}
|
|
||||||
onChangeText={setSearchQuery}
|
|
||||||
autoFocus={platformIsWeb}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{isContentReady ? (
|
{view === "groups" ? (
|
||||||
<SelectorContent
|
<GroupsView
|
||||||
view={view}
|
|
||||||
providerDefinitions={providerDefinitions}
|
providerDefinitions={providerDefinitions}
|
||||||
allProviderModels={allProviderModels}
|
allProviderModels={allProviderModels}
|
||||||
selectedProvider={selectedProvider}
|
selectedProvider={selectedProvider}
|
||||||
selectedModel={selectedModel}
|
selectedModel={selectedModel}
|
||||||
searchQuery={searchQuery}
|
|
||||||
onSearchChange={setSearchQuery}
|
|
||||||
favoriteKeys={favoriteKeys}
|
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
canSelectProvider={canSelectProvider}
|
onDrillDown={(provider) => {
|
||||||
onToggleFavorite={onToggleFavorite}
|
setView({ provider });
|
||||||
onDrillDown={(providerId, providerLabel) => {
|
setSearchQuery("");
|
||||||
setView({ kind: "provider", providerId, providerLabel });
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.sheetLoadingState}>
|
<DrillDownModelView
|
||||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
provider={view.provider}
|
||||||
<Text style={styles.sheetLoadingText}>Loading model selector…</Text>
|
providerDefinitions={providerDefinitions}
|
||||||
</View>
|
models={allProviderModels.get(view.provider) ?? []}
|
||||||
|
selectedProvider={selectedProvider}
|
||||||
|
selectedModel={selectedModel}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
onSearchChange={setSearchQuery}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
onBack={() => {
|
||||||
|
setView("groups");
|
||||||
|
setSearchQuery("");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</Combobox>
|
</Combobox>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function GroupsView({
|
||||||
|
providerDefinitions,
|
||||||
|
allProviderModels,
|
||||||
|
selectedProvider,
|
||||||
|
selectedModel,
|
||||||
|
onSelect,
|
||||||
|
onDrillDown,
|
||||||
|
}: {
|
||||||
|
providerDefinitions: AgentProviderDefinition[];
|
||||||
|
allProviderModels: Map<string, AgentModelDefinition[]>;
|
||||||
|
selectedProvider: string;
|
||||||
|
selectedModel: string;
|
||||||
|
onSelect: (provider: string, modelId: string) => void;
|
||||||
|
onDrillDown: (provider: string) => void;
|
||||||
|
}) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
{providerDefinitions.map((def, index) => {
|
||||||
|
const models = allProviderModels.get(def.id) ?? [];
|
||||||
|
const isInline = models.length <= INLINE_MODEL_THRESHOLD;
|
||||||
|
const ProvIcon = getProviderIcon(def.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View key={def.id}>
|
||||||
|
{index > 0 ? <View style={styles.separator} /> : null}
|
||||||
|
|
||||||
|
{isInline ? (
|
||||||
|
<>
|
||||||
|
<View style={styles.sectionHeading}>
|
||||||
|
<ProvIcon size={14} color={theme.colors.foregroundMuted} />
|
||||||
|
<Text style={styles.sectionHeadingText}>{def.label}</Text>
|
||||||
|
</View>
|
||||||
|
{models.map((model) => (
|
||||||
|
<ComboboxItem
|
||||||
|
key={model.id}
|
||||||
|
label={model.label}
|
||||||
|
selected={model.id === selectedModel && def.id === selectedProvider}
|
||||||
|
onPress={() => onSelect(def.id, model.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => onDrillDown(def.id)}
|
||||||
|
style={({ pressed, hovered }) => [
|
||||||
|
styles.drillDownRow,
|
||||||
|
hovered && styles.drillDownRowHovered,
|
||||||
|
pressed && styles.drillDownRowPressed,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<ProvIcon size={14} color={theme.colors.foregroundMuted} />
|
||||||
|
<Text style={styles.drillDownText}>{def.label}</Text>
|
||||||
|
<View style={styles.drillDownTrailing}>
|
||||||
|
<Text style={styles.drillDownCount}>{models.length}</Text>
|
||||||
|
<ChevronRight size={14} color={theme.colors.foregroundMuted} />
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrillDownModelView({
|
||||||
|
provider,
|
||||||
|
providerDefinitions,
|
||||||
|
models,
|
||||||
|
selectedProvider,
|
||||||
|
selectedModel,
|
||||||
|
searchQuery,
|
||||||
|
onSearchChange,
|
||||||
|
onSelect,
|
||||||
|
onBack,
|
||||||
|
}: {
|
||||||
|
provider: string;
|
||||||
|
providerDefinitions: AgentProviderDefinition[];
|
||||||
|
models: AgentModelDefinition[];
|
||||||
|
selectedProvider: string;
|
||||||
|
selectedModel: string;
|
||||||
|
searchQuery: string;
|
||||||
|
onSearchChange: (query: string) => void;
|
||||||
|
onSelect: (provider: string, modelId: string) => void;
|
||||||
|
onBack: () => void;
|
||||||
|
}) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const ProvIcon = getProviderIcon(provider);
|
||||||
|
const providerLabel = providerDefinitions.find((d) => d.id === provider)?.label ?? provider;
|
||||||
|
|
||||||
|
const filteredModels = useMemo(() => {
|
||||||
|
if (!searchQuery.trim()) return models;
|
||||||
|
const q = searchQuery.toLowerCase();
|
||||||
|
return models.filter(
|
||||||
|
(m) => m.label.toLowerCase().includes(q) || m.id.toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}, [models, searchQuery]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<Pressable
|
||||||
|
onPress={onBack}
|
||||||
|
style={({ pressed, hovered }) => [
|
||||||
|
styles.backButton,
|
||||||
|
hovered && styles.backButtonHovered,
|
||||||
|
pressed && styles.backButtonPressed,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<ArrowLeft size={14} color={theme.colors.foregroundMuted} />
|
||||||
|
<ProvIcon size={14} color={theme.colors.foregroundMuted} />
|
||||||
|
<Text style={styles.backButtonText}>{providerLabel}</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<SearchInput
|
||||||
|
placeholder="Search models..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChangeText={onSearchChange}
|
||||||
|
autoFocus={Platform.OS === "web"}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{filteredModels.map((model) => (
|
||||||
|
<ComboboxItem
|
||||||
|
key={model.id}
|
||||||
|
label={model.label}
|
||||||
|
description={model.description}
|
||||||
|
selected={model.id === selectedModel && provider === selectedProvider}
|
||||||
|
onPress={() => onSelect(provider, model.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{filteredModels.length === 0 ? (
|
||||||
|
<View style={styles.emptyState}>
|
||||||
|
<Text style={styles.emptyStateText}>No models match your search</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
trigger: {
|
trigger: {
|
||||||
height: 28,
|
height: 28,
|
||||||
@@ -725,28 +307,17 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
fontWeight: theme.fontWeight.normal,
|
fontWeight: theme.fontWeight.normal,
|
||||||
},
|
},
|
||||||
customTriggerWrapper: {
|
|
||||||
paddingHorizontal: 0,
|
|
||||||
paddingVertical: 0,
|
|
||||||
height: "auto",
|
|
||||||
},
|
|
||||||
favoritesContainer: {
|
|
||||||
backgroundColor: theme.colors.surface1,
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: theme.colors.border,
|
|
||||||
},
|
|
||||||
separator: {
|
separator: {
|
||||||
height: 1,
|
height: 1,
|
||||||
backgroundColor: theme.colors.border,
|
backgroundColor: theme.colors.border,
|
||||||
|
marginVertical: theme.spacing[1],
|
||||||
},
|
},
|
||||||
sectionHeading: {
|
sectionHeading: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[3],
|
||||||
paddingTop: theme.spacing[2],
|
paddingVertical: theme.spacing[1],
|
||||||
paddingBottom: theme.spacing[1],
|
|
||||||
...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1] }),
|
|
||||||
},
|
},
|
||||||
sectionHeadingText: {
|
sectionHeadingText: {
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
@@ -760,7 +331,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[3],
|
||||||
paddingVertical: theme.spacing[2],
|
paddingVertical: theme.spacing[2],
|
||||||
minHeight: 36,
|
minHeight: 36,
|
||||||
...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1] }),
|
|
||||||
},
|
},
|
||||||
drillDownRowHovered: {
|
drillDownRowHovered: {
|
||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surface1,
|
||||||
@@ -770,8 +340,8 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
drillDownText: {
|
drillDownText: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.xs,
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foregroundMuted,
|
||||||
},
|
},
|
||||||
drillDownTrailing: {
|
drillDownTrailing: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
@@ -782,11 +352,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
},
|
},
|
||||||
level2Header: {
|
|
||||||
backgroundColor: theme.colors.surface1,
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: theme.colors.border,
|
|
||||||
},
|
|
||||||
backButton: {
|
backButton: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -795,10 +360,9 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
paddingVertical: theme.spacing[2],
|
paddingVertical: theme.spacing[2],
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: theme.colors.border,
|
borderBottomColor: theme.colors.border,
|
||||||
...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1] }),
|
|
||||||
},
|
},
|
||||||
backButtonHovered: {
|
backButtonHovered: {
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface1,
|
||||||
},
|
},
|
||||||
backButtonPressed: {
|
backButtonPressed: {
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
@@ -810,46 +374,9 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
emptyState: {
|
emptyState: {
|
||||||
paddingVertical: theme.spacing[4],
|
paddingVertical: theme.spacing[4],
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[2],
|
|
||||||
},
|
},
|
||||||
emptyStateText: {
|
emptyStateText: {
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
},
|
},
|
||||||
favoriteButton: {
|
|
||||||
width: 24,
|
|
||||||
height: 24,
|
|
||||||
borderRadius: theme.borderRadius.full,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
},
|
|
||||||
favoriteButtonHovered: {
|
|
||||||
backgroundColor: theme.colors.surface2,
|
|
||||||
},
|
|
||||||
favoriteButtonPressed: {
|
|
||||||
backgroundColor: theme.colors.surface1,
|
|
||||||
},
|
|
||||||
sheetLoadingState: {
|
|
||||||
minHeight: 160,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing[2],
|
|
||||||
},
|
|
||||||
sheetLoadingText: {
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
},
|
|
||||||
providerSearchContainer: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
paddingHorizontal: theme.spacing[3],
|
|
||||||
gap: theme.spacing[2],
|
|
||||||
...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1] }),
|
|
||||||
},
|
|
||||||
providerSearchInput: {
|
|
||||||
flex: 1,
|
|
||||||
paddingVertical: theme.spacing[3],
|
|
||||||
color: theme.colors.foreground,
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
|
|
||||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
|
||||||
import { buildFavoriteModelKey, type FavoriteModelRow } from "@/hooks/use-form-preferences";
|
|
||||||
|
|
||||||
export type SelectorModelRow = FavoriteModelRow;
|
|
||||||
|
|
||||||
export function resolveProviderLabel(
|
|
||||||
providerDefinitions: AgentProviderDefinition[],
|
|
||||||
providerId: string,
|
|
||||||
): string {
|
|
||||||
return (
|
|
||||||
providerDefinitions.find((definition) => definition.id === providerId)?.label ?? providerId
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildSelectedTriggerLabel(providerLabel: string, modelLabel: string): string {
|
|
||||||
return modelLabel;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildModelRows(
|
|
||||||
providerDefinitions: AgentProviderDefinition[],
|
|
||||||
allProviderModels: Map<string, AgentModelDefinition[]>,
|
|
||||||
): SelectorModelRow[] {
|
|
||||||
const providerLabelMap = new Map(
|
|
||||||
providerDefinitions.map((definition) => [definition.id, definition.label]),
|
|
||||||
);
|
|
||||||
const rows: SelectorModelRow[] = [];
|
|
||||||
|
|
||||||
for (const definition of providerDefinitions) {
|
|
||||||
const providerLabel = providerLabelMap.get(definition.id) ?? definition.label;
|
|
||||||
for (const model of allProviderModels.get(definition.id) ?? []) {
|
|
||||||
rows.push({
|
|
||||||
favoriteKey: buildFavoriteModelKey({ provider: definition.id, modelId: model.id }),
|
|
||||||
provider: definition.id,
|
|
||||||
providerLabel,
|
|
||||||
modelId: model.id,
|
|
||||||
modelLabel: model.label,
|
|
||||||
description: model.description,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function matchesSearch(row: SelectorModelRow, normalizedQuery: string): boolean {
|
|
||||||
if (!normalizedQuery) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [row.modelLabel, row.modelId, row.providerLabel].some((value) =>
|
|
||||||
value.toLowerCase().includes(normalizedQuery),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
|
import { Modal, Pressable, ScrollView, Text, TextInput, View, Platform } from "react-native";
|
||||||
import { memo, useEffect, useRef, type ReactNode } from "react";
|
import { memo, useEffect, useRef, type ReactNode } from "react";
|
||||||
import { Plus, Settings } from "lucide-react-native";
|
import { Plus, Settings } from "lucide-react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
@@ -8,7 +8,6 @@ import { formatTimeAgo } from "@/utils/time";
|
|||||||
import { shortenPath } from "@/utils/shorten-path";
|
import { shortenPath } from "@/utils/shorten-path";
|
||||||
import { AgentStatusDot } from "@/components/agent-status-dot";
|
import { AgentStatusDot } from "@/components/agent-status-dot";
|
||||||
import { Shortcut } from "@/components/ui/shortcut";
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
import { isNative } from "@/constants/platform";
|
|
||||||
|
|
||||||
function agentKey(agent: Pick<AggregatedAgent, "serverId" | "id">): string {
|
function agentKey(agent: Pick<AggregatedAgent, "serverId" | "id">): string {
|
||||||
return `${agent.serverId}:${agent.id}`;
|
return `${agent.serverId}:${agent.id}`;
|
||||||
@@ -91,7 +90,7 @@ export function CommandCenter() {
|
|||||||
}
|
}
|
||||||
}, [activeIndex, open]);
|
}, [activeIndex, open]);
|
||||||
|
|
||||||
if (isNative || !open) return null;
|
if (Platform.OS !== "web" || !open) return null;
|
||||||
|
|
||||||
const actionItems = items.filter((item) => item.kind === "action");
|
const actionItems = items.filter((item) => item.kind === "action");
|
||||||
const agentItems = items.filter((item) => item.kind === "agent");
|
const agentItems = items.filter((item) => item.kind === "agent");
|
||||||
@@ -269,7 +268,10 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderRadius: theme.borderRadius.lg,
|
borderRadius: theme.borderRadius.lg,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
...theme.shadow.lg,
|
shadowColor: "#000",
|
||||||
|
shadowOpacity: 0.4,
|
||||||
|
shadowRadius: 24,
|
||||||
|
shadowOffset: { width: 0, height: 12 },
|
||||||
},
|
},
|
||||||
header: {
|
header: {
|
||||||
paddingHorizontal: theme.spacing[4],
|
paddingHorizontal: theme.spacing[4],
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { resolveStatusControlMode } from "./agent-input-area.status-controls";
|
import { resolveStatusControlMode } from "./composer.status-controls";
|
||||||
|
|
||||||
describe("resolveStatusControlMode", () => {
|
describe("resolveStatusControlMode", () => {
|
||||||
it("uses ready mode when no controlled status controls are provided", () => {
|
it("uses ready mode when no controlled status controls are provided", () => {
|
||||||
+26
-78
@@ -1,7 +1,6 @@
|
|||||||
import { View, Pressable, Text, ActivityIndicator } from "react-native";
|
import { View, Pressable, Text, ActivityIndicator, Platform } from "react-native";
|
||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
|
||||||
import { useShallow } from "zustand/shallow";
|
import { useShallow } from "zustand/shallow";
|
||||||
import { ArrowUp, Square, Pencil, AudioLines } from "lucide-react-native";
|
import { ArrowUp, Square, Pencil, AudioLines } from "lucide-react-native";
|
||||||
import Animated from "react-native-reanimated";
|
import Animated from "react-native-reanimated";
|
||||||
@@ -13,7 +12,6 @@ import {
|
|||||||
DraftAgentStatusBar,
|
DraftAgentStatusBar,
|
||||||
type DraftAgentStatusBarProps,
|
type DraftAgentStatusBarProps,
|
||||||
} from "./agent-status-bar";
|
} from "./agent-status-bar";
|
||||||
import { ContextWindowMeter } from "./context-window-meter";
|
|
||||||
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
|
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import {
|
import {
|
||||||
@@ -43,14 +41,12 @@ import {
|
|||||||
persistAttachmentFromBlob,
|
persistAttachmentFromBlob,
|
||||||
persistAttachmentFromFileUri,
|
persistAttachmentFromFileUri,
|
||||||
} from "@/attachments/service";
|
} from "@/attachments/service";
|
||||||
import { resolveStatusControlMode } from "@/components/agent-input-area.status-controls";
|
import { resolveStatusControlMode } from "@/components/composer.status-controls";
|
||||||
import { markScrollInvestigationRender } from "@/utils/scroll-jank-investigation";
|
import { markScrollInvestigationRender } from "@/utils/scroll-jank-investigation";
|
||||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||||
import { submitAgentInput } from "@/components/agent-input-submit";
|
import { submitAgentInput } from "@/components/agent-input-submit";
|
||||||
import { useAppSettings } from "@/hooks/use-settings";
|
|
||||||
import { isWeb, isNative } from "@/constants/platform";
|
|
||||||
|
|
||||||
type QueuedMessage = {
|
type QueuedMessage = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -60,11 +56,12 @@ type QueuedMessage = {
|
|||||||
|
|
||||||
type ImageListUpdater = ImageAttachment[] | ((prev: ImageAttachment[]) => ImageAttachment[]);
|
type ImageListUpdater = ImageAttachment[] | ((prev: ImageAttachment[]) => ImageAttachment[]);
|
||||||
|
|
||||||
interface AgentInputAreaProps {
|
interface ComposerProps {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
isInputActive: boolean;
|
isInputActive: boolean;
|
||||||
onSubmitMessage?: (payload: MessagePayload) => Promise<void>;
|
onSubmitMessage?: (payload: MessagePayload) => Promise<void>;
|
||||||
|
allowEmptySubmit?: boolean;
|
||||||
/** Externally controlled loading state. When true, disables the submit button. */
|
/** Externally controlled loading state. When true, disables the submit button. */
|
||||||
isSubmitLoading?: boolean;
|
isSubmitLoading?: boolean;
|
||||||
/** When true, blurs the input immediately when submitting. */
|
/** When true, blurs the input immediately when submitting. */
|
||||||
@@ -78,8 +75,6 @@ interface AgentInputAreaProps {
|
|||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
/** Callback to expose the addImages function to parent components */
|
/** Callback to expose the addImages function to parent components */
|
||||||
onAddImages?: (addImages: (images: ImageAttachment[]) => void) => void;
|
onAddImages?: (addImages: (images: ImageAttachment[]) => void) => void;
|
||||||
/** Callback to expose a focus function to parent components (desktop only). */
|
|
||||||
onFocusInput?: (focus: () => void) => void;
|
|
||||||
/** Optional draft context for listing commands before an agent exists. */
|
/** Optional draft context for listing commands before an agent exists. */
|
||||||
commandDraftConfig?: DraftCommandConfig;
|
commandDraftConfig?: DraftCommandConfig;
|
||||||
/** Called when a message is about to be sent (any path: keyboard, dictation, queued). */
|
/** Called when a message is about to be sent (any path: keyboard, dictation, queued). */
|
||||||
@@ -95,11 +90,12 @@ const EMPTY_ARRAY: readonly QueuedMessage[] = [];
|
|||||||
const DESKTOP_MESSAGE_PLACEHOLDER = "Message the agent, tag @files, or use /commands and /skills";
|
const DESKTOP_MESSAGE_PLACEHOLDER = "Message the agent, tag @files, or use /commands and /skills";
|
||||||
const MOBILE_MESSAGE_PLACEHOLDER = "Message, @files, /commands";
|
const MOBILE_MESSAGE_PLACEHOLDER = "Message, @files, /commands";
|
||||||
|
|
||||||
export function AgentInputArea({
|
export function Composer({
|
||||||
agentId,
|
agentId,
|
||||||
serverId,
|
serverId,
|
||||||
isInputActive,
|
isInputActive,
|
||||||
onSubmitMessage,
|
onSubmitMessage,
|
||||||
|
allowEmptySubmit = false,
|
||||||
isSubmitLoading = false,
|
isSubmitLoading = false,
|
||||||
blurOnSubmit = false,
|
blurOnSubmit = false,
|
||||||
value,
|
value,
|
||||||
@@ -109,17 +105,16 @@ export function AgentInputArea({
|
|||||||
clearDraft,
|
clearDraft,
|
||||||
autoFocus = false,
|
autoFocus = false,
|
||||||
onAddImages,
|
onAddImages,
|
||||||
onFocusInput,
|
|
||||||
commandDraftConfig,
|
commandDraftConfig,
|
||||||
onMessageSent,
|
onMessageSent,
|
||||||
onComposerHeightChange,
|
onComposerHeightChange,
|
||||||
onAttentionInputFocus,
|
onAttentionInputFocus,
|
||||||
onAttentionPromptSend,
|
onAttentionPromptSend,
|
||||||
statusControls,
|
statusControls,
|
||||||
}: AgentInputAreaProps) {
|
}: ComposerProps) {
|
||||||
markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`);
|
markScrollInvestigationRender(`Composer:${serverId}:${agentId}`);
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const buttonIconSize = isWeb ? theme.iconSize.md : theme.iconSize.lg;
|
const buttonIconSize = Platform.OS === "web" ? theme.iconSize.md : theme.iconSize.lg;
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const client = useHostRuntimeClient(serverId);
|
const client = useHostRuntimeClient(serverId);
|
||||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||||
@@ -134,15 +129,11 @@ export function AgentInputArea({
|
|||||||
agentDirectoryStatus === "revalidating" ||
|
agentDirectoryStatus === "revalidating" ||
|
||||||
agentDirectoryStatus === "error_after_ready");
|
agentDirectoryStatus === "error_after_ready");
|
||||||
|
|
||||||
const { settings: appSettings } = useAppSettings();
|
|
||||||
|
|
||||||
const agentState = useSessionStore(
|
const agentState = useSessionStore(
|
||||||
useShallow((state) => {
|
useShallow((state) => {
|
||||||
const agent = state.sessions[serverId]?.agents?.get(agentId) ?? null;
|
const agent = state.sessions[serverId]?.agents?.get(agentId) ?? null;
|
||||||
return {
|
return {
|
||||||
status: agent?.status ?? null,
|
status: agent?.status ?? null,
|
||||||
contextWindowMaxTokens: agent?.lastUsage?.contextWindowMaxTokens ?? null,
|
|
||||||
contextWindowUsedTokens: agent?.lastUsage?.contextWindowUsedTokens ?? null,
|
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -156,8 +147,10 @@ export function AgentInputArea({
|
|||||||
const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail);
|
const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail);
|
||||||
const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead);
|
const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead);
|
||||||
|
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isDesktopWebBreakpoint =
|
||||||
const isDesktopWebBreakpoint = isWeb && !isMobile;
|
Platform.OS === "web" &&
|
||||||
|
UnistylesRuntime.breakpoint !== "xs" &&
|
||||||
|
UnistylesRuntime.breakpoint !== "sm";
|
||||||
const messagePlaceholder = isDesktopWebBreakpoint
|
const messagePlaceholder = isDesktopWebBreakpoint
|
||||||
? DESKTOP_MESSAGE_PLACEHOLDER
|
? DESKTOP_MESSAGE_PLACEHOLDER
|
||||||
: MOBILE_MESSAGE_PLACEHOLDER;
|
: MOBILE_MESSAGE_PLACEHOLDER;
|
||||||
@@ -217,21 +210,6 @@ export function AgentInputArea({
|
|||||||
onAddImages?.(addImages);
|
onAddImages?.(addImages);
|
||||||
}, [addImages, onAddImages]);
|
}, [addImages, onAddImages]);
|
||||||
|
|
||||||
const focusInput = useCallback(() => {
|
|
||||||
if (isNative) return;
|
|
||||||
focusWithRetries({
|
|
||||||
focus: () => messageInputRef.current?.focus(),
|
|
||||||
isFocused: () => {
|
|
||||||
const el = messageInputRef.current?.getNativeElement?.() ?? null;
|
|
||||||
return el != null && document.activeElement === el;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onFocusInput?.(focusInput);
|
|
||||||
}, [focusInput, onFocusInput]);
|
|
||||||
|
|
||||||
const submitMessage = useCallback(
|
const submitMessage = useCallback(
|
||||||
async (text: string, images?: ImageAttachment[]) => {
|
async (text: string, images?: ImageAttachment[]) => {
|
||||||
onMessageSent?.();
|
onMessageSent?.();
|
||||||
@@ -426,12 +404,8 @@ export function AgentInputArea({
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch (action.id) {
|
switch (action.id) {
|
||||||
case "message-input.send":
|
|
||||||
return messageInputRef.current?.runKeyboardAction("send") ?? false;
|
|
||||||
case "message-input.dictation-confirm":
|
|
||||||
return messageInputRef.current?.runKeyboardAction("dictation-confirm") ?? false;
|
|
||||||
case "message-input.focus":
|
case "message-input.focus":
|
||||||
if (isNative) {
|
if (Platform.OS !== "web") {
|
||||||
messageInputRef.current?.focus();
|
messageInputRef.current?.focus();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -468,10 +442,8 @@ export function AgentInputArea({
|
|||||||
handlerId: keyboardHandlerIdRef.current,
|
handlerId: keyboardHandlerIdRef.current,
|
||||||
actions: [
|
actions: [
|
||||||
"message-input.focus",
|
"message-input.focus",
|
||||||
"message-input.send",
|
|
||||||
"message-input.dictation-toggle",
|
"message-input.dictation-toggle",
|
||||||
"message-input.dictation-cancel",
|
"message-input.dictation-cancel",
|
||||||
"message-input.dictation-confirm",
|
|
||||||
"message-input.voice-toggle",
|
"message-input.voice-toggle",
|
||||||
"message-input.voice-mute-toggle",
|
"message-input.voice-mute-toggle",
|
||||||
],
|
],
|
||||||
@@ -510,7 +482,7 @@ export function AgentInputArea({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void voice.startVoice(serverId, agentId).catch((error) => {
|
void voice.startVoice(serverId, agentId).catch((error) => {
|
||||||
console.error("[AgentInputArea] Failed to start voice mode", error);
|
console.error("[Composer] Failed to start voice mode", error);
|
||||||
const message =
|
const message =
|
||||||
error instanceof Error ? error.message : typeof error === "string" ? error : null;
|
error instanceof Error ? error.message : typeof error === "string" ? error : null;
|
||||||
if (message && message.trim().length > 0) {
|
if (message && message.trim().length > 0) {
|
||||||
@@ -597,14 +569,14 @@ export function AgentInputArea({
|
|||||||
)}
|
)}
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top" align="center" offset={8}>
|
<TooltipContent side="top" align="center" offset={8}>
|
||||||
<View style={styles.tooltipRow}>
|
<View style={styles.tooltipRow}>
|
||||||
<Text style={styles.tooltipText}>Interrupt</Text>
|
<Text style={styles.tooltipText}>Interrupt</Text>
|
||||||
{dictationCancelKeys ? (
|
{dictationCancelKeys ? (
|
||||||
<Shortcut chord={dictationCancelKeys} style={styles.tooltipShortcut} />
|
<Shortcut chord={dictationCancelKeys} style={styles.tooltipShortcut} />
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
const rightContent = (
|
const rightContent = (
|
||||||
@@ -642,28 +614,11 @@ export function AgentInputArea({
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasContextWindowMeter =
|
|
||||||
typeof agentState.contextWindowMaxTokens === "number" &&
|
|
||||||
typeof agentState.contextWindowUsedTokens === "number";
|
|
||||||
const contextWindowMaxTokens = hasContextWindowMeter ? agentState.contextWindowMaxTokens : null;
|
|
||||||
const contextWindowUsedTokens = hasContextWindowMeter ? agentState.contextWindowUsedTokens : null;
|
|
||||||
|
|
||||||
const beforeVoiceContent = (
|
|
||||||
<View style={styles.contextWindowMeterSlot}>
|
|
||||||
{contextWindowMaxTokens !== null && contextWindowUsedTokens !== null ? (
|
|
||||||
<ContextWindowMeter
|
|
||||||
maxTokens={contextWindowMaxTokens}
|
|
||||||
usedTokens={contextWindowUsedTokens}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
const leftContent =
|
const leftContent =
|
||||||
resolveStatusControlMode(statusControls) === "draft" && statusControls ? (
|
resolveStatusControlMode(statusControls) === "draft" && statusControls ? (
|
||||||
<DraftAgentStatusBar {...statusControls} />
|
<DraftAgentStatusBar {...statusControls} />
|
||||||
) : (
|
) : (
|
||||||
<AgentStatusBar agentId={agentId} serverId={serverId} onDropdownClose={focusInput} />
|
<AgentStatusBar agentId={agentId} serverId={serverId} />
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -724,6 +679,7 @@ export function AgentInputArea({
|
|||||||
value={userInput}
|
value={userInput}
|
||||||
onChangeText={setUserInput}
|
onChangeText={setUserInput}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
|
allowEmptySubmit={allowEmptySubmit}
|
||||||
isSubmitDisabled={isProcessing || isSubmitLoading}
|
isSubmitDisabled={isProcessing || isSubmitLoading}
|
||||||
isSubmitLoading={isProcessing || isSubmitLoading}
|
isSubmitLoading={isProcessing || isSubmitLoading}
|
||||||
images={selectedImages}
|
images={selectedImages}
|
||||||
@@ -738,12 +694,10 @@ export function AgentInputArea({
|
|||||||
disabled={isSubmitLoading}
|
disabled={isSubmitLoading}
|
||||||
isInputActive={isInputActive}
|
isInputActive={isInputActive}
|
||||||
leftContent={leftContent}
|
leftContent={leftContent}
|
||||||
beforeVoiceContent={beforeVoiceContent}
|
|
||||||
rightContent={rightContent}
|
rightContent={rightContent}
|
||||||
voiceServerId={serverId}
|
voiceServerId={serverId}
|
||||||
voiceAgentId={agentId}
|
voiceAgentId={agentId}
|
||||||
isAgentRunning={isAgentRunning}
|
isAgentRunning={isAgentRunning}
|
||||||
defaultSendBehavior={appSettings.sendBehavior}
|
|
||||||
onQueue={handleQueue}
|
onQueue={handleQueue}
|
||||||
onSubmitLoadingPress={isAgentRunning ? handleCancelAgent : undefined}
|
onSubmitLoadingPress={isAgentRunning ? handleCancelAgent : undefined}
|
||||||
onKeyPress={handleCommandKeyPress}
|
onKeyPress={handleCommandKeyPress}
|
||||||
@@ -815,12 +769,6 @@ const styles = StyleSheet.create(((theme: Theme) => ({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
},
|
},
|
||||||
contextWindowMeterSlot: {
|
|
||||||
width: 28,
|
|
||||||
height: 28,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
},
|
|
||||||
realtimeVoiceButton: {
|
realtimeVoiceButton: {
|
||||||
width: 28,
|
width: 28,
|
||||||
height: 28,
|
height: 28,
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
import { Pressable, Text, View } from "react-native";
|
|
||||||
import Svg, { Circle } from "react-native-svg";
|
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
|
||||||
|
|
||||||
type ContextWindowMeterProps = {
|
|
||||||
maxTokens: number;
|
|
||||||
usedTokens: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const SVG_SIZE = 20;
|
|
||||||
const CENTER = SVG_SIZE / 2;
|
|
||||||
const RADIUS = 7;
|
|
||||||
const STROKE_WIDTH = 2.25;
|
|
||||||
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
|
|
||||||
|
|
||||||
function isValidMaxTokens(value: number): boolean {
|
|
||||||
return Number.isFinite(value) && value > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isValidUsedTokens(value: number): boolean {
|
|
||||||
return Number.isFinite(value) && value >= 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getUsagePercentage(maxTokens: number, usedTokens: number): number | null {
|
|
||||||
if (!isValidMaxTokens(maxTokens) || !isValidUsedTokens(usedTokens)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (usedTokens / maxTokens) * 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clampPercentage(value: number): number {
|
|
||||||
return Math.max(0, Math.min(100, value));
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTokenCount(value: number): string {
|
|
||||||
if (value >= 1_000_000) {
|
|
||||||
return `${Math.round(value / 1_000_000)}m`;
|
|
||||||
}
|
|
||||||
if (value >= 1_000) {
|
|
||||||
return `${Math.round(value / 1_000)}k`;
|
|
||||||
}
|
|
||||||
return Math.round(value).toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function getMeterColors(
|
|
||||||
percentage: number,
|
|
||||||
theme: ReturnType<typeof useUnistyles>["theme"],
|
|
||||||
): { progress: string; track: string } {
|
|
||||||
const track = theme.colors.surface3;
|
|
||||||
if (percentage > 90) {
|
|
||||||
return { progress: theme.colors.destructive, track };
|
|
||||||
}
|
|
||||||
if (percentage >= 70) {
|
|
||||||
return { progress: theme.colors.palette.amber[500], track };
|
|
||||||
}
|
|
||||||
return { progress: theme.colors.foregroundMuted, track };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ContextWindowMeter({ maxTokens, usedTokens }: ContextWindowMeterProps) {
|
|
||||||
const { theme } = useUnistyles();
|
|
||||||
const percentage = getUsagePercentage(maxTokens, usedTokens);
|
|
||||||
|
|
||||||
if (percentage === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const clampedPercentage = clampPercentage(percentage);
|
|
||||||
const roundedPercentage = Math.round(percentage);
|
|
||||||
const dashOffset = CIRCUMFERENCE - (clampedPercentage / 100) * CIRCUMFERENCE;
|
|
||||||
const colors = getMeterColors(clampedPercentage, theme);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
|
|
||||||
<TooltipTrigger asChild triggerRefProp="ref">
|
|
||||||
<Pressable
|
|
||||||
style={styles.container}
|
|
||||||
accessibilityRole="image"
|
|
||||||
accessibilityLabel={`Context window ${roundedPercentage}% used`}
|
|
||||||
>
|
|
||||||
<Svg
|
|
||||||
width={SVG_SIZE}
|
|
||||||
height={SVG_SIZE}
|
|
||||||
viewBox={`0 0 ${SVG_SIZE} ${SVG_SIZE}`}
|
|
||||||
style={styles.svg}
|
|
||||||
accessibilityElementsHidden
|
|
||||||
importantForAccessibility="no-hide-descendants"
|
|
||||||
>
|
|
||||||
<Circle
|
|
||||||
cx={CENTER}
|
|
||||||
cy={CENTER}
|
|
||||||
r={RADIUS}
|
|
||||||
fill="none"
|
|
||||||
stroke={colors.track}
|
|
||||||
strokeWidth={STROKE_WIDTH}
|
|
||||||
/>
|
|
||||||
<Circle
|
|
||||||
cx={CENTER}
|
|
||||||
cy={CENTER}
|
|
||||||
r={RADIUS}
|
|
||||||
fill="none"
|
|
||||||
stroke={colors.progress}
|
|
||||||
strokeWidth={STROKE_WIDTH}
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeDasharray={CIRCUMFERENCE}
|
|
||||||
strokeDashoffset={dashOffset}
|
|
||||||
/>
|
|
||||||
</Svg>
|
|
||||||
</Pressable>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top" align="center" offset={8}>
|
|
||||||
<View style={styles.tooltipContent}>
|
|
||||||
<Text style={styles.tooltipTitle}>Context window</Text>
|
|
||||||
<Text style={styles.tooltipText}>{`${roundedPercentage}% used`}</Text>
|
|
||||||
<Text
|
|
||||||
style={styles.tooltipDetail}
|
|
||||||
>{`${formatTokenCount(usedTokens)} / ${formatTokenCount(maxTokens)} tokens`}</Text>
|
|
||||||
</View>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
|
||||||
container: {
|
|
||||||
width: 28,
|
|
||||||
height: 28,
|
|
||||||
borderRadius: theme.borderRadius.full,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
},
|
|
||||||
svg: {
|
|
||||||
transform: [{ rotate: "-90deg" }],
|
|
||||||
},
|
|
||||||
tooltipContent: {
|
|
||||||
gap: theme.spacing[1],
|
|
||||||
},
|
|
||||||
tooltipTitle: {
|
|
||||||
color: theme.colors.foreground,
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
fontWeight: theme.fontWeight.semibold,
|
|
||||||
},
|
|
||||||
tooltipText: {
|
|
||||||
color: theme.colors.foreground,
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
lineHeight: theme.fontSize.sm * 1.4,
|
|
||||||
},
|
|
||||||
tooltipDetail: {
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
fontSize: theme.fontSize.xs,
|
|
||||||
lineHeight: theme.fontSize.xs * 1.4,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import { getIsElectronRuntime } from "@/constants/layout";
|
|
||||||
import { isNative } from "@/constants/platform";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* VS Code-style titlebar drag region for Electron.
|
|
||||||
*
|
|
||||||
* Copied from VS Code at commit daa0a70:
|
|
||||||
* - titlebarPart.ts:463-464 → prepend(container, $('div.titlebar-drag-region'))
|
|
||||||
* - titlebarpart.css:57-64 → position: absolute, full size, -webkit-app-region: drag
|
|
||||||
* - titlebarpart.css:249-260 → top-edge resizer, no-drag, 4px
|
|
||||||
*
|
|
||||||
* VS Code's drag region is a static DOM element — no z-index, no pointer-events,
|
|
||||||
* no state, no event listeners. Interactive elements get no-drag from their own
|
|
||||||
* CSS (global backstop in index.html). The drag region never re-renders.
|
|
||||||
*
|
|
||||||
* The resizer is Windows/Linux only (titlebarpart.css:249 scopes to .windows/.linux).
|
|
||||||
* On macOS, Electron handles edge resize natively.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Static drag overlay and top-edge resizer. Returns null on non-Electron.
|
|
||||||
* Place as FIRST child of any positioned container that should be draggable.
|
|
||||||
*/
|
|
||||||
export function TitlebarDragRegion() {
|
|
||||||
if (isNative || !getIsElectronRuntime()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Drag overlay — VS Code .titlebar-drag-region (titlebarpart.css:57-64) */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
display: "block",
|
|
||||||
position: "absolute",
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
// @ts-expect-error — WebkitAppRegion is not in CSSProperties
|
|
||||||
WebkitAppRegion: "drag",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* Top-edge resizer — VS Code .resizer (titlebarpart.css:249-256) */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
width: "100%",
|
|
||||||
height: 4,
|
|
||||||
// @ts-expect-error — WebkitAppRegion is not in CSSProperties
|
|
||||||
WebkitAppRegion: "no-drag",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { View, Text, ScrollView as RNScrollView } from "react-native";
|
import { View, Text, Platform, ScrollView as RNScrollView } from "react-native";
|
||||||
import { ScrollView as GHScrollView } from "react-native-gesture-handler";
|
import { ScrollView as GHScrollView } from "react-native-gesture-handler";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { Fonts } from "@/constants/theme";
|
import { Fonts } from "@/constants/theme";
|
||||||
import type { DiffLine, DiffSegment } from "@/utils/tool-call-parsers";
|
import type { DiffLine, DiffSegment } from "@/utils/tool-call-parsers";
|
||||||
import { getCodeInsets } from "./code-insets";
|
import { getCodeInsets } from "./code-insets";
|
||||||
import { isWeb } from "@/constants/platform";
|
|
||||||
|
|
||||||
const ScrollView = isWeb ? RNScrollView : GHScrollView;
|
const ScrollView = Platform.OS === "web" ? RNScrollView : GHScrollView;
|
||||||
|
|
||||||
interface DiffViewerProps {
|
interface DiffViewerProps {
|
||||||
diffLines: DiffLine[];
|
diffLines: DiffLine[];
|
||||||
@@ -131,7 +130,7 @@ const styles = StyleSheet.create((theme) => {
|
|||||||
fontFamily: Fonts.mono,
|
fontFamily: Fonts.mono,
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
...(isWeb
|
...(Platform.OS === "web"
|
||||||
? {
|
? {
|
||||||
whiteSpace: "pre",
|
whiteSpace: "pre",
|
||||||
overflowWrap: "normal",
|
overflowWrap: "normal",
|
||||||
|
|||||||
@@ -108,7 +108,11 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
borderColor: theme.colors.border,
|
borderColor: theme.colors.border,
|
||||||
paddingVertical: theme.spacing[3],
|
paddingVertical: theme.spacing[3],
|
||||||
paddingHorizontal: theme.spacing[4],
|
paddingHorizontal: theme.spacing[4],
|
||||||
...theme.shadow.md,
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.15,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 8,
|
||||||
},
|
},
|
||||||
textContainer: {
|
textContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
} from "@dnd-kit/sortable";
|
} from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import type { DraggableListProps, DraggableRenderItemInfo } from "./draggable-list.types";
|
import type { DraggableListProps, DraggableRenderItemInfo } from "./draggable-list.types";
|
||||||
import { useWebScrollViewScrollbar } from "./use-web-scrollbar";
|
import { WebDesktopScrollbarOverlay, useWebDesktopScrollbarMetrics } from "./web-desktop-scrollbar";
|
||||||
|
|
||||||
export type { DraggableListProps, DraggableRenderItemInfo };
|
export type { DraggableListProps, DraggableRenderItemInfo };
|
||||||
|
|
||||||
@@ -133,11 +133,8 @@ export function DraggableList<T>({
|
|||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
const [dragItems, setDragItems] = useState<T[] | null>(null);
|
const [dragItems, setDragItems] = useState<T[] | null>(null);
|
||||||
const items = dragItems ?? data;
|
const items = dragItems ?? data;
|
||||||
const showCustomScrollbar = enableDesktopWebScrollbar && scrollEnabled;
|
|
||||||
const scrollViewRef = useRef<ScrollView>(null);
|
const scrollViewRef = useRef<ScrollView>(null);
|
||||||
const scrollbar = useWebScrollViewScrollbar(scrollViewRef, {
|
const scrollbarMetrics = useWebDesktopScrollbarMetrics();
|
||||||
enabled: showCustomScrollbar,
|
|
||||||
});
|
|
||||||
|
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, {
|
useSensor(PointerSensor, {
|
||||||
@@ -180,6 +177,7 @@ export function DraggableList<T>({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const ids = items.map((item, index) => keyExtractor(item, index));
|
const ids = items.map((item, index) => keyExtractor(item, index));
|
||||||
|
const showCustomScrollbar = enableDesktopWebScrollbar && scrollEnabled;
|
||||||
const wrapperStyle = [
|
const wrapperStyle = [
|
||||||
{ position: "relative" as const },
|
{ position: "relative" as const },
|
||||||
scrollEnabled ? { flex: 1, minHeight: 0 } : null,
|
scrollEnabled ? { flex: 1, minHeight: 0 } : null,
|
||||||
@@ -195,10 +193,12 @@ export function DraggableList<T>({
|
|||||||
style={style}
|
style={style}
|
||||||
contentContainerStyle={contentContainerStyle}
|
contentContainerStyle={contentContainerStyle}
|
||||||
showsVerticalScrollIndicator={showCustomScrollbar ? false : showsVerticalScrollIndicator}
|
showsVerticalScrollIndicator={showCustomScrollbar ? false : showsVerticalScrollIndicator}
|
||||||
onLayout={scrollbar.onLayout}
|
onLayout={showCustomScrollbar ? scrollbarMetrics.onLayout : undefined}
|
||||||
onContentSizeChange={scrollbar.onContentSizeChange}
|
onContentSizeChange={
|
||||||
onScroll={scrollbar.onScroll}
|
showCustomScrollbar ? scrollbarMetrics.onContentSizeChange : undefined
|
||||||
scrollEventThrottle={16}
|
}
|
||||||
|
onScroll={showCustomScrollbar ? scrollbarMetrics.onScroll : undefined}
|
||||||
|
scrollEventThrottle={showCustomScrollbar ? 16 : undefined}
|
||||||
>
|
>
|
||||||
{ListHeaderComponent}
|
{ListHeaderComponent}
|
||||||
{items.length === 0 && ListEmptyComponent}
|
{items.length === 0 && ListEmptyComponent}
|
||||||
@@ -259,7 +259,13 @@ export function DraggableList<T>({
|
|||||||
{ListFooterComponent}
|
{ListFooterComponent}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{scrollbar.overlay}
|
<WebDesktopScrollbarOverlay
|
||||||
|
enabled={showCustomScrollbar}
|
||||||
|
metrics={scrollbarMetrics}
|
||||||
|
onScrollToOffset={(nextOffset) => {
|
||||||
|
scrollViewRef.current?.scrollTo({ y: nextOffset, animated: false });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,10 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
import {
|
import { View, Text, Pressable, Platform, useWindowDimensions } from "react-native";
|
||||||
View,
|
|
||||||
Text,
|
|
||||||
Pressable,
|
|
||||||
useWindowDimensions,
|
|
||||||
StyleSheet as RNStyleSheet,
|
|
||||||
} from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useIsFocused } from "@react-navigation/native";
|
import { useIsFocused } from "@react-navigation/native";
|
||||||
import Animated, { useAnimatedStyle, useSharedValue, runOnJS } from "react-native-reanimated";
|
import Animated, { useAnimatedStyle, useSharedValue, runOnJS } from "react-native-reanimated";
|
||||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
import { X } from "lucide-react-native";
|
import { X } from "lucide-react-native";
|
||||||
import {
|
import {
|
||||||
usePanelStore,
|
usePanelStore,
|
||||||
@@ -19,13 +13,11 @@ import {
|
|||||||
type ExplorerTab,
|
type ExplorerTab,
|
||||||
} from "@/stores/panel-store";
|
} from "@/stores/panel-store";
|
||||||
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
||||||
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
|
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
|
||||||
import { GitDiffPane } from "./git-diff-pane";
|
import { GitDiffPane } from "./git-diff-pane";
|
||||||
import { FileExplorerPane } from "./file-explorer-pane";
|
import { FileExplorerPane } from "./file-explorer-pane";
|
||||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||||
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
|
||||||
import { isWeb } from "@/constants/platform";
|
|
||||||
|
|
||||||
const MIN_CHAT_WIDTH = 400;
|
const MIN_CHAT_WIDTH = 400;
|
||||||
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
|
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
|
||||||
@@ -48,7 +40,7 @@ export function ExplorerSidebar({
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isScreenFocused = useIsFocused();
|
const isScreenFocused = useIsFocused();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const mobileView = usePanelStore((state) => state.mobileView);
|
||||||
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
||||||
const closeToAgent = usePanelStore((state) => state.closeToAgent);
|
const closeToAgent = usePanelStore((state) => state.closeToAgent);
|
||||||
@@ -88,7 +80,6 @@ export function ExplorerSidebar({
|
|||||||
animateToOpen,
|
animateToOpen,
|
||||||
animateToClose,
|
animateToClose,
|
||||||
isGesturing,
|
isGesturing,
|
||||||
gestureAnimatingRef,
|
|
||||||
closeGestureRef,
|
closeGestureRef,
|
||||||
} = useExplorerSidebarAnimation();
|
} = useExplorerSidebarAnimation();
|
||||||
|
|
||||||
@@ -109,11 +100,6 @@ export function ExplorerSidebar({
|
|||||||
[closeToAgent, desktopFileExplorerOpen, isOpen, mobileView],
|
[closeToAgent, desktopFileExplorerOpen, isOpen, mobileView],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCloseFromGesture = useCallback(() => {
|
|
||||||
gestureAnimatingRef.current = true;
|
|
||||||
closeToAgent();
|
|
||||||
}, [closeToAgent, gestureAnimatingRef]);
|
|
||||||
|
|
||||||
const enableSidebarCloseGesture = isMobile && isOpen;
|
const enableSidebarCloseGesture = isMobile && isOpen;
|
||||||
|
|
||||||
const handleTabPress = useCallback(
|
const handleTabPress = useCallback(
|
||||||
@@ -188,7 +174,7 @@ export function ExplorerSidebar({
|
|||||||
});
|
});
|
||||||
if (shouldClose) {
|
if (shouldClose) {
|
||||||
animateToClose();
|
animateToClose();
|
||||||
runOnJS(handleCloseFromGesture)();
|
runOnJS(handleClose)("swipe-close-gesture");
|
||||||
} else {
|
} else {
|
||||||
animateToOpen();
|
animateToOpen();
|
||||||
}
|
}
|
||||||
@@ -203,7 +189,7 @@ export function ExplorerSidebar({
|
|||||||
backdropOpacity,
|
backdropOpacity,
|
||||||
animateToOpen,
|
animateToOpen,
|
||||||
animateToClose,
|
animateToClose,
|
||||||
handleCloseFromGesture,
|
handleClose,
|
||||||
isGesturing,
|
isGesturing,
|
||||||
closeGestureRef,
|
closeGestureRef,
|
||||||
closeTouchStartX,
|
closeTouchStartX,
|
||||||
@@ -252,7 +238,7 @@ export function ExplorerSidebar({
|
|||||||
|
|
||||||
// Mobile: full-screen overlay with gesture.
|
// Mobile: full-screen overlay with gesture.
|
||||||
// On web, keep it interactive only while open so closed sidebars don't eat taps.
|
// On web, keep it interactive only while open so closed sidebars don't eat taps.
|
||||||
const overlayPointerEvents = isWeb ? (isOpen ? "auto" : "none") : "box-none";
|
const overlayPointerEvents = Platform.OS === "web" ? (isOpen ? "auto" : "none") : "box-none";
|
||||||
|
|
||||||
// Navigation stacks can keep previous screens mounted; hide sidebars for unfocused
|
// Navigation stacks can keep previous screens mounted; hide sidebars for unfocused
|
||||||
// screens so only the active screen exposes explorer/terminal surfaces.
|
// screens so only the active screen exposes explorer/terminal surfaces.
|
||||||
@@ -264,17 +250,18 @@ export function ExplorerSidebar({
|
|||||||
return (
|
return (
|
||||||
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
|
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
|
||||||
{/* Backdrop */}
|
{/* Backdrop */}
|
||||||
<Animated.View style={[explorerStaticStyles.backdrop, backdropAnimatedStyle]} />
|
<Animated.View style={[styles.backdrop, backdropAnimatedStyle]}>
|
||||||
|
<Pressable
|
||||||
|
style={styles.backdropPressable}
|
||||||
|
onPress={() => handleClose("backdrop-press")}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
<GestureDetector gesture={closeGesture} touchAction="pan-y">
|
<GestureDetector gesture={closeGesture} touchAction="pan-y">
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[
|
style={[
|
||||||
explorerStaticStyles.mobileSidebar,
|
styles.mobileSidebar,
|
||||||
{
|
{ width: windowWidth, paddingTop: insets.top },
|
||||||
width: windowWidth,
|
|
||||||
paddingTop: insets.top,
|
|
||||||
backgroundColor: theme.colors.surfaceSidebar,
|
|
||||||
},
|
|
||||||
sidebarAnimatedStyle,
|
sidebarAnimatedStyle,
|
||||||
mobileKeyboardInsetStyle,
|
mobileKeyboardInsetStyle,
|
||||||
]}
|
]}
|
||||||
@@ -303,27 +290,25 @@ export function ExplorerSidebar({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View
|
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }]}>
|
||||||
style={[explorerStaticStyles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }]}
|
{/* Resize handle - absolutely positioned over left border */}
|
||||||
>
|
<GestureDetector gesture={resizeGesture}>
|
||||||
<View style={[styles.desktopSidebarBorder, { flex: 1 }]}>
|
<View
|
||||||
{/* Resize handle - absolutely positioned over left border */}
|
style={[styles.resizeHandle, Platform.OS === "web" && ({ cursor: "col-resize" } as any)]}
|
||||||
<GestureDetector gesture={resizeGesture}>
|
|
||||||
<View style={[styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as any)]} />
|
|
||||||
</GestureDetector>
|
|
||||||
|
|
||||||
<SidebarContent
|
|
||||||
activeTab={explorerTab}
|
|
||||||
onTabPress={handleTabPress}
|
|
||||||
onClose={() => handleClose("desktop-close-button")}
|
|
||||||
serverId={serverId}
|
|
||||||
workspaceId={workspaceId}
|
|
||||||
workspaceRoot={workspaceRoot}
|
|
||||||
isGit={isGit}
|
|
||||||
isMobile={false}
|
|
||||||
onOpenFile={onOpenFile}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</GestureDetector>
|
||||||
|
|
||||||
|
<SidebarContent
|
||||||
|
activeTab={explorerTab}
|
||||||
|
onTabPress={handleTabPress}
|
||||||
|
onClose={() => handleClose("desktop-close-button")}
|
||||||
|
serverId={serverId}
|
||||||
|
workspaceId={workspaceId}
|
||||||
|
workspaceRoot={workspaceRoot}
|
||||||
|
isGit={isGit}
|
||||||
|
isMobile={false}
|
||||||
|
onOpenFile={onOpenFile}
|
||||||
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -359,7 +344,6 @@ function SidebarContent({
|
|||||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||||
{/* Header with tabs and close button */}
|
{/* Header with tabs and close button */}
|
||||||
<View style={[styles.header, { paddingRight: padding.right }]} testID="explorer-header">
|
<View style={[styles.header, { paddingRight: padding.right }]} testID="explorer-header">
|
||||||
<TitlebarDragRegion />
|
|
||||||
<View style={styles.tabsContainer}>
|
<View style={styles.tabsContainer}>
|
||||||
{isGit && (
|
{isGit && (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -414,28 +398,24 @@ function SidebarContent({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Static styles for Animated.Views — must NOT use Unistyles dynamic theme to
|
const styles = StyleSheet.create((theme) => ({
|
||||||
// avoid the "Unable to find node on an unmounted component" crash when Unistyles
|
|
||||||
// tries to patch the native node that Reanimated also manages.
|
|
||||||
const explorerStaticStyles = RNStyleSheet.create({
|
|
||||||
backdrop: {
|
backdrop: {
|
||||||
...RNStyleSheet.absoluteFillObject,
|
...StyleSheet.absoluteFillObject,
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
},
|
},
|
||||||
|
backdropPressable: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
mobileSidebar: {
|
mobileSidebar: {
|
||||||
position: "absolute" as const,
|
position: "absolute",
|
||||||
top: 0,
|
top: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
overflow: "hidden" as const,
|
backgroundColor: theme.colors.surfaceSidebar,
|
||||||
|
overflow: "hidden",
|
||||||
},
|
},
|
||||||
desktopSidebar: {
|
desktopSidebar: {
|
||||||
position: "relative" as const,
|
position: "relative",
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
|
||||||
desktopSidebarBorder: {
|
|
||||||
borderLeftWidth: 1,
|
borderLeftWidth: 1,
|
||||||
borderLeftColor: theme.colors.border,
|
borderLeftColor: theme.colors.border,
|
||||||
backgroundColor: theme.colors.surfaceSidebar,
|
backgroundColor: theme.colors.surfaceSidebar,
|
||||||
@@ -454,7 +434,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
},
|
},
|
||||||
header: {
|
header: {
|
||||||
position: "relative",
|
|
||||||
height: HEADER_INNER_HEIGHT,
|
height: HEADER_INNER_HEIGHT,
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -476,7 +455,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
borderRadius: theme.borderRadius.md,
|
borderRadius: theme.borderRadius.md,
|
||||||
},
|
},
|
||||||
tabActive: {
|
tabActive: {
|
||||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
backgroundColor: theme.colors.surface1,
|
||||||
},
|
},
|
||||||
tabText: {
|
tabText: {
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { View, Text } from "react-native";
|
import { View, Text, Platform } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import Animated, { useAnimatedStyle, withTiming, useSharedValue } from "react-native-reanimated";
|
import Animated, { useAnimatedStyle, withTiming, useSharedValue } from "react-native-reanimated";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { Upload } from "lucide-react-native";
|
import { Upload } from "lucide-react-native";
|
||||||
import { useFileDropZone } from "@/hooks/use-file-drop-zone";
|
import { useFileDropZone } from "@/hooks/use-file-drop-zone";
|
||||||
import type { ImageAttachment } from "./message-input";
|
import type { ImageAttachment } from "./message-input";
|
||||||
import { isWeb } from "@/constants/platform";
|
|
||||||
|
|
||||||
interface FileDropZoneProps {
|
interface FileDropZoneProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -13,7 +12,7 @@ interface FileDropZoneProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const IS_WEB = isWeb;
|
const IS_WEB = Platform.OS === "web";
|
||||||
|
|
||||||
export function FileDropZone({ children, onFilesDropped, disabled = false }: FileDropZoneProps) {
|
export function FileDropZone({ children, onFilesDropped, disabled = false }: FileDropZoneProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
FlatList,
|
FlatList,
|
||||||
ListRenderItemInfo,
|
ListRenderItemInfo,
|
||||||
|
type LayoutChangeEvent,
|
||||||
|
type NativeScrollEvent,
|
||||||
|
type NativeSyntheticEvent,
|
||||||
Pressable,
|
Pressable,
|
||||||
Text,
|
Text,
|
||||||
View,
|
View,
|
||||||
|
Platform,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Gesture } from "react-native-gesture-handler";
|
import { Gesture } from "react-native-gesture-handler";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
|
||||||
import Animated, {
|
import Animated, {
|
||||||
cancelAnimation,
|
cancelAnimation,
|
||||||
Easing,
|
Easing,
|
||||||
@@ -50,8 +53,10 @@ import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-action
|
|||||||
import { usePanelStore, type SortOption } from "@/stores/panel-store";
|
import { usePanelStore, type SortOption } from "@/stores/panel-store";
|
||||||
import { formatTimeAgo } from "@/utils/time";
|
import { formatTimeAgo } from "@/utils/time";
|
||||||
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
|
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
|
||||||
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
import {
|
||||||
import { isWeb } from "@/constants/platform";
|
WebDesktopScrollbarOverlay,
|
||||||
|
useWebDesktopScrollbarMetrics,
|
||||||
|
} from "@/components/web-desktop-scrollbar";
|
||||||
|
|
||||||
const SORT_OPTIONS: { value: SortOption; label: string }[] = [
|
const SORT_OPTIONS: { value: SortOption; label: string }[] = [
|
||||||
{ value: "name", label: "Name" },
|
{ value: "name", label: "Name" },
|
||||||
@@ -90,8 +95,8 @@ export function FileExplorerPane({
|
|||||||
onOpenFile,
|
onOpenFile,
|
||||||
}: FileExplorerPaneProps) {
|
}: FileExplorerPaneProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
const showDesktopWebScrollbar = isWeb && !isMobile;
|
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
|
||||||
|
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
const daemonProfile = useMemo(
|
const daemonProfile = useMemo(
|
||||||
@@ -118,22 +123,18 @@ export function FileExplorerPane({
|
|||||||
: undefined,
|
: undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { requestDirectoryListing, requestFileDownloadToken, selectExplorerEntry } =
|
const {
|
||||||
useFileExplorerActions({
|
workspaceStateKey: actionsWorkspaceStateKey,
|
||||||
serverId,
|
requestDirectoryListing,
|
||||||
workspaceId,
|
requestFileDownloadToken,
|
||||||
workspaceRoot: normalizedWorkspaceRoot,
|
selectExplorerEntry,
|
||||||
});
|
} = useFileExplorerActions({
|
||||||
|
serverId,
|
||||||
|
workspaceId,
|
||||||
|
workspaceRoot: normalizedWorkspaceRoot,
|
||||||
|
});
|
||||||
const sortOption = usePanelStore((state) => state.explorerSortOption);
|
const sortOption = usePanelStore((state) => state.explorerSortOption);
|
||||||
const setSortOption = usePanelStore((state) => state.setExplorerSortOption);
|
const setSortOption = usePanelStore((state) => state.setExplorerSortOption);
|
||||||
const expandedPathsArray = usePanelStore((state) =>
|
|
||||||
workspaceStateKey ? state.expandedPathsByWorkspace[workspaceStateKey] : undefined,
|
|
||||||
);
|
|
||||||
const setExpandedPathsForWorkspace = usePanelStore((state) => state.setExpandedPathsForWorkspace);
|
|
||||||
const expandedPaths = useMemo(
|
|
||||||
() => new Set(expandedPathsArray && expandedPathsArray.length > 0 ? expandedPathsArray : ["."]),
|
|
||||||
[expandedPathsArray],
|
|
||||||
);
|
|
||||||
|
|
||||||
const directories = explorerState?.directories ?? new Map();
|
const directories = explorerState?.directories ?? new Map();
|
||||||
const pendingRequest = explorerState?.pendingRequest ?? null;
|
const pendingRequest = explorerState?.pendingRequest ?? null;
|
||||||
@@ -149,16 +150,16 @@ export function FileExplorerPane({
|
|||||||
[isExplorerLoading, pendingRequest?.mode, pendingRequest?.path],
|
[isExplorerLoading, pendingRequest?.mode, pendingRequest?.path],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => new Set(["."]));
|
||||||
const treeListRef = useRef<FlatList<TreeRow>>(null);
|
const treeListRef = useRef<FlatList<TreeRow>>(null);
|
||||||
const scrollbar = useWebScrollViewScrollbar(treeListRef, {
|
const treeScrollbarMetrics = useWebDesktopScrollbarMetrics();
|
||||||
enabled: showDesktopWebScrollbar,
|
|
||||||
});
|
|
||||||
|
|
||||||
const hasInitializedRef = useRef(false);
|
const hasInitializedRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
hasInitializedRef.current = false;
|
hasInitializedRef.current = false;
|
||||||
}, [workspaceStateKey]);
|
setExpandedPaths(new Set(["."]));
|
||||||
|
}, [actionsWorkspaceStateKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasWorkspaceScope) {
|
if (!hasWorkspaceScope) {
|
||||||
@@ -172,33 +173,23 @@ export function FileExplorerPane({
|
|||||||
recordHistory: false,
|
recordHistory: false,
|
||||||
setCurrentPath: false,
|
setCurrentPath: false,
|
||||||
});
|
});
|
||||||
const persistedPaths =
|
}, [hasWorkspaceScope, requestDirectoryListing]);
|
||||||
usePanelStore.getState().expandedPathsByWorkspace[workspaceStateKey ?? ""];
|
|
||||||
if (persistedPaths) {
|
|
||||||
for (const path of persistedPaths) {
|
|
||||||
if (path !== ".") {
|
|
||||||
void requestDirectoryListing(path, {
|
|
||||||
recordHistory: false,
|
|
||||||
setCurrentPath: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [hasWorkspaceScope, requestDirectoryListing, workspaceStateKey]);
|
|
||||||
|
|
||||||
// Expand ancestor directories when a file is selected (e.g., from an inline path click)
|
// Expand ancestor directories when a file is selected (e.g., from an inline path click)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedEntryPath || !workspaceStateKey) {
|
if (!selectedEntryPath || !hasWorkspaceScope) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const parentDir = getParentDirectory(selectedEntryPath);
|
const parentDir = getParentDirectory(selectedEntryPath);
|
||||||
const ancestors = getAncestorDirectories(parentDir);
|
const ancestors = getAncestorDirectories(parentDir);
|
||||||
const newPaths = ancestors.filter((path) => !expandedPaths.has(path));
|
|
||||||
if (newPaths.length === 0) {
|
setExpandedPaths((prev) => {
|
||||||
return;
|
const next = new Set(prev);
|
||||||
}
|
ancestors.forEach((path) => next.add(path));
|
||||||
setExpandedPathsForWorkspace(workspaceStateKey, [...Array.from(expandedPaths), ...newPaths]);
|
return next;
|
||||||
newPaths.forEach((path) => {
|
});
|
||||||
|
|
||||||
|
ancestors.forEach((path) => {
|
||||||
if (!directories.has(path)) {
|
if (!directories.has(path)) {
|
||||||
void requestDirectoryListing(path, {
|
void requestDirectoryListing(path, {
|
||||||
recordHistory: false,
|
recordHistory: false,
|
||||||
@@ -206,43 +197,34 @@ export function FileExplorerPane({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [
|
}, [directories, hasWorkspaceScope, requestDirectoryListing, selectedEntryPath]);
|
||||||
directories,
|
|
||||||
workspaceStateKey,
|
|
||||||
expandedPaths,
|
|
||||||
requestDirectoryListing,
|
|
||||||
selectedEntryPath,
|
|
||||||
setExpandedPathsForWorkspace,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleToggleDirectory = useCallback(
|
const handleToggleDirectory = useCallback(
|
||||||
(entry: ExplorerEntry) => {
|
(entry: ExplorerEntry) => {
|
||||||
if (!workspaceStateKey) {
|
if (!hasWorkspaceScope) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isExpanded = expandedPaths.has(entry.path);
|
const isExpanded = expandedPaths.has(entry.path);
|
||||||
if (isExpanded) {
|
const nextExpanded = !isExpanded;
|
||||||
setExpandedPathsForWorkspace(
|
setExpandedPaths((prev) => {
|
||||||
workspaceStateKey,
|
const next = new Set(prev);
|
||||||
Array.from(expandedPaths).filter((path) => path !== entry.path),
|
if (isExpanded) {
|
||||||
);
|
next.delete(entry.path);
|
||||||
} else {
|
} else {
|
||||||
setExpandedPathsForWorkspace(workspaceStateKey, [...Array.from(expandedPaths), entry.path]);
|
next.add(entry.path);
|
||||||
if (!directories.has(entry.path)) {
|
|
||||||
void requestDirectoryListing(entry.path, {
|
|
||||||
recordHistory: false,
|
|
||||||
setCurrentPath: false,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (nextExpanded && !directories.has(entry.path)) {
|
||||||
|
void requestDirectoryListing(entry.path, {
|
||||||
|
recordHistory: false,
|
||||||
|
setCurrentPath: false,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[directories, expandedPaths, hasWorkspaceScope, requestDirectoryListing],
|
||||||
workspaceStateKey,
|
|
||||||
expandedPaths,
|
|
||||||
directories,
|
|
||||||
requestDirectoryListing,
|
|
||||||
setExpandedPathsForWorkspace,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleOpenFile = useCallback(
|
const handleOpenFile = useCallback(
|
||||||
@@ -520,6 +502,24 @@ export function FileExplorerPane({
|
|||||||
});
|
});
|
||||||
}, [errorRecoveryPath, hasWorkspaceScope, requestDirectoryListing, selectExplorerEntry]);
|
}, [errorRecoveryPath, hasWorkspaceScope, requestDirectoryListing, selectExplorerEntry]);
|
||||||
|
|
||||||
|
const handleTreeListScroll = useCallback(
|
||||||
|
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||||
|
if (showDesktopWebScrollbar) {
|
||||||
|
treeScrollbarMetrics.onScroll(event);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[showDesktopWebScrollbar, treeScrollbarMetrics],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTreeListLayout = useCallback(
|
||||||
|
(event: LayoutChangeEvent) => {
|
||||||
|
if (showDesktopWebScrollbar) {
|
||||||
|
treeScrollbarMetrics.onLayout(event);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[showDesktopWebScrollbar, treeScrollbarMetrics],
|
||||||
|
);
|
||||||
|
|
||||||
if (!hasWorkspaceScope) {
|
if (!hasWorkspaceScope) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.centerState}>
|
<View style={styles.centerState}>
|
||||||
@@ -598,16 +598,27 @@ export function FileExplorerPane({
|
|||||||
keyExtractor={(row) => row.entry.path}
|
keyExtractor={(row) => row.entry.path}
|
||||||
testID="file-explorer-tree-scroll"
|
testID="file-explorer-tree-scroll"
|
||||||
contentContainerStyle={styles.entriesContent}
|
contentContainerStyle={styles.entriesContent}
|
||||||
onLayout={scrollbar.onLayout}
|
onLayout={showDesktopWebScrollbar ? handleTreeListLayout : undefined}
|
||||||
onScroll={scrollbar.onScroll}
|
onScroll={showDesktopWebScrollbar ? handleTreeListScroll : undefined}
|
||||||
onContentSizeChange={scrollbar.onContentSizeChange}
|
onContentSizeChange={
|
||||||
scrollEventThrottle={16}
|
showDesktopWebScrollbar ? treeScrollbarMetrics.onContentSizeChange : undefined
|
||||||
|
}
|
||||||
|
scrollEventThrottle={showDesktopWebScrollbar ? 16 : undefined}
|
||||||
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||||
initialNumToRender={24}
|
initialNumToRender={24}
|
||||||
maxToRenderPerBatch={40}
|
maxToRenderPerBatch={40}
|
||||||
windowSize={12}
|
windowSize={12}
|
||||||
/>
|
/>
|
||||||
{scrollbar.overlay}
|
<WebDesktopScrollbarOverlay
|
||||||
|
enabled={showDesktopWebScrollbar}
|
||||||
|
metrics={treeScrollbarMetrics}
|
||||||
|
onScrollToOffset={(nextOffset) => {
|
||||||
|
treeListRef.current?.scrollToOffset({
|
||||||
|
offset: nextOffset,
|
||||||
|
animated: false,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -839,7 +850,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
borderRadius: theme.borderRadius.md,
|
borderRadius: theme.borderRadius.md,
|
||||||
},
|
},
|
||||||
entryRowActive: {
|
entryRowActive: {
|
||||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
backgroundColor: theme.colors.surface1,
|
||||||
},
|
},
|
||||||
indentGuide: {
|
indentGuide: {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useMemo, useRef } from "react";
|
import React, { useCallback, useMemo, useRef } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
@@ -6,12 +6,18 @@ import {
|
|||||||
ScrollView as RNScrollView,
|
ScrollView as RNScrollView,
|
||||||
Text,
|
Text,
|
||||||
View,
|
View,
|
||||||
|
Platform,
|
||||||
|
type LayoutChangeEvent,
|
||||||
|
type NativeScrollEvent,
|
||||||
|
type NativeSyntheticEvent,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
|
||||||
import { Fonts } from "@/constants/theme";
|
import { Fonts } from "@/constants/theme";
|
||||||
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
|
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
|
||||||
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
import {
|
||||||
|
WebDesktopScrollbarOverlay,
|
||||||
|
useWebDesktopScrollbarMetrics,
|
||||||
|
} from "@/components/web-desktop-scrollbar";
|
||||||
import {
|
import {
|
||||||
highlightCode,
|
highlightCode,
|
||||||
darkHighlightColors,
|
darkHighlightColors,
|
||||||
@@ -20,7 +26,6 @@ import {
|
|||||||
type HighlightStyle,
|
type HighlightStyle,
|
||||||
} from "@getpaseo/highlight";
|
} from "@getpaseo/highlight";
|
||||||
import { lineNumberGutterWidth } from "@/components/code-insets";
|
import { lineNumberGutterWidth } from "@/components/code-insets";
|
||||||
import { isWeb } from "@/constants/platform";
|
|
||||||
|
|
||||||
interface CodeLineProps {
|
interface CodeLineProps {
|
||||||
tokens: HighlightToken[];
|
tokens: HighlightToken[];
|
||||||
@@ -114,14 +119,13 @@ function FilePreviewBody({
|
|||||||
filePath,
|
filePath,
|
||||||
}: FilePreviewBodyProps) {
|
}: FilePreviewBodyProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isDark = theme.colorScheme === "dark";
|
const isDark = theme.colors.surface0 === "#18181c";
|
||||||
const colorMap = isDark ? darkHighlightColors : lightHighlightColors;
|
const colorMap = isDark ? darkHighlightColors : lightHighlightColors;
|
||||||
const baseColor = isDark ? "#c9d1d9" : "#24292f";
|
const baseColor = isDark ? "#c9d1d9" : "#24292f";
|
||||||
|
|
||||||
|
const enablePreviewDesktopScrollbar = showDesktopWebScrollbar;
|
||||||
const previewScrollRef = useRef<RNScrollView>(null);
|
const previewScrollRef = useRef<RNScrollView>(null);
|
||||||
const scrollbar = useWebScrollViewScrollbar(previewScrollRef, {
|
const previewScrollbarMetrics = useWebDesktopScrollbarMetrics();
|
||||||
enabled: showDesktopWebScrollbar,
|
|
||||||
});
|
|
||||||
|
|
||||||
const highlightedLines = useMemo(() => {
|
const highlightedLines = useMemo(() => {
|
||||||
if (!preview || preview.kind !== "text") {
|
if (!preview || preview.kind !== "text") {
|
||||||
@@ -136,6 +140,24 @@ function FilePreviewBody({
|
|||||||
return lineNumberGutterWidth(highlightedLines.length);
|
return lineNumberGutterWidth(highlightedLines.length);
|
||||||
}, [highlightedLines]);
|
}, [highlightedLines]);
|
||||||
|
|
||||||
|
const handlePreviewScroll = useCallback(
|
||||||
|
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||||
|
if (enablePreviewDesktopScrollbar) {
|
||||||
|
previewScrollbarMetrics.onScroll(event);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[enablePreviewDesktopScrollbar, previewScrollbarMetrics],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePreviewLayout = useCallback(
|
||||||
|
(event: LayoutChangeEvent) => {
|
||||||
|
if (enablePreviewDesktopScrollbar) {
|
||||||
|
previewScrollbarMetrics.onLayout(event);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[enablePreviewDesktopScrollbar, previewScrollbarMetrics],
|
||||||
|
);
|
||||||
|
|
||||||
if (isLoading && !preview) {
|
if (isLoading && !preview) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.centerState}>
|
<View style={styles.centerState}>
|
||||||
@@ -175,11 +197,13 @@ function FilePreviewBody({
|
|||||||
<RNScrollView
|
<RNScrollView
|
||||||
ref={previewScrollRef}
|
ref={previewScrollRef}
|
||||||
style={styles.previewContent}
|
style={styles.previewContent}
|
||||||
onLayout={scrollbar.onLayout}
|
onLayout={enablePreviewDesktopScrollbar ? handlePreviewLayout : undefined}
|
||||||
onScroll={scrollbar.onScroll}
|
onScroll={enablePreviewDesktopScrollbar ? handlePreviewScroll : undefined}
|
||||||
onContentSizeChange={scrollbar.onContentSizeChange}
|
onContentSizeChange={
|
||||||
scrollEventThrottle={16}
|
enablePreviewDesktopScrollbar ? previewScrollbarMetrics.onContentSizeChange : undefined
|
||||||
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
}
|
||||||
|
scrollEventThrottle={enablePreviewDesktopScrollbar ? 16 : undefined}
|
||||||
|
showsVerticalScrollIndicator={!enablePreviewDesktopScrollbar}
|
||||||
>
|
>
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
<View style={styles.previewCodeScrollContent}>{codeLines}</View>
|
<View style={styles.previewCodeScrollContent}>{codeLines}</View>
|
||||||
@@ -194,7 +218,13 @@ function FilePreviewBody({
|
|||||||
</RNScrollView>
|
</RNScrollView>
|
||||||
)}
|
)}
|
||||||
</RNScrollView>
|
</RNScrollView>
|
||||||
{scrollbar.overlay}
|
<WebDesktopScrollbarOverlay
|
||||||
|
enabled={enablePreviewDesktopScrollbar}
|
||||||
|
metrics={previewScrollbarMetrics}
|
||||||
|
onScrollToOffset={(nextOffset) => {
|
||||||
|
previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -206,11 +236,13 @@ function FilePreviewBody({
|
|||||||
ref={previewScrollRef}
|
ref={previewScrollRef}
|
||||||
style={styles.previewContent}
|
style={styles.previewContent}
|
||||||
contentContainerStyle={styles.previewImageScrollContent}
|
contentContainerStyle={styles.previewImageScrollContent}
|
||||||
onLayout={scrollbar.onLayout}
|
onLayout={enablePreviewDesktopScrollbar ? handlePreviewLayout : undefined}
|
||||||
onScroll={scrollbar.onScroll}
|
onScroll={enablePreviewDesktopScrollbar ? handlePreviewScroll : undefined}
|
||||||
onContentSizeChange={scrollbar.onContentSizeChange}
|
onContentSizeChange={
|
||||||
scrollEventThrottle={16}
|
enablePreviewDesktopScrollbar ? previewScrollbarMetrics.onContentSizeChange : undefined
|
||||||
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
}
|
||||||
|
scrollEventThrottle={enablePreviewDesktopScrollbar ? 16 : undefined}
|
||||||
|
showsVerticalScrollIndicator={!enablePreviewDesktopScrollbar}
|
||||||
>
|
>
|
||||||
<RNImage
|
<RNImage
|
||||||
source={{
|
source={{
|
||||||
@@ -220,7 +252,13 @@ function FilePreviewBody({
|
|||||||
resizeMode="contain"
|
resizeMode="contain"
|
||||||
/>
|
/>
|
||||||
</RNScrollView>
|
</RNScrollView>
|
||||||
{scrollbar.overlay}
|
<WebDesktopScrollbarOverlay
|
||||||
|
enabled={enablePreviewDesktopScrollbar}
|
||||||
|
metrics={previewScrollbarMetrics}
|
||||||
|
onScrollToOffset={(nextOffset) => {
|
||||||
|
previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -242,8 +280,8 @@ export function FilePane({
|
|||||||
workspaceRoot: string;
|
workspaceRoot: string;
|
||||||
filePath: string;
|
filePath: string;
|
||||||
}) {
|
}) {
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
const showDesktopWebScrollbar = isWeb && !isMobile;
|
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
|
||||||
|
|
||||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||||
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
|
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitAct
|
|||||||
baseRefAvailable: true,
|
baseRefAvailable: true,
|
||||||
baseRefLabel: "main",
|
baseRefLabel: "main",
|
||||||
aheadCount: 0,
|
aheadCount: 0,
|
||||||
behindBaseCount: 0,
|
|
||||||
aheadOfOrigin: 0,
|
aheadOfOrigin: 0,
|
||||||
behindOfOrigin: 0,
|
behindOfOrigin: 0,
|
||||||
shouldPromoteArchive: false,
|
shouldPromoteArchive: false,
|
||||||
@@ -26,11 +25,6 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitAct
|
|||||||
status: "idle",
|
status: "idle",
|
||||||
handler: () => undefined,
|
handler: () => undefined,
|
||||||
},
|
},
|
||||||
pull: {
|
|
||||||
disabled: false,
|
|
||||||
status: "idle",
|
|
||||||
handler: () => undefined,
|
|
||||||
},
|
|
||||||
push: {
|
push: {
|
||||||
disabled: false,
|
disabled: false,
|
||||||
status: "idle",
|
status: "idle",
|
||||||
@@ -62,102 +56,89 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitAct
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("git-actions-policy", () => {
|
describe("git-actions-policy", () => {
|
||||||
it("shows only remote sync actions on the base branch", () => {
|
it("keeps the secondary menu order stable while the primary action changes", () => {
|
||||||
const actions = buildGitActions(createInput({ hasRemote: true }));
|
const noPrActions = buildGitActions(createInput());
|
||||||
|
const withPrActions = buildGitActions(
|
||||||
expect(actions.secondary.map((action) => action.id)).toEqual(["pull", "push"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("prioritizes pull when the branch is behind origin", () => {
|
|
||||||
const actions = buildGitActions(
|
|
||||||
createInput({
|
createInput({
|
||||||
hasRemote: true,
|
hasRemote: true,
|
||||||
behindOfOrigin: 2,
|
hasPullRequest: true,
|
||||||
|
pullRequestUrl: "https://example.com/pr/123",
|
||||||
|
aheadCount: 3,
|
||||||
|
aheadOfOrigin: 2,
|
||||||
|
shipDefault: "pr",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(actions.primary).toMatchObject({ id: "pull", label: "Pull" });
|
expect(noPrActions.primary).toBeNull();
|
||||||
|
expect(withPrActions.primary?.id).toBe("push");
|
||||||
|
expect(noPrActions.secondary.map((action) => action.id)).toEqual([
|
||||||
|
"merge-branch",
|
||||||
|
"pr",
|
||||||
|
"merge-from-base",
|
||||||
|
"push",
|
||||||
|
]);
|
||||||
|
expect(withPrActions.secondary.map((action) => action.id)).toEqual([
|
||||||
|
"merge-branch",
|
||||||
|
"pr",
|
||||||
|
"merge-from-base",
|
||||||
|
"push",
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps push clickable with a clearer message when the branch diverged", () => {
|
it("disables hidden-before actions with explanations instead", () => {
|
||||||
const actions = buildGitActions(
|
const actions = buildGitActions(createInput());
|
||||||
createInput({
|
const actionById = new Map(actions.secondary.map((action) => [action.id, action]));
|
||||||
hasRemote: true,
|
|
||||||
aheadOfOrigin: 1,
|
|
||||||
behindOfOrigin: 1,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const pushAction = actions.secondary.find((action) => action.id === "push");
|
|
||||||
|
|
||||||
expect(pushAction).toMatchObject({
|
expect(actionById.get("push")).toMatchObject({
|
||||||
disabled: false,
|
disabled: true,
|
||||||
unavailableMessage:
|
description: "No remote configured",
|
||||||
"Push isn't available yet because there are newer changes to bring in first",
|
|
||||||
});
|
});
|
||||||
});
|
expect(actionById.get("pr")).toMatchObject({
|
||||||
|
label: "Create PR",
|
||||||
it("shows update-from-base only on feature branches that are behind the base branch", () => {
|
disabled: true,
|
||||||
const actions = buildGitActions(
|
description: "Branch has no commits ahead of main",
|
||||||
createInput({
|
|
||||||
hasRemote: true,
|
|
||||||
isOnBaseBranch: false,
|
|
||||||
behindBaseCount: 3,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const updateAction = actions.secondary.find((action) => action.id === "merge-from-base");
|
|
||||||
|
|
||||||
expect(updateAction).toMatchObject({
|
|
||||||
label: "Update from main",
|
|
||||||
disabled: false,
|
|
||||||
unavailableMessage: undefined,
|
|
||||||
});
|
});
|
||||||
});
|
expect(actionById.get("merge-branch")).toMatchObject({
|
||||||
|
disabled: true,
|
||||||
it("uses a clear sentence when pull is unavailable", () => {
|
description: "No commits to merge into main",
|
||||||
const actions = buildGitActions(createInput({ hasRemote: true }));
|
|
||||||
const pullAction = actions.secondary.find((action) => action.id === "pull");
|
|
||||||
|
|
||||||
expect(pullAction).toMatchObject({
|
|
||||||
disabled: false,
|
|
||||||
unavailableMessage: "Pull isn't available because this branch is already up to date",
|
|
||||||
});
|
});
|
||||||
|
expect(actionById.get("merge-from-base")).toMatchObject({
|
||||||
|
disabled: true,
|
||||||
|
description: "No remote configured",
|
||||||
|
});
|
||||||
|
expect(actionById.has("archive-worktree")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps update-from-base off the base branch entirely", () => {
|
it("keeps the current primary action visible in the menu", () => {
|
||||||
const actions = buildGitActions(
|
const actions = buildGitActions(
|
||||||
createInput({
|
createInput({
|
||||||
hasRemote: true,
|
hasRemote: true,
|
||||||
behindOfOrigin: 2,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(actions.secondary.some((action) => action.id === "merge-from-base")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps feature branch actions available off the base branch", () => {
|
|
||||||
const actions = buildGitActions(
|
|
||||||
createInput({
|
|
||||||
hasRemote: true,
|
|
||||||
isOnBaseBranch: false,
|
|
||||||
aheadCount: 2,
|
|
||||||
behindBaseCount: 1,
|
|
||||||
hasPullRequest: true,
|
hasPullRequest: true,
|
||||||
pullRequestUrl: "https://example.com/pr/456",
|
pullRequestUrl: "https://example.com/pr/456",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(actions.secondary.map((action) => action.id)).toEqual([
|
expect(actions.primary?.id).toBe("pr");
|
||||||
"pull",
|
|
||||||
"push",
|
|
||||||
"merge-from-base",
|
|
||||||
"merge-branch",
|
|
||||||
"pr",
|
|
||||||
]);
|
|
||||||
expect(
|
expect(
|
||||||
actions.secondary.some((action) => action.id === "pr" && action.label === "View PR"),
|
actions.secondary.some((action) => action.id === "pr" && action.label === "View PR"),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("disables sync on the base branch when already up to date", () => {
|
||||||
|
const actions = buildGitActions(
|
||||||
|
createInput({
|
||||||
|
hasRemote: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const syncAction = actions.secondary.find((action) => action.id === "merge-from-base");
|
||||||
|
|
||||||
|
expect(syncAction).toMatchObject({
|
||||||
|
label: "Sync",
|
||||||
|
disabled: true,
|
||||||
|
description: "Already up to date",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("only shows archive worktree for paseo worktrees", () => {
|
it("only shows archive worktree for paseo worktrees", () => {
|
||||||
const hidden = buildGitActions(createInput());
|
const hidden = buildGitActions(createInput());
|
||||||
const shown = buildGitActions(createInput({ isPaseoOwnedWorktree: true }));
|
const shown = buildGitActions(createInput({ isPaseoOwnedWorktree: true }));
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import type { ActionStatus } from "@/components/ui/dropdown-menu";
|
|||||||
|
|
||||||
export type GitActionId =
|
export type GitActionId =
|
||||||
| "commit"
|
| "commit"
|
||||||
| "pull"
|
|
||||||
| "push"
|
| "push"
|
||||||
| "pr"
|
| "pr"
|
||||||
| "merge-branch"
|
| "merge-branch"
|
||||||
@@ -18,7 +17,7 @@ export interface GitAction {
|
|||||||
successLabel: string;
|
successLabel: string;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
status: ActionStatus;
|
status: ActionStatus;
|
||||||
unavailableMessage?: string;
|
description?: string;
|
||||||
icon?: ReactElement;
|
icon?: ReactElement;
|
||||||
handler: () => void;
|
handler: () => void;
|
||||||
}
|
}
|
||||||
@@ -48,7 +47,6 @@ export interface BuildGitActionsInput {
|
|||||||
baseRefAvailable: boolean;
|
baseRefAvailable: boolean;
|
||||||
baseRefLabel: string;
|
baseRefLabel: string;
|
||||||
aheadCount: number;
|
aheadCount: number;
|
||||||
behindBaseCount: number;
|
|
||||||
aheadOfOrigin: number;
|
aheadOfOrigin: number;
|
||||||
behindOfOrigin: number;
|
behindOfOrigin: number;
|
||||||
shouldPromoteArchive: boolean;
|
shouldPromoteArchive: boolean;
|
||||||
@@ -56,8 +54,7 @@ export interface BuildGitActionsInput {
|
|||||||
runtime: Record<GitActionId, GitActionRuntimeState>;
|
runtime: Record<GitActionId, GitActionRuntimeState>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const REMOTE_ACTION_IDS: GitActionId[] = ["pull", "push"];
|
const SECONDARY_ACTION_IDS: GitActionId[] = ["merge-branch", "pr", "merge-from-base", "push"];
|
||||||
const FEATURE_ACTION_IDS: GitActionId[] = ["merge-from-base", "merge-branch", "pr"];
|
|
||||||
|
|
||||||
export function buildGitActions(input: BuildGitActionsInput): GitActions {
|
export function buildGitActions(input: BuildGitActionsInput): GitActions {
|
||||||
if (!input.isGit) {
|
if (!input.isGit) {
|
||||||
@@ -77,26 +74,14 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
|
|||||||
handler: input.runtime.commit.handler,
|
handler: input.runtime.commit.handler,
|
||||||
});
|
});
|
||||||
|
|
||||||
allActions.set("pull", {
|
|
||||||
id: "pull",
|
|
||||||
label: "Pull",
|
|
||||||
pendingLabel: "Pulling...",
|
|
||||||
successLabel: "Pulled",
|
|
||||||
disabled: input.runtime.pull.disabled,
|
|
||||||
status: input.runtime.pull.status,
|
|
||||||
unavailableMessage: input.runtime.pull.disabled ? undefined : getPullUnavailableMessage(input),
|
|
||||||
icon: input.runtime.pull.icon,
|
|
||||||
handler: input.runtime.pull.handler,
|
|
||||||
});
|
|
||||||
|
|
||||||
allActions.set("push", {
|
allActions.set("push", {
|
||||||
id: "push",
|
id: "push",
|
||||||
label: "Push",
|
label: "Push",
|
||||||
pendingLabel: "Pushing...",
|
pendingLabel: "Pushing...",
|
||||||
successLabel: "Pushed",
|
successLabel: "Pushed",
|
||||||
disabled: input.runtime.push.disabled,
|
disabled: input.runtime.push.disabled || !input.hasRemote,
|
||||||
status: input.runtime.push.status,
|
status: input.runtime.push.status,
|
||||||
unavailableMessage: input.runtime.push.disabled ? undefined : getPushUnavailableMessage(input),
|
description: input.hasRemote ? undefined : "No remote configured",
|
||||||
icon: input.runtime.push.icon,
|
icon: input.runtime.push.icon,
|
||||||
handler: input.runtime.push.handler,
|
handler: input.runtime.push.handler,
|
||||||
});
|
});
|
||||||
@@ -108,25 +93,25 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
|
|||||||
label: `Merge into ${input.baseRefLabel}`,
|
label: `Merge into ${input.baseRefLabel}`,
|
||||||
pendingLabel: "Merging...",
|
pendingLabel: "Merging...",
|
||||||
successLabel: "Merged",
|
successLabel: "Merged",
|
||||||
disabled: input.runtime["merge-branch"].disabled,
|
disabled:
|
||||||
|
input.runtime["merge-branch"].disabled ||
|
||||||
|
!input.baseRefAvailable ||
|
||||||
|
input.hasUncommittedChanges ||
|
||||||
|
input.aheadCount === 0,
|
||||||
status: input.runtime["merge-branch"].status,
|
status: input.runtime["merge-branch"].status,
|
||||||
unavailableMessage: input.runtime["merge-branch"].disabled
|
description: getMergeBranchDescription(input),
|
||||||
? undefined
|
|
||||||
: getMergeBranchUnavailableMessage(input),
|
|
||||||
icon: input.runtime["merge-branch"].icon,
|
icon: input.runtime["merge-branch"].icon,
|
||||||
handler: input.runtime["merge-branch"].handler,
|
handler: input.runtime["merge-branch"].handler,
|
||||||
});
|
});
|
||||||
|
|
||||||
allActions.set("merge-from-base", {
|
allActions.set("merge-from-base", {
|
||||||
id: "merge-from-base",
|
id: "merge-from-base",
|
||||||
label: `Update from ${input.baseRefLabel}`,
|
label: input.isOnBaseBranch ? "Sync" : `Update from ${input.baseRefLabel}`,
|
||||||
pendingLabel: "Updating...",
|
pendingLabel: "Updating...",
|
||||||
successLabel: "Updated",
|
successLabel: "Updated",
|
||||||
disabled: input.runtime["merge-from-base"].disabled,
|
disabled: input.runtime["merge-from-base"].disabled || !canMergeFromBase(input),
|
||||||
status: input.runtime["merge-from-base"].status,
|
status: input.runtime["merge-from-base"].status,
|
||||||
unavailableMessage: input.runtime["merge-from-base"].disabled
|
description: getMergeFromBaseDescription(input),
|
||||||
? undefined
|
|
||||||
: getMergeFromBaseUnavailableMessage(input),
|
|
||||||
icon: input.runtime["merge-from-base"].icon,
|
icon: input.runtime["merge-from-base"].icon,
|
||||||
handler: input.runtime["merge-from-base"].handler,
|
handler: input.runtime["merge-from-base"].handler,
|
||||||
});
|
});
|
||||||
@@ -136,32 +121,21 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
|
|||||||
label: "Archive worktree",
|
label: "Archive worktree",
|
||||||
pendingLabel: "Archiving...",
|
pendingLabel: "Archiving...",
|
||||||
successLabel: "Archived",
|
successLabel: "Archived",
|
||||||
disabled: input.runtime["archive-worktree"].disabled,
|
disabled: input.runtime["archive-worktree"].disabled || !input.isPaseoOwnedWorktree,
|
||||||
status: input.runtime["archive-worktree"].status,
|
status: input.runtime["archive-worktree"].status,
|
||||||
unavailableMessage:
|
description: input.isPaseoOwnedWorktree ? undefined : "Only available for Paseo worktrees",
|
||||||
input.runtime["archive-worktree"].disabled || input.isPaseoOwnedWorktree
|
|
||||||
? undefined
|
|
||||||
: "Archive isn't available here because this workspace was not created as a Paseo worktree",
|
|
||||||
icon: input.runtime["archive-worktree"].icon,
|
icon: input.runtime["archive-worktree"].icon,
|
||||||
handler: input.runtime["archive-worktree"].handler,
|
handler: input.runtime["archive-worktree"].handler,
|
||||||
});
|
});
|
||||||
|
|
||||||
const primaryActionId = getPrimaryActionId(input);
|
const primaryActionId = getPrimaryActionId(input);
|
||||||
const primary = primaryActionId ? (allActions.get(primaryActionId) ?? null) : null;
|
const primary = primaryActionId ? (allActions.get(primaryActionId) ?? null) : null;
|
||||||
|
const secondary = SECONDARY_ACTION_IDS.map((id) => allActions.get(id)!);
|
||||||
const secondaryIds = [...REMOTE_ACTION_IDS];
|
|
||||||
if (!input.isOnBaseBranch) {
|
|
||||||
secondaryIds.push(...FEATURE_ACTION_IDS);
|
|
||||||
}
|
|
||||||
if (input.isPaseoOwnedWorktree) {
|
if (input.isPaseoOwnedWorktree) {
|
||||||
secondaryIds.push("archive-worktree");
|
secondary.push(allActions.get("archive-worktree")!);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return { primary, secondary, menu: [] };
|
||||||
primary,
|
|
||||||
secondary: secondaryIds.map((id) => allActions.get(id)!),
|
|
||||||
menu: [],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
|
function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
|
||||||
@@ -171,19 +145,20 @@ function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
|
|||||||
if (input.hasUncommittedChanges) {
|
if (input.hasUncommittedChanges) {
|
||||||
return "commit";
|
return "commit";
|
||||||
}
|
}
|
||||||
if (canPull(input)) {
|
if (input.aheadOfOrigin > 0 && input.hasRemote) {
|
||||||
return "pull";
|
|
||||||
}
|
|
||||||
if (canPush(input)) {
|
|
||||||
return "push";
|
return "push";
|
||||||
}
|
}
|
||||||
if (!input.isOnBaseBranch && canMergeFromBase(input)) {
|
|
||||||
return "merge-from-base";
|
|
||||||
}
|
|
||||||
if (input.githubFeaturesEnabled && input.hasPullRequest && input.pullRequestUrl) {
|
if (input.githubFeaturesEnabled && input.hasPullRequest && input.pullRequestUrl) {
|
||||||
return "pr";
|
return "pr";
|
||||||
}
|
}
|
||||||
if (!input.isOnBaseBranch && input.aheadCount > 0) {
|
if (
|
||||||
|
input.isOnBaseBranch &&
|
||||||
|
input.hasRemote &&
|
||||||
|
(input.aheadOfOrigin > 0 || input.behindOfOrigin > 0)
|
||||||
|
) {
|
||||||
|
return "merge-from-base";
|
||||||
|
}
|
||||||
|
if (input.aheadCount > 0) {
|
||||||
return input.shipDefault === "merge" ? "merge-branch" : "pr";
|
return input.shipDefault === "merge" ? "merge-branch" : "pr";
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -196,12 +171,9 @@ function buildPrAction(input: BuildGitActionsInput): GitAction {
|
|||||||
label: "View PR",
|
label: "View PR",
|
||||||
pendingLabel: "View PR",
|
pendingLabel: "View PR",
|
||||||
successLabel: "View PR",
|
successLabel: "View PR",
|
||||||
disabled: input.runtime.pr.disabled,
|
disabled: input.runtime.pr.disabled || !input.githubFeaturesEnabled,
|
||||||
status: input.runtime.pr.status,
|
status: input.runtime.pr.status,
|
||||||
unavailableMessage:
|
description: input.githubFeaturesEnabled ? undefined : "GitHub features unavailable",
|
||||||
input.runtime.pr.disabled || input.githubFeaturesEnabled
|
|
||||||
? undefined
|
|
||||||
: "View PR isn't available right now because GitHub isn't connected",
|
|
||||||
icon: input.runtime.pr.icon,
|
icon: input.runtime.pr.icon,
|
||||||
handler: input.runtime.pr.handler,
|
handler: input.runtime.pr.handler,
|
||||||
};
|
};
|
||||||
@@ -212,100 +184,65 @@ function buildPrAction(input: BuildGitActionsInput): GitAction {
|
|||||||
label: "Create PR",
|
label: "Create PR",
|
||||||
pendingLabel: "Creating PR...",
|
pendingLabel: "Creating PR...",
|
||||||
successLabel: "PR Created",
|
successLabel: "PR Created",
|
||||||
disabled: input.runtime.pr.disabled,
|
disabled: input.runtime.pr.disabled || !input.githubFeaturesEnabled || input.aheadCount === 0,
|
||||||
status: input.runtime.pr.status,
|
status: input.runtime.pr.status,
|
||||||
unavailableMessage: input.runtime.pr.disabled
|
description: getCreatePrDescription(input),
|
||||||
? undefined
|
|
||||||
: getCreatePrUnavailableMessage(input),
|
|
||||||
icon: input.runtime.pr.icon,
|
icon: input.runtime.pr.icon,
|
||||||
handler: input.runtime.pr.handler,
|
handler: input.runtime.pr.handler,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function canPull(input: BuildGitActionsInput): boolean {
|
|
||||||
return input.hasRemote && !input.hasUncommittedChanges && input.behindOfOrigin > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function canPush(input: BuildGitActionsInput): boolean {
|
|
||||||
return input.hasRemote && input.aheadOfOrigin > 0 && input.behindOfOrigin === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function canMergeBranch(input: BuildGitActionsInput): boolean {
|
|
||||||
return (
|
|
||||||
!input.isOnBaseBranch &&
|
|
||||||
input.baseRefAvailable &&
|
|
||||||
!input.hasUncommittedChanges &&
|
|
||||||
input.aheadCount > 0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function canMergeFromBase(input: BuildGitActionsInput): boolean {
|
function canMergeFromBase(input: BuildGitActionsInput): boolean {
|
||||||
return (
|
if (!input.baseRefAvailable || input.hasUncommittedChanges) {
|
||||||
!input.isOnBaseBranch &&
|
return false;
|
||||||
input.baseRefAvailable &&
|
}
|
||||||
!input.hasUncommittedChanges &&
|
if (!input.isOnBaseBranch) {
|
||||||
input.behindBaseCount > 0
|
return true;
|
||||||
);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function getPullUnavailableMessage(input: BuildGitActionsInput): string | undefined {
|
|
||||||
if (!input.hasRemote) {
|
if (!input.hasRemote) {
|
||||||
return "Pull isn't available here because this branch is not connected to a remote yet";
|
return false;
|
||||||
}
|
}
|
||||||
if (input.hasUncommittedChanges) {
|
return input.aheadOfOrigin > 0 || input.behindOfOrigin > 0;
|
||||||
return "Pull isn't available while you have local changes so commit or stash them first";
|
|
||||||
}
|
|
||||||
if (input.behindOfOrigin === 0) {
|
|
||||||
return "Pull isn't available because this branch is already up to date";
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPushUnavailableMessage(input: BuildGitActionsInput): string | undefined {
|
function getCreatePrDescription(input: BuildGitActionsInput): string | undefined {
|
||||||
if (!input.hasRemote) {
|
|
||||||
return "Push isn't available here because this branch is not connected to a remote yet";
|
|
||||||
}
|
|
||||||
if (input.behindOfOrigin > 0) {
|
|
||||||
return "Push isn't available yet because there are newer changes to bring in first";
|
|
||||||
}
|
|
||||||
if (input.aheadOfOrigin === 0) {
|
|
||||||
return "Push isn't available because there is nothing new to send";
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCreatePrUnavailableMessage(input: BuildGitActionsInput): string | undefined {
|
|
||||||
if (!input.githubFeaturesEnabled) {
|
if (!input.githubFeaturesEnabled) {
|
||||||
return "Create PR isn't available right now because GitHub isn't connected";
|
return "GitHub features unavailable";
|
||||||
}
|
}
|
||||||
if (input.aheadCount === 0) {
|
if (input.aheadCount === 0) {
|
||||||
return "Create PR isn't available because this branch doesn't have any new commits yet";
|
return `Branch has no commits ahead of ${input.baseRefLabel}`;
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMergeBranchUnavailableMessage(input: BuildGitActionsInput): string | undefined {
|
function getMergeBranchDescription(input: BuildGitActionsInput): string | undefined {
|
||||||
if (!input.baseRefAvailable) {
|
if (!input.baseRefAvailable) {
|
||||||
return "Merge isn't available because we couldn't determine the base branch";
|
return "Base ref unavailable";
|
||||||
}
|
}
|
||||||
if (input.hasUncommittedChanges) {
|
if (input.hasUncommittedChanges) {
|
||||||
return "Merge isn't available while you have local changes so commit or stash them first";
|
return "Requires clean working tree";
|
||||||
}
|
}
|
||||||
if (input.aheadCount === 0) {
|
if (input.aheadCount === 0) {
|
||||||
return "Merge isn't available because this branch doesn't have anything new to merge yet";
|
return `No commits to merge into ${input.baseRefLabel}`;
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMergeFromBaseUnavailableMessage(input: BuildGitActionsInput): string | undefined {
|
function getMergeFromBaseDescription(input: BuildGitActionsInput): string | undefined {
|
||||||
if (!input.baseRefAvailable) {
|
if (!input.baseRefAvailable) {
|
||||||
return "Update isn't available because we couldn't determine the base branch";
|
return "Base ref unavailable";
|
||||||
}
|
}
|
||||||
if (input.hasUncommittedChanges) {
|
if (input.hasUncommittedChanges) {
|
||||||
return "Update isn't available while you have local changes so commit or stash them first";
|
return "Requires clean working tree";
|
||||||
}
|
}
|
||||||
if (input.behindBaseCount === 0) {
|
if (!input.isOnBaseBranch) {
|
||||||
return `Update isn't available because this branch is already up to date with ${input.baseRefLabel}`;
|
return undefined;
|
||||||
|
}
|
||||||
|
if (!input.hasRemote) {
|
||||||
|
return "No remote configured";
|
||||||
|
}
|
||||||
|
if (input.aheadOfOrigin === 0 && input.behindOfOrigin === 0) {
|
||||||
|
return "Already up to date";
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { View, Text, ActivityIndicator, Pressable } from "react-native";
|
import { View, Text, ActivityIndicator, Pressable } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { ChevronDown, Info, MoreVertical } from "lucide-react-native";
|
import { ChevronDown, MoreVertical } from "lucide-react-native";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Shortcut } from "@/components/ui/shortcut";
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||||
import { useToast } from "@/contexts/toast-context";
|
|
||||||
import type { GitAction, GitActions } from "@/components/git-actions-policy";
|
import type { GitAction, GitActions } from "@/components/git-actions-policy";
|
||||||
|
|
||||||
interface GitActionsSplitButtonProps {
|
interface GitActionsSplitButtonProps {
|
||||||
@@ -20,7 +19,6 @@ interface GitActionsSplitButtonProps {
|
|||||||
|
|
||||||
export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps) {
|
export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const toast = useToast();
|
|
||||||
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
|
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
|
||||||
|
|
||||||
const getActionDisplayLabel = useCallback((action: GitAction): string => {
|
const getActionDisplayLabel = useCallback((action: GitAction): string => {
|
||||||
@@ -29,20 +27,6 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
|
|||||||
return action.label;
|
return action.label;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleActionSelect = useCallback(
|
|
||||||
(action: GitAction) => {
|
|
||||||
if (action.unavailableMessage) {
|
|
||||||
toast.show(action.unavailableMessage, {
|
|
||||||
durationMs: 3200,
|
|
||||||
icon: <Info size={16} color={theme.colors.foreground} />,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
action.handler();
|
|
||||||
},
|
|
||||||
[theme.colors.foreground, toast],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
{gitActions.primary ? (
|
{gitActions.primary ? (
|
||||||
@@ -89,8 +73,7 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" testID="changes-primary-cta-menu">
|
<DropdownMenuContent align="end" testID="changes-primary-cta-menu">
|
||||||
{gitActions.secondary.map((action, index) => {
|
{gitActions.secondary.map((action, index) => {
|
||||||
const needsSeparator =
|
const needsSeparator = action.id === "merge-from-base" || action.id === "push";
|
||||||
action.id === "merge-from-base" || action.id === "archive-worktree";
|
|
||||||
return (
|
return (
|
||||||
<View key={action.id}>
|
<View key={action.id}>
|
||||||
{needsSeparator && index > 0 ? <DropdownMenuSeparator /> : null}
|
{needsSeparator && index > 0 ? <DropdownMenuSeparator /> : null}
|
||||||
@@ -98,12 +81,11 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
|
|||||||
testID={`changes-menu-${action.id}`}
|
testID={`changes-menu-${action.id}`}
|
||||||
leading={action.icon}
|
leading={action.icon}
|
||||||
trailing={
|
trailing={
|
||||||
action.id === "archive-worktree" && archiveShortcutKeys ? (
|
action.id === "archive-worktree" && archiveShortcutKeys
|
||||||
<Shortcut chord={archiveShortcutKeys} />
|
? <Shortcut chord={archiveShortcutKeys} />
|
||||||
) : undefined
|
: undefined
|
||||||
}
|
}
|
||||||
disabled={action.disabled}
|
disabled={action.disabled}
|
||||||
muted={Boolean(action.unavailableMessage)}
|
|
||||||
status={action.status}
|
status={action.status}
|
||||||
pendingLabel={action.pendingLabel}
|
pendingLabel={action.pendingLabel}
|
||||||
successLabel={action.successLabel}
|
successLabel={action.successLabel}
|
||||||
@@ -112,7 +94,8 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
|
|||||||
action.id === "pr" &&
|
action.id === "pr" &&
|
||||||
action.label === "View PR"
|
action.label === "View PR"
|
||||||
}
|
}
|
||||||
onSelect={() => handleActionSelect(action)}
|
description={action.description}
|
||||||
|
onSelect={action.handler}
|
||||||
>
|
>
|
||||||
{action.label}
|
{action.label}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -142,12 +125,11 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
|
|||||||
testID={`changes-menu-${action.id}`}
|
testID={`changes-menu-${action.id}`}
|
||||||
leading={action.icon}
|
leading={action.icon}
|
||||||
disabled={action.disabled}
|
disabled={action.disabled}
|
||||||
muted={Boolean(action.unavailableMessage)}
|
|
||||||
status={action.status}
|
status={action.status}
|
||||||
pendingLabel={action.pendingLabel}
|
pendingLabel={action.pendingLabel}
|
||||||
successLabel={action.successLabel}
|
successLabel={action.successLabel}
|
||||||
closeOnSelect={false}
|
closeOnSelect={false}
|
||||||
onSelect={() => handleActionSelect(action)}
|
onSelect={action.handler}
|
||||||
>
|
>
|
||||||
{action.label}
|
{action.label}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,16 @@
|
|||||||
import type { ReactElement, ReactNode } from "react";
|
import type { ReactElement, ReactNode } from "react";
|
||||||
import { Text, View, type PressableProps, type StyleProp, type ViewStyle } from "react-native";
|
import {
|
||||||
|
Platform,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
type PressableProps,
|
||||||
|
type StyleProp,
|
||||||
|
type ViewStyle,
|
||||||
|
} from "react-native";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { Shortcut } from "@/components/ui/shortcut";
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||||
import { isWeb } from "@/constants/platform";
|
|
||||||
|
|
||||||
interface HeaderToggleButtonState {
|
interface HeaderToggleButtonState {
|
||||||
hovered: boolean;
|
hovered: boolean;
|
||||||
@@ -38,7 +44,7 @@ export function HeaderToggleButton({
|
|||||||
: undefined;
|
: undefined;
|
||||||
const expandedState = (props.accessibilityState as { expanded?: boolean } | undefined)?.expanded;
|
const expandedState = (props.accessibilityState as { expanded?: boolean } | undefined)?.expanded;
|
||||||
const ariaExpandedProps =
|
const ariaExpandedProps =
|
||||||
isWeb && typeof expandedState === "boolean"
|
Platform.OS === "web" && typeof expandedState === "boolean"
|
||||||
? ({ "aria-expanded": expandedState } as any)
|
? ({ "aria-expanded": expandedState } as any)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
|
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
import { PanelLeft } from "lucide-react-native";
|
import { PanelLeft } from "lucide-react-native";
|
||||||
import { ScreenHeader } from "./screen-header";
|
import { ScreenHeader } from "./screen-header";
|
||||||
import { HeaderToggleButton } from "./header-toggle-button";
|
import { HeaderToggleButton } from "./header-toggle-button";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
|
||||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||||
|
|
||||||
interface MenuHeaderProps {
|
interface MenuHeaderProps {
|
||||||
@@ -44,7 +43,7 @@ export function SidebarMenuToggle({
|
|||||||
nativeID = "menu-button",
|
nativeID = "menu-button",
|
||||||
}: SidebarMenuToggleProps = {}) {
|
}: SidebarMenuToggleProps = {}) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const mobileView = usePanelStore((state) => state.mobileView);
|
||||||
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||||
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
|
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { View, type StyleProp, type ViewStyle } from "react-native";
|
import { View, type StyleProp, type ViewStyle } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
import {
|
import {
|
||||||
HEADER_INNER_HEIGHT,
|
HEADER_INNER_HEIGHT,
|
||||||
HEADER_INNER_HEIGHT_MOBILE,
|
HEADER_INNER_HEIGHT_MOBILE,
|
||||||
HEADER_TOP_PADDING_MOBILE,
|
HEADER_TOP_PADDING_MOBILE,
|
||||||
useIsCompactFormFactor,
|
|
||||||
} from "@/constants/layout";
|
} from "@/constants/layout";
|
||||||
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
import { useDesktopDragHandlers, useWindowControlsPadding } from "@/utils/desktop-window";
|
||||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
|
||||||
|
|
||||||
interface ScreenHeaderProps {
|
interface ScreenHeaderProps {
|
||||||
left?: ReactNode;
|
left?: ReactNode;
|
||||||
@@ -23,21 +21,17 @@ interface ScreenHeaderProps {
|
|||||||
* Shared frame for the home/back headers so we only maintain padding, border,
|
* Shared frame for the home/back headers so we only maintain padding, border,
|
||||||
* and safe-area logic in one place.
|
* and safe-area logic in one place.
|
||||||
*/
|
*/
|
||||||
export function ScreenHeader({
|
export function ScreenHeader({ left, right, leftStyle, rightStyle, borderless }: ScreenHeaderProps) {
|
||||||
left,
|
|
||||||
right,
|
|
||||||
leftStyle,
|
|
||||||
rightStyle,
|
|
||||||
borderless,
|
|
||||||
}: ScreenHeaderProps) {
|
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
const padding = useWindowControlsPadding("header");
|
const padding = useWindowControlsPadding("header");
|
||||||
// Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets
|
// Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets
|
||||||
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
|
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
|
||||||
const baseHorizontalPadding = theme.spacing[2];
|
const baseHorizontalPadding = theme.spacing[2];
|
||||||
|
|
||||||
|
const dragHandlers = useDesktopDragHandlers();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<View style={[styles.inner, { paddingTop: insets.top + topPadding }]}>
|
<View style={[styles.inner, { paddingTop: insets.top + topPadding }]}>
|
||||||
@@ -50,8 +44,8 @@ export function ScreenHeader({
|
|||||||
},
|
},
|
||||||
borderless && styles.borderless,
|
borderless && styles.borderless,
|
||||||
]}
|
]}
|
||||||
|
{...dragHandlers}
|
||||||
>
|
>
|
||||||
<TitlebarDragRegion />
|
|
||||||
<View style={[styles.left, leftStyle]}>{left}</View>
|
<View style={[styles.left, leftStyle]}>{left}</View>
|
||||||
<View style={[styles.right, rightStyle]}>{right}</View>
|
<View style={[styles.right, rightStyle]}>{right}</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -66,7 +60,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
inner: {},
|
inner: {},
|
||||||
row: {
|
row: {
|
||||||
position: "relative",
|
|
||||||
height: {
|
height: {
|
||||||
xs: HEADER_INNER_HEIGHT_MOBILE,
|
xs: HEADER_INNER_HEIGHT_MOBILE,
|
||||||
md: HEADER_INNER_HEIGHT,
|
md: HEADER_INNER_HEIGHT,
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
import type { ReactNode } from "react";
|
|
||||||
import { useHostRuntimeBootstrapState, useStoreReady } from "@/app/_layout";
|
|
||||||
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
|
|
||||||
|
|
||||||
export function HostRouteBootstrapBoundary({ children }: { children: ReactNode }) {
|
|
||||||
const storeReady = useStoreReady();
|
|
||||||
const bootstrapState = useHostRuntimeBootstrapState();
|
|
||||||
|
|
||||||
if (!storeReady) {
|
|
||||||
return <StartupSplashScreen bootstrapState={bootstrapState} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user